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

@@ -19,8 +19,6 @@ const enquiryRequestSchema = enquirySchema.extend({
contactId: z.string().nullable().optional(),
branchId: z.string().nullable().optional(),
leadChannel: z.string().nullable().optional(),
estimatedValue: z.number().nullable().optional(),
chancePercent: z.number().nullable().optional(),
expectedCloseDate: z.string().nullable().optional()
});

View File

@@ -10,8 +10,6 @@ const enquiryRequestSchema = enquirySchema.extend({
contactId: z.string().nullable().optional(),
branchId: z.string().nullable().optional(),
leadChannel: z.string().nullable().optional(),
estimatedValue: z.number().nullable().optional(),
chancePercent: z.number().nullable().optional(),
expectedCloseDate: z.string().nullable().optional()
});

View File

@@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from 'next/server';
import { auditAction } from '@/features/foundation/audit-log/service';
import {
generateApprovedQuotationPdf,
getStoredApprovedQuotationPdf
} from '@/features/foundation/pdf-generator/server/service';
import { getQuotationDetail } from '@/features/crm/quotations/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationPdfDownload
});
const pdf = await getStoredApprovedQuotationPdf(id, organization.id);
return new NextResponse(pdf.buffer, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `inline; filename="${pdf.fileName}"`
}
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to load approved PDF' }, { status: 500 });
}
}
export async function POST(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationPdfGenerateApproved
});
const before = await getQuotationDetail(id, organization.id);
const pdf = await generateApprovedQuotationPdf(id, organization.id, session.user.id);
const after = await getQuotationDetail(id, organization.id);
await auditAction({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_quotation_pdf_generate_approved',
entityId: id,
action: 'generate_approved',
beforeData: before,
afterData: {
approvedPdfUrl: after.approvedPdfUrl,
approvedTemplateVersionId: after.approvedTemplateVersionId
}
});
return new NextResponse(pdf.buffer, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${pdf.fileName}"`
}
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to generate approved PDF' }, { status: 500 });
}
}

View File

@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
import { generateQuotationPdf } from '@/features/foundation/pdf-generator/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationPdfDownload
});
const pdf = await generateQuotationPdf(id, organization.id);
return new NextResponse(pdf.buffer, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${pdf.fileName}"`
}
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to generate quotation PDF' }, { status: 500 });
}
}

View File

@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
import { generateQuotationPreviewPdf } from '@/features/foundation/pdf-generator/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireOrganizationAccess({
permission: PERMISSIONS.crmQuotationPdfPreview
});
const pdf = await generateQuotationPreviewPdf(id, organization.id);
return new NextResponse(pdf.buffer, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `inline; filename="${pdf.fileName}"`
}
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to generate preview PDF' }, { status: 500 });
}
}

View File

@@ -91,6 +91,21 @@ export default async function QuotationDetailRoute({ params }: PageProps) {
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationDocumentPreview)));
const canPreviewPdf =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationPdfPreview)));
const canDownloadPdf =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationPdfDownload)));
const canGenerateApprovedPdf =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationPdfGenerateApproved)));
const queryClient = getQueryClient();
if (canRead) {
@@ -149,6 +164,9 @@ export default async function QuotationDetailRoute({ params }: PageProps) {
isOrgAdmin={isOrgAdmin}
currentUserId={session?.user?.id ?? ''}
canPreviewDocument={canPreviewDocument}
canPreviewPdf={canPreviewPdf}
canDownloadPdf={canDownloadPdf}
canGenerateApprovedPdf={canGenerateApprovedPdf}
/>
</HydrationBoundary>
) : null}

View File

@@ -31,6 +31,7 @@ import {
IconDeviceLaptop,
IconDots,
IconDotsVertical,
IconDownload,
IconEdit,
IconExternalLink,
IconEyeOff,
@@ -164,6 +165,7 @@ export const Icons = {
add: IconPlus,
edit: IconEdit,
upload: IconUpload,
download: IconDownload,
share: IconShare,
login: IconLogin,
logout: IconLogout,

View File

@@ -284,6 +284,10 @@ export const crmQuotations = pgTable(
isSent: boolean('is_sent').default(false).notNull(),
sentAt: timestamp('sent_at', { withTimezone: true }),
sentVia: text('sent_via'),
approvedAt: timestamp('approved_at', { withTimezone: true }),
approvedPdfUrl: text('approved_pdf_url'),
approvedSnapshot: jsonb('approved_snapshot'),
approvedTemplateVersionId: text('approved_template_version_id'),
acceptedAt: timestamp('accepted_at', { withTimezone: true }),
rejectedAt: timestamp('rejected_at', { withTimezone: true }),
rejectionReason: text('rejection_reason'),

View File

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

View File

@@ -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(),

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

View File

@@ -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));
}

View 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);
}

View File

@@ -57,7 +57,10 @@ export const PERMISSIONS = {
crmDocumentTemplateCreate: 'crm.document_template.create',
crmDocumentTemplateUpdate: 'crm.document_template.update',
crmDocumentTemplateDelete: 'crm.document_template.delete',
crmQuotationDocumentPreview: 'crm.quotation.document.preview'
crmQuotationDocumentPreview: 'crm.quotation.document.preview',
crmQuotationPdfPreview: 'crm.quotation.pdf.preview',
crmQuotationPdfDownload: 'crm.quotation.pdf.download',
crmQuotationPdfGenerateApproved: 'crm.quotation.pdf.generate_approved'
} as const;
export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
@@ -104,7 +107,10 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
PERMISSIONS.crmDocumentTemplateCreate,
PERMISSIONS.crmDocumentTemplateUpdate,
PERMISSIONS.crmDocumentTemplateDelete,
PERMISSIONS.crmQuotationDocumentPreview
PERMISSIONS.crmQuotationDocumentPreview,
PERMISSIONS.crmQuotationPdfPreview,
PERMISSIONS.crmQuotationPdfDownload,
PERMISSIONS.crmQuotationPdfGenerateApproved
];
}
@@ -117,7 +123,9 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
PERMISSIONS.crmQuotationRead,
PERMISSIONS.crmApprovalRead,
PERMISSIONS.crmDocumentTemplateRead,
PERMISSIONS.crmQuotationDocumentPreview
PERMISSIONS.crmQuotationDocumentPreview,
PERMISSIONS.crmQuotationPdfPreview,
PERMISSIONS.crmQuotationPdfDownload
];
}