From cf8dac0c6490469dab88a560004b0c07dbd48612 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Thu, 18 Sep 2025 00:23:40 +0000 Subject: (대표님) rfq, 계약, 서명 등 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/basic-contract/actions.ts | 67 +- .../vendor-table/basic-contract-sign-dialog.tsx | 2 - .../viewer/basic-contract-sign-viewer.tsx | 72 +- lib/forms/services.ts | 2 +- lib/itb/service.ts | 741 ++++++++++++++ lib/itb/table/approve-purchase-request-dialog.tsx | 0 lib/itb/table/create-purchase-request-dialog.tsx | 995 ++++++++++++++++++ lib/itb/table/create-rfq-dialog.tsx | 380 +++++++ lib/itb/table/delete-purchase-request-dialog.tsx | 225 ++++ lib/itb/table/edit-purchase-request-sheet.tsx | 1081 ++++++++++++++++++++ lib/itb/table/items-dialog.tsx | 167 +++ lib/itb/table/purchase-request-columns.tsx | 380 +++++++ lib/itb/table/purchase-requests-table.tsx | 229 +++++ lib/itb/table/view-purchase-request-sheet.tsx | 809 +++++++++++++++ lib/itb/validations.ts | 85 ++ lib/items/service.ts | 32 + lib/mail/templates/tbe-request.hbs | 198 ++++ lib/rfq-last/contract-actions.ts | 329 ++++-- lib/rfq-last/quotation-compare-view.tsx | 447 ++++++-- lib/rfq-last/service.ts | 134 ++- lib/rfq-last/table/create-general-rfq-dialog.tsx | 4 +- lib/rfq-last/table/rfq-assign-pic-dialog.tsx | 311 ++++++ lib/rfq-last/table/rfq-table-toolbar-actions.tsx | 402 +++----- lib/rfq-last/vendor/rfq-vendor-table.tsx | 100 +- lib/rfq-last/vendor/send-rfq-dialog.tsx | 378 +++---- lib/shi-signature/buyer-signature.ts | 186 ++++ lib/shi-signature/signature-list.tsx | 149 +++ lib/shi-signature/upload-form.tsx | 115 +++ lib/tbe-last/service.ts | 181 +++- lib/tbe-last/table/tbe-last-table-columns.tsx | 49 + lib/tbe-last/table/tbe-last-table.tsx | 118 ++- lib/tbe-last/vendor-tbe-service.ts | 4 +- 32 files changed, 7663 insertions(+), 709 deletions(-) create mode 100644 lib/itb/service.ts create mode 100644 lib/itb/table/approve-purchase-request-dialog.tsx create mode 100644 lib/itb/table/create-purchase-request-dialog.tsx create mode 100644 lib/itb/table/create-rfq-dialog.tsx create mode 100644 lib/itb/table/delete-purchase-request-dialog.tsx create mode 100644 lib/itb/table/edit-purchase-request-sheet.tsx create mode 100644 lib/itb/table/items-dialog.tsx create mode 100644 lib/itb/table/purchase-request-columns.tsx create mode 100644 lib/itb/table/purchase-requests-table.tsx create mode 100644 lib/itb/table/view-purchase-request-sheet.tsx create mode 100644 lib/itb/validations.ts create mode 100644 lib/mail/templates/tbe-request.hbs create mode 100644 lib/rfq-last/table/rfq-assign-pic-dialog.tsx create mode 100644 lib/shi-signature/buyer-signature.ts create mode 100644 lib/shi-signature/signature-list.tsx create mode 100644 lib/shi-signature/upload-form.tsx (limited to 'lib') diff --git a/lib/basic-contract/actions.ts b/lib/basic-contract/actions.ts index 0af9b948..eb4389ae 100644 --- a/lib/basic-contract/actions.ts +++ b/lib/basic-contract/actions.ts @@ -67,69 +67,4 @@ export async function restoreDocuments(documentIds: number[]) { console.error('문서 복구 처리 오류:', error) throw new Error('문서 복구 처리 중 오류가 발생했습니다.') } -} - -export async function createDocumentRevisionAction(input: { - baseDocumentId: number; - contractTemplateName: string; - contractTemplateType: string; - revision: number; - legalReviewRequired: boolean; - fileName: string; - filePath: string; -}) { - try { - const { createBasicContractTemplateRevision } = await import('./service'); - - const { data, error } = await createBasicContractTemplateRevision({ - ...input, - status: 'ACTIVE' as const - }); - - if (error) { - throw new Error(error); - } - - return { - success: true, - data, - message: `${input.contractTemplateName} v${input.revision} 리비전이 성공적으로 생성되었습니다.` - }; - - } catch (error) { - console.error('문서 리비전 생성 오류:', error); - throw new Error(error instanceof Error ? error.message : '문서 리비전 생성 중 오류가 발생했습니다.'); - } -} - -// 업로드 완료 후 문서 생성 (클라이언트에서 직접 호출 가능한 서버 액션) -export async function createDocumentFromUpload(input: { - contractTemplateType: string - contractTemplateName: string - legalReviewRequired: boolean - fileName: string - filePath: string -}) { - try { - const { createBasicContractTemplate } = await import('./service'); - - const { data, error } = await createBasicContractTemplate({ - contractTemplateType: input.contractTemplateType, - contractTemplateName: input.contractTemplateName, - revision: 1, - status: 'ACTIVE', - legalReviewRequired: input.legalReviewRequired, - fileName: input.fileName, - filePath: input.filePath, - } as any) - - if (error) throw new Error(error) - - revalidateTag('basic-contract-templates') - revalidatePath('/evcp/basic-contract-template') - - return { success: true, id: data?.id } - } catch (e: any) { - return { success: false, error: e?.message || '문서 생성 실패' } - } -} +} \ No newline at end of file diff --git a/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx b/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx index fa68c9c8..534a2705 100644 --- a/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx +++ b/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx @@ -86,8 +86,6 @@ export function BasicContractSignDialog({ isComplete?: boolean; }>>({}); - console.log(gtcCommentStatus, "gtcCommentStatus") - const router = useRouter() // 실제 사용할 open 상태 (외부 제어가 있으면 외부 상태 사용, 없으면 내부 상태 사용) diff --git a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx index 5698428e..77bfaf41 100644 --- a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx +++ b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx @@ -25,6 +25,7 @@ import { getActiveSurveyTemplate, getVendorSignatureFile, type SurveyTemplateWit import { useConditionalSurvey } from '../vendor-table/survey-conditional'; import { SurveyComponent } from './SurveyComponent'; import { GtcClausesComponent } from './GtcClausesComponent'; +import { getBuyerSignatureFileWithFallback } from "@/lib/shi-signature/buyer-signature"; interface FileInfo { path: string; @@ -256,6 +257,63 @@ class AutoSignatureFieldDetector { } } +const applyBuyerSignatureAutomatically = async (instance: WebViewerInstance) => { + const { Core } = instance; + const { documentViewer, annotationManager } = Core; + const document = documentViewer.getDocument(); + + if (!document) return; + + try { + console.log('🔍 구매자 서명란 자동 서명 시작...'); + + // "삼성중공업_서명란" 텍스트 검색 + const searchText = '삼성중공업_서명란'; + const textSearchIterator = await document.getTextSearchIterator(); + textSearchIterator.begin(searchText, Core.Search.Mode.PAGE_STOP | Core.Search.Mode.HIGHLIGHT); + + let searchResult = await textSearchIterator.next(); + + if (searchResult && searchResult.resultCode === Core.Search.ResultCode.FOUND) { + const pageNumber = searchResult.pageNum; + const quads = searchResult.quads; + + if (quads && quads.length > 0) { + const quad = quads[0]; + const x = Math.min(quad.x1, quad.x2, quad.x3, quad.x4); + const y = Math.min(quad.y1, quad.y2, quad.y3, quad.y4); + const textHeight = Math.abs(quad.y3 - quad.y1); + + // 구매자 서명 이미지 가져오기 + const buyerSignature = await getBuyerSignatureFileWithFallback(); + + if (buyerSignature) { + // 스탬프 어노테이션 생성 + const stamp = new Core.Annotations.StampAnnotation(); + stamp.PageNumber = pageNumber; + stamp.X = x; + stamp.Y = y + textHeight + 5; // 텍스트 아래 5픽셀 + stamp.Width = 150; + stamp.Height = 50; + + await stamp.setImageData(buyerSignature.data.dataUrl); + + // 어노테이션 추가 + annotationManager.addAnnotation(stamp); + annotationManager.drawAnnotationsFromList([stamp]); + + console.log('✅ 구매자 서명 자동 적용 완료'); + toast.info('삼성중공업 서명이 자동으로 적용되었습니다.', { + duration: 3000 + }); + } + } + } + } catch (error) { + console.error('구매자 자동 서명 처리 실패:', error); + } +}; + function useAutoSignatureFields(instance: WebViewerInstance | null, mode: 'vendor' | 'buyer' = 'vendor') { const [signatureFields, setSignatureFields] = useState([]); @@ -790,10 +848,20 @@ export function BasicContractSignViewer({ const { WidgetFlags } = Annotations; const FitMode = newInstance.UI.FitMode; - const handleDocumentLoaded = () => { + const handleDocumentLoaded = async () => { setFileLoading(false); newInstance.UI.setFitMode(FitMode.FitWidth); - + + // GTC 템플릿이 아닌 경우에만 구매자 자동 서명 적용 + if (!templateName.includes('GTC')) { + try { + // 구매자 서명란 찾아서 자동 서명 + await applyBuyerSignatureAutomatically(newInstance); + } catch (error) { + console.error('구매자 자동 서명 실패:', error); + } + } + requestAnimationFrame(() => { try { documentViewer.refreshAll(); diff --git a/lib/forms/services.ts b/lib/forms/services.ts index bf4fc3a0..6310b693 100644 --- a/lib/forms/services.ts +++ b/lib/forms/services.ts @@ -1632,7 +1632,7 @@ export async function sendDataToSEDP( // Make the API call const response = await fetch( - `${SEDP_API_BASE_URL}/AdapterData/Create`, + `${SEDP_API_BASE_URL}/AdapterData/Overwrite`, { method: 'POST', headers: { diff --git a/lib/itb/service.ts b/lib/itb/service.ts new file mode 100644 index 00000000..66732c02 --- /dev/null +++ b/lib/itb/service.ts @@ -0,0 +1,741 @@ +// app/actions/purchase-requests.ts +"use server"; + +import db from "@/db/db"; +import { purchaseRequestsView, purchaseRequests, purchaseRequestAttachments, rfqsLast, rfqLastAttachments, rfqLastAttachmentRevisions,users } from "@/db/schema"; +import { eq, and, desc, ilike, or, sql, asc, inArray ,like} from "drizzle-orm"; +import { revalidatePath, revalidateTag } from "next/cache"; +import { getServerSession } from 'next-auth/next' +import { authOptions } from '@/app/api/auth/[...nextauth]/route' +import { filterColumns } from "@/lib/filter-columns"; +import { unstable_cache } from "@/lib/unstable-cache"; +import { GetPurchaseRequestsSchema } from "./validations"; +import { z } from "zod" + +const createRequestSchema = z.object({ + requestTitle: z.string().min(1), + requestDescription: z.string().optional(), + projectId: z.number().optional(), + projectCode: z.string().optional(), + projectName: z.string().optional(), + projectCompany: z.string().optional(), + projectSite: z.string().optional(), + classNo: z.string().optional(), + packageNo: z.string().optional(), + packageName: z.string().optional(), + majorItemMaterialCategory: z.string().optional(), + majorItemMaterialDescription: z.string().optional(), + smCode: z.string().optional(), + estimatedBudget: z.string().optional(), + requestedDeliveryDate: z.date().optional(), + items: z.array(z.object({ + id: z.string(), + itemCode: z.string(), + itemName: z.string(), + specification: z.string(), + quantity: z.number(), + unit: z.string(), + estimatedUnitPrice: z.number().optional(), + remarks: z.string().optional(), + })).optional(), + attachments: z.array(z.object({ + fileName: z.string(), + originalFileName: z.string(), + filePath: z.string(), + fileSize: z.number(), + fileType: z.string(), + })).optional(), +}) + +// 구매요청 생성 +export async function createPurchaseRequest(input: z.infer) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return { error: "인증되지 않은 사용자입니다." } + } + + const userId = Number(session.user.id) + const data = createRequestSchema.parse(input) + + const result = await db.transaction(async (tx) => { + // 요청 번호 생성 + const year = new Date().getFullYear() + const lastRequest = await tx + .select() + .from(purchaseRequests) + .where(ilike(purchaseRequests.requestCode, `PR-${year}-%`)) + .orderBy(desc(purchaseRequests.requestCode)) + .limit(1) + + const nextNumber = lastRequest.length > 0 + ? parseInt(lastRequest[0].requestCode.split('-')[2]) + 1 + : 1 + + const requestCode = `PR-${year}-${String(nextNumber).padStart(5, '0')}` + + // 요청 생성 + const [request] = await tx + .insert(purchaseRequests) + .values({ + requestCode, + projectId: data.projectId, + projectCode: data.projectCode, + projectName: data.projectName, + projectCompany: data.projectCompany, + projectSite: data.projectSite, + classNo: data.classNo, + packageNo: data.packageNo, + packageName: data.packageName, + majorItemMaterialCategory: data.majorItemMaterialCategory, + majorItemMaterialDescription: data.majorItemMaterialDescription, + smCode: data.smCode, + requestTitle: data.requestTitle, + requestDescription: data.requestDescription, + estimatedBudget: data.estimatedBudget, + requestedDeliveryDate: data.requestedDeliveryDate, + items: data.items || [], + engPicId: userId, + engPicName: session.user.name, + status: "작성중", + createdBy: userId, + updatedBy: userId, + }) + .returning() + + // 첨부파일 저장 + if (data.attachments && data.attachments.length > 0) { + await tx.insert(purchaseRequestAttachments).values( + data.attachments.map(file => ({ + requestId: request.id, + ...file, + category: "설계문서", + createdBy: userId, + })) + ) + } + + return request + }) + + revalidateTag("purchase-requests") + + return { + success: true, + data: result, + message: `구매요청 ${result.requestCode}이(가) 생성되었습니다.` + } + } catch (error) { + console.error("Create purchase request error:", error) + return { error: "구매요청 생성 중 오류가 발생했습니다." } + } +} + +// 구매요청 수정 +export async function updatePurchaseRequest( + id: number, + input: Partial> +) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return { error: "인증되지 않은 사용자입니다." } + } + + const userId = Number(session.user.id) + + const result = await db.transaction(async (tx) => { + // 수정 가능한 상태인지 확인 + const [existing] = await tx + .select() + .from(purchaseRequests) + .where(eq(purchaseRequests.id, id)) + .limit(1) + + if (!existing) { + return { error: "구매요청을 찾을 수 없습니다." } + } + + if (existing.status !== "작성중") { + return { error: "작성중 상태의 요청만 수정할 수 있습니다." } + } + + // 구매요청 업데이트 + const [updated] = await tx + .update(purchaseRequests) + .set({ + projectId: input.projectId, + projectCode: input.projectCode, + projectName: input.projectName, + projectCompany: input.projectCompany, + projectSite: input.projectSite, + classNo: input.classNo, + packageNo: input.packageNo, + packageName: input.packageName, + majorItemMaterialCategory: input.majorItemMaterialCategory, + majorItemMaterialDescription: input.majorItemMaterialDescription, + smCode: input.smCode, + requestTitle: input.requestTitle, + requestDescription: input.requestDescription, + estimatedBudget: input.estimatedBudget, + requestedDeliveryDate: input.requestedDeliveryDate, + items: input.items || [], + updatedBy: userId, + updatedAt: new Date(), + }) + .where(eq(purchaseRequests.id, id)) + .returning() + + // 첨부파일 처리 + if (input.attachments !== undefined) { + // 기존 첨부파일 모두 삭제 + await tx + .delete(purchaseRequestAttachments) + .where(eq(purchaseRequestAttachments.requestId, id)) + + // 새 첨부파일 추가 + if (input.attachments && input.attachments.length > 0) { + await tx.insert(purchaseRequestAttachments).values( + input.attachments.map(file => ({ + requestId: id, + fileName: file.fileName, + originalFileName: file.originalFileName, + filePath: file.filePath, + fileSize: file.fileSize, + fileType: file.fileType, + category: "설계문서", + createdBy: userId, + })) + ) + } + } + + return updated + }) + + revalidateTag("purchase-requests") + + return { + success: true, + data: result, + message: "구매요청이 수정되었습니다." + } + } catch (error) { + console.error("Update purchase request error:", error) + return { error: "구매요청 수정 중 오류가 발생했습니다." } + } +} + +// 첨부파일 삭제 +export async function deletePurchaseRequestAttachment(attachmentId: number) { + const session = await getServerSession(authOptions) + if (!session?.user?.id) throw new Error("Unauthorized"); + await db.delete(purchaseRequestAttachments) + .where(eq(purchaseRequestAttachments.id, attachmentId)); + + revalidatePath("/evcp/purchase-requests"); +} + +// 요청 확정 (작성중 → 요청완료) +export async function confirmPurchaseRequest(requestId: number) { + const session = await getServerSession(authOptions) + if (!session?.user?.id) throw new Error("Unauthorized"); + + const userId = Number(session.user.id) + + const [request] = await db + .select() + .from(purchaseRequests) + .where(eq(purchaseRequests.id, requestId)); + + if (!request) throw new Error("Request not found"); + if (request.status !== "작성중") { + throw new Error("Only draft requests can be confirmed"); + } + + const [updated] = await db + .update(purchaseRequests) + .set({ + status: "요청완료", + confirmedAt: new Date(), + confirmedBy: userId, + updatedBy: userId, + updatedAt: new Date(), + }) + .where(eq(purchaseRequests.id, requestId)) + .returning(); + + revalidatePath("/evcp/purchase-requests"); + return updated; +} + +// 요청 승인 및 RFQ 생성 (첨부파일 이관 포함) +export async function approvePurchaseRequestAndCreateRfq( + requestId: number, + purchasePicId?: number +) { + const session = await getServerSession(authOptions) + if (!session?.user?.id) throw new Error("Unauthorized"); + const userId = Number(session.user.id) + + + return await db.transaction(async (tx) => { + // 구매 요청 및 첨부파일 조회 + const [request] = await tx + .select() + .from(purchaseRequests) + .where(eq(purchaseRequests.id, requestId)); + + const attachments = await tx + .select() + .from(purchaseRequestAttachments) + .where(eq(purchaseRequestAttachments.requestId, requestId)); + + + const rfqCode = await generateItbRfqCode(purchasePicId); + + const [rfq] = await tx.insert(rfqsLast).values({ + rfqCode, + projectId: request.projectId, + itemCode: request.items?.[0]?.itemCode, + itemName: request.items?.[0]?.itemName, + packageNo: request.packageNo, + packageName: request.packageName, + EngPicName: request.engPicName, + pic: purchasePicId || null, + status: "RFQ 생성", + projectCompany: request.projectCompany, + projectSite: request.projectSite, + smCode: request.smCode, + createdBy: userId, + updatedBy: userId, + }).returning(); + + // 첨부파일 이관 + for (const [index, attachment] of attachments.entries()) { + const [rfqAttachment] = await tx.insert(rfqLastAttachments).values({ + attachmentType: "설계", + serialNo: `ENG-${String(index + 1).padStart(3, '0')}`, + rfqId: rfq.id, + description: attachment.description || `설계문서 - ${attachment.originalFileName}`, + currentRevision: "Rev.0", + createdBy: userId, + }).returning(); + + const [revision] = await tx.insert(rfqLastAttachmentRevisions).values({ + attachmentId: rfqAttachment.id, + revisionNo: "Rev.0", + revisionComment: "구매 요청에서 이관된 설계 문서", + isLatest: true, + fileName: attachment.fileName, + originalFileName: attachment.originalFileName, + filePath: attachment.filePath, + fileSize: attachment.fileSize, + fileType: attachment.fileType, + createdBy: userId, + }).returning(); + + await tx + .update(rfqLastAttachments) + .set({ latestRevisionId: revision.id }) + .where(eq(rfqLastAttachments.id, rfqAttachment.id)); + } + + // 구매 요청 상태 업데이트 + await tx + .update(purchaseRequests) + .set({ + status: "RFQ생성완료", + rfqId: rfq.id, + rfqCode: rfq.rfqCode, + rfqCreatedAt: new Date(), + purchasePicId, + updatedBy: userId, + updatedAt: new Date(), + }) + .where(eq(purchaseRequests.id, requestId)); + + return rfq; + }); +} + +export async function getAllPurchaseRequests(input: GetPurchaseRequestsSchema) { + return unstable_cache( + async () => { + // 페이징 + const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10); + const limit = input.perPage ?? 10; + + // 고급 필터 + const advancedWhere = filterColumns({ + table: purchaseRequestsView, + filters: input.filters ?? [], + joinOperator: input.joinOperator ?? "and", + }); + + // 글로벌 검색 + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + sql`${purchaseRequestsView.requestCode} ILIKE ${s}`, + sql`${purchaseRequestsView.requestTitle} ILIKE ${s}`, + sql`${purchaseRequestsView.projectCode} ILIKE ${s}`, + sql`${purchaseRequestsView.projectName} ILIKE ${s}`, + sql`${purchaseRequestsView.packageNo} ILIKE ${s}`, + sql`${purchaseRequestsView.packageName} ILIKE ${s}`, + sql`${purchaseRequestsView.engPicName} ILIKE ${s}`, + sql`${purchaseRequestsView.purchasePicName} ILIKE ${s}`, + sql`${purchaseRequestsView.majorItemMaterialCategory} ILIKE ${s}`, + sql`${purchaseRequestsView.smCode} ILIKE ${s}` + ); + } + + // 최종 WHERE + const finalWhere = and(advancedWhere, globalWhere); + + // 정렬 + const orderBy = input.sort?.length + ? input.sort.map((s) => { + const col = (purchaseRequestsView as any)[s.id]; + return s.desc ? desc(col) : asc(col); + }) + : [desc(purchaseRequestsView.createdAt)]; + + // 메인 SELECT + const [rows, total] = await db.transaction(async (tx) => { + const data = await tx + .select() + .from(purchaseRequestsView) + .where(finalWhere) + .orderBy(...orderBy) + .offset(offset) + .limit(limit); + + const [{ count }] = await tx + .select({ count: sql`count(*)`.as("count") }) + .from(purchaseRequestsView) + .where(finalWhere); + + return [data, Number(count)]; + }); + + const pageCount = Math.ceil(total / limit); + return { data: rows, pageCount }; + }, + [JSON.stringify(input)], + { + revalidate: 60, + tags: ["purchase-requests"], + } + )(); +} + +// 통계 정보 조회 함수 (선택사항) +export async function getPurchaseRequestStats() { + return unstable_cache( + async () => { + const stats = await db + .select({ + total: sql`count(*)`.as("total"), + draft: sql`count(*) filter (where status = '작성중')`.as("draft"), + rfqCreated: sql`count(*) filter (where status = 'RFQ생성완료')`.as("rfqCreated"), + }) + .from(purchaseRequestsView); + + return stats[0]; + }, + [], + { + revalidate: 60, + tags: ["purchase-request-stats"], + } + )(); +} + + +// 스키마 정의 +const deleteSchema = z.object({ + ids: z.array(z.number()), +}) + + +// 구매요청 삭제 (단일 또는 복수) +export async function deletePurchaseRequests(input: z.infer) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return { error: "인증되지 않은 사용자입니다." } + } + + const { ids } = deleteSchema.parse(input) + + if (ids.length === 0) { + return { error: "삭제할 요청을 선택해주세요." } + } + + // 삭제 가능한 상태인지 확인 (작성중 상태만 삭제 가능) + const requests = await db + .select({ + id: purchaseRequests.id, + status: purchaseRequests.status, + requestCode: purchaseRequests.requestCode, + }) + .from(purchaseRequests) + .where(inArray(purchaseRequests.id, ids)) + + const nonDeletable = requests.filter(req => req.status !== "작성중") + + if (nonDeletable.length > 0) { + const codes = nonDeletable.map(req => req.requestCode).join(", ") + return { + error: `다음 요청은 삭제할 수 없습니다: ${codes}. 작성중 상태의 요청만 삭제 가능합니다.` + } + } + + // 트랜잭션으로 삭제 처리 + await db.transaction(async (tx) => { + // 1. 첨부파일 먼저 삭제 + await tx + .delete(purchaseRequestAttachments) + .where(inArray(purchaseRequestAttachments.requestId, ids)) + + // 2. 구매요청 삭제 + await tx + .delete(purchaseRequests) + .where(inArray(purchaseRequests.id, ids)) + }) + + // 캐시 무효화 + revalidateTag("purchase-requests") + + return { + success: true, + message: ids.length === 1 + ? "구매요청이 삭제되었습니다." + : `${ids.length}개의 구매요청이 삭제되었습니다.` + } + } catch (error) { + console.error("Delete purchase requests error:", error) + return { error: "구매요청 삭제 중 오류가 발생했습니다." } + } +} + +export async function getPurchaseRequestAttachments(requestId: number) { + try { + const attachments = await db + .select({ + id: purchaseRequestAttachments.id, + fileName: purchaseRequestAttachments.fileName, + originalFileName: purchaseRequestAttachments.originalFileName, + filePath: purchaseRequestAttachments.filePath, + fileSize: purchaseRequestAttachments.fileSize, + fileType: purchaseRequestAttachments.fileType, + category: purchaseRequestAttachments.category, + description: purchaseRequestAttachments.description, + createdBy: purchaseRequestAttachments.createdBy, + createdAt: purchaseRequestAttachments.createdAt, + }) + .from(purchaseRequestAttachments) + .where(eq(purchaseRequestAttachments.requestId, requestId)) + .orderBy(desc(purchaseRequestAttachments.createdAt)) + + return { + success: true, + data: attachments + } + } catch (error) { + console.error("Get attachments error:", error) + return { + success: false, + error: "첨부파일 조회 중 오류가 발생했습니다.", + data: [] + } + } +} + + +export async function generateItbRfqCode(purchasePicId?: number): Promise { + try { + let userCode = "???"; + + // purchasePicId가 있으면 users 테이블에서 userCode 조회 + if (purchasePicId) { + const [user] = await db + .select({ userCode: users.userCode }) + .from(users) + .where(eq(users.id, purchasePicId)) + .limit(1); + + if (user?.userCode) { + userCode = user.userCode; + } + } + + // 동일한 userCode로 시작하는 마지막 RFQ 조회 + const lastRfq = await db + .select({ rfqCode: rfqsLast.rfqCode }) + .from(rfqsLast) + .where(like(rfqsLast.rfqCode, `I${userCode}%`)) + .orderBy(desc(rfqsLast.createdAt)) + .limit(1); + + let nextNumber = 1; + + if (lastRfq.length > 0 && lastRfq[0].rfqCode) { + const rfqCode = lastRfq[0].rfqCode; + const serialNumber = rfqCode.slice(-5); // 마지막 5자리 + + if (/^\d{5}$/.test(serialNumber)) { + nextNumber = parseInt(serialNumber) + 1; + } + } + + const paddedNumber = String(nextNumber).padStart(5, "0"); + + return `I${userCode}${paddedNumber}`; + } catch (error) { + console.error("Error generating ITB RFQ code:", error); + const fallback = Date.now().toString().slice(-5); + return `I???${fallback}`; + } + } + + + // lib/purchase-requests/service.ts에 추가 + +// 여러 구매 요청 승인 및 RFQ 생성 +export async function approvePurchaseRequestsAndCreateRfqs( + requestIds: number[], + purchasePicId?: number + ) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) throw new Error("Unauthorized"); + const userId = Number(session.user.id) + + const results = [] + + for (const requestId of requestIds) { + try { + const result = await db.transaction(async (tx) => { + // 구매 요청 조회 + const [request] = await tx + .select() + .from(purchaseRequests) + .where(eq(purchaseRequests.id, requestId)) + + if (!request) { + throw new Error(`구매 요청 ${requestId}를 찾을 수 없습니다.`) + } + + if (request.status === "RFQ생성완료") { + return { skipped: true, requestId, message: "이미 RFQ가 생성되었습니다." } + } + + const attachments = await tx + .select() + .from(purchaseRequestAttachments) + .where(eq(purchaseRequestAttachments.requestId, requestId)) + + const rfqCode = await generateItbRfqCode(purchasePicId) + + const [rfq] = await tx + .insert(rfqsLast) + .values({ + rfqCode, + projectId: request.projectId, + itemCode: request.items?.[0]?.itemCode, + itemName: request.items?.[0]?.itemName, + packageNo: request.packageNo, + packageName: request.packageName, + EngPicName: request.engPicName, + pic: purchasePicId || null, + status: "RFQ 생성", + projectCompany: request.projectCompany, + projectSite: request.projectSite, + smCode: request.smCode, + createdBy: userId, + updatedBy: userId, + }) + .returning() + + // 첨부파일 이관 + for (const [index, attachment] of attachments.entries()) { + const [rfqAttachment] = await tx + .insert(rfqLastAttachments) + .values({ + attachmentType: "설계", + serialNo: `ENG-${String(index + 1).padStart(3, "0")}`, + rfqId: rfq.id, + description: + attachment.description || + `설계문서 - ${attachment.originalFileName}`, + currentRevision: "Rev.0", + createdBy: userId, + }) + .returning() + + const [revision] = await tx + .insert(rfqLastAttachmentRevisions) + .values({ + attachmentId: rfqAttachment.id, + revisionNo: "Rev.0", + revisionComment: "구매 요청에서 이관된 설계 문서", + isLatest: true, + fileName: attachment.fileName, + originalFileName: attachment.originalFileName, + filePath: attachment.filePath, + fileSize: attachment.fileSize, + fileType: attachment.fileType, + createdBy: userId, + }) + .returning() + + await tx + .update(rfqLastAttachments) + .set({ latestRevisionId: revision.id }) + .where(eq(rfqLastAttachments.id, rfqAttachment.id)) + } + + // 구매 요청 상태 업데이트 + await tx + .update(purchaseRequests) + .set({ + status: "RFQ생성완료", + rfqId: rfq.id, + rfqCode: rfq.rfqCode, + rfqCreatedAt: new Date(), + purchasePicId, + updatedBy: userId, + updatedAt: new Date(), + }) + .where(eq(purchaseRequests.id, requestId)) + + return { success: true, rfq, requestId } + }) + + results.push(result) + } catch (err: any) { + console.error(`구매 요청 ${requestId} 처리 중 오류:`, err) + results.push({ + success: false, + requestId, + error: err.message || "알 수 없는 오류 발생", + }) + } + } + + // 캐시 무효화 + revalidateTag("purchase-requests") + revalidateTag( "purchase-request-stats") + + revalidateTag("rfqs") + + return results + } catch (err: any) { + console.error("approvePurchaseRequestsAndCreateRfqs 실행 오류:", err) + throw new Error(err.message || "구매 요청 처리 중 오류가 발생했습니다.") + } + } + \ No newline at end of file diff --git a/lib/itb/table/approve-purchase-request-dialog.tsx b/lib/itb/table/approve-purchase-request-dialog.tsx new file mode 100644 index 00000000..e69de29b diff --git a/lib/itb/table/create-purchase-request-dialog.tsx b/lib/itb/table/create-purchase-request-dialog.tsx new file mode 100644 index 00000000..27b8a342 --- /dev/null +++ b/lib/itb/table/create-purchase-request-dialog.tsx @@ -0,0 +1,995 @@ +// components/purchase-requests/create-purchase-request-dialog.tsx +"use client"; + +import * as React from "react"; +import { useForm, useFieldArray } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/ui/tabs"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { ProjectSelector } from "@/components/ProjectSelector"; +import { PackageSelector } from "@/components/PackageSelector"; +import type { PackageItem } from "@/lib/items/service"; +import { MaterialGroupSelector } from "@/components/common/material/material-group-selector"; +import { + Dropzone, + DropzoneDescription, + DropzoneInput, + DropzoneTitle, + DropzoneUploadIcon, + DropzoneZone, +} from "@/components/ui/dropzone"; +import { + FileList, + FileListAction, + FileListDescription, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, + FileListSize, +} from "@/components/ui/file-list"; +import { Progress } from "@/components/ui/progress"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { CalendarIcon, X, Plus, FileText, Package, Trash2, Upload, FileIcon, AlertCircle, Paperclip } from "lucide-react"; +import { format } from "date-fns"; +import { cn } from "@/lib/utils"; +import { createPurchaseRequest } from "../service"; +import { toast } from "sonner"; +import { nanoid } from "nanoid"; + +const attachmentSchema = z.object({ + fileName: z.string(), + originalFileName: z.string(), + filePath: z.string(), + fileSize: z.number(), + fileType: z.string(), +}); + +const itemSchema = z.object({ + id: z.string(), + itemCode: z.string(), + itemName: z.string().min(1, "아이템명을 입력해주세요"), + specification: z.string().default(""), + quantity: z.coerce.number().min(1, "수량은 1 이상이어야 합니다"), + unit: z.string().min(1, "단위를 입력해주세요"), + estimatedUnitPrice: z.coerce.number().optional(), + remarks: z.string().optional(), +}); + +const formSchema = z.object({ + requestTitle: z.string().min(1, "요청 제목을 입력해주세요"), + requestDescription: z.string().optional(), + projectId: z.number({ + required_error: "프로젝트를 선택해주세요", + }), + projectCode: z.string().min(1), + projectName: z.string().min(1), + projectCompany: z.string().optional(), + projectSite: z.string().optional(), + classNo: z.string().optional(), + packageNo: z.string({ + required_error: "패키지를 선택해주세요", + }).min(1, "패키지를 선택해주세요"), + packageName: z.string().min(1), + majorItemMaterialCategory: z.string({ + required_error: "자재그룹을 선택해주세요", + }).min(1, "자재그룹을 선택해주세요"), + majorItemMaterialDescription: z.string().min(1), + smCode: z.string({ + required_error: "SM 코드가 필요합니다", + }).min(1, "SM 코드가 필요합니다"), + estimatedBudget: z.string().optional(), + requestedDeliveryDate: z.date().optional(), + items: z.array(itemSchema).optional().default([]), + attachments: z.array(attachmentSchema).min(1, "최소 1개 이상의 파일을 첨부해주세요"), +}); + +type FormData = z.infer; + +interface CreatePurchaseRequestDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess?: () => void; +} + +export function CreatePurchaseRequestDialog({ + open, + onOpenChange, + onSuccess, +}: CreatePurchaseRequestDialogProps) { + const [isLoading, setIsLoading] = React.useState(false); + const [activeTab, setActiveTab] = React.useState("basic"); + const [selectedPackage, setSelectedPackage] = React.useState(null); + const [selectedMaterials, setSelectedMaterials] = React.useState([]); + const [resetKey, setResetKey] = React.useState(0); + const [uploadedFiles, setUploadedFiles] = React.useState([]); + const [isUploading, setIsUploading] = React.useState(false); + const [uploadProgress, setUploadProgress] = React.useState(0); + + const form = useForm({ + resolver: zodResolver(formSchema), + mode: "onChange", + criteriaMode: "all", + defaultValues: { + requestTitle: "", + requestDescription: "", + projectId: undefined, + projectCode: "", + projectName: "", + projectCompany: "", + projectSite: "", + classNo: "", + packageNo: "", + packageName: "", + majorItemMaterialCategory: "", + majorItemMaterialDescription: "", + smCode: "", + estimatedBudget: "", + requestedDeliveryDate: undefined, + items: [], + attachments: [], + }, + }); + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "items", + }); + + // 파일 업로드 핸들러 + const handleFileUpload = async (files: File[]) => { + if (files.length === 0) return; + + setIsUploading(true); + setUploadProgress(0); + + try { + const formData = new FormData(); + files.forEach(file => { + formData.append("files", file); + }); + + // 프로그레스 시뮬레이션 (실제로는 XMLHttpRequest 또는 fetch with progress를 사용) + const progressInterval = setInterval(() => { + setUploadProgress(prev => Math.min(prev + 10, 90)); + }, 200); + + const response = await fetch("/api/upload/purchase-request", { + method: "POST", + body: formData, + }); + + clearInterval(progressInterval); + setUploadProgress(100); + + if (!response.ok) { + throw new Error("파일 업로드 실패"); + } + + const data = await response.json(); + + // 업로드된 파일 정보를 상태에 추가 + const newFiles = [...uploadedFiles, ...data.files]; + setUploadedFiles(newFiles); + + // form의 attachments 필드 업데이트 + form.setValue("attachments", newFiles); + + toast.success(`${files.length}개 파일이 업로드되었습니다`); + } catch (error) { + console.error("Upload error:", error); + toast.error("파일 업로드 중 오류가 발생했습니다"); + } finally { + setIsUploading(false); + setUploadProgress(0); + } + }; + + // 파일 삭제 핸들러 + const handleFileRemove = (fileName: string) => { + const updatedFiles = uploadedFiles.filter(f => f.fileName !== fileName); + setUploadedFiles(updatedFiles); + form.setValue("attachments", updatedFiles); + }; + + // 프로젝트 선택 처리 + const handleProjectSelect = (project: any) => { + form.setValue("projectId", project.id); + form.setValue("projectCode", project.projectCode); + form.setValue("projectName", project.projectName); + form.setValue("projectCompany", project.projectCompany); + form.setValue("projectSite", project.projectSite); + + // 프로젝트 변경시 패키지 초기화 + setSelectedPackage(null); + form.setValue("packageNo", ""); + form.setValue("packageName", ""); + form.setValue("smCode", ""); + + // requestTitle 업데이트 + updateRequestTitle(project.projectName, "", ""); + }; + + // 패키지 선택 처리 + const handlePackageSelect = (packageItem: PackageItem) => { + setSelectedPackage(packageItem); + form.setValue("packageNo", packageItem.packageCode); + form.setValue("packageName", packageItem.description); + + // SM 코드가 패키지에 있으면 자동 설정 + if (packageItem.smCode) { + form.setValue("smCode", packageItem.smCode); + } + + // requestTitle 업데이트 + const projectName = form.getValues("projectName"); + const materialDesc = form.getValues("majorItemMaterialDescription"); + updateRequestTitle(projectName, packageItem.description, materialDesc); + }; + + // 자재그룹 선택 처리 + const handleMaterialsChange = (materials: any[]) => { + setSelectedMaterials(materials); + if (materials.length > 0) { + const material = materials[0]; // single select이므로 첫번째 항목만 + form.setValue("majorItemMaterialCategory", material.materialGroupCode); + form.setValue("majorItemMaterialDescription", material.materialGroupDescription); + + // requestTitle 업데이트 + const projectName = form.getValues("projectName"); + const packageName = form.getValues("packageName"); + updateRequestTitle(projectName, packageName, material.materialGroupDescription); + } else { + form.setValue("majorItemMaterialCategory", ""); + form.setValue("majorItemMaterialDescription", ""); + } + }; + + // requestTitle 자동 생성 + const updateRequestTitle = (projectName: string, packageName: string, materialDesc: string) => { + const parts = []; + if (projectName) parts.push(projectName); + if (packageName) parts.push(packageName); + if (materialDesc) parts.push(materialDesc); + + if (parts.length > 0) { + const title = `${parts.join(" - ")} 구매 요청`; + form.setValue("requestTitle", title); + } + }; + + const addItem = () => { + append({ + id: nanoid(), + itemCode: "", + itemName: "", + specification: "", + quantity: 1, + unit: "개", + estimatedUnitPrice: undefined, + remarks: "", + }); + }; + + const calculateTotal = () => { + const items = form.watch("items"); + return items?.reduce((sum, item) => { + const subtotal = (item.quantity || 0) * (item.estimatedUnitPrice || 0); + return sum + subtotal; + }, 0) || 0; + }; + + const onSubmit = async (values: FormData) => { + try { + setIsLoading(true); + + const result = await createPurchaseRequest({ + ...values, + items: values.items, + attachments: uploadedFiles, // 첨부파일 포함 + }); + + if (result.error) { + toast.error(result.error); + return; + } + + toast.success("구매 요청이 등록되었습니다"); + handleClose(); + onSuccess?.(); + } catch (error) { + toast.error("구매 요청 등록에 실패했습니다"); + console.error(error); + } finally { + setIsLoading(false); + } + }; + + const handleClose = () => { + form.reset({ + requestTitle: "", + requestDescription: "", + projectId: undefined, + projectCode: "", + projectName: "", + projectCompany: "", + projectSite: "", + classNo: "", + packageNo: "", + packageName: "", + majorItemMaterialCategory: "", + majorItemMaterialDescription: "", + smCode: "", + estimatedBudget: "", + requestedDeliveryDate: undefined, + items: [], + attachments: [], + }); + setSelectedPackage(null); + setSelectedMaterials([]); + setUploadedFiles([]); + setActiveTab("basic"); + setResetKey((k) => k + 1); + onOpenChange(false); + }; + + return ( + + + + 새 구매 요청 + + 구매가 필요한 품목과 관련 정보를 입력해주세요 + + + +
+ + + + + + 기본 정보 + + + + 품목 정보 + {fields.length > 0 && ( + + {fields.length} + + )} + + + + 첨부파일 + {uploadedFiles.length > 0 && ( + + {uploadedFiles.length} + + )} + + + +
+ + {/* 프로젝트 선택 */} + + + 프로젝트 정보 * + 프로젝트, 패키지, 자재그룹을 순서대로 선택하세요 + + + {/* 프로젝트 선택 */} + ( + + 프로젝트 * + + + + )} + /> + + {/* 패키지 선택 */} + ( + + 패키지 * + + + + )} + /> + + {/* 자재그룹 선택 */} + ( + + 자재그룹 * + + + + )} + /> + + {/* SM 코드 */} + ( + + SM 코드 * + + + + + + )} + /> + + {/* 선택된 정보 표시 */} + {form.watch("projectId") && ( +
+
+

프로젝트 코드

+

{form.watch("projectCode") || "-"}

+
+
+

프로젝트명

+

{form.watch("projectName") || "-"}

+
+
+

발주처

+

{form.watch("projectCompany") || "-"}

+
+
+

현장

+

{form.watch("projectSite") || "-"}

+
+
+ )} +
+
+ + {/* 기본 정보 */} +
+ ( + + 요청 제목 * + + + + + 자동 생성된 제목을 수정할 수 있습니다 + + + + )} + /> + + ( + + 요청 설명 + +