From b51bf3e02a054912cba5d925d2edfee65a101dfb Mon Sep 17 00:00:00 2001 From: phaichayon Date: Tue, 14 Jul 2026 18:37:09 +0700 Subject: [PATCH] preview pdf --- .../document-library/versions/route.ts | 62 ++ .../crm/opportunities/server/service.ts | 25 +- src/features/crm/quotations/api/service.ts | 22 + .../components/quotation-detail.tsx | 98 ++- .../components/quotation-form-sheet.tsx | 596 ++++++++++++------ .../foundation/document-library/service.ts | 3 +- 6 files changed, 557 insertions(+), 249 deletions(-) create mode 100644 src/app/api/crm/settings/document-library/versions/route.ts diff --git a/src/app/api/crm/settings/document-library/versions/route.ts b/src/app/api/crm/settings/document-library/versions/route.ts new file mode 100644 index 0000000..dfcf7ea --- /dev/null +++ b/src/app/api/crm/settings/document-library/versions/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { uploadDocumentLibraryVersion } from '@/features/foundation/document-library/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +export async function POST(request: NextRequest) { + try { + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmDocumentLibraryUpload + }); + + const formData = await request.formData(); + const libraryId = formData.get('libraryId'); + const version = formData.get('version'); + const file = formData.get('file'); + + if (typeof libraryId !== 'string' || !libraryId.trim()) { + return NextResponse.json({ message: 'Library ID is required' }, { status: 400 }); + } + + if (typeof version !== 'string' || !version.trim()) { + return NextResponse.json({ message: 'Version is required' }, { status: 400 }); + } + + if (!(file instanceof File)) { + return NextResponse.json({ message: 'PDF file required' }, { status: 400 }); + } + + const created = await uploadDocumentLibraryVersion({ + libraryId, + organizationId: organization.id, + userId: session.user.id, + version, + file: { + name: file.name, + type: file.type, + size: file.size, + buffer: Buffer.from(await file.arrayBuffer()) + } + }); + + return NextResponse.json({ + success: true, + message: 'Document library version uploaded successfully', + version: created + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json( + { message: 'Unable to upload document library version' }, + { status: 500 } + ); + } +} diff --git a/src/features/crm/opportunities/server/service.ts b/src/features/crm/opportunities/server/service.ts index bb16e9e..53b23cd 100644 --- a/src/features/crm/opportunities/server/service.ts +++ b/src/features/crm/opportunities/server/service.ts @@ -52,8 +52,9 @@ import { import { PERMISSIONS } from '@/lib/auth/rbac'; const ENQUIRY_OPTION_CATEGORIES = { - status: 'crm_opportunity_stage', - productType: 'crm_product_type', +status: 'crm_opportunity_status', +stage: 'crm_opportunity_stage', +productType: 'crm_product_type', priority: 'crm_priority', leadChannel: 'crm_lead_channel', followupType: 'crm_followup_type', @@ -692,9 +693,10 @@ export async function getOpportunityReferenceData( organizationId: string ): Promise { const [ - branches, - stages, - outcomeStatuses, +branches, +statuses, +stages, +outcomeStatuses, productTypes, priorities, leadChannels, @@ -707,9 +709,10 @@ export async function getOpportunityReferenceData( contacts, assignableUsers ] = await Promise.all([ - getUserBranches(), - getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.status, { organizationId }), - getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.outcomeStatus, { organizationId }), +getUserBranches(), +getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.status, { organizationId }), +getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.stage, { organizationId }), +getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.outcomeStatus, { organizationId }), getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.productType, { organizationId }), getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.priority, { organizationId }), getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.leadChannel, { organizationId }), @@ -751,9 +754,9 @@ export async function getOpportunityReferenceData( const ownerMap = new Map(ownerRows.map((row) => [row.id, row.name])); return { - branches: branches.map(mapBranch), - statuses: stages.map(mapOption), - stages: stages.map(mapOption), +branches: branches.map(mapBranch), +statuses: statuses.map(mapOption), +stages: stages.map(mapOption), outcomeStatuses: outcomeStatuses.map(mapOption), productTypes: productTypes.map(mapOption), priorities: priorities.map(mapOption), diff --git a/src/features/crm/quotations/api/service.ts b/src/features/crm/quotations/api/service.ts index 649407d..33fcfea 100644 --- a/src/features/crm/quotations/api/service.ts +++ b/src/features/crm/quotations/api/service.ts @@ -73,6 +73,28 @@ export async function downloadQuotationCustomerPackage(quotationId: string): Pro }; } +export async function generateApprovedQuotationPdf(quotationId: string): Promise<{ + blob: Blob; + fileName: string; +}> { + const response = await fetch(`/api/crm/quotations/${quotationId}/approved-pdf`, { + method: 'POST' + }); + + if (!response.ok) { + const payload = (await response.json().catch(() => null)) as { message?: string } | null; + throw new Error(payload?.message ?? 'Failed to generate approved PDF'); + } + + const contentDisposition = response.headers.get('content-disposition') ?? ''; + const fileNameMatch = contentDisposition.match(/filename="?([^"]+)"?/i); + + return { + blob: await response.blob(), + fileName: fileNameMatch?.[1] ?? `${quotationId}-approved.pdf` + }; +} + export async function downloadApprovedQuotationPdf(quotationId: string): Promise<{ blob: Blob; fileName: string; diff --git a/src/features/crm/quotations/components/quotation-detail.tsx b/src/features/crm/quotations/components/quotation-detail.tsx index f65cc99..9b6a0c7 100644 --- a/src/features/crm/quotations/components/quotation-detail.tsx +++ b/src/features/crm/quotations/components/quotation-detail.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { useMemo, useState } from "react"; -import { useMutation, useSuspenseQuery } from "@tanstack/react-query"; +import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; import { toast } from "sonner"; import { AlertModal } from "@/components/modal/alert-modal"; import { Icons } from "@/components/icons"; @@ -76,6 +76,7 @@ import { import { downloadApprovedQuotationPdf, downloadQuotationCustomerPackage, + generateApprovedQuotationPdf, } from "../api/service"; import type { QuotationAttachmentMutationPayload, @@ -814,7 +815,8 @@ export function QuotationDetail({ canDownloadCustomerPackage: boolean; canGenerateApprovedPdf: boolean; }) { - const { data } = useSuspenseQuery(quotationByIdOptions(quotationId)); + const queryClient = useQueryClient(); + const { data } = useSuspenseQuery(quotationByIdOptions(quotationId)); const { data: itemsData } = useSuspenseQuery( quotationItemsOptions(quotationId), ); @@ -1064,21 +1066,39 @@ export function QuotationDetail({ toast.error(message); }, }); - const downloadApprovedPdf = useMutation({ - mutationFn: async () => downloadApprovedQuotationPdf(quotationId), - onSuccess: ({ blob, fileName }) => { - downloadBlobFile(blob, fileName); - toast.success("Approved PDF downloaded successfully"); +const downloadApprovedPdf = useMutation({ + mutationFn: async () => downloadApprovedQuotationPdf(quotationId), + onSuccess: ({ blob, fileName }) => { + downloadBlobFile(blob, fileName); + toast.success("Approved PDF downloaded successfully"); }, onError: (error) => toast.error( - error instanceof Error - ? error.message - : "Failed to download approved PDF", - ), - }); + error instanceof Error + ? error.message + : "Failed to download approved PDF", + ), +}); +const createApprovedPdf = useMutation({ + mutationFn: async () => generateApprovedQuotationPdf(quotationId), + onSuccess: async ({ blob, fileName }) => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: quotationKeys.detail(quotationId) }), + queryClient.invalidateQueries({ queryKey: quotationKeys.lists() }), + queryClient.invalidateQueries({ queryKey: quotationDocumentKeys.data(quotationId) }), + queryClient.invalidateQueries({ queryKey: quotationDocumentKeys.preview(quotationId) }), + ]); + setCustomerPackageError(null); + downloadBlobFile(blob, fileName); + toast.success("Approved PDF generated successfully"); + }, + onError: (error) => + toast.error( + error instanceof Error ? error.message : "Failed to generate approved PDF", + ), +}); - const branchMap = useMemo( +const branchMap = useMemo( () => new Map(referenceData.branches.map((item) => [item.id, item.name])), [referenceData.branches], ); @@ -1134,7 +1154,7 @@ export function QuotationDetail({ ? customerPackageError : hasOfficialDocument ? "Generate the package from the approved PDF and active document library files." - : "Customer package requires an approved PDF before it can be generated."; + : "Customer package requires an approved PDF before it can be generated. Generate the official PDF first."; const canReviseByStatus = [ "accepted", "rejected", @@ -2044,13 +2064,24 @@ export function QuotationDetail({ ), )} -
- {canGenerateCustomerPackage ? ( - +) : null} +{canGenerateCustomerPackage ? ( +
-
- {canGenerateCustomerPackage ? ( - +) : null} +{canGenerateCustomerPackage ? ( + - @@ -729,7 +903,7 @@ export function QuotationFormSheet({ } export function QuotationFormSheetTrigger({ - referenceData + referenceData, }: { referenceData: QuotationReferenceData; }) { @@ -738,10 +912,14 @@ export function QuotationFormSheetTrigger({ return ( <> - + ); } diff --git a/src/features/foundation/document-library/service.ts b/src/features/foundation/document-library/service.ts index 96ff3c8..27f07b9 100644 --- a/src/features/foundation/document-library/service.ts +++ b/src/features/foundation/document-library/service.ts @@ -66,10 +66,11 @@ export async function uploadDocumentLibraryVersion( payload: DocumentLibraryVersionUploadPayload ) { const formData = new FormData(); + formData.set('libraryId', libraryId); formData.set('version', payload.version); formData.set('file', payload.file); return uploadFormData( - `${BASE_PATH}/${libraryId}/versions`, + `${BASE_PATH}/versions`, formData ); }