('/crm/quotations', {
method: 'POST',
diff --git a/src/features/crm/quotations/components/quotation-detail.tsx b/src/features/crm/quotations/components/quotation-detail.tsx
index bb62412..8e1e24f 100644
--- a/src/features/crm/quotations/components/quotation-detail.tsx
+++ b/src/features/crm/quotations/components/quotation-detail.tsx
@@ -65,7 +65,10 @@ import {
quotationRevisionsOptions,
quotationTopicsOptions
} from '../api/queries';
-import { downloadQuotationCustomerPackage } from '../api/service';
+import {
+ downloadApprovedQuotationPdf,
+ downloadQuotationCustomerPackage
+} from '../api/service';
import type {
QuotationAttachmentMutationPayload,
QuotationAttachmentRecord,
@@ -110,6 +113,17 @@ function EmptyState({ message }: { message: string }) {
);
}
+function downloadBlobFile(blob: Blob, fileName: string) {
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement('a');
+ anchor.href = url;
+ anchor.download = fileName;
+ document.body.append(anchor);
+ anchor.click();
+ anchor.remove();
+ URL.revokeObjectURL(url);
+}
+
function CustomerPackageSourceItem({
item
}: {
@@ -901,20 +915,13 @@ export function QuotationDetail({
toast.error(message);
}
});
- const downloadCustomerPackage = useMutation({
- mutationFn: async () => downloadQuotationCustomerPackage(quotationId),
- onSuccess: async ({ blob, fileName }) => {
- setCustomerPackageError(null);
- const url = URL.createObjectURL(blob);
- const anchor = document.createElement('a');
- anchor.href = url;
- anchor.download = fileName;
- document.body.append(anchor);
- anchor.click();
- anchor.remove();
- URL.revokeObjectURL(url);
- toast.success('Customer package downloaded successfully');
- },
+const downloadCustomerPackage = useMutation({
+ mutationFn: async () => downloadQuotationCustomerPackage(quotationId),
+ onSuccess: async ({ blob, fileName }) => {
+ setCustomerPackageError(null);
+ downloadBlobFile(blob, fileName);
+ toast.success('Customer package downloaded successfully');
+ },
onError: (error) => {
const message = mapCustomerPackageErrorMessage(
error instanceof Error ? error.message : null
@@ -923,51 +930,15 @@ export function QuotationDetail({
toast.error(message);
}
});
- const generateApprovedPdf = useMutation({
- mutationFn: async () => {
- 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');
- }
-
- return response.blob();
- },
- onSuccess: async (blob) => {
- const url = URL.createObjectURL(blob);
- const anchor = document.createElement('a');
- anchor.href = url;
- anchor.download = `${quotation.code}-approved.pdf`;
- document.body.append(anchor);
- anchor.click();
- anchor.remove();
- URL.revokeObjectURL(url);
-
- const queryClient = getQueryClient();
- await Promise.all([
- queryClient.invalidateQueries({
- queryKey: quotationKeys.detail(quotationId)
- }),
- queryClient.invalidateQueries({
- queryKey: quotationKeys.attachments(quotationId)
- }),
- queryClient.invalidateQueries({
- queryKey: quotationDocumentKeys.data(quotationId)
- }),
- queryClient.invalidateQueries({
- queryKey: quotationDocumentKeys.preview(quotationId)
- })
- ]);
- toast.success('Approved PDF generated successfully');
- },
- onError: (error) =>
- toast.error(error instanceof Error ? error.message : 'Failed to generate approved PDF')
- });
+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')
+});
const branchMap = useMemo(
() => new Map(referenceData.branches.map((item) => [item.id, item.name])),
@@ -1005,7 +976,9 @@ const customer = customerMap.get(quotation.customerId);
const contact = quotation.contactId ? contactMap.get(quotation.contactId) : null;
const status = statusMap.get(quotation.status);
const customerPackage = quotation.customerPackage;
-const customerPackageActionLabel = customerPackage ? 'Regenerate Package' : 'Generate Customer Package';
+const hasOfficialDocument = Boolean(quotation.approvedPdfUrl);
+const officialDocumentPreviewHref = `/api/crm/quotations/${quotationId}/approved-pdf`;
+const customerPackageActionLabel = customerPackage ? 'Regenerate Package' : 'Generate Package';
const customerPackageStatusLabel =
customerPackageStatus === 'generated'
? 'Generated'
@@ -1017,13 +990,14 @@ const customerPackageStatusDescription =
? 'Customer-facing assembled PDF is ready.'
: customerPackageStatus === 'failed'
? customerPackageError
- : 'Generate the package before downloading or previewing it.';
+ : hasOfficialDocument
+ ? 'Generate the package from the approved PDF and active document library files.'
+ : 'Customer package requires an approved PDF before it can be generated.';
const canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes(
status?.code ?? ''
);
const canSubmitCurrentQuotation =
canSubmitApproval && ['draft', 'revised'].includes(status?.code ?? '');
- const canGenerateApprovedNow = canGenerateApprovedPdf && (status?.code ?? '') === 'approved';
return (
@@ -1240,171 +1214,9 @@ const canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes(
Edit Quotation
) : null}
- {canPreviewPdf ? (
-
-
- Preview PDF
-
-
- ) : null}
- {canDownloadPdf ? (
-
-
- Download PDF
-
-
- ) : null}
- {canGenerateApprovedNow ? (
- generateApprovedPdf.mutate()}
- >
- Generate Approved PDF
-
- ) : null}
- {quotation.approvedPdfUrl ? (
-
-
- View Approved PDF
-
-
- ) : null}
-
-
-
- Customer Package
-
- Generate and download the assembled customer-facing quotation package.
-
-
-
- {canGenerateCustomerPackage ? (
-
- customerPackage
- ? setConfirmRegenerateOpen(true)
- : generateCustomerPackage.mutate({ quotationId })
- }
- >
-
- {customerPackageActionLabel}
-
- ) : null}
- {canPreviewCustomerPackage ? (
-
- {
- if (!customerPackage) {
- event.preventDefault();
- }
- }}
- >
- View Customer Package
-
-
- ) : null}
- {canDownloadCustomerPackage ? (
- downloadCustomerPackage.mutate()}
- >
- Download Customer Package
-
- ) : null}
-
-
-
- {!canGenerateCustomerPackage && !canDownloadCustomerPackage && !canPreviewCustomerPackage ? (
-
- ) : (
- <>
-
-
-
-
-
-
-
-
-
-
-
- {customerPackageStatusDescription}
-
- {customerPackage?.warnings.length ? (
-
-
Warnings
- {customerPackage.warnings.map((warning) => (
-
- {warning.message}
-
- ))}
-
- ) : null}
-
-
Included Documents
- {!customerPackage?.includedDocuments.length ? (
-
- ) : (
-
- {customerPackage.includedDocuments.map((item) => (
-
- ))}
-
- )}
-
- >
- )}
-
-
-
@@ -1412,6 +1224,8 @@ const canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes(
Overview
+ Documents
+ Customer Package
Items
ผู้เกี่ยวข้องในโครงการ
Topics
@@ -1419,7 +1233,7 @@ const canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes(
Attachments
Activity
Approval
- Document Preview
+
@@ -1898,24 +1712,331 @@ const canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes(
/>
-
- {canPreviewDocument ? (
-
- ) : (
+
+
- Document Preview
+ Documents
- Preview permission is required to load document data.
+ Working documents, official documents, and customer package follow the quotation lifecycle.
-
-
+
+
+
+ Working Documents
+
+ Internal preview generated from current quotation data. This is not the customer-deliverable record.
+
+
+
+
+
+ Working documents always reflect live quotation data and can be regenerated anytime for internal review.
+
+
+ {canPreviewPdf ? (
+
+
+ Generate
+
+
+ ) : (
+
+ Generate
+
+ )}
+ {canPreviewDocument ? (
+
+
+ Preview
+
+
+ ) : (
+
+ Preview
+
+ )}
+
+
+
+
+
+
+ Official Documents
+
+ Immutable approved record stored from the approved snapshot and used for audit and revision history.
+
+
+
+
+
+
+
+ {hasOfficialDocument
+ ? 'Official documents are read-only and represent the approved business record.'
+ : 'Official documents become available after approval completes and the approved PDF exists.'}
+
+
+ {canDownloadPdf && hasOfficialDocument ? (
+
+
+ Preview
+
+
+ ) : (
+
+ Preview
+
+ )}
+ {canDownloadPdf ? (
+ downloadApprovedPdf.mutate()}
+ >
+ Download
+
+ ) : null}
+
+
+
+
+
+
+ Customer Package
+
+ Customer-deliverable package assembled from the approved PDF plus active document library appendices.
+
+
+
+
+
+
+
+ {customerPackageStatusDescription}
+
+
+ {customerPackage?.includedDocuments.map((item) => (
+
+ {getCustomerPackageRoleLabel(item.role)}
+
+ ))}
+
+
+ {canGenerateCustomerPackage ? (
+
+ customerPackage
+ ? setConfirmRegenerateOpen(true)
+ : generateCustomerPackage.mutate({ quotationId })
+ }
+ >
+
+ {customerPackageActionLabel}
+
+ ) : null}
+ {canPreviewCustomerPackage && customerPackage ? (
+
+
+ Preview Package
+
+
+ ) : (
+
+ Preview Package
+
+ )}
+ {canDownloadCustomerPackage ? (
+ downloadCustomerPackage.mutate()}
+ >
+ Download Package
+
+ ) : null}
+
+ Send Package
+
+
+
+
- )}
+
+ {canPreviewDocument ? (
+
+
+
+ ) : (
+
+
+ Working Document Preview
+
+ Preview permission is required to load working document data.
+
+
+
+
+
+
+ )}
+
-
+
+
+
+
+
+ Customer Package
+
+ Operational detail for the assembled customer-deliverable package.
+
+
+
+ {canGenerateCustomerPackage ? (
+
+ customerPackage
+ ? setConfirmRegenerateOpen(true)
+ : generateCustomerPackage.mutate({ quotationId })
+ }
+ >
+
+ {customerPackageActionLabel}
+
+ ) : null}
+ {canPreviewCustomerPackage && customerPackage ? (
+
+
+ Preview Package
+
+
+ ) : (
+
+ Preview Package
+
+ )}
+ {canDownloadCustomerPackage ? (
+ downloadCustomerPackage.mutate()}
+ >
+ Download Package
+
+ ) : null}
+
+
+
+ {!canGenerateCustomerPackage && !canDownloadCustomerPackage && !canPreviewCustomerPackage ? (
+
+ ) : (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ {customerPackageStatusDescription}
+
+ {customerPackage?.warnings.length ? (
+
+
Warnings
+ {customerPackage.warnings.map((warning) => (
+
+ {warning.message}
+
+ ))}
+
+ ) : null}
+
+
Included Documents
+ {!customerPackage?.includedDocuments.length ? (
+
+ ) : (
+
+ {customerPackage.includedDocuments.map((item) => (
+
+ ))}
+
+ )}
+
+ >
+ )}
+
+
+
@@ -2044,3 +2165,8 @@ const canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes(
);
}
+
+
+
+
+
diff --git a/src/features/crm/quotations/customer-package.test.ts b/src/features/crm/quotations/customer-package.test.ts
index 9e89e4c..78484a1 100644
--- a/src/features/crm/quotations/customer-package.test.ts
+++ b/src/features/crm/quotations/customer-package.test.ts
@@ -13,6 +13,13 @@ test('mapCustomerPackageErrorMessage rewrites not-generated error', () => {
);
});
+test('mapCustomerPackageErrorMessage rewrites approved-pdf prerequisite error', () => {
+ assert.equal(
+ mapCustomerPackageErrorMessage('Approved PDF has not been generated yet'),
+ 'Customer package requires an approved PDF first. Complete approval before generating the package.'
+ );
+});
+
test('mapCustomerPackageErrorMessage rewrites missing document error', () => {
assert.equal(
mapCustomerPackageErrorMessage(
@@ -30,7 +37,7 @@ test('mapCustomerPackageErrorMessage rewrites forbidden error', () => {
});
test('getCustomerPackageRoleLabel falls back to humanized labels', () => {
- assert.equal(getCustomerPackageRoleLabel('main'), 'Quotation');
+ assert.equal(getCustomerPackageRoleLabel('main'), 'Approved PDF');
assert.equal(getCustomerPackageRoleLabel('custom_appendix'), 'Custom Appendix');
});
@@ -55,6 +62,7 @@ test('getCustomerPackageUiStatus reflects generated, failed, and not-generated s
}),
'generated'
);
+
assert.equal(
getCustomerPackageUiStatus({
customerPackage: null,
@@ -62,6 +70,7 @@ test('getCustomerPackageUiStatus reflects generated, failed, and not-generated s
}),
'failed'
);
+
assert.equal(
getCustomerPackageUiStatus({
customerPackage: null,
diff --git a/src/features/crm/quotations/customer-package.ts b/src/features/crm/quotations/customer-package.ts
index 7605e24..b0a4a5f 100644
--- a/src/features/crm/quotations/customer-package.ts
+++ b/src/features/crm/quotations/customer-package.ts
@@ -1,7 +1,7 @@
import type { QuotationCustomerPackageSummary } from './api/types';
const CUSTOMER_PACKAGE_ROLE_LABELS: Record = {
- main: 'Quotation',
+ main: 'Approved PDF',
sla: 'SLA',
warranty: 'Warranty',
datasheet: 'Datasheet',
@@ -36,10 +36,20 @@ export function mapCustomerPackageErrorMessage(message?: string | null): string
return 'You do not have permission to generate or download customer package.';
}
- if (normalized === 'Assembled quotation PDF has not been generated yet.') {
+ if (
+ normalized === 'Assembled quotation PDF has not been generated yet.' ||
+ normalized === 'Assembled quotation PDF not been generated yet.'
+ ) {
return 'Customer package has not been generated yet. Please generate it first.';
}
+ if (
+ normalized === 'Approved PDF has not been generated yet' ||
+ normalized === 'Approved PDF has not been generated yet.'
+ ) {
+ return 'Customer package requires an approved PDF first. Complete approval before generating the package.';
+ }
+
const missingRequiredDocument = normalized.match(/^Missing ([A-Z_ ]+) document/i);
if (missingRequiredDocument) {
const role = getCustomerPackageRoleLabel(missingRequiredDocument[1].toLowerCase());
diff --git a/src/features/foundation/document-assembly/server/service.ts b/src/features/foundation/document-assembly/server/service.ts
index 790a79a..d5adc9d 100644
--- a/src/features/foundation/document-assembly/server/service.ts
+++ b/src/features/foundation/document-assembly/server/service.ts
@@ -23,7 +23,7 @@ import {
buildQuotationDocumentData,
getQuotationDocumentPreviewData,
} from '@/features/crm/quotations/document/server/service';
-import { generateQuotationPdf } from '@/features/foundation/pdf-generator/server/service';
+import { getStoredApprovedQuotationPdf } from '@/features/foundation/pdf-generator/server/service';
import type {
AssemblyPolicy,
AssemblySourcePolicy,
@@ -540,10 +540,12 @@ export async function generateAssembledQuotationPdf(
policy,
sourceResolver: async (sourcePolicy) => {
if (sourcePolicy.sourceType === 'generated') {
- const generated = await generateQuotationPdf(quotationId, organizationId);
+ const generated = await getStoredApprovedQuotationPdf(quotationId, organizationId, {
+ userId
+ });
const pageCount = await getPdfPageCount(
generated.buffer,
- 'Generated quotation PDF',
+ 'Approved quotation PDF',
);
return {
@@ -558,7 +560,7 @@ export async function generateAssembledQuotationPdf(
fileSize: generated.buffer.byteLength,
metadata: {
templateVersionId: generated.templateVersionId,
- sourceDocumentType: 'quotation_pdf',
+ sourceDocumentType: 'approved_pdf',
},
buffer: generated.buffer,
};
@@ -579,7 +581,7 @@ export async function generateAssembledQuotationPdf(
const metadata: QuotationAssemblyMetadata = {
documentType: 'assembled_pdf',
- sourceDocumentType: 'quotation_pdf',
+ sourceDocumentType: 'approved_pdf',
sourceDocumentId: quotationId,
sourceDocumentCode: preview.documentData.quotation.code,
assemblyPolicy: assembled.policy,