This commit is contained in:
phaichayon
2026-06-25 16:26:14 +07:00
parent 69279fc341
commit 5a83040077
11 changed files with 547 additions and 64 deletions

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts"; import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

305
plans/task-fixcrm.md Normal file
View File

@@ -0,0 +1,305 @@
# Task Fix: CRM Dashboard Opportunity Rename Compatibility
## Objective
Fix CRM Dashboard API/runtime errors after the force rename from Enquiry to Opportunity.
The current issue appears when the CRM dashboard query returns `500 Internal Server Error`, causing TanStack Query hydration to reject:
```txt
["crm-dashboard", {...}]
```
This task aligns dashboard, reports, query keys, imports, SQL, DTOs, and permissions with the new Opportunity domain.
---
## Error Context
Observed error:
```txt
A query that was dehydrated as pending ended up rejecting.
[["crm-dashboard", {...}]]: Error: API error: 500 Internal Server Error
```
Likely root cause:
```txt
Dashboard API still references old Enquiry domain after D.5.5.1 rename.
```
Examples of stale references:
```txt
crm_enquiries
crmEnquiries
enquiryId
Enquiry
enquiry-aging
crm.enquiry.*
```
---
## Mandatory Review
Review:
```txt
src/features/crm/dashboard/**
src/app/api/crm/dashboard/**
src/app/dashboard/crm/**
src/app/api/crm/reports/**
src/app/dashboard/crm/reports/**
src/features/crm/reports/**
src/db/schema.ts
src/features/crm/opportunities/**
src/config/nav-config.ts
src/db/seeds/foundation.seed.ts
```
---
## Scope 1: Identify Stale References
Run:
```bash
rg "crmEnquir|crm_enquir|enquiry|Enquiry|enquiryId|crm\.enquiry|/crm/enquiries" src/features/crm/dashboard src/app/api/crm/dashboard src/app/dashboard/crm src/features/crm/reports src/app/api/crm/reports src/app/dashboard/crm/reports
```
Expected action:
* Replace active application references with Opportunity terminology.
* Historical docs can remain unchanged.
* No active dashboard/report runtime code should depend on Enquiry naming.
---
## Scope 2: Dashboard API Fix
Update dashboard API/service to use:
```txt
crmOpportunities
crm_opportunities
opportunityId
crm.opportunity.*
```
instead of:
```txt
crmEnquiries
crm_enquiries
enquiryId
crm.enquiry.*
```
Ensure all dashboard metrics still return successfully.
Required dashboard response areas to verify:
```txt
summary cards
pipeline counts
sales ranking
follow-up due / overdue
hot projects
quotation summary
won / lost summary
project party filters
```
---
## Scope 3: SQL / Drizzle Query Fix
Fix all raw SQL and Drizzle queries that reference old table/column names.
Replace:
```txt
crm_enquiries
crm_enquiry_followups
crm_enquiry_customers
enquiry_id
```
with:
```txt
crm_opportunities
crm_opportunity_followups
crm_opportunity_customers
opportunity_id
```
If any remaining DB column intentionally keeps legacy naming, document it and map it at service boundary only.
---
## Scope 4: Dashboard DTO / Mapper Fix
Update dashboard DTOs, mappers, and labels.
Rename:
```txt
enquiryCount
enquiryPipeline
enquiryValue
enquiryAging
```
to:
```txt
opportunityCount
opportunityPipeline
opportunityValue
opportunityAging
```
Rules:
* API response should use Opportunity naming.
* UI labels should use Opportunity naming.
* Do not expose Enquiry naming in active CRM dashboard.
---
## Scope 5: Report Route Compatibility
Verify renamed routes:
```txt
/api/crm/reports/opportunity-aging
/dashboard/crm/reports/opportunity-aging
```
Remove or replace active references to:
```txt
/api/crm/reports/enquiry-aging
/dashboard/crm/reports/enquiry-aging
```
Update nav config if needed.
---
## Scope 6: Permissions
Replace dashboard/report permission checks:
```txt
crm.enquiry.*
```
with:
```txt
crm.opportunity.*
```
Verify seed/config contains the required permissions:
```txt
crm.opportunity.read
crm.opportunity.create
crm.opportunity.update
crm.opportunity.delete
crm.opportunity.assign
crm.opportunity.followup.create
crm.opportunity.followup.update
crm.opportunity.followup.delete
crm.opportunity.lifecycle.update
crm.opportunity.lifecycle.reopen
```
---
## Scope 7: Error Handling Improvement
Prevent dashboard SSR hydration from failing hard when API returns an error.
Improve dashboard query behavior:
* Server prefetch should catch API failure and return a safe empty dashboard model, or
* Client component should render an error state instead of causing hydration failure.
Do not hide backend errors in development logs.
Recommended:
```txt
dashboard API logs detailed server error
dashboard UI shows recoverable error message
query fallback uses empty dashboard model only when safe
```
---
## Scope 8: Verification
Run:
```bash
npm exec tsc --noEmit
npm run build
```
Search validation:
```bash
rg "crmEnquir|crm_enquir|enquiry|Enquiry|enquiryId|crm\.enquiry|/crm/enquiries" src
```
Allowed only if:
```txt
historical docs
comments explicitly marked legacy
migration history
```
Manual verification:
```txt
Open CRM Dashboard
Dashboard API returns 200
No TanStack hydration rejection
Opportunity counts render
Quotation counts render
Won/Lost summary renders
Sales ranking renders
Follow-up widgets render
Hot project widgets render
Opportunity aging report opens
Navigation links use opportunity routes
```
---
## Definition of Done
Task is complete when:
* CRM Dashboard no longer returns 500
* Dashboard no longer references active Enquiry domain
* Reports use Opportunity naming
* Permissions use `crm.opportunity.*`
* API response and UI labels use Opportunity terminology
* TanStack hydration error is gone
* `npm exec tsc --noEmit` passes
* `npm run build` passes
Result:
```txt
Dashboard Opportunity Rename Compatibility = Fixed
CRM Dashboard Runtime = Restored
Ready to continue Approval + Notification roadmap
```

