diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-18 00:23:40 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-18 00:23:40 +0000 |
| commit | cf8dac0c6490469dab88a560004b0c07dbd48612 (patch) | |
| tree | b9e76061e80d868331e6b4277deecb9086f845f3 /lib | |
| parent | e5745fc0268bbb5770bc14a55fd58a0ec30b466e (diff) | |
(대표님) rfq, 계약, 서명 등
Diffstat (limited to 'lib')
32 files changed, 7663 insertions, 709 deletions
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<string[]>([]); @@ -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<typeof createRequestSchema>) { + 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<z.infer<typeof createRequestSchema>> +) { + 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<number>`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<number>`count(*)`.as("total"), + draft: sql<number>`count(*) filter (where status = '작성중')`.as("draft"), + rfqCreated: sql<number>`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<typeof deleteSchema>) { + 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<string> { + 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 --- /dev/null +++ b/lib/itb/table/approve-purchase-request-dialog.tsx 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<typeof formSchema>; + +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<PackageItem | null>(null); + const [selectedMaterials, setSelectedMaterials] = React.useState<any[]>([]); + const [resetKey, setResetKey] = React.useState(0); + const [uploadedFiles, setUploadedFiles] = React.useState<File[]>([]); + const [isUploading, setIsUploading] = React.useState(false); + const [uploadProgress, setUploadProgress] = React.useState(0); + + const form = useForm<FormData>({ + 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 ( + <Dialog open={open} onOpenChange={handleClose}> + <DialogContent key={resetKey} className="max-w-5xl h-[85vh] overflow-hidden flex flex-col min-h-0"> + <DialogHeader> + <DialogTitle>새 구매 요청</DialogTitle> + <DialogDescription> + 구매가 필요한 품목과 관련 정보를 입력해주세요 + </DialogDescription> + </DialogHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex-1 overflow-hidden flex flex-col min-h-0"> + <Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col min-h-0"> + <TabsList className="grid w-full grid-cols-3 flex-shrink-0"> + <TabsTrigger value="basic"> + <FileText className="mr-2 h-4 w-4" /> + 기본 정보 + </TabsTrigger> + <TabsTrigger value="items"> + <Package className="mr-2 h-4 w-4" /> + 품목 정보 + {fields.length > 0 && ( + <span className="ml-2 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground"> + {fields.length} + </span> + )} + </TabsTrigger> + <TabsTrigger value="files"> + <Paperclip className="mr-2 h-4 w-4" /> + 첨부파일 + {uploadedFiles.length > 0 && ( + <span className="ml-2 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground"> + {uploadedFiles.length} + </span> + )} + </TabsTrigger> + </TabsList> + + <div className="flex-1 overflow-y-auto px-1 min-h-0"> + <TabsContent value="basic" className="space-y-6 mt-6"> + {/* 프로젝트 선택 */} + <Card> + <CardHeader> + <CardTitle className="text-base">프로젝트 정보 <span className="text-red-500">*</span></CardTitle> + <CardDescription>프로젝트, 패키지, 자재그룹을 순서대로 선택하세요</CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + {/* 프로젝트 선택 */} + <FormField + control={form.control} + name="projectId" + render={({ field }) => ( + <FormItem> + <FormLabel>프로젝트 <span className="text-red-500">*</span></FormLabel> + <ProjectSelector + key={`project-${resetKey}`} + selectedProjectId={field.value} + onProjectSelect={handleProjectSelect} + placeholder="프로젝트를 검색하여 선택..." + /> + <FormMessage /> + </FormItem> + )} + /> + + {/* 패키지 선택 */} + <FormField + control={form.control} + name="packageNo" + render={({ field }) => ( + <FormItem> + <FormLabel>패키지 <span className="text-red-500">*</span></FormLabel> + <PackageSelector + projectNo={form.watch("projectCode")} + selectedPackage={selectedPackage} + onPackageSelect={handlePackageSelect} + placeholder="패키지 선택..." + /> + <FormMessage /> + </FormItem> + )} + /> + + {/* 자재그룹 선택 */} + <FormField + control={form.control} + name="majorItemMaterialCategory" + render={({ field }) => ( + <FormItem> + <FormLabel>자재그룹 <span className="text-red-500">*</span></FormLabel> + <MaterialGroupSelector + selectedMaterials={selectedMaterials} + onMaterialsChange={handleMaterialsChange} + singleSelect={true} + placeholder="자재그룹을 검색하세요..." + noValuePlaceHolder="자재그룹을 선택해주세요" + /> + <FormMessage /> + </FormItem> + )} + /> + + {/* SM 코드 */} + <FormField + control={form.control} + name="smCode" + render={({ field }) => ( + <FormItem> + <FormLabel>SM 코드 <span className="text-red-500">*</span></FormLabel> + <FormControl> + <Input + placeholder="SM 코드 입력" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 선택된 정보 표시 */} + {form.watch("projectId") && ( + <div className="grid grid-cols-2 gap-4 pt-4 border-t"> + <div> + <p className="text-sm text-muted-foreground">프로젝트 코드</p> + <p className="font-medium">{form.watch("projectCode") || "-"}</p> + </div> + <div> + <p className="text-sm text-muted-foreground">프로젝트명</p> + <p className="font-medium">{form.watch("projectName") || "-"}</p> + </div> + <div> + <p className="text-sm text-muted-foreground">발주처</p> + <p className="font-medium">{form.watch("projectCompany") || "-"}</p> + </div> + <div> + <p className="text-sm text-muted-foreground">현장</p> + <p className="font-medium">{form.watch("projectSite") || "-"}</p> + </div> + </div> + )} + </CardContent> + </Card> + + {/* 기본 정보 */} + <div className="space-y-4"> + <FormField + control={form.control} + name="requestTitle" + render={({ field }) => ( + <FormItem> + <FormLabel>요청 제목 <span className="text-red-500">*</span></FormLabel> + <FormControl> + <Input + placeholder="예: 프로젝트 A용 밸브 구매" + {...field} + /> + </FormControl> + <FormDescription> + 자동 생성된 제목을 수정할 수 있습니다 + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="requestDescription" + render={({ field }) => ( + <FormItem> + <FormLabel>요청 설명</FormLabel> + <FormControl> + <Textarea + placeholder="구매 요청에 대한 상세 설명을 입력하세요" + className="min-h-[100px]" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + {/* 예산 및 납기 */} + <div className="space-y-4"> + <h3 className="text-sm font-medium">예산 및 납기</h3> + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="estimatedBudget" + render={({ field }) => ( + <FormItem> + <FormLabel>예상 예산</FormLabel> + <FormControl> + <Input placeholder="예: 10,000,000원" {...field} /> + </FormControl> + <FormDescription> + 전체 구매 예상 예산을 입력하세요 + </FormDescription> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="requestedDeliveryDate" + render={({ field }) => ( + <FormItem> + <FormLabel>희망 납기일</FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + className={cn( + "w-full pl-3 text-left font-normal", + !field.value && "text-muted-foreground" + )} + > + {field.value ? ( + format(field.value, "yyyy-MM-dd") + ) : ( + <span>날짜 선택</span> + )} + <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <Calendar + mode="single" + selected={field.value} + onSelect={field.onChange} + disabled={(date) => date < new Date()} + /> + </PopoverContent> + </Popover> + <FormDescription> + 자재가 필요한 날짜를 선택하세요 + </FormDescription> + </FormItem> + )} + /> + </div> + </div> + </TabsContent> + + <TabsContent value="items" className="space-y-4 mt-6"> + {/* 품목 목록 */} + <div className="flex justify-between items-center"> + <h3 className="text-sm font-medium">품목 목록</h3> + <Button + type="button" + variant="outline" + size="sm" + onClick={addItem} + > + <Plus className="mr-2 h-4 w-4" /> + 품목 추가 + </Button> + </div> + + {fields.length === 0 ? ( + <Card> + <CardContent className="flex flex-col items-center justify-center py-8"> + <Package className="h-12 w-12 text-muted-foreground mb-4" /> + <p className="text-sm text-muted-foreground mb-4"> + 아직 추가된 품목이 없습니다 + </p> + <Button + type="button" + variant="outline" + size="sm" + onClick={addItem} + > + <Plus className="mr-2 h-4 w-4" /> + 첫 품목 추가 + </Button> + </CardContent> + </Card> + ) : ( + <div className="border rounded-lg"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[120px]">아이템 코드</TableHead> + <TableHead className="w-[150px]">아이템명 <span className="text-red-500">*</span></TableHead> + <TableHead>사양</TableHead> + <TableHead className="w-[80px]">수량 <span className="text-red-500">*</span></TableHead> + <TableHead className="w-[80px]">단위 <span className="text-red-500">*</span></TableHead> + <TableHead className="w-[120px]">예상 단가</TableHead> + <TableHead className="w-[120px]">예상 금액</TableHead> + <TableHead>비고</TableHead> + <TableHead className="w-[50px]"></TableHead> + </TableRow> + </TableHeader> + <TableBody> + {fields.map((field, index) => ( + <TableRow key={field.id}> + <TableCell> + <FormField + control={form.control} + name={`items.${index}.itemCode`} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + placeholder="코드" + {...field} + className="h-8" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </TableCell> + <TableCell> + <FormField + control={form.control} + name={`items.${index}.itemName`} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + placeholder="아이템명" + {...field} + className="h-8" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </TableCell> + <TableCell> + <FormField + control={form.control} + name={`items.${index}.specification`} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + placeholder="사양" + {...field} + className="h-8" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + <TableCell> + <FormField + control={form.control} + name={`items.${index}.quantity`} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + type="number" + min="1" + {...field} + className="h-8" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </TableCell> + <TableCell> + <FormField + control={form.control} + name={`items.${index}.unit`} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + placeholder="개" + {...field} + className="h-8" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </TableCell> + <TableCell> + <FormField + control={form.control} + name={`items.${index}.estimatedUnitPrice`} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + type="number" + min="0" + placeholder="0" + {...field} + className="h-8" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + <TableCell> + <div className="text-right font-medium"> + {new Intl.NumberFormat('ko-KR').format( + (form.watch(`items.${index}.quantity`) || 0) * + (form.watch(`items.${index}.estimatedUnitPrice`) || 0) + )} + </div> + </TableCell> + <TableCell> + <FormField + control={form.control} + name={`items.${index}.remarks`} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + placeholder="비고" + {...field} + className="h-8" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + <TableCell> + <Button + type="button" + variant="ghost" + size="icon" + className="h-8 w-8" + onClick={() => remove(index)} + disabled={fields.length === 1} + > + <Trash2 className="h-4 w-4" /> + </Button> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + + <div className="flex justify-between items-center p-4 border-t bg-muted/30"> + <div className="text-sm text-muted-foreground"> + 총 {fields.length}개 품목 + </div> + <div className="text-right"> + <p className="text-sm text-muted-foreground">예상 총액</p> + <p className="text-lg font-semibold"> + {new Intl.NumberFormat('ko-KR', { + style: 'currency', + currency: 'KRW' + }).format(calculateTotal())} + </p> + </div> + </div> + </div> + )} + </TabsContent> + + <TabsContent value="files" className="space-y-4 mt-6"> + {/* 파일 업로드 영역 */} + <Card> + <CardHeader> + <CardTitle className="text-base">첨부파일 업로드 <span className="text-red-500">*</span></CardTitle> + <CardDescription> + 설계 도면, 사양서, 견적서 등 구매 요청 관련 문서를 업로드하세요 (필수) + </CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + {/* 필수 알림 */} + <FormField + control={form.control} + name="attachments" + render={({ field }) => ( + <FormItem> + <FormMessage /> + </FormItem> + )} + /> + + {/* 업로드 진행 상태 */} + {isUploading && ( + <Alert> + <AlertCircle className="h-4 w-4" /> + <AlertDescription className="space-y-2"> + <p>파일을 업로드 중입니다...</p> + <Progress value={uploadProgress} className="w-full" /> + </AlertDescription> + </Alert> + )} + + {/* Dropzone */} + <Dropzone + onDrop={handleFileUpload} + accept={{ + 'application/pdf': ['.pdf'], + 'application/vnd.ms-excel': ['.xls'], + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], + 'application/msword': ['.doc'], + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], + 'image/*': ['.png', '.jpg', '.jpeg', '.gif'], + 'application/x-zip-compressed': ['.zip'], + 'application/x-rar-compressed': ['.rar'], + }} + maxSize={50 * 1024 * 1024} // 50MB + disabled={isUploading} + > + <DropzoneInput /> + <DropzoneZone> + <DropzoneUploadIcon className="h-12 w-12 text-muted-foreground mb-4" /> + <DropzoneTitle> + 파일을 드래그하거나 클릭하여 선택하세요 + </DropzoneTitle> + <DropzoneDescription> + PDF, Excel, Word, 이미지 파일 등을 업로드할 수 있습니다 (최대 50MB) + </DropzoneDescription> + </DropzoneZone> + </Dropzone> + + {/* 업로드된 파일 목록 */} + {uploadedFiles.length > 0 && ( + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <h4 className="text-sm font-medium">업로드된 파일</h4> + <span className="text-sm text-muted-foreground"> + 총 {uploadedFiles.length}개 파일 + </span> + </div> + + <FileList> + {uploadedFiles.map((file) => ( + <FileListItem key={file.fileName}> + <FileListIcon> + <FileIcon className="h-4 w-4" /> + </FileListIcon> + <FileListInfo> + <FileListHeader> + <FileListName> + {file.originalFileName} + </FileListName> + <FileListSize> + {file.fileSize} + </FileListSize> + </FileListHeader> + </FileListInfo> + <FileListAction> + <Button + variant="ghost" + size="icon" + className="h-8 w-8" + onClick={() => handleFileRemove(file.fileName)} + disabled={isLoading || isUploading} + > + <X className="h-4 w-4" /> + </Button> + </FileListAction> + </FileListItem> + ))} + </FileList> + </div> + )} + + {/* 파일 업로드 안내 */} + <Alert> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + <ul className="list-disc list-inside space-y-1 text-sm"> + <li className="font-medium">최소 1개 이상의 파일 첨부가 필수입니다</li> + <li>최대 파일 크기: 50MB</li> + <li>지원 형식: PDF, Excel, Word, 이미지 파일</li> + <li>여러 파일을 한 번에 선택하여 업로드할 수 있습니다</li> + </ul> + </AlertDescription> + </Alert> + </CardContent> + </Card> + </TabsContent> + </div> + </Tabs> + + <DialogFooter className="mt-6"> + <Button + type="button" + variant="outline" + onClick={handleClose} + disabled={isLoading || isUploading} + > + 취소 + </Button> + <Button + type="submit" + disabled={isLoading || isUploading || !form.formState.isValid} + > + {isLoading ? "등록 중..." : "구매요청 등록"} + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/lib/itb/table/create-rfq-dialog.tsx b/lib/itb/table/create-rfq-dialog.tsx new file mode 100644 index 00000000..57a4b9d4 --- /dev/null +++ b/lib/itb/table/create-rfq-dialog.tsx @@ -0,0 +1,380 @@ +// components/purchase-requests/create-rfq-dialog.tsx +"use client"; + +import * as React from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + FileText, + Package, + AlertCircle, + CheckCircle, + User, + ChevronsUpDown, + Check, + Loader2, + Info +} from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import type { PurchaseRequestView } from "@/db/schema"; +import { approvePurchaseRequestsAndCreateRfqs } from "../service"; +import { getPUsersForFilter } from "@/lib/rfq-last/service"; +import { useRouter } from "next/navigation"; + +interface CreateRfqDialogProps { + requests: PurchaseRequestView[]; + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess?: () => void; +} + +export function CreateRfqDialog({ + requests, + open, + onOpenChange, + onSuccess, +}: CreateRfqDialogProps) { + const [isLoading, setIsLoading] = React.useState(false); + const [userPopoverOpen, setUserPopoverOpen] = React.useState(false); + const [users, setUsers] = React.useState<any[]>([]); + const [selectedUser, setSelectedUser] = React.useState<any>(null); + const [isLoadingUsers, setIsLoadingUsers] = React.useState(false); + const [userSearchTerm, setUserSearchTerm] = React.useState(""); + const router = useRouter(); + + // 유저 목록 로드 + React.useEffect(() => { + const loadUsers = async () => { + setIsLoadingUsers(true); + try { + const userList = await getPUsersForFilter(); + setUsers(userList); + } catch (error) { + console.log("사용자 목록 로드 오류:", error); + toast.error("사용자 목록을 불러오는데 실패했습니다"); + } finally { + setIsLoadingUsers(false); + } + }; + + if (open) { + loadUsers(); + } + }, [open]); + + // 검색된 사용자 필터링 + const filteredUsers = React.useMemo(() => { + if (!userSearchTerm) return users; + + return users.filter(user => + user.name.toLowerCase().includes(userSearchTerm.toLowerCase()) || + user.userCode?.toLowerCase().includes(userSearchTerm.toLowerCase()) + ); + }, [users, userSearchTerm]); + + // 유효한 요청만 필터링 (이미 RFQ 생성된 것 제외) + const validRequests = requests.filter(r => r.status !== "RFQ생성완료"); + const invalidRequests = requests.filter(r => r.status === "RFQ생성완료"); + + const handleSelectUser = (user: any) => { + setSelectedUser(user); + setUserPopoverOpen(false); + }; + + const handleSubmit = async () => { + if (validRequests.length === 0) { + toast.error("RFQ를 생성할 수 있는 구매 요청이 없습니다"); + return; + } + + try { + setIsLoading(true); + + const requestIds = validRequests.map(r => r.id); + const results = await approvePurchaseRequestsAndCreateRfqs( + requestIds, + selectedUser?.id + ); + + const successCount = results.filter(r => r.success).length; + const skipCount = results.filter(r => r.skipped).length; + + if (successCount > 0) { + toast.success(`${successCount}개의 RFQ가 생성되었습니다`); + } + + if (skipCount > 0) { + toast.info(`${skipCount}개는 이미 RFQ가 생성되어 건너뛰었습니다`); + } + + onOpenChange(false); + onSuccess?.(); + router.refresh() + } catch (error) { + console.error("RFQ 생성 오류:", error); + toast.error("RFQ 생성 중 오류가 발생했습니다"); + } finally { + setIsLoading(false); + } + }; + + const handleClose = () => { + if (!isLoading) { + setSelectedUser(null); + setUserSearchTerm(""); + onOpenChange(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={handleClose}> + <DialogContent className="max-w-4xl"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <FileText className="h-5 w-5" /> + RFQ 생성 + </DialogTitle> + <DialogDescription> + 선택한 구매 요청을 기반으로 RFQ를 생성합니다. + {invalidRequests.length > 0 && " 이미 RFQ가 생성된 항목은 제외됩니다."} + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {/* 경고 메시지 */} + {invalidRequests.length > 0 && ( + <Alert> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + {invalidRequests.length}개 항목은 이미 RFQ가 생성되어 제외됩니다. + </AlertDescription> + </Alert> + )} + + {/* 구매 담당자 선택 */} + <div className="space-y-2"> + <label className="text-sm font-medium"> + 구매 담당자 (선택사항) + </label> + <Popover open={userPopoverOpen} onOpenChange={setUserPopoverOpen}> + <PopoverTrigger asChild> + <Button + type="button" + variant="outline" + className="w-full justify-between h-10" + disabled={isLoadingUsers} + > + {isLoadingUsers ? ( + <> + <span>담당자 로딩 중...</span> + <Loader2 className="ml-2 h-4 w-4 animate-spin" /> + </> + ) : ( + <> + <span className="flex items-center gap-2"> + <User className="h-4 w-4" /> + {selectedUser ? ( + <> + {selectedUser.name} + {selectedUser.userCode && ( + <span className="text-muted-foreground"> + ({selectedUser.userCode}) + </span> + )} + </> + ) : ( + <span className="text-muted-foreground"> + 구매 담당자를 선택하세요 (선택사항) + </span> + )} + </span> + <ChevronsUpDown className="h-4 w-4 opacity-50" /> + </> + )} + </Button> + </PopoverTrigger> + <PopoverContent className="w-[400px] p-0"> + <Command> + <CommandInput + placeholder="이름 또는 코드로 검색..." + value={userSearchTerm} + onValueChange={setUserSearchTerm} + /> + <CommandList className="max-h-[300px]"> + <CommandEmpty>검색 결과가 없습니다</CommandEmpty> + <CommandGroup> + {filteredUsers.map((user) => ( + <CommandItem + key={user.id} + onSelect={() => handleSelectUser(user)} + className="flex items-center justify-between" + > + <span className="flex items-center gap-2"> + <User className="h-4 w-4" /> + {user.name} + {user.userCode && ( + <span className="text-muted-foreground text-sm"> + ({user.userCode}) + </span> + )} + </span> + <Check + className={cn( + "h-4 w-4", + selectedUser?.id === user.id ? "opacity-100" : "opacity-0" + )} + /> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + <p className="text-xs text-muted-foreground"> + 구매 담당자를 선택하지 않으면 나중에 지정할 수 있습니다 + </p> + </div> + + {/* RFQ 생성 대상 목록 */} + <div className="space-y-2"> + <label className="text-sm font-medium"> + RFQ 생성 대상 ({validRequests.length}개) + </label> + <div className="border rounded-lg max-h-[300px] overflow-y-auto"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[140px]">요청번호</TableHead> + <TableHead>요청제목</TableHead> + <TableHead className="w-[120px]">프로젝트</TableHead> + <TableHead className="w-[100px]">패키지</TableHead> + <TableHead className="w-[80px] text-center">품목</TableHead> + <TableHead className="w-[80px] text-center">첨부</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {validRequests.length === 0 ? ( + <TableRow> + <TableCell colSpan={6} className="text-center text-muted-foreground py-8"> + RFQ를 생성할 수 있는 구매 요청이 없습니다 + </TableCell> + </TableRow> + ) : ( + validRequests.map((request) => ( + <TableRow key={request.id}> + <TableCell className="font-mono text-sm"> + {request.requestCode} + </TableCell> + <TableCell className="max-w-[250px]"> + <div className="truncate" title={request.requestTitle}> + {request.requestTitle} + </div> + </TableCell> + <TableCell> + <div className="truncate" title={request.projectName}> + {request.projectCode} + </div> + </TableCell> + <TableCell> + <div className="truncate" title={request.packageName}> + {request.packageNo} + </div> + </TableCell> + <TableCell className="text-center"> + {request.itemCount > 0 && ( + <Badge variant="secondary" className="gap-1"> + <Package className="h-3 w-3" /> + {request.itemCount} + </Badge> + )} + </TableCell> + <TableCell className="text-center"> + {request.attachmentCount > 0 && ( + <Badge variant="secondary" className="gap-1"> + <FileText className="h-3 w-3" /> + {request.attachmentCount} + </Badge> + )} + </TableCell> + </TableRow> + )) + )} + </TableBody> + </Table> + </div> + </div> + + {/* 안내 메시지 */} + <Alert> + <Info className="h-4 w-4" /> + <AlertDescription> + <ul className="list-disc list-inside space-y-1 text-sm"> + <li>RFQ 생성 시 구매 요청의 첨부파일이 자동으로 이관됩니다</li> + <li>구매 요청 상태가 "RFQ생성완료"로 변경됩니다</li> + <li>각 구매 요청별로 개별 RFQ가 생성됩니다</li> + </ul> + </AlertDescription> + </Alert> + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={handleClose} + disabled={isLoading} + > + 취소 + </Button> + <Button + onClick={handleSubmit} + disabled={isLoading || validRequests.length === 0} + > + {isLoading ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + RFQ 생성 중... + </> + ) : ( + <> + <CheckCircle className="mr-2 h-4 w-4" /> + RFQ 생성 ({validRequests.length}개) + </> + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/lib/itb/table/delete-purchase-request-dialog.tsx b/lib/itb/table/delete-purchase-request-dialog.tsx new file mode 100644 index 00000000..2f09cf70 --- /dev/null +++ b/lib/itb/table/delete-purchase-request-dialog.tsx @@ -0,0 +1,225 @@ +// components/purchase-requests/delete-purchase-request-dialog.tsx +"use client" + +import * as React from "react" +import { type PurchaseRequestView } from "@/db/schema/purchase-requests-view" +import { type Row } from "@tanstack/react-table" +import { Loader, Trash } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" + +import { deletePurchaseRequests } from "../service" + +interface DeletePurchaseRequestDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + requests?: Row<PurchaseRequestView>["original"][] | PurchaseRequestView[] + request?: PurchaseRequestView // 단일 삭제용 + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeletePurchaseRequestDialog({ + requests: requestsProp, + request, + showTrigger = true, + onSuccess, + ...props +}: DeletePurchaseRequestDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + // 단일 또는 복수 요청 처리 + const requests = requestsProp || (request ? [request] : []) + const isMultiple = requests.length > 1 + + // 삭제 불가능한 상태 체크 + const nonDeletableRequests = requests.filter( + req => req.status !== "작성중" + ) + const canDelete = nonDeletableRequests.length === 0 + + function onDelete() { + if (!canDelete) { + toast.error("작성중 상태의 요청만 삭제할 수 있습니다.") + return + } + + startDeleteTransition(async () => { + const { error } = await deletePurchaseRequests({ + ids: requests.map((req) => req.id), + }) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + toast.success( + isMultiple + ? `${requests.length}개의 구매요청이 삭제되었습니다.` + : "구매요청이 삭제되었습니다." + ) + onSuccess?.() + }) + } + + const dialogContent = ( + <> + <div className="space-y-4"> + <div> + <p className="text-sm text-muted-foreground"> + 이 작업은 되돌릴 수 없습니다. + {isMultiple ? ( + <> + 선택한 <span className="font-medium text-foreground">{requests.length}개</span>의 구매요청이 영구적으로 삭제됩니다. + </> + ) : ( + <> + 구매요청 <span className="font-medium text-foreground">{requests[0]?.requestCode}</span>이(가) 영구적으로 삭제됩니다. + </> + )} + </p> + </div> + + {/* 삭제할 요청 목록 표시 (복수인 경우) */} + {isMultiple && ( + <div className="rounded-lg border bg-muted/30 p-3"> + <p className="text-sm font-medium mb-2">삭제할 구매요청:</p> + <ul className="space-y-1 max-h-32 overflow-y-auto"> + {requests.map((req) => ( + <li key={req.id} className="text-sm text-muted-foreground"> + <span className="font-mono">{req.requestCode}</span> + {" - "} + <span className="truncate">{req.requestTitle}</span> + </li> + ))} + </ul> + </div> + )} + + {/* 삭제 불가능한 요청이 있는 경우 경고 */} + {nonDeletableRequests.length > 0 && ( + <div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3"> + <p className="text-sm font-medium text-destructive mb-2"> + 삭제할 수 없는 요청: + </p> + <ul className="space-y-1"> + {nonDeletableRequests.map((req) => ( + <li key={req.id} className="text-sm text-destructive/80"> + <span className="font-mono">{req.requestCode}</span> + {" - 상태: "} + <span className="font-medium">{req.status}</span> + </li> + ))} + </ul> + <p className="text-xs text-destructive/60 mt-2"> + ※ 작성중 상태의 요청만 삭제할 수 있습니다. + </p> + </div> + )} + </div> + </> + ) + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm" disabled={requests.length === 0}> + <Trash className="mr-2 size-4" aria-hidden="true" /> + 삭제 {requests.length > 0 && `(${requests.length})`} + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>구매요청을 삭제하시겠습니까?</DialogTitle> + <DialogDescription> + {dialogContent} + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">취소</Button> + </DialogClose> + <Button + aria-label="Delete selected requests" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending || !canDelete} + > + {isDeletePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + 삭제 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm" disabled={requests.length === 0}> + <Trash className="mr-2 size-4" aria-hidden="true" /> + 삭제 {requests.length > 0 && `(${requests.length})`} + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>구매요청을 삭제하시겠습니까?</DrawerTitle> + <DrawerDescription> + {dialogContent} + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">취소</Button> + </DrawerClose> + <Button + aria-label="Delete selected requests" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending || !canDelete} + > + {isDeletePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + 삭제 + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +}
\ No newline at end of file diff --git a/lib/itb/table/edit-purchase-request-sheet.tsx b/lib/itb/table/edit-purchase-request-sheet.tsx new file mode 100644 index 00000000..8a818ca5 --- /dev/null +++ b/lib/itb/table/edit-purchase-request-sheet.tsx @@ -0,0 +1,1081 @@ +// components/purchase-requests/edit-purchase-request-sheet.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 { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetFooter, +} from "@/components/ui/sheet"; +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 { CalendarIcon, X, Plus, FileText, Package, Trash2, Upload, FileIcon, AlertCircle, Paperclip, Save } from "lucide-react"; +import { format } from "date-fns"; +import { cn } from "@/lib/utils"; +import { updatePurchaseRequest } from "../service"; +import { toast } from "sonner"; +import { nanoid } from "nanoid"; +import type { PurchaseRequestView } from "@/db/schema"; +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"; + +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([]), +}); + +type FormData = z.infer<typeof formSchema>; + +interface EditPurchaseRequestSheetProps { + request: PurchaseRequestView; + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess?: () => void; +} + +export function EditPurchaseRequestSheet({ + request, + open, + onOpenChange, + onSuccess, +}: EditPurchaseRequestSheetProps) { + const [isLoading, setIsLoading] = React.useState(false); + const [activeTab, setActiveTab] = React.useState("basic"); + const [selectedPackage, setSelectedPackage] = React.useState<PackageItem | null>(null); + const [selectedMaterials, setSelectedMaterials] = React.useState<any[]>([]); + const [resetKey, setResetKey] = React.useState(0); + const [newFiles, setNewFiles] = React.useState<File[]>([]); // 새로 추가할 파일 + const [existingFiles, setExistingFiles] = React.useState<z.infer<typeof attachmentSchema>[]>([]); // 기존 파일 + const [isUploading, setIsUploading] = React.useState(false); + const [uploadProgress, setUploadProgress] = React.useState(0); + + // 기존 아이템 처리 + const existingItems = React.useMemo(() => { + if (!request.items || !Array.isArray(request.items) || request.items.length === 0) { + return []; + } + return request.items.map(item => ({ + ...item, + id: item.id || nanoid(), + })); + }, [request.items]); + + // 기존 첨부파일 로드 (실제로는 API에서 가져와야 함) + React.useEffect(() => { + // TODO: 기존 첨부파일 로드 API 호출 + // setExistingFiles(request.attachments || []); + }, [request.id]); + + // 기존 자재그룹 데이터 설정 + React.useEffect(() => { + if (request.majorItemMaterialCategory && request.majorItemMaterialDescription) { + setSelectedMaterials([{ + materialGroupCode: request.majorItemMaterialCategory, + materialGroupDescription: request.majorItemMaterialDescription, + displayText:`${request.majorItemMaterialCategory} - ${request.majorItemMaterialDescription}` + }]); + } + }, [request.majorItemMaterialCategory, request.majorItemMaterialDescription]); + + // 기존 패키지 데이터 설정 + React.useEffect(() => { + if (request.packageNo && request.packageName) { + setSelectedPackage({ + packageCode: request.packageNo, + description: request.packageName, + smCode: request.smCode || "", + } as PackageItem); + } + }, [request.packageNo, request.packageName, request.smCode]); + + const form = useForm<FormData>({ + resolver: zodResolver(formSchema), + mode: "onChange", + criteriaMode: "all", + defaultValues: { + requestTitle: request.requestTitle || "", + requestDescription: request.requestDescription || "", + projectId: request.projectId || undefined, + projectCode: request.projectCode || "", + projectName: request.projectName || "", + projectCompany: request.projectCompany || "", + projectSite: request.projectSite || "", + classNo: request.classNo || "", + packageNo: request.packageNo || "", + packageName: request.packageName || "", + majorItemMaterialCategory: request.majorItemMaterialCategory || "", + majorItemMaterialDescription: request.majorItemMaterialDescription || "", + smCode: request.smCode || "", + estimatedBudget: request.estimatedBudget || "", + requestedDeliveryDate: request.requestedDeliveryDate + ? new Date(request.requestedDeliveryDate) + : undefined, + items: existingItems, + }, + }); + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "items", + }); + + // 프로젝트 선택 처리 + 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 handleFileAdd = (files: File[]) => { + if (files.length === 0) return; + + // 중복 파일 체크 + const duplicates = files.filter(file => + newFiles.some(existing => existing.name === file.name) + ); + + if (duplicates.length > 0) { + toast.warning(`${duplicates.length}개 파일이 이미 추가되어 있습니다`); + } + + const uniqueFiles = files.filter(file => + !newFiles.some(existing => existing.name === file.name) + ); + + if (uniqueFiles.length > 0) { + setNewFiles(prev => [...prev, ...uniqueFiles]); + toast.success(`${uniqueFiles.length}개 파일이 추가되었습니다`); + } + }; + + // 새 파일 삭제 핸들러 + const handleNewFileRemove = (fileName: string) => { + setNewFiles(prev => prev.filter(f => f.name !== fileName)); + }; + + // 기존 파일 삭제 핸들러 (실제로는 서버에 삭제 요청) + const handleExistingFileRemove = (fileName: string) => { + // TODO: 서버에 삭제 표시 + setExistingFiles(prev => prev.filter(f => f.fileName !== fileName)); + }; + + const onSubmit = async (values: FormData) => { + try { + setIsLoading(true); + + // 새 파일이 있으면 먼저 업로드 + let uploadedAttachments: z.infer<typeof attachmentSchema>[] = []; + + if (newFiles.length > 0) { + setIsUploading(true); + setUploadProgress(10); + + const formData = new FormData(); + newFiles.forEach(file => { + formData.append("files", file); + }); + + setUploadProgress(30); + + try { + const response = await fetch("/api/upload/purchase-request", { + method: "POST", + body: formData, + }); + + setUploadProgress(60); + + if (!response.ok) { + throw new Error("파일 업로드 실패"); + } + + const data = await response.json(); + uploadedAttachments = data.files; + setUploadProgress(80); + } catch (error) { + toast.error("파일 업로드 중 오류가 발생했습니다"); + setIsUploading(false); + setIsLoading(false); + setUploadProgress(0); + return; + } + } + + setUploadProgress(90); + + // 구매 요청 업데이트 (기존 파일 + 새로 업로드된 파일) + const result = await updatePurchaseRequest(request.id, { + ...values, + items: values.items, + attachments: [...existingFiles, ...uploadedAttachments], + }); + + setUploadProgress(100); + + if (result.error) { + toast.error(result.error); + return; + } + + toast.success("구매 요청이 수정되었습니다"); + handleClose(); + onSuccess?.(); + } catch (error) { + toast.error("구매 요청 수정에 실패했습니다"); + console.error(error); + } finally { + setIsLoading(false); + setIsUploading(false); + setUploadProgress(0); + } + }; + + const handleClose = () => { + form.reset(); + setSelectedPackage(null); + setSelectedMaterials([]); + setNewFiles([]); + setExistingFiles([]); + setActiveTab("basic"); + setResetKey((k) => k + 1); + onOpenChange(false); + }; + + const canEdit = request.status === "작성중"; + const totalFileCount = existingFiles.length + newFiles.length; + + return ( + <Sheet open={open} onOpenChange={handleClose}> + <SheetContent key={resetKey} className="w-[900px] max-w-[900px] overflow-hidden flex flex-col min-h-0" style={{width:900, maxWidth:900}}> + <SheetHeader> + <SheetTitle>구매 요청 수정</SheetTitle> + <SheetDescription> + 요청번호: {request.requestCode} | 상태: {request.status} + </SheetDescription> + </SheetHeader> + + {!canEdit ? ( + <div className="flex-1 flex items-center justify-center"> + <Card> + <CardContent className="pt-6"> + <p className="text-center text-muted-foreground"> + 작성중 상태의 요청만 수정할 수 있습니다. + </p> + </CardContent> + </Card> + </div> + ) : ( + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex-1 overflow-hidden flex flex-col min-h-0"> + <Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col min-h-0"> + <TabsList className="grid w-full grid-cols-3 flex-shrink-0"> + <TabsTrigger value="basic"> + <FileText className="mr-2 h-4 w-4" /> + 기본 정보 + </TabsTrigger> + <TabsTrigger value="items"> + <Package className="mr-2 h-4 w-4" /> + 품목 정보 + {fields.length > 0 && ( + <span className="ml-2 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground"> + {fields.length} + </span> + )} + </TabsTrigger> + <TabsTrigger value="files"> + <Paperclip className="mr-2 h-4 w-4" /> + 첨부파일 + {totalFileCount > 0 && ( + <span className="ml-2 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground"> + {totalFileCount} + </span> + )} + </TabsTrigger> + </TabsList> + + <div className="flex-1 overflow-y-auto px-1 min-h-0"> + <TabsContent value="basic" className="space-y-6 mt-6"> + {/* 프로젝트 정보 */} + <Card> + <CardHeader> + <CardTitle className="text-base">프로젝트 정보 <span className="text-red-500">*</span></CardTitle> + <CardDescription>프로젝트, 패키지, 자재그룹을 순서대로 선택하세요</CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + {/* 프로젝트 선택 */} + <FormField + control={form.control} + name="projectId" + render={({ field }) => ( + <FormItem> + <FormLabel>프로젝트 <span className="text-red-500">*</span></FormLabel> + <ProjectSelector + key={`project-${resetKey}`} + selectedProjectId={field.value} + onProjectSelect={handleProjectSelect} + placeholder="프로젝트를 검색하여 선택..." + /> + <FormMessage /> + </FormItem> + )} + /> + + {/* 패키지 선택 */} + <FormField + control={form.control} + name="packageNo" + render={({ field }) => ( + <FormItem> + <FormLabel>패키지 <span className="text-red-500">*</span></FormLabel> + <PackageSelector + projectNo={form.watch("projectCode")} + selectedPackage={selectedPackage} + onPackageSelect={handlePackageSelect} + placeholder="패키지 선택..." + /> + <FormMessage /> + </FormItem> + )} + /> + + {/* 자재그룹 선택 */} + <FormField + control={form.control} + name="majorItemMaterialCategory" + render={({ field }) => ( + <FormItem> + <FormLabel>자재그룹 <span className="text-red-500">*</span></FormLabel> + <MaterialGroupSelector + selectedMaterials={selectedMaterials} + onMaterialsChange={handleMaterialsChange} + singleSelect={true} + placeholder="자재그룹을 검색하세요..." + noValuePlaceHolder="자재그룹을 선택해주세요" + /> + <FormMessage /> + </FormItem> + )} + /> + + {/* SM 코드 */} + <FormField + control={form.control} + name="smCode" + render={({ field }) => ( + <FormItem> + <FormLabel>SM 코드 <span className="text-red-500">*</span></FormLabel> + <FormControl> + <Input + placeholder="SM 코드 입력" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 선택된 정보 표시 */} + {form.watch("projectId") && ( + <div className="grid grid-cols-2 gap-4 pt-4 border-t"> + <div> + <p className="text-sm text-muted-foreground">프로젝트 코드</p> + <p className="font-medium">{form.watch("projectCode") || "-"}</p> + </div> + <div> + <p className="text-sm text-muted-foreground">프로젝트명</p> + <p className="font-medium">{form.watch("projectName") || "-"}</p> + </div> + <div> + <p className="text-sm text-muted-foreground">발주처</p> + <p className="font-medium">{form.watch("projectCompany") || "-"}</p> + </div> + <div> + <p className="text-sm text-muted-foreground">현장</p> + <p className="font-medium">{form.watch("projectSite") || "-"}</p> + </div> + </div> + )} + </CardContent> + </Card> + + {/* 기본 정보 */} + <div className="space-y-4"> + <FormField + control={form.control} + name="requestTitle" + render={({ field }) => ( + <FormItem> + <FormLabel>요청 제목 <span className="text-red-500">*</span></FormLabel> + <FormControl> + <Input + placeholder="예: 프로젝트 A용 밸브 구매" + {...field} + /> + </FormControl> + <FormDescription> + 자동 생성된 제목을 수정할 수 있습니다 + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="requestDescription" + render={({ field }) => ( + <FormItem> + <FormLabel>요청 설명</FormLabel> + <FormControl> + <Textarea + placeholder="구매 요청에 대한 상세 설명을 입력하세요" + className="min-h-[100px]" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + {/* 예산 및 납기 */} + <div className="space-y-4"> + <h3 className="text-sm font-medium">예산 및 납기</h3> + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="estimatedBudget" + render={({ field }) => ( + <FormItem> + <FormLabel>예상 예산</FormLabel> + <FormControl> + <Input placeholder="예: 10,000,000원" {...field} /> + </FormControl> + <FormDescription> + 전체 구매 예상 예산을 입력하세요 + </FormDescription> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="requestedDeliveryDate" + render={({ field }) => ( + <FormItem> + <FormLabel>희망 납기일</FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + className={cn( + "w-full pl-3 text-left font-normal", + !field.value && "text-muted-foreground" + )} + > + {field.value ? ( + format(field.value, "yyyy-MM-dd") + ) : ( + <span>날짜 선택</span> + )} + <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <Calendar + mode="single" + selected={field.value} + onSelect={field.onChange} + disabled={(date) => date < new Date()} + /> + </PopoverContent> + </Popover> + <FormDescription> + 자재가 필요한 날짜를 선택하세요 + </FormDescription> + </FormItem> + )} + /> + </div> + </div> + </TabsContent> + + <TabsContent value="items" className="space-y-4 mt-6"> + {/* 품목 목록 */} + <div className="flex justify-between items-center"> + <h3 className="text-sm font-medium">품목 목록</h3> + <Button + type="button" + variant="outline" + size="sm" + onClick={addItem} + > + <Plus className="mr-2 h-4 w-4" /> + 품목 추가 + </Button> + </div> + + {fields.length === 0 ? ( + <Card> + <CardContent className="flex flex-col items-center justify-center py-8"> + <Package className="h-12 w-12 text-muted-foreground mb-4" /> + <p className="text-sm text-muted-foreground mb-4"> + 아직 추가된 품목이 없습니다 + </p> + <Button + type="button" + variant="outline" + size="sm" + onClick={addItem} + > + <Plus className="mr-2 h-4 w-4" /> + 첫 품목 추가 + </Button> + </CardContent> + </Card> + ) : ( + <div className="border rounded-lg"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[120px]">아이템 코드</TableHead> + <TableHead className="w-[150px]">아이템명 <span className="text-red-500">*</span></TableHead> + <TableHead>사양</TableHead> + <TableHead className="w-[80px]">수량 <span className="text-red-500">*</span></TableHead> + <TableHead className="w-[80px]">단위 <span className="text-red-500">*</span></TableHead> + <TableHead className="w-[120px]">예상 단가</TableHead> + <TableHead className="w-[120px]">예상 금액</TableHead> + <TableHead>비고</TableHead> + <TableHead className="w-[50px]"></TableHead> + </TableRow> + </TableHeader> + <TableBody> + {fields.map((field, index) => ( + <TableRow key={field.id}> + <TableCell> + <FormField + control={form.control} + name={`items.${index}.itemCode`} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + placeholder="코드" + {...field} + className="h-8" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </TableCell> + <TableCell> + <FormField + control={form.control} + name={`items.${index}.itemName`} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + placeholder="아이템명" + {...field} + className="h-8" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </TableCell> + <TableCell> + <FormField + control={form.control} + name={`items.${index}.specification`} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + placeholder="사양" + {...field} + className="h-8" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + <TableCell> + <FormField + control={form.control} + name={`items.${index}.quantity`} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + type="number" + min="1" + {...field} + className="h-8" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </TableCell> + <TableCell> + <FormField + control={form.control} + name={`items.${index}.unit`} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + placeholder="개" + {...field} + className="h-8" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </TableCell> + <TableCell> + <FormField + control={form.control} + name={`items.${index}.estimatedUnitPrice`} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + type="number" + min="0" + placeholder="0" + {...field} + className="h-8" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + <TableCell> + <div className="text-right font-medium"> + {new Intl.NumberFormat('ko-KR').format( + (form.watch(`items.${index}.quantity`) || 0) * + (form.watch(`items.${index}.estimatedUnitPrice`) || 0) + )} + </div> + </TableCell> + <TableCell> + <FormField + control={form.control} + name={`items.${index}.remarks`} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + placeholder="비고" + {...field} + className="h-8" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + <TableCell> + <Button + type="button" + variant="ghost" + size="icon" + className="h-8 w-8" + onClick={() => remove(index)} + disabled={fields.length === 1} + > + <Trash2 className="h-4 w-4" /> + </Button> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + + <div className="flex justify-between items-center p-4 border-t bg-muted/30"> + <div className="text-sm text-muted-foreground"> + 총 {fields.length}개 품목 + </div> + <div className="text-right"> + <p className="text-sm text-muted-foreground">예상 총액</p> + <p className="text-lg font-semibold"> + {new Intl.NumberFormat('ko-KR', { + style: 'currency', + currency: 'KRW' + }).format(calculateTotal())} + </p> + </div> + </div> + </div> + )} + </TabsContent> + + <TabsContent value="files" className="space-y-4 mt-6"> + {/* 파일 업로드 영역 */} + <Card> + <CardHeader> + <CardTitle className="text-base">첨부파일 관리</CardTitle> + <CardDescription> + 설계 도면, 사양서, 견적서 등 구매 요청 관련 문서를 관리하세요 + </CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + {/* 업로드 진행 상태 */} + {isUploading && ( + <Alert> + <AlertCircle className="h-4 w-4" /> + <AlertDescription className="space-y-2"> + <p>파일을 업로드하고 데이터를 저장 중입니다...</p> + <Progress value={uploadProgress} className="w-full" /> + </AlertDescription> + </Alert> + )} + + {/* 기존 파일 목록 */} + {existingFiles.length > 0 && ( + <div className="space-y-2"> + <h4 className="text-sm font-medium">기존 첨부파일</h4> + <FileList> + {existingFiles.map((file) => ( + <FileListItem key={file.fileName}> + <FileListIcon> + <FileIcon className="h-4 w-4" /> + </FileListIcon> + <FileListInfo> + <FileListHeader> + <FileListName> + {file.originalFileName} + </FileListName> + <FileListSize> + {file.fileSize} + </FileListSize> + </FileListHeader> + + </FileListInfo> + <FileListAction> + <Button + variant="ghost" + size="icon" + className="h-8 w-8" + onClick={() => handleExistingFileRemove(file.fileName)} + disabled={isLoading} + > + <X className="h-4 w-4" /> + </Button> + </FileListAction> + </FileListItem> + ))} + </FileList> + </div> + )} + + {/* Dropzone */} + <Dropzone + onDrop={handleFileAdd} + accept={{ + 'application/pdf': ['.pdf'], + 'application/vnd.ms-excel': ['.xls'], + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], + 'application/msword': ['.doc'], + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], + 'image/*': ['.png', '.jpg', '.jpeg', '.gif'], + 'application/x-zip-compressed': ['.zip'], + 'application/x-rar-compressed': ['.rar'], + }} + maxSize={50 * 1024 * 1024} // 50MB + disabled={isLoading} + > + <DropzoneInput /> + <DropzoneZone> + <DropzoneUploadIcon className="h-12 w-12 text-muted-foreground mb-4" /> + <DropzoneTitle> + 파일을 드래그하거나 클릭하여 선택하세요 + </DropzoneTitle> + <DropzoneDescription> + PDF, Excel, Word, 이미지 파일 등을 업로드할 수 있습니다 (최대 50MB) + </DropzoneDescription> + </DropzoneZone> + </Dropzone> + + {/* 새로 추가할 파일 목록 */} + {newFiles.length > 0 && ( + <div className="space-y-2"> + <h4 className="text-sm font-medium text-blue-600">새로 추가할 파일</h4> + <FileList> + {newFiles.map((file) => ( + <FileListItem key={file.name} className="bg-blue-50"> + <FileListIcon> + <FileIcon className="h-4 w-4 text-blue-600" /> + </FileListIcon> + <FileListInfo> + <FileListHeader> + <FileListName> + {file.name} + </FileListName> + <FileListSize> + {file.size} + </FileListSize> + </FileListHeader> + <FileListDescription> + {file.type || "application/octet-stream"} + </FileListDescription> + </FileListInfo> + <FileListAction> + <Button + variant="ghost" + size="icon" + className="h-8 w-8" + onClick={() => handleNewFileRemove(file.name)} + disabled={isLoading} + > + <X className="h-4 w-4" /> + </Button> + </FileListAction> + </FileListItem> + ))} + </FileList> + </div> + )} + + {/* 파일 업로드 안내 */} + <Alert> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + <ul className="list-disc list-inside space-y-1 text-sm"> + <li>새 파일은 수정 완료 시 서버로 업로드됩니다</li> + <li>최대 파일 크기: 50MB</li> + <li>지원 형식: PDF, Excel, Word, 이미지 파일</li> + <li>여러 파일을 한 번에 선택하여 추가할 수 있습니다</li> + </ul> + </AlertDescription> + </Alert> + </CardContent> + </Card> + </TabsContent> + </div> + </Tabs> + + <SheetFooter className="mt-6"> + <Button + type="button" + variant="outline" + onClick={handleClose} + disabled={isLoading || isUploading} + > + 취소 + </Button> + <Button type="submit" disabled={isLoading || isUploading || !form.formState.isValid}> + <Save className="mr-2 h-4 w-4" /> + {isLoading ? "수정 중..." : "수정 완료"} + </Button> + </SheetFooter> + </form> + </Form> + )} + </SheetContent> + </Sheet> + ); +}
\ No newline at end of file diff --git a/lib/itb/table/items-dialog.tsx b/lib/itb/table/items-dialog.tsx new file mode 100644 index 00000000..dd688ce9 --- /dev/null +++ b/lib/itb/table/items-dialog.tsx @@ -0,0 +1,167 @@ +// components/purchase-requests/items-dialog.tsx +"use client"; + +import * as React from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + TableFooter, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { Package } from "lucide-react"; + +interface Item { + id: string; + itemCode: string; + itemName: string; + specification: string; + quantity: number; + unit: string; + estimatedUnitPrice?: number; + remarks?: string; +} + +interface ItemsDialogProps { + requestId: number; + requestCode: string; + items: Item[] | any; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function ItemsDialog({ + requestId, + requestCode, + items, + open, + onOpenChange, +}: ItemsDialogProps) { + // items가 없거나 배열이 아닌 경우 처리 + const itemList = React.useMemo(() => { + if (!items || !Array.isArray(items)) return []; + return items; + }, [items]); + + // 총액 계산 + const totalAmount = React.useMemo(() => { + return itemList.reduce((sum, item) => { + const subtotal = (item.quantity || 0) * (item.estimatedUnitPrice || 0); + return sum + subtotal; + }, 0); + }, [itemList]); + + // 총 수량 계산 + const totalQuantity = React.useMemo(() => { + return itemList.reduce((sum, item) => sum + (item.quantity || 0), 0); + }, [itemList]); + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-5xl max-h-[80vh] overflow-hidden flex flex-col"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Package className="h-5 w-5" /> + 품목 상세 정보 + </DialogTitle> + <DialogDescription> + 요청번호: {requestCode} | 총 {itemList.length}개 품목 + </DialogDescription> + </DialogHeader> + + <div className="flex-1 overflow-auto"> + {itemList.length === 0 ? ( + <div className="flex items-center justify-center h-32"> + <p className="text-muted-foreground">등록된 품목이 없습니다.</p> + </div> + ) : ( + <Table> + <TableHeader className="sticky top-0 bg-background"> + <TableRow> + <TableHead className="w-[50px]">번호</TableHead> + <TableHead className="w-[120px]">아이템 코드</TableHead> + <TableHead>아이템명</TableHead> + <TableHead>사양</TableHead> + <TableHead className="text-right w-[80px]">수량</TableHead> + <TableHead className="w-[60px]">단위</TableHead> + <TableHead className="text-right w-[120px]">예상 단가</TableHead> + <TableHead className="text-right w-[140px]">예상 금액</TableHead> + <TableHead className="w-[150px]">비고</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {itemList.map((item, index) => { + const subtotal = (item.quantity || 0) * (item.estimatedUnitPrice || 0); + return ( + <TableRow key={item.id || index}> + <TableCell className="text-center text-muted-foreground"> + {index + 1} + </TableCell> + <TableCell className="font-mono text-sm"> + {item.itemCode} + </TableCell> + <TableCell className="font-medium"> + {item.itemName} + </TableCell> + <TableCell className="text-sm"> + {item.specification || "-"} + </TableCell> + <TableCell className="text-right font-medium"> + {item.quantity?.toLocaleString('ko-KR')} + </TableCell> + <TableCell className="text-center"> + {item.unit} + </TableCell> + <TableCell className="text-right"> + {item.estimatedUnitPrice + ? new Intl.NumberFormat('ko-KR').format(item.estimatedUnitPrice) + : "-"} + </TableCell> + <TableCell className="text-right font-medium"> + {subtotal > 0 + ? new Intl.NumberFormat('ko-KR').format(subtotal) + : "-"} + </TableCell> + <TableCell className="text-sm text-muted-foreground"> + {item.remarks || "-"} + </TableCell> + </TableRow> + ); + })} + </TableBody> + <TableFooter> + <TableRow className="bg-muted/50"> + <TableCell colSpan={4} className="font-medium"> + 합계 + </TableCell> + <TableCell className="text-right font-bold"> + {totalQuantity.toLocaleString('ko-KR')} + </TableCell> + <TableCell></TableCell> + <TableCell></TableCell> + <TableCell className="text-right font-bold text-lg"> + {new Intl.NumberFormat('ko-KR', { + style: 'currency', + currency: 'KRW' + }).format(totalAmount)} + </TableCell> + <TableCell></TableCell> + </TableRow> + </TableFooter> + </Table> + )} + </div> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/lib/itb/table/purchase-request-columns.tsx b/lib/itb/table/purchase-request-columns.tsx new file mode 100644 index 00000000..55321a21 --- /dev/null +++ b/lib/itb/table/purchase-request-columns.tsx @@ -0,0 +1,380 @@ +// components/purchase-requests/purchase-request-columns.tsx +"use client"; + +import { type ColumnDef } from "@tanstack/react-table"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Eye, + Edit, + Trash2, + CheckCircle, + XCircle, + FileText, + Package, + Send, + Clock +} from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { MoreHorizontal } from "lucide-react"; +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"; +import { format } from "date-fns"; +import { ko } from "date-fns/locale"; +import type { DataTableRowAction } from "@/types/table"; +import type { PurchaseRequestView } from "@/db/schema"; + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<PurchaseRequestView> | null>>; +} + +const statusConfig = { + "작성중": { + label: "작성중", + variant: "secondary" as const, + icon: Edit, + color: "text-gray-500" + }, + "요청완료": { + label: "요청완료", + variant: "default" as const, + icon: Send, + color: "text-blue-500" + }, + "검토중": { + label: "검토중", + variant: "warning" as const, + icon: Clock, + color: "text-yellow-500" + }, + "승인": { + label: "승인", + variant: "success" as const, + icon: CheckCircle, + color: "text-green-500" + }, + "반려": { + label: "반려", + variant: "destructive" as const, + icon: XCircle, + color: "text-red-500" + }, + "RFQ생성완료": { + label: "RFQ생성완료", + variant: "outline" as const, + icon: FileText, + color: "text-purple-500" + }, +}; + +export function getPurchaseRequestColumns({ + setRowAction +}: GetColumnsProps): ColumnDef<PurchaseRequestView>[] { + return [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + /> + ), + enableSorting: false, + enableHiding: false, + size: 40, + }, + { + accessorKey: "requestCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="요청번호" /> + ), + cell: ({ row }) => ( + <Button + variant="ghost" + className="h-auto p-0 font-mono font-medium text-primary hover:underline" + onClick={() => setRowAction({ row, type: "view" })} + > + {row.getValue("requestCode")} + </Button> + ), + size: 130, + }, + { + accessorKey: "status", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="상태" /> + ), + cell: ({ row }) => { + const status = row.getValue("status") as keyof typeof statusConfig; + const config = statusConfig[status]; + const Icon = config?.icon; + + return ( + <Badge variant={config?.variant} className="font-medium"> + {Icon && <Icon className={`mr-1 h-3 w-3 ${config.color}`} />} + {config?.label || status} + </Badge> + ); + }, + filterFn: (row, id, value) => { + return value.includes(row.getValue(id)); + }, + size: 120, + }, + { + accessorKey: "requestTitle", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="요청제목" /> + ), + cell: ({ row }) => ( + <div className="max-w-[300px]"> + <div className="truncate font-medium" title={row.getValue("requestTitle")}> + {row.getValue("requestTitle")} + </div> + {row.original.requestDescription && ( + <div className="truncate text-xs text-muted-foreground mt-0.5" + title={row.original.requestDescription}> + {row.original.requestDescription} + </div> + )} + </div> + ), + size: 300, + }, + { + accessorKey: "projectName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="프로젝트" /> + ), + cell: ({ row }) => ( + <div className="flex flex-col space-y-0.5"> + {row.original.projectCode && ( + <span className="font-mono text-xs text-muted-foreground"> + {row.original.projectCode} + </span> + )} + <span className="truncate text-sm" title={row.original.projectName}> + {row.original.projectName || "-"} + </span> + </div> + ), + size: 180, + }, + { + accessorKey: "packageName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="패키지" /> + ), + cell: ({ row }) => ( + <div className="flex flex-col space-y-0.5"> + {row.original.packageNo && ( + <span className="font-mono text-xs text-muted-foreground"> + {row.original.packageNo} + </span> + )} + <span className="truncate text-sm" title={row.original.packageName}> + {row.original.packageName || "-"} + </span> + </div> + ), + size: 150, + }, + { + id: "attachments", + header: "첨부", + cell: ({ row }) => { + const count = Number(row.original.attachmentCount) || 0; + return count > 0 ? ( + <Button + variant="ghost" + size="sm" + className="h-8 px-2" + onClick={() => setRowAction({ row, type: "attachments" })} + > + <FileText className="h-4 w-4 mr-1" /> + {count} + </Button> + ) : ( + <span className="text-muted-foreground text-sm">-</span> + ); + }, + size: 70, + }, + { + id: "items", + header: "품목", + cell: ({ row }) => { + const count = row.original.itemCount || 0; + const totalQuantity = row.original.totalQuantity; + return count > 0 ? ( + <Button + variant="ghost" + size="sm" + className="h-8 px-2" + onClick={() => setRowAction({ row, type: "items" })} + > + <Package className="h-4 w-4 mr-1" /> + <div className="flex flex-col items-start"> + <span>{count}종</span> + {totalQuantity && ( + <span className="text-xs text-muted-foreground"> + 총 {totalQuantity}개 + </span> + )} + </div> + </Button> + ) : ( + <span className="text-muted-foreground text-sm">-</span> + ); + }, + size: 90, + }, + { + accessorKey: "engPicName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="설계담당" /> + ), + cell: ({ row }) => ( + <div className="text-sm"> + {row.getValue("engPicName") || "-"} + </div> + ), + size: 100, + }, + { + accessorKey: "purchasePicName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="구매담당" /> + ), + cell: ({ row }) => ( + <div className="text-sm"> + {row.getValue("purchasePicName") || "-"} + </div> + ), + size: 100, + }, + { + accessorKey: "estimatedBudget", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="예상예산" /> + ), + cell: ({ row }) => ( + <div className="text-sm font-mono"> + {row.getValue("estimatedBudget") || "-"} + </div> + ), + size: 100, + }, + { + accessorKey: "requestedDeliveryDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="희망납기" /> + ), + cell: ({ row }) => { + const date = row.getValue("requestedDeliveryDate") as Date | null; + return ( + <div className="text-sm"> + {date ? format(new Date(date), "yyyy-MM-dd", { locale: ko }) : "-"} + </div> + ); + }, + size: 100, + }, + { + accessorKey: "createdAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="등록일" /> + ), + cell: ({ row }) => { + const date = row.getValue("createdAt") as Date; + return ( + <div className="text-sm text-muted-foreground"> + {date ? format(new Date(date), "yyyy-MM-dd", { locale: ko }) : "-"} + </div> + ); + }, + size: 100, + }, + { + id: "actions", + header: "작업", + cell: ({ row }) => { + const status = row.original.status; + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" className="h-8 w-8 p-0"> + <span className="sr-only">메뉴 열기</span> + <MoreHorizontal className="h-4 w-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuLabel>작업</DropdownMenuLabel> + <DropdownMenuItem + onClick={() => setRowAction({ row, type: "view" })} + > + <Eye className="mr-2 h-4 w-4" /> + 상세보기 + </DropdownMenuItem> + <DropdownMenuSeparator /> + + + + <DropdownMenuItem + onClick={() => setRowAction({ row, type: "update" })} + > + <Edit className="mr-2 h-4 w-4" /> + 수정 + </DropdownMenuItem> + <DropdownMenuItem + onClick={() => setRowAction({ row, type: "delete" })} + className="text-destructive" + > + <Trash2 className="mr-2 h-4 w-4" /> + 삭제 + </DropdownMenuItem> + + + + +{status ==="작성중" && +<> + <DropdownMenuSeparator /> + <DropdownMenuItem + onClick={() => setRowAction({ row, type: "createRfq" })} + className="text-primary" + > + <FileText className="mr-2 h-4 w-4" /> + RFQ 생성 + </DropdownMenuItem> + </> + } + + </DropdownMenuContent> + </DropdownMenu> + + ); + }, + size: 80, + }, + ]; +}
\ No newline at end of file diff --git a/lib/itb/table/purchase-requests-table.tsx b/lib/itb/table/purchase-requests-table.tsx new file mode 100644 index 00000000..88f27666 --- /dev/null +++ b/lib/itb/table/purchase-requests-table.tsx @@ -0,0 +1,229 @@ +// components/purchase-requests/purchase-requests-table.tsx +"use client"; + +import * as React from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Plus, RefreshCw, FileText } from "lucide-react"; +import { DataTable } from "@/components/data-table/data-table"; +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"; +import { useDataTable } from "@/hooks/use-data-table"; +import { getPurchaseRequestColumns } from "./purchase-request-columns"; +import { CreatePurchaseRequestDialog } from "./create-purchase-request-dialog"; +import { EditPurchaseRequestSheet } from "./edit-purchase-request-sheet"; +import { DeletePurchaseRequestDialog } from "./delete-purchase-request-dialog"; +import { ViewPurchaseRequestSheet } from "./view-purchase-request-sheet"; +// import { AttachmentsDialog } from "./attachments-dialog"; +import { ItemsDialog } from "./items-dialog"; +import type { DataTableRowAction } from "@/types/table"; +import type { PurchaseRequestView } from "@/db/schema"; +import { CreateRfqDialog } from "./create-rfq-dialog"; +import { toast } from "sonner"; + +interface PurchaseRequestsTableProps { + promises: Promise<[ + { + data: PurchaseRequestView[]; + pageCount: number; + }, + any + ]>; +} + +export function PurchaseRequestsTable({ promises }: PurchaseRequestsTableProps) { + const router = useRouter(); + const [{ data, pageCount }] = React.use(promises); + const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false); + const [isCreateRfqDialogOpen, setIsCreateRfqDialogOpen] = React.useState(false); + const [selectedRequestsForRfq, setSelectedRequestsForRfq] = React.useState<PurchaseRequestView[]>([]); + const [rowAction, setRowAction] = React.useState<DataTableRowAction<PurchaseRequestView> | null>(null); + + const columns = React.useMemo( + () => getPurchaseRequestColumns({ setRowAction }), + [setRowAction] + ); + + const { table } = useDataTable({ + data, + columns, + pageCount, + enablePinning: true, + enableAdvancedFilter: true, + defaultPerPage: 10, + defaultSort: [{ id: "createdAt", desc: true }], + }); + + const refreshData = () => { + router.refresh(); + }; + + // 선택된 행들 가져오기 + const selectedRows = table.getSelectedRowModel().rows + .map(row => row.original) + .filter(row => row.status === "작성중") + ; + + // 선택된 행들로 RFQ 생성 다이얼로그 열기 + const handleBulkCreateRfq = () => { + const selectedRequests = selectedRows + + if (selectedRequests.length === 0) { + toast.error("RFQ를 생성할 구매 요청을 선택해주세요"); + return; + } + + setSelectedRequestsForRfq(selectedRequests); + setIsCreateRfqDialogOpen(true); + }; + + // 개별 행 액션 처리 + React.useEffect(() => { + if (rowAction?.type === "createRfq") { + setSelectedRequestsForRfq([rowAction.row.original]); + setIsCreateRfqDialogOpen(true); + setRowAction(null); + } + }, [rowAction]); + + // RFQ 생성 성공 후 처리 + const handleRfqSuccess = () => { + table.resetRowSelection(); // 선택 초기화 + refreshData(); + }; + + return ( + <> + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={[ + { + id: "requestCode", + label: "요청번호", + placeholder: "PR-2025-00001" + }, + { + id: "requestTitle", + label: "요청제목", + placeholder: "요청 제목 검색" + }, + { + id: "projectName", + label: "프로젝트명", + placeholder: "프로젝트명 검색" + }, + { + id: "packageName", + label: "패키지명", + placeholder: "패키지명 검색" + }, + { + id: "status", + label: "상태", + options: [ + { label: "작성중", value: "작성중" }, + { label: "RFQ생성완료", value: "RFQ생성완료" }, + ] + }, + { + id: "engPicName", + label: "설계 담당자", + placeholder: "담당자명 검색" + }, + { + id: "purchasePicName", + label: "구매 담당자", + placeholder: "담당자명 검색" + }, + ]} + > + <div className="flex items-center gap-2"> + <Button + size="sm" + onClick={() => setIsCreateDialogOpen(true)} + > + <Plus className="mr-2 h-4 w-4" /> + 새 구매요청 + </Button> + + {/* 선택된 항목들로 RFQ 일괄 생성 버튼 */} + {selectedRows.length > 0 && ( + <Button + size="sm" + variant="secondary" + onClick={handleBulkCreateRfq} + > + <FileText className="mr-2 h-4 w-4" /> + RFQ 생성 ({selectedRows.length}개) + </Button> + )} + + <Button + variant="outline" + size="sm" + onClick={refreshData} + > + <RefreshCw className="h-4 w-4" /> + </Button> + </div> + </DataTableAdvancedToolbar> + </DataTable> + + {/* 다이얼로그들 */} + <CreatePurchaseRequestDialog + open={isCreateDialogOpen} + onOpenChange={setIsCreateDialogOpen} + onSuccess={refreshData} + /> + + {/* RFQ 생성 다이얼로그 */} + <CreateRfqDialog + requests={selectedRequestsForRfq} + open={isCreateRfqDialogOpen} + onOpenChange={(open) => { + setIsCreateRfqDialogOpen(open); + if (!open) { + setSelectedRequestsForRfq([]); + } + }} + onSuccess={handleRfqSuccess} + /> + + {rowAction?.type === "view" && ( + <ViewPurchaseRequestSheet + request={rowAction.row.original} + open={true} + onOpenChange={() => setRowAction(null)} + /> + )} + + {rowAction?.type === "update" && ( + <EditPurchaseRequestSheet + request={rowAction.row.original} + open={true} + onOpenChange={() => setRowAction(null)} + onSuccess={refreshData} + /> + )} + + {rowAction?.type === "delete" && ( + <DeletePurchaseRequestDialog + request={rowAction.row.original} + open={true} + onOpenChange={() => setRowAction(null)} + onSuccess={refreshData} + /> + )} + + {rowAction?.type === "items" && ( + <ItemsDialog + requestId={rowAction.row.original.id} + requestCode={rowAction.row.original.requestCode} + items={rowAction.row.original.items} + open={true} + onOpenChange={() => setRowAction(null)} + /> + )} + </> + ); +}
\ No newline at end of file diff --git a/lib/itb/table/view-purchase-request-sheet.tsx b/lib/itb/table/view-purchase-request-sheet.tsx new file mode 100644 index 00000000..c4ff9416 --- /dev/null +++ b/lib/itb/table/view-purchase-request-sheet.tsx @@ -0,0 +1,809 @@ +// components/purchase-requests/view-purchase-request-sheet.tsx +"use client"; + +import * as React from "react"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetFooter, +} from "@/components/ui/sheet"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/ui/tabs"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + TableFooter, +} from "@/components/ui/table"; +import { Separator } from "@/components/ui/separator"; +import { + FileList, + FileListDescription, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, + FileListSize, +} from "@/components/ui/file-list"; +import { + FileText, + Package, + Edit, + Download, + Calendar, + User, + Building, + MapPin, + Hash, + DollarSign, + Clock, + CheckCircle, + XCircle, + AlertCircle, + Layers, + Tag, + Paperclip, + FileIcon, + Eye +} from "lucide-react"; +import { format } from "date-fns"; +import { ko } from "date-fns/locale"; +import type { PurchaseRequestView } from "@/db/schema"; +import { useRouter } from "next/navigation"; +import { getPurchaseRequestAttachments } from "../service"; +import { downloadFile, quickPreview, formatFileSize, getFileInfo } from "@/lib/file-download"; + +interface ViewPurchaseRequestSheetProps { + request: PurchaseRequestView; + open: boolean; + onOpenChange: (open: boolean) => void; + onEditClick?: () => void; +} + +const statusConfig = { + "작성중": { + variant: "secondary" as const, + color: "text-gray-500", + icon: Edit, + bgColor: "bg-gray-100" + }, + "요청완료": { + variant: "default" as const, + color: "text-blue-500", + icon: CheckCircle, + bgColor: "bg-blue-50" + }, + "검토중": { + variant: "warning" as const, + color: "text-yellow-500", + icon: Clock, + bgColor: "bg-yellow-50" + }, + "승인": { + variant: "success" as const, + color: "text-green-500", + icon: CheckCircle, + bgColor: "bg-green-50" + }, + "반려": { + variant: "destructive" as const, + color: "text-red-500", + icon: XCircle, + bgColor: "bg-red-50" + }, + "RFQ생성완료": { + variant: "outline" as const, + color: "text-purple-500", + icon: Package, + bgColor: "bg-purple-50" + }, +}; + +export function ViewPurchaseRequestSheet({ + request, + open, + onOpenChange, + onEditClick, +}: ViewPurchaseRequestSheetProps) { + const router = useRouter(); + const [activeTab, setActiveTab] = React.useState("overview"); + const [attachments, setAttachments] = React.useState<any[]>([]); + const [isLoadingFiles, setIsLoadingFiles] = React.useState(false); + + // 첨부파일 로드 + React.useEffect(() => { + async function loadAttachments() { + if (open && request.id) { + setIsLoadingFiles(true); + try { + const result = await getPurchaseRequestAttachments(request.id); + if (result.success && result.data) { + setAttachments(result.data); + } else { + console.error("Failed to load attachments:", result.error); + setAttachments([]); + } + } catch (error) { + console.error("Error loading attachments:", error); + setAttachments([]); + } finally { + setIsLoadingFiles(false); + } + } + } + + loadAttachments(); + }, [open, request.id]); + + // 파일 다운로드 핸들러 + const handleFileDownload = async (file: any) => { + const result = await downloadFile(file.filePath, file.originalFileName, { + action: 'download', + showToast: true, + onError: (error) => { + console.error("Download failed:", error); + }, + onSuccess: (fileName, fileSize) => { + console.log(`Successfully downloaded: ${fileName} (${fileSize} bytes)`); + } + }); + + return result; + }; + + // 파일 미리보기 핸들러 + const handleFilePreview = async (file: any) => { + const fileInfo = getFileInfo(file.originalFileName); + + if (!fileInfo.canPreview) { + // 미리보기가 지원되지 않는 파일은 다운로드 + return handleFileDownload(file); + } + + const result = await quickPreview(file.filePath, file.originalFileName); + return result; + }; + + // 전체 다운로드 핸들러 + const handleDownloadAll = async () => { + for (const file of attachments) { + await handleFileDownload(file); + // 여러 파일 다운로드 시 간격 두기 + await new Promise(resolve => setTimeout(resolve, 500)); + } + }; + + // 아이템 총액 계산 + const totalAmount = React.useMemo(() => { + if (!request.items || !Array.isArray(request.items)) return 0; + return request.items.reduce((sum, item) => { + const subtotal = (item.quantity || 0) * (item.estimatedUnitPrice || 0); + return sum + subtotal; + }, 0); + }, [request.items]); + + const handleEdit = () => { + if (onEditClick) { + onEditClick(); + } else { + onOpenChange(false); + } + }; + + const handleExport = () => { + // Export to Excel 기능 + console.log("Export to Excel"); + }; + + const StatusIcon = statusConfig[request.status]?.icon || AlertCircle; + const statusBgColor = statusConfig[request.status]?.bgColor || "bg-gray-50"; + + return ( + <Sheet open={open} onOpenChange={onOpenChange}> + <SheetContent className="w-[900px] max-w-[900px] overflow-hidden flex flex-col min-h-0" style={{width:900 , maxWidth:900}}> + <SheetHeader className="flex-shrink-0"> + <div className="flex items-center justify-between"> + <SheetTitle>구매요청 상세</SheetTitle> + <div className={`flex items-center gap-2 px-3 py-1.5 rounded-full ${statusBgColor}`}> + <StatusIcon className={`h-4 w-4 ${statusConfig[request.status]?.color}`} /> + <span className={`text-sm font-medium ${statusConfig[request.status]?.color}`}> + {request.status} + </span> + </div> + </div> + <SheetDescription> + 요청번호: <span className="font-mono font-medium">{request.requestCode}</span> | + 작성일: {request.createdAt && format(new Date(request.createdAt), "yyyy-MM-dd")} + </SheetDescription> + </SheetHeader> + + <Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col min-h-0"> + <TabsList className="grid w-full grid-cols-4 flex-shrink-0"> + <TabsTrigger value="overview"> + <FileText className="mr-2 h-4 w-4" /> + 개요 + </TabsTrigger> + <TabsTrigger value="items"> + <Package className="mr-2 h-4 w-4" /> + 품목 정보 + {request.itemCount > 0 && ( + <span className="ml-2 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground"> + {request.itemCount} + </span> + )} + </TabsTrigger> + <TabsTrigger value="files"> + <Paperclip className="mr-2 h-4 w-4" /> + 첨부파일 + {attachments.length > 0 && ( + <span className="ml-2 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground"> + {attachments.length} + </span> + )} + </TabsTrigger> + <TabsTrigger value="history"> + <Clock className="mr-2 h-4 w-4" /> + 처리 이력 + </TabsTrigger> + </TabsList> + + <div className="flex-1 overflow-y-auto px-1 min-h-0"> + <TabsContent value="overview" className="space-y-6 mt-6"> + {/* 기본 정보 */} + <Card> + <CardHeader> + <CardTitle className="text-base">기본 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div> + <p className="text-sm text-muted-foreground mb-1">요청 제목</p> + <p className="text-lg font-semibold">{request.requestTitle}</p> + </div> + + {request.requestDescription && ( + <div> + <p className="text-sm text-muted-foreground mb-1">요청 설명</p> + <p className="text-sm bg-muted/30 p-3 rounded-lg">{request.requestDescription}</p> + </div> + )} + + <Separator /> + + <div className="grid grid-cols-2 gap-4"> + <div className="flex items-center gap-3"> + <Calendar className="h-4 w-4 text-muted-foreground" /> + <div> + <p className="text-sm text-muted-foreground">희망 납기일</p> + <p className="font-medium"> + {request.requestedDeliveryDate + ? format(new Date(request.requestedDeliveryDate), "yyyy-MM-dd") + : "-"} + </p> + </div> + </div> + + <div className="flex items-center gap-3"> + <DollarSign className="h-4 w-4 text-muted-foreground" /> + <div> + <p className="text-sm text-muted-foreground">예상 예산</p> + <p className="font-medium">{request.estimatedBudget || "-"}</p> + </div> + </div> + </div> + </CardContent> + </Card> + + {/* 프로젝트 & 패키지 정보 */} + <Card> + <CardHeader> + <CardTitle className="text-base">프로젝트 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div className="grid grid-cols-2 gap-4"> + <div className="flex items-start gap-3"> + <Hash className="h-4 w-4 text-muted-foreground mt-0.5" /> + <div> + <p className="text-sm text-muted-foreground">프로젝트 코드</p> + <p className="font-medium">{request.projectCode || "-"}</p> + </div> + </div> + + <div className="flex items-start gap-3"> + <FileText className="h-4 w-4 text-muted-foreground mt-0.5" /> + <div> + <p className="text-sm text-muted-foreground">프로젝트명</p> + <p className="font-medium">{request.projectName || "-"}</p> + </div> + </div> + + <div className="flex items-start gap-3"> + <Building className="h-4 w-4 text-muted-foreground mt-0.5" /> + <div> + <p className="text-sm text-muted-foreground">발주처</p> + <p className="font-medium">{request.projectCompany || "-"}</p> + </div> + </div> + + <div className="flex items-start gap-3"> + <MapPin className="h-4 w-4 text-muted-foreground mt-0.5" /> + <div> + <p className="text-sm text-muted-foreground">현장</p> + <p className="font-medium">{request.projectSite || "-"}</p> + </div> + </div> + </div> + + <Separator /> + + <div className="grid grid-cols-2 gap-4"> + <div className="flex items-start gap-3"> + <Package className="h-4 w-4 text-muted-foreground mt-0.5" /> + <div> + <p className="text-sm text-muted-foreground">패키지</p> + <p className="font-medium">{request.packageNo} - {request.packageName || "-"}</p> + </div> + </div> + + <div className="flex items-start gap-3"> + <Tag className="h-4 w-4 text-muted-foreground mt-0.5" /> + <div> + <p className="text-sm text-muted-foreground">SM 코드</p> + <p className="font-medium">{request.smCode || "-"}</p> + </div> + </div> + </div> + </CardContent> + </Card> + + {/* 자재 정보 */} + {(request.majorItemMaterialCategory || request.majorItemMaterialDescription) && ( + <Card> + <CardHeader> + <CardTitle className="text-base">자재 정보</CardTitle> + </CardHeader> + <CardContent> + <div className="grid grid-cols-2 gap-4"> + <div className="flex items-start gap-3"> + <Layers className="h-4 w-4 text-muted-foreground mt-0.5" /> + <div> + <p className="text-sm text-muted-foreground">자재 그룹</p> + <p className="font-medium">{request.majorItemMaterialCategory || "-"}</p> + </div> + </div> + + <div className="flex items-start gap-3"> + <FileText className="h-4 w-4 text-muted-foreground mt-0.5" /> + <div> + <p className="text-sm text-muted-foreground">자재 설명</p> + <p className="font-medium">{request.majorItemMaterialDescription || "-"}</p> + </div> + </div> + </div> + </CardContent> + </Card> + )} + + {/* 담당자 정보 */} + <Card> + <CardHeader> + <CardTitle className="text-base">담당자 정보</CardTitle> + </CardHeader> + <CardContent> + <div className="grid grid-cols-2 gap-4"> + <div className="flex items-start gap-3"> + <User className="h-4 w-4 text-muted-foreground mt-0.5" /> + <div> + <p className="text-sm text-muted-foreground">설계 담당자</p> + <p className="font-medium">{request.engPicName || "-"}</p> + {request.engPicEmail && ( + <p className="text-sm text-muted-foreground">{request.engPicEmail}</p> + )} + </div> + </div> + + <div className="flex items-start gap-3"> + <User className="h-4 w-4 text-muted-foreground mt-0.5" /> + <div> + <p className="text-sm text-muted-foreground">구매 담당자</p> + <p className="font-medium">{request.purchasePicName || "미배정"}</p> + {request.purchasePicEmail && ( + <p className="text-sm text-muted-foreground">{request.purchasePicEmail}</p> + )} + </div> + </div> + </div> + </CardContent> + </Card> + + {/* 반려 사유 */} + {request.status === "반려" && request.rejectReason && ( + <Card className="border-destructive"> + <CardHeader className="bg-destructive/5"> + <CardTitle className="text-base text-destructive flex items-center gap-2"> + <XCircle className="h-4 w-4" /> + 반려 사유 + </CardTitle> + </CardHeader> + <CardContent className="pt-4"> + <p className="text-sm">{request.rejectReason}</p> + </CardContent> + </Card> + )} + </TabsContent> + + <TabsContent value="items" className="mt-6"> + <Card> + <CardHeader> + <div className="flex items-center justify-between"> + <div> + <CardTitle className="text-base">품목 목록</CardTitle> + <CardDescription className="mt-1"> + 총 {request.itemCount || 0}개 품목 | 총 수량 {request.totalQuantity || 0}개 + </CardDescription> + </div> + <Button variant="outline" size="sm" onClick={handleExport}> + <Download className="mr-2 h-4 w-4" /> + Excel 다운로드 + </Button> + </div> + </CardHeader> + <CardContent> + {(!request.items || request.items.length === 0) ? ( + <div className="flex flex-col items-center justify-center h-32 text-muted-foreground"> + <Package className="h-8 w-8 mb-2" /> + <p>등록된 품목이 없습니다</p> + </div> + ) : ( + <div className="border rounded-lg"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[50px] text-center">번호</TableHead> + <TableHead>아이템 코드</TableHead> + <TableHead>아이템명</TableHead> + <TableHead>사양</TableHead> + <TableHead className="text-right">수량</TableHead> + <TableHead className="text-center">단위</TableHead> + <TableHead className="text-right">예상 단가</TableHead> + <TableHead className="text-right">예상 금액</TableHead> + {request.items[0]?.remarks && <TableHead>비고</TableHead>} + </TableRow> + </TableHeader> + <TableBody> + {request.items.map((item: any, index: number) => { + const subtotal = (item.quantity || 0) * (item.estimatedUnitPrice || 0); + return ( + <TableRow key={item.id || index}> + <TableCell className="text-center">{index + 1}</TableCell> + <TableCell className="font-mono text-sm">{item.itemCode || "-"}</TableCell> + <TableCell className="font-medium">{item.itemName}</TableCell> + <TableCell className="text-sm">{item.specification || "-"}</TableCell> + <TableCell className="text-right font-medium"> + {item.quantity?.toLocaleString('ko-KR')} + </TableCell> + <TableCell className="text-center">{item.unit}</TableCell> + <TableCell className="text-right"> + {item.estimatedUnitPrice + ? item.estimatedUnitPrice.toLocaleString('ko-KR') + "원" + : "-"} + </TableCell> + <TableCell className="text-right font-medium"> + {subtotal > 0 + ? subtotal.toLocaleString('ko-KR') + "원" + : "-"} + </TableCell> + {request.items[0]?.remarks && ( + <TableCell className="text-sm">{item.remarks || "-"}</TableCell> + )} + </TableRow> + ); + })} + </TableBody> + <TableFooter> + <TableRow> + <TableCell colSpan={6} className="text-right font-medium"> + 총 합계 + </TableCell> + <TableCell colSpan={2} className="text-right"> + <div className="flex flex-col"> + <span className="text-2xl font-bold text-primary"> + {new Intl.NumberFormat('ko-KR', { + style: 'currency', + currency: 'KRW' + }).format(totalAmount)} + </span> + </div> + </TableCell> + {request.items[0]?.remarks && <TableCell />} + </TableRow> + </TableFooter> + </Table> + </div> + )} + </CardContent> + </Card> + </TabsContent> + + <TabsContent value="files" className="mt-6"> + <Card> + <CardHeader> + <div className="flex items-center justify-between"> + <div> + <CardTitle className="text-base">첨부파일</CardTitle> + <CardDescription className="mt-1"> + 구매 요청 관련 문서 및 파일 + </CardDescription> + </div> + </div> + </CardHeader> + <CardContent> + {isLoadingFiles ? ( + <div className="flex items-center justify-center h-32"> + <p className="text-muted-foreground">파일 목록을 불러오는 중...</p> + </div> + ) : attachments.length === 0 ? ( + <div className="flex flex-col items-center justify-center h-32 text-muted-foreground"> + <Paperclip className="h-8 w-8 mb-2" /> + <p>첨부된 파일이 없습니다</p> + </div> + ) : ( + <div className="space-y-4"> + <div className="flex items-center justify-between mb-4"> + <p className="text-sm text-muted-foreground"> + 총 {attachments.length}개의 파일이 첨부되어 있습니다 + </p> + <Button + variant="outline" + size="sm" + onClick={handleDownloadAll} + disabled={attachments.length === 0} + > + <Download className="mr-2 h-4 w-4" /> + 전체 다운로드 + </Button> + </div> + + <FileList> + {attachments.map((file, index) => { + const fileInfo = getFileInfo(file.originalFileName || file.fileName); + return ( + <FileListItem key={file.id || file.fileName || index}> + <FileListIcon> + <FileIcon className="h-4 w-4" /> + </FileListIcon> + <FileListInfo> + <FileListHeader> + <FileListName> + {file.originalFileName || file.fileName} + </FileListName> + <FileListSize> + {file.fileSize} + </FileListSize> + </FileListHeader> + <FileListDescription className="flex items-center gap-4"> + {file.createdAt && ( + <span className="text-xs"> + {format(new Date(file.createdAt), "yyyy-MM-dd HH:mm")} + </span> + )} + {file.category && ( + <Badge variant="secondary" className="text-xs"> + {file.category} + </Badge> + )} + </FileListDescription> + </FileListInfo> + <div className="flex items-center gap-1"> + {fileInfo.canPreview && ( + <Button + variant="ghost" + size="icon" + className="h-8 w-8" + onClick={() => handleFilePreview(file)} + title="미리보기" + > + <Eye className="h-4 w-4" /> + </Button> + )} + <Button + variant="ghost" + size="icon" + className="h-8 w-8" + onClick={() => handleFileDownload(file)} + title="다운로드" + > + <Download className="h-4 w-4" /> + </Button> + </div> + </FileListItem> + ); + })} + </FileList> + + {/* 파일 종류별 요약 */} + {attachments.length > 0 && ( + <div className="mt-6 p-4 bg-muted/30 rounded-lg"> + <h4 className="text-sm font-medium mb-3">파일 요약</h4> + <div className="grid grid-cols-3 gap-4 text-sm"> + <div> + <p className="text-muted-foreground">총 파일 수</p> + <p className="font-medium">{attachments.length}개</p> + </div> + <div> + <p className="text-muted-foreground">총 용량</p> + <p className="font-medium"> + {formatFileSize( + attachments.reduce((sum, file) => sum + (file.fileSize || 0), 0) + )} + </p> + </div> + <div> + <p className="text-muted-foreground">최근 업로드</p> + <p className="font-medium"> + {attachments[0]?.createdAt + ? format(new Date(attachments[0].createdAt), "yyyy-MM-dd") + : "-"} + </p> + </div> + </div> + </div> + )} + </div> + )} + </CardContent> + </Card> + </TabsContent> + + <TabsContent value="history" className="mt-6"> + <Card> + <CardHeader> + <CardTitle className="text-base">처리 이력</CardTitle> + <CardDescription>요청서의 처리 현황을 시간순으로 확인할 수 있습니다</CardDescription> + </CardHeader> + <CardContent> + <div className="relative"> + <div className="absolute left-3 top-0 bottom-0 w-0.5 bg-border" /> + + <div className="space-y-6"> + {/* 생성 */} + <div className="flex gap-4"> + <div className="relative"> + <div className="h-6 w-6 rounded-full bg-primary flex items-center justify-center"> + <div className="h-2 w-2 rounded-full bg-white" /> + </div> + </div> + <div className="flex-1 -mt-0.5"> + <p className="font-medium">요청서 작성</p> + <p className="text-sm text-muted-foreground"> + {request.createdByName} ({request.createdByEmail}) + </p> + <p className="text-xs text-muted-foreground mt-1"> + {request.createdAt && format(new Date(request.createdAt), "yyyy-MM-dd HH:mm:ss", { locale: ko })} + </p> + </div> + </div> + + {/* 확정 */} + {request.confirmedAt && ( + <div className="flex gap-4"> + <div className="relative"> + <div className="h-6 w-6 rounded-full bg-blue-500 flex items-center justify-center"> + <CheckCircle className="h-3 w-3 text-white" /> + </div> + </div> + <div className="flex-1 -mt-0.5"> + <p className="font-medium">요청 확정</p> + <p className="text-sm text-muted-foreground"> + {request.confirmedByName} + </p> + <p className="text-xs text-muted-foreground mt-1"> + {format(new Date(request.confirmedAt), "yyyy-MM-dd HH:mm:ss", { locale: ko })} + </p> + </div> + </div> + )} + + {/* 반려 */} + {request.status === "반려" && request.rejectReason && ( + <div className="flex gap-4"> + <div className="relative"> + <div className="h-6 w-6 rounded-full bg-destructive flex items-center justify-center"> + <XCircle className="h-3 w-3 text-white" /> + </div> + </div> + <div className="flex-1 -mt-0.5"> + <p className="font-medium text-destructive">반려됨</p> + <p className="text-sm text-muted-foreground"> + {request.rejectReason} + </p> + <p className="text-xs text-muted-foreground mt-1"> + {request.updatedAt && format(new Date(request.updatedAt), "yyyy-MM-dd HH:mm:ss", { locale: ko })} + </p> + </div> + </div> + )} + + {/* RFQ 생성 */} + {request.rfqCreatedAt && ( + <div className="flex gap-4"> + <div className="relative"> + <div className="h-6 w-6 rounded-full bg-green-500 flex items-center justify-center"> + <Package className="h-3 w-3 text-white" /> + </div> + </div> + <div className="flex-1 -mt-0.5"> + <p className="font-medium">RFQ 생성 완료</p> + <p className="text-sm text-muted-foreground"> + RFQ 번호: <span className="font-mono font-medium">{request.rfqCode}</span> + </p> + <p className="text-xs text-muted-foreground mt-1"> + {format(new Date(request.rfqCreatedAt), "yyyy-MM-dd HH:mm:ss", { locale: ko })} + </p> + </div> + </div> + )} + + {/* 최종 수정 */} + {request.updatedAt && request.updatedAt !== request.createdAt && !request.rfqCreatedAt && request.status !== "반려" && ( + <div className="flex gap-4"> + <div className="relative"> + <div className="h-6 w-6 rounded-full bg-muted flex items-center justify-center"> + <Edit className="h-3 w-3 text-muted-foreground" /> + </div> + </div> + <div className="flex-1 -mt-0.5"> + <p className="font-medium">최종 수정</p> + <p className="text-sm text-muted-foreground"> + {request.updatedByName} ({request.updatedByEmail}) + </p> + <p className="text-xs text-muted-foreground mt-1"> + {format(new Date(request.updatedAt), "yyyy-MM-dd HH:mm:ss", { locale: ko })} + </p> + </div> + </div> + )} + </div> + </div> + </CardContent> + </Card> + </TabsContent> + </div> + </Tabs> + + <SheetFooter className="mt-6 flex-shrink-0"> + <div className="flex w-full justify-between"> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 닫기 + </Button> + {request.status === "작성중" && ( + <Button onClick={handleEdit}> + <Edit className="mr-2 h-4 w-4" /> + 수정하기 + </Button> + )} + </div> + </SheetFooter> + </SheetContent> + </Sheet> + ); +}
\ No newline at end of file diff --git a/lib/itb/validations.ts b/lib/itb/validations.ts new file mode 100644 index 00000000..e481fe73 --- /dev/null +++ b/lib/itb/validations.ts @@ -0,0 +1,85 @@ +// lib/purchase-requests/validations.ts + +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, + parseAsBoolean + } from "nuqs/server"; + import * as z from "zod"; + + import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"; + import { type PurchaseRequestView } from "@/db/schema"; + + export const searchParamsPurchaseRequestCache = createSearchParamsCache({ + // 1) 공통 플래그 + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + + // 2) 페이지네이션 + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 3) 정렬 (PurchaseRequestView 테이블) + sort: getSortingStateParser<PurchaseRequestView>().withDefault([ + { id: "createdAt", desc: true }, + ]), + + // 4) 상태 필터 (선택사항) + status: parseAsArrayOf( + z.enum(["작성중", "RFQ생성완료"]) + ).withDefault([]), + + // 5) 날짜 범위 필터 (선택사항) + dateFrom: parseAsString.withDefault(""), + dateTo: parseAsString.withDefault(""), + + // 6) 고급 필터 (nuqs - filterColumns) + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 7) 글로벌 검색어 + search: parseAsString.withDefault(""), + }); + + export type GetPurchaseRequestsSchema = Awaited< + ReturnType<typeof searchParamsPurchaseRequestCache.parse> + >; + + // 구매요청 생성/수정 스키마 + export const purchaseRequestFormSchema = 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(), + engPicId: z.number().optional(), + engPicName: z.string().optional(), + purchasePicId: z.number().optional(), + purchasePicName: z.string().optional(), + items: z.array( + z.object({ + id: z.string(), + itemCode: z.string().min(1, "아이템 코드를 입력하세요"), + itemName: z.string().min(1, "아이템명을 입력하세요"), + specification: z.string(), + quantity: z.number().min(1, "수량은 1 이상이어야 합니다"), + unit: z.string().min(1, "단위를 입력하세요"), + estimatedUnitPrice: z.number().optional(), + remarks: z.string().optional(), + }) + ).min(1, "최소 1개 이상의 아이템을 추가하세요"), + }); + + export type PurchaseRequestFormData = z.infer<typeof purchaseRequestFormSchema>;
\ No newline at end of file diff --git a/lib/items/service.ts b/lib/items/service.ts index 1eab3e25..f4865cfa 100644 --- a/lib/items/service.ts +++ b/lib/items/service.ts @@ -457,3 +457,35 @@ export async function searchItemsForPQ(query: string): Promise<{ itemCode: strin return []; } } + + +export interface PackageItem { + packageCode: string; + description: string; + smCode: string | null; +} + +export async function getPackagesByProject(projectNo: string): Promise<PackageItem[]> { + try { + // selectDistinct를 사용하여 중복 제거 + const result = await db + .selectDistinct({ + packageCode: items.packageCode, + description: items.description, + smCode: items.smCode, + }) + .from(items) + .where(eq(items.ProjectNo, projectNo)) + .orderBy(items.packageCode); + + // null 값을 처리하고 타입을 정리 + return result.map(item => ({ + packageCode: item.packageCode, + description: item.description || item.packageCode, + smCode: item.smCode || null + })); + } catch (error) { + console.error("Failed to fetch packages:", error); + return []; + } +}
\ No newline at end of file diff --git a/lib/mail/templates/tbe-request.hbs b/lib/mail/templates/tbe-request.hbs new file mode 100644 index 00000000..580bf72c --- /dev/null +++ b/lib/mail/templates/tbe-request.hbs @@ -0,0 +1,198 @@ +<!-- templates/tbe-request.hbs --> +<!DOCTYPE html> +<html lang="ko"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>[TBE 요청] {{rfqCode}} - 기술입찰평가 서류 제출 요청</title> + <style> + body { + font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif; + line-height: 1.6; + color: #333; + max-width: 800px; + margin: 0 auto; + padding: 20px; + } + .header { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 30px; + border-radius: 10px 10px 0 0; + text-align: center; + } + .content { + background: #ffffff; + padding: 30px; + border: 1px solid #e0e0e0; + border-radius: 0 0 10px 10px; + } + .info-table { + width: 100%; + border-collapse: collapse; + margin: 20px 0; + background: #f9f9f9; + border-radius: 8px; + overflow: hidden; + } + .info-table th { + background: #f0f0f0; + padding: 12px; + text-align: left; + font-weight: 600; + width: 30%; + border-bottom: 1px solid #e0e0e0; + } + .info-table td { + padding: 12px; + border-bottom: 1px solid #e0e0e0; + } + .highlight-box { + background: #fff3cd; + border-left: 4px solid #ffc107; + padding: 15px; + margin: 20px 0; + border-radius: 4px; + } + .requirements { + background: #f8f9fa; + padding: 20px; + border-radius: 8px; + margin: 20px 0; + } + .requirements h3 { + color: #495057; + margin-top: 0; + } + .requirements ul { + margin: 10px 0; + padding-left: 20px; + } + .requirements li { + margin: 8px 0; + } + .cta-button { + display: inline-block; + background: #667eea; + color: white; + padding: 12px 30px; + border-radius: 6px; + text-decoration: none; + font-weight: 600; + margin: 20px 0; + } + .footer { + margin-top: 30px; + padding-top: 20px; + border-top: 1px solid #e0e0e0; + text-align: center; + color: #666; + font-size: 14px; + } + .warning { + color: #dc3545; + font-weight: bold; + } + </style> +</head> +<body> + <div class="header"> + <h1>기술입찰평가(TBE) 요청</h1> + <p style="margin: 0; opacity: 0.9;">Technical Bid Evaluation Request</p> + </div> + + <div class="content"> + <p>안녕하세요, <strong>{{vendorName}}</strong> 담당자님</p> + + <p> + 아래 RFQ 건에 대한 기술입찰평가(TBE) 서류 제출을 요청드립니다. + </p> + + <table class="info-table"> + <tr> + <th>RFQ 번호</th> + <td><strong>{{rfqCode}}</strong></td> + </tr> + <tr> + <th>RFQ 제목</th> + <td>{{rfqTitle}}</td> + </tr> + <tr> + <th>프로젝트</th> + <td>{{projectCode}} - {{projectName}}</td> + </tr> + <tr> + <th>패키지</th> + <td>{{packageNo}} - {{packageName}}</td> + </tr> + <tr> + <th>RFQ 마감일</th> + <td>{{rfqDueDate}}</td> + </tr> + <tr> + <th>구매 담당자</th> + <td>{{picName}} ({{picEmail}})</td> + </tr> + </table> + + <div class="highlight-box"> + <strong class="warning">⚠️ TBE 서류 제출 기한: {{tbeDeadline}}</strong> + <p style="margin: 5px 0 0 0; font-size: 14px;"> + * RFQ 마감일 최소 7일 전까지 제출 필수 + </p> + </div> + + <div class="requirements"> + <h3>📋 제출 필요 서류</h3> + <ul> + <li><strong>기술 제안서</strong> (Technical Proposal)</li> + <li><strong>제품 사양서</strong> (Product Specifications)</li> + <li><strong>품질 인증서</strong> (Quality Certificates)</li> + <li><strong>납기 일정표</strong> (Delivery Schedule)</li> + <li><strong>과거 실적 자료</strong> (Past Performance References)</li> + <li><strong>기타 요구 문서</strong> (Other Required Documents)</li> + </ul> + </div> + + <div style="background: #e8f4fd; padding: 15px; border-radius: 8px; margin: 20px 0;"> + <h4 style="margin-top: 0; color: #0066cc;">📌 제출 방법</h4> + <ol style="margin: 10px 0; padding-left: 20px;"> + <li>모든 서류는 PDF 형식으로 준비해 주세요.</li> + <li>파일명은 "[{{vendorCode}}]_[{{rfqCode}}]_[문서명]" 형식으로 작성해 주세요.</li> + <li>이메일 제목: "TBE 서류 제출 - {{rfqCode}} - {{vendorName}}"</li> + <li>제출처: {{picEmail}}</li> + </ol> + </div> + + <div style="text-align: center; margin: 30px 0;"> + <a href="mailto:{{picEmail}}?subject=TBE 서류 제출 - {{rfqCode}} - {{vendorName}}" class="cta-button"> + 이메일로 서류 제출하기 + </a> + </div> + + <div style="background: #f8f9fa; padding: 15px; border-radius: 8px;"> + <p style="margin: 0;"> + <strong>문의사항이 있으신 경우:</strong><br> + 구매 담당자: {{picName}}<br> + 이메일: <a href="mailto:{{picEmail}}">{{picEmail}}</a><br> + {{#if picPhone}} + 전화: {{picPhone}} + {{/if}} + </p> + </div> + </div> + + <div class="footer"> + <p> + 본 메일은 기술입찰평가(TBE) 요청을 위한 공식 메일입니다.<br> + This is an official email for Technical Bid Evaluation (TBE) request. + </p> + <p style="margin-top: 10px; font-size: 12px; color: #999;"> + © 2024 {{companyName}}. All rights reserved. + </p> + </div> +</body> +</html> + +{{!-- subject 템플릿 정의 --}} +{{#*inline "subject"}}[TBE 요청] {{rfqCode}} - 기술입찰평가 서류 제출 요청{{/inline}}
\ No newline at end of file diff --git a/lib/rfq-last/contract-actions.ts b/lib/rfq-last/contract-actions.ts index 1144cf4f..082716a0 100644 --- a/lib/rfq-last/contract-actions.ts +++ b/lib/rfq-last/contract-actions.ts @@ -1,9 +1,15 @@ "use server"; import db from "@/db/db"; -import { rfqsLast, rfqLastDetails } from "@/db/schema"; +import { rfqsLast, rfqLastDetails,rfqPrItems, + prItemsForBidding,biddingConditions,biddingCompanies, projects, + biddings,generalContracts ,generalContractItems} from "@/db/schema"; import { eq, and } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { generateContractNumber } from "../general-contracts/service"; +import { generateBiddingNumber } from "../bidding/service"; // ===== PO (SAP) 생성 ===== interface CreatePOParams { @@ -63,13 +69,13 @@ export async function createPO(params: CreatePOParams) { ); // RFQ 상태 업데이트 - await tx - .update(rfqsLast) - .set({ - status: "PO 생성 완료", - updatedAt: new Date(), - }) - .where(eq(rfqsLast.id, params.rfqId)); + // await tx + // .update(rfqsLast) + // .set({ + // status: "PO 생성 완료", + // updatedAt: new Date(), + // }) + // .where(eq(rfqsLast.id, params.rfqId)); }); revalidatePath(`/rfq/${params.rfqId}`); @@ -89,14 +95,13 @@ export async function createPO(params: CreatePOParams) { } } -// ===== 일반계약 생성 ===== interface CreateGeneralContractParams { rfqId: number; vendorId: number; vendorName: string; totalAmount: number; currency: string; - contractType?: string; + contractType: string; // 계약종류 추가 (UP, LE, IL 등) contractStartDate?: Date; contractEndDate?: Date; contractTerms?: string; @@ -104,40 +109,126 @@ interface CreateGeneralContractParams { export async function createGeneralContract(params: CreateGeneralContractParams) { try { - const userId = 1; // TODO: 실제 사용자 ID 가져오기 + const session = await getServerSession(authOptions) + if (!session?.user) { + throw new Error("인증이 필요합니다.") + } - // 1. 선정된 업체 확인 - const [selectedVendor] = await db - .select() - .from(rfqLastDetails) + const userId = session.user.id; + + // 1. RFQ 정보 및 선정된 업체 확인 + const [rfqData] = await db + .select({ + rfq: rfqsLast, + vendor: rfqLastDetails, + project: projects, + }) + .from(rfqsLast) + .leftJoin(rfqLastDetails, eq(rfqLastDetails.rfqsLastId, rfqsLast.id)) + .leftJoin(projects, eq(projects.id, rfqsLast.projectId)) .where( and( - eq(rfqLastDetails.rfqsLastId, params.rfqId), + eq(rfqsLast.id, params.rfqId), eq(rfqLastDetails.vendorsId, params.vendorId), eq(rfqLastDetails.isSelected, true), eq(rfqLastDetails.isLatest, true) ) ); - if (!selectedVendor) { - throw new Error("선정된 업체 정보를 찾을 수 없습니다."); + if (!rfqData || !rfqData.vendor) { + throw new Error("RFQ 정보 또는 선정된 업체 정보를 찾을 수 없습니다."); } - // 2. 계약 생성 로직 (TODO: 실제 구현 필요) - // - 계약서 템플릿 선택 - // - 계약 조건 설정 - // - 계약서 문서 생성 - // - 전자서명 프로세스 시작 + // 2. PR 아이템 정보 조회 (계약 아이템으로 변환용) + const prItems = await db + .select() + .from(rfqPrItems) + .where(eq(rfqPrItems.rfqsLastId, params.rfqId)); - // 3. 계약 상태 업데이트 - await db.transaction(async (tx) => { - // rfqLastDetails에 계약 정보 업데이트 + // 3. 계약번호 생성 - generateContractNumber 함수 사용 + const contractNumber = await generateContractNumber( + params.contractType, // 계약종류 (UP, LE, IL 등) + rfqData.rfq.picCode || undefined // 발주담당자 코드 + ); + + // 4. 트랜잭션으로 계약 생성 + const result = await db.transaction(async (tx) => { + // 4-1. generalContracts 테이블에 계약 생성 + const [newContract] = await tx + .insert(generalContracts) + .values({ + contractNumber, + contractSourceType: 'estimate', // 견적에서 넘어온 계약 + status: 'Draft', // 초안 상태로 시작 + category: '일반계약', + type: params.contractType, // 계약종류 저장 (UP, LE, IL 등) + executionMethod: '일반계약', + name: `${rfqData.project?.name || rfqData.rfq.itemName || ''} 일반계약`, + selectionMethod: '견적', + + // 업체 정보 + vendorId: params.vendorId, + + // 계약 기간 + startDate: params.contractStartDate || new Date(), + endDate: params.contractEndDate || null, + + // 연계 정보 + linkedRfqOrItb: rfqData.rfq.rfqCode || undefined, + + // 금액 정보 + contractAmount: params.totalAmount, + currency: params.currency || 'KRW', + totalAmount: params.totalAmount, + + // 조건 정보 (RFQ에서 가져옴) + contractCurrency: rfqData.vendor.currency || 'KRW', + paymentTerm: rfqData.vendor.paymentTermsCode || undefined, + taxType: rfqData.vendor.taxCode || undefined, + deliveryTerm: rfqData.vendor.incotermsCode || undefined, + shippingLocation: rfqData.vendor.placeOfShipping || undefined, + dischargeLocation: rfqData.vendor.placeOfDestination || undefined, + contractDeliveryDate: rfqData.vendor.deliveryDate || undefined, + + // 연동제 적용 여부 + interlockingSystem: rfqData.vendor.materialPriceRelatedYn ? 'Y' : 'N', + + // 시스템 정보 + registeredById: userId, + registeredAt: new Date(), + lastUpdatedById: userId, + lastUpdatedAt: new Date(), + notes: params.contractTerms || rfqData.vendor.remark || undefined, + }) + .returning(); + + // 4-2. generalContractItems 테이블에 아이템 생성 + if (prItems.length > 0) { + const contractItems = prItems.map(item => ({ + contractId: newContract.id, + project: rfqData.project?.name || undefined, + itemCode: item.materialCode || undefined, + itemInfo: `${item.materialCategory || ''} / ${item.materialCode || ''}`, + specification: item.materialDescription || undefined, + quantity: item.quantity, + quantityUnit: item.uom || undefined, + contractDeliveryDate: item.deliveryDate || undefined, + contractCurrency: params.currency || 'KRW', + // 단가와 금액은 견적 데이터에서 가져와야 함 (현재는 총액을 아이템 수로 나눔) + contractUnitPrice: params.totalAmount / prItems.length, + contractAmount: params.totalAmount / prItems.length, + })); + + await tx.insert(generalContractItems).values(contractItems); + } + + // 4-3. rfqLastDetails 상태 업데이트 await tx .update(rfqLastDetails) .set({ - contractStatus: "진행중", + contractStatus: "일반계약 진행중", contractCreatedAt: new Date(), - contractNo: `CONTRACT-${Date.now()}`, // TODO: 실제 계약번호로 변경 + contractNo: contractNumber, updatedAt: new Date(), updatedBy: userId, }) @@ -149,23 +240,18 @@ export async function createGeneralContract(params: CreateGeneralContractParams) ) ); - // RFQ 상태 업데이트 - await tx - .update(rfqsLast) - .set({ - status: "일반계약 진행중", - updatedAt: new Date(), - }) - .where(eq(rfqsLast.id, params.rfqId)); + return newContract; }); revalidatePath(`/rfq/${params.rfqId}`); revalidatePath("/rfq"); + revalidatePath("/contracts"); return { success: true, message: "일반계약이 성공적으로 생성되었습니다.", - contractNumber: `CONTRACT-${Date.now()}`, // TODO: 실제 계약번호 반환 + contractNumber: result.contractNumber, + contractId: result.id, }; } catch (error) { console.error("일반계약 생성 오류:", error); @@ -183,49 +269,162 @@ interface CreateBiddingParams { vendorName: string; totalAmount: number; currency: string; - biddingType?: string; // 공개입찰, 제한입찰 등 - biddingStartDate?: Date; - biddingEndDate?: Date; + contractType: "unit_price" | "general" | "sale"; // 계약구분 + biddingType: string; // 입찰유형 (equipment, construction 등) + awardCount: "single" | "multiple"; // 낙찰수 + biddingStartDate: Date; + biddingEndDate: Date; biddingRequirements?: string; } export async function createBidding(params: CreateBiddingParams) { try { - const userId = 1; // TODO: 실제 사용자 ID 가져오기 + const session = await getServerSession(authOptions) + if (!session?.user) { + throw new Error("인증이 필요합니다.") + } - // 1. 선정된 업체 확인 - const [selectedVendor] = await db - .select() - .from(rfqLastDetails) + const userId = session.user.id; + + // 1. RFQ 정보 및 선정된 업체 확인 + const [rfqData] = await db + .select({ + rfq: rfqsLast, + vendor: rfqLastDetails, + project: projects, + }) + .from(rfqsLast) + .leftJoin(rfqLastDetails, eq(rfqLastDetails.rfqsLastId, rfqsLast.id)) + .leftJoin(projects, eq(projects.id, rfqsLast.projectId)) .where( and( - eq(rfqLastDetails.rfqsLastId, params.rfqId), + eq(rfqsLast.id, params.rfqId), eq(rfqLastDetails.vendorsId, params.vendorId), eq(rfqLastDetails.isSelected, true), eq(rfqLastDetails.isLatest, true) ) ); - if (!selectedVendor) { - throw new Error("선정된 업체 정보를 찾을 수 없습니다."); + if (!rfqData || !rfqData.vendor) { + throw new Error("RFQ 정보 또는 선정된 업체 정보를 찾을 수 없습니다."); } - // 2. 입찰 생성 로직 (TODO: 실제 구현 필요) - // - 입찰 공고 생성 - // - 입찰 참가자격 설정 - // - 입찰 일정 등록 - // - 평가 기준 설정 - // - 입찰 시스템 등록 + // 2. PR 아이템 정보 조회 + const prItems = await db + .select() + .from(rfqPrItems) + .where(eq(rfqPrItems.rfqsLastId, params.rfqId)); - // 3. 입찰 상태 업데이트 - await db.transaction(async (tx) => { - // rfqLastDetails에 입찰 정보 업데이트 + // 3. 트랜잭션으로 입찰 생성 + const result = await db.transaction(async (tx) => { + // 3-1. 입찰번호 생성 - generateBiddingNumber 함수 사용 + const biddingNumber = await generateBiddingNumber( + rfqData.rfq.picCode || undefined, // 발주담당자 코드 + tx // 트랜잭션 컨텍스트 전달 + ); + + // 3-2. biddings 테이블에 입찰 생성 + const [newBidding] = await tx + .insert(biddings) + .values({ + biddingNumber, + revision: 0, + biddingSourceType: 'estimate', + projectId: rfqData.rfq.projectId || undefined, + + // 기본 정보 + projectName: rfqData.project?.name || undefined, + itemName: rfqData.rfq.itemName || undefined, + title: `${rfqData.project?.name || rfqData.rfq.itemName || ''} 입찰`, + description: params.biddingRequirements || rfqData.rfq.remark || undefined, + + // 계약 정보 - 파라미터에서 받은 값 사용 + contractType: params.contractType, + biddingType: params.biddingType, + awardCount: params.awardCount, + + // 일정 관리 - 파라미터에서 받은 날짜 사용 + biddingRegistrationDate: new Date(), + submissionStartDate: params.biddingStartDate, + submissionEndDate: params.biddingEndDate, + + // 예산 및 가격 정보 + currency: params.currency || 'KRW', + budget: params.totalAmount, + targetPrice: params.totalAmount, + + // PR 정보 + prNumber: rfqData.rfq.prNumber || undefined, + hasPrDocument: false, + + // 상태 + status: 'bidding_generated', + isPublic: false, // 다이얼로그에서 체크박스로 받을 수 있음 + isUrgent: false, // 다이얼로그에서 체크박스로 받을 수 있음 + + // 담당자 정보 + managerName: rfqData.rfq.picName || undefined, + + // 메타 정보 + createdBy: String(userId), + createdAt: new Date(), + updatedAt: new Date(), + updatedBy: String(userId), + ANFNR: rfqData.rfq.ANFNR || undefined, + }) + .returning(); + + // 3-3. PR 아이템을 입찰 아이템으로 변환 + if (prItems.length > 0) { + const biddingPrItems = prItems.map(item => ({ + biddingId: newBidding.id, + itemNumber: item.rfqItem || undefined, + projectInfo: rfqData.project?.name || undefined, // 프로젝트 이름 사용 + itemInfo: item.materialDescription || undefined, + requestedDeliveryDate: item.deliveryDate || undefined, + currency: params.currency || 'KRW', + quantity: item.quantity, + quantityUnit: item.uom || undefined, + totalWeight: item.grossWeight || undefined, + weightUnit: item.gwUom || undefined, + materialDescription: item.materialDescription || undefined, + prNumber: item.prNo || undefined, + hasSpecDocument: !!item.specUrl, + })); + + await tx.insert(prItemsForBidding).values(biddingPrItems); + } + + // 3-4. 선정된 업체를 입찰 참여 업체로 등록 + await tx.insert(biddingCompanies).values({ + biddingId: newBidding.id, + companyId: params.vendorId, + invitationStatus: 'pending', + preQuoteAmount: params.totalAmount, // 견적 금액을 사전견적으로 사용 + isPreQuoteSelected: true, // 본입찰 대상으로 자동 선정 + isBiddingInvited: true, + notes: '견적에서 선정된 업체', + }); + + // 3-5. 입찰 조건 생성 (RFQ 조건 활용) + await tx.insert(biddingConditions).values({ + biddingId: newBidding.id, + paymentTerms: JSON.stringify([rfqData.vendor.paymentTermsCode]), + taxConditions: JSON.stringify([rfqData.vendor.taxCode]), + contractDeliveryDate: rfqData.vendor.deliveryDate || undefined, + isPriceAdjustmentApplicable: rfqData.vendor.materialPriceRelatedYn || false, + incoterms: JSON.stringify([rfqData.vendor.incotermsCode]), + shippingPort: rfqData.vendor.placeOfShipping || undefined, + destinationPort: rfqData.vendor.placeOfDestination || undefined, + }); + + // 3-6. rfqLastDetails 상태 업데이트 await tx .update(rfqLastDetails) .set({ contractStatus: "입찰진행중", contractCreatedAt: new Date(), - contractNo: `BID-${Date.now()}`, // TODO: 실제 입찰번호로 변경 + contractNo: biddingNumber, updatedAt: new Date(), updatedBy: userId, }) @@ -237,23 +436,18 @@ export async function createBidding(params: CreateBiddingParams) { ) ); - // RFQ 상태 업데이트 - await tx - .update(rfqsLast) - .set({ - status: "입찰 진행중", - updatedAt: new Date(), - }) - .where(eq(rfqsLast.id, params.rfqId)); + return newBidding; }); revalidatePath(`/rfq/${params.rfqId}`); revalidatePath("/rfq"); + revalidatePath("/biddings"); return { success: true, message: "입찰이 성공적으로 생성되었습니다.", - biddingNumber: `BID-${Date.now()}`, // TODO: 실제 입찰번호 반환 + biddingNumber: result.biddingNumber, + biddingId: result.id, }; } catch (error) { console.error("입찰 생성 오류:", error); @@ -263,7 +457,6 @@ export async function createBidding(params: CreateBiddingParams) { }; } } - // ===== 계약 타입 확인 ===== export async function checkContractStatus(rfqId: number) { try { diff --git a/lib/rfq-last/quotation-compare-view.tsx b/lib/rfq-last/quotation-compare-view.tsx index 491a1962..28c8b3b1 100644 --- a/lib/rfq-last/quotation-compare-view.tsx +++ b/lib/rfq-last/quotation-compare-view.tsx @@ -46,6 +46,7 @@ import { import { ComparisonData, selectVendor, cancelVendorSelection } from "./compare-action"; import { createPO, createGeneralContract, createBidding } from "./contract-actions"; import { toast } from "sonner"; +import { useRouter } from "next/navigation" interface QuotationCompareViewProps { data: ComparisonData; @@ -61,6 +62,61 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { const [selectionReason, setSelectionReason] = React.useState(""); const [cancelReason, setCancelReason] = React.useState(""); const [isSubmitting, setIsSubmitting] = React.useState(false); + const router = useRouter() + + const [selectedGeneralContractType, setSelectedGeneralContractType] = React.useState(""); + const [contractStartDate, setContractStartDate] = React.useState(""); + const [contractEndDate, setContractEndDate] = React.useState(""); + + // 계약종류 옵션 + const contractTypes = [ + { value: 'UP', label: 'UP - 자재단가계약' }, + { value: 'LE', label: 'LE - 임대차계약' }, + { value: 'IL', label: 'IL - 개별운송계약' }, + { value: 'AL', label: 'AL - 연간운송계약' }, + { value: 'OS', label: 'OS - 외주용역계약' }, + { value: 'OW', label: 'OW - 도급계약' }, + { value: 'IS', label: 'IS - 검사계약' }, + { value: 'LO', label: 'LO - LOI (의향서)' }, + { value: 'FA', label: 'FA - Frame Agreement' }, + { value: 'SC', label: 'SC - 납품합의계약' }, + { value: 'OF', label: 'OF - 클레임상계계약' }, + { value: 'AW', label: 'AW - 사전작업합의' }, + { value: 'AD', label: 'AD - 사전납품합의' }, + { value: 'AM', label: 'AM - 설계계약' }, + { value: 'SC_SELL', label: 'SC - 폐기물매각계약' }, + ]; + + // 입찰 관련 state 추가 + const [biddingContractType, setBiddingContractType] = React.useState<"unit_price" | "general" | "sale" | "">(""); + const [biddingType, setBiddingType] = React.useState<string>(""); + const [awardCount, setAwardCount] = React.useState<"single" | "multiple">("single"); + const [biddingStartDate, setBiddingStartDate] = React.useState(""); + const [biddingEndDate, setBiddingEndDate] = React.useState(""); + + // 입찰 옵션들 + const biddingContractTypes = [ + { value: 'unit_price', label: '단가계약' }, + { value: 'general', label: '일반계약' }, + { value: 'sale', label: '매각계약' } + ]; + + const biddingTypes = [ + { value: 'equipment', label: '기자재' }, + { value: 'construction', label: '공사' }, + { value: 'service', label: '용역' }, + { value: 'lease', label: '임차' }, + { value: 'steel_stock', label: '형강스톡' }, + { value: 'piping', label: '배관' }, + { value: 'transport', label: '운송' }, + { value: 'waste', label: '폐기물' }, + { value: 'sale', label: '매각' } + ]; + + const awardCounts = [ + { value: 'single', label: '단수' }, + { value: 'multiple', label: '복수' } + ]; // 선정된 업체 정보 확인 const selectedVendor = data.vendors.find(v => v.isSelected); @@ -76,15 +132,56 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { return; } + // 일반계약인 경우 계약종류와 날짜 확인 + if (selectedContractType === "CONTRACT") { + if (!selectedGeneralContractType) { + toast.error("계약종류를 선택해주세요."); + return; + } + if (!contractStartDate) { + toast.error("계약 시작일을 선택해주세요."); + return; + } + if (!contractEndDate) { + toast.error("계약 종료일을 선택해주세요."); + return; + } + if (new Date(contractStartDate) >= new Date(contractEndDate)) { + toast.error("계약 종료일은 시작일보다 이후여야 합니다."); + return; + } + } + + // 입찰 검증 + if (selectedContractType === "BIDDING") { + if (!biddingContractType) { + toast.error("계약구분을 선택해주세요."); + return; + } + if (!biddingType) { + toast.error("입찰유형을 선택해주세요."); + return; + } + if (!biddingStartDate || !biddingEndDate) { + toast.error("입찰 기간을 입력해주세요."); + return; + } + if (new Date(biddingStartDate) >= new Date(biddingEndDate)) { + toast.error("입찰 마감일은 시작일보다 이후여야 합니다."); + return; + } + } + if (!selectedVendor) { toast.error("선정된 업체가 없습니다."); return; } setIsSubmitting(true); + try { let result; - + switch (selectedContractType) { case "PO": result = await createPO({ @@ -94,9 +191,10 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { totalAmount: selectedVendor.totalAmount, currency: selectedVendor.currency, selectionReason: selectedVendor.selectionReason, + }); break; - + case "CONTRACT": result = await createGeneralContract({ rfqId: data.rfqInfo.id, @@ -104,9 +202,13 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { vendorName: selectedVendor.vendorName, totalAmount: selectedVendor.totalAmount, currency: selectedVendor.currency, + contractStartDate: new Date(contractStartDate), + contractEndDate: new Date(contractEndDate), + contractType: selectedGeneralContractType, // 계약종류 추가 + }); break; - + case "BIDDING": result = await createBidding({ rfqId: data.rfqInfo.id, @@ -114,9 +216,14 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { vendorName: selectedVendor.vendorName, totalAmount: selectedVendor.totalAmount, currency: selectedVendor.currency, + contractType: biddingContractType, + biddingType: biddingType, + awardCount: awardCount, + biddingStartDate: new Date(biddingStartDate), + biddingEndDate: new Date(biddingEndDate), }); break; - + default: throw new Error("올바른 계약 유형이 아닙니다."); } @@ -124,8 +231,17 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { if (result.success) { toast.success(result.message || "계약 프로세스가 시작되었습니다."); setShowContractDialog(false); + // 모든 state 초기화 setSelectedContractType(""); - window.location.reload(); + setSelectedGeneralContractType(""); + setContractStartDate(""); + setContractEndDate(""); + setBiddingContractType(""); + setBiddingType(""); + setAwardCount("single"); + setBiddingStartDate(""); + setBiddingEndDate(""); + router.refresh(); } else { throw new Error(result.error || "계약 진행 중 오류가 발생했습니다."); } @@ -224,7 +340,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { toast.success("업체가 성공적으로 선정되었습니다."); setShowSelectionDialog(false); setSelectionReason(""); - window.location.reload(); // 페이지 새로고침으로 선정 상태 반영 + router.refresh() } else { throw new Error(result.error || "업체 선정 중 오류가 발생했습니다."); } @@ -246,13 +362,13 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { setIsSubmitting(true); try { // 파라미터를 올바르게 전달 - const result = await cancelVendorSelection(Number(data.rfqInfo.id),cancelReason); + const result = await cancelVendorSelection(Number(data.rfqInfo.id), cancelReason); if (result.success) { toast.success("업체 선정이 취소되었습니다."); setShowCancelDialog(false); setCancelReason(""); - window.location.reload(); + router.refresh() } else { throw new Error(result.error || "선정 취소 중 오류가 발생했습니다."); } @@ -312,13 +428,13 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { {hasSelection && ( <Alert className={cn( "border-2", - hasContract + hasContract ? "border-purple-500 bg-purple-50" - : isSelectionApproved - ? "border-green-500 bg-green-50" - : isPendingApproval - ? "border-yellow-500 bg-yellow-50" - : "border-blue-500 bg-blue-50" + : isSelectionApproved + ? "border-green-500 bg-green-50" + : isPendingApproval + ? "border-yellow-500 bg-yellow-50" + : "border-blue-500 bg-blue-50" )}> <div className="flex items-start justify-between"> <div className="flex gap-3"> @@ -335,11 +451,11 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { <AlertTitle className="text-lg"> {hasContract ? "계약 진행중" - : isSelectionApproved - ? "업체 선정 승인 완료" - : isPendingApproval - ? "업체 선정 승인 대기중" - : "업체 선정 완료"} + : isSelectionApproved + ? "업체 선정 승인 완료" + : isPendingApproval + ? "업체 선정 승인 대기중" + : "업체 선정 완료"} </AlertTitle> <AlertDescription className="space-y-1"> <p className="font-semibold">선정 업체: {selectedVendor.vendorName} ({selectedVendor.vendorCode})</p> @@ -521,13 +637,13 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { key={vendor.vendorId} className={cn( "flex items-center justify-between p-4 border rounded-lg transition-colors", - vendor.isSelected - ? "bg-blue-100 border-blue-400 border-2" + vendor.isSelected + ? "bg-blue-100 border-blue-400 border-2" : hasSelection - ? "opacity-60" - : selectedVendorId === vendor.vendorId.toString() - ? "bg-blue-50 border-blue-300 cursor-pointer" - : "hover:bg-gray-50 cursor-pointer" + ? "opacity-60" + : selectedVendorId === vendor.vendorId.toString() + ? "bg-blue-50 border-blue-300 cursor-pointer" + : "hover:bg-gray-50 cursor-pointer" )} onClick={() => { if (!hasSelection) { @@ -683,10 +799,10 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { </TooltipContent> </Tooltip> </TooltipProvider> - {vendor.vendorConditions.paymentTermsCode && - vendor.vendorConditions.paymentTermsCode !== vendor.buyerConditions.paymentTermsCode && ( - <Badge variant="outline" className="text-xs">변경</Badge> - )} + {vendor.vendorConditions.paymentTermsCode && + vendor.vendorConditions.paymentTermsCode !== vendor.buyerConditions.paymentTermsCode && ( + <Badge variant="outline" className="text-xs">변경</Badge> + )} </div> </td> ))} @@ -770,8 +886,8 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { {quote.deliveryDate ? format(new Date(quote.deliveryDate), "yyyy-MM-dd") : quote.leadTime - ? `${quote.leadTime}일` - : "-"} + ? `${quote.leadTime}일` + : "-"} </td> <td className="p-2"> {quote.manufacturer && ( @@ -817,7 +933,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { <div> <p className="text-muted-foreground">가격 차이</p> <p className="font-semibold"> - {((item.priceAnalysis.highestPrice - item.priceAnalysis.lowestPrice) / + {((item.priceAnalysis.highestPrice - item.priceAnalysis.lowestPrice) / item.priceAnalysis.lowestPrice * 100).toFixed(1)}% </p> </div> @@ -848,7 +964,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { <div className="space-y-3"> {data.vendors.map((vendor) => { if (!vendor.conditionDifferences.hasDifferences) return null; - + return ( <div key={vendor.vendorId} className="p-3 border rounded-lg"> <p className="font-medium mb-2">{vendor.vendorName}</p> @@ -930,12 +1046,12 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { // 가격 순위와 조건 차이를 고려한 점수 계산 const scoredVendors = data.vendors.map(v => ({ ...v, - score: (v.rank || 10) + v.conditionDifferences.criticalDifferences.length * 3 + - v.conditionDifferences.differences.length + score: (v.rank || 10) + v.conditionDifferences.criticalDifferences.length * 3 + + v.conditionDifferences.differences.length })); scoredVendors.sort((a, b) => a.score - b.score); const recommended = scoredVendors[0]; - + return ( <div> <p className="text-sm text-purple-700"> @@ -963,7 +1079,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { <div className="space-y-4"> {data.vendors.map((vendor) => { if (!vendor.generalRemark && !vendor.technicalProposal) return null; - + return ( <div key={vendor.vendorId} className="border rounded-lg p-4"> <p className="font-medium mb-2">{vendor.vendorName}</p> @@ -996,7 +1112,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { <h3 className="text-lg font-semibold mb-4"> {hasSelection ? "업체 재선정 확인" : "업체 선정 확인"} </h3> - + {selectedVendorId && ( <div className="space-y-4"> <div className="rounded-lg border p-4"> @@ -1076,7 +1192,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { <div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center"> <div className="bg-white rounded-lg p-6 max-w-lg w-full mx-4"> <h3 className="text-lg font-semibold mb-4">업체 선정 취소</h3> - + <Alert className="mb-4"> <AlertTriangle className="h-4 w-4" /> <AlertDescription> @@ -1141,13 +1257,13 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { {/* 계약 진행 모달 */} {showContractDialog && ( <div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center"> - <div className="bg-white rounded-lg p-6 max-w-lg w-full mx-4"> + <div className="bg-white rounded-lg p-6 max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto"> <h3 className="text-lg font-semibold mb-4"> {selectedContractType === "PO" && "PO (SAP) 생성"} {selectedContractType === "CONTRACT" && "일반계약 생성"} {selectedContractType === "BIDDING" && "입찰 생성"} </h3> - + {selectedVendor && ( <div className="space-y-4"> <div className="rounded-lg border p-4 bg-gray-50"> @@ -1180,32 +1296,222 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { </AlertDescription> </Alert> - {/* 추가 옵션이 필요한 경우 여기에 추가 */} + {/* 일반계약 선택 시 계약종류 및 기간 선택 */} {selectedContractType === "CONTRACT" && ( - <div className="space-y-2"> - <p className="text-sm font-medium">계약 옵션</p> - <div className="space-y-2 text-sm"> - <label className="flex items-center gap-2"> - <input type="checkbox" className="rounded" /> - <span>표준계약서 사용</span> - </label> - <label className="flex items-center gap-2"> - <input type="checkbox" className="rounded" /> - <span>전자서명 요청</span> + <div className="space-y-4"> + {/* 계약종류 선택 */} + <div className="space-y-2"> + <label htmlFor="contract-type" className="text-sm font-medium"> + 계약종류 선택 * </label> + <select + id="contract-type" + className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + value={selectedGeneralContractType} + onChange={(e) => setSelectedGeneralContractType(e.target.value)} + required + > + <option value="">계약종류를 선택하세요</option> + {contractTypes.map(type => ( + <option key={type.value} value={type.value}> + {type.label} + </option> + ))} + </select> + </div> + + {/* 계약 기간 */} + <div className="grid grid-cols-2 gap-3"> + <div className="space-y-2"> + <label htmlFor="start-date" className="text-sm font-medium"> + 계약 시작일 * + </label> + <input + id="start-date" + type="date" + className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + value={contractStartDate} + onChange={(e) => setContractStartDate(e.target.value)} + min={new Date().toISOString().split('T')[0]} + required + /> + </div> + + <div className="space-y-2"> + <label htmlFor="end-date" className="text-sm font-medium"> + 계약 종료일 * + </label> + <input + id="end-date" + type="date" + className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + value={contractEndDate} + onChange={(e) => setContractEndDate(e.target.value)} + min={contractStartDate || new Date().toISOString().split('T')[0]} + required + /> + </div> + </div> + + {/* 계약 기간 표시 */} + {contractStartDate && contractEndDate && ( + <div className="p-3 bg-blue-50 rounded-md"> + <p className="text-sm text-blue-700"> + 계약 기간: {format(new Date(contractStartDate), "yyyy년 MM월 dd일", { locale: ko })} ~ {format(new Date(contractEndDate), "yyyy년 MM월 dd일", { locale: ko })} + <span className="ml-2 font-medium"> + ({Math.ceil((new Date(contractEndDate).getTime() - new Date(contractStartDate).getTime()) / (1000 * 60 * 60 * 24))}일간) + </span> + </p> + </div> + )} + + {/* 계약 옵션 */} + <div className="space-y-2"> + <p className="text-sm font-medium">추가 옵션</p> + <div className="space-y-2 text-sm"> + <label className="flex items-center gap-2"> + <input type="checkbox" className="rounded" /> + <span>표준계약서 사용</span> + </label> + <label className="flex items-center gap-2"> + <input type="checkbox" className="rounded" /> + <span>전자서명 요청</span> + </label> + <label className="flex items-center gap-2"> + <input type="checkbox" className="rounded" /> + <span>자동 연장 조항 포함</span> + </label> + </div> </div> </div> )} - + {/* 입찰 옵션 */} {selectedContractType === "BIDDING" && ( - <div className="space-y-2"> - <p className="text-sm font-medium">입찰 유형</p> - <select className="w-full px-3 py-2 border rounded-md"> - <option value="">선택하세요</option> - <option value="open">공개입찰</option> - <option value="limited">제한입찰</option> - <option value="private">지명입찰</option> - </select> + <div className="space-y-4"> + {/* 계약구분 선택 */} + <div className="space-y-2"> + <label htmlFor="bidding-contract-type" className="text-sm font-medium"> + 계약구분 * + </label> + <select + id="bidding-contract-type" + className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500" + value={biddingContractType} + onChange={(e) => setBiddingContractType(e.target.value as any)} + required + > + <option value="">계약구분을 선택하세요</option> + {biddingContractTypes.map(type => ( + <option key={type.value} value={type.value}> + {type.label} + </option> + ))} + </select> + </div> + + {/* 입찰유형 선택 */} + <div className="space-y-2"> + <label htmlFor="bidding-type" className="text-sm font-medium"> + 입찰유형 * + </label> + <select + id="bidding-type" + className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500" + value={biddingType} + onChange={(e) => setBiddingType(e.target.value)} + required + > + <option value="">입찰유형을 선택하세요</option> + {biddingTypes.map(type => ( + <option key={type.value} value={type.value}> + {type.label} + </option> + ))} + </select> + </div> + + {/* 낙찰수 선택 */} + <div className="space-y-2"> + <label htmlFor="award-count" className="text-sm font-medium"> + 낙찰수 + </label> + <select + id="award-count" + className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500" + value={awardCount} + onChange={(e) => setAwardCount(e.target.value as any)} + > + {awardCounts.map(type => ( + <option key={type.value} value={type.value}> + {type.label} + </option> + ))} + </select> + </div> + + {/* 입찰 기간 */} + <div className="grid grid-cols-2 gap-3"> + <div className="space-y-2"> + <label htmlFor="bidding-start-date" className="text-sm font-medium"> + 입찰 시작일 * + </label> + <input + id="bidding-start-date" + type="date" + className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500" + value={biddingStartDate} + onChange={(e) => setBiddingStartDate(e.target.value)} + min={new Date().toISOString().split('T')[0]} + required + /> + </div> + + <div className="space-y-2"> + <label htmlFor="bidding-end-date" className="text-sm font-medium"> + 입찰 마감일 * + </label> + <input + id="bidding-end-date" + type="date" + className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500" + value={biddingEndDate} + onChange={(e) => setBiddingEndDate(e.target.value)} + min={biddingStartDate || new Date().toISOString().split('T')[0]} + required + /> + </div> + </div> + + {/* 입찰 기간 표시 */} + {biddingStartDate && biddingEndDate && ( + <div className="p-3 bg-purple-50 rounded-md"> + <p className="text-sm text-purple-700"> + 입찰 기간: {format(new Date(biddingStartDate), "yyyy년 MM월 dd일", { locale: ko })} ~ {format(new Date(biddingEndDate), "yyyy년 MM월 dd일", { locale: ko })} + <span className="ml-2 font-medium"> + ({Math.ceil((new Date(biddingEndDate).getTime() - new Date(biddingStartDate).getTime()) / (1000 * 60 * 60 * 24))}일간) + </span> + </p> + </div> + )} + + {/* 추가 옵션 */} + <div className="space-y-2"> + <p className="text-sm font-medium">추가 옵션</p> + <div className="space-y-2 text-sm"> + <label className="flex items-center gap-2"> + <input type="checkbox" className="rounded" /> + <span>긴급입찰</span> + </label> + <label className="flex items-center gap-2"> + <input type="checkbox" className="rounded" /> + <span>사양설명회 개최</span> + </label> + <label className="flex items-center gap-2"> + <input type="checkbox" className="rounded" /> + <span>공개입찰</span> + </label> + </div> + </div> </div> )} </div> @@ -1216,7 +1522,16 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { variant="outline" onClick={() => { setShowContractDialog(false); + // 모든 state 초기화 setSelectedContractType(""); + setSelectedGeneralContractType(""); + setContractStartDate(""); + setContractEndDate(""); + setBiddingContractType(""); + setBiddingType(""); + setAwardCount("single"); + setBiddingStartDate(""); + setBiddingEndDate(""); }} disabled={isSubmitting} > @@ -1224,7 +1539,13 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { </Button> <Button onClick={handleContractCreation} - disabled={isSubmitting} + disabled={ + isSubmitting || + (selectedContractType === "CONTRACT" && + (!selectedGeneralContractType || !contractStartDate || !contractEndDate)) || + (selectedContractType === "BIDDING" && + (!biddingContractType || !biddingType || !biddingStartDate || !biddingEndDate)) + } > {isSubmitting ? "처리 중..." : "진행"} </Button> @@ -1232,6 +1553,8 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { </div> </div> )} + + </div> ); }
\ No newline at end of file diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts index 723a69fe..85db1ea7 100644 --- a/lib/rfq-last/service.ts +++ b/lib/rfq-last/service.ts @@ -16,6 +16,7 @@ import { addDays, format } from "date-fns" import { ko, enUS } from "date-fns/locale" import { generateBasicContractsForVendor } from "../basic-contract/gen-service"; import { writeFile, mkdir } from "fs/promises"; +import { generateItbRfqCode } from "../itb/service"; export async function getRfqs(input: GetRfqsSchema) { @@ -37,17 +38,14 @@ export async function getRfqs(input: GetRfqsSchema) { break; case "itb": // ITB: projectCompany가 있는 경우 - typeFilter = and( - isNotNull(rfqsLastView.projectCompany), - ne(rfqsLastView.projectCompany, '') - ); + typeFilter = + like(rfqsLastView.rfqCode,'I%') + + ; break; case "rfq": // RFQ: prNumber가 있는 경우 - typeFilter = and( - isNotNull(rfqsLastView.prNumber), - ne(rfqsLastView.prNumber, '') - ); + typeFilter = like(rfqsLastView.rfqCode,'R%'); break; } } @@ -1854,6 +1852,26 @@ export async function getRfqWithDetails(rfqId: number) { ) .orderBy(desc(rfqLastDetailsView.detailId)); + const tbeSessionsData = await db + .select({ + vendorId: rfqLastTbeSessions.vendorId, + sessionCode: rfqLastTbeSessions.sessionCode, + status: rfqLastTbeSessions.status, + evaluationResult: rfqLastTbeSessions.evaluationResult, + conditionalRequirements: rfqLastTbeSessions.conditionalRequirements, + conditionsFulfilled: rfqLastTbeSessions.conditionsFulfilled, + plannedStartDate: rfqLastTbeSessions.plannedStartDate, + actualStartDate: rfqLastTbeSessions.actualStartDate, + actualEndDate: rfqLastTbeSessions.actualEndDate, + }) + .from(rfqLastTbeSessions) + .where(eq(rfqLastTbeSessions.rfqsLastId, rfqId)); + + const tbeByVendor = tbeSessionsData.reduce((acc, tbe) => { + acc[tbe.vendorId] = tbe; + return acc; + }, {} as Record<number, typeof tbeSessionsData[0]>); + return { success: true, data: { @@ -1990,6 +2008,12 @@ export async function getRfqWithDetails(rfqId: number) { emailResentCount: d.emailResentCount, lastEmailSentAt: d.lastEmailSentAt, emailStatus: d.emailStatus, + + // TBE 정보 추가 + tbeSession: d.vendorId ? tbeByVendor[d.vendorId] : null, + tbeStatus: d.vendorId ? tbeByVendor[d.vendorId]?.status : null, + tbeEvaluationResult: d.vendorId ? tbeByVendor[d.vendorId]?.evaluationResult : null, + tbeSessionCode: d.vendorId ? tbeByVendor[d.vendorId]?.sessionCode : null, })), } }; @@ -2987,19 +3011,25 @@ async function prepareEmailAttachments(rfqId: number, attachmentIds: number[]) { for (const { attachment, revision } of attachments) { if (revision?.filePath) { + + const cleanPath = revision.filePath.startsWith('/api/files') + ? revision.filePath.slice('/api/files'.length) + : revision.filePath; + try { const isProduction = process.env.NODE_ENV === "production"; - const fullPath = isProduction + + const fullPath = !isProduction ? path.join( process.cwd(), `public`, - revision.filePath + cleanPath ) : path.join( `${process.env.NAS_PATH}`, - revision.filePath + cleanPath ); const fileBuffer = await fs.readFile(fullPath); @@ -3009,7 +3039,8 @@ async function prepareEmailAttachments(rfqId: number, attachmentIds: number[]) { contentType: revision.fileType || 'application/octet-stream' }); } catch (error) { - console.error(`첨부파일 읽기 실패: ${revision.filePath}`, error); + + console.error(`첨부파일 읽기 실패: ${cleanPath}`, error); } } } @@ -4320,4 +4351,83 @@ export async function updateShortList( console.error("Short List 업데이트 실패:", error); throw new Error("Short List 업데이트에 실패했습니다."); } +} + +interface AssignPicParams { + rfqIds: number[]; + picUserId: number; +} + +export async function assignPicToRfqs({ rfqIds, picUserId }: AssignPicParams) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + throw new Error("인증이 필요합니다."); + } + + // 선택된 담당자 정보 조회 + const picUser = await db.query.users.findFirst({ + where: eq(users.id, picUserId), + }); + + if (!picUser) { + throw new Error("선택한 담당자를 찾을 수 없습니다."); + } + + // RFQ 코드가 "I"로 시작하는 것들만 필터링 (추가 검증) + const targetRfqs = await db.query.rfqsLast.findMany({ + where: inArray(rfqsLast.id, rfqIds), + }); + + // "I"로 시작하는 RFQ만 필터링 + const validRfqs = targetRfqs.filter(rfq => rfq.rfqCode?.startsWith("I")); + + if (validRfqs.length === 0) { + throw new Error("담당자를 지정할 수 있는 ITB가 없습니다."); + } + + // 트랜잭션으로 처리하여 동시성 문제 방지 + const updatedCount = await db.transaction(async (tx) => { + let successCount = 0; + + for (const rfq of validRfqs) { + // 각 RFQ에 대해 새로운 코드 생성 + const newRfqCode = await generateItbRfqCode(picUser.id); + + // RFQ 업데이트 + const result = await tx.update(rfqsLast) + .set({ + rfqCode: newRfqCode, // 새로운 RFQ 코드로 업데이트 + pic: picUser.id, + picCode: picUser.userCode || undefined, + picName: picUser.name, + status: "구매담당지정", // 상태도 업데이트 + updatedBy: parseInt(session.user.id), + updatedAt: new Date(), + }) + .where(eq(rfqsLast.id, rfq.id)); + + if (result) { + successCount++; + console.log(`RFQ ${rfq.rfqCode} -> ${newRfqCode} 업데이트 완료`); + } + } + + return successCount; + }); + + revalidatePath("/evcp/rfq-last"); + + return { + success: true, + message: `${updatedCount}건의 ITB에 담당자가 지정되고 코드가 재발급되었습니다.`, + updatedCount + }; + } catch (error) { + console.error("담당자 지정 오류:", error); + return { + success: false, + message: error instanceof Error ? error.message : "담당자 지정 중 오류가 발생했습니다." + }; + } }
\ No newline at end of file diff --git a/lib/rfq-last/table/create-general-rfq-dialog.tsx b/lib/rfq-last/table/create-general-rfq-dialog.tsx index 14564686..7abf06a3 100644 --- a/lib/rfq-last/table/create-general-rfq-dialog.tsx +++ b/lib/rfq-last/table/create-general-rfq-dialog.tsx @@ -60,7 +60,7 @@ import { createGeneralRfqAction, getPUsersForFilter, previewGeneralRfqCode } fro // 아이템 스키마 const itemSchema = z.object({ - itemCode: z.string().min(1, "자재코드를 입력해주세요"), + itemCode: z.string().optional(), itemName: z.string().min(1, "자재명을 입력해주세요"), quantity: z.number().min(1, "수량은 1 이상이어야 합니다"), uom: z.string().min(1, "단위를 입력해주세요"), @@ -645,7 +645,7 @@ export function CreateGeneralRfqDialog({ onSuccess }: CreateGeneralRfqDialogProp render={({ field }) => ( <FormItem> <FormLabel className="text-xs"> - 자재코드 <span className="text-red-500">*</span> + 자재코드 </FormLabel> <FormControl> <Input diff --git a/lib/rfq-last/table/rfq-assign-pic-dialog.tsx b/lib/rfq-last/table/rfq-assign-pic-dialog.tsx new file mode 100644 index 00000000..89dda979 --- /dev/null +++ b/lib/rfq-last/table/rfq-assign-pic-dialog.tsx @@ -0,0 +1,311 @@ +"use client"; + +import * as React from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Check, ChevronsUpDown, Loader2, User, Users } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; +import { getPUsersForFilter } from "@/lib/rfq-last/service"; +import { assignPicToRfqs } from "../service"; +import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription } from "@/components/ui/alert"; + +interface User { + id: number; + name: string; + userCode?: string; + email?: string; +} + +interface RfqAssignPicDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + selectedRfqIds: number[]; + selectedRfqCodes: string[]; + onSuccess?: () => void; +} + +export function RfqAssignPicDialog({ + open, + onOpenChange, + selectedRfqIds, + selectedRfqCodes, + onSuccess, +}: RfqAssignPicDialogProps) { + const [users, setUsers] = React.useState<User[]>([]); + const [isLoadingUsers, setIsLoadingUsers] = React.useState(false); + const [isAssigning, setIsAssigning] = React.useState(false); + const [selectedUser, setSelectedUser] = React.useState<User | null>(null); + const [userPopoverOpen, setUserPopoverOpen] = React.useState(false); + const [userSearchTerm, setUserSearchTerm] = React.useState(""); + + // ITB만 필터링 (rfqCode가 "I"로 시작하는 것) + const itbCodes = React.useMemo(() => { + return selectedRfqCodes.filter(code => code.startsWith("I")); + }, [selectedRfqCodes]); + + const itbIds = React.useMemo(() => { + return selectedRfqIds.filter((id, index) => selectedRfqCodes[index]?.startsWith("I")); + }, [selectedRfqIds, selectedRfqCodes]); + + // 유저 목록 로드 + React.useEffect(() => { + const loadUsers = async () => { + setIsLoadingUsers(true); + try { + const userList = await getPUsersForFilter(); + setUsers(userList); + } catch (error) { + console.log("사용자 목록 로드 오류:", error); + toast.error("사용자 목록을 불러오는데 실패했습니다"); + } finally { + setIsLoadingUsers(false); + } + }; + + if (open) { + loadUsers(); + // 다이얼로그 열릴 때 초기화 + setSelectedUser(null); + setUserSearchTerm(""); + } + }, [open]); + + // 유저 검색 + const filteredUsers = React.useMemo(() => { + if (!userSearchTerm) return users; + + const lowerSearchTerm = userSearchTerm.toLowerCase(); + return users.filter( + (user) => + user.name.toLowerCase().includes(lowerSearchTerm) || + user.userCode?.toLowerCase().includes(lowerSearchTerm) + ); + }, [users, userSearchTerm]); + + const handleSelectUser = (user: User) => { + setSelectedUser(user); + setUserPopoverOpen(false); + }; + + const handleAssign = async () => { + if (!selectedUser) { + toast.error("담당자를 선택해주세요"); + return; + } + + if (itbIds.length === 0) { + toast.error("선택한 항목 중 ITB가 없습니다"); + return; + } + + setIsAssigning(true); + try { + const result = await assignPicToRfqs({ + rfqIds: itbIds, + picUserId: selectedUser.id, + }); + + if (result.success) { + toast.success(result.message); + onSuccess?.(); + onOpenChange(false); + } else { + toast.error(result.message); + } + } catch (error) { + console.error("담당자 지정 오류:", error); + toast.error("담당자 지정 중 오류가 발생했습니다"); + } finally { + setIsAssigning(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[500px]"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Users className="h-5 w-5" /> + 담당자 지정 + </DialogTitle> + <DialogDescription> + 선택한 ITB에 구매 담당자를 지정합니다 + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {/* 선택된 ITB 정보 */} + <div className="space-y-2"> + <label className="text-sm font-medium">선택된 ITB</label> + <div className="p-3 bg-muted rounded-md"> + <div className="flex items-center gap-2 mb-2"> + <Badge variant="secondary">{itbCodes.length}건</Badge> + {itbCodes.length !== selectedRfqCodes.length && ( + <span className="text-xs text-muted-foreground"> + (전체 {selectedRfqCodes.length}건 중) + </span> + )} + </div> + <div className="max-h-[100px] overflow-y-auto"> + <div className="flex flex-wrap gap-1"> + {itbCodes.slice(0, 10).map((code, index) => ( + <Badge key={index} variant="outline" className="text-xs"> + {code} + </Badge> + ))} + {itbCodes.length > 10 && ( + <Badge variant="outline" className="text-xs"> + +{itbCodes.length - 10}개 + </Badge> + )} + </div> + </div> + </div> + {itbCodes.length === 0 && ( + <Alert className="border-orange-200 bg-orange-50"> + <AlertDescription className="text-orange-800"> + 선택한 항목 중 ITB (I로 시작하는 코드)가 없습니다. + </AlertDescription> + </Alert> + )} + </div> + + {/* 담당자 선택 */} + <div className="space-y-2"> + <label className="text-sm font-medium">구매 담당자</label> + <Popover open={userPopoverOpen} onOpenChange={setUserPopoverOpen}> + <PopoverTrigger asChild> + <Button + type="button" + variant="outline" + className="w-full justify-between h-10" + disabled={isLoadingUsers || itbCodes.length === 0} + > + {isLoadingUsers ? ( + <> + <span>담당자 로딩 중...</span> + <Loader2 className="ml-2 h-4 w-4 animate-spin" /> + </> + ) : ( + <> + <span className="flex items-center gap-2"> + <User className="h-4 w-4" /> + {selectedUser ? ( + <> + {selectedUser.name} + {selectedUser.userCode && ( + <span className="text-muted-foreground"> + ({selectedUser.userCode}) + </span> + )} + </> + ) : ( + <span className="text-muted-foreground"> + 구매 담당자를 선택하세요 + </span> + )} + </span> + <ChevronsUpDown className="h-4 w-4 opacity-50" /> + </> + )} + </Button> + </PopoverTrigger> + <PopoverContent className="w-[460px] p-0"> + <Command> + <CommandInput + placeholder="이름 또는 코드로 검색..." + value={userSearchTerm} + onValueChange={setUserSearchTerm} + /> + <CommandList className="max-h-[300px]"> + <CommandEmpty>검색 결과가 없습니다</CommandEmpty> + <CommandGroup> + {filteredUsers.map((user) => ( + <CommandItem + key={user.id} + onSelect={() => handleSelectUser(user)} + className="flex items-center justify-between" + > + <span className="flex items-center gap-2"> + <User className="h-4 w-4" /> + {user.name} + {user.userCode && ( + <span className="text-muted-foreground text-sm"> + ({user.userCode}) + </span> + )} + </span> + <Check + className={cn( + "h-4 w-4", + selectedUser?.id === user.id + ? "opacity-100" + : "opacity-0" + )} + /> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + {selectedUser && ( + <p className="text-xs text-muted-foreground"> + 선택한 담당자: {selectedUser.name} + {selectedUser.userCode && ` (${selectedUser.userCode})`} + </p> + )} + </div> + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isAssigning} + > + 취소 + </Button> + <Button + type="submit" + onClick={handleAssign} + disabled={!selectedUser || itbCodes.length === 0 || isAssigning} + > + {isAssigning ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 지정 중... + </> + ) : ( + "담당자 지정" + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/lib/rfq-last/table/rfq-table-toolbar-actions.tsx b/lib/rfq-last/table/rfq-table-toolbar-actions.tsx index 91b2798f..d933fa95 100644 --- a/lib/rfq-last/table/rfq-table-toolbar-actions.tsx +++ b/lib/rfq-last/table/rfq-table-toolbar-actions.tsx @@ -1,308 +1,148 @@ "use client"; import * as React from "react"; -import { type Table } from "@tanstack/react-table"; -import { Download, RefreshCw, Plus, Lock, LockOpen } from "lucide-react"; - +import { Table } from "@tanstack/react-table"; import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { toast } from "sonner"; +import { Users, RefreshCw, FileDown, Plus } from "lucide-react"; import { RfqsLastView } from "@/db/schema"; -import { CreateGeneralRfqDialog } from "./create-general-rfq-dialog"; -import { sealMultipleRfqs, unsealMultipleRfqs } from "../service"; - -interface RfqTableToolbarActionsProps { - table: Table<RfqsLastView>; - onRefresh?: () => void; +import { RfqAssignPicDialog } from "./rfq-assign-pic-dialog"; +import { Badge } from "@/components/ui/badge"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +interface RfqTableToolbarActionsProps<TData> { + table: Table<TData>; rfqCategory?: "general" | "itb" | "rfq"; + onRefresh?: () => void; } -export function RfqTableToolbarActions({ - table, - onRefresh, +export function RfqTableToolbarActions<TData>({ + table, rfqCategory = "itb", -}: RfqTableToolbarActionsProps) { - const [isExporting, setIsExporting] = React.useState(false); - const [isSealing, setIsSealing] = React.useState(false); - const [sealDialogOpen, setSealDialogOpen] = React.useState(false); - const [sealAction, setSealAction] = React.useState<"seal" | "unseal">("seal"); - + onRefresh +}: RfqTableToolbarActionsProps<TData>) { + const [showAssignDialog, setShowAssignDialog] = React.useState(false); + + // 선택된 행 가져오기 const selectedRows = table.getFilteredSelectedRowModel().rows; - const selectedRfqIds = selectedRows.map(row => row.original.id); - // 선택된 항목들의 밀봉 상태 확인 - const sealedCount = selectedRows.filter(row => row.original.rfqSealedYn).length; - const unsealedCount = selectedRows.filter(row => !row.original.rfqSealedYn).length; - - const handleSealAction = React.useCallback(async (action: "seal" | "unseal") => { - setSealAction(action); - setSealDialogOpen(true); - }, []); - - const confirmSealAction = React.useCallback(async () => { - setIsSealing(true); - try { - const result = sealAction === "seal" - ? await sealMultipleRfqs(selectedRfqIds) - : await unsealMultipleRfqs(selectedRfqIds); - - if (result.success) { - toast.success(result.message); - table.toggleAllRowsSelected(false); // 선택 해제 - onRefresh?.(); // 데이터 새로고침 - } else { - toast.error(result.error); - } - } catch (error) { - toast.error("작업 중 오류가 발생했습니다."); - } finally { - setIsSealing(false); - setSealDialogOpen(false); - } - }, [sealAction, selectedRfqIds, table, onRefresh]); - - const handleExportCSV = React.useCallback(async () => { - setIsExporting(true); - try { - const data = table.getFilteredRowModel().rows.map((row) => { - const original = row.original; - return { - "RFQ 코드": original.rfqCode || "", - "상태": original.status || "", - "밀봉여부": original.rfqSealedYn ? "밀봉" : "미밀봉", - "프로젝트 코드": original.projectCode || "", - "프로젝트명": original.projectName || "", - "자재코드": original.itemCode || "", - "자재명": original.itemName || "", - "패키지 번호": original.packageNo || "", - "패키지명": original.packageName || "", - "구매담당자": original.picUserName || original.picName || "", - "엔지니어링 담당": original.engPicName || "", - "발송일": original.rfqSendDate ? new Date(original.rfqSendDate).toLocaleDateString("ko-KR") : "", - "마감일": original.dueDate ? new Date(original.dueDate).toLocaleDateString("ko-KR") : "", - "업체수": original.vendorCount || 0, - "Short List": original.shortListedVendorCount || 0, - "견적접수": original.quotationReceivedCount || 0, - "PR Items": original.prItemsCount || 0, - "주요 Items": original.majorItemsCount || 0, - "시리즈": original.series || "", - "견적 유형": original.rfqType || "", - "견적 제목": original.rfqTitle || "", - "프로젝트 회사": original.projectCompany || "", - "프로젝트 사이트": original.projectSite || "", - "SM 코드": original.smCode || "", - "PR 번호": original.prNumber || "", - "PR 발행일": original.prIssueDate ? new Date(original.prIssueDate).toLocaleDateString("ko-KR") : "", - "생성자": original.createdByUserName || "", - "생성일": original.createdAt ? new Date(original.createdAt).toLocaleDateString("ko-KR") : "", - "수정자": original.updatedByUserName || "", - "수정일": original.updatedAt ? new Date(original.updatedAt).toLocaleDateString("ko-KR") : "", - }; - }); - - const fileName = `RFQ_목록_${new Date().toISOString().split("T")[0]}.csv`; - exportTableToCSV({ data, filename: fileName }); - } catch (error) { - console.error("Export failed:", error); - } finally { - setIsExporting(false); - } - }, [table]); - - const handleExportSelected = React.useCallback(async () => { - setIsExporting(true); - try { - const selectedRows = table.getFilteredSelectedRowModel().rows; - if (selectedRows.length === 0) { - alert("선택된 항목이 없습니다."); - return; - } - - const data = selectedRows.map((row) => { - const original = row.original; - return { - "RFQ 코드": original.rfqCode || "", - "상태": original.status || "", - "밀봉여부": original.rfqSealedYn ? "밀봉" : "미밀봉", - "프로젝트 코드": original.projectCode || "", - "프로젝트명": original.projectName || "", - "자재코드": original.itemCode || "", - "자재명": original.itemName || "", - "패키지 번호": original.packageNo || "", - "패키지명": original.packageName || "", - "구매담당자": original.picUserName || original.picName || "", - "엔지니어링 담당": original.engPicName || "", - "발송일": original.rfqSendDate ? new Date(original.rfqSendDate).toLocaleDateString("ko-KR") : "", - "마감일": original.dueDate ? new Date(original.dueDate).toLocaleDateString("ko-KR") : "", - "업체수": original.vendorCount || 0, - "Short List": original.shortListedVendorCount || 0, - "견적접수": original.quotationReceivedCount || 0, - }; - }); - - const fileName = `RFQ_선택항목_${new Date().toISOString().split("T")[0]}.csv`; - exportTableToCSV({ data, filename: fileName }); - } catch (error) { - console.error("Export failed:", error); - } finally { - setIsExporting(false); - } - }, [table]); + // 선택된 RFQ의 ID와 코드 추출 + const selectedRfqData = React.useMemo(() => { + const rows = selectedRows.map(row => row.original as RfqsLastView); + return { + ids: rows.map(row => row.id), + codes: rows.map(row => row.rfqCode || ""), + // "I"로 시작하는 ITB만 필터링 + itbCount: rows.filter(row => row.rfqCode?.startsWith("I")).length, + totalCount: rows.length + }; + }, [selectedRows]); + + // 담당자 지정 가능 여부 체크 ("I"로 시작하는 항목이 있는지) + const canAssignPic = selectedRfqData.itbCount > 0; + + const handleAssignSuccess = () => { + // 테이블 선택 초기화 + table.toggleAllPageRowsSelected(false); + // 데이터 새로고침 + onRefresh?.(); + }; return ( <> <div className="flex items-center gap-2"> - {onRefresh && ( - <Button - variant="outline" - size="sm" - onClick={onRefresh} - className="h-8 px-2 lg:px-3" - > - <RefreshCw className="mr-2 h-4 w-4" /> - 새로고침 - </Button> + {/* 담당자 지정 버튼 - 선택된 항목 중 ITB가 있을 때만 표시 */} + {selectedRfqData.totalCount > 0 && canAssignPic && ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="default" + size="sm" + onClick={() => setShowAssignDialog(true)} + className="flex items-center gap-2" + > + <Users className="h-4 w-4" /> + 담당자 지정 + <Badge variant="secondary" className="ml-1"> + {selectedRfqData.itbCount}건 + </Badge> + </Button> + </TooltipTrigger> + <TooltipContent> + <p>선택한 ITB에 구매 담당자를 지정합니다</p> + {selectedRfqData.itbCount !== selectedRfqData.totalCount && ( + <p className="text-xs text-muted-foreground mt-1"> + 전체 {selectedRfqData.totalCount}건 중 ITB {selectedRfqData.itbCount}건만 지정됩니다 + </p> + )} + </TooltipContent> + </Tooltip> + </TooltipProvider> )} - {/* 견적 밀봉/해제 버튼 */} - {selectedRfqIds.length > 0 && ( - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - variant="outline" - size="sm" - className="h-8 px-2 lg:px-3" - disabled={isSealing} - > - <Lock className="mr-2 h-4 w-4" /> - 견적 밀봉 - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - <DropdownMenuItem - onClick={() => handleSealAction("seal")} - disabled={unsealedCount === 0} - > - <Lock className="mr-2 h-4 w-4" /> - 선택 항목 밀봉 ({unsealedCount}개) - </DropdownMenuItem> - <DropdownMenuItem - onClick={() => handleSealAction("unseal")} - disabled={sealedCount === 0} - > - <LockOpen className="mr-2 h-4 w-4" /> - 선택 항목 밀봉 해제 ({sealedCount}개) - </DropdownMenuItem> - <DropdownMenuSeparator /> - <div className="px-2 py-1.5 text-xs text-muted-foreground"> - 전체 {selectedRfqIds.length}개 선택됨 - </div> - </DropdownMenuContent> - </DropdownMenu> + {/* 선택된 항목 표시 */} + {selectedRfqData.totalCount > 0 && ( + <div className="flex items-center gap-2 px-3 py-1.5 bg-muted rounded-md"> + <span className="text-sm text-muted-foreground"> + 선택된 항목: + </span> + <Badge variant="secondary"> + {selectedRfqData.totalCount}건 + </Badge> + {selectedRfqData.totalCount !== selectedRfqData.itbCount && ( + <Badge variant="outline" className="text-xs"> + ITB {selectedRfqData.itbCount}건 + </Badge> + )} + </div> )} - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - variant="outline" - size="sm" - className="h-8 px-2 lg:px-3" - disabled={isExporting} - > - <Download className="mr-2 h-4 w-4" /> - {isExporting ? "내보내는 중..." : "내보내기"} - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - <DropdownMenuItem onClick={handleExportCSV}> - 전체 데이터 내보내기 - </DropdownMenuItem> - <DropdownMenuItem - onClick={handleExportSelected} - disabled={table.getFilteredSelectedRowModel().rows.length === 0} - > - 선택한 항목 내보내기 ({table.getFilteredSelectedRowModel().rows.length}개) - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> + {/* 기존 버튼들 */} + <Button + variant="outline" + size="sm" + onClick={onRefresh} + className="flex items-center gap-2" + > + <RefreshCw className="h-4 w-4" /> + 새로고침 + </Button> - {/* rfqCategory가 'general'일 때만 일반견적 생성 다이얼로그 표시 */} {rfqCategory === "general" && ( - <CreateGeneralRfqDialog onSuccess={onRefresh} /> + <Button + variant="outline" + size="sm" + className="flex items-center gap-2" + > + <Plus className="h-4 w-4" /> + 일반견적 생성 + </Button> )} + + <Button + variant="outline" + size="sm" + className="flex items-center gap-2" + disabled={selectedRfqData.totalCount === 0} + > + <FileDown className="h-4 w-4" /> + 엑셀 다운로드 + </Button> </div> - {/* 밀봉 확인 다이얼로그 */} - <AlertDialog open={sealDialogOpen} onOpenChange={setSealDialogOpen}> - <AlertDialogContent> - <AlertDialogHeader> - <AlertDialogTitle> - {sealAction === "seal" ? "견적 밀봉 확인" : "견적 밀봉 해제 확인"} - </AlertDialogTitle> - <AlertDialogDescription> - {sealAction === "seal" - ? `선택한 ${unsealedCount}개의 견적을 밀봉하시겠습니까? 밀봉된 견적은 업체에서 수정할 수 없습니다.` - : `선택한 ${sealedCount}개의 견적 밀봉을 해제하시겠습니까? 밀봉이 해제되면 업체에서 견적을 수정할 수 있습니다.`} - </AlertDialogDescription> - </AlertDialogHeader> - <AlertDialogFooter> - <AlertDialogCancel disabled={isSealing}>취소</AlertDialogCancel> - <AlertDialogAction - onClick={confirmSealAction} - disabled={isSealing} - className={sealAction === "seal" ? "bg-red-600 hover:bg-red-700" : ""} - > - {isSealing ? "처리 중..." : "확인"} - </AlertDialogAction> - </AlertDialogFooter> - </AlertDialogContent> - </AlertDialog> + {/* 담당자 지정 다이얼로그 */} + <RfqAssignPicDialog + open={showAssignDialog} + onOpenChange={setShowAssignDialog} + selectedRfqIds={selectedRfqData.ids} + selectedRfqCodes={selectedRfqData.codes} + onSuccess={handleAssignSuccess} + /> </> ); -} - -// CSV 내보내기 유틸리티 함수 -function exportTableToCSV({ data, filename }: { data: any[]; filename: string }) { - if (!data || data.length === 0) { - console.warn("No data to export"); - return; - } - - const headers = Object.keys(data[0]); - const csvContent = [ - headers.join(","), - ...data.map(row => - headers.map(header => { - const value = row[header]; - // 값에 쉼표, 줄바꿈, 따옴표가 있으면 따옴표로 감싸기 - if (typeof value === "string" && (value.includes(",") || value.includes("\n") || value.includes('"'))) { - return `"${value.replace(/"/g, '""')}"`; - } - return value; - }).join(",") - ) - ].join("\n"); - - const blob = new Blob(["\uFEFF" + csvContent], { type: "text/csv;charset=utf-8;" }); - const link = document.createElement("a"); - link.href = URL.createObjectURL(blob); - link.download = filename; - link.click(); - URL.revokeObjectURL(link.href); }
\ No newline at end of file diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx index 88ae968a..72539113 100644 --- a/lib/rfq-last/vendor/rfq-vendor-table.tsx +++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx @@ -657,6 +657,102 @@ export function RfqVendorTable({ }, { + accessorKey: "tbeStatus", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="TBE 상태" /> + ), + cell: ({ row }) => { + const status = row.original.tbeStatus; + + if (!status || status === "준비중") { + return ( + <Badge variant="outline" className="text-gray-500"> + <Clock className="h-3 w-3 mr-1" /> + 대기 + </Badge> + ); + } + + const statusConfig = { + "진행중": { variant: "default", icon: <Clock className="h-3 w-3 mr-1" />, color: "text-blue-600" }, + "검토중": { variant: "secondary", icon: <Eye className="h-3 w-3 mr-1" />, color: "text-orange-600" }, + "보류": { variant: "outline", icon: <AlertCircle className="h-3 w-3 mr-1" />, color: "text-yellow-600" }, + "완료": { variant: "success", icon: <CheckCircle className="h-3 w-3 mr-1" />, color: "text-green-600" }, + "취소": { variant: "destructive", icon: <XCircle className="h-3 w-3 mr-1" />, color: "text-red-600" }, + }[status] || { variant: "outline", icon: null, color: "text-gray-600" }; + + return ( + <Badge variant={statusConfig.variant as any} className={statusConfig.color}> + {statusConfig.icon} + {status} + </Badge> + ); + }, + size: 100, + }, + + { + accessorKey: "tbeEvaluationResult", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="TBE 평가" /> + ), + cell: ({ row }) => { + const result = row.original.tbeEvaluationResult; + const status = row.original.tbeStatus; + + // TBE가 완료되지 않았으면 표시하지 않음 + if (status !== "완료" || !result) { + return <span className="text-xs text-muted-foreground">-</span>; + } + + const resultConfig = { + "Acceptable": { + variant: "success", + icon: <CheckCircle className="h-3 w-3" />, + text: "적합", + color: "bg-green-50 text-green-700 border-green-200" + }, + "Acceptable with Comment": { + variant: "warning", + icon: <AlertCircle className="h-3 w-3" />, + text: "조건부 적합", + color: "bg-yellow-50 text-yellow-700 border-yellow-200" + }, + "Not Acceptable": { + variant: "destructive", + icon: <XCircle className="h-3 w-3" />, + text: "부적합", + color: "bg-red-50 text-red-700 border-red-200" + }, + }[result]; + + return ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger> + <Badge className={cn("text-xs", resultConfig?.color)}> + {resultConfig?.icon} + <span className="ml-1">{resultConfig?.text}</span> + </Badge> + </TooltipTrigger> + <TooltipContent> + <div className="text-xs"> + <p className="font-semibold">{result}</p> + {row.original.conditionalRequirements && ( + <p className="mt-1 text-muted-foreground"> + 조건: {row.original.conditionalRequirements} + </p> + )} + </div> + </TooltipContent> + </Tooltip> + </TooltipProvider> + ); + }, + size: 120, + }, + + { accessorKey: "contractRequirements", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="기본계약 요청" />, cell: ({ row }) => { @@ -785,8 +881,8 @@ export function RfqVendorTable({ // emailSentTo JSON 파싱 let recipients = { to: [], cc: [], sentBy: "" }; try { - if (response?.email?.emailSentTo) { - recipients = JSON.parse(response.email.emailSentTo); + if (response?.emailSentTo) { + recipients = JSON.parse(response.emailSentTo); } } catch (e) { console.error("Failed to parse emailSentTo", e); diff --git a/lib/rfq-last/vendor/send-rfq-dialog.tsx b/lib/rfq-last/vendor/send-rfq-dialog.tsx index ce97dcde..34777864 100644 --- a/lib/rfq-last/vendor/send-rfq-dialog.tsx +++ b/lib/rfq-last/vendor/send-rfq-dialog.tsx @@ -123,13 +123,13 @@ interface Vendor { contactsByPosition?: Record<string, ContactDetail[]>; primaryEmail?: string | null; currency?: string | null; - + // 기본계약 정보 ndaYn?: boolean; generalGtcYn?: boolean; projectGtcYn?: boolean; agreementYn?: boolean; - + // 발송 정보 sendVersion?: number; } @@ -243,15 +243,15 @@ export function SendRfqDialog({ const [showCustomEmailForm, setShowCustomEmailForm] = React.useState<Record<number, boolean>>({}); const [showResendConfirmDialog, setShowResendConfirmDialog] = React.useState(false); const [resendVendorsInfo, setResendVendorsInfo] = React.useState<{ count: number; names: string[] }>({ count: 0, names: [] }); - + const [isGeneratingPdfs, setIsGeneratingPdfs] = React.useState(false); const [pdfGenerationProgress, setPdfGenerationProgress] = React.useState(0); const [currentGeneratingContract, setCurrentGeneratingContract] = React.useState(""); const [generatedPdfs, setGeneratedPdfs] = React.useState<Map<string, { buffer: number[], fileName: string }>>(new Map()); - + // 재전송 시 기본계약 스킵 옵션 - 업체별 관리 const [skipContractsForVendor, setSkipContractsForVendor] = React.useState<Record<number, boolean>>({}); - + const generateContractPdf = async ( vendor: VendorWithRecipients, contractType: string, @@ -288,9 +288,9 @@ export function SendRfqDialog({ // 3. PDFtron WebViewer로 PDF 변환 const pdfBuffer = await convertToPdfWithWebViewer(templateFile, templateData); - + const fileName = `${contractType}_${vendor.vendorCode || vendor.vendorId}_${Date.now()}.pdf`; - + return { buffer: Array.from(pdfBuffer), // Uint8Array를 일반 배열로 변환 fileName @@ -374,7 +374,7 @@ export function SendRfqDialog({ // 초기화 setCustomEmailInputs({}); setShowCustomEmailForm({}); - + // 재전송 업체들의 기본계약 스킵 옵션 초기화 (기본값: false - 재생성) const skipOptions: Record<number, boolean> = {}; selectedVendors.forEach(v => { @@ -511,137 +511,137 @@ export function SendRfqDialog({ const proceedWithSend = React.useCallback(async () => { try { setIsSending(true); - - // 기본계약이 필요한 계약서 목록 수집 - const contractsToGenerate: ContractToGenerate[] = []; - - for (const vendor of vendorsWithRecipients) { - // 재전송 업체이고 해당 업체의 스킵 옵션이 켜져 있으면 계약서 생성 건너뛰기 - const isResendVendor = vendor.sendVersion && vendor.sendVersion > 0; - if (isResendVendor && skipContractsForVendor[vendor.vendorId]) { - continue; // 이 벤더의 계약서 생성을 스킵 - } - - if (vendor.ndaYn) { - contractsToGenerate.push({ - vendorId: vendor.vendorId, - vendorName: vendor.vendorName, - type: "NDA", - templateName: "비밀" - }); - } - if (vendor.generalGtcYn) { - contractsToGenerate.push({ - vendorId: vendor.vendorId, - vendorName: vendor.vendorName, - type: "General_GTC", - templateName: "General GTC" - }); - } - if (vendor.projectGtcYn && rfqInfo?.projectCode) { - contractsToGenerate.push({ - vendorId: vendor.vendorId, - vendorName: vendor.vendorName, - type: "Project_GTC", - templateName: rfqInfo.projectCode - }); - } - if (vendor.agreementYn) { - contractsToGenerate.push({ - vendorId: vendor.vendorId, - vendorName: vendor.vendorName, - type: "기술자료", - templateName: "기술" - }); - } - } - - let pdfsMap = new Map<string, { buffer: number[], fileName: string }>(); - - // PDF 생성이 필요한 경우 - if (contractsToGenerate.length > 0) { - setIsGeneratingPdfs(true); - setPdfGenerationProgress(0); - - try { - let completed = 0; - - for (const contract of contractsToGenerate) { - setCurrentGeneratingContract(`${contract.vendorName} - ${contract.type}`); - - const vendor = vendorsWithRecipients.find(v => v.vendorId === contract.vendorId); - if (!vendor) continue; - - const pdf = await generateContractPdf(vendor, contract.type, contract.templateName); - pdfsMap.set(`${contract.vendorId}_${contract.type}_${contract.templateName}`, pdf); - - completed++; - setPdfGenerationProgress((completed / contractsToGenerate.length) * 100); - - await new Promise(resolve => setTimeout(resolve, 100)); + + // 기본계약이 필요한 계약서 목록 수집 + const contractsToGenerate: ContractToGenerate[] = []; + + for (const vendor of vendorsWithRecipients) { + // 재전송 업체이고 해당 업체의 스킵 옵션이 켜져 있으면 계약서 생성 건너뛰기 + const isResendVendor = vendor.sendVersion && vendor.sendVersion > 0; + if (isResendVendor && skipContractsForVendor[vendor.vendorId]) { + continue; // 이 벤더의 계약서 생성을 스킵 + } + + if (vendor.ndaYn) { + contractsToGenerate.push({ + vendorId: vendor.vendorId, + vendorName: vendor.vendorName, + type: "NDA", + templateName: "비밀" + }); + } + if (vendor.generalGtcYn) { + contractsToGenerate.push({ + vendorId: vendor.vendorId, + vendorName: vendor.vendorName, + type: "General_GTC", + templateName: "General GTC" + }); + } + if (vendor.projectGtcYn && rfqInfo?.projectCode) { + contractsToGenerate.push({ + vendorId: vendor.vendorId, + vendorName: vendor.vendorName, + type: "Project_GTC", + templateName: rfqInfo.projectCode + }); + } + if (vendor.agreementYn) { + contractsToGenerate.push({ + vendorId: vendor.vendorId, + vendorName: vendor.vendorName, + type: "기술자료", + templateName: "기술" + }); + } } - setGeneratedPdfs(pdfsMap); // UI 업데이트용 + let pdfsMap = new Map<string, { buffer: number[], fileName: string }>(); + + // PDF 생성이 필요한 경우 + if (contractsToGenerate.length > 0) { + setIsGeneratingPdfs(true); + setPdfGenerationProgress(0); + + try { + let completed = 0; + + for (const contract of contractsToGenerate) { + setCurrentGeneratingContract(`${contract.vendorName} - ${contract.type}`); + + const vendor = vendorsWithRecipients.find(v => v.vendorId === contract.vendorId); + if (!vendor) continue; + + const pdf = await generateContractPdf(vendor, contract.type, contract.templateName); + pdfsMap.set(`${contract.vendorId}_${contract.type}_${contract.templateName}`, pdf); + + completed++; + setPdfGenerationProgress((completed / contractsToGenerate.length) * 100); + + await new Promise(resolve => setTimeout(resolve, 100)); + } + + setGeneratedPdfs(pdfsMap); // UI 업데이트용 + } catch (error) { + console.error("PDF 생성 실패:", error); + toast.error("기본계약서 생성에 실패했습니다."); + setIsGeneratingPdfs(false); + setPdfGenerationProgress(0); + return; + } + } + + // RFQ 발송 - pdfsMap을 직접 사용 + setIsGeneratingPdfs(false); + setIsSending(true); + + await onSend({ + vendors: vendorsWithRecipients.map(v => ({ + vendorId: v.vendorId, + vendorName: v.vendorName, + vendorCode: v.vendorCode, + vendorCountry: v.vendorCountry, + selectedMainEmail: v.selectedMainEmail, + additionalEmails: v.additionalEmails, + customEmails: v.customEmails.map(c => ({ email: c.email, name: c.name })), + currency: v.currency, + contractRequirements: { + ndaYn: v.ndaYn || false, + generalGtcYn: v.generalGtcYn || false, + projectGtcYn: v.projectGtcYn || false, + agreementYn: v.agreementYn || false, + projectCode: v.projectGtcYn ? rfqInfo?.projectCode : undefined, + }, + isResend: (v.sendVersion || 0) > 0, + sendVersion: v.sendVersion, + contractsSkipped: ((v.sendVersion || 0) > 0) && skipContractsForVendor[v.vendorId], + })), + attachments: selectedAttachments, + message: additionalMessage, + // 생성된 PDF 데이터 추가 + generatedPdfs: Array.from(pdfsMap.entries()).map(([key, data]) => ({ + key, + ...data + })), + }); + + toast.success( + `${vendorsWithRecipients.length}개 업체에 RFQ를 발송했습니다.` + + (contractsToGenerate.length > 0 ? ` ${contractsToGenerate.length}개의 기본계약서가 포함되었습니다.` : '') + ); + onOpenChange(false); + } catch (error) { - console.error("PDF 생성 실패:", error); - toast.error("기본계약서 생성에 실패했습니다."); + console.error("RFQ 발송 실패:", error); + toast.error("RFQ 발송에 실패했습니다."); + } finally { + setIsSending(false); setIsGeneratingPdfs(false); setPdfGenerationProgress(0); - return; + setCurrentGeneratingContract(""); + setSkipContractsForVendor({}); // 초기화 } - } - - // RFQ 발송 - pdfsMap을 직접 사용 - setIsGeneratingPdfs(false); - setIsSending(true); - - await onSend({ - vendors: vendorsWithRecipients.map(v => ({ - vendorId: v.vendorId, - vendorName: v.vendorName, - vendorCode: v.vendorCode, - vendorCountry: v.vendorCountry, - selectedMainEmail: v.selectedMainEmail, - additionalEmails: v.additionalEmails, - customEmails: v.customEmails.map(c => ({ email: c.email, name: c.name })), - currency: v.currency, - contractRequirements: { - ndaYn: v.ndaYn || false, - generalGtcYn: v.generalGtcYn || false, - projectGtcYn: v.projectGtcYn || false, - agreementYn: v.agreementYn || false, - projectCode: v.projectGtcYn ? rfqInfo?.projectCode : undefined, - }, - isResend: (v.sendVersion || 0) > 0, - sendVersion: v.sendVersion, - contractsSkipped: ((v.sendVersion || 0) > 0) && skipContractsForVendor[v.vendorId], - })), - attachments: selectedAttachments, - message: additionalMessage, - // 생성된 PDF 데이터 추가 - generatedPdfs: Array.from(pdfsMap.entries()).map(([key, data]) => ({ - key, - ...data - })), - }); - - toast.success( - `${vendorsWithRecipients.length}개 업체에 RFQ를 발송했습니다.` + - (contractsToGenerate.length > 0 ? ` ${contractsToGenerate.length}개의 기본계약서가 포함되었습니다.` : '') - ); - onOpenChange(false); - - } catch (error) { - console.error("RFQ 발송 실패:", error); - toast.error("RFQ 발송에 실패했습니다."); - } finally { - setIsSending(false); - setIsGeneratingPdfs(false); - setPdfGenerationProgress(0); - setCurrentGeneratingContract(""); - setSkipContractsForVendor({}); // 초기화 - } -}, [vendorsWithRecipients, selectedAttachments, additionalMessage, onSend, onOpenChange, rfqInfo, skipContractsForVendor]); + }, [vendorsWithRecipients, selectedAttachments, additionalMessage, onSend, onOpenChange, rfqInfo, skipContractsForVendor]); // 전송 처리 const handleSend = async () => { @@ -712,7 +712,7 @@ export function SendRfqDialog({ <li>업체는 새로운 버전의 견적서를 작성해야 합니다.</li> <li>이전에 제출한 견적서는 더 이상 유효하지 않습니다.</li> </ul> - + {/* 기본계약 재발송 정보 */} <div className="mt-3 pt-3 border-t border-yellow-400"> <div className="space-y-2"> @@ -836,8 +836,8 @@ export function SendRfqDialog({ setSkipContractsForVendor(newSkipOptions); }} > - {Object.values(skipContractsForVendor).every(v => v) ? "전체 재생성" : - Object.values(skipContractsForVendor).every(v => !v) ? "전체 유지" : "전체 유지"} + {Object.values(skipContractsForVendor).every(v => v) ? "전체 재생성" : + Object.values(skipContractsForVendor).every(v => !v) ? "전체 유지" : "전체 유지"} </Button> </TooltipTrigger> <TooltipContent> @@ -993,7 +993,7 @@ export function SendRfqDialog({ [vendor.vendorId]: !checked })); }} - // className="data-[state=checked]:bg-orange-600 data-[state=checked]:border-orange-600" + // className="data-[state=checked]:bg-orange-600 data-[state=checked]:border-orange-600" /> <span className="text-xs"> {skipContractsForVendor[vendor.vendorId] ? "유지" : "재생성"} @@ -1002,8 +1002,8 @@ export function SendRfqDialog({ </TooltipTrigger> <TooltipContent> <p className="text-xs"> - {skipContractsForVendor[vendor.vendorId] - ? "기존 계약서를 그대로 유지합니다" + {skipContractsForVendor[vendor.vendorId] + ? "기존 계약서를 그대로 유지합니다" : "기존 계약서를 삭제하고 새로 생성합니다"} </p> </TooltipContent> @@ -1306,9 +1306,9 @@ export function SendRfqDialog({ onChange={(e) => setAdditionalMessage(e.target.value)} /> </div> - - {/* PDF 생성 진행 상황 표시 */} - {isGeneratingPdfs && ( + + {/* PDF 생성 진행 상황 표시 */} + {isGeneratingPdfs && ( <Alert className="border-blue-500 bg-blue-50"> <div className="space-y-3"> <div className="flex items-center gap-2"> @@ -1327,8 +1327,8 @@ export function SendRfqDialog({ </div> </Alert> )} - - + + </div> </div> @@ -1371,7 +1371,7 @@ export function SendRfqDialog({ </Button> </DialogFooter> </DialogContent> - + {/* 재발송 확인 다이얼로그 */} <AlertDialog open={showResendConfirmDialog} onOpenChange={setShowResendConfirmDialog}> <AlertDialogContent className="max-w-2xl"> @@ -1385,7 +1385,7 @@ export function SendRfqDialog({ <p className="text-sm"> <span className="font-semibold text-yellow-700">{resendVendorsInfo.count}개 업체</span>가 재발송 대상입니다. </p> - + {/* 재발송 대상 업체 목록 및 계약서 설정 */} <div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3"> <p className="text-sm font-medium text-yellow-800 mb-3">재발송 대상 업체 및 계약서 설정:</p> @@ -1398,7 +1398,7 @@ export function SendRfqDialog({ if (vendor.generalGtcYn) contracts.push("General GTC"); if (vendor.projectGtcYn) contracts.push("Project GTC"); if (vendor.agreementYn) contracts.push("기술자료"); - + return ( <div key={vendor.vendorId} className="flex items-center justify-between p-2 bg-white rounded border border-yellow-100"> <div className="flex items-center gap-3"> @@ -1422,7 +1422,7 @@ export function SendRfqDialog({ [vendor.vendorId]: !checked })); }} - // className="data-[state=checked]:bg-orange-600 data-[state=checked]:border-orange-600" + // className="data-[state=checked]:bg-orange-600 data-[state=checked]:border-orange-600" /> <span className="text-xs text-yellow-800"> {skipContractsForVendor[vendor.vendorId] ? "계약서 유지" : "계약서 재생성"} @@ -1433,43 +1433,43 @@ export function SendRfqDialog({ ); })} </div> - + {/* 전체 선택 버튼 */} - {vendorsWithRecipients.some(v => v.sendVersion && v.sendVersion > 0 && + {vendorsWithRecipients.some(v => v.sendVersion && v.sendVersion > 0 && (v.ndaYn || v.generalGtcYn || v.projectGtcYn || v.agreementYn)) && ( - <div className="mt-3 pt-3 border-t border-yellow-300 flex justify-end gap-2"> - <Button - variant="outline" - size="sm" - onClick={() => { - const resendVendors = vendorsWithRecipients.filter(v => v.sendVersion && v.sendVersion > 0); - const newSkipOptions: Record<number, boolean> = {}; - resendVendors.forEach(v => { - newSkipOptions[v.vendorId] = true; // 모두 유지 - }); - setSkipContractsForVendor(newSkipOptions); - }} - > - 전체 계약서 유지 - </Button> - <Button - variant="outline" - size="sm" - onClick={() => { - const resendVendors = vendorsWithRecipients.filter(v => v.sendVersion && v.sendVersion > 0); - const newSkipOptions: Record<number, boolean> = {}; - resendVendors.forEach(v => { - newSkipOptions[v.vendorId] = false; // 모두 재생성 - }); - setSkipContractsForVendor(newSkipOptions); - }} - > - 전체 계약서 재생성 - </Button> - </div> - )} + <div className="mt-3 pt-3 border-t border-yellow-300 flex justify-end gap-2"> + <Button + variant="outline" + size="sm" + onClick={() => { + const resendVendors = vendorsWithRecipients.filter(v => v.sendVersion && v.sendVersion > 0); + const newSkipOptions: Record<number, boolean> = {}; + resendVendors.forEach(v => { + newSkipOptions[v.vendorId] = true; // 모두 유지 + }); + setSkipContractsForVendor(newSkipOptions); + }} + > + 전체 계약서 유지 + </Button> + <Button + variant="outline" + size="sm" + onClick={() => { + const resendVendors = vendorsWithRecipients.filter(v => v.sendVersion && v.sendVersion > 0); + const newSkipOptions: Record<number, boolean> = {}; + resendVendors.forEach(v => { + newSkipOptions[v.vendorId] = false; // 모두 재생성 + }); + setSkipContractsForVendor(newSkipOptions); + }} + > + 전체 계약서 재생성 + </Button> + </div> + )} </div> - + {/* 경고 메시지 */} <Alert className="border-red-200 bg-red-50"> <AlertCircle className="h-4 w-4 text-red-600" /> @@ -1479,17 +1479,17 @@ export function SendRfqDialog({ <li>기존에 작성된 견적 데이터가 <strong>모두 초기화</strong>됩니다.</li> <li>업체는 처음부터 새로 견적서를 작성해야 합니다.</li> <li>이전에 제출한 견적서는 더 이상 유효하지 않습니다.</li> - {Object.entries(skipContractsForVendor).some(([vendorId, skip]) => !skip && + {Object.entries(skipContractsForVendor).some(([vendorId, skip]) => !skip && vendorsWithRecipients.find(v => v.vendorId === Number(vendorId))) && ( - <li className="text-orange-700 font-medium"> - ⚠️ 선택한 업체의 기존 기본계약서가 <strong>삭제</strong>되고 새로운 계약서가 발송됩니다. - </li> - )} + <li className="text-orange-700 font-medium"> + ⚠️ 선택한 업체의 기존 기본계약서가 <strong>삭제</strong>되고 새로운 계약서가 발송됩니다. + </li> + )} <li>이 작업은 <strong>취소할 수 없습니다</strong>.</li> </ul> </AlertDescription> </Alert> - + <p className="text-sm text-muted-foreground"> 재발송을 진행하시겠습니까? </p> @@ -1503,7 +1503,7 @@ export function SendRfqDialog({ }}> 취소 </AlertDialogCancel> - <AlertDialogAction + <AlertDialogAction onClick={() => { setShowResendConfirmDialog(false); proceedWithSend(); diff --git a/lib/shi-signature/buyer-signature.ts b/lib/shi-signature/buyer-signature.ts new file mode 100644 index 00000000..d464ae54 --- /dev/null +++ b/lib/shi-signature/buyer-signature.ts @@ -0,0 +1,186 @@ +'use server'; + +import db from '@/db/db'; +import { buyerSignatures } from '@/db/schema'; +import { eq } from 'drizzle-orm'; +import { revalidatePath } from 'next/cache'; +import { writeFile, mkdir } from 'fs/promises'; +import path from 'path'; +import { v4 as uuidv4 } from 'uuid'; + +export async function uploadBuyerSignature(formData: FormData) { + try { + const file = formData.get('file') as File; + if (!file) { + return { success: false, error: '파일이 없습니다.' }; + } + + // 파일 크기 체크 (5MB) + if (file.size > 5 * 1024 * 1024) { + return { success: false, error: '파일 크기는 5MB 이하여야 합니다.' }; + } + + // 파일 타입 체크 + if (!file.type.startsWith('image/')) { + return { success: false, error: '이미지 파일만 업로드 가능합니다.' }; + } + + const bytes = await file.arrayBuffer(); + const buffer = Buffer.from(bytes); + + // Base64 변환 + const base64 = `data:${file.type};base64,${buffer.toString('base64')}`; + + // 파일 저장 경로 + const fileName = `${uuidv4()}-${file.name}`; + const uploadDir = path.join(process.cwd(), 'public', 'uploads', 'signatures'); + + // 디렉토리 생성 + await mkdir(uploadDir, { recursive: true }); + + const filePath = path.join(uploadDir, fileName); + await writeFile(filePath, buffer); + + // 기존 활성 서명 비활성화 + await db.update(buyerSignatures) + .set({ isActive: false }) + .where(eq(buyerSignatures.isActive, true)); + + // 새 서명 저장 + const [newSignature] = await db.insert(buyerSignatures) + .values({ + name: '삼성중공업', + imageUrl: `/uploads/signatures/${fileName}`, + dataUrl: base64, + mimeType: file.type, + fileSize: file.size, + isActive: true, + }) + .returning(); + + revalidatePath('/admin/buyer-signature'); + + return { success: true, signature: newSignature }; + } catch (error) { + console.error('서명 업로드 실패:', error); + return { success: false, error: '서명 업로드에 실패했습니다.' }; + } +} + +export async function getActiveSignature() { + try { + const [signature] = await db + .select() + .from(buyerSignatures) + .where(eq(buyerSignatures.isActive, true)) + .limit(1); + + return signature; + } catch (error) { + console.error('활성 서명 조회 실패:', error); + return null; + } +} + +export async function getAllSignatures() { + try { + const signatures = await db + .select() + .from(buyerSignatures) + .orderBy(buyerSignatures.createdAt); + + return signatures; + } catch (error) { + console.error('서명 목록 조회 실패:', error); + return []; + } +} + +export async function setActiveSignature(id: number) { + try { + // 모든 서명 비활성화 + await db.update(buyerSignatures) + .set({ isActive: false }) + .where(eq(buyerSignatures.isActive, true)); + + // 선택한 서명 활성화 + await db.update(buyerSignatures) + .set({ isActive: true }) + .where(eq(buyerSignatures.id, id)); + + revalidatePath('/admin/buyer-signature'); + + return { success: true }; + } catch (error) { + console.error('활성 서명 설정 실패:', error); + return { success: false, error: '활성 서명 설정에 실패했습니다.' }; + } +} + +export async function deleteSignature(id: number) { + try { + await db.delete(buyerSignatures) + .where(eq(buyerSignatures.id, id)); + + revalidatePath('/admin/buyer-signature'); + + return { success: true }; + } catch (error) { + console.error('서명 삭제 실패:', error); + return { success: false, error: '서명 삭제에 실패했습니다.' }; + } +} + + +// 클라이언트에서 직접 호출할 수 있는 서버 액션 +export async function getBuyerSignatureFile() { + try { + const [signature] = await db + .select() + .from(buyerSignatures) + .where(eq(buyerSignatures.isActive, true)) + .limit(1); + + if (!signature || !signature.dataUrl) { + console.log('활성화된 구매자 서명이 없습니다.'); + return null; + } + + return { + data: { + dataUrl: signature.dataUrl, + imageUrl: signature.imageUrl, + mimeType: signature.mimeType + } + }; + } catch (error) { + console.error('구매자 서명 조회 실패:', error); + return null; + } +} + +// 대체 서명이나 기본 서명이 필요한 경우 +export async function getBuyerSignatureFileWithFallback() { + try { + // 먼저 DB에서 활성 서명 조회 + const signature = await getBuyerSignatureFile(); + + if (signature) { + return signature; + } + + // DB에 서명이 없으면 기본 서명 반환 (선택사항) + const defaultSignature = { + data: { + dataUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', // 1x1 투명 픽셀 또는 실제 기본 서명 + imageUrl: '/images/default-buyer-signature.png', + mimeType: 'image/png' + } + }; + + return defaultSignature; + } catch (error) { + console.error('서명 조회 실패 (fallback 포함):', error); + return null; + } +}
\ No newline at end of file diff --git a/lib/shi-signature/signature-list.tsx b/lib/shi-signature/signature-list.tsx new file mode 100644 index 00000000..93cd3dbe --- /dev/null +++ b/lib/shi-signature/signature-list.tsx @@ -0,0 +1,149 @@ +'use client'; + +import { useState } from 'react'; +import { BuyerSignature } from '@/db/schemae'; +import { setActiveSignature, deleteSignature } from './buyer-signature'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Trash2, CheckCircle, Circle,Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; + +interface SignatureListProps { + signatures: BuyerSignature[]; +} + +export function SignatureList({ signatures }: SignatureListProps) { + const [isUpdating, setIsUpdating] = useState<number | null>(null); + + const handleSetActive = async (id: number) => { + setIsUpdating(id); + try { + const result = await setActiveSignature(id); + if (result.success) { + toast.success('활성 서명이 변경되었습니다.'); + } else { + toast.error(result.error || '변경에 실패했습니다.'); + } + } catch (error) { + toast.error('오류가 발생했습니다.'); + } finally { + setIsUpdating(null); + } + }; + + const handleDelete = async (id: number) => { + try { + const result = await deleteSignature(id); + if (result.success) { + toast.success('서명이 삭제되었습니다.'); + } else { + toast.error(result.error || '삭제에 실패했습니다.'); + } + } catch (error) { + toast.error('오류가 발생했습니다.'); + } + }; + + if (signatures.length === 0) { + return ( + <Card> + <CardContent className="py-8 text-center text-muted-foreground"> + 아직 업로드된 서명이 없습니다. + </CardContent> + </Card> + ); + } + + return ( + <Card> + <CardHeader> + <CardTitle>서명 목록</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + {signatures.map((signature) => ( + <div + key={signature.id} + className="flex items-center justify-between p-4 border rounded-lg" + > + <div className="flex items-center space-x-4"> + <img + src={signature.imageUrl} + alt="서명" + className="h-12 object-contain border rounded p-1" + /> + <div> + <div className="flex items-center space-x-2"> + <span className="font-medium">{signature.name}</span> + {signature.isActive && ( + <Badge variant="default" className="text-xs"> + 활성 + </Badge> + )} + </div> + <p className="text-sm text-muted-foreground"> + {new Date(signature.createdAt).toLocaleDateString()} + </p> + </div> + </div> + + <div className="flex items-center space-x-2"> + {!signature.isActive && ( + <Button + variant="outline" + size="sm" + onClick={() => handleSetActive(signature.id)} + disabled={isUpdating === signature.id} + > + {isUpdating === signature.id ? ( + <Loader2 className="h-4 w-4 animate-spin" /> + ) : ( + <Circle className="h-4 w-4" /> + )} + <span className="ml-2">활성화</span> + </Button> + )} + + <AlertDialog> + <AlertDialogTrigger asChild> + <Button + variant="outline" + size="sm" + disabled={signature.isActive} + > + <Trash2 className="h-4 w-4" /> + </Button> + </AlertDialogTrigger> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>서명 삭제</AlertDialogTitle> + <AlertDialogDescription> + 이 서명을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다. + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>취소</AlertDialogCancel> + <AlertDialogAction onClick={() => handleDelete(signature.id)}> + 삭제 + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </div> + </div> + ))} + </CardContent> + </Card> + ); +}
\ No newline at end of file diff --git a/lib/shi-signature/upload-form.tsx b/lib/shi-signature/upload-form.tsx new file mode 100644 index 00000000..642cd1a5 --- /dev/null +++ b/lib/shi-signature/upload-form.tsx @@ -0,0 +1,115 @@ +'use client'; + +import { useState } from 'react'; +import { uploadBuyerSignature } from './buyer-signature'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Upload, Loader2, CheckCircle } from 'lucide-react'; +import { toast } from 'sonner'; + +export function BuyerSignatureUploadForm() { + const [isUploading, setIsUploading] = useState(false); + const [preview, setPreview] = useState<string | null>(null); + + const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const file = e.target.files?.[0]; + if (file) { + const reader = new FileReader(); + reader.onloadend = () => { + setPreview(reader.result as string); + }; + reader.readAsDataURL(file); + } + }; + + const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { + e.preventDefault(); + setIsUploading(true); + + const formData = new FormData(e.currentTarget); + + try { + const result = await uploadBuyerSignature(formData); + + if (result.success) { + toast.success('서명이 성공적으로 업로드되었습니다.'); + setPreview(null); + (e.target as HTMLFormElement).reset(); + } else { + toast.error(result.error || '업로드에 실패했습니다.'); + } + } catch (error) { + toast.error('업로드 중 오류가 발생했습니다.'); + } finally { + setIsUploading(false); + } + }; + + return ( + <Card> + <CardHeader> + <CardTitle>구매자 서명 업로드</CardTitle> + <CardDescription> + 삼성중공업 서명 이미지를 업로드하세요. 이 서명은 계약서에 자동으로 적용됩니다. + </CardDescription> + </CardHeader> + <CardContent> + <form onSubmit={handleSubmit} className="space-y-4"> + <div className="space-y-2"> + <Label htmlFor="file">서명 이미지</Label> + <Input + id="file" + name="file" + type="file" + accept="image/*" + onChange={handleFileChange} + required + disabled={isUploading} + /> + <p className="text-sm text-muted-foreground"> + PNG, JPG, JPEG 형식 (최대 5MB) + </p> + </div> + + {preview && ( + <div className="border rounded-lg p-4 bg-gray-50"> + <Label className="text-sm font-medium mb-2 block">미리보기</Label> + <img + src={preview} + alt="서명 미리보기" + className="max-h-32 object-contain" + /> + </div> + )} + + <Alert> + <AlertDescription> + 업로드한 서명은 즉시 활성화되며, 새로운 계약서에 자동으로 적용됩니다. + </AlertDescription> + </Alert> + + <Button + type="submit" + disabled={isUploading || !preview} + className="w-full" + > + {isUploading ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 업로드 중... + </> + ) : ( + <> + <Upload className="mr-2 h-4 w-4" /> + 서명 업로드 + </> + )} + </Button> + </form> + </CardContent> + </Card> + ); +}
\ No newline at end of file diff --git a/lib/tbe-last/service.ts b/lib/tbe-last/service.ts index 32d5a5f5..34c274f5 100644 --- a/lib/tbe-last/service.ts +++ b/lib/tbe-last/service.ts @@ -1,7 +1,7 @@ // lib/tbe-last/service.ts 'use server' -import { unstable_cache } from "next/cache"; +import { revalidatePath, unstable_cache } from "next/cache"; 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"; @@ -547,4 +547,181 @@ function getReviewStatusClass(status?: string): string { default: return "unreviewed" } -}
\ No newline at end of file +} + + +interface RfqInfo { + rfqCode: string; + rfqTitle: string; + rfqDueDate: Date | null; + projectCode: string; + projectName: string; + packageNo: string; + packageName: string; + picName: string; + rfqId: number; // rfqLastId 추가 +} + +interface VendorInfo { + sessionId: number; + vendorId: number; // vendor ID 추가 + vendorCode: string; + vendorName: string; +} + +export async function requestTBEForRFQ( + rfqInfo: RfqInfo, + vendors: VendorInfo[] +) { + try { + const session = await getServerSession(authOptions) + if (!session?.user) { + return { success: false, error: "인증이 필요합니다" } + } + + // 벤더별 이메일 정보 조회 + const vendorEmails = await db + .select({ + vendorId: rfqLastDetails.vendorsId, + emailSentTo: rfqLastDetails.emailSentTo, + }) + .from(rfqLastDetails) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqInfo.rfqId), + inArray(rfqLastDetails.vendorsId, vendors.map(v => v.vendorId)), + eq(rfqLastDetails.isLatest, true) + ) + ); + + // 이메일 정보 매핑 + const vendorEmailMap = new Map(); + vendorEmails.forEach(ve => { + if (ve.emailSentTo) { + try { + const emailData = JSON.parse(ve.emailSentTo); + vendorEmailMap.set(ve.vendorId, emailData); + } catch (error) { + console.error(`이메일 파싱 실패 - vendorId: ${ve.vendorId}`, error); + } + } + }); + + // 1. 트랜잭션으로 모든 세션 상태 업데이트 + await db.transaction(async (tx) => { + // 세션 상태 업데이트 + const sessionIds = vendors.map(v => v.sessionId); + + await tx + .update(rfqLastTbeSessions) + .set({ + status: "진행중", + updatedAt: new Date(), + }) + .where(inArray(rfqLastTbeSessions.id, sessionIds)); + }); + + // 2. 각 벤더에게 이메일 발송 + const emailPromises = vendors.map(async (vendor) => { + const emailInfo = vendorEmailMap.get(vendor.vendorId); + + if (!emailInfo) { + console.warn(`벤더 ${vendor.vendorName}의 이메일 정보가 없습니다.`); + return { success: false, vendor: vendor.vendorName, error: "이메일 정보 없음" }; + } + + try { + // to와 cc 이메일 추출 + const toEmails = Array.isArray(emailInfo.to) ? emailInfo.to : [emailInfo.to]; + const ccEmails = Array.isArray(emailInfo.cc) ? emailInfo.cc : []; + + // 모든 to 이메일 주소로 발송 + const emailResults = await Promise.all( + toEmails.filter(Boolean).map(toEmail => + sendEmail({ + to: toEmail, + template: "tbe-request", + subject: `[TBE 요청] ${rfqInfo.rfqCode} - 기술입찰평가 서류 제출 요청`, + context: { + vendorName: vendor.vendorName, + vendorCode: vendor.vendorCode, + rfqCode: rfqInfo.rfqCode, + rfqTitle: rfqInfo.rfqTitle, + rfqDueDate: rfqInfo.rfqDueDate ? + new Date(rfqInfo.rfqDueDate).toLocaleDateString("ko-KR") : + "미정", + projectCode: rfqInfo.projectCode, + projectName: rfqInfo.projectName, + packageNo: rfqInfo.packageNo, + packageName: rfqInfo.packageName, + picName: rfqInfo.picName, + picEmail: session.user.email, + picPhone: process.env.DEFAULT_PIC_PHONE || "", + tbeDeadline: calculateTBEDeadline(rfqInfo.rfqDueDate), + companyName: process.env.COMPANY_NAME || "Your Company", + }, + cc: [ + ...ccEmails.filter(Boolean) + ], + }) + ) + ); + + return { + success: true, + vendor: vendor.vendorName, + emailsSent: emailResults.length + }; + + } catch (error) { + console.error(`이메일 발송 실패 - ${vendor.vendorName}:`, error); + return { success: false, vendor: vendor.vendorName, error }; + } + }); + + const emailResults = await Promise.allSettled(emailPromises); + + // 3. 결과 확인 + const successResults = emailResults.filter( + r => r.status === "fulfilled" && r.value?.success + ); + const failedResults = emailResults.filter( + r => r.status === "rejected" || (r.status === "fulfilled" && !r.value?.success) + ); + + if (failedResults.length > 0) { + console.warn(`${failedResults.length}개 벤더의 이메일 발송 실패`); + } + + revalidatePath("/evcp/tbe-last"); + + return { + success: true, + message: `${successResults.length}개 벤더에 TBE 요청 완료`, + emailsSent: successResults.length, + emailsFailed: failedResults.length + }; + + } catch (error) { + console.error("TBE 요청 실패:", error); + return { + success: false, + error: error instanceof Error ? error.message : "TBE 요청 중 오류가 발생했습니다." + }; + } +} + +// TBE 제출 기한 계산 (RFQ 마감일 7일 전) +function calculateTBEDeadline(rfqDueDate: Date | null): string { + if (!rfqDueDate) return "추후 공지"; + + const deadline = new Date(rfqDueDate); + deadline.setDate(deadline.getDate() - 7); // 7일 전 + + return deadline.toLocaleDateString("ko-KR", { + year: "numeric", + month: "long", + day: "numeric", + }); +} + diff --git a/lib/tbe-last/table/tbe-last-table-columns.tsx b/lib/tbe-last/table/tbe-last-table-columns.tsx index 726d8925..b18e51c0 100644 --- a/lib/tbe-last/table/tbe-last-table-columns.tsx +++ b/lib/tbe-last/table/tbe-last-table-columns.tsx @@ -86,6 +86,55 @@ export function getColumns({ cell: ({ row }) => row.original.rfqCode, size: 120, }, + + { + id: "tbeRequired", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="TBE 필요" /> + ), + cell: ({ row, table }) => { + const rfqCode = row.original.rfqCode; + const sessionStatus = row.original.sessionStatus; + + // 같은 RFQ의 첫 번째 행에만 체크박스 표시 + const allRows = table.getRowModel().rows; + const isFirstInGroup = allRows.findIndex( + r => r.original.rfqCode === rfqCode + ) === allRows.indexOf(row); + + if (!isFirstInGroup) return null; + + // 같은 RFQ의 모든 row + const rfqRows = allRows.filter( + r => r.original.rfqCode === rfqCode + ); + + const vendorCount = rfqRows.length; + + // 같은 RFQ의 row들이 선택되었는지 확인 + const isChecked = rfqRows.every(r => r.getIsSelected()); + const isIndeterminate = rfqRows.some(r => r.getIsSelected()) && !isChecked; + + return ( + <div className="flex items-center gap-2"> + <Checkbox + checked={isChecked} + indeterminate={isIndeterminate} + onCheckedChange={(checked) => { + // RFQ의 모든 벤더 선택/해제 + rfqRows.forEach(r => { + r.toggleSelected(!!checked); + }); + }} + /> + <span className="text-xs text-muted-foreground"> + ({vendorCount} vendors) + </span> + </div> + ); + }, + size: 120, + }, // RFQ Title { diff --git a/lib/tbe-last/table/tbe-last-table.tsx b/lib/tbe-last/table/tbe-last-table.tsx index a9328bdf..c18768cd 100644 --- a/lib/tbe-last/table/tbe-last-table.tsx +++ b/lib/tbe-last/table/tbe-last-table.tsx @@ -10,7 +10,7 @@ import { DataTable } from "@/components/data-table/data-table" import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" import { getColumns } from "./tbe-last-table-columns" import { TbeLastView } from "@/db/schema" -import { getAllTBELast, getTBESessionDetail } from "@/lib/tbe-last/service" +import { getAllTBELast, getTBESessionDetail, requestTBEForRFQ } from "@/lib/tbe-last/service" import { Button } from "@/components/ui/button" import { Download, RefreshCw } from "lucide-react" import { exportTableToExcel } from "@/lib/export" @@ -20,6 +20,7 @@ import { SessionDetailDialog } from "./session-detail-dialog" import { DocumentsSheet } from "./documents-sheet" import { PrItemsDialog } from "./pr-items-dialog" import { EvaluationDialog } from "./evaluation-dialog" +import { toast } from "sonner" interface TbeLastTableProps { promises: Promise<[ @@ -30,21 +31,21 @@ interface TbeLastTableProps { export function TbeLastTable({ promises }: TbeLastTableProps) { const router = useRouter() const [{ data, pageCount }] = React.use(promises) - - console.log(data,"data") + + console.log(data, "data") // Dialog states const [sessionDetailOpen, setSessionDetailOpen] = React.useState(false) const [documentsOpen, setDocumentsOpen] = React.useState(false) const [prItemsOpen, setPrItemsOpen] = React.useState(false) const [evaluationOpen, setEvaluationOpen] = React.useState(false) - + const [selectedSessionId, setSelectedSessionId] = React.useState<number | null>(null) const [selectedRfqId, setSelectedRfqId] = React.useState<number | null>(null) const [selectedSession, setSelectedSession] = React.useState<TbeLastView | null>(null) const [sessionDetail, setSessionDetail] = React.useState<any>(null) const [isLoadingDetail, setIsLoadingDetail] = React.useState(false) - + // Load session detail when needed const loadSessionDetail = React.useCallback(async (sessionId: number) => { setIsLoadingDetail(true) @@ -57,37 +58,37 @@ export function TbeLastTable({ promises }: TbeLastTableProps) { setIsLoadingDetail(false) } }, []) - + // Handlers const handleOpenSessionDetail = React.useCallback((sessionId: number) => { setSelectedSessionId(sessionId) setSessionDetailOpen(true) loadSessionDetail(sessionId) }, [loadSessionDetail]) - + const handleOpenDocuments = React.useCallback((sessionId: number) => { setSelectedSessionId(sessionId) setDocumentsOpen(true) loadSessionDetail(sessionId) }, [loadSessionDetail]) - + const handleOpenPrItems = React.useCallback((rfqId: number) => { setSelectedRfqId(rfqId) setPrItemsOpen(true) loadSessionDetail(rfqId) }, [loadSessionDetail]) - + const handleOpenEvaluation = React.useCallback((session: TbeLastView) => { setSelectedSession(session) setEvaluationOpen(true) loadSessionDetail(session.rfqId) }, []) - + const handleRefresh = React.useCallback(() => { router.refresh() }, [router]) - + // Table columns const columns = React.useMemo( () => @@ -99,7 +100,7 @@ export function TbeLastTable({ promises }: TbeLastTableProps) { }), [handleOpenSessionDetail, handleOpenDocuments, handleOpenPrItems, handleOpenEvaluation] ) - + // Filter fields const filterFields: DataTableAdvancedFilterField<TbeLastView>[] = [ { @@ -125,7 +126,7 @@ export function TbeLastTable({ promises }: TbeLastTableProps) { ], }, ] - + // Data table const { table } = useDataTable({ data, @@ -142,7 +143,64 @@ export function TbeLastTable({ promises }: TbeLastTableProps) { shallow: false, clearOnDefault: true, }) - + + const handleBulkTBERequest = React.useCallback(async (rfqGroups: Map<string, TbeLastView[]>) => { + try { + const promises = Array.from(rfqGroups.entries()).map(async ([rfqCode, sessions]) => { + // 준비중 상태인 세션만 필터링 + const pendingSessions = sessions.filter(s => s.sessionStatus === "준비중"); + + if (pendingSessions.length === 0) { + toast.info(`RFQ ${rfqCode}: 이미 TBE가 요청되었습니다.`); + return null; + } + + const vendors = pendingSessions.map(session => ({ + sessionId: session.tbeSessionId, + vendorId: session.vendorId, // vendor ID 추가 + vendorCode: session.vendorCode, + vendorName: session.vendorName, + })); + + const rfqInfo = { + rfqId: sessions[0].rfqId, // rfqLastId 추가 + rfqCode: sessions[0].rfqCode, + rfqTitle: sessions[0].rfqTitle || "", + rfqDueDate: sessions[0].rfqDueDate, + projectCode: sessions[0].projectCode || "", + projectName: sessions[0].projectName || "", + packageNo: sessions[0].packageNo || "", + packageName: sessions[0].packageName || "", + picName: sessions[0].picName || "", + }; + + return requestTBEForRFQ(rfqInfo, vendors); + }); + + const results = await Promise.allSettled(promises); + + const successCount = results.filter(r => r.status === "fulfilled" && r.value?.success).length; + const failCount = results.filter(r => r.status === "rejected" || (r.status === "fulfilled" && !r.value?.success)).length; + + if (successCount > 0) { + toast.success(`${successCount}개 RFQ에 대한 TBE 요청이 완료되었습니다.`); + } + + if (failCount > 0) { + toast.error(`${failCount}개 RFQ에 대한 TBE 요청이 실패했습니다.`); + } + + // 테이블 새로고침 + router.refresh(); + table.resetRowSelection(); + + } catch (error) { + console.error("TBE 요청 처리 중 오류:", error); + toast.error("TBE 요청 처리 중 오류가 발생했습니다."); + } + }, [router, table]); + + return ( <> <DataTable table={table}> @@ -152,6 +210,30 @@ export function TbeLastTable({ promises }: TbeLastTableProps) { shallow={false} > <div className="flex items-center gap-2"> + + {table.getFilteredSelectedRowModel().rows.length > 0 && ( + <Button + variant="default" + size="sm" + onClick={() => { + const selectedRows = table.getFilteredSelectedRowModel().rows; + const rfqGroups = new Map(); + + // RFQ별로 그룹핑 + selectedRows.forEach(row => { + const rfqCode = row.original.rfqCode; + if (!rfqGroups.has(rfqCode)) { + rfqGroups.set(rfqCode, []); + } + rfqGroups.get(rfqCode).push(row.original); + }); + + handleBulkTBERequest(rfqGroups); + }} + > + 선택된 항목 TBE 요청 ({table.getFilteredSelectedRowModel().rows.length}) + </Button> + )} <Button variant="outline" size="sm" @@ -178,7 +260,7 @@ export function TbeLastTable({ promises }: TbeLastTableProps) { </div> </DataTableAdvancedToolbar> </DataTable> - + {/* Session Detail Dialog */} <SessionDetailDialog open={sessionDetailOpen} @@ -186,7 +268,7 @@ export function TbeLastTable({ promises }: TbeLastTableProps) { sessionDetail={sessionDetail} isLoading={isLoadingDetail} /> - + {/* Documents Sheet */} <DocumentsSheet open={documentsOpen} @@ -194,7 +276,7 @@ export function TbeLastTable({ promises }: TbeLastTableProps) { sessionDetail={sessionDetail} isLoading={isLoadingDetail} /> - + {/* PR Items Dialog */} <PrItemsDialog open={prItemsOpen} @@ -202,7 +284,7 @@ export function TbeLastTable({ promises }: TbeLastTableProps) { sessionDetail={sessionDetail} isLoading={isLoadingDetail} /> - + {/* Evaluation Dialog */} <EvaluationDialog open={evaluationOpen} diff --git a/lib/tbe-last/vendor-tbe-service.ts b/lib/tbe-last/vendor-tbe-service.ts index 8335eb4f..858a5817 100644 --- a/lib/tbe-last/vendor-tbe-service.ts +++ b/lib/tbe-last/vendor-tbe-service.ts @@ -4,7 +4,7 @@ import { unstable_cache } from "next/cache" import db from "@/db/db" -import { and, desc, asc, eq, sql, or } from "drizzle-orm" +import { and, desc, asc, eq, sql, ne } from "drizzle-orm" import { tbeLastView, rfqLastTbeSessions } from "@/db/schema" import { rfqPrItems } from "@/db/schema/rfqLast" import { getServerSession } from "next-auth" @@ -42,7 +42,7 @@ export async function getTBEforVendor( const limit = input.perPage ?? 10 // 벤더 필터링 - const vendorWhere = eq(tbeLastView.vendorId, vendorId) + const vendorWhere =and(eq(tbeLastView.vendorId, vendorId),ne(tbeLastView.sessionStatus, "준비중")) // 데이터 조회 const [rows, total] = await db.transaction(async (tx) => { |
