This commit is contained in:
phaichayon
2026-06-17 16:04:19 +07:00
parent 5be6c54272
commit 04a886ea26
57 changed files with 6477 additions and 105 deletions

View File

@@ -0,0 +1,74 @@
# Task J: CRM Dashboard KPI and Sales Analytics
## Files added
- `src/features/crm/dashboard/api/types.ts`
- `src/features/crm/dashboard/api/service.ts`
- `src/features/crm/dashboard/api/queries.ts`
- `src/features/crm/dashboard/server/service.ts`
- `src/features/crm/dashboard/components/crm-dashboard.tsx`
- `src/features/crm/dashboard/components/dashboard-filters.tsx`
- `src/features/crm/dashboard/components/dashboard-summary-cards.tsx`
- `src/features/crm/dashboard/components/dashboard-funnel.tsx`
- `src/features/crm/dashboard/components/dashboard-revenue-cards.tsx`
- `src/features/crm/dashboard/components/dashboard-sales-ranking.tsx`
- `src/features/crm/dashboard/components/dashboard-followups.tsx`
- `src/features/crm/dashboard/components/dashboard-approval-metrics.tsx`
- `src/features/crm/dashboard/components/dashboard-hot-projects.tsx`
- `src/app/api/crm/dashboard/route.ts`
- `src/app/api/crm/dashboard/export/route.ts`
## Files modified
- `src/app/dashboard/crm/page.tsx`
- `src/config/nav-config.ts`
- `src/lib/auth/rbac.ts`
- `src/lib/searchparams.ts`
- `docs/implementation/technical-debt.md`
## KPI widgets added
- Replaced the CRM placeholder route with a production dashboard at `/dashboard/crm`.
- Added summary cards for lead, opportunity, quotation, and revenue metrics.
- Added a funnel widget for lead -> opportunity -> quotation -> pending approval -> approved -> closed won.
- Added a hot-project widget based on real enquiry data.
## Revenue analytics added
- Added top end-customer, billing-customer, contractor, and consultant revenue tables.
- Reused the Task D.2.1 reporting helpers so revenue attribution follows the frozen governance rules.
- Added export support for revenue analytics through the dashboard export route.
## Sales ranking added
- Added a production sales ranking table using enquiry assignment and quotation salesman ownership rules.
- Added CSV export for sales ranking.
## Approval and follow-up analytics added
- Added approval KPI cards and a `My Pending Approvals` table.
- Added follow-up KPI cards and an `Upcoming Follow Ups` table from enquiry and quotation follow-up sources.
## Export and permissions added
- Added `crm.dashboard.read`.
- Added `crm.dashboard.export`.
- Added audit logging for `crm_dashboard_export` with `export_csv` and `export_excel`.
- Added sidebar navigation for the CRM dashboard.
## Remaining risks
- `closed_won` is not yet a first-class enquiry lifecycle field, so the dashboard currently uses quotation status `accepted` as the practical won-stage proxy.
- follow-up completion is inferred from `outcome` presence because the follow-up model still has no explicit completion flag.
- the global `Project Party Role` filter currently influences revenue analytics and exports, but the rest of the dashboard still depends mainly on enquiry and quotation core filters.
- sales users currently read organization-wide dashboard data; own-scope filtering is still future work.
## Task K readiness
- the CRM dashboard now uses real production data
- KPI widgets are live
- revenue analytics is reusable
- sales ranking is exportable
- approval and follow-up analytics exist
- export and audit hooks are in place
- the app is ready for Task K report-center expansion

View File

@@ -0,0 +1,85 @@
# Task J.1: CRM Admin Configuration Center
## Files Added
- `src/app/api/crm/settings/**`
- `src/app/dashboard/crm/settings/approval-workflows/page.tsx`
- `src/features/foundation/approval/components/approval-workflow-settings.tsx`
- `src/features/foundation/document-sequence/client-service.ts`
- `src/features/foundation/document-sequence/components/document-sequence-settings.tsx`
- `src/features/foundation/document-sequence/mutations.ts`
- `src/features/foundation/document-sequence/queries.ts`
- `src/features/foundation/document-template/mutations.ts`
- `docs/implementation/task-j1-crm-admin-configuration-center.md`
## Files Modified
- `src/config/nav-config.ts`
- `src/lib/auth/rbac.ts`
- `src/app/dashboard/crm/settings/document-sequences/page.tsx`
- `src/features/foundation/approval/{mutations,queries,server/service,service,types}.ts`
- `src/features/foundation/document-sequence/{service,types}.ts`
- `src/features/foundation/document-template/{components/template-settings,mutations,server/service,service,types}.ts`
## What Was Added
- CRM Settings menu now includes `Approval Workflows` and uses CRM-specific permission checks for `Document Sequences`.
- `/dashboard/crm/settings/templates` now supports:
- create/edit template metadata
- create/edit template versions with JSON schema textarea
- create/edit/delete template mappings
- table-column mapping input for `table` data type
- `/dashboard/crm/settings/approval-workflows` now supports:
- create/edit/delete workflows
- manage sequential workflow steps
- role-based step definitions using CRM business roles
- `/dashboard/crm/settings/document-sequences` now supports:
- create/edit/delete sequences
- reset sequence counters
- preview next code from stored sequence state
## API Routes Added
- `/api/crm/settings/document-templates/**`
- `/api/crm/settings/approval-workflows/**`
- `/api/crm/settings/document-sequences/**`
## Permissions Added
- `crm.approval.workflow.read`
- `crm.approval.workflow.create`
- `crm.approval.workflow.update`
- `crm.approval.workflow.delete`
- `crm.document_sequence.read`
- `crm.document_sequence.create`
- `crm.document_sequence.update`
- `crm.document_sequence.reset`
## Audit Coverage
- `crm_document_template_version`
- `crm_document_template_mapping`
- `crm_approval_workflow`
- `crm_approval_step`
- `crm_document_sequence`
## Verification
- `npx tsc --noEmit` passed on June 17, 2026.
- `npm run lint` still fails because of pre-existing repo lint errors outside Task J.1 scope, including:
- `src/components/ui/input-group.tsx`
- `src/features/chat/components/message-composer.tsx`
- `src/components/forms/demo-form.tsx`
## Remaining Risks
- Template settings UI is functional but not yet a polished table-first admin experience.
- Document template version activation currently supports state updates, but the page focuses on version edit/create over dedicated activate/deactivate action buttons.
- Sequence reset UI currently resets to `0`; custom reset value can be added later if the product needs it.
- Approval step management currently replaces the full ordered step list in one save flow rather than exposing per-row move controls.
## Task K Readiness
- CRM settings foundations are now exposed through organization-scoped admin routes.
- Query invalidation is wired for template, workflow, and sequence mutations.
- The configuration center is ready for KPI/report consumers to reference the same settings data in later tasks.

View File

@@ -297,3 +297,51 @@ Future:
- decide whether approved quotation snapshots or another immutable reporting snapshot should become the historical source of truth - decide whether approved quotation snapshots or another immutable reporting snapshot should become the historical source of truth
- define how reporting should behave if project parties change after quotation approval or after a won/lost outcome - define how reporting should behave if project parties change after quotation approval or after a won/lost outcome
- add backfill or reconciliation tooling if historical party drift becomes a business issue - add backfill or reconciliation tooling if historical party drift becomes a business issue
## After Task J
### Closed-won lifecycle fidelity
Task J ships a live CRM dashboard, but the current schema still lacks a first-class enquiry `closed_won` lifecycle field and dedicated close timestamps.
Current:
- dashboard won-stage metrics currently use quotation status `accepted` as the practical proxy for `closed_won`
Future:
- add explicit enquiry lifecycle support for `closed_won`
- add `closedWonAt` to support precise won-date analytics
- remove dashboard proxy logic once the lifecycle model is formalized
### Follow-up completion semantics
Task J adds follow-up analytics, but the follow-up model still has no explicit completion flag.
Current:
- dashboard `Completed` follow-up metrics infer completion from `outcome` presence
Future:
- add an explicit completion state or activity outcome model for follow-ups
- separate scheduled, completed, cancelled, and skipped follow-up reporting cleanly
### Dashboard own-scope policy
Task J introduces `crm.dashboard.read`, but sales users still view organization-wide KPI data for now.
Future:
- add own-scope dashboard filtering for sales roles
- define whether mixed-role users see own, team, or organization scope by default
- keep exports aligned with the same scope policy once enforced
### Global project-party filter coverage
Task J adds a global `Project Party Role` filter in the dashboard UI, but the strongest enforcement today is in revenue analytics and export sections.
Future:
- decide which non-revenue widgets should fully honor project-party role filtering
- add a unified filtered reporting projection if cross-widget party-role filtering becomes a hard requirement

419
plans/task-j.1.md Normal file
View File

@@ -0,0 +1,419 @@
# Task J.1: CRM Admin Configuration Center
## Goal
สร้างหน้า Admin Configuration สำหรับจัดการ configuration สำคัญของ CRM ที่ปัจจุบันมี foundation แล้ว แต่ยังจัดการผ่าน UI ไม่สมบูรณ์
ครอบคลุม:
1. Document Templates
2. Approval Workflows
3. Document Sequences
ยังไม่ทำ:
* Template drag/drop designer เต็มรูปแบบ
* PDF visual designer
* Advanced conditional approval
* Report builder
---
# Must Read
```txt
Layout.md
AGENTS.md
.agents/skills/kiranism-shadcn-dashboard
src/features/foundation/document-template/**
src/features/foundation/approval/**
src/features/foundation/document-sequence/**
src/features/foundation/master-options/**
src/features/foundation/storage/**
src/db/schema.ts
docs/adr/0009-approved-document-storage-lifecycle.md
docs/adr/0010-revenue-attribution-governance.md
docs/implementation/technical-debt.md
```
---
# Scope 1: Admin Settings Navigation
Under:
```txt
/dashboard/crm/settings
```
Add/complete pages:
```txt
/dashboard/crm/settings/templates
/dashboard/crm/settings/approval-workflows
/dashboard/crm/settings/document-sequences
```
Use existing:
```txt
PageContainer
DataTable
Sheet/Dialog
Bento/Settings cards
```
No new design system.
---
# Scope 2: Document Template Management
Current state:
* template schema exists
* template versions exist
* template mappings exist
* default pdfme template seed exists
* template settings page is mostly listing/read-focused
Need UI:
## Template List
Show:
```txt
Template Name
Document Type
Product Type
File Type
Default
Active
Latest Version
Mapping Count
Actions
```
Actions:
```txt
Create Template
Edit Template Metadata
Activate/Deactivate
Set Default
View Versions
```
## Template Version Management
For each template:
```txt
Version
Active
File Path
Schema JSON
Preview Image URL
Created By
Created At
```
Actions:
```txt
Create Version
View Version
Activate Version
Deactivate Version
```
## Template Mapping Management
Add basic CRUD UI for mappings:
```txt
placeholderKey
sourcePath
dataType
defaultValue
formatMask
sortOrder
```
For table mapping:
```txt
columnName
sourceField
columnLetter
sortOrder
formatMask
```
Rules:
* role: admin only
* audit template mutations
* no drag/drop designer yet
* JSON textarea is acceptable for schemaJson in this task
* validate schemaJson is valid JSON
Permissions:
```txt
crm.document_template.read
crm.document_template.create
crm.document_template.update
crm.document_template.delete
```
---
# Scope 3: Approval Workflow Management
Current state:
* approval schema exists
* seeded quotation standard workflow exists
* approval engine works
* no workflow management UI
Need UI:
## Workflow List
Show:
```txt
Workflow Code
Workflow Name
Entity Type
Active
Step Count
Actions
```
Actions:
```txt
Create Workflow
Edit Workflow
Activate/Deactivate
Manage Steps
```
## Workflow Step Management
For each workflow:
```txt
Step Number
Role Code
Role Name
Required
```
Actions:
```txt
Add Step
Edit Step
Remove Step
Reorder Steps
```
Rules:
* sequential approval only
* no parallel approval
* no conditional approval
* no delegation
* no escalation
* roleCode must match CRM businessRole or permission strategy
* do not break existing quotation approval flow
Permissions:
```txt
crm.approval.workflow.read
crm.approval.workflow.create
crm.approval.workflow.update
crm.approval.workflow.delete
```
Audit:
```txt
crm_approval_workflow
crm_approval_step
```
---
# Scope 4: Document Sequence Management
Current state:
* document sequence service exists
* seed exists
* generate/preview works
* admin UI incomplete
Need UI:
## Sequence List
Show:
```txt
Document Type
Prefix
Period
Branch
Current Number
Padding Length
Next Preview
Updated At
Actions
```
Actions:
```txt
Create Sequence
Edit Sequence
Preview Next Code
Reset Current Number
Deactivate/Delete if supported
```
Rules:
* reset requires confirmation
* changing prefix should not rewrite old document codes
* preview must not increment
* generation remains server-only
* organization scoped
* branch optional or from crm_branch options
Permissions:
```txt
crm.document_sequence.read
crm.document_sequence.create
crm.document_sequence.update
crm.document_sequence.reset
```
Audit:
```txt
crm_document_sequence
```
---
# Scope 5: Route Handlers
Add or complete APIs:
```txt
/api/crm/settings/document-templates/**
/api/crm/settings/approval-workflows/**
/api/crm/settings/document-sequences/**
```
หรือถ้ามี route เดิมแล้ว ให้ reuse และเติม missing mutation
All APIs must:
```txt
requireOrganizationAccess
requirePermission
filter by organizationId
audit mutations
return user-safe errors
```
---
# Scope 6: Mutation Freshness Rule
All CRUD pages must follow project hotfix rule:
```txt
mutation success
invalidate list query
invalidate detail query if relevant
refresh table/detail immediately
```
No stale config table after save.
---
# Scope 7: Safety Constraints
Do NOT implement:
```txt
visual template designer
drag/drop pdf designer
advanced approval engine
parallel approval
conditional workflow
notification
report builder
dashboard changes
```
---
# Output
1. Files Added
2. Files Modified
3. Template Config UI Added
4. Approval Workflow UI Added
5. Document Sequence UI Added
6. API Routes Added
7. Permissions Added
8. Audit Integration
9. Remaining Risks
10. Task K Readiness
---
# Definition of Done
✔ Admin can manage template metadata
✔ Admin can manage template versions
✔ Admin can manage template mappings
✔ Admin can manage approval workflows
✔ Admin can manage approval steps
✔ Admin can manage document sequences
✔ Preview next document code works
✔ All mutations refresh table data
✔ All settings routes are organization scoped
✔ Permission guard works
✔ Audit works
✔ No drag/drop designer
✔ No advanced approval logic

View File

@@ -0,0 +1,540 @@
# Task J: CRM Dashboard KPI & Sales Analytics
## Background
Task J.0 KPI Definition Freeze completed.
Task D.2.1 Revenue Attribution and Party Governance completed.
Dashboard implementation MUST follow:
```txt
Lead Definition
Opportunity Definition
Revenue Attribution Rules
Project Party Reporting Rules
```
No dashboard logic may redefine KPI formulas.
---
# Must Read
```txt
Layout.md
.agents/skills/kiranism-shadcn-dashboard
docs/adr/0010-revenue-attribution-governance.md
docs/implementation/technical-debt.md
src/features/crm/reporting/server/**
src/features/crm/customers/**
src/features/crm/enquiries/**
src/features/crm/quotations/**
src/features/foundation/approval/**
src/features/foundation/document-artifact/**
src/db/schema.ts
```
---
# Goal
Replace CRM placeholder dashboard with a production KPI dashboard powered by real CRM data.
Route:
```txt
/ dashboard / crm
```
No mock data allowed.
---
# Scope J1: Dashboard Foundation
Create:
```txt
src/features/crm/dashboard/**
```
Recommended structure:
```txt
api/
types.ts
service.ts
queries.ts
server/
service.ts
components/
dashboard-summary-cards.tsx
dashboard-funnel.tsx
dashboard-sales-ranking.tsx
dashboard-followups.tsx
dashboard-approval-metrics.tsx
dashboard-revenue-cards.tsx
dashboard-hot-projects.tsx
```
Use:
```txt
Server Components First
React Query
PageContainer
Bento Grid Layout
```
Follow:
```txt
Layout.md
kiranism-shadcn-dashboard
```
---
# Scope J2: Executive Summary Cards
Cards:
## Lead
```txt
Lead Count
New Leads
Unassigned Leads
Lead Aging Average
```
Formula:
```txt
pipelineStage = lead
```
---
## Opportunity
```txt
Opportunity Count
Open Opportunities
Hot Opportunities
Opportunity Value
```
Formula:
```txt
pipelineStage = opportunity
```
Source:
```txt
crm_enquiries.estimatedValue
```
---
## Quotation
```txt
Draft Quotations
Pending Approval
Approved Quotations
Sent Quotations
```
---
## Revenue
```txt
Quotation Value
Won Value
Lost Value
```
Use frozen revenue rules.
---
# Scope J3: Sales Pipeline Funnel
Create funnel widget:
```txt
Lead
Opportunity
Quotation
Pending Approval
Approved
Closed Won
```
Display:
```txt
Count
Value
Conversion %
```
Formulas must follow Task J.0.
---
# Scope J4: Revenue Analytics
Use:
```txt
src/features/crm/reporting/server/**
```
Cards:
```txt
Top End Customers
Top Billing Customers
Top Contractors
Top Consultants
```
Metrics:
```txt
Revenue
Quotation Count
Won Revenue
```
Revenue Attribution:
```txt
End Customer
Fallback Billing Customer
```
must follow ADR.
---
# Scope J5: Sales Ranking
Create table:
Columns:
```txt
Sales Person
Lead Count
Opportunity Count
Quotation Count
Approved Quotations
Won Revenue
Conversion Rate
```
Ownership:
```txt
Lead / Opportunity
→ assignedToUserId
Quotation
→ salesmanId
```
Use KPI Freeze definitions.
---
# Scope J6: Follow-up Dashboard
Source:
```txt
crm_enquiry_followups
crm_quotation_followups
```
Cards:
```txt
Due Today
Due This Week
Overdue
Completed
```
Table:
```txt
Upcoming Follow Ups
```
Display:
```txt
Date
Customer
Opportunity
Assigned Sales
```
---
# Scope J7: Approval Analytics
Source:
```txt
approval module
```
Cards:
```txt
Pending Approvals
Approved Today
Rejected Today
Returned Today
```
Metrics:
```txt
Average Approval Time
```
Table:
```txt
My Pending Approvals
```
---
# Scope J8: Hot Projects
Source:
```txt
crm_enquiries.isHotProject
crm_quotations.isHotProject
```
Widget:
```txt
Top Hot Opportunities
```
Display:
```txt
Project
Customer
Value
Assigned Sales
Probability
```
---
# Scope J9: Global Filters
Add filters:
```txt
Date Range
Branch
Sales Person
Project Party Role
Product Type
```
Use:
```txt
nuqs
```
Rules:
```txt
organization scoped
server query aware
```
---
# Scope J10: Permissions
Add:
```txt
crm.dashboard.read
crm.dashboard.export
```
Rules:
```txt
super_admin
organization_admin
sales_manager
```
full access
sales:
```txt
own scope only (future-ready)
```
For now:
```txt
organization scope allowed
```
Document as technical debt.
---
# Scope J11: Export
Support:
```txt
CSV
Excel
```
Exports:
```txt
Sales Ranking
Revenue Analytics
Pipeline Funnel
```
Use current export patterns.
Do NOT generate PDF dashboards.
---
# Scope J12: Performance Rules
Required:
```txt
Aggregate queries
Server-side calculations
No N+1 queries
Pagination for tables
Reusable KPI service layer
```
Dashboard should remain fast with:
```txt
100k+
quotations
enquiries
customers
```
---
# Scope J13: Audit
Audit:
```txt
crm_dashboard_export
```
Actions:
```txt
export_csv
export_excel
```
Reading dashboard does not require audit.
---
# Explicit Non-Scope
Do NOT implement:
```txt
AI Forecasting
Predictive Sales
Power BI
Data Warehouse
Notification Center
Report Builder
```
---
# Output
1. Files Added
2. Files Modified
3. KPI Widgets Added
4. Revenue Analytics Added
5. Sales Ranking Added
6. Approval Analytics Added
7. Export Added
8. Permissions Added
9. Remaining Risks
10. Task K Readiness
---
# Definition of Done
✔ Dashboard uses real data
✔ KPI summary cards complete
✔ Revenue analytics complete
✔ Sales ranking complete
✔ Approval analytics complete
✔ Follow-up analytics complete
✔ Pipeline funnel complete
✔ Filters working
✔ Export working
✔ No mock data
✔ Ready for Task K Report Center

View File

@@ -0,0 +1,131 @@
import { NextRequest, NextResponse } from 'next/server';
import { auditAction } from '@/features/foundation/audit-log/service';
import { getCrmDashboardData } from '@/features/crm/dashboard/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
function escapeCsv(value: string | number | null | undefined) {
const text = value === null || value === undefined ? '' : String(value);
return `"${text.replaceAll('"', '""')}"`;
}
function buildCsv(rows: string[][]) {
return rows.map((row) => row.map((cell) => escapeCsv(cell)).join(',')).join('\n');
}
function buildExcelTable(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 GET(request: NextRequest) {
try {
const { session, membership, organization } = await requireOrganizationAccess({
permission: PERMISSIONS.crmDashboardExport
});
const searchParams = request.nextUrl.searchParams;
const section = searchParams.get('section') ?? 'sales-ranking';
const format = searchParams.get('format') ?? 'csv';
const data = await getCrmDashboardData(
{
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
},
{
dateFrom: searchParams.get('dateFrom'),
dateTo: searchParams.get('dateTo'),
branch: searchParams.get('branch'),
salesman: searchParams.get('salesman'),
projectPartyRole: searchParams.get('projectPartyRole'),
productType: searchParams.get('productType')
}
);
let rows: string[][] = [];
if (section === 'revenue-analytics') {
rows = [
['Dimension', 'Customer Code', 'Customer Name', 'Revenue', 'Quotation Count', 'Won Revenue'],
...[
['End Customer', ...data.revenueAnalytics.topEndCustomers],
['Billing Customer', ...data.revenueAnalytics.topBillingCustomers],
['Contractor', ...data.revenueAnalytics.topContractors],
['Consultant', ...data.revenueAnalytics.topConsultants]
].flatMap(([dimension, ...entries]) =>
(entries as typeof data.revenueAnalytics.topEndCustomers).map((row) => [
String(dimension),
row.customerCode,
row.customerName,
String(row.revenue),
String(row.quotationCount),
String(row.wonRevenue)
])
)
];
} else if (section === 'pipeline-funnel') {
rows = [
['Stage', 'Count', 'Value', 'Conversion Rate'],
...data.funnel.map((row) => [
row.label,
String(row.count),
String(row.value),
row.conversionRate === null ? '' : String(row.conversionRate)
])
];
} else {
rows = [
['Sales Person', 'Lead Count', 'Opportunity Count', 'Quotation Count', 'Approved Quotations', 'Won Revenue', 'Conversion Rate'],
...data.salesRanking.map((row) => [
row.salesPersonName,
String(row.leadCount),
String(row.opportunityCount),
String(row.quotationCount),
String(row.approvedQuotations),
String(row.wonRevenue),
String(row.conversionRate)
])
];
}
await auditAction({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_dashboard_export',
entityId: section,
action: format === 'xls' ? 'export_excel' : 'export_csv',
afterData: {
section,
format,
filters: data.filters
}
});
const body = format === 'xls' ? buildExcelTable(rows) : buildCsv(rows);
const contentType =
format === 'xls' ? 'application/vnd.ms-excel; charset=utf-8' : 'text/csv; charset=utf-8';
const extension = format === 'xls' ? 'xls' : 'csv';
return new NextResponse(body, {
headers: {
'Content-Type': contentType,
'Content-Disposition': `attachment; filename="crm-dashboard-${section}.${extension}"`
}
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to export CRM dashboard data' }, { status: 500 });
}
}

View File

@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCrmDashboardData } from '@/features/crm/dashboard/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
export async function GET(request: NextRequest) {
try {
const { session, membership, organization } = await requireOrganizationAccess({
permission: PERMISSIONS.crmDashboardRead
});
const searchParams = request.nextUrl.searchParams;
const data = await getCrmDashboardData(
{
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
},
{
dateFrom: searchParams.get('dateFrom'),
dateTo: searchParams.get('dateTo'),
branch: searchParams.get('branch'),
salesman: searchParams.get('salesman'),
projectPartyRole: searchParams.get('projectPartyRole'),
productType: searchParams.get('productType')
}
);
return NextResponse.json(data);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load CRM dashboard' }, { status: 500 });
}
}

View File

@@ -0,0 +1,118 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
import {
getApprovalWorkflow,
softDeleteApprovalWorkflow,
updateApprovalWorkflow
} from '@/features/foundation/approval/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
const workflowSchema = z.object({
code: z.string().min(1).optional(),
name: z.string().min(1).optional(),
entityType: z.string().min(1).optional(),
isActive: z.boolean().optional()
});
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireOrganizationAccess({
permission: PERMISSIONS.crmApprovalWorkflowRead
});
const workflow = await getApprovalWorkflow(id, organization.id);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Approval workflow loaded successfully',
workflow
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to load approval workflow' }, { status: 500 });
}
}
export async function PATCH(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmApprovalWorkflowUpdate
});
const payload = workflowSchema.parse(await request.json());
const before = await getApprovalWorkflow(id, organization.id);
const updated = await updateApprovalWorkflow(id, organization.id, payload);
await auditUpdate({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_approval_workflow',
entityId: id,
beforeData: before,
afterData: updated
});
return NextResponse.json({
success: true,
message: 'Approval workflow updated successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to update approval workflow' }, { status: 500 });
}
}
export async function DELETE(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmApprovalWorkflowDelete
});
const result = await softDeleteApprovalWorkflow(id, organization.id);
await auditDelete({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_approval_workflow',
entityId: id,
beforeData: result.before,
afterData: result.after
});
return NextResponse.json({
success: true,
message: 'Approval workflow deleted successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to delete approval workflow' }, { status: 500 });
}
}

