lead menu
nquiry menu
This commit is contained in:
@@ -16,11 +16,16 @@ import type {
|
||||
EnquiryFollowupMutationPayload,
|
||||
EnquiryMutationPayload
|
||||
} from './types';
|
||||
import { crmDashboardKeys } from '@/features/crm/dashboard/api/queries';
|
||||
|
||||
async function invalidateEnquiryLists() {
|
||||
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.lists() });
|
||||
}
|
||||
|
||||
async function invalidateDashboardQueries() {
|
||||
await getQueryClient().invalidateQueries({ queryKey: crmDashboardKeys.all });
|
||||
}
|
||||
|
||||
async function invalidateEnquiryDetail(id: string) {
|
||||
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.detail(id) });
|
||||
}
|
||||
@@ -37,7 +42,8 @@ export async function invalidateEnquiryMutationQueries(id: string) {
|
||||
await Promise.all([
|
||||
invalidateEnquiryLists(),
|
||||
invalidateEnquiryDetail(id),
|
||||
invalidateEnquiryProjectParties(id)
|
||||
invalidateEnquiryProjectParties(id),
|
||||
invalidateDashboardQueries()
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -45,7 +51,8 @@ export async function invalidateEnquiryFollowupMutationQueries(enquiryId: string
|
||||
await Promise.all([
|
||||
invalidateEnquiryLists(),
|
||||
invalidateEnquiryDetail(enquiryId),
|
||||
invalidateEnquiryFollowups(enquiryId)
|
||||
invalidateEnquiryFollowups(enquiryId),
|
||||
invalidateDashboardQueries()
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -53,7 +60,7 @@ export const createEnquiryMutation = mutationOptions({
|
||||
mutationFn: (data: EnquiryMutationPayload) => createEnquiry(data),
|
||||
onSettled: async (_data, error) => {
|
||||
if (!error) {
|
||||
await invalidateEnquiryLists();
|
||||
await Promise.all([invalidateEnquiryLists(), invalidateDashboardQueries()]);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -75,7 +82,7 @@ export const deleteEnquiryMutation = mutationOptions({
|
||||
getQueryClient().removeQueries({ queryKey: enquiryKeys.detail(id) });
|
||||
getQueryClient().removeQueries({ queryKey: enquiryKeys.projectParties(id) });
|
||||
getQueryClient().removeQueries({ queryKey: enquiryKeys.followups(id) });
|
||||
await invalidateEnquiryLists();
|
||||
await Promise.all([invalidateEnquiryLists(), invalidateDashboardQueries()]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ export async function getEnquiries(filters: EnquiryFilters): Promise<EnquiryList
|
||||
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.pipelineStage) searchParams.set('pipelineStage', filters.pipelineStage);
|
||||
if (filters.status) searchParams.set('status', filters.status);
|
||||
if (filters.productType) searchParams.set('productType', filters.productType);
|
||||
if (filters.priority) searchParams.set('priority', filters.priority);
|
||||
|
||||
@@ -54,6 +54,8 @@ export interface EnquiryAssignableUserLookup {
|
||||
businessRole: string;
|
||||
}
|
||||
|
||||
export type EnquiryPipelineStage = 'lead' | 'enquiry' | 'closed_won' | 'closed_lost';
|
||||
|
||||
export interface EnquiryRecord {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
@@ -78,6 +80,7 @@ export interface EnquiryRecord {
|
||||
notes: string | null;
|
||||
isHotProject: boolean;
|
||||
isActive: boolean;
|
||||
pipelineStage: EnquiryPipelineStage;
|
||||
assignedToUserId: string | null;
|
||||
assignedToName: string | null;
|
||||
assignedAt: string | null;
|
||||
@@ -95,6 +98,9 @@ export interface EnquiryListItem extends EnquiryRecord {
|
||||
customerName: string;
|
||||
contactName: string | null;
|
||||
followupCount: number;
|
||||
quotationCount: number;
|
||||
nextFollowupDate: string | null;
|
||||
createdByName: string | null;
|
||||
}
|
||||
|
||||
export interface EnquiryFollowupRecord {
|
||||
@@ -142,6 +148,7 @@ export interface EnquiryFilters {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
pipelineStage?: EnquiryPipelineStage;
|
||||
status?: string;
|
||||
productType?: string;
|
||||
priority?: string;
|
||||
|
||||
@@ -13,20 +13,22 @@ import { getEnquiryColumns } from './enquiry-columns';
|
||||
|
||||
export function EnquiriesTable({
|
||||
referenceData,
|
||||
workspace,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canAssign,
|
||||
canReassign
|
||||
}: {
|
||||
referenceData: EnquiryReferenceData;
|
||||
workspace: 'lead' | 'enquiry';
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
canAssign: boolean;
|
||||
canReassign: boolean;
|
||||
}) {
|
||||
const columns = useMemo(
|
||||
() => getEnquiryColumns({ referenceData, canUpdate, canDelete, canAssign, canReassign }),
|
||||
[referenceData, canUpdate, canDelete, canAssign, canReassign]
|
||||
() => getEnquiryColumns({ referenceData, workspace, canUpdate, canDelete, canAssign, canReassign }),
|
||||
[referenceData, workspace, canUpdate, canDelete, canAssign, canReassign]
|
||||
);
|
||||
const columnIds = columns.map((column) => column.id).filter(Boolean) as string[];
|
||||
const [params] = useQueryStates({
|
||||
@@ -44,6 +46,7 @@ export function EnquiriesTable({
|
||||
const filters = {
|
||||
page: params.page,
|
||||
limit: params.perPage,
|
||||
pipelineStage: workspace,
|
||||
...(params.name && { search: params.name }),
|
||||
...(params.status && { status: params.status }),
|
||||
...(params.productType && { productType: params.productType }),
|
||||
|
||||
@@ -57,7 +57,7 @@ export function EnquiryAssignmentDialog({
|
||||
const assignMutation = useMutation({
|
||||
...assignEnquiryMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Enquiry assigned successfully');
|
||||
toast.success('Lead converted to enquiry successfully');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
@@ -109,10 +109,10 @@ export function EnquiryAssignmentDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{mode === 'assign' ? 'Assign Sales' : 'Reassign Sales'}</DialogTitle>
|
||||
<DialogTitle>{mode === 'assign' ? 'Convert To Enquiry' : 'Reassign Enquiry Owner'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{mode === 'assign'
|
||||
? 'Select the sales owner who should take this enquiry forward.'
|
||||
? 'Assign this lead to sales and convert it into an enquiry.'
|
||||
: 'Move this enquiry to a different sales owner and keep the handoff note visible.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -127,7 +127,7 @@ export function EnquiryAssignmentDialog({
|
||||
<>
|
||||
<FormSelectField
|
||||
name='assignedToUserId'
|
||||
label='Sales User'
|
||||
label='Enquiry Owner'
|
||||
required
|
||||
options={referenceData.assignableUsers.map((user) => ({
|
||||
value: user.id,
|
||||
@@ -156,7 +156,7 @@ export function EnquiryAssignmentDialog({
|
||||
disabled={noUsersAvailable}
|
||||
>
|
||||
<Icons.userPen className='mr-2 h-4 w-4' />
|
||||
{mode === 'assign' ? 'Assign Sales' : 'Reassign Sales'}
|
||||
{mode === 'assign' ? 'Convert To Enquiry' : 'Reassign Owner'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { EnquiryFormSheet } from './enquiry-form-sheet';
|
||||
export function EnquiryCellAction({
|
||||
data,
|
||||
referenceData,
|
||||
workspace,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canAssign: _canAssign,
|
||||
@@ -28,6 +29,7 @@ export function EnquiryCellAction({
|
||||
}: {
|
||||
data: EnquiryListItem;
|
||||
referenceData: EnquiryReferenceData;
|
||||
workspace: 'lead' | 'enquiry';
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
canAssign: boolean;
|
||||
@@ -40,11 +42,15 @@ export function EnquiryCellAction({
|
||||
const deleteMutation = useMutation({
|
||||
...deleteEnquiryMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Enquiry deleted successfully');
|
||||
toast.success(`${workspace === 'lead' ? 'Lead' : 'Enquiry'} deleted successfully`);
|
||||
setDeleteOpen(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to delete enquiry')
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: `Failed to delete ${workspace === 'lead' ? 'lead' : 'enquiry'}`
|
||||
)
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -60,6 +66,7 @@ export function EnquiryCellAction({
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
referenceData={referenceData}
|
||||
workspace={workspace}
|
||||
/>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -70,11 +77,19 @@ export function EnquiryCellAction({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => router.push(`/dashboard/crm/enquiries/${data.id}`)}>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
router.push(
|
||||
workspace === 'lead'
|
||||
? `/dashboard/crm/leads/${data.id}`
|
||||
: `/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
|
||||
<Icons.edit className='mr-2 h-4 w-4' /> {workspace === 'lead' ? 'Edit Lead' : 'Edit Enquiry'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setDeleteOpen(true)} disabled={!canDelete}>
|
||||
<Icons.trash className='mr-2 h-4 w-4' /> Delete
|
||||
|
||||
@@ -9,52 +9,116 @@ import type { EnquiryListItem, EnquiryReferenceData } from '../api/types';
|
||||
import { EnquiryCellAction } from './enquiry-cell-action';
|
||||
import { EnquiryStatusBadge } from './enquiry-status-badge';
|
||||
|
||||
export function getEnquiryColumns({
|
||||
function getLeadColumns({
|
||||
referenceData,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canAssign,
|
||||
canReassign
|
||||
}: {
|
||||
referenceData: EnquiryReferenceData;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
canAssign: boolean;
|
||||
canReassign: boolean;
|
||||
}): ColumnDef<EnquiryListItem>[] {
|
||||
}: SharedColumnsConfig): 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]));
|
||||
const leadChannelMap = new Map(referenceData.leadChannels.map((item) => [item.id, item.label]));
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'title',
|
||||
accessorKey: 'title',
|
||||
id: 'code',
|
||||
accessorKey: 'code',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Enquiry' />
|
||||
<DataTableColumnHeader column={column} title='Code' />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className='flex flex-col'>
|
||||
<Link
|
||||
href={`/dashboard/crm/enquiries/${row.original.id}`}
|
||||
href={`/dashboard/crm/leads/${row.original.id}`}
|
||||
className='font-medium hover:underline'
|
||||
>
|
||||
{row.original.title}
|
||||
{row.original.code}
|
||||
</Link>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{row.original.code} • {row.original.customerName}
|
||||
</span>
|
||||
<span className='text-muted-foreground text-xs'>{row.original.title}</span>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
label: 'Enquiry',
|
||||
placeholder: 'Search enquiries...',
|
||||
label: 'Code',
|
||||
placeholder: 'Search leads...',
|
||||
variant: 'text' as const,
|
||||
icon: Icons.search
|
||||
},
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'leadChannel',
|
||||
accessorFn: (row) => row.leadChannel ?? '',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Lead Source' />
|
||||
),
|
||||
cell: ({ row }) => <span>{leadChannelMap.get(row.original.leadChannel ?? '') ?? '-'}</span>
|
||||
},
|
||||
{
|
||||
id: 'customer',
|
||||
accessorFn: (row) => row.customerId,
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Company' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.customerName}</span>,
|
||||
meta: {
|
||||
label: 'Company',
|
||||
variant: 'multiSelect' as const,
|
||||
options: referenceData.customers.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.name
|
||||
}))
|
||||
},
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'contact',
|
||||
accessorFn: (row) => row.contactId ?? '',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Contact' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.contactName ?? '-'}</span>
|
||||
},
|
||||
{
|
||||
id: 'assignedSales',
|
||||
accessorFn: (row) => row.assignedToName ?? '',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Assigned Sales' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.assignedToName ?? 'Unassigned'}</span>
|
||||
},
|
||||
{
|
||||
id: 'createdBy',
|
||||
accessorFn: (row) => row.createdByName ?? row.createdBy,
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Created By' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.createdByName ?? row.original.createdBy}</span>
|
||||
},
|
||||
{
|
||||
id: 'createdAt',
|
||||
accessorKey: 'createdAt',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Created At' />
|
||||
),
|
||||
cell: ({ row }) => <span>{new Date(row.original.createdAt).toLocaleDateString()}</span>
|
||||
},
|
||||
{
|
||||
id: 'age',
|
||||
accessorFn: (row) => row.createdAt,
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Age' />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const ageInDays = Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
(Date.now() - new Date(row.original.createdAt).getTime()) / (1000 * 60 * 60 * 24)
|
||||
)
|
||||
);
|
||||
|
||||
return <span>{ageInDays} days</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
accessorKey: 'status',
|
||||
@@ -76,79 +140,74 @@ export function getEnquiryColumns({
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'productType',
|
||||
accessorKey: 'productType',
|
||||
id: 'actions',
|
||||
cell: ({ row }) => (
|
||||
<EnquiryCellAction
|
||||
data={row.original}
|
||||
referenceData={referenceData}
|
||||
workspace='lead'
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
canAssign={canAssign}
|
||||
canReassign={canReassign}
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function getEnquiryWorkspaceColumns({
|
||||
referenceData,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canAssign,
|
||||
canReassign
|
||||
}: SharedColumnsConfig): ColumnDef<EnquiryListItem>[] {
|
||||
const statusMap = new Map(referenceData.statuses.map((item) => [item.id, item]));
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'code',
|
||||
accessorKey: 'code',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Product Type' />
|
||||
<DataTableColumnHeader column={column} title='Code' />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Badge variant='outline'>
|
||||
{productTypeMap.get(row.original.productType)?.label ?? 'Unknown'}
|
||||
</Badge>
|
||||
<div className='flex flex-col'>
|
||||
<Link
|
||||
href={`/dashboard/crm/enquiries/${row.original.id}`}
|
||||
className='font-medium hover:underline'
|
||||
>
|
||||
{row.original.code}
|
||||
</Link>
|
||||
<span className='text-muted-foreground text-xs'>{row.original.title}</span>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
label: 'Product Type',
|
||||
variant: 'multiSelect' as const,
|
||||
options: referenceData.productTypes.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
}))
|
||||
label: 'Code',
|
||||
placeholder: 'Search enquiries...',
|
||||
variant: 'text' as const,
|
||||
icon: Icons.search
|
||||
},
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'priority',
|
||||
accessorKey: 'priority',
|
||||
id: 'projectName',
|
||||
accessorFn: (row) => row.projectName ?? row.title,
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Priority' />
|
||||
<DataTableColumnHeader column={column} title='Project' />
|
||||
),
|
||||
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
|
||||
cell: ({ row }) => <span>{row.original.projectName ?? row.original.title}</span>
|
||||
},
|
||||
{
|
||||
id: 'customer',
|
||||
accessorFn: (row) => row.customerId,
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Customer' />
|
||||
<DataTableColumnHeader column={column} title='Billing Customer' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.customerName}</span>,
|
||||
meta: {
|
||||
label: 'Customer',
|
||||
label: 'Billing Customer',
|
||||
variant: 'multiSelect' as const,
|
||||
options: referenceData.customers.map((item) => ({
|
||||
value: item.id,
|
||||
@@ -159,25 +218,77 @@ export function getEnquiryColumns({
|
||||
},
|
||||
{
|
||||
id: 'assignedSales',
|
||||
header: 'Assigned Sales',
|
||||
accessorFn: (row) => row.assignedToName ?? '',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Enquiry Owner' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.assignedToName ?? '-'}</span>
|
||||
},
|
||||
{
|
||||
id: 'chancePercent',
|
||||
accessorKey: 'chancePercent',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Chance %' />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className='flex flex-col'>
|
||||
<span>{row.original.assignedToName ?? 'Unassigned'}</span>
|
||||
{row.original.assignedAt ? (
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{new Date(row.original.assignedAt).toLocaleDateString()}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<span>{row.original.chancePercent !== null ? `${row.original.chancePercent}%` : '-'}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'followupCount',
|
||||
accessorKey: 'followupCount',
|
||||
id: 'estimatedValue',
|
||||
accessorKey: 'estimatedValue',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Follow-ups' />
|
||||
<DataTableColumnHeader column={column} title='Estimated Value' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.followupCount}</span>
|
||||
cell: ({ row }) => (
|
||||
<span>
|
||||
{row.original.estimatedValue !== null
|
||||
? row.original.estimatedValue.toLocaleString()
|
||||
: '-'}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'quotationCount',
|
||||
accessorKey: 'quotationCount',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Quotation Count' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.quotationCount}</span>
|
||||
},
|
||||
{
|
||||
id: 'nextFollowupDate',
|
||||
accessorKey: 'nextFollowupDate',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Next Follow-up' />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span>
|
||||
{row.original.nextFollowupDate
|
||||
? new Date(row.original.nextFollowupDate).toLocaleDateString()
|
||||
: '-'}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
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: 'actions',
|
||||
@@ -185,6 +296,7 @@ export function getEnquiryColumns({
|
||||
<EnquiryCellAction
|
||||
data={row.original}
|
||||
referenceData={referenceData}
|
||||
workspace='enquiry'
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
canAssign={canAssign}
|
||||
@@ -194,3 +306,34 @@ export function getEnquiryColumns({
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
type SharedColumnsConfig = {
|
||||
referenceData: EnquiryReferenceData;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
canAssign: boolean;
|
||||
canReassign: boolean;
|
||||
};
|
||||
|
||||
export function getEnquiryColumns({
|
||||
referenceData,
|
||||
workspace,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canAssign,
|
||||
canReassign
|
||||
}: SharedColumnsConfig & {
|
||||
workspace: 'lead' | 'enquiry';
|
||||
}): ColumnDef<EnquiryListItem>[] {
|
||||
const sharedConfig = {
|
||||
referenceData,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canAssign,
|
||||
canReassign
|
||||
};
|
||||
|
||||
return workspace === 'lead'
|
||||
? getLeadColumns(sharedConfig)
|
||||
: getEnquiryWorkspaceColumns(sharedConfig);
|
||||
}
|
||||
|
||||
@@ -9,12 +9,12 @@ 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, enquiryProjectPartiesOptions } from '../api/queries';
|
||||
import type { EnquiryReferenceData } from '../api/types';
|
||||
import type { QuotationRelationItem } from '@/features/crm/quotations/api/types';
|
||||
import { enquiryByIdOptions, enquiryProjectPartiesOptions } from '../api/queries';
|
||||
import type { EnquiryRecord, EnquiryReferenceData } from '../api/types';
|
||||
import { EnquiryAssignmentDialog } from './enquiry-assignment-dialog';
|
||||
import { EnquiryFormSheet } from './enquiry-form-sheet';
|
||||
import { EnquiryFollowupsTab } from './enquiry-followups-tab';
|
||||
import { EnquiryFormSheet } from './enquiry-form-sheet';
|
||||
import { EnquiryStatusBadge } from './enquiry-status-badge';
|
||||
|
||||
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
|
||||
@@ -26,27 +26,48 @@ function FieldItem({ label, value }: { label: string; value: string | null | und
|
||||
);
|
||||
}
|
||||
|
||||
export function EnquiryDetail({
|
||||
enquiryId,
|
||||
referenceData,
|
||||
relatedQuotations,
|
||||
canUpdate,
|
||||
canAssign,
|
||||
canReassign,
|
||||
canManageFollowups
|
||||
}: {
|
||||
function getPipelineStageLabel(stage: EnquiryRecord['pipelineStage']) {
|
||||
switch (stage) {
|
||||
case 'lead':
|
||||
return 'Lead';
|
||||
case 'enquiry':
|
||||
return 'Enquiry';
|
||||
case 'closed_won':
|
||||
return 'Closed Won';
|
||||
case 'closed_lost':
|
||||
return 'Closed Lost';
|
||||
default:
|
||||
return stage;
|
||||
}
|
||||
}
|
||||
|
||||
interface EnquiryDetailProps {
|
||||
enquiryId: string;
|
||||
referenceData: EnquiryReferenceData;
|
||||
relatedQuotations: QuotationRelationItem[];
|
||||
workspace: 'lead' | 'enquiry';
|
||||
canUpdate: boolean;
|
||||
canAssign: boolean;
|
||||
canReassign: boolean;
|
||||
canViewRelatedQuotations: boolean;
|
||||
canManageFollowups: {
|
||||
create: boolean;
|
||||
update: boolean;
|
||||
delete: boolean;
|
||||
};
|
||||
}) {
|
||||
}
|
||||
|
||||
export function EnquiryDetail({
|
||||
enquiryId,
|
||||
referenceData,
|
||||
relatedQuotations,
|
||||
workspace,
|
||||
canUpdate,
|
||||
canAssign,
|
||||
canReassign,
|
||||
canViewRelatedQuotations,
|
||||
canManageFollowups
|
||||
}: EnquiryDetailProps) {
|
||||
const { data } = useSuspenseQuery(enquiryByIdOptions(enquiryId));
|
||||
const { data: projectPartiesData } = useSuspenseQuery(enquiryProjectPartiesOptions(enquiryId));
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
@@ -85,6 +106,29 @@ export function EnquiryDetail({
|
||||
const status = statusMap.get(enquiry.status);
|
||||
const canManageAssignment = enquiry.assignedToUserId ? canReassign : canAssign;
|
||||
const assignmentMode = enquiry.assignedToUserId ? 'reassign' : 'assign';
|
||||
const pipelineStageLabel = getPipelineStageLabel(enquiry.pipelineStage);
|
||||
const isLeadWorkspace = workspace === 'lead';
|
||||
const backHref = isLeadWorkspace ? '/dashboard/crm/leads' : '/dashboard/crm/enquiries';
|
||||
const recordLabel = isLeadWorkspace ? 'Lead' : 'Enquiry';
|
||||
const ownerLabel = isLeadWorkspace ? 'Assigned Sales' : 'Enquiry Owner';
|
||||
const infoCardTitle = isLeadWorkspace ? 'Lead Information' : 'Enquiry Information';
|
||||
const infoCardDescription = isLeadWorkspace
|
||||
? 'Lead source, qualification context, and sales handoff readiness.'
|
||||
: 'Project scope, ownership context, and active sales execution details.';
|
||||
const assignmentButtonLabel =
|
||||
assignmentMode === 'assign'
|
||||
? isLeadWorkspace
|
||||
? 'Assign To Sales'
|
||||
: 'Assign Enquiry Owner'
|
||||
: isLeadWorkspace
|
||||
? 'Reassign Sales Owner'
|
||||
: 'Reassign Enquiry Owner';
|
||||
const projectSectionLabel = isLeadWorkspace ? 'Lead Scope' : 'Project Information';
|
||||
const followupTabLabel = isLeadWorkspace ? 'Lead Follow-ups' : 'Follow-ups';
|
||||
const relatedTabLabel = isLeadWorkspace ? 'Related Quotations' : 'Quotations';
|
||||
const snapshotDescription = isLeadWorkspace
|
||||
? 'Operational metadata and lead ownership handoff state for this record.'
|
||||
: 'Operational metadata and assignment state for this enquiry record.';
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
@@ -92,7 +136,7 @@ export function EnquiryDetail({
|
||||
<div className='space-y-3'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button asChild variant='ghost' size='icon'>
|
||||
<Link href='/dashboard/crm/enquiries'>
|
||||
<Link href={backHref}>
|
||||
<Icons.chevronLeft className='h-4 w-4' />
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -103,8 +147,8 @@ export function EnquiryDetail({
|
||||
<div>
|
||||
<h2 className='text-2xl font-semibold'>{enquiry.title}</h2>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{customer?.name ?? 'Unknown customer'}
|
||||
{contact ? ` • ${contact.name}` : ''}
|
||||
{pipelineStageLabel} | {customer?.name ?? 'Unknown customer'}
|
||||
{contact ? ` | ${contact.name}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -112,12 +156,13 @@ export function EnquiryDetail({
|
||||
{canManageAssignment ? (
|
||||
<Button variant='outline' onClick={() => setAssignmentOpen(true)}>
|
||||
<Icons.userPen className='mr-2 h-4 w-4' />
|
||||
{assignmentMode === 'assign' ? 'Assign Sales' : 'Reassign Sales'}
|
||||
{assignmentButtonLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
{canUpdate ? (
|
||||
<Button variant='outline' onClick={() => setEditOpen(true)}>
|
||||
<Icons.edit className='mr-2 h-4 w-4' /> Edit Enquiry
|
||||
<Icons.edit className='mr-2 h-4 w-4' />
|
||||
{`Edit ${recordLabel}`}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -130,20 +175,23 @@ export function EnquiryDetail({
|
||||
<Tabs defaultValue='overview' className='gap-4'>
|
||||
<TabsList>
|
||||
<TabsTrigger value='overview'>Overview</TabsTrigger>
|
||||
<TabsTrigger value='followups'>Follow-ups</TabsTrigger>
|
||||
<TabsTrigger value='followups'>{followupTabLabel}</TabsTrigger>
|
||||
<TabsTrigger value='activity'>Activity</TabsTrigger>
|
||||
<TabsTrigger value='related'>Related Quotations</TabsTrigger>
|
||||
{canViewRelatedQuotations ? (
|
||||
<TabsTrigger value='related'>{relatedTabLabel}</TabsTrigger>
|
||||
) : null}
|
||||
</TabsList>
|
||||
<TabsContent value='overview'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Overview</CardTitle>
|
||||
<CardDescription>
|
||||
Opportunity scope, project context, and commercial direction.
|
||||
</CardDescription>
|
||||
<CardTitle>{infoCardTitle}</CardTitle>
|
||||
<CardDescription>{infoCardDescription}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||
<FieldItem label='Billing Customer' value={customer?.name} />
|
||||
<FieldItem
|
||||
label={isLeadWorkspace ? 'Company' : 'Billing Customer'}
|
||||
value={customer?.name}
|
||||
/>
|
||||
<FieldItem label='Contact' value={contact?.name} />
|
||||
<FieldItem
|
||||
label='Branch'
|
||||
@@ -159,7 +207,7 @@ export function EnquiryDetail({
|
||||
value={priorityMap.get(enquiry.priority)?.label}
|
||||
/>
|
||||
<FieldItem
|
||||
label='Lead Channel'
|
||||
label='Lead Source'
|
||||
value={leadChannelMap.get(enquiry.leadChannel ?? '')?.label}
|
||||
/>
|
||||
<FieldItem
|
||||
@@ -184,7 +232,8 @@ export function EnquiryDetail({
|
||||
/>
|
||||
<FieldItem label='Competitor' value={enquiry.competitor} />
|
||||
<FieldItem label='Source' value={enquiry.source} />
|
||||
<FieldItem label='Assigned Sales' value={enquiry.assignedToName} />
|
||||
<FieldItem label='Pipeline Stage' value={pipelineStageLabel} />
|
||||
<FieldItem label={ownerLabel} value={enquiry.assignedToName} />
|
||||
<FieldItem
|
||||
label='Assigned At'
|
||||
value={
|
||||
@@ -195,10 +244,14 @@ export function EnquiryDetail({
|
||||
<FieldItem label='Assignment Remark' value={enquiry.assignmentRemark} />
|
||||
<div className='md:col-span-2 xl:col-span-4'>
|
||||
<div className='space-y-3'>
|
||||
<div className='text-muted-foreground text-xs'>Project Parties</div>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{isLeadWorkspace ? 'Lead Parties' : 'Project Parties'}
|
||||
</div>
|
||||
{!projectPartiesData.items.length ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'>
|
||||
No project parties have been added yet.
|
||||
{isLeadWorkspace
|
||||
? 'No lead parties have been added yet.'
|
||||
: 'No project parties have been added yet.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-3'>
|
||||
@@ -217,11 +270,10 @@ export function EnquiryDetail({
|
||||
</div>
|
||||
<div className='md:col-span-2 xl:col-span-4'>
|
||||
<FieldItem
|
||||
label='Project'
|
||||
label={projectSectionLabel}
|
||||
value={
|
||||
[enquiry.projectName, enquiry.projectLocation]
|
||||
.filter(Boolean)
|
||||
.join(' • ') || null
|
||||
[enquiry.projectName, enquiry.projectLocation].filter(Boolean).join(' | ') ||
|
||||
null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -250,9 +302,9 @@ export function EnquiryDetail({
|
||||
<TabsContent value='activity'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Activity</CardTitle>
|
||||
<CardTitle>{isLeadWorkspace ? 'Lead Activity' : 'Activity'}</CardTitle>
|
||||
<CardDescription>
|
||||
Audit events for the enquiry and its follow-up records.
|
||||
Audit events for this record and its follow-up history.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
@@ -289,43 +341,45 @@ export function EnquiryDetail({
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value='related'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Related Quotations</CardTitle>
|
||||
<CardDescription>
|
||||
Production quotations already linked to this enquiry.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!relatedQuotations.length ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
||||
No quotations have been linked to this enquiry yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-3'>
|
||||
{relatedQuotations.map((quotation) => (
|
||||
<Link
|
||||
key={quotation.id}
|
||||
href={`/dashboard/crm/quotations/${quotation.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'>{quotation.code}</div>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
{quotation.totalAmount.toLocaleString()}
|
||||
{canViewRelatedQuotations ? (
|
||||
<TabsContent value='related'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{relatedTabLabel}</CardTitle>
|
||||
<CardDescription>
|
||||
Production quotations already linked to this record.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!relatedQuotations.length ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
||||
No quotations have been linked to this record yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-3'>
|
||||
{relatedQuotations.map((quotation) => (
|
||||
<Link
|
||||
key={quotation.id}
|
||||
href={`/dashboard/crm/quotations/${quotation.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'>{quotation.code}</div>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
{quotation.totalAmount.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<Icons.arrowRight className='text-muted-foreground h-4 w-4' />
|
||||
</div>
|
||||
<Icons.arrowRight className='text-muted-foreground h-4 w-4' />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -335,12 +389,11 @@ export function EnquiryDetail({
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Record Snapshot</CardTitle>
|
||||
<CardDescription>
|
||||
Operational metadata and assignment state for this enquiry row.
|
||||
</CardDescription>
|
||||
<CardDescription>{snapshotDescription}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<FieldItem label='Assigned Sales' value={enquiry.assignedToName} />
|
||||
<FieldItem label='Pipeline Stage' value={pipelineStageLabel} />
|
||||
<FieldItem label={ownerLabel} value={enquiry.assignedToName} />
|
||||
<FieldItem label='Assigned By' value={enquiry.assignedByName} />
|
||||
<FieldItem label='Created By' value={enquiry.createdBy} />
|
||||
<FieldItem label='Updated By' value={enquiry.updatedBy} />
|
||||
@@ -367,6 +420,7 @@ export function EnquiryDetail({
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
referenceData={referenceData}
|
||||
workspace={workspace}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -64,12 +64,14 @@ export function EnquiryFormSheet({
|
||||
enquiry,
|
||||
open,
|
||||
onOpenChange,
|
||||
referenceData
|
||||
referenceData,
|
||||
workspace = 'enquiry'
|
||||
}: {
|
||||
enquiry?: EnquiryRecord;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
referenceData: EnquiryReferenceData;
|
||||
workspace?: 'lead' | 'enquiry';
|
||||
}) {
|
||||
const isEdit = !!enquiry;
|
||||
const defaultValues = useMemo(
|
||||
@@ -88,21 +90,21 @@ export function EnquiryFormSheet({
|
||||
const createMutation = useMutation({
|
||||
...createEnquiryMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Enquiry created successfully');
|
||||
toast.success(`${recordLabel} created successfully`);
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to create enquiry')
|
||||
toast.error(error instanceof Error ? error.message : `Failed to create ${recordLabel.toLowerCase()}`)
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
...updateEnquiryMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Enquiry updated successfully');
|
||||
toast.success(`${recordLabel} updated successfully`);
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to update enquiry')
|
||||
toast.error(error instanceof Error ? error.message : `Failed to update ${recordLabel.toLowerCase()}`)
|
||||
});
|
||||
|
||||
const contactsForCustomer = referenceData.contacts.filter(
|
||||
@@ -177,13 +179,17 @@ export function EnquiryFormSheet({
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
const recordLabel = workspace === 'lead' ? 'Lead' : 'Enquiry';
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className='flex flex-col sm:max-w-4xl'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{isEdit ? 'Edit Enquiry' : 'New Enquiry'}</SheetTitle>
|
||||
<SheetTitle>{isEdit ? `Edit ${recordLabel}` : `New ${recordLabel}`}</SheetTitle>
|
||||
<SheetDescription>
|
||||
Capture the opportunity details before quotation and approval stages begin.
|
||||
{workspace === 'lead'
|
||||
? 'Capture marketing lead details before assignment to sales.'
|
||||
: 'Capture sales enquiry details before quotation work begins.'}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
@@ -261,7 +267,7 @@ export function EnquiryFormSheet({
|
||||
name='title'
|
||||
label='Title'
|
||||
required
|
||||
placeholder='Warehouse crane upgrade opportunity'
|
||||
placeholder='Warehouse crane upgrade enquiry'
|
||||
/>
|
||||
<FormTextField
|
||||
name='projectName'
|
||||
@@ -338,7 +344,7 @@ export function EnquiryFormSheet({
|
||||
<FormTextareaField
|
||||
name='description'
|
||||
label='Description'
|
||||
placeholder='High-level summary of the opportunity'
|
||||
placeholder='High-level summary of the lead or enquiry'
|
||||
/>
|
||||
</div>
|
||||
<div className='md:col-span-2'>
|
||||
@@ -352,19 +358,19 @@ export function EnquiryFormSheet({
|
||||
<FormTextareaField
|
||||
name='notes'
|
||||
label='Notes'
|
||||
placeholder='Internal notes, blockers, or guidance for the sales team'
|
||||
placeholder='Internal notes, blockers, or handoff guidance'
|
||||
/>
|
||||
</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.'
|
||||
description='Use for urgent or highly strategic enquiries.'
|
||||
/>
|
||||
<FormSwitchField
|
||||
name='isActive'
|
||||
label='Active Record'
|
||||
description='Inactive enquiries stay in history but should not progress further.'
|
||||
description='Inactive leads and enquiries stay in history but should not progress further.'
|
||||
/>
|
||||
</div>
|
||||
<div className='md:col-span-2'>
|
||||
@@ -385,7 +391,7 @@ export function EnquiryFormSheet({
|
||||
</Button>
|
||||
<Button type='submit' form='enquiry-form-sheet' isLoading={isPending}>
|
||||
<Icons.check className='mr-2 h-4 w-4' />
|
||||
{isEdit ? 'Update Enquiry' : 'Create Enquiry'}
|
||||
{isEdit ? `Update ${recordLabel}` : `Create ${recordLabel}`}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
@@ -394,18 +400,25 @@ export function EnquiryFormSheet({
|
||||
}
|
||||
|
||||
export function EnquiryFormSheetTrigger({
|
||||
referenceData
|
||||
referenceData,
|
||||
workspace = 'enquiry'
|
||||
}: {
|
||||
referenceData: EnquiryReferenceData;
|
||||
workspace?: 'lead' | 'enquiry';
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)}>
|
||||
<Icons.add className='mr-2 h-4 w-4' /> Add Enquiry
|
||||
<Icons.add className='mr-2 h-4 w-4' /> {workspace === 'lead' ? 'Add Lead' : 'Add Enquiry'}
|
||||
</Button>
|
||||
<EnquiryFormSheet open={open} onOpenChange={setOpen} referenceData={referenceData} />
|
||||
<EnquiryFormSheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
referenceData={referenceData}
|
||||
workspace={workspace}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,12 +7,14 @@ import { EnquiriesTable } from './enquiries-table';
|
||||
|
||||
export default function EnquiryListing({
|
||||
referenceData,
|
||||
workspace,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canAssign,
|
||||
canReassign
|
||||
}: {
|
||||
referenceData: EnquiryReferenceData;
|
||||
workspace: 'lead' | 'enquiry';
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
canAssign: boolean;
|
||||
@@ -30,6 +32,7 @@ export default function EnquiryListing({
|
||||
const filters = {
|
||||
page,
|
||||
limit,
|
||||
pipelineStage: workspace,
|
||||
...(search && { search }),
|
||||
...(status && { status }),
|
||||
...(productType && { productType }),
|
||||
@@ -46,6 +49,7 @@ export default function EnquiryListing({
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<EnquiriesTable
|
||||
referenceData={referenceData}
|
||||
workspace={workspace}
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
canAssign={canAssign}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
crmEnquiries,
|
||||
crmEnquiryCustomers,
|
||||
crmEnquiryFollowups,
|
||||
crmQuotations,
|
||||
memberships,
|
||||
users
|
||||
} from '@/db/schema';
|
||||
@@ -29,6 +30,7 @@ import type {
|
||||
EnquiryProjectPartyListItem,
|
||||
EnquiryProjectPartyMutationPayload,
|
||||
EnquiryProjectPartyRecord,
|
||||
EnquiryPipelineStage,
|
||||
EnquiryRecord,
|
||||
EnquiryReferenceData
|
||||
} from '../api/types';
|
||||
@@ -48,6 +50,30 @@ const ENQUIRY_OPTION_CATEGORIES = {
|
||||
} as const;
|
||||
|
||||
const ASSIGNABLE_BUSINESS_ROLES = new Set(['sales_manager', 'sales', 'sales_support']);
|
||||
const SALES_OWNED_SCOPE_ROLES = new Set(['sales', 'sales_support']);
|
||||
const ENQUIRY_FULL_ACCESS_ROLES = new Set([
|
||||
'marketing',
|
||||
'sales_manager',
|
||||
'department_manager',
|
||||
'top_manager'
|
||||
]);
|
||||
const SALES_CREATED_ENQUIRY_ROLES = new Set([
|
||||
'sales',
|
||||
'sales_support',
|
||||
'sales_manager',
|
||||
'department_manager',
|
||||
'top_manager'
|
||||
]);
|
||||
const CLOSED_LOST_STATUS_CODES = new Set(['closed_lost', 'cancelled']);
|
||||
const CLOSED_WON_QUOTATION_STATUS_CODES = new Set(['accepted']);
|
||||
const CLOSED_LOST_QUOTATION_STATUS_CODES = new Set(['lost', 'rejected']);
|
||||
|
||||
export interface EnquiryAccessContext {
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
membershipRole: string;
|
||||
businessRole: string;
|
||||
}
|
||||
|
||||
function mapOption(
|
||||
option: Awaited<ReturnType<typeof getActiveOptionsByCategory>>[number]
|
||||
@@ -99,6 +125,7 @@ function mapEnquiryRecord(
|
||||
notes: row.notes,
|
||||
isHotProject: row.isHotProject,
|
||||
isActive: row.isActive,
|
||||
pipelineStage: row.pipelineStage as EnquiryPipelineStage,
|
||||
assignedToUserId: row.assignedToUserId,
|
||||
assignedToName: options?.assignedToName ?? null,
|
||||
assignedAt: row.assignedAt?.toISOString() ?? null,
|
||||
@@ -113,6 +140,41 @@ function mapEnquiryRecord(
|
||||
};
|
||||
}
|
||||
|
||||
function hasEnquiryFullAccess(context: EnquiryAccessContext) {
|
||||
return (
|
||||
context.membershipRole === 'admin' || ENQUIRY_FULL_ACCESS_ROLES.has(context.businessRole)
|
||||
);
|
||||
}
|
||||
|
||||
function canAccessEnquiryRow(
|
||||
row: typeof crmEnquiries.$inferSelect,
|
||||
context: EnquiryAccessContext
|
||||
) {
|
||||
if (hasEnquiryFullAccess(context)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (SALES_OWNED_SCOPE_ROLES.has(context.businessRole)) {
|
||||
return row.createdBy === context.userId || row.assignedToUserId === context.userId;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildAccessScopedFilters(context: EnquiryAccessContext): SQL[] {
|
||||
if (hasEnquiryFullAccess(context)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (SALES_OWNED_SCOPE_ROLES.has(context.businessRole)) {
|
||||
return [
|
||||
or(eq(crmEnquiries.createdBy, context.userId), eq(crmEnquiries.assignedToUserId, context.userId))!
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function mapFollowupRecord(row: typeof crmEnquiryFollowups.$inferSelect): EnquiryFollowupRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -218,6 +280,19 @@ async function assertMasterOptionValue(
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveOptionCodeById(
|
||||
organizationId: string,
|
||||
category: string,
|
||||
optionId?: string | null
|
||||
) {
|
||||
if (!optionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const options = await getActiveOptionsByCategory(category, { organizationId });
|
||||
return options.find((option) => option.id === optionId)?.code ?? null;
|
||||
}
|
||||
|
||||
export async function assertCustomerBelongsToOrganization(id: string, organizationId: string) {
|
||||
const [customer] = await db
|
||||
.select()
|
||||
@@ -309,7 +384,11 @@ export async function assertAssignableUserBelongsToOrganization(
|
||||
return membership;
|
||||
}
|
||||
|
||||
async function assertEnquiryBelongsToOrganization(id: string, organizationId: string) {
|
||||
async function assertEnquiryBelongsToOrganization(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
accessContext?: EnquiryAccessContext
|
||||
) {
|
||||
const [enquiry] = await db
|
||||
.select()
|
||||
.from(crmEnquiries)
|
||||
@@ -326,6 +405,10 @@ async function assertEnquiryBelongsToOrganization(id: string, organizationId: st
|
||||
throw new AuthError('Enquiry not found', 404);
|
||||
}
|
||||
|
||||
if (accessContext && !canAccessEnquiryRow(enquiry, accessContext)) {
|
||||
throw new AuthError('Forbidden', 403);
|
||||
}
|
||||
|
||||
return enquiry;
|
||||
}
|
||||
|
||||
@@ -395,9 +478,14 @@ async function validateEnquiryPayload(organizationId: string, payload: EnquiryMu
|
||||
async function validateFollowupPayload(
|
||||
organizationId: string,
|
||||
enquiryId: string,
|
||||
payload: EnquiryFollowupMutationPayload
|
||||
payload: EnquiryFollowupMutationPayload,
|
||||
accessContext?: EnquiryAccessContext
|
||||
) {
|
||||
const enquiry = await assertEnquiryBelongsToOrganization(enquiryId, organizationId);
|
||||
const enquiry = await assertEnquiryBelongsToOrganization(
|
||||
enquiryId,
|
||||
organizationId,
|
||||
accessContext
|
||||
);
|
||||
|
||||
await assertMasterOptionValue(
|
||||
organizationId,
|
||||
@@ -410,7 +498,8 @@ async function validateFollowupPayload(
|
||||
}
|
||||
}
|
||||
|
||||
function buildEnquiryFilters(organizationId: string, filters: EnquiryFilters): SQL[] {
|
||||
function buildEnquiryFilters(context: EnquiryAccessContext, filters: EnquiryFilters): SQL[] {
|
||||
const pipelineStages = splitFilterValue(filters.pipelineStage);
|
||||
const statuses = splitFilterValue(filters.status);
|
||||
const productTypes = splitFilterValue(filters.productType);
|
||||
const priorities = splitFilterValue(filters.priority);
|
||||
@@ -418,8 +507,10 @@ function buildEnquiryFilters(organizationId: string, filters: EnquiryFilters): S
|
||||
const customers = splitFilterValue(filters.customer);
|
||||
|
||||
return [
|
||||
eq(crmEnquiries.organizationId, organizationId),
|
||||
eq(crmEnquiries.organizationId, context.organizationId),
|
||||
isNull(crmEnquiries.deletedAt),
|
||||
...buildAccessScopedFilters(context),
|
||||
...(pipelineStages.length ? [inArray(crmEnquiries.pipelineStage, pipelineStages)] : []),
|
||||
...(statuses.length ? [inArray(crmEnquiries.status, statuses)] : []),
|
||||
...(productTypes.length ? [inArray(crmEnquiries.productType, productTypes)] : []),
|
||||
...(priorities.length ? [inArray(crmEnquiries.priority, priorities)] : []),
|
||||
@@ -438,6 +529,54 @@ function buildEnquiryFilters(organizationId: string, filters: EnquiryFilters): S
|
||||
];
|
||||
}
|
||||
|
||||
function derivePipelineStageFromRole(businessRole: string): EnquiryPipelineStage {
|
||||
return SALES_CREATED_ENQUIRY_ROLES.has(businessRole) ? 'enquiry' : 'lead';
|
||||
}
|
||||
|
||||
async function resolvePipelineStageForCreate(
|
||||
organizationId: string,
|
||||
businessRole: string,
|
||||
statusId: string
|
||||
): Promise<EnquiryPipelineStage> {
|
||||
const statusCode = await resolveOptionCodeById(
|
||||
organizationId,
|
||||
ENQUIRY_OPTION_CATEGORIES.status,
|
||||
statusId
|
||||
);
|
||||
|
||||
if (statusCode && CLOSED_LOST_STATUS_CODES.has(statusCode)) {
|
||||
return 'closed_lost';
|
||||
}
|
||||
|
||||
return derivePipelineStageFromRole(businessRole);
|
||||
}
|
||||
|
||||
async function resolvePipelineStageForUpdate(
|
||||
organizationId: string,
|
||||
current: typeof crmEnquiries.$inferSelect,
|
||||
payload: EnquiryMutationPayload
|
||||
): Promise<EnquiryPipelineStage> {
|
||||
const statusCode = await resolveOptionCodeById(
|
||||
organizationId,
|
||||
ENQUIRY_OPTION_CATEGORIES.status,
|
||||
payload.status
|
||||
);
|
||||
|
||||
if (statusCode && CLOSED_LOST_STATUS_CODES.has(statusCode)) {
|
||||
return 'closed_lost';
|
||||
}
|
||||
|
||||
if (current.pipelineStage === 'closed_won') {
|
||||
return 'closed_won';
|
||||
}
|
||||
|
||||
if (current.pipelineStage === 'enquiry' || current.assignedToUserId) {
|
||||
return 'enquiry';
|
||||
}
|
||||
|
||||
return 'lead';
|
||||
}
|
||||
|
||||
export async function getEnquiryReferenceData(
|
||||
organizationId: string
|
||||
): Promise<EnquiryReferenceData> {
|
||||
@@ -505,12 +644,12 @@ export async function getEnquiryReferenceData(
|
||||
}
|
||||
|
||||
export async function listEnquiries(
|
||||
organizationId: string,
|
||||
context: EnquiryAccessContext,
|
||||
filters: EnquiryFilters
|
||||
): Promise<{ items: EnquiryListItem[]; totalItems: number }> {
|
||||
const page = filters.page ?? 1;
|
||||
const limit = filters.limit ?? 10;
|
||||
const whereFilters = buildEnquiryFilters(organizationId, filters);
|
||||
const whereFilters = buildEnquiryFilters(context, filters);
|
||||
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
@@ -528,16 +667,17 @@ export async function listEnquiries(
|
||||
const assignedUserIds = [
|
||||
...new Set(rows.map((row) => row.assignedToUserId).filter(Boolean) as string[])
|
||||
];
|
||||
const createdByUserIds = [...new Set(rows.map((row) => row.createdBy).filter(Boolean) as string[])];
|
||||
const enquiryIds = rows.map((row) => row.id);
|
||||
|
||||
const [customers, contacts, assignedUsers, followupCounts] = await Promise.all([
|
||||
const [customers, contacts, assignedUsers, createdByUsers, followupCounts, quotationCounts, nextFollowups] = await Promise.all([
|
||||
customerIds.length
|
||||
? db
|
||||
.select()
|
||||
.from(crmCustomers)
|
||||
.where(
|
||||
and(
|
||||
eq(crmCustomers.organizationId, organizationId),
|
||||
eq(crmCustomers.organizationId, context.organizationId),
|
||||
inArray(crmCustomers.id, customerIds)
|
||||
)
|
||||
)
|
||||
@@ -548,7 +688,7 @@ export async function listEnquiries(
|
||||
.from(crmCustomerContacts)
|
||||
.where(
|
||||
and(
|
||||
eq(crmCustomerContacts.organizationId, organizationId),
|
||||
eq(crmCustomerContacts.organizationId, context.organizationId),
|
||||
inArray(crmCustomerContacts.id, contactIds)
|
||||
)
|
||||
)
|
||||
@@ -559,6 +699,12 @@ export async function listEnquiries(
|
||||
.from(users)
|
||||
.where(inArray(users.id, assignedUserIds))
|
||||
: [],
|
||||
createdByUserIds.length
|
||||
? db
|
||||
.select({ id: users.id, name: users.name })
|
||||
.from(users)
|
||||
.where(inArray(users.id, createdByUserIds))
|
||||
: [],
|
||||
enquiryIds.length
|
||||
? db
|
||||
.select({
|
||||
@@ -568,19 +714,68 @@ export async function listEnquiries(
|
||||
.from(crmEnquiryFollowups)
|
||||
.where(
|
||||
and(
|
||||
eq(crmEnquiryFollowups.organizationId, organizationId),
|
||||
eq(crmEnquiryFollowups.organizationId, context.organizationId),
|
||||
inArray(crmEnquiryFollowups.enquiryId, enquiryIds),
|
||||
isNull(crmEnquiryFollowups.deletedAt)
|
||||
)
|
||||
)
|
||||
.groupBy(crmEnquiryFollowups.enquiryId)
|
||||
: [],
|
||||
enquiryIds.length
|
||||
? db
|
||||
.select({
|
||||
enquiryId: crmQuotations.enquiryId,
|
||||
value: count()
|
||||
})
|
||||
.from(crmQuotations)
|
||||
.where(
|
||||
and(
|
||||
eq(crmQuotations.organizationId, context.organizationId),
|
||||
inArray(crmQuotations.enquiryId, enquiryIds),
|
||||
isNull(crmQuotations.deletedAt)
|
||||
)
|
||||
)
|
||||
.groupBy(crmQuotations.enquiryId)
|
||||
: [],
|
||||
enquiryIds.length
|
||||
? db
|
||||
.select({
|
||||
enquiryId: crmEnquiryFollowups.enquiryId,
|
||||
nextFollowupDate: crmEnquiryFollowups.nextFollowupDate
|
||||
})
|
||||
.from(crmEnquiryFollowups)
|
||||
.where(
|
||||
and(
|
||||
eq(crmEnquiryFollowups.organizationId, context.organizationId),
|
||||
inArray(crmEnquiryFollowups.enquiryId, enquiryIds),
|
||||
isNull(crmEnquiryFollowups.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(crmEnquiryFollowups.nextFollowupDate))
|
||||
: []
|
||||
]);
|
||||
|
||||
const customerMap = new Map(customers.map((customer) => [customer.id, customer.name]));
|
||||
const contactMap = new Map(contacts.map((contact) => [contact.id, contact.name]));
|
||||
const assignedUserMap = new Map(assignedUsers.map((user) => [user.id, user.name]));
|
||||
const createdByUserMap = new Map(createdByUsers.map((user) => [user.id, user.name]));
|
||||
const followupCountMap = new Map(followupCounts.map((item) => [item.enquiryId, item.value]));
|
||||
const quotationCountMap = new Map(
|
||||
quotationCounts
|
||||
.filter((item) => item.enquiryId !== null)
|
||||
.map((item) => [item.enquiryId as string, item.value])
|
||||
);
|
||||
const nextFollowupMap = new Map<string, string | null>();
|
||||
|
||||
for (const item of nextFollowups) {
|
||||
if (!item.nextFollowupDate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!nextFollowupMap.has(item.enquiryId)) {
|
||||
nextFollowupMap.set(item.enquiryId, item.nextFollowupDate.toISOString());
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
items: rows.map((row) => ({
|
||||
@@ -591,14 +786,21 @@ export async function listEnquiries(
|
||||
}),
|
||||
customerName: customerMap.get(row.customerId) ?? 'Unknown customer',
|
||||
contactName: row.contactId ? (contactMap.get(row.contactId) ?? null) : null,
|
||||
followupCount: followupCountMap.get(row.id) ?? 0
|
||||
followupCount: followupCountMap.get(row.id) ?? 0,
|
||||
quotationCount: quotationCountMap.get(row.id) ?? 0,
|
||||
nextFollowupDate: nextFollowupMap.get(row.id) ?? null,
|
||||
createdByName: createdByUserMap.get(row.createdBy) ?? null
|
||||
})),
|
||||
totalItems: totalResult?.value ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
export async function getEnquiryDetail(id: string, organizationId: string): Promise<EnquiryRecord> {
|
||||
const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId);
|
||||
export async function getEnquiryDetail(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
accessContext?: EnquiryAccessContext
|
||||
): Promise<EnquiryRecord> {
|
||||
const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
|
||||
const relatedUserIds = [enquiry.assignedToUserId, enquiry.assignedBy].filter(Boolean) as string[];
|
||||
const userRows = relatedUserIds.length
|
||||
? await db
|
||||
@@ -711,9 +913,10 @@ async function syncEnquiryProjectParties(
|
||||
|
||||
export async function listEnquiryProjectParties(
|
||||
id: string,
|
||||
organizationId: string
|
||||
organizationId: string,
|
||||
accessContext?: EnquiryAccessContext
|
||||
): Promise<EnquiryProjectPartyListItem[]> {
|
||||
await assertEnquiryBelongsToOrganization(id, organizationId);
|
||||
await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
|
||||
const [relations, customers, roleOptions] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
@@ -754,8 +957,10 @@ export async function listEnquiryProjectParties(
|
||||
|
||||
export async function getEnquiryActivity(
|
||||
id: string,
|
||||
organizationId: string
|
||||
organizationId: string,
|
||||
accessContext?: EnquiryAccessContext
|
||||
): Promise<EnquiryActivityRecord[]> {
|
||||
await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
|
||||
const auditLogs = await listAuditLogs({
|
||||
organizationId,
|
||||
entityId: id,
|
||||
@@ -791,9 +996,15 @@ export async function getEnquiryActivity(
|
||||
export async function createEnquiry(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
businessRole: string,
|
||||
payload: EnquiryMutationPayload
|
||||
) {
|
||||
await validateEnquiryPayload(organizationId, payload);
|
||||
const pipelineStage = await resolvePipelineStageForCreate(
|
||||
organizationId,
|
||||
businessRole,
|
||||
payload.status
|
||||
);
|
||||
|
||||
const documentCode = await generateNextDocumentCode({
|
||||
organizationId,
|
||||
@@ -828,6 +1039,7 @@ export async function createEnquiry(
|
||||
notes: payload.notes?.trim() || null,
|
||||
isHotProject: payload.isHotProject ?? false,
|
||||
isActive: payload.isActive ?? true,
|
||||
pipelineStage,
|
||||
createdBy: userId,
|
||||
updatedBy: userId
|
||||
})
|
||||
@@ -849,10 +1061,12 @@ export async function updateEnquiry(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: EnquiryMutationPayload
|
||||
payload: EnquiryMutationPayload,
|
||||
accessContext?: EnquiryAccessContext
|
||||
) {
|
||||
await validateEnquiryPayload(organizationId, payload);
|
||||
await assertEnquiryBelongsToOrganization(id, organizationId);
|
||||
const current = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
|
||||
const pipelineStage = await resolvePipelineStageForUpdate(organizationId, current, payload);
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
const [updated] = await tx
|
||||
@@ -878,6 +1092,7 @@ export async function updateEnquiry(
|
||||
notes: payload.notes?.trim() || null,
|
||||
isHotProject: payload.isHotProject ?? false,
|
||||
isActive: payload.isActive ?? true,
|
||||
pipelineStage,
|
||||
updatedBy: userId,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
@@ -896,8 +1111,13 @@ export async function updateEnquiry(
|
||||
});
|
||||
}
|
||||
|
||||
export async function softDeleteEnquiry(id: string, organizationId: string, userId: string) {
|
||||
await assertEnquiryBelongsToOrganization(id, organizationId);
|
||||
export async function softDeleteEnquiry(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
accessContext?: EnquiryAccessContext
|
||||
) {
|
||||
await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
|
||||
const now = new Date();
|
||||
|
||||
const [updated] = await db
|
||||
@@ -964,6 +1184,7 @@ async function updateEnquiryAssignment(
|
||||
const [updated] = await db
|
||||
.update(crmEnquiries)
|
||||
.set({
|
||||
pipelineStage: 'enquiry',
|
||||
assignedToUserId: payload.assignedToUserId,
|
||||
assignedAt: new Date(),
|
||||
assignedBy: userId,
|
||||
@@ -995,8 +1216,12 @@ export async function reassignEnquiryToUser(
|
||||
return updateEnquiryAssignment(id, organizationId, userId, payload, 'reassign');
|
||||
}
|
||||
|
||||
export async function listEnquiryFollowups(id: string, organizationId: string) {
|
||||
await assertEnquiryBelongsToOrganization(id, organizationId);
|
||||
export async function listEnquiryFollowups(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
accessContext?: EnquiryAccessContext
|
||||
) {
|
||||
await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(crmEnquiryFollowups)
|
||||
@@ -1016,9 +1241,10 @@ export async function createEnquiryFollowup(
|
||||
enquiryId: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: EnquiryFollowupMutationPayload
|
||||
payload: EnquiryFollowupMutationPayload,
|
||||
accessContext?: EnquiryAccessContext
|
||||
) {
|
||||
await validateFollowupPayload(organizationId, enquiryId, payload);
|
||||
await validateFollowupPayload(organizationId, enquiryId, payload, accessContext);
|
||||
|
||||
const [created] = await db
|
||||
.insert(crmEnquiryFollowups)
|
||||
@@ -1046,9 +1272,10 @@ export async function updateEnquiryFollowup(
|
||||
followupId: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: EnquiryFollowupMutationPayload
|
||||
payload: EnquiryFollowupMutationPayload,
|
||||
accessContext?: EnquiryAccessContext
|
||||
) {
|
||||
await validateFollowupPayload(organizationId, enquiryId, payload);
|
||||
await validateFollowupPayload(organizationId, enquiryId, payload, accessContext);
|
||||
await assertFollowupBelongsToEnquiry(followupId, enquiryId, organizationId);
|
||||
|
||||
const [updated] = await db
|
||||
@@ -1074,8 +1301,10 @@ export async function softDeleteEnquiryFollowup(
|
||||
enquiryId: string,
|
||||
followupId: string,
|
||||
organizationId: string,
|
||||
userId: string
|
||||
userId: string,
|
||||
accessContext?: EnquiryAccessContext
|
||||
) {
|
||||
await assertEnquiryBelongsToOrganization(enquiryId, organizationId, accessContext);
|
||||
await assertFollowupBelongsToEnquiry(followupId, enquiryId, organizationId);
|
||||
|
||||
const [updated] = await db
|
||||
@@ -1117,3 +1346,39 @@ export async function listCustomerEnquiryRelations(
|
||||
updatedAt: row.updatedAt.toISOString()
|
||||
}));
|
||||
}
|
||||
|
||||
export async function syncEnquiryPipelineStageFromQuotationStatus(
|
||||
enquiryId: string,
|
||||
organizationId: string,
|
||||
quotationStatusCode: string | null
|
||||
) {
|
||||
if (!quotationStatusCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextStage: EnquiryPipelineStage | null = CLOSED_WON_QUOTATION_STATUS_CODES.has(
|
||||
quotationStatusCode
|
||||
)
|
||||
? 'closed_won'
|
||||
: CLOSED_LOST_QUOTATION_STATUS_CODES.has(quotationStatusCode)
|
||||
? 'closed_lost'
|
||||
: null;
|
||||
|
||||
if (!nextStage) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(crmEnquiries)
|
||||
.set({
|
||||
pipelineStage: nextStage,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(crmEnquiries.id, enquiryId),
|
||||
eq(crmEnquiries.organizationId, organizationId),
|
||||
isNull(crmEnquiries.deletedAt)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user