task-p.7.1.1
This commit is contained in:
@@ -40,6 +40,7 @@ import {
|
||||
createQuotationAttachmentMutation,
|
||||
createQuotationCustomerMutation,
|
||||
createQuotationFollowupMutation,
|
||||
generateQuotationCustomerPackageMutation,
|
||||
createQuotationItemMutation,
|
||||
createQuotationRevisionMutation,
|
||||
createQuotationTopicMutation,
|
||||
@@ -64,10 +65,12 @@ import {
|
||||
quotationRevisionsOptions,
|
||||
quotationTopicsOptions
|
||||
} from '../api/queries';
|
||||
import { downloadQuotationCustomerPackage } from '../api/service';
|
||||
import type {
|
||||
QuotationAttachmentMutationPayload,
|
||||
QuotationAttachmentRecord,
|
||||
QuotationCustomerListItem,
|
||||
QuotationCustomerPackageSource,
|
||||
QuotationCustomerMutationPayload,
|
||||
QuotationFollowupMutationPayload,
|
||||
QuotationFollowupRecord,
|
||||
@@ -79,6 +82,11 @@ import type {
|
||||
} from '../api/types';
|
||||
import { submitQuotationApprovalMutation } from '@/features/foundation/approval/mutations';
|
||||
import { quotationDocumentKeys } from '@/features/crm/quotations/document/queries';
|
||||
import {
|
||||
getCustomerPackageRoleLabel,
|
||||
getCustomerPackageUiStatus,
|
||||
mapCustomerPackageErrorMessage
|
||||
} from '../customer-package';
|
||||
import { QuotationDocumentPreview } from './quotation-document-preview';
|
||||
import { QuotationFormSheet } from './quotation-form-sheet';
|
||||
import { QuotationApprovalTab } from './quotation-approval-tab';
|
||||
@@ -102,6 +110,27 @@ function EmptyState({ message }: { message: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function CustomerPackageSourceItem({
|
||||
item
|
||||
}: {
|
||||
item: QuotationCustomerPackageSource;
|
||||
}) {
|
||||
return (
|
||||
<div className='rounded-lg border p-3'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<span className='font-medium'>{getCustomerPackageRoleLabel(item.role)}</span>
|
||||
<Badge variant='outline'>{item.sourceType === 'generated' ? 'Generated' : 'Library'}</Badge>
|
||||
</div>
|
||||
<div className='text-muted-foreground mt-2 text-sm'>{item.fileName}</div>
|
||||
<div className='text-muted-foreground mt-1 text-xs'>
|
||||
{item.pageCount ? `${formatNumber(item.pageCount)} page(s)` : 'Page count unavailable'}
|
||||
{item.fileSize ? ` • ${formatNumber(item.fileSize)} bytes` : ''}
|
||||
{item.checksum ? ` • ${item.checksum.slice(0, 16)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -673,7 +702,10 @@ export function QuotationDetail({
|
||||
currentUserId,
|
||||
canPreviewDocument,
|
||||
canPreviewPdf,
|
||||
canPreviewCustomerPackage,
|
||||
canDownloadPdf,
|
||||
canGenerateCustomerPackage,
|
||||
canDownloadCustomerPackage,
|
||||
canGenerateApprovedPdf
|
||||
}: {
|
||||
quotationId: string;
|
||||
@@ -694,7 +726,10 @@ export function QuotationDetail({
|
||||
currentUserId: string;
|
||||
canPreviewDocument: boolean;
|
||||
canPreviewPdf: boolean;
|
||||
canPreviewCustomerPackage: boolean;
|
||||
canDownloadPdf: boolean;
|
||||
canGenerateCustomerPackage: boolean;
|
||||
canDownloadCustomerPackage: boolean;
|
||||
canGenerateApprovedPdf: boolean;
|
||||
}) {
|
||||
const { data } = useSuspenseQuery(quotationByIdOptions(quotationId));
|
||||
@@ -705,6 +740,12 @@ export function QuotationDetail({
|
||||
const { data: attachmentsData } = useSuspenseQuery(quotationAttachmentsOptions(quotationId));
|
||||
const { data: revisionsData } = useSuspenseQuery(quotationRevisionsOptions(quotationId));
|
||||
const quotation = data.quotation;
|
||||
const [customerPackageError, setCustomerPackageError] = useState<string | null>(null);
|
||||
const [confirmRegenerateOpen, setConfirmRegenerateOpen] = useState(false);
|
||||
const customerPackageStatus = getCustomerPackageUiStatus({
|
||||
customerPackage: quotation.customerPackage,
|
||||
lastErrorMessage: customerPackageError
|
||||
});
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
|
||||
const [itemOpen, setItemOpen] = useState(false);
|
||||
@@ -844,6 +885,44 @@ export function QuotationDetail({
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to submit for approval')
|
||||
});
|
||||
const generateCustomerPackage = useMutation({
|
||||
...generateQuotationCustomerPackageMutation,
|
||||
onSuccess: () => {
|
||||
setCustomerPackageError(null);
|
||||
setConfirmRegenerateOpen(false);
|
||||
toast.success('Customer package generated successfully');
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = mapCustomerPackageErrorMessage(
|
||||
error instanceof Error ? error.message : null
|
||||
);
|
||||
setCustomerPackageError(message);
|
||||
setConfirmRegenerateOpen(false);
|
||||
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');
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = mapCustomerPackageErrorMessage(
|
||||
error instanceof Error ? error.message : null
|
||||
);
|
||||
setCustomerPackageError(message);
|
||||
toast.error(message);
|
||||
}
|
||||
});
|
||||
const generateApprovedPdf = useMutation({
|
||||
mutationFn: async () => {
|
||||
const response = await fetch(`/api/crm/quotations/${quotationId}/approved-pdf`, {
|
||||
@@ -918,16 +997,30 @@ export function QuotationDetail({
|
||||
() => new Map(referenceData.topicTypes.map((item) => [item.id, item])),
|
||||
[referenceData.topicTypes]
|
||||
);
|
||||
const followupTypeMap = useMemo(
|
||||
() => new Map(referenceData.followupTypes.map((item) => [item.id, item])),
|
||||
[referenceData.followupTypes]
|
||||
);
|
||||
const customer = customerMap.get(quotation.customerId);
|
||||
const contact = quotation.contactId ? contactMap.get(quotation.contactId) : null;
|
||||
const status = statusMap.get(quotation.status);
|
||||
const canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes(
|
||||
status?.code ?? ''
|
||||
);
|
||||
const followupTypeMap = useMemo(
|
||||
() => new Map(referenceData.followupTypes.map((item) => [item.id, item])),
|
||||
[referenceData.followupTypes]
|
||||
);
|
||||
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 customerPackageStatusLabel =
|
||||
customerPackageStatus === 'generated'
|
||||
? 'Generated'
|
||||
: customerPackageStatus === 'failed'
|
||||
? 'Failed'
|
||||
: 'Not Generated';
|
||||
const customerPackageStatusDescription =
|
||||
customerPackageStatus === 'generated'
|
||||
? 'Customer-facing assembled PDF is ready.'
|
||||
: customerPackageStatus === 'failed'
|
||||
? customerPackageError
|
||||
: 'Generate the package before downloading or previewing it.';
|
||||
const canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes(
|
||||
status?.code ?? ''
|
||||
);
|
||||
const canSubmitCurrentQuotation =
|
||||
canSubmitApproval && ['draft', 'revised'].includes(status?.code ?? '');
|
||||
const canGenerateApprovedNow = canGenerateApprovedPdf && (status?.code ?? '') === 'approved';
|
||||
@@ -1075,6 +1168,31 @@ export function QuotationDetail({
|
||||
onOpenChange={setEditOpen}
|
||||
referenceData={referenceData}
|
||||
/>
|
||||
<Dialog open={confirmRegenerateOpen} onOpenChange={setConfirmRegenerateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Regenerate Customer Package?</DialogTitle>
|
||||
<DialogDescription>
|
||||
A new assembled package artifact will replace the current active package for this quotation.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => setConfirmRegenerateOpen(false)}
|
||||
disabled={generateCustomerPackage.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
isLoading={generateCustomerPackage.isPending}
|
||||
onClick={() => generateCustomerPackage.mutate({ quotationId })}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className='flex flex-col gap-4 rounded-xl border p-5 lg:flex-row lg:items-start lg:justify-between'>
|
||||
<div className='space-y-3'>
|
||||
@@ -1162,6 +1280,131 @@ export function QuotationDetail({
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user