task-d.5.4

This commit is contained in:
phaichayon
2026-06-25 08:06:53 +07:00
parent 0c28735c90
commit 6093003b70
22 changed files with 1930 additions and 207 deletions

View File

@@ -0,0 +1,85 @@
# Task D.5.4: Lead / Enquiry Workspace Separation UI Foundation
## Objective
Establish separate CRM workspaces for marketing-owned Leads and sales-owned Enquiries without changing existing quotation, dashboard, reporting, or approval behavior.
## Review Completed
- `AGENTS.md`
- `plans/task-d.5.4.md`
- `docs/standards/project-foundations.md`
- `docs/standards/task-catalog.md`
- `docs/adr/0018-lead-enquiry-domain-separation.md`
- `docs/implementation/task-d5-lead-enquiry-domain-separation.md`
- `docs/implementation/task-d52-lead-domain-service-api-foundation.md`
- `docs/implementation/task-d53-lead-assignment-create-enquiry-foundation.md`
- `docs/security/crm-authorization-boundaries.md`
- `src/config/nav-config.ts`
- existing enquiry list, detail, form, and assignment UI
- existing lead route handlers and lead assignment route
## What Changed
- Replaced legacy lead route aliases with real lead pages:
- [page.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/dashboard/crm/leads/page.tsx)
- [page.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/dashboard/crm/leads/[id]/page.tsx)
- Added lead client API/query/mutation stack:
- [service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/leads/api/service.ts)
- [queries.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/leads/api/queries.ts)
- [mutations.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/leads/api/mutations.ts)
- Added lead UI foundation:
- [lead-list.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/leads/components/lead-list.tsx)
- [leads-table.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/leads/components/leads-table.tsx)
- [lead-columns.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/leads/components/lead-columns.tsx)
- [lead-form.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/leads/components/lead-form.tsx)
- [lead-detail.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/leads/components/lead-detail.tsx)
- [lead-assignment-dialog.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/leads/components/lead-assignment-dialog.tsx)
- [lead-followup-panel.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/leads/components/lead-followup-panel.tsx)
- Extended lead domain read models in:
- [types.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/leads/types.ts)
- [service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/leads/server/service.ts)
- Lead detail now surfaces:
- linked enquiries
- assignment result visibility
- audit-safe activity list
- customer/contact/assignable-user reference data for forms
- Enquiry detail compatibility updated in [page.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/dashboard/crm/enquiries/[id]/page.tsx):
- preserves existing enquiry component contract
- loads linked lead server-side when `leadId` exists
- shows a read-only `Source Lead` block with open-lead navigation
- Enquiry read DTO now includes `leadId` in:
- [types.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/enquiries/api/types.ts)
- [service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/enquiries/server/service.ts)
## APIs Consumed
- `GET /api/crm/leads`
- `POST /api/crm/leads`
- `GET /api/crm/leads/[id]`
- `PATCH /api/crm/leads/[id]`
- `DELETE /api/crm/leads/[id]`
- `POST /api/crm/leads/[id]/followups`
- `POST /api/crm/leads/[id]/assign`
- existing enquiry detail and follow-up APIs remain unchanged
## Permission Mapping
- Lead list/detail visibility uses:
- `crm.lead.read`
- Lead create button and create form use:
- `crm.lead.create`
- Lead edit actions use:
- `crm.lead.update`
- Lead delete action uses:
- `crm.lead.delete`
- Lead assignment action uses:
- `crm.lead.assign`
- Enquiry detail compatibility block does not widen enquiry permissions; it only renders when the user can already open the enquiry page.
## Compatibility Notes
- CRM nav already exposed separate Leads and Enquiries entries, so D.5.4 kept navigation structure and replaced the lead pages behind it.
- Lead assignment still goes through the D.5.3 server assignment flow and does not create enquiries client-side.
- Existing enquiry workflow, quotation conversion flow, dashboard datasets, and reports were not changed.
## Known Limitations
- Lead form intentionally does not introduce a separate persisted `notes` field because D.5.4 is out of scope for schema changes.
- Lead follow-up entry uses the existing audit-backed foundation and remains compatible with the current D.5.2 persistence approach.
## Verification
- `npm exec tsc --noEmit`

View File

@@ -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) {

View File

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

View File

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

View File

@@ -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>

View File

@@ -81,6 +81,7 @@ export interface EnquiryRecord {
id: string;
organizationId: string;
branchId: string | null;
leadId: string | null;
code: string;
customerId: string;
contactId: string | null;

View File

@@ -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,

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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