This commit is contained in:
phaichayon
2026-06-22 20:07:51 +07:00
parent ffa5de8311
commit 5c28080e9b
25 changed files with 1726 additions and 269 deletions

View File

@@ -0,0 +1,27 @@
import { queryOptions } from '@tanstack/react-query';
import { getCrmReportCatalog, getCrmReportFilters, getCrmReports } from './service';
export const crmReportKeys = {
all: ['crm-reports'] as const,
list: () => [...crmReportKeys.all, 'list'] as const,
catalog: () => [...crmReportKeys.all, 'catalog'] as const,
filters: () => [...crmReportKeys.all, 'filters'] as const
};
export const crmReportsQueryOptions = () =>
queryOptions({
queryKey: crmReportKeys.list(),
queryFn: () => getCrmReports()
});
export const crmReportCatalogQueryOptions = () =>
queryOptions({
queryKey: crmReportKeys.catalog(),
queryFn: () => getCrmReportCatalog()
});
export const crmReportFiltersQueryOptions = () =>
queryOptions({
queryKey: crmReportKeys.filters(),
queryFn: () => getCrmReportFilters()
});

View File

@@ -0,0 +1,18 @@
import { apiClient } from '@/lib/api-client';
import type {
CrmReportCatalogResponse,
CrmReportFiltersResponse,
CrmReportsResponse
} from './types';
export async function getCrmReports() {
return apiClient<CrmReportsResponse>('/crm/reports');
}
export async function getCrmReportCatalog() {
return apiClient<CrmReportCatalogResponse>('/crm/reports/catalog');
}
export async function getCrmReportFilters() {
return apiClient<CrmReportFiltersResponse>('/crm/reports/filters');
}

View File

@@ -0,0 +1,75 @@
export interface CrmReportDefinitionRecord {
id: string;
code: string;
name: string;
description: string | null;
category: string;
categoryLabel: string;
isSystem: boolean;
isActive: boolean;
}
export interface CrmReportCatalogGroup {
category: string;
categoryLabel: string;
items: CrmReportDefinitionRecord[];
}
export interface CrmReportFilterOption {
id: string;
code: string;
label: string;
}
export interface CrmReportUserOption {
id: string;
name: string;
}
export interface CrmSharedReportFilters {
dateFrom?: string | null;
dateTo?: string | null;
branch?: string | null;
productType?: string | null;
sales?: string | null;
customer?: string | null;
customerOwner?: string | null;
leadSource?: string | null;
pipelineStage?: string | null;
outcome?: string | null;
lostReason?: string | null;
}
export interface CrmReportFilterMetadata {
branches: CrmReportFilterOption[];
productTypes: CrmReportFilterOption[];
sales: CrmReportUserOption[];
customers: CrmReportFilterOption[];
customerOwners: CrmReportUserOption[];
leadSources: CrmReportFilterOption[];
pipelineStages: CrmReportFilterOption[];
outcomes: CrmReportFilterOption[];
lostReasons: CrmReportFilterOption[];
}
export interface CrmReportsResponse {
success: boolean;
time: string;
message: string;
items: CrmReportDefinitionRecord[];
groups: CrmReportCatalogGroup[];
}
export interface CrmReportCatalogResponse {
success: boolean;
time: string;
message: string;
groups: CrmReportCatalogGroup[];
}
export interface CrmReportFiltersResponse {
success: boolean;
time: string;
message: string;
filters: CrmReportFilterMetadata;
}

View File

