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