View File

@@ -15,17 +15,14 @@ export async function GET(request: NextRequest) {
userId: session.user.id, userId: session.user.id,
membership membership
}); });
const data = await getCrmDashboardData( const data = await getCrmDashboardData(securityContext, {
securityContext,
{
dateFrom: searchParams.get('dateFrom'), dateFrom: searchParams.get('dateFrom'),
dateTo: searchParams.get('dateTo'), dateTo: searchParams.get('dateTo'),
branch: searchParams.get('branch'), branch: searchParams.get('branch'),
salesman: searchParams.get('salesman'), salesman: searchParams.get('salesman'),
projectPartyRole: searchParams.get('projectPartyRole'), projectPartyRole: searchParams.get('projectPartyRole'),
productType: searchParams.get('productType') productType: searchParams.get('productType')
} });
);
return NextResponse.json(data); return NextResponse.json(data);
} catch (error) { } catch (error) {
@@ -35,6 +32,22 @@ export async function GET(request: NextRequest) {
console.error('GET /api/crm/dashboard failed', error); console.error('GET /api/crm/dashboard failed', error);
return NextResponse.json({ message: 'Unable to load CRM dashboard' }, { status: 500 }); return NextResponse.json(
{
message: 'Unable to load CRM dashboard',
...(process.env.NODE_ENV !== 'production' && error instanceof Error
? {
detail: error.message,
cause:
error.cause instanceof Error
? error.cause.message
: error.cause
? String(error.cause)
: null
}
: {})
},
{ status: 500 }
);
} }
} }

View File

