commit task-p.7.2.1
This commit is contained in:
@@ -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
2
next-env.d.ts
vendored
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}: {
|
||||
@@ -905,14 +919,7 @@ export function QuotationDetail({
|
||||
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,50 +930,14 @@ 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(
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user