task-d5.5.1

This commit is contained in:
phaichayon
2026-06-25 12:16:41 +07:00
parent f2c7156851
commit c2a74b6764
121 changed files with 3407 additions and 2679 deletions

View File

@@ -14,7 +14,7 @@ export default async function CustomerDetailRoute({ params }: PageProps) {
return (
<PageContainer
pageTitle='Customer Detail'
pageDescription='Overview, contacts, shared contacts, enquiries, quotations, and activity log'
pageDescription='Overview, contacts, shared contacts, opportunities, quotations, and activity log'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<CustomerDetailPage id={id} />
@@ -22,3 +22,4 @@ export default async function CustomerDetailRoute({ params }: PageProps) {
</PageContainer>
);
}

View File

@@ -1,24 +1,25 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { enquiryByIdOptions } from '@/features/crm-demo/api/queries';
import { EnquiryDetailPage } from '@/features/crm-demo/components/enquiry-detail';
import { opportunityByIdOptions } from '@/features/crm-demo/api/queries';
import { OpportunityDetailPage } from '@/features/crm-demo/components/opportunity-detail';
import { getQueryClient } from '@/lib/query-client';
type PageProps = { params: Promise<{ id: string }> };
export default async function EnquiryDetailRoute({ params }: PageProps) {
export default async function OpportunityDetailRoute({ params }: PageProps) {
const { id } = await params;
const queryClient = getQueryClient();
void queryClient.prefetchQuery(enquiryByIdOptions(id));
void queryClient.prefetchQuery(opportunityByIdOptions(id));
return (
<PageContainer
pageTitle='Enquiry Detail'
pageTitle='Opportunity Detail'
pageDescription='Header summary, customer/contact, requirement summary, timeline, and related quotations'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<EnquiryDetailPage id={id} />
<OpportunityDetailPage id={id} />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -1,16 +1,16 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import type { SearchParams } from 'nuqs/server';
import PageContainer from '@/components/layout/page-container';
import { crmReferenceQueryOptions, enquiriesQueryOptions } from '@/features/crm-demo/api/queries';
import { EnquiriesTablePage } from '@/features/crm-demo/components/enquiries-table';
import { crmReferenceQueryOptions, opportunitiesQueryOptions } from '@/features/crm-demo/api/queries';
import { OpportunitiesTablePage } from '@/features/crm-demo/components/opportunities-table';
import { getQueryClient } from '@/lib/query-client';
import { searchParamsCache } from '@/lib/searchparams';
export const metadata = {
title: 'Dashboard: CRM Enquiries'
title: 'Dashboard: CRM Opportunities'
};
export default async function EnquiriesRoute({
export default async function OpportunitiesRoute({
searchParams
}: {
searchParams: Promise<SearchParams>;
@@ -29,7 +29,7 @@ export default async function EnquiriesRoute({
void queryClient.prefetchQuery(crmReferenceQueryOptions());
void queryClient.prefetchQuery(
enquiriesQueryOptions({
opportunitiesQueryOptions({
page,
limit: perPage,
...(search && { search }),
@@ -44,12 +44,13 @@ export default async function EnquiriesRoute({
return (
<PageContainer
pageTitle='Enquiries'
pageTitle='Opportunities'
pageDescription='List ของ sales opportunity พร้อม filter, date range และ convert to quotation'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<EnquiriesTablePage />
<OpportunitiesTablePage />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -15,7 +15,7 @@ export default function CrmDashboardRoute() {
return (
<PageContainer
pageTitle='CRM Dashboard'
pageDescription='ภาพรวม workflow ของ Customer → Enquiry → Quotation → Approval → Won/Lost'
pageDescription='ภาพรวม workflow ของ Customer → Opportunity → Quotation → Approval → Won/Lost'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<CrmDashboardPage />
@@ -23,3 +23,4 @@ export default function CrmDashboardRoute() {
</PageContainer>
);
}

View File

@@ -7,7 +7,7 @@ import {
getCustomerDetail,
getCustomerReferenceData
} from '@/features/crm/customers/server/service';
import { listCustomerEnquiryRelations } from '@/features/crm/enquiries/server/service';
import { listCustomerOpportunityRelations } from '@/features/crm/opportunities/server/service';
import { listCustomerQuotationRelations } from '@/features/crm/quotations/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { getQueryClient } from '@/lib/query-client';
@@ -100,9 +100,9 @@ export default async function CustomerDetailRoute({ params }: PageProps) {
resolvedCanRead && session?.user?.activeOrganizationId
? await getCustomerReferenceData(session.user.activeOrganizationId)
: null;
const relatedEnquiries =
const relatedOpportunities =
resolvedCanRead && session?.user?.activeOrganizationId && accessContext
? await listCustomerEnquiryRelations(id, session.user.activeOrganizationId, accessContext)
? await listCustomerOpportunityRelations(id, session.user.activeOrganizationId, accessContext)
: [];
const relatedQuotations =
resolvedCanRead && session?.user?.activeOrganizationId && accessContext
@@ -135,7 +135,7 @@ export default async function CustomerDetailRoute({ params }: PageProps) {
canReadContactShares={canContactShareRead}
canManageContactShares={canContactShareManage}
canManageOwner={canOwnerManage}
relatedEnquiries={relatedEnquiries}
relatedOpportunities={relatedOpportunities}
relatedQuotations={relatedQuotations}
/>
</HydrationBoundary>
@@ -143,3 +143,4 @@ export default async function CustomerDetailRoute({ params }: PageProps) {
</PageContainer>
);
}

View File

@@ -1,83 +0,0 @@
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import EnquiryListing from '@/features/crm/enquiries/components/enquiry-listing';
import { EnquiryFormSheetTrigger } from '@/features/crm/enquiries/components/enquiry-form-sheet';
import { getEnquiryReferenceData } from '@/features/crm/enquiries/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import type { SearchParams } from 'nuqs/server';
export const metadata = {
title: 'Dashboard: CRM Opportunities'
};
type PageProps = {
searchParams: Promise<SearchParams>;
};
export default async function EnquiriesRoute(props: PageProps) {
await props.searchParams;
const session = await auth();
const canRead =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryRead)));
const canCreate =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryCreate)));
const canUpdate =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryUpdate)));
const canDelete =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryDelete)));
const canAssign =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryAssign)));
const canReassign =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryReassign)));
if (!canRead || !session?.user?.activeOrganizationId) {
return (
<PageContainer access={false} pageTitle='CRM Opportunities'>
{null}
</PageContainer>
);
}
const referenceData = await getEnquiryReferenceData(session.user.activeOrganizationId);
return (
<PageContainer
pageTitle='CRM Opportunities'
pageDescription='Sales execution workspace for opportunities created directly or handed off from marketing leads.'
pageHeaderAction={canCreate ? <EnquiryFormSheetTrigger referenceData={referenceData} /> : null}
>
<EnquiryListing
referenceData={referenceData}
workspace='enquiry'
canUpdate={canUpdate}
canDelete={canDelete}
canAssign={canAssign}
canReassign={canReassign}
/>
</PageContainer>
);
}

View File

@@ -64,7 +64,7 @@ export default async function LeadsRoute(props: PageProps) {
return (
<PageContainer
pageTitle="CRM Leads"
pageDescription="Marketing-qualified lead workspace before handoff into sales enquiries."
pageDescription="Marketing-qualified lead workspace before handoff into sales opportunities."
pageHeaderAction={
canCreate ? <LeadFormTrigger referenceData={referenceData} /> : null
}
@@ -80,3 +80,4 @@ export default async function LeadsRoute(props: PageProps) {
</PageContainer>
);
}

View File

@@ -3,14 +3,14 @@ import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { notFound } from 'next/navigation';
import PageContainer from '@/components/layout/page-container';
import {
enquiryByIdOptions,
enquiryFollowupsOptions,
enquiryProjectPartiesOptions
} from '@/features/crm/enquiries/api/queries';
import { EnquiryDetail } from '@/features/crm/enquiries/components/enquiry-detail';
import { getEnquiryDetail, getEnquiryReferenceData } from '@/features/crm/enquiries/server/service';
opportunityByIdOptions,
opportunityFollowupsOptions,
opportunityProjectPartiesOptions
} from '@/features/crm/opportunities/api/queries';
import { OpportunityDetail } from '@/features/crm/opportunities/components/opportunity-detail';
import { getOpportunityDetail, getOpportunityReferenceData } from '@/features/crm/opportunities/server/service';
import { getLeadById } from '@/features/crm/leads/server/service';
import { listEnquiryQuotationRelations } from '@/features/crm/quotations/server/service';
import { listOpportunityQuotationRelations } from '@/features/crm/quotations/server/service';
import { getQuotationReferenceData } from '@/features/crm/quotations/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { getQueryClient } from '@/lib/query-client';
@@ -19,7 +19,7 @@ type PageProps = {
params: Promise<{ id: string }>;
};
export default async function EnquiryDetailRoute({ params }: PageProps) {
export default async function OpportunityDetailRoute({ params }: PageProps) {
const { id } = await params;
const session = await auth();
@@ -27,61 +27,61 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryRead)));
session.user.activePermissions.includes(PERMISSIONS.crmOpportunityRead)));
const canUpdate =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryUpdate)));
session.user.activePermissions.includes(PERMISSIONS.crmOpportunityUpdate)));
const canAssign =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryAssign)));
session.user.activePermissions.includes(PERMISSIONS.crmOpportunityAssign)));
const canReassign =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryReassign)));
session.user.activePermissions.includes(PERMISSIONS.crmOpportunityReassign)));
const canManageFollowups = {
create:
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryFollowupCreate))),
session.user.activePermissions.includes(PERMISSIONS.crmOpportunityFollowupCreate))),
update:
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryFollowupUpdate))),
session.user.activePermissions.includes(PERMISSIONS.crmOpportunityFollowupUpdate))),
delete:
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryFollowupDelete)))
session.user.activePermissions.includes(PERMISSIONS.crmOpportunityFollowupDelete)))
};
const canMarkWon =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryMarkWon)));
session.user.activePermissions.includes(PERMISSIONS.crmOpportunityMarkWon)));
const canMarkLost =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryMarkLost)));
session.user.activePermissions.includes(PERMISSIONS.crmOpportunityMarkLost)));
const canReopen =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryReopen)));
session.user.activePermissions.includes(PERMISSIONS.crmOpportunityReopen)));
const canViewRelatedQuotations =
session?.user?.systemRole === 'super_admin' ||
@@ -118,17 +118,17 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
const queryClient = getQueryClient();
await Promise.all([
queryClient.prefetchQuery(enquiryByIdOptions(id)),
queryClient.prefetchQuery(enquiryFollowupsOptions(id)),
queryClient.prefetchQuery(enquiryProjectPartiesOptions(id))
queryClient.prefetchQuery(opportunityByIdOptions(id)),
queryClient.prefetchQuery(opportunityFollowupsOptions(id)),
queryClient.prefetchQuery(opportunityProjectPartiesOptions(id))
]);
const enquiryDetail = await getEnquiryDetail(id, session.user.activeOrganizationId, accessContext);
const opportunityDetail = await getOpportunityDetail(id, session.user.activeOrganizationId, accessContext);
const [referenceData, relatedQuotations, sourceLead, quotationReferenceData] = await Promise.all([
getEnquiryReferenceData(session.user.activeOrganizationId),
listEnquiryQuotationRelations(id, session.user.activeOrganizationId),
enquiryDetail.leadId
? getLeadById(enquiryDetail.leadId, session.user.activeOrganizationId).catch(() => null)
getOpportunityReferenceData(session.user.activeOrganizationId),
listOpportunityQuotationRelations(id, session.user.activeOrganizationId),
opportunityDetail.leadId
? getLeadById(opportunityDetail.leadId, session.user.activeOrganizationId).catch(() => null)
: Promise.resolve(null),
canCreateQuotation ? getQuotationReferenceData(session.user.activeOrganizationId) : Promise.resolve(null)
]);
@@ -139,11 +139,11 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
pageDescription='Sales execution workspace for follow-up continuity, quotation readiness, and outcome control.'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<EnquiryDetail
enquiryId={id}
<OpportunityDetail
opportunityId={id}
referenceData={referenceData}
relatedQuotations={relatedQuotations}
workspace='enquiry'
workspace='opportunity'
canUpdate={canUpdate}
canAssign={canAssign}
canReassign={canReassign}
@@ -171,3 +171,4 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
</PageContainer>
);
}

