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,