This commit is contained in:
phaichayon
2026-06-16 13:42:38 +07:00
parent 56683ee7b9
commit edee45375e
29 changed files with 4240 additions and 51 deletions

View File

@@ -52,6 +52,7 @@ import {
quotationCustomersOptions,
quotationFollowupsOptions,
quotationItemsOptions,
quotationKeys,
quotationRevisionsOptions,
quotationTopicsOptions
} from '../api/queries';
@@ -69,10 +70,12 @@ import type {
QuotationTopicRecord
} from '../api/types';
import { submitQuotationApprovalMutation } from '@/features/foundation/approval/mutations';
import { quotationDocumentKeys } from '@/features/crm/quotations/document/queries';
import { QuotationDocumentPreview } from './quotation-document-preview';
import { QuotationFormSheet } from './quotation-form-sheet';
import { QuotationApprovalTab } from './quotation-approval-tab';
import { QuotationStatusBadge } from './quotation-status-badge';
import { getQueryClient } from '@/lib/query-client';
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
return (
@@ -483,7 +486,10 @@ export function QuotationDetail({
activeBusinessRole,
isOrgAdmin,
currentUserId,
canPreviewDocument
canPreviewDocument,
canPreviewPdf,
canDownloadPdf,
canGenerateApprovedPdf
}: {
quotationId: string;
referenceData: QuotationReferenceData;
@@ -502,6 +508,9 @@ export function QuotationDetail({
isOrgAdmin: boolean;
currentUserId: string;
canPreviewDocument: boolean;
canPreviewPdf: boolean;
canDownloadPdf: boolean;
canGenerateApprovedPdf: boolean;
}) {
const { data } = useSuspenseQuery(quotationByIdOptions(quotationId));
const { data: itemsData } = useSuspenseQuery(quotationItemsOptions(quotationId));
@@ -634,6 +643,41 @@ export function QuotationDetail({
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to submit for approval')
});
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 branchMap = useMemo(
() => new Map(referenceData.branches.map((item) => [item.id, item.name])),
@@ -675,6 +719,8 @@ export function QuotationDetail({
);
const canSubmitCurrentQuotation =
canSubmitApproval && ['draft', 'revised'].includes(status?.code ?? '');
const canGenerateApprovedNow =
canGenerateApprovedPdf && (status?.code ?? '') === 'approved';
return (
<div className='space-y-6'>
@@ -856,6 +902,36 @@ export function QuotationDetail({
<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={`/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>
@@ -1246,6 +1322,8 @@ export function QuotationDetail({
<FieldItem label='Created At' value={new Date(quotation.createdAt).toLocaleString()} />
<FieldItem label='Updated At' value={new Date(quotation.updatedAt).toLocaleString()} />
<FieldItem label='Revision' value={`R${String(quotation.revision).padStart(2, '0')}`} />
<FieldItem label='Approved At' value={quotation.approvedAt ? new Date(quotation.approvedAt).toLocaleString() : null} />
<FieldItem label='Approved Template Version' value={quotation.approvedTemplateVersionId} />
</CardContent>
</Card>
</div>