View File

@@ -0,0 +1,93 @@
import { auth } from "@/auth";
import PageContainer from "@/components/layout/page-container";
import OpportunityListing from "@/features/crm/opportunities/components/opportunity-listing";
import { OpportunityFormSheetTrigger } from "@/features/crm/opportunities/components/opportunity-form-sheet";
import { getOpportunityReferenceData } from "@/features/crm/opportunities/server/service";
import { PERMISSIONS } from "@/lib/auth/rbac";
import type { SearchParams } from "nuqs/server";
import { searchParamsCache } from "@/lib/searchparams";
export const metadata = {
title: "Dashboard: CRM Opportunities",
};
type PageProps = {
searchParams: Promise<SearchParams>;
};
export default async function OpportunitiesRoute(props: PageProps) {
const searchParams = await props.searchParams;
const session = await auth();
const canRead =
session?.user?.systemRole === "super_admin" ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === "admin" ||
session.user.activePermissions.includes(PERMISSIONS.crmOpportunityRead)));
const canCreate =
session?.user?.systemRole === "super_admin" ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === "admin" ||
session.user.activePermissions.includes(PERMISSIONS.crmOpportunityCreate)));
const canUpdate =
session?.user?.systemRole === "super_admin" ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === "admin" ||
session.user.activePermissions.includes(PERMISSIONS.crmOpportunityUpdate)));
const canDelete =
session?.user?.systemRole === "super_admin" ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === "admin" ||
session.user.activePermissions.includes(PERMISSIONS.crmOpportunityDelete)));
const canAssign =
session?.user?.systemRole === "super_admin" ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === "admin" ||
session.user.activePermissions.includes(PERMISSIONS.crmOpportunityAssign)));
const canReassign =
session?.user?.systemRole === "super_admin" ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === "admin" ||
session.user.activePermissions.includes(
PERMISSIONS.crmOpportunityReassign,
)));
if (!canRead || !session?.user?.activeOrganizationId) {
return (
<PageContainer access={false} pageTitle="CRM Opportunities">
{null}
</PageContainer>
);
}
searchParamsCache.parse(searchParams);
const referenceData = await getOpportunityReferenceData(
session.user.activeOrganizationId,
);
return (
<PageContainer
pageTitle="CRM Opportunities"
pageDescription="Sales execution workspace for opportunities created directly or handed off from marketing leads."
pageHeaderAction={
canCreate ? (
<OpportunityFormSheetTrigger referenceData={referenceData} />
) : null
}
>
<OpportunityListing
referenceData={referenceData}
workspace="opportunity"
canUpdate={canUpdate}
canDelete={canDelete}
canAssign={canAssign}
canReassign={canReassign}
/>
</PageContainer>
);
}

