From 04a886ea26a4d6eebcefa4945465e946fd88afee Mon Sep 17 00:00:00 2001 From: phaichayon Date: Wed, 17 Jun 2026 16:04:19 +0700 Subject: [PATCH] task-j1 --- .../task-j-crm-dashboard-kpi.md | 74 ++ .../task-j1-crm-admin-configuration-center.md | 85 ++ docs/implementation/technical-debt.md | 48 ++ plans/task-j.1.md | 419 +++++++++ plans/task-j.md | 540 ++++++++++++ src/app/api/crm/dashboard/export/route.ts | 131 +++ src/app/api/crm/dashboard/route.ts | 37 + .../settings/approval-workflows/[id]/route.ts | 118 +++ .../approval-workflows/[id]/steps/route.ts | 67 ++ .../crm/settings/approval-workflows/route.ts | 78 ++ .../document-sequences/[id]/preview/route.ts | 36 + .../document-sequences/[id]/reset/route.ts | 57 ++ .../settings/document-sequences/[id]/route.ts | 122 +++ .../crm/settings/document-sequences/route.ts | 80 ++ .../settings/document-templates/[id]/route.ts | 1 + .../document-templates/[id]/versions/route.ts | 1 + .../document-templates/mappings/[id]/route.ts | 108 +++ .../crm/settings/document-templates/route.ts | 1 + .../versions/[id]/mappings/route.ts | 70 ++ .../document-templates/versions/[id]/route.ts | 81 ++ src/app/dashboard/crm/page.tsx | 74 +- .../crm/settings/approval-workflows/page.tsx | 40 + .../crm/settings/document-sequences/page.tsx | 45 +- src/components/layout/app-sidebar.tsx | 9 +- src/config/nav-config.ts | 23 +- src/features/crm/dashboard/api/queries.ts | 14 + src/features/crm/dashboard/api/service.ts | 16 + src/features/crm/dashboard/api/types.ts | 151 ++++ .../dashboard/components/crm-dashboard.tsx | 52 ++ .../components/dashboard-approval-metrics.tsx | 76 ++ .../components/dashboard-filters.tsx | 177 ++++ .../components/dashboard-followups.tsx | 67 ++ .../dashboard/components/dashboard-funnel.tsx | 45 + .../components/dashboard-hot-projects.tsx | 55 ++ .../components/dashboard-revenue-cards.tsx | 92 ++ .../components/dashboard-sales-ranking.tsx | 56 ++ .../components/dashboard-summary-cards.tsx | 84 ++ src/features/crm/dashboard/server/service.ts | 705 ++++++++++++++++ .../components/approval-workflow-settings.tsx | 341 ++++++++ src/features/foundation/approval/mutations.ts | 79 +- src/features/foundation/approval/queries.ts | 27 +- .../foundation/approval/server/service.ts | 242 ++++++ src/features/foundation/approval/service.ts | 47 ++ src/features/foundation/approval/types.ts | 36 + .../document-sequence/client-service.ts | 53 ++ .../components/document-sequence-settings.tsx | 247 ++++++ .../foundation/document-sequence/mutations.ts | 68 ++ .../foundation/document-sequence/queries.ts | 34 + .../foundation/document-sequence/service.ts | 168 +++- .../foundation/document-sequence/types.ts | 58 ++ .../components/template-settings.tsx | 792 +++++++++++++++++- .../foundation/document-template/mutations.ts | 138 ++- .../document-template/server/service.ts | 201 ++++- .../foundation/document-template/service.ts | 62 +- .../foundation/document-template/types.ts | 20 + src/lib/auth/rbac.ts | 33 + src/lib/searchparams.ts | 1 + 57 files changed, 6477 insertions(+), 105 deletions(-) create mode 100644 docs/implementation/task-j-crm-dashboard-kpi.md create mode 100644 docs/implementation/task-j1-crm-admin-configuration-center.md create mode 100644 plans/task-j.1.md create mode 100644 src/app/api/crm/dashboard/export/route.ts create mode 100644 src/app/api/crm/dashboard/route.ts create mode 100644 src/app/api/crm/settings/approval-workflows/[id]/route.ts create mode 100644 src/app/api/crm/settings/approval-workflows/[id]/steps/route.ts create mode 100644 src/app/api/crm/settings/approval-workflows/route.ts create mode 100644 src/app/api/crm/settings/document-sequences/[id]/preview/route.ts create mode 100644 src/app/api/crm/settings/document-sequences/[id]/reset/route.ts create mode 100644 src/app/api/crm/settings/document-sequences/[id]/route.ts create mode 100644 src/app/api/crm/settings/document-sequences/route.ts create mode 100644 src/app/api/crm/settings/document-templates/[id]/route.ts create mode 100644 src/app/api/crm/settings/document-templates/[id]/versions/route.ts create mode 100644 src/app/api/crm/settings/document-templates/mappings/[id]/route.ts create mode 100644 src/app/api/crm/settings/document-templates/route.ts create mode 100644 src/app/api/crm/settings/document-templates/versions/[id]/mappings/route.ts create mode 100644 src/app/api/crm/settings/document-templates/versions/[id]/route.ts create mode 100644 src/app/dashboard/crm/settings/approval-workflows/page.tsx create mode 100644 src/features/crm/dashboard/api/queries.ts create mode 100644 src/features/crm/dashboard/api/service.ts create mode 100644 src/features/crm/dashboard/api/types.ts create mode 100644 src/features/crm/dashboard/components/crm-dashboard.tsx create mode 100644 src/features/crm/dashboard/components/dashboard-approval-metrics.tsx create mode 100644 src/features/crm/dashboard/components/dashboard-filters.tsx create mode 100644 src/features/crm/dashboard/components/dashboard-followups.tsx create mode 100644 src/features/crm/dashboard/components/dashboard-funnel.tsx create mode 100644 src/features/crm/dashboard/components/dashboard-hot-projects.tsx create mode 100644 src/features/crm/dashboard/components/dashboard-revenue-cards.tsx create mode 100644 src/features/crm/dashboard/components/dashboard-sales-ranking.tsx create mode 100644 src/features/crm/dashboard/components/dashboard-summary-cards.tsx create mode 100644 src/features/crm/dashboard/server/service.ts create mode 100644 src/features/foundation/approval/components/approval-workflow-settings.tsx create mode 100644 src/features/foundation/document-sequence/client-service.ts create mode 100644 src/features/foundation/document-sequence/components/document-sequence-settings.tsx create mode 100644 src/features/foundation/document-sequence/mutations.ts create mode 100644 src/features/foundation/document-sequence/queries.ts diff --git a/docs/implementation/task-j-crm-dashboard-kpi.md b/docs/implementation/task-j-crm-dashboard-kpi.md new file mode 100644 index 0000000..decbde4 --- /dev/null +++ b/docs/implementation/task-j-crm-dashboard-kpi.md @@ -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 diff --git a/docs/implementation/task-j1-crm-admin-configuration-center.md b/docs/implementation/task-j1-crm-admin-configuration-center.md new file mode 100644 index 0000000..7680c39 --- /dev/null +++ b/docs/implementation/task-j1-crm-admin-configuration-center.md @@ -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. diff --git a/docs/implementation/technical-debt.md b/docs/implementation/technical-debt.md index 73d9145..19efa5b 100644 --- a/docs/implementation/technical-debt.md +++ b/docs/implementation/technical-debt.md @@ -297,3 +297,51 @@ Future: - 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 - 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 diff --git a/plans/task-j.1.md b/plans/task-j.1.md new file mode 100644 index 0000000..69f5d0c --- /dev/null +++ b/plans/task-j.1.md @@ -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 diff --git a/plans/task-j.md b/plans/task-j.md index e69de29..14c3d22 100644 --- a/plans/task-j.md +++ b/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 diff --git a/src/app/api/crm/dashboard/export/route.ts b/src/app/api/crm/dashboard/export/route.ts new file mode 100644 index 0000000..dfe6ebd --- /dev/null +++ b/src/app/api/crm/dashboard/export/route.ts @@ -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) => + `${row + .map((cell) => (rowIndex === 0 ? `${cell}` : `${cell}`)) + .join('')}` + ) + .join(''); + + return `${body}
`; +} + +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 }); + } +} diff --git a/src/app/api/crm/dashboard/route.ts b/src/app/api/crm/dashboard/route.ts new file mode 100644 index 0000000..da33fb4 --- /dev/null +++ b/src/app/api/crm/dashboard/route.ts @@ -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 }); + } +} diff --git a/src/app/api/crm/settings/approval-workflows/[id]/route.ts b/src/app/api/crm/settings/approval-workflows/[id]/route.ts new file mode 100644 index 0000000..1093103 --- /dev/null +++ b/src/app/api/crm/settings/approval-workflows/[id]/route.ts @@ -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 }); + } +} diff --git a/src/app/api/crm/settings/approval-workflows/[id]/steps/route.ts b/src/app/api/crm/settings/approval-workflows/[id]/steps/route.ts new file mode 100644 index 0000000..7ff88bf --- /dev/null +++ b/src/app/api/crm/settings/approval-workflows/[id]/steps/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/crm/settings/approval-workflows/route.ts b/src/app/api/crm/settings/approval-workflows/route.ts new file mode 100644 index 0000000..3702036 --- /dev/null +++ b/src/app/api/crm/settings/approval-workflows/route.ts @@ -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 }); + } +} diff --git a/src/app/api/crm/settings/document-sequences/[id]/preview/route.ts b/src/app/api/crm/settings/document-sequences/[id]/preview/route.ts new file mode 100644 index 0000000..0ba824b --- /dev/null +++ b/src/app/api/crm/settings/document-sequences/[id]/preview/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/crm/settings/document-sequences/[id]/reset/route.ts b/src/app/api/crm/settings/document-sequences/[id]/reset/route.ts new file mode 100644 index 0000000..bad3150 --- /dev/null +++ b/src/app/api/crm/settings/document-sequences/[id]/reset/route.ts @@ -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 }); + } +} diff --git a/src/app/api/crm/settings/document-sequences/[id]/route.ts b/src/app/api/crm/settings/document-sequences/[id]/route.ts new file mode 100644 index 0000000..6675cc8 --- /dev/null +++ b/src/app/api/crm/settings/document-sequences/[id]/route.ts @@ -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 }); + } +} diff --git a/src/app/api/crm/settings/document-sequences/route.ts b/src/app/api/crm/settings/document-sequences/route.ts new file mode 100644 index 0000000..48f9c1c --- /dev/null +++ b/src/app/api/crm/settings/document-sequences/route.ts @@ -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 }); + } +} diff --git a/src/app/api/crm/settings/document-templates/[id]/route.ts b/src/app/api/crm/settings/document-templates/[id]/route.ts new file mode 100644 index 0000000..c3eb8f6 --- /dev/null +++ b/src/app/api/crm/settings/document-templates/[id]/route.ts @@ -0,0 +1 @@ +export { GET, PATCH, DELETE } from '@/app/api/crm/document-templates/[id]/route'; diff --git a/src/app/api/crm/settings/document-templates/[id]/versions/route.ts b/src/app/api/crm/settings/document-templates/[id]/versions/route.ts new file mode 100644 index 0000000..f47b46e --- /dev/null +++ b/src/app/api/crm/settings/document-templates/[id]/versions/route.ts @@ -0,0 +1 @@ +export { GET, POST } from '@/app/api/crm/document-templates/[id]/versions/route'; diff --git a/src/app/api/crm/settings/document-templates/mappings/[id]/route.ts b/src/app/api/crm/settings/document-templates/mappings/[id]/route.ts new file mode 100644 index 0000000..fbcf0ce --- /dev/null +++ b/src/app/api/crm/settings/document-templates/mappings/[id]/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/crm/settings/document-templates/route.ts b/src/app/api/crm/settings/document-templates/route.ts new file mode 100644 index 0000000..b447c13 --- /dev/null +++ b/src/app/api/crm/settings/document-templates/route.ts @@ -0,0 +1 @@ +export { GET, POST } from '@/app/api/crm/document-templates/route'; diff --git a/src/app/api/crm/settings/document-templates/versions/[id]/mappings/route.ts b/src/app/api/crm/settings/document-templates/versions/[id]/mappings/route.ts new file mode 100644 index 0000000..d9e6a43 --- /dev/null +++ b/src/app/api/crm/settings/document-templates/versions/[id]/mappings/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/crm/settings/document-templates/versions/[id]/route.ts b/src/app/api/crm/settings/document-templates/versions/[id]/route.ts new file mode 100644 index 0000000..353c671 --- /dev/null +++ b/src/app/api/crm/settings/document-templates/versions/[id]/route.ts @@ -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 } + ); + } +} diff --git a/src/app/dashboard/crm/page.tsx b/src/app/dashboard/crm/page.tsx index c715fc9..8faa947 100644 --- a/src/app/dashboard/crm/page.tsx +++ b/src/app/dashboard/crm/page.tsx @@ -1,31 +1,67 @@ +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; +import { auth } from '@/auth'; 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 = { title: 'Dashboard: CRM' }; -export default function CrmDashboardRoute() { +type PageProps = { + searchParams: Promise; +}; + +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 ( + You do not have access to the CRM dashboard. + + } > - + {canRead ? ( + + + + ) : null} ); } diff --git a/src/app/dashboard/crm/settings/approval-workflows/page.tsx b/src/app/dashboard/crm/settings/approval-workflows/page.tsx new file mode 100644 index 0000000..025ce13 --- /dev/null +++ b/src/app/dashboard/crm/settings/approval-workflows/page.tsx @@ -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 ( + + You do not have access to CRM approval workflow settings. + + } + > + {canRead ? ( + + + + ) : null} + + ); +} diff --git a/src/app/dashboard/crm/settings/document-sequences/page.tsx b/src/app/dashboard/crm/settings/document-sequences/page.tsx index c4e7f99..cdb248e 100644 --- a/src/app/dashboard/crm/settings/document-sequences/page.tsx +++ b/src/app/dashboard/crm/settings/document-sequences/page.tsx @@ -1,23 +1,40 @@ +import { auth } from '@/auth'; +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; 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 ( + You do not have access to CRM document sequences. + + } > - + {canRead ? ( + + + + ) : null} ); } diff --git a/src/components/layout/app-sidebar.tsx b/src/components/layout/app-sidebar.tsx index 125aa57..07a8b6a 100644 --- a/src/components/layout/app-sidebar.tsx +++ b/src/components/layout/app-sidebar.tsx @@ -83,9 +83,10 @@ export default function AppSidebar() { {group.items.map((item) => { 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 ? ( {item.items?.map((subItem) => ( - + {subItem.title} @@ -114,7 +117,7 @@ export default function AppSidebar() { ) : ( - + [...crmDashboardKeys.all, filters] as const +}; + +export const crmDashboardQueryOptions = (filters: CrmDashboardFilters) => + queryOptions({ + queryKey: crmDashboardKeys.data(filters), + queryFn: () => getCrmDashboard(filters) + }); diff --git a/src/features/crm/dashboard/api/service.ts b/src/features/crm/dashboard/api/service.ts new file mode 100644 index 0000000..4ba9edb --- /dev/null +++ b/src/features/crm/dashboard/api/service.ts @@ -0,0 +1,16 @@ +import { apiClient } from '@/lib/api-client'; +import type { CrmDashboardFilters, CrmDashboardResponse } from './types'; + +export async function getCrmDashboard(filters: CrmDashboardFilters): Promise { + 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(`/crm/dashboard${query ? `?${query}` : ''}`); +} diff --git a/src/features/crm/dashboard/api/types.ts b/src/features/crm/dashboard/api/types.ts new file mode 100644 index 0000000..b2d4fff --- /dev/null +++ b/src/features/crm/dashboard/api/types.ts @@ -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; + 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[]; +} diff --git a/src/features/crm/dashboard/components/crm-dashboard.tsx b/src/features/crm/dashboard/components/crm-dashboard.tsx new file mode 100644 index 0000000..7e8cf4f --- /dev/null +++ b/src/features/crm/dashboard/components/crm-dashboard.tsx @@ -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 ( +
+ + +
+ + +
+ +
+ + +
+ +
+ ); +} diff --git a/src/features/crm/dashboard/components/dashboard-approval-metrics.tsx b/src/features/crm/dashboard/components/dashboard-approval-metrics.tsx new file mode 100644 index 0000000..953806a --- /dev/null +++ b/src/features/crm/dashboard/components/dashboard-approval-metrics.tsx @@ -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 ( +
+
{label}
+
+ {value} + {suffix ? {suffix} : null} +
+
+ ); +} + +export function DashboardApprovalMetrics({ + approvals +}: { + approvals: CrmDashboardResponse['approvals']; +}) { + return ( + + + Approval Analytics + Live workflow counts plus the current approver queue. + + +
+ + + + + +
+ + + + Document + Workflow + Current Step + Requested At + + + + {approvals.myPendingApprovals.length === 0 ? ( + + + No pending approvals are assigned to your current approver scope. + + + ) : ( + approvals.myPendingApprovals.map((row) => ( + + +
{row.entityCode ?? 'Approval'}
+
{row.entityTitle ?? '-'}
+
+ {row.workflowName} + {row.currentStepRoleName ?? '-'} + {new Date(row.requestedAt).toLocaleString()} +
+ )) + )} +
+
+
+
+ ); +} diff --git a/src/features/crm/dashboard/components/dashboard-filters.tsx b/src/features/crm/dashboard/components/dashboard-filters.tsx new file mode 100644 index 0000000..f58806c --- /dev/null +++ b/src/features/crm/dashboard/components/dashboard-filters.tsx @@ -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 ( +
+
+ void setParams({ dateFrom: event.target.value || null })} + /> + void setParams({ dateTo: event.target.value || null })} + /> + + + + +
+
+ + {canExport ? ( + <> + + + + + ) : null} +
+
+ ); +} diff --git a/src/features/crm/dashboard/components/dashboard-followups.tsx b/src/features/crm/dashboard/components/dashboard-followups.tsx new file mode 100644 index 0000000..273d74f --- /dev/null +++ b/src/features/crm/dashboard/components/dashboard-followups.tsx @@ -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 ( +
+
{label}
+
{value}
+
+ ); +} + +export function DashboardFollowups({ + followups +}: { + followups: CrmDashboardResponse['followups']; +}) { + return ( + + + Follow-up Dashboard + Upcoming and completed CRM follow-ups across enquiries and quotations. + + +
+ + + + +
+ + + + Date + Customer + Opportunity + Assigned Sales + Source + + + + {followups.upcoming.length === 0 ? ( + + + No upcoming follow-ups match the current filters. + + + ) : ( + followups.upcoming.map((row) => ( + + {new Date(row.followupDate).toLocaleDateString()} + {row.customerName} + {row.opportunityName} + {row.assignedSalesName ?? '-'} + {row.sourceType} + + )) + )} + +
+
+
+ ); +} diff --git a/src/features/crm/dashboard/components/dashboard-funnel.tsx b/src/features/crm/dashboard/components/dashboard-funnel.tsx new file mode 100644 index 0000000..b02f278 --- /dev/null +++ b/src/features/crm/dashboard/components/dashboard-funnel.tsx @@ -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 ( + + + Sales Pipeline Funnel + Frozen lead, opportunity, quotation, approval, and won stages. + + + {steps.map((step) => ( +
+
+
+
{step.label}
+
+ {formatNumber(step.count)} items, value {formatNumber(step.value)} +
+
+
+
{step.conversionRate === null ? '-' : `${formatNumber(step.conversionRate)}%`}
+
conversion
+
+
+
+
0 ? 8 : 0)}%` }} + /> +
+
+ ))} + + + ); +} diff --git a/src/features/crm/dashboard/components/dashboard-hot-projects.tsx b/src/features/crm/dashboard/components/dashboard-hot-projects.tsx new file mode 100644 index 0000000..3168051 --- /dev/null +++ b/src/features/crm/dashboard/components/dashboard-hot-projects.tsx @@ -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 ( + + + Top Hot Opportunities + Production hot projects from enquiries, ranked by commercial value. + + + + + + Project + Customer + Value + Assigned Sales + Probability + + + + {rows.length === 0 ? ( + + + No hot opportunities match the current filters. + + + ) : ( + rows.map((row) => ( + + +
{row.projectName}
+
{row.enquiryCode}
+
+ {row.customerName} + {formatNumber(row.value)} + {row.assignedSalesName ?? '-'} + {row.probability ?? 0}% +
+ )) + )} +
+
+
+
+ ); +} diff --git a/src/features/crm/dashboard/components/dashboard-revenue-cards.tsx b/src/features/crm/dashboard/components/dashboard-revenue-cards.tsx new file mode 100644 index 0000000..b4db79a --- /dev/null +++ b/src/features/crm/dashboard/components/dashboard-revenue-cards.tsx @@ -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 ( + + + {title} + {description} + + + + + + Customer + Revenue + Quotations + Won Revenue + + + + {rows.length === 0 ? ( + + + No revenue rows match the current filters. + + + ) : ( + rows.map((row) => ( + + +
{row.customerName}
+
{row.customerCode}
+
+ {formatNumber(row.revenue)} + {formatNumber(row.quotationCount)} + {formatNumber(row.wonRevenue)} +
+ )) + )} +
+
+
+
+ ); +} + +export function DashboardRevenueCards({ + revenueAnalytics +}: { + revenueAnalytics: CrmDashboardResponse['revenueAnalytics']; +}) { + return ( +
+ + + + +
+ ); +} diff --git a/src/features/crm/dashboard/components/dashboard-sales-ranking.tsx b/src/features/crm/dashboard/components/dashboard-sales-ranking.tsx new file mode 100644 index 0000000..c5f3820 --- /dev/null +++ b/src/features/crm/dashboard/components/dashboard-sales-ranking.tsx @@ -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 ( + + + Sales Ranking + Lead and opportunity ownership follow assignment rules. Quotation ownership uses salesman. + + + + + + Sales Person + Lead + Opportunity + Quotation + Approved + Won Revenue + Conversion + + + + {rows.length === 0 ? ( + + + No sales ranking rows match the current filters. + + + ) : ( + rows.map((row) => ( + + {row.salesPersonName} + {formatNumber(row.leadCount)} + {formatNumber(row.opportunityCount)} + {formatNumber(row.quotationCount)} + {formatNumber(row.approvedQuotations)} + {formatNumber(row.wonRevenue)} + {formatNumber(row.conversionRate)}% + + )) + )} + +
+
+
+ ); +} diff --git a/src/features/crm/dashboard/components/dashboard-summary-cards.tsx b/src/features/crm/dashboard/components/dashboard-summary-cards.tsx new file mode 100644 index 0000000..a3edd47 --- /dev/null +++ b/src/features/crm/dashboard/components/dashboard-summary-cards.tsx @@ -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 ( + + + {title} + {description} + + + {items.map((item) => ( +
+
{item.label}
+
+ {formatNumber(item.value)} + {item.suffix ? {item.suffix} : null} +
+
+ ))} +
+
+ ); +} + +export function DashboardSummaryCards({ summary }: { summary: CrmDashboardSummary }) { + return ( +
+ + + + +
+ ); +} diff --git a/src/features/crm/dashboard/server/service.ts b/src/features/crm/dashboard/server/service.ts new file mode 100644 index 0000000..2650709 --- /dev/null +++ b/src/features/crm/dashboard/server/service.ts @@ -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; + quotationStatusById: Map; +}; + +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 { + 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 { + 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, + scopedQuotations: Array, + 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, + 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>, + wonRows: Awaited> + ): 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, + scopedQuotations: Array, + statusMaps: StatusMaps +): Promise { + 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(); + + 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((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((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 { + 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 { + 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 + }; +} diff --git a/src/features/foundation/approval/components/approval-workflow-settings.tsx b/src/features/foundation/approval/components/approval-workflow-settings.tsx new file mode 100644 index 0000000..7330a61 --- /dev/null +++ b/src/features/foundation/approval/components/approval-workflow-settings.tsx @@ -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(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 ( + + + + {workflow ? 'Edit Workflow' : 'Create Workflow'} + Sequential workflow only. No parallel or conditional logic is introduced here. + +
+ setState((current) => ({ ...current, code: e.target.value }))} /> + setState((current) => ({ ...current, name: e.target.value }))} /> + setState((current) => ({ ...current, entityType: e.target.value }))} /> +
+
+
Active
+
Inactive workflows stay available in audit history only.
+
+ setState((current) => ({ ...current, isActive: checked }))} /> +
+ + + + +
+
+
+ ); +} + +function StepsDialog({ + workflow, + open, + onOpenChange +}: { + workflow: ApprovalWorkflowDetail; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [steps, setSteps] = React.useState(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 ( + + + + Manage Steps + Each step resolves to a CRM business role and runs strictly in order. + +
+ {steps.map((step, index) => ( +
+ updateStep(index, 'stepNumber', e.target.value)} /> + updateStep(index, 'roleCode', e.target.value)} /> + updateStep(index, 'roleName', e.target.value)} /> +
+ Required + updateStep(index, 'isRequired', checked)} /> +
+
+ ))} + + + + + +
+
+
+ ); +} + +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 ( + <> +
+ + + +
+
+ {workflow.steps.map((step) => ( +
+
Step {step.stepNumber}
+
{step.roleName}
+
+ {step.roleCode} + + {step.isRequired ? 'Required' : 'Optional'} + +
+
+ ))} +
+ + + + ); +} + +export function ApprovalWorkflowSettings() { + const { data } = useSuspenseQuery(approvalWorkflowsQueryOptions()); + const [createOpen, setCreateOpen] = React.useState(false); + + return ( +
+
+
+
Approval Workflow Admin
+
Configure sequential CRM approval workflows and business-role steps.
+
+ +
+ + {data.items.length === 0 ? ( +
+ No approval workflows configured. +
+ ) : ( + data.items.map((workflow) => ( +
+
+
+
{workflow.name}
+
+ {workflow.code} • {workflow.entityType} +
+
+
+ + {workflow.isActive ? 'Active' : 'Inactive'} + + {workflow.stepCount} steps +
+
+ Loading workflow detail...
}> + + +
+ )) + )} + + +
+ ); +} diff --git a/src/features/foundation/approval/mutations.ts b/src/features/foundation/approval/mutations.ts index 194ba01..aa4f7f5 100644 --- a/src/features/foundation/approval/mutations.ts +++ b/src/features/foundation/approval/mutations.ts @@ -4,14 +4,23 @@ import { getQueryClient } from '@/lib/query-client'; import { approveApproval, cancelApproval, + createApprovalWorkflow, + deleteApprovalWorkflow, rejectApproval, + replaceApprovalWorkflowSteps, returnApproval, submitApproval, - submitQuotationForApproval + submitQuotationForApproval, + updateApprovalWorkflow } from './service'; -import { approvalKeys } from './queries'; +import { approvalKeys, approvalWorkflowKeys } from './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() { await getQueryClient().invalidateQueries({ queryKey: approvalKeys.lists() }); @@ -21,6 +30,14 @@ async function invalidateApprovalDetail(id: string) { 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) { const queryClient = getQueryClient(); 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; + }) => 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) + ]); + } + } +}); diff --git a/src/features/foundation/approval/queries.ts b/src/features/foundation/approval/queries.ts index 54ac1cd..e20b328 100644 --- a/src/features/foundation/approval/queries.ts +++ b/src/features/foundation/approval/queries.ts @@ -1,5 +1,10 @@ import { queryOptions } from '@tanstack/react-query'; -import { getApprovalById, getApprovals } from './service'; +import { + getApprovalById, + getApprovalWorkflowById, + getApprovalWorkflows, + getApprovals +} from './service'; import type { ApprovalFilters } from './types'; export const approvalKeys = { @@ -10,6 +15,14 @@ export const approvalKeys = { 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) => queryOptions({ queryKey: approvalKeys.list(filters), @@ -21,3 +34,15 @@ export const approvalByIdOptions = (id: string) => queryKey: approvalKeys.detail(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) + }); diff --git a/src/features/foundation/approval/server/service.ts b/src/features/foundation/approval/server/service.ts index a085634..b7cc406 100644 --- a/src/features/foundation/approval/server/service.ts +++ b/src/features/foundation/approval/server/service.ts @@ -19,9 +19,13 @@ import type { ApprovalDetailRecord, ApprovalFilters, ApprovalListItem, + ApprovalStepMutationPayload, ApprovalRequestRecord, ApprovalStepRecord, ApprovalTimelineItem, + ApprovalWorkflowDetail, + ApprovalWorkflowListItem, + ApprovalWorkflowMutationPayload, ApprovalWorkflowRecord, SubmitApprovalPayload } from '../types'; @@ -170,6 +174,46 @@ async function assertWorkflowByCode(organizationId: string, code: string, entity 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) { const [request] = await db .select() @@ -209,6 +253,204 @@ async function listWorkflowSteps( return rows.map(mapStepRecord); } +export async function listApprovalWorkflows( + organizationId: string +): Promise { + 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 { + 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 { + 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 +): Promise { + 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 { + 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 +): Promise { + 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 { + 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( organizationId: string, entityType: string, diff --git a/src/features/foundation/approval/service.ts b/src/features/foundation/approval/service.ts index cba5239..4330753 100644 --- a/src/features/foundation/approval/service.ts +++ b/src/features/foundation/approval/service.ts @@ -4,6 +4,10 @@ import type { ApprovalDetailResponse, ApprovalFilters, ApprovalListResponse, + ApprovalStepMutationPayload, + ApprovalWorkflowDetailResponse, + ApprovalWorkflowListResponse, + ApprovalWorkflowMutationPayload, MutationSuccessResponse, SubmitApprovalPayload } from './types'; @@ -67,3 +71,46 @@ export async function submitQuotationForApproval(id: string, remark?: string) { body: JSON.stringify({ remark }) }); } + +const SETTINGS_BASE = '/crm/settings/approval-workflows'; + +export async function getApprovalWorkflows() { + return apiClient(SETTINGS_BASE); +} + +export async function getApprovalWorkflowById(id: string) { + return apiClient(`${SETTINGS_BASE}/${id}`); +} + +export async function createApprovalWorkflow(data: ApprovalWorkflowMutationPayload) { + return apiClient(SETTINGS_BASE, { + method: 'POST', + body: JSON.stringify(data) + }); +} + +export async function updateApprovalWorkflow( + id: string, + data: Partial +) { + return apiClient(`${SETTINGS_BASE}/${id}`, { + method: 'PATCH', + body: JSON.stringify(data) + }); +} + +export async function deleteApprovalWorkflow(id: string) { + return apiClient(`${SETTINGS_BASE}/${id}`, { + method: 'DELETE' + }); +} + +export async function replaceApprovalWorkflowSteps( + workflowId: string, + data: ApprovalStepMutationPayload[] +) { + return apiClient(`${SETTINGS_BASE}/${workflowId}/steps`, { + method: 'PUT', + body: JSON.stringify({ steps: data }) + }); +} diff --git a/src/features/foundation/approval/types.ts b/src/features/foundation/approval/types.ts index 633c7ae..fb91b1a 100644 --- a/src/features/foundation/approval/types.ts +++ b/src/features/foundation/approval/types.ts @@ -23,6 +23,14 @@ export interface ApprovalStepRecord { deletedAt: string | null; } +export interface ApprovalWorkflowListItem extends ApprovalWorkflowRecord { + stepCount: number; +} + +export interface ApprovalWorkflowDetail extends ApprovalWorkflowRecord { + steps: ApprovalStepRecord[]; +} + export interface ApprovalRequestRecord { id: string; organizationId: string; @@ -98,6 +106,20 @@ export interface ApprovalActionPayload { 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 { success: boolean; time: string; @@ -115,6 +137,20 @@ export interface ApprovalDetailResponse { 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 { success: boolean; message: string; diff --git a/src/features/foundation/document-sequence/client-service.ts b/src/features/foundation/document-sequence/client-service.ts new file mode 100644 index 0000000..6250ce6 --- /dev/null +++ b/src/features/foundation/document-sequence/client-service.ts @@ -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(BASE_PATH); +} + +export async function getDocumentSequenceById(id: string) { + return apiClient(`${BASE_PATH}/${id}`); +} + +export async function createDocumentSequence(data: DocumentSequenceMutationPayload) { + return apiClient(BASE_PATH, { + method: 'POST', + body: JSON.stringify(data) + }); +} + +export async function updateDocumentSequence( + id: string, + data: Partial +) { + return apiClient(`${BASE_PATH}/${id}`, { + method: 'PATCH', + body: JSON.stringify(data) + }); +} + +export async function deleteDocumentSequence(id: string) { + return apiClient(`${BASE_PATH}/${id}`, { + method: 'DELETE' + }); +} + +export async function previewDocumentSequence(id: string) { + return apiClient(`${BASE_PATH}/${id}/preview`); +} + +export async function resetDocumentSequence(id: string, data: DocumentSequenceResetPayload) { + return apiClient(`${BASE_PATH}/${id}/reset`, { + method: 'POST', + body: JSON.stringify(data) + }); +} diff --git a/src/features/foundation/document-sequence/components/document-sequence-settings.tsx b/src/features/foundation/document-sequence/components/document-sequence-settings.tsx new file mode 100644 index 0000000..ff8ff56 --- /dev/null +++ b/src/features/foundation/document-sequence/components/document-sequence-settings.tsx @@ -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(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 ( + + + + {sequence ? 'Edit Sequence' : 'Create Sequence'} + Preview never increments the counter. Generation remains server-side only. + +
+ setState((current) => ({ ...current, documentType: e.target.value }))} /> +
+ setState((current) => ({ ...current, prefix: e.target.value }))} /> + setState((current) => ({ ...current, period: e.target.value }))} /> +
+
+ setState((current) => ({ ...current, branchId: e.target.value }))} /> + setState((current) => ({ ...current, currentNumber: e.target.value }))} /> + setState((current) => ({ ...current, paddingLength: e.target.value }))} /> +
+
+
+
Active
+
Inactive sequences can stay preserved for legacy numbering.
+
+ setState((current) => ({ ...current, isActive: checked }))} /> +
+ + + + +
+
+
+ ); +} + +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 ( +
+
+
+
Document Sequence Admin
+
Maintain organization-scoped numbering strategy without rewriting historic document codes.
+
+ +
+ + {data.items.length === 0 ? ( +
+ No document sequences configured yet. +
+ ) : ( + data.items.map((sequence) => ( +
+
+
+
{sequence.documentType}
+
+ {sequence.prefix}{sequence.period} • branch {sequence.branchId || 'default'} +
+
+
+ + {sequence.isActive ? 'Active' : 'Inactive'} + + Next {sequence.nextPreview} +
+
+
+
+
Current Number
+
{sequence.currentNumber}
+
+
+
Padding Length
+
{sequence.paddingLength}
+
+
+
Updated At
+
{new Date(sequence.updatedAt).toLocaleString()}
+
+
+
Preview
+
{sequence.nextPreview}
+
+
+
+ + + +
+
+ )) + )} + + setDialogState((current) => ({ ...current, open }))} + sequence={dialogState.sequence} + /> +
+ ); +} diff --git a/src/features/foundation/document-sequence/mutations.ts b/src/features/foundation/document-sequence/mutations.ts new file mode 100644 index 0000000..48d8721 --- /dev/null +++ b/src/features/foundation/document-sequence/mutations.ts @@ -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 }) => + 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) + ]); + } + } +}); diff --git a/src/features/foundation/document-sequence/queries.ts b/src/features/foundation/document-sequence/queries.ts new file mode 100644 index 0000000..26a5248 --- /dev/null +++ b/src/features/foundation/document-sequence/queries.ts @@ -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) + }); diff --git a/src/features/foundation/document-sequence/service.ts b/src/features/foundation/document-sequence/service.ts index a5dce15..f2ceabe 100644 --- a/src/features/foundation/document-sequence/service.ts +++ b/src/features/foundation/document-sequence/service.ts @@ -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 { db } from '@/lib/db'; +import { AuthError } from '@/lib/auth/session'; 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 = { customer: 'CUS', @@ -28,6 +36,38 @@ function buildDocumentCode( 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) { if (organizationId) { return organizationId; @@ -42,6 +82,22 @@ async function resolveOrganizationId(organizationId?: string) { 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( organizationId: string, documentType: string, @@ -95,12 +151,116 @@ function toSequenceResult(row: typeof documentSequences.$inferSelect): DocumentS }; } +export async function listDocumentSequences(organizationId: string): Promise { + 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 { + const row = await assertSequence(id, organizationId); + return mapSequenceRecord(row); +} + +export async function createDocumentSequence( + organizationId: string, + payload: DocumentSequenceMutationPayload +): Promise { + 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 +): Promise { + 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 { + 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 { + 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 { + const sequence = await assertSequence(id, organizationId); + return toSequenceResult(sequence); +} + export async function previewNextDocumentCode( input: DocumentSequenceInput ): Promise { const organizationId = await resolveOrganizationId(input.organizationId); const period = input.period ?? getCurrentPeriod(); - const branchId = input.branchId ?? ''; + const branchId = normalizeBranchId(input.branchId); const sequence = await ensureSequence(organizationId, input.documentType, period, branchId); return toSequenceResult(sequence); @@ -111,7 +271,7 @@ export async function generateNextDocumentCode( ): Promise { const organizationId = await resolveOrganizationId(input.organizationId); const period = input.period ?? getCurrentPeriod(); - const branchId = input.branchId ?? ''; + const branchId = normalizeBranchId(input.branchId); await ensureSequence(organizationId, input.documentType, period, branchId); diff --git a/src/features/foundation/document-sequence/types.ts b/src/features/foundation/document-sequence/types.ts index 0f9e4ec..160368f 100644 --- a/src/features/foundation/document-sequence/types.ts +++ b/src/features/foundation/document-sequence/types.ts @@ -5,6 +5,20 @@ export interface DocumentSequenceInput { 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 { code: string; documentType: string; @@ -14,3 +28,47 @@ export interface DocumentSequenceResult { period: 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; +} diff --git a/src/features/foundation/document-template/components/template-settings.tsx b/src/features/foundation/document-template/components/template-settings.tsx index 506881c..848b297 100644 --- a/src/features/foundation/document-template/components/template-settings.tsx +++ b/src/features/foundation/document-template/components/template-settings.tsx @@ -1,28 +1,733 @@ '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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { documentTemplatesQueryOptions } from '../queries'; +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 { 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 ( +
+
{label}
+ {children} +
+ ); +} + +function TemplateDialog({ + open, + onOpenChange, + template +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + template?: DocumentTemplateDetail; +}) { + const [state, setState] = React.useState(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 ( + + + + {template ? 'Edit Template' : 'Create Template'} + Manage template metadata for CRM document generation. + +
+
+ + setState((current) => ({ ...current, templateName: e.target.value }))} /> + + + setState((current) => ({ ...current, documentType: e.target.value }))} /> + + + setState((current) => ({ ...current, productType: e.target.value }))} /> + + + setState((current) => ({ ...current, fileType: e.target.value }))} /> + +
+ +