hotfix-add subgroup customer
This commit is contained in:
@@ -18,7 +18,8 @@ type Params = {
|
||||
const customerRequestSchema = customerSchema.extend({
|
||||
branchId: z.string().nullable().optional(),
|
||||
leadChannel: z.string().nullable().optional(),
|
||||
customerGroup: z.string().nullable().optional()
|
||||
customerGroup: z.string().nullable().optional(),
|
||||
customerSubGroup: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
export async function GET(_request: NextRequest, { params }: Params) {
|
||||
@@ -44,9 +45,7 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
console.error('GET /api/crm/customers/[id] failed', error);
|
||||
|
||||
return NextResponse.json({ message: 'Unable to load customer' }, { status: 500 });
|
||||
}
|
||||
@@ -89,9 +88,7 @@ export async function PATCH(request: NextRequest, { params }: Params) {
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
console.error('PATCH /api/crm/customers/[id] failed', error);
|
||||
|
||||
return NextResponse.json({ message: 'Unable to update customer' }, { status: 500 });
|
||||
}
|
||||
@@ -125,9 +122,7 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
console.error('DELETE /api/crm/customers/[id] failed', error);
|
||||
|
||||
return NextResponse.json({ message: 'Unable to delete customer' }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
const customerRequestSchema = customerSchema.extend({
|
||||
branchId: z.string().nullable().optional(),
|
||||
leadChannel: z.string().nullable().optional(),
|
||||
customerGroup: z.string().nullable().optional()
|
||||
customerGroup: z.string().nullable().optional(),
|
||||
customerSubGroup: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
@@ -50,9 +51,7 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
console.error('GET /api/crm/customers failed', error);
|
||||
|
||||
return NextResponse.json({ message: 'Unable to load customers' }, { status: 500 });
|
||||
}
|
||||
@@ -95,9 +94,7 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
console.error('POST /api/crm/customers failed', error);
|
||||
|
||||
return NextResponse.json({ message: 'Unable to create customer' }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
console.error('GET /api/crm/dashboard failed', error);
|
||||
|
||||
return NextResponse.json({ message: 'Unable to load CRM dashboard' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,41 @@
|
||||
import Providers from '@/components/layout/providers';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { fontVariables } from '@/components/themes/font.config';
|
||||
import { DEFAULT_THEME, THEMES } from '@/components/themes/theme.config';
|
||||
import ThemeProvider from '@/components/themes/theme-provider';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { cookies } from 'next/headers';
|
||||
import NextTopLoader from 'nextjs-toploader';
|
||||
import { NuqsAdapter } from 'nuqs/adapters/next/app';
|
||||
import '../styles/globals.css';
|
||||
import Providers from "@/components/layout/providers";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { fontVariables } from "@/components/themes/font.config";
|
||||
import { DEFAULT_THEME, THEMES } from "@/components/themes/theme.config";
|
||||
import ThemeProvider from "@/components/themes/theme-provider";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import NextTopLoader from "nextjs-toploader";
|
||||
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
||||
import "../styles/globals.css";
|
||||
|
||||
const META_THEME_COLORS = {
|
||||
light: '#f9fbfa',
|
||||
dark: '#001e2b'
|
||||
light: "#f9fbfa",
|
||||
dark: "#001e2b",
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Next Shadcn',
|
||||
description: 'Basic dashboard with Next.js and Shadcn'
|
||||
title: "ALLA OS",
|
||||
description: "alla os",
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: META_THEME_COLORS.light
|
||||
themeColor: META_THEME_COLORS.light,
|
||||
};
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const cookieStore = await cookies();
|
||||
const activeThemeValue = cookieStore.get('active_theme')?.value;
|
||||
const activeThemeValue = cookieStore.get("active_theme")?.value;
|
||||
const isValidTheme = THEMES.some((t) => t.value === activeThemeValue);
|
||||
const themeToApply = isValidTheme ? activeThemeValue! : DEFAULT_THEME;
|
||||
|
||||
return (
|
||||
<html lang='en' suppressHydrationWarning data-theme={themeToApply}>
|
||||
<html lang="en" suppressHydrationWarning data-theme={themeToApply}>
|
||||
<head>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
@@ -42,21 +46,21 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', '${META_THEME_COLORS.dark}')
|
||||
}
|
||||
} catch (_) {}
|
||||
`
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
className={cn(
|
||||
'bg-background overflow-x-hidden overscroll-none font-sans antialiased',
|
||||
fontVariables
|
||||
"bg-background overflow-x-hidden overscroll-none font-sans antialiased",
|
||||
fontVariables,
|
||||
)}
|
||||
>
|
||||
<NextTopLoader color='var(--primary)' showSpinner={false} />
|
||||
<NextTopLoader color="var(--primary)" showSpinner={false} />
|
||||
<NuqsAdapter>
|
||||
<ThemeProvider
|
||||
attribute='class'
|
||||
defaultTheme='system'
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
enableColorScheme
|
||||
|
||||
@@ -25,6 +25,7 @@ interface SelectFieldProps {
|
||||
required?: boolean;
|
||||
options: Option[];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function SelectField({
|
||||
@@ -32,7 +33,8 @@ export function SelectField({
|
||||
description,
|
||||
required,
|
||||
options,
|
||||
placeholder = 'Select an option'
|
||||
placeholder = 'Select an option',
|
||||
disabled = false
|
||||
}: SelectFieldProps) {
|
||||
const field = useFieldContext();
|
||||
const isTouched = useStore(field.store, (s) => s.meta.isTouched);
|
||||
@@ -48,12 +50,13 @@ export function SelectField({
|
||||
</FieldLabel>
|
||||
<Select
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onValueChange={field.handleChange}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) field.handleBlur();
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id={field.name} aria-invalid={isTouched && !isValid}>
|
||||
<SelectTrigger id={field.name} aria-invalid={isTouched && !isValid} disabled={disabled}>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -34,52 +34,52 @@ import { NavGroup } from "@/types";
|
||||
* Use the `access` property for new items.
|
||||
*/
|
||||
export const navGroups: NavGroup[] = [
|
||||
{
|
||||
label: "Overview",
|
||||
items: [
|
||||
{
|
||||
title: "Dashboard Overview",
|
||||
url: "/dashboard",
|
||||
icon: "dashboard",
|
||||
isActive: false,
|
||||
shortcut: ["d", "d"],
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
title: "Workspaces",
|
||||
url: "/dashboard/workspaces",
|
||||
icon: "workspace",
|
||||
isActive: false,
|
||||
items: [],
|
||||
access: { systemRole: "super_admin" },
|
||||
},
|
||||
{
|
||||
title: "Teams",
|
||||
url: "/dashboard/workspaces/team",
|
||||
icon: "teams",
|
||||
isActive: false,
|
||||
items: [],
|
||||
access: { requireOrg: true, role: "admin" },
|
||||
},
|
||||
{
|
||||
title: "Users",
|
||||
url: "/dashboard/users",
|
||||
icon: "teams",
|
||||
shortcut: ["u", "u"],
|
||||
isActive: false,
|
||||
items: [],
|
||||
access: { requireOrg: true, permission: "users:manage" },
|
||||
},
|
||||
{
|
||||
title: "Kanban",
|
||||
url: "/dashboard/kanban",
|
||||
icon: "kanban",
|
||||
shortcut: ["k", "k"],
|
||||
isActive: false,
|
||||
items: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
// {
|
||||
// label: "Overview",
|
||||
// items: [
|
||||
// {
|
||||
// title: "Dashboard Overview",
|
||||
// url: "/dashboard",
|
||||
// icon: "dashboard",
|
||||
// isActive: false,
|
||||
// shortcut: ["d", "d"],
|
||||
// items: [],
|
||||
// },
|
||||
// {
|
||||
// title: "Workspaces",
|
||||
// url: "/dashboard/workspaces",
|
||||
// icon: "workspace",
|
||||
// isActive: false,
|
||||
// items: [],
|
||||
// access: { systemRole: "super_admin" },
|
||||
// },
|
||||
// {
|
||||
// title: "Teams",
|
||||
// url: "/dashboard/workspaces/team",
|
||||
// icon: "teams",
|
||||
// isActive: false,
|
||||
// items: [],
|
||||
// access: { requireOrg: true, role: "admin" },
|
||||
// },
|
||||
// {
|
||||
// title: "Users",
|
||||
// url: "/dashboard/users",
|
||||
// icon: "teams",
|
||||
// shortcut: ["u", "u"],
|
||||
// isActive: false,
|
||||
// items: [],
|
||||
// access: { requireOrg: true, permission: "users:manage" },
|
||||
// },
|
||||
// {
|
||||
// title: "Kanban",
|
||||
// url: "/dashboard/kanban",
|
||||
// icon: "kanban",
|
||||
// shortcut: ["k", "k"],
|
||||
// isActive: false,
|
||||
// items: [],
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
{
|
||||
label: "CRM",
|
||||
items: [
|
||||
|
||||
@@ -153,6 +153,7 @@ export const crmCustomers = pgTable(
|
||||
website: text('website'),
|
||||
leadChannel: text('lead_channel'),
|
||||
customerGroup: text('customer_group'),
|
||||
customerSubGroup: text('customer_sub_group'),
|
||||
notes: text('notes'),
|
||||
isActive: boolean('is_active').default(true).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
|
||||
@@ -9,6 +9,8 @@ type SeedOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
sortOrder: number;
|
||||
parentCode?: string;
|
||||
parentCategory?: string;
|
||||
};
|
||||
|
||||
type BranchSeedRow = {
|
||||
@@ -110,9 +112,85 @@ const FOUNDATION_OPTIONS = {
|
||||
{ code: 'individual', label: 'Individual', value: 'individual', sortOrder: 2 }
|
||||
],
|
||||
crm_customer_group: [
|
||||
{ code: 'strategic', label: 'Strategic', value: 'strategic', sortOrder: 1 },
|
||||
{ code: 'standard', label: 'Standard', value: 'standard', sortOrder: 2 },
|
||||
{ code: 'project', label: 'Project', value: 'project', sortOrder: 3 }
|
||||
{ code: 'industrial', label: 'Industrial', value: 'industrial', sortOrder: 1 },
|
||||
{ code: 'construction', label: 'Construction', value: 'construction', sortOrder: 2 },
|
||||
{ code: 'government', label: 'Government', value: 'government', sortOrder: 3 },
|
||||
{ code: 'dealer', label: 'Dealer', value: 'dealer', sortOrder: 4 },
|
||||
{ code: 'other', label: 'Other', value: 'other', sortOrder: 5 }
|
||||
],
|
||||
crm_customer_sub_group: [
|
||||
{
|
||||
code: 'factory',
|
||||
label: 'Factory',
|
||||
value: 'factory',
|
||||
sortOrder: 1,
|
||||
parentCode: 'industrial',
|
||||
parentCategory: 'crm_customer_group'
|
||||
},
|
||||
{
|
||||
code: 'warehouse',
|
||||
label: 'Warehouse',
|
||||
value: 'warehouse',
|
||||
sortOrder: 2,
|
||||
parentCode: 'industrial',
|
||||
parentCategory: 'crm_customer_group'
|
||||
},
|
||||
{
|
||||
code: 'logistics',
|
||||
label: 'Logistics',
|
||||
value: 'logistics',
|
||||
sortOrder: 3,
|
||||
parentCode: 'industrial',
|
||||
parentCategory: 'crm_customer_group'
|
||||
},
|
||||
{
|
||||
code: 'contractor',
|
||||
label: 'Contractor',
|
||||
value: 'contractor',
|
||||
sortOrder: 4,
|
||||
parentCode: 'construction',
|
||||
parentCategory: 'crm_customer_group'
|
||||
},
|
||||
{
|
||||
code: 'consultant',
|
||||
label: 'Consultant',
|
||||
value: 'consultant',
|
||||
sortOrder: 5,
|
||||
parentCode: 'construction',
|
||||
parentCategory: 'crm_customer_group'
|
||||
},
|
||||
{
|
||||
code: 'state_enterprise',
|
||||
label: 'State Enterprise',
|
||||
value: 'state_enterprise',
|
||||
sortOrder: 6,
|
||||
parentCode: 'government',
|
||||
parentCategory: 'crm_customer_group'
|
||||
},
|
||||
{
|
||||
code: 'government_agency',
|
||||
label: 'Government Agency',
|
||||
value: 'government_agency',
|
||||
sortOrder: 7,
|
||||
parentCode: 'government',
|
||||
parentCategory: 'crm_customer_group'
|
||||
},
|
||||
{
|
||||
code: 'reseller',
|
||||
label: 'Reseller',
|
||||
value: 'reseller',
|
||||
sortOrder: 8,
|
||||
parentCode: 'dealer',
|
||||
parentCategory: 'crm_customer_group'
|
||||
},
|
||||
{
|
||||
code: 'other',
|
||||
label: 'Other',
|
||||
value: 'other',
|
||||
sortOrder: 9,
|
||||
parentCode: 'other',
|
||||
parentCategory: 'crm_customer_group'
|
||||
}
|
||||
],
|
||||
crm_enquiry_status: [
|
||||
{ code: 'new', label: 'New', value: 'new', sortOrder: 1 },
|
||||
@@ -265,7 +343,7 @@ const DOCUMENT_TEMPLATE_MAPPINGS: TemplateMappingSeed[] = [
|
||||
},
|
||||
{
|
||||
placeholderKey: 'customer_att',
|
||||
sourcePath: 'contact.name',
|
||||
sourcePath: 'quotation.attention',
|
||||
dataType: 'scalar',
|
||||
sortOrder: 5
|
||||
},
|
||||
@@ -283,9 +361,9 @@ const DOCUMENT_TEMPLATE_MAPPINGS: TemplateMappingSeed[] = [
|
||||
},
|
||||
{
|
||||
placeholderKey: 'quotation_date',
|
||||
sourcePath: 'quotation.quotationDate',
|
||||
sourcePath: 'pdfme.quotation_date',
|
||||
dataType: 'scalar',
|
||||
formatMask: 'date',
|
||||
formatMask: null,
|
||||
sortOrder: 8
|
||||
},
|
||||
{
|
||||
@@ -296,34 +374,70 @@ const DOCUMENT_TEMPLATE_MAPPINGS: TemplateMappingSeed[] = [
|
||||
},
|
||||
{
|
||||
placeholderKey: 'quotation_price',
|
||||
sourcePath: 'quotation.totalAmount',
|
||||
sourcePath: 'pdfme.quotation_price',
|
||||
dataType: 'scalar',
|
||||
formatMask: 'currency',
|
||||
formatMask: null,
|
||||
sortOrder: 10
|
||||
},
|
||||
{
|
||||
placeholderKey: 'currency',
|
||||
sourcePath: 'quotation.currencyCode',
|
||||
sourcePath: 'quotation.currency',
|
||||
dataType: 'scalar',
|
||||
sortOrder: 11
|
||||
},
|
||||
{
|
||||
placeholderKey: 'exclusion_data',
|
||||
sourcePath: 'topics.exclusion',
|
||||
dataType: 'multiline',
|
||||
sourcePath: 'pdfme.exclusion_data',
|
||||
dataType: 'table',
|
||||
sortOrder: 12
|
||||
},
|
||||
{
|
||||
placeholderKey: 'item_topic',
|
||||
sourcePath: 'topics.scope',
|
||||
dataType: 'multiline',
|
||||
placeholderKey: 'app1',
|
||||
sourcePath: 'signatures.preparedBy',
|
||||
dataType: 'scalar',
|
||||
defaultValue: '-',
|
||||
sortOrder: 13
|
||||
},
|
||||
{
|
||||
placeholderKey: 'app1_position',
|
||||
sourcePath: 'signatures.preparedByPosition',
|
||||
dataType: 'scalar',
|
||||
defaultValue: '-',
|
||||
sortOrder: 14
|
||||
},
|
||||
{
|
||||
placeholderKey: 'app2',
|
||||
sourcePath: 'signatures.salesManager',
|
||||
dataType: 'scalar',
|
||||
defaultValue: '-',
|
||||
sortOrder: 15
|
||||
},
|
||||
{
|
||||
placeholderKey: 'app2_position',
|
||||
sourcePath: 'signatures.salesManagerPosition',
|
||||
dataType: 'scalar',
|
||||
defaultValue: '-',
|
||||
sortOrder: 16
|
||||
},
|
||||
{
|
||||
placeholderKey: 'app3',
|
||||
sourcePath: 'signatures.topManager',
|
||||
dataType: 'scalar',
|
||||
defaultValue: '-',
|
||||
sortOrder: 17
|
||||
},
|
||||
{
|
||||
placeholderKey: 'app3_position',
|
||||
sourcePath: 'signatures.topManagerPosition',
|
||||
dataType: 'scalar',
|
||||
defaultValue: '-',
|
||||
sortOrder: 18
|
||||
},
|
||||
{
|
||||
placeholderKey: 'items_table',
|
||||
sourcePath: 'items',
|
||||
dataType: 'table',
|
||||
sortOrder: 14,
|
||||
sortOrder: 19,
|
||||
columns: [
|
||||
{ columnName: 'Item', sourceField: 'itemNumber', columnLetter: 'A', sortOrder: 1 },
|
||||
{ columnName: 'Description', sourceField: 'description', columnLetter: 'B', sortOrder: 2 },
|
||||
@@ -387,6 +501,20 @@ async function upsertMasterOptionsForOrganization(
|
||||
[string, SeedOption[]]
|
||||
>) {
|
||||
for (const option of options) {
|
||||
const parentRow =
|
||||
option.parentCode && option.parentCategory
|
||||
? (
|
||||
await sql`
|
||||
select id
|
||||
from ms_options
|
||||
where organization_id = ${organizationId}
|
||||
and category = ${option.parentCategory}
|
||||
and code = ${option.parentCode}
|
||||
and deleted_at is null
|
||||
limit 1
|
||||
`
|
||||
)[0] ?? null
|
||||
: null;
|
||||
const [row] = await sql`
|
||||
insert into ms_options (
|
||||
id,
|
||||
@@ -407,7 +535,7 @@ async function upsertMasterOptionsForOrganization(
|
||||
${option.code},
|
||||
${option.label},
|
||||
${option.value},
|
||||
${null},
|
||||
${parentRow?.id ?? null},
|
||||
${option.sortOrder},
|
||||
${true},
|
||||
${null},
|
||||
@@ -651,38 +779,7 @@ async function upsertDocumentTemplatesForOrganization(
|
||||
}
|
||||
|
||||
for (const mapping of DOCUMENT_TEMPLATE_MAPPINGS) {
|
||||
const [mappingRow] = await sql`
|
||||
insert into crm_document_template_mappings (
|
||||
id,
|
||||
organization_id,
|
||||
template_version_id,
|
||||
placeholder_key,
|
||||
source_path,
|
||||
data_type,
|
||||
sheet_name,
|
||||
default_value,
|
||||
format_mask,
|
||||
sort_order,
|
||||
deleted_at
|
||||
) values (
|
||||
${crypto.randomUUID()},
|
||||
${organization.id},
|
||||
${resolvedVersionId},
|
||||
${mapping.placeholderKey},
|
||||
${mapping.sourcePath},
|
||||
${mapping.dataType},
|
||||
${null},
|
||||
${mapping.defaultValue ?? null},
|
||||
${mapping.formatMask ?? null},
|
||||
${mapping.sortOrder},
|
||||
${null}
|
||||
)
|
||||
on conflict do nothing
|
||||
returning id
|
||||
`;
|
||||
|
||||
const resolvedMappingId =
|
||||
mappingRow?.id ??
|
||||
const existingMapping =
|
||||
(
|
||||
await sql`
|
||||
select id
|
||||
@@ -690,16 +787,88 @@ async function upsertDocumentTemplatesForOrganization(
|
||||
where organization_id = ${organization.id}
|
||||
and template_version_id = ${resolvedVersionId}
|
||||
and placeholder_key = ${mapping.placeholderKey}
|
||||
and deleted_at is null
|
||||
limit 1
|
||||
`
|
||||
)[0]?.id;
|
||||
)[0] ?? null;
|
||||
|
||||
const resolvedMappingId = existingMapping?.id ?? crypto.randomUUID();
|
||||
|
||||
if (existingMapping) {
|
||||
await sql`
|
||||
update crm_document_template_mappings
|
||||
set
|
||||
source_path = ${mapping.sourcePath},
|
||||
data_type = ${mapping.dataType},
|
||||
sheet_name = ${null},
|
||||
default_value = ${mapping.defaultValue ?? null},
|
||||
format_mask = ${mapping.formatMask ?? null},
|
||||
sort_order = ${mapping.sortOrder},
|
||||
deleted_at = ${null},
|
||||
updated_at = now()
|
||||
where id = ${resolvedMappingId}
|
||||
`;
|
||||
} else {
|
||||
await sql`
|
||||
insert into crm_document_template_mappings (
|
||||
id,
|
||||
organization_id,
|
||||
template_version_id,
|
||||
placeholder_key,
|
||||
source_path,
|
||||
data_type,
|
||||
sheet_name,
|
||||
default_value,
|
||||
format_mask,
|
||||
sort_order,
|
||||
deleted_at
|
||||
) values (
|
||||
${resolvedMappingId},
|
||||
${organization.id},
|
||||
${resolvedVersionId},
|
||||
${mapping.placeholderKey},
|
||||
${mapping.sourcePath},
|
||||
${mapping.dataType},
|
||||
${null},
|
||||
${mapping.defaultValue ?? null},
|
||||
${mapping.formatMask ?? null},
|
||||
${mapping.sortOrder},
|
||||
${null}
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
if (!resolvedMappingId || !mapping.columns?.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const column of mapping.columns) {
|
||||
const existingColumn =
|
||||
(
|
||||
await sql`
|
||||
select id
|
||||
from crm_document_template_table_columns
|
||||
where organization_id = ${organization.id}
|
||||
and mapping_id = ${resolvedMappingId}
|
||||
and column_name = ${column.columnName}
|
||||
limit 1
|
||||
`
|
||||
)[0] ?? null;
|
||||
|
||||
if (existingColumn) {
|
||||
await sql`
|
||||
update crm_document_template_table_columns
|
||||
set
|
||||
source_field = ${column.sourceField},
|
||||
column_letter = ${column.columnLetter ?? null},
|
||||
sort_order = ${column.sortOrder},
|
||||
format_mask = ${column.formatMask ?? null},
|
||||
deleted_at = ${null},
|
||||
updated_at = now()
|
||||
where id = ${existingColumn.id}
|
||||
`;
|
||||
continue;
|
||||
}
|
||||
|
||||
await sql`
|
||||
insert into crm_document_template_table_columns (
|
||||
id,
|
||||
@@ -722,10 +891,25 @@ async function upsertDocumentTemplatesForOrganization(
|
||||
${column.formatMask ?? null},
|
||||
${null}
|
||||
)
|
||||
on conflict do nothing
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
await sql`
|
||||
update crm_document_template_mappings
|
||||
set
|
||||
deleted_at = now(),
|
||||
updated_at = now()
|
||||
where organization_id = ${organization.id}
|
||||
and template_version_id = ${resolvedVersionId}
|
||||
and (
|
||||
placeholder_key in (${'topic'}, ${'data_topic'}, ${'item_topic'})
|
||||
or placeholder_key like 'topic\_%' escape '\'
|
||||
or placeholder_key like 'data_topic\_%' escape '\'
|
||||
or placeholder_key like 'item_topic\_%' escape '\'
|
||||
)
|
||||
and deleted_at is null
|
||||
`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
204
src/features/crm/quotations/document/server/pdf-topic-engine.ts
Normal file
204
src/features/crm/quotations/document/server/pdf-topic-engine.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
143
src/features/crm/quotations/document/server/pdfme-transforms.ts
Normal file
143
src/features/crm/quotations/document/server/pdfme-transforms.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
56
src/features/crm/quotations/document/server/topic-mapping.ts
Normal file
56
src/features/crm/quotations/document/server/topic-mapping.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user