View File

@@ -2,7 +2,7 @@ import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import {
crmEnquiryAgingReportQueryOptions,
crmOpportunityAgingReportQueryOptions,
crmReportFiltersQueryOptions
} from '@/features/crm/reports/api/queries';
import { AgingReportView } from '@/features/crm/reports/components/aging-report-view';
@@ -11,7 +11,7 @@ import { getQueryClient } from '@/lib/query-client';
import type { SearchParams } from 'nuqs/server';
export const metadata = {
title: 'CRM Enquiry Aging Report'
title: 'CRM Opportunity Aging Report'
};
type PageProps = {
@@ -22,7 +22,7 @@ function getFilterValue(value: string | string[] | undefined): string | null {
return typeof value === 'string' ? value : null;
}
export default async function CrmEnquiryAgingRoute(props: PageProps) {
export default async function CrmOpportunityAgingRoute(props: PageProps) {
const searchParams = await props.searchParams;
const session = await auth();
const canRead =
@@ -48,25 +48,26 @@ export default async function CrmEnquiryAgingRoute(props: PageProps) {
if (canRead) {
void queryClient.prefetchQuery(crmReportFiltersQueryOptions());
void queryClient.prefetchQuery(crmEnquiryAgingReportQueryOptions(filters));
void queryClient.prefetchQuery(crmOpportunityAgingReportQueryOptions(filters));
}
return (
<PageContainer
pageTitle='Enquiry Aging Report'
pageDescription='Identify stalled enquiries that need follow-up or progression.'
pageTitle='Opportunity Aging Report'
pageDescription='Identify stalled opportunities that need follow-up or progression.'
access={canRead}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
You do not have access to CRM enquiry aging reports.
You do not have access to CRM opportunity aging reports.
</div>
}
>
{canRead ? (
<HydrationBoundary state={dehydrate(queryClient)}>
<AgingReportView variant='enquiry' canExport={canExport} />
<AgingReportView variant='opportunity' canExport={canExport} />
</HydrationBoundary>
) : null}
</PageContainer>
);
}

View File

@@ -56,7 +56,7 @@ export default async function CrmPipelineReportsRoute(props: PageProps) {
return (
<PageContainer
pageTitle='Pipeline Reports'
pageDescription='Lead, enquiry, conversion, and funnel analytics for the CRM pipeline lifecycle.'
pageDescription='Lead, opportunity, conversion, and funnel analytics for the CRM pipeline lifecycle.'
access={canRead}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
@@ -72,3 +72,4 @@ export default async function CrmPipelineReportsRoute(props: PageProps) {
</PageContainer>
);
}