hotfix-add subgroup customer

This commit is contained in:
phaichayon
2026-06-18 08:58:01 +07:00
parent 04a886ea26
commit 0650cf6297
38 changed files with 8674 additions and 687 deletions

View File

@@ -3,6 +3,7 @@ export interface CustomerOption {
code: string;
label: string;
value: string | null;
parentId?: string | null;
}
export interface BranchOption {
@@ -34,6 +35,7 @@ export interface CustomerRecord {
website: string | null;
leadChannel: string | null;
customerGroup: string | null;
customerSubGroup: string | null;
notes: string | null;
isActive: boolean;
createdAt: string;
@@ -92,6 +94,7 @@ export interface CustomerReferenceData {
customerStatuses: CustomerOption[];
leadChannels: CustomerOption[];
customerGroups: CustomerOption[];
customerSubGroups: CustomerOption[];
}
export interface CustomerFilters {
@@ -148,6 +151,7 @@ export interface CustomerMutationPayload {
website?: string;
leadChannel?: string | null;
customerGroup?: string | null;
customerSubGroup?: string | null;
notes?: string;
isActive?: boolean;
}

View File

@@ -21,6 +21,8 @@ export function getCustomerColumns({
const branchMap = new Map(referenceData.branches.map((item) => [item.id, item]));
const typeMap = new Map(referenceData.customerTypes.map((item) => [item.id, item]));
const statusMap = new Map(referenceData.customerStatuses.map((item) => [item.id, item]));
const groupMap = new Map(referenceData.customerGroups.map((item) => [item.id, item.label]));
const subGroupMap = new Map(referenceData.customerSubGroups.map((item) => [item.id, item.label]));
return [
{
@@ -115,6 +117,22 @@ export function getCustomerColumns({
},
enableColumnFilter: true
},
{
id: 'customerGroup',
accessorKey: 'customerGroup',
header: ({ column }: { column: Column<CustomerListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Group' />
),
cell: ({ row }) => <span>{groupMap.get(row.original.customerGroup ?? '') ?? '-'}</span>
},
{
id: 'customerSubGroup',
accessorKey: 'customerSubGroup',
header: ({ column }: { column: Column<CustomerListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Sub Group' />
),
cell: ({ row }) => <span>{subGroupMap.get(row.original.customerSubGroup ?? '') ?? '-'}</span>
},
{
id: 'contactCount',
accessorKey: 'contactCount',

View File

@@ -70,6 +70,10 @@ export function CustomerDetail({
() => new Map(referenceData.customerGroups.map((item) => [item.id, item.label])),
[referenceData]
);
const subGroupMap = useMemo(
() => new Map(referenceData.customerSubGroups.map((item) => [item.id, item.label])),
[referenceData]
);
const customer = data.customer;
const status = statusMap.get(customer.customerStatus);
const type = typeMap.get(customer.customerType);
@@ -145,6 +149,10 @@ export function CustomerDetail({
label='Customer Group'
value={groupMap.get(customer.customerGroup ?? '')}
/>
<FieldItem
label='Customer Sub Group'
value={subGroupMap.get(customer.customerSubGroup ?? '')}
/>
<FieldItem label='Active' value={customer.isActive ? 'Yes' : 'No'} />
<div className='md:col-span-2 xl:col-span-4'>
<FieldItem

View File

@@ -2,6 +2,7 @@
import * as React from 'react';
import { useEffect, useMemo } from 'react';
import { useStore } from '@tanstack/react-form';
import { useMutation } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Icons } from '@/components/icons';
@@ -49,6 +50,7 @@ function toDefaultValues(
website: customer?.website ?? '',
leadChannel: customer?.leadChannel ?? undefined,
customerGroup: customer?.customerGroup ?? undefined,
customerSubGroup: customer?.customerSubGroup ?? undefined,
notes: customer?.notes ?? '',
isActive: customer?.isActive ?? true
};
@@ -98,7 +100,8 @@ export function CustomerFormSheet({
...value,
branchId: value.branchId || null,
leadChannel: value.leadChannel || null,
customerGroup: value.customerGroup || null
customerGroup: value.customerGroup || null,
customerSubGroup: value.customerSubGroup || null
};
if (isEdit && customer) {
@@ -117,6 +120,25 @@ export function CustomerFormSheet({
form.reset(defaultValues);
}, [defaultValues, form, open]);
const selectedCustomerGroup = useStore(form.store, (state) => state.values.customerGroup);
const selectedCustomerSubGroup = useStore(form.store, (state) => state.values.customerSubGroup);
const filteredCustomerSubGroups = useMemo(
() =>
referenceData.customerSubGroups.filter((item) => item.parentId === selectedCustomerGroup),
[referenceData.customerSubGroups, selectedCustomerGroup]
);
useEffect(() => {
if (!selectedCustomerSubGroup) {
return;
}
const isValid = filteredCustomerSubGroups.some((item) => item.id === selectedCustomerSubGroup);
if (!isValid) {
form.setFieldValue('customerSubGroup', '');
}
}, [filteredCustomerSubGroups, form, selectedCustomerSubGroup]);
const isPending = createMutation.isPending || updateMutation.isPending;
@@ -193,6 +215,18 @@ export function CustomerFormSheet({
label: item.label
}))}
/>
<FormSelectField
name='customerSubGroup'
label='Customer Sub Group'
disabled={!selectedCustomerGroup}
placeholder={
selectedCustomerGroup ? 'Select customer sub group' : 'Select customer group first'
}
options={filteredCustomerSubGroups.map((item) => ({
value: item.id,
label: item.label
}))}
/>
<div className='md:col-span-2'>
<FormTextField name='address' label='Address' placeholder='123 Main Road' />
</div>

View File

@@ -22,6 +22,7 @@ export const customerSchema = z.object({
website: z.string().optional(),
leadChannel: z.string().optional().nullable(),
customerGroup: z.string().optional().nullable(),
customerSubGroup: z.string().optional().nullable(),
notes: z.string().optional(),
isActive: z.boolean().default(true)
});

View File

@@ -1,4 +1,4 @@
import { and, asc, count, desc, eq, ilike, inArray, isNull, or, type SQL } from 'drizzle-orm';
import { and, asc, count, desc, eq, ilike, inArray, isNull, or, sql, type SQL } from 'drizzle-orm';
import { crmCustomerContacts, crmCustomers, msOptions, users } from '@/db/schema';
import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
@@ -19,11 +19,18 @@ import type {
CustomerReferenceData
} from '../api/types';
type CustomerRecordSource = Omit<typeof crmCustomers.$inferSelect, 'customerSubGroup'> & {
customerSubGroup?: string | null;
};
let customerSubGroupColumnAvailable: boolean | undefined;
const CUSTOMER_OPTION_CATEGORIES = {
customerType: 'crm_customer_type',
customerStatus: 'crm_customer_status',
leadChannel: 'crm_lead_channel',
customerGroup: 'crm_customer_group'
customerGroup: 'crm_customer_group',
customerSubGroup: 'crm_customer_sub_group'
} as const;
function mapCustomerOption(
@@ -33,7 +40,18 @@ function mapCustomerOption(
id: option.id,
code: option.code,
label: option.label,
value: option.value
value: option.value,
parentId: option.parentId
};
}
function mapCustomerOptionRow(row: typeof msOptions.$inferSelect): CustomerOption {
return {
id: row.id,
code: row.code,
label: row.label,
value: row.value,
parentId: row.parentId
};
}
@@ -48,7 +66,7 @@ function mapBranchOption(
};
}
function mapCustomerRecord(row: typeof crmCustomers.$inferSelect): CustomerRecord {
function mapCustomerRecord(row: CustomerRecordSource): CustomerRecord {
return {
id: row.id,
organizationId: row.organizationId,
@@ -71,6 +89,7 @@ function mapCustomerRecord(row: typeof crmCustomers.$inferSelect): CustomerRecor
website: row.website,
leadChannel: row.leadChannel,
customerGroup: row.customerGroup,
customerSubGroup: row.customerSubGroup ?? null,
notes: row.notes,
isActive: row.isActive,
createdAt: row.createdAt.toISOString(),
@@ -81,6 +100,66 @@ function mapCustomerRecord(row: typeof crmCustomers.$inferSelect): CustomerRecor
};
}
const baseCustomerRecordSelection = {
id: crmCustomers.id,
organizationId: crmCustomers.organizationId,
branchId: crmCustomers.branchId,
code: crmCustomers.code,
name: crmCustomers.name,
abbr: crmCustomers.abbr,
taxId: crmCustomers.taxId,
customerType: crmCustomers.customerType,
customerStatus: crmCustomers.customerStatus,
address: crmCustomers.address,
province: crmCustomers.province,
district: crmCustomers.district,
subDistrict: crmCustomers.subDistrict,
postalCode: crmCustomers.postalCode,
country: crmCustomers.country,
phone: crmCustomers.phone,
fax: crmCustomers.fax,
email: crmCustomers.email,
website: crmCustomers.website,
leadChannel: crmCustomers.leadChannel,
customerGroup: crmCustomers.customerGroup,
notes: crmCustomers.notes,
isActive: crmCustomers.isActive,
createdAt: crmCustomers.createdAt,
updatedAt: crmCustomers.updatedAt,
deletedAt: crmCustomers.deletedAt,
createdBy: crmCustomers.createdBy,
updatedBy: crmCustomers.updatedBy
} as const;
function getCustomerRecordSelection(includeCustomerSubGroup: boolean) {
return includeCustomerSubGroup
? {
...baseCustomerRecordSelection,
customerSubGroup: crmCustomers.customerSubGroup
}
: baseCustomerRecordSelection;
}
async function hasCustomerSubGroupColumn() {
if (customerSubGroupColumnAvailable !== undefined) {
return customerSubGroupColumnAvailable;
}
const result = await db.execute<{ exists: boolean }>(sql`
select exists (
select 1
from information_schema.columns
where table_schema = 'public'
and table_name = 'crm_customers'
and column_name = 'customer_sub_group'
) as "exists"
`);
customerSubGroupColumnAvailable = Boolean(result[0]?.exists);
return customerSubGroupColumnAvailable;
}
function mapCustomerContactRecord(
row: typeof crmCustomerContacts.$inferSelect
): CustomerContactRecord {
@@ -170,13 +249,18 @@ async function assertMasterOptionValue(
}
async function assertCustomerBelongsToOrganization(id: string, organizationId: string) {
const customer = await db.query.crmCustomers.findFirst({
where: and(
eq(crmCustomers.id, id),
eq(crmCustomers.organizationId, organizationId),
isNull(crmCustomers.deletedAt)
const includeCustomerSubGroup = await hasCustomerSubGroupColumn();
const [customer] = await db
.select(getCustomerRecordSelection(includeCustomerSubGroup))
.from(crmCustomers)
.where(
and(
eq(crmCustomers.id, id),
eq(crmCustomers.organizationId, organizationId),
isNull(crmCustomers.deletedAt)
)
)
});
.limit(1);
if (!customer) {
throw new AuthError('Customer not found', 404);
@@ -227,10 +311,38 @@ async function validateCustomerPayload(organizationId: string, payload: Customer
CUSTOMER_OPTION_CATEGORIES.customerGroup,
payload.customerGroup ?? null
);
await assertMasterOptionValue(
organizationId,
CUSTOMER_OPTION_CATEGORIES.customerSubGroup,
payload.customerSubGroup ?? null
);
if (payload.branchId) {
await validateBranchAccess(payload.branchId);
}
if (payload.customerSubGroup) {
if (!payload.customerGroup) {
throw new AuthError('Customer sub group requires a customer group', 400);
}
const subGroup = await db.query.msOptions.findFirst({
where: and(
eq(msOptions.organizationId, organizationId),
eq(msOptions.category, CUSTOMER_OPTION_CATEGORIES.customerSubGroup),
eq(msOptions.id, payload.customerSubGroup),
isNull(msOptions.deletedAt)
)
});
if (!subGroup) {
throw new AuthError('Invalid customer sub group', 400);
}
if (subGroup.parentId !== payload.customerGroup) {
throw new AuthError('Selected customer sub group does not belong to the selected customer group', 400);
}
}
}
function buildCustomerFilters(organizationId: string, filters: CustomerFilters): SQL[] {
@@ -261,13 +373,25 @@ function buildCustomerFilters(organizationId: string, filters: CustomerFilters):
export async function getCustomerReferenceData(
organizationId: string
): Promise<CustomerReferenceData> {
const [branches, customerTypes, customerStatuses, leadChannels, customerGroups] =
const [branches, customerTypes, customerStatuses, leadChannels, customerGroups, customerSubGroups] =
await Promise.all([
getUserBranches(),
getActiveOptionsByCategory(CUSTOMER_OPTION_CATEGORIES.customerType, { organizationId }),
getActiveOptionsByCategory(CUSTOMER_OPTION_CATEGORIES.customerStatus, { organizationId }),
getActiveOptionsByCategory(CUSTOMER_OPTION_CATEGORIES.leadChannel, { organizationId }),
getActiveOptionsByCategory(CUSTOMER_OPTION_CATEGORIES.customerGroup, { organizationId })
getActiveOptionsByCategory(CUSTOMER_OPTION_CATEGORIES.customerGroup, { organizationId }),
db
.select()
.from(msOptions)
.where(
and(
eq(msOptions.organizationId, organizationId),
eq(msOptions.category, CUSTOMER_OPTION_CATEGORIES.customerSubGroup),
eq(msOptions.isActive, true),
isNull(msOptions.deletedAt)
)
)
.orderBy(asc(msOptions.sortOrder), asc(msOptions.label))
]);
return {
@@ -275,7 +399,8 @@ export async function getCustomerReferenceData(
customerTypes: customerTypes.map(mapCustomerOption),
customerStatuses: customerStatuses.map(mapCustomerOption),
leadChannels: leadChannels.map(mapCustomerOption),
customerGroups: customerGroups.map(mapCustomerOption)
customerGroups: customerGroups.map(mapCustomerOption),
customerSubGroups: customerSubGroups.map(mapCustomerOptionRow)
};
}
@@ -283,6 +408,7 @@ export async function listCustomers(
organizationId: string,
filters: CustomerFilters
): Promise<{ items: CustomerListItem[]; totalItems: number }> {
const includeCustomerSubGroup = await hasCustomerSubGroupColumn();
const page = filters.page ?? 1;
const limit = filters.limit ?? 10;
const whereFilters = buildCustomerFilters(organizationId, filters);
@@ -291,7 +417,7 @@ export async function listCustomers(
const [totalResult] = await db.select({ value: count() }).from(crmCustomers).where(where);
const rows = await db
.select()
.select(getCustomerRecordSelection(includeCustomerSubGroup))
.from(crmCustomers)
.where(where)
.orderBy(parseSort(filters.sort))
@@ -401,6 +527,7 @@ export async function createCustomer(
website: payload.website?.trim() || null,
leadChannel: payload.leadChannel ?? null,
customerGroup: payload.customerGroup ?? null,
customerSubGroup: payload.customerSubGroup ?? null,
notes: payload.notes?.trim() || null,
isActive: payload.isActive ?? true,
createdBy: userId,
@@ -441,6 +568,7 @@ export async function updateCustomer(
website: payload.website?.trim() || null,
leadChannel: payload.leadChannel ?? null,
customerGroup: payload.customerGroup ?? null,
customerSubGroup: payload.customerSubGroup ?? null,
notes: payload.notes?.trim() || null,
isActive: payload.isActive ?? true,
updatedBy: userId,

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,11 @@ function Field({ label, value }: { label: string; value: string | null | undefin
export function QuotationDocumentPreview({ quotationId }: { quotationId: string }) {
const { data } = useSuspenseQuery(quotationDocumentPreviewOptions(quotationId));
const { documentData, template } = data.preview;
const topicGroups: Array<{ key: 'scope' | 'exclusion' | 'payment'; title: string }> = [
{ key: 'scope', title: 'Scope' },
{ key: 'exclusion', title: 'Exclusions' },
{ key: 'payment', title: 'Payment Terms' }
];
return (
<div className='space-y-6'>
@@ -137,12 +142,8 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
<CardDescription>Scope, exclusions, and payment terms from topic records.</CardDescription>
</CardHeader>
<CardContent className='space-y-6'>
{[
{ key: 'scope', title: 'Scope' },
{ key: 'exclusion', title: 'Exclusions' },
{ key: 'payment', title: 'Payment Terms' }
].map((group) => {
const entries = documentData.topics[group.key as keyof typeof documentData.topics];
{topicGroups.map((group) => {
const entries = documentData.topics[group.key];
return (
<div key={group.key} className='space-y-3'>
<div className='font-medium'>{group.title}</div>

View File

@@ -0,0 +1,204 @@
import type { Template } from '@pdfme/common';
import { formatTopicItems } from './pdfme-transforms';
import type { PdfTopic, PdfTopicEngineOptions } from './pdf-topic.type';
type TemplateSchema = {
name?: string;
position?: { x?: number; y?: number };
width?: number;
height?: number;
content?: string;
[key: string]: unknown;
};
const DEFAULT_TOPIC_ENGINE_OPTIONS: Required<PdfTopicEngineOptions> = {
targetPageIndex: 1,
pageStartY: 35,
pageBottomY: 250,
topicSpacing: 10,
keepTogetherNames: [
'Please_do_not',
'yours_faithfuly',
'app1',
'app1_position',
'app2',
'app2_position',
'app3',
'app3_position'
],
topicTemplateName: 'topic',
topicDataTemplateName: 'data_topic',
rowHeight: 7,
keepTogetherMinSpace: 60
};
function cloneTemplate<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function getFieldY(field: TemplateSchema) {
return typeof field.position?.y === 'number' ? field.position.y : 0;
}
function getFieldBottom(field: TemplateSchema) {
return getFieldY(field) + (typeof field.height === 'number' ? field.height : 0);
}
function setFieldY(field: TemplateSchema, y: number) {
field.position = {
...(field.position ?? {}),
y
};
}
function sortTopics(topics: PdfTopic[]) {
return [...topics].sort((left, right) => {
const sortDelta = (left.sortOrder ?? Number.MAX_SAFE_INTEGER) - (right.sortOrder ?? Number.MAX_SAFE_INTEGER);
if (sortDelta !== 0) {
return sortDelta;
}
return String(left.id ?? left.topicType).localeCompare(String(right.id ?? right.topicType));
});
}
export function buildPdfTopicTemplate(
template: Template,
topics: PdfTopic[],
options?: PdfTopicEngineOptions
): {
template: Template;
topicInputs: Record<string, string[][]>;
} {
const resolvedOptions = { ...DEFAULT_TOPIC_ENGINE_OPTIONS, ...options };
const nextTemplate = cloneTemplate(template);
const pages = Array.isArray(nextTemplate.schemas) ? [...nextTemplate.schemas] : [];
const targetPage = pages[resolvedOptions.targetPageIndex];
if (!Array.isArray(targetPage)) {
return { template: nextTemplate, topicInputs: {} };
}
const topicTemplate = targetPage.find(
(field) => (field as TemplateSchema).name === resolvedOptions.topicTemplateName
) as TemplateSchema | undefined;
const topicDataTemplate = targetPage.find(
(field) => (field as TemplateSchema).name === resolvedOptions.topicDataTemplateName
) as TemplateSchema | undefined;
if (!topicTemplate || !topicDataTemplate) {
console.warn('[pdf-topic-engine] Missing topic or data_topic schema template');
return { template: nextTemplate, topicInputs: {} };
}
const keepTogetherSet = new Set(resolvedOptions.keepTogetherNames);
const pageWithoutDynamicFields = targetPage.filter((field) => {
const name = (field as TemplateSchema).name;
return (
name !== resolvedOptions.topicTemplateName && name !== resolvedOptions.topicDataTemplateName
);
}) as TemplateSchema[];
const keepTogetherFields = pageWithoutDynamicFields
.filter((field) => keepTogetherSet.has(field.name ?? ''))
.map((field) => cloneTemplate(field));
const staticFields = pageWithoutDynamicFields
.filter((field) => !keepTogetherSet.has(field.name ?? ''))
.map((field) => cloneTemplate(field));
const sortedTopics = sortTopics(topics);
const topicInputs: Record<string, string[][]> = {};
const generatedPages: Template['schemas'] = [];
const topicToDataOffset = getFieldY(topicDataTemplate) - getFieldY(topicTemplate);
const keepTogetherTop =
keepTogetherFields.length > 0
? Math.min(...keepTogetherFields.map((field) => getFieldY(field)))
: resolvedOptions.pageBottomY;
const keepTogetherHeight =
keepTogetherFields.length > 0
? Math.max(...keepTogetherFields.map((field) => getFieldBottom(field))) - keepTogetherTop
: 0;
let currentPageFields = staticFields.map((field) => cloneTemplate(field));
let currentY = Math.max(
resolvedOptions.pageStartY,
currentPageFields.length > 0
? Math.max(...currentPageFields.map((field) => getFieldBottom(field)), resolvedOptions.pageStartY)
: resolvedOptions.pageStartY
);
const pushCurrentPage = () => {
generatedPages.push(currentPageFields as Template['schemas'][number]);
currentPageFields = [];
currentY = resolvedOptions.pageStartY;
};
for (const [topicIndex, topic] of sortedTopics.entries()) {
const topicRows = formatTopicItems({ items: topic.items ?? [] });
const topicKey = `topic_${resolvedOptions.targetPageIndex}_${topicIndex}`;
const itemKey = `item_topic_${resolvedOptions.targetPageIndex}_${topicIndex}`;
const labelHeight =
typeof topicTemplate.height === 'number' ? topicTemplate.height : resolvedOptions.rowHeight;
const tableHeight = Math.max(
typeof topicDataTemplate.height === 'number' ? topicDataTemplate.height : resolvedOptions.rowHeight,
topicRows.length * resolvedOptions.rowHeight
);
const blockHeight = topicToDataOffset + tableHeight;
if (currentY + blockHeight > resolvedOptions.pageBottomY && currentPageFields.length > 0) {
pushCurrentPage();
}
const topicField = cloneTemplate(topicTemplate);
topicField.name = topicKey;
topicField.content = `[[\"{${topicKey}}\"]]`;
topicField.height = labelHeight;
setFieldY(topicField, currentY);
const topicDataField = cloneTemplate(topicDataTemplate);
topicDataField.name = itemKey;
topicDataField.content = `[[\"{${itemKey}}\"]]`;
topicDataField.height = tableHeight;
setFieldY(topicDataField, currentY + topicToDataOffset);
topicInputs[topicKey] = [[topic.topicType?.trim() || '-']];
topicInputs[itemKey] = topicRows;
currentPageFields.push(topicField, topicDataField);
currentY = getFieldY(topicDataField) + tableHeight + resolvedOptions.topicSpacing;
}
if (keepTogetherFields.length > 0) {
const remainingSpace = resolvedOptions.pageBottomY - currentY;
if (
remainingSpace < resolvedOptions.keepTogetherMinSpace ||
currentY + keepTogetherHeight > resolvedOptions.pageBottomY
) {
pushCurrentPage();
}
const shiftDelta = currentY - keepTogetherTop;
for (const field of keepTogetherFields) {
const nextField = cloneTemplate(field);
setFieldY(nextField, getFieldY(field) + shiftDelta);
currentPageFields.push(nextField);
}
}
if (currentPageFields.length > 0) {
generatedPages.push(currentPageFields as Template['schemas'][number]);
}
nextTemplate.schemas = [
...pages.slice(0, resolvedOptions.targetPageIndex),
...(generatedPages.length > 0
? generatedPages
: [pageWithoutDynamicFields as Template['schemas'][number]]),
...pages.slice(resolvedOptions.targetPageIndex + 1)
];
return {
template: nextTemplate,
topicInputs
};
}

View File

@@ -0,0 +1,24 @@
export type PdfTopicItem = {
id: string | number;
content: string;
sortOrder?: number | null;
};
export type PdfTopic = {
id?: string | number;
topicType: string;
sortOrder?: number | null;
items?: PdfTopicItem[];
};
export type PdfTopicEngineOptions = {
targetPageIndex?: number;
pageStartY?: number;
pageBottomY?: number;
topicSpacing?: number;
keepTogetherNames?: string[];
topicTemplateName?: string;
topicDataTemplateName?: string;
rowHeight?: number;
keepTogetherMinSpace?: number;
};

View File

@@ -0,0 +1,143 @@
const EMPTY_TABLE_FALLBACK = [['-']];
function normalizeNumber(
amount: number | string | null | undefined
): number | null {
if (typeof amount === 'number' && Number.isFinite(amount)) {
return amount;
}
if (typeof amount === 'string') {
const normalized = Number(amount.replaceAll(',', '').trim());
return Number.isFinite(normalized) ? normalized : null;
}
return null;
}
export function formatPdfDate(
dateString: string | Date | null | undefined,
language: 'th' | 'en' = 'en'
): string {
if (!dateString) {
return '-';
}
const date = dateString instanceof Date ? dateString : new Date(dateString);
if (Number.isNaN(date.getTime())) {
return '-';
}
if (language === 'th') {
return date.toLocaleDateString('th-TH', {
day: 'numeric',
month: 'short',
year: 'numeric',
calendar: 'buddhist'
});
}
return date.toLocaleDateString('en-US', {
day: 'numeric',
month: 'short',
year: 'numeric'
});
}
export function formatPdfCurrency(
amount: number | string | null | undefined,
currencyCode = 'THB'
): string {
const numericAmount = normalizeNumber(amount);
if (numericAmount === null) {
return '-';
}
const normalizedCode = currencyCode.toUpperCase();
const symbols: Record<string, string> = {
THB: 'THB ',
USD: '$',
EUR: 'EUR '
};
const symbol = symbols[normalizedCode] ?? `${normalizedCode} `;
const formatted = new Intl.NumberFormat('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(numericAmount);
return `${symbol}${formatted}`.trim();
}
export function formatTopicItems(topic?: {
items?: Array<{ content?: string | null; sortOrder?: number | null }> | string[];
}): string[][] {
if (!topic?.items?.length) {
return EMPTY_TABLE_FALLBACK;
}
const rows = [...topic.items]
.sort((left, right) => {
if (typeof left === 'string' || typeof right === 'string') {
return 0;
}
return (left.sortOrder ?? Number.MAX_SAFE_INTEGER) - (right.sortOrder ?? Number.MAX_SAFE_INTEGER);
})
.map((item) => {
if (typeof item === 'string') {
return item.trim() || '-';
}
return item?.content?.trim() || '-';
})
.filter(Boolean)
.map((content) => [content]);
return rows.length ? rows : EMPTY_TABLE_FALLBACK;
}
export function normalizePdfmeTable(
value: unknown,
columns?: Array<{ sourceField: string; formatMask?: string | null }>
): string[][] {
if (!Array.isArray(value) || value.length === 0) {
return EMPTY_TABLE_FALLBACK;
}
if (value.every((row) => Array.isArray(row))) {
const normalizedRows = value.map((row) =>
(row as unknown[]).map((cell) => {
const stringValue = String(cell ?? '').trim();
return stringValue || '-';
})
);
return normalizedRows.length ? normalizedRows : EMPTY_TABLE_FALLBACK;
}
if (!columns?.length) {
return value.map((row) => [String(row ?? '').trim() || '-']) || EMPTY_TABLE_FALLBACK;
}
const rows = value.map((row) => {
const rowRecord = row as Record<string, unknown>;
return columns.map((column) => {
const cell = rowRecord[column.sourceField];
if (typeof cell === 'number' || typeof cell === 'string') {
return String(cell).trim() || '-';
}
if (cell === null || cell === undefined) {
return '-';
}
return String(cell).trim() || '-';
});
});
return rows.length ? rows : EMPTY_TABLE_FALLBACK;
}

View File

@@ -26,6 +26,15 @@ import type {
QuotationDocumentApprovalStep,
QuotationDocumentData
} from '../types';
import type { PdfTopic } from './pdf-topic.type';
import {
formatPdfCurrency,
formatPdfDate,
formatTopicItems
} from './pdfme-transforms';
import { buildPdfTopicTemplate } from './pdf-topic-engine';
import { resolveQuotationTopicTypeMapping } from './topic-mapping';
import type { Template } from '@pdfme/common';
const DOCUMENT_OPTION_CATEGORIES = {
branch: 'crm_branch',
@@ -84,6 +93,57 @@ function extractSinglePlaceholder(value: string) {
return uniqueMatches.length === 1 ? uniqueMatches[0] : null;
}
function normalizeTopicKey(value: string | null | undefined) {
return value?.trim().toLowerCase().replaceAll(/[\s_-]+/g, '') ?? '';
}
function matchesTopicAlias(actualCode: string | null | undefined, expectedCode: string) {
const actual = normalizeTopicKey(actualCode);
const expected = normalizeTopicKey(expectedCode);
if (!actual || !expected) {
return false;
}
if (actual === expected) {
return true;
}
const aliasGroups = [
['scope', 'scopeofwork', 'dockscopeofwork', 'solarcellscopeofwork'],
[
'exclusion',
'exclusionfromscopeofsupply',
'exclusionofsupplyinstallation',
'solarcellexclusionofsupplyinstallation'
],
[
'payment',
'paymentconditions',
'termsofpaymentuponprogressofeachitem',
'solarcellpaymentconditions'
],
['delivery', 'deliverydate', 'solarcelldelivery'],
['warranty', 'solarcellwarranty']
];
return aliasGroups.some(
(group) => group.includes(actual) && group.includes(expected)
);
}
function sortBySortOrder<T extends { sortOrder?: number | null }>(items: T[]) {
return [...items].sort((left, right) => {
const delta = (left.sortOrder ?? Number.MAX_SAFE_INTEGER) - (right.sortOrder ?? Number.MAX_SAFE_INTEGER);
if (delta !== 0) {
return delta;
}
return 0;
});
}
function normalizePdfmeTemplateInput(
schemaJson: unknown,
templateInput: Record<string, unknown>
@@ -250,6 +310,68 @@ export async function buildQuotationDocumentData(
const statusCode = findOptionCode(statusOptions, quotation.status);
const statusLabel = findOptionLabel(statusOptions, quotation.status);
const topicCodeById = new Map(topicTypeOptions.map((option) => [option.id, option.code]));
const primaryProductType =
items.find((item) => findOptionCode(productTypeOptions, item.productType))?.productType ?? null;
const productTypeCode =
findOptionCode(productTypeOptions, primaryProductType) ??
findOptionCode(quotationTypeOptions, quotationDetail.quotationType) ??
findOptionLabel(quotationTypeOptions, quotationDetail.quotationType) ??
null;
const topicTypeMapping = resolveQuotationTopicTypeMapping(productTypeCode);
const resolvedTopics = sortBySortOrder(topics).map((item) => {
const topicLabel =
findOptionLabel(topicTypeOptions, item.topicType) ??
item.title?.trim() ??
topicCodeById.get(item.topicType) ??
'Topic';
return {
id: item.id,
title: item.title,
topicType: topicLabel,
topicCode: topicCodeById.get(item.topicType) ?? item.title,
sortOrder: item.sortOrder,
items: sortBySortOrder(item.items).map((child) => ({
id: child.id,
content: child.content,
sortOrder: child.sortOrder
}))
};
});
const pdfTopics: PdfTopic[] = resolvedTopics.map((item) => ({
id: item.id,
topicType: item.topicType,
sortOrder: item.sortOrder,
items: item.items.map((child) => ({
id: child.id,
content: child.content?.trim() || '-',
sortOrder: child.sortOrder
}))
}));
const scopeTopics = resolvedTopics.filter((item) =>
matchesTopicAlias(item.topicCode, topicTypeMapping.scope)
);
const exclusionTopics = resolvedTopics.filter((item) =>
matchesTopicAlias(item.topicCode, topicTypeMapping.exclusion)
);
const paymentTopics = resolvedTopics.filter((item) =>
matchesTopicAlias(item.topicCode, topicTypeMapping.payment)
);
const warrantyTopics = resolvedTopics.filter((item) =>
matchesTopicAlias(item.topicCode, topicTypeMapping.warranty)
);
const deliveryTopics = resolvedTopics.filter((item) =>
matchesTopicAlias(item.topicCode, topicTypeMapping.delivery)
);
const otherTopics = resolvedTopics.filter((item) => {
return ![topicTypeMapping.scope, topicTypeMapping.exclusion, topicTypeMapping.payment, topicTypeMapping.warranty, topicTypeMapping.delivery].some(
(expectedCode) => matchesTopicAlias(item.topicCode, expectedCode)
);
});
const exclusionTopic = exclusionTopics[0];
const paymentTopic = paymentTopics[0];
const warrantyTopic = warrantyTopics[0];
const deliveryTopic = deliveryTopics[0];
const approvalApprovers: QuotationDocumentApprovalStep[] =
approvalDetail?.timeline
.filter((item) => ['approve', 'reject', 'return'].includes(item.action))
@@ -315,6 +437,7 @@ export async function buildQuotationDocumentData(
statusLabel,
quotationTypeCode: findOptionCode(quotationTypeOptions, quotationDetail.quotationType),
quotationTypeLabel: findOptionLabel(quotationTypeOptions, quotationDetail.quotationType),
currency: findOptionCode(currencyOptions, quotationDetail.currency),
currencyCode: findOptionCode(currencyOptions, quotationDetail.currency),
currencyLabel: findOptionLabel(currencyOptions, quotationDetail.currency),
exchangeRate: quotationDetail.exchangeRate,
@@ -353,21 +476,46 @@ export async function buildQuotationDocumentData(
isPrimary: item.isPrimary
})),
topics: {
scope: topics
.filter((item) => topicCodeById.get(item.topicType) === 'scope')
.map((item) => ({ title: item.title, items: item.items.map((child) => child.content) })),
exclusion: topics
.filter((item) => topicCodeById.get(item.topicType) === 'exclusion')
.map((item) => ({ title: item.title, items: item.items.map((child) => child.content) })),
payment: topics
.filter((item) => topicCodeById.get(item.topicType) === 'payment')
.map((item) => ({ title: item.title, items: item.items.map((child) => child.content) })),
other: topics
.filter((item) => {
const topicCode = topicCodeById.get(item.topicType);
return !['scope', 'exclusion', 'payment'].includes(topicCode ?? '');
})
.map((item) => ({ title: item.title, items: item.items.map((child) => child.content) }))
scope: scopeTopics.map((item) => ({
title: item.title,
items: item.items.map((child) => child.content ?? '-')
})),
exclusion: exclusionTopics.map((item) => ({
title: item.title,
items: item.items.map((child) => child.content ?? '-')
})),
payment: paymentTopics.map((item) => ({
title: item.title,
items: item.items.map((child) => child.content ?? '-')
})),
warranty: warrantyTopics.map((item) => ({
title: item.title,
items: item.items.map((child) => child.content ?? '-')
})),
delivery: deliveryTopics.map((item) => ({
title: item.title,
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 ?? '-')
})),
all: pdfTopics
},
pdfme: {
quotation_date: formatPdfDate(quotationDetail.quotationDate, 'en'),
quotation_price: formatPdfCurrency(
quotationDetail.totalAmount,
findOptionCode(currencyOptions, quotationDetail.currency) ?? 'THB'
),
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: {}
},
approval: {
requestId: approvalDetail?.request.id ?? null,
@@ -381,12 +529,16 @@ export async function buildQuotationDocumentData(
},
signatures: {
preparedBy: preparer?.name ?? null,
preparedByPosition: null,
requestedBy: contact?.name ?? customer.name,
salesManager:
approvalApprovers.find((item) => item.roleCode === 'sales_manager')?.actorName ?? null,
salesManagerPosition: null,
departmentManager:
approvalApprovers.find((item) => item.roleCode === 'department_manager')?.actorName ?? null,
topManager: approvalApprovers.find((item) => item.roleCode === 'top_manager')?.actorName ?? null
departmentManagerPosition: null,
topManager: approvalApprovers.find((item) => item.roleCode === 'top_manager')?.actorName ?? null,
topManagerPosition: null
},
watermarkStatus: statusCode && statusCode !== 'approved' ? statusLabel ?? statusCode : null
};
@@ -412,12 +564,28 @@ export async function getQuotationDocumentPreviewData(
template.version.schemaJson,
mapDocumentDataToTemplateInput(documentData as unknown as Record<string, unknown>, mappings)
);
const { template: renderTemplate, topicInputs } = buildPdfTopicTemplate(
template.version.schemaJson as Template,
documentData.topics.all
);
const mergedTemplateInput = {
...templateInput,
...topicInputs
};
documentData.pdfme.topic_inputs = topicInputs;
return {
documentData,
template,
template: {
...template,
version: {
...template.version,
schemaJson: renderTemplate
}
},
mappings,
templateInput
templateInput: mergedTemplateInput
};
}

View File

@@ -0,0 +1,56 @@
export const QUOTATION_TOPIC_TYPE_MAPPING = {
crane: {
scope: 'scope_of_work',
exclusion: 'exclusion_from_scope_of_supply',
payment: 'terms_of_payment_upon_progress_of_each_item',
delivery: 'delivery_date',
warranty: 'warranty'
},
dockdoor: {
scope: 'dock_scope_of_work',
exclusion: 'exclusion_of_supply_installation',
payment: 'payment_conditions',
delivery: 'delivery_date',
warranty: 'warranty'
},
solarcell: {
scope: 'solarcell_scope_of_work',
exclusion: 'solarcell_exclusion_of_supply_installation',
payment: 'solarcell_payment_conditions',
delivery: 'solarcell_delivery',
warranty: 'solarcell_warranty'
},
default: {
scope: 'scope_of_work',
exclusion: 'exclusion_from_scope_of_supply',
payment: 'payment_conditions',
delivery: 'delivery',
warranty: 'warranty'
}
} as const;
function normalizeKey(value: string | null | undefined) {
return value?.trim().toLowerCase().replaceAll(/[\s_-]+/g, '') ?? '';
}
export function getQuotationTopicMapping(quotationType?: string | null) {
const normalized = normalizeKey(quotationType);
if (normalized.includes('crane')) {
return QUOTATION_TOPIC_TYPE_MAPPING.crane;
}
if (normalized.includes('dock')) {
return QUOTATION_TOPIC_TYPE_MAPPING.dockdoor;
}
if (normalized.includes('solar')) {
return QUOTATION_TOPIC_TYPE_MAPPING.solarcell;
}
return QUOTATION_TOPIC_TYPE_MAPPING.default;
}
export function resolveQuotationTopicTypeMapping(productType: string | null | undefined) {
return getQuotationTopicMapping(productType);
}

View File

@@ -2,9 +2,12 @@ import type {
DocumentTemplateMappingWithColumns,
ResolvedDocumentTemplate
} from '@/features/foundation/document-template/types';
import type { PdfTopic } from './server/pdf-topic.type';
export interface QuotationDocumentTopicGroup {
id?: string;
title: string;
sortOrder?: number | null;
items: string[];
}
@@ -63,6 +66,7 @@ export interface QuotationDocumentData {
statusLabel: string | null;
quotationTypeCode: string | null;
quotationTypeLabel: string | null;
currency: string | null;
currencyCode: string | null;
currencyLabel: string | null;
exchangeRate: number;
@@ -104,7 +108,20 @@ export interface QuotationDocumentData {
scope: QuotationDocumentTopicGroup[];
exclusion: QuotationDocumentTopicGroup[];
payment: QuotationDocumentTopicGroup[];
warranty: QuotationDocumentTopicGroup[];
delivery: QuotationDocumentTopicGroup[];
other: QuotationDocumentTopicGroup[];
all: PdfTopic[];
};
pdfme: {
quotation_date: string;
quotation_price: string;
exclusion_data: string[][];
payment_data?: string[][];
warranty_data?: string[][];
delivery_data?: string[][];
topics_data?: string[][];
topic_inputs?: Record<string, string[][]>;
};
approval: {
requestId: string | null;
@@ -117,10 +134,14 @@ export interface QuotationDocumentData {
};
signatures: {
preparedBy: string | null;
preparedByPosition?: string | null;
requestedBy: string | null;
salesManager: string | null;
salesManagerPosition?: string | null;
departmentManager: string | null;
departmentManagerPosition?: string | null;
topManager: string | null;
topManagerPosition?: string | null;
};
watermarkStatus: string | null;
}

View File

@@ -9,6 +9,7 @@ import {
lte,
ne,
or,
sql,
type SQL
} from 'drizzle-orm';
import {
@@ -32,6 +33,13 @@ interface NormalizedProjectParty {
roleCode: string;
}
type ProjectPartyTableAvailability = {
enquiryCustomers: boolean;
quotationCustomers: boolean;
};
let projectPartyTableAvailability: ProjectPartyTableAvailability | null = null;
function splitFilterValue(value?: string) {
return value
?.split(',')
@@ -152,11 +160,34 @@ function pickAttributedParties(
return authoritativeParties.filter((party) => party.roleCode === roleCode);
}
async function getProjectPartyTableAvailability(): Promise<ProjectPartyTableAvailability> {
if (projectPartyTableAvailability) {
return projectPartyTableAvailability;
}
const rows = await db.execute<{ tableName: string }>(sql`
select table_name as "tableName"
from information_schema.tables
where table_schema = 'public'
and table_name in ('crm_enquiry_customers', 'crm_quotation_customers')
`);
const available = new Set(rows.map((row) => row.tableName));
projectPartyTableAvailability = {
enquiryCustomers: available.has('crm_enquiry_customers'),
quotationCustomers: available.has('crm_quotation_customers')
};
return projectPartyTableAvailability;
}
async function getRevenueByRole(
organizationId: string,
roleCode: SupportedRevenueRole,
filters: RevenueAttributionFilters = {}
): Promise<RevenueAttributionSummary[]> {
const tableAvailability = await getProjectPartyTableAvailability();
const whereFilters = buildRevenueAttributionFilters(organizationId, filters);
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
@@ -164,6 +195,7 @@ async function getRevenueByRole(
.select({
id: crmQuotations.id,
enquiryId: crmQuotations.enquiryId,
customerId: crmQuotations.customerId,
totalAmount: crmQuotations.totalAmount
})
.from(crmQuotations)
@@ -179,21 +211,23 @@ async function getRevenueByRole(
const [roleOptions, quotationParties, enquiryParties] = await Promise.all([
getActiveOptionsByCategory(PROJECT_PARTY_OPTION_CATEGORY, { organizationId }),
db
.select({
quotationId: crmQuotationCustomers.quotationId,
customerId: crmQuotationCustomers.customerId,
role: crmQuotationCustomers.role
})
.from(crmQuotationCustomers)
.where(
and(
eq(crmQuotationCustomers.organizationId, organizationId),
inArray(crmQuotationCustomers.quotationId, quotationIds),
isNull(crmQuotationCustomers.deletedAt)
)
),
enquiryIds.length
tableAvailability.quotationCustomers
? db
.select({
quotationId: crmQuotationCustomers.quotationId,
customerId: crmQuotationCustomers.customerId,
role: crmQuotationCustomers.role
})
.from(crmQuotationCustomers)
.where(
and(
eq(crmQuotationCustomers.organizationId, organizationId),
inArray(crmQuotationCustomers.quotationId, quotationIds),
isNull(crmQuotationCustomers.deletedAt)
)
)
: [],
tableAvailability.enquiryCustomers && enquiryIds.length
? db
.select({
enquiryId: crmEnquiryCustomers.enquiryId,
@@ -233,11 +267,16 @@ async function getRevenueByRole(
const attributionByQuotation = new Map<string, NormalizedProjectParty[]>();
for (const quotation of quotations) {
const attributedParties = pickAttributedParties(
roleCode,
quotationPartyMap.get(quotation.id) ?? [],
quotation.enquiryId ? (enquiryPartyMap.get(quotation.enquiryId) ?? []) : []
);
const quotationAttributedParties = quotationPartyMap.get(quotation.id) ?? [];
const enquiryAttributedParties = quotation.enquiryId
? (enquiryPartyMap.get(quotation.enquiryId) ?? [])
: [];
const attributedParties =
quotationAttributedParties.length > 0 || enquiryAttributedParties.length > 0
? pickAttributedParties(roleCode, quotationAttributedParties, enquiryAttributedParties)
: roleCode === 'end_customer' || roleCode === 'billing_customer'
? [{ customerId: quotation.customerId, roleCode }]
: [];
attributionByQuotation.set(quotation.id, attributedParties);

View File

@@ -7,6 +7,11 @@ import {
} from '@/db/schema';
import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
import {
formatPdfCurrency,
formatPdfDate,
normalizePdfmeTable
} from '@/features/crm/quotations/document/server/pdfme-transforms';
import type {
DocumentTemplateDetail,
DocumentTemplateFilters,
@@ -682,12 +687,18 @@ function applyFormatMask(value: unknown, formatMask?: string | null) {
return value;
}
if (formatMask === 'currency' && typeof value === 'number') {
return value.toLocaleString();
if (formatMask === 'currency' || formatMask?.startsWith('currency_')) {
const currencyCode =
formatMask === 'currency' ? 'THB' : formatMask.replace('currency_', '');
return formatPdfCurrency(value as number | string, currencyCode);
}
if (formatMask === 'date' && typeof value === 'string') {
return new Date(value).toLocaleDateString();
if (formatMask === 'date' || formatMask === 'date_en') {
return formatPdfDate(value as string, 'en');
}
if (formatMask === 'date_th') {
return formatPdfDate(value as string, 'th');
}
return value;
@@ -704,16 +715,32 @@ export function mapDocumentDataToTemplateInput(
if (mapping.dataType === 'table') {
const rows = Array.isArray(rawValue) ? rawValue : [];
result[mapping.placeholderKey] = rows.map((row) => {
const rowRecord = row as Record<string, unknown>;
return mapping.columns.reduce<Record<string, unknown>>((accumulator, column) => {
accumulator[column.columnName] = applyFormatMask(
rowRecord[column.sourceField],
column.formatMask
);
return accumulator;
}, {});
});
if (rows.length === 0) {
result[mapping.placeholderKey] = normalizePdfmeTable([]);
continue;
}
if (rows.every((row) => Array.isArray(row))) {
result[mapping.placeholderKey] = normalizePdfmeTable(rows);
continue;
}
if (mapping.columns.length) {
const normalizedRows = rows.map((row) => {
const rowRecord = row as Record<string, unknown>;
return mapping.columns.map((column) => {
const formattedValue = applyFormatMask(rowRecord[column.sourceField], column.formatMask);
return String(formattedValue ?? '-').trim() || '-';
});
});
result[mapping.placeholderKey] = normalizePdfmeTable(normalizedRows);
continue;
}
result[mapping.placeholderKey] = normalizePdfmeTable(rows);
continue;
}
@@ -743,7 +770,7 @@ export function mapDocumentDataToTemplateInput(
}
result[mapping.placeholderKey] =
applyFormatMask(rawValue, mapping.formatMask) ?? mapping.defaultValue ?? '';
applyFormatMask(rawValue, mapping.formatMask) ?? mapping.defaultValue ?? '-';
}
return result;