diff --git a/docs/implementation/task-d54-lead-enquiry-workspace-separation-ui-foundation.md b/docs/implementation/task-d54-lead-enquiry-workspace-separation-ui-foundation.md new file mode 100644 index 0000000..3d061cb --- /dev/null +++ b/docs/implementation/task-d54-lead-enquiry-workspace-separation-ui-foundation.md @@ -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` diff --git a/src/app/api/crm/leads/[id]/assign/route.ts b/src/app/api/crm/leads/[id]/assign/route.ts index 2868bac..6660ada 100644 --- a/src/app/api/crm/leads/[id]/assign/route.ts +++ b/src/app/api/crm/leads/[id]/assign/route.ts @@ -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) { diff --git a/src/app/dashboard/crm/enquiries/[id]/page.tsx b/src/app/dashboard/crm/enquiries/[id]/page.tsx index 4c99b6d..6fd48a9 100644 --- a/src/app/dashboard/crm/enquiries/[id]/page.tsx +++ b/src/app/dashboard/crm/enquiries/[id]/page.tsx @@ -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 ( - You do not have access to this enquiry. - - } + pageDescription='พื้นที่ทำงานของฝ่ายขายสำหรับติดตาม execution ของ enquiry และเชื่อมกับ quotation' > - {referenceData ? ( - - - + {sourceLead ? ( + + + Source Lead + + + + {sourceLead.code} + +
{sourceLead.projectName ?? 'No project name'}
+
+
) : null} + + + +
); } diff --git a/src/app/dashboard/crm/leads/[id]/page.tsx b/src/app/dashboard/crm/leads/[id]/page.tsx index ced0db1..2fd2e92 100644 --- a/src/app/dashboard/crm/leads/[id]/page.tsx +++ b/src/app/dashboard/crm/leads/[id]/page.tsx @@ -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 ( + + {null} + + ); + } + 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 ( - You do not have access to this lead. - - } + pageTitle='Lead Detail' + pageDescription='Separated lead workspace for marketing qualification and sales handoff.' > - {referenceData ? ( - - - - ) : null} + + + ); } diff --git a/src/app/dashboard/crm/leads/page.tsx b/src/app/dashboard/crm/leads/page.tsx index 9f01135..2a77bdf 100644 --- a/src/app/dashboard/crm/leads/page.tsx +++ b/src/app/dashboard/crm/leads/page.tsx @@ -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 ( + + {null} + + ); + } searchParamsCache.parse(searchParams); - - const referenceData = - canRead && session?.user?.activeOrganizationId - ? await getEnquiryReferenceData(session.user.activeOrganizationId) - : null; + const referenceData = await getLeadReferenceData( + session.user.activeOrganizationId, + ); return ( - You do not have access to CRM leads. - - } + pageTitle="CRM Leads" + pageDescription="Marketing-qualified lead workspace before handoff into sales enquiries." pageHeaderAction={ - canCreate && referenceData ? ( - - ) : null + canCreate ? : null } > {referenceData ? ( - ) : null} diff --git a/src/features/crm/enquiries/api/types.ts b/src/features/crm/enquiries/api/types.ts index dc74832..f34762f 100644 --- a/src/features/crm/enquiries/api/types.ts +++ b/src/features/crm/enquiries/api/types.ts @@ -81,6 +81,7 @@ export interface EnquiryRecord { id: string; organizationId: string; branchId: string | null; + leadId: string | null; code: string; customerId: string; contactId: string | null; diff --git a/src/features/crm/enquiries/server/service.ts b/src/features/crm/enquiries/server/service.ts index c214024..8a3bf68 100644 --- a/src/features/crm/enquiries/server/service.ts +++ b/src/features/crm/enquiries/server/service.ts @@ -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, diff --git a/src/features/crm/leads/api/mutations.ts b/src/features/crm/leads/api/mutations.ts new file mode 100644 index 0000000..d3374b9 --- /dev/null +++ b/src/features/crm/leads/api/mutations.ts @@ -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); + } + } +}); diff --git a/src/features/crm/leads/api/queries.ts b/src/features/crm/leads/api/queries.ts new file mode 100644 index 0000000..f13a800 --- /dev/null +++ b/src/features/crm/leads/api/queries.ts @@ -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) + }); diff --git a/src/features/crm/leads/api/service.ts b/src/features/crm/leads/api/service.ts new file mode 100644 index 0000000..0d98f6d --- /dev/null +++ b/src/features/crm/leads/api/service.ts @@ -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 { + 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(`/crm/leads${query ? `?${query}` : ''}`); +} + +export async function getLeadById(id: string): Promise { + return apiClient(`/crm/leads/${id}`); +} + +export async function createLead(data: CreateLeadInput): Promise { + return apiClient('/crm/leads', { + method: 'POST', + body: JSON.stringify(data) + }); +} + +export async function updateLead( + id: string, + data: UpdateLeadInput +): Promise { + return apiClient(`/crm/leads/${id}`, { + method: 'PATCH', + body: JSON.stringify(data) + }); +} + +export async function deleteLead(id: string): Promise { + return apiClient(`/crm/leads/${id}`, { + method: 'DELETE' + }); +} + +export async function createLeadFollowup( + leadId: string, + data: CreateLeadFollowupInput +): Promise { + return apiClient(`/crm/leads/${leadId}/followups`, { + method: 'POST', + body: JSON.stringify(data) + }); +} + +export async function assignLead( + leadId: string, + data: { salesOwnerId: string; assignmentRemark?: string | null } +): Promise { + return apiClient(`/crm/leads/${leadId}/assign`, { + method: 'POST', + body: JSON.stringify(data) + }); +} diff --git a/src/features/crm/leads/api/types.ts b/src/features/crm/leads/api/types.ts new file mode 100644 index 0000000..03de2cc --- /dev/null +++ b/src/features/crm/leads/api/types.ts @@ -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'; diff --git a/src/features/crm/leads/components/lead-assignment-dialog.tsx b/src/features/crm/leads/components/lead-assignment-dialog.tsx new file mode 100644 index 0000000..e10738e --- /dev/null +++ b/src/features/crm/leads/components/lead-assignment-dialog.tsx @@ -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(); + + 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 ( + + + + Assign Lead to Sales + + มอบหมายลีดให้ฝ่ายขายและสร้าง enquiry workspace แยกจาก lead workspace + + + + + + ({ + value: user.id, + label: user.name + }))} + /> + + + + + {lead.suggestedSalesOwnerName ? ( +
+ Suggested customer owner: {lead.suggestedSalesOwnerName} +
+ ) : null} + + + + + + + {'linkedEnquiries' in lead && lead.linkedEnquiries.length > 0 ? ( +
+
Linked enquiries
+
+ {lead.linkedEnquiries.map((enquiry) => ( + + {enquiry.code} - {enquiry.title} + + ))} +
+
+ ) : null} +
+
+ ); +} diff --git a/src/features/crm/leads/components/lead-cell-action.tsx b/src/features/crm/leads/components/lead-cell-action.tsx new file mode 100644 index 0000000..9793ef3 --- /dev/null +++ b/src/features/crm/leads/components/lead-cell-action.tsx @@ -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 ( + <> + + + setDeleteOpen(false)} + onConfirm={() => deleteMutation.mutate({ id: data.id })} + loading={deleteMutation.isPending} + /> + + + + + + + Actions + {canUpdate ? ( + setEditOpen(true)}>Edit lead + ) : null} + {canAssign ? ( + setAssignOpen(true)}> + {data.assignedSalesOwnerId ? 'Reassign to sales' : 'Assign to sales'} + + ) : null} + {canDelete ? ( + setDeleteOpen(true)}> + Delete lead + + ) : null} + + + + ); +} diff --git a/src/features/crm/leads/components/lead-columns.tsx b/src/features/crm/leads/components/lead-columns.tsx new file mode 100644 index 0000000..3c534da --- /dev/null +++ b/src/features/crm/leads/components/lead-columns.tsx @@ -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[] { + return [ + { + id: 'code', + accessorKey: 'code', + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => ( +
+ + {row.original.code} + + {row.original.customerName ?? '-'} +
+ ), + 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 }) => ( + + ), + cell: ({ row }) => {row.original.statusLabel ?? row.original.status}, + 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 }) => ( + + ), + cell: ({ row }) => { + const branch = referenceData.branches.find((item) => item.id === row.original.branchId); + return {branch?.name ?? '-'}; + }, + 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 }) => ( + + ), + cell: ({ row }) => {row.original.customerName ?? '-'}, + 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 }) => ( + + ), + cell: ({ row }) => {row.original.projectName ?? '-'} + }, + { + id: 'priority', + accessorFn: (row) => row.priority ?? '', + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => {row.original.priority ?? '-'} + }, + { + id: 'suggestedSalesOwner', + accessorFn: (row) => row.suggestedSalesOwnerId ?? '', + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => {row.original.suggestedSalesOwnerName ?? '-'} + }, + { + id: 'assignedSalesOwner', + accessorFn: (row) => row.assignedSalesOwnerId ?? '', + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => {row.original.assignedSalesOwnerName ?? '-'} + }, + { + id: 'estimatedValue', + accessorKey: 'estimatedValue', + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.estimatedValue !== null ? row.original.estimatedValue.toLocaleString() : '-'} + + ) + }, + { + id: 'createdAt', + accessorKey: 'createdAt', + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => {new Date(row.original.createdAt).toLocaleDateString()} + }, + { + id: 'actions', + cell: ({ row }) => ( + + ) + } + ]; +} diff --git a/src/features/crm/leads/components/lead-detail.tsx b/src/features/crm/leads/components/lead-detail.tsx new file mode 100644 index 0000000..1b04039 --- /dev/null +++ b/src/features/crm/leads/components/lead-detail.tsx @@ -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 ( +
+
{label}
+
{value || '-'}
+
+ ); +} + +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 ( +
+ + + +
+
+
+

{lead.code}

+ {lead.statusLabel ?? lead.status} +
+

+ Lead workspace for marketing qualification before sales enquiry handoff +

+
+ +
+ {canAssign ? ( + + ) : null} + {canUpdate ? ( + + ) : null} +
+
+ +
+
+ + + Lead Summary + + + + + + + + + + + + + + + + + + + + Follow-ups + Linked Enquiries + Activity + + + + + + + + + + Linked Enquiries + + + {lead.linkedEnquiries.length === 0 ? ( +
No linked enquiries yet.
+ ) : ( + lead.linkedEnquiries.map((enquiry) => ( +
+
+ + {enquiry.code} + + {enquiry.status} +
+
{enquiry.title}
+
+ Owner: {enquiry.assignedToName ?? '-'} +
+
+ )) + )} +
+
+
+ + + + + Activity + + + {lead.activity.length === 0 ? ( +
No activity yet.
+ ) : ( + lead.activity.map((activity) => ( +
+
{activity.action}
+
+ {activity.actorName ?? activity.userId} -{' '} + {new Date(activity.createdAt).toLocaleString()} +
+
+ )) + )} +
+
+
+
+
+ +
+ + + Workspace Status + + + + + + + + + +
+
+
+ ); +} diff --git a/src/features/crm/leads/components/lead-followup-panel.tsx b/src/features/crm/leads/components/lead-followup-panel.tsx new file mode 100644 index 0000000..18f4fcd --- /dev/null +++ b/src/features/crm/leads/components/lead-followup-panel.tsx @@ -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(); + + 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 ( +
+ + + New Follow-up + + + + + + + ({ + value: item.id, + label: item.label + }))} + /> +
+ +
+ +
+ + + + + + + + History + + + {detail.followups.length === 0 ? ( +
No follow-up history yet.
+ ) : ( + detail.followups.map((followup) => ( +
+
+
{followup.followupStatusLabel ?? followup.followupStatus}
+
+ {new Date(followup.followupDate).toLocaleDateString()} +
+
+ {followup.note ?
{followup.note}
: null} +
+ {followup.createdByName ?? followup.createdBy} +
+
+ )) + )} +
+
+
+ ); +} diff --git a/src/features/crm/leads/components/lead-form.tsx b/src/features/crm/leads/components/lead-form.tsx new file mode 100644 index 0000000..1afde33 --- /dev/null +++ b/src/features/crm/leads/components/lead-form.tsx @@ -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(); + + 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 ( + + + + {isEdit ? 'แก้ไขลีด' : 'สร้างลีด'} + บันทึกข้อมูลลีดของฝ่ายการตลาดก่อนส่งต่อให้ฝ่ายขาย + + +
+ + + ( + + + ลูกค้า + + + + + )} + /> + + ( + + + ผู้ติดต่อ + + + + + )} + /> + + + + + + ({ + value: branch.id, + label: branch.name + }))} + /> + ({ + value: item.id, + label: item.label + }))} + /> + ({ + value: item.id, + label: item.label + }))} + /> + + ({ + value: item.id, + label: item.label + }))} + /> + ({ + value: item.id, + label: item.label + }))} + /> + ({ + value: item.id, + label: item.label + }))} + /> + ({ + value: item.id, + label: item.label + }))} + /> + ({ + value: user.id, + label: user.name + }))} + /> + + +
+ + + + + +
+
+ ); +} + +export function LeadFormTrigger({ referenceData }: { referenceData: LeadReferenceData }) { + const [open, setOpen] = React.useState(false); + + return ( + <> + + + + ); +} diff --git a/src/features/crm/leads/components/lead-list.tsx b/src/features/crm/leads/components/lead-list.tsx new file mode 100644 index 0000000..c9931d0 --- /dev/null +++ b/src/features/crm/leads/components/lead-list.tsx @@ -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 ( + + + + ); +} diff --git a/src/features/crm/leads/components/leads-table.tsx b/src/features/crm/leads/components/leads-table.tsx new file mode 100644 index 0000000..f486f6f --- /dev/null +++ b/src/features/crm/leads/components/leads-table.tsx @@ -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 ( + + + + ); +} diff --git a/src/features/crm/leads/server/assignment.service.ts b/src/features/crm/leads/server/assignment.service.ts index 4a7ec06..d187b08 100644 --- a/src/features/crm/leads/server/assignment.service.ts +++ b/src/features/crm/leads/server/assignment.service.ts @@ -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 }; } diff --git a/src/features/crm/leads/server/service.ts b/src/features/crm/leads/server/service.ts index df75737..91656d7 100644 --- a/src/features/crm/leads/server/service.ts +++ b/src/features/crm/leads/server/service.ts @@ -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; priorityLabels: Map; customerNames: Map; + customerOwnerIds: Map; contactNames: Map; userNames: Map; }; @@ -92,6 +98,170 @@ function mapOption( }; } +async function listAssignableLeadUsers(organizationId: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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>[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 { - 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 { +): 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 { 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: [] + }; }); } diff --git a/src/features/crm/leads/types.ts b/src/features/crm/leads/types.ts index 2a7390f..953cda4 100644 --- a/src/features/crm/leads/types.ts +++ b/src/features/crm/leads/types.ts @@ -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; }