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

@@ -79,6 +79,10 @@ export interface QuotationRecord {
isSent: boolean;
sentAt: string | null;
sentVia: string | null;
approvedAt: string | null;
approvedPdfUrl: string | null;
approvedSnapshot: unknown | null;
approvedTemplateVersionId: string | null;
acceptedAt: string | null;
rejectedAt: string | null;
rejectionReason: string | null;

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>

View File

@@ -345,15 +345,19 @@ export async function getQuotationDocumentPreviewData(
export async function prepareApprovedQuotationSnapshot(
quotationId: string,
organizationId: string
organizationId: string,
userId: string
): Promise<ApprovedQuotationSnapshot> {
const preview = await getQuotationDocumentPreviewData(quotationId, organizationId);
const generatedAt = new Date().toISOString();
return {
quotationId,
approvedAt: preview.documentData.approval.approvedAt,
documentData: preview.documentData,
templateVersionId: preview.template.version.id,
templateInput: preview.templateInput
templateInput: preview.templateInput,
generatedAt,
generatedBy: userId
};
}

View File

@@ -150,4 +150,6 @@ export interface ApprovedQuotationSnapshot {
documentData: QuotationDocumentData;
templateVersionId: string;
templateInput: Record<string, unknown>;
generatedAt: string;
generatedBy: string;
}

View File

@@ -1,5 +1,43 @@
import * as z from 'zod';
function coerceOptionalNumber(message: string) {
return z.preprocess((value) => {
if (value === '' || value === null || value === undefined) {
return undefined;
}
if (typeof value === 'number') {
return value;
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
return Number(trimmed);
}
return value;
}, z.number().refine((value) => !Number.isNaN(value), message).optional());
}
function coerceRequiredNumber(message: string) {
return z.preprocess((value) => {
if (typeof value === 'number') {
return value;
}
if (typeof value === 'string') {
return Number(value.trim());
}
return value;
}, z.number().refine((value) => !Number.isNaN(value), message));
}
export const quotationSchema = z.object({
enquiryId: z.string().optional().nullable(),
customerId: z.string().min(1, 'Please select a customer'),
@@ -12,14 +50,11 @@ export const quotationSchema = z.object({
attention: z.string().optional(),
branchId: z.string().optional().nullable(),
currency: z.string().min(1, 'Please select a currency'),
exchangeRate: z.number().nullable().optional(),
exchangeRate: coerceOptionalNumber('Exchange rate must be a number'),
status: z.string().min(1, 'Please select a status'),
chancePercent: z
.number()
.nullable()
.optional()
chancePercent: coerceOptionalNumber('Chance percent must be a number')
.refine(
(value) => value === null || value === undefined || (value >= 0 && value <= 100),
(value) => value === undefined || (value >= 0 && value <= 100),
'Chance percent must be between 0 and 100'
),
isHotProject: z.boolean().default(false),
@@ -27,9 +62,9 @@ export const quotationSchema = z.object({
reference: z.string().optional(),
notes: z.string().optional(),
salesmanId: z.string().optional().nullable(),
discount: z.number().nullable().optional(),
discount: coerceOptionalNumber('Discount must be a number'),
discountType: z.string().optional().nullable(),
taxRate: z.number().nullable().optional(),
taxRate: coerceOptionalNumber('Tax rate must be a number'),
isActive: z.boolean().default(true),
sentVia: z.string().optional().nullable(),
revisionRemark: z.string().optional().nullable()
@@ -38,14 +73,26 @@ export const quotationSchema = z.object({
export const quotationItemSchema = z.object({
productType: z.string().min(1, 'Please select a product type'),
description: z.string().min(2, 'Description must be at least 2 characters'),
quantity: z.number().positive('Quantity must be greater than 0'),
quantity: coerceRequiredNumber('Quantity must be a number').refine(
(value) => value > 0,
'Quantity must be greater than 0'
),
unit: z.string().optional(),
unitPrice: z.number().min(0, 'Unit price must be 0 or greater'),
discount: z.number().min(0).nullable().optional(),
unitPrice: coerceRequiredNumber('Unit price must be a number').refine(
(value) => value >= 0,
'Unit price must be 0 or greater'
),
discount: coerceOptionalNumber('Discount must be a number').refine(
(value) => value === undefined || value >= 0,
'Discount must be 0 or greater'
),
discountType: z.string().nullable().optional(),
taxRate: z.number().min(0).nullable().optional(),
taxRate: coerceOptionalNumber('Tax rate must be a number').refine(
(value) => value === undefined || value >= 0,
'Tax rate must be 0 or greater'
),
notes: z.string().optional(),
sortOrder: z.number().nullable().optional()
sortOrder: coerceOptionalNumber('Sort order must be a number')
});
export const quotationCustomerSchema = z.object({
@@ -58,13 +105,13 @@ export const quotationTopicSchema = z.object({
id: z.string().optional(),
topicType: z.string().min(1, 'Please select a topic type'),
title: z.string().min(2, 'Title must be at least 2 characters'),
sortOrder: z.number().nullable().optional(),
sortOrder: coerceOptionalNumber('Sort order must be a number'),
items: z
.array(
z.object({
id: z.string().optional(),
content: z.string().min(1, 'Topic item content is required'),
sortOrder: z.number().nullable().optional()
sortOrder: coerceOptionalNumber('Sort order must be a number')
})
)
.min(1, 'At least one topic item is required')
@@ -86,7 +133,7 @@ export const quotationAttachmentSchema = z.object({
fileName: z.string().min(1, 'Stored file name is required'),
originalFileName: z.string().min(1, 'Original file name is required'),
filePath: z.string().min(1, 'File path is required'),
fileSize: z.number().nullable().optional(),
fileSize: coerceOptionalNumber('File size must be a number'),
fileType: z.string().nullable().optional(),
description: z.string().optional()
});

View File

@@ -131,6 +131,10 @@ function mapQuotationRecord(row: typeof crmQuotations.$inferSelect): QuotationRe
isSent: row.isSent,
sentAt: row.sentAt?.toISOString() ?? null,
sentVia: row.sentVia,
approvedAt: row.approvedAt?.toISOString() ?? null,
approvedPdfUrl: row.approvedPdfUrl,
approvedSnapshot: row.approvedSnapshot ?? null,
approvedTemplateVersionId: row.approvedTemplateVersionId,
acceptedAt: row.acceptedAt?.toISOString() ?? null,
rejectedAt: row.rejectedAt?.toISOString() ?? null,
rejectionReason: row.rejectionReason,
@@ -1024,6 +1028,10 @@ export async function createQuotation(
salesmanId: payload.salesmanId ?? null,
isSent: false,
sentVia: payload.sentVia ?? null,
approvedAt: null,
approvedPdfUrl: null,
approvedSnapshot: null,
approvedTemplateVersionId: null,
isActive: payload.isActive ?? true,
createdBy: userId,
updatedBy: userId
@@ -1765,6 +1773,10 @@ export async function createQuotationRevision(
salesmanId: parent.salesmanId,
isSent: false,
sentVia: parent.sentVia,
approvedAt: null,
approvedPdfUrl: null,
approvedSnapshot: null,
approvedTemplateVersionId: null,
isActive: parent.isActive,
createdBy: userId,
updatedBy: userId