task-fix
This commit is contained in:
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -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
305
plans/task-fixcrm.md
Normal 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
|
||||||
|
```
|
||||||
@@ -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'),
|
||||||
{
|
dateTo: searchParams.get('dateTo'),
|
||||||
dateFrom: searchParams.get('dateFrom'),
|
branch: searchParams.get('branch'),
|
||||||
dateTo: searchParams.get('dateTo'),
|
salesman: searchParams.get('salesman'),
|
||||||
branch: searchParams.get('branch'),
|
projectPartyRole: searchParams.get('projectPartyRole'),
|
||||||
salesman: searchParams.get('salesman'),
|
productType: searchParams.get('productType')
|
||||||
projectPartyRole: searchParams.get('projectPartyRole'),
|
});
|
||||||
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 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,8 +782,8 @@ 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(
|
||||||
eq(crmOpportunities.organizationId, context.organizationId),
|
eq(crmOpportunities.organizationId, context.organizationId),
|
||||||
@@ -871,4 +905,3 @@ export async function getCrmDashboardData(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user