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

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 (
<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} />
<DashboardSummaryCards
summary={data.summary}
canViewCommercialData={data.visibility.canViewCommercialData}
/>
{data.visibility.canViewCommercialData ? (
<div className='grid gap-6 2xl:grid-cols-[1.35fr_1fr]'>
<DashboardFunnel steps={data.funnel} />
<DashboardHotProjects rows={data.hotProjects} />
</div>
) : null}
{data.visibility.canViewCommercialData ? (
<DashboardRevenueCards revenueAnalytics={data.revenueAnalytics} />
) : null}
{data.visibility.canViewCommercialData || data.visibility.canViewApprovalData ? (
<div className='grid gap-6 2xl:grid-cols-[1.2fr_1fr]'>
{data.visibility.canViewCommercialData ? (
<DashboardSalesRanking rows={data.salesRanking} />
) : null}
{data.visibility.canViewApprovalData ? (
<DashboardApprovalMetrics approvals={data.approvals} />
) : null}
</div>
) : null}
<DashboardFollowups followups={data.followups} />
</div>
);

View File

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

View File

@@ -47,6 +47,17 @@ type ProjectPartyTableAvailability = {
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) {
return value
?.split(',')
@@ -412,7 +423,7 @@ async function getOutcomeRevenueRecords(
): Promise<OutcomeRevenueRecord[]> {
const whereFilters = buildOutcomeRevenueFilters(organizationId, stage, filters);
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) {
return [];
@@ -556,4 +567,3 @@ export async function getWinRate(
return Math.round((wonRows.length / denominator) * 10000) / 100;
}

View File

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

View File

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

View File

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