task-d complate
This commit is contained in:
@@ -77,6 +77,15 @@ export interface CustomerActivityRecord {
|
||||
actorName: string | null;
|
||||
}
|
||||
|
||||
export interface CustomerRelatedEnquiryItem {
|
||||
id: string;
|
||||
code: string;
|
||||
title: string;
|
||||
status: string;
|
||||
productType: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CustomerReferenceData {
|
||||
branches: BranchOption[];
|
||||
customerTypes: CustomerOption[];
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { CustomerReferenceData } from '../api/types';
|
||||
import { CustomerFormSheet } from './customer-form-sheet';
|
||||
import { CustomerContactsTab } from './customer-contacts-tab';
|
||||
import { CustomerStatusBadge } from './customer-status-badge';
|
||||
import type { CustomerRelatedEnquiryItem } from '../api/types';
|
||||
|
||||
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
|
||||
return (
|
||||
@@ -28,7 +29,8 @@ export function CustomerDetail({
|
||||
customerId,
|
||||
referenceData,
|
||||
canUpdate,
|
||||
canManageContacts
|
||||
canManageContacts,
|
||||
relatedEnquiries
|
||||
}: {
|
||||
customerId: string;
|
||||
referenceData: CustomerReferenceData;
|
||||
@@ -38,6 +40,7 @@ export function CustomerDetail({
|
||||
update: boolean;
|
||||
delete: boolean;
|
||||
};
|
||||
relatedEnquiries: CustomerRelatedEnquiryItem[];
|
||||
}) {
|
||||
const { data } = useSuspenseQuery(customerByIdOptions(customerId));
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
@@ -212,14 +215,36 @@ export function CustomerDetail({
|
||||
<CardHeader>
|
||||
<CardTitle>Related Documents</CardTitle>
|
||||
<CardDescription>
|
||||
Task C stops at customer and contact scope, so downstream CRM documents stay
|
||||
intentionally deferred.
|
||||
Production enquiry links are now visible here, while quotation and approval
|
||||
modules stay 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>
|
||||
{!relatedEnquiries.length ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
||||
No enquiries have been linked to this customer yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-3'>
|
||||
{relatedEnquiries.map((enquiry) => (
|
||||
<Link
|
||||
key={enquiry.id}
|
||||
href={`/dashboard/crm/enquiries/${enquiry.id}`}
|
||||
className='block rounded-lg border p-4 transition-colors hover:bg-muted/40'
|
||||
>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='space-y-1'>
|
||||
<div className='font-medium'>{enquiry.title}</div>
|
||||
<div className='text-muted-foreground font-mono text-xs'>
|
||||
{enquiry.code}
|
||||
</div>
|
||||
</div>
|
||||
<Icons.arrowRight className='text-muted-foreground h-4 w-4' />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
74
src/features/crm/enquiries/api/mutations.ts
Normal file
74
src/features/crm/enquiries/api/mutations.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { mutationOptions } from '@tanstack/react-query';
|
||||
import { getQueryClient } from '@/lib/query-client';
|
||||
import {
|
||||
createEnquiry,
|
||||
createEnquiryFollowup,
|
||||
deleteEnquiry,
|
||||
deleteEnquiryFollowup,
|
||||
updateEnquiry,
|
||||
updateEnquiryFollowup
|
||||
} from './service';
|
||||
import { enquiryKeys } from './queries';
|
||||
import type { EnquiryFollowupMutationPayload, EnquiryMutationPayload } from './types';
|
||||
|
||||
export const createEnquiryMutation = mutationOptions({
|
||||
mutationFn: (data: EnquiryMutationPayload) => createEnquiry(data),
|
||||
onSuccess: () => {
|
||||
getQueryClient().invalidateQueries({ queryKey: enquiryKeys.all });
|
||||
}
|
||||
});
|
||||
|
||||
export const updateEnquiryMutation = mutationOptions({
|
||||
mutationFn: ({ id, values }: { id: string; values: EnquiryMutationPayload }) =>
|
||||
updateEnquiry(id, values),
|
||||
onSuccess: (_data, variables) => {
|
||||
getQueryClient().invalidateQueries({ queryKey: enquiryKeys.all });
|
||||
getQueryClient().invalidateQueries({ queryKey: enquiryKeys.detail(variables.id) });
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteEnquiryMutation = mutationOptions({
|
||||
mutationFn: (id: string) => deleteEnquiry(id),
|
||||
onSuccess: () => {
|
||||
getQueryClient().invalidateQueries({ queryKey: enquiryKeys.all });
|
||||
}
|
||||
});
|
||||
|
||||
export const createEnquiryFollowupMutation = mutationOptions({
|
||||
mutationFn: ({
|
||||
enquiryId,
|
||||
values
|
||||
}: {
|
||||
enquiryId: string;
|
||||
values: EnquiryFollowupMutationPayload;
|
||||
}) => createEnquiryFollowup(enquiryId, values),
|
||||
onSuccess: (_data, variables) => {
|
||||
getQueryClient().invalidateQueries({ queryKey: enquiryKeys.followups(variables.enquiryId) });
|
||||
getQueryClient().invalidateQueries({ queryKey: enquiryKeys.detail(variables.enquiryId) });
|
||||
}
|
||||
});
|
||||
|
||||
export const updateEnquiryFollowupMutation = mutationOptions({
|
||||
mutationFn: ({
|
||||
enquiryId,
|
||||
followupId,
|
||||
values
|
||||
}: {
|
||||
enquiryId: string;
|
||||
followupId: string;
|
||||
values: EnquiryFollowupMutationPayload;
|
||||
}) => updateEnquiryFollowup(enquiryId, followupId, values),
|
||||
onSuccess: (_data, variables) => {
|
||||
getQueryClient().invalidateQueries({ queryKey: enquiryKeys.followups(variables.enquiryId) });
|
||||
getQueryClient().invalidateQueries({ queryKey: enquiryKeys.detail(variables.enquiryId) });
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteEnquiryFollowupMutation = mutationOptions({
|
||||
mutationFn: ({ enquiryId, followupId }: { enquiryId: string; followupId: string }) =>
|
||||
deleteEnquiryFollowup(enquiryId, followupId),
|
||||
onSuccess: (_data, variables) => {
|
||||
getQueryClient().invalidateQueries({ queryKey: enquiryKeys.followups(variables.enquiryId) });
|
||||
getQueryClient().invalidateQueries({ queryKey: enquiryKeys.detail(variables.enquiryId) });
|
||||
}
|
||||
});
|
||||
28
src/features/crm/enquiries/api/queries.ts
Normal file
28
src/features/crm/enquiries/api/queries.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
import { getEnquiries, getEnquiryById, getEnquiryFollowups } from './service';
|
||||
import type { EnquiryFilters } from './types';
|
||||
|
||||
export const enquiryKeys = {
|
||||
all: ['crm-enquiries'] as const,
|
||||
list: (filters: EnquiryFilters) => [...enquiryKeys.all, 'list', filters] as const,
|
||||
detail: (id: string) => [...enquiryKeys.all, 'detail', id] as const,
|
||||
followups: (id: string) => [...enquiryKeys.all, 'followups', id] as const
|
||||
};
|
||||
|
||||
export const enquiriesQueryOptions = (filters: EnquiryFilters) =>
|
||||
queryOptions({
|
||||
queryKey: enquiryKeys.list(filters),
|
||||
queryFn: () => getEnquiries(filters)
|
||||
});
|
||||
|
||||
export const enquiryByIdOptions = (id: string) =>
|
||||
queryOptions({
|
||||
queryKey: enquiryKeys.detail(id),
|
||||
queryFn: () => getEnquiryById(id)
|
||||
});
|
||||
|
||||
export const enquiryFollowupsOptions = (id: string) =>
|
||||
queryOptions({
|
||||
queryKey: enquiryKeys.followups(id),
|
||||
queryFn: () => getEnquiryFollowups(id)
|
||||
});
|
||||
83
src/features/crm/enquiries/api/service.ts
Normal file
83
src/features/crm/enquiries/api/service.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type {
|
||||
EnquiryDetailResponse,
|
||||
EnquiryFilters,
|
||||
EnquiryFollowupMutationPayload,
|
||||
EnquiryFollowupsResponse,
|
||||
EnquiryListResponse,
|
||||
EnquiryMutationPayload,
|
||||
MutationSuccessResponse
|
||||
} from './types';
|
||||
|
||||
export async function getEnquiries(filters: EnquiryFilters): Promise<EnquiryListResponse> {
|
||||
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.status) searchParams.set('status', filters.status);
|
||||
if (filters.productType) searchParams.set('productType', filters.productType);
|
||||
if (filters.priority) searchParams.set('priority', filters.priority);
|
||||
if (filters.branch) searchParams.set('branch', filters.branch);
|
||||
if (filters.customer) searchParams.set('customer', filters.customer);
|
||||
if (filters.sort) searchParams.set('sort', filters.sort);
|
||||
|
||||
const query = searchParams.toString();
|
||||
|
||||
return apiClient<EnquiryListResponse>(`/crm/enquiries${query ? `?${query}` : ''}`);
|
||||
}
|
||||
|
||||
export async function getEnquiryById(id: string): Promise<EnquiryDetailResponse> {
|
||||
return apiClient<EnquiryDetailResponse>(`/crm/enquiries/${id}`);
|
||||
}
|
||||
|
||||
export async function createEnquiry(data: EnquiryMutationPayload) {
|
||||
return apiClient<MutationSuccessResponse>('/crm/enquiries', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateEnquiry(id: string, data: EnquiryMutationPayload) {
|
||||
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteEnquiry(id: string) {
|
||||
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
export async function getEnquiryFollowups(id: string): Promise<EnquiryFollowupsResponse> {
|
||||
return apiClient<EnquiryFollowupsResponse>(`/crm/enquiries/${id}/followups`);
|
||||
}
|
||||
|
||||
export async function createEnquiryFollowup(
|
||||
enquiryId: string,
|
||||
data: EnquiryFollowupMutationPayload
|
||||
) {
|
||||
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${enquiryId}/followups`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateEnquiryFollowup(
|
||||
enquiryId: string,
|
||||
followupId: string,
|
||||
data: EnquiryFollowupMutationPayload
|
||||
) {
|
||||
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${enquiryId}/followups/${followupId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteEnquiryFollowup(enquiryId: string, followupId: string) {
|
||||
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${enquiryId}/followups/${followupId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
189
src/features/crm/enquiries/api/types.ts
Normal file
189
src/features/crm/enquiries/api/types.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
export interface EnquiryOption {
|
||||
id: string;
|
||||
code: string;
|
||||
label: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
export interface EnquiryBranchOption {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
export interface EnquiryCustomerLookup {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
branchId: string | null;
|
||||
}
|
||||
|
||||
export interface EnquiryContactLookup {
|
||||
id: string;
|
||||
customerId: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
mobile: string | null;
|
||||
isPrimary: boolean;
|
||||
}
|
||||
|
||||
export interface EnquiryRecord {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
branchId: string | null;
|
||||
code: string;
|
||||
customerId: string;
|
||||
contactId: string | null;
|
||||
title: string;
|
||||
description: string | null;
|
||||
requirement: string | null;
|
||||
projectName: string | null;
|
||||
projectLocation: string | null;
|
||||
productType: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
leadChannel: string | null;
|
||||
estimatedValue: number | null;
|
||||
chancePercent: number | null;
|
||||
expectedCloseDate: string | null;
|
||||
competitor: string | null;
|
||||
source: string | null;
|
||||
notes: string | null;
|
||||
isHotProject: boolean;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
createdBy: string;
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
export interface EnquiryListItem extends EnquiryRecord {
|
||||
customerName: string;
|
||||
contactName: string | null;
|
||||
followupCount: number;
|
||||
}
|
||||
|
||||
export interface EnquiryFollowupRecord {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
enquiryId: string;
|
||||
followupDate: string;
|
||||
followupType: string;
|
||||
contactId: string | null;
|
||||
outcome: string | null;
|
||||
notes: string | null;
|
||||
nextFollowupDate: string | null;
|
||||
nextAction: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
createdBy: string;
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
export interface EnquiryActivityRecord {
|
||||
id: string;
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
createdAt: string;
|
||||
userId: string;
|
||||
actorName: string | null;
|
||||
}
|
||||
|
||||
export interface EnquiryReferenceData {
|
||||
branches: EnquiryBranchOption[];
|
||||
statuses: EnquiryOption[];
|
||||
productTypes: EnquiryOption[];
|
||||
priorities: EnquiryOption[];
|
||||
leadChannels: EnquiryOption[];
|
||||
followupTypes: EnquiryOption[];
|
||||
customers: EnquiryCustomerLookup[];
|
||||
contacts: EnquiryContactLookup[];
|
||||
}
|
||||
|
||||
export interface EnquiryFilters {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
status?: string;
|
||||
productType?: string;
|
||||
priority?: string;
|
||||
branch?: string;
|
||||
customer?: string;
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
export interface EnquiryListResponse {
|
||||
success: boolean;
|
||||
time: string;
|
||||
message: string;
|
||||
totalItems: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
items: EnquiryListItem[];
|
||||
}
|
||||
|
||||
export interface EnquiryDetailResponse {
|
||||
success: boolean;
|
||||
time: string;
|
||||
message: string;
|
||||
enquiry: EnquiryRecord;
|
||||
activity: EnquiryActivityRecord[];
|
||||
}
|
||||
|
||||
export interface EnquiryFollowupsResponse {
|
||||
success: boolean;
|
||||
time: string;
|
||||
message: string;
|
||||
items: EnquiryFollowupRecord[];
|
||||
}
|
||||
|
||||
export interface EnquiryMutationPayload {
|
||||
customerId: string;
|
||||
contactId?: string | null;
|
||||
title: string;
|
||||
description?: string;
|
||||
requirement?: string;
|
||||
projectName?: string;
|
||||
projectLocation?: string;
|
||||
branchId?: string | null;
|
||||
productType: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
leadChannel?: string | null;
|
||||
estimatedValue?: number | null;
|
||||
chancePercent?: number | null;
|
||||
expectedCloseDate?: string | null;
|
||||
competitor?: string;
|
||||
source?: string;
|
||||
isHotProject?: boolean;
|
||||
notes?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface EnquiryFollowupMutationPayload {
|
||||
followupDate: string;
|
||||
followupType: string;
|
||||
contactId?: string | null;
|
||||
outcome?: string;
|
||||
notes?: string;
|
||||
nextFollowupDate?: string | null;
|
||||
nextAction?: string;
|
||||
}
|
||||
|
||||
export interface EnquiryCustomerRelationItem {
|
||||
id: string;
|
||||
code: string;
|
||||
title: string;
|
||||
status: string;
|
||||
productType: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface MutationSuccessResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
70
src/features/crm/enquiries/components/enquiries-table.tsx
Normal file
70
src/features/crm/enquiries/components/enquiries-table.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
'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 { enquiriesQueryOptions } from '../api/queries';
|
||||
import type { EnquiryReferenceData } from '../api/types';
|
||||
import { getEnquiryColumns } from './enquiry-columns';
|
||||
|
||||
export function EnquiriesTable({
|
||||
referenceData,
|
||||
canUpdate,
|
||||
canDelete
|
||||
}: {
|
||||
referenceData: EnquiryReferenceData;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
}) {
|
||||
const columns = useMemo(
|
||||
() => getEnquiryColumns({ 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,
|
||||
status: parseAsString,
|
||||
productType: parseAsString,
|
||||
priority: parseAsString,
|
||||
branch: parseAsString,
|
||||
customer: parseAsString,
|
||||
sort: getSortingStateParser(columnIds).withDefault([])
|
||||
});
|
||||
|
||||
const filters = {
|
||||
page: params.page,
|
||||
limit: params.perPage,
|
||||
...(params.name && { search: params.name }),
|
||||
...(params.status && { status: params.status }),
|
||||
...(params.productType && { productType: params.productType }),
|
||||
...(params.priority && { priority: params.priority }),
|
||||
...(params.branch && { branch: params.branch }),
|
||||
...(params.customer && { customer: params.customer }),
|
||||
...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) })
|
||||
};
|
||||
|
||||
const { data } = useSuspenseQuery(enquiriesQueryOptions(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>
|
||||
);
|
||||
}
|
||||
@@ -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 { deleteEnquiryMutation } from '../api/mutations';
|
||||
import type { EnquiryListItem, EnquiryReferenceData } from '../api/types';
|
||||
import { EnquiryFormSheet } from './enquiry-form-sheet';
|
||||
|
||||
export function EnquiryCellAction({
|
||||
data,
|
||||
referenceData,
|
||||
canUpdate,
|
||||
canDelete
|
||||
}: {
|
||||
data: EnquiryListItem;
|
||||
referenceData: EnquiryReferenceData;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
...deleteEnquiryMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Enquiry deleted successfully');
|
||||
setDeleteOpen(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to delete enquiry')
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<AlertModal
|
||||
isOpen={deleteOpen}
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
onConfirm={() => deleteMutation.mutate(data.id)}
|
||||
loading={deleteMutation.isPending}
|
||||
/>
|
||||
<EnquiryFormSheet
|
||||
enquiry={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/enquiries/${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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
176
src/features/crm/enquiries/components/enquiry-columns.tsx
Normal file
176
src/features/crm/enquiries/components/enquiry-columns.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
'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 { EnquiryListItem, EnquiryReferenceData } from '../api/types';
|
||||
import { EnquiryCellAction } from './enquiry-cell-action';
|
||||
import { EnquiryStatusBadge } from './enquiry-status-badge';
|
||||
|
||||
export function getEnquiryColumns({
|
||||
referenceData,
|
||||
canUpdate,
|
||||
canDelete
|
||||
}: {
|
||||
referenceData: EnquiryReferenceData;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
}): ColumnDef<EnquiryListItem>[] {
|
||||
const statusMap = new Map(referenceData.statuses.map((item) => [item.id, item]));
|
||||
const productTypeMap = new Map(referenceData.productTypes.map((item) => [item.id, item]));
|
||||
const priorityMap = new Map(referenceData.priorities.map((item) => [item.id, item]));
|
||||
const branchMap = new Map(referenceData.branches.map((item) => [item.id, item]));
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'title',
|
||||
accessorKey: 'title',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Enquiry' />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className='flex flex-col'>
|
||||
<Link
|
||||
href={`/dashboard/crm/enquiries/${row.original.id}`}
|
||||
className='font-medium hover:underline'
|
||||
>
|
||||
{row.original.title}
|
||||
</Link>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{row.original.code} • {row.original.customerName}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
label: 'Enquiry',
|
||||
placeholder: 'Search enquiries...',
|
||||
variant: 'text' as const,
|
||||
icon: Icons.search
|
||||
},
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
accessorKey: 'status',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Status' />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const status = statusMap.get(row.original.status);
|
||||
return <EnquiryStatusBadge code={status?.code} label={status?.label ?? 'Unknown'} />;
|
||||
},
|
||||
meta: {
|
||||
label: 'Status',
|
||||
variant: 'multiSelect' as const,
|
||||
options: referenceData.statuses.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
}))
|
||||
},
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'productType',
|
||||
accessorKey: 'productType',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Product Type' />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Badge variant='outline'>
|
||||
{productTypeMap.get(row.original.productType)?.label ?? 'Unknown'}
|
||||
</Badge>
|
||||
),
|
||||
meta: {
|
||||
label: 'Product Type',
|
||||
variant: 'multiSelect' as const,
|
||||
options: referenceData.productTypes.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
}))
|
||||
},
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'priority',
|
||||
accessorKey: 'priority',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Priority' />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Badge variant='secondary'>
|
||||
{priorityMap.get(row.original.priority)?.label ?? 'Unknown'}
|
||||
</Badge>
|
||||
),
|
||||
meta: {
|
||||
label: 'Priority',
|
||||
variant: 'multiSelect' as const,
|
||||
options: referenceData.priorities.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
}))
|
||||
},
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'branch',
|
||||
accessorFn: (row) => row.branchId ?? '',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, 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: 'customer',
|
||||
accessorFn: (row) => row.customerId,
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Customer' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.customerName}</span>,
|
||||
meta: {
|
||||
label: 'Customer',
|
||||
variant: 'multiSelect' as const,
|
||||
options: referenceData.customers.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.name
|
||||
}))
|
||||
},
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'followupCount',
|
||||
accessorKey: 'followupCount',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Follow-ups' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.followupCount}</span>
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => (
|
||||
<EnquiryCellAction
|
||||
data={row.original}
|
||||
referenceData={referenceData}
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
}
|
||||
288
src/features/crm/enquiries/components/enquiry-detail.tsx
Normal file
288
src/features/crm/enquiries/components/enquiry-detail.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
'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 { enquiryByIdOptions } from '../api/queries';
|
||||
import type { EnquiryReferenceData } from '../api/types';
|
||||
import { EnquiryFormSheet } from './enquiry-form-sheet';
|
||||
import { EnquiryFollowupsTab } from './enquiry-followups-tab';
|
||||
import { EnquiryStatusBadge } from './enquiry-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 EnquiryDetail({
|
||||
enquiryId,
|
||||
referenceData,
|
||||
canUpdate,
|
||||
canManageFollowups
|
||||
}: {
|
||||
enquiryId: string;
|
||||
referenceData: EnquiryReferenceData;
|
||||
canUpdate: boolean;
|
||||
canManageFollowups: {
|
||||
create: boolean;
|
||||
update: boolean;
|
||||
delete: boolean;
|
||||
};
|
||||
}) {
|
||||
const { data } = useSuspenseQuery(enquiryByIdOptions(enquiryId));
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const enquiry = data.enquiry;
|
||||
const branchMap = useMemo(
|
||||
() => new Map(referenceData.branches.map((item) => [item.id, item.name])),
|
||||
[referenceData]
|
||||
);
|
||||
const customerMap = useMemo(
|
||||
() => new Map(referenceData.customers.map((item) => [item.id, item])),
|
||||
[referenceData]
|
||||
);
|
||||
const contactMap = useMemo(
|
||||
() => new Map(referenceData.contacts.map((item) => [item.id, item])),
|
||||
[referenceData]
|
||||
);
|
||||
const statusMap = useMemo(
|
||||
() => new Map(referenceData.statuses.map((item) => [item.id, item])),
|
||||
[referenceData]
|
||||
);
|
||||
const productTypeMap = useMemo(
|
||||
() => new Map(referenceData.productTypes.map((item) => [item.id, item])),
|
||||
[referenceData]
|
||||
);
|
||||
const priorityMap = useMemo(
|
||||
() => new Map(referenceData.priorities.map((item) => [item.id, item])),
|
||||
[referenceData]
|
||||
);
|
||||
const leadChannelMap = useMemo(
|
||||
() => new Map(referenceData.leadChannels.map((item) => [item.id, item])),
|
||||
[referenceData]
|
||||
);
|
||||
const customer = customerMap.get(enquiry.customerId);
|
||||
const contact = enquiry.contactId ? contactMap.get(enquiry.contactId) : null;
|
||||
const status = statusMap.get(enquiry.status);
|
||||
|
||||
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/enquiries'>
|
||||
<Icons.chevronLeft className='h-4 w-4' />
|
||||
</Link>
|
||||
</Button>
|
||||
<span className='text-muted-foreground font-mono text-sm'>{enquiry.code}</span>
|
||||
<EnquiryStatusBadge code={status?.code} label={status?.label ?? 'Unknown'} />
|
||||
{enquiry.isHotProject ? <Badge>Hot</Badge> : null}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className='text-2xl font-semibold'>{enquiry.title}</h2>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{customer?.name ?? 'Unknown customer'}
|
||||
{contact ? ` • ${contact.name}` : ''}
|
||||
</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 Enquiry
|
||||
</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='followups'>Follow-ups</TabsTrigger>
|
||||
<TabsTrigger value='activity'>Activity</TabsTrigger>
|
||||
<TabsTrigger value='related'>Related Quotations</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value='overview'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Overview</CardTitle>
|
||||
<CardDescription>
|
||||
Opportunity scope, project context, and commercial direction.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||
<FieldItem label='Customer' value={customer?.name} />
|
||||
<FieldItem label='Contact' value={contact?.name} />
|
||||
<FieldItem
|
||||
label='Branch'
|
||||
value={enquiry.branchId ? branchMap.get(enquiry.branchId) : null}
|
||||
/>
|
||||
<FieldItem label='Active' value={enquiry.isActive ? 'Yes' : 'No'} />
|
||||
<FieldItem
|
||||
label='Product Type'
|
||||
value={productTypeMap.get(enquiry.productType)?.label}
|
||||
/>
|
||||
<FieldItem
|
||||
label='Priority'
|
||||
value={priorityMap.get(enquiry.priority)?.label}
|
||||
/>
|
||||
<FieldItem
|
||||
label='Lead Channel'
|
||||
value={leadChannelMap.get(enquiry.leadChannel ?? '')?.label}
|
||||
/>
|
||||
<FieldItem
|
||||
label='Expected Close Date'
|
||||
value={
|
||||
enquiry.expectedCloseDate
|
||||
? new Date(enquiry.expectedCloseDate).toLocaleDateString()
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<FieldItem
|
||||
label='Estimated Value'
|
||||
value={
|
||||
enquiry.estimatedValue !== null
|
||||
? enquiry.estimatedValue.toLocaleString()
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<FieldItem
|
||||
label='Chance %'
|
||||
value={enquiry.chancePercent !== null ? `${enquiry.chancePercent}%` : null}
|
||||
/>
|
||||
<FieldItem label='Competitor' value={enquiry.competitor} />
|
||||
<FieldItem label='Source' value={enquiry.source} />
|
||||
<div className='md:col-span-2 xl:col-span-4'>
|
||||
<FieldItem
|
||||
label='Project'
|
||||
value={
|
||||
[enquiry.projectName, enquiry.projectLocation]
|
||||
.filter(Boolean)
|
||||
.join(' • ') || null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className='md:col-span-2 xl:col-span-4'>
|
||||
<FieldItem label='Description' value={enquiry.description} />
|
||||
</div>
|
||||
<div className='md:col-span-2 xl:col-span-4'>
|
||||
<FieldItem label='Requirement' value={enquiry.requirement} />
|
||||
</div>
|
||||
<div className='md:col-span-2 xl:col-span-4'>
|
||||
<FieldItem label='Notes' value={enquiry.notes} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value='followups'>
|
||||
<EnquiryFollowupsTab
|
||||
enquiryId={enquiryId}
|
||||
customerId={enquiry.customerId}
|
||||
referenceData={referenceData}
|
||||
canCreate={canManageFollowups.create}
|
||||
canUpdate={canManageFollowups.update}
|
||||
canDelete={canManageFollowups.delete}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value='activity'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Activity</CardTitle>
|
||||
<CardDescription>
|
||||
Audit events for the enquiry and its follow-up records.
|
||||
</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>
|
||||
<Badge variant='secondary'>
|
||||
{item.entityType.replaceAll('_', ' ')}
|
||||
</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 Quotations</CardTitle>
|
||||
<CardDescription>
|
||||
Task D ends at enquiry readiness, so quotation production stays
|
||||
intentionally deferred.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
||||
Quotation linkage will land here in Task E.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className='space-y-6'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Record Snapshot</CardTitle>
|
||||
<CardDescription>Operational metadata for this enquiry row.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<FieldItem label='Created By' value={enquiry.createdBy} />
|
||||
<FieldItem label='Updated By' value={enquiry.updatedBy} />
|
||||
<FieldItem label='Created At' value={new Date(enquiry.createdAt).toLocaleString()} />
|
||||
<FieldItem label='Updated At' value={new Date(enquiry.updatedAt).toLocaleString()} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EnquiryFormSheet
|
||||
enquiry={enquiry}
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
referenceData={referenceData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
150
src/features/crm/enquiries/components/enquiry-followups-tab.tsx
Normal file
150
src/features/crm/enquiries/components/enquiry-followups-tab.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
'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 { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { deleteEnquiryFollowupMutation } from '../api/mutations';
|
||||
import { enquiryFollowupsOptions } from '../api/queries';
|
||||
import type { EnquiryReferenceData } from '../api/types';
|
||||
import { FollowupFormSheet } from './followup-form-sheet';
|
||||
|
||||
export function EnquiryFollowupsTab({
|
||||
enquiryId,
|
||||
customerId,
|
||||
referenceData,
|
||||
canCreate,
|
||||
canUpdate,
|
||||
canDelete
|
||||
}: {
|
||||
enquiryId: string;
|
||||
customerId: string;
|
||||
referenceData: EnquiryReferenceData;
|
||||
canCreate: boolean;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
}) {
|
||||
const { data } = useSuspenseQuery(enquiryFollowupsOptions(enquiryId));
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const selected = data.items.find((item) => item.id === selectedId);
|
||||
const followupTypeMap = new Map(referenceData.followupTypes.map((item) => [item.id, item.label]));
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
...deleteEnquiryFollowupMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Follow-up deleted successfully');
|
||||
setDeleteOpen(false);
|
||||
setSelectedId(null);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to delete follow-up')
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-start justify-between gap-4'>
|
||||
<div>
|
||||
<CardTitle>Follow-ups</CardTitle>
|
||||
<CardDescription>
|
||||
Track the latest contact moments, outcomes, and next steps for this enquiry.
|
||||
</CardDescription>
|
||||
</div>
|
||||
{canCreate ? (
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
setSelectedId(null);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
<Icons.add className='mr-2 h-4 w-4' /> Add Follow-up
|
||||
</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 follow-ups recorded yet.
|
||||
</div>
|
||||
) : (
|
||||
data.items.map((item) => (
|
||||
<div
|
||||
key={item.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-2'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<Badge variant='outline'>
|
||||
{followupTypeMap.get(item.followupType) ?? 'Unknown type'}
|
||||
</Badge>
|
||||
<span className='text-sm font-medium'>
|
||||
{new Date(item.followupDate).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
{item.outcome ? <p className='text-sm'>{item.outcome}</p> : null}
|
||||
{item.nextAction ? (
|
||||
<p className='text-muted-foreground text-sm'>Next action: {item.nextAction}</p>
|
||||
) : null}
|
||||
{item.nextFollowupDate ? (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Next follow-up: {new Date(item.nextFollowupDate).toLocaleDateString()}
|
||||
</p>
|
||||
) : null}
|
||||
{item.notes ? <p className='text-muted-foreground text-sm'>{item.notes}</p> : null}
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={!canUpdate}
|
||||
onClick={() => {
|
||||
setSelectedId(item.id);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
<Icons.edit className='mr-2 h-4 w-4' /> Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={!canDelete}
|
||||
onClick={() => {
|
||||
setSelectedId(item.id);
|
||||
setDeleteOpen(true);
|
||||
}}
|
||||
>
|
||||
<Icons.trash className='mr-2 h-4 w-4' /> Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<FollowupFormSheet
|
||||
enquiryId={enquiryId}
|
||||
followup={selected}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
referenceData={referenceData}
|
||||
customerId={customerId}
|
||||
/>
|
||||
<AlertModal
|
||||
isOpen={deleteOpen}
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
onConfirm={() => {
|
||||
if (selectedId) {
|
||||
deleteMutation.mutate({ enquiryId, followupId: selectedId });
|
||||
}
|
||||
}}
|
||||
loading={deleteMutation.isPending}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
377
src/features/crm/enquiries/components/enquiry-form-sheet.tsx
Normal file
377
src/features/crm/enquiries/components/enquiry-form-sheet.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle
|
||||
} from '@/components/ui/sheet';
|
||||
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
|
||||
import { createEnquiryMutation, updateEnquiryMutation } from '../api/mutations';
|
||||
import type { EnquiryMutationPayload, EnquiryRecord, EnquiryReferenceData } from '../api/types';
|
||||
import { enquirySchema, type EnquiryFormValues } from '../schemas/enquiry.schema';
|
||||
|
||||
function toDefaultValues(
|
||||
enquiry: EnquiryRecord | undefined,
|
||||
referenceData: EnquiryReferenceData
|
||||
): EnquiryFormValues {
|
||||
return {
|
||||
customerId: enquiry?.customerId ?? referenceData.customers[0]?.id ?? '',
|
||||
contactId: enquiry?.contactId ?? undefined,
|
||||
title: enquiry?.title ?? '',
|
||||
description: enquiry?.description ?? '',
|
||||
requirement: enquiry?.requirement ?? '',
|
||||
projectName: enquiry?.projectName ?? '',
|
||||
projectLocation: enquiry?.projectLocation ?? '',
|
||||
branchId: enquiry?.branchId ?? undefined,
|
||||
productType: enquiry?.productType ?? referenceData.productTypes[0]?.id ?? '',
|
||||
status: enquiry?.status ?? referenceData.statuses[0]?.id ?? '',
|
||||
priority:
|
||||
enquiry?.priority ?? referenceData.priorities[1]?.id ?? referenceData.priorities[0]?.id ?? '',
|
||||
leadChannel: enquiry?.leadChannel ?? undefined,
|
||||
estimatedValue: enquiry?.estimatedValue ? String(enquiry.estimatedValue) : '',
|
||||
chancePercent:
|
||||
enquiry?.chancePercent !== null && enquiry?.chancePercent !== undefined
|
||||
? String(enquiry.chancePercent)
|
||||
: '',
|
||||
expectedCloseDate: enquiry?.expectedCloseDate ? enquiry.expectedCloseDate.slice(0, 10) : '',
|
||||
competitor: enquiry?.competitor ?? '',
|
||||
source: enquiry?.source ?? '',
|
||||
isHotProject: enquiry?.isHotProject ?? false,
|
||||
notes: enquiry?.notes ?? '',
|
||||
isActive: enquiry?.isActive ?? true
|
||||
};
|
||||
}
|
||||
|
||||
export function EnquiryFormSheet({
|
||||
enquiry,
|
||||
open,
|
||||
onOpenChange,
|
||||
referenceData
|
||||
}: {
|
||||
enquiry?: EnquiryRecord;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
referenceData: EnquiryReferenceData;
|
||||
}) {
|
||||
const isEdit = !!enquiry;
|
||||
const defaultValues = useMemo(
|
||||
() => toDefaultValues(enquiry, referenceData),
|
||||
[enquiry, referenceData]
|
||||
);
|
||||
const [selectedCustomerId, setSelectedCustomerId] = useState(defaultValues.customerId);
|
||||
const { FormTextField, FormTextareaField, FormSelectField, FormSwitchField } =
|
||||
useFormFields<EnquiryFormValues>();
|
||||
|
||||
const createMutation = useMutation({
|
||||
...createEnquiryMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Enquiry created successfully');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to create enquiry')
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
...updateEnquiryMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Enquiry updated successfully');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to update enquiry')
|
||||
});
|
||||
|
||||
const contactsForCustomer = referenceData.contacts.filter(
|
||||
(contact) => contact.customerId === selectedCustomerId
|
||||
);
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues,
|
||||
validators: {
|
||||
onSubmit: enquirySchema
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
const payload: EnquiryMutationPayload = {
|
||||
customerId: value.customerId,
|
||||
contactId: value.contactId || null,
|
||||
title: value.title,
|
||||
description: value.description,
|
||||
requirement: value.requirement,
|
||||
projectName: value.projectName,
|
||||
projectLocation: value.projectLocation,
|
||||
branchId: value.branchId || null,
|
||||
productType: value.productType,
|
||||
status: value.status,
|
||||
priority: value.priority,
|
||||
leadChannel: value.leadChannel || null,
|
||||
estimatedValue: value.estimatedValue ? Number(value.estimatedValue) : null,
|
||||
chancePercent: value.chancePercent ? Number(value.chancePercent) : null,
|
||||
expectedCloseDate: value.expectedCloseDate || null,
|
||||
competitor: value.competitor,
|
||||
source: value.source,
|
||||
isHotProject: value.isHotProject ?? false,
|
||||
notes: value.notes,
|
||||
isActive: value.isActive ?? true
|
||||
};
|
||||
|
||||
if (isEdit && enquiry) {
|
||||
await updateMutation.mutateAsync({ id: enquiry.id, values: payload });
|
||||
return;
|
||||
}
|
||||
|
||||
await createMutation.mutateAsync(payload);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedCustomerId(defaultValues.customerId);
|
||||
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-4xl'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{isEdit ? 'Edit Enquiry' : 'New Enquiry'}</SheetTitle>
|
||||
<SheetDescription>
|
||||
Capture the opportunity details before quotation and approval stages begin.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className='flex-1 overflow-auto'>
|
||||
<form.AppForm>
|
||||
<form.Form id='enquiry-form-sheet' className='grid gap-4 md:grid-cols-2'>
|
||||
<form.AppField
|
||||
name='customerId'
|
||||
children={(field) => (
|
||||
<field.FieldSet>
|
||||
<field.Field>
|
||||
<field.FieldLabel>Customer *</field.FieldLabel>
|
||||
<Select
|
||||
value={field.state.value ?? ''}
|
||||
onValueChange={(value) => {
|
||||
field.handleChange(value);
|
||||
field.handleBlur();
|
||||
setSelectedCustomerId(value);
|
||||
form.setFieldValue('contactId', '');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger aria-label='Customer'>
|
||||
<SelectValue placeholder='Select customer' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{referenceData.customers.map((customer) => (
|
||||
<SelectItem key={customer.id} value={customer.id}>
|
||||
{customer.name} ({customer.code})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</field.Field>
|
||||
<field.FieldError />
|
||||
</field.FieldSet>
|
||||
)}
|
||||
/>
|
||||
|
||||
<form.AppField
|
||||
name='contactId'
|
||||
children={(field) => (
|
||||
<field.FieldSet>
|
||||
<field.Field>
|
||||
<field.FieldLabel>Contact</field.FieldLabel>
|
||||
<Select
|
||||
value={field.state.value ?? ''}
|
||||
onValueChange={(value) => {
|
||||
field.handleChange(value === '__none__' ? '' : value);
|
||||
field.handleBlur();
|
||||
}}
|
||||
>
|
||||
<SelectTrigger aria-label='Contact'>
|
||||
<SelectValue placeholder='Select contact' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{contactsForCustomer.length ? (
|
||||
contactsForCustomer.map((contact) => (
|
||||
<SelectItem key={contact.id} value={contact.id}>
|
||||
{contact.name}
|
||||
{contact.isPrimary ? ' (Primary)' : ''}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem value='__none__'>No contact selected</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</field.Field>
|
||||
<field.FieldError />
|
||||
</field.FieldSet>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormTextField
|
||||
name='title'
|
||||
label='Title'
|
||||
required
|
||||
placeholder='Warehouse crane upgrade opportunity'
|
||||
/>
|
||||
<FormTextField
|
||||
name='projectName'
|
||||
label='Project Name'
|
||||
placeholder='Bangna Warehouse Expansion'
|
||||
/>
|
||||
<FormTextField
|
||||
name='projectLocation'
|
||||
label='Project Location'
|
||||
placeholder='Bangkok'
|
||||
/>
|
||||
<FormTextField
|
||||
name='source'
|
||||
label='Source'
|
||||
placeholder='Existing customer referral'
|
||||
/>
|
||||
<FormSelectField
|
||||
name='branchId'
|
||||
label='Branch'
|
||||
options={referenceData.branches.map((branch) => ({
|
||||
value: branch.id,
|
||||
label: branch.name
|
||||
}))}
|
||||
/>
|
||||
<FormSelectField
|
||||
name='productType'
|
||||
label='Product Type'
|
||||
required
|
||||
options={referenceData.productTypes.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
}))}
|
||||
/>
|
||||
<FormSelectField
|
||||
name='status'
|
||||
label='Status'
|
||||
required
|
||||
options={referenceData.statuses.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
}))}
|
||||
/>
|
||||
<FormSelectField
|
||||
name='priority'
|
||||
label='Priority'
|
||||
required
|
||||
options={referenceData.priorities.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
}))}
|
||||
/>
|
||||
<FormSelectField
|
||||
name='leadChannel'
|
||||
label='Lead Channel'
|
||||
options={referenceData.leadChannels.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
}))}
|
||||
/>
|
||||
<FormTextField
|
||||
name='estimatedValue'
|
||||
label='Estimated Value'
|
||||
type='number'
|
||||
placeholder='2500000'
|
||||
/>
|
||||
<FormTextField name='chancePercent' label='Chance %' type='number' placeholder='60' />
|
||||
<FormTextField
|
||||
name='expectedCloseDate'
|
||||
label='Expected Close Date'
|
||||
placeholder='YYYY-MM-DD'
|
||||
/>
|
||||
<FormTextField name='competitor' label='Competitor' placeholder='Other vendor name' />
|
||||
<div className='md:col-span-2'>
|
||||
<FormTextareaField
|
||||
name='description'
|
||||
label='Description'
|
||||
placeholder='High-level summary of the opportunity'
|
||||
/>
|
||||
</div>
|
||||
<div className='md:col-span-2'>
|
||||
<FormTextareaField
|
||||
name='requirement'
|
||||
label='Requirement'
|
||||
placeholder='Specific technical or commercial requirements'
|
||||
/>
|
||||
</div>
|
||||
<div className='md:col-span-2'>
|
||||
<FormTextareaField
|
||||
name='notes'
|
||||
label='Notes'
|
||||
placeholder='Internal notes, blockers, or guidance for the sales team'
|
||||
/>
|
||||
</div>
|
||||
<div className='md:col-span-2 grid gap-4 md:grid-cols-2'>
|
||||
<FormSwitchField
|
||||
name='isHotProject'
|
||||
label='Hot Project'
|
||||
description='Use for urgent or highly strategic opportunities.'
|
||||
/>
|
||||
<FormSwitchField
|
||||
name='isActive'
|
||||
label='Active Record'
|
||||
description='Inactive enquiries stay in history but should not progress further.'
|
||||
/>
|
||||
</div>
|
||||
</form.Form>
|
||||
</form.AppForm>
|
||||
</div>
|
||||
|
||||
<SheetFooter>
|
||||
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' form='enquiry-form-sheet' isLoading={isPending}>
|
||||
<Icons.check className='mr-2 h-4 w-4' />
|
||||
{isEdit ? 'Update Enquiry' : 'Create Enquiry'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
export function EnquiryFormSheetTrigger({
|
||||
referenceData
|
||||
}: {
|
||||
referenceData: EnquiryReferenceData;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)}>
|
||||
<Icons.add className='mr-2 h-4 w-4' /> Add Enquiry
|
||||
</Button>
|
||||
<EnquiryFormSheet open={open} onOpenChange={setOpen} referenceData={referenceData} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
46
src/features/crm/enquiries/components/enquiry-listing.tsx
Normal file
46
src/features/crm/enquiries/components/enquiry-listing.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
||||
import { getQueryClient } from '@/lib/query-client';
|
||||
import { searchParamsCache } from '@/lib/searchparams';
|
||||
import type { EnquiryReferenceData } from '../api/types';
|
||||
import { enquiriesQueryOptions } from '../api/queries';
|
||||
import { EnquiriesTable } from './enquiries-table';
|
||||
|
||||
export default function EnquiryListing({
|
||||
referenceData,
|
||||
canUpdate,
|
||||
canDelete
|
||||
}: {
|
||||
referenceData: EnquiryReferenceData;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
}) {
|
||||
const page = searchParamsCache.get('page');
|
||||
const limit = searchParamsCache.get('perPage');
|
||||
const search = searchParamsCache.get('name');
|
||||
const status = searchParamsCache.get('status');
|
||||
const productType = searchParamsCache.get('productType');
|
||||
const priority = searchParamsCache.get('priority');
|
||||
const branch = searchParamsCache.get('branch');
|
||||
const customer = searchParamsCache.get('customer');
|
||||
const sort = searchParamsCache.get('sort');
|
||||
const filters = {
|
||||
page,
|
||||
limit,
|
||||
...(search && { search }),
|
||||
...(status && { status }),
|
||||
...(productType && { productType }),
|
||||
...(priority && { priority }),
|
||||
...(branch && { branch }),
|
||||
...(customer && { customer }),
|
||||
...(sort && { sort })
|
||||
};
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
void queryClient.prefetchQuery(enquiriesQueryOptions(filters));
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<EnquiriesTable referenceData={referenceData} canUpdate={canUpdate} canDelete={canDelete} />
|
||||
</HydrationBoundary>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Icons } from '@/components/icons';
|
||||
|
||||
export function EnquiryStatusBadge({ code, label }: { code?: string | null; label: string }) {
|
||||
const normalized = code?.toLowerCase() ?? '';
|
||||
const variant =
|
||||
normalized === 'closed_lost' || normalized === 'cancelled'
|
||||
? 'outline'
|
||||
: normalized === 'converted'
|
||||
? 'default'
|
||||
: normalized === 'follow_up'
|
||||
? 'secondary'
|
||||
: 'secondary';
|
||||
const Icon =
|
||||
normalized === 'converted'
|
||||
? Icons.circleCheck
|
||||
: normalized === 'closed_lost' || normalized === 'cancelled'
|
||||
? Icons.warning
|
||||
: normalized === 'follow_up'
|
||||
? Icons.clock
|
||||
: Icons.circle;
|
||||
|
||||
return (
|
||||
<Badge variant={variant} className='capitalize'>
|
||||
<Icon className='h-3 w-3' />
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
249
src/features/crm/enquiries/components/followup-form-sheet.tsx
Normal file
249
src/features/crm/enquiries/components/followup-form-sheet.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
'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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle
|
||||
} from '@/components/ui/sheet';
|
||||
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
|
||||
import { createEnquiryFollowupMutation, updateEnquiryFollowupMutation } from '../api/mutations';
|
||||
import type {
|
||||
EnquiryFollowupMutationPayload,
|
||||
EnquiryFollowupRecord,
|
||||
EnquiryReferenceData
|
||||
} from '../api/types';
|
||||
import { enquiryFollowupSchema, type EnquiryFollowupFormValues } from '../schemas/enquiry.schema';
|
||||
|
||||
function toDefaultValues(followup: EnquiryFollowupRecord | undefined): EnquiryFollowupFormValues {
|
||||
return {
|
||||
followupDate: followup?.followupDate ? followup.followupDate.slice(0, 10) : '',
|
||||
followupType: followup?.followupType ?? '',
|
||||
contactId: followup?.contactId ?? undefined,
|
||||
outcome: followup?.outcome ?? '',
|
||||
notes: followup?.notes ?? '',
|
||||
nextFollowupDate: followup?.nextFollowupDate ? followup.nextFollowupDate.slice(0, 10) : '',
|
||||
nextAction: followup?.nextAction ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
export function FollowupFormSheet({
|
||||
enquiryId,
|
||||
followup,
|
||||
open,
|
||||
onOpenChange,
|
||||
referenceData,
|
||||
customerId
|
||||
}: {
|
||||
enquiryId: string;
|
||||
followup?: EnquiryFollowupRecord;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
referenceData: EnquiryReferenceData;
|
||||
customerId: string;
|
||||
}) {
|
||||
const isEdit = !!followup;
|
||||
const defaultValues = useMemo(() => toDefaultValues(followup), [followup]);
|
||||
const { FormTextField, FormTextareaField } = useFormFields<EnquiryFollowupFormValues>();
|
||||
const contactOptions = referenceData.contacts.filter((item) => item.customerId === customerId);
|
||||
|
||||
const createMutation = useMutation({
|
||||
...createEnquiryFollowupMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Follow-up created successfully');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to create follow-up')
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
...updateEnquiryFollowupMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Follow-up updated successfully');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to update follow-up')
|
||||
});
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues,
|
||||
validators: {
|
||||
onSubmit: enquiryFollowupSchema
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
const payload: EnquiryFollowupMutationPayload = {
|
||||
followupDate: value.followupDate,
|
||||
followupType: value.followupType,
|
||||
contactId: value.contactId || null,
|
||||
outcome: value.outcome,
|
||||
notes: value.notes,
|
||||
nextFollowupDate: value.nextFollowupDate || null,
|
||||
nextAction: value.nextAction
|
||||
};
|
||||
|
||||
if (isEdit && followup) {
|
||||
await updateMutation.mutateAsync({
|
||||
enquiryId,
|
||||
followupId: followup.id,
|
||||
values: payload
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await createMutation.mutateAsync({
|
||||
enquiryId,
|
||||
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 Follow-up' : 'New Follow-up'}</SheetTitle>
|
||||
<SheetDescription>
|
||||
Keep the enquiry timeline current with the latest customer interaction and next step.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className='flex-1 overflow-auto'>
|
||||
<form.AppForm>
|
||||
<form.Form id='enquiry-followup-form' className='grid gap-4 md:grid-cols-2'>
|
||||
<FormTextField
|
||||
name='followupDate'
|
||||
label='Follow-up Date'
|
||||
required
|
||||
placeholder='YYYY-MM-DD'
|
||||
/>
|
||||
<form.AppField
|
||||
name='followupType'
|
||||
children={(field) => (
|
||||
<field.FieldSet>
|
||||
<field.Field>
|
||||
<field.FieldLabel>Follow-up Type *</field.FieldLabel>
|
||||
<Select
|
||||
value={field.state.value ?? ''}
|
||||
onValueChange={(value) => {
|
||||
field.handleChange(value === '__none__' ? '' : value);
|
||||
field.handleBlur();
|
||||
}}
|
||||
>
|
||||
<SelectTrigger aria-label='Follow-up type'>
|
||||
<SelectValue placeholder='Select follow-up type' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{referenceData.followupTypes.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</field.Field>
|
||||
<field.FieldError />
|
||||
</field.FieldSet>
|
||||
)}
|
||||
/>
|
||||
<form.AppField
|
||||
name='contactId'
|
||||
children={(field) => (
|
||||
<field.FieldSet>
|
||||
<field.Field>
|
||||
<field.FieldLabel>Contact</field.FieldLabel>
|
||||
<Select
|
||||
value={field.state.value ?? ''}
|
||||
onValueChange={(value) => {
|
||||
field.handleChange(value);
|
||||
field.handleBlur();
|
||||
}}
|
||||
>
|
||||
<SelectTrigger aria-label='Follow-up contact'>
|
||||
<SelectValue placeholder='Select contact' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{contactOptions.length ? (
|
||||
contactOptions.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem value='__none__'>No contact selected</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</field.Field>
|
||||
<field.FieldError />
|
||||
</field.FieldSet>
|
||||
)}
|
||||
/>
|
||||
<FormTextField
|
||||
name='nextFollowupDate'
|
||||
label='Next Follow-up Date'
|
||||
placeholder='YYYY-MM-DD'
|
||||
/>
|
||||
<div className='md:col-span-2'>
|
||||
<FormTextField
|
||||
name='nextAction'
|
||||
label='Next Action'
|
||||
placeholder='Prepare revised technical proposal'
|
||||
/>
|
||||
</div>
|
||||
<div className='md:col-span-2'>
|
||||
<FormTextareaField
|
||||
name='outcome'
|
||||
label='Outcome'
|
||||
placeholder='Customer asked for revised dimensions and a site survey'
|
||||
/>
|
||||
</div>
|
||||
<div className='md:col-span-2'>
|
||||
<FormTextareaField
|
||||
name='notes'
|
||||
label='Notes'
|
||||
placeholder='Internal notes from the follow-up session'
|
||||
/>
|
||||
</div>
|
||||
</form.Form>
|
||||
</form.AppForm>
|
||||
</div>
|
||||
|
||||
<SheetFooter>
|
||||
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' form='enquiry-followup-form' isLoading={isPending}>
|
||||
<Icons.check className='mr-2 h-4 w-4' />
|
||||
{isEdit ? 'Update Follow-up' : 'Create Follow-up'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
47
src/features/crm/enquiries/schemas/enquiry.schema.ts
Normal file
47
src/features/crm/enquiries/schemas/enquiry.schema.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
export const enquirySchema = z.object({
|
||||
customerId: z.string().min(1, 'Please select a customer'),
|
||||
contactId: z.string().optional().nullable(),
|
||||
title: z.string().min(2, 'Title must be at least 2 characters'),
|
||||
description: z.string().optional(),
|
||||
requirement: z.string().optional(),
|
||||
projectName: z.string().optional(),
|
||||
projectLocation: z.string().optional(),
|
||||
branchId: z.string().optional().nullable(),
|
||||
productType: z.string().min(1, 'Please select a product type'),
|
||||
status: z.string().min(1, 'Please select a status'),
|
||||
priority: z.string().min(1, 'Please select a priority'),
|
||||
leadChannel: z.string().optional().nullable(),
|
||||
estimatedValue: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((value) => !value || !Number.isNaN(Number(value)), 'Estimated value must be a number'),
|
||||
chancePercent: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((value) => !value || !Number.isNaN(Number(value)), 'Chance percent must be a number')
|
||||
.refine(
|
||||
(value) => !value || (Number(value) >= 0 && Number(value) <= 100),
|
||||
'Chance percent must be between 0 and 100'
|
||||
),
|
||||
expectedCloseDate: z.string().optional().nullable(),
|
||||
competitor: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
isHotProject: z.boolean().default(false),
|
||||
notes: z.string().optional(),
|
||||
isActive: z.boolean().default(true)
|
||||
});
|
||||
|
||||
export const enquiryFollowupSchema = z.object({
|
||||
followupDate: z.string().min(1, 'Follow-up date is required'),
|
||||
followupType: z.string().min(1, 'Please select a follow-up type'),
|
||||
contactId: z.string().optional().nullable(),
|
||||
outcome: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
nextFollowupDate: z.string().optional().nullable(),
|
||||
nextAction: z.string().optional()
|
||||
});
|
||||
|
||||
export type EnquiryFormValues = z.input<typeof enquirySchema>;
|
||||
export type EnquiryFollowupFormValues = z.input<typeof enquiryFollowupSchema>;
|
||||
734
src/features/crm/enquiries/server/service.ts
Normal file
734
src/features/crm/enquiries/server/service.ts
Normal file
@@ -0,0 +1,734 @@
|
||||
import { and, asc, count, desc, eq, ilike, inArray, isNull, or, type SQL } from 'drizzle-orm';
|
||||
import {
|
||||
crmCustomerContacts,
|
||||
crmCustomers,
|
||||
crmEnquiries,
|
||||
crmEnquiryFollowups,
|
||||
users
|
||||
} from '@/db/schema';
|
||||
import { db } from '@/lib/db';
|
||||
import { AuthError } from '@/lib/auth/session';
|
||||
import { listAuditLogs } from '@/features/foundation/audit-log/service';
|
||||
import { getUserBranches, validateBranchAccess } from '@/features/foundation/branch-scope/service';
|
||||
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
|
||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||
import type {
|
||||
EnquiryActivityRecord,
|
||||
EnquiryBranchOption,
|
||||
EnquiryContactLookup,
|
||||
EnquiryCustomerLookup,
|
||||
EnquiryCustomerRelationItem,
|
||||
EnquiryFilters,
|
||||
EnquiryFollowupMutationPayload,
|
||||
EnquiryFollowupRecord,
|
||||
EnquiryListItem,
|
||||
EnquiryMutationPayload,
|
||||
EnquiryOption,
|
||||
EnquiryRecord,
|
||||
EnquiryReferenceData
|
||||
} from '../api/types';
|
||||
|
||||
const ENQUIRY_OPTION_CATEGORIES = {
|
||||
status: 'crm_enquiry_status',
|
||||
productType: 'crm_product_type',
|
||||
priority: 'crm_priority',
|
||||
leadChannel: 'crm_lead_channel',
|
||||
followupType: 'crm_followup_type'
|
||||
} as const;
|
||||
|
||||
function mapOption(
|
||||
option: Awaited<ReturnType<typeof getActiveOptionsByCategory>>[number]
|
||||
): EnquiryOption {
|
||||
return {
|
||||
id: option.id,
|
||||
code: option.code,
|
||||
label: option.label,
|
||||
value: option.value
|
||||
};
|
||||
}
|
||||
|
||||
function mapBranch(
|
||||
branch: Awaited<ReturnType<typeof getUserBranches>>[number]
|
||||
): EnquiryBranchOption {
|
||||
return {
|
||||
id: branch.id,
|
||||
code: branch.code,
|
||||
name: branch.name,
|
||||
value: branch.value
|
||||
};
|
||||
}
|
||||
|
||||
function mapEnquiryRecord(row: typeof crmEnquiries.$inferSelect): EnquiryRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
organizationId: row.organizationId,
|
||||
branchId: row.branchId,
|
||||
code: row.code,
|
||||
customerId: row.customerId,
|
||||
contactId: row.contactId,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
requirement: row.requirement,
|
||||
projectName: row.projectName,
|
||||
projectLocation: row.projectLocation,
|
||||
productType: row.productType,
|
||||
status: row.status,
|
||||
priority: row.priority,
|
||||
leadChannel: row.leadChannel,
|
||||
estimatedValue: row.estimatedValue,
|
||||
chancePercent: row.chancePercent,
|
||||
expectedCloseDate: row.expectedCloseDate?.toISOString() ?? null,
|
||||
competitor: row.competitor,
|
||||
source: row.source,
|
||||
notes: row.notes,
|
||||
isHotProject: row.isHotProject,
|
||||
isActive: row.isActive,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
deletedAt: row.deletedAt?.toISOString() ?? null,
|
||||
createdBy: row.createdBy,
|
||||
updatedBy: row.updatedBy
|
||||
};
|
||||
}
|
||||
|
||||
function mapFollowupRecord(row: typeof crmEnquiryFollowups.$inferSelect): EnquiryFollowupRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
organizationId: row.organizationId,
|
||||
enquiryId: row.enquiryId,
|
||||
followupDate: row.followupDate.toISOString(),
|
||||
followupType: row.followupType,
|
||||
contactId: row.contactId,
|
||||
outcome: row.outcome,
|
||||
notes: row.notes,
|
||||
nextFollowupDate: row.nextFollowupDate?.toISOString() ?? null,
|
||||
nextAction: row.nextAction,
|
||||
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(crmEnquiries.updatedAt);
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(sort) as Array<{ id: string; desc?: boolean }>;
|
||||
const [rule] = parsed;
|
||||
|
||||
if (!rule) {
|
||||
return desc(crmEnquiries.updatedAt);
|
||||
}
|
||||
|
||||
const sortMap = {
|
||||
code: crmEnquiries.code,
|
||||
title: crmEnquiries.title,
|
||||
status: crmEnquiries.status,
|
||||
priority: crmEnquiries.priority,
|
||||
productType: crmEnquiries.productType,
|
||||
branch: crmEnquiries.branchId,
|
||||
customer: crmEnquiries.customerId,
|
||||
updatedAt: crmEnquiries.updatedAt
|
||||
} as const;
|
||||
|
||||
const column = sortMap[rule.id as keyof typeof sortMap];
|
||||
|
||||
if (!column) {
|
||||
return desc(crmEnquiries.updatedAt);
|
||||
}
|
||||
|
||||
return rule.desc ? desc(column) : asc(column);
|
||||
} catch {
|
||||
return desc(crmEnquiries.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);
|
||||
}
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export async function assertContactBelongsToOrganization(
|
||||
contactId: string,
|
||||
organizationId: string,
|
||||
customerId?: string | null
|
||||
) {
|
||||
const contact = await db.query.crmCustomerContacts.findFirst({
|
||||
where: and(
|
||||
eq(crmCustomerContacts.id, contactId),
|
||||
eq(crmCustomerContacts.organizationId, organizationId),
|
||||
isNull(crmCustomerContacts.deletedAt),
|
||||
...(customerId ? [eq(crmCustomerContacts.customerId, customerId)] : [])
|
||||
)
|
||||
});
|
||||
|
||||
if (!contact) {
|
||||
throw new AuthError('Contact not found', 404);
|
||||
}
|
||||
|
||||
return contact;
|
||||
}
|
||||
|
||||
async function assertEnquiryBelongsToOrganization(id: string, organizationId: string) {
|
||||
const enquiry = await db.query.crmEnquiries.findFirst({
|
||||
where: and(
|
||||
eq(crmEnquiries.id, id),
|
||||
eq(crmEnquiries.organizationId, organizationId),
|
||||
isNull(crmEnquiries.deletedAt)
|
||||
)
|
||||
});
|
||||
|
||||
if (!enquiry) {
|
||||
throw new AuthError('Enquiry not found', 404);
|
||||
}
|
||||
|
||||
return enquiry;
|
||||
}
|
||||
|
||||
async function assertFollowupBelongsToEnquiry(
|
||||
followupId: string,
|
||||
enquiryId: string,
|
||||
organizationId: string
|
||||
) {
|
||||
const followup = await db.query.crmEnquiryFollowups.findFirst({
|
||||
where: and(
|
||||
eq(crmEnquiryFollowups.id, followupId),
|
||||
eq(crmEnquiryFollowups.enquiryId, enquiryId),
|
||||
eq(crmEnquiryFollowups.organizationId, organizationId),
|
||||
isNull(crmEnquiryFollowups.deletedAt)
|
||||
)
|
||||
});
|
||||
|
||||
if (!followup) {
|
||||
throw new AuthError('Follow-up not found', 404);
|
||||
}
|
||||
|
||||
return followup;
|
||||
}
|
||||
|
||||
async function validateEnquiryPayload(organizationId: string, payload: EnquiryMutationPayload) {
|
||||
await assertCustomerBelongsToOrganization(payload.customerId, organizationId);
|
||||
|
||||
if (payload.contactId) {
|
||||
await assertContactBelongsToOrganization(payload.contactId, organizationId, payload.customerId);
|
||||
}
|
||||
|
||||
await assertMasterOptionValue(organizationId, ENQUIRY_OPTION_CATEGORIES.status, payload.status);
|
||||
await assertMasterOptionValue(
|
||||
organizationId,
|
||||
ENQUIRY_OPTION_CATEGORIES.productType,
|
||||
payload.productType
|
||||
);
|
||||
await assertMasterOptionValue(
|
||||
organizationId,
|
||||
ENQUIRY_OPTION_CATEGORIES.priority,
|
||||
payload.priority
|
||||
);
|
||||
await assertMasterOptionValue(
|
||||
organizationId,
|
||||
ENQUIRY_OPTION_CATEGORIES.leadChannel,
|
||||
payload.leadChannel ?? null
|
||||
);
|
||||
|
||||
if (payload.branchId) {
|
||||
await validateBranchAccess(payload.branchId);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateFollowupPayload(
|
||||
organizationId: string,
|
||||
enquiryId: string,
|
||||
payload: EnquiryFollowupMutationPayload
|
||||
) {
|
||||
const enquiry = await assertEnquiryBelongsToOrganization(enquiryId, organizationId);
|
||||
|
||||
await assertMasterOptionValue(
|
||||
organizationId,
|
||||
ENQUIRY_OPTION_CATEGORIES.followupType,
|
||||
payload.followupType
|
||||
);
|
||||
|
||||
if (payload.contactId) {
|
||||
await assertContactBelongsToOrganization(payload.contactId, organizationId, enquiry.customerId);
|
||||
}
|
||||
}
|
||||
|
||||
function buildEnquiryFilters(organizationId: string, filters: EnquiryFilters): SQL[] {
|
||||
const statuses = splitFilterValue(filters.status);
|
||||
const productTypes = splitFilterValue(filters.productType);
|
||||
const priorities = splitFilterValue(filters.priority);
|
||||
const branches = splitFilterValue(filters.branch);
|
||||
const customers = splitFilterValue(filters.customer);
|
||||
|
||||
return [
|
||||
eq(crmEnquiries.organizationId, organizationId),
|
||||
isNull(crmEnquiries.deletedAt),
|
||||
...(statuses.length ? [inArray(crmEnquiries.status, statuses)] : []),
|
||||
...(productTypes.length ? [inArray(crmEnquiries.productType, productTypes)] : []),
|
||||
...(priorities.length ? [inArray(crmEnquiries.priority, priorities)] : []),
|
||||
...(branches.length ? [inArray(crmEnquiries.branchId, branches)] : []),
|
||||
...(customers.length ? [inArray(crmEnquiries.customerId, customers)] : []),
|
||||
...(filters.search
|
||||
? [
|
||||
or(
|
||||
ilike(crmEnquiries.code, `%${filters.search}%`),
|
||||
ilike(crmEnquiries.title, `%${filters.search}%`),
|
||||
ilike(crmEnquiries.projectName, `%${filters.search}%`),
|
||||
ilike(crmEnquiries.competitor, `%${filters.search}%`)
|
||||
)!
|
||||
]
|
||||
: [])
|
||||
];
|
||||
}
|
||||
|
||||
export async function getEnquiryReferenceData(
|
||||
organizationId: string
|
||||
): Promise<EnquiryReferenceData> {
|
||||
const [
|
||||
branches,
|
||||
statuses,
|
||||
productTypes,
|
||||
priorities,
|
||||
leadChannels,
|
||||
followupTypes,
|
||||
customers,
|
||||
contacts
|
||||
] = await Promise.all([
|
||||
getUserBranches(),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.status, { organizationId }),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.productType, { organizationId }),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.priority, { organizationId }),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.leadChannel, { organizationId }),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.followupType, { organizationId }),
|
||||
db.query.crmCustomers.findMany({
|
||||
where: and(eq(crmCustomers.organizationId, organizationId), isNull(crmCustomers.deletedAt)),
|
||||
orderBy: [asc(crmCustomers.name)]
|
||||
}),
|
||||
db.query.crmCustomerContacts.findMany({
|
||||
where: and(
|
||||
eq(crmCustomerContacts.organizationId, organizationId),
|
||||
isNull(crmCustomerContacts.deletedAt)
|
||||
),
|
||||
orderBy: [desc(crmCustomerContacts.isPrimary), asc(crmCustomerContacts.name)]
|
||||
})
|
||||
]);
|
||||
|
||||
return {
|
||||
branches: branches.map(mapBranch),
|
||||
statuses: statuses.map(mapOption),
|
||||
productTypes: productTypes.map(mapOption),
|
||||
priorities: priorities.map(mapOption),
|
||||
leadChannels: leadChannels.map(mapOption),
|
||||
followupTypes: followupTypes.map(mapOption),
|
||||
customers: customers.map((customer) => ({
|
||||
id: customer.id,
|
||||
code: customer.code,
|
||||
name: customer.name,
|
||||
branchId: customer.branchId
|
||||
})),
|
||||
contacts: contacts.map((contact) => ({
|
||||
id: contact.id,
|
||||
customerId: contact.customerId,
|
||||
name: contact.name,
|
||||
email: contact.email,
|
||||
mobile: contact.mobile,
|
||||
isPrimary: contact.isPrimary
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
export async function listEnquiries(
|
||||
organizationId: string,
|
||||
filters: EnquiryFilters
|
||||
): Promise<{ items: EnquiryListItem[]; totalItems: number }> {
|
||||
const page = filters.page ?? 1;
|
||||
const limit = filters.limit ?? 10;
|
||||
const whereFilters = buildEnquiryFilters(organizationId, filters);
|
||||
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const [totalResult] = await db.select({ value: count() }).from(crmEnquiries).where(where);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(crmEnquiries)
|
||||
.where(where)
|
||||
.orderBy(parseSort(filters.sort))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
const customerIds = [...new Set(rows.map((row) => row.customerId))];
|
||||
const contactIds = [...new Set(rows.map((row) => row.contactId).filter(Boolean) as string[])];
|
||||
const enquiryIds = rows.map((row) => row.id);
|
||||
|
||||
const [customers, contacts, followupCounts] = await Promise.all([
|
||||
customerIds.length
|
||||
? db.query.crmCustomers.findMany({
|
||||
where: and(
|
||||
eq(crmCustomers.organizationId, organizationId),
|
||||
inArray(crmCustomers.id, customerIds)
|
||||
)
|
||||
})
|
||||
: [],
|
||||
contactIds.length
|
||||
? db.query.crmCustomerContacts.findMany({
|
||||
where: and(
|
||||
eq(crmCustomerContacts.organizationId, organizationId),
|
||||
inArray(crmCustomerContacts.id, contactIds)
|
||||
)
|
||||
})
|
||||
: [],
|
||||
enquiryIds.length
|
||||
? db
|
||||
.select({
|
||||
enquiryId: crmEnquiryFollowups.enquiryId,
|
||||
value: count()
|
||||
})
|
||||
.from(crmEnquiryFollowups)
|
||||
.where(
|
||||
and(
|
||||
eq(crmEnquiryFollowups.organizationId, organizationId),
|
||||
inArray(crmEnquiryFollowups.enquiryId, enquiryIds),
|
||||
isNull(crmEnquiryFollowups.deletedAt)
|
||||
)
|
||||
)
|
||||
.groupBy(crmEnquiryFollowups.enquiryId)
|
||||
: []
|
||||
]);
|
||||
|
||||
const customerMap = new Map(customers.map((customer) => [customer.id, customer.name]));
|
||||
const contactMap = new Map(contacts.map((contact) => [contact.id, contact.name]));
|
||||
const followupCountMap = new Map(followupCounts.map((item) => [item.enquiryId, item.value]));
|
||||
|
||||
return {
|
||||
items: rows.map((row) => ({
|
||||
...mapEnquiryRecord(row),
|
||||
customerName: customerMap.get(row.customerId) ?? 'Unknown customer',
|
||||
contactName: row.contactId ? (contactMap.get(row.contactId) ?? null) : null,
|
||||
followupCount: followupCountMap.get(row.id) ?? 0
|
||||
})),
|
||||
totalItems: totalResult?.value ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
export async function getEnquiryDetail(id: string, organizationId: string): Promise<EnquiryRecord> {
|
||||
const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId);
|
||||
return mapEnquiryRecord(enquiry);
|
||||
}
|
||||
|
||||
export async function getEnquiryActivity(
|
||||
id: string,
|
||||
organizationId: string
|
||||
): Promise<EnquiryActivityRecord[]> {
|
||||
const auditLogs = await listAuditLogs({
|
||||
organizationId,
|
||||
entityId: id,
|
||||
limit: 30
|
||||
});
|
||||
const filteredLogs = auditLogs.filter(
|
||||
(log) =>
|
||||
(log.entityType === 'crm_enquiry' && log.entityId === id) ||
|
||||
(log.entityType === 'crm_enquiry_followup' &&
|
||||
((log.afterData as { enquiryId?: string } | null)?.enquiryId === id ||
|
||||
(log.beforeData as { enquiryId?: string } | null)?.enquiryId === id))
|
||||
);
|
||||
const userIds = [...new Set(filteredLogs.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 filteredLogs.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 createEnquiry(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: EnquiryMutationPayload
|
||||
) {
|
||||
await validateEnquiryPayload(organizationId, payload);
|
||||
|
||||
const documentCode = await generateNextDocumentCode({
|
||||
organizationId,
|
||||
documentType: 'enquiry',
|
||||
branchId: payload.branchId ?? ''
|
||||
});
|
||||
|
||||
const [created] = await db
|
||||
.insert(crmEnquiries)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId,
|
||||
branchId: payload.branchId ?? null,
|
||||
code: documentCode.code,
|
||||
customerId: payload.customerId,
|
||||
contactId: payload.contactId ?? null,
|
||||
title: payload.title.trim(),
|
||||
description: payload.description?.trim() || null,
|
||||
requirement: payload.requirement?.trim() || null,
|
||||
projectName: payload.projectName?.trim() || null,
|
||||
projectLocation: payload.projectLocation?.trim() || null,
|
||||
productType: payload.productType,
|
||||
status: payload.status,
|
||||
priority: payload.priority,
|
||||
leadChannel: payload.leadChannel ?? null,
|
||||
estimatedValue: payload.estimatedValue ?? null,
|
||||
chancePercent: payload.chancePercent ?? null,
|
||||
expectedCloseDate: payload.expectedCloseDate ? new Date(payload.expectedCloseDate) : null,
|
||||
competitor: payload.competitor?.trim() || null,
|
||||
source: payload.source?.trim() || null,
|
||||
notes: payload.notes?.trim() || null,
|
||||
isHotProject: payload.isHotProject ?? false,
|
||||
isActive: payload.isActive ?? true,
|
||||
createdBy: userId,
|
||||
updatedBy: userId
|
||||
})
|
||||
.returning();
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function updateEnquiry(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: EnquiryMutationPayload
|
||||
) {
|
||||
await validateEnquiryPayload(organizationId, payload);
|
||||
await assertEnquiryBelongsToOrganization(id, organizationId);
|
||||
|
||||
const [updated] = await db
|
||||
.update(crmEnquiries)
|
||||
.set({
|
||||
branchId: payload.branchId ?? null,
|
||||
customerId: payload.customerId,
|
||||
contactId: payload.contactId ?? null,
|
||||
title: payload.title.trim(),
|
||||
description: payload.description?.trim() || null,
|
||||
requirement: payload.requirement?.trim() || null,
|
||||
projectName: payload.projectName?.trim() || null,
|
||||
projectLocation: payload.projectLocation?.trim() || null,
|
||||
productType: payload.productType,
|
||||
status: payload.status,
|
||||
priority: payload.priority,
|
||||
leadChannel: payload.leadChannel ?? null,
|
||||
estimatedValue: payload.estimatedValue ?? null,
|
||||
chancePercent: payload.chancePercent ?? null,
|
||||
expectedCloseDate: payload.expectedCloseDate ? new Date(payload.expectedCloseDate) : null,
|
||||
competitor: payload.competitor?.trim() || null,
|
||||
source: payload.source?.trim() || null,
|
||||
notes: payload.notes?.trim() || null,
|
||||
isHotProject: payload.isHotProject ?? false,
|
||||
isActive: payload.isActive ?? true,
|
||||
updatedBy: userId,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(crmEnquiries.id, id))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function softDeleteEnquiry(id: string, organizationId: string, userId: string) {
|
||||
await assertEnquiryBelongsToOrganization(id, organizationId);
|
||||
const now = new Date();
|
||||
|
||||
const [updated] = await db
|
||||
.update(crmEnquiries)
|
||||
.set({
|
||||
deletedAt: now,
|
||||
updatedAt: now,
|
||||
updatedBy: userId,
|
||||
isActive: false
|
||||
})
|
||||
.where(eq(crmEnquiries.id, id))
|
||||
.returning();
|
||||
|
||||
await db
|
||||
.update(crmEnquiryFollowups)
|
||||
.set({
|
||||
deletedAt: now,
|
||||
updatedAt: now,
|
||||
updatedBy: userId
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(crmEnquiryFollowups.enquiryId, id),
|
||||
eq(crmEnquiryFollowups.organizationId, organizationId)
|
||||
)
|
||||
);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function listEnquiryFollowups(id: string, organizationId: string) {
|
||||
await assertEnquiryBelongsToOrganization(id, organizationId);
|
||||
const rows = await db.query.crmEnquiryFollowups.findMany({
|
||||
where: and(
|
||||
eq(crmEnquiryFollowups.enquiryId, id),
|
||||
eq(crmEnquiryFollowups.organizationId, organizationId),
|
||||
isNull(crmEnquiryFollowups.deletedAt)
|
||||
),
|
||||
orderBy: [desc(crmEnquiryFollowups.followupDate)]
|
||||
});
|
||||
|
||||
return rows.map(mapFollowupRecord);
|
||||
}
|
||||
|
||||
export async function createEnquiryFollowup(
|
||||
enquiryId: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: EnquiryFollowupMutationPayload
|
||||
) {
|
||||
await validateFollowupPayload(organizationId, enquiryId, payload);
|
||||
|
||||
const [created] = await db
|
||||
.insert(crmEnquiryFollowups)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId,
|
||||
enquiryId,
|
||||
followupDate: new Date(payload.followupDate),
|
||||
followupType: payload.followupType,
|
||||
contactId: payload.contactId ?? null,
|
||||
outcome: payload.outcome?.trim() || null,
|
||||
notes: payload.notes?.trim() || null,
|
||||
nextFollowupDate: payload.nextFollowupDate ? new Date(payload.nextFollowupDate) : null,
|
||||
nextAction: payload.nextAction?.trim() || null,
|
||||
createdBy: userId,
|
||||
updatedBy: userId
|
||||
})
|
||||
.returning();
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function updateEnquiryFollowup(
|
||||
enquiryId: string,
|
||||
followupId: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: EnquiryFollowupMutationPayload
|
||||
) {
|
||||
await validateFollowupPayload(organizationId, enquiryId, payload);
|
||||
await assertFollowupBelongsToEnquiry(followupId, enquiryId, organizationId);
|
||||
|
||||
const [updated] = await db
|
||||
.update(crmEnquiryFollowups)
|
||||
.set({
|
||||
followupDate: new Date(payload.followupDate),
|
||||
followupType: payload.followupType,
|
||||
contactId: payload.contactId ?? null,
|
||||
outcome: payload.outcome?.trim() || null,
|
||||
notes: payload.notes?.trim() || null,
|
||||
nextFollowupDate: payload.nextFollowupDate ? new Date(payload.nextFollowupDate) : null,
|
||||
nextAction: payload.nextAction?.trim() || null,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: userId
|
||||
})
|
||||
.where(eq(crmEnquiryFollowups.id, followupId))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function softDeleteEnquiryFollowup(
|
||||
enquiryId: string,
|
||||
followupId: string,
|
||||
organizationId: string,
|
||||
userId: string
|
||||
) {
|
||||
await assertFollowupBelongsToEnquiry(followupId, enquiryId, organizationId);
|
||||
|
||||
const [updated] = await db
|
||||
.update(crmEnquiryFollowups)
|
||||
.set({
|
||||
deletedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
updatedBy: userId
|
||||
})
|
||||
.where(eq(crmEnquiryFollowups.id, followupId))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function listCustomerEnquiryRelations(
|
||||
customerId: string,
|
||||
organizationId: string
|
||||
): Promise<EnquiryCustomerRelationItem[]> {
|
||||
const rows = await db.query.crmEnquiries.findMany({
|
||||
where: and(
|
||||
eq(crmEnquiries.customerId, customerId),
|
||||
eq(crmEnquiries.organizationId, organizationId),
|
||||
isNull(crmEnquiries.deletedAt)
|
||||
),
|
||||
orderBy: [desc(crmEnquiries.updatedAt)],
|
||||
limit: 10
|
||||
});
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
title: row.title,
|
||||
status: row.status,
|
||||
productType: row.productType,
|
||||
updatedAt: row.updatedAt.toISOString()
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user