commit task-p.7.2.1
This commit is contained in:
@@ -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
|
||||
}: {
|
||||
@@ -901,20 +915,13 @@ export function QuotationDetail({
|
||||
toast.error(message);
|
||||
}
|
||||
});
|
||||
const downloadCustomerPackage = useMutation({
|
||||
mutationFn: async () => downloadQuotationCustomerPackage(quotationId),
|
||||
onSuccess: async ({ blob, fileName }) => {
|
||||
setCustomerPackageError(null);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = fileName;
|
||||
document.body.append(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success('Customer package downloaded successfully');
|
||||
},
|
||||
const downloadCustomerPackage = useMutation({
|
||||
mutationFn: async () => downloadQuotationCustomerPackage(quotationId),
|
||||
onSuccess: async ({ blob, fileName }) => {
|
||||
setCustomerPackageError(null);
|
||||
downloadBlobFile(blob, fileName);
|
||||
toast.success('Customer package downloaded successfully');
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = mapCustomerPackageErrorMessage(
|
||||
error instanceof Error ? error.message : null
|
||||
@@ -923,51 +930,15 @@ export function QuotationDetail({
|
||||
toast.error(message);
|
||||
}
|
||||
});
|
||||
const generateApprovedPdf = useMutation({
|
||||
mutationFn: async () => {
|
||||
const response = await fetch(`/api/crm/quotations/${quotationId}/approved-pdf`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = (await response.json().catch(() => null)) as {
|
||||
message?: string;
|
||||
} | null;
|
||||
throw new Error(payload?.message ?? 'Failed to generate approved PDF');
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
},
|
||||
onSuccess: async (blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = `${quotation.code}-approved.pdf`;
|
||||
document.body.append(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
const queryClient = getQueryClient();
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: quotationKeys.detail(quotationId)
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: quotationKeys.attachments(quotationId)
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: quotationDocumentKeys.data(quotationId)
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: quotationDocumentKeys.preview(quotationId)
|
||||
})
|
||||
]);
|
||||
toast.success('Approved PDF generated successfully');
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to generate approved PDF')
|
||||
});
|
||||
const downloadApprovedPdf = useMutation({
|
||||
mutationFn: async () => downloadApprovedQuotationPdf(quotationId),
|
||||
onSuccess: ({ blob, fileName }) => {
|
||||
downloadBlobFile(blob, fileName);
|
||||
toast.success('Approved PDF downloaded successfully');
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to download approved PDF')
|
||||
});
|
||||
|
||||
const branchMap = useMemo(
|
||||
() => new Map(referenceData.branches.map((item) => [item.id, item.name])),
|
||||
@@ -1005,7 +976,9 @@ const customer = customerMap.get(quotation.customerId);
|
||||
const contact = quotation.contactId ? contactMap.get(quotation.contactId) : null;
|
||||
const status = statusMap.get(quotation.status);
|
||||
const customerPackage = quotation.customerPackage;
|
||||
const customerPackageActionLabel = customerPackage ? 'Regenerate Package' : 'Generate Customer Package';
|
||||
const hasOfficialDocument = Boolean(quotation.approvedPdfUrl);
|
||||
const officialDocumentPreviewHref = `/api/crm/quotations/${quotationId}/approved-pdf`;
|
||||
const customerPackageActionLabel = customerPackage ? 'Regenerate Package' : 'Generate Package';
|
||||
const customerPackageStatusLabel =
|
||||
customerPackageStatus === 'generated'
|
||||
? 'Generated'
|
||||
@@ -1017,13 +990,14 @@ const customerPackageStatusDescription =
|
||||
? 'Customer-facing assembled PDF is ready.'
|
||||
: customerPackageStatus === 'failed'
|
||||
? customerPackageError
|
||||
: 'Generate the package before downloading or previewing it.';
|
||||
: hasOfficialDocument
|
||||
? 'Generate the package from the approved PDF and active document library files.'
|
||||
: 'Customer package requires an approved PDF before it can be generated.';
|
||||
const canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes(
|
||||
status?.code ?? ''
|
||||
);
|
||||
const canSubmitCurrentQuotation =
|
||||
canSubmitApproval && ['draft', 'revised'].includes(status?.code ?? '');
|
||||
const canGenerateApprovedNow = canGenerateApprovedPdf && (status?.code ?? '') === 'approved';
|
||||
|
||||
return (
|
||||
<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,24 +1712,331 @@ const canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes(
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='preview'>
|
||||
{canPreviewDocument ? (
|
||||
<QuotationDocumentPreview quotationId={quotationId} />
|
||||
) : (
|
||||
<TabsContent value='documents'>
|
||||
<div className='flex flex-col gap-6'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Document Preview</CardTitle>
|
||||
<CardTitle>Documents</CardTitle>
|
||||
<CardDescription>
|
||||
Preview permission is required to load document data.
|
||||
Working documents, official documents, and customer package follow the quotation lifecycle.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<EmptyState message='You do not have access to quotation document preview.' />
|
||||
<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>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>
|
||||
</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());
|
||||
|
||||
Reference in New Issue
Block a user