tsk-C
compleate
This commit is contained in:
74
src/features/crm/customers/api/mutations.ts
Normal file
74
src/features/crm/customers/api/mutations.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { mutationOptions } from '@tanstack/react-query';
|
||||
import { getQueryClient } from '@/lib/query-client';
|
||||
import {
|
||||
createCustomer,
|
||||
createCustomerContact,
|
||||
deleteCustomer,
|
||||
deleteCustomerContact,
|
||||
updateCustomer,
|
||||
updateCustomerContact
|
||||
} from './service';
|
||||
import { customerKeys } from './queries';
|
||||
import type { CustomerContactMutationPayload, CustomerMutationPayload } from './types';
|
||||
|
||||
export const createCustomerMutation = mutationOptions({
|
||||
mutationFn: (data: CustomerMutationPayload) => createCustomer(data),
|
||||
onSuccess: () => {
|
||||
getQueryClient().invalidateQueries({ queryKey: customerKeys.all });
|
||||
}
|
||||
});
|
||||
|
||||
export const updateCustomerMutation = mutationOptions({
|
||||
mutationFn: ({ id, values }: { id: string; values: CustomerMutationPayload }) =>
|
||||
updateCustomer(id, values),
|
||||
onSuccess: (_data, variables) => {
|
||||
getQueryClient().invalidateQueries({ queryKey: customerKeys.all });
|
||||
getQueryClient().invalidateQueries({ queryKey: customerKeys.detail(variables.id) });
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteCustomerMutation = mutationOptions({
|
||||
mutationFn: (id: string) => deleteCustomer(id),
|
||||
onSuccess: () => {
|
||||
getQueryClient().invalidateQueries({ queryKey: customerKeys.all });
|
||||
}
|
||||
});
|
||||
|
||||
export const createCustomerContactMutation = mutationOptions({
|
||||
mutationFn: ({
|
||||
customerId,
|
||||
values
|
||||
}: {
|
||||
customerId: string;
|
||||
values: CustomerContactMutationPayload;
|
||||
}) => createCustomerContact(customerId, values),
|
||||
onSuccess: (_data, variables) => {
|
||||
getQueryClient().invalidateQueries({ queryKey: customerKeys.contacts(variables.customerId) });
|
||||
getQueryClient().invalidateQueries({ queryKey: customerKeys.detail(variables.customerId) });
|
||||
}
|
||||
});
|
||||
|
||||
export const updateCustomerContactMutation = mutationOptions({
|
||||
mutationFn: ({
|
||||
customerId,
|
||||
contactId,
|
||||
values
|
||||
}: {
|
||||
customerId: string;
|
||||
contactId: string;
|
||||
values: CustomerContactMutationPayload;
|
||||
}) => updateCustomerContact(customerId, contactId, values),
|
||||
onSuccess: (_data, variables) => {
|
||||
getQueryClient().invalidateQueries({ queryKey: customerKeys.contacts(variables.customerId) });
|
||||
getQueryClient().invalidateQueries({ queryKey: customerKeys.detail(variables.customerId) });
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteCustomerContactMutation = mutationOptions({
|
||||
mutationFn: ({ customerId, contactId }: { customerId: string; contactId: string }) =>
|
||||
deleteCustomerContact(customerId, contactId),
|
||||
onSuccess: (_data, variables) => {
|
||||
getQueryClient().invalidateQueries({ queryKey: customerKeys.contacts(variables.customerId) });
|
||||
getQueryClient().invalidateQueries({ queryKey: customerKeys.detail(variables.customerId) });
|
||||
}
|
||||
});
|
||||
28
src/features/crm/customers/api/queries.ts
Normal file
28
src/features/crm/customers/api/queries.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
import { getCustomerById, getCustomerContacts, getCustomers } from './service';
|
||||
import type { CustomerFilters } from './types';
|
||||
|
||||
export const customerKeys = {
|
||||
all: ['crm-customers'] as const,
|
||||
list: (filters: CustomerFilters) => [...customerKeys.all, 'list', filters] as const,
|
||||
detail: (id: string) => [...customerKeys.all, 'detail', id] as const,
|
||||
contacts: (id: string) => [...customerKeys.all, 'contacts', id] as const
|
||||
};
|
||||
|
||||
export const customersQueryOptions = (filters: CustomerFilters) =>
|
||||
queryOptions({
|
||||
queryKey: customerKeys.list(filters),
|
||||
queryFn: () => getCustomers(filters)
|
||||
});
|
||||
|
||||
export const customerByIdOptions = (id: string) =>
|
||||
queryOptions({
|
||||
queryKey: customerKeys.detail(id),
|
||||
queryFn: () => getCustomerById(id)
|
||||
});
|
||||
|
||||
export const customerContactsOptions = (id: string) =>
|
||||
queryOptions({
|
||||
queryKey: customerKeys.contacts(id),
|
||||
queryFn: () => getCustomerContacts(id)
|
||||
});
|
||||
81
src/features/crm/customers/api/service.ts
Normal file
81
src/features/crm/customers/api/service.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type {
|
||||
CustomerContactMutationPayload,
|
||||
CustomerContactsResponse,
|
||||
CustomerDetailResponse,
|
||||
CustomerFilters,
|
||||
CustomerListResponse,
|
||||
CustomerMutationPayload,
|
||||
MutationSuccessResponse
|
||||
} from './types';
|
||||
|
||||
export async function getCustomers(filters: CustomerFilters): Promise<CustomerListResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (filters.page) searchParams.set('page', String(filters.page));
|
||||
if (filters.limit) searchParams.set('limit', String(filters.limit));
|
||||
if (filters.search) searchParams.set('search', filters.search);
|
||||
if (filters.customerStatus) searchParams.set('customerStatus', filters.customerStatus);
|
||||
if (filters.customerType) searchParams.set('customerType', filters.customerType);
|
||||
if (filters.branch) searchParams.set('branch', filters.branch);
|
||||
if (filters.sort) searchParams.set('sort', filters.sort);
|
||||
|
||||
const query = searchParams.toString();
|
||||
|
||||
return apiClient<CustomerListResponse>(`/crm/customers${query ? `?${query}` : ''}`);
|
||||
}
|
||||
|
||||
export async function getCustomerById(id: string): Promise<CustomerDetailResponse> {
|
||||
return apiClient<CustomerDetailResponse>(`/crm/customers/${id}`);
|
||||
}
|
||||
|
||||
export async function createCustomer(data: CustomerMutationPayload) {
|
||||
return apiClient<{ success: boolean; message: string }>('/crm/customers', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateCustomer(id: string, data: CustomerMutationPayload) {
|
||||
return apiClient<{ success: boolean; message: string }>(`/crm/customers/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteCustomer(id: string) {
|
||||
return apiClient<MutationSuccessResponse>(`/crm/customers/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCustomerContacts(id: string): Promise<CustomerContactsResponse> {
|
||||
return apiClient<CustomerContactsResponse>(`/crm/customers/${id}/contacts`);
|
||||
}
|
||||
|
||||
export async function createCustomerContact(
|
||||
customerId: string,
|
||||
data: CustomerContactMutationPayload
|
||||
) {
|
||||
return apiClient<MutationSuccessResponse>(`/crm/customers/${customerId}/contacts`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateCustomerContact(
|
||||
customerId: string,
|
||||
contactId: string,
|
||||
data: CustomerContactMutationPayload
|
||||
) {
|
||||
return apiClient<MutationSuccessResponse>(`/crm/customers/${customerId}/contacts/${contactId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteCustomerContact(customerId: string, contactId: string) {
|
||||
return apiClient<MutationSuccessResponse>(`/crm/customers/${customerId}/contacts/${contactId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
161
src/features/crm/customers/api/types.ts
Normal file
161
src/features/crm/customers/api/types.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
export interface CustomerOption {
|
||||
id: string;
|
||||
code: string;
|
||||
label: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
export interface BranchOption {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
export interface CustomerRecord {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
branchId: string | null;
|
||||
code: string;
|
||||
name: string;
|
||||
abbr: string | null;
|
||||
taxId: string | null;
|
||||
customerType: string;
|
||||
customerStatus: string;
|
||||
address: string | null;
|
||||
province: string | null;
|
||||
district: string | null;
|
||||
subDistrict: string | null;
|
||||
postalCode: string | null;
|
||||
country: string | null;
|
||||
phone: string | null;
|
||||
fax: string | null;
|
||||
email: string | null;
|
||||
website: string | null;
|
||||
leadChannel: string | null;
|
||||
customerGroup: string | null;
|
||||
notes: string | null;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
createdBy: string;
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
export interface CustomerListItem extends CustomerRecord {
|
||||
contactCount: number;
|
||||
}
|
||||
|
||||
export interface CustomerContactRecord {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
customerId: string;
|
||||
name: string;
|
||||
position: string | null;
|
||||
department: string | null;
|
||||
phone: string | null;
|
||||
mobile: string | null;
|
||||
email: string | null;
|
||||
isPrimary: boolean;
|
||||
notes: string | null;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
createdBy: string;
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
export interface CustomerActivityRecord {
|
||||
id: string;
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
createdAt: string;
|
||||
userId: string;
|
||||
actorName: string | null;
|
||||
}
|
||||
|
||||
export interface CustomerReferenceData {
|
||||
branches: BranchOption[];
|
||||
customerTypes: CustomerOption[];
|
||||
customerStatuses: CustomerOption[];
|
||||
leadChannels: CustomerOption[];
|
||||
customerGroups: CustomerOption[];
|
||||
}
|
||||
|
||||
export interface CustomerFilters {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
customerStatus?: string;
|
||||
customerType?: string;
|
||||
branch?: string;
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
export interface CustomerListResponse {
|
||||
success: boolean;
|
||||
time: string;
|
||||
message: string;
|
||||
totalItems: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
items: CustomerListItem[];
|
||||
}
|
||||
|
||||
export interface CustomerDetailResponse {
|
||||
success: boolean;
|
||||
time: string;
|
||||
message: string;
|
||||
customer: CustomerRecord;
|
||||
activity: CustomerActivityRecord[];
|
||||
}
|
||||
|
||||
export interface CustomerContactsResponse {
|
||||
success: boolean;
|
||||
time: string;
|
||||
message: string;
|
||||
items: CustomerContactRecord[];
|
||||
}
|
||||
|
||||
export interface CustomerMutationPayload {
|
||||
name: string;
|
||||
abbr?: string;
|
||||
taxId?: string;
|
||||
customerType: string;
|
||||
customerStatus: string;
|
||||
branchId?: string | null;
|
||||
address?: string;
|
||||
province?: string;
|
||||
district?: string;
|
||||
subDistrict?: string;
|
||||
postalCode?: string;
|
||||
country?: string;
|
||||
phone?: string;
|
||||
fax?: string;
|
||||
email?: string;
|
||||
website?: string;
|
||||
leadChannel?: string | null;
|
||||
customerGroup?: string | null;
|
||||
notes?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface CustomerContactMutationPayload {
|
||||
name: string;
|
||||
position?: string;
|
||||
department?: string;
|
||||
phone?: string;
|
||||
mobile?: string;
|
||||
email?: string;
|
||||
isPrimary?: boolean;
|
||||
notes?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface MutationSuccessResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
164
src/features/crm/customers/components/contact-form-sheet.tsx
Normal file
164
src/features/crm/customers/components/contact-form-sheet.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle
|
||||
} from '@/components/ui/sheet';
|
||||
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
|
||||
import { createCustomerContactMutation, updateCustomerContactMutation } from '../api/mutations';
|
||||
import type { CustomerContactMutationPayload, CustomerContactRecord } from '../api/types';
|
||||
import { customerContactSchema, type CustomerContactFormValues } from '../schemas/customer.schema';
|
||||
|
||||
function toDefaultValues(contact?: CustomerContactRecord): CustomerContactFormValues {
|
||||
return {
|
||||
name: contact?.name ?? '',
|
||||
position: contact?.position ?? '',
|
||||
department: contact?.department ?? '',
|
||||
phone: contact?.phone ?? '',
|
||||
mobile: contact?.mobile ?? '',
|
||||
email: contact?.email ?? '',
|
||||
isPrimary: contact?.isPrimary ?? false,
|
||||
notes: contact?.notes ?? '',
|
||||
isActive: contact?.isActive ?? true
|
||||
};
|
||||
}
|
||||
|
||||
export function ContactFormSheet({
|
||||
customerId,
|
||||
contact,
|
||||
open,
|
||||
onOpenChange
|
||||
}: {
|
||||
customerId: string;
|
||||
contact?: CustomerContactRecord;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const isEdit = !!contact;
|
||||
const defaultValues = useMemo(() => toDefaultValues(contact), [contact]);
|
||||
const { FormTextField, FormTextareaField, FormSwitchField } =
|
||||
useFormFields<CustomerContactFormValues>();
|
||||
|
||||
const createMutation = useMutation({
|
||||
...createCustomerContactMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Contact created successfully');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to create contact')
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
...updateCustomerContactMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Contact updated successfully');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to update contact')
|
||||
});
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues,
|
||||
validators: {
|
||||
onSubmit: customerContactSchema
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
const payload: CustomerContactMutationPayload = value;
|
||||
|
||||
if (isEdit && contact) {
|
||||
await updateMutation.mutateAsync({
|
||||
customerId,
|
||||
contactId: contact.id,
|
||||
values: payload
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await createMutation.mutateAsync({
|
||||
customerId,
|
||||
values: payload
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.reset(defaultValues);
|
||||
}, [defaultValues, form, open]);
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className='flex flex-col sm:max-w-2xl'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{isEdit ? 'Edit Contact' : 'New Contact'}</SheetTitle>
|
||||
<SheetDescription>
|
||||
Capture the operational contact person for this customer account.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className='flex-1 overflow-auto'>
|
||||
<form.AppForm>
|
||||
<form.Form id='customer-contact-form' className='grid gap-4 md:grid-cols-2'>
|
||||
<FormTextField name='name' label='Contact Name' required placeholder='Jane Doe' />
|
||||
<FormTextField name='position' label='Position' placeholder='Procurement Manager' />
|
||||
<FormTextField name='department' label='Department' placeholder='Procurement' />
|
||||
<FormTextField name='phone' label='Phone' placeholder='02-000-0000' />
|
||||
<FormTextField name='mobile' label='Mobile' placeholder='081-000-0000' />
|
||||
<FormTextField
|
||||
name='email'
|
||||
label='Email'
|
||||
type='email'
|
||||
placeholder='jane@example.com'
|
||||
/>
|
||||
<div className='md:col-span-2'>
|
||||
<FormTextareaField
|
||||
name='notes'
|
||||
label='Notes'
|
||||
placeholder='Preferred communication style, escalation notes, or meeting context'
|
||||
/>
|
||||
</div>
|
||||
<div className='md:col-span-2 grid gap-4 md:grid-cols-2'>
|
||||
<FormSwitchField
|
||||
name='isPrimary'
|
||||
label='Primary Contact'
|
||||
description='When enabled, this contact becomes the main person for this customer.'
|
||||
/>
|
||||
<FormSwitchField
|
||||
name='isActive'
|
||||
label='Active Contact'
|
||||
description='Inactive contacts remain in history and can be restored later.'
|
||||
/>
|
||||
</div>
|
||||
</form.Form>
|
||||
</form.AppForm>
|
||||
</div>
|
||||
|
||||
<SheetFooter>
|
||||
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' form='customer-contact-form' isLoading={isPending}>
|
||||
<Icons.check className='mr-2 h-4 w-4' />
|
||||
{isEdit ? 'Update Contact' : 'Create Contact'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { AlertModal } from '@/components/modal/alert-modal';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { deleteCustomerMutation } from '../api/mutations';
|
||||
import type { CustomerRecord, CustomerReferenceData } from '../api/types';
|
||||
import { CustomerFormSheet } from './customer-form-sheet';
|
||||
|
||||
export function CustomerCellAction({
|
||||
data,
|
||||
referenceData,
|
||||
canUpdate,
|
||||
canDelete
|
||||
}: {
|
||||
data: CustomerRecord;
|
||||
referenceData: CustomerReferenceData;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
...deleteCustomerMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Customer deleted successfully');
|
||||
setDeleteOpen(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to delete customer')
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<AlertModal
|
||||
isOpen={deleteOpen}
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
onConfirm={() => deleteMutation.mutate(data.id)}
|
||||
loading={deleteMutation.isPending}
|
||||
/>
|
||||
<CustomerFormSheet
|
||||
customer={data}
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
referenceData={referenceData}
|
||||
/>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='ghost' className='h-8 w-8 p-0'>
|
||||
<span className='sr-only'>Open actions</span>
|
||||
<Icons.ellipsis className='h-4 w-4' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => router.push(`/dashboard/crm/customers/${data.id}`)}>
|
||||
<Icons.arrowRight className='mr-2 h-4 w-4' /> View
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setEditOpen(true)} disabled={!canUpdate}>
|
||||
<Icons.edit className='mr-2 h-4 w-4' /> Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setDeleteOpen(true)} disabled={!canDelete}>
|
||||
<Icons.trash className='mr-2 h-4 w-4' /> Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
138
src/features/crm/customers/components/customer-columns.tsx
Normal file
138
src/features/crm/customers/components/customer-columns.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import type { Column, ColumnDef } from '@tanstack/react-table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header';
|
||||
import { Icons } from '@/components/icons';
|
||||
import type { CustomerListItem, CustomerReferenceData } from '../api/types';
|
||||
import { CustomerCellAction } from './customer-cell-action';
|
||||
import { CustomerStatusBadge } from './customer-status-badge';
|
||||
|
||||
export function getCustomerColumns({
|
||||
referenceData,
|
||||
canUpdate,
|
||||
canDelete
|
||||
}: {
|
||||
referenceData: CustomerReferenceData;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
}): ColumnDef<CustomerListItem>[] {
|
||||
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]));
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'name',
|
||||
accessorKey: 'name',
|
||||
header: ({ column }: { column: Column<CustomerListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Customer' />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className='flex flex-col'>
|
||||
<Link
|
||||
href={`/dashboard/crm/customers/${row.original.id}`}
|
||||
className='font-medium hover:underline'
|
||||
>
|
||||
{row.original.name}
|
||||
</Link>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{row.original.code}
|
||||
{row.original.email ? ` • ${row.original.email}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
label: 'Customer',
|
||||
placeholder: 'Search customers...',
|
||||
variant: 'text' as const,
|
||||
icon: Icons.search
|
||||
},
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'customerStatus',
|
||||
accessorKey: 'customerStatus',
|
||||
header: ({ column }: { column: Column<CustomerListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Status' />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const option = statusMap.get(row.original.customerStatus);
|
||||
return <CustomerStatusBadge code={option?.code} label={option?.label ?? 'Unknown'} />;
|
||||
},
|
||||
meta: {
|
||||
label: 'Status',
|
||||
variant: 'multiSelect' as const,
|
||||
options: referenceData.customerStatuses.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
}))
|
||||
},
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'customerType',
|
||||
accessorKey: 'customerType',
|
||||
header: ({ column }: { column: Column<CustomerListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Type' />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Badge variant='outline'>
|
||||
{typeMap.get(row.original.customerType)?.label ?? 'Unknown'}
|
||||
</Badge>
|
||||
),
|
||||
meta: {
|
||||
label: 'Type',
|
||||
variant: 'multiSelect' as const,
|
||||
options: referenceData.customerTypes.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
}))
|
||||
},
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'branch',
|
||||
accessorFn: (row) => row.branchId ?? '',
|
||||
header: ({ column }: { column: Column<CustomerListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Branch' />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span>
|
||||
{row.original.branchId
|
||||
? (branchMap.get(row.original.branchId)?.name ?? 'Unknown')
|
||||
: 'Unassigned'}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
label: 'Branch',
|
||||
variant: 'multiSelect' as const,
|
||||
options: referenceData.branches.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.name
|
||||
}))
|
||||
},
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'contactCount',
|
||||
accessorKey: 'contactCount',
|
||||
header: ({ column }: { column: Column<CustomerListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Contacts' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.contactCount}</span>
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => (
|
||||
<CustomerCellAction
|
||||
data={row.original}
|
||||
referenceData={referenceData}
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
}
|
||||
136
src/features/crm/customers/components/customer-contacts-tab.tsx
Normal file
136
src/features/crm/customers/components/customer-contacts-tab.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { AlertModal } from '@/components/modal/alert-modal';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { deleteCustomerContactMutation } from '../api/mutations';
|
||||
import { customerContactsOptions } from '../api/queries';
|
||||
import { ContactFormSheet } from './contact-form-sheet';
|
||||
|
||||
export function CustomerContactsTab({
|
||||
customerId,
|
||||
canCreate,
|
||||
canUpdate,
|
||||
canDelete
|
||||
}: {
|
||||
customerId: string;
|
||||
canCreate: boolean;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
}) {
|
||||
const { data } = useSuspenseQuery(customerContactsOptions(customerId));
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const selectedContact = data.items.find((contact) => contact.id === selectedId);
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
...deleteCustomerContactMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Contact deleted successfully');
|
||||
setDeleteOpen(false);
|
||||
setSelectedId(null);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to delete contact')
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-start justify-between gap-4'>
|
||||
<div>
|
||||
<CardTitle>Contacts</CardTitle>
|
||||
<CardDescription>Manage the people attached to this customer account.</CardDescription>
|
||||
</div>
|
||||
{canCreate ? (
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
setSelectedId(null);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
<Icons.add className='mr-2 h-4 w-4' /> Add Contact
|
||||
</Button>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
{!data.items.length ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
||||
No contacts have been added yet.
|
||||
</div>
|
||||
) : (
|
||||
data.items.map((contact) => (
|
||||
<div
|
||||
key={contact.id}
|
||||
className='flex flex-col gap-3 rounded-lg border p-4 md:flex-row md:items-start md:justify-between'
|
||||
>
|
||||
<div className='space-y-1'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<span className='font-medium'>{contact.name}</span>
|
||||
{contact.isPrimary ? <Badge>Primary</Badge> : null}
|
||||
{!contact.isActive ? <Badge variant='outline'>Inactive</Badge> : null}
|
||||
</div>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
{[contact.position, contact.department].filter(Boolean).join(' • ') ||
|
||||
'No role details'}
|
||||
</div>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
{[contact.phone, contact.mobile, contact.email].filter(Boolean).join(' • ') ||
|
||||
'No contact methods'}
|
||||
</div>
|
||||
{contact.notes ? <p className='text-sm'>{contact.notes}</p> : null}
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={!canUpdate}
|
||||
onClick={() => {
|
||||
setSelectedId(contact.id);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
<Icons.edit className='mr-2 h-4 w-4' /> Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={!canDelete}
|
||||
onClick={() => {
|
||||
setSelectedId(contact.id);
|
||||
setDeleteOpen(true);
|
||||
}}
|
||||
>
|
||||
<Icons.trash className='mr-2 h-4 w-4' /> Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<ContactFormSheet
|
||||
customerId={customerId}
|
||||
contact={selectedContact}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
/>
|
||||
<AlertModal
|
||||
isOpen={deleteOpen}
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
onConfirm={() => {
|
||||
if (selectedId) {
|
||||
deleteMutation.mutate({ customerId, contactId: selectedId });
|
||||
}
|
||||
}}
|
||||
loading={deleteMutation.isPending}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
255
src/features/crm/customers/components/customer-detail.tsx
Normal file
255
src/features/crm/customers/components/customer-detail.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { customerByIdOptions } from '../api/queries';
|
||||
import type { CustomerReferenceData } from '../api/types';
|
||||
import { CustomerFormSheet } from './customer-form-sheet';
|
||||
import { CustomerContactsTab } from './customer-contacts-tab';
|
||||
import { CustomerStatusBadge } from './customer-status-badge';
|
||||
|
||||
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
|
||||
return (
|
||||
<div className='space-y-1'>
|
||||
<div className='text-muted-foreground text-xs'>{label}</div>
|
||||
<div className='text-sm'>{value || '-'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomerDetail({
|
||||
customerId,
|
||||
referenceData,
|
||||
canUpdate,
|
||||
canManageContacts
|
||||
}: {
|
||||
customerId: string;
|
||||
referenceData: CustomerReferenceData;
|
||||
canUpdate: boolean;
|
||||
canManageContacts: {
|
||||
create: boolean;
|
||||
update: boolean;
|
||||
delete: boolean;
|
||||
};
|
||||
}) {
|
||||
const { data } = useSuspenseQuery(customerByIdOptions(customerId));
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const branchMap = useMemo(
|
||||
() => new Map(referenceData.branches.map((item) => [item.id, item.name])),
|
||||
[referenceData]
|
||||
);
|
||||
const typeMap = useMemo(
|
||||
() => new Map(referenceData.customerTypes.map((item) => [item.id, item])),
|
||||
[referenceData]
|
||||
);
|
||||
const statusMap = useMemo(
|
||||
() => new Map(referenceData.customerStatuses.map((item) => [item.id, item])),
|
||||
[referenceData]
|
||||
);
|
||||
const leadMap = useMemo(
|
||||
() => new Map(referenceData.leadChannels.map((item) => [item.id, item.label])),
|
||||
[referenceData]
|
||||
);
|
||||
const groupMap = useMemo(
|
||||
() => new Map(referenceData.customerGroups.map((item) => [item.id, item.label])),
|
||||
[referenceData]
|
||||
);
|
||||
const customer = data.customer;
|
||||
const status = statusMap.get(customer.customerStatus);
|
||||
const type = typeMap.get(customer.customerType);
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<div className='flex flex-col gap-4 rounded-xl border p-5 lg:flex-row lg:items-start lg:justify-between'>
|
||||
<div className='space-y-3'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button asChild variant='ghost' size='icon'>
|
||||
<Link href='/dashboard/crm/customers'>
|
||||
<Icons.chevronLeft className='h-4 w-4' />
|
||||
</Link>
|
||||
</Button>
|
||||
<span className='text-muted-foreground font-mono text-sm'>{customer.code}</span>
|
||||
<CustomerStatusBadge code={status?.code} label={status?.label ?? 'Unknown'} />
|
||||
{type ? <Badge variant='outline'>{type.label}</Badge> : null}
|
||||
{customer.branchId ? (
|
||||
<Badge variant='secondary'>
|
||||
{branchMap.get(customer.branchId) ?? 'Unknown branch'}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className='text-2xl font-semibold'>{customer.name}</h2>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{customer.abbr || customer.email || customer.phone || 'Customer detail record'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{canUpdate ? (
|
||||
<Button variant='outline' onClick={() => setEditOpen(true)}>
|
||||
<Icons.edit className='mr-2 h-4 w-4' /> Edit Customer
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-6 lg:grid-cols-3'>
|
||||
<div className='space-y-6 lg:col-span-2'>
|
||||
<Card>
|
||||
<CardContent className='pt-6'>
|
||||
<Tabs defaultValue='overview' className='gap-4'>
|
||||
<TabsList>
|
||||
<TabsTrigger value='overview'>Overview</TabsTrigger>
|
||||
<TabsTrigger value='contacts'>Contacts</TabsTrigger>
|
||||
<TabsTrigger value='activity'>Activity</TabsTrigger>
|
||||
<TabsTrigger value='related'>Related Documents</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value='overview'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Overview</CardTitle>
|
||||
<CardDescription>
|
||||
Core account data, location, and CRM classification.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||
<FieldItem label='Tax ID' value={customer.taxId} />
|
||||
<FieldItem label='Phone' value={customer.phone} />
|
||||
<FieldItem label='Email' value={customer.email} />
|
||||
<FieldItem label='Website' value={customer.website} />
|
||||
<FieldItem
|
||||
label='Branch'
|
||||
value={customer.branchId ? branchMap.get(customer.branchId) : null}
|
||||
/>
|
||||
<FieldItem
|
||||
label='Lead Channel'
|
||||
value={leadMap.get(customer.leadChannel ?? '')}
|
||||
/>
|
||||
<FieldItem
|
||||
label='Customer Group'
|
||||
value={groupMap.get(customer.customerGroup ?? '')}
|
||||
/>
|
||||
<FieldItem label='Active' value={customer.isActive ? 'Yes' : 'No'} />
|
||||
<div className='md:col-span-2 xl:col-span-4'>
|
||||
<FieldItem
|
||||
label='Address'
|
||||
value={
|
||||
[
|
||||
customer.address,
|
||||
customer.subDistrict,
|
||||
customer.district,
|
||||
customer.province,
|
||||
customer.postalCode,
|
||||
customer.country
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ') || null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className='md:col-span-2 xl:col-span-4'>
|
||||
<FieldItem label='Notes' value={customer.notes} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value='contacts'>
|
||||
<CustomerContactsTab
|
||||
customerId={customerId}
|
||||
canCreate={canManageContacts.create}
|
||||
canUpdate={canManageContacts.update}
|
||||
canDelete={canManageContacts.delete}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value='activity'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Activity</CardTitle>
|
||||
<CardDescription>
|
||||
Audit log entries captured from customer mutations.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
{!data.activity.length ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
||||
No activity recorded yet.
|
||||
</div>
|
||||
) : (
|
||||
data.activity.map((item, index) => (
|
||||
<div key={item.id} className='space-y-4'>
|
||||
<div className='flex items-start gap-3'>
|
||||
<div className='bg-primary mt-2 h-2 w-2 rounded-full' />
|
||||
<div className='space-y-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Badge variant='outline' className='capitalize'>
|
||||
{item.action}
|
||||
</Badge>
|
||||
<span className='text-sm font-medium'>
|
||||
{item.actorName ?? item.userId}
|
||||
</span>
|
||||
</div>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{new Date(item.createdAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{index < data.activity.length - 1 ? <Separator /> : null}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value='related'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Related Documents</CardTitle>
|
||||
<CardDescription>
|
||||
Task C stops at customer and contact scope, so downstream CRM documents stay
|
||||
intentionally deferred.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
||||
Enquiry, quotation, and approval links will land here in Task D and later.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className='space-y-6'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Record Snapshot</CardTitle>
|
||||
<CardDescription>Operational metadata for this customer row.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<FieldItem label='Created By' value={customer.createdBy} />
|
||||
<FieldItem label='Updated By' value={customer.updatedBy} />
|
||||
<FieldItem label='Created At' value={new Date(customer.createdAt).toLocaleString()} />
|
||||
<FieldItem label='Updated At' value={new Date(customer.updatedAt).toLocaleString()} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CustomerFormSheet
|
||||
customer={customer}
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
referenceData={referenceData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
250
src/features/crm/customers/components/customer-form-sheet.tsx
Normal file
250
src/features/crm/customers/components/customer-form-sheet.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle
|
||||
} from '@/components/ui/sheet';
|
||||
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
|
||||
import { createCustomerMutation, updateCustomerMutation } from '../api/mutations';
|
||||
import type { CustomerMutationPayload, CustomerRecord, CustomerReferenceData } from '../api/types';
|
||||
import { customerSchema, type CustomerFormValues } from '../schemas/customer.schema';
|
||||
|
||||
interface CustomerFormSheetProps {
|
||||
customer?: CustomerRecord;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
referenceData: CustomerReferenceData;
|
||||
}
|
||||
|
||||
function toDefaultValues(
|
||||
customer: CustomerRecord | undefined,
|
||||
referenceData: CustomerReferenceData
|
||||
): CustomerFormValues {
|
||||
return {
|
||||
name: customer?.name ?? '',
|
||||
abbr: customer?.abbr ?? '',
|
||||
taxId: customer?.taxId ?? '',
|
||||
customerType: customer?.customerType ?? referenceData.customerTypes[0]?.id ?? '',
|
||||
customerStatus: customer?.customerStatus ?? referenceData.customerStatuses[0]?.id ?? '',
|
||||
branchId: customer?.branchId ?? undefined,
|
||||
address: customer?.address ?? '',
|
||||
province: customer?.province ?? '',
|
||||
district: customer?.district ?? '',
|
||||
subDistrict: customer?.subDistrict ?? '',
|
||||
postalCode: customer?.postalCode ?? '',
|
||||
country: customer?.country ?? 'Thailand',
|
||||
phone: customer?.phone ?? '',
|
||||
fax: customer?.fax ?? '',
|
||||
email: customer?.email ?? '',
|
||||
website: customer?.website ?? '',
|
||||
leadChannel: customer?.leadChannel ?? undefined,
|
||||
customerGroup: customer?.customerGroup ?? undefined,
|
||||
notes: customer?.notes ?? '',
|
||||
isActive: customer?.isActive ?? true
|
||||
};
|
||||
}
|
||||
|
||||
export function CustomerFormSheet({
|
||||
customer,
|
||||
open,
|
||||
onOpenChange,
|
||||
referenceData
|
||||
}: CustomerFormSheetProps) {
|
||||
const isEdit = !!customer;
|
||||
const defaultValues = useMemo(
|
||||
() => toDefaultValues(customer, referenceData),
|
||||
[customer, referenceData]
|
||||
);
|
||||
const { FormTextField, FormSelectField, FormTextareaField, FormSwitchField } =
|
||||
useFormFields<CustomerFormValues>();
|
||||
|
||||
const createMutation = useMutation({
|
||||
...createCustomerMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Customer created successfully');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to create customer')
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
...updateCustomerMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Customer updated successfully');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to update customer')
|
||||
});
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues,
|
||||
validators: {
|
||||
onSubmit: customerSchema
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
const payload: CustomerMutationPayload = {
|
||||
...value,
|
||||
branchId: value.branchId || null,
|
||||
leadChannel: value.leadChannel || null,
|
||||
customerGroup: value.customerGroup || null
|
||||
};
|
||||
|
||||
if (isEdit && customer) {
|
||||
await updateMutation.mutateAsync({ id: customer.id, values: payload });
|
||||
return;
|
||||
}
|
||||
|
||||
await createMutation.mutateAsync(payload);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.reset(defaultValues);
|
||||
}, [defaultValues, form, open]);
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className='flex flex-col sm:max-w-3xl'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{isEdit ? 'Edit Customer' : 'New Customer'}</SheetTitle>
|
||||
<SheetDescription>
|
||||
Maintain the core account profile, ownership branch, and CRM classification here.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className='flex-1 overflow-auto'>
|
||||
<form.AppForm>
|
||||
<form.Form id='customer-form-sheet' className='grid gap-4 md:grid-cols-2'>
|
||||
<FormTextField
|
||||
name='name'
|
||||
label='Customer Name'
|
||||
required
|
||||
placeholder='ALLA Service Co., Ltd.'
|
||||
/>
|
||||
<FormTextField name='abbr' label='Abbreviation' placeholder='ALLA' />
|
||||
<FormTextField name='taxId' label='Tax ID' placeholder='0105556...' />
|
||||
<FormTextField name='phone' label='Phone' placeholder='02-000-0000' />
|
||||
<FormTextField name='fax' label='Fax' placeholder='02-000-0001' />
|
||||
<FormTextField
|
||||
name='email'
|
||||
label='Email'
|
||||
type='email'
|
||||
placeholder='sales@example.com'
|
||||
/>
|
||||
<FormTextField name='website' label='Website' placeholder='https://example.com' />
|
||||
<FormTextField name='country' label='Country' placeholder='Thailand' />
|
||||
<FormSelectField
|
||||
name='customerType'
|
||||
label='Customer Type'
|
||||
required
|
||||
options={referenceData.customerTypes.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
}))}
|
||||
/>
|
||||
<FormSelectField
|
||||
name='customerStatus'
|
||||
label='Customer Status'
|
||||
required
|
||||
options={referenceData.customerStatuses.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
}))}
|
||||
/>
|
||||
<FormSelectField
|
||||
name='branchId'
|
||||
label='Branch'
|
||||
options={referenceData.branches.map((branch) => ({
|
||||
value: branch.id,
|
||||
label: branch.name
|
||||
}))}
|
||||
/>
|
||||
<FormSelectField
|
||||
name='leadChannel'
|
||||
label='Lead Channel'
|
||||
options={referenceData.leadChannels.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
}))}
|
||||
/>
|
||||
<FormSelectField
|
||||
name='customerGroup'
|
||||
label='Customer Group'
|
||||
options={referenceData.customerGroups.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
}))}
|
||||
/>
|
||||
<div className='md:col-span-2'>
|
||||
<FormTextField name='address' label='Address' placeholder='123 Main Road' />
|
||||
</div>
|
||||
<FormTextField name='province' label='Province' placeholder='Bangkok' />
|
||||
<FormTextField name='district' label='District' placeholder='Bang Kapi' />
|
||||
<FormTextField name='subDistrict' label='Sub District' placeholder='Hua Mak' />
|
||||
<FormTextField name='postalCode' label='Postal Code' placeholder='10240' />
|
||||
<div className='md:col-span-2'>
|
||||
<FormTextareaField
|
||||
name='notes'
|
||||
label='Notes'
|
||||
placeholder='Internal notes about this customer relationship'
|
||||
/>
|
||||
</div>
|
||||
<div className='md:col-span-2'>
|
||||
<FormSwitchField
|
||||
name='isActive'
|
||||
label='Active Record'
|
||||
description='Inactive customers remain visible in history but should not be used for new work.'
|
||||
/>
|
||||
</div>
|
||||
</form.Form>
|
||||
</form.AppForm>
|
||||
</div>
|
||||
|
||||
<SheetFooter>
|
||||
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' form='customer-form-sheet' isLoading={isPending}>
|
||||
<Icons.check className='mr-2 h-4 w-4' />
|
||||
{isEdit ? 'Update Customer' : 'Create Customer'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomerFormSheetTrigger({
|
||||
referenceData
|
||||
}: {
|
||||
referenceData: CustomerReferenceData;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)}>
|
||||
<Icons.add className='mr-2 h-4 w-4' /> Add Customer
|
||||
</Button>
|
||||
<CustomerFormSheet open={open} onOpenChange={setOpen} referenceData={referenceData} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
42
src/features/crm/customers/components/customer-listing.tsx
Normal file
42
src/features/crm/customers/components/customer-listing.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
||||
import { getQueryClient } from '@/lib/query-client';
|
||||
import { searchParamsCache } from '@/lib/searchparams';
|
||||
import type { CustomerReferenceData } from '../api/types';
|
||||
import { customersQueryOptions } from '../api/queries';
|
||||
import { CustomersTable } from './customers-table';
|
||||
|
||||
export default function CustomerListing({
|
||||
referenceData,
|
||||
canUpdate,
|
||||
canDelete
|
||||
}: {
|
||||
referenceData: CustomerReferenceData;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
}) {
|
||||
const page = searchParamsCache.get('page');
|
||||
const limit = searchParamsCache.get('perPage');
|
||||
const search = searchParamsCache.get('name');
|
||||
const customerStatus = searchParamsCache.get('customerStatus');
|
||||
const customerType = searchParamsCache.get('customerType');
|
||||
const branch = searchParamsCache.get('branch');
|
||||
const sort = searchParamsCache.get('sort');
|
||||
const filters = {
|
||||
page,
|
||||
limit,
|
||||
...(search && { search }),
|
||||
...(customerStatus && { customerStatus }),
|
||||
...(customerType && { customerType }),
|
||||
...(branch && { branch }),
|
||||
...(sort && { sort })
|
||||
};
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
void queryClient.prefetchQuery(customersQueryOptions(filters));
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<CustomersTable referenceData={referenceData} canUpdate={canUpdate} canDelete={canDelete} />
|
||||
</HydrationBoundary>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Icons } from '@/components/icons';
|
||||
|
||||
export function CustomerStatusBadge({ code, label }: { code?: string | null; label: string }) {
|
||||
const normalized = code?.toLowerCase() ?? '';
|
||||
const variant =
|
||||
normalized === 'active'
|
||||
? 'default'
|
||||
: normalized === 'inactive' || normalized === 'suspended'
|
||||
? 'outline'
|
||||
: 'secondary';
|
||||
const Icon =
|
||||
normalized === 'active'
|
||||
? Icons.circleCheck
|
||||
: normalized === 'inactive' || normalized === 'suspended'
|
||||
? Icons.warning
|
||||
: Icons.circle;
|
||||
|
||||
return (
|
||||
<Badge variant={variant} className='capitalize'>
|
||||
<Icon className='h-3 w-3' />
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
66
src/features/crm/customers/components/customers-table.tsx
Normal file
66
src/features/crm/customers/components/customers-table.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { DataTable } from '@/components/ui/table/data-table';
|
||||
import { DataTableToolbar } from '@/components/ui/table/data-table-toolbar';
|
||||
import { useDataTable } from '@/hooks/use-data-table';
|
||||
import { getSortingStateParser } from '@/lib/parsers';
|
||||
import { customersQueryOptions } from '../api/queries';
|
||||
import type { CustomerReferenceData } from '../api/types';
|
||||
import { getCustomerColumns } from './customer-columns';
|
||||
|
||||
export function CustomersTable({
|
||||
referenceData,
|
||||
canUpdate,
|
||||
canDelete
|
||||
}: {
|
||||
referenceData: CustomerReferenceData;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
}) {
|
||||
const columns = useMemo(
|
||||
() => getCustomerColumns({ referenceData, canUpdate, canDelete }),
|
||||
[referenceData, canUpdate, canDelete]
|
||||
);
|
||||
const columnIds = columns.map((column) => column.id).filter(Boolean) as string[];
|
||||
const [params] = useQueryStates({
|
||||
page: parseAsInteger.withDefault(1),
|
||||
perPage: parseAsInteger.withDefault(10),
|
||||
name: parseAsString,
|
||||
customerStatus: parseAsString,
|
||||
customerType: parseAsString,
|
||||
branch: parseAsString,
|
||||
sort: getSortingStateParser(columnIds).withDefault([])
|
||||
});
|
||||
|
||||
const filters = {
|
||||
page: params.page,
|
||||
limit: params.perPage,
|
||||
...(params.name && { search: params.name }),
|
||||
...(params.customerStatus && { customerStatus: params.customerStatus }),
|
||||
...(params.customerType && { customerType: params.customerType }),
|
||||
...(params.branch && { branch: params.branch }),
|
||||
...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) })
|
||||
};
|
||||
|
||||
const { data } = useSuspenseQuery(customersQueryOptions(filters));
|
||||
const pageCount = Math.ceil(data.totalItems / params.perPage);
|
||||
const { table } = useDataTable({
|
||||
data: data.items,
|
||||
columns,
|
||||
pageCount,
|
||||
shallow: true,
|
||||
debounceMs: 500,
|
||||
initialState: {
|
||||
columnPinning: { right: ['actions'] }
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<DataTable table={table}>
|
||||
<DataTableToolbar table={table} />
|
||||
</DataTable>
|
||||
);
|
||||
}
|
||||
45
src/features/crm/customers/schemas/customer.schema.ts
Normal file
45
src/features/crm/customers/schemas/customer.schema.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
export const customerSchema = z.object({
|
||||
name: z.string().min(2, 'Customer name must be at least 2 characters'),
|
||||
abbr: z.string().optional(),
|
||||
taxId: z.string().optional(),
|
||||
customerType: z.string().min(1, 'Please select a customer type'),
|
||||
customerStatus: z.string().min(1, 'Please select a customer status'),
|
||||
branchId: z.string().optional().nullable(),
|
||||
address: z.string().optional(),
|
||||
province: z.string().optional(),
|
||||
district: z.string().optional(),
|
||||
subDistrict: z.string().optional(),
|
||||
postalCode: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
phone: z.string().optional(),
|
||||
fax: z.string().optional(),
|
||||
email: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((value) => !value || z.email().safeParse(value).success, 'Please enter a valid email'),
|
||||
website: z.string().optional(),
|
||||
leadChannel: z.string().optional().nullable(),
|
||||
customerGroup: z.string().optional().nullable(),
|
||||
notes: z.string().optional(),
|
||||
isActive: z.boolean().default(true)
|
||||
});
|
||||
|
||||
export const customerContactSchema = z.object({
|
||||
name: z.string().min(2, 'Contact name must be at least 2 characters'),
|
||||
position: z.string().optional(),
|
||||
department: z.string().optional(),
|
||||
phone: z.string().optional(),
|
||||
mobile: z.string().optional(),
|
||||
email: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((value) => !value || z.email().safeParse(value).success, 'Please enter a valid email'),
|
||||
isPrimary: z.boolean().default(false),
|
||||
notes: z.string().optional(),
|
||||
isActive: z.boolean().default(true)
|
||||
});
|
||||
|
||||
export type CustomerFormValues = z.input<typeof customerSchema>;
|
||||
export type CustomerContactFormValues = z.input<typeof customerContactSchema>;
|
||||
646
src/features/crm/customers/server/service.ts
Normal file
646
src/features/crm/customers/server/service.ts
Normal file
@@ -0,0 +1,646 @@
|
||||
import { and, asc, count, desc, eq, ilike, inArray, isNull, or, type SQL } from 'drizzle-orm';
|
||||
import { crmCustomerContacts, crmCustomers, msOptions, users } from '@/db/schema';
|
||||
import { db } from '@/lib/db';
|
||||
import { AuthError } from '@/lib/auth/session';
|
||||
import { listAuditLogs } from '@/features/foundation/audit-log/service';
|
||||
import { validateBranchAccess, getUserBranches } from '@/features/foundation/branch-scope/service';
|
||||
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
|
||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||
import type {
|
||||
BranchOption,
|
||||
CustomerActivityRecord,
|
||||
CustomerContactMutationPayload,
|
||||
CustomerContactRecord,
|
||||
CustomerFilters,
|
||||
CustomerListItem,
|
||||
CustomerMutationPayload,
|
||||
CustomerOption,
|
||||
CustomerRecord,
|
||||
CustomerReferenceData
|
||||
} from '../api/types';
|
||||
|
||||
const CUSTOMER_OPTION_CATEGORIES = {
|
||||
customerType: 'crm_customer_type',
|
||||
customerStatus: 'crm_customer_status',
|
||||
leadChannel: 'crm_lead_channel',
|
||||
customerGroup: 'crm_customer_group'
|
||||
} as const;
|
||||
|
||||
function mapCustomerOption(
|
||||
option: Awaited<ReturnType<typeof getActiveOptionsByCategory>>[number]
|
||||
): CustomerOption {
|
||||
return {
|
||||
id: option.id,
|
||||
code: option.code,
|
||||
label: option.label,
|
||||
value: option.value
|
||||
};
|
||||
}
|
||||
|
||||
function mapBranchOption(
|
||||
branch: Awaited<ReturnType<typeof getUserBranches>>[number]
|
||||
): BranchOption {
|
||||
return {
|
||||
id: branch.id,
|
||||
code: branch.code,
|
||||
name: branch.name,
|
||||
value: branch.value
|
||||
};
|
||||
}
|
||||
|
||||
function mapCustomerRecord(row: typeof crmCustomers.$inferSelect): CustomerRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
organizationId: row.organizationId,
|
||||
branchId: row.branchId,
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
abbr: row.abbr,
|
||||
taxId: row.taxId,
|
||||
customerType: row.customerType,
|
||||
customerStatus: row.customerStatus,
|
||||
address: row.address,
|
||||
province: row.province,
|
||||
district: row.district,
|
||||
subDistrict: row.subDistrict,
|
||||
postalCode: row.postalCode,
|
||||
country: row.country,
|
||||
phone: row.phone,
|
||||
fax: row.fax,
|
||||
email: row.email,
|
||||
website: row.website,
|
||||
leadChannel: row.leadChannel,
|
||||
customerGroup: row.customerGroup,
|
||||
notes: row.notes,
|
||||
isActive: row.isActive,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
deletedAt: row.deletedAt?.toISOString() ?? null,
|
||||
createdBy: row.createdBy,
|
||||
updatedBy: row.updatedBy
|
||||
};
|
||||
}
|
||||
|
||||
function mapCustomerContactRecord(
|
||||
row: typeof crmCustomerContacts.$inferSelect
|
||||
): CustomerContactRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
organizationId: row.organizationId,
|
||||
customerId: row.customerId,
|
||||
name: row.name,
|
||||
position: row.position,
|
||||
department: row.department,
|
||||
phone: row.phone,
|
||||
mobile: row.mobile,
|
||||
email: row.email,
|
||||
isPrimary: row.isPrimary,
|
||||
notes: row.notes,
|
||||
isActive: row.isActive,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
deletedAt: row.deletedAt?.toISOString() ?? null,
|
||||
createdBy: row.createdBy,
|
||||
updatedBy: row.updatedBy
|
||||
};
|
||||
}
|
||||
|
||||
function parseSort(sort?: string) {
|
||||
if (!sort) {
|
||||
return desc(crmCustomers.updatedAt);
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(sort) as Array<{ id: string; desc?: boolean }>;
|
||||
const [rule] = parsed;
|
||||
|
||||
if (!rule) {
|
||||
return desc(crmCustomers.updatedAt);
|
||||
}
|
||||
|
||||
const sortMap = {
|
||||
code: crmCustomers.code,
|
||||
name: crmCustomers.name,
|
||||
customerStatus: crmCustomers.customerStatus,
|
||||
customerType: crmCustomers.customerType,
|
||||
branch: crmCustomers.branchId,
|
||||
updatedAt: crmCustomers.updatedAt
|
||||
} as const;
|
||||
|
||||
const column = sortMap[rule.id as keyof typeof sortMap];
|
||||
|
||||
if (!column) {
|
||||
return desc(crmCustomers.updatedAt);
|
||||
}
|
||||
|
||||
return rule.desc ? desc(column) : asc(column);
|
||||
} catch {
|
||||
return desc(crmCustomers.updatedAt);
|
||||
}
|
||||
}
|
||||
|
||||
function splitFilterValue(value?: string) {
|
||||
return value
|
||||
? value
|
||||
.split(/[.,]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
}
|
||||
|
||||
async function resolveValidOptionIds(category: string, organizationId: string) {
|
||||
const options = await getActiveOptionsByCategory(category, { organizationId });
|
||||
return new Set(options.map((option) => option.id));
|
||||
}
|
||||
|
||||
async function assertMasterOptionValue(
|
||||
organizationId: string,
|
||||
category: string,
|
||||
optionId?: string | null
|
||||
) {
|
||||
if (!optionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validIds = await resolveValidOptionIds(category, organizationId);
|
||||
|
||||
if (!validIds.has(optionId)) {
|
||||
throw new AuthError(`Invalid option for ${category}`, 400);
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
});
|
||||
|
||||
if (!customer) {
|
||||
throw new AuthError('Customer not found', 404);
|
||||
}
|
||||
|
||||
return customer;
|
||||
}
|
||||
|
||||
async function assertContactBelongsToCustomer(
|
||||
contactId: string,
|
||||
customerId: string,
|
||||
organizationId: string
|
||||
) {
|
||||
const contact = await db.query.crmCustomerContacts.findFirst({
|
||||
where: and(
|
||||
eq(crmCustomerContacts.id, contactId),
|
||||
eq(crmCustomerContacts.customerId, customerId),
|
||||
eq(crmCustomerContacts.organizationId, organizationId),
|
||||
isNull(crmCustomerContacts.deletedAt)
|
||||
)
|
||||
});
|
||||
|
||||
if (!contact) {
|
||||
throw new AuthError('Contact not found', 404);
|
||||
}
|
||||
|
||||
return contact;
|
||||
}
|
||||
|
||||
async function validateCustomerPayload(organizationId: string, payload: CustomerMutationPayload) {
|
||||
await assertMasterOptionValue(
|
||||
organizationId,
|
||||
CUSTOMER_OPTION_CATEGORIES.customerType,
|
||||
payload.customerType
|
||||
);
|
||||
await assertMasterOptionValue(
|
||||
organizationId,
|
||||
CUSTOMER_OPTION_CATEGORIES.customerStatus,
|
||||
payload.customerStatus
|
||||
);
|
||||
await assertMasterOptionValue(
|
||||
organizationId,
|
||||
CUSTOMER_OPTION_CATEGORIES.leadChannel,
|
||||
payload.leadChannel ?? null
|
||||
);
|
||||
await assertMasterOptionValue(
|
||||
organizationId,
|
||||
CUSTOMER_OPTION_CATEGORIES.customerGroup,
|
||||
payload.customerGroup ?? null
|
||||
);
|
||||
|
||||
if (payload.branchId) {
|
||||
await validateBranchAccess(payload.branchId);
|
||||
}
|
||||
}
|
||||
|
||||
function buildCustomerFilters(organizationId: string, filters: CustomerFilters): SQL[] {
|
||||
const statuses = splitFilterValue(filters.customerStatus);
|
||||
const customerTypes = splitFilterValue(filters.customerType);
|
||||
const branches = splitFilterValue(filters.branch);
|
||||
|
||||
return [
|
||||
eq(crmCustomers.organizationId, organizationId),
|
||||
isNull(crmCustomers.deletedAt),
|
||||
...(statuses.length ? [inArray(crmCustomers.customerStatus, statuses)] : []),
|
||||
...(customerTypes.length ? [inArray(crmCustomers.customerType, customerTypes)] : []),
|
||||
...(branches.length ? [inArray(crmCustomers.branchId, branches)] : []),
|
||||
...(filters.search
|
||||
? [
|
||||
or(
|
||||
ilike(crmCustomers.code, `%${filters.search}%`),
|
||||
ilike(crmCustomers.name, `%${filters.search}%`),
|
||||
ilike(crmCustomers.abbr, `%${filters.search}%`),
|
||||
ilike(crmCustomers.taxId, `%${filters.search}%`),
|
||||
ilike(crmCustomers.email, `%${filters.search}%`)
|
||||
)!
|
||||
]
|
||||
: [])
|
||||
];
|
||||
}
|
||||
|
||||
export async function getCustomerReferenceData(
|
||||
organizationId: string
|
||||
): Promise<CustomerReferenceData> {
|
||||
const [branches, customerTypes, customerStatuses, leadChannels, customerGroups] =
|
||||
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 })
|
||||
]);
|
||||
|
||||
return {
|
||||
branches: branches.map(mapBranchOption),
|
||||
customerTypes: customerTypes.map(mapCustomerOption),
|
||||
customerStatuses: customerStatuses.map(mapCustomerOption),
|
||||
leadChannels: leadChannels.map(mapCustomerOption),
|
||||
customerGroups: customerGroups.map(mapCustomerOption)
|
||||
};
|
||||
}
|
||||
|
||||
export async function listCustomers(
|
||||
organizationId: string,
|
||||
filters: CustomerFilters
|
||||
): Promise<{ items: CustomerListItem[]; totalItems: number }> {
|
||||
const page = filters.page ?? 1;
|
||||
const limit = filters.limit ?? 10;
|
||||
const whereFilters = buildCustomerFilters(organizationId, filters);
|
||||
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const [totalResult] = await db.select({ value: count() }).from(crmCustomers).where(where);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(crmCustomers)
|
||||
.where(where)
|
||||
.orderBy(parseSort(filters.sort))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
const customerIds = rows.map((row) => row.id);
|
||||
const contactCounts = customerIds.length
|
||||
? await db
|
||||
.select({
|
||||
customerId: crmCustomerContacts.customerId,
|
||||
value: count()
|
||||
})
|
||||
.from(crmCustomerContacts)
|
||||
.where(
|
||||
and(
|
||||
eq(crmCustomerContacts.organizationId, organizationId),
|
||||
inArray(crmCustomerContacts.customerId, customerIds),
|
||||
isNull(crmCustomerContacts.deletedAt)
|
||||
)
|
||||
)
|
||||
.groupBy(crmCustomerContacts.customerId)
|
||||
: [];
|
||||
const countMap = new Map(contactCounts.map((item) => [item.customerId, item.value]));
|
||||
|
||||
return {
|
||||
items: rows.map((row) => ({
|
||||
...mapCustomerRecord(row),
|
||||
contactCount: countMap.get(row.id) ?? 0
|
||||
})),
|
||||
totalItems: totalResult?.value ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCustomerDetail(
|
||||
id: string,
|
||||
organizationId: string
|
||||
): Promise<CustomerRecord> {
|
||||
const customer = await assertCustomerBelongsToOrganization(id, organizationId);
|
||||
return mapCustomerRecord(customer);
|
||||
}
|
||||
|
||||
export async function getCustomerActivity(
|
||||
id: string,
|
||||
organizationId: string
|
||||
): Promise<CustomerActivityRecord[]> {
|
||||
const auditLogs = await listAuditLogs({
|
||||
organizationId,
|
||||
entityType: 'crm_customer',
|
||||
entityId: id,
|
||||
limit: 25
|
||||
});
|
||||
const userIds = [...new Set(auditLogs.map((log) => log.userId))];
|
||||
const actorRows = userIds.length
|
||||
? await db
|
||||
.select({ id: users.id, name: users.name })
|
||||
.from(users)
|
||||
.where(inArray(users.id, userIds))
|
||||
: [];
|
||||
const actorMap = new Map(actorRows.map((row) => [row.id, row.name]));
|
||||
|
||||
return auditLogs.map((log) => ({
|
||||
id: log.id,
|
||||
action: log.action,
|
||||
entityType: log.entityType,
|
||||
entityId: log.entityId,
|
||||
createdAt: log.createdAt,
|
||||
userId: log.userId,
|
||||
actorName: actorMap.get(log.userId) ?? null
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createCustomer(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: CustomerMutationPayload
|
||||
) {
|
||||
await validateCustomerPayload(organizationId, payload);
|
||||
|
||||
const documentCode = await generateNextDocumentCode({
|
||||
organizationId,
|
||||
documentType: 'customer',
|
||||
branchId: payload.branchId ?? ''
|
||||
});
|
||||
|
||||
const [created] = await db
|
||||
.insert(crmCustomers)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId,
|
||||
branchId: payload.branchId ?? null,
|
||||
code: documentCode.code,
|
||||
name: payload.name.trim(),
|
||||
abbr: payload.abbr?.trim() || null,
|
||||
taxId: payload.taxId?.trim() || null,
|
||||
customerType: payload.customerType,
|
||||
customerStatus: payload.customerStatus,
|
||||
address: payload.address?.trim() || null,
|
||||
province: payload.province?.trim() || null,
|
||||
district: payload.district?.trim() || null,
|
||||
subDistrict: payload.subDistrict?.trim() || null,
|
||||
postalCode: payload.postalCode?.trim() || null,
|
||||
country: payload.country?.trim() || null,
|
||||
phone: payload.phone?.trim() || null,
|
||||
fax: payload.fax?.trim() || null,
|
||||
email: payload.email?.trim() || null,
|
||||
website: payload.website?.trim() || null,
|
||||
leadChannel: payload.leadChannel ?? null,
|
||||
customerGroup: payload.customerGroup ?? null,
|
||||
notes: payload.notes?.trim() || null,
|
||||
isActive: payload.isActive ?? true,
|
||||
createdBy: userId,
|
||||
updatedBy: userId
|
||||
})
|
||||
.returning();
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function updateCustomer(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: CustomerMutationPayload
|
||||
) {
|
||||
await validateCustomerPayload(organizationId, payload);
|
||||
await assertCustomerBelongsToOrganization(id, organizationId);
|
||||
|
||||
const [updated] = await db
|
||||
.update(crmCustomers)
|
||||
.set({
|
||||
branchId: payload.branchId ?? null,
|
||||
name: payload.name.trim(),
|
||||
abbr: payload.abbr?.trim() || null,
|
||||
taxId: payload.taxId?.trim() || null,
|
||||
customerType: payload.customerType,
|
||||
customerStatus: payload.customerStatus,
|
||||
address: payload.address?.trim() || null,
|
||||
province: payload.province?.trim() || null,
|
||||
district: payload.district?.trim() || null,
|
||||
subDistrict: payload.subDistrict?.trim() || null,
|
||||
postalCode: payload.postalCode?.trim() || null,
|
||||
country: payload.country?.trim() || null,
|
||||
phone: payload.phone?.trim() || null,
|
||||
fax: payload.fax?.trim() || null,
|
||||
email: payload.email?.trim() || null,
|
||||
website: payload.website?.trim() || null,
|
||||
leadChannel: payload.leadChannel ?? null,
|
||||
customerGroup: payload.customerGroup ?? null,
|
||||
notes: payload.notes?.trim() || null,
|
||||
isActive: payload.isActive ?? true,
|
||||
updatedBy: userId,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(crmCustomers.id, id))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function softDeleteCustomer(id: string, organizationId: string, userId: string) {
|
||||
await assertCustomerBelongsToOrganization(id, organizationId);
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const [updated] = await db
|
||||
.update(crmCustomers)
|
||||
.set({
|
||||
deletedAt: now,
|
||||
updatedAt: now,
|
||||
updatedBy: userId,
|
||||
isActive: false
|
||||
})
|
||||
.where(eq(crmCustomers.id, id))
|
||||
.returning();
|
||||
|
||||
await db
|
||||
.update(crmCustomerContacts)
|
||||
.set({
|
||||
deletedAt: now,
|
||||
updatedAt: now,
|
||||
updatedBy: userId,
|
||||
isActive: false
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(crmCustomerContacts.customerId, id),
|
||||
eq(crmCustomerContacts.organizationId, organizationId)
|
||||
)
|
||||
);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function listCustomerContacts(id: string, organizationId: string) {
|
||||
await assertCustomerBelongsToOrganization(id, organizationId);
|
||||
const rows = await db.query.crmCustomerContacts.findMany({
|
||||
where: and(
|
||||
eq(crmCustomerContacts.customerId, id),
|
||||
eq(crmCustomerContacts.organizationId, organizationId),
|
||||
isNull(crmCustomerContacts.deletedAt)
|
||||
),
|
||||
orderBy: [desc(crmCustomerContacts.isPrimary), asc(crmCustomerContacts.name)]
|
||||
});
|
||||
|
||||
return rows.map(mapCustomerContactRecord);
|
||||
}
|
||||
|
||||
export async function createCustomerContact(
|
||||
customerId: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: CustomerContactMutationPayload
|
||||
) {
|
||||
await assertCustomerBelongsToOrganization(customerId, organizationId);
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
if (payload.isPrimary) {
|
||||
await tx
|
||||
.update(crmCustomerContacts)
|
||||
.set({
|
||||
isPrimary: false,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: userId
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(crmCustomerContacts.customerId, customerId),
|
||||
eq(crmCustomerContacts.organizationId, organizationId),
|
||||
isNull(crmCustomerContacts.deletedAt)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [created] = await tx
|
||||
.insert(crmCustomerContacts)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId,
|
||||
customerId,
|
||||
name: payload.name.trim(),
|
||||
position: payload.position?.trim() || null,
|
||||
department: payload.department?.trim() || null,
|
||||
phone: payload.phone?.trim() || null,
|
||||
mobile: payload.mobile?.trim() || null,
|
||||
email: payload.email?.trim() || null,
|
||||
isPrimary: payload.isPrimary ?? false,
|
||||
notes: payload.notes?.trim() || null,
|
||||
isActive: payload.isActive ?? true,
|
||||
createdBy: userId,
|
||||
updatedBy: userId
|
||||
})
|
||||
.returning();
|
||||
|
||||
return created;
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateCustomerContact(
|
||||
customerId: string,
|
||||
contactId: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: CustomerContactMutationPayload
|
||||
) {
|
||||
await assertCustomerBelongsToOrganization(customerId, organizationId);
|
||||
await assertContactBelongsToCustomer(contactId, customerId, organizationId);
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
if (payload.isPrimary) {
|
||||
await tx
|
||||
.update(crmCustomerContacts)
|
||||
.set({
|
||||
isPrimary: false,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: userId
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(crmCustomerContacts.customerId, customerId),
|
||||
eq(crmCustomerContacts.organizationId, organizationId),
|
||||
isNull(crmCustomerContacts.deletedAt)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [updated] = await tx
|
||||
.update(crmCustomerContacts)
|
||||
.set({
|
||||
name: payload.name.trim(),
|
||||
position: payload.position?.trim() || null,
|
||||
department: payload.department?.trim() || null,
|
||||
phone: payload.phone?.trim() || null,
|
||||
mobile: payload.mobile?.trim() || null,
|
||||
email: payload.email?.trim() || null,
|
||||
isPrimary: payload.isPrimary ?? false,
|
||||
notes: payload.notes?.trim() || null,
|
||||
isActive: payload.isActive ?? true,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: userId
|
||||
})
|
||||
.where(eq(crmCustomerContacts.id, contactId))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
export async function softDeleteCustomerContact(
|
||||
customerId: string,
|
||||
contactId: string,
|
||||
organizationId: string,
|
||||
userId: string
|
||||
) {
|
||||
await assertCustomerBelongsToOrganization(customerId, organizationId);
|
||||
await assertContactBelongsToCustomer(contactId, customerId, organizationId);
|
||||
|
||||
const [updated] = await db
|
||||
.update(crmCustomerContacts)
|
||||
.set({
|
||||
deletedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
updatedBy: userId,
|
||||
isActive: false,
|
||||
isPrimary: false
|
||||
})
|
||||
.where(eq(crmCustomerContacts.id, contactId))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function resolveCustomerOptionLabel(
|
||||
organizationId: string,
|
||||
category: string,
|
||||
id: string | null
|
||||
) {
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const row = await db.query.msOptions.findFirst({
|
||||
where: and(
|
||||
eq(msOptions.organizationId, organizationId),
|
||||
eq(msOptions.category, category),
|
||||
eq(msOptions.id, id),
|
||||
isNull(msOptions.deletedAt)
|
||||
)
|
||||
});
|
||||
|
||||
return row?.label ?? null;
|
||||
}
|
||||
Reference in New Issue
Block a user