task h.1 adn fix permission
This commit is contained in:
@@ -1,15 +1,21 @@
|
||||
import { mutationOptions } from '@tanstack/react-query';
|
||||
import { getQueryClient } from '@/lib/query-client';
|
||||
import {
|
||||
assignEnquiry,
|
||||
createEnquiry,
|
||||
createEnquiryFollowup,
|
||||
deleteEnquiry,
|
||||
deleteEnquiryFollowup,
|
||||
reassignEnquiry,
|
||||
updateEnquiry,
|
||||
updateEnquiryFollowup
|
||||
} from './service';
|
||||
import { enquiryKeys } from './queries';
|
||||
import type { EnquiryFollowupMutationPayload, EnquiryMutationPayload } from './types';
|
||||
import type {
|
||||
EnquiryAssignmentMutationPayload,
|
||||
EnquiryFollowupMutationPayload,
|
||||
EnquiryMutationPayload
|
||||
} from './types';
|
||||
|
||||
async function invalidateEnquiryLists() {
|
||||
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.lists() });
|
||||
@@ -65,6 +71,26 @@ export const deleteEnquiryMutation = mutationOptions({
|
||||
}
|
||||
});
|
||||
|
||||
export const assignEnquiryMutation = mutationOptions({
|
||||
mutationFn: ({ id, values }: { id: string; values: EnquiryAssignmentMutationPayload }) =>
|
||||
assignEnquiry(id, values),
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await invalidateEnquiryMutationQueries(variables.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const reassignEnquiryMutation = mutationOptions({
|
||||
mutationFn: ({ id, values }: { id: string; values: EnquiryAssignmentMutationPayload }) =>
|
||||
reassignEnquiry(id, values),
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await invalidateEnquiryMutationQueries(variables.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const createEnquiryFollowupMutation = mutationOptions({
|
||||
mutationFn: ({
|
||||
enquiryId,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type {
|
||||
EnquiryAssignmentMutationPayload,
|
||||
EnquiryDetailResponse,
|
||||
EnquiryFilters,
|
||||
EnquiryFollowupMutationPayload,
|
||||
@@ -51,6 +52,20 @@ export async function deleteEnquiry(id: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function assignEnquiry(id: string, data: EnquiryAssignmentMutationPayload) {
|
||||
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${id}/assign`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function reassignEnquiry(id: string, data: EnquiryAssignmentMutationPayload) {
|
||||
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${id}/reassign`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function getEnquiryFollowups(id: string): Promise<EnquiryFollowupsResponse> {
|
||||
return apiClient<EnquiryFollowupsResponse>(`/crm/enquiries/${id}/followups`);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,13 @@ export interface EnquiryContactLookup {
|
||||
isPrimary: boolean;
|
||||
}
|
||||
|
||||
export interface EnquiryAssignableUserLookup {
|
||||
id: string;
|
||||
name: string;
|
||||
membershipRole: 'admin' | 'user';
|
||||
businessRole: string;
|
||||
}
|
||||
|
||||
export interface EnquiryRecord {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
@@ -52,6 +59,12 @@ export interface EnquiryRecord {
|
||||
notes: string | null;
|
||||
isHotProject: boolean;
|
||||
isActive: boolean;
|
||||
assignedToUserId: string | null;
|
||||
assignedToName: string | null;
|
||||
assignedAt: string | null;
|
||||
assignedBy: string | null;
|
||||
assignedByName: string | null;
|
||||
assignmentRemark: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
@@ -102,6 +115,7 @@ export interface EnquiryReferenceData {
|
||||
followupTypes: EnquiryOption[];
|
||||
customers: EnquiryCustomerLookup[];
|
||||
contacts: EnquiryContactLookup[];
|
||||
assignableUsers: EnquiryAssignableUserLookup[];
|
||||
}
|
||||
|
||||
export interface EnquiryFilters {
|
||||
@@ -174,6 +188,11 @@ export interface EnquiryFollowupMutationPayload {
|
||||
nextAction?: string;
|
||||
}
|
||||
|
||||
export interface EnquiryAssignmentMutationPayload {
|
||||
assignedToUserId: string;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface EnquiryCustomerRelationItem {
|
||||
id: string;
|
||||
code: string;
|
||||
|
||||
@@ -14,15 +14,19 @@ import { getEnquiryColumns } from './enquiry-columns';
|
||||
export function EnquiriesTable({
|
||||
referenceData,
|
||||
canUpdate,
|
||||
canDelete
|
||||
canDelete,
|
||||
canAssign,
|
||||
canReassign
|
||||
}: {
|
||||
referenceData: EnquiryReferenceData;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
canAssign: boolean;
|
||||
canReassign: boolean;
|
||||
}) {
|
||||
const columns = useMemo(
|
||||
() => getEnquiryColumns({ referenceData, canUpdate, canDelete }),
|
||||
[referenceData, canUpdate, canDelete]
|
||||
() => getEnquiryColumns({ referenceData, canUpdate, canDelete, canAssign, canReassign }),
|
||||
[referenceData, canUpdate, canDelete, canAssign, canReassign]
|
||||
);
|
||||
const columnIds = columns.map((column) => column.id).filter(Boolean) as string[];
|
||||
const [params] = useQueryStates({
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { z } from 'zod';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { assignEnquiryMutation, reassignEnquiryMutation } from '../api/mutations';
|
||||
import type { EnquiryRecord, EnquiryReferenceData } from '../api/types';
|
||||
|
||||
const enquiryAssignmentSchema = z.object({
|
||||
assignedToUserId: z.string().min(1, 'Sales user is required'),
|
||||
remark: z.string().optional()
|
||||
});
|
||||
|
||||
type EnquiryAssignmentFormValues = z.infer<typeof enquiryAssignmentSchema>;
|
||||
|
||||
function toDefaultValues(
|
||||
enquiry: EnquiryRecord,
|
||||
referenceData: EnquiryReferenceData
|
||||
): EnquiryAssignmentFormValues {
|
||||
return {
|
||||
assignedToUserId: enquiry.assignedToUserId ?? referenceData.assignableUsers[0]?.id ?? '',
|
||||
remark: enquiry.assignmentRemark ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
export function EnquiryAssignmentDialog({
|
||||
enquiry,
|
||||
referenceData,
|
||||
mode,
|
||||
open,
|
||||
onOpenChange
|
||||
}: {
|
||||
enquiry: EnquiryRecord;
|
||||
referenceData: EnquiryReferenceData;
|
||||
mode: 'assign' | 'reassign';
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const defaultValues = useMemo(
|
||||
() => toDefaultValues(enquiry, referenceData),
|
||||
[enquiry, referenceData]
|
||||
);
|
||||
const { FormSelectField, FormTextareaField } = useFormFields<EnquiryAssignmentFormValues>();
|
||||
|
||||
const assignMutation = useMutation({
|
||||
...assignEnquiryMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Enquiry assigned successfully');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to assign enquiry')
|
||||
});
|
||||
|
||||
const reassignMutation = useMutation({
|
||||
...reassignEnquiryMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Enquiry reassigned successfully');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to reassign enquiry')
|
||||
});
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues,
|
||||
validators: {
|
||||
onSubmit: enquiryAssignmentSchema
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
const values = {
|
||||
assignedToUserId: value.assignedToUserId,
|
||||
remark: value.remark?.trim() || undefined
|
||||
};
|
||||
|
||||
if (mode === 'assign') {
|
||||
await assignMutation.mutateAsync({ id: enquiry.id, values });
|
||||
return;
|
||||
}
|
||||
|
||||
await reassignMutation.mutateAsync({ id: enquiry.id, values });
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.reset(defaultValues);
|
||||
}, [defaultValues, form, open]);
|
||||
|
||||
const isPending = assignMutation.isPending || reassignMutation.isPending;
|
||||
const noUsersAvailable = referenceData.assignableUsers.length === 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{mode === 'assign' ? 'Assign Sales' : 'Reassign Sales'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{mode === 'assign'
|
||||
? 'Select the sales owner who should take this enquiry forward.'
|
||||
: 'Move this enquiry to a different sales owner and keep the handoff note visible.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form.AppForm>
|
||||
<form.Form id='enquiry-assignment-form' className='px-0 md:px-0'>
|
||||
{noUsersAvailable ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'>
|
||||
No assignable sales users are available in this workspace yet.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<FormSelectField
|
||||
name='assignedToUserId'
|
||||
label='Sales User'
|
||||
required
|
||||
options={referenceData.assignableUsers.map((user) => ({
|
||||
value: user.id,
|
||||
label: `${user.name} (${user.businessRole.replaceAll('_', ' ')})`
|
||||
}))}
|
||||
placeholder='Select sales user'
|
||||
/>
|
||||
<FormTextareaField
|
||||
name='remark'
|
||||
label='Remark'
|
||||
placeholder='Optional handoff context or assignment note'
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</form.Form>
|
||||
</form.AppForm>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
form='enquiry-assignment-form'
|
||||
isLoading={isPending}
|
||||
disabled={noUsersAvailable}
|
||||
>
|
||||
<Icons.userPen className='mr-2 h-4 w-4' />
|
||||
{mode === 'assign' ? 'Assign Sales' : 'Reassign Sales'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -22,12 +22,16 @@ export function EnquiryCellAction({
|
||||
data,
|
||||
referenceData,
|
||||
canUpdate,
|
||||
canDelete
|
||||
canDelete,
|
||||
canAssign: _canAssign,
|
||||
canReassign: _canReassign
|
||||
}: {
|
||||
data: EnquiryListItem;
|
||||
referenceData: EnquiryReferenceData;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
canAssign: boolean;
|
||||
canReassign: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
@@ -12,11 +12,15 @@ import { EnquiryStatusBadge } from './enquiry-status-badge';
|
||||
export function getEnquiryColumns({
|
||||
referenceData,
|
||||
canUpdate,
|
||||
canDelete
|
||||
canDelete,
|
||||
canAssign,
|
||||
canReassign
|
||||
}: {
|
||||
referenceData: EnquiryReferenceData;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
canAssign: boolean;
|
||||
canReassign: 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]));
|
||||
@@ -153,6 +157,20 @@ export function getEnquiryColumns({
|
||||
},
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'assignedSales',
|
||||
header: 'Assigned Sales',
|
||||
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>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'followupCount',
|
||||
accessorKey: 'followupCount',
|
||||
@@ -169,6 +187,8 @@ export function getEnquiryColumns({
|
||||
referenceData={referenceData}
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
canAssign={canAssign}
|
||||
canReassign={canReassign}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { enquiryByIdOptions } from '../api/queries';
|
||||
import type { EnquiryReferenceData } from '../api/types';
|
||||
import type { QuotationRelationItem } from '@/features/crm/quotations/api/types';
|
||||
import { EnquiryAssignmentDialog } from './enquiry-assignment-dialog';
|
||||
import { EnquiryFormSheet } from './enquiry-form-sheet';
|
||||
import { EnquiryFollowupsTab } from './enquiry-followups-tab';
|
||||
import { EnquiryStatusBadge } from './enquiry-status-badge';
|
||||
@@ -30,12 +31,16 @@ export function EnquiryDetail({
|
||||
referenceData,
|
||||
relatedQuotations,
|
||||
canUpdate,
|
||||
canAssign,
|
||||
canReassign,
|
||||
canManageFollowups
|
||||
}: {
|
||||
enquiryId: string;
|
||||
referenceData: EnquiryReferenceData;
|
||||
relatedQuotations: QuotationRelationItem[];
|
||||
canUpdate: boolean;
|
||||
canAssign: boolean;
|
||||
canReassign: boolean;
|
||||
canManageFollowups: {
|
||||
create: boolean;
|
||||
update: boolean;
|
||||
@@ -44,6 +49,7 @@ export function EnquiryDetail({
|
||||
}) {
|
||||
const { data } = useSuspenseQuery(enquiryByIdOptions(enquiryId));
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [assignmentOpen, setAssignmentOpen] = useState(false);
|
||||
const enquiry = data.enquiry;
|
||||
const branchMap = useMemo(
|
||||
() => new Map(referenceData.branches.map((item) => [item.id, item.name])),
|
||||
@@ -76,6 +82,8 @@ export function EnquiryDetail({
|
||||
const customer = customerMap.get(enquiry.customerId);
|
||||
const contact = enquiry.contactId ? contactMap.get(enquiry.contactId) : null;
|
||||
const status = statusMap.get(enquiry.status);
|
||||
const canManageAssignment = enquiry.assignedToUserId ? canReassign : canAssign;
|
||||
const assignmentMode = enquiry.assignedToUserId ? 'reassign' : 'assign';
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
@@ -100,6 +108,12 @@ export function EnquiryDetail({
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{canManageAssignment ? (
|
||||
<Button variant='outline' onClick={() => setAssignmentOpen(true)}>
|
||||
<Icons.userPen className='mr-2 h-4 w-4' />
|
||||
{assignmentMode === 'assign' ? 'Assign Sales' : 'Reassign Sales'}
|
||||
</Button>
|
||||
) : null}
|
||||
{canUpdate ? (
|
||||
<Button variant='outline' onClick={() => setEditOpen(true)}>
|
||||
<Icons.edit className='mr-2 h-4 w-4' /> Edit Enquiry
|
||||
@@ -169,6 +183,15 @@ export function EnquiryDetail({
|
||||
/>
|
||||
<FieldItem label='Competitor' value={enquiry.competitor} />
|
||||
<FieldItem label='Source' value={enquiry.source} />
|
||||
<FieldItem label='Assigned Sales' value={enquiry.assignedToName} />
|
||||
<FieldItem
|
||||
label='Assigned At'
|
||||
value={
|
||||
enquiry.assignedAt ? new Date(enquiry.assignedAt).toLocaleString() : null
|
||||
}
|
||||
/>
|
||||
<FieldItem label='Assigned By' value={enquiry.assignedByName} />
|
||||
<FieldItem label='Assignment Remark' value={enquiry.assignmentRemark} />
|
||||
<div className='md:col-span-2 xl:col-span-4'>
|
||||
<FieldItem
|
||||
label='Project'
|
||||
@@ -289,11 +312,19 @@ export function EnquiryDetail({
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Record Snapshot</CardTitle>
|
||||
<CardDescription>Operational metadata for this enquiry row.</CardDescription>
|
||||
<CardDescription>
|
||||
Operational metadata and assignment state for this enquiry row.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<FieldItem label='Assigned Sales' value={enquiry.assignedToName} />
|
||||
<FieldItem label='Assigned By' value={enquiry.assignedByName} />
|
||||
<FieldItem label='Created By' value={enquiry.createdBy} />
|
||||
<FieldItem label='Updated By' value={enquiry.updatedBy} />
|
||||
<FieldItem
|
||||
label='Assigned At'
|
||||
value={enquiry.assignedAt ? new Date(enquiry.assignedAt).toLocaleString() : null}
|
||||
/>
|
||||
<FieldItem label='Created At' value={new Date(enquiry.createdAt).toLocaleString()} />
|
||||
<FieldItem label='Updated At' value={new Date(enquiry.updatedAt).toLocaleString()} />
|
||||
</CardContent>
|
||||
@@ -301,6 +332,13 @@ export function EnquiryDetail({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EnquiryAssignmentDialog
|
||||
enquiry={enquiry}
|
||||
referenceData={referenceData}
|
||||
mode={assignmentMode}
|
||||
open={assignmentOpen}
|
||||
onOpenChange={setAssignmentOpen}
|
||||
/>
|
||||
<EnquiryFormSheet
|
||||
enquiry={enquiry}
|
||||
open={editOpen}
|
||||
|
||||
@@ -8,11 +8,15 @@ import { EnquiriesTable } from './enquiries-table';
|
||||
export default function EnquiryListing({
|
||||
referenceData,
|
||||
canUpdate,
|
||||
canDelete
|
||||
canDelete,
|
||||
canAssign,
|
||||
canReassign
|
||||
}: {
|
||||
referenceData: EnquiryReferenceData;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
canAssign: boolean;
|
||||
canReassign: boolean;
|
||||
}) {
|
||||
const page = searchParamsCache.get('page');
|
||||
const limit = searchParamsCache.get('perPage');
|
||||
@@ -40,7 +44,13 @@ export default function EnquiryListing({
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<EnquiriesTable referenceData={referenceData} canUpdate={canUpdate} canDelete={canDelete} />
|
||||
<EnquiriesTable
|
||||
referenceData={referenceData}
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
canAssign={canAssign}
|
||||
canReassign={canReassign}
|
||||
/>
|
||||
</HydrationBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
crmCustomers,
|
||||
crmEnquiries,
|
||||
crmEnquiryFollowups,
|
||||
memberships,
|
||||
users
|
||||
} from '@/db/schema';
|
||||
import { db } from '@/lib/db';
|
||||
@@ -14,7 +15,9 @@ import { generateNextDocumentCode } from '@/features/foundation/document-sequenc
|
||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||
import type {
|
||||
EnquiryActivityRecord,
|
||||
EnquiryAssignableUserLookup,
|
||||
EnquiryBranchOption,
|
||||
EnquiryAssignmentMutationPayload,
|
||||
EnquiryCustomerRelationItem,
|
||||
EnquiryFilters,
|
||||
EnquiryFollowupMutationPayload,
|
||||
@@ -34,6 +37,8 @@ const ENQUIRY_OPTION_CATEGORIES = {
|
||||
followupType: 'crm_followup_type'
|
||||
} as const;
|
||||
|
||||
const ASSIGNABLE_BUSINESS_ROLES = new Set(['sales_manager', 'sales', 'sales_support']);
|
||||
|
||||
function mapOption(
|
||||
option: Awaited<ReturnType<typeof getActiveOptionsByCategory>>[number]
|
||||
): EnquiryOption {
|
||||
@@ -56,7 +61,10 @@ function mapBranch(
|
||||
};
|
||||
}
|
||||
|
||||
function mapEnquiryRecord(row: typeof crmEnquiries.$inferSelect): EnquiryRecord {
|
||||
function mapEnquiryRecord(
|
||||
row: typeof crmEnquiries.$inferSelect,
|
||||
options?: { assignedToName?: string | null; assignedByName?: string | null }
|
||||
): EnquiryRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
organizationId: row.organizationId,
|
||||
@@ -81,6 +89,12 @@ function mapEnquiryRecord(row: typeof crmEnquiries.$inferSelect): EnquiryRecord
|
||||
notes: row.notes,
|
||||
isHotProject: row.isHotProject,
|
||||
isActive: row.isActive,
|
||||
assignedToUserId: row.assignedToUserId,
|
||||
assignedToName: options?.assignedToName ?? null,
|
||||
assignedAt: row.assignedAt?.toISOString() ?? null,
|
||||
assignedBy: row.assignedBy,
|
||||
assignedByName: options?.assignedByName ?? null,
|
||||
assignmentRemark: row.assignmentRemark,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
deletedAt: row.deletedAt?.toISOString() ?? null,
|
||||
@@ -220,6 +234,52 @@ export async function assertContactBelongsToOrganization(
|
||||
return contact;
|
||||
}
|
||||
|
||||
async function listAssignableUsers(organizationId: string): Promise<EnquiryAssignableUserLookup[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
userId: memberships.userId,
|
||||
membershipRole: memberships.role,
|
||||
businessRole: memberships.businessRole,
|
||||
name: users.name
|
||||
})
|
||||
.from(memberships)
|
||||
.innerJoin(users, eq(memberships.userId, users.id))
|
||||
.where(eq(memberships.organizationId, organizationId))
|
||||
.orderBy(asc(users.name));
|
||||
|
||||
const uniqueRows = [...new Map(rows.map((row) => [row.userId, row])).values()];
|
||||
|
||||
return uniqueRows
|
||||
.filter(
|
||||
(row) => row.membershipRole === 'admin' || ASSIGNABLE_BUSINESS_ROLES.has(row.businessRole)
|
||||
)
|
||||
.map<EnquiryAssignableUserLookup>((row) => ({
|
||||
id: row.userId,
|
||||
name: row.name,
|
||||
membershipRole: row.membershipRole === 'admin' ? 'admin' : 'user',
|
||||
businessRole: row.businessRole
|
||||
}));
|
||||
}
|
||||
|
||||
export async function assertAssignableUserBelongsToOrganization(
|
||||
userId: string,
|
||||
organizationId: string
|
||||
) {
|
||||
const [membership] = await db
|
||||
.select({
|
||||
userId: memberships.userId
|
||||
})
|
||||
.from(memberships)
|
||||
.where(and(eq(memberships.organizationId, organizationId), eq(memberships.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!membership) {
|
||||
throw new AuthError('Assignee not found in organization', 404);
|
||||
}
|
||||
|
||||
return membership;
|
||||
}
|
||||
|
||||
async function assertEnquiryBelongsToOrganization(id: string, organizationId: string) {
|
||||
const [enquiry] = await db
|
||||
.select()
|
||||
@@ -351,7 +411,8 @@ export async function getEnquiryReferenceData(
|
||||
leadChannels,
|
||||
followupTypes,
|
||||
customers,
|
||||
contacts
|
||||
contacts,
|
||||
assignableUsers
|
||||
] = await Promise.all([
|
||||
getUserBranches(),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.status, { organizationId }),
|
||||
@@ -373,7 +434,8 @@ export async function getEnquiryReferenceData(
|
||||
isNull(crmCustomerContacts.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(crmCustomerContacts.isPrimary), asc(crmCustomerContacts.name))
|
||||
.orderBy(desc(crmCustomerContacts.isPrimary), asc(crmCustomerContacts.name)),
|
||||
listAssignableUsers(organizationId)
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -396,7 +458,8 @@ export async function getEnquiryReferenceData(
|
||||
email: contact.email,
|
||||
mobile: contact.mobile,
|
||||
isPrimary: contact.isPrimary
|
||||
}))
|
||||
})),
|
||||
assignableUsers
|
||||
};
|
||||
}
|
||||
|
||||
@@ -421,9 +484,12 @@ export async function listEnquiries(
|
||||
|
||||
const customerIds = [...new Set(rows.map((row) => row.customerId))];
|
||||
const contactIds = [...new Set(rows.map((row) => row.contactId).filter(Boolean) as string[])];
|
||||
const assignedUserIds = [
|
||||
...new Set(rows.map((row) => row.assignedToUserId).filter(Boolean) as string[])
|
||||
];
|
||||
const enquiryIds = rows.map((row) => row.id);
|
||||
|
||||
const [customers, contacts, followupCounts] = await Promise.all([
|
||||
const [customers, contacts, assignedUsers, followupCounts] = await Promise.all([
|
||||
customerIds.length
|
||||
? db
|
||||
.select()
|
||||
@@ -446,6 +512,12 @@ export async function listEnquiries(
|
||||
)
|
||||
)
|
||||
: [],
|
||||
assignedUserIds.length
|
||||
? db
|
||||
.select({ id: users.id, name: users.name })
|
||||
.from(users)
|
||||
.where(inArray(users.id, assignedUserIds))
|
||||
: [],
|
||||
enquiryIds.length
|
||||
? db
|
||||
.select({
|
||||
@@ -466,11 +538,16 @@ export async function listEnquiries(
|
||||
|
||||
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 followupCountMap = new Map(followupCounts.map((item) => [item.enquiryId, item.value]));
|
||||
|
||||
return {
|
||||
items: rows.map((row) => ({
|
||||
...mapEnquiryRecord(row),
|
||||
...mapEnquiryRecord(row, {
|
||||
assignedToName: row.assignedToUserId
|
||||
? (assignedUserMap.get(row.assignedToUserId) ?? null)
|
||||
: null
|
||||
}),
|
||||
customerName: customerMap.get(row.customerId) ?? 'Unknown customer',
|
||||
contactName: row.contactId ? (contactMap.get(row.contactId) ?? null) : null,
|
||||
followupCount: followupCountMap.get(row.id) ?? 0
|
||||
@@ -481,7 +558,21 @@ export async function listEnquiries(
|
||||
|
||||
export async function getEnquiryDetail(id: string, organizationId: string): Promise<EnquiryRecord> {
|
||||
const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId);
|
||||
return mapEnquiryRecord(enquiry);
|
||||
const relatedUserIds = [enquiry.assignedToUserId, enquiry.assignedBy].filter(Boolean) as string[];
|
||||
const userRows = relatedUserIds.length
|
||||
? await db
|
||||
.select({ id: users.id, name: users.name })
|
||||
.from(users)
|
||||
.where(inArray(users.id, relatedUserIds))
|
||||
: [];
|
||||
const userMap = new Map(userRows.map((row) => [row.id, row.name]));
|
||||
|
||||
return mapEnquiryRecord(enquiry, {
|
||||
assignedToName: enquiry.assignedToUserId
|
||||
? (userMap.get(enquiry.assignedToUserId) ?? null)
|
||||
: null,
|
||||
assignedByName: enquiry.assignedBy ? (userMap.get(enquiry.assignedBy) ?? null) : null
|
||||
});
|
||||
}
|
||||
|
||||
export async function getEnquiryActivity(
|
||||
@@ -640,6 +731,59 @@ export async function softDeleteEnquiry(id: string, organizationId: string, user
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function updateEnquiryAssignment(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: EnquiryAssignmentMutationPayload,
|
||||
mode: 'assign' | 'reassign'
|
||||
) {
|
||||
const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId);
|
||||
|
||||
if (mode === 'assign' && enquiry.assignedToUserId) {
|
||||
throw new AuthError('Enquiry is already assigned. Use reassign instead.', 400);
|
||||
}
|
||||
|
||||
if (mode === 'reassign' && !enquiry.assignedToUserId) {
|
||||
throw new AuthError('Enquiry is not assigned yet. Use assign instead.', 400);
|
||||
}
|
||||
|
||||
await assertAssignableUserBelongsToOrganization(payload.assignedToUserId, organizationId);
|
||||
|
||||
const [updated] = await db
|
||||
.update(crmEnquiries)
|
||||
.set({
|
||||
assignedToUserId: payload.assignedToUserId,
|
||||
assignedAt: new Date(),
|
||||
assignedBy: userId,
|
||||
assignmentRemark: payload.remark?.trim() || null,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: userId
|
||||
})
|
||||
.where(eq(crmEnquiries.id, id))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function assignEnquiryToUser(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: EnquiryAssignmentMutationPayload
|
||||
) {
|
||||
return updateEnquiryAssignment(id, organizationId, userId, payload, 'assign');
|
||||
}
|
||||
|
||||
export async function reassignEnquiryToUser(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: EnquiryAssignmentMutationPayload
|
||||
) {
|
||||
return updateEnquiryAssignment(id, organizationId, userId, payload, 'reassign');
|
||||
}
|
||||
|
||||
export async function listEnquiryFollowups(id: string, organizationId: string) {
|
||||
await assertEnquiryBelongsToOrganization(id, organizationId);
|
||||
const rows = await db
|
||||
|
||||
Reference in New Issue
Block a user