hotfix-add subgroup customer
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user