task-d.5.4
This commit is contained in:
@@ -39,7 +39,8 @@ export async function POST(request: NextRequest, { params }: Params) {
|
||||
message: 'Lead assigned successfully',
|
||||
leadId: result.leadId,
|
||||
enquiryId: result.enquiryId,
|
||||
enquiryCode: result.enquiryCode
|
||||
enquiryCode: result.enquiryCode,
|
||||
reusedExistingEnquiry: result.reusedExistingEnquiry
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { auth } from '@/auth';
|
||||
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
||||
import { notFound } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import PageContainer from '@/components/layout/page-container';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
enquiryByIdOptions,
|
||||
enquiryFollowupsOptions,
|
||||
enquiryProjectPartiesOptions
|
||||
} from '@/features/crm/enquiries/api/queries';
|
||||
import { EnquiryDetail } from '@/features/crm/enquiries/components/enquiry-detail';
|
||||
import { getEnquiryReferenceData } from '@/features/crm/enquiries/server/service';
|
||||
import { getEnquiryDetail, getEnquiryReferenceData } from '@/features/crm/enquiries/server/service';
|
||||
import { getLeadById } from '@/features/crm/leads/server/service';
|
||||
import { listEnquiryQuotationRelations } from '@/features/crm/quotations/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { getQueryClient } from '@/lib/query-client';
|
||||
@@ -19,117 +23,146 @@ type PageProps = {
|
||||
export default async function EnquiryDetailRoute({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
const session = await auth();
|
||||
|
||||
const canRead =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryRead)));
|
||||
|
||||
const canUpdate =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryUpdate)));
|
||||
const canFollowupCreate =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryFollowupCreate)));
|
||||
const canFollowupUpdate =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryFollowupUpdate)));
|
||||
const canFollowupDelete =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryFollowupDelete)));
|
||||
|
||||
const canAssign =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryAssign)));
|
||||
|
||||
const canReassign =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryReassign)));
|
||||
const canReadQuotation =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationRead)));
|
||||
const canViewOutcomeCommercialData =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationPricingRead)));
|
||||
|
||||
const canManageFollowups = {
|
||||
create:
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryFollowupCreate))),
|
||||
update:
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryFollowupUpdate))),
|
||||
delete:
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryFollowupDelete)))
|
||||
};
|
||||
|
||||
const canMarkWon =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryMarkWon)));
|
||||
|
||||
const canMarkLost =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryMarkLost)));
|
||||
|
||||
const canReopen =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryReopen)));
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
if (canRead) {
|
||||
void queryClient.prefetchQuery(enquiryByIdOptions(id));
|
||||
void queryClient.prefetchQuery(enquiryProjectPartiesOptions(id));
|
||||
void queryClient.prefetchQuery(enquiryFollowupsOptions(id));
|
||||
const canViewRelatedQuotations =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationRead)));
|
||||
|
||||
const canViewOutcomeCommercialData = canRead;
|
||||
|
||||
if (!canRead || !session?.user?.activeOrganizationId || !session.user.id) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const referenceData =
|
||||
canRead && session?.user?.activeOrganizationId
|
||||
? await getEnquiryReferenceData(session.user.activeOrganizationId)
|
||||
: null;
|
||||
const relatedQuotations =
|
||||
canRead && canReadQuotation && session?.user?.activeOrganizationId
|
||||
? await listEnquiryQuotationRelations(id, session.user.activeOrganizationId)
|
||||
: [];
|
||||
const accessContext = {
|
||||
organizationId: session.user.activeOrganizationId,
|
||||
userId: session.user.id,
|
||||
membershipRole: session.user.activeMembershipRole ?? 'user',
|
||||
businessRole: session.user.activeBusinessRole ?? 'sales_support',
|
||||
businessRoles: [session.user.activeBusinessRole ?? 'sales_support'],
|
||||
permissions: session.user.activePermissions ?? [],
|
||||
branchScopeIds: session.user.activeBranchScopeIds ?? [],
|
||||
productTypeScopeIds: session.user.activeProductTypeScopeIds ?? [],
|
||||
ownershipScope: session.user.activeOwnershipScope ?? 'organization',
|
||||
branchScopeMode: 'all',
|
||||
productScopeMode: 'all',
|
||||
approvalAuthority: null
|
||||
} as const;
|
||||
|
||||
const queryClient = getQueryClient();
|
||||
await Promise.all([
|
||||
queryClient.prefetchQuery(enquiryByIdOptions(id)),
|
||||
queryClient.prefetchQuery(enquiryFollowupsOptions(id)),
|
||||
queryClient.prefetchQuery(enquiryProjectPartiesOptions(id))
|
||||
]);
|
||||
|
||||
const enquiryDetail = await getEnquiryDetail(id, session.user.activeOrganizationId, accessContext);
|
||||
const [referenceData, relatedQuotations, sourceLead] = await Promise.all([
|
||||
getEnquiryReferenceData(session.user.activeOrganizationId),
|
||||
listEnquiryQuotationRelations(id, session.user.activeOrganizationId),
|
||||
enquiryDetail.leadId
|
||||
? getLeadById(enquiryDetail.leadId, session.user.activeOrganizationId).catch(() => null)
|
||||
: Promise.resolve(null)
|
||||
]);
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
pageTitle='รายละเอียดโอกาสขาย'
|
||||
pageDescription='ข้อมูลโอกาสขาย ไทม์ไลน์การติดตาม และสถานะการดูแลของฝ่ายขาย'
|
||||
access={canRead}
|
||||
accessFallback={
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
||||
You do not have access to this enquiry.
|
||||
</div>
|
||||
}
|
||||
pageDescription='พื้นที่ทำงานของฝ่ายขายสำหรับติดตาม execution ของ enquiry และเชื่อมกับ quotation'
|
||||
>
|
||||
{referenceData ? (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<EnquiryDetail
|
||||
enquiryId={id}
|
||||
referenceData={referenceData}
|
||||
relatedQuotations={relatedQuotations}
|
||||
workspace='enquiry'
|
||||
canUpdate={canUpdate}
|
||||
canAssign={canAssign}
|
||||
canReassign={canReassign}
|
||||
canViewRelatedQuotations={canReadQuotation}
|
||||
canViewOutcomeCommercialData={canViewOutcomeCommercialData}
|
||||
canMarkWon={canMarkWon}
|
||||
canMarkLost={canMarkLost}
|
||||
canReopen={canReopen}
|
||||
canManageFollowups={{
|
||||
create: canFollowupCreate,
|
||||
update: canFollowupUpdate,
|
||||
delete: canFollowupDelete
|
||||
}}
|
||||
/>
|
||||
</HydrationBoundary>
|
||||
{sourceLead ? (
|
||||
<Card className='mb-6'>
|
||||
<CardHeader>
|
||||
<CardTitle>Source Lead</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='text-sm'>
|
||||
<Link href={`/dashboard/crm/leads/${sourceLead.id}`} className='font-medium hover:underline'>
|
||||
{sourceLead.code}
|
||||
</Link>
|
||||
<div className='text-muted-foreground mt-1'>{sourceLead.projectName ?? 'No project name'}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<EnquiryDetail
|
||||
enquiryId={id}
|
||||
referenceData={referenceData}
|
||||
relatedQuotations={relatedQuotations}
|
||||
workspace='enquiry'
|
||||
canUpdate={canUpdate}
|
||||
canAssign={canAssign}
|
||||
canReassign={canReassign}
|
||||
canViewRelatedQuotations={canViewRelatedQuotations}
|
||||
canViewOutcomeCommercialData={canViewOutcomeCommercialData}
|
||||
canMarkWon={canMarkWon}
|
||||
canMarkLost={canMarkLost}
|
||||
canReopen={canReopen}
|
||||
canManageFollowups={canManageFollowups}
|
||||
/>
|
||||
</HydrationBoundary>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { auth } from '@/auth';
|
||||
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import PageContainer from '@/components/layout/page-container';
|
||||
import {
|
||||
enquiryByIdOptions,
|
||||
enquiryFollowupsOptions,
|
||||
enquiryProjectPartiesOptions
|
||||
} from '@/features/crm/enquiries/api/queries';
|
||||
import { EnquiryDetail } from '@/features/crm/enquiries/components/enquiry-detail';
|
||||
import { getEnquiryDetail, getEnquiryReferenceData } from '@/features/crm/enquiries/server/service';
|
||||
import { leadByIdOptions } from '@/features/crm/leads/api/queries';
|
||||
import { LeadDetail } from '@/features/crm/leads/components/lead-detail';
|
||||
import { getLeadReferenceData } from '@/features/crm/leads/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { getQueryClient } from '@/lib/query-client';
|
||||
|
||||
@@ -19,91 +14,57 @@ type PageProps = {
|
||||
export default async function LeadDetailRoute({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
const session = await auth();
|
||||
|
||||
const canRead =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmLeadRead)));
|
||||
|
||||
const canUpdate =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmLeadUpdate)));
|
||||
|
||||
const canDelete =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmLeadDelete)));
|
||||
|
||||
const canAssign =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmLeadAssign)));
|
||||
|
||||
if (!canRead || !session?.user?.activeOrganizationId) {
|
||||
return (
|
||||
<PageContainer access={false} pageTitle='Lead Detail'>
|
||||
{null}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
if (!session?.user?.activeOrganizationId) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const enquiry = canRead
|
||||
? await getEnquiryDetail(id, session.user.activeOrganizationId, {
|
||||
organizationId: session.user.activeOrganizationId,
|
||||
userId: session.user.id,
|
||||
membershipRole: session.user.activeMembershipRole ?? 'user',
|
||||
businessRole: session.user.activeBusinessRole ?? 'sales_support',
|
||||
permissions: session.user.activePermissions ?? [],
|
||||
branchScopeIds: session.user.activeBranchScopeIds ?? [],
|
||||
productTypeScopeIds: session.user.activeProductTypeScopeIds ?? [],
|
||||
ownershipScope: session.user.activeOwnershipScope ?? 'organization',
|
||||
branchScopeMode: 'all',
|
||||
productScopeMode: 'all'
|
||||
})
|
||||
: null;
|
||||
|
||||
if (enquiry && enquiry.pipelineStage !== 'lead') {
|
||||
redirect(`/dashboard/crm/enquiries/${id}`);
|
||||
}
|
||||
|
||||
if (canRead) {
|
||||
void queryClient.prefetchQuery(enquiryByIdOptions(id));
|
||||
void queryClient.prefetchQuery(enquiryProjectPartiesOptions(id));
|
||||
void queryClient.prefetchQuery(enquiryFollowupsOptions(id));
|
||||
}
|
||||
|
||||
const referenceData =
|
||||
canRead && session.user.activeOrganizationId
|
||||
? await getEnquiryReferenceData(session.user.activeOrganizationId)
|
||||
: null;
|
||||
await queryClient.prefetchQuery(leadByIdOptions(id));
|
||||
const referenceData = await getLeadReferenceData(session.user.activeOrganizationId);
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
pageTitle='รายละเอียดลีด'
|
||||
pageDescription='ข้อมูลลีดสำหรับการคัดกรอง ติดตาม และส่งต่อให้ฝ่ายขาย'
|
||||
access={canRead}
|
||||
accessFallback={
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
||||
You do not have access to this lead.
|
||||
</div>
|
||||
}
|
||||
pageTitle='Lead Detail'
|
||||
pageDescription='Separated lead workspace for marketing qualification and sales handoff.'
|
||||
>
|
||||
{referenceData ? (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<EnquiryDetail
|
||||
enquiryId={id}
|
||||
referenceData={referenceData}
|
||||
relatedQuotations={[]}
|
||||
workspace='lead'
|
||||
canUpdate={canUpdate}
|
||||
canAssign={canAssign}
|
||||
canReassign={false}
|
||||
canViewRelatedQuotations={false}
|
||||
canViewOutcomeCommercialData={false}
|
||||
canMarkWon={false}
|
||||
canMarkLost={false}
|
||||
canReopen={false}
|
||||
canManageFollowups={{
|
||||
create: false,
|
||||
update: false,
|
||||
delete: false
|
||||
}}
|
||||
/>
|
||||
</HydrationBoundary>
|
||||
) : null}
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<LeadDetail
|
||||
leadId={id}
|
||||
referenceData={referenceData}
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
canAssign={canAssign}
|
||||
/>
|
||||
</HydrationBoundary>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { auth } from '@/auth';
|
||||
import PageContainer from '@/components/layout/page-container';
|
||||
import EnquiryListing from '@/features/crm/enquiries/components/enquiry-listing';
|
||||
import { EnquiryFormSheetTrigger } from '@/features/crm/enquiries/components/enquiry-form-sheet';
|
||||
import { getEnquiryReferenceData } from '@/features/crm/enquiries/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { searchParamsCache } from '@/lib/searchparams';
|
||||
import type { SearchParams } from 'nuqs/server';
|
||||
import { auth } from "@/auth";
|
||||
import PageContainer from "@/components/layout/page-container";
|
||||
import { LeadFormTrigger } from "@/features/crm/leads/components/lead-form";
|
||||
import { LeadList } from "@/features/crm/leads/components/lead-list";
|
||||
import { getLeadReferenceData } from "@/features/crm/leads/server/service";
|
||||
import { PERMISSIONS } from "@/lib/auth/rbac";
|
||||
import { searchParamsCache } from "@/lib/searchparams";
|
||||
import type { SearchParams } from "nuqs/server";
|
||||
|
||||
export const metadata = {
|
||||
title: 'Dashboard: CRM Leads'
|
||||
title: "Dashboard: CRM Leads",
|
||||
};
|
||||
|
||||
type PageProps = {
|
||||
@@ -18,63 +18,63 @@ type PageProps = {
|
||||
export default async function LeadsRoute(props: PageProps) {
|
||||
const searchParams = await props.searchParams;
|
||||
const session = await auth();
|
||||
|
||||
const canRead =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
session?.user?.systemRole === "super_admin" ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
(session.user.activeMembershipRole === "admin" ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmLeadRead)));
|
||||
|
||||
const canCreate =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
session?.user?.systemRole === "super_admin" ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
(session.user.activeMembershipRole === "admin" ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmLeadCreate)));
|
||||
|
||||
const canUpdate =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
session?.user?.systemRole === "super_admin" ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
(session.user.activeMembershipRole === "admin" ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmLeadUpdate)));
|
||||
|
||||
const canDelete =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
session?.user?.systemRole === "super_admin" ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
(session.user.activeMembershipRole === "admin" ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmLeadDelete)));
|
||||
|
||||
const canAssign =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
session?.user?.systemRole === "super_admin" ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
(session.user.activeMembershipRole === "admin" ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmLeadAssign)));
|
||||
|
||||
if (!canRead || !session?.user?.activeOrganizationId) {
|
||||
return (
|
||||
<PageContainer access={false} pageTitle="CRM Leads">
|
||||
{null}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
searchParamsCache.parse(searchParams);
|
||||
|
||||
const referenceData =
|
||||
canRead && session?.user?.activeOrganizationId
|
||||
? await getEnquiryReferenceData(session.user.activeOrganizationId)
|
||||
: null;
|
||||
const referenceData = await getLeadReferenceData(
|
||||
session.user.activeOrganizationId,
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
pageTitle='ลีด'
|
||||
pageDescription='พื้นที่ทำงานของการตลาดสำหรับเก็บลีด คัดกรองความพร้อม และส่งต่อให้ฝ่ายขาย'
|
||||
access={canRead}
|
||||
accessFallback={
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
||||
You do not have access to CRM leads.
|
||||
</div>
|
||||
}
|
||||
pageTitle="CRM Leads"
|
||||
pageDescription="Marketing-qualified lead workspace before handoff into sales enquiries."
|
||||
pageHeaderAction={
|
||||
canCreate && referenceData ? (
|
||||
<EnquiryFormSheetTrigger referenceData={referenceData} workspace='lead' />
|
||||
) : null
|
||||
canCreate ? <LeadFormTrigger referenceData={referenceData} /> : null
|
||||
}
|
||||
>
|
||||
{referenceData ? (
|
||||
<EnquiryListing
|
||||
<LeadList
|
||||
referenceData={referenceData}
|
||||
workspace='lead'
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
canAssign={canAssign}
|
||||
canReassign={false}
|
||||
/>
|
||||
) : null}
|
||||
</PageContainer>
|
||||
|
||||
@@ -81,6 +81,7 @@ export interface EnquiryRecord {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
branchId: string | null;
|
||||
leadId: string | null;
|
||||
code: string;
|
||||
customerId: string;
|
||||
contactId: string | null;
|
||||
|
||||
@@ -125,6 +125,7 @@ function mapEnquiryRecord(
|
||||
id: row.id,
|
||||
organizationId: row.organizationId,
|
||||
branchId: row.branchId,
|
||||
leadId: row.leadId ?? null,
|
||||
code: row.code,
|
||||
customerId: row.customerId,
|
||||
contactId: row.contactId,
|
||||
|
||||
75
src/features/crm/leads/api/mutations.ts
Normal file
75
src/features/crm/leads/api/mutations.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { mutationOptions } from '@tanstack/react-query';
|
||||
import { getQueryClient } from '@/lib/query-client';
|
||||
import { assignLead, createLead, createLeadFollowup, deleteLead, updateLead } from './service';
|
||||
import { leadKeys } from './queries';
|
||||
import type { CreateLeadFollowupInput, CreateLeadInput, UpdateLeadInput } from './types';
|
||||
|
||||
async function invalidateLeadLists() {
|
||||
await getQueryClient().invalidateQueries({ queryKey: leadKeys.lists() });
|
||||
}
|
||||
|
||||
async function invalidateLeadDetail(id: string) {
|
||||
await getQueryClient().invalidateQueries({ queryKey: leadKeys.detail(id) });
|
||||
}
|
||||
|
||||
export async function invalidateLeadMutationQueries(id: string) {
|
||||
await Promise.all([invalidateLeadLists(), invalidateLeadDetail(id)]);
|
||||
}
|
||||
|
||||
export const createLeadMutation = mutationOptions({
|
||||
mutationFn: (values: CreateLeadInput) => createLead(values),
|
||||
onSettled: async (data, error) => {
|
||||
if (!error && data?.lead?.id) {
|
||||
await invalidateLeadMutationQueries(data.lead.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!error) {
|
||||
await invalidateLeadLists();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const updateLeadMutation = mutationOptions({
|
||||
mutationFn: ({ id, values }: { id: string; values: UpdateLeadInput }) => updateLead(id, values),
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await invalidateLeadMutationQueries(variables.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteLeadMutation = mutationOptions({
|
||||
mutationFn: ({ id }: { id: string }) => deleteLead(id),
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await invalidateLeadLists();
|
||||
getQueryClient().removeQueries({ queryKey: leadKeys.detail(variables.id) });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const createLeadFollowupMutation = mutationOptions({
|
||||
mutationFn: ({ leadId, values }: { leadId: string; values: CreateLeadFollowupInput }) =>
|
||||
createLeadFollowup(leadId, values),
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await invalidateLeadDetail(variables.leadId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const assignLeadMutation = mutationOptions({
|
||||
mutationFn: ({
|
||||
leadId,
|
||||
values
|
||||
}: {
|
||||
leadId: string;
|
||||
values: { salesOwnerId: string; assignmentRemark?: string | null };
|
||||
}) => assignLead(leadId, values),
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await invalidateLeadMutationQueries(variables.leadId);
|
||||
}
|
||||
}
|
||||
});
|
||||
23
src/features/crm/leads/api/queries.ts
Normal file
23
src/features/crm/leads/api/queries.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
import { getLeadById, getLeads } from './service';
|
||||
import type { LeadListFilters } from './types';
|
||||
|
||||
export const leadKeys = {
|
||||
all: ['crm-leads'] as const,
|
||||
lists: () => [...leadKeys.all, 'list'] as const,
|
||||
list: (filters: LeadListFilters) => [...leadKeys.lists(), filters] as const,
|
||||
details: () => [...leadKeys.all, 'detail'] as const,
|
||||
detail: (id: string) => [...leadKeys.details(), id] as const
|
||||
};
|
||||
|
||||
export const leadsQueryOptions = (filters: LeadListFilters) =>
|
||||
queryOptions({
|
||||
queryKey: leadKeys.list(filters),
|
||||
queryFn: () => getLeads(filters)
|
||||
});
|
||||
|
||||
export const leadByIdOptions = (id: string) =>
|
||||
queryOptions({
|
||||
queryKey: leadKeys.detail(id),
|
||||
queryFn: () => getLeadById(id)
|
||||
});
|
||||
79
src/features/crm/leads/api/service.ts
Normal file
79
src/features/crm/leads/api/service.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type {
|
||||
AssignLeadResult,
|
||||
CreateLeadFollowupInput,
|
||||
CreateLeadInput,
|
||||
LeadDetailResponse,
|
||||
LeadListFilters,
|
||||
LeadListResponse,
|
||||
LeadMutationSuccessResponse,
|
||||
UpdateLeadInput
|
||||
} from './types';
|
||||
|
||||
export async function getLeads(filters: LeadListFilters): Promise<LeadListResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (filters.page) searchParams.set('page', String(filters.page));
|
||||
if (filters.limit) searchParams.set('limit', String(filters.limit));
|
||||
if (filters.search) searchParams.set('search', filters.search);
|
||||
if (filters.branchId) searchParams.set('branchId', filters.branchId);
|
||||
if (filters.customerId) searchParams.set('customerId', filters.customerId);
|
||||
if (filters.status) searchParams.set('status', filters.status);
|
||||
if (filters.awarenessId) searchParams.set('awarenessId', filters.awarenessId);
|
||||
if (filters.followupStatus) searchParams.set('followupStatus', filters.followupStatus);
|
||||
if (filters.outcome) searchParams.set('outcome', filters.outcome);
|
||||
if (filters.ownerMarketingUserId) {
|
||||
searchParams.set('ownerMarketingUserId', filters.ownerMarketingUserId);
|
||||
}
|
||||
if (filters.sort) searchParams.set('sort', filters.sort);
|
||||
|
||||
const query = searchParams.toString();
|
||||
return apiClient<LeadListResponse>(`/crm/leads${query ? `?${query}` : ''}`);
|
||||
}
|
||||
|
||||
export async function getLeadById(id: string): Promise<LeadDetailResponse> {
|
||||
return apiClient<LeadDetailResponse>(`/crm/leads/${id}`);
|
||||
}
|
||||
|
||||
export async function createLead(data: CreateLeadInput): Promise<LeadMutationSuccessResponse> {
|
||||
return apiClient<LeadMutationSuccessResponse>('/crm/leads', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateLead(
|
||||
id: string,
|
||||
data: UpdateLeadInput
|
||||
): Promise<LeadMutationSuccessResponse> {
|
||||
return apiClient<LeadMutationSuccessResponse>(`/crm/leads/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteLead(id: string): Promise<LeadMutationSuccessResponse> {
|
||||
return apiClient<LeadMutationSuccessResponse>(`/crm/leads/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
export async function createLeadFollowup(
|
||||
leadId: string,
|
||||
data: CreateLeadFollowupInput
|
||||
): Promise<LeadMutationSuccessResponse> {
|
||||
return apiClient<LeadMutationSuccessResponse>(`/crm/leads/${leadId}/followups`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function assignLead(
|
||||
leadId: string,
|
||||
data: { salesOwnerId: string; assignmentRemark?: string | null }
|
||||
): Promise<AssignLeadResult> {
|
||||
return apiClient<AssignLeadResult>(`/crm/leads/${leadId}/assign`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
20
src/features/crm/leads/api/types.ts
Normal file
20
src/features/crm/leads/api/types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export type {
|
||||
AssignLeadInput,
|
||||
AssignLeadResult,
|
||||
CreateLeadFollowupInput,
|
||||
CreateLeadInput,
|
||||
LeadActivityRecord,
|
||||
LeadAssignableUserLookup,
|
||||
LeadBranchOption,
|
||||
LeadDetail,
|
||||
LeadDetailResponse,
|
||||
LeadFollowupSummary,
|
||||
LeadLinkedEnquirySummary,
|
||||
LeadListFilters,
|
||||
LeadListResponse,
|
||||
LeadMutationSuccessResponse,
|
||||
LeadOption,
|
||||
LeadReferenceData,
|
||||
LeadSummary,
|
||||
UpdateLeadInput
|
||||
} from '../types';
|
||||
149
src/features/crm/leads/components/lead-assignment-dialog.tsx
Normal file
149
src/features/crm/leads/components/lead-assignment-dialog.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog';
|
||||
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
|
||||
import type { LeadDetail, LeadReferenceData, LeadSummary } from '../api/types';
|
||||
import { assignLeadMutation } from '../api/mutations';
|
||||
|
||||
type AssignmentFormValues = {
|
||||
salesOwnerId: string;
|
||||
assignmentRemark: string | null;
|
||||
};
|
||||
|
||||
function toDefaultValues(
|
||||
lead: LeadSummary | LeadDetail,
|
||||
referenceData: LeadReferenceData
|
||||
): AssignmentFormValues {
|
||||
return {
|
||||
salesOwnerId:
|
||||
lead.assignedSalesOwnerId ??
|
||||
lead.suggestedSalesOwnerId ??
|
||||
referenceData.assignableUsers[0]?.id ??
|
||||
'',
|
||||
assignmentRemark: lead.assignmentRemark ?? null
|
||||
};
|
||||
}
|
||||
|
||||
export function LeadAssignmentDialog({
|
||||
lead,
|
||||
referenceData,
|
||||
open,
|
||||
onOpenChange
|
||||
}: {
|
||||
lead: LeadSummary | LeadDetail;
|
||||
referenceData: LeadReferenceData;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const defaultValues = useMemo(() => toDefaultValues(lead, referenceData), [lead, referenceData]);
|
||||
const { FormSelectField, FormTextareaField } = useFormFields<AssignmentFormValues>();
|
||||
|
||||
const mutation = useMutation({
|
||||
...assignLeadMutation,
|
||||
onSuccess: (result) => {
|
||||
toast.success(
|
||||
result.reusedExistingEnquiry
|
||||
? `มอบหมายสำเร็จและใช้ enquiry เดิม ${result.enquiryCode}`
|
||||
: `มอบหมายสำเร็จและสร้าง enquiry ${result.enquiryCode}`
|
||||
);
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : 'ไม่สามารถมอบหมายลีดได้');
|
||||
}
|
||||
});
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues,
|
||||
onSubmit: async ({ value }) => {
|
||||
await mutation.mutateAsync({
|
||||
leadId: lead.id,
|
||||
values: {
|
||||
salesOwnerId: value.salesOwnerId,
|
||||
assignmentRemark: value.assignmentRemark || null
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.reset(defaultValues);
|
||||
}
|
||||
}, [defaultValues, form, open]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Assign Lead to Sales</DialogTitle>
|
||||
<DialogDescription>
|
||||
มอบหมายลีดให้ฝ่ายขายและสร้าง enquiry workspace แยกจาก lead workspace
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form.AppForm>
|
||||
<form.Form id='lead-assignment-form' className='space-y-4'>
|
||||
<FormSelectField
|
||||
name='salesOwnerId'
|
||||
label='Sales Owner'
|
||||
options={referenceData.assignableUsers.map((user) => ({
|
||||
value: user.id,
|
||||
label: user.name
|
||||
}))}
|
||||
/>
|
||||
<FormTextareaField
|
||||
name='assignmentRemark'
|
||||
label='Assignment Remark'
|
||||
placeholder='Optional handoff context for sales'
|
||||
/>
|
||||
</form.Form>
|
||||
</form.AppForm>
|
||||
|
||||
{lead.suggestedSalesOwnerName ? (
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
Suggested customer owner: {lead.suggestedSalesOwnerName}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={() => onOpenChange(false)} disabled={mutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' form='lead-assignment-form' isLoading={mutation.isPending}>
|
||||
Assign
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
{'linkedEnquiries' in lead && lead.linkedEnquiries.length > 0 ? (
|
||||
<div className='border-border rounded-md border p-3 text-sm'>
|
||||
<div className='mb-2 font-medium'>Linked enquiries</div>
|
||||
<div className='space-y-1'>
|
||||
{lead.linkedEnquiries.map((enquiry) => (
|
||||
<Link
|
||||
key={enquiry.id}
|
||||
href={`/dashboard/crm/enquiries/${enquiry.id}`}
|
||||
className='block hover:underline'
|
||||
>
|
||||
{enquiry.code} - {enquiry.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
91
src/features/crm/leads/components/lead-cell-action.tsx
Normal file
91
src/features/crm/leads/components/lead-cell-action.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { AlertModal } from '@/components/modal/alert-modal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { deleteLeadMutation } from '../api/mutations';
|
||||
import type { LeadReferenceData, LeadSummary } from '../api/types';
|
||||
import { LeadAssignmentDialog } from './lead-assignment-dialog';
|
||||
import { LeadForm } from './lead-form';
|
||||
|
||||
export function LeadCellAction({
|
||||
data,
|
||||
referenceData,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canAssign
|
||||
}: {
|
||||
data: LeadSummary;
|
||||
referenceData: LeadReferenceData;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
canAssign: boolean;
|
||||
}) {
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [assignOpen, setAssignOpen] = useState(false);
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
...deleteLeadMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('ลบลีดสำเร็จ');
|
||||
setDeleteOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : 'ไม่สามารถลบลีดได้');
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<LeadForm open={editOpen} onOpenChange={setEditOpen} referenceData={referenceData} lead={data} />
|
||||
<LeadAssignmentDialog
|
||||
lead={data}
|
||||
referenceData={referenceData}
|
||||
open={assignOpen}
|
||||
onOpenChange={setAssignOpen}
|
||||
/>
|
||||
<AlertModal
|
||||
isOpen={deleteOpen}
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
onConfirm={() => deleteMutation.mutate({ id: data.id })}
|
||||
loading={deleteMutation.isPending}
|
||||
/>
|
||||
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='ghost' className='h-8 w-8 p-0'>
|
||||
<span className='sr-only'>Open menu</span>
|
||||
<Icons.ellipsis className='h-4 w-4' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
{canUpdate ? (
|
||||
<DropdownMenuItem onClick={() => setEditOpen(true)}>Edit lead</DropdownMenuItem>
|
||||
) : null}
|
||||
{canAssign ? (
|
||||
<DropdownMenuItem onClick={() => setAssignOpen(true)}>
|
||||
{data.assignedSalesOwnerId ? 'Reassign to sales' : 'Assign to sales'}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{canDelete ? (
|
||||
<DropdownMenuItem className='text-destructive' onClick={() => setDeleteOpen(true)}>
|
||||
Delete lead
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
164
src/features/crm/leads/components/lead-columns.tsx
Normal file
164
src/features/crm/leads/components/lead-columns.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import type { Column, ColumnDef } from '@tanstack/react-table';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header';
|
||||
import type { LeadReferenceData, LeadSummary } from '../api/types';
|
||||
import { LeadCellAction } from './lead-cell-action';
|
||||
|
||||
export function getLeadColumns({
|
||||
referenceData,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canAssign
|
||||
}: {
|
||||
referenceData: LeadReferenceData;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
canAssign: boolean;
|
||||
}): ColumnDef<LeadSummary>[] {
|
||||
return [
|
||||
{
|
||||
id: 'code',
|
||||
accessorKey: 'code',
|
||||
header: ({ column }: { column: Column<LeadSummary, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Lead Code' />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className='flex flex-col'>
|
||||
<Link href={`/dashboard/crm/leads/${row.original.id}`} className='font-medium hover:underline'>
|
||||
{row.original.code}
|
||||
</Link>
|
||||
<span className='text-muted-foreground text-xs'>{row.original.customerName ?? '-'}</span>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
label: 'Lead Code',
|
||||
placeholder: 'Search leads...',
|
||||
variant: 'text' as const,
|
||||
icon: Icons.search
|
||||
},
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
accessorFn: (row) => row.status,
|
||||
header: ({ column }: { column: Column<LeadSummary, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Status' />
|
||||
),
|
||||
cell: ({ row }) => <Badge variant='outline'>{row.original.statusLabel ?? row.original.status}</Badge>,
|
||||
meta: {
|
||||
label: 'Status',
|
||||
variant: 'select' as const,
|
||||
options: referenceData.statuses.map((item) => ({
|
||||
label: item.label,
|
||||
value: item.id
|
||||
}))
|
||||
},
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'branchId',
|
||||
accessorFn: (row) => row.branchId ?? '',
|
||||
header: ({ column }: { column: Column<LeadSummary, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Branch' />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const branch = referenceData.branches.find((item) => item.id === row.original.branchId);
|
||||
return <span>{branch?.name ?? '-'}</span>;
|
||||
},
|
||||
meta: {
|
||||
label: 'Branch',
|
||||
variant: 'select' as const,
|
||||
options: referenceData.branches.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id
|
||||
}))
|
||||
},
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'customerId',
|
||||
accessorFn: (row) => row.customerId ?? '',
|
||||
header: ({ column }: { column: Column<LeadSummary, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Customer' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.customerName ?? '-'}</span>,
|
||||
meta: {
|
||||
label: 'Customer',
|
||||
variant: 'select' as const,
|
||||
options: referenceData.customers.map((item) => ({
|
||||
label: `${item.name} (${item.code})`,
|
||||
value: item.id
|
||||
}))
|
||||
},
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'projectName',
|
||||
accessorKey: 'projectName',
|
||||
header: ({ column }: { column: Column<LeadSummary, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Project' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.projectName ?? '-'}</span>
|
||||
},
|
||||
{
|
||||
id: 'priority',
|
||||
accessorFn: (row) => row.priority ?? '',
|
||||
header: ({ column }: { column: Column<LeadSummary, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Priority' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.priority ?? '-'}</span>
|
||||
},
|
||||
{
|
||||
id: 'suggestedSalesOwner',
|
||||
accessorFn: (row) => row.suggestedSalesOwnerId ?? '',
|
||||
header: ({ column }: { column: Column<LeadSummary, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Suggested Owner' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.suggestedSalesOwnerName ?? '-'}</span>
|
||||
},
|
||||
{
|
||||
id: 'assignedSalesOwner',
|
||||
accessorFn: (row) => row.assignedSalesOwnerId ?? '',
|
||||
header: ({ column }: { column: Column<LeadSummary, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Assigned Owner' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.assignedSalesOwnerName ?? '-'}</span>
|
||||
},
|
||||
{
|
||||
id: 'estimatedValue',
|
||||
accessorKey: 'estimatedValue',
|
||||
header: ({ column }: { column: Column<LeadSummary, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Estimated Value' />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span>
|
||||
{row.original.estimatedValue !== null ? row.original.estimatedValue.toLocaleString() : '-'}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'createdAt',
|
||||
accessorKey: 'createdAt',
|
||||
header: ({ column }: { column: Column<LeadSummary, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Created' />
|
||||
),
|
||||
cell: ({ row }) => <span>{new Date(row.original.createdAt).toLocaleDateString()}</span>
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => (
|
||||
<LeadCellAction
|
||||
data={row.original}
|
||||
referenceData={referenceData}
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
canAssign={canAssign}
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
}
|
||||
190
src/features/crm/leads/components/lead-detail.tsx
Normal file
190
src/features/crm/leads/components/lead-detail.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { leadByIdOptions } from '../api/queries';
|
||||
import type { LeadReferenceData } from '../api/types';
|
||||
import { LeadAssignmentDialog } from './lead-assignment-dialog';
|
||||
import { LeadFollowupPanel } from './lead-followup-panel';
|
||||
import { LeadForm } from './lead-form';
|
||||
|
||||
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
|
||||
return (
|
||||
<div className='space-y-1'>
|
||||
<div className='text-muted-foreground text-xs'>{label}</div>
|
||||
<div className='text-sm'>{value || '-'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LeadDetail({
|
||||
leadId,
|
||||
referenceData,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canAssign
|
||||
}: {
|
||||
leadId: string;
|
||||
referenceData: LeadReferenceData;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
canAssign: boolean;
|
||||
}) {
|
||||
const { data } = useSuspenseQuery(leadByIdOptions(leadId));
|
||||
const lead = data.lead;
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [assignOpen, setAssignOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<LeadForm open={editOpen} onOpenChange={setEditOpen} referenceData={referenceData} lead={lead} />
|
||||
<LeadAssignmentDialog
|
||||
lead={lead}
|
||||
referenceData={referenceData}
|
||||
open={assignOpen}
|
||||
onOpenChange={setAssignOpen}
|
||||
/>
|
||||
|
||||
<div className='flex items-start justify-between gap-4'>
|
||||
<div>
|
||||
<div className='flex items-center gap-3'>
|
||||
<h2 className='text-2xl font-semibold'>{lead.code}</h2>
|
||||
<Badge variant='outline'>{lead.statusLabel ?? lead.status}</Badge>
|
||||
</div>
|
||||
<p className='text-muted-foreground mt-2 text-sm'>
|
||||
Lead workspace for marketing qualification before sales enquiry handoff
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{canAssign ? (
|
||||
<Button variant='outline' onClick={() => setAssignOpen(true)}>
|
||||
{lead.assignedSalesOwnerId ? 'Reassign to Sales' : 'Assign to Sales'}
|
||||
</Button>
|
||||
) : null}
|
||||
{canUpdate ? (
|
||||
<Button variant='outline' onClick={() => setEditOpen(true)}>
|
||||
Edit Lead
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-6 lg:grid-cols-3'>
|
||||
<div className='space-y-6 lg:col-span-2'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Lead Summary</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
|
||||
<FieldItem label='Customer' value={lead.customerName} />
|
||||
<FieldItem label='Contact' value={lead.contactName} />
|
||||
<FieldItem label='Project Name' value={lead.projectName} />
|
||||
<FieldItem label='Project Location' value={lead.projectLocation} />
|
||||
<FieldItem label='Awareness' value={lead.awarenessLabel} />
|
||||
<FieldItem label='Priority' value={lead.priority} />
|
||||
<FieldItem label='Marketing Owner' value={lead.ownerMarketingName} />
|
||||
<FieldItem label='Suggested Sales Owner' value={lead.suggestedSalesOwnerName} />
|
||||
<FieldItem label='Assigned Sales Owner' value={lead.assignedSalesOwnerName} />
|
||||
<FieldItem
|
||||
label='Estimated Value'
|
||||
value={lead.estimatedValue !== null ? lead.estimatedValue.toLocaleString() : null}
|
||||
/>
|
||||
<FieldItem label='Assignment Remark' value={lead.assignmentRemark} />
|
||||
<FieldItem label='Description' value={lead.description} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Tabs defaultValue='followups' className='space-y-4'>
|
||||
<TabsList>
|
||||
<TabsTrigger value='followups'>Follow-ups</TabsTrigger>
|
||||
<TabsTrigger value='linked-enquiries'>Linked Enquiries</TabsTrigger>
|
||||
<TabsTrigger value='activity'>Activity</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='followups'>
|
||||
<LeadFollowupPanel leadId={leadId} detail={data} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='linked-enquiries'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Linked Enquiries</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-3'>
|
||||
{lead.linkedEnquiries.length === 0 ? (
|
||||
<div className='text-muted-foreground text-sm'>No linked enquiries yet.</div>
|
||||
) : (
|
||||
lead.linkedEnquiries.map((enquiry) => (
|
||||
<div key={enquiry.id} className='border-border rounded-md border p-3'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<Link
|
||||
href={`/dashboard/crm/enquiries/${enquiry.id}`}
|
||||
className='font-medium hover:underline'
|
||||
>
|
||||
{enquiry.code}
|
||||
</Link>
|
||||
<Badge variant='outline'>{enquiry.status}</Badge>
|
||||
</div>
|
||||
<div className='mt-1 text-sm'>{enquiry.title}</div>
|
||||
<div className='text-muted-foreground mt-2 text-xs'>
|
||||
Owner: {enquiry.assignedToName ?? '-'}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='activity'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Activity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-3'>
|
||||
{lead.activity.length === 0 ? (
|
||||
<div className='text-muted-foreground text-sm'>No activity yet.</div>
|
||||
) : (
|
||||
lead.activity.map((activity) => (
|
||||
<div key={activity.id} className='border-border rounded-md border p-3'>
|
||||
<div className='font-medium'>{activity.action}</div>
|
||||
<div className='text-muted-foreground mt-1 text-xs'>
|
||||
{activity.actorName ?? activity.userId} -{' '}
|
||||
{new Date(activity.createdAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className='space-y-6'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Workspace Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-3'>
|
||||
<FieldItem label='Current Workspace' value='Lead' />
|
||||
<FieldItem label='Related Enquiries' value={String(lead.relatedEnquiryCount)} />
|
||||
<FieldItem
|
||||
label='Assigned At'
|
||||
value={lead.assignedAt ? new Date(lead.assignedAt).toLocaleString() : null}
|
||||
/>
|
||||
<FieldItem label='Assigned By' value={lead.assignedByName} />
|
||||
<FieldItem label='Deleted' value={canDelete ? 'Allowed' : 'Not allowed'} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
src/features/crm/leads/components/lead-followup-panel.tsx
Normal file
120
src/features/crm/leads/components/lead-followup-panel.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
|
||||
import type { LeadDetailResponse } from '../api/types';
|
||||
import { createLeadFollowupMutation } from '../api/mutations';
|
||||
|
||||
type FollowupFormValues = {
|
||||
followupDate: string;
|
||||
followupStatus: string;
|
||||
note: string | null;
|
||||
nextFollowupDate: string | null;
|
||||
};
|
||||
|
||||
export function LeadFollowupPanel({
|
||||
leadId,
|
||||
detail
|
||||
}: {
|
||||
leadId: string;
|
||||
detail: LeadDetailResponse;
|
||||
}) {
|
||||
const { FormTextField, FormSelectField, FormTextareaField } = useFormFields<FollowupFormValues>();
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: {
|
||||
followupDate: '',
|
||||
followupStatus: detail.referenceData.followupStatuses[0]?.id ?? '',
|
||||
note: null,
|
||||
nextFollowupDate: null
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
await mutation.mutateAsync({
|
||||
leadId,
|
||||
values: {
|
||||
followupDate: value.followupDate,
|
||||
followupStatus: value.followupStatus,
|
||||
note: value.note || null,
|
||||
nextFollowupDate: value.nextFollowupDate || null
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
...createLeadFollowupMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('บันทึก follow-up สำเร็จ');
|
||||
form.reset();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : 'ไม่สามารถบันทึก follow-up ได้');
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>New Follow-up</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form.AppForm>
|
||||
<form.Form id='lead-followup-form' className='grid gap-4 md:grid-cols-2'>
|
||||
<FormTextField name='followupDate' label='Follow-up Date' placeholder='YYYY-MM-DD' />
|
||||
<FormTextField
|
||||
name='nextFollowupDate'
|
||||
label='Next Follow-up Date'
|
||||
placeholder='YYYY-MM-DD'
|
||||
/>
|
||||
<FormSelectField
|
||||
name='followupStatus'
|
||||
label='Follow-up Status'
|
||||
options={detail.referenceData.followupStatuses.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
}))}
|
||||
/>
|
||||
<div />
|
||||
<FormTextareaField name='note' label='Note' className='md:col-span-2' />
|
||||
<div className='md:col-span-2'>
|
||||
<Button type='submit' form='lead-followup-form' isLoading={mutation.isPending}>
|
||||
Save Follow-up
|
||||
</Button>
|
||||
</div>
|
||||
</form.Form>
|
||||
</form.AppForm>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>History</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-3'>
|
||||
{detail.followups.length === 0 ? (
|
||||
<div className='text-muted-foreground text-sm'>No follow-up history yet.</div>
|
||||
) : (
|
||||
detail.followups.map((followup) => (
|
||||
<div key={followup.id} className='border-border rounded-md border p-3'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='font-medium'>{followup.followupStatusLabel ?? followup.followupStatus}</div>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{new Date(followup.followupDate).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
{followup.note ? <div className='mt-2 text-sm'>{followup.note}</div> : null}
|
||||
<div className='text-muted-foreground mt-2 text-xs'>
|
||||
{followup.createdByName ?? followup.createdBy}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
323
src/features/crm/leads/components/lead-form.tsx
Normal file
323
src/features/crm/leads/components/lead-form.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle
|
||||
} from '@/components/ui/sheet';
|
||||
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
|
||||
import { createLeadMutation, updateLeadMutation } from '../api/mutations';
|
||||
import type { LeadReferenceData, LeadSummary } from '../api/types';
|
||||
import { leadSchema } from '../schemas/lead.schema';
|
||||
|
||||
type LeadFormValues = {
|
||||
branchId: string | null;
|
||||
customerId: string | null;
|
||||
contactId: string | null;
|
||||
description: string | null;
|
||||
projectName: string | null;
|
||||
projectLocation: string | null;
|
||||
productType: string | null;
|
||||
priority: string | null;
|
||||
estimatedValue: number | null;
|
||||
awarenessId: string | null;
|
||||
status: string;
|
||||
followupStatus: string | null;
|
||||
lostReason: string | null;
|
||||
outcome: 'open' | 'won' | 'lost';
|
||||
ownerMarketingUserId: string | null;
|
||||
};
|
||||
|
||||
function toDefaultValues(lead: LeadSummary | undefined, referenceData: LeadReferenceData): LeadFormValues {
|
||||
return {
|
||||
branchId: lead?.branchId ?? referenceData.branches[0]?.id ?? null,
|
||||
customerId: lead?.customerId ?? null,
|
||||
contactId: lead?.contactId ?? null,
|
||||
description: lead?.description ?? null,
|
||||
projectName: lead?.projectName ?? null,
|
||||
projectLocation: lead?.projectLocation ?? null,
|
||||
productType: lead?.productType ?? referenceData.productTypes[0]?.id ?? null,
|
||||
priority: lead?.priority ?? referenceData.priorities[0]?.id ?? null,
|
||||
estimatedValue: lead?.estimatedValue ?? null,
|
||||
awarenessId: lead?.awarenessId ?? referenceData.awarenesses[0]?.id ?? null,
|
||||
status: lead?.status ?? referenceData.statuses[0]?.id ?? '',
|
||||
followupStatus: lead?.followupStatus ?? null,
|
||||
lostReason: lead?.lostReason ?? null,
|
||||
outcome: (lead?.outcome as 'open' | 'won' | 'lost' | undefined) ?? 'open',
|
||||
ownerMarketingUserId: lead?.ownerMarketingUserId ?? null
|
||||
};
|
||||
}
|
||||
|
||||
export function LeadForm({
|
||||
open,
|
||||
onOpenChange,
|
||||
referenceData,
|
||||
lead
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
referenceData: LeadReferenceData;
|
||||
lead?: LeadSummary;
|
||||
}) {
|
||||
const isEdit = Boolean(lead);
|
||||
const defaultValues = useMemo(() => toDefaultValues(lead, referenceData), [lead, referenceData]);
|
||||
const [selectedCustomerId, setSelectedCustomerId] = useState(defaultValues.customerId ?? '');
|
||||
const { FormTextField, FormTextareaField, FormSelectField, FormCurrencyField } =
|
||||
useFormFields<LeadFormValues>();
|
||||
|
||||
const createMutation = useMutation({
|
||||
...createLeadMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('สร้างลีดสำเร็จ');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : 'ไม่สามารถสร้างลีดได้');
|
||||
}
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
...updateLeadMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('อัปเดตลีดสำเร็จ');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : 'ไม่สามารถอัปเดตลีดได้');
|
||||
}
|
||||
});
|
||||
|
||||
const contactsForCustomer = referenceData.contacts.filter(
|
||||
(contact) => contact.customerId === selectedCustomerId
|
||||
);
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues,
|
||||
onSubmit: async ({ value }) => {
|
||||
const payload = {
|
||||
...value,
|
||||
branchId: value.branchId || null,
|
||||
customerId: value.customerId || null,
|
||||
contactId: value.contactId || null,
|
||||
description: value.description || null,
|
||||
projectName: value.projectName || null,
|
||||
projectLocation: value.projectLocation || null,
|
||||
productType: value.productType || null,
|
||||
priority: value.priority || null,
|
||||
estimatedValue: value.estimatedValue ?? null,
|
||||
awarenessId: value.awarenessId || null,
|
||||
followupStatus: value.followupStatus || null,
|
||||
lostReason: value.lostReason || null,
|
||||
ownerMarketingUserId: value.ownerMarketingUserId || null
|
||||
};
|
||||
|
||||
if (isEdit && lead) {
|
||||
await updateMutation.mutateAsync({ id: lead.id, values: payload });
|
||||
return;
|
||||
}
|
||||
|
||||
await createMutation.mutateAsync(payload);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
form.reset(defaultValues);
|
||||
setSelectedCustomerId(defaultValues.customerId ?? '');
|
||||
}, [defaultValues, form, open]);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className='flex w-full flex-col sm:max-w-3xl'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{isEdit ? 'แก้ไขลีด' : 'สร้างลีด'}</SheetTitle>
|
||||
<SheetDescription>บันทึกข้อมูลลีดของฝ่ายการตลาดก่อนส่งต่อให้ฝ่ายขาย</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className='flex-1 overflow-auto'>
|
||||
<form.AppForm>
|
||||
<form.Form id='lead-form' className='grid gap-4 md:grid-cols-2'>
|
||||
<form.AppField
|
||||
name='customerId'
|
||||
children={(field) => (
|
||||
<field.FieldSet>
|
||||
<field.Field>
|
||||
<field.FieldLabel>ลูกค้า</field.FieldLabel>
|
||||
<Select
|
||||
value={field.state.value ?? ''}
|
||||
onValueChange={(nextValue) => {
|
||||
field.handleChange(nextValue === '__none__' ? null : nextValue);
|
||||
field.handleBlur();
|
||||
setSelectedCustomerId(nextValue === '__none__' ? '' : nextValue);
|
||||
form.setFieldValue('contactId', null);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger aria-label='ลูกค้า'>
|
||||
<SelectValue placeholder='เลือกลูกค้า' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='__none__'>ไม่เลือก</SelectItem>
|
||||
{referenceData.customers.map((customer) => (
|
||||
<SelectItem key={customer.id} value={customer.id}>
|
||||
{customer.name} ({customer.code})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</field.Field>
|
||||
<field.FieldError />
|
||||
</field.FieldSet>
|
||||
)}
|
||||
/>
|
||||
|
||||
<form.AppField
|
||||
name='contactId'
|
||||
children={(field) => (
|
||||
<field.FieldSet>
|
||||
<field.Field>
|
||||
<field.FieldLabel>ผู้ติดต่อ</field.FieldLabel>
|
||||
<Select
|
||||
value={field.state.value ?? '__none__'}
|
||||
onValueChange={(nextValue) => {
|
||||
field.handleChange(nextValue === '__none__' ? null : nextValue);
|
||||
field.handleBlur();
|
||||
}}
|
||||
>
|
||||
<SelectTrigger aria-label='ผู้ติดต่อ'>
|
||||
<SelectValue placeholder='เลือกผู้ติดต่อ' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='__none__'>ไม่เลือก</SelectItem>
|
||||
{contactsForCustomer.map((contact) => (
|
||||
<SelectItem key={contact.id} value={contact.id}>
|
||||
{contact.name}
|
||||
{contact.isPrimary ? ' (หลัก)' : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</field.Field>
|
||||
<field.FieldError />
|
||||
</field.FieldSet>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormTextField name='projectName' label='Project Name' />
|
||||
<FormTextField name='projectLocation' label='Project Location' />
|
||||
<FormTextareaField name='description' label='Description' className='md:col-span-2' />
|
||||
|
||||
<FormSelectField
|
||||
name='branchId'
|
||||
label='สาขา'
|
||||
options={referenceData.branches.map((branch) => ({
|
||||
value: branch.id,
|
||||
label: branch.name
|
||||
}))}
|
||||
/>
|
||||
<FormSelectField
|
||||
name='productType'
|
||||
label='Product Type'
|
||||
options={referenceData.productTypes.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
}))}
|
||||
/>
|
||||
<FormSelectField
|
||||
name='priority'
|
||||
label='Priority'
|
||||
options={referenceData.priorities.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
}))}
|
||||
/>
|
||||
<FormCurrencyField name='estimatedValue' label='Estimated Value' currencyLabel='THB' />
|
||||
<FormSelectField
|
||||
name='awarenessId'
|
||||
label='Awareness'
|
||||
options={referenceData.awarenesses.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
}))}
|
||||
/>
|
||||
<FormSelectField
|
||||
name='status'
|
||||
label='Status'
|
||||
options={referenceData.statuses.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
}))}
|
||||
/>
|
||||
<FormSelectField
|
||||
name='followupStatus'
|
||||
label='Follow-up Status'
|
||||
options={referenceData.followupStatuses.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
}))}
|
||||
/>
|
||||
<FormSelectField
|
||||
name='lostReason'
|
||||
label='Lost Reason'
|
||||
options={referenceData.lostReasons.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
}))}
|
||||
/>
|
||||
<FormSelectField
|
||||
name='ownerMarketingUserId'
|
||||
label='Marketing Owner'
|
||||
options={referenceData.assignableUsers.map((user) => ({
|
||||
value: user.id,
|
||||
label: user.name
|
||||
}))}
|
||||
/>
|
||||
</form.Form>
|
||||
</form.AppForm>
|
||||
</div>
|
||||
|
||||
<SheetFooter>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
ยกเลิก
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
form='lead-form'
|
||||
isLoading={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
<Icons.add className='mr-2 h-4 w-4' />
|
||||
{isEdit ? 'อัปเดตลีด' : 'สร้างลีด'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
export function LeadFormTrigger({ referenceData }: { referenceData: LeadReferenceData }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)}>
|
||||
<Icons.add className='mr-2 h-4 w-4' />
|
||||
เพิ่มลีด
|
||||
</Button>
|
||||
<LeadForm open={open} onOpenChange={setOpen} referenceData={referenceData} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
48
src/features/crm/leads/components/lead-list.tsx
Normal file
48
src/features/crm/leads/components/lead-list.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
||||
import { getQueryClient } from '@/lib/query-client';
|
||||
import { searchParamsCache } from '@/lib/searchparams';
|
||||
import type { LeadReferenceData } from '../api/types';
|
||||
import { leadsQueryOptions } from '../api/queries';
|
||||
import { LeadsTable } from './leads-table';
|
||||
|
||||
export function LeadList({
|
||||
referenceData,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canAssign
|
||||
}: {
|
||||
referenceData: LeadReferenceData;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
canAssign: boolean;
|
||||
}) {
|
||||
const page = searchParamsCache.get('page');
|
||||
const limit = searchParamsCache.get('perPage');
|
||||
const search = searchParamsCache.get('name');
|
||||
const status = searchParamsCache.get('status');
|
||||
const branch = searchParamsCache.get('branch');
|
||||
const customer = searchParamsCache.get('customer');
|
||||
const sort = searchParamsCache.get('sort');
|
||||
const filters = {
|
||||
page,
|
||||
limit,
|
||||
...(search && { search }),
|
||||
...(status && { status }),
|
||||
...(branch && { branchId: branch }),
|
||||
...(customer && { customerId: customer }),
|
||||
...(sort && { sort })
|
||||
};
|
||||
const queryClient = getQueryClient();
|
||||
void queryClient.prefetchQuery(leadsQueryOptions(filters));
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<LeadsTable
|
||||
referenceData={referenceData}
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
canAssign={canAssign}
|
||||
/>
|
||||
</HydrationBoundary>
|
||||
);
|
||||
}
|
||||
68
src/features/crm/leads/components/leads-table.tsx
Normal file
68
src/features/crm/leads/components/leads-table.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { DataTable } from '@/components/ui/table/data-table';
|
||||
import { DataTableToolbar } from '@/components/ui/table/data-table-toolbar';
|
||||
import { useDataTable } from '@/hooks/use-data-table';
|
||||
import { getSortingStateParser } from '@/lib/parsers';
|
||||
import type { LeadReferenceData } from '../api/types';
|
||||
import { leadsQueryOptions } from '../api/queries';
|
||||
import { getLeadColumns } from './lead-columns';
|
||||
|
||||
export function LeadsTable({
|
||||
referenceData,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canAssign
|
||||
}: {
|
||||
referenceData: LeadReferenceData;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
canAssign: boolean;
|
||||
}) {
|
||||
const columns = useMemo(
|
||||
() => getLeadColumns({ referenceData, canUpdate, canDelete, canAssign }),
|
||||
[referenceData, canUpdate, canDelete, canAssign]
|
||||
);
|
||||
const columnIds = columns.map((column) => column.id).filter(Boolean) as string[];
|
||||
|
||||
const [params] = useQueryStates({
|
||||
page: parseAsInteger.withDefault(1),
|
||||
perPage: parseAsInteger.withDefault(10),
|
||||
name: parseAsString,
|
||||
status: parseAsString,
|
||||
branchId: parseAsString,
|
||||
customerId: parseAsString,
|
||||
sort: getSortingStateParser(columnIds).withDefault([])
|
||||
});
|
||||
|
||||
const filters = {
|
||||
page: params.page,
|
||||
limit: params.perPage,
|
||||
...(params.name && { search: params.name }),
|
||||
...(params.status && { status: params.status }),
|
||||
...(params.branchId && { branchId: params.branchId }),
|
||||
...(params.customerId && { customerId: params.customerId }),
|
||||
...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) })
|
||||
};
|
||||
|
||||
const { data } = useSuspenseQuery(leadsQueryOptions(filters));
|
||||
const pageCount = Math.max(1, Math.ceil((data.totalItems || 0) / params.perPage));
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: data.items,
|
||||
columns,
|
||||
pageCount,
|
||||
shallow: true,
|
||||
debounceMs: 500,
|
||||
clearOnDefault: true
|
||||
});
|
||||
|
||||
return (
|
||||
<DataTable table={table}>
|
||||
<DataTableToolbar table={table} />
|
||||
</DataTable>
|
||||
);
|
||||
}
|
||||
@@ -243,7 +243,8 @@ export async function assignLead(
|
||||
return {
|
||||
leadId: input.leadId,
|
||||
enquiryId: existingEnquiry.id,
|
||||
enquiryCode: existingEnquiry.code
|
||||
enquiryCode: existingEnquiry.code,
|
||||
reusedExistingEnquiry: true
|
||||
};
|
||||
}
|
||||
|
||||
@@ -295,6 +296,7 @@ export async function assignLead(
|
||||
return {
|
||||
leadId: input.leadId,
|
||||
enquiryId: createdEnquiry.id,
|
||||
enquiryCode: createdEnquiry.code
|
||||
enquiryCode: createdEnquiry.code,
|
||||
reusedExistingEnquiry: false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import { db } from '@/lib/db';
|
||||
import { AuthError } from '@/lib/auth/session';
|
||||
import { getUserBranches, validateBranchAccess } from '@/features/foundation/branch-scope/service';
|
||||
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
|
||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||
import { auditAction, listAuditLogs } from '@/features/foundation/audit-log/service';
|
||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||
import {
|
||||
buildCrmSecurityContext,
|
||||
@@ -34,9 +34,14 @@ import {
|
||||
import type {
|
||||
CreateLeadFollowupInput,
|
||||
CreateLeadInput,
|
||||
LeadActivityRecord,
|
||||
LeadAssignableUserLookup,
|
||||
LeadBranchOption,
|
||||
LeadContactLookup,
|
||||
LeadCustomerLookup,
|
||||
LeadDetail,
|
||||
LeadFollowupSummary,
|
||||
LeadLinkedEnquirySummary,
|
||||
LeadListFilters,
|
||||
LeadListResponse,
|
||||
LeadOption,
|
||||
@@ -66,6 +71,7 @@ type LeadLookupMaps = {
|
||||
productTypeLabels: Map<string, string>;
|
||||
priorityLabels: Map<string, string>;
|
||||
customerNames: Map<string, string>;
|
||||
customerOwnerIds: Map<string, string | null>;
|
||||
contactNames: Map<string, string>;
|
||||
userNames: Map<string, string>;
|
||||
};
|
||||
@@ -92,6 +98,170 @@ function mapOption(
|
||||
};
|
||||
}
|
||||
|
||||
async function listAssignableLeadUsers(organizationId: string): Promise<LeadAssignableUserLookup[]> {
|
||||
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' ||
|
||||
['sales_manager', 'sales', 'sales_support'].includes(row.businessRole)
|
||||
)
|
||||
.map((row) => ({
|
||||
id: row.userId,
|
||||
name: row.name,
|
||||
membershipRole: row.membershipRole === 'admin' ? 'admin' : 'user',
|
||||
businessRole: row.businessRole
|
||||
}));
|
||||
}
|
||||
|
||||
async function listLeadCustomers(organizationId: string): Promise<LeadCustomerLookup[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: crmCustomers.id,
|
||||
code: crmCustomers.code,
|
||||
name: crmCustomers.name,
|
||||
branchId: crmCustomers.branchId,
|
||||
ownerUserId: crmCustomers.ownerUserId
|
||||
})
|
||||
.from(crmCustomers)
|
||||
.where(and(eq(crmCustomers.organizationId, organizationId), isNull(crmCustomers.deletedAt)))
|
||||
.orderBy(asc(crmCustomers.name));
|
||||
|
||||
const ownerIds = [
|
||||
...new Set(rows.map((row) => row.ownerUserId).filter((value): value is string => Boolean(value)))
|
||||
];
|
||||
const owners = ownerIds.length
|
||||
? await db
|
||||
.select({ id: users.id, name: users.name })
|
||||
.from(users)
|
||||
.where(inArray(users.id, ownerIds))
|
||||
: [];
|
||||
const ownerMap = new Map(owners.map((row) => [row.id, row.name]));
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
branchId: row.branchId ?? null,
|
||||
ownerUserId: row.ownerUserId ?? null,
|
||||
ownerName: row.ownerUserId ? (ownerMap.get(row.ownerUserId) ?? null) : null
|
||||
}));
|
||||
}
|
||||
|
||||
async function listLeadContacts(organizationId: string): Promise<LeadContactLookup[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: crmCustomerContacts.id,
|
||||
customerId: crmCustomerContacts.customerId,
|
||||
name: crmCustomerContacts.name,
|
||||
email: crmCustomerContacts.email,
|
||||
mobile: crmCustomerContacts.mobile,
|
||||
isPrimary: crmCustomerContacts.isPrimary
|
||||
})
|
||||
.from(crmCustomerContacts)
|
||||
.where(
|
||||
and(eq(crmCustomerContacts.organizationId, organizationId), isNull(crmCustomerContacts.deletedAt))
|
||||
)
|
||||
.orderBy(desc(crmCustomerContacts.isPrimary), asc(crmCustomerContacts.name));
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
customerId: row.customerId,
|
||||
name: row.name,
|
||||
email: row.email ?? null,
|
||||
mobile: row.mobile ?? null,
|
||||
isPrimary: row.isPrimary
|
||||
}));
|
||||
}
|
||||
|
||||
async function listLinkedEnquiries(
|
||||
leadId: string,
|
||||
organizationId: string
|
||||
): Promise<LeadLinkedEnquirySummary[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: crmEnquiries.id,
|
||||
code: crmEnquiries.code,
|
||||
title: crmEnquiries.title,
|
||||
status: crmEnquiries.status,
|
||||
assignedToUserId: crmEnquiries.assignedToUserId,
|
||||
updatedAt: crmEnquiries.updatedAt
|
||||
})
|
||||
.from(crmEnquiries)
|
||||
.where(
|
||||
and(
|
||||
eq(crmEnquiries.organizationId, organizationId),
|
||||
eq(crmEnquiries.leadId, leadId),
|
||||
isNull(crmEnquiries.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(crmEnquiries.updatedAt));
|
||||
|
||||
const assigneeIds = [
|
||||
...new Set(rows.map((row) => row.assignedToUserId).filter((value): value is string => Boolean(value)))
|
||||
];
|
||||
const assigneeRows = assigneeIds.length
|
||||
? await db
|
||||
.select({ id: users.id, name: users.name })
|
||||
.from(users)
|
||||
.where(inArray(users.id, assigneeIds))
|
||||
: [];
|
||||
const assigneeMap = new Map(assigneeRows.map((row) => [row.id, row.name]));
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
title: row.title,
|
||||
status: row.status,
|
||||
assignedToUserId: row.assignedToUserId ?? null,
|
||||
assignedToName: row.assignedToUserId ? (assigneeMap.get(row.assignedToUserId) ?? null) : null,
|
||||
updatedAt: row.updatedAt.toISOString()
|
||||
}));
|
||||
}
|
||||
|
||||
async function listLeadActivity(
|
||||
leadId: string,
|
||||
organizationId: string
|
||||
): Promise<LeadActivityRecord[]> {
|
||||
const auditLogs = await listAuditLogs({
|
||||
organizationId,
|
||||
entityType: 'crm_lead',
|
||||
entityId: leadId,
|
||||
limit: 25
|
||||
});
|
||||
const userIds = [...new Set(auditLogs.map((log) => log.userId))];
|
||||
const actorRows = userIds.length
|
||||
? await db
|
||||
.select({ id: users.id, name: users.name })
|
||||
.from(users)
|
||||
.where(inArray(users.id, userIds))
|
||||
: [];
|
||||
const actorMap = new Map(actorRows.map((row) => [row.id, row.name]));
|
||||
|
||||
return auditLogs.map((log) => ({
|
||||
id: log.id,
|
||||
action: log.action,
|
||||
entityType: log.entityType,
|
||||
entityId: log.entityId,
|
||||
createdAt: log.createdAt,
|
||||
userId: log.userId,
|
||||
actorName: actorMap.get(log.userId) ?? null
|
||||
}));
|
||||
}
|
||||
|
||||
function mapBranch(
|
||||
branch: Awaited<ReturnType<typeof getUserBranches>>[number]
|
||||
): LeadBranchOption {
|
||||
@@ -150,6 +320,11 @@ function mapLeadSummary(row: LeadRow, lookups: LeadLookupMaps): LeadSummary {
|
||||
assignedSalesOwnerName: row.assignedSalesOwnerId
|
||||
? (lookups.userNames.get(row.assignedSalesOwnerId) ?? null)
|
||||
: null,
|
||||
suggestedSalesOwnerId: row.customerId ? (lookups.customerOwnerIds.get(row.customerId) ?? null) : null,
|
||||
suggestedSalesOwnerName:
|
||||
row.customerId && lookups.customerOwnerIds.get(row.customerId)
|
||||
? (lookups.userNames.get(lookups.customerOwnerIds.get(row.customerId) ?? '') ?? null)
|
||||
: null,
|
||||
assignedAt: toIsoDateTime(row.assignedAt),
|
||||
assignedBy: row.assignedBy ?? null,
|
||||
assignedByName: row.assignedBy ? (lookups.userNames.get(row.assignedBy) ?? null) : null,
|
||||
@@ -170,7 +345,9 @@ function mapLeadDetail(
|
||||
return {
|
||||
...mapLeadSummary(row, lookups),
|
||||
suggestedSalesOwnerId,
|
||||
relatedEnquiryCount
|
||||
relatedEnquiryCount,
|
||||
linkedEnquiries: [],
|
||||
activity: []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -383,10 +560,10 @@ async function buildLookupMaps(organizationId: string, rows: LeadRow[]): Promise
|
||||
)
|
||||
];
|
||||
|
||||
const [customerRows, contactRows, userRows] = await Promise.all([
|
||||
const [customerRows, contactRows] = await Promise.all([
|
||||
customerIds.length
|
||||
? db
|
||||
.select({ id: crmCustomers.id, name: crmCustomers.name })
|
||||
.select({ id: crmCustomers.id, name: crmCustomers.name, ownerUserId: crmCustomers.ownerUserId })
|
||||
.from(crmCustomers)
|
||||
.where(inArray(crmCustomers.id, customerIds))
|
||||
: Promise.resolve([]),
|
||||
@@ -396,13 +573,22 @@ async function buildLookupMaps(organizationId: string, rows: LeadRow[]): Promise
|
||||
.from(crmCustomerContacts)
|
||||
.where(inArray(crmCustomerContacts.id, contactIds))
|
||||
: Promise.resolve([]),
|
||||
userIds.length
|
||||
? db
|
||||
.select({ id: users.id, name: users.name })
|
||||
.from(users)
|
||||
.where(inArray(users.id, userIds))
|
||||
: Promise.resolve([])
|
||||
]);
|
||||
const resolvedUserIds = [
|
||||
...new Set(
|
||||
userIds.concat(
|
||||
customerRows
|
||||
.map((item) => item.ownerUserId)
|
||||
.filter((value): value is string => Boolean(value))
|
||||
)
|
||||
)
|
||||
];
|
||||
const userRows = resolvedUserIds.length
|
||||
? await db
|
||||
.select({ id: users.id, name: users.name })
|
||||
.from(users)
|
||||
.where(inArray(users.id, resolvedUserIds))
|
||||
: [];
|
||||
|
||||
return {
|
||||
awarenessLabels: new Map(awarenesses.map((item) => [item.id, item.label])),
|
||||
@@ -412,6 +598,7 @@ async function buildLookupMaps(organizationId: string, rows: LeadRow[]): Promise
|
||||
productTypeLabels: new Map(productTypes.map((item) => [item.id, item.label])),
|
||||
priorityLabels: new Map(priorities.map((item) => [item.id, item.label])),
|
||||
customerNames: new Map(customerRows.map((item) => [item.id, item.name])),
|
||||
customerOwnerIds: new Map(customerRows.map((item) => [item.id, item.ownerUserId ?? null])),
|
||||
contactNames: new Map(contactRows.map((item) => [item.id, item.name])),
|
||||
userNames: new Map(userRows.map((item) => [item.id, item.name]))
|
||||
};
|
||||
@@ -486,7 +673,18 @@ async function validateLeadPayload(
|
||||
}
|
||||
|
||||
export async function getLeadReferenceData(organizationId: string): Promise<LeadReferenceData> {
|
||||
const [awarenesses, statuses, followupStatuses, lostReasons, productTypes, priorities, branches] =
|
||||
const [
|
||||
awarenesses,
|
||||
statuses,
|
||||
followupStatuses,
|
||||
lostReasons,
|
||||
productTypes,
|
||||
priorities,
|
||||
branches,
|
||||
customers,
|
||||
contacts,
|
||||
assignableUsers
|
||||
] =
|
||||
await Promise.all([
|
||||
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.awareness, { organizationId }),
|
||||
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.status, { organizationId }),
|
||||
@@ -494,7 +692,10 @@ export async function getLeadReferenceData(organizationId: string): Promise<Lead
|
||||
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.lostReason, { organizationId }),
|
||||
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.productType, { organizationId }),
|
||||
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.priority, { organizationId }),
|
||||
getUserBranches()
|
||||
getUserBranches(),
|
||||
listLeadCustomers(organizationId),
|
||||
listLeadContacts(organizationId),
|
||||
listAssignableLeadUsers(organizationId)
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -504,7 +705,10 @@ export async function getLeadReferenceData(organizationId: string): Promise<Lead
|
||||
lostReasons: lostReasons.map(mapOption),
|
||||
productTypes: productTypes.map(mapOption),
|
||||
priorities: priorities.map(mapOption),
|
||||
branches: branches.map(mapBranch)
|
||||
branches: branches.map(mapBranch),
|
||||
customers,
|
||||
contacts,
|
||||
assignableUsers
|
||||
};
|
||||
}
|
||||
|
||||
@@ -512,7 +716,7 @@ export async function listLeads(
|
||||
organizationId: string,
|
||||
filters: LeadListFilters = {},
|
||||
accessContext?: LeadAccessContext
|
||||
): Promise<LeadListResponse> {
|
||||
): Promise<{ items: LeadSummary[]; totalItems: number }> {
|
||||
const page = filters.page ?? 1;
|
||||
const limit = filters.limit ?? 10;
|
||||
const offset = (page - 1) * limit;
|
||||
@@ -562,7 +766,7 @@ export async function getLeadById(
|
||||
accessContext?: LeadAccessContext
|
||||
): Promise<LeadDetail> {
|
||||
const lead = await assertLeadBelongsToOrganization(id, organizationId, accessContext);
|
||||
const [lookups, relatedEnquiryResult, customer] = await Promise.all([
|
||||
const [lookups, relatedEnquiryResult, customer, linkedEnquiries, activity] = await Promise.all([
|
||||
buildLookupMaps(organizationId, [lead]),
|
||||
db
|
||||
.select({ value: count() })
|
||||
@@ -574,15 +778,22 @@ export async function getLeadById(
|
||||
isNull(crmEnquiries.deletedAt)
|
||||
)
|
||||
),
|
||||
lead.customerId ? assertCustomerBelongsToOrganization(lead.customerId, organizationId) : Promise.resolve(null)
|
||||
lead.customerId ? assertCustomerBelongsToOrganization(lead.customerId, organizationId) : Promise.resolve(null),
|
||||
listLinkedEnquiries(id, organizationId),
|
||||
listLeadActivity(id, organizationId)
|
||||
]);
|
||||
|
||||
return mapLeadDetail(
|
||||
const detail = mapLeadDetail(
|
||||
lead,
|
||||
lookups,
|
||||
customer?.ownerUserId ?? null,
|
||||
relatedEnquiryResult[0]?.value ?? 0
|
||||
);
|
||||
|
||||
return {
|
||||
...detail,
|
||||
linkedEnquiries,
|
||||
activity
|
||||
};
|
||||
}
|
||||
|
||||
export async function createLead(
|
||||
@@ -713,7 +924,11 @@ export async function deleteLead(
|
||||
|
||||
const lookups = await buildLookupMaps(organizationId, deleted ? [deleted] : []);
|
||||
|
||||
return mapLeadDetail(deleted!, lookups, null, 0);
|
||||
return {
|
||||
...mapLeadDetail(deleted!, lookups, null, 0),
|
||||
linkedEnquiries: [],
|
||||
activity: []
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,51 @@ export interface LeadBranchOption {
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
export interface LeadAssignableUserLookup {
|
||||
id: string;
|
||||
name: string;
|
||||
membershipRole: 'admin' | 'user';
|
||||
businessRole: string;
|
||||
}
|
||||
|
||||
export interface LeadCustomerLookup {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
branchId: string | null;
|
||||
ownerUserId: string | null;
|
||||
ownerName: string | null;
|
||||
}
|
||||
|
||||
export interface LeadContactLookup {
|
||||
id: string;
|
||||
customerId: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
mobile: string | null;
|
||||
isPrimary: boolean;
|
||||
}
|
||||
|
||||
export interface LeadLinkedEnquirySummary {
|
||||
id: string;
|
||||
code: string;
|
||||
title: string;
|
||||
status: string;
|
||||
assignedToUserId: string | null;
|
||||
assignedToName: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface LeadActivityRecord {
|
||||
id: string;
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
createdAt: string;
|
||||
userId: string;
|
||||
actorName: string | null;
|
||||
}
|
||||
|
||||
export interface LeadReferenceData {
|
||||
awarenesses: LeadOption[];
|
||||
statuses: LeadOption[];
|
||||
@@ -20,6 +65,9 @@ export interface LeadReferenceData {
|
||||
productTypes: LeadOption[];
|
||||
priorities: LeadOption[];
|
||||
branches: LeadBranchOption[];
|
||||
customers: LeadCustomerLookup[];
|
||||
contacts: LeadContactLookup[];
|
||||
assignableUsers: LeadAssignableUserLookup[];
|
||||
}
|
||||
|
||||
export interface CreateLeadInput {
|
||||
@@ -113,6 +161,8 @@ export interface LeadSummary {
|
||||
ownerMarketingName: string | null;
|
||||
assignedSalesOwnerId: string | null;
|
||||
assignedSalesOwnerName: string | null;
|
||||
suggestedSalesOwnerId: string | null;
|
||||
suggestedSalesOwnerName: string | null;
|
||||
assignedAt: string | null;
|
||||
assignedBy: string | null;
|
||||
assignedByName: string | null;
|
||||
@@ -124,13 +174,9 @@ export interface LeadSummary {
|
||||
}
|
||||
|
||||
export interface LeadDetail extends LeadSummary {
|
||||
suggestedSalesOwnerId: string | null;
|
||||
relatedEnquiryCount: number;
|
||||
}
|
||||
|
||||
export interface LeadListResponse {
|
||||
items: LeadSummary[];
|
||||
totalItems: number;
|
||||
linkedEnquiries: LeadLinkedEnquirySummary[];
|
||||
activity: LeadActivityRecord[];
|
||||
}
|
||||
|
||||
export interface LeadFollowupSummary {
|
||||
@@ -150,4 +196,32 @@ export interface AssignLeadResult {
|
||||
leadId: string;
|
||||
enquiryId: string;
|
||||
enquiryCode: string;
|
||||
reusedExistingEnquiry: boolean;
|
||||
}
|
||||
|
||||
export interface LeadListResponse {
|
||||
success: boolean;
|
||||
time: string;
|
||||
message: string;
|
||||
totalItems: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
items: LeadSummary[];
|
||||
referenceData: LeadReferenceData;
|
||||
}
|
||||
|
||||
export interface LeadDetailResponse {
|
||||
success: boolean;
|
||||
time: string;
|
||||
message: string;
|
||||
lead: LeadDetail;
|
||||
followups: LeadFollowupSummary[];
|
||||
referenceData: LeadReferenceData;
|
||||
}
|
||||
|
||||
export interface LeadMutationSuccessResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
lead?: LeadDetail;
|
||||
followup?: LeadFollowupSummary;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user