task-k.1
This commit is contained in:
48
docs/adr/0017-report-foundation.md
Normal file
48
docs/adr/0017-report-foundation.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# ADR 0017: CRM Report Foundation
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
CRM now has stable foundations for:
|
||||||
|
|
||||||
|
- access control and scope resolution
|
||||||
|
- customer ownership and contact sharing
|
||||||
|
- lead and enquiry lifecycle
|
||||||
|
- won/lost governance
|
||||||
|
- revenue attribution
|
||||||
|
- dashboard KPI
|
||||||
|
|
||||||
|
Future report modules need one shared architecture so they do not re-implement:
|
||||||
|
|
||||||
|
- report discovery
|
||||||
|
- filter contracts
|
||||||
|
- export helpers
|
||||||
|
- access resolution
|
||||||
|
- audit logging
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
We introduce a dedicated report foundation under `src/features/crm/reports/`.
|
||||||
|
|
||||||
|
The foundation includes:
|
||||||
|
|
||||||
|
- `crm_report_definitions` as the report registry
|
||||||
|
- `crm_report_category` master options for grouping and future permissions
|
||||||
|
- shared filter metadata endpoint
|
||||||
|
- `ResolvedReportContext` for organization, scope, and permission resolution
|
||||||
|
- `resolveCrmAccess()` as the common context adapter for reports
|
||||||
|
- dataset and builder layers for report queries
|
||||||
|
- shared CSV/XLS export helpers
|
||||||
|
- `crm_report` audit entries for `view`, `export_csv`, and `export_excel`
|
||||||
|
|
||||||
|
Reports must not query the database directly from UI routes. Route handlers call the report server layer instead.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- K.2-K.7 can add business reports without redefining report plumbing
|
||||||
|
- scope and permission enforcement stays aligned with CRM authorization work from Task L
|
||||||
|
- export behavior becomes reusable instead of dashboard-specific
|
||||||
|
- report navigation can be driven from the seeded registry and category metadata
|
||||||
39
docs/implementation/task-k1-report-foundation.md
Normal file
39
docs/implementation/task-k1-report-foundation.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Task K.1 Implementation Summary
|
||||||
|
|
||||||
|
## Completed
|
||||||
|
|
||||||
|
- added `crm_report_definitions` schema and migration
|
||||||
|
- seeded `crm_report_category` master options
|
||||||
|
- seeded system report definitions:
|
||||||
|
- `lead_pipeline`
|
||||||
|
- `enquiry_pipeline`
|
||||||
|
- `sales_performance`
|
||||||
|
- `lost_analysis`
|
||||||
|
- `revenue_by_customer`
|
||||||
|
- added permissions:
|
||||||
|
- `crm.report.read`
|
||||||
|
- `crm.report.export`
|
||||||
|
- created `src/features/crm/reports/` foundation with:
|
||||||
|
- API contracts
|
||||||
|
- shared query options
|
||||||
|
- `ResolvedReportContext`
|
||||||
|
- `resolveCrmAccess()`
|
||||||
|
- dataset layer
|
||||||
|
- builder layer
|
||||||
|
- shared filter metadata builder
|
||||||
|
- export helpers
|
||||||
|
- added report discovery APIs:
|
||||||
|
- `GET /api/crm/reports`
|
||||||
|
- `GET /api/crm/reports/catalog`
|
||||||
|
- `GET /api/crm/reports/filters`
|
||||||
|
- added audit logging for report foundation reads
|
||||||
|
- added CRM Reports landing page and sidebar navigation entry
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `npm exec tsc --noEmit`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- this task creates the shared report platform only; it does not implement business report execution pages yet
|
||||||
|
- export helpers are centralized and ready for future report-specific export routes
|
||||||
15
drizzle/0018_report_foundation.sql
Normal file
15
drizzle/0018_report_foundation.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS "crm_report_definitions" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"organization_id" text NOT NULL,
|
||||||
|
"code" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"category" text NOT NULL,
|
||||||
|
"is_system" boolean DEFAULT true NOT NULL,
|
||||||
|
"is_active" boolean DEFAULT true NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "crm_report_definitions_org_code_idx"
|
||||||
|
ON "crm_report_definitions" ("organization_id", "code");
|
||||||
@@ -127,6 +127,13 @@
|
|||||||
"when": 1782121929212,
|
"when": 1782121929212,
|
||||||
"tag": "0017_short_swordsman",
|
"tag": "0017_short_swordsman",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 18,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1782799200000,
|
||||||
|
"tag": "0018_report_foundation",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
392
plans/task-k.1.md
Normal file
392
plans/task-k.1.md
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
# Task K.1: CRM Report Foundation
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Create a reusable reporting foundation for all future CRM reports.
|
||||||
|
|
||||||
|
This task does not focus on business reports themselves.
|
||||||
|
|
||||||
|
Instead it builds:
|
||||||
|
|
||||||
|
* report architecture
|
||||||
|
* report filters
|
||||||
|
* report security
|
||||||
|
* report exports
|
||||||
|
* report datasets
|
||||||
|
* report metadata registry
|
||||||
|
|
||||||
|
for all future report modules.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Background
|
||||||
|
|
||||||
|
Completed foundations:
|
||||||
|
|
||||||
|
* Customer Ownership (C.1)
|
||||||
|
* Contact Sharing (C.1)
|
||||||
|
* Lead / Enquiry Lifecycle (D.3)
|
||||||
|
* Won / Lost Governance (D.4)
|
||||||
|
* Revenue Attribution (D.2.1)
|
||||||
|
* CRM Dashboard KPI (J)
|
||||||
|
* CRM Authorization (L)
|
||||||
|
|
||||||
|
The data model is now stable enough to support reporting.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope K1.1 Report Registry
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm_report_definitions
|
||||||
|
```
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
Store report metadata.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
id
|
||||||
|
|
||||||
|
code
|
||||||
|
name
|
||||||
|
|
||||||
|
description
|
||||||
|
|
||||||
|
category
|
||||||
|
|
||||||
|
isSystem
|
||||||
|
isActive
|
||||||
|
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
lead_pipeline
|
||||||
|
enquiry_pipeline
|
||||||
|
sales_performance
|
||||||
|
lost_analysis
|
||||||
|
revenue_by_customer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope K1.2 Report Categories
|
||||||
|
|
||||||
|
Seed:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Pipeline
|
||||||
|
Sales
|
||||||
|
Revenue
|
||||||
|
Customer
|
||||||
|
Quotation
|
||||||
|
Approval
|
||||||
|
Outcome
|
||||||
|
Executive
|
||||||
|
```
|
||||||
|
|
||||||
|
Used for:
|
||||||
|
|
||||||
|
* report navigation
|
||||||
|
* report grouping
|
||||||
|
* future permissions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope K1.3 Shared Report Filter Model
|
||||||
|
|
||||||
|
Create reusable filter object.
|
||||||
|
|
||||||
|
Support:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Date Range
|
||||||
|
|
||||||
|
Branch
|
||||||
|
|
||||||
|
Product Type
|
||||||
|
|
||||||
|
Sales
|
||||||
|
|
||||||
|
Customer
|
||||||
|
|
||||||
|
Customer Owner
|
||||||
|
|
||||||
|
Lead Source
|
||||||
|
|
||||||
|
Pipeline Stage
|
||||||
|
|
||||||
|
Won/Lost
|
||||||
|
|
||||||
|
Lost Reason
|
||||||
|
```
|
||||||
|
|
||||||
|
Reusable across all reports.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope K1.4 Report Query Context
|
||||||
|
|
||||||
|
Create:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
ResolvedReportContext
|
||||||
|
```
|
||||||
|
|
||||||
|
Combines:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Organization
|
||||||
|
|
||||||
|
Branch Scope
|
||||||
|
|
||||||
|
Product Scope
|
||||||
|
|
||||||
|
Ownership Scope
|
||||||
|
|
||||||
|
Permissions
|
||||||
|
```
|
||||||
|
|
||||||
|
Must reuse CRM authorization model.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope K1.5 Report Dataset Layer
|
||||||
|
|
||||||
|
Create:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
src/features/crm/reports/server/
|
||||||
|
```
|
||||||
|
|
||||||
|
Structure:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
datasets/
|
||||||
|
builders/
|
||||||
|
filters/
|
||||||
|
exports/
|
||||||
|
```
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
Single source of truth for report queries.
|
||||||
|
|
||||||
|
Reports must never query database directly from UI routes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope K1.6 Report Security
|
||||||
|
|
||||||
|
All reports must use:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
resolveCrmAccess()
|
||||||
|
```
|
||||||
|
|
||||||
|
and
|
||||||
|
|
||||||
|
```txt
|
||||||
|
ResolvedReportContext
|
||||||
|
```
|
||||||
|
|
||||||
|
Security rules inherited from:
|
||||||
|
|
||||||
|
* Task L
|
||||||
|
* Task L.1
|
||||||
|
* Task L.2
|
||||||
|
* Task L.3
|
||||||
|
* Task L.3.1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope K1.7 Report Export Foundation
|
||||||
|
|
||||||
|
Create common export service.
|
||||||
|
|
||||||
|
Supported:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
CSV
|
||||||
|
Excel
|
||||||
|
```
|
||||||
|
|
||||||
|
All reports share same export mechanism.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope K1.8 Report Audit Logging
|
||||||
|
|
||||||
|
Entity:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm_report
|
||||||
|
```
|
||||||
|
|
||||||
|
Actions:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
view
|
||||||
|
export_csv
|
||||||
|
export_excel
|
||||||
|
```
|
||||||
|
|
||||||
|
Audit payload:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
reportCode
|
||||||
|
filters
|
||||||
|
userId
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope K1.9 Report Navigation
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
CRM
|
||||||
|
└─ Reports
|
||||||
|
```
|
||||||
|
|
||||||
|
Landing page only.
|
||||||
|
|
||||||
|
Display:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Pipeline Reports
|
||||||
|
Sales Reports
|
||||||
|
Revenue Reports
|
||||||
|
Customer Reports
|
||||||
|
Outcome Reports
|
||||||
|
Approval Reports
|
||||||
|
```
|
||||||
|
|
||||||
|
No business reports yet.
|
||||||
|
|
||||||
|
Only report catalog.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope K1.10 Foundation APIs
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
GET /api/crm/reports
|
||||||
|
|
||||||
|
GET /api/crm/reports/catalog
|
||||||
|
|
||||||
|
GET /api/crm/reports/filters
|
||||||
|
```
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
Report discovery.
|
||||||
|
|
||||||
|
No heavy report execution yet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope K1.11 Permissions
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm.report.read
|
||||||
|
crm.report.export
|
||||||
|
```
|
||||||
|
|
||||||
|
Future reports inherit these permissions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope K1.12 Documentation
|
||||||
|
|
||||||
|
Create:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
docs/adr/0017-report-foundation.md
|
||||||
|
|
||||||
|
docs/implementation/task-k1-report-foundation.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Document:
|
||||||
|
|
||||||
|
* report architecture
|
||||||
|
* security model
|
||||||
|
* dataset model
|
||||||
|
* export model
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Explicit Non-Scope
|
||||||
|
|
||||||
|
Do NOT build:
|
||||||
|
|
||||||
|
* Lead Report
|
||||||
|
* Sales Report
|
||||||
|
* Revenue Report
|
||||||
|
* Customer Report
|
||||||
|
* Executive Report
|
||||||
|
|
||||||
|
Those belong to:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
K.2
|
||||||
|
K.3
|
||||||
|
K.4
|
||||||
|
K.5
|
||||||
|
K.6
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Deliverables
|
||||||
|
|
||||||
|
1. Report Registry
|
||||||
|
2. Report Catalog
|
||||||
|
3. Shared Filters
|
||||||
|
4. Dataset Layer
|
||||||
|
5. Export Foundation
|
||||||
|
6. Security Foundation
|
||||||
|
7. Report Navigation
|
||||||
|
8. Audit Logging
|
||||||
|
9. ADR 0017
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Definition of Done
|
||||||
|
|
||||||
|
Task K.1 is complete when:
|
||||||
|
|
||||||
|
* Reports menu exists
|
||||||
|
* Report catalog exists
|
||||||
|
* Shared filter model exists
|
||||||
|
* Dataset architecture exists
|
||||||
|
* Export foundation exists
|
||||||
|
* Security foundation exists
|
||||||
|
* Report permissions exist
|
||||||
|
* Report ADR exists
|
||||||
|
|
||||||
|
Result:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
CRM Report Center Foundation = READY
|
||||||
|
|
||||||
|
Ready for:
|
||||||
|
|
||||||
|
K.2 Pipeline Reports
|
||||||
|
K.3 Outcome Analytics
|
||||||
|
K.4 Revenue Analytics
|
||||||
|
K.5 Sales Performance
|
||||||
|
K.6 Customer Analytics
|
||||||
|
K.7 Approval Analytics
|
||||||
|
```
|
||||||
29
src/app/api/crm/reports/catalog/route.ts
Normal file
29
src/app/api/crm/reports/catalog/route.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { resolveCrmAccess } from '@/features/crm/reports/server/context';
|
||||||
|
import { getCrmReportCatalogResponse } from '@/features/crm/reports/server/service';
|
||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmReportRead
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
await getCrmReportCatalogResponse(
|
||||||
|
resolveCrmAccess({
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
membership
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ message: 'Unable to load CRM report catalog' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/app/api/crm/reports/filters/route.ts
Normal file
29
src/app/api/crm/reports/filters/route.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { resolveCrmAccess } from '@/features/crm/reports/server/context';
|
||||||
|
import { getCrmReportFiltersResponse } from '@/features/crm/reports/server/service';
|
||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmReportRead
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
await getCrmReportFiltersResponse(
|
||||||
|
resolveCrmAccess({
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
membership
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ message: 'Unable to load CRM report filters' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/app/api/crm/reports/route.ts
Normal file
29
src/app/api/crm/reports/route.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getCrmReportsResponse } from '@/features/crm/reports/server/service';
|
||||||
|
import { resolveCrmAccess } from '@/features/crm/reports/server/context';
|
||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmReportRead
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
await getCrmReportsResponse(
|
||||||
|
resolveCrmAccess({
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
membership
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ message: 'Unable to load CRM reports' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/app/dashboard/crm/reports/page.tsx
Normal file
48
src/app/dashboard/crm/reports/page.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
||||||
|
import { auth } from '@/auth';
|
||||||
|
import PageContainer from '@/components/layout/page-container';
|
||||||
|
import {
|
||||||
|
crmReportCatalogQueryOptions,
|
||||||
|
crmReportFiltersQueryOptions
|
||||||
|
} from '@/features/crm/reports/api/queries';
|
||||||
|
import { ReportCatalog } from '@/features/crm/reports/components/report-catalog';
|
||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
import { getQueryClient } from '@/lib/query-client';
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: 'CRM Reports'
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function CrmReportsRoute() {
|
||||||
|
const session = await auth();
|
||||||
|
const canRead =
|
||||||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
|
(!!session?.user?.activeOrganizationId &&
|
||||||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
|
session.user.activePermissions.includes(PERMISSIONS.crmReportRead)));
|
||||||
|
const queryClient = getQueryClient();
|
||||||
|
|
||||||
|
if (canRead) {
|
||||||
|
void queryClient.prefetchQuery(crmReportCatalogQueryOptions());
|
||||||
|
void queryClient.prefetchQuery(crmReportFiltersQueryOptions());
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer
|
||||||
|
pageTitle='CRM Reports'
|
||||||
|
pageDescription='Report center foundation, shared filters, and catalog for upcoming CRM analytics modules.'
|
||||||
|
access={canRead}
|
||||||
|
accessFallback={
|
||||||
|
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
||||||
|
You do not have access to the CRM report center.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{canRead ? (
|
||||||
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
|
<ReportCatalog />
|
||||||
|
</HydrationBoundary>
|
||||||
|
) : null}
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -131,6 +131,14 @@ export const navGroups: NavGroup[] = [
|
|||||||
items: [],
|
items: [],
|
||||||
access: { requireOrg: true, permission: "crm.approval.read" },
|
access: { requireOrg: true, permission: "crm.approval.read" },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Reports",
|
||||||
|
url: "/dashboard/crm/reports",
|
||||||
|
icon: "dashboard",
|
||||||
|
isActive: false,
|
||||||
|
items: [],
|
||||||
|
access: { requireOrg: true, permission: "crm.report.read" },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "ตั้งค่า CRM",
|
title: "ตั้งค่า CRM",
|
||||||
url: "/dashboard/crm/settings/master-options",
|
url: "/dashboard/crm/settings/master-options",
|
||||||
|
|||||||
@@ -394,6 +394,28 @@ export const crmEnquiryAttachments = pgTable('crm_enquiry_attachments', {
|
|||||||
deletedAt: timestamp('deleted_at', { withTimezone: true })
|
deletedAt: timestamp('deleted_at', { withTimezone: true })
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const crmReportDefinitions = pgTable(
|
||||||
|
'crm_report_definitions',
|
||||||
|
{
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
organizationId: text('organization_id').notNull(),
|
||||||
|
code: text('code').notNull(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
description: text('description'),
|
||||||
|
category: text('category').notNull(),
|
||||||
|
isSystem: boolean('is_system').default(true).notNull(),
|
||||||
|
isActive: boolean('is_active').default(true).notNull(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
organizationCodeIdx: uniqueIndex('crm_report_definitions_org_code_idx').on(
|
||||||
|
table.organizationId,
|
||||||
|
table.code
|
||||||
|
)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
export const crmQuotations = pgTable(
|
export const crmQuotations = pgTable(
|
||||||
'crm_quotations',
|
'crm_quotations',
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ type TemplateMappingSeed = {
|
|||||||
columns?: TemplateColumnSeed[];
|
columns?: TemplateColumnSeed[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ReportDefinitionSeed = {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
};
|
||||||
|
|
||||||
function loadEnvFile(filePath: string) {
|
function loadEnvFile(filePath: string) {
|
||||||
if (!fs.existsSync(filePath)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
return;
|
return;
|
||||||
@@ -334,9 +341,52 @@ const FOUNDATION_OPTIONS = {
|
|||||||
{ code: 'facebook', label: 'Facebook', value: 'facebook', sortOrder: 4 },
|
{ code: 'facebook', label: 'Facebook', value: 'facebook', sortOrder: 4 },
|
||||||
{ code: 'line', label: 'LINE', value: 'line', sortOrder: 5 },
|
{ code: 'line', label: 'LINE', value: 'line', sortOrder: 5 },
|
||||||
{ code: 'other', label: 'Other', value: 'other', sortOrder: 6 }
|
{ code: 'other', label: 'Other', value: 'other', sortOrder: 6 }
|
||||||
|
],
|
||||||
|
crm_report_category: [
|
||||||
|
{ code: 'pipeline', label: 'Pipeline', value: 'pipeline', sortOrder: 1 },
|
||||||
|
{ code: 'sales', label: 'Sales', value: 'sales', sortOrder: 2 },
|
||||||
|
{ code: 'revenue', label: 'Revenue', value: 'revenue', sortOrder: 3 },
|
||||||
|
{ code: 'customer', label: 'Customer', value: 'customer', sortOrder: 4 },
|
||||||
|
{ code: 'quotation', label: 'Quotation', value: 'quotation', sortOrder: 5 },
|
||||||
|
{ code: 'approval', label: 'Approval', value: 'approval', sortOrder: 6 },
|
||||||
|
{ code: 'outcome', label: 'Outcome', value: 'outcome', sortOrder: 7 },
|
||||||
|
{ code: 'executive', label: 'Executive', value: 'executive', sortOrder: 8 }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const REPORT_DEFINITIONS: ReportDefinitionSeed[] = [
|
||||||
|
{
|
||||||
|
code: 'lead_pipeline',
|
||||||
|
name: 'Lead Pipeline',
|
||||||
|
description: 'Foundation catalog entry for future lead pipeline reporting.',
|
||||||
|
category: 'pipeline'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'enquiry_pipeline',
|
||||||
|
name: 'Enquiry Pipeline',
|
||||||
|
description: 'Foundation catalog entry for opportunity stage reporting.',
|
||||||
|
category: 'pipeline'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'sales_performance',
|
||||||
|
name: 'Sales Performance',
|
||||||
|
description: 'Foundation catalog entry for salesperson KPI and ranking reports.',
|
||||||
|
category: 'sales'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'lost_analysis',
|
||||||
|
name: 'Lost Analysis',
|
||||||
|
description: 'Foundation catalog entry for lost reason and competitor analysis.',
|
||||||
|
category: 'outcome'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'revenue_by_customer',
|
||||||
|
name: 'Revenue By Customer',
|
||||||
|
description: 'Foundation catalog entry for customer revenue attribution reports.',
|
||||||
|
category: 'revenue'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
const DOCUMENT_SEQUENCES = [
|
const DOCUMENT_SEQUENCES = [
|
||||||
{ documentType: 'customer', prefix: 'CUS' },
|
{ documentType: 'customer', prefix: 'CUS' },
|
||||||
{ documentType: 'contact', prefix: 'CON' },
|
{ documentType: 'contact', prefix: 'CON' },
|
||||||
@@ -972,6 +1022,43 @@ async function upsertDocumentTemplatesForOrganization(
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function upsertReportDefinitionsForOrganization(
|
||||||
|
sql: SqlClient,
|
||||||
|
organization: { id: string }
|
||||||
|
) {
|
||||||
|
for (const definition of REPORT_DEFINITIONS) {
|
||||||
|
await sql`
|
||||||
|
insert into crm_report_definitions (
|
||||||
|
id,
|
||||||
|
organization_id,
|
||||||
|
code,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
category,
|
||||||
|
is_system,
|
||||||
|
is_active
|
||||||
|
) values (
|
||||||
|
${crypto.randomUUID()},
|
||||||
|
${organization.id},
|
||||||
|
${definition.code},
|
||||||
|
${definition.name},
|
||||||
|
${definition.description},
|
||||||
|
${definition.category},
|
||||||
|
${true},
|
||||||
|
${true}
|
||||||
|
)
|
||||||
|
on conflict (organization_id, code) do update
|
||||||
|
set
|
||||||
|
name = excluded.name,
|
||||||
|
description = excluded.description,
|
||||||
|
category = excluded.category,
|
||||||
|
is_system = excluded.is_system,
|
||||||
|
is_active = excluded.is_active,
|
||||||
|
updated_at = now()
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
loadLocalEnv();
|
loadLocalEnv();
|
||||||
|
|
||||||
@@ -996,6 +1083,7 @@ async function main() {
|
|||||||
await upsertDocumentSequencesForOrganization(sql, organization.id, branchRows);
|
await upsertDocumentSequencesForOrganization(sql, organization.id, branchRows);
|
||||||
await upsertApprovalWorkflowsForOrganization(sql, organization.id);
|
await upsertApprovalWorkflowsForOrganization(sql, organization.id);
|
||||||
await upsertDocumentTemplatesForOrganization(sql, organization);
|
await upsertDocumentTemplatesForOrganization(sql, organization);
|
||||||
|
await upsertReportDefinitionsForOrganization(sql, organization);
|
||||||
console.log(`[seed-foundation] Seeded foundation data for ${organization.name}`);
|
console.log(`[seed-foundation] Seeded foundation data for ${organization.name}`);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
27
src/features/crm/reports/api/queries.ts
Normal file
27
src/features/crm/reports/api/queries.ts
Normal 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()
|
||||||
|
});
|
||||||
18
src/features/crm/reports/api/service.ts
Normal file
18
src/features/crm/reports/api/service.ts
Normal 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');
|
||||||
|
}
|
||||||
75
src/features/crm/reports/api/types.ts
Normal file
75
src/features/crm/reports/api/types.ts
Normal 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;
|
||||||
|
}
|
||||||
73
src/features/crm/reports/components/report-catalog.tsx
Normal file
73
src/features/crm/reports/components/report-catalog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
src/features/crm/reports/server/builders/catalog-builder.ts
Normal file
21
src/features/crm/reports/server/builders/catalog-builder.ts
Normal 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()];
|
||||||
|
}
|
||||||
33
src/features/crm/reports/server/context.ts
Normal file
33
src/features/crm/reports/server/context.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
42
src/features/crm/reports/server/datasets/catalog.ts
Normal file
42
src/features/crm/reports/server/datasets/catalog.ts
Normal 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
|
||||||
|
}));
|
||||||
|
}
|
||||||
44
src/features/crm/reports/server/exports/service.ts
Normal file
44
src/features/crm/reports/server/exports/service.ts
Normal 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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
71
src/features/crm/reports/server/filters/shared.ts
Normal file
71
src/features/crm/reports/server/filters/shared.ts
Normal 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 }))
|
||||||
|
};
|
||||||
|
}
|
||||||
78
src/features/crm/reports/server/service.ts
Normal file
78
src/features/crm/reports/server/service.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
25
src/features/crm/reports/server/types.ts
Normal file
25
src/features/crm/reports/server/types.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -83,6 +83,8 @@ export const PERMISSIONS = {
|
|||||||
crmApprovalWorkflowDelete: 'crm.approval.workflow.delete',
|
crmApprovalWorkflowDelete: 'crm.approval.workflow.delete',
|
||||||
crmDashboardRead: 'crm.dashboard.read',
|
crmDashboardRead: 'crm.dashboard.read',
|
||||||
crmDashboardExport: 'crm.dashboard.export',
|
crmDashboardExport: 'crm.dashboard.export',
|
||||||
|
crmReportRead: 'crm.report.read',
|
||||||
|
crmReportExport: 'crm.report.export',
|
||||||
crmDocumentTemplateRead: 'crm.document_template.read',
|
crmDocumentTemplateRead: 'crm.document_template.read',
|
||||||
crmDocumentTemplateCreate: 'crm.document_template.create',
|
crmDocumentTemplateCreate: 'crm.document_template.create',
|
||||||
crmDocumentTemplateUpdate: 'crm.document_template.update',
|
crmDocumentTemplateUpdate: 'crm.document_template.update',
|
||||||
@@ -152,7 +154,8 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
|||||||
PERMISSIONS.crmContactRead,
|
PERMISSIONS.crmContactRead,
|
||||||
PERMISSIONS.crmEnquiryRead,
|
PERMISSIONS.crmEnquiryRead,
|
||||||
PERMISSIONS.crmEnquiryFollowupRead,
|
PERMISSIONS.crmEnquiryFollowupRead,
|
||||||
PERMISSIONS.crmDashboardRead
|
PERMISSIONS.crmDashboardRead,
|
||||||
|
PERMISSIONS.crmReportRead
|
||||||
],
|
],
|
||||||
ownershipScope: 'monitor',
|
ownershipScope: 'monitor',
|
||||||
branchScopeMode: 'assigned',
|
branchScopeMode: 'assigned',
|
||||||
@@ -188,6 +191,7 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
|||||||
PERMISSIONS.crmApprovalRead,
|
PERMISSIONS.crmApprovalRead,
|
||||||
PERMISSIONS.crmApprovalSubmit,
|
PERMISSIONS.crmApprovalSubmit,
|
||||||
PERMISSIONS.crmDashboardRead,
|
PERMISSIONS.crmDashboardRead,
|
||||||
|
PERMISSIONS.crmReportRead,
|
||||||
PERMISSIONS.crmDocumentArtifactRead,
|
PERMISSIONS.crmDocumentArtifactRead,
|
||||||
PERMISSIONS.crmDocumentArtifactDownload,
|
PERMISSIONS.crmDocumentArtifactDownload,
|
||||||
PERMISSIONS.crmQuotationDocumentPreview,
|
PERMISSIONS.crmQuotationDocumentPreview,
|
||||||
@@ -226,6 +230,7 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
|||||||
PERMISSIONS.crmQuotationPricingRead,
|
PERMISSIONS.crmQuotationPricingRead,
|
||||||
PERMISSIONS.crmApprovalRead,
|
PERMISSIONS.crmApprovalRead,
|
||||||
PERMISSIONS.crmDashboardRead,
|
PERMISSIONS.crmDashboardRead,
|
||||||
|
PERMISSIONS.crmReportRead,
|
||||||
PERMISSIONS.crmDocumentArtifactRead,
|
PERMISSIONS.crmDocumentArtifactRead,
|
||||||
PERMISSIONS.crmDocumentArtifactDownload,
|
PERMISSIONS.crmDocumentArtifactDownload,
|
||||||
PERMISSIONS.crmQuotationDocumentPreview,
|
PERMISSIONS.crmQuotationDocumentPreview,
|
||||||
@@ -286,6 +291,8 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
|||||||
PERMISSIONS.crmApprovalReturn,
|
PERMISSIONS.crmApprovalReturn,
|
||||||
PERMISSIONS.crmDashboardRead,
|
PERMISSIONS.crmDashboardRead,
|
||||||
PERMISSIONS.crmDashboardExport,
|
PERMISSIONS.crmDashboardExport,
|
||||||
|
PERMISSIONS.crmReportRead,
|
||||||
|
PERMISSIONS.crmReportExport,
|
||||||
PERMISSIONS.crmRoleAssignmentRead,
|
PERMISSIONS.crmRoleAssignmentRead,
|
||||||
PERMISSIONS.crmUserRoleAssignmentRead,
|
PERMISSIONS.crmUserRoleAssignmentRead,
|
||||||
PERMISSIONS.crmDocumentArtifactRead,
|
PERMISSIONS.crmDocumentArtifactRead,
|
||||||
@@ -324,6 +331,8 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
|||||||
PERMISSIONS.crmApprovalReturn,
|
PERMISSIONS.crmApprovalReturn,
|
||||||
PERMISSIONS.crmDashboardRead,
|
PERMISSIONS.crmDashboardRead,
|
||||||
PERMISSIONS.crmDashboardExport,
|
PERMISSIONS.crmDashboardExport,
|
||||||
|
PERMISSIONS.crmReportRead,
|
||||||
|
PERMISSIONS.crmReportExport,
|
||||||
PERMISSIONS.crmRoleAssignmentRead,
|
PERMISSIONS.crmRoleAssignmentRead,
|
||||||
PERMISSIONS.crmUserRoleAssignmentRead,
|
PERMISSIONS.crmUserRoleAssignmentRead,
|
||||||
PERMISSIONS.crmDocumentArtifactRead,
|
PERMISSIONS.crmDocumentArtifactRead,
|
||||||
@@ -359,6 +368,8 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
|||||||
PERMISSIONS.crmApprovalReturn,
|
PERMISSIONS.crmApprovalReturn,
|
||||||
PERMISSIONS.crmDashboardRead,
|
PERMISSIONS.crmDashboardRead,
|
||||||
PERMISSIONS.crmDashboardExport,
|
PERMISSIONS.crmDashboardExport,
|
||||||
|
PERMISSIONS.crmReportRead,
|
||||||
|
PERMISSIONS.crmReportExport,
|
||||||
PERMISSIONS.crmRoleAssignmentRead,
|
PERMISSIONS.crmRoleAssignmentRead,
|
||||||
PERMISSIONS.crmUserRoleAssignmentRead,
|
PERMISSIONS.crmUserRoleAssignmentRead,
|
||||||
PERMISSIONS.crmDocumentArtifactRead,
|
PERMISSIONS.crmDocumentArtifactRead,
|
||||||
@@ -378,6 +389,8 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
|||||||
description: 'CRM configuration authority for templates, workflows, sequences, and permissions.',
|
description: 'CRM configuration authority for templates, workflows, sequences, and permissions.',
|
||||||
permissions: [
|
permissions: [
|
||||||
PERMISSIONS.crmDashboardRead,
|
PERMISSIONS.crmDashboardRead,
|
||||||
|
PERMISSIONS.crmReportRead,
|
||||||
|
PERMISSIONS.crmReportExport,
|
||||||
PERMISSIONS.crmEnquiryMarkWon,
|
PERMISSIONS.crmEnquiryMarkWon,
|
||||||
PERMISSIONS.crmEnquiryMarkLost,
|
PERMISSIONS.crmEnquiryMarkLost,
|
||||||
PERMISSIONS.crmEnquiryReopen,
|
PERMISSIONS.crmEnquiryReopen,
|
||||||
@@ -527,6 +540,14 @@ export const CRM_PERMISSION_GROUPS: PermissionGroup[] = [
|
|||||||
{ key: PERMISSIONS.crmDashboardExport, label: 'Export dashboard' }
|
{ key: PERMISSIONS.crmDashboardExport, label: 'Export dashboard' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'report',
|
||||||
|
label: 'Reports',
|
||||||
|
permissions: [
|
||||||
|
{ key: PERMISSIONS.crmReportRead, label: 'Read reports' },
|
||||||
|
{ key: PERMISSIONS.crmReportExport, label: 'Export reports' }
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'settings',
|
key: 'settings',
|
||||||
label: 'CRM Settings',
|
label: 'CRM Settings',
|
||||||
|
|||||||
Reference in New Issue
Block a user