From df1821982dc5d94526c5ce49e5a16c2358dcf000 Mon Sep 17 00:00:00 2001 From: phaichayon Date: Wed, 15 Jul 2026 05:46:59 +0700 Subject: [PATCH] commit --- package.json | 1 + scripts/reseed-pdf-template-version.ts | 11 + .../crm/quotations/[id]/pdf-preview/route.ts | 12 +- .../components/opportunity-form-sheet.tsx | 39 +- .../components/quotation-detail.tsx | 1600 ++++++----------- .../crm/quotations/document/server/service.ts | 413 ++--- .../document-template/server/service.ts | 115 +- .../foundation/document-template/types.ts | 19 +- .../pdf-generator/server/service.ts | 19 +- .../ALLA_template_pdfme_product_v1.json | 49 +- 10 files changed, 932 insertions(+), 1346 deletions(-) diff --git a/package.json b/package.json index 4cd9c27..42fdc51 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "audit:pdf:visual": "node --experimental-strip-types scripts/audit-pdf-visual-regression.ts", "audit:pdf:visual:baseline": "node --experimental-strip-types scripts/audit-pdf-visual-regression.ts --refresh-baseline", "audit:pdf": "npm run audit:pdf:inventory && npm run audit:pdf:coverage && npm run audit:pdf:runtime && npm run audit:pdf:versions && npm run audit:pdf:placeholders && npm run audit:pdf:parity && npm run audit:pdf:snapshot && npm run audit:pdf:report && npm run audit:pdf:integrity", + "pdf:seed:product": "node --disable-warning=ExperimentalWarning --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types scripts/reseed-pdf-template-version.ts --template-variant=product-v1", "pdf:activate:product": "node --experimental-strip-types scripts/reseed-pdf-template-version.ts --template-variant=product-v1 --activate" }, "dependencies": { diff --git a/scripts/reseed-pdf-template-version.ts b/scripts/reseed-pdf-template-version.ts index 54f43dc..b6506fd 100644 --- a/scripts/reseed-pdf-template-version.ts +++ b/scripts/reseed-pdf-template-version.ts @@ -311,6 +311,7 @@ async function main() { matchingVersion, }); const targetVersionId = matchingVersion?.id ?? randomUUID(); + const isProductVariant = options.variant === 'product-v1'; await sql.begin(async (tx) => { if (!matchingVersion) { @@ -322,6 +323,7 @@ async function main() { version, file_path, schema_json, + metadata_json, preview_image_url, is_active, deleted_at, @@ -333,6 +335,10 @@ async function main() { ${targetVersionLabel}, ${templateSource.relativePath}, ${JSON.stringify(targetSchema)}, + case + when ${isProductVariant} then jsonb_build_object('templateVariant', 'product-v1') + else null + end, ${null}, ${options.activate}, ${null}, @@ -344,6 +350,11 @@ async function main() { update crm_document_template_versions set file_path = ${templateSource.relativePath}, + metadata_json = case + when ${isProductVariant} + then coalesce(metadata_json, '{}'::jsonb) || jsonb_build_object('templateVariant', 'product-v1') + else metadata_json + end, is_active = ${options.activate ? true : matchingVersion.isActive}, updated_at = now() where id = ${targetVersionId} diff --git a/src/app/api/crm/quotations/[id]/pdf-preview/route.ts b/src/app/api/crm/quotations/[id]/pdf-preview/route.ts index 8275d6c..a87ce14 100644 --- a/src/app/api/crm/quotations/[id]/pdf-preview/route.ts +++ b/src/app/api/crm/quotations/[id]/pdf-preview/route.ts @@ -12,9 +12,15 @@ type Params = { params: Promise<{ id: string }>; }; -export async function GET(_request: NextRequest, { params }: Params) { +export async function GET(request: NextRequest, { params }: Params) { try { const { id } = await params; + const templateVariant = request.nextUrl.searchParams.get('templateVariant'); + + if (templateVariant && templateVariant !== 'product-v1') { + return NextResponse.json({ message: 'Unsupported template variant' }, { status: 400 }); + } + const { organization, session, membership } = await requireOrganizationAccess({ permission: PERMISSIONS.crmQuotationPdfPreview }); @@ -36,7 +42,9 @@ export async function GET(_request: NextRequest, { params }: Params) { return NextResponse.json({ message: 'Forbidden' }, { status: 403 }); } - const pdf = await generateQuotationPreviewPdf(id, organization.id); + const pdf = await generateQuotationPreviewPdf(id, organization.id, { + templateVariant + }); return new NextResponse(pdf.buffer, { headers: { diff --git a/src/features/crm/opportunities/components/opportunity-form-sheet.tsx b/src/features/crm/opportunities/components/opportunity-form-sheet.tsx index 6be94a9..0ad632b 100644 --- a/src/features/crm/opportunities/components/opportunity-form-sheet.tsx +++ b/src/features/crm/opportunities/components/opportunity-form-sheet.tsx @@ -33,6 +33,8 @@ import type { OpportunityMutationPayload, OpportunityRecord, OpportunityReferenc import { opportunitySchema, type OpportunityFormValues } from '../schemas/opportunity.schema'; import { isHotProjectSuggested } from '@/features/crm/shared/hot-project'; +const CHANCE_PERCENT_OPTIONS = [10, 25, 50, 75, 85] as const; + function toDefaultValues( opportunity: OpportunityRecord | undefined, referenceData: OpportunityReferenceData @@ -93,8 +95,7 @@ export function OpportunityFormSheet({ FormSelectField, FormSwitchField, FormDatePickerField, - FormCurrencyField, - FormPercentageField + FormCurrencyField } = useFormFields(); const projectPartiesQuery = useQuery({ ...opportunityProjectPartiesOptions(opportunity?.id ?? ''), @@ -395,10 +396,38 @@ form.setFieldValue('isHotProject', suggested); currencyLabel='THB' placeholder='2500000' /> - ( + + + โอกาสปิดการขาย % + + + + + )} /> -
{label}
-
{value ?? "-"}
+
+
{label}
+
{value ?? '-'}
); } function EmptyState({ message }: { message: string }) { return ( -
+
{message}
); @@ -130,7 +118,7 @@ function EmptyState({ message }: { message: string }) { function downloadBlobFile(blob: Blob, fileName: string) { const url = URL.createObjectURL(blob); - const anchor = document.createElement("a"); + const anchor = document.createElement('a'); anchor.href = url; anchor.download = fileName; document.body.append(anchor); @@ -139,28 +127,18 @@ function downloadBlobFile(blob: Blob, fileName: string) { URL.revokeObjectURL(url); } -function CustomerPackageSourceItem({ - item, -}: { - item: QuotationCustomerPackageSource; -}) { +function CustomerPackageSourceItem({ item }: { item: QuotationCustomerPackageSource }) { return ( -
-
- - {getCustomerPackageRoleLabel(item.role)} - - - {item.sourceType === "generated" ? "Generated" : "Library"} - +
+
+ {getCustomerPackageRoleLabel(item.role)} + {item.sourceType === 'generated' ? 'Generated' : 'Library'}
-
{item.fileName}
-
- {item.pageCount - ? `${formatNumber(item.pageCount)} page(s)` - : "Page count unavailable"} - {item.fileSize ? ` • ${formatNumber(item.fileSize)} bytes` : ""} - {item.checksum ? ` • ${item.checksum.slice(0, 16)}` : ""} +
{item.fileName}
+
+ {item.pageCount ? `${formatNumber(item.pageCount)} page(s)` : 'Page count unavailable'} + {item.fileSize ? ` • ${formatNumber(item.fileSize)} bytes` : ''} + {item.checksum ? ` • ${item.checksum.slice(0, 16)}` : ''}
); @@ -172,7 +150,7 @@ function ItemDialog({ onSubmit, pending, referenceData, - item, + item }: { open: boolean; onOpenChange: (open: boolean) => void; @@ -182,35 +160,31 @@ function ItemDialog({ item?: QuotationItemRecord; }) { const [values, setValues] = useState({ - productType: item?.productType ?? referenceData.productTypes[0]?.id ?? "", - description: item?.description ?? "", + productType: item?.productType ?? referenceData.productTypes[0]?.id ?? '', + description: item?.description ?? '', quantity: String(item?.quantity ?? 1), - unit: item?.unit ?? "", + unit: item?.unit ?? '', unitPrice: String(item?.unitPrice ?? 0), discount: String(item?.discount ?? 0), - discountType: item?.discountType ?? "", + discountType: item?.discountType ?? '', taxRate: String(item?.taxRate ?? 0), - notes: item?.notes ?? "", + notes: item?.notes ?? '' }); return ( - {item ? "Edit Item" : "Add Item"} - - Line items drive quotation totals from the server. - + {item ? 'Edit Item' : 'Add Item'} + Line items drive quotation totals from the server. -
+
- setValues((s) => ({ ...s, description: e.target.value })) - } - placeholder="Description" + onChange={(e) => setValues((s) => ({ ...s, description: e.target.value }))} + placeholder='Description' /> -
+
- setValues((s) => ({ ...s, quantity: value })) - } - placeholder="Quantity" + onChange={(value) => setValues((s) => ({ ...s, quantity: value }))} + placeholder='Quantity' />
-
+
- setValues((s) => ({ ...s, unitPrice: value })) - } - placeholder="Unit price" - currencyLabel="THB" + onChange={(value) => setValues((s) => ({ ...s, unitPrice: value }))} + placeholder='Unit price' + currencyLabel='THB' /> - setValues((s) => ({ ...s, discount: value })) - } - placeholder="Discount" - currencyLabel="THB" + onChange={(value) => setValues((s) => ({ ...s, discount: value }))} + placeholder='Discount' + currencyLabel='THB' />
-
+
- + {referenceData.customers.map((item) => ( @@ -390,7 +350,7 @@ function CustomerDialog({ setRemark(event.target.value)} - placeholder="Remark" + placeholder='Remark' />
-
-
-
-
- - - {quotation.code} - - + {quotation.code} + {quotation.isHotProject ? Hot : null}
-

- {quotation.projectName || "Untitled quotation"} +

+ {quotation.projectName || 'Untitled quotation'}

-

- {customer?.name ?? "Unknown customer"} - {contact ? ` - ${contact.name}` : ""} +

+ {customer?.name ?? 'Unknown customer'} + {contact ? ` - ${contact.name}` : ''}

-
+
{canSubmitCurrentQuotation ? ( ) : null} {canCreateRevision && canReviseByStatus ? ( ) : null} {canUpdate ? ( - ) : null}
-
-
+
+
- - + {/* + /> */} - - - Overview - Documents - - Customer Package - - Items - - ผู้เกี่ยวข้องในโครงการ - - Topics - Follow-ups - Attachments - Audit Log - Approval + + + Overview + Documents + Customer Package + Items + ผู้เกี่ยวข้องในโครงการ + Topics + Follow-ups + Attachments + Audit Log + Approval - + Overview @@ -1433,125 +1268,88 @@ const branchMap = useMemo( Commercial header data and quotation summary. - + + + - + - - + - + + + + - - - - -
-
-
- ผู้เกี่ยวข้องในโครงการ -
+
+
+
ผู้เกี่ยวข้องในโครงการ
{!customersData.items.length ? ( -
+
No project parties have been added yet.
) : ( -
+
{customersData.items.map((item) => ( -
-
- {item.customerName} -
-
+
+
{item.customerName}
+
Role: {item.roleLabel ?? item.roleCode}
- {item.remark ? ( -
{item.remark}
- ) : null} + {item.remark ?
{item.remark}
: null}
))}
)}
-
+
-
+
-
- +
+
- + - +
Items @@ -1565,48 +1363,45 @@ const branchMap = useMemo( setItemOpen(true); }} > - Add Item + Add Item ) : null} - + {!itemsData.items.length ? ( - + ) : ( itemsData.items.map((item) => ( -
-
-
-
- {item.description} -
-
- Qty {item.quantity} {item.unit || ""} x{" "} +
+
+
+
{item.description}
+
+ Qty {item.quantity} {item.unit || ''} x{' '} {formatNumber(item.unitPrice)}
-
+
Total {formatNumber(item.totalPrice)}
{canManageItems ? ( -
+
) : null} @@ -1618,14 +1413,13 @@ const branchMap = useMemo( - + - +
ผู้เกี่ยวข้องในโครงการ - Related companies for this quotation, each with a - visible role. + Related companies for this quotation, each with a visible role.
{canManageCustomers ? ( @@ -1635,51 +1429,48 @@ const branchMap = useMemo( setCustomerOpen(true); }} > - Add Party + Add Party ) : null}
- + {!customersData.items.length ? ( - + ) : ( customersData.items.map((item) => (
-
- {item.customerName} -
-
+
{item.customerName}
+
Role: {item.roleLabel ?? item.roleCode}
{item.remark ? ( -
+
{item.remark}
) : null}
{canManageCustomers ? ( -
+
) : null} @@ -1690,14 +1481,13 @@ const branchMap = useMemo( - + - +
Topics - Scope, exclusions, payment terms, and other document - sections. + Scope, exclusions, payment terms, and other document sections.
{canManageTopics ? ( @@ -1707,48 +1497,46 @@ const branchMap = useMemo( setTopicOpen(true); }} > - Add Topic + Add Topic ) : null}
- + {!topicsData.items.length ? ( - + ) : ( topicsData.items.map((topic) => ( -
-
+
+
-
{topic.title}
-
- {topicTypeMap.get(topic.topicType)?.label ?? - topic.topicType} +
{topic.title}
+
+ {topicTypeMap.get(topic.topicType)?.label ?? topic.topicType}
{canManageTopics ? ( -
+
) : null}
-
    +
      {topic.items.map((item) => (
    • {item.content}
    • ))} @@ -1760,14 +1548,12 @@ const branchMap = useMemo( - + - +
      Follow-ups - - Commercial chasing and response history. - + Commercial chasing and response history.
      {canManageFollowups ? ( ) : null}
      - + {!followupsData.items.length ? ( - + ) : ( followupsData.items.map((item) => ( -
      -
      -
      -
      - {followupTypeMap.get(item.followupType) - ?.label ?? item.followupType} +
      +
      +
      +
      + {followupTypeMap.get(item.followupType)?.label ?? + item.followupType}
      -
      +
      {formatDate(item.followupDate)} - {item.outcome ? ` - ${item.outcome}` : ""} -
      -
      - {item.notes || "-"} + {item.outcome ? ` - ${item.outcome}` : ''}
      +
      {item.notes || '-'}
      {canManageFollowups ? ( -
      +
      ) : null} @@ -1830,14 +1613,13 @@ const branchMap = useMemo( - + - +
      Attachments - Metadata only in Task E, ready for storage integration - later. + Metadata only in Task E, ready for storage integration later.
      {canManageAttachments ? ( @@ -1847,49 +1629,44 @@ const branchMap = useMemo( setAttachmentOpen(true); }} > - Add Attachment + Add Attachment ) : null}
      - + {!attachmentsData.items.length ? ( - + ) : ( attachmentsData.items.map((item) => (
      -
      - {item.originalFileName} -
      -
      - {item.fileType || "Unknown type"} - {item.fileSize - ? ` - ${formatNumber(item.fileSize)} bytes` - : ""} +
      {item.originalFileName}
      +
      + {item.fileType || 'Unknown type'} + {item.fileSize ? ` - ${formatNumber(item.fileSize)} bytes` : ''}
      {canManageAttachments ? ( -
      +
      ) : null} @@ -1900,15 +1677,15 @@ const branchMap = useMemo( - + - + - -
      + +
      Documents - Working documents, official documents, and customer - package follow the quotation lifecycle. + Working quotation previews generated from live data. - - {/* + + - Working Documents + Quotation PDF Preview - Internal preview generated from current quotation data. This is not the customer-deliverable record. + Working preview generated from current quotation data. Approval and + customer package generation are not required. - -
      - Working documents always reflect live quotation data and can be regenerated anytime for internal review. +
      + + +
      {canPreviewPdf ? ( ) : ( + )} + {canPreviewPdf ? ( + + ) : ( + )} {canPreviewDocument ? ( - ) : ( - - )} -
      - - */} - - {/* - - Official Documents - - Immutable approved record stored from the approved snapshot and used for audit and revision history. - - - - - - -
      - {hasOfficialDocument - ? 'Official documents are read-only and represent the approved business record.' - : 'Official documents become available after approval completes and the approved PDF exists.'} -
      -
      - {canDownloadPdf && hasOfficialDocument ? ( - - ) : ( - - )} - {canDownloadPdf ? ( - ) : null}
      -
      */} - - - - PDF - - Customer-deliverable package assembled from the - approved PDF plus active document library - appendices. - - - - - - -
      - {customerPackageStatusDescription} -
      -
      - {customerPackage?.includedDocuments.map( - (item) => ( - - {getCustomerPackageRoleLabel(item.role)} - - ), - )} -
      -
      -{canGenerateApprovedPdf && !hasOfficialDocument ? ( - -) : null} -{canGenerateCustomerPackage ? ( - - ) : null} - {canPreviewCustomerPackage && customerPackage ? ( - - ) : ( - - )} - {canDownloadCustomerPackage ? ( - - ) : null} - -
      -
      {canPreviewDocument ? ( -
      +
      ) : ( @@ -2143,99 +1775,91 @@ customerPackage Working Document Preview - Preview permission is required to load working - document data. + Preview permission is required to load working document data. - + )}
      - + - +
      Customer Package - Operational detail for the assembled - customer-deliverable package. + Operational detail for the assembled customer-deliverable package.
      -
      -{canGenerateApprovedPdf && !hasOfficialDocument ? ( - -) : null} -{canGenerateCustomerPackage ? ( - + ) : null} + {canGenerateCustomerPackage ? ( + ) : null} {canPreviewCustomerPackage && customerPackage ? ( - ) : ( - )} {canDownloadCustomerPackage ? ( ) : null}
      - + {!canGenerateCustomerPackage && !canDownloadCustomerPackage && !canPreviewCustomerPackage ? ( - + ) : ( <> -
      +
      + -
      -
      +
      {customerPackageStatusDescription}
      {customerPackage?.warnings.length ? ( -
      -
      - Warnings -
      +
      +
      Warnings
      {customerPackage.warnings.map((warning) => (
      {warning.message}
      ))}
      ) : null} -
      -
      - Included Documents -
      +
      +
      Included Documents
      {!customerPackage?.includedDocuments.length ? ( - + ) : ( -
      - {customerPackage.includedDocuments.map( - (item) => ( - - ), - )} +
      + {customerPackage.includedDocuments.map((item) => ( + + ))}
      )}
      @@ -2337,7 +1953,7 @@ customerPackage
      -
      +
      Revision Chain @@ -2345,24 +1961,24 @@ customerPackage Family of quotations copied from the same parent record. - + {!revisionsData.items.length ? ( - + ) : ( revisionsData.items.map((item) => ( -
      +
      -
      {item.code}
      -
      +
      {item.code}
      +
      {formatNumber(item.totalAmount)}
      - +
      )) @@ -2373,49 +1989,37 @@ customerPackage Record Snapshot - - Operational metadata for this quotation row. - + Operational metadata for this quotation row. - + + + - - -
      -
      - Artifact Status -
      -
      +
      +
      Artifact Status
      +
      {quotation.approvedArtifact ? ( {quotation.approvedArtifact.status} ) : ( - - + - )} {quotation.approvedArtifact?.lockedAt ? ( - - Locked{" "} - {formatDateTime(quotation.approvedArtifact.lockedAt)} + + Locked {formatDateTime(quotation.approvedArtifact.lockedAt)} ) : null}
      {quotation.hasLegacyApprovedPdf ? ( -
      -
      - Legacy approved PDF detected -
      -
      - This quotation still points to the old `public/generated` - storage path and has not been migrated into the artifact - storage model yet. +
      +
      Legacy approved PDF detected
      +
      + This quotation still points to the old `public/generated` storage path and has + not been migrated into the artifact storage model yet.
      ) : null} diff --git a/src/features/crm/quotations/document/server/service.ts b/src/features/crm/quotations/document/server/service.ts index 0826935..0ef5c31 100644 --- a/src/features/crm/quotations/document/server/service.ts +++ b/src/features/crm/quotations/document/server/service.ts @@ -1,72 +1,64 @@ -import { and, eq, inArray, isNull } from "drizzle-orm"; -import { - crmCustomerContacts, - crmCustomers, - crmQuotations, - organizations, -} from "@/db/schema"; -import { db } from "@/lib/db"; -import { AuthError } from "@/lib/auth/session"; +import { and, eq, inArray, isNull } from 'drizzle-orm'; +import { crmCustomerContacts, crmCustomers, crmQuotations, organizations } from '@/db/schema'; +import { db } from '@/lib/db'; +import { AuthError } from '@/lib/auth/session'; import { listApprovalRequests, - getApprovalRequest, -} from "@/features/foundation/approval/server/service"; + getApprovalRequest +} from '@/features/foundation/approval/server/service'; import { mapDocumentDataToTemplateInput, resolveTemplateForDocument, - resolveTemplateMappings, -} from "@/features/foundation/document-template/server/service"; -import { getActiveOptionsByCategory } from "@/features/foundation/master-options/service"; + resolveTemplateMappings +} from '@/features/foundation/document-template/server/service'; +import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service'; import { getQuotationDetail, listQuotationCustomers, listQuotationItems, - listQuotationTopics, -} from "@/features/crm/quotations/server/service"; + listQuotationTopics +} from '@/features/crm/quotations/server/service'; import type { ApprovedQuotationSnapshot, QuotationDocumentApprovalStep, - QuotationDocumentData, -} from "../types"; -import type { PdfTopic } from "./pdf-topic.type"; + QuotationDocumentData +} from '../types'; +import type { PdfTopic } from './pdf-topic.type'; import { buildPdfPriceTableData, formatPdfCurrency, formatPdfDate, - formatTopicItems, -} from "./pdfme-transforms"; -import { buildQuotationPdfRuntime } from "./quotation-pdf-runtime"; -import { resolveQuotationSignatures } from "./signature-resolver"; -import { resolveQuotationTopicTypeMapping } from "./topic-mapping"; + formatTopicItems +} from './pdfme-transforms'; +import { buildQuotationPdfRuntime } from './quotation-pdf-runtime'; +import { resolveQuotationSignatures } from './signature-resolver'; +import { resolveQuotationTopicTypeMapping } from './topic-mapping'; const DOCUMENT_OPTION_CATEGORIES = { - branch: "crm_branch", - status: "crm_quotation_status", - quotationType: "crm_quotation_type", - currency: "crm_currency", - discountType: "crm_discount_type", - unit: "crm_unit", - customerRole: "crm_project_party_role", - productType: "crm_product_type", - topicType: "crm_quotation_topic_type", + branch: 'crm_branch', + status: 'crm_quotation_status', + quotationType: 'crm_quotation_type', + currency: 'crm_currency', + discountType: 'crm_discount_type', + unit: 'crm_unit', + customerRole: 'crm_project_party_role', + productType: 'crm_product_type', + topicType: 'crm_quotation_topic_type' } as const; const PDFME_DEFAULT_LABELS = { - tel: "Tel", - email: "Email", - att: "Att", - project: "Project", - location: "Location", + tel: 'Tel', + email: 'Email', + att: 'Att', + project: 'Project', + location: 'Location' } as const; function toRevisionLabel(revision: number) { - return `R${String(revision).padStart(2, "0")}`; + return `R${String(revision).padStart(2, '0')}`; } -async function assertQuotationDocumentAccess( - quotationId: string, - organizationId: string, -) { +async function assertQuotationDocumentAccess(quotationId: string, organizationId: string) { const [quotation] = await db .select() .from(crmQuotations) @@ -74,13 +66,13 @@ async function assertQuotationDocumentAccess( and( eq(crmQuotations.id, quotationId), eq(crmQuotations.organizationId, organizationId), - isNull(crmQuotations.deletedAt), - ), + isNull(crmQuotations.deletedAt) + ) ) .limit(1); if (!quotation) { - throw new AuthError("Quotation not found", 404); + throw new AuthError('Quotation not found', 404); } return quotation; @@ -88,16 +80,14 @@ async function assertQuotationDocumentAccess( function findOptionLabel( options: Array<{ id: string; code: string; label: string }>, - id: string | null | undefined, + id: string | null | undefined ) { - return id - ? (options.find((option) => option.id === id)?.label ?? null) - : null; + return id ? (options.find((option) => option.id === id)?.label ?? null) : null; } function findOptionCode( options: Array<{ id: string; code: string; label: string }>, - id: string | null | undefined, + id: string | null | undefined ) { return id ? (options.find((option) => option.id === id)?.code ?? null) : null; } @@ -111,10 +101,10 @@ function extractSinglePlaceholder(value: string) { function getFieldName(field: unknown) { return field && - typeof field === "object" && - typeof (field as { name?: unknown }).name === "string" - ? ((field as { name: string }).name ?? "") - : ""; + typeof field === 'object' && + typeof (field as { name?: unknown }).name === 'string' + ? ((field as { name: string }).name ?? '') + : ''; } function normalizeTopicKey(value: string | null | undefined) { @@ -122,14 +112,11 @@ function normalizeTopicKey(value: string | null | undefined) { value ?.trim() .toLowerCase() - .replaceAll(/[\s_-]+/g, "") ?? "" + .replaceAll(/[\s_-]+/g, '') ?? '' ); } -function matchesTopicAlias( - actualCode: string | null | undefined, - expectedCode: string, -) { +function matchesTopicAlias(actualCode: string | null | undefined, expectedCode: string) { const actual = normalizeTopicKey(actualCode); const expected = normalizeTopicKey(expectedCode); @@ -142,33 +129,30 @@ function matchesTopicAlias( } const aliasGroups = [ - ["scope", "scopeofwork", "dockscopeofwork", "solarcellscopeofwork"], + ['scope', 'scopeofwork', 'dockscopeofwork', 'solarcellscopeofwork'], [ - "exclusion", - "exclusionfromscopeofsupply", - "exclusionofsupplyinstallation", - "solarcellexclusionofsupplyinstallation", + 'exclusion', + 'exclusionfromscopeofsupply', + 'exclusionofsupplyinstallation', + 'solarcellexclusionofsupplyinstallation' ], [ - "payment", - "paymentconditions", - "termsofpaymentuponprogressofeachitem", - "solarcellpaymentconditions", + 'payment', + 'paymentconditions', + 'termsofpaymentuponprogressofeachitem', + 'solarcellpaymentconditions' ], - ["delivery", "deliverydate", "solarcelldelivery"], - ["warranty", "solarcellwarranty"], + ['delivery', 'deliverydate', 'solarcelldelivery'], + ['warranty', 'solarcellwarranty'] ]; - return aliasGroups.some( - (group) => group.includes(actual) && group.includes(expected), - ); + return aliasGroups.some((group) => group.includes(actual) && group.includes(expected)); } function sortBySortOrder(items: T[]) { return [...items].sort((left, right) => { const delta = - (left.sortOrder ?? Number.MAX_SAFE_INTEGER) - - (right.sortOrder ?? Number.MAX_SAFE_INTEGER); + (left.sortOrder ?? Number.MAX_SAFE_INTEGER) - (right.sortOrder ?? Number.MAX_SAFE_INTEGER); if (delta !== 0) { return delta; @@ -178,12 +162,9 @@ function sortBySortOrder(items: T[]) { }); } -function normalizePdfmeTemplateInput( - schemaJson: unknown, - templateInput: Record, -) { +function normalizePdfmeTemplateInput(schemaJson: unknown, templateInput: Record) { const pages = - schemaJson && typeof schemaJson === "object" && "schemas" in schemaJson + schemaJson && typeof schemaJson === 'object' && 'schemas' in schemaJson ? (schemaJson as { schemas?: unknown }).schemas : null; @@ -199,37 +180,28 @@ function normalizePdfmeTemplateInput( } for (const field of page) { - if (!field || typeof field !== "object") { + if (!field || typeof field !== 'object') { continue; } const tableField = field as { type?: unknown; content?: unknown }; const fieldName = getFieldName(field); const placeholderKey = - typeof tableField.content === "string" + typeof tableField.content === 'string' ? extractSinglePlaceholder(tableField.content) : null; if (fieldName && placeholderKey && fieldName !== placeholderKey) { - if ( - normalized[fieldName] !== undefined && - normalized[placeholderKey] === undefined - ) { + if (normalized[fieldName] !== undefined && normalized[placeholderKey] === undefined) { normalized[placeholderKey] = normalized[fieldName]; } - if ( - normalized[placeholderKey] !== undefined && - normalized[fieldName] === undefined - ) { + if (normalized[placeholderKey] !== undefined && normalized[fieldName] === undefined) { normalized[fieldName] = normalized[placeholderKey]; } } - if ( - tableField.type !== "table" || - typeof tableField.content !== "string" - ) { + if (tableField.type !== 'table' || typeof tableField.content !== 'string') { continue; } @@ -239,12 +211,12 @@ function normalizePdfmeTemplateInput( const value = normalized[placeholderKey]; - if (typeof value !== "string") { + if (typeof value !== 'string') { continue; } const lines = value - .split("\n") + .split('\n') .map((line) => line.trim()) .filter(Boolean); @@ -264,20 +236,14 @@ function logQuotationPdfRuntimeWarnings(issues: Array<{ message: string }>) { return; } - console.warn( - "[quotation-pdf-runtime]", - issues.map((issue) => issue.message).join(" | "), - ); + console.warn('[quotation-pdf-runtime]', issues.map((issue) => issue.message).join(' | ')); } export async function buildQuotationDocumentData( quotationId: string, - organizationId: string, + organizationId: string ): Promise { - const quotation = await assertQuotationDocumentAccess( - quotationId, - organizationId, - ); + const quotation = await assertQuotationDocumentAccess(quotationId, organizationId); const [ company, quotationDetail, @@ -293,7 +259,7 @@ export async function buildQuotationDocumentData( customerRoleOptions, productTypeOptions, topicTypeOptions, - approvalRequests, + approvalRequests ] = await Promise.all([ db .select() @@ -305,41 +271,41 @@ export async function buildQuotationDocumentData( listQuotationCustomers(quotationId, organizationId), listQuotationTopics(quotationId, organizationId), getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.branch, { - organizationId, + organizationId }), getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.status, { - organizationId, + organizationId }), getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.quotationType, { - organizationId, + organizationId }), getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.currency, { - organizationId, + organizationId }), getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.discountType, { - organizationId, + organizationId }), getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.unit, { - organizationId, + organizationId }), getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.customerRole, { - organizationId, + organizationId }), getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.productType, { - organizationId, + organizationId }), getActiveOptionsByCategory(DOCUMENT_OPTION_CATEGORIES.topicType, { - organizationId, + organizationId }), listApprovalRequests(organizationId, { - entityType: "quotation", + entityType: 'quotation', entityId: quotationId, - limit: 20, - }), + limit: 20 + }) ]); if (!company) { - throw new AuthError("Organization not found", 404); + throw new AuthError('Organization not found', 404); } const [customer, contact, relatedCustomers, signatures] = await Promise.all([ @@ -350,8 +316,8 @@ export async function buildQuotationDocumentData( and( eq(crmCustomers.id, quotation.customerId), eq(crmCustomers.organizationId, organizationId), - isNull(crmCustomers.deletedAt), - ), + isNull(crmCustomers.deletedAt) + ) ) .then((rows) => rows[0] ?? null), quotation.contactId @@ -362,8 +328,8 @@ export async function buildQuotationDocumentData( and( eq(crmCustomerContacts.id, quotation.contactId), eq(crmCustomerContacts.organizationId, organizationId), - isNull(crmCustomerContacts.deletedAt), - ), + isNull(crmCustomerContacts.deletedAt) + ) ) .then((rows) => rows[0] ?? null) : Promise.resolve(null), @@ -372,7 +338,7 @@ export async function buildQuotationDocumentData( .select({ id: crmCustomers.id, code: crmCustomers.code, - name: crmCustomers.name, + name: crmCustomers.name }) .from(crmCustomers) .where( @@ -380,39 +346,33 @@ export async function buildQuotationDocumentData( eq(crmCustomers.organizationId, organizationId), inArray( crmCustomers.id, - quotationCustomers.map((item) => item.customerId), + quotationCustomers.map((item) => item.customerId) ), - isNull(crmCustomers.deletedAt), - ), + isNull(crmCustomers.deletedAt) + ) ) : Promise.resolve([]), - resolveQuotationSignatures(quotationId, organizationId), + resolveQuotationSignatures(quotationId, organizationId) ]); if (!customer) { - throw new AuthError("Customer not found", 404); + throw new AuthError('Customer not found', 404); } const approvalRequest = - approvalRequests.items.find((item) => item.status === "approved") ?? + approvalRequests.items.find((item) => item.status === 'approved') ?? approvalRequests.items[0] ?? null; const approvalDetail = approvalRequest ? await getApprovalRequest(approvalRequest.id, organizationId) : null; - const branch = - branchOptions.find((option) => option.id === quotation.branchId) ?? null; - const relatedCustomerMap = new Map( - relatedCustomers.map((item) => [item.id, item]), - ); + const branch = branchOptions.find((option) => option.id === quotation.branchId) ?? null; + const relatedCustomerMap = new Map(relatedCustomers.map((item) => [item.id, item])); const statusCode = findOptionCode(statusOptions, quotation.status); const statusLabel = findOptionLabel(statusOptions, quotation.status); - const topicCodeById = new Map( - topicTypeOptions.map((option) => [option.id, option.code]), - ); + const topicCodeById = new Map(topicTypeOptions.map((option) => [option.id, option.code])); const primaryProductType = - items.find((item) => findOptionCode(productTypeOptions, item.productType)) - ?.productType ?? null; + items.find((item) => findOptionCode(productTypeOptions, item.productType))?.productType ?? null; const productTypeCode = findOptionCode(productTypeOptions, primaryProductType) ?? findOptionCode(quotationTypeOptions, quotationDetail.quotationType) ?? @@ -424,7 +384,7 @@ export async function buildQuotationDocumentData( findOptionLabel(topicTypeOptions, item.topicType) ?? item.title?.trim() ?? topicCodeById.get(item.topicType) ?? - "Topic"; + 'Topic'; return { id: item.id, @@ -435,8 +395,8 @@ export async function buildQuotationDocumentData( items: sortBySortOrder(item.items).map((child) => ({ id: child.id, content: child.content, - sortOrder: child.sortOrder, - })), + sortOrder: child.sortOrder + })) }; }); const pdfTopics: PdfTopic[] = resolvedTopics.map((item) => ({ @@ -445,24 +405,24 @@ export async function buildQuotationDocumentData( sortOrder: item.sortOrder, items: item.items.map((child) => ({ id: child.id, - content: child.content?.trim() || "-", - sortOrder: child.sortOrder, - })), + content: child.content?.trim() || '-', + sortOrder: child.sortOrder + })) })); const scopeTopics = resolvedTopics.filter((item) => - matchesTopicAlias(item.topicCode, topicTypeMapping.scope), + matchesTopicAlias(item.topicCode, topicTypeMapping.scope) ); const exclusionTopics = resolvedTopics.filter((item) => - matchesTopicAlias(item.topicCode, topicTypeMapping.exclusion), + matchesTopicAlias(item.topicCode, topicTypeMapping.exclusion) ); const paymentTopics = resolvedTopics.filter((item) => - matchesTopicAlias(item.topicCode, topicTypeMapping.payment), + matchesTopicAlias(item.topicCode, topicTypeMapping.payment) ); const warrantyTopics = resolvedTopics.filter((item) => - matchesTopicAlias(item.topicCode, topicTypeMapping.warranty), + matchesTopicAlias(item.topicCode, topicTypeMapping.warranty) ); const deliveryTopics = resolvedTopics.filter((item) => - matchesTopicAlias(item.topicCode, topicTypeMapping.delivery), + matchesTopicAlias(item.topicCode, topicTypeMapping.delivery) ); const otherTopics = resolvedTopics.filter((item) => { return ![ @@ -470,7 +430,7 @@ export async function buildQuotationDocumentData( topicTypeMapping.exclusion, topicTypeMapping.payment, topicTypeMapping.warranty, - topicTypeMapping.delivery, + topicTypeMapping.delivery ].some((expectedCode) => matchesTopicAlias(item.topicCode, expectedCode)); }); const exclusionTopic = exclusionTopics[0]; @@ -479,23 +439,22 @@ export async function buildQuotationDocumentData( const deliveryTopic = deliveryTopics[0]; const approvalApprovers: QuotationDocumentApprovalStep[] = approvalDetail?.timeline - .filter((item) => ["approve", "reject", "return"].includes(item.action)) + .filter((item) => ['approve', 'reject', 'return'].includes(item.action)) .map((item) => { const step = approvalDetail.steps.find( - (candidate) => candidate.stepNumber === item.stepNumber, + (candidate) => candidate.stepNumber === item.stepNumber ); return { stepNumber: item.stepNumber, - roleCode: step?.roleCode ?? "", + roleCode: step?.roleCode ?? '', roleName: step?.roleName ?? `Step ${item.stepNumber}`, action: item.action, actorName: item.actorName, actedAt: item.actedAt, - remark: item.remark, + remark: item.remark }; }) ?? []; - const currencyCode = - findOptionCode(currencyOptions, quotationDetail.currency) ?? "THB"; + const currencyCode = findOptionCode(currencyOptions, quotationDetail.currency) ?? 'THB'; return { company: { @@ -503,14 +462,14 @@ export async function buildQuotationDocumentData( name: company.name, slug: company.slug, imageUrl: company.imageUrl, - plan: company.plan, + plan: company.plan }, branch: branch ? { id: branch.id, code: branch.code, label: branch.label, - value: branch.value, + value: branch.value } : null, customer: { @@ -520,7 +479,7 @@ export async function buildQuotationDocumentData( address: customer.address, phone: customer.phone, email: customer.email, - taxId: customer.taxId, + taxId: customer.taxId }, contact: contact ? { @@ -528,7 +487,7 @@ export async function buildQuotationDocumentData( name: contact.name, email: contact.email, mobile: contact.mobile, - position: contact.position, + position: contact.position } : null, quotation: { @@ -544,32 +503,20 @@ export async function buildQuotationDocumentData( revisionLabel: toRevisionLabel(quotationDetail.revision), statusCode, statusLabel, - quotationTypeCode: findOptionCode( - quotationTypeOptions, - quotationDetail.quotationType, - ), - quotationTypeLabel: findOptionLabel( - quotationTypeOptions, - quotationDetail.quotationType, - ), + quotationTypeCode: findOptionCode(quotationTypeOptions, quotationDetail.quotationType), + quotationTypeLabel: findOptionLabel(quotationTypeOptions, quotationDetail.quotationType), currency: currencyCode, currencyCode, currencyLabel: findOptionLabel(currencyOptions, quotationDetail.currency), exchangeRate: quotationDetail.exchangeRate, subtotal: quotationDetail.subtotal, discount: quotationDetail.discount, - discountTypeCode: findOptionCode( - discountTypeOptions, - quotationDetail.discountType, - ), - discountTypeLabel: findOptionLabel( - discountTypeOptions, - quotationDetail.discountType, - ), + discountTypeCode: findOptionCode(discountTypeOptions, quotationDetail.discountType), + discountTypeLabel: findOptionLabel(discountTypeOptions, quotationDetail.discountType), taxRate: quotationDetail.taxRate, taxAmount: quotationDetail.taxAmount, totalAmount: quotationDetail.totalAmount, - notes: quotationDetail.notes, + notes: quotationDetail.notes }, items: items.map((item) => ({ id: item.id, @@ -585,130 +532,107 @@ export async function buildQuotationDocumentData( discountTypeCode: findOptionCode(discountTypeOptions, item.discountType), taxRate: item.taxRate, totalPrice: item.totalPrice, - notes: item.notes, + notes: item.notes })), quotationCustomers: quotationCustomers.map((item) => ({ id: item.id, customerId: item.customerId, - customerName: - relatedCustomerMap.get(item.customerId)?.name ?? item.customerName, - customerCode: - relatedCustomerMap.get(item.customerId)?.code ?? item.customerCode, + customerName: relatedCustomerMap.get(item.customerId)?.name ?? item.customerName, + customerCode: relatedCustomerMap.get(item.customerId)?.code ?? item.customerCode, roleCode: item.roleCode, - roleLabel: - item.roleLabel ?? findOptionLabel(customerRoleOptions, item.role), - isPrimary: item.isPrimary, + roleLabel: item.roleLabel ?? findOptionLabel(customerRoleOptions, item.role), + isPrimary: item.isPrimary })), topics: { scope: scopeTopics.map((item) => ({ title: item.title, - items: item.items.map((child) => child.content ?? "-"), + items: item.items.map((child) => child.content ?? '-') })), exclusion: exclusionTopics.map((item) => ({ title: item.title, - items: item.items.map((child) => child.content ?? "-"), + items: item.items.map((child) => child.content ?? '-') })), payment: paymentTopics.map((item) => ({ title: item.title, - items: item.items.map((child) => child.content ?? "-"), + items: item.items.map((child) => child.content ?? '-') })), warranty: warrantyTopics.map((item) => ({ title: item.title, - items: item.items.map((child) => child.content ?? "-"), + items: item.items.map((child) => child.content ?? '-') })), delivery: deliveryTopics.map((item) => ({ title: item.title, - items: item.items.map((child) => child.content ?? "-"), + items: item.items.map((child) => child.content ?? '-') })), other: otherTopics.map((item) => ({ id: item.id, title: item.title, sortOrder: item.sortOrder, - items: item.items.map((child) => child.content ?? "-"), + items: item.items.map((child) => child.content ?? '-') })), - all: pdfTopics, + all: pdfTopics }, pdfme: { labels: PDFME_DEFAULT_LABELS, - quotation_date: formatPdfDate(quotationDetail.quotationDate, "en"), - quotation_price: formatPdfCurrency( - quotationDetail.subtotal, - currencyCode, - ), - quotation_price_data: buildPdfPriceTableData( - quotationDetail.subtotal, - currencyCode, - ), + quotation_date: formatPdfDate(quotationDetail.quotationDate, 'en'), + quotation_price: formatPdfCurrency(quotationDetail.subtotal, currencyCode), + quotation_price_data: buildPdfPriceTableData(quotationDetail.subtotal, currencyCode), exclusion_data: formatTopicItems(exclusionTopic), payment_data: formatTopicItems(paymentTopic), warranty_data: formatTopicItems(warrantyTopic), delivery_data: formatTopicItems(deliveryTopic), - topics_data: formatTopicItems( - scopeTopics[0] ?? exclusionTopic ?? paymentTopic, - ), - topic_inputs: {}, + topics_data: formatTopicItems(scopeTopics[0] ?? exclusionTopic ?? paymentTopic), + topic_inputs: {} }, approval: { requestId: approvalDetail?.request.id ?? null, workflowName: approvalDetail?.workflow.name ?? null, status: approvalDetail?.request.status ?? null, approvedAt: - approvalDetail?.request.status === "approved" - ? approvalDetail.request.completedAt - : null, + approvalDetail?.request.status === 'approved' ? approvalDetail.request.completedAt : null, currentStep: approvalDetail?.request.currentStep ?? null, currentStepRoleName: approvalDetail?.currentStep?.roleName ?? null, - approvers: approvalApprovers, + approvers: approvalApprovers }, signatures, - watermarkStatus: - statusCode && statusCode !== "approved" - ? (statusLabel ?? statusCode) - : null, + watermarkStatus: statusCode && statusCode !== 'approved' ? (statusLabel ?? statusCode) : null }; } export async function getQuotationDocumentPreviewData( quotationId: string, organizationId: string, + options?: { + templateVariant?: string | null; + } ) { - const documentData = await buildQuotationDocumentData( - quotationId, - organizationId, - ); + const documentData = await buildQuotationDocumentData(quotationId, organizationId); const template = await resolveTemplateForDocument(organizationId, { - documentType: "quotation", - productType: "default", - fileType: "pdfme", + documentType: 'quotation', + productType: 'default', + fileType: 'pdfme', + templateVariant: options?.templateVariant ?? null }); - const mappings = await resolveTemplateMappings( - template.version.id, - organizationId, - ); + const mappings = await resolveTemplateMappings(template.version.id, organizationId); if (!mappings.length) { throw new AuthError( - "Document template mappings are not configured for this template version", - 409, + 'Document template mappings are not configured for this template version', + 409 ); } const templateInput = normalizePdfmeTemplateInput( template.version.schemaJson, - mapDocumentDataToTemplateInput( - documentData as unknown as Record, - mappings, - ), + mapDocumentDataToTemplateInput(documentData as unknown as Record, mappings) ); const runtime = await buildQuotationPdfRuntime({ documentData, template, mappings, - templateInput, + templateInput }); - const runtimeWarnings = runtime.issues.filter( - (issue) => issue.severity === "warning", - ); + const runtimeWarnings = runtime.issues.filter((issue) => issue.severity === 'warning'); logQuotationPdfRuntimeWarnings(runtimeWarnings); @@ -720,23 +644,20 @@ export async function getQuotationDocumentPreviewData( ...template, version: { ...template.version, - schemaJson: runtime.assembled.template, - }, + schemaJson: runtime.assembled.template + } }, mappings, - templateInput: runtime.assembled.templateInput, + templateInput: runtime.assembled.templateInput }; } export async function prepareApprovedQuotationSnapshot( quotationId: string, organizationId: string, - userId: string, + userId: string ): Promise { - const preview = await getQuotationDocumentPreviewData( - quotationId, - organizationId, - ); + const preview = await getQuotationDocumentPreviewData(quotationId, organizationId); const generatedAt = new Date().toISOString(); return { @@ -746,6 +667,6 @@ export async function prepareApprovedQuotationSnapshot( templateVersionId: preview.template.version.id, templateInput: preview.templateInput, generatedAt, - generatedBy: userId, + generatedBy: userId }; } diff --git a/src/features/foundation/document-template/server/service.ts b/src/features/foundation/document-template/server/service.ts index 05d8fd4..47359e3 100644 --- a/src/features/foundation/document-template/server/service.ts +++ b/src/features/foundation/document-template/server/service.ts @@ -16,7 +16,7 @@ import { CURRENT_TEMPLATE_RUNTIME_VERSION, getManagementMetadata, mergeManagementMetadata, - sanitizeTemplateSchemaJson, + sanitizeTemplateSchemaJson } from './metadata'; import type { DocumentTemplateDetail, @@ -71,7 +71,7 @@ function mapVersionRecord( ): DocumentTemplateVersionRecord { const metadata = getManagementMetadata({ metadataJson: row.metadataJson, - schemaJson: row.schemaJson, + schemaJson: row.schemaJson }); return { @@ -218,7 +218,10 @@ export async function resolveTemplateMappings( isNull(crmDocumentTemplateMappings.deletedAt) ) ) - .orderBy(asc(crmDocumentTemplateMappings.sortOrder), asc(crmDocumentTemplateMappings.createdAt)); + .orderBy( + asc(crmDocumentTemplateMappings.sortOrder), + asc(crmDocumentTemplateMappings.createdAt) + ); const columns = await db .select() .from(crmDocumentTemplateTableColumns) @@ -257,7 +260,9 @@ export async function listDocumentTemplates( and( eq(crmDocumentTemplates.organizationId, organizationId), isNull(crmDocumentTemplates.deletedAt), - ...(filters.documentType ? [eq(crmDocumentTemplates.documentType, filters.documentType)] : []), + ...(filters.documentType + ? [eq(crmDocumentTemplates.documentType, filters.documentType)] + : []), ...(filters.fileType ? [eq(crmDocumentTemplates.fileType, filters.fileType)] : []), ...(filters.isActive === 'true' ? [eq(crmDocumentTemplates.isActive, true)] : []), ...(filters.isActive === 'false' ? [eq(crmDocumentTemplates.isActive, false)] : []) @@ -288,7 +293,9 @@ export async function listDocumentTemplates( const templateVersions = versions.filter((item) => item.templateId === template.id); const latestVersion = templateVersions.at(-1) ?? null; const templateVersionIds = new Set(templateVersions.map((item) => item.id)); - const mappingCount = mappings.filter((item) => templateVersionIds.has(item.templateVersionId)).length; + const mappingCount = mappings.filter((item) => + templateVersionIds.has(item.templateVersionId) + ).length; return { ...mapTemplateRecord(template), @@ -379,7 +386,12 @@ export async function createDocumentTemplate( .returning(); if (created.isDefault) { - await normalizeDefaultTemplate(created.id, organizationId, created.documentType, created.fileType); + await normalizeDefaultTemplate( + created.id, + organizationId, + created.documentType, + created.fileType + ); } return created; @@ -399,7 +411,10 @@ export async function updateDocumentTemplate( productType: payload.productType ?? template.productType, fileType: payload.fileType ?? template.fileType, templateName: payload.templateName?.trim() ?? template.templateName, - description: payload.description === undefined ? template.description : payload.description?.trim() || null, + description: + payload.description === undefined + ? template.description + : payload.description?.trim() || null, isDefault: payload.isDefault ?? template.isDefault, isActive: payload.isActive ?? template.isActive, updatedAt: new Date(), @@ -409,7 +424,12 @@ export async function updateDocumentTemplate( .returning(); if (updated.isDefault) { - await normalizeDefaultTemplate(updated.id, organizationId, updated.documentType, updated.fileType); + await normalizeDefaultTemplate( + updated.id, + organizationId, + updated.documentType, + updated.fileType + ); } return updated; @@ -445,7 +465,11 @@ async function normalizeDefaultTemplate( .where(eq(crmDocumentTemplates.id, templateId)); } -export async function softDeleteDocumentTemplate(id: string, organizationId: string, userId: string) { +export async function softDeleteDocumentTemplate( + id: string, + organizationId: string, + userId: string +) { await assertTemplate(id, organizationId); const [updated] = await db .update(crmDocumentTemplates) @@ -473,8 +497,8 @@ export async function createDocumentTemplateVersion( lifecycleStatus: payload.isActive ? 'active' : 'draft', runtimeVersion: payload.runtimeVersion ?? CURRENT_TEMPLATE_RUNTIME_VERSION, templateVariant: payload.templateVariant ?? null, - brand: payload.brand ?? null, - }, + brand: payload.brand ?? null + } }); const [created] = await db .insert(crmDocumentTemplateVersions) @@ -503,13 +527,14 @@ export async function updateDocumentTemplateVersion( const current = await assertTemplateVersion(versionId, organizationId); const currentMetadata = getManagementMetadata({ metadataJson: current.metadataJson, - schemaJson: current.schemaJson, + schemaJson: current.schemaJson }); const [updated] = await db .update(crmDocumentTemplateVersions) .set({ version: payload.version?.trim() ?? current.version, - filePath: payload.filePath === undefined ? current.filePath : payload.filePath?.trim() || null, + filePath: + payload.filePath === undefined ? current.filePath : payload.filePath?.trim() || null, schemaJson: payload.schemaJson === undefined ? sanitizeTemplateSchemaJson(current.schemaJson) @@ -520,8 +545,8 @@ export async function updateDocumentTemplateVersion( patch: { runtimeVersion: payload.runtimeVersion ?? currentMetadata.runtimeVersion ?? null, templateVariant: payload.templateVariant ?? currentMetadata.templateVariant ?? null, - brand: payload.brand ?? currentMetadata.brand ?? null, - }, + brand: payload.brand ?? currentMetadata.brand ?? null + } }), previewImageUrl: payload.previewImageUrl === undefined @@ -555,7 +580,7 @@ export function createDuplicateVersionLabel(existingVersions: string[], baseVers export async function duplicateDocumentTemplateVersion( versionId: string, organizationId: string, - userId: string, + userId: string ) { const sourceVersion = await assertTemplateVersion(versionId, organizationId); const siblingVersions = await db @@ -565,18 +590,18 @@ export async function duplicateDocumentTemplateVersion( and( eq(crmDocumentTemplateVersions.templateId, sourceVersion.templateId), eq(crmDocumentTemplateVersions.organizationId, organizationId), - isNull(crmDocumentTemplateVersions.deletedAt), - ), + isNull(crmDocumentTemplateVersions.deletedAt) + ) ) .orderBy(asc(crmDocumentTemplateVersions.createdAt)); const metadata = getManagementMetadata({ metadataJson: sourceVersion.metadataJson, - schemaJson: sourceVersion.schemaJson, + schemaJson: sourceVersion.schemaJson }); const duplicateId = crypto.randomUUID(); const duplicateVersionLabel = createDuplicateVersionLabel( siblingVersions.map((item) => item.version), - sourceVersion.version, + sourceVersion.version ); const [createdVersion] = await db @@ -601,11 +626,11 @@ export async function duplicateDocumentTemplateVersion( nextVersionId: null, validationSummary: null, auditSummary: null, - visualSummary: null, + visualSummary: null }, previewImageUrl: sourceVersion.previewImageUrl, isActive: false, - createdBy: userId, + createdBy: userId }) .returning(); @@ -622,7 +647,7 @@ export async function duplicateDocumentTemplateVersion( sheetName: mapping.sheetName, defaultValue: mapping.defaultValue, formatMask: mapping.formatMask, - sortOrder: mapping.sortOrder, + sortOrder: mapping.sortOrder }); if (mapping.columns.length) { @@ -635,8 +660,8 @@ export async function duplicateDocumentTemplateVersion( sourceField: column.sourceField, columnLetter: column.columnLetter, sortOrder: column.sortOrder, - formatMask: column.formatMask, - })), + formatMask: column.formatMask + })) ); } } @@ -721,9 +746,12 @@ export async function updateDocumentTemplateMapping( placeholderKey: payload.placeholderKey?.trim() ?? current.placeholderKey, sourcePath: payload.sourcePath?.trim() ?? current.sourcePath, dataType: payload.dataType ?? current.dataType, - sheetName: payload.sheetName === undefined ? current.sheetName : payload.sheetName?.trim() || null, + sheetName: + payload.sheetName === undefined ? current.sheetName : payload.sheetName?.trim() || null, defaultValue: - payload.defaultValue === undefined ? current.defaultValue : payload.defaultValue?.trim() || null, + payload.defaultValue === undefined + ? current.defaultValue + : payload.defaultValue?.trim() || null, formatMask: payload.formatMask === undefined ? current.formatMask : payload.formatMask?.trim() || null, sortOrder: payload.sortOrder ?? current.sortOrder, @@ -753,8 +781,8 @@ export async function updateDocumentTemplateMapping( } } - const [mapping] = await resolveTemplateMappings(updated.templateVersionId, organizationId).then((items) => - items.filter((item) => item.id === mappingId) + const [mapping] = await resolveTemplateMappings(updated.templateVersionId, organizationId).then( + (items) => items.filter((item) => item.id === mappingId) ); return mapping ?? { ...mapMappingRecord(updated), columns: [] }; @@ -820,15 +848,30 @@ export async function resolveTemplateForDocument( and( eq(crmDocumentTemplateVersions.organizationId, organizationId), eq(crmDocumentTemplateVersions.templateId, exactTemplate.id), - eq(crmDocumentTemplateVersions.isActive, true), isNull(crmDocumentTemplateVersions.deletedAt) ) ) .orderBy(asc(crmDocumentTemplateVersions.createdAt)); - const version = versions.at(-1); + const templateVariant = params.templateVariant?.trim() || null; + const eligibleVersions = templateVariant + ? versions.filter((item) => { + const metadata = getManagementMetadata({ + metadataJson: item.metadataJson, + schemaJson: item.schemaJson + }); + + return metadata.templateVariant === templateVariant; + }) + : versions.filter((item) => item.isActive); + const version = eligibleVersions.at(-1); if (!version) { - throw new AuthError('No active template version found', 404); + throw new AuthError( + templateVariant === 'product-v1' + ? 'product-v1 template version is not seeded' + : 'No active template version found', + 404 + ); } return { @@ -861,8 +904,7 @@ function applyFormatMask(value: unknown, formatMask?: string | null) { } if (formatMask === 'currency' || formatMask?.startsWith('currency_')) { - const currencyCode = - formatMask === 'currency' ? 'THB' : formatMask.replace('currency_', ''); + const currencyCode = formatMask === 'currency' ? 'THB' : formatMask.replace('currency_', ''); return formatPdfCurrency(value as number | string, currencyCode); } @@ -943,7 +985,10 @@ export function mapDocumentDataToTemplateInput( const rowRecord = row as Record; return mapping.columns.map((column) => { - const formattedValue = applyFormatMask(rowRecord[column.sourceField], column.formatMask); + const formattedValue = applyFormatMask( + rowRecord[column.sourceField], + column.formatMask + ); return String(formattedValue ?? '-').trim() || '-'; }); }); diff --git a/src/features/foundation/document-template/types.ts b/src/features/foundation/document-template/types.ts index c195b68..338778f 100644 --- a/src/features/foundation/document-template/types.ts +++ b/src/features/foundation/document-template/types.ts @@ -143,13 +143,11 @@ export interface DocumentTemplateMappingMutationPayload { columns?: DocumentTemplateMappingColumnMutationPayload[]; } -export interface DocumentTemplateMappingWithColumns - extends DocumentTemplateMappingRecord { +export interface DocumentTemplateMappingWithColumns extends DocumentTemplateMappingRecord { columns: DocumentTemplateTableColumnRecord[]; } -export interface DocumentTemplateVersionDetail - extends DocumentTemplateVersionRecord { +export interface DocumentTemplateVersionDetail extends DocumentTemplateVersionRecord { mappings: DocumentTemplateMappingWithColumns[]; validationSummary?: DocumentTemplateVersionValidationSummary | null; auditSummary?: DocumentTemplateVersionAuditSummary | null; @@ -200,6 +198,7 @@ export interface ResolveTemplateParams { documentType: string; productType: string; fileType: string; + templateVariant?: string | null; } export interface ResolvedDocumentTemplate { @@ -227,18 +226,15 @@ export interface DocumentTemplateVersionsResponse extends MutationSuccessRespons items: DocumentTemplateVersionDetail[]; } -export interface DocumentTemplateVersionActionResponse - extends MutationSuccessResponse { +export interface DocumentTemplateVersionActionResponse extends MutationSuccessResponse { version: DocumentTemplateVersionDetail; } -export interface DocumentTemplateVersionValidateResponse - extends MutationSuccessResponse { +export interface DocumentTemplateVersionValidateResponse extends MutationSuccessResponse { validation: DocumentTemplateVersionValidationSummary; } -export interface DocumentTemplateVersionCompareResponse - extends MutationSuccessResponse { +export interface DocumentTemplateVersionCompareResponse extends MutationSuccessResponse { comparison: { leftVersionId: string; rightVersionId: string; @@ -277,8 +273,7 @@ export interface DocumentTemplateVersionCompareResponse }; } -export interface DocumentTemplateVersionPreviewResponse - extends MutationSuccessResponse { +export interface DocumentTemplateVersionPreviewResponse extends MutationSuccessResponse { preview: { fileName: string; contentType: 'application/pdf'; diff --git a/src/features/foundation/pdf-generator/server/service.ts b/src/features/foundation/pdf-generator/server/service.ts index a55dac5..37d52a6 100644 --- a/src/features/foundation/pdf-generator/server/service.ts +++ b/src/features/foundation/pdf-generator/server/service.ts @@ -299,9 +299,14 @@ async function loadLegacyApprovedQuotationPdf( export async function generateQuotationPdf( quotationId: string, - organizationId: string + organizationId: string, + options?: { + templateVariant?: string | null; + } ): Promise { - const preview = await getQuotationDocumentPreviewData(quotationId, organizationId); + const preview = await getQuotationDocumentPreviewData(quotationId, organizationId, { + templateVariant: options?.templateVariant ?? null + }); const buffer = await generatePdfFromTemplate({ template: preview.template.version.schemaJson as Template, inputs: [preview.templateInput] @@ -314,8 +319,14 @@ export async function generateQuotationPdf( }; } -export async function generateQuotationPreviewPdf(quotationId: string, organizationId: string) { - return generateQuotationPdf(quotationId, organizationId); +export async function generateQuotationPreviewPdf( + quotationId: string, + organizationId: string, + options?: { + templateVariant?: string | null; + } +) { + return generateQuotationPdf(quotationId, organizationId, options); } export async function generateApprovedQuotationPdf( diff --git a/src/pdfme_template/ALLA_template_pdfme_product_v1.json b/src/pdfme_template/ALLA_template_pdfme_product_v1.json index bdd20bf..11ae414 100644 --- a/src/pdfme_template/ALLA_template_pdfme_product_v1.json +++ b/src/pdfme_template/ALLA_template_pdfme_product_v1.json @@ -380,17 +380,9 @@ "content": "[[\"Price (Exclude VAT)\",\"{quotation_price}\",\" \",\"{currency}\"]]", "showHead": false, "repeatHead": false, - "head": [ - "Name", - "City", - "Head 3", - "Head 4" - ], + "head": ["Name", "City", "Head 3", "Head 4"], "headWidthPercentages": [ - 28.125, - 58.34767550533545, - 2.4513428524798244, - 11.075981642184729 + 28.125, 58.34767550533545, 2.4513428524798244, 11.075981642184729 ], "tableStyles": { "borderWidth": 0, @@ -1023,7 +1015,7 @@ { "name": "product_items_title", "type": "text", - "content": "Product Item Details", + "content": "", "position": { "x": 12, "y": 22 @@ -1050,7 +1042,7 @@ { "name": "product_items_subtitle", "type": "text", - "content": "Detailed quotation item list", + "content": "", "position": { "x": 12, "y": 29 @@ -1111,15 +1103,7 @@ "Discount", "Total" ], - "headWidthPercentages": [ - 8, - 34, - 8, - 10, - 14, - 12, - 14 - ], + "headWidthPercentages": [8, 34, 8, 10, 14, 12, 14], "tableStyles": { "borderWidth": 0.2, "borderColor": "#000000" @@ -1376,12 +1360,8 @@ "content": "[[\"Topic:\"]]", "showHead": false, "repeatHead": false, - "head": [ - "Terms of payment upon progress of each item:" - ], - "headWidthPercentages": [ - 100 - ], + "head": ["Terms of payment upon progress of each item:"], + "headWidthPercentages": [100], "tableStyles": { "borderWidth": 0, "borderColor": "#000000" @@ -1449,12 +1429,8 @@ "content": "[[\"{item_topic}\"]]", "showHead": false, "repeatHead": false, - "head": [ - "Head 2" - ], - "headWidthPercentages": [ - 100 - ], + "head": ["Head 2"], + "headWidthPercentages": [100], "tableStyles": { "borderWidth": 0, "borderColor": "#000000" @@ -1731,12 +1707,7 @@ "basePdf": { "width": 210, "height": 297, - "padding": [ - 31, - 10, - 25, - 10 - ], + "padding": [31, 10, 25, 10], "staticSchema": [ { "name": "field1",