From c2a74b67647a7ba5351f474207f358617c7d2f72 Mon Sep 17 00:00:00 2001 From: phaichayon Date: Thu, 25 Jun 2026 12:16:41 +0700 Subject: [PATCH] task-d5.5.1 --- .editorconfig | 12 + .gitattributes | 8 + ...force-rename-enquiry-domain-opportunity.md | 60 ++ .../crm-access-enforcement-inventory.md | 2 +- docs/standards/project-foundations.md | 40 +- docs/standards/ui-ux-rules.md | 4 +- drizzle.config.ts | 2 +- ...wift_prism.sql => 0000_hard_butterfly.sql} | 76 +- drizzle/meta/0000_snapshot.json | 428 +++++------ drizzle/meta/_journal.json | 4 +- next-env.d.ts | 2 +- package.json | 5 +- plans/task-d.5.5.1.md | 470 ++++++++++++ scripts/verify-encoding.mjs | 95 +++ src/app/api/crm/dashboard/export/route.ts | 3 +- src/app/api/crm/leads/[id]/assign/route.ts | 7 +- .../[id]/_schemas.ts | 3 +- .../[id]/assign/route.ts | 23 +- .../[id]/customers/route.ts | 11 +- .../[id]/followups/[followupId]/route.ts | 37 +- .../[id]/followups/route.ts | 29 +- .../[id]/mark-lost}/route.ts | 23 +- .../[id]/mark-won}/route.ts | 23 +- .../po-attachments/[attachmentId]/route.ts | 7 +- .../[id]/po-attachments/route.ts | 15 +- .../[id]/reassign/route.ts | 21 +- .../[id]/reopen}/route.ts | 23 +- .../[id]/route.ts | 57 +- .../crm/{enquiries => opportunities}/route.ts | 33 +- src/app/api/crm/quotations/route.ts | 5 +- src/app/api/crm/reports/export/route.ts | 63 +- .../route.ts | 7 +- .../crm-demo/customers/[id]/page.tsx | 3 +- .../crm-demo/enquiries/[id]/page.tsx | 13 +- src/app/dashboard/crm-demo/enquiries/page.tsx | 15 +- src/app/dashboard/crm-demo/page.tsx | 3 +- src/app/dashboard/crm/customers/[id]/page.tsx | 9 +- src/app/dashboard/crm/enquiries/page.tsx | 83 --- src/app/dashboard/crm/leads/page.tsx | 3 +- .../[id]/page.tsx | 59 +- src/app/dashboard/crm/opportunities/page.tsx | 93 +++ .../page.tsx | 17 +- .../dashboard/crm/reports/pipeline/page.tsx | 3 +- src/config/nav-config.ts | 383 +++++----- src/db/schema.ts | 21 +- src/db/seeds/foundation.seed.ts | 27 +- src/features/crm-demo/api/mutations.ts | 7 +- src/features/crm-demo/api/queries.ts | 23 +- src/features/crm-demo/api/service.ts | 101 +-- src/features/crm-demo/api/types.ts | 29 +- .../crm-demo/components/crm-dashboard.tsx | 5 +- .../crm-demo/components/customer-detail.tsx | 15 +- ...ries-table.tsx => opportunities-table.tsx} | 35 +- ...uiry-detail.tsx => opportunity-detail.tsx} | 29 +- .../crm-demo/components/quotations-table.tsx | 3 +- src/features/crm-demo/components/shared.tsx | 9 +- src/features/crm-demo/data/mock-crm.ts | 31 +- src/features/crm-demo/data/options.ts | 3 +- src/features/crm-demo/utils/status.ts | 5 +- src/features/crm/customers/api/types.ts | 3 +- .../customers/components/customer-detail.tsx | 21 +- src/features/crm/customers/server/service.ts | 35 +- src/features/crm/dashboard/api/types.ts | 21 +- .../components/dashboard-followups.tsx | 5 +- .../components/dashboard-hot-projects.tsx | 7 +- .../components/dashboard-sales-ranking.tsx | 4 +- .../components/dashboard-summary-cards.tsx | 11 +- src/features/crm/dashboard/server/service.ts | 155 ++-- src/features/crm/enquiries/api/mutations.ts | 190 ----- src/features/crm/enquiries/api/queries.ts | 53 -- src/features/crm/enquiries/api/service.ts | 133 ---- src/features/crm/leads/api/types.ts | 3 +- .../components/lead-assignment-dialog.tsx | 21 +- .../crm/leads/components/lead-detail.tsx | 29 +- .../crm/leads/server/assignment.service.ts | 85 +-- src/features/crm/leads/server/service.ts | 55 +- src/features/crm/leads/types.ts | 13 +- .../crm/opportunities/api/mutations.ts | 191 +++++ src/features/crm/opportunities/api/queries.ts | 54 ++ src/features/crm/opportunities/api/service.ts | 134 ++++ .../{enquiries => opportunities}/api/types.ts | 103 +-- .../components/followup-form-sheet.tsx | 41 +- .../components/opportunities-table.tsx} | 17 +- .../opportunity-assignment-dialog.tsx} | 47 +- .../components/opportunity-cell-action.tsx} | 25 +- .../components/opportunity-columns.tsx} | 65 +- .../components/opportunity-detail.tsx} | 113 +-- .../components/opportunity-followups-tab.tsx} | 25 +- .../components/opportunity-form-sheet.tsx} | 115 +-- .../components/opportunity-listing.tsx} | 17 +- .../components/opportunity-outcome-card.tsx} | 117 +-- .../components/opportunity-status-badge.tsx} | 3 +- .../components/quotation-readiness-panel.tsx | 17 +- .../components/source-lead-card.tsx | 0 .../schemas/opportunity.schema.ts} | 21 +- .../server/service.ts | 689 +++++++++--------- src/features/crm/quotations/api/service.ts | 3 +- src/features/crm/quotations/api/types.ts | 13 +- .../components/quotation-columns.tsx | 9 +- .../components/quotation-form-sheet.tsx | 33 +- .../components/quotation-listing.tsx | 5 +- .../components/quotations-table.tsx | 5 +- .../quotations/schemas/quotation.schema.ts | 3 +- src/features/crm/quotations/server/service.ts | 137 ++-- src/features/crm/reporting/server/service.ts | 119 +-- src/features/crm/reporting/server/types.ts | 5 +- src/features/crm/reports/api/queries.ts | 13 +- src/features/crm/reports/api/service.ts | 9 +- src/features/crm/reports/api/types.ts | 33 +- .../reports/components/aging-report-view.tsx | 39 +- .../components/pipeline-report-view.tsx | 29 +- .../crm/reports/components/report-catalog.tsx | 7 +- .../datasets/pipeline-report.dataset.ts | 81 +- .../crm/reports/server/filters/shared.ts | 3 +- src/features/crm/reports/server/service.ts | 107 +-- src/features/crm/reports/server/types.ts | 5 +- src/features/crm/shared/terminology.ts | 9 +- .../foundation/document-sequence/service.ts | 5 +- .../foundation/master-options/types.ts | 3 +- src/lib/auth/rbac.ts | 145 ++-- src/lib/searchparams.ts | 3 +- 121 files changed, 3407 insertions(+), 2679 deletions(-) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 docs/implementation/task-d551-force-rename-enquiry-domain-opportunity.md rename drizzle/{0000_swift_prism.sql => 0000_hard_butterfly.sql} (98%) create mode 100644 plans/task-d.5.5.1.md create mode 100644 scripts/verify-encoding.mjs rename src/app/api/crm/{enquiries => opportunities}/[id]/_schemas.ts (69%) rename src/app/api/crm/{enquiries => opportunities}/[id]/assign/route.ts (75%) rename src/app/api/crm/{enquiries => opportunities}/[id]/customers/route.ts (79%) rename src/app/api/crm/{enquiries => opportunities}/[id]/followups/[followupId]/route.ts (78%) rename src/app/api/crm/{enquiries => opportunities}/[id]/followups/route.ts (78%) rename src/app/api/crm/{enquiries/[id]/reopen => opportunities/[id]/mark-lost}/route.ts (66%) rename src/app/api/crm/{enquiries/[id]/mark-lost => opportunities/[id]/mark-won}/route.ts (67%) rename src/app/api/crm/{enquiries => opportunities}/[id]/po-attachments/[attachmentId]/route.ts (93%) rename src/app/api/crm/{enquiries => opportunities}/[id]/po-attachments/route.ts (92%) rename src/app/api/crm/{enquiries => opportunities}/[id]/reassign/route.ts (77%) rename src/app/api/crm/{enquiries/[id]/mark-won => opportunities/[id]/reopen}/route.ts (67%) rename src/app/api/crm/{enquiries => opportunities}/[id]/route.ts (73%) rename src/app/api/crm/{enquiries => opportunities}/route.ts (82%) rename src/app/api/crm/reports/{enquiry-aging => opportunity-aging}/route.ts (82%) delete mode 100644 src/app/dashboard/crm/enquiries/page.tsx rename src/app/dashboard/crm/{enquiries => opportunities}/[id]/page.tsx (74%) create mode 100644 src/app/dashboard/crm/opportunities/page.tsx rename src/app/dashboard/crm/reports/{enquiry-aging => opportunity-aging}/page.tsx (80%) rename src/features/crm-demo/components/{enquiries-table.tsx => opportunities-table.tsx} (82%) rename src/features/crm-demo/components/{enquiry-detail.tsx => opportunity-detail.tsx} (61%) delete mode 100644 src/features/crm/enquiries/api/mutations.ts delete mode 100644 src/features/crm/enquiries/api/queries.ts delete mode 100644 src/features/crm/enquiries/api/service.ts create mode 100644 src/features/crm/opportunities/api/mutations.ts create mode 100644 src/features/crm/opportunities/api/queries.ts create mode 100644 src/features/crm/opportunities/api/service.ts rename src/features/crm/{enquiries => opportunities}/api/types.ts (68%) rename src/features/crm/{enquiries => opportunities}/components/followup-form-sheet.tsx (87%) rename src/features/crm/{enquiries/components/enquiries-table.tsx => opportunities/components/opportunities-table.tsx} (84%) rename src/features/crm/{enquiries/components/enquiry-assignment-dialog.tsx => opportunities/components/opportunity-assignment-dialog.tsx} (78%) rename src/features/crm/{enquiries/components/enquiry-cell-action.tsx => opportunities/components/opportunity-cell-action.tsx} (83%) rename src/features/crm/{enquiries/components/enquiry-columns.tsx => opportunities/components/opportunity-columns.tsx} (78%) rename src/features/crm/{enquiries/components/enquiry-detail.tsx => opportunities/components/opportunity-detail.tsx} (75%) rename src/features/crm/{enquiries/components/enquiry-followups-tab.tsx => opportunities/components/opportunity-followups-tab.tsx} (88%) rename src/features/crm/{enquiries/components/enquiry-form-sheet.tsx => opportunities/components/opportunity-form-sheet.tsx} (83%) rename src/features/crm/{enquiries/components/enquiry-listing.tsx => opportunities/components/opportunity-listing.tsx} (78%) rename src/features/crm/{enquiries/components/enquiry-outcome-card.tsx => opportunities/components/opportunity-outcome-card.tsx} (79%) rename src/features/crm/{enquiries/components/enquiry-status-badge.tsx => opportunities/components/opportunity-status-badge.tsx} (88%) rename src/features/crm/{enquiries => opportunities}/components/quotation-readiness-panel.tsx (62%) rename src/features/crm/{enquiries => opportunities}/components/source-lead-card.tsx (100%) rename src/features/crm/{enquiries/schemas/enquiry.schema.ts => opportunities/schemas/opportunity.schema.ts} (81%) rename src/features/crm/{enquiries => opportunities}/server/service.ts (67%) diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..1014ba7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..cacc129 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +* text=auto eol=lf + +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.webp binary +*.pdf binary diff --git a/docs/implementation/task-d551-force-rename-enquiry-domain-opportunity.md b/docs/implementation/task-d551-force-rename-enquiry-domain-opportunity.md new file mode 100644 index 0000000..5e78bdf --- /dev/null +++ b/docs/implementation/task-d551-force-rename-enquiry-domain-opportunity.md @@ -0,0 +1,60 @@ +# Task D.5.5.1: Force Rename Enquiry Domain Opportunity + +## Objective +Rename the sales execution domain from `Enquiry` to `Opportunity` across active application code for the development-only reset phase of D.5.5.1. + +## Review Completed +- `AGENTS.md` +- `plans/task-d.5.5.1.md` +- `plans/task-d.5.5.md` +- `docs/adr/0018-lead-enquiry-domain-separation.md` +- `docs/implementation/task-d55-opportunity-sales-workspace-refinement.md` +- `docs/security/crm-authorization-boundaries.md` +- `src/db/schema.ts` +- `src/config/nav-config.ts` +- current CRM route and feature structure + +## What Changed +- Renamed active CRM feature module from `src/features/crm/enquiries` to: + - [src/features/crm/opportunities](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/opportunities) +- Renamed API route base from `src/app/api/crm/enquiries` to: + - [src/app/api/crm/opportunities](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/opportunities) +- Renamed dashboard route base from `src/app/dashboard/crm/enquiries` to: + - [src/app/dashboard/crm/opportunities](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/dashboard/crm/opportunities) +- Renamed opportunity-facing component files, including: + - `opportunity-detail.tsx` + - `opportunities-table.tsx` + - `opportunity-form-sheet.tsx` + - `opportunity-followups-tab.tsx` + - `opportunity-assignment-dialog.tsx` + - `opportunity-outcome-card.tsx` + - `opportunity-status-badge.tsx` +- Renamed schema file: + - `opportunity.schema.ts` +- Replaced active application imports, symbols, route strings, and permission identifiers in `src/` from `enquiry` terminology to `opportunity` terminology. +- Updated active CRM permission namespace usage in code from `crmEnquiry*` to `crmOpportunity*`. +- Updated active sales route URLs from `/dashboard/crm/enquiries` to `/dashboard/crm/opportunities`. +- Updated active API URLs from `/api/crm/enquiries` to `/api/crm/opportunities`. +- Renamed active report routes from `/dashboard/crm/reports/enquiry-aging` and `/api/crm/reports/enquiry-aging` to `/dashboard/crm/reports/opportunity-aging` and `/api/crm/reports/opportunity-aging`. +- Updated active governance references in project foundations, UI/UX rules, and CRM access inventory to point at the `opportunity` domain. +- Fixed the corrupted Thai branch label in `src/db/seeds/foundation.seed.ts` and normalized touched config/seed files to UTF-8. + +## Key Rename Direction +- `Enquiry` -> `Opportunity` +- `enquiry` -> `opportunity` +- `crm_enquiries` -> `crm_opportunities` +- `crm_enquiry` -> `crm_opportunity` +- `crm.enquiry.*` -> `crm.opportunity.*` + +## Development Reset Implication +This task is implemented under the development-only breaking-change assumption from the task contract: +- database reset is expected +- migration history compatibility is not preserved +- seed/config/document-sequence data must be regenerated around the new opportunity domain + +## Verification +- `npm exec tsc --noEmit` + +## Known Follow-up Areas +- Historical docs under `docs/**` still contain legacy enquiry references where they describe earlier phases or legacy architecture. +- Database reset, migration regeneration, and seed verification still need to be executed explicitly in the development environment before treating the rename as operationally complete. diff --git a/docs/security/crm-access-enforcement-inventory.md b/docs/security/crm-access-enforcement-inventory.md index df48305..0d2f168 100644 --- a/docs/security/crm-access-enforcement-inventory.md +++ b/docs/security/crm-access-enforcement-inventory.md @@ -13,7 +13,7 @@ | --- | --- | --- | --- | | Customers | `/api/crm/customers` | `Protected` | List/detail/update/delete now pass resolved CRM scope context and customer visibility is filtered by ownership plus related enquiry/quotation signals | | Contacts | `/api/crm/customers/[id]/contacts` | `Protected` | Contact APIs now inherit customer visibility and creator/admin/team visibility rules from resolved CRM access | -| Leads / Enquiries | `/api/crm/enquiries` and child routes | `Partially Protected` | Uses branch/product/own-scope logic already, but still passes legacy `businessRole` fields through route/service seams | +| Leads / Opportunities | `/api/crm/opportunities` and child routes | `Partially Protected` | Uses branch/product/own-scope logic already, but still passes legacy `businessRole` fields through route/service seams | | Quotations | `/api/crm/quotations` and `[id]` | `Protected` | List/detail/create/update/delete now pass resolved CRM scope context and enforce branch/product/own visibility | | Quotation pricing surfaces | document data, preview, PDF preview/download, approved PDF | `Protected` | Pricing-bearing outputs now require pricing visibility and emit `crm_security_access` audit events when denied | | Document artifacts | `/api/crm/document-artifacts/[id]/download` | `Protected` | Approved quotation artifacts now require artifact permission plus pricing visibility guard | diff --git a/docs/standards/project-foundations.md b/docs/standards/project-foundations.md index b3cf42f..0b45988 100644 --- a/docs/standards/project-foundations.md +++ b/docs/standards/project-foundations.md @@ -113,7 +113,7 @@ If ADR coverage is incomplete, this registry marks the gap explicitly. | Related ADRs | ADR-0006. Governance gap: no dedicated ADR yet for numbering policy or reset policy. | | Reusable services | `listDocumentSequences()`, `getDocumentSequence()`, `createDocumentSequence()`, `updateDocumentSequence()`, `resetDocumentSequence()`, `previewDocumentSequenceById()`, `previewNextDocumentCode()`, `generateNextDocumentCode()` | | Reusable APIs | `/api/crm/settings/document-sequences`, `/api/crm/settings/document-sequences/[id]`, `/api/crm/settings/document-sequences/[id]/preview`, `/api/crm/settings/document-sequences/[id]/reset` | -| Reuse status | Reusable and active. Current production source for customer, contact, enquiry, quotation, and approval codes. | +| Reuse status | Reusable and active. Current production source for customer, contact, opportunity, quotation, and approval codes. | ### Document Template Foundation @@ -247,31 +247,31 @@ If ADR coverage is incomplete, this registry marks the gap explicitly. | Reusable services | None production-ready yet. D.5.1 only stabilizes schema and seed foundations before D.5.2 API work. | | Reusable APIs | None yet. Lead API surface is explicitly deferred to Task D.5.2. | | Reuse status | Foundation introduced and partially stabilized. Schema and seed contracts exist, but feature services, APIs, UI, migration flows, and separated datasets are not complete yet. | -| Governance note | This foundation supersedes the long-term direction of the old single-record lead/enquiry model in ADR-0011, but current production behavior still largely runs on the shared `crm_enquiries` implementation until later D.5 phases are completed. | +| Governance note | This foundation supersedes the old shared lead/enquiry model in ADR-0011. After Task D.5.5.1, active sales execution behavior runs on the renamed `crm_opportunities` implementation while older ADRs and implementation notes remain historical references. | -### Lead / Enquiry Foundation +### Lead / Opportunity Foundation | Field | Details | | --- | --- | -| Purpose | Single-record `crm_enquiries` lifecycle with marketing-owned leads, sales-owned enquiries, assignment governance, related project parties, and follow-up child resources | -| Location | `src/features/crm/enquiries/**`, `src/app/api/crm/enquiries/**`, `docs/adr/0011-lead-enquiry-ownership-model.md` | +| Purpose | Shared opportunity lifecycle with linked lead provenance, assignment governance, related project parties, and follow-up child resources | +| Location | `src/features/crm/opportunities/**`, `src/app/api/crm/opportunities/**`, `docs/adr/0011-lead-enquiry-ownership-model.md`, `docs/implementation/task-d551-force-rename-enquiry-domain-opportunity.md` | | Related tasks | Task D, Task D.1, Task D.3, Task D.3.1 | | Related ADRs | ADR-0011, ADR-0014 | -| Reusable services | `getEnquiryReferenceData()`, `listEnquiries()`, `getEnquiryDetail()`, `createEnquiry()`, `updateEnquiry()`, `softDeleteEnquiry()`, `assignEnquiryToUser()`, `reassignEnquiryToUser()`, `listEnquiryProjectParties()` | -| Reusable APIs | `/api/crm/enquiries`, `/api/crm/enquiries/[id]`, `/api/crm/enquiries/[id]/assign`, `/api/crm/enquiries/[id]/reassign`, `/api/crm/enquiries/[id]/customers` | -| Reuse status | Reusable and active. This is the source of truth for lead versus enquiry separation. | -| Governance note | ADR-0018 introduces the target split-domain direction, but this shared enquiry foundation still describes the active production service layer until D.5 migration phases complete. | +| Reusable services | `getOpportunityReferenceData()`, `listOpportunities()`, `getOpportunityDetail()`, `createOpportunity()`, `updateOpportunity()`, `softDeleteOpportunity()`, `assignOpportunityToUser()`, `reassignOpportunityToUser()`, `listOpportunityProjectParties()` | +| Reusable APIs | `/api/crm/opportunities`, `/api/crm/opportunities/[id]`, `/api/crm/opportunities/[id]/assign`, `/api/crm/opportunities/[id]/reassign`, `/api/crm/opportunities/[id]/customers` | +| Reuse status | Reusable and active. This is the source of truth for the sales opportunity domain. | +| Governance note | Historical ADR/task lineage may still say `enquiry`, but active application code, APIs, and schema were force-renamed to `opportunity` in Task D.5.5.1. | ### Won / Lost Governance Foundation | Field | Details | | --- | --- | -| Purpose | Freeze business outcome lifecycle on `crm_enquiries`, including PO capture, lost reason governance, and reopen rules | -| Location | `src/features/crm/enquiries/**`, `src/app/api/crm/enquiries/[id]/mark-won/route.ts`, `src/app/api/crm/enquiries/[id]/mark-lost/route.ts`, `src/app/api/crm/enquiries/[id]/reopen/route.ts` | +| Purpose | Freeze business outcome lifecycle on `crm_opportunities`, including PO capture, lost reason governance, and reopen rules | +| Location | `src/features/crm/opportunities/**`, `src/app/api/crm/opportunities/[id]/mark-won/route.ts`, `src/app/api/crm/opportunities/[id]/mark-lost/route.ts`, `src/app/api/crm/opportunities/[id]/reopen/route.ts` | | Related tasks | Task D.4, Task J, Task K | | Related ADRs | ADR-0016 | -| Reusable services | `markEnquiryAsWon()`, `markEnquiryAsLost()`, `reopenLostEnquiry()`, PO attachment helpers under enquiry service | -| Reusable APIs | `/api/crm/enquiries/[id]/mark-won`, `/api/crm/enquiries/[id]/mark-lost`, `/api/crm/enquiries/[id]/reopen`, `/api/crm/enquiries/[id]/po-attachments` | +| Reusable services | `markOpportunityAsWon()`, `markOpportunityAsLost()`, `reopenLostOpportunity()`, PO attachment helpers under opportunity service | +| Reusable APIs | `/api/crm/opportunities/[id]/mark-won`, `/api/crm/opportunities/[id]/mark-lost`, `/api/crm/opportunities/[id]/reopen`, `/api/crm/opportunities/[id]/po-attachments` | | Reuse status | Reusable and active. Mandatory for outcome transitions and outcome-driven KPI/report work. | ### Quotation Foundation @@ -328,14 +328,14 @@ If ADR coverage is incomplete, this registry marks the gap explicitly. | Field | Details | | --- | --- | -| Purpose | Shared follow-up tracking across enquiries and quotations, including list/detail child resources, dashboard analytics, and report visibility | -| Location | `src/features/crm/enquiries/**`, `src/features/crm/quotations/**`, `src/features/crm/dashboard/**`, `src/features/crm/reports/server/datasets/pipeline-report.dataset.ts` | +| Purpose | Shared follow-up tracking across opportunities and quotations, including list/detail child resources, dashboard analytics, and report visibility | +| Location | `src/features/crm/opportunities/**`, `src/features/crm/quotations/**`, `src/features/crm/dashboard/**`, `src/features/crm/reports/server/datasets/pipeline-report.dataset.ts` | | Related tasks | Task D, Task E, Task J, Task K | | Related ADRs | ADR-0011, ADR-0016, ADR-0017 | -| Reusable services | `listEnquiryFollowups()`, `createEnquiryFollowup()`, `updateEnquiryFollowup()`, `softDeleteEnquiryFollowup()`, quotation follow-up mutation and query flows under `src/features/crm/quotations/server/service.ts`, dashboard follow-up analytics in `getCrmDashboardData()` | -| Reusable APIs | `/api/crm/enquiries/[id]/followups`, `/api/crm/enquiries/[id]/followups/[followupId]`, `/api/crm/quotations/[id]/followups` | +| Reusable services | `listOpportunityFollowups()`, `createOpportunityFollowup()`, `updateOpportunityFollowup()`, `softDeleteOpportunityFollowup()`, quotation follow-up mutation and query flows under `src/features/crm/quotations/server/service.ts`, dashboard follow-up analytics in `getCrmDashboardData()` | +| Reusable APIs | `/api/crm/opportunities/[id]/followups`, `/api/crm/opportunities/[id]/followups/[followupId]`, `/api/crm/quotations/[id]/followups` | | Reuse status | Active but fragmented. | -| Governance gap | There is no single shared standalone follow-up server foundation yet. Reuse existing enquiry and quotation follow-up services rather than building a third implementation. | +| Governance gap | There is no single shared standalone follow-up server foundation yet. Reuse existing opportunity and quotation follow-up services rather than building a third implementation. | ### Reporting Foundation @@ -345,8 +345,8 @@ If ADR coverage is incomplete, this registry marks the gap explicitly. | Location | `src/features/crm/reports/**`, `src/app/api/crm/reports/**`, `docs/adr/0017-report-foundation.md` | | Related tasks | Task K.1, Task K.2 | | Related ADRs | ADR-0010, ADR-0011, ADR-0016, ADR-0017 | -| Reusable services | `buildResolvedReportContext()`, `getCrmReportsResponse()`, `getCrmReportCatalogResponse()`, `getCrmReportFiltersResponse()`, `getCrmPipelineReportResponse()`, `getCrmLeadAgingReportResponse()`, `getCrmEnquiryAgingReportResponse()`, `buildSharedReportFilters()`, export helpers in `server/exports/service.ts` | -| Reusable APIs | `/api/crm/reports`, `/api/crm/reports/catalog`, `/api/crm/reports/filters`, `/api/crm/reports/pipeline`, `/api/crm/reports/lead-aging`, `/api/crm/reports/enquiry-aging`, `/api/crm/reports/export` | +| Reusable services | `buildResolvedReportContext()`, `getCrmReportsResponse()`, `getCrmReportCatalogResponse()`, `getCrmReportFiltersResponse()`, `getCrmPipelineReportResponse()`, `getCrmLeadAgingReportResponse()`, `getCrmOpportunityAgingReportResponse()`, `buildSharedReportFilters()`, export helpers in `server/exports/service.ts` | +| Reusable APIs | `/api/crm/reports`, `/api/crm/reports/catalog`, `/api/crm/reports/filters`, `/api/crm/reports/pipeline`, `/api/crm/reports/lead-aging`, `/api/crm/reports/opportunity-aging`, `/api/crm/reports/export` | | Reuse status | Reusable and active. Mandatory for new CRM reports and exports. | ## Governance Gaps And Duplication Risks diff --git a/docs/standards/ui-ux-rules.md b/docs/standards/ui-ux-rules.md index 4ea7efb..1b5e22a 100644 --- a/docs/standards/ui-ux-rules.md +++ b/docs/standards/ui-ux-rules.md @@ -44,7 +44,7 @@ Reference docs and files: - `docs/forms.md` - `src/features/crm/customers/components/customer-form-sheet.tsx` -- `src/features/crm/enquiries/components/enquiry-form-sheet.tsx` +- `src/features/crm/opportunities/components/opportunity-form-sheet.tsx` - `src/features/crm/quotations/components/quotation-form-sheet.tsx` ## Table Standards @@ -59,7 +59,7 @@ Reference files: - `src/components/ui/table/data-table.tsx` - `src/features/users/components/users-table/index.tsx` - `src/features/crm/customers/components/customers-table.tsx` -- `src/features/crm/enquiries/components/enquiries-table.tsx` +- `src/features/crm/opportunities/components/opportunities-table.tsx` ## Filter Standards diff --git a/drizzle.config.ts b/drizzle.config.ts index ab4faef..6271753 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,6 +1,6 @@ import { defineConfig } from "drizzle-kit"; import "dotenv/config"; -console.log("DATABASE_URL is:", process.env.DATABASE_URL); // ดูผลลัพธ์ตอนรัน drizzle-kit +console.log("DATABASE_URL is:", process.env.DATABASE_URL); // Inspect env while running drizzle-kit export default defineConfig({ schema: "./src/db/schema.ts", out: "./drizzle", diff --git a/drizzle/0000_swift_prism.sql b/drizzle/0000_hard_butterfly.sql similarity index 98% rename from drizzle/0000_swift_prism.sql rename to drizzle/0000_hard_butterfly.sql index 16af9c4..4f30360 100644 --- a/drizzle/0000_swift_prism.sql +++ b/drizzle/0000_hard_butterfly.sql @@ -226,7 +226,36 @@ CREATE TABLE "crm_document_templates" ( "updated_by" text NOT NULL ); --> statement-breakpoint -CREATE TABLE "crm_enquiries" ( +CREATE TABLE "crm_leads" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "branch_id" text, + "code" text NOT NULL, + "customer_id" text, + "contact_id" text, + "description" text, + "project_name" text, + "project_location" text, + "product_type" text, + "priority" text, + "estimated_value" double precision, + "awareness_id" text, + "status" text DEFAULT 'new_job' NOT NULL, + "followup_status" text, + "lost_reason" text, + "outcome" text DEFAULT 'open' NOT NULL, + "owner_marketing_user_id" text, + "assigned_sales_owner_id" text, + "assigned_at" timestamp with time zone, + "assigned_by" text, + "assignment_remark" text, + "created_by" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "deleted_at" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE "crm_opportunities" ( "id" text PRIMARY KEY NOT NULL, "organization_id" text NOT NULL, "branch_id" text, @@ -273,10 +302,10 @@ CREATE TABLE "crm_enquiries" ( "updated_by" text NOT NULL ); --> statement-breakpoint -CREATE TABLE "crm_enquiry_attachments" ( +CREATE TABLE "crm_opportunity_attachments" ( "id" text PRIMARY KEY NOT NULL, "organization_id" text NOT NULL, - "enquiry_id" text NOT NULL, + "opportunity_id" text NOT NULL, "category" text NOT NULL, "file_name" text NOT NULL, "original_file_name" text NOT NULL, @@ -292,10 +321,10 @@ CREATE TABLE "crm_enquiry_attachments" ( "deleted_at" timestamp with time zone ); --> statement-breakpoint -CREATE TABLE "crm_enquiry_customers" ( +CREATE TABLE "crm_opportunity_customers" ( "id" text PRIMARY KEY NOT NULL, "organization_id" text NOT NULL, - "enquiry_id" text NOT NULL, + "opportunity_id" text NOT NULL, "customer_id" text NOT NULL, "role" text NOT NULL, "remark" text, @@ -304,10 +333,10 @@ CREATE TABLE "crm_enquiry_customers" ( "deleted_at" timestamp with time zone ); --> statement-breakpoint -CREATE TABLE "crm_enquiry_followups" ( +CREATE TABLE "crm_opportunity_followups" ( "id" text PRIMARY KEY NOT NULL, "organization_id" text NOT NULL, - "enquiry_id" text NOT NULL, + "opportunity_id" text NOT NULL, "followup_date" timestamp with time zone NOT NULL, "followup_type" text NOT NULL, "contact_id" text, @@ -322,35 +351,6 @@ CREATE TABLE "crm_enquiry_followups" ( "updated_by" text NOT NULL ); --> statement-breakpoint -CREATE TABLE "crm_leads" ( - "id" text PRIMARY KEY NOT NULL, - "organization_id" text NOT NULL, - "branch_id" text, - "code" text NOT NULL, - "customer_id" text, - "contact_id" text, - "description" text, - "project_name" text, - "project_location" text, - "product_type" text, - "priority" text, - "estimated_value" double precision, - "awareness_id" text, - "status" text DEFAULT 'new_job' NOT NULL, - "followup_status" text, - "lost_reason" text, - "outcome" text DEFAULT 'open' NOT NULL, - "owner_marketing_user_id" text, - "assigned_sales_owner_id" text, - "assigned_at" timestamp with time zone, - "assigned_by" text, - "assignment_remark" text, - "created_by" text NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - "deleted_at" timestamp with time zone -); ---> statement-breakpoint CREATE TABLE "crm_quotation_attachments" ( "id" text PRIMARY KEY NOT NULL, "organization_id" text NOT NULL, @@ -448,7 +448,7 @@ CREATE TABLE "crm_quotations" ( "organization_id" text NOT NULL, "branch_id" text, "code" text NOT NULL, - "enquiry_id" text, + "opportunity_id" text, "customer_id" text NOT NULL, "contact_id" text, "quotation_date" timestamp with time zone NOT NULL, @@ -645,8 +645,8 @@ CREATE UNIQUE INDEX "crm_document_template_mappings_version_placeholder_idx" ON CREATE UNIQUE INDEX "crm_document_template_table_columns_mapping_name_idx" ON "crm_document_template_table_columns" USING btree ("mapping_id","column_name");--> statement-breakpoint CREATE UNIQUE INDEX "crm_document_template_versions_template_version_idx" ON "crm_document_template_versions" USING btree ("template_id","version");--> statement-breakpoint CREATE UNIQUE INDEX "crm_document_templates_org_doc_product_file_name_idx" ON "crm_document_templates" USING btree ("organization_id","document_type","product_type","file_type","template_name");--> statement-breakpoint -CREATE UNIQUE INDEX "crm_enquiries_org_code_idx" ON "crm_enquiries" USING btree ("organization_id","code");--> statement-breakpoint CREATE UNIQUE INDEX "crm_leads_org_code_idx" ON "crm_leads" USING btree ("organization_id","code");--> statement-breakpoint +CREATE UNIQUE INDEX "crm_opportunities_org_code_idx" ON "crm_opportunities" USING btree ("organization_id","code");--> statement-breakpoint CREATE UNIQUE INDEX "crm_quotations_org_code_idx" ON "crm_quotations" USING btree ("organization_id","code");--> statement-breakpoint CREATE UNIQUE INDEX "crm_report_definitions_org_code_idx" ON "crm_report_definitions" USING btree ("organization_id","code");--> statement-breakpoint CREATE UNIQUE INDEX "crm_role_profiles_org_code_idx" ON "crm_role_profiles" USING btree ("organization_id","code");--> statement-breakpoint diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json index eb35bd1..e51701b 100644 --- a/drizzle/meta/0000_snapshot.json +++ b/drizzle/meta/0000_snapshot.json @@ -1,5 +1,5 @@ { - "id": "5b317a18-e2f9-48c7-a371-c6a972e13344", + "id": "e01a80c3-a4b5-4461-afb0-52b663dfa084", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", @@ -1551,8 +1551,203 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.crm_enquiries": { - "name": "crm_enquiries", + "public.crm_leads": { + "name": "crm_leads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_name": { + "name": "project_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_location": { + "name": "project_location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_type": { + "name": "product_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "estimated_value": { + "name": "estimated_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "awareness_id": { + "name": "awareness_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'new_job'" + }, + "followup_status": { + "name": "followup_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lost_reason": { + "name": "lost_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "owner_marketing_user_id": { + "name": "owner_marketing_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_sales_owner_id": { + "name": "assigned_sales_owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignment_remark": { + "name": "assignment_remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "crm_leads_org_code_idx": { + "name": "crm_leads_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_opportunities": { + "name": "crm_opportunities", "schema": "", "columns": { "id": { @@ -1826,8 +2021,8 @@ } }, "indexes": { - "crm_enquiries_org_code_idx": { - "name": "crm_enquiries_org_code_idx", + "crm_opportunities_org_code_idx": { + "name": "crm_opportunities_org_code_idx", "columns": [ { "expression": "organization_id", @@ -1855,8 +2050,8 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.crm_enquiry_attachments": { - "name": "crm_enquiry_attachments", + "public.crm_opportunity_attachments": { + "name": "crm_opportunity_attachments", "schema": "", "columns": { "id": { @@ -1871,8 +2066,8 @@ "primaryKey": false, "notNull": true }, - "enquiry_id": { - "name": "enquiry_id", + "opportunity_id": { + "name": "opportunity_id", "type": "text", "primaryKey": false, "notNull": true @@ -1967,8 +2162,8 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.crm_enquiry_customers": { - "name": "crm_enquiry_customers", + "public.crm_opportunity_customers": { + "name": "crm_opportunity_customers", "schema": "", "columns": { "id": { @@ -1983,8 +2178,8 @@ "primaryKey": false, "notNull": true }, - "enquiry_id": { - "name": "enquiry_id", + "opportunity_id": { + "name": "opportunity_id", "type": "text", "primaryKey": false, "notNull": true @@ -2036,8 +2231,8 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.crm_enquiry_followups": { - "name": "crm_enquiry_followups", + "public.crm_opportunity_followups": { + "name": "crm_opportunity_followups", "schema": "", "columns": { "id": { @@ -2052,8 +2247,8 @@ "primaryKey": false, "notNull": true }, - "enquiry_id": { - "name": "enquiry_id", + "opportunity_id": { + "name": "opportunity_id", "type": "text", "primaryKey": false, "notNull": true @@ -2141,201 +2336,6 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.crm_leads": { - "name": "crm_leads", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "organization_id": { - "name": "organization_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "branch_id": { - "name": "branch_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "code": { - "name": "code", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "customer_id": { - "name": "customer_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "contact_id": { - "name": "contact_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "project_name": { - "name": "project_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "project_location": { - "name": "project_location", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "product_type": { - "name": "product_type", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "priority": { - "name": "priority", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "estimated_value": { - "name": "estimated_value", - "type": "double precision", - "primaryKey": false, - "notNull": false - }, - "awareness_id": { - "name": "awareness_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'new_job'" - }, - "followup_status": { - "name": "followup_status", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "lost_reason": { - "name": "lost_reason", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "outcome": { - "name": "outcome", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'open'" - }, - "owner_marketing_user_id": { - "name": "owner_marketing_user_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "assigned_sales_owner_id": { - "name": "assigned_sales_owner_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "assigned_at": { - "name": "assigned_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "assigned_by": { - "name": "assigned_by", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "assignment_remark": { - "name": "assignment_remark", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_by": { - "name": "created_by", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "deleted_at": { - "name": "deleted_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "crm_leads_org_code_idx": { - "name": "crm_leads_org_code_idx", - "columns": [ - { - "expression": "organization_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "code", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, "public.crm_quotation_attachments": { "name": "crm_quotation_attachments", "schema": "", @@ -2908,8 +2908,8 @@ "primaryKey": false, "notNull": true }, - "enquiry_id": { - "name": "enquiry_id", + "opportunity_id": { + "name": "opportunity_id", "type": "text", "primaryKey": false, "notNull": false diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index daa9086..8cb8991 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "7", - "when": 1782285058938, - "tag": "0000_swift_prism", + "when": 1782359607850, + "tag": "0000_hard_butterfly", "breakpoints": true } ] diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818..9edff1c 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/package.json b/package.json index fa05946..0e36b36 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build", + "build": "npm run verify:encoding && next build", "start": "next start", "lint": "oxlint", "lint:fix": "oxlint --fix && bun format", @@ -12,7 +12,7 @@ "format": "oxfmt --write .", "format:check": "oxfmt --check .", "prepare": "husky", - "gen": "npx drizzle-kit generate", + "gen": "npm run verify:encoding && npx drizzle-kit generate", "migrate": "npx drizzle-kit migrate", "studio": "npx drizzle-kit studio", "seed:super-admin": "node scripts/seed-super-admin.js", @@ -27,6 +27,7 @@ "audit:pdf:coverage": "node --experimental-strip-types scripts/audit-pdf-mapping-coverage.ts", "audit:pdf:runtime": "node --experimental-strip-types scripts/audit-pdf-runtime-payload.ts", "audit:pdf:report": "node --experimental-strip-types scripts/generate-pdf-audit-report.ts", + "verify:encoding": "node scripts/verify-encoding.mjs", "audit:pdf:versions": "node --experimental-strip-types scripts/audit-template-version-integrity.ts", "audit:pdf:placeholders": "node --experimental-strip-types scripts/audit-placeholder-integrity.ts", "audit:pdf:parity": "node --experimental-strip-types scripts/audit-payload-parity.ts", diff --git a/plans/task-d.5.5.1.md b/plans/task-d.5.5.1.md new file mode 100644 index 0000000..5b862e2 --- /dev/null +++ b/plans/task-d.5.5.1.md @@ -0,0 +1,470 @@ +# Task D.5.5.1: Force Rename Enquiry Domain to Opportunity + +## Status + +Breaking Change (Development Only) + +## Objective + +Perform a full domain rename from **Enquiry** to **Opportunity** across the CRM codebase. + +This task intentionally introduces breaking changes and requires a full database reset. + +The goal is to eliminate future ambiguity between: + +```txt +Sales Opportunity +``` + +and the future business document: + +```txt +Sales Enquiry +``` + +After this task: + +```txt +Lead + ↓ +Opportunity + ↓ +Quotation +``` + +becomes the official CRM domain model. + +--- + +# Preconditions + +This task assumes: + +* Development environment only +* Existing data may be discarded +* Database will be dropped +* Migration history may be regenerated +* Seed data will be recreated + +No production migration compatibility is required. + +--- + +# Mandatory Review + +Review: + +* AGENTS.md +* Project Foundations Registry +* Task Catalog +* ADR-0018 Lead / Enquiry Domain Separation +* Task D.5.1 – D.5.5 +* CRM Authorization Boundaries +* Database schema +* Navigation configuration +* Route structure + +--- + +# Scope D5.5.1.1 Database Rename + +Rename tables: + +```txt +crm_enquiries +→ crm_opportunities + +crm_enquiry_followups +→ crm_opportunity_followups + +crm_enquiry_customers +→ crm_opportunity_customers +``` + +Rename foreign keys accordingly. + +Rename indexes and constraints to use the new naming convention. + +Keep: + +```txt +crm_leads +crm_quotations +``` + +unchanged. + +--- + +# Scope D5.5.1.2 Schema Refactor + +Update: + +```txt +src/db/schema.ts +``` + +Rename: + +```txt +enquiries +→ opportunities +``` + +Rename: + +```txt +enquiryRelations +→ opportunityRelations +``` + +Rename every Drizzle export to Opportunity terminology. + +--- + +# Scope D5.5.1.3 Domain Refactor + +Rename feature module: + +```txt +src/features/crm/enquiries +``` + +to + +```txt +src/features/crm/opportunities +``` + +Rename: + +```txt +service.ts +types.ts +schemas +components +hooks +queries +mutations +``` + +to Opportunity terminology. + +No legacy aliases remain. + +--- + +# Scope D5.5.1.4 API Refactor + +Replace: + +```txt +/api/crm/enquiries +``` + +with + +```txt +/api/crm/opportunities +``` + +Routes: + +```txt +GET +POST +PATCH +DELETE +followups +assign +``` + +must all use Opportunity. + +Do not leave compatibility aliases. + +--- + +# Scope D5.5.1.5 App Route Refactor + +Rename: + +```txt +/ dashboard / crm / enquiries +``` + +to + +```txt +/ dashboard / crm / opportunities +``` + +Update navigation accordingly. + +--- + +# Scope D5.5.1.6 Type Refactor + +Rename: + +```txt +EnquirySummary +→ OpportunitySummary + +EnquiryDetail +→ OpportunityDetail + +CreateEnquiryInput +→ CreateOpportunityInput + +UpdateEnquiryInput +→ UpdateOpportunityInput +``` + +Apply consistently across server and client. + +--- + +# Scope D5.5.1.7 Permission Refactor + +Rename permissions: + +```txt +crm.enquiry.read +crm.enquiry.create +crm.enquiry.update +crm.enquiry.delete +crm.enquiry.assign +crm.enquiry.followup.* +``` + +to + +```txt +crm.opportunity.read +crm.opportunity.create +crm.opportunity.update +crm.opportunity.delete +crm.opportunity.assign +crm.opportunity.followup.* +``` + +Update seed/configuration if permissions are managed in code. + +--- + +# Scope D5.5.1.8 Document Sequence + +Rename: + +```txt +crm_enquiry +``` + +to + +```txt +crm_opportunity +``` + +Document code format remains unchanged unless business requires a new prefix. + +Update sequence configuration and seed. + +--- + +# Scope D5.5.1.9 Audit Refactor + +Rename audit entity: + +```txt +crm_enquiry +``` + +to + +```txt +crm_opportunity +``` + +Rename audit actions: + +```txt +create_enquiry +update_enquiry +delete_enquiry +assign_enquiry +``` + +to + +```txt +create_opportunity +update_opportunity +delete_opportunity +assign_opportunity +``` + +--- + +# Scope D5.5.1.10 UI Refactor + +Rename all user-facing wording: + +```txt +Enquiry +``` + +to + +```txt +Opportunity +``` + +except historical documentation. + +Update: + +* page titles +* dialogs +* breadcrumbs +* badges +* table headers +* empty states +* permission messages + +--- + +# Scope D5.5.1.11 Documentation + +Update: + +* ADR references +* task documentation +* architecture diagrams +* implementation notes + +Replace "Enquiry" with "Opportunity" where referring to the sales execution domain. + +Keep historical notes where necessary. + +--- + +# Database Reset + +This task requires: + +```txt +Drop Database Schema + +Run Fresh Migration + +Run Seed +``` + +Old migration history does not need compatibility. + +--- + +# Deliverables + +Updated: + +```txt +src/db/schema.ts + +src/features/crm/opportunities/** + +src/app/api/crm/opportunities/** + +src/app/dashboard/crm/opportunities/** +``` + +Updated documentation: + +```txt +ADR +Task documents +Architecture documents +``` + +--- + +# Explicit Non-Scope + +Do not: + +* change Lead domain +* change Quotation domain +* change Approval workflow +* change Dashboard +* change Reports +* implement Won/Lost lifecycle +* introduce Sales Enquiry document + +Those belong to later tasks. + +--- + +# Verification + +Run: + +```txt +npm exec tsc --noEmit +npm run lint +npm run build +``` + +Verify: + +```txt +Opportunity CRUD +Opportunity Assignment +Lead → Opportunity +Opportunity → Quotation +Follow-up +Permissions +Navigation +Document Sequence +Audit Logging +``` + +Verify no remaining references: + +```txt +crm_enquiries +/api/crm/enquiries +features/crm/enquiries +EnquiryDetail +EnquirySummary +crm.enquiry.* +``` + +except historical documentation. + +--- + +# Definition of Done + +Task is complete when: + +* Opportunity becomes the official CRM sales domain +* Database uses `crm_opportunities` +* API uses `/api/crm/opportunities` +* Feature module uses `opportunities` +* UI uses Opportunity terminology +* Permission namespace uses `crm.opportunity.*` +* Audit uses Opportunity terminology +* Fresh migration and seed complete successfully +* No active application code depends on the old Enquiry domain + +Result: + +```txt +Lead Domain = Established +Opportunity Domain = Established +Quotation Domain = Unchanged + +Ready for D.5.6 Opportunity Lifecycle +``` diff --git a/scripts/verify-encoding.mjs b/scripts/verify-encoding.mjs new file mode 100644 index 0000000..8db475c --- /dev/null +++ b/scripts/verify-encoding.mjs @@ -0,0 +1,95 @@ +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; + +const roots = ['src', 'scripts', 'drizzle']; +const explicitFiles = ['drizzle.config.ts']; +const extensions = new Set([ + '.ts', + '.tsx', + '.js', + '.jsx', + '.mjs', + '.cjs', + '.json', + '.sql' +]); + +const suspiciousChars = [ + '\u0081', + '\u0087', + '\u008d', + '\u0099', + '\u009e', + '\u00a0', + '\u201d', + '\u20ac' +]; + +function walk(dir) { + const entries = readdirSync(dir, { withFileTypes: true }); + const files = []; + + for (const entry of entries) { + const fullPath = join(dir, entry.name); + + if (entry.isDirectory()) { + files.push(...walk(fullPath)); + continue; + } + + if ([...extensions].some((extension) => entry.name.endsWith(extension))) { + files.push(fullPath); + } + } + + return files; +} + +function hasUtf8Bom(buffer) { + return buffer.length >= 3 && buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf; +} + +function findSuspiciousChars(text) { + return suspiciousChars.filter((char) => text.includes(char)); +} + +const filesToCheck = [ + ...roots.flatMap((root) => (statSync(root, { throwIfNoEntry: false })?.isDirectory() ? walk(root) : [])), + ...explicitFiles.filter((file) => statSync(file, { throwIfNoEntry: false })?.isFile()) +]; + +const issues = []; + +for (const file of filesToCheck) { + const buffer = readFileSync(file); + + if (hasUtf8Bom(buffer)) { + issues.push(`${relative(process.cwd(), file)}: UTF-8 BOM detected`); + } + + let text; + + try { + text = buffer.toString('utf8'); + } catch (error) { + issues.push(`${relative(process.cwd(), file)}: unable to decode as UTF-8 (${error.message})`); + continue; + } + + const suspicious = findSuspiciousChars(text); + + if (suspicious.length > 0) { + const labels = suspicious.map((char) => `U+${char.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')}`); + issues.push(`${relative(process.cwd(), file)}: suspicious mojibake markers found (${labels.join(', ')})`); + } +} + +if (issues.length > 0) { + console.error('Encoding verification failed:'); + for (const issue of issues) { + console.error(`- ${issue}`); + } + process.exit(1); +} + +console.log(`Encoding verification passed for ${filesToCheck.length} files.`); diff --git a/src/app/api/crm/dashboard/export/route.ts b/src/app/api/crm/dashboard/export/route.ts index f2d6d66..a56b5cd 100644 --- a/src/app/api/crm/dashboard/export/route.ts +++ b/src/app/api/crm/dashboard/export/route.ts @@ -107,7 +107,7 @@ export async function GET(request: NextRequest) { ...data.salesRanking.map((row) => [ row.salesPersonName, String(row.leadCount), - String(row.enquiryCount), + String(row.opportunityCount), String(row.quotationCount), String(row.approvedQuotations), String(row.wonRevenue), @@ -148,3 +148,4 @@ export async function GET(request: NextRequest) { return NextResponse.json({ message: 'Unable to export CRM dashboard data' }, { status: 500 }); } } + diff --git a/src/app/api/crm/leads/[id]/assign/route.ts b/src/app/api/crm/leads/[id]/assign/route.ts index 6660ada..56bd5f3 100644 --- a/src/app/api/crm/leads/[id]/assign/route.ts +++ b/src/app/api/crm/leads/[id]/assign/route.ts @@ -38,9 +38,9 @@ export async function POST(request: NextRequest, { params }: Params) { success: true, message: 'Lead assigned successfully', leadId: result.leadId, - enquiryId: result.enquiryId, - enquiryCode: result.enquiryCode, - reusedExistingEnquiry: result.reusedExistingEnquiry + opportunityId: result.opportunityId, + opportunityCode: result.opportunityCode, + reusedExistingOpportunity: result.reusedExistingOpportunity }); } catch (error) { if (error instanceof AuthError) { @@ -61,3 +61,4 @@ export async function POST(request: NextRequest, { params }: Params) { return NextResponse.json({ message: 'Unable to assign lead' }, { status: 500 }); } } + diff --git a/src/app/api/crm/enquiries/[id]/_schemas.ts b/src/app/api/crm/opportunities/[id]/_schemas.ts similarity index 69% rename from src/app/api/crm/enquiries/[id]/_schemas.ts rename to src/app/api/crm/opportunities/[id]/_schemas.ts index d83c7fb..903d8a3 100644 --- a/src/app/api/crm/enquiries/[id]/_schemas.ts +++ b/src/app/api/crm/opportunities/[id]/_schemas.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; -export const enquiryAssignmentRequestSchema = z.object({ +export const opportunityAssignmentRequestSchema = z.object({ assignedToUserId: z.string().min(1, 'Sales user is required'), remark: z.string().trim().max(1000).optional() }); + diff --git a/src/app/api/crm/enquiries/[id]/assign/route.ts b/src/app/api/crm/opportunities/[id]/assign/route.ts similarity index 75% rename from src/app/api/crm/enquiries/[id]/assign/route.ts rename to src/app/api/crm/opportunities/[id]/assign/route.ts index 6b0eae5..90d6c2c 100644 --- a/src/app/api/crm/enquiries/[id]/assign/route.ts +++ b/src/app/api/crm/opportunities/[id]/assign/route.ts @@ -1,10 +1,10 @@ import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; import { auditAction } from '@/features/foundation/audit-log/service'; -import { assignEnquiryToUser, getEnquiryDetail } from '@/features/crm/enquiries/server/service'; +import { assignOpportunityToUser, getOpportunityDetail } from '@/features/crm/opportunities/server/service'; import { PERMISSIONS } from '@/lib/auth/rbac'; import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; -import { enquiryAssignmentRequestSchema } from '../_schemas'; +import { opportunityAssignmentRequestSchema } from '../_schemas'; type Params = { params: Promise<{ id: string }>; @@ -14,9 +14,9 @@ export async function POST(request: NextRequest, { params }: Params) { try { const { id } = await params; const { organization, session, membership } = await requireOrganizationAccess({ - permission: PERMISSIONS.crmEnquiryAssign + permission: PERMISSIONS.crmOpportunityAssign }); - const payload = enquiryAssignmentRequestSchema.parse(await request.json()); + const payload = opportunityAssignmentRequestSchema.parse(await request.json()); const accessContext = { organizationId: organization.id, userId: session.user.id, @@ -29,8 +29,8 @@ export async function POST(request: NextRequest, { params }: Params) { branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all', productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all' }; - const before = await getEnquiryDetail(id, organization.id, accessContext); - const updated = await assignEnquiryToUser( + const before = await getOpportunityDetail(id, organization.id, accessContext); + const updated = await assignOpportunityToUser( id, organization.id, session.user.id, @@ -42,9 +42,9 @@ export async function POST(request: NextRequest, { params }: Params) { organizationId: organization.id, branchId: updated.branchId, userId: session.user.id, - entityType: 'crm_enquiry', + entityType: 'crm_opportunity', entityId: id, - action: 'lead_to_enquiry', + action: 'lead_to_opportunity', beforeData: { oldAssignedToUserId: before.assignedToUserId, oldPipelineStage: before.pipelineStage @@ -58,8 +58,8 @@ export async function POST(request: NextRequest, { params }: Params) { return NextResponse.json({ success: true, - message: 'Lead converted to enquiry successfully', - enquiry: updated + message: 'Lead converted to opportunity successfully', + opportunity: updated }); } catch (error) { if (error instanceof AuthError) { @@ -77,6 +77,7 @@ export async function POST(request: NextRequest, { params }: Params) { return NextResponse.json({ message: error.message }, { status: 400 }); } - return NextResponse.json({ message: 'Unable to assign enquiry' }, { status: 500 }); + return NextResponse.json({ message: 'Unable to assign opportunity' }, { status: 500 }); } } + diff --git a/src/app/api/crm/enquiries/[id]/customers/route.ts b/src/app/api/crm/opportunities/[id]/customers/route.ts similarity index 79% rename from src/app/api/crm/enquiries/[id]/customers/route.ts rename to src/app/api/crm/opportunities/[id]/customers/route.ts index a9efdca..f4580cf 100644 --- a/src/app/api/crm/enquiries/[id]/customers/route.ts +++ b/src/app/api/crm/opportunities/[id]/customers/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { listEnquiryProjectParties } from '@/features/crm/enquiries/server/service'; +import { listOpportunityProjectParties } from '@/features/crm/opportunities/server/service'; import { PERMISSIONS } from '@/lib/auth/rbac'; import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; @@ -11,7 +11,7 @@ export async function GET(_request: NextRequest, { params }: Params) { try { const { id } = await params; const { organization, session, membership } = await requireOrganizationAccess({ - permission: PERMISSIONS.crmEnquiryRead + permission: PERMISSIONS.crmOpportunityRead }); const accessContext = { organizationId: organization.id, @@ -24,12 +24,12 @@ export async function GET(_request: NextRequest, { params }: Params) { branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all', productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all' }; - const items = await listEnquiryProjectParties(id, organization.id, accessContext); + const items = await listOpportunityProjectParties(id, organization.id, accessContext); return NextResponse.json({ success: true, time: new Date().toISOString(), - message: 'Enquiry project parties loaded successfully', + message: 'Opportunity project parties loaded successfully', items }); } catch (error) { @@ -42,8 +42,9 @@ export async function GET(_request: NextRequest, { params }: Params) { } return NextResponse.json( - { message: 'Unable to load enquiry project parties' }, + { message: 'Unable to load opportunity project parties' }, { status: 500 } ); } } + diff --git a/src/app/api/crm/enquiries/[id]/followups/[followupId]/route.ts b/src/app/api/crm/opportunities/[id]/followups/[followupId]/route.ts similarity index 78% rename from src/app/api/crm/enquiries/[id]/followups/[followupId]/route.ts rename to src/app/api/crm/opportunities/[id]/followups/[followupId]/route.ts index 3175b1f..dfecca0 100644 --- a/src/app/api/crm/enquiries/[id]/followups/[followupId]/route.ts +++ b/src/app/api/crm/opportunities/[id]/followups/[followupId]/route.ts @@ -2,11 +2,11 @@ import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service'; import { - listEnquiryFollowups, - softDeleteEnquiryFollowup, - updateEnquiryFollowup -} from '@/features/crm/enquiries/server/service'; -import { enquiryFollowupSchema } from '@/features/crm/enquiries/schemas/enquiry.schema'; + listOpportunityFollowups, + softDeleteOpportunityFollowup, + updateOpportunityFollowup +} from '@/features/crm/opportunities/server/service'; +import { opportunityFollowupSchema } from '@/features/crm/opportunities/schemas/opportunity.schema'; import { PERMISSIONS } from '@/lib/auth/rbac'; import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; @@ -14,7 +14,7 @@ type Params = { params: Promise<{ id: string; followupId: string }>; }; -const followupRequestSchema = enquiryFollowupSchema.extend({ +const followupRequestSchema = opportunityFollowupSchema.extend({ contactId: z.string().nullable().optional(), nextFollowupDate: z.string().nullable().optional() }); @@ -23,7 +23,7 @@ export async function PATCH(request: NextRequest, { params }: Params) { try { const { id, followupId } = await params; const { organization, session, membership } = await requireOrganizationAccess({ - permission: PERMISSIONS.crmEnquiryFollowupUpdate + permission: PERMISSIONS.crmOpportunityFollowupUpdate }); const payload = followupRequestSchema.parse(await request.json()); const accessContext = { @@ -38,7 +38,7 @@ export async function PATCH(request: NextRequest, { params }: Params) { productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all' }; const before = ( - await listEnquiryFollowups(id, organization.id, accessContext) + await listOpportunityFollowups(id, organization.id, accessContext) ).find( (item) => item.id === followupId ); @@ -47,7 +47,7 @@ export async function PATCH(request: NextRequest, { params }: Params) { return NextResponse.json({ message: 'Follow-up not found' }, { status: 404 }); } - const updated = await updateEnquiryFollowup( + const updated = await updateOpportunityFollowup( id, followupId, organization.id, @@ -59,7 +59,7 @@ export async function PATCH(request: NextRequest, { params }: Params) { await auditUpdate({ organizationId: organization.id, userId: session.user.id, - entityType: 'crm_enquiry_followup', + entityType: 'crm_opportunity_followup', entityId: followupId, beforeData: before, afterData: updated @@ -67,7 +67,7 @@ export async function PATCH(request: NextRequest, { params }: Params) { return NextResponse.json({ success: true, - message: 'Enquiry follow-up updated successfully', + message: 'Opportunity follow-up updated successfully', followup: updated }); } catch (error) { @@ -86,7 +86,7 @@ export async function PATCH(request: NextRequest, { params }: Params) { return NextResponse.json({ message: error.message }, { status: 400 }); } - return NextResponse.json({ message: 'Unable to update enquiry follow-up' }, { status: 500 }); + return NextResponse.json({ message: 'Unable to update opportunity follow-up' }, { status: 500 }); } } @@ -94,7 +94,7 @@ export async function DELETE(_request: NextRequest, { params }: Params) { try { const { id, followupId } = await params; const { organization, session, membership } = await requireOrganizationAccess({ - permission: PERMISSIONS.crmEnquiryFollowupDelete + permission: PERMISSIONS.crmOpportunityFollowupDelete }); const accessContext = { organizationId: organization.id, @@ -108,7 +108,7 @@ export async function DELETE(_request: NextRequest, { params }: Params) { productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all' }; const before = ( - await listEnquiryFollowups(id, organization.id, accessContext) + await listOpportunityFollowups(id, organization.id, accessContext) ).find( (item) => item.id === followupId ); @@ -117,7 +117,7 @@ export async function DELETE(_request: NextRequest, { params }: Params) { return NextResponse.json({ message: 'Follow-up not found' }, { status: 404 }); } - const updated = await softDeleteEnquiryFollowup( + const updated = await softDeleteOpportunityFollowup( id, followupId, organization.id, @@ -128,7 +128,7 @@ export async function DELETE(_request: NextRequest, { params }: Params) { await auditDelete({ organizationId: organization.id, userId: session.user.id, - entityType: 'crm_enquiry_followup', + entityType: 'crm_opportunity_followup', entityId: followupId, beforeData: before, afterData: updated @@ -136,7 +136,7 @@ export async function DELETE(_request: NextRequest, { params }: Params) { return NextResponse.json({ success: true, - message: 'Enquiry follow-up deleted successfully' + message: 'Opportunity follow-up deleted successfully' }); } catch (error) { if (error instanceof AuthError) { @@ -147,6 +147,7 @@ export async function DELETE(_request: NextRequest, { params }: Params) { return NextResponse.json({ message: error.message }, { status: 400 }); } - return NextResponse.json({ message: 'Unable to delete enquiry follow-up' }, { status: 500 }); + return NextResponse.json({ message: 'Unable to delete opportunity follow-up' }, { status: 500 }); } } + diff --git a/src/app/api/crm/enquiries/[id]/followups/route.ts b/src/app/api/crm/opportunities/[id]/followups/route.ts similarity index 78% rename from src/app/api/crm/enquiries/[id]/followups/route.ts rename to src/app/api/crm/opportunities/[id]/followups/route.ts index 21de1d0..ba7f994 100644 --- a/src/app/api/crm/enquiries/[id]/followups/route.ts +++ b/src/app/api/crm/opportunities/[id]/followups/route.ts @@ -2,10 +2,10 @@ import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; import { auditCreate } from '@/features/foundation/audit-log/service'; import { - createEnquiryFollowup, - listEnquiryFollowups -} from '@/features/crm/enquiries/server/service'; -import { enquiryFollowupSchema } from '@/features/crm/enquiries/schemas/enquiry.schema'; + createOpportunityFollowup, + listOpportunityFollowups +} from '@/features/crm/opportunities/server/service'; +import { opportunityFollowupSchema } from '@/features/crm/opportunities/schemas/opportunity.schema'; import { PERMISSIONS } from '@/lib/auth/rbac'; import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; @@ -13,7 +13,7 @@ type Params = { params: Promise<{ id: string }>; }; -const followupRequestSchema = enquiryFollowupSchema.extend({ +const followupRequestSchema = opportunityFollowupSchema.extend({ contactId: z.string().nullable().optional(), nextFollowupDate: z.string().nullable().optional() }); @@ -22,7 +22,7 @@ export async function GET(_request: NextRequest, { params }: Params) { try { const { id } = await params; const { organization, session, membership } = await requireOrganizationAccess({ - permission: PERMISSIONS.crmEnquiryFollowupRead + permission: PERMISSIONS.crmOpportunityFollowupRead }); const accessContext = { organizationId: organization.id, @@ -35,12 +35,12 @@ export async function GET(_request: NextRequest, { params }: Params) { branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all', productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all' }; - const items = await listEnquiryFollowups(id, organization.id, accessContext); + const items = await listOpportunityFollowups(id, organization.id, accessContext); return NextResponse.json({ success: true, time: new Date().toISOString(), - message: 'Enquiry follow-ups loaded successfully', + message: 'Opportunity follow-ups loaded successfully', items }); } catch (error) { @@ -52,7 +52,7 @@ export async function GET(_request: NextRequest, { params }: Params) { return NextResponse.json({ message: error.message }, { status: 400 }); } - return NextResponse.json({ message: 'Unable to load enquiry follow-ups' }, { status: 500 }); + return NextResponse.json({ message: 'Unable to load opportunity follow-ups' }, { status: 500 }); } } @@ -60,7 +60,7 @@ export async function POST(request: NextRequest, { params }: Params) { try { const { id } = await params; const { organization, session, membership } = await requireOrganizationAccess({ - permission: PERMISSIONS.crmEnquiryFollowupCreate + permission: PERMISSIONS.crmOpportunityFollowupCreate }); const payload = followupRequestSchema.parse(await request.json()); const accessContext = { @@ -74,7 +74,7 @@ export async function POST(request: NextRequest, { params }: Params) { branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all', productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all' }; - const created = await createEnquiryFollowup( + const created = await createOpportunityFollowup( id, organization.id, session.user.id, @@ -85,7 +85,7 @@ export async function POST(request: NextRequest, { params }: Params) { await auditCreate({ organizationId: organization.id, userId: session.user.id, - entityType: 'crm_enquiry_followup', + entityType: 'crm_opportunity_followup', entityId: created.id, afterData: created }); @@ -93,7 +93,7 @@ export async function POST(request: NextRequest, { params }: Params) { return NextResponse.json( { success: true, - message: 'Enquiry follow-up created successfully', + message: 'Opportunity follow-up created successfully', followup: created }, { status: 201 } @@ -114,6 +114,7 @@ export async function POST(request: NextRequest, { params }: Params) { return NextResponse.json({ message: error.message }, { status: 400 }); } - return NextResponse.json({ message: 'Unable to create enquiry follow-up' }, { status: 500 }); + return NextResponse.json({ message: 'Unable to create opportunity follow-up' }, { status: 500 }); } } + diff --git a/src/app/api/crm/enquiries/[id]/reopen/route.ts b/src/app/api/crm/opportunities/[id]/mark-lost/route.ts similarity index 66% rename from src/app/api/crm/enquiries/[id]/reopen/route.ts rename to src/app/api/crm/opportunities/[id]/mark-lost/route.ts index c8818ff..c27ab15 100644 --- a/src/app/api/crm/enquiries/[id]/reopen/route.ts +++ b/src/app/api/crm/opportunities/[id]/mark-lost/route.ts @@ -1,7 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; -import { enquiryReopenSchema } from '@/features/crm/enquiries/schemas/enquiry.schema'; -import { getEnquiryDetail, reopenLostEnquiry } from '@/features/crm/enquiries/server/service'; +import { + opportunityMarkLostSchema +} from '@/features/crm/opportunities/schemas/opportunity.schema'; +import { getOpportunityDetail, markOpportunityAsLost } from '@/features/crm/opportunities/server/service'; import { PERMISSIONS } from '@/lib/auth/rbac'; import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; @@ -13,9 +15,9 @@ export async function POST(request: NextRequest, { params }: Params) { try { const { id } = await params; const { organization, session, membership } = await requireOrganizationAccess({ - permission: PERMISSIONS.crmEnquiryReopen + permission: PERMISSIONS.crmOpportunityMarkLost }); - const payload = enquiryReopenSchema.parse(await request.json()); + const payload = opportunityMarkLostSchema.parse(await request.json()); const accessContext = { organizationId: organization.id, userId: session.user.id, @@ -28,15 +30,15 @@ export async function POST(request: NextRequest, { params }: Params) { branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all', productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all' }; - const before = await getEnquiryDetail(id, organization.id, accessContext); - const updated = await reopenLostEnquiry(id, organization.id, session.user.id, payload, accessContext); - const after = await getEnquiryDetail(id, organization.id, accessContext); + const before = await getOpportunityDetail(id, organization.id, accessContext); + const updated = await markOpportunityAsLost(id, organization.id, session.user.id, payload, accessContext); + const after = await getOpportunityDetail(id, organization.id, accessContext); return NextResponse.json({ success: true, - message: 'Enquiry reopened successfully', + message: 'Opportunity marked as lost successfully', before, - enquiry: updated, + opportunity: updated, after }); } catch (error) { @@ -55,6 +57,7 @@ export async function POST(request: NextRequest, { params }: Params) { return NextResponse.json({ message: error.message }, { status: 400 }); } - return NextResponse.json({ message: 'Unable to reopen enquiry' }, { status: 500 }); + return NextResponse.json({ message: 'Unable to mark opportunity as lost' }, { status: 500 }); } } + diff --git a/src/app/api/crm/enquiries/[id]/mark-lost/route.ts b/src/app/api/crm/opportunities/[id]/mark-won/route.ts similarity index 67% rename from src/app/api/crm/enquiries/[id]/mark-lost/route.ts rename to src/app/api/crm/opportunities/[id]/mark-won/route.ts index b65f402..5d99eb0 100644 --- a/src/app/api/crm/enquiries/[id]/mark-lost/route.ts +++ b/src/app/api/crm/opportunities/[id]/mark-won/route.ts @@ -1,9 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; import { - enquiryMarkLostSchema -} from '@/features/crm/enquiries/schemas/enquiry.schema'; -import { getEnquiryDetail, markEnquiryAsLost } from '@/features/crm/enquiries/server/service'; + opportunityMarkWonSchema +} from '@/features/crm/opportunities/schemas/opportunity.schema'; +import { getOpportunityDetail, markOpportunityAsWon } from '@/features/crm/opportunities/server/service'; import { PERMISSIONS } from '@/lib/auth/rbac'; import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; @@ -15,9 +15,9 @@ export async function POST(request: NextRequest, { params }: Params) { try { const { id } = await params; const { organization, session, membership } = await requireOrganizationAccess({ - permission: PERMISSIONS.crmEnquiryMarkLost + permission: PERMISSIONS.crmOpportunityMarkWon }); - const payload = enquiryMarkLostSchema.parse(await request.json()); + const payload = opportunityMarkWonSchema.parse(await request.json()); const accessContext = { organizationId: organization.id, userId: session.user.id, @@ -30,15 +30,15 @@ export async function POST(request: NextRequest, { params }: Params) { branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all', productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all' }; - const before = await getEnquiryDetail(id, organization.id, accessContext); - const updated = await markEnquiryAsLost(id, organization.id, session.user.id, payload, accessContext); - const after = await getEnquiryDetail(id, organization.id, accessContext); + const before = await getOpportunityDetail(id, organization.id, accessContext); + const updated = await markOpportunityAsWon(id, organization.id, session.user.id, payload, accessContext); + const after = await getOpportunityDetail(id, organization.id, accessContext); return NextResponse.json({ success: true, - message: 'Enquiry marked as lost successfully', + message: 'Opportunity marked as won successfully', before, - enquiry: updated, + opportunity: updated, after }); } catch (error) { @@ -57,6 +57,7 @@ export async function POST(request: NextRequest, { params }: Params) { return NextResponse.json({ message: error.message }, { status: 400 }); } - return NextResponse.json({ message: 'Unable to mark enquiry as lost' }, { status: 500 }); + return NextResponse.json({ message: 'Unable to mark opportunity as won' }, { status: 500 }); } } + diff --git a/src/app/api/crm/enquiries/[id]/po-attachments/[attachmentId]/route.ts b/src/app/api/crm/opportunities/[id]/po-attachments/[attachmentId]/route.ts similarity index 93% rename from src/app/api/crm/enquiries/[id]/po-attachments/[attachmentId]/route.ts rename to src/app/api/crm/opportunities/[id]/po-attachments/[attachmentId]/route.ts index dbd6d03..93e32d6 100644 --- a/src/app/api/crm/enquiries/[id]/po-attachments/[attachmentId]/route.ts +++ b/src/app/api/crm/opportunities/[id]/po-attachments/[attachmentId]/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getEnquiryAttachment } from '@/features/crm/enquiries/server/service'; +import { getOpportunityAttachment } from '@/features/crm/opportunities/server/service'; import { getStorageProvider } from '@/features/foundation/storage/service'; import { PERMISSIONS } from '@/lib/auth/rbac'; import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; @@ -40,7 +40,7 @@ export async function GET(request: NextRequest, { params }: Params) { try { const { id, attachmentId } = await params; const { organization, session, membership } = await requireOrganizationAccess({ - permission: PERMISSIONS.crmEnquiryRead + permission: PERMISSIONS.crmOpportunityRead }); if ( @@ -50,7 +50,7 @@ export async function GET(request: NextRequest, { params }: Params) { return NextResponse.json({ message: 'Forbidden' }, { status: 403 }); } - const attachment = await getEnquiryAttachment( + const attachment = await getOpportunityAttachment( id, attachmentId, organization.id, @@ -81,3 +81,4 @@ export async function GET(request: NextRequest, { params }: Params) { return NextResponse.json({ message: 'Unable to load PO attachment' }, { status: 500 }); } } + diff --git a/src/app/api/crm/enquiries/[id]/po-attachments/route.ts b/src/app/api/crm/opportunities/[id]/po-attachments/route.ts similarity index 92% rename from src/app/api/crm/enquiries/[id]/po-attachments/route.ts rename to src/app/api/crm/opportunities/[id]/po-attachments/route.ts index e6ee660..e0fe5ea 100644 --- a/src/app/api/crm/enquiries/[id]/po-attachments/route.ts +++ b/src/app/api/crm/opportunities/[id]/po-attachments/route.ts @@ -1,8 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { - listEnquiryAttachments, + listOpportunityAttachments, uploadPurchaseOrderAttachment -} from '@/features/crm/enquiries/server/service'; +} from '@/features/crm/opportunities/server/service'; import { PERMISSIONS } from '@/lib/auth/rbac'; import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; @@ -49,14 +49,14 @@ export async function GET(_request: NextRequest, { params }: Params) { try { const { id } = await params; const { organization, session, membership } = await requireOrganizationAccess({ - permission: PERMISSIONS.crmEnquiryRead + permission: PERMISSIONS.crmOpportunityRead }); if (!canViewCommercialData(membership)) { return NextResponse.json({ message: 'Forbidden' }, { status: 403 }); } - const items = await listEnquiryAttachments( + const items = await listOpportunityAttachments( id, organization.id, buildAccessContext({ @@ -69,7 +69,7 @@ export async function GET(_request: NextRequest, { params }: Params) { return NextResponse.json({ success: true, time: new Date().toISOString(), - message: 'Enquiry purchase order attachments loaded successfully', + message: 'Opportunity purchase order attachments loaded successfully', items }); } catch (error) { @@ -89,7 +89,7 @@ export async function POST(request: NextRequest, { params }: Params) { try { const { id } = await params; const { organization, session, membership } = await requireOrganizationAccess({ - permission: PERMISSIONS.crmEnquiryMarkWon + permission: PERMISSIONS.crmOpportunityMarkWon }); if (!canViewCommercialData(membership)) { @@ -104,7 +104,7 @@ export async function POST(request: NextRequest, { params }: Params) { } const created = await uploadPurchaseOrderAttachment({ - enquiryId: id, + opportunityId: id, organizationId: organization.id, userId: session.user.id, description: @@ -139,3 +139,4 @@ export async function POST(request: NextRequest, { params }: Params) { return NextResponse.json({ message: 'Unable to upload PO attachment' }, { status: 500 }); } } + diff --git a/src/app/api/crm/enquiries/[id]/reassign/route.ts b/src/app/api/crm/opportunities/[id]/reassign/route.ts similarity index 77% rename from src/app/api/crm/enquiries/[id]/reassign/route.ts rename to src/app/api/crm/opportunities/[id]/reassign/route.ts index 7b7d714..4e140c3 100644 --- a/src/app/api/crm/enquiries/[id]/reassign/route.ts +++ b/src/app/api/crm/opportunities/[id]/reassign/route.ts @@ -1,10 +1,10 @@ import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; import { auditAction } from '@/features/foundation/audit-log/service'; -import { getEnquiryDetail, reassignEnquiryToUser } from '@/features/crm/enquiries/server/service'; +import { getOpportunityDetail, reassignOpportunityToUser } from '@/features/crm/opportunities/server/service'; import { PERMISSIONS } from '@/lib/auth/rbac'; import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; -import { enquiryAssignmentRequestSchema } from '../_schemas'; +import { opportunityAssignmentRequestSchema } from '../_schemas'; type Params = { params: Promise<{ id: string }>; @@ -14,9 +14,9 @@ export async function POST(request: NextRequest, { params }: Params) { try { const { id } = await params; const { organization, session, membership } = await requireOrganizationAccess({ - permission: PERMISSIONS.crmEnquiryReassign + permission: PERMISSIONS.crmOpportunityReassign }); - const payload = enquiryAssignmentRequestSchema.parse(await request.json()); + const payload = opportunityAssignmentRequestSchema.parse(await request.json()); const accessContext = { organizationId: organization.id, userId: session.user.id, @@ -29,8 +29,8 @@ export async function POST(request: NextRequest, { params }: Params) { branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all', productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all' }; - const before = await getEnquiryDetail(id, organization.id, accessContext); - const updated = await reassignEnquiryToUser( + const before = await getOpportunityDetail(id, organization.id, accessContext); + const updated = await reassignOpportunityToUser( id, organization.id, session.user.id, @@ -42,7 +42,7 @@ export async function POST(request: NextRequest, { params }: Params) { organizationId: organization.id, branchId: updated.branchId, userId: session.user.id, - entityType: 'crm_enquiry', + entityType: 'crm_opportunity', entityId: id, action: 'reassign', beforeData: { @@ -58,8 +58,8 @@ export async function POST(request: NextRequest, { params }: Params) { return NextResponse.json({ success: true, - message: 'Enquiry reassigned successfully', - enquiry: updated + message: 'Opportunity reassigned successfully', + opportunity: updated }); } catch (error) { if (error instanceof AuthError) { @@ -77,6 +77,7 @@ export async function POST(request: NextRequest, { params }: Params) { return NextResponse.json({ message: error.message }, { status: 400 }); } - return NextResponse.json({ message: 'Unable to reassign enquiry' }, { status: 500 }); + return NextResponse.json({ message: 'Unable to reassign opportunity' }, { status: 500 }); } } + diff --git a/src/app/api/crm/enquiries/[id]/mark-won/route.ts b/src/app/api/crm/opportunities/[id]/reopen/route.ts similarity index 67% rename from src/app/api/crm/enquiries/[id]/mark-won/route.ts rename to src/app/api/crm/opportunities/[id]/reopen/route.ts index 94be523..83b9b4b 100644 --- a/src/app/api/crm/enquiries/[id]/mark-won/route.ts +++ b/src/app/api/crm/opportunities/[id]/reopen/route.ts @@ -1,9 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; -import { - enquiryMarkWonSchema -} from '@/features/crm/enquiries/schemas/enquiry.schema'; -import { getEnquiryDetail, markEnquiryAsWon } from '@/features/crm/enquiries/server/service'; +import { opportunityReopenSchema } from '@/features/crm/opportunities/schemas/opportunity.schema'; +import { getOpportunityDetail, reopenLostOpportunity } from '@/features/crm/opportunities/server/service'; import { PERMISSIONS } from '@/lib/auth/rbac'; import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; @@ -15,9 +13,9 @@ export async function POST(request: NextRequest, { params }: Params) { try { const { id } = await params; const { organization, session, membership } = await requireOrganizationAccess({ - permission: PERMISSIONS.crmEnquiryMarkWon + permission: PERMISSIONS.crmOpportunityReopen }); - const payload = enquiryMarkWonSchema.parse(await request.json()); + const payload = opportunityReopenSchema.parse(await request.json()); const accessContext = { organizationId: organization.id, userId: session.user.id, @@ -30,15 +28,15 @@ export async function POST(request: NextRequest, { params }: Params) { branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all', productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all' }; - const before = await getEnquiryDetail(id, organization.id, accessContext); - const updated = await markEnquiryAsWon(id, organization.id, session.user.id, payload, accessContext); - const after = await getEnquiryDetail(id, organization.id, accessContext); + const before = await getOpportunityDetail(id, organization.id, accessContext); + const updated = await reopenLostOpportunity(id, organization.id, session.user.id, payload, accessContext); + const after = await getOpportunityDetail(id, organization.id, accessContext); return NextResponse.json({ success: true, - message: 'Enquiry marked as won successfully', + message: 'Opportunity reopened successfully', before, - enquiry: updated, + opportunity: updated, after }); } catch (error) { @@ -57,6 +55,7 @@ export async function POST(request: NextRequest, { params }: Params) { return NextResponse.json({ message: error.message }, { status: 400 }); } - return NextResponse.json({ message: 'Unable to mark enquiry as won' }, { status: 500 }); + return NextResponse.json({ message: 'Unable to reopen opportunity' }, { status: 500 }); } } + diff --git a/src/app/api/crm/enquiries/[id]/route.ts b/src/app/api/crm/opportunities/[id]/route.ts similarity index 73% rename from src/app/api/crm/enquiries/[id]/route.ts rename to src/app/api/crm/opportunities/[id]/route.ts index 0e37412..26308fe 100644 --- a/src/app/api/crm/enquiries/[id]/route.ts +++ b/src/app/api/crm/opportunities/[id]/route.ts @@ -2,12 +2,12 @@ import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service'; import { - getEnquiryActivity, - getEnquiryDetail, - softDeleteEnquiry, - updateEnquiry -} from '@/features/crm/enquiries/server/service'; -import { enquirySchema } from '@/features/crm/enquiries/schemas/enquiry.schema'; + getOpportunityActivity, + getOpportunityDetail, + softDeleteOpportunity, + updateOpportunity +} from '@/features/crm/opportunities/server/service'; +import { opportunitySchema } from '@/features/crm/opportunities/schemas/opportunity.schema'; import { PERMISSIONS } from '@/lib/auth/rbac'; import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; @@ -15,7 +15,7 @@ type Params = { params: Promise<{ id: string }>; }; -const enquiryRequestSchema = enquirySchema.extend({ +const opportunityRequestSchema = opportunitySchema.extend({ contactId: z.string().nullable().optional(), branchId: z.string().nullable().optional(), leadChannel: z.string().nullable().optional(), @@ -26,7 +26,7 @@ export async function GET(_request: NextRequest, { params }: Params) { try { const { id } = await params; const { organization, session, membership } = await requireOrganizationAccess({ - permission: PERMISSIONS.crmEnquiryRead + permission: PERMISSIONS.crmOpportunityRead }); const accessContext = { organizationId: organization.id, @@ -40,16 +40,16 @@ export async function GET(_request: NextRequest, { params }: Params) { branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all', productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all' }; - const [enquiry, activity] = await Promise.all([ - getEnquiryDetail(id, organization.id, accessContext), - getEnquiryActivity(id, organization.id, accessContext) + const [opportunity, activity] = await Promise.all([ + getOpportunityDetail(id, organization.id, accessContext), + getOpportunityActivity(id, organization.id, accessContext) ]); return NextResponse.json({ success: true, time: new Date().toISOString(), - message: 'Enquiry loaded successfully', - enquiry, + message: 'Opportunity loaded successfully', + opportunity, activity }); } catch (error) { @@ -61,7 +61,7 @@ export async function GET(_request: NextRequest, { params }: Params) { return NextResponse.json({ message: error.message }, { status: 400 }); } - return NextResponse.json({ message: 'Unable to load enquiry' }, { status: 500 }); + return NextResponse.json({ message: 'Unable to load opportunity' }, { status: 500 }); } } @@ -69,9 +69,9 @@ export async function PATCH(request: NextRequest, { params }: Params) { try { const { id } = await params; const { organization, session, membership } = await requireOrganizationAccess({ - permission: PERMISSIONS.crmEnquiryUpdate + permission: PERMISSIONS.crmOpportunityUpdate }); - const payload = enquiryRequestSchema.parse(await request.json()); + const payload = opportunityRequestSchema.parse(await request.json()); const accessContext = { organizationId: organization.id, userId: session.user.id, @@ -84,14 +84,14 @@ export async function PATCH(request: NextRequest, { params }: Params) { branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all', productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all' }; - const before = await getEnquiryDetail(id, organization.id, accessContext); - const updated = await updateEnquiry(id, organization.id, session.user.id, payload, accessContext); + const before = await getOpportunityDetail(id, organization.id, accessContext); + const updated = await updateOpportunity(id, organization.id, session.user.id, payload, accessContext); await auditUpdate({ organizationId: organization.id, branchId: updated.branchId, userId: session.user.id, - entityType: 'crm_enquiry', + entityType: 'crm_opportunity', entityId: id, beforeData: before, afterData: updated @@ -99,8 +99,8 @@ export async function PATCH(request: NextRequest, { params }: Params) { return NextResponse.json({ success: true, - message: 'Enquiry updated successfully', - enquiry: updated + message: 'Opportunity updated successfully', + opportunity: updated }); } catch (error) { if (error instanceof AuthError) { @@ -118,7 +118,7 @@ export async function PATCH(request: NextRequest, { params }: Params) { return NextResponse.json({ message: error.message }, { status: 400 }); } - return NextResponse.json({ message: 'Unable to update enquiry' }, { status: 500 }); + return NextResponse.json({ message: 'Unable to update opportunity' }, { status: 500 }); } } @@ -126,7 +126,7 @@ export async function DELETE(_request: NextRequest, { params }: Params) { try { const { id } = await params; const { organization, session, membership } = await requireOrganizationAccess({ - permission: PERMISSIONS.crmEnquiryDelete + permission: PERMISSIONS.crmOpportunityDelete }); const accessContext = { organizationId: organization.id, @@ -140,14 +140,14 @@ export async function DELETE(_request: NextRequest, { params }: Params) { branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all', productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all' }; - const before = await getEnquiryDetail(id, organization.id, accessContext); - const updated = await softDeleteEnquiry(id, organization.id, session.user.id, accessContext); + const before = await getOpportunityDetail(id, organization.id, accessContext); + const updated = await softDeleteOpportunity(id, organization.id, session.user.id, accessContext); await auditDelete({ organizationId: organization.id, branchId: updated.branchId, userId: session.user.id, - entityType: 'crm_enquiry', + entityType: 'crm_opportunity', entityId: id, beforeData: before, afterData: updated @@ -155,7 +155,7 @@ export async function DELETE(_request: NextRequest, { params }: Params) { return NextResponse.json({ success: true, - message: 'Enquiry deleted successfully' + message: 'Opportunity deleted successfully' }); } catch (error) { if (error instanceof AuthError) { @@ -166,6 +166,7 @@ export async function DELETE(_request: NextRequest, { params }: Params) { return NextResponse.json({ message: error.message }, { status: 400 }); } - return NextResponse.json({ message: 'Unable to delete enquiry' }, { status: 500 }); + return NextResponse.json({ message: 'Unable to delete opportunity' }, { status: 500 }); } } + diff --git a/src/app/api/crm/enquiries/route.ts b/src/app/api/crm/opportunities/route.ts similarity index 82% rename from src/app/api/crm/enquiries/route.ts rename to src/app/api/crm/opportunities/route.ts index b610bea..499293c 100644 --- a/src/app/api/crm/enquiries/route.ts +++ b/src/app/api/crm/opportunities/route.ts @@ -1,13 +1,13 @@ import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; import { auditAction, auditCreate } from '@/features/foundation/audit-log/service'; -import { createEnquiry, listEnquiries } from '@/features/crm/enquiries/server/service'; +import { createOpportunity, listOpportunities } from '@/features/crm/opportunities/server/service'; import { getCustomerDetail } from '@/features/crm/customers/server/service'; -import { enquirySchema } from '@/features/crm/enquiries/schemas/enquiry.schema'; +import { opportunitySchema } from '@/features/crm/opportunities/schemas/opportunity.schema'; import { PERMISSIONS } from '@/lib/auth/rbac'; import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; -const enquiryRequestSchema = enquirySchema.extend({ +const opportunityRequestSchema = opportunitySchema.extend({ contactId: z.string().nullable().optional(), assignedToUserId: z.string().nullable().optional(), branchId: z.string().nullable().optional(), @@ -18,7 +18,7 @@ const enquiryRequestSchema = enquirySchema.extend({ export async function GET(request: NextRequest) { try { const { organization, session, membership } = await requireOrganizationAccess({ - permission: PERMISSIONS.crmEnquiryRead + permission: PERMISSIONS.crmOpportunityRead }); const { searchParams } = request.nextUrl; const page = Number(searchParams.get('page') ?? 1); @@ -42,13 +42,13 @@ export async function GET(request: NextRequest) { branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all', productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all' }; - const result = await listEnquiries( + const result = await listOpportunities( accessContext, { page, limit, search, - pipelineStage: pipelineStage as 'lead' | 'enquiry' | 'closed_won' | 'closed_lost' | undefined, + pipelineStage: pipelineStage as 'lead' | 'opportunity' | 'closed_won' | 'closed_lost' | undefined, status, productType, priority, @@ -62,7 +62,7 @@ export async function GET(request: NextRequest) { return NextResponse.json({ success: true, time: new Date().toISOString(), - message: 'Enquiries loaded successfully', + message: 'Opportunities loaded successfully', totalItems: result.totalItems, offset, limit, @@ -77,16 +77,16 @@ export async function GET(request: NextRequest) { return NextResponse.json({ message: error.message }, { status: 400 }); } - return NextResponse.json({ message: 'Unable to load enquiries' }, { status: 500 }); + return NextResponse.json({ message: 'Unable to load opportunities' }, { status: 500 }); } } export async function POST(request: NextRequest) { try { const { organization, session, membership } = await requireOrganizationAccess({ - permission: PERMISSIONS.crmEnquiryCreate + permission: PERMISSIONS.crmOpportunityCreate }); - const payload = enquiryRequestSchema.parse(await request.json()); + const payload = opportunityRequestSchema.parse(await request.json()); const accessContext = { organizationId: organization.id, userId: session.user.id, @@ -98,7 +98,7 @@ export async function POST(request: NextRequest) { branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all', productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all' }; - const created = await createEnquiry( + const created = await createOpportunity( organization.id, session.user.id, accessContext, @@ -110,7 +110,7 @@ export async function POST(request: NextRequest) { organizationId: organization.id, branchId: created.branchId, userId: session.user.id, - entityType: 'crm_enquiry', + entityType: 'crm_opportunity', entityId: created.id, afterData: created }); @@ -120,7 +120,7 @@ export async function POST(request: NextRequest) { organizationId: organization.id, branchId: created.branchId, userId: session.user.id, - entityType: 'crm_enquiry', + entityType: 'crm_opportunity', entityId: created.id, action: payload.assignedToUserId && payload.assignedToUserId !== customer.ownerUserId @@ -137,8 +137,8 @@ export async function POST(request: NextRequest) { return NextResponse.json( { success: true, - message: 'Enquiry created successfully', - enquiry: created + message: 'Opportunity created successfully', + opportunity: created }, { status: 201 } ); @@ -158,6 +158,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ message: error.message }, { status: 400 }); } - return NextResponse.json({ message: 'Unable to create enquiry' }, { status: 500 }); + return NextResponse.json({ message: 'Unable to create opportunity' }, { status: 500 }); } } + diff --git a/src/app/api/crm/quotations/route.ts b/src/app/api/crm/quotations/route.ts index 45edfe1..d7f7328 100644 --- a/src/app/api/crm/quotations/route.ts +++ b/src/app/api/crm/quotations/route.ts @@ -20,7 +20,7 @@ export async function GET(request: NextRequest) { const quotationType = searchParams.get('quotationType') ?? undefined; const branch = searchParams.get('branch') ?? undefined; const customer = searchParams.get('customer') ?? undefined; - const enquiry = searchParams.get('enquiry') ?? undefined; + const opportunity = searchParams.get('opportunity') ?? undefined; const hot = searchParams.get('hot') ?? undefined; const sort = searchParams.get('sort') ?? undefined; const accessContext = buildCrmSecurityContext({ @@ -36,7 +36,7 @@ export async function GET(request: NextRequest) { quotationType, branch, customer, - enquiry, + opportunity, hot, sort }, accessContext); @@ -113,3 +113,4 @@ export async function POST(request: NextRequest) { return NextResponse.json({ message: 'Unable to create quotation' }, { status: 500 }); } } + diff --git a/src/app/api/crm/reports/export/route.ts b/src/app/api/crm/reports/export/route.ts index 95ebec2..026eee6 100644 --- a/src/app/api/crm/reports/export/route.ts +++ b/src/app/api/crm/reports/export/route.ts @@ -7,7 +7,7 @@ import { } from '@/features/crm/reports/server/exports/service'; import { parseSharedReportFilters } from '@/features/crm/reports/server/filters/shared'; import { - getCrmEnquiryAgingReportResponse, + getCrmOpportunityAgingReportResponse, getCrmLeadAgingReportResponse, getCrmPipelineReportResponse } from '@/features/crm/reports/server/service'; @@ -18,7 +18,7 @@ function buildExportRows(input: { reportCode: string; pipeline?: Awaited>; leadAging?: Awaited>; - enquiryAging?: Awaited>; + opportunityAging?: Awaited>; }) { switch (input.reportCode) { case 'lead_pipeline': @@ -37,15 +37,15 @@ function buildExportRows(input: { String(row.convertedLeads) ]) ]; - case 'enquiry_pipeline': + case 'opportunity_pipeline': return [ - ['Salesman', 'Branch', 'Product Type', 'Customer Owner', 'Open Enquiries', 'Converted To Quotation', 'Won', 'Lost'], - ...(input.pipeline?.enquiryPipeline ?? []).map((row) => [ + ['Salesman', 'Branch', 'Product Type', 'Customer Owner', 'Open Opportunities', 'Converted To Quotation', 'Won', 'Lost'], + ...(input.pipeline?.opportunityPipeline ?? []).map((row) => [ row.salesmanName, row.branchLabel, row.productTypeLabel, row.customerOwnerName, - String(row.openEnquiries), + String(row.openOpportunities), String(row.convertedToQuotation), String(row.won), String(row.lost) @@ -53,26 +53,26 @@ function buildExportRows(input: { ]; case 'lead_conversion': return [ - ['Lead Source', 'Branch', 'Product Type', 'Marketing User', 'Total Leads', 'Converted Enquiries', 'Conversion Rate %'], + ['Lead Source', 'Branch', 'Product Type', 'Marketing User', 'Total Leads', 'Converted Opportunities', 'Conversion Rate %'], ...(input.pipeline?.leadConversion ?? []).map((row) => [ row.leadSourceLabel, row.branchLabel, row.productTypeLabel, row.marketingUserName, String(row.totalLeads), - String(row.convertedEnquiries), + String(row.convertedOpportunities), String(row.conversionRate) ]) ]; - case 'enquiry_conversion': + case 'opportunity_conversion': return [ - ['Salesman', 'Branch', 'Product Type', 'Total Enquiries', 'Enquiries With Quotation', 'Conversion Rate %'], - ...(input.pipeline?.enquiryConversion ?? []).map((row) => [ + ['Salesman', 'Branch', 'Product Type', 'Total Opportunities', 'Opportunities With Quotation', 'Conversion Rate %'], + ...(input.pipeline?.opportunityConversion ?? []).map((row) => [ row.salesmanName, row.branchLabel, row.productTypeLabel, - String(row.totalEnquiries), - String(row.enquiriesWithQuotation), + String(row.totalOpportunities), + String(row.opportunitiesWithQuotation), String(row.conversionRate) ]) ]; @@ -97,11 +97,11 @@ function buildExportRows(input: { String(row.agingDays) ]) ]; - case 'enquiry_aging': + case 'opportunity_aging': return [ - ['Enquiry No', 'Customer', 'Salesman', 'Last Follow-Up', 'Aging Days'], - ...(input.enquiryAging?.rows ?? []).map((row) => [ - row.enquiryCode, + ['Opportunity No', 'Customer', 'Salesman', 'Last Follow-Up', 'Aging Days'], + ...(input.opportunityAging?.rows ?? []).map((row) => [ + row.opportunityCode, row.customerName, row.salesmanName ?? '', row.lastFollowupDate ?? '', @@ -117,23 +117,23 @@ function getRecordCount(input: { reportCode: string; pipeline?: Awaited>; leadAging?: Awaited>; - enquiryAging?: Awaited>; + opportunityAging?: Awaited>; }) { switch (input.reportCode) { case 'lead_pipeline': return input.pipeline?.leadPipeline.length ?? 0; - case 'enquiry_pipeline': - return input.pipeline?.enquiryPipeline.length ?? 0; + case 'opportunity_pipeline': + return input.pipeline?.opportunityPipeline.length ?? 0; case 'lead_conversion': return input.pipeline?.leadConversion.length ?? 0; - case 'enquiry_conversion': - return input.pipeline?.enquiryConversion.length ?? 0; + case 'opportunity_conversion': + return input.pipeline?.opportunityConversion.length ?? 0; case 'pipeline_funnel': return input.pipeline?.funnel.length ?? 0; case 'lead_aging': return input.leadAging?.rows.length ?? 0; - case 'enquiry_aging': - return input.enquiryAging?.rows.length ?? 0; + case 'opportunity_aging': + return input.opportunityAging?.rows.length ?? 0; default: return 0; } @@ -155,9 +155,9 @@ export async function GET(request: NextRequest) { const pipelineCodes = new Set([ 'lead_pipeline', - 'enquiry_pipeline', + 'opportunity_pipeline', 'lead_conversion', - 'enquiry_conversion', + 'opportunity_conversion', 'pipeline_funnel' ]); const pipeline = pipelineCodes.has(reportCode) @@ -165,11 +165,11 @@ export async function GET(request: NextRequest) { : undefined; const leadAging = reportCode === 'lead_aging' ? await getCrmLeadAgingReportResponse(context, filters) : undefined; - const enquiryAging = - reportCode === 'enquiry_aging' - ? await getCrmEnquiryAgingReportResponse(context, filters) + const opportunityAging = + reportCode === 'opportunity_aging' + ? await getCrmOpportunityAgingReportResponse(context, filters) : undefined; - const rows = buildExportRows({ reportCode, pipeline, leadAging, enquiryAging }); + const rows = buildExportRows({ reportCode, pipeline, leadAging, opportunityAging }); if (!rows) { return NextResponse.json({ message: 'Unsupported report code' }, { status: 400 }); @@ -186,7 +186,7 @@ export async function GET(request: NextRequest) { reportCode, format, filters: { ...filters }, - recordCount: getRecordCount({ reportCode, pipeline, leadAging, enquiryAging }) + recordCount: getRecordCount({ reportCode, pipeline, leadAging, opportunityAging }) }); return new NextResponse(body, { @@ -203,3 +203,4 @@ export async function GET(request: NextRequest) { return NextResponse.json({ message: 'Unable to export CRM report' }, { status: 500 }); } } + diff --git a/src/app/api/crm/reports/enquiry-aging/route.ts b/src/app/api/crm/reports/opportunity-aging/route.ts similarity index 82% rename from src/app/api/crm/reports/enquiry-aging/route.ts rename to src/app/api/crm/reports/opportunity-aging/route.ts index c3d2ed7..505ffa8 100644 --- a/src/app/api/crm/reports/enquiry-aging/route.ts +++ b/src/app/api/crm/reports/opportunity-aging/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { resolveCrmAccess } from '@/features/crm/reports/server/context'; import { parseSharedReportFilters } from '@/features/crm/reports/server/filters/shared'; -import { getCrmEnquiryAgingReportResponse } from '@/features/crm/reports/server/service'; +import { getCrmOpportunityAgingReportResponse } from '@/features/crm/reports/server/service'; import { PERMISSIONS } from '@/lib/auth/rbac'; import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; @@ -12,7 +12,7 @@ export async function GET(request: NextRequest) { }); return NextResponse.json( - await getCrmEnquiryAgingReportResponse( + await getCrmOpportunityAgingReportResponse( resolveCrmAccess({ organizationId: organization.id, userId: session.user.id, @@ -27,8 +27,9 @@ export async function GET(request: NextRequest) { } return NextResponse.json( - { message: 'Unable to load CRM enquiry aging report' }, + { message: 'Unable to load CRM opportunity aging report' }, { status: 500 } ); } } + diff --git a/src/app/dashboard/crm-demo/customers/[id]/page.tsx b/src/app/dashboard/crm-demo/customers/[id]/page.tsx index 8a3b1e1..7b029c0 100644 --- a/src/app/dashboard/crm-demo/customers/[id]/page.tsx +++ b/src/app/dashboard/crm-demo/customers/[id]/page.tsx @@ -14,7 +14,7 @@ export default async function CustomerDetailRoute({ params }: PageProps) { return ( @@ -22,3 +22,4 @@ export default async function CustomerDetailRoute({ params }: PageProps) { ); } + diff --git a/src/app/dashboard/crm-demo/enquiries/[id]/page.tsx b/src/app/dashboard/crm-demo/enquiries/[id]/page.tsx index 58e7cf6..99e48b1 100644 --- a/src/app/dashboard/crm-demo/enquiries/[id]/page.tsx +++ b/src/app/dashboard/crm-demo/enquiries/[id]/page.tsx @@ -1,24 +1,25 @@ import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; import PageContainer from '@/components/layout/page-container'; -import { enquiryByIdOptions } from '@/features/crm-demo/api/queries'; -import { EnquiryDetailPage } from '@/features/crm-demo/components/enquiry-detail'; +import { opportunityByIdOptions } from '@/features/crm-demo/api/queries'; +import { OpportunityDetailPage } from '@/features/crm-demo/components/opportunity-detail'; import { getQueryClient } from '@/lib/query-client'; type PageProps = { params: Promise<{ id: string }> }; -export default async function EnquiryDetailRoute({ params }: PageProps) { +export default async function OpportunityDetailRoute({ params }: PageProps) { const { id } = await params; const queryClient = getQueryClient(); - void queryClient.prefetchQuery(enquiryByIdOptions(id)); + void queryClient.prefetchQuery(opportunityByIdOptions(id)); return ( - + ); } + diff --git a/src/app/dashboard/crm-demo/enquiries/page.tsx b/src/app/dashboard/crm-demo/enquiries/page.tsx index c646179..a4b017b 100644 --- a/src/app/dashboard/crm-demo/enquiries/page.tsx +++ b/src/app/dashboard/crm-demo/enquiries/page.tsx @@ -1,16 +1,16 @@ import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; import type { SearchParams } from 'nuqs/server'; import PageContainer from '@/components/layout/page-container'; -import { crmReferenceQueryOptions, enquiriesQueryOptions } from '@/features/crm-demo/api/queries'; -import { EnquiriesTablePage } from '@/features/crm-demo/components/enquiries-table'; +import { crmReferenceQueryOptions, opportunitiesQueryOptions } from '@/features/crm-demo/api/queries'; +import { OpportunitiesTablePage } from '@/features/crm-demo/components/opportunities-table'; import { getQueryClient } from '@/lib/query-client'; import { searchParamsCache } from '@/lib/searchparams'; export const metadata = { - title: 'Dashboard: CRM Enquiries' + title: 'Dashboard: CRM Opportunities' }; -export default async function EnquiriesRoute({ +export default async function OpportunitiesRoute({ searchParams }: { searchParams: Promise; @@ -29,7 +29,7 @@ export default async function EnquiriesRoute({ void queryClient.prefetchQuery(crmReferenceQueryOptions()); void queryClient.prefetchQuery( - enquiriesQueryOptions({ + opportunitiesQueryOptions({ page, limit: perPage, ...(search && { search }), @@ -44,12 +44,13 @@ export default async function EnquiriesRoute({ return ( - + ); } + diff --git a/src/app/dashboard/crm-demo/page.tsx b/src/app/dashboard/crm-demo/page.tsx index 80db018..3de98b3 100644 --- a/src/app/dashboard/crm-demo/page.tsx +++ b/src/app/dashboard/crm-demo/page.tsx @@ -15,7 +15,7 @@ export default function CrmDashboardRoute() { return ( @@ -23,3 +23,4 @@ export default function CrmDashboardRoute() { ); } + diff --git a/src/app/dashboard/crm/customers/[id]/page.tsx b/src/app/dashboard/crm/customers/[id]/page.tsx index 86ff35d..6ce9789 100644 --- a/src/app/dashboard/crm/customers/[id]/page.tsx +++ b/src/app/dashboard/crm/customers/[id]/page.tsx @@ -7,7 +7,7 @@ import { getCustomerDetail, getCustomerReferenceData } from '@/features/crm/customers/server/service'; -import { listCustomerEnquiryRelations } from '@/features/crm/enquiries/server/service'; +import { listCustomerOpportunityRelations } from '@/features/crm/opportunities/server/service'; import { listCustomerQuotationRelations } from '@/features/crm/quotations/server/service'; import { PERMISSIONS } from '@/lib/auth/rbac'; import { getQueryClient } from '@/lib/query-client'; @@ -100,9 +100,9 @@ export default async function CustomerDetailRoute({ params }: PageProps) { resolvedCanRead && session?.user?.activeOrganizationId ? await getCustomerReferenceData(session.user.activeOrganizationId) : null; - const relatedEnquiries = + const relatedOpportunities = resolvedCanRead && session?.user?.activeOrganizationId && accessContext - ? await listCustomerEnquiryRelations(id, session.user.activeOrganizationId, accessContext) + ? await listCustomerOpportunityRelations(id, session.user.activeOrganizationId, accessContext) : []; const relatedQuotations = resolvedCanRead && session?.user?.activeOrganizationId && accessContext @@ -135,7 +135,7 @@ export default async function CustomerDetailRoute({ params }: PageProps) { canReadContactShares={canContactShareRead} canManageContactShares={canContactShareManage} canManageOwner={canOwnerManage} - relatedEnquiries={relatedEnquiries} + relatedOpportunities={relatedOpportunities} relatedQuotations={relatedQuotations} /> @@ -143,3 +143,4 @@ export default async function CustomerDetailRoute({ params }: PageProps) { ); } + diff --git a/src/app/dashboard/crm/enquiries/page.tsx b/src/app/dashboard/crm/enquiries/page.tsx deleted file mode 100644 index 2ddbd41..0000000 --- a/src/app/dashboard/crm/enquiries/page.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { auth } from '@/auth'; -import PageContainer from '@/components/layout/page-container'; -import EnquiryListing from '@/features/crm/enquiries/components/enquiry-listing'; -import { EnquiryFormSheetTrigger } from '@/features/crm/enquiries/components/enquiry-form-sheet'; -import { getEnquiryReferenceData } from '@/features/crm/enquiries/server/service'; -import { PERMISSIONS } from '@/lib/auth/rbac'; -import type { SearchParams } from 'nuqs/server'; - -export const metadata = { - title: 'Dashboard: CRM Opportunities' -}; - -type PageProps = { - searchParams: Promise; -}; - -export default async function EnquiriesRoute(props: PageProps) { - 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.crmEnquiryRead))); - - const canCreate = - session?.user?.systemRole === 'super_admin' || - (!!session?.user?.activeOrganizationId && - (session.user.activeMembershipRole === 'admin' || - session.user.activePermissions.includes(PERMISSIONS.crmEnquiryCreate))); - - const canUpdate = - session?.user?.systemRole === 'super_admin' || - (!!session?.user?.activeOrganizationId && - (session.user.activeMembershipRole === 'admin' || - session.user.activePermissions.includes(PERMISSIONS.crmEnquiryUpdate))); - - const canDelete = - session?.user?.systemRole === 'super_admin' || - (!!session?.user?.activeOrganizationId && - (session.user.activeMembershipRole === 'admin' || - session.user.activePermissions.includes(PERMISSIONS.crmEnquiryDelete))); - - const canAssign = - session?.user?.systemRole === 'super_admin' || - (!!session?.user?.activeOrganizationId && - (session.user.activeMembershipRole === 'admin' || - session.user.activePermissions.includes(PERMISSIONS.crmEnquiryAssign))); - - const canReassign = - session?.user?.systemRole === 'super_admin' || - (!!session?.user?.activeOrganizationId && - (session.user.activeMembershipRole === 'admin' || - session.user.activePermissions.includes(PERMISSIONS.crmEnquiryReassign))); - - if (!canRead || !session?.user?.activeOrganizationId) { - return ( - - {null} - - ); - } - - const referenceData = await getEnquiryReferenceData(session.user.activeOrganizationId); - - return ( - : null} - > - - - ); -} diff --git a/src/app/dashboard/crm/leads/page.tsx b/src/app/dashboard/crm/leads/page.tsx index 2a77bdf..0d9ca86 100644 --- a/src/app/dashboard/crm/leads/page.tsx +++ b/src/app/dashboard/crm/leads/page.tsx @@ -64,7 +64,7 @@ export default async function LeadsRoute(props: PageProps) { return ( : null } @@ -80,3 +80,4 @@ export default async function LeadsRoute(props: PageProps) { ); } + diff --git a/src/app/dashboard/crm/enquiries/[id]/page.tsx b/src/app/dashboard/crm/opportunities/[id]/page.tsx similarity index 74% rename from src/app/dashboard/crm/enquiries/[id]/page.tsx rename to src/app/dashboard/crm/opportunities/[id]/page.tsx index b900ed0..2d6bc07 100644 --- a/src/app/dashboard/crm/enquiries/[id]/page.tsx +++ b/src/app/dashboard/crm/opportunities/[id]/page.tsx @@ -3,14 +3,14 @@ import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; import { notFound } from 'next/navigation'; import PageContainer from '@/components/layout/page-container'; import { - enquiryByIdOptions, - enquiryFollowupsOptions, - enquiryProjectPartiesOptions -} from '@/features/crm/enquiries/api/queries'; -import { EnquiryDetail } from '@/features/crm/enquiries/components/enquiry-detail'; -import { getEnquiryDetail, getEnquiryReferenceData } from '@/features/crm/enquiries/server/service'; + opportunityByIdOptions, + opportunityFollowupsOptions, + opportunityProjectPartiesOptions +} from '@/features/crm/opportunities/api/queries'; +import { OpportunityDetail } from '@/features/crm/opportunities/components/opportunity-detail'; +import { getOpportunityDetail, getOpportunityReferenceData } from '@/features/crm/opportunities/server/service'; import { getLeadById } from '@/features/crm/leads/server/service'; -import { listEnquiryQuotationRelations } from '@/features/crm/quotations/server/service'; +import { listOpportunityQuotationRelations } from '@/features/crm/quotations/server/service'; import { getQuotationReferenceData } from '@/features/crm/quotations/server/service'; import { PERMISSIONS } from '@/lib/auth/rbac'; import { getQueryClient } from '@/lib/query-client'; @@ -19,7 +19,7 @@ type PageProps = { params: Promise<{ id: string }>; }; -export default async function EnquiryDetailRoute({ params }: PageProps) { +export default async function OpportunityDetailRoute({ params }: PageProps) { const { id } = await params; const session = await auth(); @@ -27,61 +27,61 @@ export default async function EnquiryDetailRoute({ params }: PageProps) { session?.user?.systemRole === 'super_admin' || (!!session?.user?.activeOrganizationId && (session.user.activeMembershipRole === 'admin' || - session.user.activePermissions.includes(PERMISSIONS.crmEnquiryRead))); + session.user.activePermissions.includes(PERMISSIONS.crmOpportunityRead))); const canUpdate = session?.user?.systemRole === 'super_admin' || (!!session?.user?.activeOrganizationId && (session.user.activeMembershipRole === 'admin' || - session.user.activePermissions.includes(PERMISSIONS.crmEnquiryUpdate))); + session.user.activePermissions.includes(PERMISSIONS.crmOpportunityUpdate))); const canAssign = session?.user?.systemRole === 'super_admin' || (!!session?.user?.activeOrganizationId && (session.user.activeMembershipRole === 'admin' || - session.user.activePermissions.includes(PERMISSIONS.crmEnquiryAssign))); + session.user.activePermissions.includes(PERMISSIONS.crmOpportunityAssign))); const canReassign = session?.user?.systemRole === 'super_admin' || (!!session?.user?.activeOrganizationId && (session.user.activeMembershipRole === 'admin' || - session.user.activePermissions.includes(PERMISSIONS.crmEnquiryReassign))); + session.user.activePermissions.includes(PERMISSIONS.crmOpportunityReassign))); const canManageFollowups = { create: session?.user?.systemRole === 'super_admin' || (!!session?.user?.activeOrganizationId && (session.user.activeMembershipRole === 'admin' || - session.user.activePermissions.includes(PERMISSIONS.crmEnquiryFollowupCreate))), + session.user.activePermissions.includes(PERMISSIONS.crmOpportunityFollowupCreate))), update: session?.user?.systemRole === 'super_admin' || (!!session?.user?.activeOrganizationId && (session.user.activeMembershipRole === 'admin' || - session.user.activePermissions.includes(PERMISSIONS.crmEnquiryFollowupUpdate))), + session.user.activePermissions.includes(PERMISSIONS.crmOpportunityFollowupUpdate))), delete: session?.user?.systemRole === 'super_admin' || (!!session?.user?.activeOrganizationId && (session.user.activeMembershipRole === 'admin' || - session.user.activePermissions.includes(PERMISSIONS.crmEnquiryFollowupDelete))) + session.user.activePermissions.includes(PERMISSIONS.crmOpportunityFollowupDelete))) }; const canMarkWon = session?.user?.systemRole === 'super_admin' || (!!session?.user?.activeOrganizationId && (session.user.activeMembershipRole === 'admin' || - session.user.activePermissions.includes(PERMISSIONS.crmEnquiryMarkWon))); + session.user.activePermissions.includes(PERMISSIONS.crmOpportunityMarkWon))); const canMarkLost = session?.user?.systemRole === 'super_admin' || (!!session?.user?.activeOrganizationId && (session.user.activeMembershipRole === 'admin' || - session.user.activePermissions.includes(PERMISSIONS.crmEnquiryMarkLost))); + session.user.activePermissions.includes(PERMISSIONS.crmOpportunityMarkLost))); const canReopen = session?.user?.systemRole === 'super_admin' || (!!session?.user?.activeOrganizationId && (session.user.activeMembershipRole === 'admin' || - session.user.activePermissions.includes(PERMISSIONS.crmEnquiryReopen))); + session.user.activePermissions.includes(PERMISSIONS.crmOpportunityReopen))); const canViewRelatedQuotations = session?.user?.systemRole === 'super_admin' || @@ -118,17 +118,17 @@ export default async function EnquiryDetailRoute({ params }: PageProps) { const queryClient = getQueryClient(); await Promise.all([ - queryClient.prefetchQuery(enquiryByIdOptions(id)), - queryClient.prefetchQuery(enquiryFollowupsOptions(id)), - queryClient.prefetchQuery(enquiryProjectPartiesOptions(id)) + queryClient.prefetchQuery(opportunityByIdOptions(id)), + queryClient.prefetchQuery(opportunityFollowupsOptions(id)), + queryClient.prefetchQuery(opportunityProjectPartiesOptions(id)) ]); - const enquiryDetail = await getEnquiryDetail(id, session.user.activeOrganizationId, accessContext); + const opportunityDetail = await getOpportunityDetail(id, session.user.activeOrganizationId, accessContext); const [referenceData, relatedQuotations, sourceLead, quotationReferenceData] = await Promise.all([ - getEnquiryReferenceData(session.user.activeOrganizationId), - listEnquiryQuotationRelations(id, session.user.activeOrganizationId), - enquiryDetail.leadId - ? getLeadById(enquiryDetail.leadId, session.user.activeOrganizationId).catch(() => null) + getOpportunityReferenceData(session.user.activeOrganizationId), + listOpportunityQuotationRelations(id, session.user.activeOrganizationId), + opportunityDetail.leadId + ? getLeadById(opportunityDetail.leadId, session.user.activeOrganizationId).catch(() => null) : Promise.resolve(null), canCreateQuotation ? getQuotationReferenceData(session.user.activeOrganizationId) : Promise.resolve(null) ]); @@ -139,11 +139,11 @@ export default async function EnquiryDetailRoute({ params }: PageProps) { pageDescription='Sales execution workspace for follow-up continuity, quotation readiness, and outcome control.' > - ); } + diff --git a/src/app/dashboard/crm/opportunities/page.tsx b/src/app/dashboard/crm/opportunities/page.tsx new file mode 100644 index 0000000..3fd9834 --- /dev/null +++ b/src/app/dashboard/crm/opportunities/page.tsx @@ -0,0 +1,93 @@ +import { auth } from "@/auth"; +import PageContainer from "@/components/layout/page-container"; +import OpportunityListing from "@/features/crm/opportunities/components/opportunity-listing"; +import { OpportunityFormSheetTrigger } from "@/features/crm/opportunities/components/opportunity-form-sheet"; +import { getOpportunityReferenceData } from "@/features/crm/opportunities/server/service"; +import { PERMISSIONS } from "@/lib/auth/rbac"; +import type { SearchParams } from "nuqs/server"; +import { searchParamsCache } from "@/lib/searchparams"; + +export const metadata = { + title: "Dashboard: CRM Opportunities", +}; + +type PageProps = { + searchParams: Promise; +}; + +export default async function OpportunitiesRoute(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.crmOpportunityRead))); + + const canCreate = + session?.user?.systemRole === "super_admin" || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === "admin" || + session.user.activePermissions.includes(PERMISSIONS.crmOpportunityCreate))); + + const canUpdate = + session?.user?.systemRole === "super_admin" || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === "admin" || + session.user.activePermissions.includes(PERMISSIONS.crmOpportunityUpdate))); + + const canDelete = + session?.user?.systemRole === "super_admin" || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === "admin" || + session.user.activePermissions.includes(PERMISSIONS.crmOpportunityDelete))); + + const canAssign = + session?.user?.systemRole === "super_admin" || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === "admin" || + session.user.activePermissions.includes(PERMISSIONS.crmOpportunityAssign))); + + const canReassign = + session?.user?.systemRole === "super_admin" || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === "admin" || + session.user.activePermissions.includes( + PERMISSIONS.crmOpportunityReassign, + ))); + + if (!canRead || !session?.user?.activeOrganizationId) { + return ( + + {null} + + ); + } + searchParamsCache.parse(searchParams); + const referenceData = await getOpportunityReferenceData( + session.user.activeOrganizationId, + ); + + return ( + + ) : null + } + > + + + ); +} + diff --git a/src/app/dashboard/crm/reports/enquiry-aging/page.tsx b/src/app/dashboard/crm/reports/opportunity-aging/page.tsx similarity index 80% rename from src/app/dashboard/crm/reports/enquiry-aging/page.tsx rename to src/app/dashboard/crm/reports/opportunity-aging/page.tsx index 867530e..096f883 100644 --- a/src/app/dashboard/crm/reports/enquiry-aging/page.tsx +++ b/src/app/dashboard/crm/reports/opportunity-aging/page.tsx @@ -2,7 +2,7 @@ import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; import { auth } from '@/auth'; import PageContainer from '@/components/layout/page-container'; import { - crmEnquiryAgingReportQueryOptions, + crmOpportunityAgingReportQueryOptions, crmReportFiltersQueryOptions } from '@/features/crm/reports/api/queries'; import { AgingReportView } from '@/features/crm/reports/components/aging-report-view'; @@ -11,7 +11,7 @@ import { getQueryClient } from '@/lib/query-client'; import type { SearchParams } from 'nuqs/server'; export const metadata = { - title: 'CRM Enquiry Aging Report' + title: 'CRM Opportunity Aging Report' }; type PageProps = { @@ -22,7 +22,7 @@ function getFilterValue(value: string | string[] | undefined): string | null { return typeof value === 'string' ? value : null; } -export default async function CrmEnquiryAgingRoute(props: PageProps) { +export default async function CrmOpportunityAgingRoute(props: PageProps) { const searchParams = await props.searchParams; const session = await auth(); const canRead = @@ -48,25 +48,26 @@ export default async function CrmEnquiryAgingRoute(props: PageProps) { if (canRead) { void queryClient.prefetchQuery(crmReportFiltersQueryOptions()); - void queryClient.prefetchQuery(crmEnquiryAgingReportQueryOptions(filters)); + void queryClient.prefetchQuery(crmOpportunityAgingReportQueryOptions(filters)); } return ( - You do not have access to CRM enquiry aging reports. + You do not have access to CRM opportunity aging reports. } > {canRead ? ( - + ) : null} ); } + diff --git a/src/app/dashboard/crm/reports/pipeline/page.tsx b/src/app/dashboard/crm/reports/pipeline/page.tsx index 75a739c..3554165 100644 --- a/src/app/dashboard/crm/reports/pipeline/page.tsx +++ b/src/app/dashboard/crm/reports/pipeline/page.tsx @@ -56,7 +56,7 @@ export default async function CrmPipelineReportsRoute(props: PageProps) { return ( @@ -72,3 +72,4 @@ export default async function CrmPipelineReportsRoute(props: PageProps) { ); } + diff --git a/src/config/nav-config.ts b/src/config/nav-config.ts index 901331a..9896598 100644 --- a/src/config/nav-config.ts +++ b/src/config/nav-config.ts @@ -1,211 +1,184 @@ import { NavGroup } from "@/types"; - -/** - * Navigation configuration with RBAC support - * - * This configuration is used for both the sidebar navigation and Cmd+K bar. - * Items are organized into groups, each rendered with a SidebarGroupLabel. - * - * RBAC Access Control: - * Each navigation item can have an `access` property that controls visibility - * based on permissions, plans, features, roles, and organization context. - * - * Examples: - * - * 1. Require organization: - * access: { requireOrg: true } - * - * 2. Require specific permission: - * access: { requireOrg: true, permission: 'org:teams:manage' } - * - * 3. Require specific plan: - * access: { plan: 'pro' } - * - * 4. Require specific feature: - * access: { feature: 'premium_access' } - * - * 5. Require specific role: - * access: { role: 'admin' } - * - * 6. Multiple conditions (all must be true): - * access: { requireOrg: true, permission: 'org:teams:manage', plan: 'pro' } - * - * Note: The `visible` function is deprecated but still supported for backward compatibility. - * Use the `access` property for new items. - */ -export const navGroups: NavGroup[] = [ - { - label: "Overview", - items: [ - { - title: "Dashboard Overview", - url: "/dashboard", - icon: "dashboard", - isActive: false, - shortcut: ["d", "d"], - items: [], - }, - { - title: "Workspaces", - url: "/dashboard/workspaces", - icon: "workspace", - isActive: false, - items: [], - access: { systemRole: "super_admin" }, - }, - { - title: "Teams", - url: "/dashboard/workspaces/team", - icon: "teams", - isActive: false, - items: [], - access: { requireOrg: true, permission: "crm.quotation.read" }, - }, - { - title: "Users", - url: "/dashboard/users", - icon: "teams", - shortcut: ["u", "u"], - isActive: false, - items: [], - access: { requireOrg: true, permission: "users:manage" }, - }, - { - title: "Kanban", - url: "/dashboard/kanban", - icon: "kanban", - shortcut: ["k", "k"], - isActive: false, - items: [], - }, - ], - }, - { - label: "CRM", - items: [ - { - title: "Dashboard", - url: "/dashboard/crm", - icon: "dashboard", - isActive: false, - items: [], - access: { requireOrg: true, permission: "crm.dashboard.read" }, - }, - { - title: "ลูกค้า", - url: "/dashboard/crm/customers", - icon: "teams", - isActive: false, - items: [], - access: { requireOrg: true, permission: "crm.customer.read" }, - }, - { - title: "ลีด", - url: "/dashboard/crm/leads", - icon: "forms", - isActive: false, - items: [], - access: { requireOrg: true, permission: "crm.lead.read" }, - }, - { - title: "โอกาสขาย", - url: "/dashboard/crm/enquiries", - icon: "forms", - isActive: false, - items: [], - access: { requireOrg: true, permission: "crm.enquiry.read" }, - }, - { - title: "ใบเสนอราคา", - url: "/dashboard/crm/quotations", - icon: "post", - isActive: false, - items: [], - access: { requireOrg: true, role: "admin" }, - }, - { - title: "อนุมัติเอกสาร", - url: "/dashboard/crm/approvals", - icon: "checks", - isActive: false, - items: [], - access: { requireOrg: true, permission: "crm.approval.read" }, - }, - { - title: "Reports", - url: "/dashboard/crm/reports", - icon: "dashboard", - isActive: false, - items: [ - { - title: "Pipeline Suite", - url: "/dashboard/crm/reports/pipeline", - access: { requireOrg: true, permission: "crm.report.read" }, - }, - { - title: "Lead Aging", - url: "/dashboard/crm/reports/lead-aging", - access: { requireOrg: true, permission: "crm.report.read" }, - }, - { - title: "Enquiry Aging", - url: "/dashboard/crm/reports/enquiry-aging", - access: { requireOrg: true, permission: "crm.report.read" }, - }, - ], - access: { requireOrg: true, permission: "crm.report.read" }, - }, - { - title: "ตั้งค่า CRM", - url: "/dashboard/crm/settings/master-options", - icon: "settings", - isActive: false, - access: { requireOrg: true, permission: "crm.user_role_assignment.read" }, - items: [ - { - title: "Roles & Permissions", - url: "/dashboard/crm/settings/roles", - access: { requireOrg: true, permission: "crm.role.read" }, - }, - { - title: "User Role Assignments", - url: "/dashboard/crm/settings/user-role-assignments", - access: { - requireOrg: true, - permission: "crm.user_role_assignment.read", +/** * Navigation configuration with RBAC support * * This configuration is used for both the sidebar navigation and Cmd+K bar. * Items are organized into groups, each rendered with a SidebarGroupLabel. * * RBAC Access Control: * Each navigation item can have an `access` property that controls visibility * based on permissions, plans, features, roles, and organization context. * * Examples: * * 1. Require organization: * access: { requireOrg: true } * * 2. Require specific permission: * access: { requireOrg: true, permission: 'org:teams:manage' } * * 3. Require specific plan: * access: { plan: 'pro' } * * 4. Require specific feature: * access: { feature: 'premium_access' } * * 5. Require specific role: * access: { role: 'admin' } * * 6. Multiple conditions (all must be true): * access: { requireOrg: true, permission: 'org:teams:manage', plan: 'pro' } * * Note: The `visible` function is deprecated but still supported for backward compatibility. * Use the `access` property for new items. */ export const navGroups: NavGroup[] = + [ + { + label: "Overview", + items: [ + { + title: "Dashboard Overview", + url: "/dashboard", + icon: "dashboard", + isActive: false, + shortcut: ["d", "d"], + items: [], + }, + { + title: "Workspaces", + url: "/dashboard/workspaces", + icon: "workspace", + isActive: false, + items: [], + access: { systemRole: "super_admin" }, + }, + { + title: "Teams", + url: "/dashboard/workspaces/team", + icon: "teams", + isActive: false, + items: [], + access: { requireOrg: true, permission: "crm.quotation.read" }, + }, + { + title: "Users", + url: "/dashboard/users", + icon: "teams", + shortcut: ["u", "u"], + isActive: false, + items: [], + access: { requireOrg: true, permission: "users:manage" }, + }, + { + title: "Kanban", + url: "/dashboard/kanban", + icon: "kanban", + shortcut: ["k", "k"], + isActive: false, + items: [], + }, + ], + }, + { + label: "CRM", + items: [ + { + title: "Dashboard", + url: "/dashboard/crm", + icon: "dashboard", + isActive: false, + items: [], + access: { requireOrg: true, permission: "crm.dashboard.read" }, + }, + { + title: "ลูกค้า", + url: "/dashboard/crm/customers", + icon: "teams", + isActive: false, + items: [], + access: { requireOrg: true, permission: "crm.customer.read" }, + }, + { + title: "ลีด", + url: "/dashboard/crm/leads", + icon: "forms", + isActive: false, + items: [], + access: { requireOrg: true, permission: "crm.lead.read" }, + }, + { + title: "โอกาสขาย", + url: "/dashboard/crm/opportunities", + icon: "forms", + isActive: false, + items: [], + access: { requireOrg: true, permission: "crm.opportunity.read" }, + }, + { + title: "ใบเสนอราคา", + url: "/dashboard/crm/quotations", + icon: "post", + isActive: false, + items: [], + access: { requireOrg: true, role: "admin" }, + }, + { + title: "อนุมัติเอกสาร", + url: "/dashboard/crm/approvals", + icon: "checks", + isActive: false, + items: [], + access: { requireOrg: true, permission: "crm.approval.read" }, + }, + { + title: "Reports", + url: "/dashboard/crm/reports", + icon: "dashboard", + isActive: false, + items: [ + { + title: "Pipeline Suite", + url: "/dashboard/crm/reports/pipeline", + access: { requireOrg: true, permission: "crm.report.read" }, }, - }, - { - title: "Master Options", - url: "/dashboard/crm/settings/master-options", - access: { requireOrg: true, permission: "crm.master_option.read" }, - }, - { - title: "Document Sequences", - url: "/dashboard/crm/settings/document-sequences", - access: { - requireOrg: true, - permission: "crm.document_sequence.read", + { + title: "Lead Aging", + url: "/dashboard/crm/reports/lead-aging", + access: { requireOrg: true, permission: "crm.report.read" }, }, - }, - { - title: "Approval Workflows", - url: "/dashboard/crm/settings/approval-workflows", - access: { - requireOrg: true, - permission: "crm.approval.workflow.read", + { + title: "Opportunity Aging", + url: "/dashboard/crm/reports/opportunity-aging", + access: { requireOrg: true, permission: "crm.report.read" }, }, + ], + access: { requireOrg: true, permission: "crm.report.read" }, + }, + { + title: "ตั้งค่า CRM", + url: "/dashboard/crm/settings/master-options", + icon: "settings", + isActive: false, + access: { + requireOrg: true, + permission: "crm.user_role_assignment.read", }, - { - title: "Templates", - url: "/dashboard/crm/settings/templates", - access: { - requireOrg: true, - permission: "crm.document_template.read", + items: [ + { + title: "Roles & Permissions", + url: "/dashboard/crm/settings/roles", + access: { requireOrg: true, permission: "crm.role.read" }, }, - }, - ], - }, - ], - }, -]; + { + title: "User Role Assignments", + url: "/dashboard/crm/settings/user-role-assignments", + access: { + requireOrg: true, + permission: "crm.user_role_assignment.read", + }, + }, + { + title: "Master Options", + url: "/dashboard/crm/settings/master-options", + access: { + requireOrg: true, + permission: "crm.master_option.read", + }, + }, + { + title: "Document Sequences", + url: "/dashboard/crm/settings/document-sequences", + access: { + requireOrg: true, + permission: "crm.document_sequence.read", + }, + }, + { + title: "Approval Workflows", + url: "/dashboard/crm/settings/approval-workflows", + access: { + requireOrg: true, + permission: "crm.approval.workflow.read", + }, + }, + { + title: "Templates", + url: "/dashboard/crm/settings/templates", + access: { + requireOrg: true, + permission: "crm.document_template.read", + }, + }, + ], + }, + ], + }, + ]; diff --git a/src/db/schema.ts b/src/db/schema.ts index 2a40a16..400491a 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -325,8 +325,8 @@ export const crmContactShares = pgTable( }) ); -export const crmEnquiries = pgTable( - 'crm_enquiries', +export const crmOpportunities = pgTable( + 'crm_opportunities', { id: text('id').primaryKey(), organizationId: text('organization_id').notNull(), @@ -374,17 +374,17 @@ export const crmEnquiries = pgTable( updatedBy: text('updated_by').notNull() }, (table) => ({ - organizationCodeIdx: uniqueIndex('crm_enquiries_org_code_idx').on( + organizationCodeIdx: uniqueIndex('crm_opportunities_org_code_idx').on( table.organizationId, table.code ) }) ); -export const crmEnquiryFollowups = pgTable('crm_enquiry_followups', { +export const crmOpportunityFollowups = pgTable('crm_opportunity_followups', { id: text('id').primaryKey(), organizationId: text('organization_id').notNull(), - enquiryId: text('enquiry_id').notNull(), + opportunityId: text('opportunity_id').notNull(), followupDate: timestamp('followup_date', { withTimezone: true }).notNull(), followupType: text('followup_type').notNull(), contactId: text('contact_id'), @@ -399,10 +399,10 @@ export const crmEnquiryFollowups = pgTable('crm_enquiry_followups', { updatedBy: text('updated_by').notNull() }); -export const crmEnquiryCustomers = pgTable('crm_enquiry_customers', { +export const crmOpportunityCustomers = pgTable('crm_opportunity_customers', { id: text('id').primaryKey(), organizationId: text('organization_id').notNull(), - enquiryId: text('enquiry_id').notNull(), + opportunityId: text('opportunity_id').notNull(), customerId: text('customer_id').notNull(), role: text('role').notNull(), remark: text('remark'), @@ -411,10 +411,10 @@ export const crmEnquiryCustomers = pgTable('crm_enquiry_customers', { deletedAt: timestamp('deleted_at', { withTimezone: true }) }); -export const crmEnquiryAttachments = pgTable('crm_enquiry_attachments', { +export const crmOpportunityAttachments = pgTable('crm_opportunity_attachments', { id: text('id').primaryKey(), organizationId: text('organization_id').notNull(), - enquiryId: text('enquiry_id').notNull(), + opportunityId: text('opportunity_id').notNull(), category: text('category').notNull(), fileName: text('file_name').notNull(), originalFileName: text('original_file_name').notNull(), @@ -459,7 +459,7 @@ export const crmQuotations = pgTable( organizationId: text('organization_id').notNull(), branchId: text('branch_id'), code: text('code').notNull(), - enquiryId: text('enquiry_id'), + opportunityId: text('opportunity_id'), customerId: text('customer_id').notNull(), contactId: text('contact_id'), quotationDate: timestamp('quotation_date', { withTimezone: true }).notNull(), @@ -804,3 +804,4 @@ export const crmDocumentTemplateTableColumns = pgTable( ) }) ); + diff --git a/src/db/seeds/foundation.seed.ts b/src/db/seeds/foundation.seed.ts index bf83cda..3db9c89 100644 --- a/src/db/seeds/foundation.seed.ts +++ b/src/db/seeds/foundation.seed.ts @@ -199,7 +199,7 @@ const FOUNDATION_OPTIONS = { parentCategory: 'crm_customer_group' } ], - crm_enquiry_status: [ + crm_opportunity_status: [ { code: 'new', label: 'New', value: 'new', sortOrder: 1 }, { code: 'qualifying', label: 'Qualifying', value: 'qualifying', sortOrder: 2 }, { code: 'requirement', label: 'Requirement', value: 'requirement', sortOrder: 3 }, @@ -468,15 +468,15 @@ const REPORT_DEFINITIONS: ReportDefinitionSeed[] = [ category: 'pipeline' }, { - code: 'enquiry_pipeline', - name: 'Enquiry Pipeline', - description: 'Operational report for open enquiries, quotation progress, and sales pipeline health.', + code: 'opportunity_pipeline', + name: 'Opportunity Pipeline', + description: 'Operational report for open opportunities, quotation progress, and sales pipeline health.', category: 'pipeline' }, { - code: 'enquiry_aging', - name: 'Enquiry Aging', - description: 'Operational report for stalled enquiries and aging opportunity follow-ups.', + code: 'opportunity_aging', + name: 'Opportunity Aging', + description: 'Operational report for stalled opportunities and aging opportunity follow-ups.', category: 'pipeline' }, { @@ -488,13 +488,13 @@ const REPORT_DEFINITIONS: ReportDefinitionSeed[] = [ { code: 'lead_conversion', name: 'Lead Conversion', - description: 'Marketing conversion report from lead creation into active enquiries.', + description: 'Marketing conversion report from lead creation into active opportunities.', category: 'pipeline' }, { - code: 'enquiry_conversion', - name: 'Enquiry Conversion', - description: 'Sales conversion report from enquiry creation into quotation generation.', + code: 'opportunity_conversion', + name: 'Opportunity Conversion', + description: 'Sales conversion report from opportunity creation into quotation generation.', category: 'pipeline' }, { @@ -520,9 +520,9 @@ const REPORT_DEFINITIONS: ReportDefinitionSeed[] = [ const DOCUMENT_SEQUENCES = [ { documentType: 'customer', prefix: 'CUS' }, { documentType: 'contact', prefix: 'CON' }, - { documentType: 'enquiry', prefix: 'ENQ' }, + { documentType: 'opportunity', prefix: 'ENQ' }, { documentType: 'crm_lead', prefix: 'LD' }, - { documentType: 'crm_enquiry', prefix: 'EN' }, + { documentType: 'crm_opportunity', prefix: 'EN' }, { documentType: 'quotation', prefix: 'QT' }, { documentType: 'approval', prefix: 'APV' } ]; @@ -1229,3 +1229,4 @@ if (require.main === module) { process.exitCode = 1; }); } + diff --git a/src/features/crm-demo/api/mutations.ts b/src/features/crm-demo/api/mutations.ts index 735e1d5..50e1ae8 100644 --- a/src/features/crm-demo/api/mutations.ts +++ b/src/features/crm-demo/api/mutations.ts @@ -2,7 +2,7 @@ import { mutationOptions } from '@tanstack/react-query'; import { getQueryClient } from '@/lib/query-client'; import { approveQuotation, - convertEnquiryToQuotation, + convertOpportunityToQuotation, createCustomer, createQuotationRevision, markQuotationStatus, @@ -35,8 +35,8 @@ export const updateCustomerMutation = mutationOptions({ onSuccess: invalidateCrm }); -export const convertEnquiryMutation = mutationOptions({ - mutationFn: (enquiryId: string) => convertEnquiryToQuotation(enquiryId), +export const convertOpportunityMutation = mutationOptions({ + mutationFn: (opportunityId: string) => convertOpportunityToQuotation(opportunityId), onSuccess: invalidateCrm }); @@ -76,3 +76,4 @@ export const revokeContactShareMutation = mutationOptions({ revokeContactShare(contactId, shareId), onSuccess: invalidateCrm }); + diff --git a/src/features/crm-demo/api/queries.ts b/src/features/crm-demo/api/queries.ts index 6b7167b..eb500e1 100644 --- a/src/features/crm-demo/api/queries.ts +++ b/src/features/crm-demo/api/queries.ts @@ -5,12 +5,12 @@ import { getCrmReferenceData, getCustomerById, getCustomers, - getEnquiries, - getEnquiryById, + getOpportunities, + getOpportunityById, getQuotationById, getQuotations } from './service'; -import type { ApprovalFilters, CustomerFilters, EnquiryFilters, QuotationFilters } from './types'; +import type { ApprovalFilters, CustomerFilters, OpportunityFilters, QuotationFilters } from './types'; export const crmKeys = { all: ['crm'] as const, @@ -18,8 +18,8 @@ export const crmKeys = { dashboard: () => [...crmKeys.all, 'dashboard'] as const, customers: (filters: CustomerFilters) => [...crmKeys.all, 'customers', filters] as const, customer: (id: string) => [...crmKeys.all, 'customer', id] as const, - enquiries: (filters: EnquiryFilters) => [...crmKeys.all, 'enquiries', filters] as const, - enquiry: (id: string) => [...crmKeys.all, 'enquiry', id] as const, + opportunities: (filters: OpportunityFilters) => [...crmKeys.all, 'opportunities', filters] as const, + opportunity: (id: string) => [...crmKeys.all, 'opportunity', id] as const, quotations: (filters: QuotationFilters) => [...crmKeys.all, 'quotations', filters] as const, quotation: (id: string) => [...crmKeys.all, 'quotation', id] as const, approvals: (filters: ApprovalFilters) => [...crmKeys.all, 'approvals', filters] as const @@ -49,16 +49,16 @@ export const customerByIdOptions = (id: string) => queryFn: () => getCustomerById(id) }); -export const enquiriesQueryOptions = (filters: EnquiryFilters) => +export const opportunitiesQueryOptions = (filters: OpportunityFilters) => queryOptions({ - queryKey: crmKeys.enquiries(filters), - queryFn: () => getEnquiries(filters) + queryKey: crmKeys.opportunities(filters), + queryFn: () => getOpportunities(filters) }); -export const enquiryByIdOptions = (id: string) => +export const opportunityByIdOptions = (id: string) => queryOptions({ - queryKey: crmKeys.enquiry(id), - queryFn: () => getEnquiryById(id) + queryKey: crmKeys.opportunity(id), + queryFn: () => getOpportunityById(id) }); export const quotationsQueryOptions = (filters: QuotationFilters) => @@ -78,3 +78,4 @@ export const approvalsQueryOptions = (filters: ApprovalFilters) => queryKey: crmKeys.approvals(filters), queryFn: () => getApprovals(filters) }); + diff --git a/src/features/crm-demo/api/service.ts b/src/features/crm-demo/api/service.ts index f943d5a..586be9f 100644 --- a/src/features/crm-demo/api/service.ts +++ b/src/features/crm-demo/api/service.ts @@ -8,9 +8,9 @@ import type { CustomerDetailResponse, CustomerFilters, CustomerMutationPayload, - Enquiry, - EnquiryDetailResponse, - EnquiryFilters, + Opportunity, + OpportunityDetailResponse, + OpportunityFilters, PaginatedResponse, Quotation, QuotationDetailResponse, @@ -70,8 +70,8 @@ export async function getCrmReferenceData(): Promise { } export async function getCrmDashboardData(): Promise { - const openEnquiries = crmState.enquiries.filter((enquiry) => - ['new', 'qualifying', 'requirement', 'follow_up'].includes(enquiry.status) + const openOpportunities = crmState.opportunities.filter((opportunity) => + ['new', 'qualifying', 'requirement', 'follow_up'].includes(opportunity.status) ); const pendingQuotations = crmState.quotations.filter( (quotation) => quotation.status === 'pending_approval' @@ -81,22 +81,22 @@ export async function getCrmDashboardData(): Promise { const hotQuotations = crmState.quotations.filter((quotation) => quotation.isHotProject); const dueFollowUps = crmState.quotations.flatMap((quotation) => quotation.followUps).slice(0, 5); const conversionRate = Math.round( - (wonQuotations.length / Math.max(1, crmState.enquiries.length)) * 100 + (wonQuotations.length / Math.max(1, crmState.opportunities.length)) * 100 ); return clone({ metrics: [ { id: 'm-1', - label: 'Total Enquiries', - value: String(crmState.enquiries.length), + label: 'Total Opportunities', + value: String(crmState.opportunities.length), change: '+12%', trend: 'up' }, { id: 'm-2', - label: 'Open Enquiries', - value: String(openEnquiries.length), + label: 'Open Opportunities', + value: String(openOpportunities.length), change: '+3', trend: 'up' }, @@ -150,23 +150,23 @@ export async function getCrmDashboardData(): Promise { trend: 'flat' } ], - enquiryPipeline: [ - { label: 'New', value: crmState.enquiries.filter((item) => item.status === 'new').length }, + opportunityPipeline: [ + { label: 'New', value: crmState.opportunities.filter((item) => item.status === 'new').length }, { label: 'Qualifying', - value: crmState.enquiries.filter((item) => item.status === 'qualifying').length + value: crmState.opportunities.filter((item) => item.status === 'qualifying').length }, { label: 'Requirement', - value: crmState.enquiries.filter((item) => item.status === 'requirement').length + value: crmState.opportunities.filter((item) => item.status === 'requirement').length }, { label: 'Follow Up', - value: crmState.enquiries.filter((item) => item.status === 'follow_up').length + value: crmState.opportunities.filter((item) => item.status === 'follow_up').length }, { label: 'Converted', - value: crmState.enquiries.filter((item) => item.status === 'converted').length + value: crmState.opportunities.filter((item) => item.status === 'converted').length } ], quotationPipeline: [ @@ -253,7 +253,7 @@ export async function getCustomerById(id: string): Promise crmState.contacts.some((contact) => contact.id === log.contactId && contact.customerId === id) ), - enquiries: crmState.enquiries.filter((enquiry) => enquiry.customerId === id), + opportunities: crmState.opportunities.filter((opportunity) => opportunity.customerId === id), quotations: crmState.quotations.filter((quotation) => quotation.customerRoles.some((role) => role.customerId === id) ), @@ -262,8 +262,8 @@ export async function getCustomerById(id: string): Promise> { - let items = [...crmState.enquiries]; +export async function getOpportunities(filters: OpportunityFilters): Promise> { + let items = [...crmState.opportunities]; if (filters.search) { const search = filters.search.toLowerCase(); @@ -283,21 +283,21 @@ export async function getEnquiries(filters: EnquiryFilters): Promise { - const enquiry = crmState.enquiries.find((item) => item.id === id); - if (!enquiry) { - throw new Error('Enquiry not found'); +export async function getOpportunityById(id: string): Promise { + const opportunity = crmState.opportunities.find((item) => item.id === id); + if (!opportunity) { + throw new Error('Opportunity not found'); } - const customer = crmState.customers.find((item) => item.id === enquiry.customerId)!; - const contact = crmState.contacts.find((item) => item.id === enquiry.contactId)!; + const customer = crmState.customers.find((item) => item.id === opportunity.customerId)!; + const contact = crmState.contacts.find((item) => item.id === opportunity.contactId)!; return clone({ - enquiry, + opportunity, customer, contact, - quotations: crmState.quotations.filter((quotation) => quotation.enquiryId === id), - branch: findBranch(enquiry.branchId)!, - salesman: findSalesperson(enquiry.salesmanId)! + quotations: crmState.quotations.filter((quotation) => quotation.opportunityId === id), + branch: findBranch(opportunity.branchId)!, + salesman: findSalesperson(opportunity.salesmanId)! }); } @@ -338,7 +338,7 @@ export async function getQuotationById(id: string): Promise item.id === quotation.enquiryId)!; + const opportunity = crmState.opportunities.find((item) => item.id === quotation.opportunityId)!; const customerIds = [...new Set(quotation.customerRoles.map((role) => role.customerId))]; const contactIds = [ ...new Set(quotation.customerRoles.map((role) => role.contactId).filter(Boolean)) @@ -346,7 +346,7 @@ export async function getQuotationById(id: string): Promise customerIds.includes(item.id)), contacts: crmState.contacts.filter((item) => contactIds.includes(item.id)), branch: findBranch(quotation.branchId)!, @@ -376,40 +376,40 @@ export async function updateCustomer(id: string, values: CustomerMutationPayload return getCustomerById(id); } -export async function convertEnquiryToQuotation(enquiryId: string) { - const enquiry = crmState.enquiries.find((item) => item.id === enquiryId); - if (!enquiry) throw new Error('Enquiry not found'); +export async function convertOpportunityToQuotation(opportunityId: string) { + const opportunity = crmState.opportunities.find((item) => item.id === opportunityId); + if (!opportunity) throw new Error('Opportunity not found'); const nextId = `qt-${Date.now()}`; const newQuotation: Quotation = { id: nextId, code: `QT2606-${String(crmState.quotations.length + 1).padStart(3, '0')}`, - enquiryId, + opportunityId, quotationDate: '2026-06-11', validUntil: '2026-07-11', quotationType: 'official', - project: enquiry.title, - siteLocation: enquiry.projectLocation, + project: opportunity.title, + siteLocation: opportunity.projectLocation, customerRoles: [ - { role: 'owner', customerId: enquiry.customerId, contactId: enquiry.contactId } + { role: 'owner', customerId: opportunity.customerId, contactId: opportunity.contactId } ], - salesmanId: enquiry.salesmanId, + salesmanId: opportunity.salesmanId, saleAdminId: 'sp-2', - branchId: enquiry.branchId, + branchId: opportunity.branchId, status: 'draft', revision: 0, - totalAmount: enquiry.expectedValue, - chancePercent: enquiry.chancePercent, - isHotProject: enquiry.chancePercent >= 70, + totalAmount: opportunity.expectedValue, + chancePercent: opportunity.chancePercent, + isHotProject: opportunity.chancePercent >= 70, items: [ { id: `${nextId}-item-1`, topic: 'Scope', - description: enquiry.requirementSummary, + description: opportunity.requirementSummary, quantity: 1, unit: 'lot', - unitPrice: enquiry.expectedValue, - amount: enquiry.expectedValue + unitPrice: opportunity.expectedValue, + amount: opportunity.expectedValue } ], topics: [ @@ -417,7 +417,7 @@ export async function convertEnquiryToQuotation(enquiryId: string) { id: `${nextId}-topic-1`, type: 'scope', label: 'Scope', - items: [enquiry.requirementSummary] + items: [opportunity.requirementSummary] }, { id: `${nextId}-topic-2`, @@ -434,8 +434,8 @@ export async function convertEnquiryToQuotation(enquiryId: string) { }; crmState.quotations.unshift(newQuotation); - crmState.enquiries = crmState.enquiries.map((item) => - item.id === enquiryId + crmState.opportunities = crmState.opportunities.map((item) => + item.id === opportunityId ? { ...item, status: 'converted', @@ -443,7 +443,7 @@ export async function convertEnquiryToQuotation(enquiryId: string) { activities: [ ...item.activities, { - id: `${enquiryId}-activity-${Date.now()}`, + id: `${opportunityId}-activity-${Date.now()}`, type: 'CONVERT', title: 'Convert to quotation', detail: `สร้าง ${newQuotation.code}`, @@ -591,3 +591,4 @@ export async function revokeContactShare(contactId: string, shareId: string) { }); } } + diff --git a/src/features/crm-demo/api/types.ts b/src/features/crm-demo/api/types.ts index 9ffebba..6d37c70 100644 --- a/src/features/crm-demo/api/types.ts +++ b/src/features/crm-demo/api/types.ts @@ -63,7 +63,7 @@ export interface Customer { contactIds: string[]; } -export type EnquiryStatus = +export type OpportunityStatus = | 'new' | 'qualifying' | 'requirement' @@ -72,7 +72,7 @@ export type EnquiryStatus = | 'closed_lost' | 'cancelled'; -export interface EnquiryActivity { +export interface OpportunityActivity { id: string; type: 'CALL' | 'MEETING' | 'VISIT' | 'NOTE' | 'UPDATE' | 'CONVERT'; title: string; @@ -81,7 +81,7 @@ export interface EnquiryActivity { createdAt: string; } -export interface Enquiry { +export interface Opportunity { id: string; code: string; title: string; @@ -90,7 +90,7 @@ export interface Enquiry { branchId: string; salesmanId: string; productType: 'crane' | 'dockdoor' | 'solarcell'; - status: EnquiryStatus; + status: OpportunityStatus; requirementSummary: string; projectLocation: string; chancePercent: number; @@ -100,7 +100,7 @@ export interface Enquiry { createdAt: string; updatedAt: string; quotationIds: string[]; - activities: EnquiryActivity[]; + activities: OpportunityActivity[]; } export type QuotationStatus = @@ -165,7 +165,7 @@ export interface ApprovalStep { export interface Quotation { id: string; code: string; - enquiryId: string; + opportunityId: string; quotationDate: string; validUntil: string; quotationType: 'budgetary' | 'official' | 'service'; @@ -204,7 +204,7 @@ export interface ApprovalItem { export interface AuditEvent { id: string; - entityType: 'customer' | 'enquiry' | 'quotation' | 'approval'; + entityType: 'customer' | 'opportunity' | 'quotation' | 'approval'; entityId: string; action: 'CREATE' | 'UPDATE' | 'SEND' | 'APPROVE' | 'REJECT' | 'CONVERT' | 'REVISE'; actorName: string; @@ -214,7 +214,7 @@ export interface AuditEvent { export interface DocumentSequence { id: string; - documentType: 'enquiry' | 'quotation' | 'quotation_revision'; + documentType: 'opportunity' | 'quotation' | 'quotation_revision'; prefix: string; period: string; branchId: string; @@ -264,7 +264,7 @@ export interface CrmChartDatum { export interface CrmDashboardData { metrics: CrmDashboardMetric[]; - enquiryPipeline: CrmChartDatum[]; + opportunityPipeline: CrmChartDatum[]; quotationPipeline: CrmChartDatum[]; salesPerformance: CrmChartDatum[]; competitorAnalysis: CrmChartDatum[]; @@ -283,7 +283,7 @@ export interface CustomerFilters { sort?: string; } -export interface EnquiryFilters { +export interface OpportunityFilters { page?: number; limit?: number; search?: string; @@ -328,14 +328,14 @@ export interface CustomerDetailResponse { contacts: CustomerContact[]; shares: ContactShare[]; shareLogs: ContactShareLog[]; - enquiries: Enquiry[]; + opportunities: Opportunity[]; quotations: Quotation[]; auditEvents: AuditEvent[]; branch: CrmBranch; } -export interface EnquiryDetailResponse { - enquiry: Enquiry; +export interface OpportunityDetailResponse { + opportunity: Opportunity; customer: Customer; contact: CustomerContact; quotations: Quotation[]; @@ -345,7 +345,7 @@ export interface EnquiryDetailResponse { export interface QuotationDetailResponse { quotation: Quotation; - enquiry: Enquiry; + opportunity: Opportunity; customers: Customer[]; contacts: CustomerContact[]; branch: CrmBranch; @@ -390,3 +390,4 @@ export interface ContactSharePayload { sharedWithUserName: string; permission: 'view' | 'edit'; } + diff --git a/src/features/crm-demo/components/crm-dashboard.tsx b/src/features/crm-demo/components/crm-dashboard.tsx index 670acc3..db19b2f 100644 --- a/src/features/crm-demo/components/crm-dashboard.tsx +++ b/src/features/crm-demo/components/crm-dashboard.tsx @@ -45,12 +45,12 @@ export function CrmDashboardPage() {
- Enquiry Pipeline + Opportunity Pipeline สถานะ opportunity ปัจจุบัน - + @@ -217,3 +217,4 @@ export function CrmDashboardPage() {
); } + diff --git a/src/features/crm-demo/components/customer-detail.tsx b/src/features/crm-demo/components/customer-detail.tsx index 7eccf09..8f7a1c9 100644 --- a/src/features/crm-demo/components/customer-detail.tsx +++ b/src/features/crm-demo/components/customer-detail.tsx @@ -63,7 +63,7 @@ export function CustomerDetailPage({ id }: { id: string }) { Overview Contacts Shared Contacts - Enquiries + Opportunities Quotations Activity Log @@ -177,13 +177,13 @@ export function CustomerDetailPage({ id }: { id: string }) { - - + +
- {data.enquiries.map((enquiry) => ( -
-

{enquiry.code}

-

{enquiry.title}

+ {data.opportunities.map((opportunity) => ( +
+

{opportunity.code}

+

{opportunity.title}

))}
@@ -203,3 +203,4 @@ export function CustomerDetailPage({ id }: { id: string }) {
); } + diff --git a/src/features/crm-demo/components/enquiries-table.tsx b/src/features/crm-demo/components/opportunities-table.tsx similarity index 82% rename from src/features/crm-demo/components/enquiries-table.tsx rename to src/features/crm-demo/components/opportunities-table.tsx index ca20eb0..f5750c2 100644 --- a/src/features/crm-demo/components/enquiries-table.tsx +++ b/src/features/crm-demo/components/opportunities-table.tsx @@ -10,19 +10,19 @@ import { DataTable } from '@/components/ui/table/data-table'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { toast } from 'sonner'; -import { crmReferenceQueryOptions, enquiriesQueryOptions } from '../api/queries'; -import { convertEnquiryMutation } from '../api/mutations'; -import type { Enquiry } from '../api/types'; -import { ChancePill, EnquiryStatusBadge } from './shared'; +import { crmReferenceQueryOptions, opportunitiesQueryOptions } from '../api/queries'; +import { convertOpportunityMutation } from '../api/mutations'; +import type { Opportunity } from '../api/types'; +import { ChancePill, OpportunityStatusBadge } from './shared'; -const columns: ColumnDef[] = [ +const columns: ColumnDef[] = [ { accessorKey: 'code', header: 'Code' }, - { accessorKey: 'title', header: 'Enquiry' }, + { accessorKey: 'title', header: 'Opportunity' }, { accessorKey: 'productType', header: 'Product' }, { accessorKey: 'status', header: 'Status', - cell: ({ row }) => + cell: ({ row }) => }, { accessorKey: 'chancePercent', @@ -32,31 +32,31 @@ const columns: ColumnDef[] = [ { id: 'actions', header: '', - cell: ({ row }) => + cell: ({ row }) => } ]; const columnIds = ['code', 'title', 'productType', 'status', 'chancePercent', 'actions']; -function RowActions({ enquiry }: { enquiry: Enquiry }) { +function RowActions({ opportunity }: { opportunity: Opportunity }) { const mutation = useMutation({ - ...convertEnquiryMutation, - onSuccess: () => toast.success('สร้าง quotation mock จาก enquiry แล้ว') + ...convertOpportunityMutation, + onSuccess: () => toast.success('สร้าง quotation mock จาก opportunity แล้ว') }); return (
-
); } -export function EnquiriesTablePage() { +export function OpportunitiesTablePage() { const [params, setParams] = useQueryStates({ page: parseAsInteger.withDefault(1), perPage: parseAsInteger.withDefault(10), @@ -82,7 +82,7 @@ export function EnquiriesTablePage() { ...(params.sort.length ? { sort: JSON.stringify(params.sort) } : {}) }; - const { data } = useSuspenseQuery(enquiriesQueryOptions(filters)); + const { data } = useSuspenseQuery(opportunitiesQueryOptions(filters)); const pageCount = Math.ceil(data.totalItems / params.perPage); const { table } = useDataTable({ data: data.items, @@ -97,7 +97,7 @@ export function EnquiriesTablePage() {
void setParams({ name: event.target.value || null, page: 1 })} /> @@ -148,10 +148,11 @@ export function EnquiriesTablePage() {
); } + diff --git a/src/features/crm-demo/components/enquiry-detail.tsx b/src/features/crm-demo/components/opportunity-detail.tsx similarity index 61% rename from src/features/crm-demo/components/enquiry-detail.tsx rename to src/features/crm-demo/components/opportunity-detail.tsx index 71ba58e..15d8869 100644 --- a/src/features/crm-demo/components/enquiry-detail.tsx +++ b/src/features/crm-demo/components/opportunity-detail.tsx @@ -3,14 +3,14 @@ import { useMutation, useSuspenseQuery } from '@tanstack/react-query'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; -import { enquiryByIdOptions } from '../api/queries'; -import { convertEnquiryMutation } from '../api/mutations'; +import { opportunityByIdOptions } from '../api/queries'; +import { convertOpportunityMutation } from '../api/mutations'; import { InfoGrid, RelatedQuotationList, SectionCard, TimelineList } from './shared'; import { formatCurrency } from '../utils/format'; -export function EnquiryDetailPage({ id }: { id: string }) { - const { data } = useSuspenseQuery(enquiryByIdOptions(id)); - const baseMutation = convertEnquiryMutation; +export function OpportunityDetailPage({ id }: { id: string }) { + const { data } = useSuspenseQuery(opportunityByIdOptions(id)); + const baseMutation = convertOpportunityMutation; const mutation = useMutation({ ...baseMutation, onSuccess: async (...args) => { @@ -22,10 +22,10 @@ export function EnquiryDetailPage({ id }: { id: string }) { return (
mutation.mutate(data.enquiry.id)}> + } @@ -35,19 +35,19 @@ export function EnquiryDetailPage({ id }: { id: string }) { items={[ { label: 'Customer', value: data.customer.name }, { label: 'Contact', value: data.contact.name }, - { label: 'Chance', value: `${data.enquiry.chancePercent}%` }, - { label: 'Potential', value: formatCurrency(data.enquiry.expectedValue) }, - { label: 'Project Location', value: data.enquiry.projectLocation }, - { label: 'Competitor', value: data.enquiry.competitor }, + { label: 'Chance', value: `${data.opportunity.chancePercent}%` }, + { label: 'Potential', value: formatCurrency(data.opportunity.expectedValue) }, + { label: 'Project Location', value: data.opportunity.projectLocation }, + { label: 'Competitor', value: data.opportunity.competitor }, { label: 'Salesman', value: data.salesman.name }, - { label: 'Requirement', value: data.enquiry.requirementSummary } + { label: 'Requirement', value: data.opportunity.requirementSummary } ]} /> ({ + items={data.opportunity.activities.map((activity) => ({ id: activity.id, title: activity.title, detail: activity.detail, @@ -63,3 +63,4 @@ export function EnquiryDetailPage({ id }: { id: string }) {
); } + diff --git a/src/features/crm-demo/components/quotations-table.tsx b/src/features/crm-demo/components/quotations-table.tsx index d20dee5..9bde54f 100644 --- a/src/features/crm-demo/components/quotations-table.tsx +++ b/src/features/crm-demo/components/quotations-table.tsx @@ -202,7 +202,7 @@ export function QuotationsTablePage() {
); } + diff --git a/src/features/crm-demo/data/mock-crm.ts b/src/features/crm-demo/data/mock-crm.ts index b2b3960..43e1260 100644 --- a/src/features/crm-demo/data/mock-crm.ts +++ b/src/features/crm-demo/data/mock-crm.ts @@ -8,7 +8,7 @@ import type { Customer, CustomerContact, DocumentSequence, - Enquiry, + Opportunity, MasterOption, Quotation, QuotationTemplate @@ -21,7 +21,7 @@ export interface CrmMockState { contacts: CustomerContact[]; contactShares: ContactShare[]; contactShareLogs: ContactShareLog[]; - enquiries: Enquiry[]; + opportunities: Opportunity[]; quotations: Quotation[]; approvals: ApprovalItem[]; auditEvents: AuditEvent[]; @@ -259,7 +259,7 @@ export const initialCrmState: CrmMockState = { createdAt: '2026-06-07T14:45:00.000Z' } ], - enquiries: [ + opportunities: [ { id: 'enq-1', code: 'ENQ2606-001', @@ -509,7 +509,7 @@ export const initialCrmState: CrmMockState = { { id: 'enq-8-act-1', type: 'CALL', - title: 'รับ enquiry ใหม่', + title: 'รับ opportunity ใหม่', detail: 'ต้องการเสนอราคาใน 7 วัน', actorName: 'Ton A.', createdAt: '2026-06-09T13:00:00.000Z' @@ -521,7 +521,7 @@ export const initialCrmState: CrmMockState = { { id: 'qt-1', code: 'QT2606-001', - enquiryId: 'enq-1', + opportunityId: 'enq-1', quotationDate: '2026-06-02', validUntil: '2026-07-02', quotationType: 'official', @@ -627,7 +627,7 @@ export const initialCrmState: CrmMockState = { { id: 'qt-2', code: 'QT2606-001-R01', - enquiryId: 'enq-1', + opportunityId: 'enq-1', quotationDate: '2026-06-07', validUntil: '2026-07-07', quotationType: 'official', @@ -721,7 +721,7 @@ export const initialCrmState: CrmMockState = { { id: 'qt-3', code: 'QT2606-002', - enquiryId: 'enq-2', + opportunityId: 'enq-2', quotationDate: '2026-06-05', validUntil: '2026-07-05', quotationType: 'budgetary', @@ -795,7 +795,7 @@ export const initialCrmState: CrmMockState = { { id: 'qt-4', code: 'QT2606-003', - enquiryId: 'enq-3', + opportunityId: 'enq-3', quotationDate: '2026-06-03', validUntil: '2026-07-03', quotationType: 'official', @@ -876,7 +876,7 @@ export const initialCrmState: CrmMockState = { { id: 'qt-5', code: 'QT2606-004', - enquiryId: 'enq-5', + opportunityId: 'enq-5', quotationDate: '2026-06-06', validUntil: '2026-07-06', quotationType: 'service', @@ -938,7 +938,7 @@ export const initialCrmState: CrmMockState = { { id: 'qt-6', code: 'QT2606-005', - enquiryId: 'enq-6', + opportunityId: 'enq-6', quotationDate: '2026-05-26', validUntil: '2026-06-25', quotationType: 'budgetary', @@ -977,7 +977,7 @@ export const initialCrmState: CrmMockState = { { id: 'qt-7', code: 'QT2606-006', - enquiryId: 'enq-4', + opportunityId: 'enq-4', quotationDate: '2026-06-09', validUntil: '2026-07-09', quotationType: 'official', @@ -1037,7 +1037,7 @@ export const initialCrmState: CrmMockState = { { id: 'qt-8', code: 'QT2606-007', - enquiryId: 'enq-8', + opportunityId: 'enq-8', quotationDate: '2026-06-10', validUntil: '2026-07-10', quotationType: 'official', @@ -1081,7 +1081,7 @@ export const initialCrmState: CrmMockState = { { id: 'qt-9', code: 'QT2606-008', - enquiryId: 'enq-2', + opportunityId: 'enq-2', quotationDate: '2026-06-08', validUntil: '2026-07-08', quotationType: 'official', @@ -1135,7 +1135,7 @@ export const initialCrmState: CrmMockState = { { id: 'qt-10', code: 'QT2606-009', - enquiryId: 'enq-5', + opportunityId: 'enq-5', quotationDate: '2026-06-10', validUntil: '2026-07-10', quotationType: 'service', @@ -1282,7 +1282,7 @@ export const initialCrmState: CrmMockState = { sequences: [ { id: 'seq-1', - documentType: 'enquiry', + documentType: 'opportunity', prefix: 'ENQ', period: '2606', branchId: 'bkk', @@ -1375,3 +1375,4 @@ export const initialCrmState: CrmMockState = { } ] }; + diff --git a/src/features/crm-demo/data/options.ts b/src/features/crm-demo/data/options.ts index 8f9103b..5e8f164 100644 --- a/src/features/crm-demo/data/options.ts +++ b/src/features/crm-demo/data/options.ts @@ -1,4 +1,4 @@ -export const enquiryStatusOptions = [ +export const opportunityStatusOptions = [ { value: 'new', label: 'New' }, { value: 'qualifying', label: 'Qualifying' }, { value: 'requirement', label: 'Requirement' }, @@ -37,3 +37,4 @@ export const quotationTypeOptions = [ { value: 'budgetary', label: 'Budgetary' }, { value: 'service', label: 'Service' } ] as const; + diff --git a/src/features/crm-demo/utils/status.ts b/src/features/crm-demo/utils/status.ts index 53695dd..cb8674e 100644 --- a/src/features/crm-demo/utils/status.ts +++ b/src/features/crm-demo/utils/status.ts @@ -1,4 +1,4 @@ -import type { Customer, EnquiryStatus, QuotationStatus } from '../api/types'; +import type { Customer, OpportunityStatus, QuotationStatus } from '../api/types'; export function getCustomerStatusLabel(status: Customer['customerStatus']) { return ( @@ -10,7 +10,7 @@ export function getCustomerStatusLabel(status: Customer['customerStatus']) { ); } -export function getEnquiryStatusLabel(status: EnquiryStatus) { +export function getOpportunityStatusLabel(status: OpportunityStatus) { return ( { new: 'New', @@ -39,3 +39,4 @@ export function getQuotationStatusLabel(status: QuotationStatus) { }[status] ?? status ); } + diff --git a/src/features/crm/customers/api/types.ts b/src/features/crm/customers/api/types.ts index c95c8d5..34c8b5e 100644 --- a/src/features/crm/customers/api/types.ts +++ b/src/features/crm/customers/api/types.ts @@ -117,7 +117,7 @@ export interface CustomerActivityRecord { actorName: string | null; } -export interface CustomerRelatedEnquiryItem { +export interface CustomerRelatedOpportunityItem { id: string; code: string; title: string; @@ -230,3 +230,4 @@ export interface MutationSuccessResponse { success: boolean; message: string; } + diff --git a/src/features/crm/customers/components/customer-detail.tsx b/src/features/crm/customers/components/customer-detail.tsx index 4a33894..435a86c 100644 --- a/src/features/crm/customers/components/customer-detail.tsx +++ b/src/features/crm/customers/components/customer-detail.tsx @@ -16,7 +16,7 @@ import { CustomerFormSheet } from './customer-form-sheet'; import { CustomerContactsTab } from './customer-contacts-tab'; import { CustomerOwnerCard } from './customer-owner-card'; import { CustomerStatusBadge } from './customer-status-badge'; -import type { CustomerRelatedEnquiryItem } from '../api/types'; +import type { CustomerRelatedOpportunityItem } from '../api/types'; import type { QuotationRelationItem } from '@/features/crm/quotations/api/types'; function FieldItem({ label, value }: { label: string; value: string | null | undefined }) { @@ -37,7 +37,7 @@ export function CustomerDetail({ canReadContactShares, canManageContactShares, canManageOwner, - relatedEnquiries, + relatedOpportunities, relatedQuotations }: { customerId: string; @@ -52,7 +52,7 @@ export function CustomerDetail({ canReadContactShares: boolean; canManageContactShares: boolean; canManageOwner: boolean; - relatedEnquiries: CustomerRelatedEnquiryItem[]; + relatedOpportunities: CustomerRelatedOpportunityItem[]; relatedQuotations: QuotationRelationItem[]; }) { const { data } = useSuspenseQuery(customerByIdOptions(customerId)); @@ -239,23 +239,23 @@ export function CustomerDetail({ ลิงก์ไปยังโอกาสขายและใบเสนอราคาที่เกี่ยวข้องกับลูกค้ารายนี้ - {!relatedEnquiries.length && !relatedQuotations.length ? ( + {!relatedOpportunities.length && !relatedQuotations.length ? (
ยังไม่มีโอกาสขายหรือใบเสนอราคาที่เชื่อมกับลูกค้ารายนี้
) : (
- {relatedEnquiries.map((enquiry) => ( + {relatedOpportunities.map((opportunity) => (
-
{enquiry.title}
+
{opportunity.title}
- {enquiry.code} + {opportunity.code}
@@ -297,7 +297,7 @@ export function CustomerDetail({ /> - ข้อมูลระบบ + เธ‚เน‰เธญเธกเธนเธฅเธฃเธฐเธšเธš ข้อมูลประกอบการติดตามและตรวจสอบของรายการลูกค้านี้ @@ -319,3 +319,4 @@ export function CustomerDetail({
); } + diff --git a/src/features/crm/customers/server/service.ts b/src/features/crm/customers/server/service.ts index f118795..d73fc47 100644 --- a/src/features/crm/customers/server/service.ts +++ b/src/features/crm/customers/server/service.ts @@ -16,7 +16,7 @@ import { crmCustomerContacts, crmCustomerOwnerHistory, crmCustomers, - crmEnquiries, + crmOpportunities, crmQuotations, memberships, msOptions, @@ -476,20 +476,20 @@ async function resolveAccessibleCustomerRelationSets( organizationId: string, accessContext: CustomerAccessContext, ) { - const [enquiries, quotations] = await Promise.all([ + const [opportunities, quotations] = await Promise.all([ db .select({ - customerId: crmEnquiries.customerId, - createdBy: crmEnquiries.createdBy, - assignedToUserId: crmEnquiries.assignedToUserId, - branchId: crmEnquiries.branchId, - productType: crmEnquiries.productType, + customerId: crmOpportunities.customerId, + createdBy: crmOpportunities.createdBy, + assignedToUserId: crmOpportunities.assignedToUserId, + branchId: crmOpportunities.branchId, + productType: crmOpportunities.productType, }) - .from(crmEnquiries) + .from(crmOpportunities) .where( and( - eq(crmEnquiries.organizationId, organizationId), - isNull(crmEnquiries.deletedAt), + eq(crmOpportunities.organizationId, organizationId), + isNull(crmOpportunities.deletedAt), ), ), db @@ -509,10 +509,10 @@ async function resolveAccessibleCustomerRelationSets( ), ]); - const enquiryCustomerIds = new Set(); + const opportunityCustomerIds = new Set(); const quotationCustomerIds = new Set(); - for (const row of enquiries) { + for (const row of opportunities) { if (!hasBranchScopeAccess(accessContext, row.branchId)) { continue; } @@ -529,7 +529,7 @@ async function resolveAccessibleCustomerRelationSets( row.createdBy === accessContext.userId || row.assignedToUserId === accessContext.userId ) { - enquiryCustomerIds.add(row.customerId); + opportunityCustomerIds.add(row.customerId); } } @@ -553,7 +553,7 @@ async function resolveAccessibleCustomerRelationSets( } } - return { enquiryCustomerIds, quotationCustomerIds }; + return { opportunityCustomerIds, quotationCustomerIds }; } async function canAccessCustomerRow( @@ -585,7 +585,7 @@ async function canAccessCustomerRow( return true; } - const { enquiryCustomerIds, quotationCustomerIds } = + const { opportunityCustomerIds, quotationCustomerIds } = relationSets ?? (await resolveAccessibleCustomerRelationSets( row.organizationId, @@ -593,13 +593,13 @@ async function canAccessCustomerRow( )); if (accessContext.ownershipScope === "monitor") { - return enquiryCustomerIds.has(row.id); + return opportunityCustomerIds.has(row.id); } return ( row.createdBy === accessContext.userId || row.ownerUserId === accessContext.userId || - enquiryCustomerIds.has(row.id) || + opportunityCustomerIds.has(row.id) || quotationCustomerIds.has(row.id) ); } @@ -1615,3 +1615,4 @@ export async function resolveCustomerOptionLabel( return row?.label ?? null; } + diff --git a/src/features/crm/dashboard/api/types.ts b/src/features/crm/dashboard/api/types.ts index d19855a..85d347f 100644 --- a/src/features/crm/dashboard/api/types.ts +++ b/src/features/crm/dashboard/api/types.ts @@ -32,14 +32,14 @@ export interface CrmDashboardSummary { unassignedLeads: number; averageLeadAgingDays: number; }; - enquiry: { - enquiryCount: number; - activeEnquiries: number; + opportunity: { + opportunityCount: number; + activeOpportunities: number; wonCount: number; lostCount: number; winRate: number; - hotEnquiries: number; - enquiryValue: number; + hotOpportunities: number; + opportunityValue: number; }; quotation: { draftQuotations: number; @@ -76,7 +76,7 @@ export interface CrmDashboardSalesRankingRow { salesPersonId: string; salesPersonName: string; leadCount: number; - enquiryCount: number; + opportunityCount: number; quotationCount: number; approvedQuotations: number; wonRevenue: number; @@ -92,10 +92,10 @@ export interface CrmDashboardFollowupSummary { export interface CrmDashboardFollowupRow { id: string; - sourceType: 'enquiry' | 'quotation'; + sourceType: 'opportunity' | 'quotation'; followupDate: string; customerName: string; - enquiryName: string; + opportunityName: string; assignedSalesName: string | null; outcome: string | null; } @@ -118,8 +118,8 @@ export interface CrmDashboardApprovalRow { } export interface CrmDashboardHotProjectRow { - enquiryId: string; - enquiryCode: string; + opportunityId: string; + opportunityCode: string; projectName: string; customerName: string; value: number; @@ -161,3 +161,4 @@ export interface CrmDashboardResponse { canViewApprovalData: boolean; }; } + diff --git a/src/features/crm/dashboard/components/dashboard-followups.tsx b/src/features/crm/dashboard/components/dashboard-followups.tsx index 771d4d9..18ac789 100644 --- a/src/features/crm/dashboard/components/dashboard-followups.tsx +++ b/src/features/crm/dashboard/components/dashboard-followups.tsx @@ -38,7 +38,7 @@ export function DashboardFollowups({ ลูกค้า โอกาสขาย ผู้ดูแลโอกาสขาย - ที่มา + เธ—เธตเนˆเธกเธฒ @@ -53,7 +53,7 @@ export function DashboardFollowups({ {new Date(row.followupDate).toLocaleDateString()} {row.customerName} - {row.enquiryName} + {row.opportunityName} {row.assignedSalesName ?? '-'} {row.sourceType} @@ -65,3 +65,4 @@ export function DashboardFollowups({ ); } + diff --git a/src/features/crm/dashboard/components/dashboard-hot-projects.tsx b/src/features/crm/dashboard/components/dashboard-hot-projects.tsx index d42202b..7815865 100644 --- a/src/features/crm/dashboard/components/dashboard-hot-projects.tsx +++ b/src/features/crm/dashboard/components/dashboard-hot-projects.tsx @@ -21,7 +21,7 @@ export function DashboardHotProjects({ rows }: { rows: CrmDashboardHotProjectRow โครงการ ลูกค้า - มูลค่า + เธกเธนเธฅเธ„เนˆเธฒ ผู้ดูแลโอกาสขาย ความน่าจะเป็น @@ -35,10 +35,10 @@ export function DashboardHotProjects({ rows }: { rows: CrmDashboardHotProjectRow ) : ( rows.map((row) => ( - +
{row.projectName}
-
{row.enquiryCode}
+
{row.opportunityCode}
{row.customerName} {formatNumber(row.value)} @@ -53,3 +53,4 @@ export function DashboardHotProjects({ rows }: { rows: CrmDashboardHotProjectRow ); } + diff --git a/src/features/crm/dashboard/components/dashboard-sales-ranking.tsx b/src/features/crm/dashboard/components/dashboard-sales-ranking.tsx index 1aa9f29..676b8f3 100644 --- a/src/features/crm/dashboard/components/dashboard-sales-ranking.tsx +++ b/src/features/crm/dashboard/components/dashboard-sales-ranking.tsx @@ -19,7 +19,7 @@ export function DashboardSalesRanking({ rows }: { rows: CrmDashboardSalesRanking - ฝ่ายขาย + ผู้ขาย ลีด โอกาสขาย ใบเสนอราคา @@ -40,7 +40,7 @@ export function DashboardSalesRanking({ rows }: { rows: CrmDashboardSalesRanking {row.salesPersonName} {formatNumber(row.leadCount)} - {formatNumber(row.enquiryCount)} + {formatNumber(row.opportunityCount)} {formatNumber(row.quotationCount)} {formatNumber(row.approvedQuotations)} {formatNumber(row.wonRevenue)} diff --git a/src/features/crm/dashboard/components/dashboard-summary-cards.tsx b/src/features/crm/dashboard/components/dashboard-summary-cards.tsx index 71bce9c..4f888bf 100644 --- a/src/features/crm/dashboard/components/dashboard-summary-cards.tsx +++ b/src/features/crm/dashboard/components/dashboard-summary-cards.tsx @@ -65,12 +65,12 @@ export function DashboardSummaryCards({ {canViewCommercialData ? ( @@ -99,3 +99,4 @@ export function DashboardSummaryCards({ ); } + diff --git a/src/features/crm/dashboard/server/service.ts b/src/features/crm/dashboard/server/service.ts index 2267cda..ad6aa3d 100644 --- a/src/features/crm/dashboard/server/service.ts +++ b/src/features/crm/dashboard/server/service.ts @@ -12,8 +12,8 @@ import { import { crmApprovalRequests, crmCustomers, - crmEnquiries, - crmEnquiryFollowups, + crmOpportunities, + crmOpportunityFollowups, crmQuotationFollowups, crmQuotations, memberships, @@ -53,7 +53,7 @@ import type { CrmDashboardSummary } from '../api/types'; -const ENQUIRY_STATUS_CATEGORY = 'crm_enquiry_status'; +const ENQUIRY_STATUS_CATEGORY = 'crm_opportunity_status'; const QUOTATION_STATUS_CATEGORY = 'crm_quotation_status'; const PRODUCT_TYPE_CATEGORY = 'crm_product_type'; const PROJECT_PARTY_ROLE_CATEGORY = 'crm_project_party_role'; @@ -81,7 +81,7 @@ type NormalizedFilters = { }; type StatusMaps = { - enquiryStatusById: Map; + opportunityStatusById: Map; quotationStatusById: Map; }; @@ -132,8 +132,8 @@ function hasDashboardFullScope(context: DashboardContext) { return context.membershipRole === 'admin' || context.ownershipScope !== 'own'; } -function canAccessDashboardEnquiry( - row: typeof crmEnquiries.$inferSelect, +function canAccessDashboardOpportunity( + row: typeof crmOpportunities.$inferSelect, context: DashboardContext ) { if (!hasBranchScopeAccess(context, row.branchId)) { @@ -179,13 +179,13 @@ function canAccessDashboardQuotation( } async function getStatusMaps(organizationId: string): Promise { - const [enquiryOptions, quotationOptions] = await Promise.all([ + const [opportunityOptions, 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])), + opportunityStatusById: new Map(opportunityOptions.map((item) => [item.id, item.code])), quotationStatusById: new Map(quotationOptions.map((item) => [item.id, item.code])) }; } @@ -219,8 +219,8 @@ async function getReferenceData(organizationId: string): Promise canAccessDashboardEnquiry(row, context)) - .filter((row) => matchesEnquiryFilters(row, filters)), + opportunities: opportunities + .filter((row) => canAccessDashboardOpportunity(row, context)) + .filter((row) => matchesOpportunityFilters(row, filters)), quotations: quotations .filter((row) => canAccessDashboardQuotation(row, context)) .filter((row) => matchesQuotationFilters(row, filters)) @@ -284,16 +284,16 @@ async function loadScopedRows( } function buildSummary( - scopedEnquiries: Array, + scopedOpportunities: Array, scopedQuotations: Array, statusMaps: StatusMaps, outcomeMetrics: { wonValue: number; lostValue: number; winRate: number } ): CrmDashboardSummary { const now = new Date(); - const leadRows = scopedEnquiries.filter((row) => isPipelineStage(row, 'lead')); - const enquiryRows = scopedEnquiries.filter((row) => isPipelineStage(row, 'enquiry')); - const wonRows = scopedEnquiries.filter((row) => isPipelineStage(row, 'closed_won')); - const lostRows = scopedEnquiries.filter((row) => isPipelineStage(row, 'closed_lost')); + const leadRows = scopedOpportunities.filter((row) => isPipelineStage(row, 'lead')); + const opportunityRows = scopedOpportunities.filter((row) => isPipelineStage(row, 'opportunity')); + const wonRows = scopedOpportunities.filter((row) => isPipelineStage(row, 'closed_won')); + const lostRows = scopedOpportunities.filter((row) => isPipelineStage(row, 'closed_lost')); const leadAges = leadRows.map((row) => (now.getTime() - row.createdAt.getTime()) / (1000 * 60 * 60 * 24)); const quotationStatusCodes = scopedQuotations.map((row) => statusMaps.quotationStatusById.get(row.status) ?? ''); @@ -305,14 +305,14 @@ function buildSummary( unassignedLeads: leadRows.filter((row) => row.assignedToUserId === null).length, averageLeadAgingDays: average(leadAges) }, - enquiry: { - enquiryCount: enquiryRows.length, - activeEnquiries: enquiryRows.length, + opportunity: { + opportunityCount: opportunityRows.length, + activeOpportunities: opportunityRows.length, wonCount: wonRows.length, lostCount: lostRows.length, winRate: outcomeMetrics.winRate, - hotEnquiries: enquiryRows.filter((row) => row.isHotProject).length, - enquiryValue: round2(enquiryRows.reduce((sum, row) => sum + (row.estimatedValue ?? 0), 0)) + 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, @@ -345,10 +345,10 @@ function buildFunnel( value: 0 }, { - key: 'enquiry', - label: getPipelineStageThaiLabel('enquiry'), - count: summary.enquiry.enquiryCount, - value: summary.enquiry.enquiryValue + key: 'opportunity', + label: getPipelineStageThaiLabel('opportunity'), + count: summary.opportunity.opportunityCount, + value: summary.opportunity.opportunityValue }, { key: 'quotation', @@ -383,7 +383,7 @@ function buildFunnel( { key: 'closed_won', label: getPipelineStageThaiLabel('closed_won'), - count: summary.enquiry.wonCount, + count: summary.opportunity.wonCount, value: summary.revenue.wonValue } ]; @@ -456,7 +456,7 @@ async function buildRevenueAnalytics( } async function buildSalesRanking( - scopedEnquiries: Array, + scopedOpportunities: Array, scopedQuotations: Array, statusMaps: StatusMaps, wonRevenueRows: Array<{ assignedToUserId: string | null; revenue: number }> @@ -464,7 +464,7 @@ async function buildSalesRanking( const userIds = [ ...new Set( [ - ...scopedEnquiries.map((row) => row.assignedToUserId).filter(Boolean), + ...scopedOpportunities.map((row) => row.assignedToUserId).filter(Boolean), ...scopedQuotations.map((row) => row.salesmanId).filter(Boolean) ] as string[] ) @@ -475,7 +475,7 @@ async function buildSalesRanking( const userMap = new Map(salesUsers.map((row) => [row.id, row.name])); const rankingMap = new Map(); - for (const row of scopedEnquiries) { + for (const row of scopedOpportunities) { if (!row.assignedToUserId) continue; const current = rankingMap.get(row.assignedToUserId) ?? @@ -483,7 +483,7 @@ async function buildSalesRanking( salesPersonId: row.assignedToUserId, salesPersonName: userMap.get(row.assignedToUserId) ?? 'Unknown', leadCount: 0, - enquiryCount: 0, + opportunityCount: 0, quotationCount: 0, approvedQuotations: 0, wonRevenue: 0, @@ -494,8 +494,8 @@ async function buildSalesRanking( current.leadCount += 1; } - if (isPipelineStage(row, 'enquiry')) { - current.enquiryCount += 1; + if (isPipelineStage(row, 'opportunity')) { + current.opportunityCount += 1; } rankingMap.set(row.assignedToUserId, current); @@ -509,7 +509,7 @@ async function buildSalesRanking( salesPersonId: row.salesmanId, salesPersonName: userMap.get(row.salesmanId) ?? 'Unknown', leadCount: 0, - enquiryCount: 0, + opportunityCount: 0, quotationCount: 0, approvedQuotations: 0, wonRevenue: 0, @@ -539,7 +539,7 @@ async function buildSalesRanking( salesPersonId: row.assignedToUserId, salesPersonName: userMap.get(row.assignedToUserId) ?? 'Unknown', leadCount: 0, - enquiryCount: 0, + opportunityCount: 0, quotationCount: 0, approvedQuotations: 0, wonRevenue: 0, @@ -570,17 +570,17 @@ async function buildFollowups( filters: NormalizedFilters ): Promise<{ summary: CrmDashboardFollowupSummary; upcoming: CrmDashboardFollowupRow[] }> { const allowQuotationFollowups = canViewCommercialData(context); - const [enquiryFollowups, quotationFollowups] = await Promise.all([ + const [opportunityFollowups, quotationFollowups] = await Promise.all([ db .select() - .from(crmEnquiryFollowups) + .from(crmOpportunityFollowups) .where( and( - eq(crmEnquiryFollowups.organizationId, context.organizationId), - isNull(crmEnquiryFollowups.deletedAt) + eq(crmOpportunityFollowups.organizationId, context.organizationId), + isNull(crmOpportunityFollowups.deletedAt) ) ) - .orderBy(asc(crmEnquiryFollowups.followupDate)), + .orderBy(asc(crmOpportunityFollowups.followupDate)), allowQuotationFollowups ? db .select() @@ -599,49 +599,49 @@ async function buildFollowups( 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 opportunityIds = [...new Set(opportunityFollowups.map((row) => row.opportunityId))]; 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)) : [], + const [opportunities, quotations] = await Promise.all([ + opportunityIds.length ? db.select().from(crmOpportunities).where(inArray(crmOpportunities.id, opportunityIds)) : [], 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 customerIds = [...new Set([...opportunities.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[] + [...opportunities.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 opportunityMap = new Map(opportunities.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 + const opportunityRows = opportunityFollowups .filter((row) => { - const enquiry = enquiryMap.get(row.enquiryId); - return enquiry ? canAccessDashboardEnquiry(enquiry, context) : false; + const opportunity = opportunityMap.get(row.opportunityId); + return opportunity ? canAccessDashboardOpportunity(opportunity, context) : false; }) .filter((row) => !isBefore(row.followupDate, from) && !isAfter(row.followupDate, to)) .flatMap((row) => { - const enquiry = enquiryMap.get(row.enquiryId); + const opportunity = opportunityMap.get(row.opportunityId); - if (!enquiry) { + if (!opportunity) { return []; } return [ { id: row.id, - sourceType: 'enquiry', + sourceType: 'opportunity', followupDate: row.followupDate.toISOString(), - customerName: customerMap.get(enquiry.customerId) ?? 'Unknown customer', - enquiryName: enquiry.projectName ?? enquiry.title, - assignedSalesName: enquiry.assignedToUserId - ? (salesMap.get(enquiry.assignedToUserId) ?? null) + customerName: customerMap.get(opportunity.customerId) ?? 'Unknown customer', + opportunityName: opportunity.projectName ?? opportunity.title, + assignedSalesName: opportunity.assignedToUserId + ? (salesMap.get(opportunity.assignedToUserId) ?? null) : null, outcome: row.outcome } @@ -666,7 +666,7 @@ async function buildFollowups( sourceType: 'quotation', followupDate: row.followupDate.toISOString(), customerName: customerMap.get(quotation.customerId) ?? 'Unknown customer', - enquiryName: quotation.projectName ?? quotation.code, + opportunityName: quotation.projectName ?? quotation.code, assignedSalesName: quotation.salesmanId ? (salesMap.get(quotation.salesmanId) ?? null) : null, @@ -674,7 +674,7 @@ async function buildFollowups( } ]; }); - const rows: CrmDashboardFollowupRow[] = [...enquiryRows, ...quotationRows]; + const rows: CrmDashboardFollowupRow[] = [...opportunityRows, ...quotationRows]; return { summary: { @@ -749,19 +749,19 @@ async function buildHotProjects( ): Promise { const rows = await db .select() - .from(crmEnquiries) + .from(crmOpportunities) .where( and( - eq(crmEnquiries.organizationId, context.organizationId), - isNull(crmEnquiries.deletedAt), - eq(crmEnquiries.isHotProject, true) + eq(crmOpportunities.organizationId, context.organizationId), + isNull(crmOpportunities.deletedAt), + eq(crmOpportunities.isHotProject, true) ) ) - .orderBy(desc(crmEnquiries.updatedAt)); + .orderBy(desc(crmOpportunities.updatedAt)); const scoped = rows - .filter((row) => canAccessDashboardEnquiry(row, context)) - .filter((row) => matchesEnquiryFilters(row, filters)) - .filter((row) => isPipelineStage(row, 'enquiry')) + .filter((row) => canAccessDashboardOpportunity(row, context)) + .filter((row) => matchesOpportunityFilters(row, filters)) + .filter((row) => isPipelineStage(row, 'opportunity')) .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[]; @@ -773,8 +773,8 @@ async function buildHotProjects( const salesMap = new Map(salesUsers.map((row) => [row.id, row.name])); return scoped.map((row) => ({ - enquiryId: row.id, - enquiryCode: row.code, + opportunityId: row.id, + opportunityCode: row.code, projectName: row.projectName ?? row.title, customerName: customerMap.get(row.customerId) ?? 'Unknown customer', value: row.estimatedValue ?? 0, @@ -812,7 +812,7 @@ export async function getCrmDashboardData( getLostByCompetitor(context.organizationId, outcomeFilters) ]) : [[], [], 0, [], []]; - const summary = buildSummary(scoped.enquiries, scoped.quotations, statusMaps, { + const summary = buildSummary(scoped.opportunities, scoped.quotations, statusMaps, { wonValue: wonRevenueRows.reduce((sum, row) => sum + row.revenue, 0), lostValue: lostRevenueRows.reduce((sum, row) => sum + row.revenue, 0), winRate @@ -827,7 +827,7 @@ export async function getCrmDashboardData( topConsultants: [] }), allowCommercialData - ? buildSalesRanking(scoped.enquiries, scoped.quotations, statusMaps, wonRevenueRows) + ? buildSalesRanking(scoped.opportunities, scoped.quotations, statusMaps, wonRevenueRows) : Promise.resolve([]), buildFollowups(context, filters), allowApprovalData @@ -871,3 +871,4 @@ export async function getCrmDashboardData( } }; } + diff --git a/src/features/crm/enquiries/api/mutations.ts b/src/features/crm/enquiries/api/mutations.ts deleted file mode 100644 index e44c133..0000000 --- a/src/features/crm/enquiries/api/mutations.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { mutationOptions } from '@tanstack/react-query'; -import { getQueryClient } from '@/lib/query-client'; -import { - assignEnquiry, - createEnquiry, - createEnquiryFollowup, - deleteEnquiry, - deleteEnquiryFollowup, - markEnquiryLost, - markEnquiryWon, - reassignEnquiry, - reopenEnquiry, - updateEnquiry, - updateEnquiryFollowup -} from './service'; -import { enquiryKeys } from './queries'; -import type { - EnquiryAssignmentMutationPayload, - EnquiryFollowupMutationPayload, - EnquiryMarkLostPayload, - EnquiryMarkWonPayload, - EnquiryMutationPayload -} from './types'; -import { crmDashboardKeys } from '@/features/crm/dashboard/api/queries'; - -async function invalidateEnquiryLists() { - await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.lists() }); -} - -async function invalidateDashboardQueries() { - await getQueryClient().invalidateQueries({ queryKey: crmDashboardKeys.all }); -} - -async function invalidateEnquiryDetail(id: string) { - await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.detail(id) }); -} - -async function invalidateEnquiryProjectParties(id: string) { - await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.projectParties(id) }); -} - -async function invalidateEnquiryFollowups(id: string) { - await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.followups(id) }); -} - -async function invalidateEnquiryAttachments(id: string) { - await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.attachments(id) }); -} - -export async function invalidateEnquiryMutationQueries(id: string) { - await Promise.all([ - invalidateEnquiryLists(), - invalidateEnquiryDetail(id), - invalidateEnquiryProjectParties(id), - invalidateEnquiryAttachments(id), - invalidateDashboardQueries() - ]); -} - -export async function invalidateEnquiryFollowupMutationQueries(enquiryId: string) { - await Promise.all([ - invalidateEnquiryLists(), - invalidateEnquiryDetail(enquiryId), - invalidateEnquiryFollowups(enquiryId), - invalidateDashboardQueries() - ]); -} - -export const createEnquiryMutation = mutationOptions({ - mutationFn: (data: EnquiryMutationPayload) => createEnquiry(data), - onSettled: async (_data, error) => { - if (!error) { - await Promise.all([invalidateEnquiryLists(), invalidateDashboardQueries()]); - } - } -}); - -export const updateEnquiryMutation = mutationOptions({ - mutationFn: ({ id, values }: { id: string; values: EnquiryMutationPayload }) => - updateEnquiry(id, values), - onSettled: async (_data, error, variables) => { - if (!error) { - await invalidateEnquiryMutationQueries(variables.id); - } - } -}); - -export const deleteEnquiryMutation = mutationOptions({ - mutationFn: (id: string) => deleteEnquiry(id), - onSettled: async (_data, error, id) => { - if (!error) { - getQueryClient().removeQueries({ queryKey: enquiryKeys.detail(id) }); - getQueryClient().removeQueries({ queryKey: enquiryKeys.projectParties(id) }); - getQueryClient().removeQueries({ queryKey: enquiryKeys.followups(id) }); - await Promise.all([invalidateEnquiryLists(), invalidateDashboardQueries()]); - } - } -}); - -export const assignEnquiryMutation = mutationOptions({ - mutationFn: ({ id, values }: { id: string; values: EnquiryAssignmentMutationPayload }) => - assignEnquiry(id, values), - onSettled: async (_data, error, variables) => { - if (!error) { - await invalidateEnquiryMutationQueries(variables.id); - } - } -}); - -export const reassignEnquiryMutation = mutationOptions({ - mutationFn: ({ id, values }: { id: string; values: EnquiryAssignmentMutationPayload }) => - reassignEnquiry(id, values), - onSettled: async (_data, error, variables) => { - if (!error) { - await invalidateEnquiryMutationQueries(variables.id); - } - } -}); - -export const createEnquiryFollowupMutation = mutationOptions({ - mutationFn: ({ - enquiryId, - values - }: { - enquiryId: string; - values: EnquiryFollowupMutationPayload; - }) => createEnquiryFollowup(enquiryId, values), - onSettled: async (_data, error, variables) => { - if (!error) { - await invalidateEnquiryFollowupMutationQueries(variables.enquiryId); - } - } -}); - -export const updateEnquiryFollowupMutation = mutationOptions({ - mutationFn: ({ - enquiryId, - followupId, - values - }: { - enquiryId: string; - followupId: string; - values: EnquiryFollowupMutationPayload; - }) => updateEnquiryFollowup(enquiryId, followupId, values), - onSettled: async (_data, error, variables) => { - if (!error) { - await invalidateEnquiryFollowupMutationQueries(variables.enquiryId); - } - } -}); - -export const deleteEnquiryFollowupMutation = mutationOptions({ - mutationFn: ({ enquiryId, followupId }: { enquiryId: string; followupId: string }) => - deleteEnquiryFollowup(enquiryId, followupId), - onSettled: async (_data, error, variables) => { - if (!error) { - await invalidateEnquiryFollowupMutationQueries(variables.enquiryId); - } - } -}); - -export const markEnquiryWonMutation = mutationOptions({ - mutationFn: ({ id, values }: { id: string; values: EnquiryMarkWonPayload }) => - markEnquiryWon(id, values), - onSettled: async (_data, error, variables) => { - if (!error) { - await invalidateEnquiryMutationQueries(variables.id); - } - } -}); - -export const markEnquiryLostMutation = mutationOptions({ - mutationFn: ({ id, values }: { id: string; values: EnquiryMarkLostPayload }) => - markEnquiryLost(id, values), - onSettled: async (_data, error, variables) => { - if (!error) { - await invalidateEnquiryMutationQueries(variables.id); - } - } -}); - -export const reopenEnquiryMutation = mutationOptions({ - mutationFn: ({ id, reason }: { id: string; reason: string }) => - reopenEnquiry(id, { reopenReason: reason }), - onSettled: async (_data, error, variables) => { - if (!error) { - await invalidateEnquiryMutationQueries(variables.id); - } - } -}); diff --git a/src/features/crm/enquiries/api/queries.ts b/src/features/crm/enquiries/api/queries.ts deleted file mode 100644 index 4d294d0..0000000 --- a/src/features/crm/enquiries/api/queries.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { queryOptions } from '@tanstack/react-query'; -import { - getEnquiryAttachments, - getEnquiries, - getEnquiryById, - getEnquiryFollowups, - getEnquiryProjectParties -} from './service'; -import type { EnquiryFilters } from './types'; - -export const enquiryKeys = { - all: ['crm-enquiries'] as const, - lists: () => [...enquiryKeys.all, 'list'] as const, - list: (filters: EnquiryFilters) => [...enquiryKeys.lists(), filters] as const, - details: () => [...enquiryKeys.all, 'detail'] as const, - detail: (id: string) => [...enquiryKeys.details(), id] as const, - projectPartiesRoot: () => [...enquiryKeys.all, 'project-parties'] as const, - projectParties: (id: string) => [...enquiryKeys.projectPartiesRoot(), id] as const, - followupsRoot: () => [...enquiryKeys.all, 'followups'] as const, - followups: (id: string) => [...enquiryKeys.followupsRoot(), id] as const, - attachmentsRoot: () => [...enquiryKeys.all, 'attachments'] as const, - attachments: (id: string) => [...enquiryKeys.attachmentsRoot(), id] as const -}; - -export const enquiriesQueryOptions = (filters: EnquiryFilters) => - queryOptions({ - queryKey: enquiryKeys.list(filters), - queryFn: () => getEnquiries(filters) - }); - -export const enquiryByIdOptions = (id: string) => - queryOptions({ - queryKey: enquiryKeys.detail(id), - queryFn: () => getEnquiryById(id) - }); - -export const enquiryProjectPartiesOptions = (id: string) => - queryOptions({ - queryKey: enquiryKeys.projectParties(id), - queryFn: () => getEnquiryProjectParties(id) - }); - -export const enquiryFollowupsOptions = (id: string) => - queryOptions({ - queryKey: enquiryKeys.followups(id), - queryFn: () => getEnquiryFollowups(id) - }); - -export const enquiryAttachmentsOptions = (id: string) => - queryOptions({ - queryKey: enquiryKeys.attachments(id), - queryFn: () => getEnquiryAttachments(id) - }); diff --git a/src/features/crm/enquiries/api/service.ts b/src/features/crm/enquiries/api/service.ts deleted file mode 100644 index 13ac25f..0000000 --- a/src/features/crm/enquiries/api/service.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { apiClient } from '@/lib/api-client'; -import type { - EnquiryAttachmentsResponse, - EnquiryAssignmentMutationPayload, - EnquiryDetailResponse, - EnquiryFilters, - EnquiryFollowupMutationPayload, - EnquiryFollowupsResponse, - EnquiryListResponse, - EnquiryMarkLostPayload, - EnquiryMarkWonPayload, - EnquiryMutationPayload, - EnquiryProjectPartiesResponse, - EnquiryReopenPayload, - MutationSuccessResponse -} from './types'; - -export async function getEnquiries(filters: EnquiryFilters): Promise { - const searchParams = new URLSearchParams(); - - if (filters.page) searchParams.set('page', String(filters.page)); - if (filters.limit) searchParams.set('limit', String(filters.limit)); - if (filters.search) searchParams.set('search', filters.search); - if (filters.pipelineStage) searchParams.set('pipelineStage', filters.pipelineStage); - if (filters.status) searchParams.set('status', filters.status); - if (filters.productType) searchParams.set('productType', filters.productType); - if (filters.priority) searchParams.set('priority', filters.priority); - if (filters.branch) searchParams.set('branch', filters.branch); - if (filters.customer) searchParams.set('customer', filters.customer); - if (filters.sort) searchParams.set('sort', filters.sort); - - const query = searchParams.toString(); - - return apiClient(`/crm/enquiries${query ? `?${query}` : ''}`); -} - -export async function getEnquiryById(id: string): Promise { - return apiClient(`/crm/enquiries/${id}`); -} - -export async function getEnquiryProjectParties(id: string): Promise { - return apiClient(`/crm/enquiries/${id}/customers`); -} - -export async function createEnquiry(data: EnquiryMutationPayload) { - return apiClient('/crm/enquiries', { - method: 'POST', - body: JSON.stringify(data) - }); -} - -export async function updateEnquiry(id: string, data: EnquiryMutationPayload) { - return apiClient(`/crm/enquiries/${id}`, { - method: 'PATCH', - body: JSON.stringify(data) - }); -} - -export async function deleteEnquiry(id: string) { - return apiClient(`/crm/enquiries/${id}`, { - method: 'DELETE' - }); -} - -export async function assignEnquiry(id: string, data: EnquiryAssignmentMutationPayload) { - return apiClient(`/crm/enquiries/${id}/assign`, { - method: 'POST', - body: JSON.stringify(data) - }); -} - -export async function reassignEnquiry(id: string, data: EnquiryAssignmentMutationPayload) { - return apiClient(`/crm/enquiries/${id}/reassign`, { - method: 'POST', - body: JSON.stringify(data) - }); -} - -export async function getEnquiryFollowups(id: string): Promise { - return apiClient(`/crm/enquiries/${id}/followups`); -} - -export async function getEnquiryAttachments(id: string): Promise { - return apiClient(`/crm/enquiries/${id}/po-attachments`); -} - -export async function createEnquiryFollowup( - enquiryId: string, - data: EnquiryFollowupMutationPayload -) { - return apiClient(`/crm/enquiries/${enquiryId}/followups`, { - method: 'POST', - body: JSON.stringify(data) - }); -} - -export async function updateEnquiryFollowup( - enquiryId: string, - followupId: string, - data: EnquiryFollowupMutationPayload -) { - return apiClient(`/crm/enquiries/${enquiryId}/followups/${followupId}`, { - method: 'PATCH', - body: JSON.stringify(data) - }); -} - -export async function deleteEnquiryFollowup(enquiryId: string, followupId: string) { - return apiClient(`/crm/enquiries/${enquiryId}/followups/${followupId}`, { - method: 'DELETE' - }); -} - -export async function markEnquiryWon(id: string, data: EnquiryMarkWonPayload) { - return apiClient(`/crm/enquiries/${id}/mark-won`, { - method: 'POST', - body: JSON.stringify(data) - }); -} - -export async function markEnquiryLost(id: string, data: EnquiryMarkLostPayload) { - return apiClient(`/crm/enquiries/${id}/mark-lost`, { - method: 'POST', - body: JSON.stringify(data) - }); -} - -export async function reopenEnquiry(id: string, data: EnquiryReopenPayload) { - return apiClient(`/crm/enquiries/${id}/reopen`, { - method: 'POST', - body: JSON.stringify(data) - }); -} diff --git a/src/features/crm/leads/api/types.ts b/src/features/crm/leads/api/types.ts index 03de2cc..eb3f6e2 100644 --- a/src/features/crm/leads/api/types.ts +++ b/src/features/crm/leads/api/types.ts @@ -9,7 +9,7 @@ export type { LeadDetail, LeadDetailResponse, LeadFollowupSummary, - LeadLinkedEnquirySummary, + LeadLinkedOpportunitySummary, LeadListFilters, LeadListResponse, LeadMutationSuccessResponse, @@ -18,3 +18,4 @@ export type { LeadSummary, UpdateLeadInput } from '../types'; + diff --git a/src/features/crm/leads/components/lead-assignment-dialog.tsx b/src/features/crm/leads/components/lead-assignment-dialog.tsx index e10738e..d77f6ff 100644 --- a/src/features/crm/leads/components/lead-assignment-dialog.tsx +++ b/src/features/crm/leads/components/lead-assignment-dialog.tsx @@ -54,9 +54,9 @@ export function LeadAssignmentDialog({ ...assignLeadMutation, onSuccess: (result) => { toast.success( - result.reusedExistingEnquiry - ? `มอบหมายสำเร็จและใช้ enquiry เดิม ${result.enquiryCode}` - : `มอบหมายสำเร็จและสร้าง enquiry ${result.enquiryCode}` + result.reusedExistingOpportunity + ? `มอบหมายสำเร็จและใช้ opportunity เดิม ${result.opportunityCode}` + : `มอบหมายสำเร็จและสร้าง opportunity ${result.opportunityCode}` ); onOpenChange(false); }, @@ -90,7 +90,7 @@ export function LeadAssignmentDialog({ Assign Lead to Sales - มอบหมายลีดให้ฝ่ายขายและสร้าง enquiry workspace แยกจาก lead workspace + มอบหมายลีดให้ฝ่ายขายและสร้าง opportunity workspace แยกจาก lead workspace @@ -127,17 +127,17 @@ export function LeadAssignmentDialog({ - {'linkedEnquiries' in lead && lead.linkedEnquiries.length > 0 ? ( + {'linkedOpportunities' in lead && lead.linkedOpportunities.length > 0 ? (
-
Linked enquiries
+
Linked opportunities
- {lead.linkedEnquiries.map((enquiry) => ( + {lead.linkedOpportunities.map((opportunity) => ( - {enquiry.code} - {enquiry.title} + {opportunity.code} - {opportunity.title} ))}
@@ -147,3 +147,4 @@ export function LeadAssignmentDialog({ ); } + diff --git a/src/features/crm/leads/components/lead-detail.tsx b/src/features/crm/leads/components/lead-detail.tsx index 1b04039..3ce33aa 100644 --- a/src/features/crm/leads/components/lead-detail.tsx +++ b/src/features/crm/leads/components/lead-detail.tsx @@ -57,7 +57,7 @@ export function LeadDetail({ {lead.statusLabel ?? lead.status}

- Lead workspace for marketing qualification before sales enquiry handoff + Lead workspace for marketing qualification before sales opportunity handoff

@@ -103,7 +103,7 @@ export function LeadDetail({ Follow-ups - Linked Enquiries + Linked Opportunities Activity @@ -111,29 +111,29 @@ export function LeadDetail({ - + - Linked Enquiries + Linked Opportunities - {lead.linkedEnquiries.length === 0 ? ( -
No linked enquiries yet.
+ {lead.linkedOpportunities.length === 0 ? ( +
No linked opportunities yet.
) : ( - lead.linkedEnquiries.map((enquiry) => ( -
+ lead.linkedOpportunities.map((opportunity) => ( +
- {enquiry.code} + {opportunity.code} - {enquiry.status} + {opportunity.status}
-
{enquiry.title}
+
{opportunity.title}
- Owner: {enquiry.assignedToName ?? '-'} + Owner: {opportunity.assignedToName ?? '-'}
)) @@ -174,7 +174,7 @@ export function LeadDetail({ - + ); } + diff --git a/src/features/crm/leads/server/assignment.service.ts b/src/features/crm/leads/server/assignment.service.ts index d187b08..62ca626 100644 --- a/src/features/crm/leads/server/assignment.service.ts +++ b/src/features/crm/leads/server/assignment.service.ts @@ -1,5 +1,5 @@ import { and, asc, eq, inArray, isNull } from 'drizzle-orm'; -import { crmEnquiries, crmLeads } from '@/db/schema'; +import { crmOpportunities, crmLeads } from '@/db/schema'; import { db } from '@/lib/db'; import { AuthError } from '@/lib/auth/session'; import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service'; @@ -13,13 +13,13 @@ import { assertAssignableUserBelongsToOrganization, assertContactBelongsToOrganization, assertCustomerBelongsToOrganization -} from '@/features/crm/enquiries/server/service'; +} from '@/features/crm/opportunities/server/service'; import type { AssignLeadInput, AssignLeadResult } from '../types'; export type LeadAssignmentAccessContext = CrmSecurityContext; type LeadRow = typeof crmLeads.$inferSelect; -type EnquiryRow = typeof crmEnquiries.$inferSelect; +type OpportunityRow = typeof crmOpportunities.$inferSelect; function canAccessLeadRow(row: LeadRow, accessContext: LeadAssignmentAccessContext) { return canAccessScopedCrmRecord(accessContext, { @@ -57,34 +57,34 @@ async function assertLeadBelongsToOrganization( return lead; } -async function resolveDefaultEnquiryStatusId(organizationId: string) { - const options = await getActiveOptionsByCategory('crm_enquiry_status', { organizationId }); +async function resolveDefaultOpportunityStatusId(organizationId: string) { + const options = await getActiveOptionsByCategory('crm_opportunity_status', { organizationId }); const preferred = options.find((option) => option.code === 'new'); const resolved = preferred ?? options[0]; if (!resolved) { - throw new AuthError('Enquiry status options are not configured', 400); + throw new AuthError('Opportunity status options are not configured', 400); } return resolved.id; } -async function findActiveEnquiryByLead(leadId: string, organizationId: string) { - const [enquiry] = await db +async function findActiveOpportunityByLead(leadId: string, organizationId: string) { + const [opportunity] = await db .select() - .from(crmEnquiries) + .from(crmOpportunities) .where( and( - eq(crmEnquiries.leadId, leadId), - eq(crmEnquiries.organizationId, organizationId), - inArray(crmEnquiries.pipelineStage, ['lead', 'enquiry']), - isNull(crmEnquiries.deletedAt) + eq(crmOpportunities.leadId, leadId), + eq(crmOpportunities.organizationId, organizationId), + inArray(crmOpportunities.pipelineStage, ['lead', 'opportunity']), + isNull(crmOpportunities.deletedAt) ) ) - .orderBy(asc(crmEnquiries.createdAt)) + .orderBy(asc(crmOpportunities.createdAt)) .limit(1); - return enquiry ?? null; + return opportunity ?? null; } async function syncLeadAssignment(input: { @@ -109,7 +109,7 @@ async function syncLeadAssignment(input: { .where(eq(crmLeads.id, input.leadId)); } -async function createEnquiryFromLead(input: { +async function createOpportunityFromLead(input: { lead: LeadRow; organizationId: string; actorUserId: string; @@ -139,17 +139,17 @@ async function createEnquiryFromLead(input: { } const [statusId, documentCode] = await Promise.all([ - resolveDefaultEnquiryStatusId(input.organizationId), + resolveDefaultOpportunityStatusId(input.organizationId), generateNextDocumentCode({ organizationId: input.organizationId, - documentType: 'crm_enquiry', + documentType: 'crm_opportunity', branchId: input.lead.branchId ?? null }) ]); const now = new Date(); const [created] = await db - .insert(crmEnquiries) + .insert(crmOpportunities) .values({ id: crypto.randomUUID(), organizationId: input.organizationId, @@ -175,7 +175,7 @@ async function createEnquiryFromLead(input: { notes: null, isHotProject: false, isActive: true, - pipelineStage: 'enquiry', + pipelineStage: 'opportunity', assignedToUserId: input.salesOwnerId, assignedAt: now, assignedBy: input.actorUserId, @@ -197,16 +197,16 @@ export async function assignLead( const lead = await assertLeadBelongsToOrganization(input.leadId, organizationId, accessContext); await assertAssignableUserBelongsToOrganization(input.salesOwnerId, organizationId); - const existingEnquiry = await findActiveEnquiryByLead(input.leadId, organizationId); + const existingOpportunity = await findActiveOpportunityByLead(input.leadId, organizationId); - if (existingEnquiry) { - const resolvedSalesOwnerId = existingEnquiry.assignedToUserId ?? input.salesOwnerId; + if (existingOpportunity) { + const resolvedSalesOwnerId = existingOpportunity.assignedToUserId ?? input.salesOwnerId; - if (!existingEnquiry.assignedToUserId) { + if (!existingOpportunity.assignedToUserId) { await db - .update(crmEnquiries) + .update(crmOpportunities) .set({ - pipelineStage: 'enquiry', + pipelineStage: 'opportunity', assignedToUserId: input.salesOwnerId, assignedAt: new Date(), assignedBy: actorUserId, @@ -214,7 +214,7 @@ export async function assignLead( updatedAt: new Date(), updatedBy: actorUserId }) - .where(eq(crmEnquiries.id, existingEnquiry.id)); + .where(eq(crmOpportunities.id, existingOpportunity.id)); } await syncLeadAssignment({ @@ -234,21 +234,21 @@ export async function assignLead( action: 'assign_lead', afterData: { leadId: input.leadId, - enquiryId: existingEnquiry.id, + opportunityId: existingOpportunity.id, salesOwnerId: resolvedSalesOwnerId, - reusedExistingEnquiry: true + reusedExistingOpportunity: true } }); return { leadId: input.leadId, - enquiryId: existingEnquiry.id, - enquiryCode: existingEnquiry.code, - reusedExistingEnquiry: true + opportunityId: existingOpportunity.id, + opportunityCode: existingOpportunity.code, + reusedExistingOpportunity: true }; } - const createdEnquiry = await createEnquiryFromLead({ + const createdOpportunity = await createOpportunityFromLead({ lead, organizationId, actorUserId, @@ -268,12 +268,12 @@ export async function assignLead( organizationId, branchId: lead.branchId, userId: actorUserId, - entityType: 'crm_enquiry', - entityId: createdEnquiry.id, - action: 'create_enquiry_from_lead', + entityType: 'crm_opportunity', + entityId: createdOpportunity.id, + action: 'create_opportunity_from_lead', afterData: { leadId: input.leadId, - enquiryId: createdEnquiry.id, + opportunityId: createdOpportunity.id, salesOwnerId: input.salesOwnerId } }); @@ -287,16 +287,17 @@ export async function assignLead( action: 'assign_lead', afterData: { leadId: input.leadId, - enquiryId: createdEnquiry.id, + opportunityId: createdOpportunity.id, salesOwnerId: input.salesOwnerId, - reusedExistingEnquiry: false + reusedExistingOpportunity: false } }); return { leadId: input.leadId, - enquiryId: createdEnquiry.id, - enquiryCode: createdEnquiry.code, - reusedExistingEnquiry: false + opportunityId: createdOpportunity.id, + opportunityCode: createdOpportunity.code, + reusedExistingOpportunity: false }; } + diff --git a/src/features/crm/leads/server/service.ts b/src/features/crm/leads/server/service.ts index 91656d7..e6518e5 100644 --- a/src/features/crm/leads/server/service.ts +++ b/src/features/crm/leads/server/service.ts @@ -13,7 +13,7 @@ import { import { crmCustomerContacts, crmCustomers, - crmEnquiries, + crmOpportunities, crmLeads, memberships, trAuditLogs, @@ -41,7 +41,7 @@ import type { LeadCustomerLookup, LeadDetail, LeadFollowupSummary, - LeadLinkedEnquirySummary, + LeadLinkedOpportunitySummary, LeadListFilters, LeadListResponse, LeadOption, @@ -187,28 +187,28 @@ async function listLeadContacts(organizationId: string): Promise { +): Promise { const rows = await db .select({ - id: crmEnquiries.id, - code: crmEnquiries.code, - title: crmEnquiries.title, - status: crmEnquiries.status, - assignedToUserId: crmEnquiries.assignedToUserId, - updatedAt: crmEnquiries.updatedAt + id: crmOpportunities.id, + code: crmOpportunities.code, + title: crmOpportunities.title, + status: crmOpportunities.status, + assignedToUserId: crmOpportunities.assignedToUserId, + updatedAt: crmOpportunities.updatedAt }) - .from(crmEnquiries) + .from(crmOpportunities) .where( and( - eq(crmEnquiries.organizationId, organizationId), - eq(crmEnquiries.leadId, leadId), - isNull(crmEnquiries.deletedAt) + eq(crmOpportunities.organizationId, organizationId), + eq(crmOpportunities.leadId, leadId), + isNull(crmOpportunities.deletedAt) ) ) - .orderBy(desc(crmEnquiries.updatedAt)); + .orderBy(desc(crmOpportunities.updatedAt)); const assigneeIds = [ ...new Set(rows.map((row) => row.assignedToUserId).filter((value): value is string => Boolean(value))) @@ -340,13 +340,13 @@ function mapLeadDetail( row: LeadRow, lookups: LeadLookupMaps, suggestedSalesOwnerId: string | null, - relatedEnquiryCount: number + relatedOpportunityCount: number ): LeadDetail { return { ...mapLeadSummary(row, lookups), suggestedSalesOwnerId, - relatedEnquiryCount, - linkedEnquiries: [], + relatedOpportunityCount, + linkedOpportunities: [], activity: [] }; } @@ -766,32 +766,32 @@ export async function getLeadById( accessContext?: LeadAccessContext ): Promise { const lead = await assertLeadBelongsToOrganization(id, organizationId, accessContext); - const [lookups, relatedEnquiryResult, customer, linkedEnquiries, activity] = await Promise.all([ + const [lookups, relatedOpportunityResult, customer, linkedOpportunities, activity] = await Promise.all([ buildLookupMaps(organizationId, [lead]), db .select({ value: count() }) - .from(crmEnquiries) + .from(crmOpportunities) .where( and( - eq(crmEnquiries.organizationId, organizationId), - eq(crmEnquiries.leadId, id), - isNull(crmEnquiries.deletedAt) + eq(crmOpportunities.organizationId, organizationId), + eq(crmOpportunities.leadId, id), + isNull(crmOpportunities.deletedAt) ) ), lead.customerId ? assertCustomerBelongsToOrganization(lead.customerId, organizationId) : Promise.resolve(null), - listLinkedEnquiries(id, organizationId), + listLinkedOpportunities(id, organizationId), listLeadActivity(id, organizationId) ]); const detail = mapLeadDetail( lead, lookups, customer?.ownerUserId ?? null, - relatedEnquiryResult[0]?.value ?? 0 + relatedOpportunityResult[0]?.value ?? 0 ); return { ...detail, - linkedEnquiries, + linkedOpportunities, activity }; } @@ -926,7 +926,7 @@ export async function deleteLead( return { ...mapLeadDetail(deleted!, lookups, null, 0), - linkedEnquiries: [], + linkedOpportunities: [], activity: [] }; }); @@ -1077,3 +1077,4 @@ export function buildLeadAccessContext(input: { }) { return buildCrmSecurityContext(input); } + diff --git a/src/features/crm/leads/types.ts b/src/features/crm/leads/types.ts index 953cda4..4dec438 100644 --- a/src/features/crm/leads/types.ts +++ b/src/features/crm/leads/types.ts @@ -37,7 +37,7 @@ export interface LeadContactLookup { isPrimary: boolean; } -export interface LeadLinkedEnquirySummary { +export interface LeadLinkedOpportunitySummary { id: string; code: string; title: string; @@ -174,8 +174,8 @@ export interface LeadSummary { } export interface LeadDetail extends LeadSummary { - relatedEnquiryCount: number; - linkedEnquiries: LeadLinkedEnquirySummary[]; + relatedOpportunityCount: number; + linkedOpportunities: LeadLinkedOpportunitySummary[]; activity: LeadActivityRecord[]; } @@ -194,9 +194,9 @@ export interface LeadFollowupSummary { export interface AssignLeadResult { leadId: string; - enquiryId: string; - enquiryCode: string; - reusedExistingEnquiry: boolean; + opportunityId: string; + opportunityCode: string; + reusedExistingOpportunity: boolean; } export interface LeadListResponse { @@ -225,3 +225,4 @@ export interface LeadMutationSuccessResponse { lead?: LeadDetail; followup?: LeadFollowupSummary; } + diff --git a/src/features/crm/opportunities/api/mutations.ts b/src/features/crm/opportunities/api/mutations.ts new file mode 100644 index 0000000..6e5698d --- /dev/null +++ b/src/features/crm/opportunities/api/mutations.ts @@ -0,0 +1,191 @@ +import { mutationOptions } from '@tanstack/react-query'; +import { getQueryClient } from '@/lib/query-client'; +import { + assignOpportunity, + createOpportunity, + createOpportunityFollowup, + deleteOpportunity, + deleteOpportunityFollowup, + markOpportunityLost, + markOpportunityWon, + reassignOpportunity, + reopenOpportunity, + updateOpportunity, + updateOpportunityFollowup +} from './service'; +import { opportunityKeys } from './queries'; +import type { + OpportunityAssignmentMutationPayload, + OpportunityFollowupMutationPayload, + OpportunityMarkLostPayload, + OpportunityMarkWonPayload, + OpportunityMutationPayload +} from './types'; +import { crmDashboardKeys } from '@/features/crm/dashboard/api/queries'; + +async function invalidateOpportunityLists() { + await getQueryClient().invalidateQueries({ queryKey: opportunityKeys.lists() }); +} + +async function invalidateDashboardQueries() { + await getQueryClient().invalidateQueries({ queryKey: crmDashboardKeys.all }); +} + +async function invalidateOpportunityDetail(id: string) { + await getQueryClient().invalidateQueries({ queryKey: opportunityKeys.detail(id) }); +} + +async function invalidateOpportunityProjectParties(id: string) { + await getQueryClient().invalidateQueries({ queryKey: opportunityKeys.projectParties(id) }); +} + +async function invalidateOpportunityFollowups(id: string) { + await getQueryClient().invalidateQueries({ queryKey: opportunityKeys.followups(id) }); +} + +async function invalidateOpportunityAttachments(id: string) { + await getQueryClient().invalidateQueries({ queryKey: opportunityKeys.attachments(id) }); +} + +export async function invalidateOpportunityMutationQueries(id: string) { + await Promise.all([ + invalidateOpportunityLists(), + invalidateOpportunityDetail(id), + invalidateOpportunityProjectParties(id), + invalidateOpportunityAttachments(id), + invalidateDashboardQueries() + ]); +} + +export async function invalidateOpportunityFollowupMutationQueries(opportunityId: string) { + await Promise.all([ + invalidateOpportunityLists(), + invalidateOpportunityDetail(opportunityId), + invalidateOpportunityFollowups(opportunityId), + invalidateDashboardQueries() + ]); +} + +export const createOpportunityMutation = mutationOptions({ + mutationFn: (data: OpportunityMutationPayload) => createOpportunity(data), + onSettled: async (_data, error) => { + if (!error) { + await Promise.all([invalidateOpportunityLists(), invalidateDashboardQueries()]); + } + } +}); + +export const updateOpportunityMutation = mutationOptions({ + mutationFn: ({ id, values }: { id: string; values: OpportunityMutationPayload }) => + updateOpportunity(id, values), + onSettled: async (_data, error, variables) => { + if (!error) { + await invalidateOpportunityMutationQueries(variables.id); + } + } +}); + +export const deleteOpportunityMutation = mutationOptions({ + mutationFn: (id: string) => deleteOpportunity(id), + onSettled: async (_data, error, id) => { + if (!error) { + getQueryClient().removeQueries({ queryKey: opportunityKeys.detail(id) }); + getQueryClient().removeQueries({ queryKey: opportunityKeys.projectParties(id) }); + getQueryClient().removeQueries({ queryKey: opportunityKeys.followups(id) }); + await Promise.all([invalidateOpportunityLists(), invalidateDashboardQueries()]); + } + } +}); + +export const assignOpportunityMutation = mutationOptions({ + mutationFn: ({ id, values }: { id: string; values: OpportunityAssignmentMutationPayload }) => + assignOpportunity(id, values), + onSettled: async (_data, error, variables) => { + if (!error) { + await invalidateOpportunityMutationQueries(variables.id); + } + } +}); + +export const reassignOpportunityMutation = mutationOptions({ + mutationFn: ({ id, values }: { id: string; values: OpportunityAssignmentMutationPayload }) => + reassignOpportunity(id, values), + onSettled: async (_data, error, variables) => { + if (!error) { + await invalidateOpportunityMutationQueries(variables.id); + } + } +}); + +export const createOpportunityFollowupMutation = mutationOptions({ + mutationFn: ({ + opportunityId, + values + }: { + opportunityId: string; + values: OpportunityFollowupMutationPayload; + }) => createOpportunityFollowup(opportunityId, values), + onSettled: async (_data, error, variables) => { + if (!error) { + await invalidateOpportunityFollowupMutationQueries(variables.opportunityId); + } + } +}); + +export const updateOpportunityFollowupMutation = mutationOptions({ + mutationFn: ({ + opportunityId, + followupId, + values + }: { + opportunityId: string; + followupId: string; + values: OpportunityFollowupMutationPayload; + }) => updateOpportunityFollowup(opportunityId, followupId, values), + onSettled: async (_data, error, variables) => { + if (!error) { + await invalidateOpportunityFollowupMutationQueries(variables.opportunityId); + } + } +}); + +export const deleteOpportunityFollowupMutation = mutationOptions({ + mutationFn: ({ opportunityId, followupId }: { opportunityId: string; followupId: string }) => + deleteOpportunityFollowup(opportunityId, followupId), + onSettled: async (_data, error, variables) => { + if (!error) { + await invalidateOpportunityFollowupMutationQueries(variables.opportunityId); + } + } +}); + +export const markOpportunityWonMutation = mutationOptions({ + mutationFn: ({ id, values }: { id: string; values: OpportunityMarkWonPayload }) => + markOpportunityWon(id, values), + onSettled: async (_data, error, variables) => { + if (!error) { + await invalidateOpportunityMutationQueries(variables.id); + } + } +}); + +export const markOpportunityLostMutation = mutationOptions({ + mutationFn: ({ id, values }: { id: string; values: OpportunityMarkLostPayload }) => + markOpportunityLost(id, values), + onSettled: async (_data, error, variables) => { + if (!error) { + await invalidateOpportunityMutationQueries(variables.id); + } + } +}); + +export const reopenOpportunityMutation = mutationOptions({ + mutationFn: ({ id, reason }: { id: string; reason: string }) => + reopenOpportunity(id, { reopenReason: reason }), + onSettled: async (_data, error, variables) => { + if (!error) { + await invalidateOpportunityMutationQueries(variables.id); + } + } +}); + diff --git a/src/features/crm/opportunities/api/queries.ts b/src/features/crm/opportunities/api/queries.ts new file mode 100644 index 0000000..c16db7c --- /dev/null +++ b/src/features/crm/opportunities/api/queries.ts @@ -0,0 +1,54 @@ +import { queryOptions } from '@tanstack/react-query'; +import { + getOpportunityAttachments, + getOpportunities, + getOpportunityById, + getOpportunityFollowups, + getOpportunityProjectParties +} from './service'; +import type { OpportunityFilters } from './types'; + +export const opportunityKeys = { + all: ['crm-opportunities'] as const, + lists: () => [...opportunityKeys.all, 'list'] as const, + list: (filters: OpportunityFilters) => [...opportunityKeys.lists(), filters] as const, + details: () => [...opportunityKeys.all, 'detail'] as const, + detail: (id: string) => [...opportunityKeys.details(), id] as const, + projectPartiesRoot: () => [...opportunityKeys.all, 'project-parties'] as const, + projectParties: (id: string) => [...opportunityKeys.projectPartiesRoot(), id] as const, + followupsRoot: () => [...opportunityKeys.all, 'followups'] as const, + followups: (id: string) => [...opportunityKeys.followupsRoot(), id] as const, + attachmentsRoot: () => [...opportunityKeys.all, 'attachments'] as const, + attachments: (id: string) => [...opportunityKeys.attachmentsRoot(), id] as const +}; + +export const opportunitiesQueryOptions = (filters: OpportunityFilters) => + queryOptions({ + queryKey: opportunityKeys.list(filters), + queryFn: () => getOpportunities(filters) + }); + +export const opportunityByIdOptions = (id: string) => + queryOptions({ + queryKey: opportunityKeys.detail(id), + queryFn: () => getOpportunityById(id) + }); + +export const opportunityProjectPartiesOptions = (id: string) => + queryOptions({ + queryKey: opportunityKeys.projectParties(id), + queryFn: () => getOpportunityProjectParties(id) + }); + +export const opportunityFollowupsOptions = (id: string) => + queryOptions({ + queryKey: opportunityKeys.followups(id), + queryFn: () => getOpportunityFollowups(id) + }); + +export const opportunityAttachmentsOptions = (id: string) => + queryOptions({ + queryKey: opportunityKeys.attachments(id), + queryFn: () => getOpportunityAttachments(id) + }); + diff --git a/src/features/crm/opportunities/api/service.ts b/src/features/crm/opportunities/api/service.ts new file mode 100644 index 0000000..510bf16 --- /dev/null +++ b/src/features/crm/opportunities/api/service.ts @@ -0,0 +1,134 @@ +import { apiClient } from '@/lib/api-client'; +import type { + OpportunityAttachmentsResponse, + OpportunityAssignmentMutationPayload, + OpportunityDetailResponse, + OpportunityFilters, + OpportunityFollowupMutationPayload, + OpportunityFollowupsResponse, + OpportunityListResponse, + OpportunityMarkLostPayload, + OpportunityMarkWonPayload, + OpportunityMutationPayload, + OpportunityProjectPartiesResponse, + OpportunityReopenPayload, + MutationSuccessResponse +} from './types'; + +export async function getOpportunities(filters: OpportunityFilters): Promise { + const searchParams = new URLSearchParams(); + + if (filters.page) searchParams.set('page', String(filters.page)); + if (filters.limit) searchParams.set('limit', String(filters.limit)); + if (filters.search) searchParams.set('search', filters.search); + if (filters.pipelineStage) searchParams.set('pipelineStage', filters.pipelineStage); + if (filters.status) searchParams.set('status', filters.status); + if (filters.productType) searchParams.set('productType', filters.productType); + if (filters.priority) searchParams.set('priority', filters.priority); + if (filters.branch) searchParams.set('branch', filters.branch); + if (filters.customer) searchParams.set('customer', filters.customer); + if (filters.sort) searchParams.set('sort', filters.sort); + + const query = searchParams.toString(); + + return apiClient(`/crm/opportunities${query ? `?${query}` : ''}`); +} + +export async function getOpportunityById(id: string): Promise { + return apiClient(`/crm/opportunities/${id}`); +} + +export async function getOpportunityProjectParties(id: string): Promise { + return apiClient(`/crm/opportunities/${id}/customers`); +} + +export async function createOpportunity(data: OpportunityMutationPayload) { + return apiClient('/crm/opportunities', { + method: 'POST', + body: JSON.stringify(data) + }); +} + +export async function updateOpportunity(id: string, data: OpportunityMutationPayload) { + return apiClient(`/crm/opportunities/${id}`, { + method: 'PATCH', + body: JSON.stringify(data) + }); +} + +export async function deleteOpportunity(id: string) { + return apiClient(`/crm/opportunities/${id}`, { + method: 'DELETE' + }); +} + +export async function assignOpportunity(id: string, data: OpportunityAssignmentMutationPayload) { + return apiClient(`/crm/opportunities/${id}/assign`, { + method: 'POST', + body: JSON.stringify(data) + }); +} + +export async function reassignOpportunity(id: string, data: OpportunityAssignmentMutationPayload) { + return apiClient(`/crm/opportunities/${id}/reassign`, { + method: 'POST', + body: JSON.stringify(data) + }); +} + +export async function getOpportunityFollowups(id: string): Promise { + return apiClient(`/crm/opportunities/${id}/followups`); +} + +export async function getOpportunityAttachments(id: string): Promise { + return apiClient(`/crm/opportunities/${id}/po-attachments`); +} + +export async function createOpportunityFollowup( + opportunityId: string, + data: OpportunityFollowupMutationPayload +) { + return apiClient(`/crm/opportunities/${opportunityId}/followups`, { + method: 'POST', + body: JSON.stringify(data) + }); +} + +export async function updateOpportunityFollowup( + opportunityId: string, + followupId: string, + data: OpportunityFollowupMutationPayload +) { + return apiClient(`/crm/opportunities/${opportunityId}/followups/${followupId}`, { + method: 'PATCH', + body: JSON.stringify(data) + }); +} + +export async function deleteOpportunityFollowup(opportunityId: string, followupId: string) { + return apiClient(`/crm/opportunities/${opportunityId}/followups/${followupId}`, { + method: 'DELETE' + }); +} + +export async function markOpportunityWon(id: string, data: OpportunityMarkWonPayload) { + return apiClient(`/crm/opportunities/${id}/mark-won`, { + method: 'POST', + body: JSON.stringify(data) + }); +} + +export async function markOpportunityLost(id: string, data: OpportunityMarkLostPayload) { + return apiClient(`/crm/opportunities/${id}/mark-lost`, { + method: 'POST', + body: JSON.stringify(data) + }); +} + +export async function reopenOpportunity(id: string, data: OpportunityReopenPayload) { + return apiClient(`/crm/opportunities/${id}/reopen`, { + method: 'POST', + body: JSON.stringify(data) + }); +} + diff --git a/src/features/crm/enquiries/api/types.ts b/src/features/crm/opportunities/api/types.ts similarity index 68% rename from src/features/crm/enquiries/api/types.ts rename to src/features/crm/opportunities/api/types.ts index 770d871..6f2bfe6 100644 --- a/src/features/crm/enquiries/api/types.ts +++ b/src/features/crm/opportunities/api/types.ts @@ -1,14 +1,14 @@ -export interface EnquiryOption { +export interface OpportunityOption { id: string; code: string; label: string; value: string | null; } -export interface EnquiryProjectPartyRecord { +export interface OpportunityProjectPartyRecord { id: string; organizationId: string; - enquiryId: string; + opportunityId: string; customerId: string; role: string; roleCode: string; @@ -19,19 +19,19 @@ export interface EnquiryProjectPartyRecord { deletedAt: string | null; } -export interface EnquiryProjectPartyListItem extends EnquiryProjectPartyRecord { +export interface OpportunityProjectPartyListItem extends OpportunityProjectPartyRecord { customerName: string; customerCode: string; } -export interface EnquiryBranchOption { +export interface OpportunityBranchOption { id: string; code: string; name: string; value: string | null; } -export interface EnquiryCustomerLookup { +export interface OpportunityCustomerLookup { id: string; code: string; name: string; @@ -40,7 +40,7 @@ export interface EnquiryCustomerLookup { ownerName: string | null; } -export interface EnquiryContactLookup { +export interface OpportunityContactLookup { id: string; customerId: string; name: string; @@ -49,17 +49,17 @@ export interface EnquiryContactLookup { isPrimary: boolean; } -export interface EnquiryAssignableUserLookup { +export interface OpportunityAssignableUserLookup { id: string; name: string; membershipRole: 'admin' | 'user'; businessRole: string; } -export interface EnquiryAttachmentRecord { +export interface OpportunityAttachmentRecord { id: string; organizationId: string; - enquiryId: string; + opportunityId: string; category: string; fileName: string; originalFileName: string; @@ -75,9 +75,9 @@ export interface EnquiryAttachmentRecord { deletedAt: string | null; } -export type EnquiryPipelineStage = 'lead' | 'enquiry' | 'closed_won' | 'closed_lost'; +export type OpportunityPipelineStage = 'lead' | 'opportunity' | 'closed_won' | 'closed_lost'; -export interface EnquiryRecord { +export interface OpportunityRecord { id: string; organizationId: string; branchId: string | null; @@ -102,7 +102,7 @@ export interface EnquiryRecord { notes: string | null; isHotProject: boolean; isActive: boolean; - pipelineStage: EnquiryPipelineStage; + pipelineStage: OpportunityPipelineStage; closedWonAt: string | null; closedLostAt: string | null; closedByUserId: string | null; @@ -129,7 +129,7 @@ export interface EnquiryRecord { updatedBy: string; } -export interface EnquiryListItem extends EnquiryRecord { +export interface OpportunityListItem extends OpportunityRecord { customerName: string; contactName: string | null; sourceLeadCode?: string | null; @@ -141,10 +141,10 @@ export interface EnquiryListItem extends EnquiryRecord { createdByName: string | null; } -export interface EnquiryFollowupRecord { +export interface OpportunityFollowupRecord { id: string; organizationId: string; - enquiryId: string; + opportunityId: string; followupDate: string; followupType: string; contactId: string | null; @@ -159,7 +159,7 @@ export interface EnquiryFollowupRecord { updatedBy: string; } -export interface EnquiryActivityRecord { +export interface OpportunityActivityRecord { id: string; action: string; entityType: string; @@ -169,25 +169,25 @@ export interface EnquiryActivityRecord { actorName: string | null; } -export interface EnquiryReferenceData { - branches: EnquiryBranchOption[]; - statuses: EnquiryOption[]; - productTypes: EnquiryOption[]; - priorities: EnquiryOption[]; - leadChannels: EnquiryOption[]; - followupTypes: EnquiryOption[]; - lostReasons: EnquiryOption[]; - projectPartyRoles: EnquiryOption[]; - customers: EnquiryCustomerLookup[]; - contacts: EnquiryContactLookup[]; - assignableUsers: EnquiryAssignableUserLookup[]; +export interface OpportunityReferenceData { + branches: OpportunityBranchOption[]; + statuses: OpportunityOption[]; + productTypes: OpportunityOption[]; + priorities: OpportunityOption[]; + leadChannels: OpportunityOption[]; + followupTypes: OpportunityOption[]; + lostReasons: OpportunityOption[]; + projectPartyRoles: OpportunityOption[]; + customers: OpportunityCustomerLookup[]; + contacts: OpportunityContactLookup[]; + assignableUsers: OpportunityAssignableUserLookup[]; } -export interface EnquiryFilters { +export interface OpportunityFilters { page?: number; limit?: number; search?: string; - pipelineStage?: EnquiryPipelineStage; + pipelineStage?: OpportunityPipelineStage; status?: string; productType?: string; priority?: string; @@ -196,52 +196,52 @@ export interface EnquiryFilters { sort?: string; } -export interface EnquiryListResponse { +export interface OpportunityListResponse { success: boolean; time: string; message: string; totalItems: number; offset: number; limit: number; - items: EnquiryListItem[]; + items: OpportunityListItem[]; } -export interface EnquiryDetailResponse { +export interface OpportunityDetailResponse { success: boolean; time: string; message: string; - enquiry: EnquiryRecord; - activity: EnquiryActivityRecord[]; + opportunity: OpportunityRecord; + activity: OpportunityActivityRecord[]; } -export interface EnquiryProjectPartiesResponse { +export interface OpportunityProjectPartiesResponse { success: boolean; time: string; message: string; - items: EnquiryProjectPartyListItem[]; + items: OpportunityProjectPartyListItem[]; } -export interface EnquiryFollowupsResponse { +export interface OpportunityFollowupsResponse { success: boolean; time: string; message: string; - items: EnquiryFollowupRecord[]; + items: OpportunityFollowupRecord[]; } -export interface EnquiryAttachmentsResponse { +export interface OpportunityAttachmentsResponse { success: boolean; time: string; message: string; - items: EnquiryAttachmentRecord[]; + items: OpportunityAttachmentRecord[]; } -export interface EnquiryProjectPartyMutationPayload { +export interface OpportunityProjectPartyMutationPayload { customerId: string; role: string; remark?: string | null; } -export interface EnquiryMutationPayload { +export interface OpportunityMutationPayload { customerId: string; contactId?: string | null; assignedToUserId?: string | null; @@ -263,10 +263,10 @@ export interface EnquiryMutationPayload { isHotProject?: boolean; notes?: string; isActive?: boolean; - projectParties?: EnquiryProjectPartyMutationPayload[]; + projectParties?: OpportunityProjectPartyMutationPayload[]; } -export interface EnquiryFollowupMutationPayload { +export interface OpportunityFollowupMutationPayload { followupDate: string; followupType: string; contactId?: string | null; @@ -276,12 +276,12 @@ export interface EnquiryFollowupMutationPayload { nextAction?: string; } -export interface EnquiryAssignmentMutationPayload { +export interface OpportunityAssignmentMutationPayload { assignedToUserId: string; remark?: string; } -export interface EnquiryMarkWonPayload { +export interface OpportunityMarkWonPayload { poNumber: string; poDate: string; poAmount?: number | null; @@ -289,17 +289,17 @@ export interface EnquiryMarkWonPayload { remark?: string | null; } -export interface EnquiryMarkLostPayload { +export interface OpportunityMarkLostPayload { lostReason: string; lostCompetitor?: string | null; lostRemark?: string | null; } -export interface EnquiryReopenPayload { +export interface OpportunityReopenPayload { reopenReason: string; } -export interface EnquiryCustomerRelationItem { +export interface OpportunityCustomerRelationItem { id: string; code: string; title: string; @@ -312,3 +312,4 @@ export interface MutationSuccessResponse { success: boolean; message: string; } + diff --git a/src/features/crm/enquiries/components/followup-form-sheet.tsx b/src/features/crm/opportunities/components/followup-form-sheet.tsx similarity index 87% rename from src/features/crm/enquiries/components/followup-form-sheet.tsx rename to src/features/crm/opportunities/components/followup-form-sheet.tsx index 58b3824..1364db6 100644 --- a/src/features/crm/enquiries/components/followup-form-sheet.tsx +++ b/src/features/crm/opportunities/components/followup-form-sheet.tsx @@ -21,15 +21,15 @@ import { SheetTitle } from '@/components/ui/sheet'; import { useAppForm, useFormFields } from '@/components/ui/tanstack-form'; -import { createEnquiryFollowupMutation, updateEnquiryFollowupMutation } from '../api/mutations'; +import { createOpportunityFollowupMutation, updateOpportunityFollowupMutation } from '../api/mutations'; import type { - EnquiryFollowupMutationPayload, - EnquiryFollowupRecord, - EnquiryReferenceData + OpportunityFollowupMutationPayload, + OpportunityFollowupRecord, + OpportunityReferenceData } from '../api/types'; -import { enquiryFollowupSchema, type EnquiryFollowupFormValues } from '../schemas/enquiry.schema'; +import { opportunityFollowupSchema, type OpportunityFollowupFormValues } from '../schemas/opportunity.schema'; -function toDefaultValues(followup: EnquiryFollowupRecord | undefined): EnquiryFollowupFormValues { +function toDefaultValues(followup: OpportunityFollowupRecord | undefined): OpportunityFollowupFormValues { return { followupDate: followup?.followupDate ? followup.followupDate.slice(0, 10) : '', followupType: followup?.followupType ?? '', @@ -42,28 +42,28 @@ function toDefaultValues(followup: EnquiryFollowupRecord | undefined): EnquiryFo } export function FollowupFormSheet({ - enquiryId, + opportunityId, followup, open, onOpenChange, referenceData, customerId }: { - enquiryId: string; - followup?: EnquiryFollowupRecord; + opportunityId: string; + followup?: OpportunityFollowupRecord; open: boolean; onOpenChange: (open: boolean) => void; - referenceData: EnquiryReferenceData; + referenceData: OpportunityReferenceData; customerId: string; }) { const isEdit = !!followup; const defaultValues = useMemo(() => toDefaultValues(followup), [followup]); const { FormTextField, FormTextareaField, FormDatePickerField } = - useFormFields(); + useFormFields(); const contactOptions = referenceData.contacts.filter((item) => item.customerId === customerId); const createMutation = useMutation({ - ...createEnquiryFollowupMutation, + ...createOpportunityFollowupMutation, onSuccess: () => { toast.success('Follow-up created successfully'); onOpenChange(false); @@ -73,7 +73,7 @@ export function FollowupFormSheet({ }); const updateMutation = useMutation({ - ...updateEnquiryFollowupMutation, + ...updateOpportunityFollowupMutation, onSuccess: () => { toast.success('Follow-up updated successfully'); onOpenChange(false); @@ -85,10 +85,10 @@ export function FollowupFormSheet({ const form = useAppForm({ defaultValues, validators: { - onSubmit: enquiryFollowupSchema + onSubmit: opportunityFollowupSchema }, onSubmit: async ({ value }) => { - const payload: EnquiryFollowupMutationPayload = { + const payload: OpportunityFollowupMutationPayload = { followupDate: value.followupDate, followupType: value.followupType, contactId: value.contactId || null, @@ -100,7 +100,7 @@ export function FollowupFormSheet({ if (isEdit && followup) { await updateMutation.mutateAsync({ - enquiryId, + opportunityId, followupId: followup.id, values: payload }); @@ -108,7 +108,7 @@ export function FollowupFormSheet({ } await createMutation.mutateAsync({ - enquiryId, + opportunityId, values: payload }); } @@ -130,13 +130,13 @@ export function FollowupFormSheet({ {isEdit ? 'Edit Follow-up' : 'New Follow-up'} - Keep the enquiry timeline current with the latest customer interaction and next step. + Keep the opportunity timeline current with the latest customer interaction and next step.
- + onOpenChange(false)}> Cancel - @@ -248,3 +248,4 @@ export function FollowupFormSheet({ ); } + diff --git a/src/features/crm/enquiries/components/enquiries-table.tsx b/src/features/crm/opportunities/components/opportunities-table.tsx similarity index 84% rename from src/features/crm/enquiries/components/enquiries-table.tsx rename to src/features/crm/opportunities/components/opportunities-table.tsx index b0e0a18..ab57ef3 100644 --- a/src/features/crm/enquiries/components/enquiries-table.tsx +++ b/src/features/crm/opportunities/components/opportunities-table.tsx @@ -7,11 +7,11 @@ import { DataTable } from '@/components/ui/table/data-table'; import { DataTableToolbar } from '@/components/ui/table/data-table-toolbar'; import { useDataTable } from '@/hooks/use-data-table'; import { getSortingStateParser } from '@/lib/parsers'; -import { enquiriesQueryOptions } from '../api/queries'; -import type { EnquiryReferenceData } from '../api/types'; -import { getEnquiryColumns } from './enquiry-columns'; +import { opportunitiesQueryOptions } from '../api/queries'; +import type { OpportunityReferenceData } from '../api/types'; +import { getOpportunityColumns } from './opportunity-columns'; -export function EnquiriesTable({ +export function OpportunitiesTable({ referenceData, workspace, canUpdate, @@ -19,8 +19,8 @@ export function EnquiriesTable({ canAssign, canReassign }: { - referenceData: EnquiryReferenceData; - workspace: 'lead' | 'enquiry'; + referenceData: OpportunityReferenceData; + workspace: 'lead' | 'opportunity'; canUpdate: boolean; canDelete: boolean; canAssign: boolean; @@ -28,7 +28,7 @@ export function EnquiriesTable({ }) { const columns = useMemo( () => - getEnquiryColumns({ + getOpportunityColumns({ referenceData, workspace, canUpdate, @@ -65,7 +65,7 @@ export function EnquiriesTable({ ...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) }) }; - const { data } = useSuspenseQuery(enquiriesQueryOptions(filters)); + const { data } = useSuspenseQuery(opportunitiesQueryOptions(filters)); const pageCount = Math.max(1, Math.ceil((data.totalItems || 0) / params.perPage)); const { table } = useDataTable({ @@ -83,3 +83,4 @@ export function EnquiriesTable({ ); } + diff --git a/src/features/crm/enquiries/components/enquiry-assignment-dialog.tsx b/src/features/crm/opportunities/components/opportunity-assignment-dialog.tsx similarity index 78% rename from src/features/crm/enquiries/components/enquiry-assignment-dialog.tsx rename to src/features/crm/opportunities/components/opportunity-assignment-dialog.tsx index 1e803d1..c1810e4 100644 --- a/src/features/crm/enquiries/components/enquiry-assignment-dialog.tsx +++ b/src/features/crm/opportunities/components/opportunity-assignment-dialog.tsx @@ -15,47 +15,47 @@ import { import { Button } from '@/components/ui/button'; import { useAppForm, useFormFields } from '@/components/ui/tanstack-form'; import { Icons } from '@/components/icons'; -import { assignEnquiryMutation, reassignEnquiryMutation } from '../api/mutations'; -import type { EnquiryRecord, EnquiryReferenceData } from '../api/types'; +import { assignOpportunityMutation, reassignOpportunityMutation } from '../api/mutations'; +import type { OpportunityRecord, OpportunityReferenceData } from '../api/types'; -const enquiryAssignmentSchema = z.object({ +const opportunityAssignmentSchema = z.object({ assignedToUserId: z.string().min(1, 'Sales user is required'), remark: z.string().optional() }); -type EnquiryAssignmentFormValues = z.infer; +type OpportunityAssignmentFormValues = z.infer; function toDefaultValues( - enquiry: EnquiryRecord, - referenceData: EnquiryReferenceData -): EnquiryAssignmentFormValues { + opportunity: OpportunityRecord, + referenceData: OpportunityReferenceData +): OpportunityAssignmentFormValues { return { - assignedToUserId: enquiry.assignedToUserId ?? referenceData.assignableUsers[0]?.id ?? '', - remark: enquiry.assignmentRemark ?? '' + assignedToUserId: opportunity.assignedToUserId ?? referenceData.assignableUsers[0]?.id ?? '', + remark: opportunity.assignmentRemark ?? '' }; } -export function EnquiryAssignmentDialog({ - enquiry, +export function OpportunityAssignmentDialog({ + opportunity, referenceData, mode, open, onOpenChange }: { - enquiry: EnquiryRecord; - referenceData: EnquiryReferenceData; + opportunity: OpportunityRecord; + referenceData: OpportunityReferenceData; mode: 'assign' | 'reassign'; open: boolean; onOpenChange: (open: boolean) => void; }) { const defaultValues = useMemo( - () => toDefaultValues(enquiry, referenceData), - [enquiry, referenceData] + () => toDefaultValues(opportunity, referenceData), + [opportunity, referenceData] ); - const { FormSelectField, FormTextareaField } = useFormFields(); + const { FormSelectField, FormTextareaField } = useFormFields(); const assignMutation = useMutation({ - ...assignEnquiryMutation, + ...assignOpportunityMutation, onSuccess: () => { toast.success('ส่งต่อลีดไปยังฝ่ายขายสำเร็จ'); onOpenChange(false); @@ -65,7 +65,7 @@ export function EnquiryAssignmentDialog({ }); const reassignMutation = useMutation({ - ...reassignEnquiryMutation, + ...reassignOpportunityMutation, onSuccess: () => { toast.success('เปลี่ยนผู้ดูแลโอกาสขายสำเร็จ'); onOpenChange(false); @@ -77,7 +77,7 @@ export function EnquiryAssignmentDialog({ const form = useAppForm({ defaultValues, validators: { - onSubmit: enquiryAssignmentSchema + onSubmit: opportunityAssignmentSchema }, onSubmit: async ({ value }) => { const values = { @@ -86,11 +86,11 @@ export function EnquiryAssignmentDialog({ }; if (mode === 'assign') { - await assignMutation.mutateAsync({ id: enquiry.id, values }); + await assignMutation.mutateAsync({ id: opportunity.id, values }); return; } - await reassignMutation.mutateAsync({ id: enquiry.id, values }); + await reassignMutation.mutateAsync({ id: opportunity.id, values }); } }); @@ -118,7 +118,7 @@ export function EnquiryAssignmentDialog({ - + {noUsersAvailable ? (
ยังไม่มีผู้ใช้ฝ่ายขายที่สามารถมอบหมายงานได้ใน workspace นี้ @@ -151,7 +151,7 @@ export function EnquiryAssignmentDialog({
@@ -126,7 +126,7 @@ export function EnquiryFollowupsTab({
setDeleteOpen(false)} onConfirm={() => { if (selectedId) { - deleteMutation.mutate({ enquiryId, followupId: selectedId }); + deleteMutation.mutate({ opportunityId, followupId: selectedId }); } }} loading={deleteMutation.isPending} @@ -146,3 +146,4 @@ export function EnquiryFollowupsTab({ ); } + diff --git a/src/features/crm/enquiries/components/enquiry-form-sheet.tsx b/src/features/crm/opportunities/components/opportunity-form-sheet.tsx similarity index 83% rename from src/features/crm/enquiries/components/enquiry-form-sheet.tsx rename to src/features/crm/opportunities/components/opportunity-form-sheet.tsx index 14cbff6..0a517ec 100644 --- a/src/features/crm/enquiries/components/enquiry-form-sheet.tsx +++ b/src/features/crm/opportunities/components/opportunity-form-sheet.tsx @@ -26,58 +26,58 @@ import { SheetTitle } from '@/components/ui/sheet'; import { useAppForm, useFormFields } from '@/components/ui/tanstack-form'; -import { enquiryProjectPartiesOptions } from '../api/queries'; -import { createEnquiryMutation, updateEnquiryMutation } from '../api/mutations'; -import type { EnquiryMutationPayload, EnquiryRecord, EnquiryReferenceData } from '../api/types'; -import { enquirySchema, type EnquiryFormValues } from '../schemas/enquiry.schema'; +import { opportunityProjectPartiesOptions } from '../api/queries'; +import { createOpportunityMutation, updateOpportunityMutation } from '../api/mutations'; +import type { OpportunityMutationPayload, OpportunityRecord, OpportunityReferenceData } from '../api/types'; +import { opportunitySchema, type OpportunityFormValues } from '../schemas/opportunity.schema'; function toDefaultValues( - enquiry: EnquiryRecord | undefined, - referenceData: EnquiryReferenceData -): EnquiryFormValues { + opportunity: OpportunityRecord | undefined, + referenceData: OpportunityReferenceData +): OpportunityFormValues { return { - customerId: enquiry?.customerId ?? referenceData.customers[0]?.id ?? '', - contactId: enquiry?.contactId ?? undefined, - assignedToUserId: enquiry?.assignedToUserId ?? undefined, - title: enquiry?.title ?? '', - description: enquiry?.description ?? '', - requirement: enquiry?.requirement ?? '', - projectName: enquiry?.projectName ?? '', - projectLocation: enquiry?.projectLocation ?? '', - branchId: enquiry?.branchId ?? undefined, - productType: enquiry?.productType ?? referenceData.productTypes[0]?.id ?? '', - status: enquiry?.status ?? referenceData.statuses[0]?.id ?? '', + customerId: opportunity?.customerId ?? referenceData.customers[0]?.id ?? '', + contactId: opportunity?.contactId ?? undefined, + assignedToUserId: opportunity?.assignedToUserId ?? undefined, + title: opportunity?.title ?? '', + description: opportunity?.description ?? '', + requirement: opportunity?.requirement ?? '', + projectName: opportunity?.projectName ?? '', + projectLocation: opportunity?.projectLocation ?? '', + branchId: opportunity?.branchId ?? undefined, + productType: opportunity?.productType ?? referenceData.productTypes[0]?.id ?? '', + status: opportunity?.status ?? referenceData.statuses[0]?.id ?? '', priority: - enquiry?.priority ?? referenceData.priorities[1]?.id ?? referenceData.priorities[0]?.id ?? '', - leadChannel: enquiry?.leadChannel ?? undefined, - estimatedValue: enquiry?.estimatedValue ?? undefined, - chancePercent: enquiry?.chancePercent ?? undefined, - expectedCloseDate: enquiry?.expectedCloseDate ? enquiry.expectedCloseDate.slice(0, 10) : '', - competitor: enquiry?.competitor ?? '', - source: enquiry?.source ?? '', - isHotProject: enquiry?.isHotProject ?? false, - notes: enquiry?.notes ?? '', - isActive: enquiry?.isActive ?? true + opportunity?.priority ?? referenceData.priorities[1]?.id ?? referenceData.priorities[0]?.id ?? '', + leadChannel: opportunity?.leadChannel ?? undefined, + estimatedValue: opportunity?.estimatedValue ?? undefined, + chancePercent: opportunity?.chancePercent ?? undefined, + expectedCloseDate: opportunity?.expectedCloseDate ? opportunity.expectedCloseDate.slice(0, 10) : '', + competitor: opportunity?.competitor ?? '', + source: opportunity?.source ?? '', + isHotProject: opportunity?.isHotProject ?? false, + notes: opportunity?.notes ?? '', + isActive: opportunity?.isActive ?? true }; } -export function EnquiryFormSheet({ - enquiry, +export function OpportunityFormSheet({ + opportunity, open, onOpenChange, referenceData, - workspace = 'enquiry' + workspace = 'opportunity' }: { - enquiry?: EnquiryRecord; + opportunity?: OpportunityRecord; open: boolean; onOpenChange: (open: boolean) => void; - referenceData: EnquiryReferenceData; - workspace?: 'lead' | 'enquiry'; + referenceData: OpportunityReferenceData; + workspace?: 'lead' | 'opportunity'; }) { - const isEdit = !!enquiry; + const isEdit = !!opportunity; const defaultValues = useMemo( - () => toDefaultValues(enquiry, referenceData), - [enquiry, referenceData] + () => toDefaultValues(opportunity, referenceData), + [opportunity, referenceData] ); const [selectedCustomerId, setSelectedCustomerId] = useState(defaultValues.customerId); const [projectParties, setProjectParties] = useState([]); @@ -89,14 +89,14 @@ export function EnquiryFormSheet({ FormDatePickerField, FormCurrencyField, FormPercentageField - } = useFormFields(); + } = useFormFields(); const projectPartiesQuery = useQuery({ - ...enquiryProjectPartiesOptions(enquiry?.id ?? ''), - enabled: open && !!enquiry?.id + ...opportunityProjectPartiesOptions(opportunity?.id ?? ''), + enabled: open && !!opportunity?.id }); const createMutation = useMutation({ - ...createEnquiryMutation, + ...createOpportunityMutation, onSuccess: () => { toast.success(`สร้าง${recordLabel}สำเร็จ`); onOpenChange(false); @@ -106,7 +106,7 @@ export function EnquiryFormSheet({ }); const updateMutation = useMutation({ - ...updateEnquiryMutation, + ...updateOpportunityMutation, onSuccess: () => { toast.success(`อัปเดต${recordLabel}สำเร็จ`); onOpenChange(false); @@ -123,10 +123,10 @@ export function EnquiryFormSheet({ const form = useAppForm({ defaultValues, validators: { - onSubmit: enquirySchema + onSubmit: opportunitySchema }, onSubmit: async ({ value }) => { - const payload: EnquiryMutationPayload = { + const payload: OpportunityMutationPayload = { customerId: value.customerId, contactId: value.contactId || null, assignedToUserId: value.assignedToUserId || null, @@ -159,8 +159,8 @@ export function EnquiryFormSheet({ })) }; - if (isEdit && enquiry) { - await updateMutation.mutateAsync({ id: enquiry.id, values: payload }); + if (isEdit && opportunity) { + await updateMutation.mutateAsync({ id: opportunity.id, values: payload }); return; } @@ -176,7 +176,7 @@ export function EnquiryFormSheet({ setSelectedCustomerId(defaultValues.customerId); form.reset(defaultValues); setProjectParties( - enquiry + opportunity ? (projectPartiesQuery.data?.items ?? []).map((item) => ({ key: item.id, customerId: item.customerId, @@ -185,7 +185,7 @@ export function EnquiryFormSheet({ })) : [] ); - }, [defaultValues, enquiry, form, open, projectPartiesQuery.data?.items]); + }, [defaultValues, opportunity, form, open, projectPartiesQuery.data?.items]); useEffect(() => { if (!open || isEdit) { @@ -216,7 +216,7 @@ export function EnquiryFormSheet({
- + ( @@ -289,7 +289,7 @@ export function EnquiryFormSheet({ description={ selectedCustomer?.ownerName ? `Suggested from customer owner: ${selectedCustomer.ownerName}` - : 'Optional sales owner suggestion for this lead or enquiry' + : 'Optional sales owner suggestion for this lead or opportunity' } options={referenceData.assignableUsers.map((user) => ({ value: user.id, @@ -301,7 +301,7 @@ export function EnquiryFormSheet({ name='title' label='ชื่อรายการ' required - placeholder='Warehouse crane upgrade enquiry' + placeholder='Warehouse crane upgrade opportunity' /> onOpenChange(false)}> ยกเลิก - @@ -439,12 +439,12 @@ export function EnquiryFormSheet({ ); } -export function EnquiryFormSheetTrigger({ +export function OpportunityFormSheetTrigger({ referenceData, - workspace = 'enquiry' + workspace = 'opportunity' }: { - referenceData: EnquiryReferenceData; - workspace?: 'lead' | 'enquiry'; + referenceData: OpportunityReferenceData; + workspace?: 'lead' | 'opportunity'; }) { const [open, setOpen] = React.useState(false); @@ -453,7 +453,7 @@ export function EnquiryFormSheetTrigger({ - ); } + diff --git a/src/features/crm/enquiries/components/enquiry-listing.tsx b/src/features/crm/opportunities/components/opportunity-listing.tsx similarity index 78% rename from src/features/crm/enquiries/components/enquiry-listing.tsx rename to src/features/crm/opportunities/components/opportunity-listing.tsx index c51698f..29584e7 100644 --- a/src/features/crm/enquiries/components/enquiry-listing.tsx +++ b/src/features/crm/opportunities/components/opportunity-listing.tsx @@ -1,11 +1,11 @@ import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; import { getQueryClient } from '@/lib/query-client'; import { searchParamsCache } from '@/lib/searchparams'; -import type { EnquiryReferenceData } from '../api/types'; -import { enquiriesQueryOptions } from '../api/queries'; -import { EnquiriesTable } from './enquiries-table'; +import type { OpportunityReferenceData } from '../api/types'; +import { opportunitiesQueryOptions } from '../api/queries'; +import { OpportunitiesTable } from './opportunities-table'; -export default function EnquiryListing({ +export default function OpportunityListing({ referenceData, workspace, canUpdate, @@ -13,8 +13,8 @@ export default function EnquiryListing({ canAssign, canReassign }: { - referenceData: EnquiryReferenceData; - workspace: 'lead' | 'enquiry'; + referenceData: OpportunityReferenceData; + workspace: 'lead' | 'opportunity'; canUpdate: boolean; canDelete: boolean; canAssign: boolean; @@ -44,11 +44,11 @@ export default function EnquiryListing({ }; const queryClient = getQueryClient(); - void queryClient.prefetchQuery(enquiriesQueryOptions(filters)); + void queryClient.prefetchQuery(opportunitiesQueryOptions(filters)); return ( - ); } + diff --git a/src/features/crm/enquiries/components/enquiry-outcome-card.tsx b/src/features/crm/opportunities/components/opportunity-outcome-card.tsx similarity index 79% rename from src/features/crm/enquiries/components/enquiry-outcome-card.tsx rename to src/features/crm/opportunities/components/opportunity-outcome-card.tsx index eb54547..eab7fb6 100644 --- a/src/features/crm/enquiries/components/enquiry-outcome-card.tsx +++ b/src/features/crm/opportunities/components/opportunity-outcome-card.tsx @@ -31,13 +31,13 @@ import { CrmTextarea } from '@/features/crm/components/crm-form-controls'; import { - invalidateEnquiryMutationQueries, - markEnquiryLostMutation, - markEnquiryWonMutation, - reopenEnquiryMutation + invalidateOpportunityMutationQueries, + markOpportunityLostMutation, + markOpportunityWonMutation, + reopenOpportunityMutation } from '../api/mutations'; -import { enquiryAttachmentsOptions, enquiryByIdOptions } from '../api/queries'; -import type { EnquiryReferenceData } from '../api/types'; +import { opportunityAttachmentsOptions, opportunityByIdOptions } from '../api/queries'; +import type { OpportunityReferenceData } from '../api/types'; function FieldItem({ label, value }: { label: string; value: string | null | undefined }) { return ( @@ -54,29 +54,29 @@ function getOutcomeLabel(stage: string) { return 'Open'; } -type EnquiryOutcomeCardProps = { - enquiryId: string; - referenceData: EnquiryReferenceData; +type OpportunityOutcomeCardProps = { + opportunityId: string; + referenceData: OpportunityReferenceData; canViewCommercialData: boolean; canMarkWon: boolean; canMarkLost: boolean; canReopen: boolean; }; -export function EnquiryOutcomeCard({ - enquiryId, +export function OpportunityOutcomeCard({ + opportunityId, referenceData, canViewCommercialData, canMarkWon, canMarkLost, canReopen -}: EnquiryOutcomeCardProps) { - const { data } = useSuspenseQuery(enquiryByIdOptions(enquiryId)); +}: OpportunityOutcomeCardProps) { + const { data } = useSuspenseQuery(opportunityByIdOptions(opportunityId)); const attachmentsQuery = useQuery({ - ...enquiryAttachmentsOptions(enquiryId), + ...opportunityAttachmentsOptions(opportunityId), enabled: canViewCommercialData }); - const enquiry = data.enquiry; + const opportunity = data.opportunity; const [wonOpen, setWonOpen] = useState(false); const [lostOpen, setLostOpen] = useState(false); const [reopenOpen, setReopenOpen] = useState(false); @@ -93,31 +93,31 @@ export function EnquiryOutcomeCard({ const [poFileDescription, setPoFileDescription] = useState(''); const wonMutation = useMutation({ - ...markEnquiryWonMutation, + ...markOpportunityWonMutation, onSuccess: async () => { - toast.success('Marked enquiry as won'); + toast.success('Marked opportunity as won'); setWonOpen(false); - await invalidateEnquiryMutationQueries(enquiryId); + await invalidateOpportunityMutationQueries(opportunityId); }, onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to mark won') }); const lostMutation = useMutation({ - ...markEnquiryLostMutation, + ...markOpportunityLostMutation, onSuccess: async () => { - toast.success('Marked enquiry as lost'); + toast.success('Marked opportunity as lost'); setLostOpen(false); - await invalidateEnquiryMutationQueries(enquiryId); + await invalidateOpportunityMutationQueries(opportunityId); }, onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to mark lost') }); const reopenMutation = useMutation({ - ...reopenEnquiryMutation, + ...reopenOpportunityMutation, onSuccess: async () => { - toast.success('Reopened enquiry'); + toast.success('Reopened opportunity'); setReopenOpen(false); - await invalidateEnquiryMutationQueries(enquiryId); + await invalidateOpportunityMutationQueries(opportunityId); }, - onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to reopen enquiry') + onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to reopen opportunity') }); const uploadMutation = useMutation({ mutationFn: async () => { @@ -128,7 +128,7 @@ export function EnquiryOutcomeCard({ const formData = new FormData(); formData.append('file', poFile); formData.append('description', poFileDescription); - const response = await fetch(`/api/crm/enquiries/${enquiryId}/po-attachments`, { + const response = await fetch(`/api/crm/opportunities/${opportunityId}/po-attachments`, { method: 'POST', body: formData }); @@ -144,26 +144,26 @@ export function EnquiryOutcomeCard({ toast.success('PO attachment uploaded'); setPoFile(null); setPoFileDescription(''); - await invalidateEnquiryMutationQueries(enquiryId); + await invalidateOpportunityMutationQueries(opportunityId); }, onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to upload PO attachment') }); - const closedDate = enquiry.closedWonAt ?? enquiry.closedLostAt; + const closedDate = opportunity.closedWonAt ?? opportunity.closedLostAt; const lostReasonLabel = useMemo(() => { - if (!enquiry.lostReason) { + if (!opportunity.lostReason) { return null; } return ( - enquiry.lostReasonLabel ?? + opportunity.lostReasonLabel ?? referenceData.lostReasons.find( - (item) => item.id === enquiry.lostReason || item.code === enquiry.lostReason + (item) => item.id === opportunity.lostReason || item.code === opportunity.lostReason )?.label ?? - enquiry.lostReason + opportunity.lostReason ); - }, [enquiry.lostReason, enquiry.lostReasonLabel, referenceData.lostReasons]); + }, [opportunity.lostReason, opportunity.lostReasonLabel, referenceData.lostReasons]); const attachmentItems = attachmentsQuery.data?.items ?? []; return ( @@ -175,16 +175,16 @@ export function EnquiryOutcomeCard({
- - {getOutcomeLabel(enquiry.pipelineStage)} + + {getOutcomeLabel(opportunity.pipelineStage)}
- {enquiry.pipelineStage === 'enquiry' && canMarkWon ? ( + {opportunity.pipelineStage === 'opportunity' && canMarkWon ? ( ) : null} - {enquiry.pipelineStage === 'enquiry' && canMarkLost ? ( + {opportunity.pipelineStage === 'opportunity' && canMarkLost ? ( ) : null} - {enquiry.pipelineStage === 'closed_lost' && canReopen ? ( + {opportunity.pipelineStage === 'closed_lost' && canReopen ? ( @@ -212,32 +212,32 @@ export function EnquiryOutcomeCard({
- + - + - - + +
- {canViewCommercialData && enquiry.pipelineStage === 'closed_won' ? ( + {canViewCommercialData && opportunity.pipelineStage === 'closed_won' ? (
Purchase Order Attachment
@@ -295,12 +295,12 @@ export function EnquiryOutcomeCard({
@@ -370,7 +370,7 @@ export function EnquiryOutcomeCard({ isLoading={wonMutation.isPending} onClick={() => wonMutation.mutate({ - id: enquiryId, + id: opportunityId, values: { poNumber, poDate, @@ -436,7 +436,7 @@ export function EnquiryOutcomeCard({ isLoading={lostMutation.isPending} onClick={() => lostMutation.mutate({ - id: enquiryId, + id: opportunityId, values: { lostReason, lostCompetitor, @@ -472,7 +472,7 @@ export function EnquiryOutcomeCard({ @@ -482,3 +482,4 @@ export function EnquiryOutcomeCard({ ); } + diff --git a/src/features/crm/enquiries/components/enquiry-status-badge.tsx b/src/features/crm/opportunities/components/opportunity-status-badge.tsx similarity index 88% rename from src/features/crm/enquiries/components/enquiry-status-badge.tsx rename to src/features/crm/opportunities/components/opportunity-status-badge.tsx index 1558f7b..a4d148c 100644 --- a/src/features/crm/enquiries/components/enquiry-status-badge.tsx +++ b/src/features/crm/opportunities/components/opportunity-status-badge.tsx @@ -3,7 +3,7 @@ import { Badge } from '@/components/ui/badge'; import { Icons } from '@/components/icons'; -export function EnquiryStatusBadge({ code, label }: { code?: string | null; label: string }) { +export function OpportunityStatusBadge({ code, label }: { code?: string | null; label: string }) { const normalized = code?.toLowerCase() ?? ''; const variant = normalized === 'closed_lost' || normalized === 'cancelled' @@ -29,3 +29,4 @@ export function EnquiryStatusBadge({ code, label }: { code?: string | null; labe ); } + diff --git a/src/features/crm/enquiries/components/quotation-readiness-panel.tsx b/src/features/crm/opportunities/components/quotation-readiness-panel.tsx similarity index 62% rename from src/features/crm/enquiries/components/quotation-readiness-panel.tsx rename to src/features/crm/opportunities/components/quotation-readiness-panel.tsx index 8df7d90..a66fb3a 100644 --- a/src/features/crm/enquiries/components/quotation-readiness-panel.tsx +++ b/src/features/crm/opportunities/components/quotation-readiness-panel.tsx @@ -1,21 +1,21 @@ 'use client'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import type { EnquiryRecord } from '../api/types'; +import type { OpportunityRecord } from '../api/types'; type ReadinessItem = { label: string; ready: boolean; }; -export function QuotationReadinessPanel({ enquiry }: { enquiry: EnquiryRecord }) { +export function QuotationReadinessPanel({ opportunity }: { opportunity: OpportunityRecord }) { const checks: ReadinessItem[] = [ - { label: 'Customer selected', ready: Boolean(enquiry.customerId) }, - { label: 'Contact selected', ready: Boolean(enquiry.contactId) }, - { label: 'Project name present', ready: Boolean(enquiry.projectName?.trim()) }, - { label: 'Product type present', ready: Boolean(enquiry.productType) }, - { label: 'Requirement present', ready: Boolean(enquiry.requirement?.trim()) }, - { label: 'Estimated value present', ready: enquiry.estimatedValue !== null } + { label: 'Customer selected', ready: Boolean(opportunity.customerId) }, + { label: 'Contact selected', ready: Boolean(opportunity.contactId) }, + { label: 'Project name present', ready: Boolean(opportunity.projectName?.trim()) }, + { label: 'Product type present', ready: Boolean(opportunity.productType) }, + { label: 'Requirement present', ready: Boolean(opportunity.requirement?.trim()) }, + { label: 'Estimated value present', ready: opportunity.estimatedValue !== null } ]; const readyCount = checks.filter((item) => item.ready).length; @@ -44,3 +44,4 @@ export function QuotationReadinessPanel({ enquiry }: { enquiry: EnquiryRecord }) ); } + diff --git a/src/features/crm/enquiries/components/source-lead-card.tsx b/src/features/crm/opportunities/components/source-lead-card.tsx similarity index 100% rename from src/features/crm/enquiries/components/source-lead-card.tsx rename to src/features/crm/opportunities/components/source-lead-card.tsx diff --git a/src/features/crm/enquiries/schemas/enquiry.schema.ts b/src/features/crm/opportunities/schemas/opportunity.schema.ts similarity index 81% rename from src/features/crm/enquiries/schemas/enquiry.schema.ts rename to src/features/crm/opportunities/schemas/opportunity.schema.ts index 7ec5820..de25664 100644 --- a/src/features/crm/enquiries/schemas/enquiry.schema.ts +++ b/src/features/crm/opportunities/schemas/opportunity.schema.ts @@ -24,7 +24,7 @@ function coerceOptionalNumber(message: string) { }, z.number().refine((value) => !Number.isNaN(value), message).optional()); } -export const enquirySchema = z.object({ +export const opportunitySchema = z.object({ customerId: z.string().min(1, 'Please select a customer'), contactId: z.string().optional().nullable(), assignedToUserId: z.string().optional().nullable(), @@ -63,7 +63,7 @@ export const enquirySchema = z.object({ .optional() }); -export const enquiryFollowupSchema = z.object({ +export const opportunityFollowupSchema = z.object({ followupDate: z.string().min(1, 'Follow-up date is required'), followupType: z.string().min(1, 'Please select a follow-up type'), contactId: z.string().optional().nullable(), @@ -73,7 +73,7 @@ export const enquiryFollowupSchema = z.object({ nextAction: z.string().optional() }); -export const enquiryMarkWonSchema = z.object({ +export const opportunityMarkWonSchema = z.object({ poNumber: z.string().min(1, 'PO number is required'), poDate: z.string().min(1, 'PO date is required'), poAmount: coerceOptionalNumber('PO amount must be a number').refine( @@ -84,18 +84,19 @@ export const enquiryMarkWonSchema = z.object({ remark: z.string().optional().nullable() }); -export const enquiryMarkLostSchema = z.object({ +export const opportunityMarkLostSchema = z.object({ lostReason: z.string().min(1, 'Lost reason is required'), lostCompetitor: z.string().optional().nullable(), lostRemark: z.string().optional().nullable() }); -export const enquiryReopenSchema = z.object({ +export const opportunityReopenSchema = z.object({ reopenReason: z.string().min(3, 'Reopen reason is required') }); -export type EnquiryFormValues = z.input; -export type EnquiryFollowupFormValues = z.input; -export type EnquiryMarkWonFormValues = z.input; -export type EnquiryMarkLostFormValues = z.input; -export type EnquiryReopenFormValues = z.input; +export type OpportunityFormValues = z.input; +export type OpportunityFollowupFormValues = z.input; +export type OpportunityMarkWonFormValues = z.input; +export type OpportunityMarkLostFormValues = z.input; +export type OpportunityReopenFormValues = z.input; + diff --git a/src/features/crm/enquiries/server/service.ts b/src/features/crm/opportunities/server/service.ts similarity index 67% rename from src/features/crm/enquiries/server/service.ts rename to src/features/crm/opportunities/server/service.ts index 8a3bf68..2497028 100644 --- a/src/features/crm/enquiries/server/service.ts +++ b/src/features/crm/opportunities/server/service.ts @@ -2,10 +2,10 @@ import { and, asc, count, desc, eq, ilike, inArray, isNull, or, type SQL } from import { crmCustomerContacts, crmCustomers, - crmEnquiries, - crmEnquiryAttachments, - crmEnquiryCustomers, - crmEnquiryFollowups, + crmOpportunities, + crmOpportunityAttachments, + crmOpportunityCustomers, + crmOpportunityFollowups, crmQuotations, memberships, users @@ -19,27 +19,27 @@ import { generateNextDocumentCode } from '@/features/foundation/document-sequenc import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service'; import { buildOrganizationStorageKey, getStorageProvider } from '@/features/foundation/storage/service'; import type { - EnquiryActivityRecord, - EnquiryAssignableUserLookup, - EnquiryAttachmentRecord, - EnquiryBranchOption, - EnquiryAssignmentMutationPayload, - EnquiryCustomerRelationItem, - EnquiryFilters, - EnquiryFollowupMutationPayload, - EnquiryFollowupRecord, - EnquiryListItem, - EnquiryMarkLostPayload, - EnquiryMarkWonPayload, - EnquiryMutationPayload, - EnquiryOption, - EnquiryProjectPartyListItem, - EnquiryProjectPartyMutationPayload, - EnquiryProjectPartyRecord, - EnquiryPipelineStage, - EnquiryReopenPayload, - EnquiryRecord, - EnquiryReferenceData + OpportunityActivityRecord, + OpportunityAssignableUserLookup, + OpportunityAttachmentRecord, + OpportunityBranchOption, + OpportunityAssignmentMutationPayload, + OpportunityCustomerRelationItem, + OpportunityFilters, + OpportunityFollowupMutationPayload, + OpportunityFollowupRecord, + OpportunityListItem, + OpportunityMarkLostPayload, + OpportunityMarkWonPayload, + OpportunityMutationPayload, + OpportunityOption, + OpportunityProjectPartyListItem, + OpportunityProjectPartyMutationPayload, + OpportunityProjectPartyRecord, + OpportunityPipelineStage, + OpportunityReopenPayload, + OpportunityRecord, + OpportunityReferenceData } from '../api/types'; import { buildProjectPartyKey, @@ -49,7 +49,7 @@ import { import { PERMISSIONS } from '@/lib/auth/rbac'; const ENQUIRY_OPTION_CATEGORIES = { - status: 'crm_enquiry_status', + status: 'crm_opportunity_status', productType: 'crm_product_type', priority: 'crm_priority', leadChannel: 'crm_lead_channel', @@ -76,7 +76,7 @@ const ALLOWED_PO_CONTENT_TYPES = new Set([ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ]); -export interface EnquiryAccessContext { +export interface OpportunityAccessContext { organizationId: string; userId: string; membershipRole: string; @@ -91,7 +91,7 @@ export interface EnquiryAccessContext { function mapOption( option: Awaited>[number] -): EnquiryOption { +): OpportunityOption { return { id: option.id, code: option.code, @@ -102,7 +102,7 @@ function mapOption( function mapBranch( branch: Awaited>[number] -): EnquiryBranchOption { +): OpportunityBranchOption { return { id: branch.id, code: branch.code, @@ -111,8 +111,8 @@ function mapBranch( }; } -function mapEnquiryRecord( - row: typeof crmEnquiries.$inferSelect, +function mapOpportunityRecord( + row: typeof crmOpportunities.$inferSelect, options?: { assignedToName?: string | null; assignedByName?: string | null; @@ -120,7 +120,7 @@ function mapEnquiryRecord( lostReason?: { code: string; label: string } | null; canViewCommercialData?: boolean; } -): EnquiryRecord { +): OpportunityRecord { return { id: row.id, organizationId: row.organizationId, @@ -146,7 +146,7 @@ function mapEnquiryRecord( notes: row.notes, isHotProject: row.isHotProject, isActive: row.isActive, - pipelineStage: row.pipelineStage as EnquiryPipelineStage, + pipelineStage: row.pipelineStage as OpportunityPipelineStage, closedWonAt: row.closedWonAt?.toISOString() ?? null, closedLostAt: row.closedLostAt?.toISOString() ?? null, closedByUserId: row.closedByUserId, @@ -174,11 +174,11 @@ function mapEnquiryRecord( }; } -function mapAttachmentRecord(row: typeof crmEnquiryAttachments.$inferSelect): EnquiryAttachmentRecord { +function mapAttachmentRecord(row: typeof crmOpportunityAttachments.$inferSelect): OpportunityAttachmentRecord { return { id: row.id, organizationId: row.organizationId, - enquiryId: row.enquiryId, + opportunityId: row.opportunityId, category: row.category, fileName: row.fileName, originalFileName: row.originalFileName, @@ -195,7 +195,7 @@ function mapAttachmentRecord(row: typeof crmEnquiryAttachments.$inferSelect): En }; } -function canViewOutcomeCommercialData(context?: EnquiryAccessContext) { +function canViewOutcomeCommercialData(context?: OpportunityAccessContext) { if (!context) { return true; } @@ -206,13 +206,13 @@ function canViewOutcomeCommercialData(context?: EnquiryAccessContext) { ); } -function hasEnquiryFullAccess(context: EnquiryAccessContext) { +function hasOpportunityFullAccess(context: OpportunityAccessContext) { return context.membershipRole === 'admin' || context.ownershipScope !== 'own'; } -function canAccessEnquiryRow( - row: typeof crmEnquiries.$inferSelect, - context: EnquiryAccessContext +function canAccessOpportunityRow( + row: typeof crmOpportunities.$inferSelect, + context: OpportunityAccessContext ) { if (!hasBranchScopeAccess(context, row.branchId)) { return false; @@ -222,7 +222,7 @@ function canAccessEnquiryRow( return false; } - if (hasEnquiryFullAccess(context)) { + if (hasOpportunityFullAccess(context)) { return true; } @@ -233,31 +233,31 @@ function canAccessEnquiryRow( return true; } -function buildAccessScopedFilters(context: EnquiryAccessContext): SQL[] { +function buildAccessScopedFilters(context: OpportunityAccessContext): SQL[] { const filters: SQL[] = []; if (context.branchScopeMode === 'assigned' && context.branchScopeIds.length > 0) { - filters.push(inArray(crmEnquiries.branchId, context.branchScopeIds)); + filters.push(inArray(crmOpportunities.branchId, context.branchScopeIds)); } if (context.productScopeMode === 'assigned' && context.productTypeScopeIds.length > 0) { - filters.push(inArray(crmEnquiries.productType, context.productTypeScopeIds)); + filters.push(inArray(crmOpportunities.productType, context.productTypeScopeIds)); } - if (!hasEnquiryFullAccess(context) && context.ownershipScope === 'own') { + if (!hasOpportunityFullAccess(context) && context.ownershipScope === 'own') { filters.push( - or(eq(crmEnquiries.createdBy, context.userId), eq(crmEnquiries.assignedToUserId, context.userId))! + or(eq(crmOpportunities.createdBy, context.userId), eq(crmOpportunities.assignedToUserId, context.userId))! ); } return filters; } -function mapFollowupRecord(row: typeof crmEnquiryFollowups.$inferSelect): EnquiryFollowupRecord { +function mapFollowupRecord(row: typeof crmOpportunityFollowups.$inferSelect): OpportunityFollowupRecord { return { id: row.id, organizationId: row.organizationId, - enquiryId: row.enquiryId, + opportunityId: row.opportunityId, followupDate: row.followupDate.toISOString(), followupType: row.followupType, contactId: row.contactId, @@ -274,13 +274,13 @@ function mapFollowupRecord(row: typeof crmEnquiryFollowups.$inferSelect): Enquir } function mapProjectPartyRecord( - row: typeof crmEnquiryCustomers.$inferSelect, + row: typeof crmOpportunityCustomers.$inferSelect, options: { role: { id: string; code: string; label: string } } -): EnquiryProjectPartyRecord { +): OpportunityProjectPartyRecord { return { id: row.id, organizationId: row.organizationId, - enquiryId: row.enquiryId, + opportunityId: row.opportunityId, customerId: row.customerId, role: options.role.id, roleCode: options.role.code, @@ -294,7 +294,7 @@ function mapProjectPartyRecord( function parseSort(sort?: string) { if (!sort) { - return desc(crmEnquiries.updatedAt); + return desc(crmOpportunities.updatedAt); } try { @@ -302,29 +302,29 @@ function parseSort(sort?: string) { const [rule] = parsed; if (!rule) { - return desc(crmEnquiries.updatedAt); + return desc(crmOpportunities.updatedAt); } const sortMap = { - code: crmEnquiries.code, - title: crmEnquiries.title, - status: crmEnquiries.status, - priority: crmEnquiries.priority, - productType: crmEnquiries.productType, - branch: crmEnquiries.branchId, - customer: crmEnquiries.customerId, - updatedAt: crmEnquiries.updatedAt + code: crmOpportunities.code, + title: crmOpportunities.title, + status: crmOpportunities.status, + priority: crmOpportunities.priority, + productType: crmOpportunities.productType, + branch: crmOpportunities.branchId, + customer: crmOpportunities.customerId, + updatedAt: crmOpportunities.updatedAt } as const; const column = sortMap[rule.id as keyof typeof sortMap]; if (!column) { - return desc(crmEnquiries.updatedAt); + return desc(crmOpportunities.updatedAt); } return rule.desc ? desc(column) : asc(column); } catch { - return desc(crmEnquiries.updatedAt); + return desc(crmOpportunities.updatedAt); } } @@ -416,7 +416,7 @@ export async function assertContactBelongsToOrganization( return contact; } -async function listAssignableUsers(organizationId: string): Promise { +async function listAssignableUsers(organizationId: string): Promise { const rows = await db .select({ userId: memberships.userId, @@ -435,7 +435,7 @@ async function listAssignableUsers(organizationId: string): Promise row.membershipRole === 'admin' || ASSIGNABLE_BUSINESS_ROLES.has(row.businessRole) ) - .map((row) => ({ + .map((row) => ({ id: row.userId, name: row.name, membershipRole: row.membershipRole === 'admin' ? 'admin' : 'user', @@ -468,48 +468,48 @@ export async function assertAssignableUserBelongsToOrganization( return membership; } -async function assertEnquiryBelongsToOrganization( +async function assertOpportunityBelongsToOrganization( id: string, organizationId: string, - accessContext?: EnquiryAccessContext + accessContext?: OpportunityAccessContext ) { - const [enquiry] = await db + const [opportunity] = await db .select() - .from(crmEnquiries) + .from(crmOpportunities) .where( and( - eq(crmEnquiries.id, id), - eq(crmEnquiries.organizationId, organizationId), - isNull(crmEnquiries.deletedAt) + eq(crmOpportunities.id, id), + eq(crmOpportunities.organizationId, organizationId), + isNull(crmOpportunities.deletedAt) ) ) .limit(1); - if (!enquiry) { - throw new AuthError('Enquiry not found', 404); + if (!opportunity) { + throw new AuthError('Opportunity not found', 404); } - if (accessContext && !canAccessEnquiryRow(enquiry, accessContext)) { + if (accessContext && !canAccessOpportunityRow(opportunity, accessContext)) { throw new AuthError('Forbidden', 403); } - return enquiry; + return opportunity; } -async function assertFollowupBelongsToEnquiry( +async function assertFollowupBelongsToOpportunity( followupId: string, - enquiryId: string, + opportunityId: string, organizationId: string ) { const [followup] = await db .select() - .from(crmEnquiryFollowups) + .from(crmOpportunityFollowups) .where( and( - eq(crmEnquiryFollowups.id, followupId), - eq(crmEnquiryFollowups.enquiryId, enquiryId), - eq(crmEnquiryFollowups.organizationId, organizationId), - isNull(crmEnquiryFollowups.deletedAt) + eq(crmOpportunityFollowups.id, followupId), + eq(crmOpportunityFollowups.opportunityId, opportunityId), + eq(crmOpportunityFollowups.organizationId, organizationId), + isNull(crmOpportunityFollowups.deletedAt) ) ) .limit(1); @@ -521,10 +521,10 @@ async function assertFollowupBelongsToEnquiry( return followup; } -async function validateEnquiryPayload( +async function validateOpportunityPayload( organizationId: string, - payload: EnquiryMutationPayload, - accessContext?: EnquiryAccessContext + payload: OpportunityMutationPayload, + accessContext?: OpportunityAccessContext ) { await assertCustomerBelongsToOrganization(payload.customerId, organizationId); @@ -577,12 +577,12 @@ async function validateEnquiryPayload( async function validateFollowupPayload( organizationId: string, - enquiryId: string, - payload: EnquiryFollowupMutationPayload, - accessContext?: EnquiryAccessContext + opportunityId: string, + payload: OpportunityFollowupMutationPayload, + accessContext?: OpportunityAccessContext ) { - const enquiry = await assertEnquiryBelongsToOrganization( - enquiryId, + const opportunity = await assertOpportunityBelongsToOrganization( + opportunityId, organizationId, accessContext ); @@ -594,11 +594,11 @@ async function validateFollowupPayload( ); if (payload.contactId) { - await assertContactBelongsToOrganization(payload.contactId, organizationId, enquiry.customerId); + await assertContactBelongsToOrganization(payload.contactId, organizationId, opportunity.customerId); } } -function buildEnquiryFilters(context: EnquiryAccessContext, filters: EnquiryFilters): SQL[] { +function buildOpportunityFilters(context: OpportunityAccessContext, filters: OpportunityFilters): SQL[] { const pipelineStages = splitFilterValue(filters.pipelineStage); const statuses = splitFilterValue(filters.status); const productTypes = splitFilterValue(filters.productType); @@ -607,55 +607,55 @@ function buildEnquiryFilters(context: EnquiryAccessContext, filters: EnquiryFilt const customers = splitFilterValue(filters.customer); return [ - eq(crmEnquiries.organizationId, context.organizationId), - isNull(crmEnquiries.deletedAt), + eq(crmOpportunities.organizationId, context.organizationId), + isNull(crmOpportunities.deletedAt), ...buildAccessScopedFilters(context), - ...(pipelineStages.length ? [inArray(crmEnquiries.pipelineStage, pipelineStages)] : []), - ...(statuses.length ? [inArray(crmEnquiries.status, statuses)] : []), - ...(productTypes.length ? [inArray(crmEnquiries.productType, productTypes)] : []), - ...(priorities.length ? [inArray(crmEnquiries.priority, priorities)] : []), - ...(branches.length ? [inArray(crmEnquiries.branchId, branches)] : []), - ...(customers.length ? [inArray(crmEnquiries.customerId, customers)] : []), + ...(pipelineStages.length ? [inArray(crmOpportunities.pipelineStage, pipelineStages)] : []), + ...(statuses.length ? [inArray(crmOpportunities.status, statuses)] : []), + ...(productTypes.length ? [inArray(crmOpportunities.productType, productTypes)] : []), + ...(priorities.length ? [inArray(crmOpportunities.priority, priorities)] : []), + ...(branches.length ? [inArray(crmOpportunities.branchId, branches)] : []), + ...(customers.length ? [inArray(crmOpportunities.customerId, customers)] : []), ...(filters.search ? [ or( - ilike(crmEnquiries.code, `%${filters.search}%`), - ilike(crmEnquiries.title, `%${filters.search}%`), - ilike(crmEnquiries.projectName, `%${filters.search}%`), - ilike(crmEnquiries.competitor, `%${filters.search}%`) + ilike(crmOpportunities.code, `%${filters.search}%`), + ilike(crmOpportunities.title, `%${filters.search}%`), + ilike(crmOpportunities.projectName, `%${filters.search}%`), + ilike(crmOpportunities.competitor, `%${filters.search}%`) )! ] : []) ]; } -function derivePipelineStageFromRole(businessRole: string): EnquiryPipelineStage { - return SALES_CREATED_ENQUIRY_ROLES.has(businessRole) ? 'enquiry' : 'lead'; +function derivePipelineStageFromRole(businessRole: string): OpportunityPipelineStage { + return SALES_CREATED_ENQUIRY_ROLES.has(businessRole) ? 'opportunity' : 'lead'; } async function resolvePipelineStageForCreate( businessRole: string, -): Promise { +): Promise { return derivePipelineStageFromRole(businessRole); } async function resolvePipelineStageForUpdate( - current: typeof crmEnquiries.$inferSelect, -): Promise { + current: typeof crmOpportunities.$inferSelect, +): Promise { if (current.pipelineStage === 'closed_won' || current.pipelineStage === 'closed_lost') { - return current.pipelineStage as EnquiryPipelineStage; + return current.pipelineStage as OpportunityPipelineStage; } - if (current.pipelineStage === 'enquiry' || current.assignedToUserId) { - return 'enquiry'; + if (current.pipelineStage === 'opportunity' || current.assignedToUserId) { + return 'opportunity'; } return 'lead'; } -export async function getEnquiryReferenceData( +export async function getOpportunityReferenceData( organizationId: string -): Promise { +): Promise { const [ branches, statuses, @@ -738,20 +738,20 @@ export async function getEnquiryReferenceData( }; } -export async function listEnquiries( - context: EnquiryAccessContext, - filters: EnquiryFilters -): Promise<{ items: EnquiryListItem[]; totalItems: number }> { +export async function listOpportunities( + context: OpportunityAccessContext, + filters: OpportunityFilters +): Promise<{ items: OpportunityListItem[]; totalItems: number }> { const page = filters.page ?? 1; const limit = filters.limit ?? 10; - const whereFilters = buildEnquiryFilters(context, filters); + const whereFilters = buildOpportunityFilters(context, filters); const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters); const offset = (page - 1) * limit; - const [totalResult] = await db.select({ value: count() }).from(crmEnquiries).where(where); + const [totalResult] = await db.select({ value: count() }).from(crmOpportunities).where(where); const rows = await db .select() - .from(crmEnquiries) + .from(crmOpportunities) .where(where) .orderBy(parseSort(filters.sort)) .limit(limit) @@ -763,7 +763,7 @@ export async function listEnquiries( ...new Set(rows.map((row) => row.assignedToUserId).filter(Boolean) as string[]) ]; const createdByUserIds = [...new Set(rows.map((row) => row.createdBy).filter(Boolean) as string[])]; - const enquiryIds = rows.map((row) => row.id); + const opportunityIds = rows.map((row) => row.id); const [customers, contacts, assignedUsers, createdByUsers, followupCounts, quotationCounts, nextFollowups] = await Promise.all([ customerIds.length @@ -800,53 +800,53 @@ export async function listEnquiries( .from(users) .where(inArray(users.id, createdByUserIds)) : [], - enquiryIds.length + opportunityIds.length ? db .select({ - enquiryId: crmEnquiryFollowups.enquiryId, + opportunityId: crmOpportunityFollowups.opportunityId, value: count() }) - .from(crmEnquiryFollowups) + .from(crmOpportunityFollowups) .where( and( - eq(crmEnquiryFollowups.organizationId, context.organizationId), - inArray(crmEnquiryFollowups.enquiryId, enquiryIds), - isNull(crmEnquiryFollowups.deletedAt) + eq(crmOpportunityFollowups.organizationId, context.organizationId), + inArray(crmOpportunityFollowups.opportunityId, opportunityIds), + isNull(crmOpportunityFollowups.deletedAt) ) ) - .groupBy(crmEnquiryFollowups.enquiryId) + .groupBy(crmOpportunityFollowups.opportunityId) : [], - enquiryIds.length + opportunityIds.length ? db .select({ - enquiryId: crmQuotations.enquiryId, + opportunityId: crmQuotations.opportunityId, value: count() }) .from(crmQuotations) .where( and( eq(crmQuotations.organizationId, context.organizationId), - inArray(crmQuotations.enquiryId, enquiryIds), + inArray(crmQuotations.opportunityId, opportunityIds), isNull(crmQuotations.deletedAt) ) ) - .groupBy(crmQuotations.enquiryId) + .groupBy(crmQuotations.opportunityId) : [], - enquiryIds.length + opportunityIds.length ? db .select({ - enquiryId: crmEnquiryFollowups.enquiryId, - nextFollowupDate: crmEnquiryFollowups.nextFollowupDate + opportunityId: crmOpportunityFollowups.opportunityId, + nextFollowupDate: crmOpportunityFollowups.nextFollowupDate }) - .from(crmEnquiryFollowups) + .from(crmOpportunityFollowups) .where( and( - eq(crmEnquiryFollowups.organizationId, context.organizationId), - inArray(crmEnquiryFollowups.enquiryId, enquiryIds), - isNull(crmEnquiryFollowups.deletedAt) + eq(crmOpportunityFollowups.organizationId, context.organizationId), + inArray(crmOpportunityFollowups.opportunityId, opportunityIds), + isNull(crmOpportunityFollowups.deletedAt) ) ) - .orderBy(asc(crmEnquiryFollowups.nextFollowupDate)) + .orderBy(asc(crmOpportunityFollowups.nextFollowupDate)) : [] ]); @@ -854,11 +854,11 @@ export async function listEnquiries( const contactMap = new Map(contacts.map((contact) => [contact.id, contact.name])); const assignedUserMap = new Map(assignedUsers.map((user) => [user.id, user.name])); const createdByUserMap = new Map(createdByUsers.map((user) => [user.id, user.name])); - const followupCountMap = new Map(followupCounts.map((item) => [item.enquiryId, item.value])); + const followupCountMap = new Map(followupCounts.map((item) => [item.opportunityId, item.value])); const quotationCountMap = new Map( quotationCounts - .filter((item) => item.enquiryId !== null) - .map((item) => [item.enquiryId as string, item.value]) + .filter((item) => item.opportunityId !== null) + .map((item) => [item.opportunityId as string, item.value]) ); const nextFollowupMap = new Map(); @@ -867,14 +867,14 @@ export async function listEnquiries( continue; } - if (!nextFollowupMap.has(item.enquiryId)) { - nextFollowupMap.set(item.enquiryId, item.nextFollowupDate.toISOString()); + if (!nextFollowupMap.has(item.opportunityId)) { + nextFollowupMap.set(item.opportunityId, item.nextFollowupDate.toISOString()); } } return { items: rows.map((row) => ({ - ...mapEnquiryRecord(row, { + ...mapOpportunityRecord(row, { assignedToName: row.assignedToUserId ? (assignedUserMap.get(row.assignedToUserId) ?? null) : null @@ -890,16 +890,16 @@ export async function listEnquiries( }; } -export async function getEnquiryDetail( +export async function getOpportunityDetail( id: string, organizationId: string, - accessContext?: EnquiryAccessContext -): Promise { - const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext); + accessContext?: OpportunityAccessContext +): Promise { + const opportunity = await assertOpportunityBelongsToOrganization(id, organizationId, accessContext); const relatedUserIds = [ - enquiry.assignedToUserId, - enquiry.assignedBy, - enquiry.closedByUserId + opportunity.assignedToUserId, + opportunity.assignedBy, + opportunity.closedByUserId ].filter(Boolean) as string[]; const [userRows, lostReasonOptions] = await Promise.all([ relatedUserIds.length @@ -909,15 +909,15 @@ export async function getEnquiryDetail( ]); const userMap = new Map(userRows.map((row) => [row.id, row.name])); const lostReasonOption = - lostReasonOptions.find((option) => option.id === enquiry.lostReason || option.code === enquiry.lostReason) ?? + lostReasonOptions.find((option) => option.id === opportunity.lostReason || option.code === opportunity.lostReason) ?? null; - return mapEnquiryRecord(enquiry, { - assignedToName: enquiry.assignedToUserId - ? (userMap.get(enquiry.assignedToUserId) ?? null) + return mapOpportunityRecord(opportunity, { + assignedToName: opportunity.assignedToUserId + ? (userMap.get(opportunity.assignedToUserId) ?? null) : null, - assignedByName: enquiry.assignedBy ? (userMap.get(enquiry.assignedBy) ?? null) : null, - closedByName: enquiry.closedByUserId ? (userMap.get(enquiry.closedByUserId) ?? null) : null, + assignedByName: opportunity.assignedBy ? (userMap.get(opportunity.assignedBy) ?? null) : null, + closedByName: opportunity.closedByUserId ? (userMap.get(opportunity.closedByUserId) ?? null) : null, lostReason: lostReasonOption ? { code: lostReasonOption.code, label: lostReasonOption.label } : null, @@ -928,7 +928,7 @@ export async function getEnquiryDetail( async function buildPreparedProjectParties( organizationId: string, billingCustomerId: string, - projectParties: EnquiryProjectPartyMutationPayload[] = [] + projectParties: OpportunityProjectPartyMutationPayload[] = [] ) { const roleOptions = await getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.projectPartyRole, { organizationId @@ -977,12 +977,12 @@ async function buildPreparedProjectParties( return [...prepared.values()]; } -async function syncEnquiryProjectParties( +async function syncOpportunityProjectParties( tx: Pick, organizationId: string, - enquiryId: string, + opportunityId: string, billingCustomerId: string, - payload: EnquiryProjectPartyMutationPayload[] + payload: OpportunityProjectPartyMutationPayload[] ) { const now = new Date(); const preparedParties = await buildPreparedProjectParties( @@ -992,13 +992,13 @@ async function syncEnquiryProjectParties( ); await tx - .update(crmEnquiryCustomers) + .update(crmOpportunityCustomers) .set({ deletedAt: now, updatedAt: now }) .where( and( - eq(crmEnquiryCustomers.organizationId, organizationId), - eq(crmEnquiryCustomers.enquiryId, enquiryId), - isNull(crmEnquiryCustomers.deletedAt) + eq(crmOpportunityCustomers.organizationId, organizationId), + eq(crmOpportunityCustomers.opportunityId, opportunityId), + isNull(crmOpportunityCustomers.deletedAt) ) ); @@ -1006,11 +1006,11 @@ async function syncEnquiryProjectParties( return; } - await tx.insert(crmEnquiryCustomers).values( + await tx.insert(crmOpportunityCustomers).values( preparedParties.map((item) => ({ id: crypto.randomUUID(), organizationId, - enquiryId, + opportunityId, customerId: item.customerId, role: item.roleId, remark: item.remark @@ -1018,24 +1018,24 @@ async function syncEnquiryProjectParties( ); } -export async function listEnquiryProjectParties( +export async function listOpportunityProjectParties( id: string, organizationId: string, - accessContext?: EnquiryAccessContext -): Promise { - await assertEnquiryBelongsToOrganization(id, organizationId, accessContext); + accessContext?: OpportunityAccessContext +): Promise { + await assertOpportunityBelongsToOrganization(id, organizationId, accessContext); const [relations, customers, roleOptions] = await Promise.all([ db .select() - .from(crmEnquiryCustomers) + .from(crmOpportunityCustomers) .where( and( - eq(crmEnquiryCustomers.enquiryId, id), - eq(crmEnquiryCustomers.organizationId, organizationId), - isNull(crmEnquiryCustomers.deletedAt) + eq(crmOpportunityCustomers.opportunityId, id), + eq(crmOpportunityCustomers.organizationId, organizationId), + isNull(crmOpportunityCustomers.deletedAt) ) ) - .orderBy(asc(crmEnquiryCustomers.createdAt)), + .orderBy(asc(crmOpportunityCustomers.createdAt)), db .select() .from(crmCustomers) @@ -1057,17 +1057,17 @@ export async function listEnquiryProjectParties( ...mapProjectPartyRecord(row, { role: resolvedRole }), customerName: customerMap.get(row.customerId)?.name ?? 'Unknown customer', customerCode: customerMap.get(row.customerId)?.code ?? '-' - } satisfies EnquiryProjectPartyListItem; + } satisfies OpportunityProjectPartyListItem; }) - .filter((item): item is EnquiryProjectPartyListItem => item !== null); + .filter((item): item is OpportunityProjectPartyListItem => item !== null); } -export async function getEnquiryActivity( +export async function getOpportunityActivity( id: string, organizationId: string, - accessContext?: EnquiryAccessContext -): Promise { - await assertEnquiryBelongsToOrganization(id, organizationId, accessContext); + accessContext?: OpportunityAccessContext +): Promise { + await assertOpportunityBelongsToOrganization(id, organizationId, accessContext); const auditLogs = await listAuditLogs({ organizationId, entityId: id, @@ -1075,10 +1075,10 @@ export async function getEnquiryActivity( }); const filteredLogs = auditLogs.filter( (log) => - (log.entityType === 'crm_enquiry' && log.entityId === id) || - (log.entityType === 'crm_enquiry_followup' && - ((log.afterData as { enquiryId?: string } | null)?.enquiryId === id || - (log.beforeData as { enquiryId?: string } | null)?.enquiryId === id)) + (log.entityType === 'crm_opportunity' && log.entityId === id) || + (log.entityType === 'crm_opportunity_followup' && + ((log.afterData as { opportunityId?: string } | null)?.opportunityId === id || + (log.beforeData as { opportunityId?: string } | null)?.opportunityId === id)) ); const userIds = [...new Set(filteredLogs.map((log) => log.userId))]; const actorRows = userIds.length @@ -1100,24 +1100,24 @@ export async function getEnquiryActivity( })); } -export async function createEnquiry( +export async function createOpportunity( organizationId: string, userId: string, - accessContext: EnquiryAccessContext, - payload: EnquiryMutationPayload + accessContext: OpportunityAccessContext, + payload: OpportunityMutationPayload ) { - await validateEnquiryPayload(organizationId, payload, accessContext); + await validateOpportunityPayload(organizationId, payload, accessContext); const pipelineStage = await resolvePipelineStageForCreate(accessContext.businessRole); const documentCode = await generateNextDocumentCode({ organizationId, - documentType: 'enquiry', + documentType: 'opportunity', branchId: payload.branchId ?? '' }); return db.transaction(async (tx) => { const [created] = await tx - .insert(crmEnquiries) + .insert(crmOpportunities) .values({ id: crypto.randomUUID(), organizationId, @@ -1152,7 +1152,7 @@ export async function createEnquiry( }) .returning(); - await syncEnquiryProjectParties( + await syncOpportunityProjectParties( tx, organizationId, created.id, @@ -1164,20 +1164,20 @@ export async function createEnquiry( }); } -export async function updateEnquiry( +export async function updateOpportunity( id: string, organizationId: string, userId: string, - payload: EnquiryMutationPayload, - accessContext?: EnquiryAccessContext + payload: OpportunityMutationPayload, + accessContext?: OpportunityAccessContext ) { - await validateEnquiryPayload(organizationId, payload, accessContext); - const current = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext); + await validateOpportunityPayload(organizationId, payload, accessContext); + const current = await assertOpportunityBelongsToOrganization(id, organizationId, accessContext); const pipelineStage = await resolvePipelineStageForUpdate(current); return db.transaction(async (tx) => { const [updated] = await tx - .update(crmEnquiries) + .update(crmOpportunities) .set({ branchId: payload.branchId ?? null, customerId: payload.customerId, @@ -1203,10 +1203,10 @@ export async function updateEnquiry( updatedBy: userId, updatedAt: new Date() }) - .where(eq(crmEnquiries.id, id)) + .where(eq(crmOpportunities.id, id)) .returning(); - await syncEnquiryProjectParties( + await syncOpportunityProjectParties( tx, organizationId, id, @@ -1218,28 +1218,28 @@ export async function updateEnquiry( }); } -export async function softDeleteEnquiry( +export async function softDeleteOpportunity( id: string, organizationId: string, userId: string, - accessContext?: EnquiryAccessContext + accessContext?: OpportunityAccessContext ) { - await assertEnquiryBelongsToOrganization(id, organizationId, accessContext); + await assertOpportunityBelongsToOrganization(id, organizationId, accessContext); const now = new Date(); const [updated] = await db - .update(crmEnquiries) + .update(crmOpportunities) .set({ deletedAt: now, updatedAt: now, updatedBy: userId, isActive: false }) - .where(eq(crmEnquiries.id, id)) + .where(eq(crmOpportunities.id, id)) .returning(); await db - .update(crmEnquiryFollowups) + .update(crmOpportunityFollowups) .set({ deletedAt: now, updatedAt: now, @@ -1247,52 +1247,52 @@ export async function softDeleteEnquiry( }) .where( and( - eq(crmEnquiryFollowups.enquiryId, id), - eq(crmEnquiryFollowups.organizationId, organizationId) + eq(crmOpportunityFollowups.opportunityId, id), + eq(crmOpportunityFollowups.organizationId, organizationId) ) ); await db - .update(crmEnquiryCustomers) + .update(crmOpportunityCustomers) .set({ deletedAt: now, updatedAt: now }) .where( and( - eq(crmEnquiryCustomers.enquiryId, id), - eq(crmEnquiryCustomers.organizationId, organizationId), - isNull(crmEnquiryCustomers.deletedAt) + eq(crmOpportunityCustomers.opportunityId, id), + eq(crmOpportunityCustomers.organizationId, organizationId), + isNull(crmOpportunityCustomers.deletedAt) ) ); return updated; } -async function updateEnquiryAssignment( +async function updateOpportunityAssignment( id: string, organizationId: string, userId: string, - payload: EnquiryAssignmentMutationPayload, + payload: OpportunityAssignmentMutationPayload, mode: 'assign' | 'reassign', - accessContext?: EnquiryAccessContext + accessContext?: OpportunityAccessContext ) { - const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext); + const opportunity = await assertOpportunityBelongsToOrganization(id, organizationId, accessContext); - if (mode === 'assign' && enquiry.assignedToUserId) { - throw new AuthError('Enquiry is already assigned. Use reassign instead.', 400); + if (mode === 'assign' && opportunity.assignedToUserId) { + throw new AuthError('Opportunity is already assigned. Use reassign instead.', 400); } - if (mode === 'reassign' && !enquiry.assignedToUserId) { - throw new AuthError('Enquiry is not assigned yet. Use assign instead.', 400); + if (mode === 'reassign' && !opportunity.assignedToUserId) { + throw new AuthError('Opportunity is not assigned yet. Use assign instead.', 400); } await assertAssignableUserBelongsToOrganization(payload.assignedToUserId, organizationId); const [updated] = await db - .update(crmEnquiries) + .update(crmOpportunities) .set({ - pipelineStage: 'enquiry', + pipelineStage: 'opportunity', assignedToUserId: payload.assignedToUserId, assignedAt: new Date(), assignedBy: userId, @@ -1300,68 +1300,68 @@ async function updateEnquiryAssignment( updatedAt: new Date(), updatedBy: userId }) - .where(eq(crmEnquiries.id, id)) + .where(eq(crmOpportunities.id, id)) .returning(); return updated; } -export async function assignEnquiryToUser( +export async function assignOpportunityToUser( id: string, organizationId: string, userId: string, - payload: EnquiryAssignmentMutationPayload, - accessContext?: EnquiryAccessContext + payload: OpportunityAssignmentMutationPayload, + accessContext?: OpportunityAccessContext ) { - return updateEnquiryAssignment(id, organizationId, userId, payload, 'assign', accessContext); + return updateOpportunityAssignment(id, organizationId, userId, payload, 'assign', accessContext); } -export async function reassignEnquiryToUser( +export async function reassignOpportunityToUser( id: string, organizationId: string, userId: string, - payload: EnquiryAssignmentMutationPayload, - accessContext?: EnquiryAccessContext + payload: OpportunityAssignmentMutationPayload, + accessContext?: OpportunityAccessContext ) { - return updateEnquiryAssignment(id, organizationId, userId, payload, 'reassign', accessContext); + return updateOpportunityAssignment(id, organizationId, userId, payload, 'reassign', accessContext); } -export async function listEnquiryFollowups( +export async function listOpportunityFollowups( id: string, organizationId: string, - accessContext?: EnquiryAccessContext + accessContext?: OpportunityAccessContext ) { - await assertEnquiryBelongsToOrganization(id, organizationId, accessContext); + await assertOpportunityBelongsToOrganization(id, organizationId, accessContext); const rows = await db .select() - .from(crmEnquiryFollowups) + .from(crmOpportunityFollowups) .where( and( - eq(crmEnquiryFollowups.enquiryId, id), - eq(crmEnquiryFollowups.organizationId, organizationId), - isNull(crmEnquiryFollowups.deletedAt) + eq(crmOpportunityFollowups.opportunityId, id), + eq(crmOpportunityFollowups.organizationId, organizationId), + isNull(crmOpportunityFollowups.deletedAt) ) ) - .orderBy(desc(crmEnquiryFollowups.followupDate)); + .orderBy(desc(crmOpportunityFollowups.followupDate)); return rows.map(mapFollowupRecord); } -export async function createEnquiryFollowup( - enquiryId: string, +export async function createOpportunityFollowup( + opportunityId: string, organizationId: string, userId: string, - payload: EnquiryFollowupMutationPayload, - accessContext?: EnquiryAccessContext + payload: OpportunityFollowupMutationPayload, + accessContext?: OpportunityAccessContext ) { - await validateFollowupPayload(organizationId, enquiryId, payload, accessContext); + await validateFollowupPayload(organizationId, opportunityId, payload, accessContext); const [created] = await db - .insert(crmEnquiryFollowups) + .insert(crmOpportunityFollowups) .values({ id: crypto.randomUUID(), organizationId, - enquiryId, + opportunityId, followupDate: new Date(payload.followupDate), followupType: payload.followupType, contactId: payload.contactId ?? null, @@ -1377,19 +1377,19 @@ export async function createEnquiryFollowup( return created; } -export async function updateEnquiryFollowup( - enquiryId: string, +export async function updateOpportunityFollowup( + opportunityId: string, followupId: string, organizationId: string, userId: string, - payload: EnquiryFollowupMutationPayload, - accessContext?: EnquiryAccessContext + payload: OpportunityFollowupMutationPayload, + accessContext?: OpportunityAccessContext ) { - await validateFollowupPayload(organizationId, enquiryId, payload, accessContext); - await assertFollowupBelongsToEnquiry(followupId, enquiryId, organizationId); + await validateFollowupPayload(organizationId, opportunityId, payload, accessContext); + await assertFollowupBelongsToOpportunity(followupId, opportunityId, organizationId); const [updated] = await db - .update(crmEnquiryFollowups) + .update(crmOpportunityFollowups) .set({ followupDate: new Date(payload.followupDate), followupType: payload.followupType, @@ -1401,53 +1401,53 @@ export async function updateEnquiryFollowup( updatedAt: new Date(), updatedBy: userId }) - .where(eq(crmEnquiryFollowups.id, followupId)) + .where(eq(crmOpportunityFollowups.id, followupId)) .returning(); return updated; } -export async function softDeleteEnquiryFollowup( - enquiryId: string, +export async function softDeleteOpportunityFollowup( + opportunityId: string, followupId: string, organizationId: string, userId: string, - accessContext?: EnquiryAccessContext + accessContext?: OpportunityAccessContext ) { - await assertEnquiryBelongsToOrganization(enquiryId, organizationId, accessContext); - await assertFollowupBelongsToEnquiry(followupId, enquiryId, organizationId); + await assertOpportunityBelongsToOrganization(opportunityId, organizationId, accessContext); + await assertFollowupBelongsToOpportunity(followupId, opportunityId, organizationId); const [updated] = await db - .update(crmEnquiryFollowups) + .update(crmOpportunityFollowups) .set({ deletedAt: new Date(), updatedAt: new Date(), updatedBy: userId }) - .where(eq(crmEnquiryFollowups.id, followupId)) + .where(eq(crmOpportunityFollowups.id, followupId)) .returning(); return updated; } -function ensureEnquiryStageForOutcome( - enquiry: typeof crmEnquiries.$inferSelect, - expectedStage: EnquiryPipelineStage +function ensureOpportunityStageForOutcome( + opportunity: typeof crmOpportunities.$inferSelect, + expectedStage: OpportunityPipelineStage ) { - if (enquiry.pipelineStage !== expectedStage) { - throw new AuthError(`Enquiry must be in ${expectedStage} stage`, 400); + if (opportunity.pipelineStage !== expectedStage) { + throw new AuthError(`Opportunity must be in ${expectedStage} stage`, 400); } } -export async function markEnquiryAsWon( +export async function markOpportunityAsWon( id: string, organizationId: string, userId: string, - payload: EnquiryMarkWonPayload, - accessContext?: EnquiryAccessContext + payload: OpportunityMarkWonPayload, + accessContext?: OpportunityAccessContext ) { - const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext); - ensureEnquiryStageForOutcome(enquiry, 'enquiry'); + const opportunity = await assertOpportunityBelongsToOrganization(id, organizationId, accessContext); + ensureOpportunityStageForOutcome(opportunity, 'opportunity'); if (!payload.poNumber.trim()) { throw new AuthError('PO number is required', 400); @@ -1455,7 +1455,7 @@ export async function markEnquiryAsWon( const now = new Date(); const [updated] = await db - .update(crmEnquiries) + .update(crmOpportunities) .set({ pipelineStage: 'closed_won', closedWonAt: now, @@ -1471,14 +1471,14 @@ export async function markEnquiryAsWon( updatedAt: now, updatedBy: userId }) - .where(eq(crmEnquiries.id, id)) + .where(eq(crmOpportunities.id, id)) .returning(); await auditAction({ organizationId, branchId: updated.branchId, userId, - entityType: 'crm_enquiry', + entityType: 'crm_opportunity', entityId: id, action: 'mark_won', afterData: { @@ -1494,15 +1494,15 @@ export async function markEnquiryAsWon( return updated; } -export async function markEnquiryAsLost( +export async function markOpportunityAsLost( id: string, organizationId: string, userId: string, - payload: EnquiryMarkLostPayload, - accessContext?: EnquiryAccessContext + payload: OpportunityMarkLostPayload, + accessContext?: OpportunityAccessContext ) { - const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext); - ensureEnquiryStageForOutcome(enquiry, 'enquiry'); + const opportunity = await assertOpportunityBelongsToOrganization(id, organizationId, accessContext); + ensureOpportunityStageForOutcome(opportunity, 'opportunity'); await assertMasterOptionValue(organizationId, ENQUIRY_OPTION_CATEGORIES.lostReason, payload.lostReason); const lostReasonCode = @@ -1510,7 +1510,7 @@ export async function markEnquiryAsLost( payload.lostReason; const now = new Date(); const [updated] = await db - .update(crmEnquiries) + .update(crmOpportunities) .set({ pipelineStage: 'closed_lost', closedWonAt: null, @@ -1526,14 +1526,14 @@ export async function markEnquiryAsLost( updatedAt: now, updatedBy: userId }) - .where(eq(crmEnquiries.id, id)) + .where(eq(crmOpportunities.id, id)) .returning(); await auditAction({ organizationId, branchId: updated.branchId, userId, - entityType: 'crm_enquiry', + entityType: 'crm_opportunity', entityId: id, action: 'mark_lost', afterData: { @@ -1547,25 +1547,25 @@ export async function markEnquiryAsLost( return updated; } -export async function reopenLostEnquiry( +export async function reopenLostOpportunity( id: string, organizationId: string, userId: string, - payload: EnquiryReopenPayload, - accessContext?: EnquiryAccessContext + payload: OpportunityReopenPayload, + accessContext?: OpportunityAccessContext ) { - const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext); + const opportunity = await assertOpportunityBelongsToOrganization(id, organizationId, accessContext); - if (enquiry.pipelineStage === 'closed_won') { - throw new AuthError('Won enquiries cannot be reopened', 400); + if (opportunity.pipelineStage === 'closed_won') { + throw new AuthError('Won opportunities cannot be reopened', 400); } - ensureEnquiryStageForOutcome(enquiry, 'closed_lost'); + ensureOpportunityStageForOutcome(opportunity, 'closed_lost'); const now = new Date(); const [updated] = await db - .update(crmEnquiries) + .update(crmOpportunities) .set({ - pipelineStage: 'enquiry', + pipelineStage: 'opportunity', closedLostAt: null, closedByUserId: null, lostReason: null, @@ -1574,19 +1574,19 @@ export async function reopenLostEnquiry( updatedAt: now, updatedBy: userId }) - .where(eq(crmEnquiries.id, id)) + .where(eq(crmOpportunities.id, id)) .returning(); await auditAction({ organizationId, branchId: updated.branchId, userId, - entityType: 'crm_enquiry', + entityType: 'crm_opportunity', entityId: id, action: 'reopen', afterData: { reopenReason: payload.reopenReason.trim(), - previousStage: enquiry.pipelineStage, + previousStage: opportunity.pipelineStage, nextStage: updated.pipelineStage } }); @@ -1594,44 +1594,44 @@ export async function reopenLostEnquiry( return updated; } -export async function listEnquiryAttachments( - enquiryId: string, +export async function listOpportunityAttachments( + opportunityId: string, organizationId: string, - accessContext?: EnquiryAccessContext + accessContext?: OpportunityAccessContext ) { - await assertEnquiryBelongsToOrganization(enquiryId, organizationId, accessContext); + await assertOpportunityBelongsToOrganization(opportunityId, organizationId, accessContext); const rows = await db .select() - .from(crmEnquiryAttachments) + .from(crmOpportunityAttachments) .where( and( - eq(crmEnquiryAttachments.organizationId, organizationId), - eq(crmEnquiryAttachments.enquiryId, enquiryId), - eq(crmEnquiryAttachments.category, PURCHASE_ORDER_ATTACHMENT_CATEGORY), - isNull(crmEnquiryAttachments.deletedAt) + eq(crmOpportunityAttachments.organizationId, organizationId), + eq(crmOpportunityAttachments.opportunityId, opportunityId), + eq(crmOpportunityAttachments.category, PURCHASE_ORDER_ATTACHMENT_CATEGORY), + isNull(crmOpportunityAttachments.deletedAt) ) ) - .orderBy(desc(crmEnquiryAttachments.uploadedAt)); + .orderBy(desc(crmOpportunityAttachments.uploadedAt)); return rows.map(mapAttachmentRecord); } -export async function getEnquiryAttachment( - enquiryId: string, +export async function getOpportunityAttachment( + opportunityId: string, attachmentId: string, organizationId: string, - accessContext?: EnquiryAccessContext + accessContext?: OpportunityAccessContext ) { - await assertEnquiryBelongsToOrganization(enquiryId, organizationId, accessContext); + await assertOpportunityBelongsToOrganization(opportunityId, organizationId, accessContext); const [attachment] = await db .select() - .from(crmEnquiryAttachments) + .from(crmOpportunityAttachments) .where( and( - eq(crmEnquiryAttachments.id, attachmentId), - eq(crmEnquiryAttachments.organizationId, organizationId), - eq(crmEnquiryAttachments.enquiryId, enquiryId), - isNull(crmEnquiryAttachments.deletedAt) + eq(crmOpportunityAttachments.id, attachmentId), + eq(crmOpportunityAttachments.organizationId, organizationId), + eq(crmOpportunityAttachments.opportunityId, opportunityId), + isNull(crmOpportunityAttachments.deletedAt) ) ) .limit(1); @@ -1644,7 +1644,7 @@ export async function getEnquiryAttachment( } export async function uploadPurchaseOrderAttachment(input: { - enquiryId: string; + opportunityId: string; organizationId: string; userId: string; file: { @@ -1654,16 +1654,16 @@ export async function uploadPurchaseOrderAttachment(input: { buffer: Buffer; }; description?: string | null; - accessContext?: EnquiryAccessContext; + accessContext?: OpportunityAccessContext; }) { - const enquiry = await assertEnquiryBelongsToOrganization( - input.enquiryId, + const opportunity = await assertOpportunityBelongsToOrganization( + input.opportunityId, input.organizationId, input.accessContext ); - if (enquiry.pipelineStage !== 'closed_won') { - throw new AuthError('PO attachment is available only for won enquiries', 400); + if (opportunity.pipelineStage !== 'closed_won') { + throw new AuthError('PO attachment is available only for won opportunities', 400); } if (!ALLOWED_PO_CONTENT_TYPES.has(input.file.type)) { @@ -1673,8 +1673,8 @@ export async function uploadPurchaseOrderAttachment(input: { const storageProvider = getStorageProvider(); const safeFileName = input.file.name.replace(/[^a-zA-Z0-9._-]/g, '-'); const storageKey = buildOrganizationStorageKey(input.organizationId, [ - 'enquiries', - input.enquiryId, + 'opportunities', + input.opportunityId, 'purchase-orders', `${Date.now()}-${safeFileName}` ]); @@ -1685,11 +1685,11 @@ export async function uploadPurchaseOrderAttachment(input: { fileName: safeFileName }); const [created] = await db - .insert(crmEnquiryAttachments) + .insert(crmOpportunityAttachments) .values({ id: crypto.randomUUID(), organizationId: input.organizationId, - enquiryId: input.enquiryId, + opportunityId: input.opportunityId, category: PURCHASE_ORDER_ATTACHMENT_CATEGORY, fileName: safeFileName, originalFileName: input.file.name, @@ -1704,14 +1704,14 @@ export async function uploadPurchaseOrderAttachment(input: { await auditAction({ organizationId: input.organizationId, - branchId: enquiry.branchId, + branchId: opportunity.branchId, userId: input.userId, - entityType: 'crm_enquiry', - entityId: input.enquiryId, + entityType: 'crm_opportunity', + entityId: input.opportunityId, action: 'upload_po', afterData: { attachmentId: created.id, - poNumber: enquiry.poNumber, + poNumber: opportunity.poNumber, fileName: created.fileName, fileType: created.fileType } @@ -1720,26 +1720,26 @@ export async function uploadPurchaseOrderAttachment(input: { return created; } -export async function listCustomerEnquiryRelations( +export async function listCustomerOpportunityRelations( customerId: string, organizationId: string, - accessContext?: EnquiryAccessContext -): Promise { + accessContext?: OpportunityAccessContext +): Promise { const rows = await db .select() - .from(crmEnquiries) + .from(crmOpportunities) .where( and( - eq(crmEnquiries.customerId, customerId), - eq(crmEnquiries.organizationId, organizationId), - isNull(crmEnquiries.deletedAt) + eq(crmOpportunities.customerId, customerId), + eq(crmOpportunities.organizationId, organizationId), + isNull(crmOpportunities.deletedAt) ) ) - .orderBy(desc(crmEnquiries.updatedAt)) + .orderBy(desc(crmOpportunities.updatedAt)) .limit(10); return rows - .filter((row) => !accessContext || canAccessEnquiryRow(row, accessContext)) + .filter((row) => !accessContext || canAccessOpportunityRow(row, accessContext)) .map((row) => ({ id: row.id, code: row.code, @@ -1750,12 +1750,13 @@ export async function listCustomerEnquiryRelations( })); } -export async function syncEnquiryPipelineStageFromQuotationStatus( - enquiryId: string, +export async function syncOpportunityPipelineStageFromQuotationStatus( + opportunityId: string, organizationId: string, quotationStatusCode: string | null ) { - void enquiryId; + void opportunityId; void organizationId; void quotationStatusCode; } + diff --git a/src/features/crm/quotations/api/service.ts b/src/features/crm/quotations/api/service.ts index caff593..9e48d61 100644 --- a/src/features/crm/quotations/api/service.ts +++ b/src/features/crm/quotations/api/service.ts @@ -28,7 +28,7 @@ export async function getQuotations(filters: QuotationFilters): Promise row.enquiryId ?? '', + id: 'opportunity', + accessorFn: (row) => row.opportunityId ?? '', header: ({ column }: { column: Column }) => ( ), - cell: ({ row }) => row.original.enquiryCode ?? '-', + cell: ({ row }) => row.original.opportunityCode ?? '-', meta: { label: 'โอกาสขาย', variant: 'multiSelect' as const, - options: referenceData.enquiries.map((item) => ({ value: item.id, label: `${item.code} - ${item.title}` })) + options: referenceData.opportunities.map((item) => ({ value: item.id, label: `${item.code} - ${item.title}` })) }, enableColumnFilter: true }, @@ -165,3 +165,4 @@ export function getQuotationColumns({ } ]; } + diff --git a/src/features/crm/quotations/components/quotation-form-sheet.tsx b/src/features/crm/quotations/components/quotation-form-sheet.tsx index 45bfc69..09445f6 100644 --- a/src/features/crm/quotations/components/quotation-form-sheet.tsx +++ b/src/features/crm/quotations/components/quotation-form-sheet.tsx @@ -39,7 +39,7 @@ import { createQuotationMutation, updateQuotationMutation } from '../api/mutatio import type { QuotationMutationPayload, QuotationRecord, QuotationReferenceData } from '../api/types'; type FormState = { - enquiryId: string; + opportunityId: string; customerId: string; contactId: string; quotationDate: string; @@ -71,7 +71,7 @@ function toFormState( referenceData: QuotationReferenceData ): FormState { return { - enquiryId: quotation?.enquiryId ?? '', + opportunityId: quotation?.opportunityId ?? '', customerId: quotation?.customerId ?? referenceData.customers[0]?.id ?? '', contactId: quotation?.contactId ?? '', quotationDate: @@ -190,16 +190,16 @@ export function QuotationFormSheet({ setState((current) => ({ ...current, [key]: value })); } - function handleEnquiryChange(value: string) { - const enquiry = referenceData.enquiries.find((item) => item.id === value); + function handleOpportunityChange(value: string) { + const opportunity = referenceData.opportunities.find((item) => item.id === value); setState((current) => ({ ...current, - enquiryId: value === '__none__' ? '' : value, - customerId: enquiry?.customerId ?? current.customerId, - contactId: enquiry?.contactId ?? '', - projectName: enquiry?.projectName ?? current.projectName, - projectLocation: enquiry?.projectLocation ?? current.projectLocation, - branchId: enquiry?.branchId ?? current.branchId + opportunityId: value === '__none__' ? '' : value, + customerId: opportunity?.customerId ?? current.customerId, + contactId: opportunity?.contactId ?? '', + projectName: opportunity?.projectName ?? current.projectName, + projectLocation: opportunity?.projectLocation ?? current.projectLocation, + branchId: opportunity?.branchId ?? current.branchId })); } @@ -207,7 +207,7 @@ export function QuotationFormSheet({ event.preventDefault(); const payload: QuotationMutationPayload = { - enquiryId: state.enquiryId || null, + opportunityId: state.opportunityId || null, customerId: state.customerId, contactId: state.contactId || null, quotationDate: state.quotationDate, @@ -263,14 +263,14 @@ export function QuotationFormSheet({
- - - + - No linked enquiry - {referenceData.enquiries.map((item) => ( + No linked opportunity + {referenceData.opportunities.map((item) => ( {item.code} - {item.title} @@ -541,3 +541,4 @@ export function QuotationFormSheetTrigger({ ); } + diff --git a/src/features/crm/quotations/components/quotation-listing.tsx b/src/features/crm/quotations/components/quotation-listing.tsx index 7668a02..34c701c 100644 --- a/src/features/crm/quotations/components/quotation-listing.tsx +++ b/src/features/crm/quotations/components/quotation-listing.tsx @@ -21,7 +21,7 @@ export default function QuotationListing({ const quotationType = searchParamsCache.get('quotationType'); const branch = searchParamsCache.get('branch'); const customer = searchParamsCache.get('customer'); - const enquiry = searchParamsCache.get('enquiry'); + const opportunity = searchParamsCache.get('opportunity'); const hot = searchParamsCache.get('hot'); const sort = searchParamsCache.get('sort'); const filters = { @@ -32,7 +32,7 @@ export default function QuotationListing({ ...(quotationType && { quotationType }), ...(branch && { branch }), ...(customer && { customer }), - ...(enquiry && { enquiry }), + ...(opportunity && { opportunity }), ...(hot && { hot }), ...(sort && { sort }) }; @@ -46,3 +46,4 @@ export default function QuotationListing({ ); } + diff --git a/src/features/crm/quotations/components/quotations-table.tsx b/src/features/crm/quotations/components/quotations-table.tsx index 0c09a96..25f6afb 100644 --- a/src/features/crm/quotations/components/quotations-table.tsx +++ b/src/features/crm/quotations/components/quotations-table.tsx @@ -33,7 +33,7 @@ export function QuotationsTable({ quotationType: parseAsString, branch: parseAsString, customer: parseAsString, - enquiry: parseAsString, + opportunity: parseAsString, hot: parseAsString, sort: getSortingStateParser(columnIds).withDefault([]) }); @@ -46,7 +46,7 @@ export function QuotationsTable({ ...(params.quotationType && { quotationType: params.quotationType }), ...(params.branch && { branch: params.branch }), ...(params.customer && { customer: params.customer }), - ...(params.enquiry && { enquiry: params.enquiry }), + ...(params.opportunity && { opportunity: params.opportunity }), ...(params.hot && { hot: params.hot }), ...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) }) }; @@ -70,3 +70,4 @@ export function QuotationsTable({ ); } + diff --git a/src/features/crm/quotations/schemas/quotation.schema.ts b/src/features/crm/quotations/schemas/quotation.schema.ts index cef274c..a7aaec0 100644 --- a/src/features/crm/quotations/schemas/quotation.schema.ts +++ b/src/features/crm/quotations/schemas/quotation.schema.ts @@ -39,7 +39,7 @@ function coerceRequiredNumber(message: string) { } export const quotationSchema = z.object({ - enquiryId: z.string().optional().nullable(), + opportunityId: z.string().optional().nullable(), customerId: z.string().min(1, 'Please select a customer'), contactId: z.string().optional().nullable(), quotationDate: z.string().min(1, 'Quotation date is required'), @@ -159,3 +159,4 @@ export type QuotationCustomerFormValues = z.input; export type QuotationFollowupFormValues = z.input; export type QuotationAttachmentFormValues = z.input; + diff --git a/src/features/crm/quotations/server/service.ts b/src/features/crm/quotations/server/service.ts index 3a6cd61..4289831 100644 --- a/src/features/crm/quotations/server/service.ts +++ b/src/features/crm/quotations/server/service.ts @@ -3,7 +3,7 @@ import { crmDocumentArtifacts, crmCustomerContacts, crmCustomers, - crmEnquiries, + crmOpportunities, crmQuotationAttachments, crmQuotationCustomers, crmQuotationFollowups, @@ -25,7 +25,7 @@ import { } from '@/features/crm/security/server/service'; import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service'; import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service'; -import { syncEnquiryPipelineStageFromQuotationStatus } from '@/features/crm/enquiries/server/service'; +import { syncOpportunityPipelineStageFromQuotationStatus } from '@/features/crm/opportunities/server/service'; import type { QuotationActivityRecord, QuotationAttachmentMutationPayload, @@ -37,7 +37,7 @@ import type { QuotationCustomerLookup, QuotationCustomerMutationPayload, QuotationCustomerRecord, - QuotationEnquiryLookup, + QuotationOpportunityLookup, QuotationFilters, QuotationFollowupMutationPayload, QuotationFollowupRecord, @@ -128,7 +128,7 @@ function mapQuotationRecord( organizationId: row.organizationId, branchId: row.branchId, code: row.code, - enquiryId: row.enquiryId, + opportunityId: row.opportunityId, customerId: row.customerId, contactId: row.contactId, quotationDate: row.quotationDate.toISOString(), @@ -437,24 +437,24 @@ export async function assertContactBelongsToOrganization( return contact; } -async function assertEnquiryBelongsToOrganization(id: string, organizationId: string) { - const [enquiry] = await db +async function assertOpportunityBelongsToOrganization(id: string, organizationId: string) { + const [opportunity] = await db .select() - .from(crmEnquiries) + .from(crmOpportunities) .where( and( - eq(crmEnquiries.id, id), - eq(crmEnquiries.organizationId, organizationId), - isNull(crmEnquiries.deletedAt) + eq(crmOpportunities.id, id), + eq(crmOpportunities.organizationId, organizationId), + isNull(crmOpportunities.deletedAt) ) ) .limit(1); - if (!enquiry) { - throw new AuthError('Enquiry not found', 404); + if (!opportunity) { + throw new AuthError('Opportunity not found', 404); } - return enquiry; + return opportunity; } async function assertQuotationBelongsToOrganization( @@ -665,11 +665,11 @@ async function validateQuotationPayload( await assertContactBelongsToOrganization(payload.contactId, organizationId, payload.customerId); } - if (payload.enquiryId) { - const enquiry = await assertEnquiryBelongsToOrganization(payload.enquiryId, organizationId); + if (payload.opportunityId) { + const opportunity = await assertOpportunityBelongsToOrganization(payload.opportunityId, organizationId); - if (enquiry.customerId !== payload.customerId) { - throw new AuthError('Selected enquiry belongs to a different customer', 400); + if (opportunity.customerId !== payload.customerId) { + throw new AuthError('Selected opportunity belongs to a different customer', 400); } } @@ -941,7 +941,7 @@ function buildQuotationFilters( const quotationTypes = splitFilterValue(filters.quotationType); const branches = splitFilterValue(filters.branch); const customers = splitFilterValue(filters.customer); - const enquiries = splitFilterValue(filters.enquiry); + const opportunities = splitFilterValue(filters.opportunity); return [ eq(crmQuotations.organizationId, organizationId), @@ -951,7 +951,7 @@ function buildQuotationFilters( ...(quotationTypes.length ? [inArray(crmQuotations.quotationType, quotationTypes)] : []), ...(branches.length ? [inArray(crmQuotations.branchId, branches)] : []), ...(customers.length ? [inArray(crmQuotations.customerId, customers)] : []), - ...(enquiries.length ? [inArray(crmQuotations.enquiryId, enquiries)] : []), + ...(opportunities.length ? [inArray(crmQuotations.opportunityId, opportunities)] : []), ...(filters.hot === 'true' ? [eq(crmQuotations.isHotProject, true)] : []), ...(filters.hot === 'false' ? [eq(crmQuotations.isHotProject, false)] : []), ...(filters.search @@ -1035,7 +1035,7 @@ export async function getQuotationReferenceData( productTypes, customers, contacts, - enquiries, + opportunities, salesmen ] = await Promise.all([ getUserBranches(), @@ -1066,9 +1066,9 @@ export async function getQuotationReferenceData( .orderBy(desc(crmCustomerContacts.isPrimary), asc(crmCustomerContacts.name)), db .select() - .from(crmEnquiries) - .where(and(eq(crmEnquiries.organizationId, organizationId), isNull(crmEnquiries.deletedAt))) - .orderBy(desc(crmEnquiries.updatedAt)), + .from(crmOpportunities) + .where(and(eq(crmOpportunities.organizationId, organizationId), isNull(crmOpportunities.deletedAt))) + .orderBy(desc(crmOpportunities.updatedAt)), db .select({ id: users.id, name: users.name }) .from(memberships) @@ -1103,15 +1103,15 @@ export async function getQuotationReferenceData( mobile: contact.mobile, isPrimary: contact.isPrimary })), - enquiries: enquiries.map((enquiry) => ({ - id: enquiry.id, - code: enquiry.code, - customerId: enquiry.customerId, - contactId: enquiry.contactId, - title: enquiry.title, - projectName: enquiry.projectName, - projectLocation: enquiry.projectLocation, - branchId: enquiry.branchId + opportunities: opportunities.map((opportunity) => ({ + id: opportunity.id, + code: opportunity.code, + customerId: opportunity.customerId, + contactId: opportunity.contactId, + title: opportunity.title, + projectName: opportunity.projectName, + projectLocation: opportunity.projectLocation, + branchId: opportunity.branchId })), salesmen: salesmen.map((salesman) => ({ id: salesman.id, @@ -1142,18 +1142,18 @@ export async function listQuotations( const customerIds = [...new Set(rows.map((row) => row.customerId))]; const contactIds = [...new Set(rows.map((row) => row.contactId).filter(Boolean))] as string[]; - const enquiryIds = [...new Set(rows.map((row) => row.enquiryId).filter(Boolean))] as string[]; + const opportunityIds = [...new Set(rows.map((row) => row.opportunityId).filter(Boolean))] as string[]; const quotationIds = rows.map((row) => row.id); - const [customers, contacts, enquiries, itemCounts] = await Promise.all([ + const [customers, contacts, opportunities, itemCounts] = await Promise.all([ customerIds.length ? db.select().from(crmCustomers).where(inArray(crmCustomers.id, customerIds)) : [], contactIds.length ? db.select().from(crmCustomerContacts).where(inArray(crmCustomerContacts.id, contactIds)) : [], - enquiryIds.length - ? db.select().from(crmEnquiries).where(inArray(crmEnquiries.id, enquiryIds)) + opportunityIds.length + ? db.select().from(crmOpportunities).where(inArray(crmOpportunities.id, opportunityIds)) : [], quotationIds.length ? db @@ -1172,7 +1172,7 @@ export async function listQuotations( const customerMap = new Map(customers.map((item) => [item.id, item])); const contactMap = new Map(contacts.map((item) => [item.id, item])); - const enquiryMap = new Map(enquiries.map((item) => [item.id, item])); + const opportunityMap = new Map(opportunities.map((item) => [item.id, item])); const itemCountMap = new Map(itemCounts.map((item) => [item.quotationId, item.value])); return { @@ -1180,7 +1180,7 @@ export async function listQuotations( ...mapQuotationRecord(row), customerName: customerMap.get(row.customerId)?.name ?? 'Unknown customer', contactName: row.contactId ? (contactMap.get(row.contactId)?.name ?? null) : null, - enquiryCode: row.enquiryId ? (enquiryMap.get(row.enquiryId)?.code ?? null) : null, + opportunityCode: row.opportunityId ? (opportunityMap.get(row.opportunityId)?.code ?? null) : null, itemCount: itemCountMap.get(row.id) ?? 0, revisionLabel: toRevisionLabel(row.revision) })), @@ -1289,13 +1289,13 @@ export async function createQuotation( payload.status ); - const enquiry = payload.enquiryId - ? await assertEnquiryBelongsToOrganization(payload.enquiryId, organizationId) + const opportunity = payload.opportunityId + ? await assertOpportunityBelongsToOrganization(payload.opportunityId, organizationId) : null; const documentCode = await generateNextDocumentCode({ organizationId, documentType: 'quotation', - branchId: payload.branchId ?? enquiry?.branchId ?? '' + branchId: payload.branchId ?? opportunity?.branchId ?? '' }); return db.transaction(async (tx) => { @@ -1304,16 +1304,16 @@ export async function createQuotation( .values({ id: crypto.randomUUID(), organizationId, - branchId: payload.branchId ?? enquiry?.branchId ?? null, + branchId: payload.branchId ?? opportunity?.branchId ?? null, code: documentCode.code, - enquiryId: payload.enquiryId ?? null, + opportunityId: payload.opportunityId ?? null, customerId: payload.customerId, - contactId: payload.contactId ?? enquiry?.contactId ?? null, + contactId: payload.contactId ?? opportunity?.contactId ?? null, quotationDate: new Date(payload.quotationDate), validUntil: payload.validUntil ? new Date(payload.validUntil) : null, quotationType: payload.quotationType, - projectName: payload.projectName?.trim() || enquiry?.projectName || null, - projectLocation: payload.projectLocation?.trim() || enquiry?.projectLocation || null, + projectName: payload.projectName?.trim() || opportunity?.projectName || null, + projectLocation: payload.projectLocation?.trim() || opportunity?.projectLocation || null, attention: payload.attention?.trim() || null, reference: payload.reference?.trim() || null, notes: payload.notes?.trim() || null, @@ -1325,9 +1325,9 @@ export async function createQuotation( discount: payload.discount ?? 0, discountType: payload.discountType ?? null, taxRate: payload.taxRate ?? 0, - chancePercent: payload.chancePercent ?? enquiry?.chancePercent ?? null, - isHotProject: payload.isHotProject ?? enquiry?.isHotProject ?? false, - competitor: payload.competitor?.trim() || enquiry?.competitor || null, + chancePercent: payload.chancePercent ?? opportunity?.chancePercent ?? null, + isHotProject: payload.isHotProject ?? opportunity?.isHotProject ?? false, + competitor: payload.competitor?.trim() || opportunity?.competitor || null, salesmanId: payload.salesmanId ?? null, isSent: false, sentVia: payload.sentVia ?? null, @@ -1350,9 +1350,9 @@ export async function createQuotation( payload.projectParties ?? [] ); - if (created.enquiryId) { - await syncEnquiryPipelineStageFromQuotationStatus( - created.enquiryId, + if (created.opportunityId) { + await syncOpportunityPipelineStageFromQuotationStatus( + created.opportunityId, organizationId, quotationStatusCode ); @@ -1376,23 +1376,23 @@ export async function updateQuotation( payload.status ); const existing = await assertQuotationBelongsToOrganization(id, organizationId, accessContext); - const enquiry = payload.enquiryId - ? await assertEnquiryBelongsToOrganization(payload.enquiryId, organizationId) + const opportunity = payload.opportunityId + ? await assertOpportunityBelongsToOrganization(payload.opportunityId, organizationId) : null; const updated = await db.transaction(async (tx) => { const [row] = await tx .update(crmQuotations) .set({ - branchId: payload.branchId ?? enquiry?.branchId ?? null, - enquiryId: payload.enquiryId ?? null, + branchId: payload.branchId ?? opportunity?.branchId ?? null, + opportunityId: payload.opportunityId ?? null, customerId: payload.customerId, - contactId: payload.contactId ?? enquiry?.contactId ?? null, + contactId: payload.contactId ?? opportunity?.contactId ?? null, quotationDate: new Date(payload.quotationDate), validUntil: payload.validUntil ? new Date(payload.validUntil) : null, quotationType: payload.quotationType, - projectName: payload.projectName?.trim() || enquiry?.projectName || null, - projectLocation: payload.projectLocation?.trim() || enquiry?.projectLocation || null, + projectName: payload.projectName?.trim() || opportunity?.projectName || null, + projectLocation: payload.projectLocation?.trim() || opportunity?.projectLocation || null, attention: payload.attention?.trim() || null, reference: payload.reference?.trim() || null, notes: payload.notes?.trim() || null, @@ -1403,9 +1403,9 @@ export async function updateQuotation( discount: payload.discount ?? 0, discountType: payload.discountType ?? null, taxRate: payload.taxRate ?? 0, - chancePercent: payload.chancePercent ?? enquiry?.chancePercent ?? null, + chancePercent: payload.chancePercent ?? opportunity?.chancePercent ?? null, isHotProject: payload.isHotProject ?? false, - competitor: payload.competitor?.trim() || enquiry?.competitor || null, + competitor: payload.competitor?.trim() || opportunity?.competitor || null, salesmanId: payload.salesmanId ?? null, sentVia: payload.sentVia ?? null, isActive: payload.isActive ?? true, @@ -1423,9 +1423,9 @@ export async function updateQuotation( payload.projectParties ?? [] ); - if (row.enquiryId) { - await syncEnquiryPipelineStageFromQuotationStatus( - row.enquiryId, + if (row.opportunityId) { + await syncOpportunityPipelineStageFromQuotationStatus( + row.opportunityId, organizationId, quotationStatusCode ); @@ -2160,7 +2160,7 @@ export async function createQuotationRevision( organizationId, branchId: parent.branchId, code: documentCode.code, - enquiryId: parent.enquiryId, + opportunityId: parent.opportunityId, customerId: parent.customerId, contactId: parent.contactId, quotationDate: new Date(), @@ -2291,8 +2291,8 @@ export async function listQuotationRevisions(id: string, organizationId: string) })); } -export async function listEnquiryQuotationRelations( - enquiryId: string, +export async function listOpportunityQuotationRelations( + opportunityId: string, organizationId: string ): Promise { const rows = await db @@ -2301,7 +2301,7 @@ export async function listEnquiryQuotationRelations( .where( and( eq(crmQuotations.organizationId, organizationId), - eq(crmQuotations.enquiryId, enquiryId), + eq(crmQuotations.opportunityId, opportunityId), isNull(crmQuotations.deletedAt) ) ) @@ -2345,3 +2345,4 @@ export async function listCustomerQuotationRelations( updatedAt: row.updatedAt.toISOString() })); } + diff --git a/src/features/crm/reporting/server/service.ts b/src/features/crm/reporting/server/service.ts index 3d58297..9969e07 100644 --- a/src/features/crm/reporting/server/service.ts +++ b/src/features/crm/reporting/server/service.ts @@ -14,8 +14,8 @@ import { } from 'drizzle-orm'; import { crmCustomers, - crmEnquiries, - crmEnquiryCustomers, + crmOpportunities, + crmOpportunityCustomers, crmQuotationCustomers, crmQuotations } from '@/db/schema'; @@ -41,7 +41,7 @@ interface NormalizedProjectParty { } type ProjectPartyTableAvailability = { - enquiryCustomers: boolean; + opportunityCustomers: boolean; quotationCustomers: boolean; }; @@ -62,7 +62,7 @@ function buildRevenueAttributionFilters( const quotationTypes = splitFilterValue(filters.quotationType); const branches = splitFilterValue(filters.branch); const customers = splitFilterValue(filters.customer); - const enquiries = splitFilterValue(filters.enquiry); + const opportunities = splitFilterValue(filters.opportunity); return [ eq(crmQuotations.organizationId, organizationId), @@ -75,7 +75,7 @@ function buildRevenueAttributionFilters( ...(quotationTypes.length ? [inArray(crmQuotations.quotationType, quotationTypes)] : []), ...(branches.length ? [inArray(crmQuotations.branchId, branches)] : []), ...(customers.length ? [inArray(crmQuotations.customerId, customers)] : []), - ...(enquiries.length ? [inArray(crmQuotations.enquiryId, enquiries)] : []), + ...(opportunities.length ? [inArray(crmQuotations.opportunityId, opportunities)] : []), ...(filters.hot === 'true' ? [eq(crmQuotations.isHotProject, true)] : []), ...(filters.hot === 'false' ? [eq(crmQuotations.isHotProject, false)] : []), ...(filters.quotationDateFrom ? [gte(crmQuotations.quotationDate, new Date(filters.quotationDateFrom))] : []), @@ -150,9 +150,9 @@ function groupNormalizedParties( function pickAttributedParties( roleCode: SupportedRevenueRole, quotationParties: NormalizedProjectParty[], - enquiryParties: NormalizedProjectParty[] + opportunityParties: NormalizedProjectParty[] ) { - const authoritativeParties = quotationParties.length > 0 ? quotationParties : enquiryParties; + const authoritativeParties = quotationParties.length > 0 ? quotationParties : opportunityParties; if (roleCode === 'end_customer') { const endCustomers = authoritativeParties.filter((party) => party.roleCode === 'end_customer'); @@ -176,13 +176,13 @@ async function getProjectPartyTableAvailability(): Promise row.tableName)); projectPartyTableAvailability = { - enquiryCustomers: available.has('crm_enquiry_customers'), + opportunityCustomers: available.has('crm_opportunity_customers'), quotationCustomers: available.has('crm_quotation_customers') }; @@ -201,7 +201,7 @@ async function getRevenueByRole( const quotations = await db .select({ id: crmQuotations.id, - enquiryId: crmQuotations.enquiryId, + opportunityId: crmQuotations.opportunityId, customerId: crmQuotations.customerId, totalAmount: crmQuotations.totalAmount }) @@ -214,9 +214,9 @@ async function getRevenueByRole( } const quotationIds = quotations.map((quotation) => quotation.id); - const enquiryIds = [...new Set(quotations.map((quotation) => quotation.enquiryId).filter(Boolean))] as string[]; + const opportunityIds = [...new Set(quotations.map((quotation) => quotation.opportunityId).filter(Boolean))] as string[]; - const [roleOptions, quotationParties, enquiryParties] = await Promise.all([ + const [roleOptions, quotationParties, opportunityParties] = await Promise.all([ getActiveOptionsByCategory(PROJECT_PARTY_OPTION_CATEGORY, { organizationId }), tableAvailability.quotationCustomers ? db @@ -234,19 +234,19 @@ async function getRevenueByRole( ) ) : [], - tableAvailability.enquiryCustomers && enquiryIds.length + tableAvailability.opportunityCustomers && opportunityIds.length ? db .select({ - enquiryId: crmEnquiryCustomers.enquiryId, - customerId: crmEnquiryCustomers.customerId, - role: crmEnquiryCustomers.role + opportunityId: crmOpportunityCustomers.opportunityId, + customerId: crmOpportunityCustomers.customerId, + role: crmOpportunityCustomers.role }) - .from(crmEnquiryCustomers) + .from(crmOpportunityCustomers) .where( and( - eq(crmEnquiryCustomers.organizationId, organizationId), - inArray(crmEnquiryCustomers.enquiryId, enquiryIds), - isNull(crmEnquiryCustomers.deletedAt) + eq(crmOpportunityCustomers.organizationId, organizationId), + inArray(crmOpportunityCustomers.opportunityId, opportunityIds), + isNull(crmOpportunityCustomers.deletedAt) ) ) : [] @@ -261,9 +261,9 @@ async function getRevenueByRole( roleOptions ); - const enquiryPartyMap = groupNormalizedParties( - enquiryParties.map((row) => ({ - groupKey: row.enquiryId, + const opportunityPartyMap = groupNormalizedParties( + opportunityParties.map((row) => ({ + groupKey: row.opportunityId, customerId: row.customerId, role: row.role })), @@ -275,12 +275,12 @@ async function getRevenueByRole( for (const quotation of quotations) { const quotationAttributedParties = quotationPartyMap.get(quotation.id) ?? []; - const enquiryAttributedParties = quotation.enquiryId - ? (enquiryPartyMap.get(quotation.enquiryId) ?? []) + const opportunityAttributedParties = quotation.opportunityId + ? (opportunityPartyMap.get(quotation.opportunityId) ?? []) : []; const attributedParties = - quotationAttributedParties.length > 0 || enquiryAttributedParties.length > 0 - ? pickAttributedParties(roleCode, quotationAttributedParties, enquiryAttributedParties) + quotationAttributedParties.length > 0 || opportunityAttributedParties.length > 0 + ? pickAttributedParties(roleCode, quotationAttributedParties, opportunityAttributedParties) : roleCode === 'end_customer' || roleCode === 'billing_customer' ? [{ customerId: quotation.customerId, roleCode }] : []; @@ -391,15 +391,15 @@ function buildOutcomeRevenueFilters( stage: 'closed_won' | 'closed_lost', filters: OutcomeRevenueFilters ): SQL[] { - const dateColumn = stage === 'closed_won' ? crmEnquiries.closedWonAt : crmEnquiries.closedLostAt; + const dateColumn = stage === 'closed_won' ? crmOpportunities.closedWonAt : crmOpportunities.closedLostAt; return [ - eq(crmEnquiries.organizationId, organizationId), - isNull(crmEnquiries.deletedAt), - eq(crmEnquiries.pipelineStage, stage), - ...(filters.branch ? [eq(crmEnquiries.branchId, filters.branch)] : []), - ...(filters.salesman ? [eq(crmEnquiries.assignedToUserId, filters.salesman)] : []), - ...(filters.productType ? [eq(crmEnquiries.productType, filters.productType)] : []), + eq(crmOpportunities.organizationId, organizationId), + isNull(crmOpportunities.deletedAt), + eq(crmOpportunities.pipelineStage, stage), + ...(filters.branch ? [eq(crmOpportunities.branchId, filters.branch)] : []), + ...(filters.salesman ? [eq(crmOpportunities.assignedToUserId, filters.salesman)] : []), + ...(filters.productType ? [eq(crmOpportunities.productType, filters.productType)] : []), ...(filters.dateFrom ? [gte(dateColumn, new Date(filters.dateFrom))] : []), ...(filters.dateTo ? [lte(dateColumn, new Date(filters.dateTo))] : []) ]; @@ -412,38 +412,38 @@ async function getOutcomeRevenueRecords( ): Promise { const whereFilters = buildOutcomeRevenueFilters(organizationId, stage, filters); const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters); - const enquiries = await db.select().from(crmEnquiries).where(where); + const opportunities = await db.select().from(crmOpportunities).where(where); - if (!enquiries.length) { + if (!opportunities.length) { return []; } - const enquiryIds = enquiries.map((row) => row.id); + const opportunityIds = opportunities.map((row) => row.id); const quotationRows = await db .select({ - enquiryId: crmQuotations.enquiryId, + opportunityId: crmQuotations.opportunityId, totalAmount: crmQuotations.totalAmount }) .from(crmQuotations) .where( and( eq(crmQuotations.organizationId, organizationId), - inArray(crmQuotations.enquiryId, enquiryIds), + inArray(crmQuotations.opportunityId, opportunityIds), isNull(crmQuotations.deletedAt) ) ); const quotationTotals = new Map(); for (const row of quotationRows) { - if (!row.enquiryId) { + if (!row.opportunityId) { continue; } - quotationTotals.set(row.enquiryId, (quotationTotals.get(row.enquiryId) ?? 0) + row.totalAmount); + quotationTotals.set(row.opportunityId, (quotationTotals.get(row.opportunityId) ?? 0) + row.totalAmount); } - return enquiries.map((row) => ({ - enquiryId: row.id, + return opportunities.map((row) => ({ + opportunityId: row.id, customerId: row.customerId, branchId: row.branchId, productType: row.productType, @@ -473,17 +473,17 @@ export async function getLostByReason( filters: OutcomeRevenueFilters = {} ): Promise { const records = await getLostRevenue(organizationId, filters); - const enquiryIds = records.map((row) => row.enquiryId); + const opportunityIds = records.map((row) => row.opportunityId); - if (!enquiryIds.length) { + if (!opportunityIds.length) { return []; } - const [enquiries, lostReasonOptions] = await Promise.all([ + const [opportunities, lostReasonOptions] = await Promise.all([ db - .select({ id: crmEnquiries.id, lostReason: crmEnquiries.lostReason }) - .from(crmEnquiries) - .where(inArray(crmEnquiries.id, enquiryIds)), + .select({ id: crmOpportunities.id, lostReason: crmOpportunities.lostReason }) + .from(crmOpportunities) + .where(inArray(crmOpportunities.id, opportunityIds)), getActiveOptionsByCategory('crm_lost_reason', { organizationId }) ]); const lostReasonMap = new Map( @@ -495,8 +495,8 @@ export async function getLostByReason( const grouped = new Map(); for (const row of records) { - const enquiry = enquiries.find((item) => item.id === row.enquiryId); - const key = enquiry?.lostReason ?? 'unknown'; + const opportunity = opportunities.find((item) => item.id === row.opportunityId); + const key = opportunity?.lostReason ?? 'unknown'; const current = grouped.get(key) ?? { key, label: lostReasonMap.get(key) ?? key, @@ -516,21 +516,21 @@ export async function getLostByCompetitor( filters: OutcomeRevenueFilters = {} ): Promise { const records = await getLostRevenue(organizationId, filters); - const enquiryIds = records.map((row) => row.enquiryId); + const opportunityIds = records.map((row) => row.opportunityId); - if (!enquiryIds.length) { + if (!opportunityIds.length) { return []; } - const enquiries = await db - .select({ id: crmEnquiries.id, lostCompetitor: crmEnquiries.lostCompetitor }) - .from(crmEnquiries) - .where(inArray(crmEnquiries.id, enquiryIds)); + const opportunities = await db + .select({ id: crmOpportunities.id, lostCompetitor: crmOpportunities.lostCompetitor }) + .from(crmOpportunities) + .where(inArray(crmOpportunities.id, opportunityIds)); const grouped = new Map(); for (const row of records) { - const enquiry = enquiries.find((item) => item.id === row.enquiryId); - const key = enquiry?.lostCompetitor?.trim() || 'unknown'; + const opportunity = opportunities.find((item) => item.id === row.opportunityId); + const key = opportunity?.lostCompetitor?.trim() || 'unknown'; const current = grouped.get(key) ?? { key, label: key, count: 0, revenue: 0 }; current.count += 1; current.revenue += row.revenue; @@ -556,3 +556,4 @@ export async function getWinRate( return Math.round((wonRows.length / denominator) * 10000) / 100; } + diff --git a/src/features/crm/reporting/server/types.ts b/src/features/crm/reporting/server/types.ts index 538007d..d7d5326 100644 --- a/src/features/crm/reporting/server/types.ts +++ b/src/features/crm/reporting/server/types.ts @@ -4,7 +4,7 @@ export interface RevenueAttributionFilters { quotationType?: string; branch?: string; customer?: string; - enquiry?: string; + opportunity?: string; hot?: string; quotationDateFrom?: string | null; quotationDateTo?: string | null; @@ -30,7 +30,7 @@ export interface OutcomeRevenueFilters { } export interface OutcomeRevenueRecord { - enquiryId: string; + opportunityId: string; customerId: string; branchId: string | null; productType: string; @@ -45,3 +45,4 @@ export interface OutcomeBreakdownRow { count: number; revenue: number; } + diff --git a/src/features/crm/reports/api/queries.ts b/src/features/crm/reports/api/queries.ts index 1bef3b9..5d30acb 100644 --- a/src/features/crm/reports/api/queries.ts +++ b/src/features/crm/reports/api/queries.ts @@ -1,7 +1,7 @@ import { queryOptions } from '@tanstack/react-query'; import type { CrmSharedReportFilters } from './types'; import { - getCrmEnquiryAgingReport, + getCrmOpportunityAgingReport, getCrmLeadAgingReport, getCrmPipelineReport, getCrmReportCatalog, @@ -18,8 +18,8 @@ export const crmReportKeys = { pipeline: (filters: CrmSharedReportFilters) => [...crmReportKeys.pipelineRoot(), filters] as const, leadAgingRoot: () => [...crmReportKeys.all, 'lead-aging'] as const, leadAging: (filters: CrmSharedReportFilters) => [...crmReportKeys.leadAgingRoot(), filters] as const, - enquiryAgingRoot: () => [...crmReportKeys.all, 'enquiry-aging'] as const, - enquiryAging: (filters: CrmSharedReportFilters) => [...crmReportKeys.enquiryAgingRoot(), filters] as const + opportunityAgingRoot: () => [...crmReportKeys.all, 'opportunity-aging'] as const, + opportunityAging: (filters: CrmSharedReportFilters) => [...crmReportKeys.opportunityAgingRoot(), filters] as const }; export const crmReportsQueryOptions = () => @@ -52,8 +52,9 @@ export const crmLeadAgingReportQueryOptions = (filters: CrmSharedReportFilters) queryFn: () => getCrmLeadAgingReport(filters) }); -export const crmEnquiryAgingReportQueryOptions = (filters: CrmSharedReportFilters) => +export const crmOpportunityAgingReportQueryOptions = (filters: CrmSharedReportFilters) => queryOptions({ - queryKey: crmReportKeys.enquiryAging(filters), - queryFn: () => getCrmEnquiryAgingReport(filters) + queryKey: crmReportKeys.opportunityAging(filters), + queryFn: () => getCrmOpportunityAgingReport(filters) }); + diff --git a/src/features/crm/reports/api/service.ts b/src/features/crm/reports/api/service.ts index e8e819e..47729df 100644 --- a/src/features/crm/reports/api/service.ts +++ b/src/features/crm/reports/api/service.ts @@ -6,7 +6,7 @@ import type { CrmSharedReportFilters, CrmPipelineReportResponse, CrmLeadAgingReportResponse, - CrmEnquiryAgingReportResponse + CrmOpportunityAgingReportResponse } from './types'; function buildReportQuery(filters: CrmSharedReportFilters) { @@ -51,9 +51,10 @@ export async function getCrmLeadAgingReport(filters: CrmSharedReportFilters) { ); } -export async function getCrmEnquiryAgingReport(filters: CrmSharedReportFilters) { +export async function getCrmOpportunityAgingReport(filters: CrmSharedReportFilters) { const query = buildReportQuery(filters); - return apiClient( - `/crm/reports/enquiry-aging${query ? `?${query}` : ''}` + return apiClient( + `/crm/reports/opportunity-aging${query ? `?${query}` : ''}` ); } + diff --git a/src/features/crm/reports/api/types.ts b/src/features/crm/reports/api/types.ts index bcdb25c..14bfd42 100644 --- a/src/features/crm/reports/api/types.ts +++ b/src/features/crm/reports/api/types.ts @@ -87,12 +87,12 @@ export interface CrmPipelineReportRow { convertedLeads: number; } -export interface CrmEnquiryPipelineReportRow { +export interface CrmOpportunityPipelineReportRow { salesmanName: string; branchLabel: string; productTypeLabel: string; customerOwnerName: string; - openEnquiries: number; + openOpportunities: number; convertedToQuotation: number; won: number; lost: number; @@ -104,21 +104,21 @@ export interface CrmLeadConversionReportRow { productTypeLabel: string; marketingUserName: string; totalLeads: number; - convertedEnquiries: number; + convertedOpportunities: number; conversionRate: number; } -export interface CrmEnquiryConversionReportRow { +export interface CrmOpportunityConversionReportRow { salesmanName: string; branchLabel: string; productTypeLabel: string; - totalEnquiries: number; - enquiriesWithQuotation: number; + totalOpportunities: number; + opportunitiesWithQuotation: number; conversionRate: number; } export interface CrmPipelineFunnelRow { - stageKey: 'lead' | 'enquiry' | 'quotation' | 'closed_won' | 'closed_lost'; + stageKey: 'lead' | 'opportunity' | 'quotation' | 'closed_won' | 'closed_lost'; stageLabel: string; count: number; value: number | null; @@ -127,7 +127,7 @@ export interface CrmPipelineFunnelRow { export interface CrmPipelineKpiSummary { totalLeads: number; - openEnquiries: number; + openOpportunities: number; quotations: number; won: number; lost: number; @@ -141,9 +141,9 @@ export interface CrmPipelineReportResponse { filters: Required; summary: CrmPipelineKpiSummary; leadPipeline: CrmPipelineReportRow[]; - enquiryPipeline: CrmEnquiryPipelineReportRow[]; + opportunityPipeline: CrmOpportunityPipelineReportRow[]; leadConversion: CrmLeadConversionReportRow[]; - enquiryConversion: CrmEnquiryConversionReportRow[]; + opportunityConversion: CrmOpportunityConversionReportRow[]; funnel: CrmPipelineFunnelRow[]; } @@ -176,25 +176,26 @@ export interface CrmLeadAgingReportResponse { rows: CrmLeadAgingRow[]; } -export interface CrmEnquiryAgingRow { - enquiryId: string; - enquiryCode: string; +export interface CrmOpportunityAgingRow { + opportunityId: string; + opportunityCode: string; customerName: string; salesmanName: string | null; lastFollowupDate: string | null; agingDays: number; } -export interface CrmEnquiryAgingReportResponse { +export interface CrmOpportunityAgingReportResponse { success: boolean; time: string; message: string; filters: Required; summary: { - openEnquiries: number; + openOpportunities: number; averageAgingDays: number; maximumAgingDays: number; }; buckets: CrmAgingBucket[]; - rows: CrmEnquiryAgingRow[]; + rows: CrmOpportunityAgingRow[]; } + diff --git a/src/features/crm/reports/components/aging-report-view.tsx b/src/features/crm/reports/components/aging-report-view.tsx index dbcce6e..bab1987 100644 --- a/src/features/crm/reports/components/aging-report-view.tsx +++ b/src/features/crm/reports/components/aging-report-view.tsx @@ -13,11 +13,11 @@ import { TableRow } from '@/components/ui/table'; import type { - CrmEnquiryAgingReportResponse, + CrmOpportunityAgingReportResponse, CrmLeadAgingReportResponse } from '../api/types'; import { - crmEnquiryAgingReportQueryOptions, + crmOpportunityAgingReportQueryOptions, crmLeadAgingReportQueryOptions, crmReportFiltersQueryOptions } from '../api/queries'; @@ -32,9 +32,9 @@ function AgingReportLayout({ canExport, data }: { - variant: 'lead' | 'enquiry'; + variant: 'lead' | 'opportunity'; canExport: boolean; - data: CrmLeadAgingReportResponse | CrmEnquiryAgingReportResponse; + data: CrmLeadAgingReportResponse | CrmOpportunityAgingReportResponse; }) { const { data: filterData } = useSuspenseQuery(crmReportFiltersQueryOptions()); @@ -47,8 +47,8 @@ function AgingReportLayout({ ] : [ [ - 'Open Enquiries', - formatNumber('openEnquiries' in data.summary ? data.summary.openEnquiries : 0) + 'Open Opportunities', + formatNumber('openOpportunities' in data.summary ? data.summary.openOpportunities : 0) ], ['Average Aging', `${data.summary.averageAgingDays} days`], ['Maximum Aging', `${data.summary.maximumAgingDays} days`] @@ -64,11 +64,11 @@ function AgingReportLayout({ : ['dateFrom', 'dateTo', 'branch', 'productType', 'sales'] } canExport={canExport} - reportCode={variant === 'lead' ? 'lead_aging' : 'enquiry_aging'} + reportCode={variant === 'lead' ? 'lead_aging' : 'opportunity_aging'} pageLinks={[ { href: '/dashboard/crm/reports/pipeline', label: 'Pipeline Suite' }, { href: '/dashboard/crm/reports/lead-aging', label: 'Lead Aging', active: variant === 'lead' }, - { href: '/dashboard/crm/reports/enquiry-aging', label: 'Enquiry Aging', active: variant === 'enquiry' } + { href: '/dashboard/crm/reports/opportunity-aging', label: 'Opportunity Aging', active: variant === 'opportunity' } ]} /> @@ -87,7 +87,7 @@ function AgingReportLayout({ - {variant === 'lead' ? 'Lead Aging Buckets' : 'Enquiry Aging Buckets'} + {variant === 'lead' ? 'Lead Aging Buckets' : 'Opportunity Aging Buckets'} Bucketed view to spot stagnant records quickly. @@ -102,11 +102,11 @@ function AgingReportLayout({ - {variant === 'lead' ? 'Lead Drill-down' : 'Enquiry Drill-down'} + {variant === 'lead' ? 'Lead Drill-down' : 'Opportunity Drill-down'} {variant === 'lead' ? 'Open leads sorted from oldest to newest.' - : 'Open enquiries with latest follow-up visibility.'} + : 'Open opportunities with latest follow-up visibility.'} @@ -124,7 +124,7 @@ function AgingReportLayout({ ) : ( <> - Enquiry No + Opportunity No Customer Salesman Last Follow-Up @@ -136,7 +136,7 @@ function AgingReportLayout({ {data.rows.length ? ( data.rows.map((row) => ( - + {'leadId' in row ? ( <> {row.leadCode} @@ -147,7 +147,7 @@ function AgingReportLayout({ ) : ( <> - {row.enquiryCode} + {row.opportunityCode} {row.customerName} {row.salesmanName ?? '-'} @@ -202,7 +202,7 @@ function LeadAgingReportInner({ canExport }: { canExport: boolean }) { return ; } -function EnquiryAgingReportInner({ canExport }: { canExport: boolean }) { +function OpportunityAgingReportInner({ canExport }: { canExport: boolean }) { const [params] = useQueryStates({ dateFrom: parseAsString, dateTo: parseAsString, @@ -222,21 +222,22 @@ function EnquiryAgingReportInner({ canExport }: { canExport: boolean }) { }), [params] ); - const { data } = useSuspenseQuery(crmEnquiryAgingReportQueryOptions(filters)); + const { data } = useSuspenseQuery(crmOpportunityAgingReportQueryOptions(filters)); - return ; + return ; } export function AgingReportView({ variant, canExport }: { - variant: 'lead' | 'enquiry'; + variant: 'lead' | 'opportunity'; canExport: boolean; }) { return variant === 'lead' ? ( ) : ( - + ); } + diff --git a/src/features/crm/reports/components/pipeline-report-view.tsx b/src/features/crm/reports/components/pipeline-report-view.tsx index eceeedf..20f1288 100644 --- a/src/features/crm/reports/components/pipeline-report-view.tsx +++ b/src/features/crm/reports/components/pipeline-report-view.tsx @@ -124,14 +124,14 @@ export function PipelineReportView({ canExport }: { canExport: boolean }) { pageLinks={[ { href: '/dashboard/crm/reports/pipeline', label: 'Pipeline Suite', active: true }, { href: '/dashboard/crm/reports/lead-aging', label: 'Lead Aging' }, - { href: '/dashboard/crm/reports/enquiry-aging', label: 'Enquiry Aging' } + { href: '/dashboard/crm/reports/opportunity-aging', label: 'Opportunity Aging' } ]} />
{[ ['Total Leads', formatNumber(data.summary.totalLeads)], - ['Open Enquiries', formatNumber(data.summary.openEnquiries)], + ['Open Opportunities', formatNumber(data.summary.openOpportunities)], ['Quotations', formatNumber(data.summary.quotations)], ['Closed Won', formatNumber(data.summary.won)], ['Closed Lost', formatNumber(data.summary.lost)] @@ -193,9 +193,9 @@ export function PipelineReportView({ canExport }: { canExport: boolean }) { /> [ + rows={data.opportunityPipeline.map((row) => [ row.salesmanName, row.branchLabel, row.productTypeLabel, row.customerOwnerName, - formatNumber(row.openEnquiries), + formatNumber(row.openOpportunities), formatNumber(row.convertedToQuotation), formatNumber(row.won), formatNumber(row.lost) @@ -220,7 +220,7 @@ export function PipelineReportView({ canExport }: { canExport: boolean }) {
[ + reportCode='opportunity_conversion' + headers={['Salesman', 'Branch', 'Product Type', 'Total Opportunities', 'With Quotation', 'Conversion %']} + rows={data.opportunityConversion.map((row) => [ row.salesmanName, row.branchLabel, row.productTypeLabel, - formatNumber(row.totalEnquiries), - formatNumber(row.enquiriesWithQuotation), + formatNumber(row.totalOpportunities), + formatNumber(row.opportunitiesWithQuotation), `${row.conversionRate}%` ])} /> @@ -253,3 +253,4 @@ export function PipelineReportView({ canExport }: { canExport: boolean }) {
); } + diff --git a/src/features/crm/reports/components/report-catalog.tsx b/src/features/crm/reports/components/report-catalog.tsx index 9f62976..bfa54cd 100644 --- a/src/features/crm/reports/components/report-catalog.tsx +++ b/src/features/crm/reports/components/report-catalog.tsx @@ -9,12 +9,12 @@ import { crmReportCatalogQueryOptions, crmReportFiltersQueryOptions } from '../a const REPORT_LINKS: Record = { lead_pipeline: '/dashboard/crm/reports/pipeline', - enquiry_pipeline: '/dashboard/crm/reports/pipeline', + opportunity_pipeline: '/dashboard/crm/reports/pipeline', lead_conversion: '/dashboard/crm/reports/pipeline', - enquiry_conversion: '/dashboard/crm/reports/pipeline', + opportunity_conversion: '/dashboard/crm/reports/pipeline', pipeline_funnel: '/dashboard/crm/reports/pipeline', lead_aging: '/dashboard/crm/reports/lead-aging', - enquiry_aging: '/dashboard/crm/reports/enquiry-aging' + opportunity_aging: '/dashboard/crm/reports/opportunity-aging' }; export function ReportCatalog() { @@ -88,3 +88,4 @@ export function ReportCatalog() {
); } + diff --git a/src/features/crm/reports/server/datasets/pipeline-report.dataset.ts b/src/features/crm/reports/server/datasets/pipeline-report.dataset.ts index de874f4..4cf00c3 100644 --- a/src/features/crm/reports/server/datasets/pipeline-report.dataset.ts +++ b/src/features/crm/reports/server/datasets/pipeline-report.dataset.ts @@ -1,8 +1,8 @@ import { and, asc, count, eq, inArray, isNull, sql } from 'drizzle-orm'; import { crmCustomers, - crmEnquiries, - crmEnquiryFollowups, + crmOpportunities, + crmOpportunityFollowups, crmQuotations, users } from '@/db/schema'; @@ -89,46 +89,46 @@ export async function listPipelineReportDataset( }> { const requiredFilters = buildRequiredFilters(filters); const { start, end } = getDateRange(requiredFilters); - const [branches, productTypes, leadSources, enquiryRows, quotationRows, followupRows] = + const [branches, productTypes, leadSources, opportunityRows, quotationRows, followupRows] = await Promise.all([ getUserBranches(), getActiveOptionsByCategory('crm_product_type', { organizationId: context.organizationId }), getActiveOptionsByCategory('crm_lead_channel', { organizationId: context.organizationId }), db .select({ - id: crmEnquiries.id, - code: crmEnquiries.code, - customerId: crmEnquiries.customerId, - branchId: crmEnquiries.branchId, - productType: crmEnquiries.productType, - leadChannel: crmEnquiries.leadChannel, - pipelineStage: crmEnquiries.pipelineStage, - createdAt: crmEnquiries.createdAt, - createdBy: crmEnquiries.createdBy, - assignedToUserId: crmEnquiries.assignedToUserId, - estimatedValue: crmEnquiries.estimatedValue, - poAmount: crmEnquiries.poAmount, - closedWonAt: crmEnquiries.closedWonAt, - closedLostAt: crmEnquiries.closedLostAt, - lostReason: crmEnquiries.lostReason, + id: crmOpportunities.id, + code: crmOpportunities.code, + customerId: crmOpportunities.customerId, + branchId: crmOpportunities.branchId, + productType: crmOpportunities.productType, + leadChannel: crmOpportunities.leadChannel, + pipelineStage: crmOpportunities.pipelineStage, + createdAt: crmOpportunities.createdAt, + createdBy: crmOpportunities.createdBy, + assignedToUserId: crmOpportunities.assignedToUserId, + estimatedValue: crmOpportunities.estimatedValue, + poAmount: crmOpportunities.poAmount, + closedWonAt: crmOpportunities.closedWonAt, + closedLostAt: crmOpportunities.closedLostAt, + lostReason: crmOpportunities.lostReason, customerName: crmCustomers.name, customerOwnerUserId: crmCustomers.ownerUserId }) - .from(crmEnquiries) - .innerJoin(crmCustomers, eq(crmCustomers.id, crmEnquiries.customerId)) + .from(crmOpportunities) + .innerJoin(crmCustomers, eq(crmCustomers.id, crmOpportunities.customerId)) .where( and( - eq(crmEnquiries.organizationId, context.organizationId), - isNull(crmEnquiries.deletedAt), - eq(crmEnquiries.isActive, true), + eq(crmOpportunities.organizationId, context.organizationId), + isNull(crmOpportunities.deletedAt), + eq(crmOpportunities.isActive, true), isNull(crmCustomers.deletedAt), eq(crmCustomers.isActive, true) ) ) - .orderBy(asc(crmEnquiries.createdAt)), + .orderBy(asc(crmOpportunities.createdAt)), db .select({ - enquiryId: crmQuotations.enquiryId, + opportunityId: crmQuotations.opportunityId, quotationCount: count(crmQuotations.id), quotationValue: sql`coalesce(sum(${crmQuotations.totalAmount}), 0)` }) @@ -138,28 +138,28 @@ export async function listPipelineReportDataset( eq(crmQuotations.organizationId, context.organizationId), isNull(crmQuotations.deletedAt), eq(crmQuotations.isActive, true), - sql`${crmQuotations.enquiryId} is not null` + sql`${crmQuotations.opportunityId} is not null` ) ) - .groupBy(crmQuotations.enquiryId), + .groupBy(crmQuotations.opportunityId), db .select({ - enquiryId: crmEnquiryFollowups.enquiryId, - lastFollowupDate: sql`max(${crmEnquiryFollowups.followupDate})` + opportunityId: crmOpportunityFollowups.opportunityId, + lastFollowupDate: sql`max(${crmOpportunityFollowups.followupDate})` }) - .from(crmEnquiryFollowups) + .from(crmOpportunityFollowups) .where( and( - eq(crmEnquiryFollowups.organizationId, context.organizationId), - isNull(crmEnquiryFollowups.deletedAt) + eq(crmOpportunityFollowups.organizationId, context.organizationId), + isNull(crmOpportunityFollowups.deletedAt) ) ) - .groupBy(crmEnquiryFollowups.enquiryId) + .groupBy(crmOpportunityFollowups.opportunityId) ]); const userIds = [ ...new Set( - enquiryRows.flatMap((row) => [ + opportunityRows.flatMap((row) => [ row.createdBy, row.assignedToUserId, row.customerOwnerUserId @@ -178,19 +178,19 @@ export async function listPipelineReportDataset( const leadSourceLabelMap = new Map(leadSources.map((item) => [item.id, item.label])); const quotationMap = new Map( quotationRows - .filter((row): row is typeof row & { enquiryId: string } => typeof row.enquiryId === 'string') + .filter((row): row is typeof row & { opportunityId: string } => typeof row.opportunityId === 'string') .map((row) => [ - row.enquiryId, + row.opportunityId, { quotationCount: Number(row.quotationCount ?? 0), quotationValue: Number(row.quotationValue ?? 0) } ]) ); - const followupMap = new Map(followupRows.map((row) => [row.enquiryId, row.lastFollowupDate])); + const followupMap = new Map(followupRows.map((row) => [row.opportunityId, row.lastFollowupDate])); const userMap = new Map(userRows.map((row) => [row.id, row.name])); - const rows = enquiryRows + const rows = opportunityRows .filter((row) => canAccessRecord(context, row)) .filter((row) => matchesDateRange(row.createdAt, start, end)) .filter((row) => (requiredFilters.branch ? row.branchId === requiredFilters.branch : true)) @@ -222,8 +222,8 @@ export async function listPipelineReportDataset( const quotation = quotationMap.get(row.id); return { - enquiryId: row.id, - enquiryCode: row.code, + opportunityId: row.id, + opportunityCode: row.code, customerId: row.customerId, customerName: row.customerName, branchId: row.branchId, @@ -260,3 +260,4 @@ export async function listPipelineReportDataset( rows }; } + diff --git a/src/features/crm/reports/server/filters/shared.ts b/src/features/crm/reports/server/filters/shared.ts index c8e4fbb..ea9149a 100644 --- a/src/features/crm/reports/server/filters/shared.ts +++ b/src/features/crm/reports/server/filters/shared.ts @@ -9,7 +9,7 @@ import type { ResolvedReportContext } from '../types'; const PIPELINE_STAGE_OPTIONS = [ { id: 'lead', code: 'lead', label: 'Lead' }, - { id: 'enquiry', code: 'enquiry', label: 'Enquiry' }, + { id: 'opportunity', code: 'opportunity', label: 'Opportunity' }, { id: 'closed_won', code: 'closed_won', label: 'Won' }, { id: 'closed_lost', code: 'closed_lost', label: 'Lost' } ] as const; @@ -85,3 +85,4 @@ export function parseSharedReportFilters(searchParams: URLSearchParams): CrmShar lostReason: searchParams.get('lostReason') }; } + diff --git a/src/features/crm/reports/server/service.ts b/src/features/crm/reports/server/service.ts index 52179eb..9c97384 100644 --- a/src/features/crm/reports/server/service.ts +++ b/src/features/crm/reports/server/service.ts @@ -7,7 +7,7 @@ import type { CrmSharedReportFilters, CrmPipelineReportResponse, CrmLeadAgingReportResponse, - CrmEnquiryAgingReportResponse + CrmOpportunityAgingReportResponse } from '../api/types'; import { buildReportCatalogGroups } from './builders/catalog-builder'; import { resolveCrmAccess } from './context'; @@ -49,8 +49,8 @@ function isConvertedLead(row: PipelineDatasetRecord) { return row.pipelineStage !== 'lead' || Boolean(row.assignedToUserId); } -function isOpenEnquiry(row: PipelineDatasetRecord) { - return row.pipelineStage === 'enquiry'; +function isOpenOpportunity(row: PipelineDatasetRecord) { + return row.pipelineStage === 'opportunity'; } function buildLeadPipelineRows(rows: PipelineDatasetRecord[]) { @@ -98,8 +98,8 @@ function buildLeadPipelineRows(rows: PipelineDatasetRecord[]) { return [...grouped.values()].toSorted((left, right) => right.totalLeads - left.totalLeads); } -function buildEnquiryPipelineRows(rows: PipelineDatasetRecord[]) { - const grouped = new Map(); +function buildOpportunityPipelineRows(rows: PipelineDatasetRecord[]) { + const grouped = new Map(); for (const row of rows) { const key = [ @@ -115,14 +115,14 @@ function buildEnquiryPipelineRows(rows: PipelineDatasetRecord[]) { branchLabel: row.branchLabel, productTypeLabel: row.productTypeLabel, customerOwnerName: row.customerOwnerName, - openEnquiries: 0, + openOpportunities: 0, convertedToQuotation: 0, won: 0, lost: 0 }; - if (isOpenEnquiry(row)) { - current.openEnquiries += 1; + if (isOpenOpportunity(row)) { + current.openOpportunities += 1; } if (row.quotationCount > 0) { @@ -142,8 +142,8 @@ function buildEnquiryPipelineRows(rows: PipelineDatasetRecord[]) { return [...grouped.values()].toSorted( (left, right) => - right.openEnquiries + right.convertedToQuotation + right.won + right.lost - - (left.openEnquiries + left.convertedToQuotation + left.won + left.lost) + right.openOpportunities + right.convertedToQuotation + right.won + right.lost - + (left.openOpportunities + left.convertedToQuotation + left.won + left.lost) ); } @@ -165,13 +165,13 @@ function buildLeadConversionRows(rows: PipelineDatasetRecord[]) { productTypeLabel: row.productTypeLabel, marketingUserName: row.createdByName, totalLeads: 0, - convertedEnquiries: 0, + convertedOpportunities: 0, conversionRate: 0 }; current.totalLeads += 1; if (isConvertedLead(row)) { - current.convertedEnquiries += 1; + current.convertedOpportunities += 1; } grouped.set(key, current); @@ -180,16 +180,16 @@ function buildLeadConversionRows(rows: PipelineDatasetRecord[]) { return [...grouped.values()] .map((row) => ({ ...row, - conversionRate: toPercent(row.convertedEnquiries, row.totalLeads) + conversionRate: toPercent(row.convertedOpportunities, row.totalLeads) })) .toSorted((left, right) => right.totalLeads - left.totalLeads); } -function buildEnquiryConversionRows(rows: PipelineDatasetRecord[]) { - const enquiryRows = rows.filter((row) => row.pipelineStage !== 'lead' || row.assignedToUserId); - const grouped = new Map(); +function buildOpportunityConversionRows(rows: PipelineDatasetRecord[]) { + const opportunityRows = rows.filter((row) => row.pipelineStage !== 'lead' || row.assignedToUserId); + const grouped = new Map(); - for (const row of enquiryRows) { + for (const row of opportunityRows) { const key = [(row.assignedToName ?? 'Unassigned'), row.branchLabel, row.productTypeLabel].join('|'); const current = grouped.get(key) ?? @@ -197,14 +197,14 @@ function buildEnquiryConversionRows(rows: PipelineDatasetRecord[]) { salesmanName: row.assignedToName ?? 'Unassigned', branchLabel: row.branchLabel, productTypeLabel: row.productTypeLabel, - totalEnquiries: 0, - enquiriesWithQuotation: 0, + totalOpportunities: 0, + opportunitiesWithQuotation: 0, conversionRate: 0 }; - current.totalEnquiries += 1; + current.totalOpportunities += 1; if (row.quotationCount > 0) { - current.enquiriesWithQuotation += 1; + current.opportunitiesWithQuotation += 1; } grouped.set(key, current); @@ -213,14 +213,14 @@ function buildEnquiryConversionRows(rows: PipelineDatasetRecord[]) { return [...grouped.values()] .map((row) => ({ ...row, - conversionRate: toPercent(row.enquiriesWithQuotation, row.totalEnquiries) + conversionRate: toPercent(row.opportunitiesWithQuotation, row.totalOpportunities) })) - .toSorted((left, right) => right.totalEnquiries - left.totalEnquiries); + .toSorted((left, right) => right.totalOpportunities - left.totalOpportunities); } function buildFunnelRows(rows: PipelineDatasetRecord[], canViewRevenueValues: boolean) { const leadCount = rows.length; - const enquiryRows = rows.filter((row) => isConvertedLead(row)); + const opportunityRows = rows.filter((row) => isConvertedLead(row)); const quotationRows = rows.filter((row) => row.quotationCount > 0); const wonRows = rows.filter((row) => row.pipelineStage === 'closed_won'); const lostRows = rows.filter((row) => row.pipelineStage === 'closed_lost'); @@ -236,18 +236,18 @@ function buildFunnelRows(rows: PipelineDatasetRecord[], canViewRevenueValues: bo conversionRate: null }, { - stageKey: 'enquiry' as const, - stageLabel: 'Enquiry', - count: enquiryRows.length, - value: valueOrNull(sumValues(enquiryRows, (row) => row.estimatedValue)), - conversionRate: toPercent(enquiryRows.length, leadCount) + stageKey: 'opportunity' as const, + stageLabel: 'Opportunity', + count: opportunityRows.length, + value: valueOrNull(sumValues(opportunityRows, (row) => row.estimatedValue)), + conversionRate: toPercent(opportunityRows.length, leadCount) }, { stageKey: 'quotation' as const, stageLabel: 'Quotation', count: quotationRows.length, value: valueOrNull(sumValues(quotationRows, (row) => row.quotationValue)), - conversionRate: toPercent(quotationRows.length, enquiryRows.length) + conversionRate: toPercent(quotationRows.length, opportunityRows.length) }, { stageKey: 'closed_won' as const, @@ -271,8 +271,8 @@ function buildLeadAging(rows: PipelineDatasetRecord[], filters: ParsedCrmReportF const leadRows = rows .filter((row) => row.pipelineStage === 'lead') .map((row) => ({ - leadId: row.enquiryId, - leadCode: row.enquiryCode, + leadId: row.opportunityId, + leadCode: row.opportunityCode, customerName: row.customerName, assignedUserName: row.assignedToName, createdDate: row.createdAt.toISOString(), @@ -302,41 +302,41 @@ function buildLeadAging(rows: PipelineDatasetRecord[], filters: ParsedCrmReportF }; } -function buildEnquiryAging( +function buildOpportunityAging( rows: PipelineDatasetRecord[], filters: ParsedCrmReportFilters -): CrmEnquiryAgingReportResponse { +): CrmOpportunityAgingReportResponse { const now = new Date(); - const enquiryRows = rows - .filter((row) => row.pipelineStage === 'enquiry') + const opportunityRows = rows + .filter((row) => row.pipelineStage === 'opportunity') .map((row) => ({ - enquiryId: row.enquiryId, - enquiryCode: row.enquiryCode, + opportunityId: row.opportunityId, + opportunityCode: row.opportunityCode, customerName: row.customerName, salesmanName: row.assignedToName, lastFollowupDate: row.lastFollowupDate?.toISOString() ?? null, agingDays: getDayDifference(row.createdAt, now) })) .toSorted((left, right) => right.agingDays - left.agingDays); - const ages = enquiryRows.map((row) => row.agingDays); + const ages = opportunityRows.map((row) => row.agingDays); return { success: true, time: new Date().toISOString(), - message: 'CRM enquiry aging report loaded successfully', + message: 'CRM opportunity aging report loaded successfully', filters, summary: { - openEnquiries: enquiryRows.length, + openOpportunities: opportunityRows.length, averageAgingDays: average(ages), maximumAgingDays: ages.length ? Math.max(...ages) : 0 }, buckets: [ - { key: '0_14', label: '0-14 Days', count: enquiryRows.filter((row) => row.agingDays <= 14).length }, - { key: '15_30', label: '15-30 Days', count: enquiryRows.filter((row) => row.agingDays >= 15 && row.agingDays <= 30).length }, - { key: '31_60', label: '31-60 Days', count: enquiryRows.filter((row) => row.agingDays >= 31 && row.agingDays <= 60).length }, - { key: '60_plus', label: '60+ Days', count: enquiryRows.filter((row) => row.agingDays > 60).length } + { key: '0_14', label: '0-14 Days', count: opportunityRows.filter((row) => row.agingDays <= 14).length }, + { key: '15_30', label: '15-30 Days', count: opportunityRows.filter((row) => row.agingDays >= 15 && row.agingDays <= 30).length }, + { key: '31_60', label: '31-60 Days', count: opportunityRows.filter((row) => row.agingDays >= 31 && row.agingDays <= 60).length }, + { key: '60_plus', label: '60+ Days', count: opportunityRows.filter((row) => row.agingDays > 60).length } ], - rows: enquiryRows + rows: opportunityRows }; } @@ -417,16 +417,16 @@ export async function getCrmPipelineReportResponse( filters: dataset.filters, summary: { totalLeads: dataset.rows.length, - openEnquiries: dataset.rows.filter((row) => row.pipelineStage === 'enquiry').length, + openOpportunities: dataset.rows.filter((row) => row.pipelineStage === 'opportunity').length, quotations: dataset.rows.filter((row) => row.quotationCount > 0).length, won: dataset.rows.filter((row) => row.pipelineStage === 'closed_won').length, lost: dataset.rows.filter((row) => row.pipelineStage === 'closed_lost').length, canViewRevenueValues }, leadPipeline: buildLeadPipelineRows(dataset.rows), - enquiryPipeline: buildEnquiryPipelineRows(dataset.rows), + opportunityPipeline: buildOpportunityPipelineRows(dataset.rows), leadConversion: buildLeadConversionRows(dataset.rows), - enquiryConversion: buildEnquiryConversionRows(dataset.rows), + opportunityConversion: buildOpportunityConversionRows(dataset.rows), funnel: buildFunnelRows(dataset.rows, canViewRevenueValues) }; @@ -447,14 +447,15 @@ export async function getCrmLeadAgingReportResponse( return response; } -export async function getCrmEnquiryAgingReportResponse( +export async function getCrmOpportunityAgingReportResponse( context: ResolvedReportContext, filters: CrmSharedReportFilters -): Promise { +): Promise { const dataset = await listPipelineReportDataset(context, filters); - const response = buildEnquiryAging(dataset.rows, dataset.filters); + const response = buildOpportunityAging(dataset.rows, dataset.filters); - await auditReportView(context, 'enquiry_aging', { ...dataset.filters }, response.rows.length); + await auditReportView(context, 'opportunity_aging', { ...dataset.filters }, response.rows.length); return response; } + diff --git a/src/features/crm/reports/server/types.ts b/src/features/crm/reports/server/types.ts index 358fd27..c5dfaaf 100644 --- a/src/features/crm/reports/server/types.ts +++ b/src/features/crm/reports/server/types.ts @@ -39,8 +39,8 @@ export interface ParsedCrmReportFilters { } export interface PipelineDatasetRecord { - enquiryId: string; - enquiryCode: string; + opportunityId: string; + opportunityCode: string; customerId: string; customerName: string; branchId: string | null; @@ -66,3 +66,4 @@ export interface PipelineDatasetRecord { closedLostAt: Date | null; lostReason: string | null; } + diff --git a/src/features/crm/shared/terminology.ts b/src/features/crm/shared/terminology.ts index 339ee2a..58a07e8 100644 --- a/src/features/crm/shared/terminology.ts +++ b/src/features/crm/shared/terminology.ts @@ -3,8 +3,8 @@ export const CRM_TERMS = { dashboard: 'Dashboard', lead: 'ลีด', leads: 'ลีด', - enquiry: 'โอกาสขาย', - enquiries: 'โอกาสขาย', + opportunity: 'โอกาสขาย', + opportunities: 'โอกาสขาย', quotation: 'ใบเสนอราคา', quotations: 'ใบเสนอราคา', approval: 'อนุมัติเอกสาร', @@ -17,7 +17,7 @@ export const CRM_TERMS = { projectParties: 'ผู้เกี่ยวข้องในโครงการ', crmSettings: 'ตั้งค่า CRM', leadCount: 'จำนวนลีด', - enquiryCount: 'จำนวนโอกาสขาย', + opportunityCount: 'จำนวนโอกาสขาย', wonCount: 'จำนวนงานที่ชนะ', lostCount: 'จำนวนงานที่แพ้', revenue: 'มูลค่าใบเสนอราคา', @@ -26,7 +26,7 @@ export const CRM_TERMS = { export const PIPELINE_STAGE_THAI_LABELS = { lead: 'ลีด', - enquiry: 'โอกาสขาย', + opportunity: 'โอกาสขาย', closed_won: 'ชนะ', closed_lost: 'แพ้' } as const; @@ -49,3 +49,4 @@ export function getPipelineStageThaiLabel( ) { return PIPELINE_STAGE_THAI_LABELS[stage as keyof typeof PIPELINE_STAGE_THAI_LABELS] ?? stage; } + diff --git a/src/features/foundation/document-sequence/service.ts b/src/features/foundation/document-sequence/service.ts index 05388f5..fb3cf39 100644 --- a/src/features/foundation/document-sequence/service.ts +++ b/src/features/foundation/document-sequence/service.ts @@ -15,9 +15,9 @@ import type { const DEFAULT_DOCUMENT_PREFIXES: Record = { customer: 'CUS', contact: 'CON', - enquiry: 'ENQ', + opportunity: 'ENQ', crm_lead: 'LD', - crm_enquiry: 'EN', + crm_opportunity: 'EN', quotation: 'QT', approval: 'APV' }; @@ -326,3 +326,4 @@ export async function generateNextDocumentCode( }; }); } + diff --git a/src/features/foundation/master-options/types.ts b/src/features/foundation/master-options/types.ts index f180d92..7234620 100644 --- a/src/features/foundation/master-options/types.ts +++ b/src/features/foundation/master-options/types.ts @@ -2,7 +2,7 @@ export const CRM_MASTER_OPTION_CATEGORIES = [ 'crm_branch', 'crm_customer_status', 'crm_customer_type', - 'crm_enquiry_status', + 'crm_opportunity_status', 'crm_lead_awareness', 'crm_lead_status', 'crm_lead_followup_status', @@ -59,3 +59,4 @@ export interface MasterOptionListResult { items: MasterOptionRecord[]; totalItems: number; } + diff --git a/src/lib/auth/rbac.ts b/src/lib/auth/rbac.ts index ee6c8a4..8390dbf 100644 --- a/src/lib/auth/rbac.ts +++ b/src/lib/auth/rbac.ts @@ -48,19 +48,19 @@ export const PERMISSIONS = { crmContactShareCreate: 'crm.contact.share.create', crmContactShareDelete: 'crm.contact.share.delete', crmContactShareManage: 'crm.contact.share.manage', - crmEnquiryRead: 'crm.enquiry.read', - crmEnquiryCreate: 'crm.enquiry.create', - crmEnquiryUpdate: 'crm.enquiry.update', - crmEnquiryDelete: 'crm.enquiry.delete', - crmEnquiryAssign: 'crm.enquiry.assign', - crmEnquiryReassign: 'crm.enquiry.reassign', - crmEnquiryMarkWon: 'crm.enquiry.mark_won', - crmEnquiryMarkLost: 'crm.enquiry.mark_lost', - crmEnquiryReopen: 'crm.enquiry.reopen', - crmEnquiryFollowupRead: 'crm.enquiry.followup.read', - crmEnquiryFollowupCreate: 'crm.enquiry.followup.create', - crmEnquiryFollowupUpdate: 'crm.enquiry.followup.update', - crmEnquiryFollowupDelete: 'crm.enquiry.followup.delete', + crmOpportunityRead: 'crm.opportunity.read', + crmOpportunityCreate: 'crm.opportunity.create', + crmOpportunityUpdate: 'crm.opportunity.update', + crmOpportunityDelete: 'crm.opportunity.delete', + crmOpportunityAssign: 'crm.opportunity.assign', + crmOpportunityReassign: 'crm.opportunity.reassign', + crmOpportunityMarkWon: 'crm.opportunity.mark_won', + crmOpportunityMarkLost: 'crm.opportunity.mark_lost', + crmOpportunityReopen: 'crm.opportunity.reopen', + crmOpportunityFollowupRead: 'crm.opportunity.followup.read', + crmOpportunityFollowupCreate: 'crm.opportunity.followup.create', + crmOpportunityFollowupUpdate: 'crm.opportunity.followup.update', + crmOpportunityFollowupDelete: 'crm.opportunity.followup.delete', crmQuotationRead: 'crm.quotation.read', crmQuotationCreate: 'crm.quotation.create', crmQuotationUpdate: 'crm.quotation.update', @@ -144,7 +144,7 @@ export interface PermissionGroup { const DEFAULT_ROLE_DEFINITIONS: Record> = { marketing: { name: 'Marketing', - description: 'Lead creation, assignment, monitoring, and enquiry visibility without commercial authority.', + description: 'Lead creation, assignment, monitoring, and opportunity visibility without commercial authority.', permissions: [ PERMISSIONS.crmLeadRead, PERMISSIONS.crmLeadCreate, @@ -152,8 +152,8 @@ const DEFAULT_ROLE_DEFINITIONS: Record