task-h
This commit is contained in:
@@ -44,11 +44,8 @@ function toDefaultValues(
|
||||
priority:
|
||||
enquiry?.priority ?? referenceData.priorities[1]?.id ?? referenceData.priorities[0]?.id ?? '',
|
||||
leadChannel: enquiry?.leadChannel ?? undefined,
|
||||
estimatedValue: enquiry?.estimatedValue ? String(enquiry.estimatedValue) : '',
|
||||
chancePercent:
|
||||
enquiry?.chancePercent !== null && enquiry?.chancePercent !== undefined
|
||||
? String(enquiry.chancePercent)
|
||||
: '',
|
||||
estimatedValue: enquiry?.estimatedValue ?? undefined,
|
||||
chancePercent: enquiry?.chancePercent ?? undefined,
|
||||
expectedCloseDate: enquiry?.expectedCloseDate ? enquiry.expectedCloseDate.slice(0, 10) : '',
|
||||
competitor: enquiry?.competitor ?? '',
|
||||
source: enquiry?.source ?? '',
|
||||
@@ -121,8 +118,10 @@ export function EnquiryFormSheet({
|
||||
status: value.status,
|
||||
priority: value.priority,
|
||||
leadChannel: value.leadChannel || null,
|
||||
estimatedValue: value.estimatedValue ? Number(value.estimatedValue) : null,
|
||||
chancePercent: value.chancePercent ? Number(value.chancePercent) : null,
|
||||
estimatedValue:
|
||||
typeof value.estimatedValue === 'number' ? value.estimatedValue : null,
|
||||
chancePercent:
|
||||
typeof value.chancePercent === 'number' ? value.chancePercent : null,
|
||||
expectedCloseDate: value.expectedCloseDate || null,
|
||||
competitor: value.competitor,
|
||||
source: value.source,
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
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());
|
||||
}
|
||||
|
||||
export const enquirySchema = z.object({
|
||||
customerId: z.string().min(1, 'Please select a customer'),
|
||||
contactId: z.string().optional().nullable(),
|
||||
@@ -13,18 +37,14 @@ export const enquirySchema = z.object({
|
||||
status: z.string().min(1, 'Please select a status'),
|
||||
priority: z.string().min(1, 'Please select a priority'),
|
||||
leadChannel: z.string().optional().nullable(),
|
||||
estimatedValue: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((value) => !value || !Number.isNaN(Number(value)), 'Estimated value must be a number'),
|
||||
chancePercent: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((value) => !value || !Number.isNaN(Number(value)), 'Chance percent must be a number')
|
||||
.refine(
|
||||
(value) => !value || (Number(value) >= 0 && Number(value) <= 100),
|
||||
'Chance percent must be between 0 and 100'
|
||||
),
|
||||
estimatedValue: coerceOptionalNumber('Estimated value must be a number').refine(
|
||||
(value) => value === undefined || value >= 0,
|
||||
'Estimated value must be 0 or greater'
|
||||
),
|
||||
chancePercent: coerceOptionalNumber('Chance percent must be a number').refine(
|
||||
(value) => value === undefined || (value >= 0 && value <= 100),
|
||||
'Chance percent must be between 0 and 100'
|
||||
),
|
||||
expectedCloseDate: z.string().optional().nullable(),
|
||||
competitor: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -150,4 +150,6 @@ export interface ApprovedQuotationSnapshot {
|
||||
documentData: QuotationDocumentData;
|
||||
templateVersionId: string;
|
||||
templateInput: Record<string, unknown>;
|
||||
generatedAt: string;
|
||||
generatedBy: string;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -314,13 +314,22 @@ async function syncQuotationStatusFromApproval(
|
||||
throw new AuthError(`Quotation status ${nextStatusCode} is not configured`, 400);
|
||||
}
|
||||
|
||||
const nextValues: Partial<typeof crmQuotations.$inferInsert> = {
|
||||
status: statusId,
|
||||
approvedAt: nextStatusCode === 'approved' ? new Date() : null,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: userId
|
||||
};
|
||||
|
||||
if (nextStatusCode !== 'approved') {
|
||||
nextValues.approvedPdfUrl = null;
|
||||
nextValues.approvedSnapshot = null;
|
||||
nextValues.approvedTemplateVersionId = null;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(crmQuotations)
|
||||
.set({
|
||||
status: statusId,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: userId
|
||||
})
|
||||
.set(nextValues)
|
||||
.where(eq(crmQuotations.id, quotationId));
|
||||
}
|
||||
|
||||
|
||||
313
src/features/foundation/pdf-generator/server/service.ts
Normal file
313
src/features/foundation/pdf-generator/server/service.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { Font, Template } from '@pdfme/common';
|
||||
import { generate } from '@pdfme/generator';
|
||||
import { image, line, table, text } from '@pdfme/schemas';
|
||||
import { and, eq, isNull } from 'drizzle-orm';
|
||||
import {
|
||||
crmQuotationAttachments,
|
||||
crmQuotations,
|
||||
organizations
|
||||
} from '@/db/schema';
|
||||
import { db } from '@/lib/db';
|
||||
import { AuthError } from '@/lib/auth/session';
|
||||
import {
|
||||
getQuotationDocumentPreviewData,
|
||||
prepareApprovedQuotationSnapshot
|
||||
} from '@/features/crm/quotations/document/server/service';
|
||||
import { getQuotationDetail } from '@/features/crm/quotations/server/service';
|
||||
|
||||
type GeneratePdfFromTemplateInput = {
|
||||
template: Template;
|
||||
inputs: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
type GeneratedQuotationPdf = {
|
||||
buffer: Buffer;
|
||||
fileName: string;
|
||||
templateVersionId: string;
|
||||
approvedPdfUrl?: string | null;
|
||||
};
|
||||
|
||||
const TEMPLATE_FONT_FALLBACKS = new Set(['cordia', 'cordiaBold']);
|
||||
|
||||
function getPdfPlugins() {
|
||||
return {
|
||||
text,
|
||||
image,
|
||||
line,
|
||||
table
|
||||
};
|
||||
}
|
||||
|
||||
async function resolvePdfFonts(): Promise<Font> {
|
||||
const regularPath = path.join(process.cwd(), 'public', 'fonts', 'cordia_new_r.ttf');
|
||||
const boldPath = path.join(process.cwd(), 'public', 'fonts', 'Cordia_New_Bold.ttf');
|
||||
|
||||
try {
|
||||
const [regularFontData, boldFontData] = await Promise.all([
|
||||
readFile(regularPath),
|
||||
readFile(boldPath)
|
||||
]);
|
||||
|
||||
return {
|
||||
cordia: {
|
||||
data: new Uint8Array(regularFontData),
|
||||
fallback: true
|
||||
},
|
||||
cordiaBold: {
|
||||
data: new Uint8Array(boldFontData)
|
||||
}
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTemplateFonts<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => normalizeTemplateFonts(item)) as T;
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
const nextObject = Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).map(([key, child]) => {
|
||||
if (
|
||||
key === 'fontName' &&
|
||||
typeof child === 'string' &&
|
||||
TEMPLATE_FONT_FALLBACKS.has(child)
|
||||
) {
|
||||
return [key, 'Roboto'];
|
||||
}
|
||||
|
||||
return [key, normalizeTemplateFonts(child)];
|
||||
})
|
||||
);
|
||||
|
||||
return nextObject as T;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function generatePdfFromTemplate(input: GeneratePdfFromTemplateInput) {
|
||||
const font = await resolvePdfFonts();
|
||||
const generatorInput = {
|
||||
template: input.template,
|
||||
inputs: input.inputs,
|
||||
plugins: getPdfPlugins(),
|
||||
options: Object.keys(font).length > 0 ? { font } : undefined
|
||||
};
|
||||
let pdf: Uint8Array;
|
||||
|
||||
try {
|
||||
pdf = await generate(generatorInput);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error.message.includes('Font "') || error.message.includes('fontKitFont.layout'))
|
||||
) {
|
||||
pdf = await generate({
|
||||
...generatorInput,
|
||||
template: normalizeTemplateFonts(input.template),
|
||||
options: undefined
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return Buffer.from(pdf);
|
||||
}
|
||||
|
||||
function toSafeFileName(value: string) {
|
||||
return value.replace(/[^a-zA-Z0-9._-]/g, '-');
|
||||
}
|
||||
|
||||
async function ensureGeneratedDirectory(organizationId: string) {
|
||||
const directory = path.join(
|
||||
process.cwd(),
|
||||
'public',
|
||||
'generated',
|
||||
'quotations',
|
||||
organizationId
|
||||
);
|
||||
await mkdir(directory, { recursive: true });
|
||||
return directory;
|
||||
}
|
||||
|
||||
async function persistApprovedQuotationArtifact(args: {
|
||||
quotationId: string;
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
fileName: string;
|
||||
buffer: Buffer;
|
||||
templateVersionId: string;
|
||||
}) {
|
||||
const quotation = await getQuotationDetail(args.quotationId, args.organizationId);
|
||||
const snapshot = await prepareApprovedQuotationSnapshot(
|
||||
args.quotationId,
|
||||
args.organizationId,
|
||||
args.userId
|
||||
);
|
||||
const directory = await ensureGeneratedDirectory(args.organizationId);
|
||||
const safeFileName = toSafeFileName(args.fileName);
|
||||
const absolutePath = path.join(directory, safeFileName);
|
||||
const publicPath = `/generated/quotations/${args.organizationId}/${safeFileName}`;
|
||||
|
||||
await writeFile(absolutePath, args.buffer);
|
||||
const fileStats = await stat(absolutePath);
|
||||
|
||||
await db
|
||||
.update(crmQuotations)
|
||||
.set({
|
||||
approvedAt: quotation.approvedAt ? new Date(quotation.approvedAt) : new Date(),
|
||||
approvedPdfUrl: publicPath,
|
||||
approvedSnapshot: snapshot,
|
||||
approvedTemplateVersionId: args.templateVersionId,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: args.userId
|
||||
})
|
||||
.where(eq(crmQuotations.id, args.quotationId));
|
||||
|
||||
const existingAttachment = await db
|
||||
.select()
|
||||
.from(crmQuotationAttachments)
|
||||
.where(
|
||||
and(
|
||||
eq(crmQuotationAttachments.quotationId, args.quotationId),
|
||||
eq(crmQuotationAttachments.organizationId, args.organizationId),
|
||||
eq(crmQuotationAttachments.filePath, publicPath),
|
||||
isNull(crmQuotationAttachments.deletedAt)
|
||||
)
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (existingAttachment) {
|
||||
await db
|
||||
.update(crmQuotationAttachments)
|
||||
.set({
|
||||
fileName: safeFileName,
|
||||
originalFileName: safeFileName,
|
||||
filePath: publicPath,
|
||||
fileSize: fileStats.size,
|
||||
fileType: 'application/pdf',
|
||||
description: 'Approved quotation PDF',
|
||||
uploadedAt: new Date(),
|
||||
uploadedBy: args.userId,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(crmQuotationAttachments.id, existingAttachment.id));
|
||||
} else {
|
||||
await db.insert(crmQuotationAttachments).values({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId: args.organizationId,
|
||||
quotationId: args.quotationId,
|
||||
fileName: safeFileName,
|
||||
originalFileName: safeFileName,
|
||||
filePath: publicPath,
|
||||
fileSize: fileStats.size,
|
||||
fileType: 'application/pdf',
|
||||
description: 'Approved quotation PDF',
|
||||
uploadedBy: args.userId
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
publicPath,
|
||||
fileSize: fileStats.size,
|
||||
snapshot
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateQuotationPdf(
|
||||
quotationId: string,
|
||||
organizationId: string
|
||||
): Promise<GeneratedQuotationPdf> {
|
||||
const preview = await getQuotationDocumentPreviewData(quotationId, organizationId);
|
||||
const buffer = await generatePdfFromTemplate({
|
||||
template: preview.template.version.schemaJson as Template,
|
||||
inputs: [preview.templateInput]
|
||||
});
|
||||
|
||||
return {
|
||||
buffer,
|
||||
fileName: `${preview.documentData.quotation.code}.pdf`,
|
||||
templateVersionId: preview.template.version.id
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateQuotationPreviewPdf(
|
||||
quotationId: string,
|
||||
organizationId: string
|
||||
) {
|
||||
return generateQuotationPdf(quotationId, organizationId);
|
||||
}
|
||||
|
||||
export async function generateApprovedQuotationPdf(
|
||||
quotationId: string,
|
||||
organizationId: string,
|
||||
userId: string
|
||||
): Promise<GeneratedQuotationPdf> {
|
||||
const preview = await getQuotationDocumentPreviewData(quotationId, organizationId);
|
||||
|
||||
if (preview.documentData.quotation.statusCode !== 'approved') {
|
||||
throw new AuthError('Only approved quotations can generate approved PDF', 400);
|
||||
}
|
||||
|
||||
const buffer = await generatePdfFromTemplate({
|
||||
template: preview.template.version.schemaJson as Template,
|
||||
inputs: [preview.templateInput]
|
||||
});
|
||||
const fileName = `${preview.documentData.quotation.code}-approved.pdf`;
|
||||
const artifact = await persistApprovedQuotationArtifact({
|
||||
quotationId,
|
||||
organizationId,
|
||||
userId,
|
||||
fileName,
|
||||
buffer,
|
||||
templateVersionId: preview.template.version.id
|
||||
});
|
||||
|
||||
return {
|
||||
buffer,
|
||||
fileName,
|
||||
templateVersionId: preview.template.version.id,
|
||||
approvedPdfUrl: artifact.publicPath
|
||||
};
|
||||
}
|
||||
|
||||
export async function getStoredApprovedQuotationPdf(
|
||||
quotationId: string,
|
||||
organizationId: string
|
||||
): Promise<GeneratedQuotationPdf> {
|
||||
const quotation = await getQuotationDetail(quotationId, organizationId);
|
||||
|
||||
if (!quotation.approvedPdfUrl) {
|
||||
throw new AuthError('Approved PDF has not been generated yet', 404);
|
||||
}
|
||||
|
||||
const relativePath = quotation.approvedPdfUrl.replace(/^\//, '');
|
||||
const absolutePath = path.join(process.cwd(), 'public', relativePath);
|
||||
const buffer = await readFile(absolutePath);
|
||||
|
||||
return {
|
||||
buffer,
|
||||
fileName: `${quotation.code}-approved.pdf`,
|
||||
templateVersionId: quotation.approvedTemplateVersionId ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
export async function getOrganizationGeneratedPdfDirectory(organizationId: string) {
|
||||
const [organization] = await db
|
||||
.select()
|
||||
.from(organizations)
|
||||
.where(eq(organizations.id, organizationId))
|
||||
.limit(1);
|
||||
|
||||
if (!organization) {
|
||||
throw new AuthError('Organization not found', 404);
|
||||
}
|
||||
|
||||
return ensureGeneratedDirectory(organizationId);
|
||||
}
|
||||
Reference in New Issue
Block a user