@@ -1,12 +1,16 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import type { SearchParams } from 'nuqs/server';
import { auth } from '@/auth'; import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container'; import PageContainer from '@/components/layout/page-container';
import { crmDashboardQueryOptions } from '@/features/crm/dashboard/api/queries'; import { crmDashboardQueryOptions } from '@/features/crm/dashboard/api/queries';
import {
createEmptyCrmDashboardResponse,
type CrmDashboardFilters
} from '@/features/crm/dashboard/api/types';
import { CrmDashboard } from '@/features/crm/dashboard/components/crm-dashboard'; import { CrmDashboard } from '@/features/crm/dashboard/components/crm-dashboard';
import { PERMISSIONS } from '@/lib/auth/rbac'; import { PERMISSIONS } from '@/lib/auth/rbac';
import { getQueryClient } from '@/lib/query-client'; import { getQueryClient } from '@/lib/query-client';
import { searchParamsCache } from '@/lib/searchparams'; import { searchParamsCache } from '@/lib/searchparams';
import type { SearchParams } from 'nuqs/server';
export const metadata = { export const metadata = {
title: 'Dashboard: CRM' title: 'Dashboard: CRM'
@@ -16,14 +20,27 @@ type PageProps = {
searchParams: Promise<SearchParams>; searchParams: Promise<SearchParams>;
}; };
function normalizeRequiredFilters(filters: CrmDashboardFilters): Required<CrmDashboardFilters> {
return {
dateFrom: filters.dateFrom ?? null,
dateTo: filters.dateTo ?? null,
branch: filters.branch ?? null,
salesman: filters.salesman ?? null,
projectPartyRole: filters.projectPartyRole ?? null,
productType: filters.productType ?? null
};
}
export default async function CrmDashboardRoute(props: PageProps) { export default async function CrmDashboardRoute(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.crmDashboardRead))); session.user.activePermissions.includes(PERMISSIONS.crmDashboardRead)));
const canExport = const canExport =
session?.user?.systemRole === 'super_admin' || session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId && (!!session?.user?.activeOrganizationId &&
@@ -40,16 +57,29 @@ export default async function CrmDashboardRoute(props: PageProps) {
projectPartyRole: searchParamsCache.get('projectPartyRole'), projectPartyRole: searchParamsCache.get('projectPartyRole'),
productType: searchParamsCache.get('productType') productType: searchParamsCache.get('productType')
}; };
const queryClient = getQueryClient(); const queryClient = getQueryClient();
const dashboardQuery = crmDashboardQueryOptions(filters);
if (canRead) { if (canRead) {
void queryClient.prefetchQuery(crmDashboardQueryOptions(filters)); try {
await queryClient.prefetchQuery(dashboardQuery);
} catch (error) {
console.error('CRM dashboard prefetch failed', error);
queryClient.setQueryData(
dashboardQuery.queryKey,
createEmptyCrmDashboardResponse(
normalizeRequiredFilters(filters),
'CRM dashboard data could not be loaded.'
)
);
}
} }
return ( return (
<PageContainer <PageContainer
pageTitle='CRM Dashboard' pageTitle='CRM Dashboard'
pageDescription='Dashboard กลางสำหรับติดตาม KPI, มูลค่าใบเสนอราคา, งานอนุมัติเอกสาร, และภาพรวมการขายจากข้อมูล CRM จริง' pageDescription='Dashboard กลางสำหรับติดตาม KPI มูลค่าใบเสนอราคา งานอนุมัติเอกสาร และภาพรวมการขายจากข้อมูล CRM จริง'
access={canRead} access={canRead}
accessFallback={ accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'> <div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>

View File

@@ -162,3 +162,85 @@ export interface CrmDashboardResponse {
}; };
} }
export function createEmptyCrmDashboardResponse(
filters: Required<CrmDashboardFilters>,
message = 'CRM dashboard is temporarily unavailable.'
): CrmDashboardResponse {
return {
success: false,
time: new Date().toISOString(),
message,
filters,
referenceData: {
branches: [],
salesmen: [],
productTypes: [],
projectPartyRoles: []
},
summary: {
lead: {
leadCount: 0,
newLeads: 0,
unassignedLeads: 0,
averageLeadAgingDays: 0
},
opportunity: {
opportunityCount: 0,
activeOpportunities: 0,
wonCount: 0,
lostCount: 0,
winRate: 0,
hotOpportunities: 0,
opportunityValue: 0
},
quotation: {
draftQuotations: 0,
pendingApproval: 0,
approvedQuotations: 0,
sentQuotations: 0
},
revenue: {
quotationValue: 0,
wonValue: 0,
lostValue: 0
}
},
funnel: [],
revenueAnalytics: {
topEndCustomers: [],
topBillingCustomers: [],
topContractors: [],
topConsultants: []
},
salesRanking: [],
followups: {
summary: {
dueToday: 0,
dueThisWeek: 0,
overdue: 0,
completed: 0
},
upcoming: []
},
approvals: {
summary: {
pendingApprovals: 0,
approvedToday: 0,
rejectedToday: 0,
returnedToday: 0,
averageApprovalTimeHours: 0
},
myPendingApprovals: []
},
hotProjects: [],
outcomeAnalytics: {
winRate: 0,
lostByReason: [],
lostByCompetitor: []
},
visibility: {
canViewCommercialData: false,
canViewApprovalData: false
}
};
}