@@ -0,0 +1,73 @@
'use client';
import { useSuspenseQuery } from '@tanstack/react-query';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { crmReportCatalogQueryOptions, crmReportFiltersQueryOptions } from '../api/queries';
export function ReportCatalog() {
const { data: catalog } = useSuspenseQuery(crmReportCatalogQueryOptions());
const { data: filterData } = useSuspenseQuery(crmReportFiltersQueryOptions());
return (
<div className='space-y-6'>
<Card>
<CardHeader>
<CardTitle>Shared Report Filters</CardTitle>
<CardDescription>
Central filter metadata prepared for upcoming CRM report modules.
</CardDescription>
</CardHeader>
<CardContent className='grid gap-3 md:grid-cols-2 xl:grid-cols-3'>
<div className='rounded-lg border p-4 text-sm'>
<div className='font-medium'>Date Range</div>
<div className='text-muted-foreground mt-1'>Shared dateFrom/dateTo contract for all reports.</div>
</div>
{[
['Branches', filterData.filters.branches.length],
['Product Types', filterData.filters.productTypes.length],
['Sales', filterData.filters.sales.length],
['Customers', filterData.filters.customers.length],
['Customer Owners', filterData.filters.customerOwners.length],
['Lead Sources', filterData.filters.leadSources.length],
['Pipeline Stages', filterData.filters.pipelineStages.length],
['Outcomes', filterData.filters.outcomes.length],
['Lost Reasons', filterData.filters.lostReasons.length]
].map(([label, count]) => (
<div key={String(label)} className='rounded-lg border p-4 text-sm'>
<div className='font-medium'>{label}</div>
<div className='text-muted-foreground mt-1'>{count} options available</div>
</div>
))}
</CardContent>
</Card>
<div className='grid gap-6 lg:grid-cols-2 xl:grid-cols-3'>
{catalog.groups.map((group) => (
<Card key={group.category} className='h-full'>
<CardHeader>
<CardTitle>{group.categoryLabel}</CardTitle>
<CardDescription>
{group.items.length} report definition{group.items.length === 1 ? '' : 's'} ready for future modules.
</CardDescription>
</CardHeader>
<CardContent className='space-y-3'>
{group.items.map((item) => (
<div key={item.id} className='rounded-lg border p-4'>
<div className='flex items-start justify-between gap-3'>
<div>
<div className='font-medium'>{item.name}</div>
<div className='text-muted-foreground mt-1 text-sm'>{item.description}</div>
</div>
{item.isSystem ? <Badge variant='secondary'>System</Badge> : null}
</div>
<div className='text-muted-foreground mt-3 text-xs'>{item.code}</div>
</div>
))}
</CardContent>
</Card>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import type { CrmReportCatalogGroup, CrmReportDefinitionRecord } from '../../api/types';
export function buildReportCatalogGroups(
items: CrmReportDefinitionRecord[]
): CrmReportCatalogGroup[] {
const grouped = new Map<string, CrmReportCatalogGroup>();
for (const item of items) {
const current =
grouped.get(item.category) ??
{
category: item.category,
categoryLabel: item.categoryLabel,
items: []
};
current.items.push(item);
grouped.set(item.category, current);
}
return [...grouped.values()];
}

View File

@@ -0,0 +1,33 @@
import type { ResolvedReportContext } from './types';
export function resolveCrmAccess(input: {
organizationId: string;
userId: string;
membership: {
role: string;
businessRole: string;
businessRoles?: string[];
permissions?: string[] | null;
branchScopeIds?: string[] | null;
productTypeScopeIds?: string[] | null;
ownershipScope?: string | null;
branchScopeMode?: string | null;
productScopeMode?: string | null;
approvalAuthority?: string | null;
};
}): ResolvedReportContext {
return {
organizationId: input.organizationId,
userId: input.userId,
membershipRole: input.membership.role,
businessRole: input.membership.businessRole,
businessRoles: input.membership.businessRoles ?? [input.membership.businessRole],
permissions: input.membership.permissions ?? [],
branchScopeIds: input.membership.branchScopeIds ?? [],
productTypeScopeIds: input.membership.productTypeScopeIds ?? [],
ownershipScope: input.membership.ownershipScope ?? 'organization',
branchScopeMode: input.membership.branchScopeMode ?? 'all',
productScopeMode: input.membership.productScopeMode ?? 'all',
approvalAuthority: input.membership.approvalAuthority ?? null
};
}

View File

@@ -0,0 +1,42 @@
import { and, asc, eq } from 'drizzle-orm';
import { crmReportDefinitions } from '@/db/schema';
import { db } from '@/lib/db';
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
import type { ReportRegistryRow, ResolvedReportContext } from '../types';
export async function listReportRegistry(
context: ResolvedReportContext
): Promise<ReportRegistryRow[]> {
const [rows, categoryOptions] = await Promise.all([
db
.select()
.from(crmReportDefinitions)
.where(
and(
eq(crmReportDefinitions.organizationId, context.organizationId),
eq(crmReportDefinitions.isActive, true)
)
)
.orderBy(asc(crmReportDefinitions.category), asc(crmReportDefinitions.name)),
getActiveOptionsByCategory('crm_report_category', {
organizationId: context.organizationId
})
]);
const categoryMap = new Map(
categoryOptions.flatMap((item) => [
[item.id, item.label] as const,
[item.code, item.label] as const
])
);
return rows.map((row) => ({
id: row.id,
code: row.code,
name: row.name,
description: row.description,
category: row.category,
categoryLabel: categoryMap.get(row.category) ?? row.category,
isSystem: row.isSystem,
isActive: row.isActive
}));
}

View File

@@ -0,0 +1,44 @@
import { auditAction } from '@/features/foundation/audit-log/service';
function escapeCsv(value: string | number | null | undefined) {
const text = value === null || value === undefined ? '' : String(value);
return `"${text.replaceAll('"', '""')}"`;
}
export function buildReportCsv(rows: string[][]) {
return rows.map((row) => row.map((cell) => escapeCsv(cell)).join(',')).join('\n');
}
export function buildReportExcel(rows: string[][]) {
const body = rows
.map(
(row, rowIndex) =>
`<tr>${row
.map((cell) => (rowIndex === 0 ? `<th>${cell}</th>` : `<td>${cell}</td>`))
.join('')}</tr>`
)
.join('');
return `<!DOCTYPE html><html><head><meta charset="utf-8" /></head><body><table>${body}</table></body></html>`;
}
export async function auditReportExport(input: {
organizationId: string;
userId: string;
reportCode: string;
format: 'csv' | 'xls';
filters?: Record<string, unknown>;
}) {
await auditAction({
organizationId: input.organizationId,
userId: input.userId,
entityType: 'crm_report',
entityId: input.reportCode,
action: input.format === 'xls' ? 'export_excel' : 'export_csv',
afterData: {
reportCode: input.reportCode,
filters: input.filters ?? {},
userId: input.userId
}
});
}

View File

@@ -0,0 +1,71 @@
import { asc, eq } from 'drizzle-orm';
import { crmCustomers, memberships, users } from '@/db/schema';
import { db } from '@/lib/db';
import { getUserBranches } from '@/features/foundation/branch-scope/service';
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access';
import type { CrmReportFilterMetadata } from '../../api/types';
import type { ResolvedReportContext } from '../types';
const PIPELINE_STAGE_OPTIONS = [
{ id: 'lead', code: 'lead', label: 'Lead' },
{ id: 'enquiry', code: 'enquiry', label: 'Enquiry' },
{ id: 'closed_won', code: 'closed_won', label: 'Won' },
{ id: 'closed_lost', code: 'closed_lost', label: 'Lost' }
] as const;
const OUTCOME_OPTIONS = [
{ id: 'open', code: 'open', label: 'Open' },
{ id: 'won', code: 'won', label: 'Won' },
{ id: 'lost', code: 'lost', label: 'Lost' }
] as const;
export async function buildSharedReportFilters(
context: ResolvedReportContext
): Promise<CrmReportFilterMetadata> {
const [branches, productTypes, leadSources, lostReasons, customers, memberRows] = await Promise.all([
getUserBranches(),
getActiveOptionsByCategory('crm_product_type', { organizationId: context.organizationId }),
getActiveOptionsByCategory('crm_lead_channel', { organizationId: context.organizationId }),
getActiveOptionsByCategory('crm_lost_reason', { organizationId: context.organizationId }),
db
.select({ id: crmCustomers.id, code: crmCustomers.code, name: crmCustomers.name, branchId: crmCustomers.branchId })
.from(crmCustomers)
.where(eq(crmCustomers.organizationId, context.organizationId))
.orderBy(asc(crmCustomers.name)),
db
.select({
userId: memberships.userId,
name: users.name
})
.from(memberships)
.innerJoin(users, eq(users.id, memberships.userId))
.where(eq(memberships.organizationId, context.organizationId))
.orderBy(asc(users.name))
]);
const visibleBranches = branches.filter((item) => hasBranchScopeAccess(context, item.id));
const visibleProductTypes = productTypes.filter((item) => hasProductScopeAccess(context, item.id));
const visibleCustomers = customers.filter((item) => hasBranchScopeAccess(context, item.branchId));
const uniqueUsers = [...new Map(memberRows.map((row) => [row.userId, row])).values()];
return {
branches: visibleBranches.map((item) => ({ id: item.id, code: item.code, label: item.name })),
productTypes: visibleProductTypes.map((item) => ({
id: item.id,
code: item.code,
label: item.label
})),
sales: uniqueUsers.map((item) => ({ id: item.userId, name: item.name })),
customers: visibleCustomers.map((item) => ({
id: item.id,
code: item.code,
label: item.name
})),
customerOwners: uniqueUsers.map((item) => ({ id: item.userId, name: item.name })),
leadSources: leadSources.map((item) => ({ id: item.id, code: item.code, label: item.label })),
pipelineStages: PIPELINE_STAGE_OPTIONS.map((item) => ({ ...item })),
outcomes: OUTCOME_OPTIONS.map((item) => ({ ...item })),
lostReasons: lostReasons.map((item) => ({ id: item.id, code: item.code, label: item.label }))
};
}

View File

@@ -0,0 +1,78 @@
import { auditAction } from '@/features/foundation/audit-log/service';
import type {
CrmReportCatalogResponse,
CrmReportFiltersResponse,
CrmReportsResponse
} from '../api/types';
import { buildReportCatalogGroups } from './builders/catalog-builder';
import { resolveCrmAccess } from './context';
import { listReportRegistry } from './datasets/catalog';
import { buildSharedReportFilters } from './filters/shared';
import type { ResolvedReportContext } from './types';
export function buildResolvedReportContext(input: Parameters<typeof resolveCrmAccess>[0]) {
return resolveCrmAccess(input);
}
async function auditReportView(context: ResolvedReportContext, reportCode: string, filters?: Record<string, unknown>) {
await auditAction({
organizationId: context.organizationId,
userId: context.userId,
entityType: 'crm_report',
entityId: reportCode,
action: 'view',
afterData: {
reportCode,
filters: filters ?? {},
userId: context.userId
}
});
}
export async function getCrmReportsResponse(
context: ResolvedReportContext
): Promise<CrmReportsResponse> {
const items = await listReportRegistry(context);
const groups = buildReportCatalogGroups(items);
await auditReportView(context, 'report_center');
return {
success: true,
time: new Date().toISOString(),
message: 'CRM reports loaded successfully',
items,
groups
};
}
export async function getCrmReportCatalogResponse(
context: ResolvedReportContext
): Promise<CrmReportCatalogResponse> {
const items = await listReportRegistry(context);
const groups = buildReportCatalogGroups(items);
await auditReportView(context, 'report_catalog');
return {
success: true,
time: new Date().toISOString(),
message: 'CRM report catalog loaded successfully',
groups
};
}
export async function getCrmReportFiltersResponse(
context: ResolvedReportContext
): Promise<CrmReportFiltersResponse> {
const filters = await buildSharedReportFilters(context);
await auditReportView(context, 'report_filters');
return {
success: true,
time: new Date().toISOString(),
message: 'CRM report filters loaded successfully',
filters
};
}

View File

@@ -0,0 +1,25 @@
export interface ResolvedReportContext {
organizationId: string;
userId: string;
membershipRole: string;
businessRole: string;
businessRoles: string[];
permissions: string[];
branchScopeIds: string[];
productTypeScopeIds: string[];
ownershipScope: string;
branchScopeMode: string;
productScopeMode: string;
approvalAuthority: string | null;
}
export interface ReportRegistryRow {
id: string;
code: string;
name: string;
description: string | null;
category: string;
categoryLabel: string;
isSystem: boolean;
isActive: boolean;
}