commit task-p.7.2.1

This commit is contained in:
phaichayon
2026-07-01 06:27:03 +07:00
parent 53dfd20913
commit 0c39472dca
8 changed files with 506 additions and 245 deletions

View File

@@ -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.

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
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.

View File

@@ -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)

View File

@@ -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<MutationSuccessResponse>('/crm/quotations', {
method: 'POST',

View File

@@ -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,18 +915,11 @@ export function QuotationDetail({
toast.error(message);
}
});
const downloadCustomerPackage = useMutation({
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);
downloadBlobFile(blob, fileName);
toast.success('Customer package downloaded successfully');
},
onError: (error) => {
@@ -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');
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 generate approved PDF')
});
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 (
<div className='space-y-6'>
@@ -1240,171 +1214,9 @@ const canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes(
<Icons.edit className='mr-2 h-4 w-4' /> Edit Quotation
</Button>
) : null}
{canPreviewPdf ? (
<Button asChild variant='outline'>
<Link href={`/api/crm/quotations/${quotationId}/pdf-preview`} target='_blank'>
<Icons.eyeOff className='mr-2 h-4 w-4' /> Preview PDF
</Link>
</Button>
) : null}
{canDownloadPdf ? (
<Button asChild variant='outline'>
<Link href={`/api/crm/quotations/${quotationId}/pdf-download`} target='_blank'>
<Icons.download className='mr-2 h-4 w-4' /> Download PDF
</Link>
</Button>
) : null}
{canGenerateApprovedNow ? (
<Button
variant='outline'
isLoading={generateApprovedPdf.isPending}
onClick={() => generateApprovedPdf.mutate()}
>
<Icons.badgeCheck className='mr-2 h-4 w-4' /> Generate Approved PDF
</Button>
) : null}
{quotation.approvedPdfUrl ? (
<Button asChild variant='outline'>
<Link
href={
quotation.approvedArtifact?.id
? `/api/crm/document-artifacts/${quotation.approvedArtifact.id}/download`
: `/api/crm/quotations/${quotationId}/approved-pdf`
}
target='_blank'
>
<Icons.externalLink className='mr-2 h-4 w-4' /> View Approved PDF
</Link>
</Button>
) : null}
</div>
</div>
<Card>
<CardHeader className='flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
<div>
<CardTitle>Customer Package</CardTitle>
<CardDescription>
Generate and download the assembled customer-facing quotation package.
</CardDescription>
</div>
<div className='flex flex-wrap gap-2'>
{canGenerateCustomerPackage ? (
<Button
variant='outline'
isLoading={generateCustomerPackage.isPending}
onClick={() =>
customerPackage
? setConfirmRegenerateOpen(true)
: generateCustomerPackage.mutate({ quotationId })
}
>
<Icons.badgeCheck className='mr-2 h-4 w-4' />
{customerPackageActionLabel}
</Button>
) : null}
{canPreviewCustomerPackage ? (
<Button asChild variant='outline' disabled={!customerPackage}>
<Link
href={`/api/crm/quotations/${quotationId}/assembled-pdf`}
target='_blank'
aria-disabled={!customerPackage}
onClick={(event) => {
if (!customerPackage) {
event.preventDefault();
}
}}
>
<Icons.eyeOff className='mr-2 h-4 w-4' /> View Customer Package
</Link>
</Button>
) : null}
{canDownloadCustomerPackage ? (
<Button
variant='outline'
disabled={!customerPackage}
isLoading={downloadCustomerPackage.isPending}
onClick={() => downloadCustomerPackage.mutate()}
>
<Icons.download className='mr-2 h-4 w-4' /> Download Customer Package
</Button>
) : null}
</div>
</CardHeader>
<CardContent className='space-y-4'>
{!canGenerateCustomerPackage && !canDownloadCustomerPackage && !canPreviewCustomerPackage ? (
<EmptyState message='You do not have permission to generate or download customer package.' />
) : (
<>
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
<FieldItem label='Status' value={customerPackageStatusLabel} />
<FieldItem
label='Generated At'
value={customerPackage?.generatedAt ? formatDateTime(customerPackage.generatedAt) : null}
/>
<FieldItem
label='Generated By'
value={customerPackage?.generatedByName ?? customerPackage?.generatedBy ?? null}
/>
<FieldItem
label='Page Count'
value={
customerPackage?.pageCount !== null && customerPackage?.pageCount !== undefined
? String(customerPackage.pageCount)
: null
}
/>
<FieldItem
label='File Size'
value={customerPackage?.fileSize ? `${formatNumber(customerPackage.fileSize)} bytes` : null}
/>
<FieldItem
label='Checksum'
value={customerPackage?.checksum ? customerPackage.checksum.slice(0, 16) : null}
/>
<FieldItem label='File Name' value={customerPackage?.fileName ?? null} />
<FieldItem
label='Included Documents'
value={
customerPackage?.includedDocuments.length
? String(customerPackage.includedDocuments.length)
: null
}
/>
</div>
<div className='rounded-lg border border-dashed p-4 text-sm'>
{customerPackageStatusDescription}
</div>
{customerPackage?.warnings.length ? (
<div className='space-y-2'>
<div className='text-sm font-medium'>Warnings</div>
{customerPackage.warnings.map((warning) => (
<div key={`${warning.code}-${warning.role}`} className='rounded-lg border p-3 text-sm'>
{warning.message}
</div>
))}
</div>
) : null}
<div className='space-y-3'>
<div className='text-sm font-medium'>Included Documents</div>
{!customerPackage?.includedDocuments.length ? (
<EmptyState message='No assembled package has been generated yet.' />
) : (
<div className='grid gap-3 md:grid-cols-2'>
{customerPackage.includedDocuments.map((item) => (
<CustomerPackageSourceItem
key={`${item.role}-${item.fileName}-${item.checksum ?? 'no-checksum'}`}
item={item}
/>
))}
</div>
)}
</div>
</>
)}
</CardContent>
</Card>
<div className='grid gap-6 lg:grid-cols-3'>
<div className='space-y-6 lg:col-span-2'>
<Card>
@@ -1412,6 +1224,8 @@ const canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes(
<Tabs defaultValue='overview' className='gap-4'>
<TabsList className='flex flex-wrap'>
<TabsTrigger value='overview'>Overview</TabsTrigger>
<TabsTrigger value='documents'>Documents</TabsTrigger>
<TabsTrigger value='customer-package'>Customer Package</TabsTrigger>
<TabsTrigger value='items'>Items</TabsTrigger>
<TabsTrigger value='customers'></TabsTrigger>
<TabsTrigger value='topics'>Topics</TabsTrigger>
@@ -1419,7 +1233,7 @@ const canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes(
<TabsTrigger value='attachments'>Attachments</TabsTrigger>
<TabsTrigger value='activity'>Activity</TabsTrigger>
<TabsTrigger value='approval'>Approval</TabsTrigger>
<TabsTrigger value='preview'>Document Preview</TabsTrigger>
</TabsList>
<TabsContent value='overview'>
@@ -1898,15 +1712,190 @@ const canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes(
/>
</TabsContent>
<TabsContent value='preview'>
<TabsContent value='documents'>
<div className='flex flex-col gap-6'>
<Card>
<CardHeader>
<CardTitle>Documents</CardTitle>
<CardDescription>
Working documents, official documents, and customer package follow the quotation lifecycle.
</CardDescription>
</CardHeader>
<CardContent className='grid gap-4 xl:grid-cols-3'>
<Card>
<CardHeader>
<CardTitle>Working Documents</CardTitle>
<CardDescription>
Internal preview generated from current quotation data. This is not the customer-deliverable record.
</CardDescription>
</CardHeader>
<CardContent className='flex flex-col gap-4'>
<FieldItem label='Primary Document' value='Preview PDF' />
<div className='rounded-lg border border-dashed p-4 text-sm'>
Working documents always reflect live quotation data and can be regenerated anytime for internal review.
</div>
<div className='flex flex-wrap gap-2'>
{canPreviewPdf ? (
<Button asChild variant='outline'>
<Link href={`/api/crm/quotations/${quotationId}/pdf-preview`} target='_blank'>
<Icons.badgeCheck className='mr-2 h-4 w-4' /> Generate
</Link>
</Button>
) : (
<Button variant='outline' disabled>
<Icons.badgeCheck className='mr-2 h-4 w-4' /> Generate
</Button>
)}
{canPreviewDocument ? (
<Button asChild variant='outline'>
<Link href='#working-document-preview'>
<Icons.eyeOff className='mr-2 h-4 w-4' /> Preview
</Link>
</Button>
) : (
<Button variant='outline' disabled>
<Icons.eyeOff className='mr-2 h-4 w-4' /> Preview
</Button>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Official Documents</CardTitle>
<CardDescription>
Immutable approved record stored from the approved snapshot and used for audit and revision history.
</CardDescription>
</CardHeader>
<CardContent className='flex flex-col gap-4'>
<FieldItem label='Primary Document' value='Approved PDF' />
<FieldItem label='Status' value={hasOfficialDocument ? 'Available' : 'Not Available'} />
<FieldItem
label='Approved At'
value={quotation.approvedAt ? formatDateTime(quotation.approvedAt) : null}
/>
<div className='rounded-lg border border-dashed p-4 text-sm'>
{hasOfficialDocument
? 'Official documents are read-only and represent the approved business record.'
: 'Official documents become available after approval completes and the approved PDF exists.'}
</div>
<div className='flex flex-wrap gap-2'>
{canDownloadPdf && hasOfficialDocument ? (
<Button asChild variant='outline'>
<Link href={officialDocumentPreviewHref} target='_blank'>
<Icons.eyeOff className='mr-2 h-4 w-4' /> Preview
</Link>
</Button>
) : (
<Button variant='outline' disabled>
<Icons.eyeOff className='mr-2 h-4 w-4' /> Preview
</Button>
)}
{canDownloadPdf ? (
<Button
variant='outline'
disabled={!hasOfficialDocument}
isLoading={downloadApprovedPdf.isPending}
onClick={() => downloadApprovedPdf.mutate()}
>
<Icons.download className='mr-2 h-4 w-4' /> Download
</Button>
) : null}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Customer Package</CardTitle>
<CardDescription>
Customer-deliverable package assembled from the approved PDF plus active document library appendices.
</CardDescription>
</CardHeader>
<CardContent className='flex flex-col gap-4'>
<FieldItem label='Status' value={customerPackageStatusLabel} />
<FieldItem
label='Included Documents'
value={
customerPackage?.includedDocuments.length
? String(customerPackage.includedDocuments.length)
: null
}
/>
<FieldItem
label='Warnings'
value={
customerPackage?.warnings.length
? String(customerPackage.warnings.length)
: null
}
/>
<div className='rounded-lg border border-dashed p-4 text-sm'>
{customerPackageStatusDescription}
</div>
<div className='flex flex-wrap gap-2'>
{customerPackage?.includedDocuments.map((item) => (
<Badge key={`${item.role}-${item.fileName}`} variant='outline'>
{getCustomerPackageRoleLabel(item.role)}
</Badge>
))}
</div>
<div className='flex flex-wrap gap-2'>
{canGenerateCustomerPackage ? (
<Button
variant='outline'
isLoading={generateCustomerPackage.isPending}
onClick={() =>
customerPackage
? setConfirmRegenerateOpen(true)
: generateCustomerPackage.mutate({ quotationId })
}
>
<Icons.badgeCheck className='mr-2 h-4 w-4' />
{customerPackageActionLabel}
</Button>
) : null}
{canPreviewCustomerPackage && customerPackage ? (
<Button asChild variant='outline'>
<Link href={`/api/crm/quotations/${quotationId}/assembled-pdf`} target='_blank'>
<Icons.eyeOff className='mr-2 h-4 w-4' /> Preview Package
</Link>
</Button>
) : (
<Button variant='outline' disabled>
<Icons.eyeOff className='mr-2 h-4 w-4' /> Preview Package
</Button>
)}
{canDownloadCustomerPackage ? (
<Button
variant='outline'
disabled={!customerPackage}
isLoading={downloadCustomerPackage.isPending}
onClick={() => downloadCustomerPackage.mutate()}
>
<Icons.download className='mr-2 h-4 w-4' /> Download Package
</Button>
) : null}
<Button variant='outline' disabled>
<Icons.send className='mr-2 h-4 w-4' /> Send Package
</Button>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
{canPreviewDocument ? (
<div id='working-document-preview' className='scroll-mt-24'>
<QuotationDocumentPreview quotationId={quotationId} />
</div>
) : (
<Card>
<CardHeader>
<CardTitle>Document Preview</CardTitle>
<CardTitle>Working Document Preview</CardTitle>
<CardDescription>
Preview permission is required to load document data.
Preview permission is required to load working document data.
</CardDescription>
</CardHeader>
<CardContent>
@@ -1914,8 +1903,140 @@ const canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes(
</CardContent>
</Card>
)}
</div>
</TabsContent>
</Tabs>
<TabsContent value='customer-package'>
<Card>
<CardHeader className='flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
<div>
<CardTitle>Customer Package</CardTitle>
<CardDescription>
Operational detail for the assembled customer-deliverable package.
</CardDescription>
</div>
<div className='flex flex-wrap gap-2'>
{canGenerateCustomerPackage ? (
<Button
variant='outline'
isLoading={generateCustomerPackage.isPending}
onClick={() =>
customerPackage
? setConfirmRegenerateOpen(true)
: generateCustomerPackage.mutate({ quotationId })
}
>
<Icons.badgeCheck className='mr-2 h-4 w-4' />
{customerPackageActionLabel}
</Button>
) : null}
{canPreviewCustomerPackage && customerPackage ? (
<Button asChild variant='outline'>
<Link href={`/api/crm/quotations/${quotationId}/assembled-pdf`} target='_blank'>
<Icons.eyeOff className='mr-2 h-4 w-4' /> Preview Package
</Link>
</Button>
) : (
<Button variant='outline' disabled>
<Icons.eyeOff className='mr-2 h-4 w-4' /> Preview Package
</Button>
)}
{canDownloadCustomerPackage ? (
<Button
variant='outline'
disabled={!customerPackage}
isLoading={downloadCustomerPackage.isPending}
onClick={() => downloadCustomerPackage.mutate()}
>
<Icons.download className='mr-2 h-4 w-4' /> Download Package
</Button>
) : null}
</div>
</CardHeader>
<CardContent className='flex flex-col gap-4'>
{!canGenerateCustomerPackage && !canDownloadCustomerPackage && !canPreviewCustomerPackage ? (
<EmptyState message='You do not have permission to generate or download customer package.' />
) : (
<>
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
<FieldItem label='Status' value={customerPackageStatusLabel} />
<FieldItem
label='Generated At'
value={
customerPackage?.generatedAt
? formatDateTime(customerPackage.generatedAt)
: null
}
/>
<FieldItem
label='Generated By'
value={customerPackage?.generatedByName ?? customerPackage?.generatedBy ?? null}
/>
<FieldItem
label='Page Count'
value={
customerPackage?.pageCount !== null && customerPackage?.pageCount !== undefined
? String(customerPackage.pageCount)
: null
}
/>
<FieldItem
label='File Size'
value={
customerPackage?.fileSize
? `${formatNumber(customerPackage.fileSize)} bytes`
: null
}
/>
<FieldItem
label='Checksum'
value={
customerPackage?.checksum ? customerPackage.checksum.slice(0, 16) : null
}
/>
<FieldItem label='File Name' value={customerPackage?.fileName ?? null} />
<FieldItem
label='Included Documents'
value={
customerPackage?.includedDocuments.length
? String(customerPackage.includedDocuments.length)
: null
}
/>
</div>
<div className='rounded-lg border border-dashed p-4 text-sm'>
{customerPackageStatusDescription}
</div>
{customerPackage?.warnings.length ? (
<div className='flex flex-col gap-2'>
<div className='text-sm font-medium'>Warnings</div>
{customerPackage.warnings.map((warning) => (
<div key={`${warning.code}-${warning.role}`} className='rounded-lg border p-3 text-sm'>
{warning.message}
</div>
))}
</div>
) : null}
<div className='flex flex-col gap-3'>
<div className='text-sm font-medium'>Included Documents</div>
{!customerPackage?.includedDocuments.length ? (
<EmptyState message='No assembled package has been generated yet.' />
) : (
<div className='grid gap-3 md:grid-cols-2'>
{customerPackage.includedDocuments.map((item) => (
<CustomerPackageSourceItem
key={`${item.role}-${item.fileName}-${item.checksum ?? 'no-checksum'}`}
item={item}
/>
))}
</div>
)}
</div>
</>
)}
</CardContent>
</Card>
</TabsContent></Tabs>
</CardContent>
</Card>
</div>
@@ -2044,3 +2165,8 @@ const canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes(
</div>
);
}

View File

@@ -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,

View File

@@ -1,7 +1,7 @@
import type { QuotationCustomerPackageSummary } from './api/types';
const CUSTOMER_PACKAGE_ROLE_LABELS: Record<string, string> = {
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());

View File

@@ -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,