View File

@@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditUpdate } from '@/features/foundation/audit-log/service';
import {
getApprovalWorkflow,
replaceApprovalWorkflowSteps
} from '@/features/foundation/approval/server/service';
import { BUSINESS_ROLES, PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
const stepSchema = z.object({
stepNumber: z.number().int().min(1),
roleCode: z.enum(BUSINESS_ROLES),
roleName: z.string().min(1),
isRequired: z.boolean().optional()
});
const payloadSchema = z.object({
steps: z.array(stepSchema)
});
export async function PUT(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmApprovalWorkflowUpdate
});
const payload = payloadSchema.parse(await request.json());
const before = await getApprovalWorkflow(id, organization.id);
const steps = await replaceApprovalWorkflowSteps(id, organization.id, payload.steps);
await auditUpdate({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_approval_step',
entityId: id,
beforeData: before.steps,
afterData: steps
});
return NextResponse.json({
success: true,
message: 'Approval workflow steps updated successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json(
{ message: 'Unable to update approval workflow steps' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditCreate } from '@/features/foundation/audit-log/service';
import {
createApprovalWorkflow,
listApprovalWorkflows
} from '@/features/foundation/approval/server/service';
import { BUSINESS_ROLES, PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
const workflowSchema = z.object({
code: z.string().min(1),
name: z.string().min(1),
entityType: z.string().min(1),
isActive: z.boolean().optional()
});
export async function GET(_request: NextRequest) {
try {
const { organization } = await requireOrganizationAccess({
permission: PERMISSIONS.crmApprovalWorkflowRead
});
const items = await listApprovalWorkflows(organization.id);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Approval workflows loaded successfully',
items
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to load approval workflows' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmApprovalWorkflowCreate
});
const payload = workflowSchema.parse(await request.json());
const created = await createApprovalWorkflow(organization.id, payload);
await auditCreate({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_approval_workflow',
entityId: created.id,
afterData: created
});
return NextResponse.json({
success: true,
message: 'Approval workflow created successfully',
allowedRoles: BUSINESS_ROLES
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to create approval workflow' }, { status: 500 });
}
}

View File

@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from 'next/server';
import { previewDocumentSequenceById } from '@/features/foundation/document-sequence/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireOrganizationAccess({
permission: PERMISSIONS.crmDocumentSequenceRead
});
const preview = await previewDocumentSequenceById(id, organization.id);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Document sequence preview loaded successfully',
preview
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json(
{ message: 'Unable to preview document sequence' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditUpdate } from '@/features/foundation/audit-log/service';
import {
getDocumentSequence,
resetDocumentSequence
} from '@/features/foundation/document-sequence/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
const resetSchema = z.object({
currentNumber: z.number().int().min(0)
});
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmDocumentSequenceReset
});
const payload = resetSchema.parse(await request.json());
const before = await getDocumentSequence(id, organization.id);
const updated = await resetDocumentSequence(id, organization.id, payload);
await auditUpdate({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_document_sequence',
entityId: id,
beforeData: before,
afterData: updated
});
return NextResponse.json({
success: true,
message: 'Document sequence reset successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to reset document sequence' }, { status: 500 });
}
}

View File

@@ -0,0 +1,122 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
import {
deleteDocumentSequence,
getDocumentSequence,
updateDocumentSequence
} from '@/features/foundation/document-sequence/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
const sequenceSchema = z.object({
documentType: z.string().min(1).optional(),
prefix: z.string().min(1).optional(),
period: z.string().min(1).optional(),
branchId: z.string().optional().nullable(),
currentNumber: z.number().int().min(0).optional(),
paddingLength: z.number().int().min(1).optional(),
isActive: z.boolean().optional()
});
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireOrganizationAccess({
permission: PERMISSIONS.crmDocumentSequenceRead
});
const sequence = await getDocumentSequence(id, organization.id);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Document sequence loaded successfully',
sequence
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to load document sequence' }, { status: 500 });
}
}
export async function PATCH(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmDocumentSequenceUpdate
});
const payload = sequenceSchema.parse(await request.json());
const before = await getDocumentSequence(id, organization.id);
const updated = await updateDocumentSequence(id, organization.id, payload);
await auditUpdate({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_document_sequence',
entityId: id,
beforeData: before,
afterData: updated
});
return NextResponse.json({
success: true,
message: 'Document sequence updated successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to update document sequence' }, { status: 500 });
}
}
export async function DELETE(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmDocumentSequenceUpdate
});
const before = await getDocumentSequence(id, organization.id);
const deleted = await deleteDocumentSequence(id, organization.id);
await auditDelete({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_document_sequence',
entityId: id,
beforeData: before,
afterData: deleted
});
return NextResponse.json({
success: true,
message: 'Document sequence deleted successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to delete document sequence' }, { status: 500 });
}
}

View File

@@ -0,0 +1,80 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditCreate } from '@/features/foundation/audit-log/service';
import {
createDocumentSequence,
listDocumentSequences
} from '@/features/foundation/document-sequence/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
const sequenceSchema = z.object({
documentType: z.string().min(1),
prefix: z.string().min(1),
period: z.string().min(1),
branchId: z.string().optional().nullable(),
currentNumber: z.number().int().min(0).optional(),
paddingLength: z.number().int().min(1),
isActive: z.boolean().optional()
});
export async function GET(_request: NextRequest) {
try {
const { organization } = await requireOrganizationAccess({
permission: PERMISSIONS.crmDocumentSequenceRead
});
const items = await listDocumentSequences(organization.id);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Document sequences loaded successfully',
items
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to load document sequences' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmDocumentSequenceCreate
});
const payload = sequenceSchema.parse(await request.json());
const created = await createDocumentSequence(organization.id, payload);
await auditCreate({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_document_sequence',
entityId: created.id,
afterData: created
});
return NextResponse.json({
success: true,
message: 'Document sequence created successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to create document sequence' }, { status: 500 });
}
}

View File

@@ -0,0 +1 @@
export { GET, PATCH, DELETE } from '@/app/api/crm/document-templates/[id]/route';

View File

@@ -0,0 +1 @@
export { GET, POST } from '@/app/api/crm/document-templates/[id]/versions/route';

View File

@@ -0,0 +1,108 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
import {
deleteDocumentTemplateMapping,
updateDocumentTemplateMapping
} from '@/features/foundation/document-template/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
const columnSchema = z.object({
columnName: z.string().min(1),
sourceField: z.string().min(1),
columnLetter: z.string().optional().nullable(),
sortOrder: z.number().int().optional(),
formatMask: z.string().optional().nullable()
});
const mappingSchema = z.object({
placeholderKey: z.string().min(1).optional(),
sourcePath: z.string().min(1).optional(),
dataType: z.enum(['scalar', 'multiline', 'table', 'image']).optional(),
sheetName: z.string().optional().nullable(),
defaultValue: z.string().optional().nullable(),
formatMask: z.string().optional().nullable(),
sortOrder: z.number().int().optional(),
columns: z.array(columnSchema).optional()
});
export async function PATCH(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmDocumentTemplateUpdate
});
const payload = mappingSchema.parse(await request.json());
const updated = await updateDocumentTemplateMapping(id, organization.id, payload);
await auditUpdate({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_document_template_mapping',
entityId: id,
afterData: updated
});
return NextResponse.json({
success: true,
message: 'Document template mapping updated successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json(
{ message: 'Unable to update document template mapping' },
{ status: 500 }
);
}
}
export async function DELETE(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmDocumentTemplateDelete
});
const result = await deleteDocumentTemplateMapping(id, organization.id);
await auditDelete({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_document_template_mapping',
entityId: id,
beforeData: result.before,
afterData: result.after
});
return NextResponse.json({
success: true,
message: 'Document template mapping deleted successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json(
{ message: 'Unable to delete document template mapping' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1 @@
export { GET, POST } from '@/app/api/crm/document-templates/route';

View File

@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditCreate } from '@/features/foundation/audit-log/service';
import { createDocumentTemplateMapping } from '@/features/foundation/document-template/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
const columnSchema = z.object({
columnName: z.string().min(1),
sourceField: z.string().min(1),
columnLetter: z.string().optional().nullable(),
sortOrder: z.number().int().optional(),
formatMask: z.string().optional().nullable()
});
const mappingSchema = z.object({
placeholderKey: z.string().min(1),
sourcePath: z.string().min(1),
dataType: z.enum(['scalar', 'multiline', 'table', 'image']),
sheetName: z.string().optional().nullable(),
defaultValue: z.string().optional().nullable(),
formatMask: z.string().optional().nullable(),
sortOrder: z.number().int().optional(),
columns: z.array(columnSchema).optional()
});
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmDocumentTemplateUpdate
});
const payload = mappingSchema.parse(await request.json());
const created = await createDocumentTemplateMapping(id, organization.id, payload);
await auditCreate({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_document_template_mapping',
entityId: created.id,
afterData: created
});
return NextResponse.json({
success: true,
message: 'Document template mapping created successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json(
{ message: 'Unable to create document template mapping' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,81 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditUpdate } from '@/features/foundation/audit-log/service';
import {
setDocumentTemplateVersionActive,
updateDocumentTemplateVersion
} from '@/features/foundation/document-template/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
const versionSchema = z.object({
version: z.string().min(1).optional(),
filePath: z.string().optional().nullable(),
schemaJson: z.unknown().optional(),
previewImageUrl: z.string().optional().nullable(),
isActive: z.boolean().optional()
});
export async function PATCH(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmDocumentTemplateUpdate
});
const payload = versionSchema.parse(await request.json());
if (Object.keys(payload).length === 1 && typeof payload.isActive === 'boolean') {
const result = await setDocumentTemplateVersionActive(id, organization.id, payload.isActive);
await auditUpdate({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_document_template_version',
entityId: id,
beforeData: result.before,
afterData: result.after
});
return NextResponse.json({
success: true,
message: 'Document template version status updated successfully'
});
}
const updated = await updateDocumentTemplateVersion(id, organization.id, payload);
await auditUpdate({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_document_template_version',
entityId: id,
afterData: updated
});
return NextResponse.json({
success: true,
message: 'Document template version updated successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json(
{ message: 'Unable to update document template version' },
{ status: 500 }
);
}
}

View File

@@ -1,31 +1,67 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container'; import PageContainer from '@/components/layout/page-container';
import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder'; import { crmDashboardQueryOptions } from '@/features/crm/dashboard/api/queries';
import { CrmDashboard } from '@/features/crm/dashboard/components/crm-dashboard';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { getQueryClient } from '@/lib/query-client';
import { searchParamsCache } from '@/lib/searchparams';
import type { SearchParams } from 'nuqs/server';
export const metadata = { export const metadata = {
title: 'Dashboard: CRM' title: 'Dashboard: CRM'
}; };
export default function CrmDashboardRoute() { type PageProps = {
searchParams: Promise<SearchParams>;
};
export default async function CrmDashboardRoute(props: PageProps) {
const searchParams = await props.searchParams;
const session = await auth();
const canRead =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmDashboardRead)));
const canExport =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmDashboardExport)));
searchParamsCache.parse(searchParams);
const filters = {
dateFrom: searchParamsCache.get('dateFrom'),
dateTo: searchParamsCache.get('dateTo'),
branch: searchParamsCache.get('branch'),
salesman: searchParamsCache.get('salesman'),
projectPartyRole: searchParamsCache.get('projectPartyRole'),
productType: searchParamsCache.get('productType')
};
const queryClient = getQueryClient();
if (canRead) {
void queryClient.prefetchQuery(crmDashboardQueryOptions(filters));
}
return ( return (
<PageContainer <PageContainer
pageTitle='CRM Foundation' pageTitle='CRM Dashboard'
pageDescription='Production CRM routes are now isolated from legacy mock state and ready for foundation-backed modules.' pageDescription='Production KPI dashboard, revenue analytics, approval visibility, and sales insights from real CRM data.'
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 dashboard.
</div>
}
> >
<CrmProductionPlaceholder {canRead ? (
title='CRM production workspace' <HydrationBoundary state={dehydrate(queryClient)}>
summary='Task B.1 isolates the production CRM path so Task C can start on real architecture instead of in-memory demo state.' <CrmDashboard canExport={canExport} />
foundationItems={[ </HydrationBoundary>
'Current user context', ) : null}
'Organization context',
'Permission helpers',
'Branch scope abstraction',
'Master options service',
'Document sequence service',
'Audit log helper'
]}
nextStep='Start Customer and Contact modules on top of Route Handlers, Drizzle, and organization-first access checks.'
demoHref='/dashboard/crm-demo'
/>
</PageContainer> </PageContainer>
); );
} }

View File

@@ -0,0 +1,40 @@
import { auth } from '@/auth';
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { ApprovalWorkflowSettings } from '@/features/foundation/approval/components/approval-workflow-settings';
import { approvalWorkflowsQueryOptions } from '@/features/foundation/approval/queries';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { getQueryClient } from '@/lib/query-client';
export default async function ApprovalWorkflowsRoute() {
const session = await auth();
const canRead =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmApprovalWorkflowRead)));
const queryClient = getQueryClient();
if (canRead) {
void queryClient.prefetchQuery(approvalWorkflowsQueryOptions());
}
return (
<PageContainer
pageTitle='Approval Workflows'
pageDescription='Configure sequential CRM approval workflows and business-role steps without touching the runtime approval engine.'
access={canRead}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
You do not have access to CRM approval workflow settings.
</div>
}
>
{canRead ? (
<HydrationBoundary state={dehydrate(queryClient)}>
<ApprovalWorkflowSettings />
</HydrationBoundary>
) : null}
</PageContainer>
);
}

View File

@@ -1,23 +1,40 @@
import { auth } from '@/auth';
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container'; import PageContainer from '@/components/layout/page-container';
import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder'; import { DocumentSequenceSettings } from '@/features/foundation/document-sequence/components/document-sequence-settings';
import { documentSequencesQueryOptions } from '@/features/foundation/document-sequence/queries';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { getQueryClient } from '@/lib/query-client';
export default async function DocumentSequencesRoute() {
const session = await auth();
const canRead =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmDocumentSequenceRead)));
const queryClient = getQueryClient();
if (canRead) {
void queryClient.prefetchQuery(documentSequencesQueryOptions());
}
export default function DocumentSequencesRoute() {
return ( return (
<PageContainer <PageContainer
pageTitle='Document Sequences' pageTitle='Document Sequences'
pageDescription='The sequence foundation is ready, but a production management UI has not been introduced yet.' pageDescription='Manage organization-scoped running numbers, prefixes, and branch-aware sequence resets.'
access={canRead}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
You do not have access to CRM document sequences.
</div>
}
> >
<CrmProductionPlaceholder {canRead ? (
title='Document sequence UI pending' <HydrationBoundary state={dehydrate(queryClient)}>
summary='Sequence generation now exists in the foundation layer, but the old mock settings page has been isolated to the demo route.' <DocumentSequenceSettings />
foundationItems={[ </HydrationBoundary>
'Preview and generate sequence helpers', ) : null}
'Organization-scoped sequence table',
'Branch-aware sequence support'
]}
nextStep='Add a production management page only when the product requires editable sequence settings.'
demoHref='/dashboard/crm-demo/settings/document-sequences'
/>
</PageContainer> </PageContainer>
); );
} }

View File

