compleate
This commit is contained in:
phaichayon
2026-06-15 13:20:39 +07:00
parent b8cd39eaa4
commit 8148850fda
33 changed files with 4800 additions and 35 deletions

View 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) });
}
});

View 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)
});

View 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'
});
}

View 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;
}

View 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>
);
}

View File

@@ -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>
</>
);
}

View 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}
/>
)
}
];
}

View 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>
);
}

View 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>
);
}

View 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} />
</>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>;

View 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;
}