View File

@@ -35,30 +35,42 @@ export function CrmDashboard({ canExport }: { canExport: boolean }) {
return ( return (
<div className='space-y-6'> <div className='space-y-6'>
{!data.success ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'>
{data.message}
</div>
) : null}
<DashboardFilters referenceData={data.referenceData} canExport={canExport} /> <DashboardFilters referenceData={data.referenceData} canExport={canExport} />
<DashboardSummaryCards <DashboardSummaryCards
summary={data.summary} summary={data.summary}
canViewCommercialData={data.visibility.canViewCommercialData} canViewCommercialData={data.visibility.canViewCommercialData}
/> />
{data.visibility.canViewCommercialData ? ( {data.visibility.canViewCommercialData ? (
<div className='grid gap-6 2xl:grid-cols-[1.35fr_1fr]'> <div className='grid gap-6 2xl:grid-cols-[1.35fr_1fr]'>
<DashboardFunnel steps={data.funnel} /> <DashboardFunnel steps={data.funnel} />
<DashboardHotProjects rows={data.hotProjects} /> <DashboardHotProjects rows={data.hotProjects} />
</div> </div>
) : null} ) : null}
{data.visibility.canViewCommercialData ? ( {data.visibility.canViewCommercialData ? (
<DashboardRevenueCards revenueAnalytics={data.revenueAnalytics} /> <DashboardRevenueCards revenueAnalytics={data.revenueAnalytics} />
) : null} ) : null}
{data.visibility.canViewCommercialData || data.visibility.canViewApprovalData ? ( {data.visibility.canViewCommercialData || data.visibility.canViewApprovalData ? (
<div className='grid gap-6 2xl:grid-cols-[1.2fr_1fr]'> <div className='grid gap-6 2xl:grid-cols-[1.2fr_1fr]'>
{data.visibility.canViewCommercialData ? ( {data.visibility.canViewCommercialData ? (
<DashboardSalesRanking rows={data.salesRanking} /> <DashboardSalesRanking rows={data.salesRanking} />
) : null} ) : null}
{data.visibility.canViewApprovalData ? ( {data.visibility.canViewApprovalData ? (
<DashboardApprovalMetrics approvals={data.approvals} /> <DashboardApprovalMetrics approvals={data.approvals} />
) : null} ) : null}
</div> </div>
) : null} ) : null}
<DashboardFollowups followups={data.followups} /> <DashboardFollowups followups={data.followups} />
</div> </div>
); );

View File

