task-j1
This commit is contained in:
74
docs/implementation/task-j-crm-dashboard-kpi.md
Normal file
74
docs/implementation/task-j-crm-dashboard-kpi.md
Normal 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
|
||||||
@@ -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.
|
||||||
@@ -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
419
plans/task-j.1.md
Normal 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
|
||||||
540
plans/task-j.md
540
plans/task-j.md
@@ -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
|
||||||
|
|||||||
131
src/app/api/crm/dashboard/export/route.ts
Normal file
131
src/app/api/crm/dashboard/export/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/app/api/crm/dashboard/route.ts
Normal file
37
src/app/api/crm/dashboard/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
118
src/app/api/crm/settings/approval-workflows/[id]/route.ts
Normal file
118
src/app/api/crm/settings/approval-workflows/[id]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/app/api/crm/settings/approval-workflows/route.ts
Normal file
78
src/app/api/crm/settings/approval-workflows/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
122
src/app/api/crm/settings/document-sequences/[id]/route.ts
Normal file
122
src/app/api/crm/settings/document-sequences/[id]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/app/api/crm/settings/document-sequences/route.ts
Normal file
80
src/app/api/crm/settings/document-sequences/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { GET, PATCH, DELETE } from '@/app/api/crm/document-templates/[id]/route';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { GET, POST } from '@/app/api/crm/document-templates/[id]/versions/route';
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/app/api/crm/settings/document-templates/route.ts
Normal file
1
src/app/api/crm/settings/document-templates/route.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { GET, POST } from '@/app/api/crm/document-templates/route';
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
40
src/app/dashboard/crm/settings/approval-workflows/page.tsx
Normal file
40
src/app/dashboard/crm/settings/approval-workflows/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
14
src/features/crm/dashboard/api/queries.ts
Normal file
14
src/features/crm/dashboard/api/queries.ts
Normal 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)
|
||||||
|
});
|
||||||
16
src/features/crm/dashboard/api/service.ts
Normal file
16
src/features/crm/dashboard/api/service.ts
Normal 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}` : ''}`);
|
||||||
|
}
|
||||||
151
src/features/crm/dashboard/api/types.ts
Normal file
151
src/features/crm/dashboard/api/types.ts
Normal 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[];
|
||||||
|
}
|
||||||
52
src/features/crm/dashboard/components/crm-dashboard.tsx
Normal file
52
src/features/crm/dashboard/components/crm-dashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
177
src/features/crm/dashboard/components/dashboard-filters.tsx
Normal file
177
src/features/crm/dashboard/components/dashboard-filters.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/features/crm/dashboard/components/dashboard-funnel.tsx
Normal file
45
src/features/crm/dashboard/components/dashboard-funnel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
705
src/features/crm/dashboard/server/service.ts
Normal file
705
src/features/crm/dashboard/server/service.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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)
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
53
src/features/foundation/document-sequence/client-service.ts
Normal file
53
src/features/foundation/document-sequence/client-service.ts
Normal 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)
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
src/features/foundation/document-sequence/mutations.ts
Normal file
68
src/features/foundation/document-sequence/mutations.ts
Normal 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)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
34
src/features/foundation/document-sequence/queries.ts
Normal file
34
src/features/foundation/document-sequence/queries.ts
Normal 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)
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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} />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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([]),
|
||||||
|
|||||||
Reference in New Issue
Block a user