task-p.7.1.1

This commit is contained in:
phaichayon
2026-06-30 20:42:28 +07:00
parent e5766ea311
commit 8feaee61cb
29 changed files with 2673 additions and 875 deletions

View File

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