@@ -53,7 +53,7 @@ import type {
CrmDashboardSummary CrmDashboardSummary
} from '../api/types'; } from '../api/types';
const ENQUIRY_STATUS_CATEGORY = 'crm_opportunity_status'; const OPPORTUNITY_STATUS_CATEGORY = 'crm_opportunity_stage';
const QUOTATION_STATUS_CATEGORY = 'crm_quotation_status'; const QUOTATION_STATUS_CATEGORY = 'crm_quotation_status';
const PRODUCT_TYPE_CATEGORY = 'crm_product_type'; const PRODUCT_TYPE_CATEGORY = 'crm_product_type';
const PROJECT_PARTY_ROLE_CATEGORY = 'crm_project_party_role'; const PROJECT_PARTY_ROLE_CATEGORY = 'crm_project_party_role';
@@ -85,6 +85,26 @@ type StatusMaps = {
quotationStatusById: Map<string, string>; quotationStatusById: Map<string, string>;
}; };
const dashboardOpportunitySelect = {
id: crmOpportunities.id,
organizationId: crmOpportunities.organizationId,
branchId: crmOpportunities.branchId,
customerId: crmOpportunities.customerId,
code: crmOpportunities.code,
title: crmOpportunities.title,
projectName: crmOpportunities.projectName,
productType: crmOpportunities.productType,
estimatedValue: crmOpportunities.estimatedValue,
chancePercent: crmOpportunities.chancePercent,
isHotProject: crmOpportunities.isHotProject,
pipelineStage: crmOpportunities.pipelineStage,
assignedToUserId: crmOpportunities.assignedToUserId,
createdAt: crmOpportunities.createdAt,
createdBy: crmOpportunities.createdBy,
updatedAt: crmOpportunities.updatedAt,
deletedAt: crmOpportunities.deletedAt
} as const;
function normalizeDashboardFilters(filters: CrmDashboardFilters): NormalizedFilters { function normalizeDashboardFilters(filters: CrmDashboardFilters): NormalizedFilters {
const now = new Date(); const now = new Date();
const monthStart = startOfDay(new Date(now.getFullYear(), now.getMonth(), 1)); const monthStart = startOfDay(new Date(now.getFullYear(), now.getMonth(), 1));
@@ -133,7 +153,10 @@ function hasDashboardFullScope(context: DashboardContext) {
} }
function canAccessDashboardOpportunity( function canAccessDashboardOpportunity(
row: typeof crmOpportunities.$inferSelect, row: Pick<
typeof crmOpportunities.$inferSelect,
'branchId' | 'productType' | 'createdBy' | 'assignedToUserId'
>,
context: DashboardContext context: DashboardContext
) { ) {
if (!hasBranchScopeAccess(context, row.branchId)) { if (!hasBranchScopeAccess(context, row.branchId)) {
@@ -180,7 +203,7 @@ function canAccessDashboardQuotation(
async function getStatusMaps(organizationId: string): Promise<StatusMaps> { async function getStatusMaps(organizationId: string): Promise<StatusMaps> {
const [opportunityOptions, quotationOptions] = await Promise.all([ const [opportunityOptions, quotationOptions] = await Promise.all([
getActiveOptionsByCategory(ENQUIRY_STATUS_CATEGORY, { organizationId }), getActiveOptionsByCategory(OPPORTUNITY_STATUS_CATEGORY, { organizationId }),
getActiveOptionsByCategory(QUOTATION_STATUS_CATEGORY, { organizationId }) getActiveOptionsByCategory(QUOTATION_STATUS_CATEGORY, { organizationId })
]); ]);
@@ -192,7 +215,7 @@ async function getStatusMaps(organizationId: string): Promise<StatusMaps> {
async function getReferenceData(organizationId: string): Promise<CrmDashboardReferenceData> { async function getReferenceData(organizationId: string): Promise<CrmDashboardReferenceData> {
const [branches, salesmen, productTypes, projectPartyRoles] = await Promise.all([ const [branches, salesmen, productTypes, projectPartyRoles] = await Promise.all([
getUserBranches(), getUserBranches(organizationId),
db db
.select({ id: users.id, name: users.name }) .select({ id: users.id, name: users.name })
.from(memberships) .from(memberships)
@@ -220,7 +243,10 @@ async function getReferenceData(organizationId: string): Promise<CrmDashboardRef
} }
function matchesOpportunityFilters( function matchesOpportunityFilters(
row: typeof crmOpportunities.$inferSelect, row: Pick<
typeof crmOpportunities.$inferSelect,
'branchId' | 'assignedToUserId' | 'productType' | 'createdAt'
>,
filters: NormalizedFilters filters: NormalizedFilters
) { ) {
const { from, to } = toDayBounds(filters); const { from, to } = toDayBounds(filters);
@@ -248,7 +274,7 @@ function matchesQuotationFilters(
} }
function isPipelineStage( function isPipelineStage(
opportunity: typeof crmOpportunities.$inferSelect, opportunity: Pick<typeof crmOpportunities.$inferSelect, 'pipelineStage'>,
stage: 'lead' | 'opportunity' | 'closed_won' | 'closed_lost' stage: 'lead' | 'opportunity' | 'closed_won' | 'closed_lost'
) { ) {
return opportunity.pipelineStage === stage; return opportunity.pipelineStage === stage;
@@ -260,7 +286,7 @@ async function loadScopedRows(
) { ) {
const [opportunities, quotations] = await Promise.all([ const [opportunities, quotations] = await Promise.all([
db db
.select() .select(dashboardOpportunitySelect)
.from(crmOpportunities) .from(crmOpportunities)
.where( .where(
and(eq(crmOpportunities.organizationId, context.organizationId), isNull(crmOpportunities.deletedAt)) and(eq(crmOpportunities.organizationId, context.organizationId), isNull(crmOpportunities.deletedAt))
@@ -284,7 +310,10 @@ async function loadScopedRows(
} }
function buildSummary( function buildSummary(
scopedOpportunities: Array<typeof crmOpportunities.$inferSelect>, scopedOpportunities: Array<Pick<
typeof crmOpportunities.$inferSelect,
'assignedToUserId' | 'createdAt' | 'estimatedValue' | 'isHotProject' | 'pipelineStage'
>>,
scopedQuotations: Array<typeof crmQuotations.$inferSelect>, scopedQuotations: Array<typeof crmQuotations.$inferSelect>,
statusMaps: StatusMaps, statusMaps: StatusMaps,
outcomeMetrics: { wonValue: number; lostValue: number; winRate: number } outcomeMetrics: { wonValue: number; lostValue: number; winRate: number }
@@ -456,7 +485,7 @@ async function buildRevenueAnalytics(
} }
async function buildSalesRanking( async function buildSalesRanking(
scopedOpportunities: Array<typeof crmOpportunities.$inferSelect>, scopedOpportunities: Array<Pick<typeof crmOpportunities.$inferSelect, 'assignedToUserId' | 'pipelineStage'>>,
scopedQuotations: Array<typeof crmQuotations.$inferSelect>, scopedQuotations: Array<typeof crmQuotations.$inferSelect>,
statusMaps: StatusMaps, statusMaps: StatusMaps,
wonRevenueRows: Array<{ assignedToUserId: string | null; revenue: number }> wonRevenueRows: Array<{ assignedToUserId: string | null; revenue: number }>
@@ -602,7 +631,12 @@ async function buildFollowups(
const opportunityIds = [...new Set(opportunityFollowups.map((row) => row.opportunityId))]; const opportunityIds = [...new Set(opportunityFollowups.map((row) => row.opportunityId))];
const quotationIds = [...new Set(quotationFollowups.map((row) => row.quotationId))]; const quotationIds = [...new Set(quotationFollowups.map((row) => row.quotationId))];
const [opportunities, quotations] = await Promise.all([ const [opportunities, quotations] = await Promise.all([
opportunityIds.length ? db.select().from(crmOpportunities).where(inArray(crmOpportunities.id, opportunityIds)) : [], opportunityIds.length
? db
.select(dashboardOpportunitySelect)
.from(crmOpportunities)
.where(inArray(crmOpportunities.id, opportunityIds))
: [],
quotationIds.length ? db.select().from(crmQuotations).where(inArray(crmQuotations.id, quotationIds)) : [] quotationIds.length ? db.select().from(crmQuotations).where(inArray(crmQuotations.id, quotationIds)) : []
]); ]);
const customerIds = [...new Set([...opportunities.map((row) => row.customerId), ...quotations.map((row) => row.customerId)])]; const customerIds = [...new Set([...opportunities.map((row) => row.customerId), ...quotations.map((row) => row.customerId)])];
@@ -748,7 +782,7 @@ async function buildHotProjects(
statusMaps: StatusMaps statusMaps: StatusMaps
): Promise<CrmDashboardHotProjectRow[]> { ): Promise<CrmDashboardHotProjectRow[]> {
const rows = await db const rows = await db
.select() .select(dashboardOpportunitySelect)
.from(crmOpportunities) .from(crmOpportunities)
.where( .where(
and( and(
@@ -871,4 +905,3 @@ export async function getCrmDashboardData(
} }
}; };
} }

View File

@@ -47,6 +47,17 @@ type ProjectPartyTableAvailability = {
let projectPartyTableAvailability: ProjectPartyTableAvailability | null = null; let projectPartyTableAvailability: ProjectPartyTableAvailability | null = null;
const outcomeOpportunitySelect = {
id: crmOpportunities.id,
customerId: crmOpportunities.customerId,
branchId: crmOpportunities.branchId,
productType: crmOpportunities.productType,
assignedToUserId: crmOpportunities.assignedToUserId,
poAmount: crmOpportunities.poAmount,
closedWonAt: crmOpportunities.closedWonAt,
closedLostAt: crmOpportunities.closedLostAt
} as const;
function splitFilterValue(value?: string) { function splitFilterValue(value?: string) {
return value return value
?.split(',') ?.split(',')
@@ -412,7 +423,7 @@ async function getOutcomeRevenueRecords(
): Promise<OutcomeRevenueRecord[]> { ): Promise<OutcomeRevenueRecord[]> {
const whereFilters = buildOutcomeRevenueFilters(organizationId, stage, filters); const whereFilters = buildOutcomeRevenueFilters(organizationId, stage, filters);
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters); const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
const opportunities = await db.select().from(crmOpportunities).where(where); const opportunities = await db.select(outcomeOpportunitySelect).from(crmOpportunities).where(where);
if (!opportunities.length) { if (!opportunities.length) {
return []; return [];
@@ -556,4 +567,3 @@ export async function getWinRate(
return Math.round((wonRows.length / denominator) * 10000) / 100; return Math.round((wonRows.length / denominator) * 10000) / 100;
} }

View File

@@ -91,7 +91,7 @@ export async function listPipelineReportDataset(
const { start, end } = getDateRange(requiredFilters); const { start, end } = getDateRange(requiredFilters);
const [branches, productTypes, leadSources, opportunityRows, quotationRows, followupRows] = const [branches, productTypes, leadSources, opportunityRows, quotationRows, followupRows] =
await Promise.all([ await Promise.all([
getUserBranches(), getUserBranches(context.organizationId),
getActiveOptionsByCategory('crm_product_type', { organizationId: context.organizationId }), getActiveOptionsByCategory('crm_product_type', { organizationId: context.organizationId }),
getActiveOptionsByCategory('crm_lead_channel', { organizationId: context.organizationId }), getActiveOptionsByCategory('crm_lead_channel', { organizationId: context.organizationId }),
db db
@@ -260,4 +260,3 @@ export async function listPipelineReportDataset(
rows rows
}; };
} }

View File

@@ -14,39 +14,35 @@ export const CRM_TERMS = {
contact: 'ผู้ติดต่อ', contact: 'ผู้ติดต่อ',
contacts: 'ผู้ติดต่อ', contacts: 'ผู้ติดต่อ',
billingCustomer: 'ลูกค้าผู้รับใบเสนอราคา', billingCustomer: 'ลูกค้าผู้รับใบเสนอราคา',
projectParties: 'ผู้เกี่ยวข้องในโครงการ', contractor: 'ผู้รับเหมา',
crmSettings: 'ตั้งค่า CRM', consultant: 'ที่ปรึกษา',
leadCount: 'จำนวนลีด', endCustomer: 'ผู้ใช้งานปลายทาง',
opportunityCount: 'จำนวนโอกาสขาย', projectParty: 'ผู้เกี่ยวข้องในโครงการ'
wonCount: 'จำนวนงานที่ชนะ',
lostCount: 'จำนวนงานที่แพ้',
revenue: 'มูลค่าใบเสนอราคา',
conversionRate: 'อัตราการเปลี่ยนสถานะ'
} as const; } as const;
export const PIPELINE_STAGE_THAI_LABELS = { const PROJECT_PARTY_ROLE_LABELS: Record<string, string> = {
lead: 'ลีด',
opportunity: 'โอกาสขาย',
closed_won: 'ชนะ',
closed_lost: 'แพ้'
} as const;
export const PROJECT_PARTY_ROLE_THAI_LABELS: Record<string, string> = {
billing_customer: 'ลูกค้าผู้รับใบเสนอราคา', billing_customer: 'ลูกค้าผู้รับใบเสนอราคา',
customer: 'ลูกค้า', customer: 'ลูกค้า',
consultant: 'ที่ปรึกษา', consultant: 'ที่ปรึกษา',
contractor: 'ผู้รับเหมา', contractor: 'ผู้รับเหมา',
end_customer: 'เจ้าของโครงการ', end_customer: 'ผู้ใช้งานปลายทาง',
other: 'อื่น ๆ' other: 'อื่น ๆ'
}; };
const PIPELINE_STAGE_LABELS: Record<string, string> = {
lead: 'ลีด',
opportunity: 'โอกาสขาย',
quotation_created: 'ออกใบเสนอราคาแล้ว',
negotiation: 'กำลังเจรจา',
closed: 'ปิดงาน',
closed_won: 'ชนะ',
closed_lost: 'แพ้'
};
export function getProjectPartyRoleThaiLabel(roleCode: string, fallback?: string | null) { export function getProjectPartyRoleThaiLabel(roleCode: string, fallback?: string | null) {
return PROJECT_PARTY_ROLE_THAI_LABELS[roleCode] ?? fallback ?? roleCode; return PROJECT_PARTY_ROLE_LABELS[roleCode] ?? fallback ?? roleCode;
} }
export function getPipelineStageThaiLabel( export function getPipelineStageThaiLabel(stageCode: string, fallback?: string | null) {
stage: keyof typeof PIPELINE_STAGE_THAI_LABELS | string return PIPELINE_STAGE_LABELS[stageCode] ?? fallback ?? stageCode;
) {
return PIPELINE_STAGE_THAI_LABELS[stage as keyof typeof PIPELINE_STAGE_THAI_LABELS] ?? stage;
} }

View File

@@ -1,5 +1,5 @@
import { AuthError } from '@/lib/auth/session';
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service'; import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
import { AuthError } from '@/lib/auth/session';
import type { FoundationBranch } from './types'; import type { FoundationBranch } from './types';
function mapBranchOption( function mapBranchOption(
@@ -13,14 +13,17 @@ function mapBranchOption(
}; };
} }
export async function getUserBranches(): Promise<FoundationBranch[]> { export async function getUserBranches(organizationId?: string): Promise<FoundationBranch[]> {
const options = await getActiveOptionsByCategory('crm_branch'); const options = await getActiveOptionsByCategory(
'crm_branch',
organizationId ? { organizationId } : undefined
);
return options.map(mapBranchOption); return options.map(mapBranchOption);
} }
export async function getActiveBranch(): Promise<FoundationBranch | null> { export async function getActiveBranch(organizationId?: string): Promise<FoundationBranch | null> {
const branches = await getUserBranches(); const branches = await getUserBranches(organizationId);
if (branches.length === 1) { if (branches.length === 1) {
return branches[0]; return branches[0];
@@ -29,12 +32,12 @@ export async function getActiveBranch(): Promise<FoundationBranch | null> {
return null; return null;
} }
export async function validateBranchAccess(branchId?: string | null) { export async function validateBranchAccess(branchId?: string | null, organizationId?: string) {
if (!branchId) { if (!branchId) {
return getActiveBranch(); return getActiveBranch(organizationId);
} }
const branches = await getUserBranches(); const branches = await getUserBranches(organizationId);
const branch = branches.find((item) => item.id === branchId); const branch = branches.find((item) => item.id === branchId);
if (!branch) { if (!branch) {