diff --git a/docs/implementation/task-p.7.2.1-working-official-documents-ux-2026-07-01.md b/docs/implementation/task-p.7.2.1-working-official-documents-ux-2026-07-01.md new file mode 100644 index 0000000..babb105 --- /dev/null +++ b/docs/implementation/task-p.7.2.1-working-official-documents-ux-2026-07-01.md @@ -0,0 +1,73 @@ +# Task P.7.2.1 Implementation Report - 2026-07-01 + +## Scope +- Apply the UX amendment that separates quotation documents into `Working Documents`, `Official Documents`, and `Customer Package`. +- Remove screen-level emphasis on technical wording such as draft-style PDF terminology. +- Enforce the business rule that customer package assembly must use the approved PDF, not a working quotation render. + +## Review Summary +Reviewed before implementation: +- [AGENTS.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/AGENTS.md) +- [docs/standards/project-foundations.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/standards/project-foundations.md) +- [docs/standards/architecture-rules.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/standards/architecture-rules.md) +- [docs/standards/ui-ux-rules.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/standards/ui-ux-rules.md) +- [docs/standards/task-review-checklist.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/standards/task-review-checklist.md) +- [docs/business/crm-terminology.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/business/crm-terminology.md) +- [plans/task-p.7.2.1.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/plans/task-p.7.2.1.md) +- [docs/implementation/task-p.7-implementation.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/task-p.7-implementation.md) +- [docs/implementation/task-p.7.1.1-customer-package-ui-integration-2026-06-30.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/task-p.7.1.1-customer-package-ui-integration-2026-06-30.md) + +Skills used this round: +- `kiranism-shadcn-dashboard` for quotation detail page and feature-module conventions +- `shadcn` for card/tabs/button/dialog composition checks +- `web-design-guidelines` for post-change UI review against the Vercel guideline source + +Foundations reused: +- quotation detail page shell in [quotation-detail.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/components/quotation-detail.tsx) +- quotation preview foundation in [quotation-document-preview.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/components/quotation-document-preview.tsx) +- approved PDF storage/retrieval in [pdf-generator service](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/pdf-generator/server/service.ts) +- document assembly foundation in [document-assembly service](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-assembly/server/service.ts) + +## Implementation +- Refactored quotation detail UI in [quotation-detail.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/components/quotation-detail.tsx) to add: + - `Documents` tab + - `Customer Package` tab + - `Working Documents` section with `Generate` and `Preview` actions + - `Official Documents` section with `Preview` and `Download` actions + - `Customer Package` section with `Generate Package`, `Preview Package`, `Download Package`, and disabled `Send Package` +- Removed screen-level actions that promoted the old flat document UX, including the previous top-of-page customer package card and the old standalone `Document Preview` tab label. +- Kept the detailed package metadata view in a dedicated `Customer Package` tab for status, warnings, included documents, checksum, and generation metadata. +- Added approved-PDF download helper in [quotation api service](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/api/service.ts) so the UI can provide an explicit `Download` action for official documents. +- Updated [customer-package helper](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/customer-package.ts) and [tests](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/customer-package.test.ts) to: + - relabel `main` source as `Approved PDF` + - map missing-approved-PDF failures to business-readable copy + - keep permission and missing-document messages readable +- Updated [document assembly service](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/document-assembly/server/service.ts) so the main package source now comes from `getStoredApprovedQuotationPdf()` instead of live working-document generation. + +## Business Rule Change +- Customer package assembly now uses: + - approved PDF artifact + - active document library appendices +- Customer package assembly no longer uses: + - live working quotation PDF generation + - preview/runtime-only working output + +This aligns the package with the task requirement that customer-deliverable content must be assembled from approved, auditable documents only. + +## Verification +- `npm exec tsc --noEmit` -> PASS +- `node --import tsx --test src/features/crm/quotations/customer-package.test.ts` -> PASS +- `npm run build` -> PASS + +## Web Review +Reviewed the updated quotation detail document surface against the fetched Vercel Web Interface Guidelines source. + +Result: +- No blocking accessibility or interaction regressions found in the new `Documents` and `Customer Package` sections. +- Buttons remain text-labeled, destructive actions still use confirmation, and preview/download actions use links or explicit button handlers appropriately. + +## Known Follow-up +- I did not add URL-deep-link sync for the new tabs in this round; the page still follows the repo's existing local tab-state pattern. + +## Outcome +Task P.7.2.1 now presents quotation documents in the intended business hierarchy and makes customer package generation semantically depend on the approved document lifecycle, not on working previews. diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818..9edff1c 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/plans/task-p.7.2.1.md b/plans/task-p.7.2.1.md index 07100c7..611fdfe 100644 --- a/plans/task-p.7.2.1.md +++ b/plans/task-p.7.2.1.md @@ -325,3 +325,24 @@ This guarantees that every package delivered to a customer is assembled from app * Customer Package assembly is based exclusively on Approved PDF. * UI hierarchy reflects the CRM document lifecycle. * The new terminology is reusable across future modules such as Purchase Order, Service Report, Inspection Report, and Work Order. + +--- + +## Implementation Status - 2026-07-01 + +Status: Completed + +Implemented: +- Added `Documents` and `Customer Package` tabs on quotation detail. +- Split document UX into `Working Documents`, `Official Documents`, and `Customer Package`. +- Added explicit `Official Documents` preview/download actions and kept `Customer Package` operational detail in a dedicated tab. +- Updated customer package assembly so the main source is the approved PDF, not the working quotation render. +- Updated customer package error mapping so missing approved PDF shows business-readable guidance. + +Verification: +- `npm exec tsc --noEmit` -> PASS +- `node --import tsx --test src/features/crm/quotations/customer-package.test.ts` -> PASS +- `npm run build` -> PASS + +Implementation report: +- [task-p.7.2.1-working-official-documents-ux-2026-07-01.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/task-p.7.2.1-working-official-documents-ux-2026-07-01.md) diff --git a/src/features/crm/quotations/api/service.ts b/src/features/crm/quotations/api/service.ts index 05ef2f4..649407d 100644 --- a/src/features/crm/quotations/api/service.ts +++ b/src/features/crm/quotations/api/service.ts @@ -73,6 +73,26 @@ export async function downloadQuotationCustomerPackage(quotationId: string): Pro }; } +export async function downloadApprovedQuotationPdf(quotationId: string): Promise<{ + blob: Blob; + fileName: string; +}> { + const response = await fetch(`/api/crm/quotations/${quotationId}/approved-pdf`); + + if (!response.ok) { + const payload = (await response.json().catch(() => null)) as { message?: string } | null; + throw new Error(payload?.message ?? 'Failed to download 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 createQuotation(data: QuotationMutationPayload) { return apiClient('/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 ? ( - - ) : null} - {canDownloadPdf ? ( - - ) : null} - {canGenerateApprovedNow ? ( - - ) : null} - {quotation.approvedPdfUrl ? ( - - ) : null}
- - -
- Customer Package - - Generate and download the assembled customer-facing quotation package. - -
-
- {canGenerateCustomerPackage ? ( - - ) : null} - {canPreviewCustomerPackage ? ( - - ) : null} - {canDownloadCustomerPackage ? ( - - ) : 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 ? ( + + ) : ( + + )} + {canPreviewDocument ? ( + + ) : ( + + )} +
+
+
+ + + + 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 ? ( + + ) : ( + + )} + {canDownloadPdf ? ( + + ) : 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 ? ( + + ) : null} + {canPreviewCustomerPackage && customerPackage ? ( + + ) : ( + + )} + {canDownloadCustomerPackage ? ( + + ) : null} + +
+
+
- )} + + {canPreviewDocument ? ( +
+ +
+ ) : ( + + + Working Document Preview + + Preview permission is required to load working document data. + + + + + + + )} +
-
+ + + + +
+ Customer Package + + Operational detail for the assembled customer-deliverable package. + +
+
+ {canGenerateCustomerPackage ? ( + + ) : null} + {canPreviewCustomerPackage && customerPackage ? ( + + ) : ( + + )} + {canDownloadCustomerPackage ? ( + + ) : 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,