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" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <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
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// 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.
|
* Customer Package assembly is based exclusively on Approved PDF.
|
||||||
* UI hierarchy reflects the CRM document lifecycle.
|
* 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.
|
* 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) {
|
export async function createQuotation(data: QuotationMutationPayload) {
|
||||||
return apiClient<MutationSuccessResponse>('/crm/quotations', {
|
return apiClient<MutationSuccessResponse>('/crm/quotations', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -65,7 +65,10 @@ import {
|
|||||||
quotationRevisionsOptions,
|
quotationRevisionsOptions,
|
||||||
quotationTopicsOptions
|
quotationTopicsOptions
|
||||||
} from '../api/queries';
|
} from '../api/queries';
|
||||||
import { downloadQuotationCustomerPackage } from '../api/service';
|
import {
|
||||||
|
downloadApprovedQuotationPdf,
|
||||||
|
downloadQuotationCustomerPackage
|
||||||
|
} from '../api/service';
|
||||||
import type {
|
import type {
|
||||||
QuotationAttachmentMutationPayload,
|
QuotationAttachmentMutationPayload,
|
||||||
QuotationAttachmentRecord,
|
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({
|
function CustomerPackageSourceItem({
|
||||||
item
|
item
|
||||||
}: {
|
}: {
|
||||||
@@ -901,20 +915,13 @@ export function QuotationDetail({
|
|||||||
toast.error(message);
|
toast.error(message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const downloadCustomerPackage = useMutation({
|
const downloadCustomerPackage = useMutation({
|
||||||
mutationFn: async () => downloadQuotationCustomerPackage(quotationId),
|
mutationFn: async () => downloadQuotationCustomerPackage(quotationId),
|
||||||
onSuccess: async ({ blob, fileName }) => {
|
onSuccess: async ({ blob, fileName }) => {
|
||||||
setCustomerPackageError(null);
|
setCustomerPackageError(null);
|
||||||
const url = URL.createObjectURL(blob);
|
downloadBlobFile(blob, fileName);
|
||||||
const anchor = document.createElement('a');
|
toast.success('Customer package downloaded successfully');
|
||||||
anchor.href = url;
|
},
|
||||||
anchor.download = fileName;
|
|
||||||
document.body.append(anchor);
|
|
||||||
anchor.click();
|
|
||||||
anchor.remove();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
toast.success('Customer package downloaded successfully');
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const message = mapCustomerPackageErrorMessage(
|
const message = mapCustomerPackageErrorMessage(
|
||||||
error instanceof Error ? error.message : null
|
error instanceof Error ? error.message : null
|
||||||
@@ -923,51 +930,15 @@ export function QuotationDetail({
|
|||||||
toast.error(message);
|
toast.error(message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const generateApprovedPdf = useMutation({
|
const downloadApprovedPdf = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => downloadApprovedQuotationPdf(quotationId),
|
||||||
const response = await fetch(`/api/crm/quotations/${quotationId}/approved-pdf`, {
|
onSuccess: ({ blob, fileName }) => {
|
||||||
method: 'POST'
|
downloadBlobFile(blob, fileName);
|
||||||
});
|
toast.success('Approved PDF downloaded successfully');
|
||||||
|
},
|
||||||
if (!response.ok) {
|
onError: (error) =>
|
||||||
const payload = (await response.json().catch(() => null)) as {
|
toast.error(error instanceof Error ? error.message : 'Failed to download approved PDF')
|
||||||
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 branchMap = useMemo(
|
const branchMap = useMemo(
|
||||||
() => new Map(referenceData.branches.map((item) => [item.id, item.name])),
|
() => 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 contact = quotation.contactId ? contactMap.get(quotation.contactId) : null;
|
||||||
const status = statusMap.get(quotation.status);
|
const status = statusMap.get(quotation.status);
|
||||||
const customerPackage = quotation.customerPackage;
|
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 =
|
const customerPackageStatusLabel =
|
||||||
customerPackageStatus === 'generated'
|
customerPackageStatus === 'generated'
|
||||||
? 'Generated'
|
? 'Generated'
|
||||||
@@ -1017,13 +990,14 @@ const customerPackageStatusDescription =
|
|||||||
? 'Customer-facing assembled PDF is ready.'
|
? 'Customer-facing assembled PDF is ready.'
|
||||||
: customerPackageStatus === 'failed'
|
: customerPackageStatus === 'failed'
|
||||||
? customerPackageError
|
? 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(
|
const canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes(
|
||||||
status?.code ?? ''
|
status?.code ?? ''
|
||||||
);
|
);
|
||||||
const canSubmitCurrentQuotation =
|
const canSubmitCurrentQuotation =
|
||||||
canSubmitApproval && ['draft', 'revised'].includes(status?.code ?? '');
|
canSubmitApproval && ['draft', 'revised'].includes(status?.code ?? '');
|
||||||
const canGenerateApprovedNow = canGenerateApprovedPdf && (status?.code ?? '') === 'approved';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-6'>
|
<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
|
<Icons.edit className='mr-2 h-4 w-4' /> Edit Quotation
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : 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>
|
||||||
</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='grid gap-6 lg:grid-cols-3'>
|
||||||
<div className='space-y-6 lg:col-span-2'>
|
<div className='space-y-6 lg:col-span-2'>
|
||||||
<Card>
|
<Card>
|
||||||
@@ -1412,6 +1224,8 @@ const canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes(
|
|||||||
<Tabs defaultValue='overview' className='gap-4'>
|
<Tabs defaultValue='overview' className='gap-4'>
|
||||||
<TabsList className='flex flex-wrap'>
|
<TabsList className='flex flex-wrap'>
|
||||||
<TabsTrigger value='overview'>Overview</TabsTrigger>
|
<TabsTrigger value='overview'>Overview</TabsTrigger>
|
||||||
|
<TabsTrigger value='documents'>Documents</TabsTrigger>
|
||||||
|
<TabsTrigger value='customer-package'>Customer Package</TabsTrigger>
|
||||||
<TabsTrigger value='items'>Items</TabsTrigger>
|
<TabsTrigger value='items'>Items</TabsTrigger>
|
||||||
<TabsTrigger value='customers'>ผู้เกี่ยวข้องในโครงการ</TabsTrigger>
|
<TabsTrigger value='customers'>ผู้เกี่ยวข้องในโครงการ</TabsTrigger>
|
||||||
<TabsTrigger value='topics'>Topics</TabsTrigger>
|
<TabsTrigger value='topics'>Topics</TabsTrigger>
|
||||||
@@ -1419,7 +1233,7 @@ const canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes(
|
|||||||
<TabsTrigger value='attachments'>Attachments</TabsTrigger>
|
<TabsTrigger value='attachments'>Attachments</TabsTrigger>
|
||||||
<TabsTrigger value='activity'>Activity</TabsTrigger>
|
<TabsTrigger value='activity'>Activity</TabsTrigger>
|
||||||
<TabsTrigger value='approval'>Approval</TabsTrigger>
|
<TabsTrigger value='approval'>Approval</TabsTrigger>
|
||||||
<TabsTrigger value='preview'>Document Preview</TabsTrigger>
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value='overview'>
|
<TabsContent value='overview'>
|
||||||
@@ -1898,24 +1712,331 @@ const canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes(
|
|||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value='preview'>
|
<TabsContent value='documents'>
|
||||||
{canPreviewDocument ? (
|
<div className='flex flex-col gap-6'>
|
||||||
<QuotationDocumentPreview quotationId={quotationId} />
|
|
||||||
) : (
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Document Preview</CardTitle>
|
<CardTitle>Documents</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Preview permission is required to load document data.
|
Working documents, official documents, and customer package follow the quotation lifecycle.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className='grid gap-4 xl:grid-cols-3'>
|
||||||
<EmptyState message='You do not have access to quotation document preview.' />
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
|
||||||
|
{canPreviewDocument ? (
|
||||||
|
<div id='working-document-preview' className='scroll-mt-24'>
|
||||||
|
<QuotationDocumentPreview quotationId={quotationId} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Working Document Preview</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Preview permission is required to load working document data.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<EmptyState message='You do not have access to quotation document preview.' />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</TabsContent>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -2044,3 +2165,8 @@ const canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes(
|
|||||||
</div>
|
</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', () => {
|
test('mapCustomerPackageErrorMessage rewrites missing document error', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
mapCustomerPackageErrorMessage(
|
mapCustomerPackageErrorMessage(
|
||||||
@@ -30,7 +37,7 @@ test('mapCustomerPackageErrorMessage rewrites forbidden error', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('getCustomerPackageRoleLabel falls back to humanized labels', () => {
|
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');
|
assert.equal(getCustomerPackageRoleLabel('custom_appendix'), 'Custom Appendix');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -55,6 +62,7 @@ test('getCustomerPackageUiStatus reflects generated, failed, and not-generated s
|
|||||||
}),
|
}),
|
||||||
'generated'
|
'generated'
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
getCustomerPackageUiStatus({
|
getCustomerPackageUiStatus({
|
||||||
customerPackage: null,
|
customerPackage: null,
|
||||||
@@ -62,6 +70,7 @@ test('getCustomerPackageUiStatus reflects generated, failed, and not-generated s
|
|||||||
}),
|
}),
|
||||||
'failed'
|
'failed'
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
getCustomerPackageUiStatus({
|
getCustomerPackageUiStatus({
|
||||||
customerPackage: null,
|
customerPackage: null,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { QuotationCustomerPackageSummary } from './api/types';
|
import type { QuotationCustomerPackageSummary } from './api/types';
|
||||||
|
|
||||||
const CUSTOMER_PACKAGE_ROLE_LABELS: Record<string, string> = {
|
const CUSTOMER_PACKAGE_ROLE_LABELS: Record<string, string> = {
|
||||||
main: 'Quotation',
|
main: 'Approved PDF',
|
||||||
sla: 'SLA',
|
sla: 'SLA',
|
||||||
warranty: 'Warranty',
|
warranty: 'Warranty',
|
||||||
datasheet: 'Datasheet',
|
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.';
|
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.';
|
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);
|
const missingRequiredDocument = normalized.match(/^Missing ([A-Z_ ]+) document/i);
|
||||||
if (missingRequiredDocument) {
|
if (missingRequiredDocument) {
|
||||||
const role = getCustomerPackageRoleLabel(missingRequiredDocument[1].toLowerCase());
|
const role = getCustomerPackageRoleLabel(missingRequiredDocument[1].toLowerCase());
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
buildQuotationDocumentData,
|
buildQuotationDocumentData,
|
||||||
getQuotationDocumentPreviewData,
|
getQuotationDocumentPreviewData,
|
||||||
} from '@/features/crm/quotations/document/server/service';
|
} 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 {
|
import type {
|
||||||
AssemblyPolicy,
|
AssemblyPolicy,
|
||||||
AssemblySourcePolicy,
|
AssemblySourcePolicy,
|
||||||
@@ -540,10 +540,12 @@ export async function generateAssembledQuotationPdf(
|
|||||||
policy,
|
policy,
|
||||||
sourceResolver: async (sourcePolicy) => {
|
sourceResolver: async (sourcePolicy) => {
|
||||||
if (sourcePolicy.sourceType === 'generated') {
|
if (sourcePolicy.sourceType === 'generated') {
|
||||||
const generated = await generateQuotationPdf(quotationId, organizationId);
|
const generated = await getStoredApprovedQuotationPdf(quotationId, organizationId, {
|
||||||
|
userId
|
||||||
|
});
|
||||||
const pageCount = await getPdfPageCount(
|
const pageCount = await getPdfPageCount(
|
||||||
generated.buffer,
|
generated.buffer,
|
||||||
'Generated quotation PDF',
|
'Approved quotation PDF',
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -558,7 +560,7 @@ export async function generateAssembledQuotationPdf(
|
|||||||
fileSize: generated.buffer.byteLength,
|
fileSize: generated.buffer.byteLength,
|
||||||
metadata: {
|
metadata: {
|
||||||
templateVersionId: generated.templateVersionId,
|
templateVersionId: generated.templateVersionId,
|
||||||
sourceDocumentType: 'quotation_pdf',
|
sourceDocumentType: 'approved_pdf',
|
||||||
},
|
},
|
||||||
buffer: generated.buffer,
|
buffer: generated.buffer,
|
||||||
};
|
};
|
||||||
@@ -579,7 +581,7 @@ export async function generateAssembledQuotationPdf(
|
|||||||
|
|
||||||
const metadata: QuotationAssemblyMetadata = {
|
const metadata: QuotationAssemblyMetadata = {
|
||||||
documentType: 'assembled_pdf',
|
documentType: 'assembled_pdf',
|
||||||
sourceDocumentType: 'quotation_pdf',
|
sourceDocumentType: 'approved_pdf',
|
||||||
sourceDocumentId: quotationId,
|
sourceDocumentId: quotationId,
|
||||||
sourceDocumentCode: preview.documentData.quotation.code,
|
sourceDocumentCode: preview.documentData.quotation.code,
|
||||||
assemblyPolicy: assembled.policy,
|
assemblyPolicy: assembled.policy,
|
||||||
|
|||||||
Reference in New Issue
Block a user