@@ -83,9 +83,10 @@ export default function AppSidebar() {
<SidebarMenu> <SidebarMenu>
{group.items.map((item) => { {group.items.map((item) => {
const Icon = item.icon ? Icons[item.icon] : Icons.logo; const Icon = item.icon ? Icons[item.icon] : Icons.logo;
const itemKey = item.url || `${group.label ?? 'group'}-${item.title}`;
return item?.items && item?.items?.length > 0 ? ( return item?.items && item?.items?.length > 0 ? (
<Collapsible <Collapsible
key={item.title} key={itemKey}
asChild asChild
defaultOpen={item.isActive} defaultOpen={item.isActive}
className='group/collapsible' className='group/collapsible'
@@ -101,7 +102,9 @@ export default function AppSidebar() {
<CollapsibleContent> <CollapsibleContent>
<SidebarMenuSub> <SidebarMenuSub>
{item.items?.map((subItem) => ( {item.items?.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}> <SidebarMenuSubItem
key={subItem.url || `${itemKey}-${subItem.title}`}
>
<SidebarMenuSubButton asChild isActive={pathname === subItem.url}> <SidebarMenuSubButton asChild isActive={pathname === subItem.url}>
<Link href={subItem.url}> <Link href={subItem.url}>
<span>{subItem.title}</span> <span>{subItem.title}</span>
@@ -114,7 +117,7 @@ export default function AppSidebar() {
</SidebarMenuItem> </SidebarMenuItem>
</Collapsible> </Collapsible>
) : ( ) : (
<SidebarMenuItem key={item.title}> <SidebarMenuItem key={itemKey}>
<SidebarMenuButton <SidebarMenuButton
asChild asChild
tooltip={item.title} tooltip={item.title}

View File

@@ -38,7 +38,7 @@ export const navGroups: NavGroup[] = [
label: "Overview", label: "Overview",
items: [ items: [
{ {
title: "Dashboard", title: "Dashboard Overview",
url: "/dashboard", url: "/dashboard",
icon: "dashboard", icon: "dashboard",
isActive: false, isActive: false,
@@ -83,6 +83,14 @@ export const navGroups: NavGroup[] = [
{ {
label: "CRM", label: "CRM",
items: [ items: [
{
title: "Dashboard CRM",
url: "/dashboard/crm",
icon: "dashboard",
isActive: false,
items: [],
access: { requireOrg: true, permission: "crm.dashboard.read" },
},
{ {
title: "Customers", title: "Customers",
url: "/dashboard/crm/customers", url: "/dashboard/crm/customers",
@@ -130,7 +138,18 @@ export const navGroups: NavGroup[] = [
{ {
title: "Document Sequences", title: "Document Sequences",
url: "/dashboard/crm/settings/document-sequences", url: "/dashboard/crm/settings/document-sequences",
access: { requireOrg: true, permission: "organization:manage" }, access: {
requireOrg: true,
permission: "crm.document_sequence.read",
},
},
{
title: "Approval Workflows",
url: "/dashboard/crm/settings/approval-workflows",
access: {
requireOrg: true,
permission: "crm.approval.workflow.read",
},
}, },
{ {
title: "Templates", title: "Templates",

View File

@@ -0,0 +1,14 @@
import { queryOptions } from '@tanstack/react-query';
import { getCrmDashboard } from './service';
import type { CrmDashboardFilters } from './types';
export const crmDashboardKeys = {
all: ['crm-dashboard'] as const,
data: (filters: CrmDashboardFilters) => [...crmDashboardKeys.all, filters] as const
};
export const crmDashboardQueryOptions = (filters: CrmDashboardFilters) =>
queryOptions({
queryKey: crmDashboardKeys.data(filters),
queryFn: () => getCrmDashboard(filters)
});

View File

@@ -0,0 +1,16 @@
import { apiClient } from '@/lib/api-client';
import type { CrmDashboardFilters, CrmDashboardResponse } from './types';
export async function getCrmDashboard(filters: CrmDashboardFilters): Promise<CrmDashboardResponse> {
const searchParams = new URLSearchParams();
if (filters.dateFrom) searchParams.set('dateFrom', filters.dateFrom);
if (filters.dateTo) searchParams.set('dateTo', filters.dateTo);
if (filters.branch) searchParams.set('branch', filters.branch);
if (filters.salesman) searchParams.set('salesman', filters.salesman);
if (filters.projectPartyRole) searchParams.set('projectPartyRole', filters.projectPartyRole);
if (filters.productType) searchParams.set('productType', filters.productType);
const query = searchParams.toString();
return apiClient<CrmDashboardResponse>(`/crm/dashboard${query ? `?${query}` : ''}`);
}

View File

@@ -0,0 +1,151 @@
export interface CrmDashboardFilters {
dateFrom?: string | null;
dateTo?: string | null;
branch?: string | null;
salesman?: string | null;
projectPartyRole?: string | null;
productType?: string | null;
}
export interface CrmDashboardOption {
id: string;
code: string;
label: string;
}
export interface CrmDashboardSalesOption {
id: string;
name: string;
}
export interface CrmDashboardReferenceData {
branches: CrmDashboardOption[];
salesmen: CrmDashboardSalesOption[];
productTypes: CrmDashboardOption[];
projectPartyRoles: CrmDashboardOption[];
}
export interface CrmDashboardSummary {
lead: {
leadCount: number;
newLeads: number;
unassignedLeads: number;
averageLeadAgingDays: number;
};
opportunity: {
opportunityCount: number;
openOpportunities: number;
hotOpportunities: number;
opportunityValue: number;
};
quotation: {
draftQuotations: number;
pendingApproval: number;
approvedQuotations: number;
sentQuotations: number;
};
revenue: {
quotationValue: number;
wonValue: number;
lostValue: number;
};
}
export interface CrmDashboardFunnelStep {
key: string;
label: string;
count: number;
value: number;
conversionRate: number | null;
}
export interface CrmDashboardRevenueRow {
customerId: string;
customerCode: string;
customerName: string;
roleCode: string;
revenue: number;
quotationCount: number;
wonRevenue: number;
}
export interface CrmDashboardSalesRankingRow {
salesPersonId: string;
salesPersonName: string;
leadCount: number;
opportunityCount: number;
quotationCount: number;
approvedQuotations: number;
wonRevenue: number;
conversionRate: number;
}
export interface CrmDashboardFollowupSummary {
dueToday: number;
dueThisWeek: number;
overdue: number;
completed: number;
}
export interface CrmDashboardFollowupRow {
id: string;
sourceType: 'enquiry' | 'quotation';
followupDate: string;
customerName: string;
opportunityName: string;
assignedSalesName: string | null;
outcome: string | null;
}
export interface CrmDashboardApprovalSummary {
pendingApprovals: number;
approvedToday: number;
rejectedToday: number;
returnedToday: number;
averageApprovalTimeHours: number;
}
export interface CrmDashboardApprovalRow {
id: string;
entityCode: string | null;
entityTitle: string | null;
workflowName: string;
currentStepRoleName: string | null;
requestedAt: string;
}
export interface CrmDashboardHotProjectRow {
enquiryId: string;
enquiryCode: string;
projectName: string;
customerName: string;
value: number;
assignedSalesName: string | null;
probability: number | null;
}
export interface CrmDashboardResponse {
success: boolean;
time: string;
message: string;
filters: Required<CrmDashboardFilters>;
referenceData: CrmDashboardReferenceData;
summary: CrmDashboardSummary;
funnel: CrmDashboardFunnelStep[];
revenueAnalytics: {
topEndCustomers: CrmDashboardRevenueRow[];
topBillingCustomers: CrmDashboardRevenueRow[];
topContractors: CrmDashboardRevenueRow[];
topConsultants: CrmDashboardRevenueRow[];
};
salesRanking: CrmDashboardSalesRankingRow[];
followups: {
summary: CrmDashboardFollowupSummary;
upcoming: CrmDashboardFollowupRow[];
};
approvals: {
summary: CrmDashboardApprovalSummary;
myPendingApprovals: CrmDashboardApprovalRow[];
};
hotProjects: CrmDashboardHotProjectRow[];
}

View File

@@ -0,0 +1,52 @@
'use client';
import { useSuspenseQuery } from '@tanstack/react-query';
import { parseAsString, useQueryStates } from 'nuqs';
import { crmDashboardQueryOptions } from '../api/queries';
import { DashboardApprovalMetrics } from './dashboard-approval-metrics';
import { DashboardFilters } from './dashboard-filters';
import { DashboardFollowups } from './dashboard-followups';
import { DashboardFunnel } from './dashboard-funnel';
import { DashboardHotProjects } from './dashboard-hot-projects';
import { DashboardRevenueCards } from './dashboard-revenue-cards';
import { DashboardSalesRanking } from './dashboard-sales-ranking';
import { DashboardSummaryCards } from './dashboard-summary-cards';
export function CrmDashboard({ canExport }: { canExport: boolean }) {
const [params] = useQueryStates({
dateFrom: parseAsString,
dateTo: parseAsString,
branch: parseAsString,
salesman: parseAsString,
projectPartyRole: parseAsString,
productType: parseAsString
});
const filters = {
dateFrom: params.dateFrom,
dateTo: params.dateTo,
branch: params.branch,
salesman: params.salesman,
projectPartyRole: params.projectPartyRole,
productType: params.productType
};
const { data } = useSuspenseQuery(crmDashboardQueryOptions(filters));
return (
<div className='space-y-6'>
<DashboardFilters referenceData={data.referenceData} canExport={canExport} />
<DashboardSummaryCards summary={data.summary} />
<div className='grid gap-6 2xl:grid-cols-[1.35fr_1fr]'>
<DashboardFunnel steps={data.funnel} />
<DashboardHotProjects rows={data.hotProjects} />
</div>
<DashboardRevenueCards revenueAnalytics={data.revenueAnalytics} />
<div className='grid gap-6 2xl:grid-cols-[1.2fr_1fr]'>
<DashboardSalesRanking rows={data.salesRanking} />
<DashboardApprovalMetrics approvals={data.approvals} />
</div>
<DashboardFollowups followups={data.followups} />
</div>
);
}

View File

@@ -0,0 +1,76 @@
'use client';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import type { CrmDashboardResponse } from '../api/types';
function Stat({ label, value, suffix }: { label: string; value: number; suffix?: string }) {
return (
<div className='rounded-lg border bg-muted/20 p-4'>
<div className='text-muted-foreground text-xs'>{label}</div>
<div className='mt-2 text-2xl font-semibold'>
{value}
{suffix ? <span className='ml-1 text-sm font-normal'>{suffix}</span> : null}
</div>
</div>
);
}
export function DashboardApprovalMetrics({
approvals
}: {
approvals: CrmDashboardResponse['approvals'];
}) {
return (
<Card>
<CardHeader>
<CardTitle>Approval Analytics</CardTitle>
<CardDescription>Live workflow counts plus the current approver queue.</CardDescription>
</CardHeader>
<CardContent className='space-y-6'>
<div className='grid gap-4 sm:grid-cols-2 xl:grid-cols-5'>
<Stat label='Pending Approvals' value={approvals.summary.pendingApprovals} />
<Stat label='Approved Today' value={approvals.summary.approvedToday} />
<Stat label='Rejected Today' value={approvals.summary.rejectedToday} />
<Stat label='Returned Today' value={approvals.summary.returnedToday} />
<Stat
label='Average Approval Time'
value={approvals.summary.averageApprovalTimeHours}
suffix='hrs'
/>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Document</TableHead>
<TableHead>Workflow</TableHead>
<TableHead>Current Step</TableHead>
<TableHead>Requested At</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{approvals.myPendingApprovals.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className='text-muted-foreground text-center'>
No pending approvals are assigned to your current approver scope.
</TableCell>
</TableRow>
) : (
approvals.myPendingApprovals.map((row) => (
<TableRow key={row.id}>
<TableCell>
<div className='font-medium'>{row.entityCode ?? 'Approval'}</div>
<div className='text-muted-foreground text-xs'>{row.entityTitle ?? '-'}</div>
</TableCell>
<TableCell>{row.workflowName}</TableCell>
<TableCell>{row.currentStepRoleName ?? '-'}</TableCell>
<TableCell>{new Date(row.requestedAt).toLocaleString()}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,177 @@
'use client';
import { useMemo } from 'react';
import Link from 'next/link';
import { parseAsString, useQueryStates } from 'nuqs';
import { Icons } from '@/components/icons';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select';
import type { CrmDashboardReferenceData } from '../api/types';
export function DashboardFilters({
referenceData,
canExport
}: {
referenceData: CrmDashboardReferenceData;
canExport: boolean;
}) {
const [params, setParams] = useQueryStates({
dateFrom: parseAsString,
dateTo: parseAsString,
branch: parseAsString,
salesman: parseAsString,
projectPartyRole: parseAsString,
productType: parseAsString
});
const exportQuery = useMemo(() => {
const searchParams = new URLSearchParams();
if (params.dateFrom) searchParams.set('dateFrom', params.dateFrom);
if (params.dateTo) searchParams.set('dateTo', params.dateTo);
if (params.branch) searchParams.set('branch', params.branch);
if (params.salesman) searchParams.set('salesman', params.salesman);
if (params.projectPartyRole) searchParams.set('projectPartyRole', params.projectPartyRole);
if (params.productType) searchParams.set('productType', params.productType);
return searchParams.toString();
}, [params]);
return (
<div className='flex flex-col gap-4 rounded-xl border bg-card p-4'>
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-6'>
<Input
type='date'
value={params.dateFrom ?? ''}
onChange={(event) => void setParams({ dateFrom: event.target.value || null })}
/>
<Input
type='date'
value={params.dateTo ?? ''}
onChange={(event) => void setParams({ dateTo: event.target.value || null })}
/>
<Select
value={params.branch ?? '__all__'}
onValueChange={(value) => void setParams({ branch: value === '__all__' ? null : value })}
>
<SelectTrigger>
<SelectValue placeholder='Branch' />
</SelectTrigger>
<SelectContent>
<SelectItem value='__all__'>All Branches</SelectItem>
{referenceData.branches.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={params.salesman ?? '__all__'}
onValueChange={(value) => void setParams({ salesman: value === '__all__' ? null : value })}
>
<SelectTrigger>
<SelectValue placeholder='Sales Person' />
</SelectTrigger>
<SelectContent>
<SelectItem value='__all__'>All Sales</SelectItem>
{referenceData.salesmen.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={params.projectPartyRole ?? '__all__'}
onValueChange={(value) =>
void setParams({ projectPartyRole: value === '__all__' ? null : value })
}
>
<SelectTrigger>
<SelectValue placeholder='Project Party Role' />
</SelectTrigger>
<SelectContent>
<SelectItem value='__all__'>All Party Roles</SelectItem>
{referenceData.projectPartyRoles.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={params.productType ?? '__all__'}
onValueChange={(value) => void setParams({ productType: value === '__all__' ? null : value })}
>
<SelectTrigger>
<SelectValue placeholder='Product Type' />
</SelectTrigger>
<SelectContent>
<SelectItem value='__all__'>All Product Types</SelectItem>
{referenceData.productTypes.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='flex flex-wrap items-center gap-2'>
<Button
variant='outline'
onClick={() =>
void setParams({
dateFrom: null,
dateTo: null,
branch: null,
salesman: null,
projectPartyRole: null,
productType: null
})
}
>
Reset Filters
</Button>
{canExport ? (
<>
<Button asChild variant='outline'>
<Link
href={`/api/crm/dashboard/export?section=sales-ranking&format=csv${
exportQuery ? `&${exportQuery}` : ''
}`}
>
<Icons.download className='mr-2 h-4 w-4' /> Export Sales CSV
</Link>
</Button>
<Button asChild variant='outline'>
<Link
href={`/api/crm/dashboard/export?section=revenue-analytics&format=xls${
exportQuery ? `&${exportQuery}` : ''
}`}
>
<Icons.download className='mr-2 h-4 w-4' /> Export Revenue Excel
</Link>
</Button>
<Button asChild variant='outline'>
<Link
href={`/api/crm/dashboard/export?section=pipeline-funnel&format=csv${
exportQuery ? `&${exportQuery}` : ''
}`}
>
<Icons.download className='mr-2 h-4 w-4' /> Export Funnel CSV
</Link>
</Button>
</>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,67 @@
'use client';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import type { CrmDashboardResponse } from '../api/types';
function Stat({ label, value }: { label: string; value: number }) {
return (
<div className='rounded-lg border bg-muted/20 p-4'>
<div className='text-muted-foreground text-xs'>{label}</div>
<div className='mt-2 text-2xl font-semibold'>{value}</div>
</div>
);
}
export function DashboardFollowups({
followups
}: {
followups: CrmDashboardResponse['followups'];
}) {
return (
<Card>
<CardHeader>
<CardTitle>Follow-up Dashboard</CardTitle>
<CardDescription>Upcoming and completed CRM follow-ups across enquiries and quotations.</CardDescription>
</CardHeader>
<CardContent className='space-y-6'>
<div className='grid gap-4 sm:grid-cols-2 xl:grid-cols-4'>
<Stat label='Due Today' value={followups.summary.dueToday} />
<Stat label='Due This Week' value={followups.summary.dueThisWeek} />
<Stat label='Overdue' value={followups.summary.overdue} />
<Stat label='Completed' value={followups.summary.completed} />
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Customer</TableHead>
<TableHead>Opportunity</TableHead>
<TableHead>Assigned Sales</TableHead>
<TableHead>Source</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{followups.upcoming.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className='text-muted-foreground text-center'>
No upcoming follow-ups match the current filters.
</TableCell>
</TableRow>
) : (
followups.upcoming.map((row) => (
<TableRow key={`${row.sourceType}-${row.id}`}>
<TableCell>{new Date(row.followupDate).toLocaleDateString()}</TableCell>
<TableCell>{row.customerName}</TableCell>
<TableCell>{row.opportunityName}</TableCell>
<TableCell>{row.assignedSalesName ?? '-'}</TableCell>
<TableCell className='capitalize'>{row.sourceType}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,45 @@
'use client';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import type { CrmDashboardFunnelStep } from '../api/types';
function formatNumber(value: number) {
return new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 }).format(value);
}
export function DashboardFunnel({ steps }: { steps: CrmDashboardFunnelStep[] }) {
const maxCount = Math.max(...steps.map((step) => step.count), 1);
return (
<Card>
<CardHeader>
<CardTitle>Sales Pipeline Funnel</CardTitle>
<CardDescription>Frozen lead, opportunity, quotation, approval, and won stages.</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
{steps.map((step) => (
<div key={step.key} className='space-y-2'>
<div className='flex items-center justify-between gap-4'>
<div>
<div className='font-medium'>{step.label}</div>
<div className='text-muted-foreground text-xs'>
{formatNumber(step.count)} items, value {formatNumber(step.value)}
</div>
</div>
<div className='text-right text-sm'>
<div>{step.conversionRate === null ? '-' : `${formatNumber(step.conversionRate)}%`}</div>
<div className='text-muted-foreground text-xs'>conversion</div>
</div>
</div>
<div className='bg-muted h-2 overflow-hidden rounded-full'>
<div
className='h-full rounded-full bg-primary'
style={{ width: `${Math.max((step.count / maxCount) * 100, step.count > 0 ? 8 : 0)}%` }}
/>
</div>
</div>
))}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,55 @@
'use client';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import type { CrmDashboardHotProjectRow } from '../api/types';
function formatNumber(value: number) {
return new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 }).format(value);
}
export function DashboardHotProjects({ rows }: { rows: CrmDashboardHotProjectRow[] }) {
return (
<Card>
<CardHeader>
<CardTitle>Top Hot Opportunities</CardTitle>
<CardDescription>Production hot projects from enquiries, ranked by commercial value.</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Customer</TableHead>
<TableHead className='text-right'>Value</TableHead>
<TableHead>Assigned Sales</TableHead>
<TableHead className='text-right'>Probability</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className='text-muted-foreground text-center'>
No hot opportunities match the current filters.
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow key={row.enquiryId}>
<TableCell>
<div className='font-medium'>{row.projectName}</div>
<div className='text-muted-foreground text-xs'>{row.enquiryCode}</div>
</TableCell>
<TableCell>{row.customerName}</TableCell>
<TableCell className='text-right'>{formatNumber(row.value)}</TableCell>
<TableCell>{row.assignedSalesName ?? '-'}</TableCell>
<TableCell className='text-right'>{row.probability ?? 0}%</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,92 @@
'use client';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import type { CrmDashboardRevenueRow, CrmDashboardResponse } from '../api/types';
function formatNumber(value: number) {
return new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 }).format(value);
}
function RevenueTable({
title,
description,
rows
}: {
title: string;
description: string;
rows: CrmDashboardRevenueRow[];
}) {
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Customer</TableHead>
<TableHead className='text-right'>Revenue</TableHead>
<TableHead className='text-right'>Quotations</TableHead>
<TableHead className='text-right'>Won Revenue</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className='text-muted-foreground text-center'>
No revenue rows match the current filters.
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow key={`${row.roleCode}-${row.customerId}`}>
<TableCell>
<div className='font-medium'>{row.customerName}</div>
<div className='text-muted-foreground text-xs'>{row.customerCode}</div>
</TableCell>
<TableCell className='text-right'>{formatNumber(row.revenue)}</TableCell>
<TableCell className='text-right'>{formatNumber(row.quotationCount)}</TableCell>
<TableCell className='text-right'>{formatNumber(row.wonRevenue)}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
);
}
export function DashboardRevenueCards({
revenueAnalytics
}: {
revenueAnalytics: CrmDashboardResponse['revenueAnalytics'];
}) {
return (
<div className='grid gap-4 xl:grid-cols-2'>
<RevenueTable
title='Top End Customers'
description='Revenue owner attribution with billing-customer fallback when end-customer is missing.'
rows={revenueAnalytics.topEndCustomers}
/>
<RevenueTable
title='Top Billing Customers'
description='Commercial relationship view grouped by billing customer.'
rows={revenueAnalytics.topBillingCustomers}
/>
<RevenueTable
title='Top Contractors'
description='Relationship analytics with full attribution for each contractor role.'
rows={revenueAnalytics.topContractors}
/>
<RevenueTable
title='Top Consultants'
description='Consultant-side revenue relationships from project parties.'
rows={revenueAnalytics.topConsultants}
/>
</div>
);
}

View File

@@ -0,0 +1,56 @@
'use client';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import type { CrmDashboardSalesRankingRow } from '../api/types';
function formatNumber(value: number) {
return new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 }).format(value);
}
export function DashboardSalesRanking({ rows }: { rows: CrmDashboardSalesRankingRow[] }) {
return (
<Card>
<CardHeader>
<CardTitle>Sales Ranking</CardTitle>
<CardDescription>Lead and opportunity ownership follow assignment rules. Quotation ownership uses salesman.</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Sales Person</TableHead>
<TableHead className='text-right'>Lead</TableHead>
<TableHead className='text-right'>Opportunity</TableHead>
<TableHead className='text-right'>Quotation</TableHead>
<TableHead className='text-right'>Approved</TableHead>
<TableHead className='text-right'>Won Revenue</TableHead>
<TableHead className='text-right'>Conversion</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className='text-muted-foreground text-center'>
No sales ranking rows match the current filters.
</TableCell>
</TableRow>
) : (
rows.map((row) => (
<TableRow key={row.salesPersonId}>
<TableCell className='font-medium'>{row.salesPersonName}</TableCell>
<TableCell className='text-right'>{formatNumber(row.leadCount)}</TableCell>
<TableCell className='text-right'>{formatNumber(row.opportunityCount)}</TableCell>
<TableCell className='text-right'>{formatNumber(row.quotationCount)}</TableCell>
<TableCell className='text-right'>{formatNumber(row.approvedQuotations)}</TableCell>
<TableCell className='text-right'>{formatNumber(row.wonRevenue)}</TableCell>
<TableCell className='text-right'>{formatNumber(row.conversionRate)}%</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,84 @@
'use client';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import type { CrmDashboardSummary } from '../api/types';
function formatNumber(value: number) {
return new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 }).format(value);
}
function SummaryCard({
title,
description,
items
}: {
title: string;
description: string;
items: Array<{ label: string; value: number; suffix?: string }>;
}) {
return (
<Card className='h-full'>
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent className='grid gap-4 sm:grid-cols-2'>
{items.map((item) => (
<div key={item.label} className='rounded-lg border bg-muted/20 p-4'>
<div className='text-muted-foreground text-xs'>{item.label}</div>
<div className='mt-2 text-2xl font-semibold'>
{formatNumber(item.value)}
{item.suffix ? <span className='ml-1 text-sm font-normal'>{item.suffix}</span> : null}
</div>
</div>
))}
</CardContent>
</Card>
);
}
export function DashboardSummaryCards({ summary }: { summary: CrmDashboardSummary }) {
return (
<div className='grid gap-4 xl:grid-cols-2 2xl:grid-cols-4'>
<SummaryCard
title='Lead'
description='Lead-stage enquiries based on the frozen KPI rules.'
items={[
{ label: 'Lead Count', value: summary.lead.leadCount },
{ label: 'New Leads', value: summary.lead.newLeads },
{ label: 'Unassigned Leads', value: summary.lead.unassignedLeads },
{ label: 'Lead Aging Avg', value: summary.lead.averageLeadAgingDays, suffix: 'days' }
]}
/>
<SummaryCard
title='Opportunity'
description='Assigned enquiries that remain active in the pipeline.'
items={[
{ label: 'Opportunity Count', value: summary.opportunity.opportunityCount },
{ label: 'Open Opportunities', value: summary.opportunity.openOpportunities },
{ label: 'Hot Opportunities', value: summary.opportunity.hotOpportunities },
{ label: 'Opportunity Value', value: summary.opportunity.opportunityValue }
]}
/>
<SummaryCard
title='Quotation'
description='Live quotation status counts from production data.'
items={[
{ label: 'Draft Quotations', value: summary.quotation.draftQuotations },
{ label: 'Pending Approval', value: summary.quotation.pendingApproval },
{ label: 'Approved Quotations', value: summary.quotation.approvedQuotations },
{ label: 'Sent Quotations', value: summary.quotation.sentQuotations }
]}
/>
<SummaryCard
title='Revenue'
description='Quotation, won, and lost values using frozen dashboard rules.'
items={[
{ label: 'Quotation Value', value: summary.revenue.quotationValue },
{ label: 'Won Value', value: summary.revenue.wonValue },
{ label: 'Lost Value', value: summary.revenue.lostValue }
]}
/>
</div>
);
}

View File

@@ -0,0 +1,705 @@
import { and, asc, desc, eq, inArray, isNull } from 'drizzle-orm';
import {
endOfDay,
endOfWeek,
formatISO,
isAfter,
isBefore,
isSameDay,
startOfDay,
startOfWeek
} from 'date-fns';
import {
crmApprovalRequests,
crmCustomers,
crmEnquiries,
crmEnquiryFollowups,
crmQuotationFollowups,
crmQuotations,
memberships,
users
} from '@/db/schema';
import { db } from '@/lib/db';
import { getUserBranches } from '@/features/foundation/branch-scope/service';
import { listApprovalRequests } from '@/features/foundation/approval/server/service';
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
import {
getRevenueByBillingCustomer,
getRevenueByConsultant,
getRevenueByContractor,
getRevenueByEndCustomer
} from '@/features/crm/reporting/server/service';
import type {
CrmDashboardApprovalRow,
CrmDashboardApprovalSummary,
CrmDashboardFilters,
CrmDashboardFollowupRow,
CrmDashboardFollowupSummary,
CrmDashboardFunnelStep,
CrmDashboardHotProjectRow,
CrmDashboardReferenceData,
CrmDashboardResponse,
CrmDashboardRevenueRow,
CrmDashboardSalesRankingRow,
CrmDashboardSummary
} from '../api/types';
const ENQUIRY_STATUS_CATEGORY = 'crm_enquiry_status';
const QUOTATION_STATUS_CATEGORY = 'crm_quotation_status';
const PRODUCT_TYPE_CATEGORY = 'crm_product_type';
const PROJECT_PARTY_ROLE_CATEGORY = 'crm_project_party_role';
type DashboardContext = {
organizationId: string;
userId: string;
membershipRole: string;
businessRole: string;
};
type NormalizedFilters = {
dateFrom: string;
dateTo: string;
branch: string | null;
salesman: string | null;
projectPartyRole: string | null;
productType: string | null;
};
type StatusMaps = {
enquiryStatusById: Map<string, string>;
quotationStatusById: Map<string, string>;
};
function normalizeDashboardFilters(filters: CrmDashboardFilters): NormalizedFilters {
const now = new Date();
const monthStart = startOfDay(new Date(now.getFullYear(), now.getMonth(), 1));
return {
dateFrom: filters.dateFrom ?? formatISO(monthStart, { representation: 'date' }),
dateTo: filters.dateTo ?? formatISO(now, { representation: 'date' }),
branch: filters.branch ?? null,
salesman: filters.salesman ?? null,
projectPartyRole: filters.projectPartyRole ?? null,
productType: filters.productType ?? null
};
}
function toDayBounds(filters: NormalizedFilters) {
return {
from: startOfDay(new Date(filters.dateFrom)),
to: endOfDay(new Date(filters.dateTo))
};
}
function round2(value: number) {
return Math.round(value * 100) / 100;
}
function average(values: number[]) {
if (values.length === 0) {
return 0;
}
return round2(values.reduce((sum, value) => sum + value, 0) / values.length);
}
async function getStatusMaps(organizationId: string): Promise<StatusMaps> {
const [enquiryOptions, quotationOptions] = await Promise.all([
getActiveOptionsByCategory(ENQUIRY_STATUS_CATEGORY, { organizationId }),
getActiveOptionsByCategory(QUOTATION_STATUS_CATEGORY, { organizationId })
]);
return {
enquiryStatusById: new Map(enquiryOptions.map((item) => [item.id, item.code])),
quotationStatusById: new Map(quotationOptions.map((item) => [item.id, item.code]))
};
}
async function getReferenceData(organizationId: string): Promise<CrmDashboardReferenceData> {
const [branches, salesmen, productTypes, projectPartyRoles] = await Promise.all([
getUserBranches(),
db
.select({ id: users.id, name: users.name })
.from(memberships)
.innerJoin(users, eq(memberships.userId, users.id))
.where(eq(memberships.organizationId, organizationId))
.orderBy(asc(users.name)),
getActiveOptionsByCategory(PRODUCT_TYPE_CATEGORY, { organizationId }),
getActiveOptionsByCategory(PROJECT_PARTY_ROLE_CATEGORY, { organizationId })
]);
return {
branches: branches.map((item) => ({ id: item.id, code: item.code, label: item.name })),
salesmen: salesmen.map((item) => ({ id: item.id, name: item.name })),
productTypes: productTypes.map((item) => ({
id: item.id,
code: item.code,
label: item.label
})),
projectPartyRoles: projectPartyRoles.map((item) => ({
id: item.id,
code: item.code,
label: item.label
}))
};
}
function matchesEnquiryFilters(
row: typeof crmEnquiries.$inferSelect,
filters: NormalizedFilters
) {
const { from, to } = toDayBounds(filters);
if (filters.branch && row.branchId !== filters.branch) return false;
if (filters.salesman && row.assignedToUserId !== filters.salesman) return false;
if (filters.productType && row.productType !== filters.productType) return false;
if (isBefore(row.createdAt, from) || isAfter(row.createdAt, to)) return false;
return true;
}
function matchesQuotationFilters(
row: typeof crmQuotations.$inferSelect,
filters: NormalizedFilters
) {
const { from, to } = toDayBounds(filters);
if (filters.branch && row.branchId !== filters.branch) return false;
if (filters.salesman && row.salesmanId !== filters.salesman) return false;
if (filters.productType && row.quotationType !== filters.productType) return false;
if (isBefore(row.quotationDate, from) || isAfter(row.quotationDate, to)) return false;
return true;
}
function isLeadStatus(statusCode: string | undefined, enquiry: typeof crmEnquiries.$inferSelect) {
return enquiry.assignedToUserId === null && !['closed_lost', 'cancelled'].includes(statusCode ?? '');
}
function isOpportunityStatus(
statusCode: string | undefined,
enquiry: typeof crmEnquiries.$inferSelect
) {
return enquiry.assignedToUserId !== null && !['closed_lost', 'cancelled'].includes(statusCode ?? '');
}
async function loadScopedRows(
organizationId: string,
filters: NormalizedFilters
) {
const [enquiries, quotations] = await Promise.all([
db
.select()
.from(crmEnquiries)
.where(and(eq(crmEnquiries.organizationId, organizationId), isNull(crmEnquiries.deletedAt))),
db
.select()
.from(crmQuotations)
.where(and(eq(crmQuotations.organizationId, organizationId), isNull(crmQuotations.deletedAt)))
]);
return {
enquiries: enquiries.filter((row) => matchesEnquiryFilters(row, filters)),
quotations: quotations.filter((row) => matchesQuotationFilters(row, filters))
};
}
function buildSummary(
scopedEnquiries: Array<typeof crmEnquiries.$inferSelect>,
scopedQuotations: Array<typeof crmQuotations.$inferSelect>,
statusMaps: StatusMaps
): CrmDashboardSummary {
const now = new Date();
const leadRows = scopedEnquiries.filter((row) =>
isLeadStatus(statusMaps.enquiryStatusById.get(row.status), row)
);
const opportunityRows = scopedEnquiries.filter((row) =>
isOpportunityStatus(statusMaps.enquiryStatusById.get(row.status), row)
);
const leadAges = leadRows.map((row) => (now.getTime() - row.createdAt.getTime()) / (1000 * 60 * 60 * 24));
const quotationStatusCodes = scopedQuotations.map((row) => statusMaps.quotationStatusById.get(row.status) ?? '');
return {
lead: {
leadCount: leadRows.length,
newLeads: leadRows.length,
unassignedLeads: leadRows.length,
averageLeadAgingDays: average(leadAges)
},
opportunity: {
opportunityCount: opportunityRows.length,
openOpportunities: opportunityRows.length,
hotOpportunities: opportunityRows.filter((row) => row.isHotProject).length,
opportunityValue: round2(opportunityRows.reduce((sum, row) => sum + (row.estimatedValue ?? 0), 0))
},
quotation: {
draftQuotations: quotationStatusCodes.filter((code) => code === 'draft').length,
pendingApproval: quotationStatusCodes.filter((code) => code === 'pending_approval').length,
approvedQuotations: quotationStatusCodes.filter((code) => code === 'approved').length,
sentQuotations: quotationStatusCodes.filter((code) => code === 'sent').length
},
revenue: {
quotationValue: round2(
scopedQuotations
.filter((row) => (statusMaps.quotationStatusById.get(row.status) ?? '') !== 'cancelled')
.reduce((sum, row) => sum + row.totalAmount, 0)
),
wonValue: round2(
scopedQuotations
.filter((row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'accepted')
.reduce((sum, row) => sum + row.totalAmount, 0)
),
lostValue: round2(
scopedQuotations
.filter((row) =>
['lost', 'rejected'].includes(statusMaps.quotationStatusById.get(row.status) ?? '')
)
.reduce((sum, row) => sum + row.totalAmount, 0)
)
}
};
}
function buildFunnel(
summary: CrmDashboardSummary,
scopedQuotations: Array<typeof crmQuotations.$inferSelect>,
statusMaps: StatusMaps
): CrmDashboardFunnelStep[] {
const steps = [
{
key: 'lead',
label: 'Lead',
count: summary.lead.leadCount,
value: 0
},
{
key: 'opportunity',
label: 'Opportunity',
count: summary.opportunity.opportunityCount,
value: summary.opportunity.opportunityValue
},
{
key: 'quotation',
label: 'Quotation',
count: scopedQuotations.filter(
(row) => (statusMaps.quotationStatusById.get(row.status) ?? '') !== 'cancelled'
).length,
value: scopedQuotations
.filter((row) => (statusMaps.quotationStatusById.get(row.status) ?? '') !== 'cancelled')
.reduce((sum, row) => sum + row.totalAmount, 0)
},
{
key: 'pending_approval',
label: 'Pending Approval',
count: scopedQuotations.filter(
(row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'pending_approval'
).length,
value: scopedQuotations
.filter((row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'pending_approval')
.reduce((sum, row) => sum + row.totalAmount, 0)
},
{
key: 'approved',
label: 'Approved',
count: scopedQuotations.filter(
(row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'approved'
).length,
value: scopedQuotations
.filter((row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'approved')
.reduce((sum, row) => sum + row.totalAmount, 0)
},
{
key: 'closed_won',
label: 'Closed Won',
count: scopedQuotations.filter(
(row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'accepted'
).length,
value: scopedQuotations
.filter((row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'accepted')
.reduce((sum, row) => sum + row.totalAmount, 0)
}
];
return steps.map((step, index) => ({
...step,
value: round2(step.value),
conversionRate:
index === 0 || steps[index - 1].count === 0
? null
: round2((step.count / steps[index - 1].count) * 100)
}));
}
async function buildRevenueAnalytics(
organizationId: string,
filters: NormalizedFilters
) {
const revenueFilters = {
dateFrom: filters.dateFrom,
dateTo: filters.dateTo,
...(filters.branch ? { branch: filters.branch } : {}),
...(filters.salesman ? { salesman: filters.salesman } : {}),
...(filters.productType ? { quotationType: filters.productType } : {})
};
const [
endCustomers,
billingCustomers,
contractors,
consultants,
wonEnd,
wonBilling,
wonContractor,
wonConsultant
] = await Promise.all([
getRevenueByEndCustomer(organizationId, revenueFilters),
getRevenueByBillingCustomer(organizationId, revenueFilters),
getRevenueByContractor(organizationId, revenueFilters),
getRevenueByConsultant(organizationId, revenueFilters),
getRevenueByEndCustomer(organizationId, { ...revenueFilters, status: 'accepted' }),
getRevenueByBillingCustomer(organizationId, { ...revenueFilters, status: 'accepted' }),
getRevenueByContractor(organizationId, { ...revenueFilters, status: 'accepted' }),
getRevenueByConsultant(organizationId, { ...revenueFilters, status: 'accepted' })
]);
function withWonRevenue(
rows: Awaited<ReturnType<typeof getRevenueByEndCustomer>>,
wonRows: Awaited<ReturnType<typeof getRevenueByEndCustomer>>
): CrmDashboardRevenueRow[] {
const wonMap = new Map(wonRows.map((row) => [row.customerId, row.revenue]));
return rows.slice(0, 5).map((row) => ({
customerId: row.customerId,
customerCode: row.customerCode,
customerName: row.customerName,
roleCode: row.roleCode,
revenue: row.revenue,
quotationCount: row.quotationCount,
wonRevenue: wonMap.get(row.customerId) ?? 0
}));
}
return {
topEndCustomers: withWonRevenue(endCustomers, wonEnd),
topBillingCustomers: withWonRevenue(billingCustomers, wonBilling),
topContractors: withWonRevenue(contractors, wonContractor),
topConsultants: withWonRevenue(consultants, wonConsultant)
};
}
async function buildSalesRanking(
scopedEnquiries: Array<typeof crmEnquiries.$inferSelect>,
scopedQuotations: Array<typeof crmQuotations.$inferSelect>,
statusMaps: StatusMaps
): Promise<CrmDashboardSalesRankingRow[]> {
const userIds = [
...new Set(
[
...scopedEnquiries.map((row) => row.assignedToUserId).filter(Boolean),
...scopedQuotations.map((row) => row.salesmanId).filter(Boolean)
] as string[]
)
];
const salesUsers = userIds.length
? await db.select({ id: users.id, name: users.name }).from(users).where(inArray(users.id, userIds))
: [];
const userMap = new Map(salesUsers.map((row) => [row.id, row.name]));
const rankingMap = new Map<string, CrmDashboardSalesRankingRow>();
for (const row of scopedEnquiries) {
if (!row.assignedToUserId) continue;
const current =
rankingMap.get(row.assignedToUserId) ??
{
salesPersonId: row.assignedToUserId,
salesPersonName: userMap.get(row.assignedToUserId) ?? 'Unknown',
leadCount: 0,
opportunityCount: 0,
quotationCount: 0,
approvedQuotations: 0,
wonRevenue: 0,
conversionRate: 0
};
if (isLeadStatus(statusMaps.enquiryStatusById.get(row.status), row)) {
current.leadCount += 1;
}
if (isOpportunityStatus(statusMaps.enquiryStatusById.get(row.status), row)) {
current.opportunityCount += 1;
}
rankingMap.set(row.assignedToUserId, current);
}
for (const row of scopedQuotations) {
if (!row.salesmanId) continue;
const current =
rankingMap.get(row.salesmanId) ??
{
salesPersonId: row.salesmanId,
salesPersonName: userMap.get(row.salesmanId) ?? 'Unknown',
leadCount: 0,
opportunityCount: 0,
quotationCount: 0,
approvedQuotations: 0,
wonRevenue: 0,
conversionRate: 0
};
const statusCode = statusMaps.quotationStatusById.get(row.status) ?? '';
if (statusCode !== 'cancelled') {
current.quotationCount += 1;
}
if (statusCode === 'approved') {
current.approvedQuotations += 1;
}
if (statusCode === 'accepted') {
current.wonRevenue += row.totalAmount;
}
rankingMap.set(row.salesmanId, current);
}
return [...rankingMap.values()]
.map((row) => ({
...row,
wonRevenue: round2(row.wonRevenue),
conversionRate:
row.quotationCount > 0 ? round2((row.approvedQuotations / row.quotationCount) * 100) : 0
}))
.sort((a, b) => {
if (b.wonRevenue !== a.wonRevenue) return b.wonRevenue - a.wonRevenue;
if (b.quotationCount !== a.quotationCount) return b.quotationCount - a.quotationCount;
return a.salesPersonName.localeCompare(b.salesPersonName);
})
.slice(0, 10);
}
async function buildFollowups(
organizationId: string,
filters: NormalizedFilters
): Promise<{ summary: CrmDashboardFollowupSummary; upcoming: CrmDashboardFollowupRow[] }> {
const [enquiryFollowups, quotationFollowups] = await Promise.all([
db
.select()
.from(crmEnquiryFollowups)
.where(and(eq(crmEnquiryFollowups.organizationId, organizationId), isNull(crmEnquiryFollowups.deletedAt)))
.orderBy(asc(crmEnquiryFollowups.followupDate)),
db
.select()
.from(crmQuotationFollowups)
.where(and(eq(crmQuotationFollowups.organizationId, organizationId), isNull(crmQuotationFollowups.deletedAt)))
.orderBy(asc(crmQuotationFollowups.followupDate))
]);
const { from, to } = toDayBounds(filters);
const now = new Date();
const weekStart = startOfWeek(now, { weekStartsOn: 1 });
const weekEnd = endOfWeek(now, { weekStartsOn: 1 });
const enquiryIds = [...new Set(enquiryFollowups.map((row) => row.enquiryId))];
const quotationIds = [...new Set(quotationFollowups.map((row) => row.quotationId))];
const [enquiries, quotations] = await Promise.all([
enquiryIds.length ? db.select().from(crmEnquiries).where(inArray(crmEnquiries.id, enquiryIds)) : [],
quotationIds.length ? db.select().from(crmQuotations).where(inArray(crmQuotations.id, quotationIds)) : []
]);
const customerIds = [...new Set([...enquiries.map((row) => row.customerId), ...quotations.map((row) => row.customerId)])];
const salesIds = [
...new Set(
[...enquiries.map((row) => row.assignedToUserId).filter(Boolean), ...quotations.map((row) => row.salesmanId).filter(Boolean)] as string[]
)
];
const [customers, salesUsers] = await Promise.all([
customerIds.length ? db.select().from(crmCustomers).where(inArray(crmCustomers.id, customerIds)) : [],
salesIds.length ? db.select({ id: users.id, name: users.name }).from(users).where(inArray(users.id, salesIds)) : []
]);
const enquiryMap = new Map(enquiries.map((row) => [row.id, row]));
const quotationMap = new Map(quotations.map((row) => [row.id, row]));
const customerMap = new Map(customers.map((row) => [row.id, row.name]));
const salesMap = new Map(salesUsers.map((row) => [row.id, row.name]));
const enquiryRows = enquiryFollowups
.filter((row) => !isBefore(row.followupDate, from) && !isAfter(row.followupDate, to))
.flatMap<CrmDashboardFollowupRow>((row) => {
const enquiry = enquiryMap.get(row.enquiryId);
if (!enquiry) {
return [];
}
return [
{
id: row.id,
sourceType: 'enquiry',
followupDate: row.followupDate.toISOString(),
customerName: customerMap.get(enquiry.customerId) ?? 'Unknown customer',
opportunityName: enquiry.projectName ?? enquiry.title,
assignedSalesName: enquiry.assignedToUserId
? (salesMap.get(enquiry.assignedToUserId) ?? null)
: null,
outcome: row.outcome
}
];
});
const quotationRows = quotationFollowups
.filter((row) => !isBefore(row.followupDate, from) && !isAfter(row.followupDate, to))
.flatMap<CrmDashboardFollowupRow>((row) => {
const quotation = quotationMap.get(row.quotationId);
if (!quotation) {
return [];
}
return [
{
id: row.id,
sourceType: 'quotation',
followupDate: row.followupDate.toISOString(),
customerName: customerMap.get(quotation.customerId) ?? 'Unknown customer',
opportunityName: quotation.projectName ?? quotation.code,
assignedSalesName: quotation.salesmanId
? (salesMap.get(quotation.salesmanId) ?? null)
: null,
outcome: row.outcome
}
];
});
const rows: CrmDashboardFollowupRow[] = [...enquiryRows, ...quotationRows];
return {
summary: {
dueToday: rows.filter((row) => isSameDay(new Date(row.followupDate), now)).length,
dueThisWeek: rows.filter((row) => {
const date = new Date(row.followupDate);
return !isBefore(date, weekStart) && !isAfter(date, weekEnd);
}).length,
overdue: rows.filter((row) => isBefore(new Date(row.followupDate), startOfDay(now)) && !row.outcome).length,
completed: rows.filter((row) => !!row.outcome).length
},
upcoming: rows
.filter((row) => !isBefore(new Date(row.followupDate), startOfDay(now)))
.sort((a, b) => new Date(a.followupDate).getTime() - new Date(b.followupDate).getTime())
.slice(0, 10)
};
}
async function buildApprovalAnalytics(
context: DashboardContext
): Promise<{ summary: CrmDashboardApprovalSummary; myPendingApprovals: CrmDashboardApprovalRow[] }> {
const pending = await listApprovalRequests(context.organizationId, {
page: 1,
limit: 50,
status: 'pending'
});
const todayStart = startOfDay(new Date());
const todayEnd = endOfDay(new Date());
const rows = await db
.select()
.from(crmApprovalRequests)
.where(and(eq(crmApprovalRequests.organizationId, context.organizationId), isNull(crmApprovalRequests.deletedAt)))
.orderBy(desc(crmApprovalRequests.updatedAt));
const approvedRows = rows.filter(
(row) => row.status === 'approved' && row.completedAt && !isBefore(row.completedAt, todayStart) && !isAfter(row.completedAt, todayEnd)
);
const rejectedRows = rows.filter(
(row) => row.status === 'rejected' && row.completedAt && !isBefore(row.completedAt, todayStart) && !isAfter(row.completedAt, todayEnd)
);
const returnedRows = rows.filter(
(row) => row.status === 'returned' && row.completedAt && !isBefore(row.completedAt, todayStart) && !isAfter(row.completedAt, todayEnd)
);
const approvedDurations = approvedRows.map((row) => (row.completedAt!.getTime() - row.requestedAt.getTime()) / (1000 * 60 * 60));
return {
summary: {
pendingApprovals: pending.totalItems,
approvedToday: approvedRows.length,
rejectedToday: rejectedRows.length,
returnedToday: returnedRows.length,
averageApprovalTimeHours: average(approvedDurations)
},
myPendingApprovals: pending.items
.filter((item) => item.currentStepRoleCode === context.businessRole || context.membershipRole === 'admin')
.slice(0, 10)
.map((item) => ({
id: item.id,
entityCode: item.entityCode,
entityTitle: item.entityTitle,
workflowName: item.workflowName,
currentStepRoleName: item.currentStepRoleName,
requestedAt: item.requestedAt
}))
};
}
async function buildHotProjects(
organizationId: string,
filters: NormalizedFilters,
statusMaps: StatusMaps
): Promise<CrmDashboardHotProjectRow[]> {
const rows = await db
.select()
.from(crmEnquiries)
.where(and(eq(crmEnquiries.organizationId, organizationId), isNull(crmEnquiries.deletedAt), eq(crmEnquiries.isHotProject, true)))
.orderBy(desc(crmEnquiries.updatedAt));
const scoped = rows
.filter((row) => matchesEnquiryFilters(row, filters))
.filter((row) => isOpportunityStatus(statusMaps.enquiryStatusById.get(row.status), row))
.slice(0, 10);
const customerIds = [...new Set(scoped.map((row) => row.customerId))];
const salesIds = [...new Set(scoped.map((row) => row.assignedToUserId).filter(Boolean))] as string[];
const [customers, salesUsers] = await Promise.all([
customerIds.length ? db.select().from(crmCustomers).where(inArray(crmCustomers.id, customerIds)) : [],
salesIds.length ? db.select({ id: users.id, name: users.name }).from(users).where(inArray(users.id, salesIds)) : []
]);
const customerMap = new Map(customers.map((row) => [row.id, row.name]));
const salesMap = new Map(salesUsers.map((row) => [row.id, row.name]));
return scoped.map((row) => ({
enquiryId: row.id,
enquiryCode: row.code,
projectName: row.projectName ?? row.title,
customerName: customerMap.get(row.customerId) ?? 'Unknown customer',
value: row.estimatedValue ?? 0,
assignedSalesName: row.assignedToUserId ? (salesMap.get(row.assignedToUserId) ?? null) : null,
probability: row.chancePercent
}));
}
export async function getCrmDashboardData(
context: DashboardContext,
rawFilters: CrmDashboardFilters
): Promise<CrmDashboardResponse> {
const filters = normalizeDashboardFilters(rawFilters);
const [referenceData, statusMaps, scoped] = await Promise.all([
getReferenceData(context.organizationId),
getStatusMaps(context.organizationId),
loadScopedRows(context.organizationId, filters)
]);
const summary = buildSummary(scoped.enquiries, scoped.quotations, statusMaps);
const [revenueAnalytics, salesRanking, followups, approvals, hotProjects] = await Promise.all([
buildRevenueAnalytics(context.organizationId, filters),
buildSalesRanking(scoped.enquiries, scoped.quotations, statusMaps),
buildFollowups(context.organizationId, filters),
buildApprovalAnalytics(context),
buildHotProjects(context.organizationId, filters, statusMaps)
]);
return {
success: true,
time: new Date().toISOString(),
message: 'CRM dashboard loaded successfully',
filters,
referenceData,
summary,
funnel: buildFunnel(summary, scoped.quotations, statusMaps),
revenueAnalytics,
salesRanking,
followups,
approvals,
hotProjects
};
}

View File

@@ -0,0 +1,341 @@
'use client';
import * as React from 'react';
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Icons } from '@/components/icons';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import {
createApprovalWorkflowMutation,
deleteApprovalWorkflowMutation,
replaceApprovalWorkflowStepsMutation,
updateApprovalWorkflowMutation
} from '../mutations';
import {
approvalWorkflowByIdOptions,
approvalWorkflowsQueryOptions
} from '../queries';
import type {
ApprovalStepMutationPayload,
ApprovalWorkflowDetail,
ApprovalWorkflowMutationPayload
} from '../types';
type WorkflowState = {
code: string;
name: string;
entityType: string;
isActive: boolean;
};
type StepState = {
stepNumber: string;
roleCode: string;
roleName: string;
isRequired: boolean;
};
function toWorkflowState(workflow?: ApprovalWorkflowDetail): WorkflowState {
return {
code: workflow?.code ?? '',
name: workflow?.name ?? '',
entityType: workflow?.entityType ?? 'quotation',
isActive: workflow?.isActive ?? true
};
}
function toStepState(workflow?: ApprovalWorkflowDetail): StepState[] {
return (
workflow?.steps.map((step) => ({
stepNumber: String(step.stepNumber),
roleCode: step.roleCode,
roleName: step.roleName,
isRequired: step.isRequired
})) ?? [{ stepNumber: '1', roleCode: 'sales_manager', roleName: 'Sales Manager', isRequired: true }]
);
}
function WorkflowDialog({
open,
onOpenChange,
workflow
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
workflow?: ApprovalWorkflowDetail;
}) {
const [state, setState] = React.useState<WorkflowState>(toWorkflowState(workflow));
const createMutation = useMutation({
...createApprovalWorkflowMutation,
onSuccess: () => {
toast.success('Workflow created');
onOpenChange(false);
},
onError: (error) => toast.error(error instanceof Error ? error.message : 'Create failed')
});
const updateMutation = useMutation({
...updateApprovalWorkflowMutation,
onSuccess: () => {
toast.success('Workflow updated');
onOpenChange(false);
},
onError: (error) => toast.error(error instanceof Error ? error.message : 'Update failed')
});
React.useEffect(() => {
if (open) {
setState(toWorkflowState(workflow));
}
}, [open, workflow]);
async function onSubmit(event: React.FormEvent) {
event.preventDefault();
const payload: ApprovalWorkflowMutationPayload = {
code: state.code,
name: state.name,
entityType: state.entityType,
isActive: state.isActive
};
if (workflow) {
await updateMutation.mutateAsync({ id: workflow.id, values: payload });
return;
}
await createMutation.mutateAsync(payload);
}
const isPending = createMutation.isPending || updateMutation.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{workflow ? 'Edit Workflow' : 'Create Workflow'}</DialogTitle>
<DialogDescription>Sequential workflow only. No parallel or conditional logic is introduced here.</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className='grid gap-4'>
<Input placeholder='Workflow code' value={state.code} onChange={(e) => setState((current) => ({ ...current, code: e.target.value }))} />
<Input placeholder='Workflow name' value={state.name} onChange={(e) => setState((current) => ({ ...current, name: e.target.value }))} />
<Input placeholder='Entity type' value={state.entityType} onChange={(e) => setState((current) => ({ ...current, entityType: e.target.value }))} />
<div className='flex items-center justify-between rounded-lg border px-4 py-3'>
<div>
<div className='font-medium'>Active</div>
<div className='text-muted-foreground text-sm'>Inactive workflows stay available in audit history only.</div>
</div>
<Switch checked={state.isActive} onCheckedChange={(checked) => setState((current) => ({ ...current, isActive: checked }))} />
</div>
<DialogFooter>
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type='submit' isLoading={isPending}>
{workflow ? 'Save Workflow' : 'Create Workflow'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function StepsDialog({
workflow,
open,
onOpenChange
}: {
workflow: ApprovalWorkflowDetail;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [steps, setSteps] = React.useState<StepState[]>(toStepState(workflow));
const replaceMutation = useMutation({
...replaceApprovalWorkflowStepsMutation,
onSuccess: () => {
toast.success('Workflow steps updated');
onOpenChange(false);
},
onError: (error) => toast.error(error instanceof Error ? error.message : 'Update failed')
});
React.useEffect(() => {
if (open) {
setSteps(toStepState(workflow));
}
}, [open, workflow]);
function updateStep(index: number, field: keyof StepState, value: string | boolean) {
setSteps((current) =>
current.map((step, stepIndex) =>
stepIndex === index ? { ...step, [field]: value } : step
)
);
}
async function onSubmit(event: React.FormEvent) {
event.preventDefault();
const payload: ApprovalStepMutationPayload[] = steps.map((step, index) => ({
stepNumber: Number(step.stepNumber || index + 1),
roleCode: step.roleCode,
roleName: step.roleName,
isRequired: step.isRequired
}));
await replaceMutation.mutateAsync({ workflowId: workflow.id, steps: payload });
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-4xl'>
<DialogHeader>
<DialogTitle>Manage Steps</DialogTitle>
<DialogDescription>Each step resolves to a CRM business role and runs strictly in order.</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className='space-y-4'>
{steps.map((step, index) => (
<div key={`${index}-${step.roleCode}`} className='grid gap-3 rounded-lg border p-4 md:grid-cols-4'>
<Input placeholder='Step no.' value={step.stepNumber} onChange={(e) => updateStep(index, 'stepNumber', e.target.value)} />
<Input placeholder='Role code' value={step.roleCode} onChange={(e) => updateStep(index, 'roleCode', e.target.value)} />
<Input placeholder='Role name' value={step.roleName} onChange={(e) => updateStep(index, 'roleName', e.target.value)} />
<div className='flex items-center justify-between rounded-lg border px-3 py-2'>
<span className='text-sm font-medium'>Required</span>
<Switch checked={step.isRequired} onCheckedChange={(checked) => updateStep(index, 'isRequired', checked)} />
</div>
</div>
))}
<Button
type='button'
variant='outline'
onClick={() =>
setSteps((current) => [
...current,
{
stepNumber: String(current.length + 1),
roleCode: '',
roleName: '',
isRequired: true
}
])
}
>
<Icons.add className='mr-2 h-4 w-4' />
Add Step
</Button>
<DialogFooter>
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type='submit' isLoading={replaceMutation.isPending}>
Save Steps
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function WorkflowDetailActions({ workflowId }: { workflowId: string }) {
const { data } = useSuspenseQuery(approvalWorkflowByIdOptions(workflowId));
const workflow = data.workflow;
const [workflowDialogOpen, setWorkflowDialogOpen] = React.useState(false);
const [stepsDialogOpen, setStepsDialogOpen] = React.useState(false);
const deleteMutation = useMutation({
...deleteApprovalWorkflowMutation,
onSuccess: () => toast.success('Workflow deleted'),
onError: (error) => toast.error(error instanceof Error ? error.message : 'Delete failed')
});
return (
<>
<div className='flex gap-2'>
<Button variant='outline' onClick={() => setWorkflowDialogOpen(true)}>
Edit Workflow
</Button>
<Button variant='outline' onClick={() => setStepsDialogOpen(true)}>
Manage Steps
</Button>
<Button variant='outline' onClick={() => deleteMutation.mutate(workflow.id)} isLoading={deleteMutation.isPending}>
Delete
</Button>
</div>
<div className='mt-3 grid gap-3 md:grid-cols-2 xl:grid-cols-4'>
{workflow.steps.map((step) => (
<div key={step.id} className='rounded-lg border p-4'>
<div className='font-medium'>Step {step.stepNumber}</div>
<div className='text-muted-foreground mt-1 text-sm'>{step.roleName}</div>
<div className='mt-2 flex gap-2'>
<Badge variant='outline'>{step.roleCode}</Badge>
<Badge variant={step.isRequired ? 'secondary' : 'outline'}>
{step.isRequired ? 'Required' : 'Optional'}
</Badge>
</div>
</div>
))}
</div>
<WorkflowDialog open={workflowDialogOpen} onOpenChange={setWorkflowDialogOpen} workflow={workflow} />
<StepsDialog open={stepsDialogOpen} onOpenChange={setStepsDialogOpen} workflow={workflow} />
</>
);
}
export function ApprovalWorkflowSettings() {
const { data } = useSuspenseQuery(approvalWorkflowsQueryOptions());
const [createOpen, setCreateOpen] = React.useState(false);
return (
<div className='space-y-6'>
<div className='flex items-center justify-between gap-4 rounded-lg border p-4'>
<div>
<div className='font-medium'>Approval Workflow Admin</div>
<div className='text-muted-foreground text-sm'>Configure sequential CRM approval workflows and business-role steps.</div>
</div>
<Button onClick={() => setCreateOpen(true)}>
<Icons.add className='mr-2 h-4 w-4' />
Create Workflow
</Button>
</div>
{data.items.length === 0 ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
No approval workflows configured.
</div>
) : (
data.items.map((workflow) => (
<div key={workflow.id} className='rounded-xl border p-5'>
<div className='flex flex-wrap items-start justify-between gap-4'>
<div>
<div className='text-xl font-semibold'>{workflow.name}</div>
<div className='text-muted-foreground text-sm'>
{workflow.code} {workflow.entityType}
</div>
</div>
<div className='flex gap-2'>
<Badge variant={workflow.isActive ? 'secondary' : 'outline'}>
{workflow.isActive ? 'Active' : 'Inactive'}
</Badge>
<Badge variant='outline'>{workflow.stepCount} steps</Badge>
</div>
</div>
<React.Suspense fallback={<div className='text-muted-foreground mt-4 text-sm'>Loading workflow detail...</div>}>
<WorkflowDetailActions workflowId={workflow.id} />
</React.Suspense>
</div>
))
)}
<WorkflowDialog open={createOpen} onOpenChange={setCreateOpen} />
</div>
);
}

View File

@@ -4,14 +4,23 @@ import { getQueryClient } from '@/lib/query-client';
import { import {
approveApproval, approveApproval,
cancelApproval, cancelApproval,
createApprovalWorkflow,
deleteApprovalWorkflow,
rejectApproval, rejectApproval,
replaceApprovalWorkflowSteps,
returnApproval, returnApproval,
submitApproval, submitApproval,
submitQuotationForApproval submitQuotationForApproval,
updateApprovalWorkflow
} from './service'; } from './service';
import { approvalKeys } from './queries'; import { approvalKeys, approvalWorkflowKeys } from './queries';
import { quotationKeys } from '@/features/crm/quotations/api/queries'; import { quotationKeys } from '@/features/crm/quotations/api/queries';
import type { ApprovalActionPayload, SubmitApprovalPayload } from './types'; import type {
ApprovalActionPayload,
ApprovalStepMutationPayload,
ApprovalWorkflowMutationPayload,
SubmitApprovalPayload
} from './types';
async function invalidateApprovalLists() { async function invalidateApprovalLists() {
await getQueryClient().invalidateQueries({ queryKey: approvalKeys.lists() }); await getQueryClient().invalidateQueries({ queryKey: approvalKeys.lists() });
@@ -21,6 +30,14 @@ async function invalidateApprovalDetail(id: string) {
await getQueryClient().invalidateQueries({ queryKey: approvalKeys.detail(id) }); await getQueryClient().invalidateQueries({ queryKey: approvalKeys.detail(id) });
} }
async function invalidateApprovalWorkflows() {
await getQueryClient().invalidateQueries({ queryKey: approvalWorkflowKeys.lists() });
}
async function invalidateApprovalWorkflowDetail(id: string) {
await getQueryClient().invalidateQueries({ queryKey: approvalWorkflowKeys.detail(id) });
}
async function invalidateQuotationQueries(quotationId: string) { async function invalidateQuotationQueries(quotationId: string) {
const queryClient = getQueryClient(); const queryClient = getQueryClient();
await Promise.all([ await Promise.all([
@@ -114,3 +131,59 @@ export const returnApprovalMutation = mutationOptions({
} }
} }
}); });
export const createApprovalWorkflowMutation = mutationOptions({
mutationFn: (data: ApprovalWorkflowMutationPayload) => createApprovalWorkflow(data),
onSettled: async (_data, error) => {
if (!error) {
await invalidateApprovalWorkflows();
}
}
});
export const updateApprovalWorkflowMutation = mutationOptions({
mutationFn: ({
id,
values
}: {
id: string;
values: Partial<ApprovalWorkflowMutationPayload>;
}) => updateApprovalWorkflow(id, values),
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
invalidateApprovalWorkflows(),
invalidateApprovalWorkflowDetail(variables.id)
]);
}
}
});
export const deleteApprovalWorkflowMutation = mutationOptions({
mutationFn: (id: string) => deleteApprovalWorkflow(id),
onSettled: async (_data, error, id) => {
if (!error) {
const queryClient = getQueryClient();
await invalidateApprovalWorkflows();
queryClient.removeQueries({ queryKey: approvalWorkflowKeys.detail(id) });
}
}
});
export const replaceApprovalWorkflowStepsMutation = mutationOptions({
mutationFn: ({
workflowId,
steps
}: {
workflowId: string;
steps: ApprovalStepMutationPayload[];
}) => replaceApprovalWorkflowSteps(workflowId, steps),
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
invalidateApprovalWorkflows(),
invalidateApprovalWorkflowDetail(variables.workflowId)
]);
}
}
});

View File

@@ -1,5 +1,10 @@
import { queryOptions } from '@tanstack/react-query'; import { queryOptions } from '@tanstack/react-query';
import { getApprovalById, getApprovals } from './service'; import {
getApprovalById,
getApprovalWorkflowById,
getApprovalWorkflows,
getApprovals
} from './service';
import type { ApprovalFilters } from './types'; import type { ApprovalFilters } from './types';
export const approvalKeys = { export const approvalKeys = {
@@ -10,6 +15,14 @@ export const approvalKeys = {
detail: (id: string) => [...approvalKeys.details(), id] as const detail: (id: string) => [...approvalKeys.details(), id] as const
}; };
export const approvalWorkflowKeys = {
all: ['crm-approval-workflows'] as const,
lists: () => [...approvalWorkflowKeys.all, 'list'] as const,
list: () => [...approvalWorkflowKeys.lists(), 'all'] as const,
details: () => [...approvalWorkflowKeys.all, 'detail'] as const,
detail: (id: string) => [...approvalWorkflowKeys.details(), id] as const
};
export const approvalsQueryOptions = (filters: ApprovalFilters) => export const approvalsQueryOptions = (filters: ApprovalFilters) =>
queryOptions({ queryOptions({
queryKey: approvalKeys.list(filters), queryKey: approvalKeys.list(filters),
@@ -21,3 +34,15 @@ export const approvalByIdOptions = (id: string) =>
queryKey: approvalKeys.detail(id), queryKey: approvalKeys.detail(id),
queryFn: () => getApprovalById(id) queryFn: () => getApprovalById(id)
}); });
export const approvalWorkflowsQueryOptions = () =>
queryOptions({
queryKey: approvalWorkflowKeys.list(),
queryFn: () => getApprovalWorkflows()
});
export const approvalWorkflowByIdOptions = (id: string) =>
queryOptions({
queryKey: approvalWorkflowKeys.detail(id),
queryFn: () => getApprovalWorkflowById(id)
});

View File

@@ -19,9 +19,13 @@ import type {
ApprovalDetailRecord, ApprovalDetailRecord,
ApprovalFilters, ApprovalFilters,
ApprovalListItem, ApprovalListItem,
ApprovalStepMutationPayload,
ApprovalRequestRecord, ApprovalRequestRecord,
ApprovalStepRecord, ApprovalStepRecord,
ApprovalTimelineItem, ApprovalTimelineItem,
ApprovalWorkflowDetail,
ApprovalWorkflowListItem,
ApprovalWorkflowMutationPayload,
ApprovalWorkflowRecord, ApprovalWorkflowRecord,
SubmitApprovalPayload SubmitApprovalPayload
} from '../types'; } from '../types';
@@ -170,6 +174,46 @@ async function assertWorkflowByCode(organizationId: string, code: string, entity
return workflow; return workflow;
} }
async function assertWorkflowById(id: string, organizationId: string) {
const [workflow] = await db
.select()
.from(crmApprovalWorkflows)
.where(
and(
eq(crmApprovalWorkflows.id, id),
eq(crmApprovalWorkflows.organizationId, organizationId),
isNull(crmApprovalWorkflows.deletedAt)
)
)
.limit(1);
if (!workflow) {
throw new AuthError('Approval workflow not found', 404);
}
return workflow;
}
async function assertWorkflowStep(id: string, organizationId: string) {
const [step] = await db
.select()
.from(crmApprovalSteps)
.where(
and(
eq(crmApprovalSteps.id, id),
eq(crmApprovalSteps.organizationId, organizationId),
isNull(crmApprovalSteps.deletedAt)
)
)
.limit(1);
if (!step) {
throw new AuthError('Approval workflow step not found', 404);
}
return step;
}
async function assertApprovalRequest(id: string, organizationId: string) { async function assertApprovalRequest(id: string, organizationId: string) {
const [request] = await db const [request] = await db
.select() .select()
@@ -209,6 +253,204 @@ async function listWorkflowSteps(
return rows.map(mapStepRecord); return rows.map(mapStepRecord);
} }
export async function listApprovalWorkflows(
organizationId: string
): Promise<ApprovalWorkflowListItem[]> {
const [workflows, steps] = await Promise.all([
db
.select()
.from(crmApprovalWorkflows)
.where(
and(
eq(crmApprovalWorkflows.organizationId, organizationId),
isNull(crmApprovalWorkflows.deletedAt)
)
)
.orderBy(asc(crmApprovalWorkflows.entityType), asc(crmApprovalWorkflows.code)),
db
.select()
.from(crmApprovalSteps)
.where(
and(eq(crmApprovalSteps.organizationId, organizationId), isNull(crmApprovalSteps.deletedAt))
)
]);
return workflows.map((workflow) => ({
...mapWorkflowRecord(workflow),
stepCount: steps.filter((step) => step.workflowId === workflow.id).length
}));
}
export async function getApprovalWorkflow(
id: string,
organizationId: string
): Promise<ApprovalWorkflowDetail> {
const workflow = await assertWorkflowById(id, organizationId);
const steps = await listWorkflowSteps(id, organizationId);
return {
...mapWorkflowRecord(workflow),
steps
};
}
export async function createApprovalWorkflow(
organizationId: string,
payload: ApprovalWorkflowMutationPayload
): Promise<ApprovalWorkflowRecord> {
const [created] = await db
.insert(crmApprovalWorkflows)
.values({
id: crypto.randomUUID(),
organizationId,
code: payload.code.trim(),
name: payload.name.trim(),
entityType: payload.entityType.trim(),
isActive: payload.isActive ?? true
})
.returning();
return mapWorkflowRecord(created);
}
export async function updateApprovalWorkflow(
id: string,
organizationId: string,
payload: Partial<ApprovalWorkflowMutationPayload>
): Promise<ApprovalWorkflowRecord> {
const current = await assertWorkflowById(id, organizationId);
const [updated] = await db
.update(crmApprovalWorkflows)
.set({
code: payload.code?.trim() ?? current.code,
name: payload.name?.trim() ?? current.name,
entityType: payload.entityType?.trim() ?? current.entityType,
isActive: payload.isActive ?? current.isActive,
updatedAt: new Date()
})
.where(eq(crmApprovalWorkflows.id, id))
.returning();
return mapWorkflowRecord(updated);
}
export async function softDeleteApprovalWorkflow(id: string, organizationId: string) {
const current = await assertWorkflowById(id, organizationId);
const [updated] = await db
.update(crmApprovalWorkflows)
.set({
deletedAt: new Date(),
updatedAt: new Date()
})
.where(eq(crmApprovalWorkflows.id, id))
.returning();
return {
before: current,
after: updated
};
}
export async function createApprovalStep(
workflowId: string,
organizationId: string,
payload: ApprovalStepMutationPayload
): Promise<ApprovalStepRecord> {
await assertWorkflowById(workflowId, organizationId);
const [created] = await db
.insert(crmApprovalSteps)
.values({
id: crypto.randomUUID(),
organizationId,
workflowId,
stepNumber: payload.stepNumber,
roleCode: payload.roleCode.trim(),
roleName: payload.roleName.trim(),
isRequired: payload.isRequired ?? true
})
.returning();
return mapStepRecord(created);
}
export async function updateApprovalStep(
id: string,
organizationId: string,
payload: Partial<ApprovalStepMutationPayload>
): Promise<ApprovalStepRecord> {
const current = await assertWorkflowStep(id, organizationId);
const [updated] = await db
.update(crmApprovalSteps)
.set({
stepNumber: payload.stepNumber ?? current.stepNumber,
roleCode: payload.roleCode?.trim() ?? current.roleCode,
roleName: payload.roleName?.trim() ?? current.roleName,
isRequired: payload.isRequired ?? current.isRequired,
updatedAt: new Date()
})
.where(eq(crmApprovalSteps.id, id))
.returning();
return mapStepRecord(updated);
}
export async function deleteApprovalStep(id: string, organizationId: string) {
const current = await assertWorkflowStep(id, organizationId);
const [updated] = await db
.update(crmApprovalSteps)
.set({
deletedAt: new Date(),
updatedAt: new Date()
})
.where(eq(crmApprovalSteps.id, id))
.returning();
return {
before: current,
after: updated
};
}
export async function replaceApprovalWorkflowSteps(
workflowId: string,
organizationId: string,
steps: ApprovalStepMutationPayload[]
): Promise<ApprovalStepRecord[]> {
await assertWorkflowById(workflowId, organizationId);
const existingSteps = await listWorkflowSteps(workflowId, organizationId);
if (existingSteps.length) {
await db
.update(crmApprovalSteps)
.set({
deletedAt: new Date(),
updatedAt: new Date()
})
.where(eq(crmApprovalSteps.workflowId, workflowId));
}
if (!steps.length) {
return [];
}
const created = await db
.insert(crmApprovalSteps)
.values(
steps.map((step, index) => ({
id: crypto.randomUUID(),
organizationId,
workflowId,
stepNumber: step.stepNumber ?? index + 1,
roleCode: step.roleCode.trim(),
roleName: step.roleName.trim(),
isRequired: step.isRequired ?? true
}))
)
.returning();
return created.map(mapStepRecord);
}
async function assertActivePendingRequestForEntity( async function assertActivePendingRequestForEntity(
organizationId: string, organizationId: string,
entityType: string, entityType: string,

View File

@@ -4,6 +4,10 @@ import type {
ApprovalDetailResponse, ApprovalDetailResponse,
ApprovalFilters, ApprovalFilters,
ApprovalListResponse, ApprovalListResponse,
ApprovalStepMutationPayload,
ApprovalWorkflowDetailResponse,
ApprovalWorkflowListResponse,
ApprovalWorkflowMutationPayload,
MutationSuccessResponse, MutationSuccessResponse,
SubmitApprovalPayload SubmitApprovalPayload
} from './types'; } from './types';
@@ -67,3 +71,46 @@ export async function submitQuotationForApproval(id: string, remark?: string) {
body: JSON.stringify({ remark }) body: JSON.stringify({ remark })
}); });
} }
const SETTINGS_BASE = '/crm/settings/approval-workflows';
export async function getApprovalWorkflows() {
return apiClient<ApprovalWorkflowListResponse>(SETTINGS_BASE);
}
export async function getApprovalWorkflowById(id: string) {
return apiClient<ApprovalWorkflowDetailResponse>(`${SETTINGS_BASE}/${id}`);
}
export async function createApprovalWorkflow(data: ApprovalWorkflowMutationPayload) {
return apiClient<MutationSuccessResponse>(SETTINGS_BASE, {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function updateApprovalWorkflow(
id: string,
data: Partial<ApprovalWorkflowMutationPayload>
) {
return apiClient<MutationSuccessResponse>(`${SETTINGS_BASE}/${id}`, {
method: 'PATCH',
body: JSON.stringify(data)
});
}
export async function deleteApprovalWorkflow(id: string) {
return apiClient<MutationSuccessResponse>(`${SETTINGS_BASE}/${id}`, {
method: 'DELETE'
});
}
export async function replaceApprovalWorkflowSteps(
workflowId: string,
data: ApprovalStepMutationPayload[]
) {
return apiClient<MutationSuccessResponse>(`${SETTINGS_BASE}/${workflowId}/steps`, {
method: 'PUT',
body: JSON.stringify({ steps: data })
});
}

View File

@@ -23,6 +23,14 @@ export interface ApprovalStepRecord {
deletedAt: string | null; deletedAt: string | null;
} }
export interface ApprovalWorkflowListItem extends ApprovalWorkflowRecord {
stepCount: number;
}
export interface ApprovalWorkflowDetail extends ApprovalWorkflowRecord {
steps: ApprovalStepRecord[];
}
export interface ApprovalRequestRecord { export interface ApprovalRequestRecord {
id: string; id: string;
organizationId: string; organizationId: string;
@@ -98,6 +106,20 @@ export interface ApprovalActionPayload {
remark?: string; remark?: string;
} }
export interface ApprovalWorkflowMutationPayload {
code: string;
name: string;
entityType: string;
isActive?: boolean;
}
export interface ApprovalStepMutationPayload {
stepNumber: number;
roleCode: string;
roleName: string;
isRequired?: boolean;
}
export interface ApprovalListResponse { export interface ApprovalListResponse {
success: boolean; success: boolean;
time: string; time: string;
@@ -115,6 +137,20 @@ export interface ApprovalDetailResponse {
approval: ApprovalDetailRecord; approval: ApprovalDetailRecord;
} }
export interface ApprovalWorkflowListResponse {
success: boolean;
time: string;
message: string;
items: ApprovalWorkflowListItem[];
}
export interface ApprovalWorkflowDetailResponse {
success: boolean;
time: string;
message: string;
workflow: ApprovalWorkflowDetail;
}
export interface MutationSuccessResponse { export interface MutationSuccessResponse {
success: boolean; success: boolean;
message: string; message: string;

View File

@@ -0,0 +1,53 @@
import { apiClient } from '@/lib/api-client';
import type {
DocumentSequenceDetailResponse,
DocumentSequenceListResponse,
DocumentSequenceMutationPayload,
DocumentSequencePreviewResponse,
DocumentSequenceResetPayload,
MutationSuccessResponse
} from './types';
const BASE_PATH = '/crm/settings/document-sequences';
export async function getDocumentSequences() {
return apiClient<DocumentSequenceListResponse>(BASE_PATH);
}
export async function getDocumentSequenceById(id: string) {
return apiClient<DocumentSequenceDetailResponse>(`${BASE_PATH}/${id}`);
}
export async function createDocumentSequence(data: DocumentSequenceMutationPayload) {
return apiClient<MutationSuccessResponse>(BASE_PATH, {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function updateDocumentSequence(
id: string,
data: Partial<DocumentSequenceMutationPayload>
) {
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/${id}`, {
method: 'PATCH',
body: JSON.stringify(data)
});
}
export async function deleteDocumentSequence(id: string) {
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/${id}`, {
method: 'DELETE'
});
}
export async function previewDocumentSequence(id: string) {
return apiClient<DocumentSequencePreviewResponse>(`${BASE_PATH}/${id}/preview`);
}
export async function resetDocumentSequence(id: string, data: DocumentSequenceResetPayload) {
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/${id}/reset`, {
method: 'POST',
body: JSON.stringify(data)
});
}

View File

@@ -0,0 +1,247 @@
'use client';
import * as React from 'react';
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Icons } from '@/components/icons';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import {
createDocumentSequenceMutation,
deleteDocumentSequenceMutation,
resetDocumentSequenceMutation,
updateDocumentSequenceMutation
} from '../mutations';
import { documentSequencesQueryOptions } from '../queries';
import type { DocumentSequenceListItem, DocumentSequenceMutationPayload } from '../types';
type SequenceState = {
documentType: string;
prefix: string;
period: string;
branchId: string;
currentNumber: string;
paddingLength: string;
isActive: boolean;
};
function toState(sequence?: DocumentSequenceListItem): SequenceState {
return {
documentType: sequence?.documentType ?? 'quotation',
prefix: sequence?.prefix ?? 'QT',
period: sequence?.period ?? '',
branchId: sequence?.branchId ?? '',
currentNumber: String(sequence?.currentNumber ?? 0),
paddingLength: String(sequence?.paddingLength ?? 3),
isActive: sequence?.isActive ?? true
};
}
function buildPayload(state: SequenceState): DocumentSequenceMutationPayload {
return {
documentType: state.documentType,
prefix: state.prefix,
period: state.period,
branchId: state.branchId || null,
currentNumber: Number(state.currentNumber || 0),
paddingLength: Number(state.paddingLength || 3),
isActive: state.isActive
};
}
function SequenceDialog({
open,
onOpenChange,
sequence
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
sequence?: DocumentSequenceListItem;
}) {
const [state, setState] = React.useState<SequenceState>(toState(sequence));
const createMutation = useMutation({
...createDocumentSequenceMutation,
onSuccess: () => {
toast.success('Sequence created');
onOpenChange(false);
},
onError: (error) => toast.error(error instanceof Error ? error.message : 'Create failed')
});
const updateMutation = useMutation({
...updateDocumentSequenceMutation,
onSuccess: () => {
toast.success('Sequence updated');
onOpenChange(false);
},
onError: (error) => toast.error(error instanceof Error ? error.message : 'Update failed')
});
React.useEffect(() => {
if (open) {
setState(toState(sequence));
}
}, [open, sequence]);
async function onSubmit(event: React.FormEvent) {
event.preventDefault();
const payload = buildPayload(state);
if (sequence) {
await updateMutation.mutateAsync({ id: sequence.id, values: payload });
return;
}
await createMutation.mutateAsync(payload);
}
const isPending = createMutation.isPending || updateMutation.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{sequence ? 'Edit Sequence' : 'Create Sequence'}</DialogTitle>
<DialogDescription>Preview never increments the counter. Generation remains server-side only.</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className='grid gap-4'>
<Input placeholder='Document type' value={state.documentType} onChange={(e) => setState((current) => ({ ...current, documentType: e.target.value }))} />
<div className='grid gap-4 md:grid-cols-2'>
<Input placeholder='Prefix' value={state.prefix} onChange={(e) => setState((current) => ({ ...current, prefix: e.target.value }))} />
<Input placeholder='Period' value={state.period} onChange={(e) => setState((current) => ({ ...current, period: e.target.value }))} />
</div>
<div className='grid gap-4 md:grid-cols-3'>
<Input placeholder='Branch ID (optional)' value={state.branchId} onChange={(e) => setState((current) => ({ ...current, branchId: e.target.value }))} />
<Input placeholder='Current number' value={state.currentNumber} onChange={(e) => setState((current) => ({ ...current, currentNumber: e.target.value }))} />
<Input placeholder='Padding length' value={state.paddingLength} onChange={(e) => setState((current) => ({ ...current, paddingLength: e.target.value }))} />
</div>
<div className='flex items-center justify-between rounded-lg border px-4 py-3'>
<div>
<div className='font-medium'>Active</div>
<div className='text-muted-foreground text-sm'>Inactive sequences can stay preserved for legacy numbering.</div>
</div>
<Switch checked={state.isActive} onCheckedChange={(checked) => setState((current) => ({ ...current, isActive: checked }))} />
</div>
<DialogFooter>
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type='submit' isLoading={isPending}>
{sequence ? 'Save Sequence' : 'Create Sequence'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export function DocumentSequenceSettings() {
const { data } = useSuspenseQuery(documentSequencesQueryOptions());
const [dialogState, setDialogState] = React.useState<{ open: boolean; sequence?: DocumentSequenceListItem }>({
open: false
});
const resetMutation = useMutation({
...resetDocumentSequenceMutation,
onSuccess: () => toast.success('Sequence reset'),
onError: (error) => toast.error(error instanceof Error ? error.message : 'Reset failed')
});
const deleteMutation = useMutation({
...deleteDocumentSequenceMutation,
onSuccess: () => toast.success('Sequence deleted'),
onError: (error) => toast.error(error instanceof Error ? error.message : 'Delete failed')
});
return (
<div className='space-y-6'>
<div className='flex items-center justify-between gap-4 rounded-lg border p-4'>
<div>
<div className='font-medium'>Document Sequence Admin</div>
<div className='text-muted-foreground text-sm'>Maintain organization-scoped numbering strategy without rewriting historic document codes.</div>
</div>
<Button onClick={() => setDialogState({ open: true })}>
<Icons.add className='mr-2 h-4 w-4' />
Create Sequence
</Button>
</div>
{data.items.length === 0 ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
No document sequences configured yet.
</div>
) : (
data.items.map((sequence) => (
<div key={sequence.id} className='rounded-xl border p-5'>
<div className='flex flex-wrap items-start justify-between gap-4'>
<div>
<div className='text-xl font-semibold'>{sequence.documentType}</div>
<div className='text-muted-foreground text-sm'>
{sequence.prefix}{sequence.period} branch {sequence.branchId || 'default'}
</div>
</div>
<div className='flex gap-2'>
<Badge variant={sequence.isActive ? 'secondary' : 'outline'}>
{sequence.isActive ? 'Active' : 'Inactive'}
</Badge>
<Badge variant='outline'>Next {sequence.nextPreview}</Badge>
</div>
</div>
<div className='mt-4 grid gap-4 md:grid-cols-4'>
<div className='rounded-lg bg-muted/40 p-4'>
<div className='text-muted-foreground text-xs'>Current Number</div>
<div className='mt-1 text-sm font-medium'>{sequence.currentNumber}</div>
</div>
<div className='rounded-lg bg-muted/40 p-4'>
<div className='text-muted-foreground text-xs'>Padding Length</div>
<div className='mt-1 text-sm font-medium'>{sequence.paddingLength}</div>
</div>
<div className='rounded-lg bg-muted/40 p-4'>
<div className='text-muted-foreground text-xs'>Updated At</div>
<div className='mt-1 text-sm font-medium'>{new Date(sequence.updatedAt).toLocaleString()}</div>
</div>
<div className='rounded-lg bg-muted/40 p-4'>
<div className='text-muted-foreground text-xs'>Preview</div>
<div className='mt-1 text-sm font-medium'>{sequence.nextPreview}</div>
</div>
</div>
<div className='mt-4 flex flex-wrap gap-2'>
<Button variant='outline' onClick={() => setDialogState({ open: true, sequence })}>
Edit Sequence
</Button>
<Button
variant='outline'
onClick={() =>
resetMutation.mutate({
id: sequence.id,
values: { currentNumber: 0 }
})
}
isLoading={resetMutation.isPending}
>
Reset Counter
</Button>
<Button variant='outline' onClick={() => deleteMutation.mutate(sequence.id)} isLoading={deleteMutation.isPending}>
Delete
</Button>
</div>
</div>
))
)}
<SequenceDialog
open={dialogState.open}
onOpenChange={(open) => setDialogState((current) => ({ ...current, open }))}
sequence={dialogState.sequence}
/>
</div>
);
}

View File

@@ -0,0 +1,68 @@
import { mutationOptions } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
import {
createDocumentSequence,
deleteDocumentSequence,
resetDocumentSequence,
updateDocumentSequence
} from './client-service';
import { documentSequenceKeys } from './queries';
import type { DocumentSequenceMutationPayload, DocumentSequenceResetPayload } from './types';
async function invalidateDocumentSequences() {
await getQueryClient().invalidateQueries({ queryKey: documentSequenceKeys.lists() });
}
async function invalidateDocumentSequenceDetail(id: string) {
await Promise.all([
getQueryClient().invalidateQueries({ queryKey: documentSequenceKeys.detail(id) }),
getQueryClient().invalidateQueries({ queryKey: documentSequenceKeys.preview(id) })
]);
}
export const createDocumentSequenceMutation = mutationOptions({
mutationFn: (data: DocumentSequenceMutationPayload) => createDocumentSequence(data),
onSettled: async (_data, error) => {
if (!error) {
await invalidateDocumentSequences();
}
}
});
export const updateDocumentSequenceMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: Partial<DocumentSequenceMutationPayload> }) =>
updateDocumentSequence(id, values),
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
invalidateDocumentSequences(),
invalidateDocumentSequenceDetail(variables.id)
]);
}
}
});
export const deleteDocumentSequenceMutation = mutationOptions({
mutationFn: (id: string) => deleteDocumentSequence(id),
onSettled: async (_data, error, id) => {
if (!error) {
const queryClient = getQueryClient();
await invalidateDocumentSequences();
queryClient.removeQueries({ queryKey: documentSequenceKeys.detail(id) });
queryClient.removeQueries({ queryKey: documentSequenceKeys.preview(id) });
}
}
});
export const resetDocumentSequenceMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: DocumentSequenceResetPayload }) =>
resetDocumentSequence(id, values),
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
invalidateDocumentSequences(),
invalidateDocumentSequenceDetail(variables.id)
]);
}
}
});

View File

@@ -0,0 +1,34 @@
import { queryOptions } from '@tanstack/react-query';
import {
getDocumentSequenceById,
getDocumentSequences,
previewDocumentSequence
} from './client-service';
export const documentSequenceKeys = {
all: ['crm-document-sequences'] as const,
lists: () => [...documentSequenceKeys.all, 'list'] as const,
list: () => [...documentSequenceKeys.lists(), 'all'] as const,
details: () => [...documentSequenceKeys.all, 'detail'] as const,
detail: (id: string) => [...documentSequenceKeys.details(), id] as const,
previewRoot: () => [...documentSequenceKeys.all, 'preview'] as const,
preview: (id: string) => [...documentSequenceKeys.previewRoot(), id] as const
};
export const documentSequencesQueryOptions = () =>
queryOptions({
queryKey: documentSequenceKeys.list(),
queryFn: () => getDocumentSequences()
});
export const documentSequenceByIdOptions = (id: string) =>
queryOptions({
queryKey: documentSequenceKeys.detail(id),
queryFn: () => getDocumentSequenceById(id)
});
export const documentSequencePreviewOptions = (id: string) =>
queryOptions({
queryKey: documentSequenceKeys.preview(id),
queryFn: () => previewDocumentSequence(id)
});

View File

@@ -1,8 +1,16 @@
import { and, eq, sql } from 'drizzle-orm'; import { and, asc, eq, sql } from 'drizzle-orm';
import { documentSequences } from '@/db/schema'; import { documentSequences } from '@/db/schema';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
import { getCurrentOrganization } from '@/features/foundation/organization-context/service'; import { getCurrentOrganization } from '@/features/foundation/organization-context/service';
import type { DocumentSequenceInput, DocumentSequenceResult } from './types'; import type {
DocumentSequenceInput,
DocumentSequenceListItem,
DocumentSequenceMutationPayload,
DocumentSequenceRecord,
DocumentSequenceResetPayload,
DocumentSequenceResult
} from './types';
const DEFAULT_DOCUMENT_PREFIXES: Record<string, string> = { const DEFAULT_DOCUMENT_PREFIXES: Record<string, string> = {
customer: 'CUS', customer: 'CUS',
@@ -28,6 +36,38 @@ function buildDocumentCode(
return `${prefix}${period}-${String(nextNumber).padStart(paddingLength, '0')}`; return `${prefix}${period}-${String(nextNumber).padStart(paddingLength, '0')}`;
} }
function normalizeBranchId(branchId?: string | null) {
return branchId?.trim() || '';
}
function mapSequenceRecord(row: typeof documentSequences.$inferSelect): DocumentSequenceRecord {
return {
id: row.id,
organizationId: row.organizationId,
branchId: row.branchId || null,
documentType: row.documentType,
prefix: row.prefix,
period: row.period,
currentNumber: row.currentNumber,
paddingLength: row.paddingLength,
isActive: row.isActive,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString()
};
}
function mapSequenceListItem(row: typeof documentSequences.$inferSelect): DocumentSequenceListItem {
return {
...mapSequenceRecord(row),
nextPreview: buildDocumentCode(
row.prefix,
row.period,
row.currentNumber + 1,
row.paddingLength
)
};
}
async function resolveOrganizationId(organizationId?: string) { async function resolveOrganizationId(organizationId?: string) {
if (organizationId) { if (organizationId) {
return organizationId; return organizationId;
@@ -42,6 +82,22 @@ async function resolveOrganizationId(organizationId?: string) {
return organization.id; return organization.id;
} }
async function assertSequence(id: string, organizationId: string) {
const [sequence] = await db
.select()
.from(documentSequences)
.where(
and(eq(documentSequences.id, id), eq(documentSequences.organizationId, organizationId))
)
.limit(1);
if (!sequence) {
throw new AuthError('Document sequence not found', 404);
}
return sequence;
}
async function ensureSequence( async function ensureSequence(
organizationId: string, organizationId: string,
documentType: string, documentType: string,
@@ -95,12 +151,116 @@ function toSequenceResult(row: typeof documentSequences.$inferSelect): DocumentS
}; };
} }
export async function listDocumentSequences(organizationId: string): Promise<DocumentSequenceListItem[]> {
const rows = await db
.select()
.from(documentSequences)
.where(eq(documentSequences.organizationId, organizationId))
.orderBy(
asc(documentSequences.documentType),
asc(documentSequences.period),
asc(documentSequences.branchId)
);
return rows.map(mapSequenceListItem);
}
export async function getDocumentSequence(id: string, organizationId: string): Promise<DocumentSequenceRecord> {
const row = await assertSequence(id, organizationId);
return mapSequenceRecord(row);
}
export async function createDocumentSequence(
organizationId: string,
payload: DocumentSequenceMutationPayload
): Promise<DocumentSequenceRecord> {
const branchId = normalizeBranchId(payload.branchId);
const [created] = await db
.insert(documentSequences)
.values({
id: crypto.randomUUID(),
organizationId,
branchId,
documentType: payload.documentType.trim(),
prefix: payload.prefix.trim(),
period: payload.period.trim(),
currentNumber: payload.currentNumber ?? 0,
paddingLength: payload.paddingLength,
isActive: payload.isActive ?? true,
updatedAt: new Date()
})
.returning();
return mapSequenceRecord(created);
}
export async function updateDocumentSequence(
id: string,
organizationId: string,
payload: Partial<DocumentSequenceMutationPayload>
): Promise<DocumentSequenceRecord> {
const current = await assertSequence(id, organizationId);
const [updated] = await db
.update(documentSequences)
.set({
documentType: payload.documentType?.trim() ?? current.documentType,
prefix: payload.prefix?.trim() ?? current.prefix,
period: payload.period?.trim() ?? current.period,
branchId:
payload.branchId === undefined ? current.branchId : normalizeBranchId(payload.branchId),
currentNumber: payload.currentNumber ?? current.currentNumber,
paddingLength: payload.paddingLength ?? current.paddingLength,
isActive: payload.isActive ?? current.isActive,
updatedAt: new Date()
})
.where(eq(documentSequences.id, id))
.returning();
return mapSequenceRecord(updated);
}
export async function resetDocumentSequence(
id: string,
organizationId: string,
payload: DocumentSequenceResetPayload
): Promise<DocumentSequenceRecord> {
await assertSequence(id, organizationId);
const [updated] = await db
.update(documentSequences)
.set({
currentNumber: payload.currentNumber,
updatedAt: new Date()
})
.where(eq(documentSequences.id, id))
.returning();
return mapSequenceRecord(updated);
}
export async function deleteDocumentSequence(id: string, organizationId: string): Promise<DocumentSequenceRecord> {
await assertSequence(id, organizationId);
const [deleted] = await db
.delete(documentSequences)
.where(eq(documentSequences.id, id))
.returning();
return mapSequenceRecord(deleted);
}
export async function previewDocumentSequenceById(
id: string,
organizationId: string
): Promise<DocumentSequenceResult> {
const sequence = await assertSequence(id, organizationId);
return toSequenceResult(sequence);
}
export async function previewNextDocumentCode( export async function previewNextDocumentCode(
input: DocumentSequenceInput input: DocumentSequenceInput
): Promise<DocumentSequenceResult> { ): Promise<DocumentSequenceResult> {
const organizationId = await resolveOrganizationId(input.organizationId); const organizationId = await resolveOrganizationId(input.organizationId);
const period = input.period ?? getCurrentPeriod(); const period = input.period ?? getCurrentPeriod();
const branchId = input.branchId ?? ''; const branchId = normalizeBranchId(input.branchId);
const sequence = await ensureSequence(organizationId, input.documentType, period, branchId); const sequence = await ensureSequence(organizationId, input.documentType, period, branchId);
return toSequenceResult(sequence); return toSequenceResult(sequence);
@@ -111,7 +271,7 @@ export async function generateNextDocumentCode(
): Promise<DocumentSequenceResult> { ): Promise<DocumentSequenceResult> {
const organizationId = await resolveOrganizationId(input.organizationId); const organizationId = await resolveOrganizationId(input.organizationId);
const period = input.period ?? getCurrentPeriod(); const period = input.period ?? getCurrentPeriod();
const branchId = input.branchId ?? ''; const branchId = normalizeBranchId(input.branchId);
await ensureSequence(organizationId, input.documentType, period, branchId); await ensureSequence(organizationId, input.documentType, period, branchId);

View File

@@ -5,6 +5,20 @@ export interface DocumentSequenceInput {
period?: string; period?: string;
} }
export interface DocumentSequenceRecord {
id: string;
organizationId: string;
branchId: string | null;
documentType: string;
prefix: string;
period: string;
currentNumber: number;
paddingLength: number;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface DocumentSequenceResult { export interface DocumentSequenceResult {
code: string; code: string;
documentType: string; documentType: string;
@@ -14,3 +28,47 @@ export interface DocumentSequenceResult {
period: string; period: string;
prefix: string; prefix: string;
} }
export interface DocumentSequenceListItem extends DocumentSequenceRecord {
nextPreview: string;
}
export interface DocumentSequenceListResponse {
success: boolean;
time: string;
message: string;
items: DocumentSequenceListItem[];
}
export interface DocumentSequenceDetailResponse {
success: boolean;
time: string;
message: string;
sequence: DocumentSequenceRecord;
}
export interface DocumentSequencePreviewResponse {
success: boolean;
time: string;
message: string;
preview: DocumentSequenceResult;
}
export interface DocumentSequenceMutationPayload {
documentType: string;
prefix: string;
period: string;
branchId?: string | null;
currentNumber?: number;
paddingLength: number;
isActive?: boolean;
}
export interface DocumentSequenceResetPayload {
currentNumber: number;
}
export interface MutationSuccessResponse {
success: boolean;
message: string;
}

View File

@@ -1,28 +1,733 @@
'use client'; 'use client';
import { useSuspenseQuery } from '@tanstack/react-query'; import * as React from 'react';
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Icons } from '@/components/icons';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button';
import { documentTemplatesQueryOptions } from '../queries'; import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import {
createDocumentTemplateMappingMutation,
createDocumentTemplateMutation,
createDocumentTemplateVersionMutation,
deleteDocumentTemplateMappingMutation,
updateDocumentTemplateMappingMutation,
updateDocumentTemplateMutation,
updateDocumentTemplateVersionMutation
} from '../mutations';
import { documentTemplateByIdOptions, documentTemplatesQueryOptions } from '../queries';
import type {
DocumentTemplateDetail,
DocumentTemplateMappingMutationPayload,
DocumentTemplateMutationPayload,
DocumentTemplateVersionDetail,
DocumentTemplateVersionMutationPayload
} from '../types';
type TemplateFormState = {
documentType: string;
productType: string;
fileType: string;
templateName: string;
description: string;
isDefault: boolean;
isActive: boolean;
};
type VersionFormState = {
version: string;
filePath: string;
schemaJson: string;
previewImageUrl: string;
isActive: boolean;
};
type MappingColumnState = {
columnName: string;
sourceField: string;
columnLetter: string;
sortOrder: string;
formatMask: string;
};
type MappingFormState = {
placeholderKey: string;
sourcePath: string;
dataType: 'scalar' | 'multiline' | 'table' | 'image';
sheetName: string;
defaultValue: string;
formatMask: string;
sortOrder: string;
columns: MappingColumnState[];
};
function toTemplateState(template?: DocumentTemplateDetail): TemplateFormState {
return {
documentType: template?.documentType ?? 'quotation',
productType: template?.productType ?? 'default',
fileType: template?.fileType ?? 'pdfme',
templateName: template?.templateName ?? '',
description: template?.description ?? '',
isDefault: template?.isDefault ?? false,
isActive: template?.isActive ?? true
};
}
function toVersionState(version?: DocumentTemplateVersionDetail): VersionFormState {
return {
version: version?.version ?? '',
filePath: version?.filePath ?? '',
schemaJson: version ? JSON.stringify(version.schemaJson, null, 2) : '{}',
previewImageUrl: version?.previewImageUrl ?? '',
isActive: version?.isActive ?? true
};
}
function toMappingState(mapping?: DocumentTemplateVersionDetail['mappings'][number]): MappingFormState {
return {
placeholderKey: mapping?.placeholderKey ?? '',
sourcePath: mapping?.sourcePath ?? '',
dataType: mapping?.dataType ?? 'scalar',
sheetName: mapping?.sheetName ?? '',
defaultValue: mapping?.defaultValue ?? '',
formatMask: mapping?.formatMask ?? '',
sortOrder: String(mapping?.sortOrder ?? 0),
columns:
mapping?.columns.map((column) => ({
columnName: column.columnName,
sourceField: column.sourceField,
columnLetter: column.columnLetter ?? '',
sortOrder: String(column.sortOrder),
formatMask: column.formatMask ?? ''
})) ?? []
};
}
function buildTemplatePayload(state: TemplateFormState): DocumentTemplateMutationPayload {
return {
documentType: state.documentType,
productType: state.productType,
fileType: state.fileType,
templateName: state.templateName,
description: state.description || null,
isDefault: state.isDefault,
isActive: state.isActive
};
}
function buildVersionPayload(state: VersionFormState): DocumentTemplateVersionMutationPayload {
let schemaJson: unknown;
try {
schemaJson = JSON.parse(state.schemaJson);
} catch {
throw new Error('Schema JSON must be valid JSON');
}
return {
version: state.version,
filePath: state.filePath || null,
schemaJson,
previewImageUrl: state.previewImageUrl || null,
isActive: state.isActive
};
}
function buildMappingPayload(state: MappingFormState): DocumentTemplateMappingMutationPayload {
return {
placeholderKey: state.placeholderKey,
sourcePath: state.sourcePath,
dataType: state.dataType,
sheetName: state.sheetName || null,
defaultValue: state.defaultValue || null,
formatMask: state.formatMask || null,
sortOrder: Number(state.sortOrder || 0),
columns:
state.dataType === 'table'
? state.columns.map((column) => ({
columnName: column.columnName,
sourceField: column.sourceField,
columnLetter: column.columnLetter || null,
sortOrder: Number(column.sortOrder || 0),
formatMask: column.formatMask || null
}))
: []
};
}
function Field({
label,
children
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className='space-y-2'>
<div className='text-sm font-medium'>{label}</div>
{children}
</div>
);
}
function TemplateDialog({
open,
onOpenChange,
template
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
template?: DocumentTemplateDetail;
}) {
const [state, setState] = React.useState<TemplateFormState>(toTemplateState(template));
const createMutation = useMutation({
...createDocumentTemplateMutation,
onSuccess: () => {
toast.success('Template created');
onOpenChange(false);
},
onError: (error) => toast.error(error instanceof Error ? error.message : 'Create failed')
});
const updateMutation = useMutation({
...updateDocumentTemplateMutation,
onSuccess: () => {
toast.success('Template updated');
onOpenChange(false);
},
onError: (error) => toast.error(error instanceof Error ? error.message : 'Update failed')
});
React.useEffect(() => {
if (open) {
setState(toTemplateState(template));
}
}, [open, template]);
async function onSubmit(event: React.FormEvent) {
event.preventDefault();
const payload = buildTemplatePayload(state);
if (template) {
await updateMutation.mutateAsync({ id: template.id, values: payload });
return;
}
await createMutation.mutateAsync(payload);
}
const isPending = createMutation.isPending || updateMutation.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-2xl'>
<DialogHeader>
<DialogTitle>{template ? 'Edit Template' : 'Create Template'}</DialogTitle>
<DialogDescription>Manage template metadata for CRM document generation.</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className='grid gap-4'>
<div className='grid gap-4 md:grid-cols-2'>
<Field label='Template Name'>
<Input value={state.templateName} onChange={(e) => setState((current) => ({ ...current, templateName: e.target.value }))} />
</Field>
<Field label='Document Type'>
<Input value={state.documentType} onChange={(e) => setState((current) => ({ ...current, documentType: e.target.value }))} />
</Field>
<Field label='Product Type'>
<Input value={state.productType} onChange={(e) => setState((current) => ({ ...current, productType: e.target.value }))} />
</Field>
<Field label='File Type'>
<Input value={state.fileType} onChange={(e) => setState((current) => ({ ...current, fileType: e.target.value }))} />
</Field>
</div>
<Field label='Description'>
<Textarea value={state.description} onChange={(e) => setState((current) => ({ ...current, description: e.target.value }))} />
</Field>
<div className='grid gap-4 md:grid-cols-2'>
<div className='flex items-center justify-between rounded-lg border px-4 py-3'>
<div>
<div className='font-medium'>Default Template</div>
<div className='text-muted-foreground text-sm'>Use this template as the default selection.</div>
</div>
<Switch checked={state.isDefault} onCheckedChange={(checked) => setState((current) => ({ ...current, isDefault: checked }))} />
</div>
<div className='flex items-center justify-between rounded-lg border px-4 py-3'>
<div>
<div className='font-medium'>Active</div>
<div className='text-muted-foreground text-sm'>Inactive templates stay visible for audit history.</div>
</div>
<Switch checked={state.isActive} onCheckedChange={(checked) => setState((current) => ({ ...current, isActive: checked }))} />
</div>
</div>
<DialogFooter>
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type='submit' isLoading={isPending}>
{template ? 'Save Changes' : 'Create Template'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function VersionDialog({
open,
onOpenChange,
templateId,
version
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
templateId: string;
version?: DocumentTemplateVersionDetail;
}) {
const [state, setState] = React.useState<VersionFormState>(toVersionState(version));
const createMutation = useMutation({
...createDocumentTemplateVersionMutation,
onSuccess: () => {
toast.success('Version saved');
onOpenChange(false);
},
onError: (error) => toast.error(error instanceof Error ? error.message : 'Save failed')
});
const updateMutation = useMutation({
...updateDocumentTemplateVersionMutation,
onSuccess: () => {
toast.success('Version updated');
onOpenChange(false);
},
onError: (error) => toast.error(error instanceof Error ? error.message : 'Update failed')
});
React.useEffect(() => {
if (open) {
setState(toVersionState(version));
}
}, [open, version]);
async function onSubmit(event: React.FormEvent) {
event.preventDefault();
const payload = buildVersionPayload(state);
if (version) {
await updateMutation.mutateAsync({ templateId, versionId: version.id, values: payload });
return;
}
await createMutation.mutateAsync({ id: templateId, values: payload });
}
const isPending = createMutation.isPending || updateMutation.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-3xl'>
<DialogHeader>
<DialogTitle>{version ? 'Edit Version' : 'Create Version'}</DialogTitle>
<DialogDescription>Schema JSON is accepted as raw JSON in this admin release.</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className='grid gap-4'>
<div className='grid gap-4 md:grid-cols-2'>
<Field label='Version'>
<Input value={state.version} onChange={(e) => setState((current) => ({ ...current, version: e.target.value }))} />
</Field>
<Field label='File Path'>
<Input value={state.filePath} onChange={(e) => setState((current) => ({ ...current, filePath: e.target.value }))} />
</Field>
</div>
<Field label='Preview Image URL'>
<Input value={state.previewImageUrl} onChange={(e) => setState((current) => ({ ...current, previewImageUrl: e.target.value }))} />
</Field>
<Field label='Schema JSON'>
<Textarea className='min-h-64 font-mono text-xs' value={state.schemaJson} onChange={(e) => setState((current) => ({ ...current, schemaJson: e.target.value }))} />
</Field>
<div className='flex items-center justify-between rounded-lg border px-4 py-3'>
<div>
<div className='font-medium'>Active Version</div>
<div className='text-muted-foreground text-sm'>Inactive versions remain stored for traceability.</div>
</div>
<Switch checked={state.isActive} onCheckedChange={(checked) => setState((current) => ({ ...current, isActive: checked }))} />
</div>
<DialogFooter>
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type='submit' isLoading={isPending}>
{version ? 'Save Version' : 'Create Version'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function MappingDialog({
open,
onOpenChange,
templateId,
version,
mapping
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
templateId: string;
version: DocumentTemplateVersionDetail;
mapping?: DocumentTemplateVersionDetail['mappings'][number];
}) {
const [state, setState] = React.useState<MappingFormState>(toMappingState(mapping));
const createMutation = useMutation({
...createDocumentTemplateMappingMutation,
onSuccess: () => {
toast.success('Mapping saved');
onOpenChange(false);
},
onError: (error) => toast.error(error instanceof Error ? error.message : 'Save failed')
});
const updateMutation = useMutation({
...updateDocumentTemplateMappingMutation,
onSuccess: () => {
toast.success('Mapping updated');
onOpenChange(false);
},
onError: (error) => toast.error(error instanceof Error ? error.message : 'Update failed')
});
React.useEffect(() => {
if (open) {
setState(toMappingState(mapping));
}
}, [open, mapping]);
async function onSubmit(event: React.FormEvent) {
event.preventDefault();
const payload = buildMappingPayload(state);
if (mapping) {
await updateMutation.mutateAsync({ templateId, mappingId: mapping.id, values: payload });
return;
}
await createMutation.mutateAsync({ templateId, versionId: version.id, values: payload });
}
function updateColumn(index: number, field: keyof MappingColumnState, value: string) {
setState((current) => ({
...current,
columns: current.columns.map((column, columnIndex) =>
columnIndex === index ? { ...column, [field]: value } : column
)
}));
}
const isPending = createMutation.isPending || updateMutation.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-4xl'>
<DialogHeader>
<DialogTitle>{mapping ? 'Edit Mapping' : 'Create Mapping'}</DialogTitle>
<DialogDescription>Table mappings can define per-column source fields and format masks.</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className='grid gap-4'>
<div className='grid gap-4 md:grid-cols-2'>
<Field label='Placeholder Key'>
<Input value={state.placeholderKey} onChange={(e) => setState((current) => ({ ...current, placeholderKey: e.target.value }))} />
</Field>
<Field label='Source Path'>
<Input value={state.sourcePath} onChange={(e) => setState((current) => ({ ...current, sourcePath: e.target.value }))} />
</Field>
<Field label='Data Type'>
<Input value={state.dataType} onChange={(e) => setState((current) => ({ ...current, dataType: e.target.value as MappingFormState['dataType'] }))} />
</Field>
<Field label='Sheet Name'>
<Input value={state.sheetName} onChange={(e) => setState((current) => ({ ...current, sheetName: e.target.value }))} />
</Field>
<Field label='Default Value'>
<Input value={state.defaultValue} onChange={(e) => setState((current) => ({ ...current, defaultValue: e.target.value }))} />
</Field>
<Field label='Format Mask'>
<Input value={state.formatMask} onChange={(e) => setState((current) => ({ ...current, formatMask: e.target.value }))} />
</Field>
</div>
<Field label='Sort Order'>
<Input value={state.sortOrder} onChange={(e) => setState((current) => ({ ...current, sortOrder: e.target.value }))} />
</Field>
{state.dataType === 'table' ? (
<div className='space-y-3 rounded-lg border p-4'>
<div className='flex items-center justify-between'>
<div className='font-medium'>Table Columns</div>
<Button
type='button'
variant='outline'
onClick={() =>
setState((current) => ({
...current,
columns: [
...current.columns,
{
columnName: '',
sourceField: '',
columnLetter: '',
sortOrder: String(current.columns.length),
formatMask: ''
}
]
}))
}
>
<Icons.add className='mr-2 h-4 w-4' />
Add Column
</Button>
</div>
{state.columns.map((column, index) => (
<div key={`${index}-${column.columnName}`} className='grid gap-3 md:grid-cols-5'>
<Input placeholder='Column name' value={column.columnName} onChange={(e) => updateColumn(index, 'columnName', e.target.value)} />
<Input placeholder='Source field' value={column.sourceField} onChange={(e) => updateColumn(index, 'sourceField', e.target.value)} />
<Input placeholder='Column letter' value={column.columnLetter} onChange={(e) => updateColumn(index, 'columnLetter', e.target.value)} />
<Input placeholder='Sort order' value={column.sortOrder} onChange={(e) => updateColumn(index, 'sortOrder', e.target.value)} />
<Input placeholder='Format mask' value={column.formatMask} onChange={(e) => updateColumn(index, 'formatMask', e.target.value)} />
</div>
))}
</div>
) : null}
<DialogFooter>
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type='submit' isLoading={isPending}>
{mapping ? 'Save Mapping' : 'Create Mapping'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function TemplateDetailCard({ templateId }: { templateId: string }) {
const { data } = useSuspenseQuery(documentTemplateByIdOptions(templateId));
const template = data.template;
const [versionDialogOpen, setVersionDialogOpen] = React.useState(false);
const [mappingDialogState, setMappingDialogState] = React.useState<{
open: boolean;
version?: DocumentTemplateVersionDetail;
mapping?: DocumentTemplateVersionDetail['mappings'][number];
}>({ open: false });
const deleteMappingMutation = useMutation({
...deleteDocumentTemplateMappingMutation,
onSuccess: () => toast.success('Mapping deleted'),
onError: (error) => toast.error(error instanceof Error ? error.message : 'Delete failed')
});
return (
<div className='space-y-4'>
<div className='grid gap-4 md:grid-cols-4'>
<div className='rounded-lg border p-4'>
<div className='text-muted-foreground text-xs'>Document Type</div>
<div className='mt-1 font-medium'>{template.documentType}</div>
</div>
<div className='rounded-lg border p-4'>
<div className='text-muted-foreground text-xs'>Product Type</div>
<div className='mt-1 font-medium'>{template.productType}</div>
</div>
<div className='rounded-lg border p-4'>
<div className='text-muted-foreground text-xs'>Version Count</div>
<div className='mt-1 font-medium'>{template.versions.length}</div>
</div>
<div className='rounded-lg border p-4'>
<div className='text-muted-foreground text-xs'>Mapping Count</div>
<div className='mt-1 font-medium'>{template.mappingCount}</div>
</div>
</div>
<Tabs defaultValue={template.versions[0]?.id ?? 'empty'} className='space-y-4'>
<div className='flex items-center justify-between gap-4'>
<TabsList className='flex flex-wrap'>
{template.versions.map((version) => (
<TabsTrigger key={version.id} value={version.id}>
{version.version}
</TabsTrigger>
))}
</TabsList>
<Button onClick={() => setVersionDialogOpen(true)}>
<Icons.add className='mr-2 h-4 w-4' />
Add Version
</Button>
</div>
{template.versions.length === 0 ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-6 text-center text-sm'>
No versions created yet.
</div>
) : null}
{template.versions.map((version) => (
<TabsContent key={version.id} value={version.id} className='space-y-4'>
<div className='rounded-lg border p-4'>
<div className='flex flex-wrap items-center justify-between gap-3'>
<div>
<div className='font-medium'>Version {version.version}</div>
<div className='text-muted-foreground text-sm'>
{version.filePath || 'No file path set'}{version.previewImageUrl ? `${version.previewImageUrl}` : ''}
</div>
</div>
<div className='flex items-center gap-2'>
<Badge variant={version.isActive ? 'secondary' : 'outline'}>
{version.isActive ? 'Active' : 'Inactive'}
</Badge>
<Button variant='outline' onClick={() => setVersionDialogOpen(true)}>
Edit Version
</Button>
<Button
onClick={() =>
setMappingDialogState({
open: true,
version
})
}
>
<Icons.add className='mr-2 h-4 w-4' />
Add Mapping
</Button>
</div>
</div>
<pre className='bg-muted mt-4 max-h-72 overflow-auto rounded-lg p-4 text-xs'>
{JSON.stringify(version.schemaJson, null, 2)}
</pre>
</div>
<div className='space-y-3'>
{version.mappings.map((mapping) => (
<div key={mapping.id} className='rounded-lg border p-4'>
<div className='flex flex-wrap items-start justify-between gap-3'>
<div>
<div className='font-medium'>{mapping.placeholderKey}</div>
<div className='text-muted-foreground text-sm'>
{mapping.sourcePath} {mapping.dataType}
</div>
</div>
<div className='flex gap-2'>
<Button
variant='outline'
onClick={() =>
setMappingDialogState({
open: true,
version,
mapping
})
}
>
Edit
</Button>
<Button
variant='outline'
onClick={() =>
deleteMappingMutation.mutate({
templateId,
mappingId: mapping.id
})
}
isLoading={deleteMappingMutation.isPending}
>
Delete
</Button>
</div>
</div>
{mapping.columns.length ? (
<div className='mt-3 rounded-lg border p-3'>
<div className='mb-2 text-sm font-medium'>Table Columns</div>
<div className='space-y-2 text-sm'>
{mapping.columns.map((column) => (
<div key={column.id} className='flex flex-wrap gap-2'>
<Badge variant='outline'>{column.columnName}</Badge>
<span>{column.sourceField}</span>
{column.columnLetter ? <span>({column.columnLetter})</span> : null}
{column.formatMask ? <span> {column.formatMask}</span> : null}
</div>
))}
</div>
</div>
) : null}
</div>
))}
{version.mappings.length === 0 ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-6 text-center text-sm'>
No mappings defined for this version yet.
</div>
) : null}
</div>
</TabsContent>
))}
</Tabs>
<VersionDialog
open={versionDialogOpen}
onOpenChange={setVersionDialogOpen}
templateId={template.id}
version={undefined}
/>
{mappingDialogState.open && mappingDialogState.version ? (
<MappingDialog
open={mappingDialogState.open}
onOpenChange={(open) => setMappingDialogState((current) => ({ ...current, open }))}
templateId={template.id}
version={mappingDialogState.version}
mapping={mappingDialogState.mapping}
/>
) : null}
</div>
);
}
export function TemplateSettings() { export function TemplateSettings() {
const { data } = useSuspenseQuery( const { data } = useSuspenseQuery(
documentTemplatesQueryOptions({ documentType: 'quotation', fileType: 'pdfme' }) documentTemplatesQueryOptions({ documentType: 'quotation', fileType: 'pdfme' })
); );
const [templateDialogState, setTemplateDialogState] = React.useState<{
open: boolean;
templateId?: string;
}>({ open: false });
return ( return (
<div className='space-y-6'> <div className='space-y-6'>
<div className='flex items-center justify-between gap-4 rounded-lg border p-4'>
<div>
<div className='font-medium'>Template Configuration Center</div>
<div className='text-muted-foreground text-sm'>
Admins can manage metadata, versions, mappings, and JSON schema without a visual designer.
</div>
</div>
<Button onClick={() => setTemplateDialogState({ open: true })}>
<Icons.add className='mr-2 h-4 w-4' />
Create Template
</Button>
</div>
{!data.items.length ? ( {!data.items.length ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'> <div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
No production document templates found. No production document templates found.
</div> </div>
) : ( ) : (
data.items.map((template) => ( data.items.map((template) => (
<Card key={template.id}> <div key={template.id} className='space-y-4 rounded-xl border p-5'>
<CardHeader className='flex flex-row items-start justify-between gap-4'> <div className='flex flex-wrap items-start justify-between gap-4'>
<div className='space-y-1'> <div className='space-y-1'>
<CardTitle>{template.templateName}</CardTitle> <div className='text-xl font-semibold'>{template.templateName}</div>
<CardDescription>{template.description || 'No description provided.'}</CardDescription> <div className='text-muted-foreground text-sm'>
{template.description || 'No description provided.'}
</div>
</div> </div>
<div className='flex flex-wrap gap-2'> <div className='flex flex-wrap gap-2'>
{template.isDefault ? <Badge>Default</Badge> : null} {template.isDefault ? <Badge>Default</Badge> : null}
@@ -30,33 +735,78 @@ export function TemplateSettings() {
{template.isActive ? 'Active' : 'Inactive'} {template.isActive ? 'Active' : 'Inactive'}
</Badge> </Badge>
<Badge variant='outline'>{template.fileType}</Badge> <Badge variant='outline'>{template.fileType}</Badge>
<Button
variant='outline'
onClick={() => setTemplateDialogState({ open: true, templateId: template.id })}
>
Edit Metadata
</Button>
</div> </div>
</CardHeader> </div>
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-5'>
<div> <div className='grid gap-4 md:grid-cols-5'>
<div className='rounded-lg bg-muted/40 p-4'>
<div className='text-muted-foreground text-xs'>Document Type</div> <div className='text-muted-foreground text-xs'>Document Type</div>
<div className='text-sm font-medium'>{template.documentType}</div> <div className='mt-1 text-sm font-medium'>{template.documentType}</div>
</div> </div>
<div> <div className='rounded-lg bg-muted/40 p-4'>
<div className='text-muted-foreground text-xs'>Product Type</div> <div className='text-muted-foreground text-xs'>Product Type</div>
<div className='text-sm font-medium'>{template.productType}</div> <div className='mt-1 text-sm font-medium'>{template.productType}</div>
</div> </div>
<div> <div className='rounded-lg bg-muted/40 p-4'>
<div className='text-muted-foreground text-xs'>Latest Version</div> <div className='text-muted-foreground text-xs'>Latest Version</div>
<div className='text-sm font-medium'>{template.latestVersion ?? '-'}</div> <div className='mt-1 text-sm font-medium'>{template.latestVersion ?? '-'}</div>
</div> </div>
<div> <div className='rounded-lg bg-muted/40 p-4'>
<div className='text-muted-foreground text-xs'>Version Count</div> <div className='text-muted-foreground text-xs'>Version Count</div>
<div className='text-sm font-medium'>{template.versionCount}</div> <div className='mt-1 text-sm font-medium'>{template.versionCount}</div>
</div> </div>
<div> <div className='rounded-lg bg-muted/40 p-4'>
<div className='text-muted-foreground text-xs'>Mapping Count</div> <div className='text-muted-foreground text-xs'>Mapping Count</div>
<div className='text-sm font-medium'>{template.mappingCount}</div> <div className='mt-1 text-sm font-medium'>{template.mappingCount}</div>
</div>
</div>
<React.Suspense
fallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-6 text-center text-sm'>
Loading template detail...
</div>
}
>
<TemplateDetailCard templateId={template.id} />
</React.Suspense>
</div> </div>
</CardContent>
</Card>
)) ))
)} )}
<TemplateDialog
open={templateDialogState.open && !templateDialogState.templateId}
onOpenChange={(open) => setTemplateDialogState((current) => ({ ...current, open }))}
/>
{templateDialogState.open && templateDialogState.templateId ? (
<React.Suspense fallback={null}>
<TemplateDetailDialog
templateId={templateDialogState.templateId}
open={templateDialogState.open}
onOpenChange={(open) => setTemplateDialogState((current) => ({ ...current, open }))}
/>
</React.Suspense>
) : null}
</div> </div>
); );
} }
function TemplateDetailDialog({
templateId,
open,
onOpenChange
}: {
templateId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { data } = useSuspenseQuery(documentTemplateByIdOptions(templateId));
return <TemplateDialog open={open} onOpenChange={onOpenChange} template={data.template} />;
}

View File

@@ -2,30 +2,38 @@ import { mutationOptions } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client'; import { getQueryClient } from '@/lib/query-client';
import { import {
createDocumentTemplate, createDocumentTemplate,
createDocumentTemplateMapping,
createDocumentTemplateVersion, createDocumentTemplateVersion,
deleteDocumentTemplate, deleteDocumentTemplate,
updateDocumentTemplate deleteDocumentTemplateMapping,
setDocumentTemplateVersionActive,
updateDocumentTemplate,
updateDocumentTemplateMapping,
updateDocumentTemplateVersion
} from './service'; } from './service';
import { documentTemplateKeys } from './queries'; import { documentTemplateKeys } from './queries';
import type { DocumentTemplateMutationPayload, DocumentTemplateVersionMutationPayload } from './types'; import type {
DocumentTemplateMappingMutationPayload,
DocumentTemplateMutationPayload,
DocumentTemplateVersionMutationPayload
} from './types';
async function invalidateDocumentTemplateLists() { async function invalidateTemplateLists() {
await getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.lists() }); await getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.lists() });
} }
async function invalidateDocumentTemplateDetail(id: string) { async function invalidateTemplateDetail(id: string) {
await getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.detail(id) }); await Promise.all([
} getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.detail(id) }),
getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.versions(id) })
async function invalidateDocumentTemplateVersions(id: string) { ]);
await getQueryClient().invalidateQueries({ queryKey: documentTemplateKeys.versions(id) });
} }
export const createDocumentTemplateMutation = mutationOptions({ export const createDocumentTemplateMutation = mutationOptions({
mutationFn: (data: DocumentTemplateMutationPayload) => createDocumentTemplate(data), mutationFn: (data: DocumentTemplateMutationPayload) => createDocumentTemplate(data),
onSettled: async (_data, error) => { onSettled: async (_data, error) => {
if (!error) { if (!error) {
await invalidateDocumentTemplateLists(); await invalidateTemplateLists();
} }
} }
}); });
@@ -35,10 +43,7 @@ export const updateDocumentTemplateMutation = mutationOptions({
updateDocumentTemplate(id, values), updateDocumentTemplate(id, values),
onSettled: async (_data, error, variables) => { onSettled: async (_data, error, variables) => {
if (!error) { if (!error) {
await Promise.all([ await Promise.all([invalidateTemplateLists(), invalidateTemplateDetail(variables.id)]);
invalidateDocumentTemplateLists(),
invalidateDocumentTemplateDetail(variables.id)
]);
} }
} }
}); });
@@ -47,9 +52,10 @@ export const deleteDocumentTemplateMutation = mutationOptions({
mutationFn: (id: string) => deleteDocumentTemplate(id), mutationFn: (id: string) => deleteDocumentTemplate(id),
onSettled: async (_data, error, id) => { onSettled: async (_data, error, id) => {
if (!error) { if (!error) {
getQueryClient().removeQueries({ queryKey: documentTemplateKeys.detail(id) }); const queryClient = getQueryClient();
getQueryClient().removeQueries({ queryKey: documentTemplateKeys.versions(id) }); await invalidateTemplateLists();
await invalidateDocumentTemplateLists(); queryClient.removeQueries({ queryKey: documentTemplateKeys.detail(id) });
queryClient.removeQueries({ queryKey: documentTemplateKeys.versions(id) });
} }
} }
}); });
@@ -59,11 +65,99 @@ export const createDocumentTemplateVersionMutation = mutationOptions({
createDocumentTemplateVersion(id, values), createDocumentTemplateVersion(id, values),
onSettled: async (_data, error, variables) => { onSettled: async (_data, error, variables) => {
if (!error) { if (!error) {
await Promise.all([ await Promise.all([invalidateTemplateLists(), invalidateTemplateDetail(variables.id)]);
invalidateDocumentTemplateLists(), }
invalidateDocumentTemplateDetail(variables.id), }
invalidateDocumentTemplateVersions(variables.id) });
]);
export const updateDocumentTemplateVersionMutation = mutationOptions({
mutationFn: ({
templateId,
versionId,
values
}: {
templateId: string;
versionId: string;
values: Partial<DocumentTemplateVersionMutationPayload>;
}) => {
void templateId;
return updateDocumentTemplateVersion(versionId, values);
},
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([invalidateTemplateLists(), invalidateTemplateDetail(variables.templateId)]);
}
}
});
export const setDocumentTemplateVersionActiveMutation = mutationOptions({
mutationFn: ({
templateId,
versionId,
isActive
}: {
templateId: string;
versionId: string;
isActive: boolean;
}) => {
void templateId;
return setDocumentTemplateVersionActive(versionId, isActive);
},
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([invalidateTemplateLists(), invalidateTemplateDetail(variables.templateId)]);
}
}
});
export const createDocumentTemplateMappingMutation = mutationOptions({
mutationFn: ({
templateId,
versionId,
values
}: {
templateId: string;
versionId: string;
values: DocumentTemplateMappingMutationPayload;
}) => {
void templateId;
return createDocumentTemplateMapping(versionId, values);
},
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([invalidateTemplateLists(), invalidateTemplateDetail(variables.templateId)]);
}
}
});
export const updateDocumentTemplateMappingMutation = mutationOptions({
mutationFn: ({
templateId,
mappingId,
values
}: {
templateId: string;
mappingId: string;
values: Partial<DocumentTemplateMappingMutationPayload>;
}) => {
void templateId;
return updateDocumentTemplateMapping(mappingId, values);
},
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([invalidateTemplateLists(), invalidateTemplateDetail(variables.templateId)]);
}
}
});
export const deleteDocumentTemplateMappingMutation = mutationOptions({
mutationFn: ({ templateId, mappingId }: { templateId: string; mappingId: string }) => {
void templateId;
return deleteDocumentTemplateMapping(mappingId);
},
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([invalidateTemplateLists(), invalidateTemplateDetail(variables.templateId)]);
} }
} }
}); });

View File

@@ -11,6 +11,7 @@ import type {
DocumentTemplateDetail, DocumentTemplateDetail,
DocumentTemplateFilters, DocumentTemplateFilters,
DocumentTemplateListItem, DocumentTemplateListItem,
DocumentTemplateMappingMutationPayload,
DocumentTemplateMappingRecord, DocumentTemplateMappingRecord,
DocumentTemplateMappingWithColumns, DocumentTemplateMappingWithColumns,
DocumentTemplateMutationPayload, DocumentTemplateMutationPayload,
@@ -119,21 +120,44 @@ async function assertTemplate(id: string, organizationId: string) {
return template; return template;
} }
async function listVersionsByTemplateIds(templateIds: string[], organizationId: string) { async function assertTemplateVersion(id: string, organizationId: string) {
if (!templateIds.length) { const [version] = await db
return [];
}
return db
.select() .select()
.from(crmDocumentTemplateVersions) .from(crmDocumentTemplateVersions)
.where( .where(
and( and(
eq(crmDocumentTemplateVersions.id, id),
eq(crmDocumentTemplateVersions.organizationId, organizationId), eq(crmDocumentTemplateVersions.organizationId, organizationId),
isNull(crmDocumentTemplateVersions.deletedAt) isNull(crmDocumentTemplateVersions.deletedAt)
) )
) )
.orderBy(asc(crmDocumentTemplateVersions.createdAt)); .limit(1);
if (!version) {
throw new AuthError('Document template version not found', 404);
}
return version;
}
async function assertTemplateMapping(id: string, organizationId: string) {
const [mapping] = await db
.select()
.from(crmDocumentTemplateMappings)
.where(
and(
eq(crmDocumentTemplateMappings.id, id),
eq(crmDocumentTemplateMappings.organizationId, organizationId),
isNull(crmDocumentTemplateMappings.deletedAt)
)
)
.limit(1);
if (!mapping) {
throw new AuthError('Document template mapping not found', 404);
}
return mapping;
} }
export async function resolveTemplateMappings( export async function resolveTemplateMappings(
@@ -417,6 +441,169 @@ export async function createDocumentTemplateVersion(
return created; return created;
} }
export async function updateDocumentTemplateVersion(
versionId: string,
organizationId: string,
payload: Partial<DocumentTemplateVersionMutationPayload>
) {
const current = await assertTemplateVersion(versionId, organizationId);
const [updated] = await db
.update(crmDocumentTemplateVersions)
.set({
version: payload.version?.trim() ?? current.version,
filePath: payload.filePath === undefined ? current.filePath : payload.filePath?.trim() || null,
schemaJson: payload.schemaJson ?? current.schemaJson,
previewImageUrl:
payload.previewImageUrl === undefined
? current.previewImageUrl
: payload.previewImageUrl?.trim() || null,
isActive: payload.isActive ?? current.isActive,
updatedAt: new Date()
})
.where(eq(crmDocumentTemplateVersions.id, versionId))
.returning();
return updated;
}
export async function setDocumentTemplateVersionActive(
versionId: string,
organizationId: string,
isActive: boolean
) {
const version = await assertTemplateVersion(versionId, organizationId);
const [updated] = await db
.update(crmDocumentTemplateVersions)
.set({
isActive,
updatedAt: new Date()
})
.where(eq(crmDocumentTemplateVersions.id, versionId))
.returning();
return {
before: version,
after: updated
};
}
export async function createDocumentTemplateMapping(
versionId: string,
organizationId: string,
payload: DocumentTemplateMappingMutationPayload
) {
await assertTemplateVersion(versionId, organizationId);
const [created] = await db
.insert(crmDocumentTemplateMappings)
.values({
id: crypto.randomUUID(),
organizationId,
templateVersionId: versionId,
placeholderKey: payload.placeholderKey.trim(),
sourcePath: payload.sourcePath.trim(),
dataType: payload.dataType,
sheetName: payload.sheetName?.trim() || null,
defaultValue: payload.defaultValue?.trim() || null,
formatMask: payload.formatMask?.trim() || null,
sortOrder: payload.sortOrder ?? 0
})
.returning();
if (payload.columns?.length) {
await db.insert(crmDocumentTemplateTableColumns).values(
payload.columns.map((column) => ({
id: crypto.randomUUID(),
organizationId,
mappingId: created.id,
columnName: column.columnName.trim(),
sourceField: column.sourceField.trim(),
columnLetter: column.columnLetter?.trim() || null,
sortOrder: column.sortOrder ?? 0,
formatMask: column.formatMask?.trim() || null
}))
);
}
const [mapping] = await resolveTemplateMappings(versionId, organizationId).then((items) =>
items.filter((item) => item.id === created.id)
);
return mapping ?? { ...mapMappingRecord(created), columns: [] };
}
export async function updateDocumentTemplateMapping(
mappingId: string,
organizationId: string,
payload: Partial<DocumentTemplateMappingMutationPayload>
) {
const current = await assertTemplateMapping(mappingId, organizationId);
const [updated] = await db
.update(crmDocumentTemplateMappings)
.set({
placeholderKey: payload.placeholderKey?.trim() ?? current.placeholderKey,
sourcePath: payload.sourcePath?.trim() ?? current.sourcePath,
dataType: payload.dataType ?? current.dataType,
sheetName: payload.sheetName === undefined ? current.sheetName : payload.sheetName?.trim() || null,
defaultValue:
payload.defaultValue === undefined ? current.defaultValue : payload.defaultValue?.trim() || null,
formatMask:
payload.formatMask === undefined ? current.formatMask : payload.formatMask?.trim() || null,
sortOrder: payload.sortOrder ?? current.sortOrder,
updatedAt: new Date()
})
.where(eq(crmDocumentTemplateMappings.id, mappingId))
.returning();
if (payload.columns) {
await db
.delete(crmDocumentTemplateTableColumns)
.where(eq(crmDocumentTemplateTableColumns.mappingId, mappingId));
if (payload.columns.length) {
await db.insert(crmDocumentTemplateTableColumns).values(
payload.columns.map((column) => ({
id: crypto.randomUUID(),
organizationId,
mappingId,
columnName: column.columnName.trim(),
sourceField: column.sourceField.trim(),
columnLetter: column.columnLetter?.trim() || null,
sortOrder: column.sortOrder ?? 0,
formatMask: column.formatMask?.trim() || null
}))
);
}
}
const [mapping] = await resolveTemplateMappings(updated.templateVersionId, organizationId).then((items) =>
items.filter((item) => item.id === mappingId)
);
return mapping ?? { ...mapMappingRecord(updated), columns: [] };
}
export async function deleteDocumentTemplateMapping(mappingId: string, organizationId: string) {
const current = await assertTemplateMapping(mappingId, organizationId);
await db
.delete(crmDocumentTemplateTableColumns)
.where(eq(crmDocumentTemplateTableColumns.mappingId, mappingId));
const [deleted] = await db
.update(crmDocumentTemplateMappings)
.set({
deletedAt: new Date(),
updatedAt: new Date()
})
.where(eq(crmDocumentTemplateMappings.id, mappingId))
.returning();
return {
before: current,
after: deleted
};
}
export async function resolveTemplateForDocument( export async function resolveTemplateForDocument(
organizationId: string, organizationId: string,
params: ResolveTemplateParams params: ResolveTemplateParams

View File

@@ -3,12 +3,15 @@ import type {
DocumentTemplateDetailResponse, DocumentTemplateDetailResponse,
DocumentTemplateFilters, DocumentTemplateFilters,
DocumentTemplateListResponse, DocumentTemplateListResponse,
DocumentTemplateMappingMutationPayload,
DocumentTemplateMutationPayload, DocumentTemplateMutationPayload,
DocumentTemplateVersionMutationPayload, DocumentTemplateVersionMutationPayload,
DocumentTemplateVersionsResponse, DocumentTemplateVersionsResponse,
MutationSuccessResponse MutationSuccessResponse
} from './types'; } from './types';
const BASE_PATH = '/crm/settings/document-templates';
export async function getDocumentTemplates(filters: DocumentTemplateFilters = {}) { export async function getDocumentTemplates(filters: DocumentTemplateFilters = {}) {
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
@@ -17,17 +20,15 @@ export async function getDocumentTemplates(filters: DocumentTemplateFilters = {}
if (filters.isActive) searchParams.set('isActive', filters.isActive); if (filters.isActive) searchParams.set('isActive', filters.isActive);
const query = searchParams.toString(); const query = searchParams.toString();
return apiClient<DocumentTemplateListResponse>( return apiClient<DocumentTemplateListResponse>(`${BASE_PATH}${query ? `?${query}` : ''}`);
`/crm/document-templates${query ? `?${query}` : ''}`
);
} }
export async function getDocumentTemplateById(id: string) { export async function getDocumentTemplateById(id: string) {
return apiClient<DocumentTemplateDetailResponse>(`/crm/document-templates/${id}`); return apiClient<DocumentTemplateDetailResponse>(`${BASE_PATH}/${id}`);
} }
export async function createDocumentTemplate(data: DocumentTemplateMutationPayload) { export async function createDocumentTemplate(data: DocumentTemplateMutationPayload) {
return apiClient<MutationSuccessResponse>('/crm/document-templates', { return apiClient<MutationSuccessResponse>(BASE_PATH, {
method: 'POST', method: 'POST',
body: JSON.stringify(data) body: JSON.stringify(data)
}); });
@@ -37,28 +38,71 @@ export async function updateDocumentTemplate(
id: string, id: string,
data: Partial<DocumentTemplateMutationPayload> data: Partial<DocumentTemplateMutationPayload>
) { ) {
return apiClient<MutationSuccessResponse>(`/crm/document-templates/${id}`, { return apiClient<MutationSuccessResponse>(`${BASE_PATH}/${id}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(data) body: JSON.stringify(data)
}); });
} }
export async function deleteDocumentTemplate(id: string) { export async function deleteDocumentTemplate(id: string) {
return apiClient<MutationSuccessResponse>(`/crm/document-templates/${id}`, { return apiClient<MutationSuccessResponse>(`${BASE_PATH}/${id}`, {
method: 'DELETE' method: 'DELETE'
}); });
} }
export async function getDocumentTemplateVersions(id: string) { export async function getDocumentTemplateVersions(id: string) {
return apiClient<DocumentTemplateVersionsResponse>(`/crm/document-templates/${id}/versions`); return apiClient<DocumentTemplateVersionsResponse>(`${BASE_PATH}/${id}/versions`);
} }
export async function createDocumentTemplateVersion( export async function createDocumentTemplateVersion(
id: string, id: string,
data: DocumentTemplateVersionMutationPayload data: DocumentTemplateVersionMutationPayload
) { ) {
return apiClient<MutationSuccessResponse>(`/crm/document-templates/${id}/versions`, { return apiClient<MutationSuccessResponse>(`${BASE_PATH}/${id}/versions`, {
method: 'POST', method: 'POST',
body: JSON.stringify(data) body: JSON.stringify(data)
}); });
} }
export async function updateDocumentTemplateVersion(
id: string,
data: Partial<DocumentTemplateVersionMutationPayload>
) {
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/versions/${id}`, {
method: 'PATCH',
body: JSON.stringify(data)
});
}
export async function setDocumentTemplateVersionActive(id: string, isActive: boolean) {
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/versions/${id}`, {
method: 'PATCH',
body: JSON.stringify({ isActive })
});
}
export async function createDocumentTemplateMapping(
versionId: string,
data: DocumentTemplateMappingMutationPayload
) {
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/versions/${versionId}/mappings`, {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function updateDocumentTemplateMapping(
id: string,
data: Partial<DocumentTemplateMappingMutationPayload>
) {
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/mappings/${id}`, {
method: 'PATCH',
body: JSON.stringify(data)
});
}
export async function deleteDocumentTemplateMapping(id: string) {
return apiClient<MutationSuccessResponse>(`${BASE_PATH}/mappings/${id}`, {
method: 'DELETE'
});
}

View File

@@ -60,6 +60,26 @@ export interface DocumentTemplateTableColumnRecord {
deletedAt: string | null; deletedAt: string | null;
} }
export interface DocumentTemplateMappingColumnMutationPayload {
id?: string;
columnName: string;
sourceField: string;
columnLetter?: string | null;
sortOrder?: number;
formatMask?: string | null;
}
export interface DocumentTemplateMappingMutationPayload {
placeholderKey: string;
sourcePath: string;
dataType: DocumentTemplateMappingRecord['dataType'];
sheetName?: string | null;
defaultValue?: string | null;
formatMask?: string | null;
sortOrder?: number;
columns?: DocumentTemplateMappingColumnMutationPayload[];
}
export interface DocumentTemplateMappingWithColumns extends DocumentTemplateMappingRecord { export interface DocumentTemplateMappingWithColumns extends DocumentTemplateMappingRecord {
columns: DocumentTemplateTableColumnRecord[]; columns: DocumentTemplateTableColumnRecord[];
} }

View File

@@ -51,10 +51,20 @@ export const PERMISSIONS = {
crmApprovalApprove: 'crm.approval.approve', crmApprovalApprove: 'crm.approval.approve',
crmApprovalReject: 'crm.approval.reject', crmApprovalReject: 'crm.approval.reject',
crmApprovalReturn: 'crm.approval.return', crmApprovalReturn: 'crm.approval.return',
crmApprovalWorkflowRead: 'crm.approval.workflow.read',
crmApprovalWorkflowCreate: 'crm.approval.workflow.create',
crmApprovalWorkflowUpdate: 'crm.approval.workflow.update',
crmApprovalWorkflowDelete: 'crm.approval.workflow.delete',
crmDashboardRead: 'crm.dashboard.read',
crmDashboardExport: 'crm.dashboard.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',
crmDocumentTemplateDelete: 'crm.document_template.delete', crmDocumentTemplateDelete: 'crm.document_template.delete',
crmDocumentSequenceRead: 'crm.document_sequence.read',
crmDocumentSequenceCreate: 'crm.document_sequence.create',
crmDocumentSequenceUpdate: 'crm.document_sequence.update',
crmDocumentSequenceReset: 'crm.document_sequence.reset',
crmDocumentArtifactRead: 'crm.document_artifact.read', crmDocumentArtifactRead: 'crm.document_artifact.read',
crmDocumentArtifactDownload: 'crm.document_artifact.download', crmDocumentArtifactDownload: 'crm.document_artifact.download',
crmDocumentArtifactVoid: 'crm.document_artifact.void', crmDocumentArtifactVoid: 'crm.document_artifact.void',
@@ -106,10 +116,20 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
PERMISSIONS.crmApprovalApprove, PERMISSIONS.crmApprovalApprove,
PERMISSIONS.crmApprovalReject, PERMISSIONS.crmApprovalReject,
PERMISSIONS.crmApprovalReturn, PERMISSIONS.crmApprovalReturn,
PERMISSIONS.crmApprovalWorkflowRead,
PERMISSIONS.crmApprovalWorkflowCreate,
PERMISSIONS.crmApprovalWorkflowUpdate,
PERMISSIONS.crmApprovalWorkflowDelete,
PERMISSIONS.crmDashboardRead,
PERMISSIONS.crmDashboardExport,
PERMISSIONS.crmDocumentTemplateRead, PERMISSIONS.crmDocumentTemplateRead,
PERMISSIONS.crmDocumentTemplateCreate, PERMISSIONS.crmDocumentTemplateCreate,
PERMISSIONS.crmDocumentTemplateUpdate, PERMISSIONS.crmDocumentTemplateUpdate,
PERMISSIONS.crmDocumentTemplateDelete, PERMISSIONS.crmDocumentTemplateDelete,
PERMISSIONS.crmDocumentSequenceRead,
PERMISSIONS.crmDocumentSequenceCreate,
PERMISSIONS.crmDocumentSequenceUpdate,
PERMISSIONS.crmDocumentSequenceReset,
PERMISSIONS.crmDocumentArtifactRead, PERMISSIONS.crmDocumentArtifactRead,
PERMISSIONS.crmDocumentArtifactDownload, PERMISSIONS.crmDocumentArtifactDownload,
PERMISSIONS.crmDocumentArtifactVoid, PERMISSIONS.crmDocumentArtifactVoid,
@@ -128,7 +148,10 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
PERMISSIONS.crmEnquiryFollowupRead, PERMISSIONS.crmEnquiryFollowupRead,
PERMISSIONS.crmQuotationRead, PERMISSIONS.crmQuotationRead,
PERMISSIONS.crmApprovalRead, PERMISSIONS.crmApprovalRead,
PERMISSIONS.crmApprovalWorkflowRead,
PERMISSIONS.crmDashboardRead,
PERMISSIONS.crmDocumentTemplateRead, PERMISSIONS.crmDocumentTemplateRead,
PERMISSIONS.crmDocumentSequenceRead,
PERMISSIONS.crmDocumentArtifactRead, PERMISSIONS.crmDocumentArtifactRead,
PERMISSIONS.crmDocumentArtifactDownload, PERMISSIONS.crmDocumentArtifactDownload,
PERMISSIONS.crmQuotationDocumentPreview, PERMISSIONS.crmQuotationDocumentPreview,
@@ -166,7 +189,11 @@ export function getBusinessRolePermissions(role: BusinessRole): Permission[] {
PERMISSIONS.crmQuotationRevisionCreate, PERMISSIONS.crmQuotationRevisionCreate,
PERMISSIONS.crmApprovalRead, PERMISSIONS.crmApprovalRead,
PERMISSIONS.crmApprovalSubmit, PERMISSIONS.crmApprovalSubmit,
PERMISSIONS.crmApprovalWorkflowRead,
PERMISSIONS.crmDashboardRead,
PERMISSIONS.crmDashboardExport,
PERMISSIONS.crmDocumentTemplateRead, PERMISSIONS.crmDocumentTemplateRead,
PERMISSIONS.crmDocumentSequenceRead,
PERMISSIONS.crmDocumentArtifactRead, PERMISSIONS.crmDocumentArtifactRead,
PERMISSIONS.crmDocumentArtifactDownload, PERMISSIONS.crmDocumentArtifactDownload,
PERMISSIONS.crmDocumentArtifactVoid, PERMISSIONS.crmDocumentArtifactVoid,
@@ -195,7 +222,10 @@ export function getBusinessRolePermissions(role: BusinessRole): Permission[] {
PERMISSIONS.crmQuotationRevisionCreate, PERMISSIONS.crmQuotationRevisionCreate,
PERMISSIONS.crmApprovalRead, PERMISSIONS.crmApprovalRead,
PERMISSIONS.crmApprovalSubmit, PERMISSIONS.crmApprovalSubmit,
PERMISSIONS.crmApprovalWorkflowRead,
PERMISSIONS.crmDashboardRead,
PERMISSIONS.crmDocumentTemplateRead, PERMISSIONS.crmDocumentTemplateRead,
PERMISSIONS.crmDocumentSequenceRead,
PERMISSIONS.crmDocumentArtifactRead, PERMISSIONS.crmDocumentArtifactRead,
PERMISSIONS.crmDocumentArtifactDownload, PERMISSIONS.crmDocumentArtifactDownload,
PERMISSIONS.crmQuotationDocumentPreview, PERMISSIONS.crmQuotationDocumentPreview,
@@ -222,7 +252,10 @@ export function getBusinessRolePermissions(role: BusinessRole): Permission[] {
PERMISSIONS.crmQuotationAttachmentManage, PERMISSIONS.crmQuotationAttachmentManage,
PERMISSIONS.crmQuotationRevisionCreate, PERMISSIONS.crmQuotationRevisionCreate,
PERMISSIONS.crmApprovalRead, PERMISSIONS.crmApprovalRead,
PERMISSIONS.crmApprovalWorkflowRead,
PERMISSIONS.crmDashboardRead,
PERMISSIONS.crmDocumentTemplateRead, PERMISSIONS.crmDocumentTemplateRead,
PERMISSIONS.crmDocumentSequenceRead,
PERMISSIONS.crmDocumentArtifactRead, PERMISSIONS.crmDocumentArtifactRead,
PERMISSIONS.crmDocumentArtifactDownload, PERMISSIONS.crmDocumentArtifactDownload,
PERMISSIONS.crmQuotationDocumentPreview, PERMISSIONS.crmQuotationDocumentPreview,

View File

@@ -28,6 +28,7 @@ export const searchParams = {
entityType: parseAsString, entityType: parseAsString,
assetType: parseAsString, assetType: parseAsString,
role: parseAsString, role: parseAsString,
projectPartyRole: parseAsString,
sort: parseAsString sort: parseAsString
// advanced filter // advanced filter
// filters: getFiltersStateParser().withDefault([]), // filters: getFiltersStateParser().withDefault([]),