task-d5.5.1
This commit is contained in:
12
.editorconfig
Normal file
12
.editorconfig
Normal file
@@ -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
|
||||||
8
.gitattributes
vendored
Normal file
8
.gitattributes
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.webp binary
|
||||||
|
*.pdf binary
|
||||||
@@ -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.
|
||||||
@@ -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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Document artifacts | `/api/crm/document-artifacts/[id]/download` | `Protected` | Approved quotation artifacts now require artifact permission plus pricing visibility guard |
|
||||||
|
|||||||
@@ -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. |
|
| 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 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` |
|
| 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
|
### 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 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. |
|
| 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. |
|
| 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 |
|
| 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 |
|
| Purpose | Shared opportunity lifecycle with linked lead provenance, 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` |
|
| 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 tasks | Task D, Task D.1, Task D.3, Task D.3.1 |
|
||||||
| Related ADRs | ADR-0011, ADR-0014 |
|
| Related ADRs | ADR-0011, ADR-0014 |
|
||||||
| Reusable services | `getEnquiryReferenceData()`, `listEnquiries()`, `getEnquiryDetail()`, `createEnquiry()`, `updateEnquiry()`, `softDeleteEnquiry()`, `assignEnquiryToUser()`, `reassignEnquiryToUser()`, `listEnquiryProjectParties()` |
|
| Reusable services | `getOpportunityReferenceData()`, `listOpportunities()`, `getOpportunityDetail()`, `createOpportunity()`, `updateOpportunity()`, `softDeleteOpportunity()`, `assignOpportunityToUser()`, `reassignOpportunityToUser()`, `listOpportunityProjectParties()` |
|
||||||
| Reusable APIs | `/api/crm/enquiries`, `/api/crm/enquiries/[id]`, `/api/crm/enquiries/[id]/assign`, `/api/crm/enquiries/[id]/reassign`, `/api/crm/enquiries/[id]/customers` |
|
| 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 lead versus enquiry separation. |
|
| Reuse status | Reusable and active. This is the source of truth for the sales opportunity domain. |
|
||||||
| 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. |
|
| 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
|
### Won / Lost Governance Foundation
|
||||||
|
|
||||||
| Field | Details |
|
| Field | Details |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Purpose | Freeze business outcome lifecycle on `crm_enquiries`, including PO capture, lost reason governance, and reopen rules |
|
| Purpose | Freeze business outcome lifecycle on `crm_opportunities`, 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` |
|
| 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 tasks | Task D.4, Task J, Task K |
|
||||||
| Related ADRs | ADR-0016 |
|
| Related ADRs | ADR-0016 |
|
||||||
| Reusable services | `markEnquiryAsWon()`, `markEnquiryAsLost()`, `reopenLostEnquiry()`, PO attachment helpers under enquiry service |
|
| Reusable services | `markOpportunityAsWon()`, `markOpportunityAsLost()`, `reopenLostOpportunity()`, PO attachment helpers under opportunity 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 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. |
|
| Reuse status | Reusable and active. Mandatory for outcome transitions and outcome-driven KPI/report work. |
|
||||||
|
|
||||||
### Quotation Foundation
|
### Quotation Foundation
|
||||||
@@ -328,14 +328,14 @@ If ADR coverage is incomplete, this registry marks the gap explicitly.
|
|||||||
|
|
||||||
| Field | Details |
|
| Field | Details |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Purpose | Shared follow-up tracking across enquiries and quotations, including list/detail child resources, dashboard analytics, and report visibility |
|
| Purpose | Shared follow-up tracking across opportunities 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` |
|
| 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 tasks | Task D, Task E, Task J, Task K |
|
||||||
| Related ADRs | ADR-0011, ADR-0016, ADR-0017 |
|
| 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 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/enquiries/[id]/followups`, `/api/crm/enquiries/[id]/followups/[followupId]`, `/api/crm/quotations/[id]/followups` |
|
| Reusable APIs | `/api/crm/opportunities/[id]/followups`, `/api/crm/opportunities/[id]/followups/[followupId]`, `/api/crm/quotations/[id]/followups` |
|
||||||
| Reuse status | Active but fragmented. |
|
| 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
|
### 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` |
|
| 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 tasks | Task K.1, Task K.2 |
|
||||||
| Related ADRs | ADR-0010, ADR-0011, ADR-0016, ADR-0017 |
|
| 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 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/enquiry-aging`, `/api/crm/reports/export` |
|
| 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. |
|
| Reuse status | Reusable and active. Mandatory for new CRM reports and exports. |
|
||||||
|
|
||||||
## Governance Gaps And Duplication Risks
|
## Governance Gaps And Duplication Risks
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ Reference docs and files:
|
|||||||
|
|
||||||
- `docs/forms.md`
|
- `docs/forms.md`
|
||||||
- `src/features/crm/customers/components/customer-form-sheet.tsx`
|
- `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`
|
- `src/features/crm/quotations/components/quotation-form-sheet.tsx`
|
||||||
|
|
||||||
## Table Standards
|
## Table Standards
|
||||||
@@ -59,7 +59,7 @@ Reference files:
|
|||||||
- `src/components/ui/table/data-table.tsx`
|
- `src/components/ui/table/data-table.tsx`
|
||||||
- `src/features/users/components/users-table/index.tsx`
|
- `src/features/users/components/users-table/index.tsx`
|
||||||
- `src/features/crm/customers/components/customers-table.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
|
## Filter Standards
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { defineConfig } from "drizzle-kit";
|
import { defineConfig } from "drizzle-kit";
|
||||||
import "dotenv/config";
|
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({
|
export default defineConfig({
|
||||||
schema: "./src/db/schema.ts",
|
schema: "./src/db/schema.ts",
|
||||||
out: "./drizzle",
|
out: "./drizzle",
|
||||||
|
|||||||
@@ -226,7 +226,36 @@ CREATE TABLE "crm_document_templates" (
|
|||||||
"updated_by" text NOT NULL
|
"updated_by" text NOT NULL
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> 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,
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
"organization_id" text NOT NULL,
|
"organization_id" text NOT NULL,
|
||||||
"branch_id" text,
|
"branch_id" text,
|
||||||
@@ -273,10 +302,10 @@ CREATE TABLE "crm_enquiries" (
|
|||||||
"updated_by" text NOT NULL
|
"updated_by" text NOT NULL
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
CREATE TABLE "crm_enquiry_attachments" (
|
CREATE TABLE "crm_opportunity_attachments" (
|
||||||
"id" text PRIMARY KEY NOT NULL,
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
"organization_id" text NOT NULL,
|
"organization_id" text NOT NULL,
|
||||||
"enquiry_id" text NOT NULL,
|
"opportunity_id" text NOT NULL,
|
||||||
"category" text NOT NULL,
|
"category" text NOT NULL,
|
||||||
"file_name" text NOT NULL,
|
"file_name" text NOT NULL,
|
||||||
"original_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
|
"deleted_at" timestamp with time zone
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
CREATE TABLE "crm_enquiry_customers" (
|
CREATE TABLE "crm_opportunity_customers" (
|
||||||
"id" text PRIMARY KEY NOT NULL,
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
"organization_id" text NOT NULL,
|
"organization_id" text NOT NULL,
|
||||||
"enquiry_id" text NOT NULL,
|
"opportunity_id" text NOT NULL,
|
||||||
"customer_id" text NOT NULL,
|
"customer_id" text NOT NULL,
|
||||||
"role" text NOT NULL,
|
"role" text NOT NULL,
|
||||||
"remark" text,
|
"remark" text,
|
||||||
@@ -304,10 +333,10 @@ CREATE TABLE "crm_enquiry_customers" (
|
|||||||
"deleted_at" timestamp with time zone
|
"deleted_at" timestamp with time zone
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
CREATE TABLE "crm_enquiry_followups" (
|
CREATE TABLE "crm_opportunity_followups" (
|
||||||
"id" text PRIMARY KEY NOT NULL,
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
"organization_id" text 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_date" timestamp with time zone NOT NULL,
|
||||||
"followup_type" text NOT NULL,
|
"followup_type" text NOT NULL,
|
||||||
"contact_id" text,
|
"contact_id" text,
|
||||||
@@ -322,35 +351,6 @@ CREATE TABLE "crm_enquiry_followups" (
|
|||||||
"updated_by" text NOT NULL
|
"updated_by" text NOT NULL
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> 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" (
|
CREATE TABLE "crm_quotation_attachments" (
|
||||||
"id" text PRIMARY KEY NOT NULL,
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
"organization_id" text NOT NULL,
|
"organization_id" text NOT NULL,
|
||||||
@@ -448,7 +448,7 @@ CREATE TABLE "crm_quotations" (
|
|||||||
"organization_id" text NOT NULL,
|
"organization_id" text NOT NULL,
|
||||||
"branch_id" text,
|
"branch_id" text,
|
||||||
"code" text NOT NULL,
|
"code" text NOT NULL,
|
||||||
"enquiry_id" text,
|
"opportunity_id" text,
|
||||||
"customer_id" text NOT NULL,
|
"customer_id" text NOT NULL,
|
||||||
"contact_id" text,
|
"contact_id" text,
|
||||||
"quotation_date" timestamp with time zone NOT NULL,
|
"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_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_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_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_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_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_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
|
CREATE UNIQUE INDEX "crm_role_profiles_org_code_idx" ON "crm_role_profiles" USING btree ("organization_id","code");--> statement-breakpoint
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"id": "5b317a18-e2f9-48c7-a371-c6a972e13344",
|
"id": "e01a80c3-a4b5-4461-afb0-52b663dfa084",
|
||||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "postgresql",
|
"dialect": "postgresql",
|
||||||
@@ -1551,8 +1551,203 @@
|
|||||||
"checkConstraints": {},
|
"checkConstraints": {},
|
||||||
"isRLSEnabled": false
|
"isRLSEnabled": false
|
||||||
},
|
},
|
||||||
"public.crm_enquiries": {
|
"public.crm_leads": {
|
||||||
"name": "crm_enquiries",
|
"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": "",
|
"schema": "",
|
||||||
"columns": {
|
"columns": {
|
||||||
"id": {
|
"id": {
|
||||||
@@ -1826,8 +2021,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {
|
"indexes": {
|
||||||
"crm_enquiries_org_code_idx": {
|
"crm_opportunities_org_code_idx": {
|
||||||
"name": "crm_enquiries_org_code_idx",
|
"name": "crm_opportunities_org_code_idx",
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
"expression": "organization_id",
|
"expression": "organization_id",
|
||||||
@@ -1855,8 +2050,8 @@
|
|||||||
"checkConstraints": {},
|
"checkConstraints": {},
|
||||||
"isRLSEnabled": false
|
"isRLSEnabled": false
|
||||||
},
|
},
|
||||||
"public.crm_enquiry_attachments": {
|
"public.crm_opportunity_attachments": {
|
||||||
"name": "crm_enquiry_attachments",
|
"name": "crm_opportunity_attachments",
|
||||||
"schema": "",
|
"schema": "",
|
||||||
"columns": {
|
"columns": {
|
||||||
"id": {
|
"id": {
|
||||||
@@ -1871,8 +2066,8 @@
|
|||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true
|
"notNull": true
|
||||||
},
|
},
|
||||||
"enquiry_id": {
|
"opportunity_id": {
|
||||||
"name": "enquiry_id",
|
"name": "opportunity_id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true
|
"notNull": true
|
||||||
@@ -1967,8 +2162,8 @@
|
|||||||
"checkConstraints": {},
|
"checkConstraints": {},
|
||||||
"isRLSEnabled": false
|
"isRLSEnabled": false
|
||||||
},
|
},
|
||||||
"public.crm_enquiry_customers": {
|
"public.crm_opportunity_customers": {
|
||||||
"name": "crm_enquiry_customers",
|
"name": "crm_opportunity_customers",
|
||||||
"schema": "",
|
"schema": "",
|
||||||
"columns": {
|
"columns": {
|
||||||
"id": {
|
"id": {
|
||||||
@@ -1983,8 +2178,8 @@
|
|||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true
|
"notNull": true
|
||||||
},
|
},
|
||||||
"enquiry_id": {
|
"opportunity_id": {
|
||||||
"name": "enquiry_id",
|
"name": "opportunity_id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true
|
"notNull": true
|
||||||
@@ -2036,8 +2231,8 @@
|
|||||||
"checkConstraints": {},
|
"checkConstraints": {},
|
||||||
"isRLSEnabled": false
|
"isRLSEnabled": false
|
||||||
},
|
},
|
||||||
"public.crm_enquiry_followups": {
|
"public.crm_opportunity_followups": {
|
||||||
"name": "crm_enquiry_followups",
|
"name": "crm_opportunity_followups",
|
||||||
"schema": "",
|
"schema": "",
|
||||||
"columns": {
|
"columns": {
|
||||||
"id": {
|
"id": {
|
||||||
@@ -2052,8 +2247,8 @@
|
|||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true
|
"notNull": true
|
||||||
},
|
},
|
||||||
"enquiry_id": {
|
"opportunity_id": {
|
||||||
"name": "enquiry_id",
|
"name": "opportunity_id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true
|
"notNull": true
|
||||||
@@ -2141,201 +2336,6 @@
|
|||||||
"checkConstraints": {},
|
"checkConstraints": {},
|
||||||
"isRLSEnabled": false
|
"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": {
|
"public.crm_quotation_attachments": {
|
||||||
"name": "crm_quotation_attachments",
|
"name": "crm_quotation_attachments",
|
||||||
"schema": "",
|
"schema": "",
|
||||||
@@ -2908,8 +2908,8 @@
|
|||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true
|
"notNull": true
|
||||||
},
|
},
|
||||||
"enquiry_id": {
|
"opportunity_id": {
|
||||||
"name": "enquiry_id",
|
"name": "opportunity_id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false
|
"notNull": false
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1782285058938,
|
"when": 1782359607850,
|
||||||
"tag": "0000_swift_prism",
|
"tag": "0000_hard_butterfly",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/dev/types/routes.d.ts";
|
import "./.next/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "npm run verify:encoding && next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "oxlint",
|
"lint": "oxlint",
|
||||||
"lint:fix": "oxlint --fix && bun format",
|
"lint:fix": "oxlint --fix && bun format",
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"format": "oxfmt --write .",
|
"format": "oxfmt --write .",
|
||||||
"format:check": "oxfmt --check .",
|
"format:check": "oxfmt --check .",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"gen": "npx drizzle-kit generate",
|
"gen": "npm run verify:encoding && npx drizzle-kit generate",
|
||||||
"migrate": "npx drizzle-kit migrate",
|
"migrate": "npx drizzle-kit migrate",
|
||||||
"studio": "npx drizzle-kit studio",
|
"studio": "npx drizzle-kit studio",
|
||||||
"seed:super-admin": "node scripts/seed-super-admin.js",
|
"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: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:runtime": "node --experimental-strip-types scripts/audit-pdf-runtime-payload.ts",
|
||||||
"audit:pdf:report": "node --experimental-strip-types scripts/generate-pdf-audit-report.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: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:placeholders": "node --experimental-strip-types scripts/audit-placeholder-integrity.ts",
|
||||||
"audit:pdf:parity": "node --experimental-strip-types scripts/audit-payload-parity.ts",
|
"audit:pdf:parity": "node --experimental-strip-types scripts/audit-payload-parity.ts",
|
||||||
|
|||||||
470
plans/task-d.5.5.1.md
Normal file
470
plans/task-d.5.5.1.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
95
scripts/verify-encoding.mjs
Normal file
95
scripts/verify-encoding.mjs
Normal file
@@ -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.`);
|
||||||
@@ -107,7 +107,7 @@ export async function GET(request: NextRequest) {
|
|||||||
...data.salesRanking.map((row) => [
|
...data.salesRanking.map((row) => [
|
||||||
row.salesPersonName,
|
row.salesPersonName,
|
||||||
String(row.leadCount),
|
String(row.leadCount),
|
||||||
String(row.enquiryCount),
|
String(row.opportunityCount),
|
||||||
String(row.quotationCount),
|
String(row.quotationCount),
|
||||||
String(row.approvedQuotations),
|
String(row.approvedQuotations),
|
||||||
String(row.wonRevenue),
|
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 });
|
return NextResponse.json({ message: 'Unable to export CRM dashboard data' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,9 +38,9 @@ export async function POST(request: NextRequest, { params }: Params) {
|
|||||||
success: true,
|
success: true,
|
||||||
message: 'Lead assigned successfully',
|
message: 'Lead assigned successfully',
|
||||||
leadId: result.leadId,
|
leadId: result.leadId,
|
||||||
enquiryId: result.enquiryId,
|
opportunityId: result.opportunityId,
|
||||||
enquiryCode: result.enquiryCode,
|
opportunityCode: result.opportunityCode,
|
||||||
reusedExistingEnquiry: result.reusedExistingEnquiry
|
reusedExistingOpportunity: result.reusedExistingOpportunity
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
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 });
|
return NextResponse.json({ message: 'Unable to assign lead' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const enquiryAssignmentRequestSchema = z.object({
|
export const opportunityAssignmentRequestSchema = z.object({
|
||||||
assignedToUserId: z.string().min(1, 'Sales user is required'),
|
assignedToUserId: z.string().min(1, 'Sales user is required'),
|
||||||
remark: z.string().trim().max(1000).optional()
|
remark: z.string().trim().max(1000).optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
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 { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
import { enquiryAssignmentRequestSchema } from '../_schemas';
|
import { opportunityAssignmentRequestSchema } from '../_schemas';
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
@@ -14,9 +14,9 @@ export async function POST(request: NextRequest, { params }: Params) {
|
|||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { organization, session, membership } = await requireOrganizationAccess({
|
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 = {
|
const accessContext = {
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
@@ -29,8 +29,8 @@ export async function POST(request: NextRequest, { params }: Params) {
|
|||||||
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
|
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
|
||||||
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
|
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
|
||||||
};
|
};
|
||||||
const before = await getEnquiryDetail(id, organization.id, accessContext);
|
const before = await getOpportunityDetail(id, organization.id, accessContext);
|
||||||
const updated = await assignEnquiryToUser(
|
const updated = await assignOpportunityToUser(
|
||||||
id,
|
id,
|
||||||
organization.id,
|
organization.id,
|
||||||
session.user.id,
|
session.user.id,
|
||||||
@@ -42,9 +42,9 @@ export async function POST(request: NextRequest, { params }: Params) {
|
|||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
branchId: updated.branchId,
|
branchId: updated.branchId,
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
entityType: 'crm_enquiry',
|
entityType: 'crm_opportunity',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
action: 'lead_to_enquiry',
|
action: 'lead_to_opportunity',
|
||||||
beforeData: {
|
beforeData: {
|
||||||
oldAssignedToUserId: before.assignedToUserId,
|
oldAssignedToUserId: before.assignedToUserId,
|
||||||
oldPipelineStage: before.pipelineStage
|
oldPipelineStage: before.pipelineStage
|
||||||
@@ -58,8 +58,8 @@ export async function POST(request: NextRequest, { params }: Params) {
|
|||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Lead converted to enquiry successfully',
|
message: 'Lead converted to opportunity successfully',
|
||||||
enquiry: updated
|
opportunity: updated
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
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: error.message }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ message: 'Unable to assign enquiry' }, { status: 500 });
|
return NextResponse.json({ message: 'Unable to assign opportunity' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
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 { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
|||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { organization, session, membership } = await requireOrganizationAccess({
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmEnquiryRead
|
permission: PERMISSIONS.crmOpportunityRead
|
||||||
});
|
});
|
||||||
const accessContext = {
|
const accessContext = {
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
@@ -24,12 +24,12 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
|||||||
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
|
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
|
||||||
productScopeMode: membership.productScopeMode === '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({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
time: new Date().toISOString(),
|
time: new Date().toISOString(),
|
||||||
message: 'Enquiry project parties loaded successfully',
|
message: 'Opportunity project parties loaded successfully',
|
||||||
items
|
items
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -42,8 +42,9 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ message: 'Unable to load enquiry project parties' },
|
{ message: 'Unable to load opportunity project parties' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2,11 +2,11 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
|
import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
|
||||||
import {
|
import {
|
||||||
listEnquiryFollowups,
|
listOpportunityFollowups,
|
||||||
softDeleteEnquiryFollowup,
|
softDeleteOpportunityFollowup,
|
||||||
updateEnquiryFollowup
|
updateOpportunityFollowup
|
||||||
} from '@/features/crm/enquiries/server/service';
|
} from '@/features/crm/opportunities/server/service';
|
||||||
import { enquiryFollowupSchema } from '@/features/crm/enquiries/schemas/enquiry.schema';
|
import { opportunityFollowupSchema } from '@/features/crm/opportunities/schemas/opportunity.schema';
|
||||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ type Params = {
|
|||||||
params: Promise<{ id: string; followupId: string }>;
|
params: Promise<{ id: string; followupId: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const followupRequestSchema = enquiryFollowupSchema.extend({
|
const followupRequestSchema = opportunityFollowupSchema.extend({
|
||||||
contactId: z.string().nullable().optional(),
|
contactId: z.string().nullable().optional(),
|
||||||
nextFollowupDate: z.string().nullable().optional()
|
nextFollowupDate: z.string().nullable().optional()
|
||||||
});
|
});
|
||||||
@@ -23,7 +23,7 @@ export async function PATCH(request: NextRequest, { params }: Params) {
|
|||||||
try {
|
try {
|
||||||
const { id, followupId } = await params;
|
const { id, followupId } = await params;
|
||||||
const { organization, session, membership } = await requireOrganizationAccess({
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmEnquiryFollowupUpdate
|
permission: PERMISSIONS.crmOpportunityFollowupUpdate
|
||||||
});
|
});
|
||||||
const payload = followupRequestSchema.parse(await request.json());
|
const payload = followupRequestSchema.parse(await request.json());
|
||||||
const accessContext = {
|
const accessContext = {
|
||||||
@@ -38,7 +38,7 @@ export async function PATCH(request: NextRequest, { params }: Params) {
|
|||||||
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
|
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
|
||||||
};
|
};
|
||||||
const before = (
|
const before = (
|
||||||
await listEnquiryFollowups(id, organization.id, accessContext)
|
await listOpportunityFollowups(id, organization.id, accessContext)
|
||||||
).find(
|
).find(
|
||||||
(item) => item.id === followupId
|
(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 });
|
return NextResponse.json({ message: 'Follow-up not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await updateEnquiryFollowup(
|
const updated = await updateOpportunityFollowup(
|
||||||
id,
|
id,
|
||||||
followupId,
|
followupId,
|
||||||
organization.id,
|
organization.id,
|
||||||
@@ -59,7 +59,7 @@ export async function PATCH(request: NextRequest, { params }: Params) {
|
|||||||
await auditUpdate({
|
await auditUpdate({
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
entityType: 'crm_enquiry_followup',
|
entityType: 'crm_opportunity_followup',
|
||||||
entityId: followupId,
|
entityId: followupId,
|
||||||
beforeData: before,
|
beforeData: before,
|
||||||
afterData: updated
|
afterData: updated
|
||||||
@@ -67,7 +67,7 @@ export async function PATCH(request: NextRequest, { params }: Params) {
|
|||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Enquiry follow-up updated successfully',
|
message: 'Opportunity follow-up updated successfully',
|
||||||
followup: updated
|
followup: updated
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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: 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 {
|
try {
|
||||||
const { id, followupId } = await params;
|
const { id, followupId } = await params;
|
||||||
const { organization, session, membership } = await requireOrganizationAccess({
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmEnquiryFollowupDelete
|
permission: PERMISSIONS.crmOpportunityFollowupDelete
|
||||||
});
|
});
|
||||||
const accessContext = {
|
const accessContext = {
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
@@ -108,7 +108,7 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
|
|||||||
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
|
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
|
||||||
};
|
};
|
||||||
const before = (
|
const before = (
|
||||||
await listEnquiryFollowups(id, organization.id, accessContext)
|
await listOpportunityFollowups(id, organization.id, accessContext)
|
||||||
).find(
|
).find(
|
||||||
(item) => item.id === followupId
|
(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 });
|
return NextResponse.json({ message: 'Follow-up not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await softDeleteEnquiryFollowup(
|
const updated = await softDeleteOpportunityFollowup(
|
||||||
id,
|
id,
|
||||||
followupId,
|
followupId,
|
||||||
organization.id,
|
organization.id,
|
||||||
@@ -128,7 +128,7 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
|
|||||||
await auditDelete({
|
await auditDelete({
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
entityType: 'crm_enquiry_followup',
|
entityType: 'crm_opportunity_followup',
|
||||||
entityId: followupId,
|
entityId: followupId,
|
||||||
beforeData: before,
|
beforeData: before,
|
||||||
afterData: updated
|
afterData: updated
|
||||||
@@ -136,7 +136,7 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
|
|||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Enquiry follow-up deleted successfully'
|
message: 'Opportunity follow-up deleted successfully'
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
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: 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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2,10 +2,10 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { auditCreate } from '@/features/foundation/audit-log/service';
|
import { auditCreate } from '@/features/foundation/audit-log/service';
|
||||||
import {
|
import {
|
||||||
createEnquiryFollowup,
|
createOpportunityFollowup,
|
||||||
listEnquiryFollowups
|
listOpportunityFollowups
|
||||||
} from '@/features/crm/enquiries/server/service';
|
} from '@/features/crm/opportunities/server/service';
|
||||||
import { enquiryFollowupSchema } from '@/features/crm/enquiries/schemas/enquiry.schema';
|
import { opportunityFollowupSchema } from '@/features/crm/opportunities/schemas/opportunity.schema';
|
||||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ type Params = {
|
|||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const followupRequestSchema = enquiryFollowupSchema.extend({
|
const followupRequestSchema = opportunityFollowupSchema.extend({
|
||||||
contactId: z.string().nullable().optional(),
|
contactId: z.string().nullable().optional(),
|
||||||
nextFollowupDate: z.string().nullable().optional()
|
nextFollowupDate: z.string().nullable().optional()
|
||||||
});
|
});
|
||||||
@@ -22,7 +22,7 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
|||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { organization, session, membership } = await requireOrganizationAccess({
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmEnquiryFollowupRead
|
permission: PERMISSIONS.crmOpportunityFollowupRead
|
||||||
});
|
});
|
||||||
const accessContext = {
|
const accessContext = {
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
@@ -35,12 +35,12 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
|||||||
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
|
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
|
||||||
productScopeMode: membership.productScopeMode === '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({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
time: new Date().toISOString(),
|
time: new Date().toISOString(),
|
||||||
message: 'Enquiry follow-ups loaded successfully',
|
message: 'Opportunity follow-ups loaded successfully',
|
||||||
items
|
items
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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: 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 {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { organization, session, membership } = await requireOrganizationAccess({
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmEnquiryFollowupCreate
|
permission: PERMISSIONS.crmOpportunityFollowupCreate
|
||||||
});
|
});
|
||||||
const payload = followupRequestSchema.parse(await request.json());
|
const payload = followupRequestSchema.parse(await request.json());
|
||||||
const accessContext = {
|
const accessContext = {
|
||||||
@@ -74,7 +74,7 @@ export async function POST(request: NextRequest, { params }: Params) {
|
|||||||
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
|
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
|
||||||
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
|
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
|
||||||
};
|
};
|
||||||
const created = await createEnquiryFollowup(
|
const created = await createOpportunityFollowup(
|
||||||
id,
|
id,
|
||||||
organization.id,
|
organization.id,
|
||||||
session.user.id,
|
session.user.id,
|
||||||
@@ -85,7 +85,7 @@ export async function POST(request: NextRequest, { params }: Params) {
|
|||||||
await auditCreate({
|
await auditCreate({
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
entityType: 'crm_enquiry_followup',
|
entityType: 'crm_opportunity_followup',
|
||||||
entityId: created.id,
|
entityId: created.id,
|
||||||
afterData: created
|
afterData: created
|
||||||
});
|
});
|
||||||
@@ -93,7 +93,7 @@ export async function POST(request: NextRequest, { params }: Params) {
|
|||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Enquiry follow-up created successfully',
|
message: 'Opportunity follow-up created successfully',
|
||||||
followup: created
|
followup: created
|
||||||
},
|
},
|
||||||
{ status: 201 }
|
{ 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: 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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { enquiryReopenSchema } from '@/features/crm/enquiries/schemas/enquiry.schema';
|
import {
|
||||||
import { getEnquiryDetail, reopenLostEnquiry } from '@/features/crm/enquiries/server/service';
|
opportunityMarkLostSchema
|
||||||
|
} from '@/features/crm/opportunities/schemas/opportunity.schema';
|
||||||
|
import { getOpportunityDetail, markOpportunityAsLost } from '@/features/crm/opportunities/server/service';
|
||||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
@@ -13,9 +15,9 @@ export async function POST(request: NextRequest, { params }: Params) {
|
|||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { organization, session, membership } = await requireOrganizationAccess({
|
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 = {
|
const accessContext = {
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
@@ -28,15 +30,15 @@ export async function POST(request: NextRequest, { params }: Params) {
|
|||||||
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
|
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
|
||||||
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
|
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
|
||||||
};
|
};
|
||||||
const before = await getEnquiryDetail(id, organization.id, accessContext);
|
const before = await getOpportunityDetail(id, organization.id, accessContext);
|
||||||
const updated = await reopenLostEnquiry(id, organization.id, session.user.id, payload, accessContext);
|
const updated = await markOpportunityAsLost(id, organization.id, session.user.id, payload, accessContext);
|
||||||
const after = await getEnquiryDetail(id, organization.id, accessContext);
|
const after = await getOpportunityDetail(id, organization.id, accessContext);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Enquiry reopened successfully',
|
message: 'Opportunity marked as lost successfully',
|
||||||
before,
|
before,
|
||||||
enquiry: updated,
|
opportunity: updated,
|
||||||
after
|
after
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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: 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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import {
|
import {
|
||||||
enquiryMarkLostSchema
|
opportunityMarkWonSchema
|
||||||
} from '@/features/crm/enquiries/schemas/enquiry.schema';
|
} from '@/features/crm/opportunities/schemas/opportunity.schema';
|
||||||
import { getEnquiryDetail, markEnquiryAsLost } from '@/features/crm/enquiries/server/service';
|
import { getOpportunityDetail, markOpportunityAsWon } from '@/features/crm/opportunities/server/service';
|
||||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
@@ -15,9 +15,9 @@ export async function POST(request: NextRequest, { params }: Params) {
|
|||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { organization, session, membership } = await requireOrganizationAccess({
|
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 = {
|
const accessContext = {
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
@@ -30,15 +30,15 @@ export async function POST(request: NextRequest, { params }: Params) {
|
|||||||
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
|
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
|
||||||
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
|
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
|
||||||
};
|
};
|
||||||
const before = await getEnquiryDetail(id, organization.id, accessContext);
|
const before = await getOpportunityDetail(id, organization.id, accessContext);
|
||||||
const updated = await markEnquiryAsLost(id, organization.id, session.user.id, payload, accessContext);
|
const updated = await markOpportunityAsWon(id, organization.id, session.user.id, payload, accessContext);
|
||||||
const after = await getEnquiryDetail(id, organization.id, accessContext);
|
const after = await getOpportunityDetail(id, organization.id, accessContext);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Enquiry marked as lost successfully',
|
message: 'Opportunity marked as won successfully',
|
||||||
before,
|
before,
|
||||||
enquiry: updated,
|
opportunity: updated,
|
||||||
after
|
after
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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: 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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
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 { getStorageProvider } from '@/features/foundation/storage/service';
|
||||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
@@ -40,7 +40,7 @@ export async function GET(request: NextRequest, { params }: Params) {
|
|||||||
try {
|
try {
|
||||||
const { id, attachmentId } = await params;
|
const { id, attachmentId } = await params;
|
||||||
const { organization, session, membership } = await requireOrganizationAccess({
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmEnquiryRead
|
permission: PERMISSIONS.crmOpportunityRead
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -50,7 +50,7 @@ export async function GET(request: NextRequest, { params }: Params) {
|
|||||||
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
|
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachment = await getEnquiryAttachment(
|
const attachment = await getOpportunityAttachment(
|
||||||
id,
|
id,
|
||||||
attachmentId,
|
attachmentId,
|
||||||
organization.id,
|
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 });
|
return NextResponse.json({ message: 'Unable to load PO attachment' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import {
|
import {
|
||||||
listEnquiryAttachments,
|
listOpportunityAttachments,
|
||||||
uploadPurchaseOrderAttachment
|
uploadPurchaseOrderAttachment
|
||||||
} from '@/features/crm/enquiries/server/service';
|
} from '@/features/crm/opportunities/server/service';
|
||||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
@@ -49,14 +49,14 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
|||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { organization, session, membership } = await requireOrganizationAccess({
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmEnquiryRead
|
permission: PERMISSIONS.crmOpportunityRead
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!canViewCommercialData(membership)) {
|
if (!canViewCommercialData(membership)) {
|
||||||
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
|
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = await listEnquiryAttachments(
|
const items = await listOpportunityAttachments(
|
||||||
id,
|
id,
|
||||||
organization.id,
|
organization.id,
|
||||||
buildAccessContext({
|
buildAccessContext({
|
||||||
@@ -69,7 +69,7 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
time: new Date().toISOString(),
|
time: new Date().toISOString(),
|
||||||
message: 'Enquiry purchase order attachments loaded successfully',
|
message: 'Opportunity purchase order attachments loaded successfully',
|
||||||
items
|
items
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -89,7 +89,7 @@ export async function POST(request: NextRequest, { params }: Params) {
|
|||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { organization, session, membership } = await requireOrganizationAccess({
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmEnquiryMarkWon
|
permission: PERMISSIONS.crmOpportunityMarkWon
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!canViewCommercialData(membership)) {
|
if (!canViewCommercialData(membership)) {
|
||||||
@@ -104,7 +104,7 @@ export async function POST(request: NextRequest, { params }: Params) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const created = await uploadPurchaseOrderAttachment({
|
const created = await uploadPurchaseOrderAttachment({
|
||||||
enquiryId: id,
|
opportunityId: id,
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
description:
|
description:
|
||||||
@@ -139,3 +139,4 @@ export async function POST(request: NextRequest, { params }: Params) {
|
|||||||
return NextResponse.json({ message: 'Unable to upload PO attachment' }, { status: 500 });
|
return NextResponse.json({ message: 'Unable to upload PO attachment' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
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 { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
import { enquiryAssignmentRequestSchema } from '../_schemas';
|
import { opportunityAssignmentRequestSchema } from '../_schemas';
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
@@ -14,9 +14,9 @@ export async function POST(request: NextRequest, { params }: Params) {
|
|||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { organization, session, membership } = await requireOrganizationAccess({
|
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 = {
|
const accessContext = {
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
@@ -29,8 +29,8 @@ export async function POST(request: NextRequest, { params }: Params) {
|
|||||||
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
|
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
|
||||||
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
|
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
|
||||||
};
|
};
|
||||||
const before = await getEnquiryDetail(id, organization.id, accessContext);
|
const before = await getOpportunityDetail(id, organization.id, accessContext);
|
||||||
const updated = await reassignEnquiryToUser(
|
const updated = await reassignOpportunityToUser(
|
||||||
id,
|
id,
|
||||||
organization.id,
|
organization.id,
|
||||||
session.user.id,
|
session.user.id,
|
||||||
@@ -42,7 +42,7 @@ export async function POST(request: NextRequest, { params }: Params) {
|
|||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
branchId: updated.branchId,
|
branchId: updated.branchId,
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
entityType: 'crm_enquiry',
|
entityType: 'crm_opportunity',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
action: 'reassign',
|
action: 'reassign',
|
||||||
beforeData: {
|
beforeData: {
|
||||||
@@ -58,8 +58,8 @@ export async function POST(request: NextRequest, { params }: Params) {
|
|||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Enquiry reassigned successfully',
|
message: 'Opportunity reassigned successfully',
|
||||||
enquiry: updated
|
opportunity: updated
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
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: error.message }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ message: 'Unable to reassign enquiry' }, { status: 500 });
|
return NextResponse.json({ message: 'Unable to reassign opportunity' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import {
|
import { opportunityReopenSchema } from '@/features/crm/opportunities/schemas/opportunity.schema';
|
||||||
enquiryMarkWonSchema
|
import { getOpportunityDetail, reopenLostOpportunity } from '@/features/crm/opportunities/server/service';
|
||||||
} from '@/features/crm/enquiries/schemas/enquiry.schema';
|
|
||||||
import { getEnquiryDetail, markEnquiryAsWon } from '@/features/crm/enquiries/server/service';
|
|
||||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
@@ -15,9 +13,9 @@ export async function POST(request: NextRequest, { params }: Params) {
|
|||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { organization, session, membership } = await requireOrganizationAccess({
|
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 = {
|
const accessContext = {
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
@@ -30,15 +28,15 @@ export async function POST(request: NextRequest, { params }: Params) {
|
|||||||
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
|
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
|
||||||
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
|
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
|
||||||
};
|
};
|
||||||
const before = await getEnquiryDetail(id, organization.id, accessContext);
|
const before = await getOpportunityDetail(id, organization.id, accessContext);
|
||||||
const updated = await markEnquiryAsWon(id, organization.id, session.user.id, payload, accessContext);
|
const updated = await reopenLostOpportunity(id, organization.id, session.user.id, payload, accessContext);
|
||||||
const after = await getEnquiryDetail(id, organization.id, accessContext);
|
const after = await getOpportunityDetail(id, organization.id, accessContext);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Enquiry marked as won successfully',
|
message: 'Opportunity reopened successfully',
|
||||||
before,
|
before,
|
||||||
enquiry: updated,
|
opportunity: updated,
|
||||||
after
|
after
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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: 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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2,12 +2,12 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
|
import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
|
||||||
import {
|
import {
|
||||||
getEnquiryActivity,
|
getOpportunityActivity,
|
||||||
getEnquiryDetail,
|
getOpportunityDetail,
|
||||||
softDeleteEnquiry,
|
softDeleteOpportunity,
|
||||||
updateEnquiry
|
updateOpportunity
|
||||||
} from '@/features/crm/enquiries/server/service';
|
} from '@/features/crm/opportunities/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 { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ type Params = {
|
|||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const enquiryRequestSchema = enquirySchema.extend({
|
const opportunityRequestSchema = opportunitySchema.extend({
|
||||||
contactId: z.string().nullable().optional(),
|
contactId: z.string().nullable().optional(),
|
||||||
branchId: z.string().nullable().optional(),
|
branchId: z.string().nullable().optional(),
|
||||||
leadChannel: z.string().nullable().optional(),
|
leadChannel: z.string().nullable().optional(),
|
||||||
@@ -26,7 +26,7 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
|||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { organization, session, membership } = await requireOrganizationAccess({
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmEnquiryRead
|
permission: PERMISSIONS.crmOpportunityRead
|
||||||
});
|
});
|
||||||
const accessContext = {
|
const accessContext = {
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
@@ -40,16 +40,16 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
|||||||
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
|
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
|
||||||
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
|
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
|
||||||
};
|
};
|
||||||
const [enquiry, activity] = await Promise.all([
|
const [opportunity, activity] = await Promise.all([
|
||||||
getEnquiryDetail(id, organization.id, accessContext),
|
getOpportunityDetail(id, organization.id, accessContext),
|
||||||
getEnquiryActivity(id, organization.id, accessContext)
|
getOpportunityActivity(id, organization.id, accessContext)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
time: new Date().toISOString(),
|
time: new Date().toISOString(),
|
||||||
message: 'Enquiry loaded successfully',
|
message: 'Opportunity loaded successfully',
|
||||||
enquiry,
|
opportunity,
|
||||||
activity
|
activity
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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: 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 {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { organization, session, membership } = await requireOrganizationAccess({
|
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 = {
|
const accessContext = {
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
@@ -84,14 +84,14 @@ export async function PATCH(request: NextRequest, { params }: Params) {
|
|||||||
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
|
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
|
||||||
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
|
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
|
||||||
};
|
};
|
||||||
const before = await getEnquiryDetail(id, organization.id, accessContext);
|
const before = await getOpportunityDetail(id, organization.id, accessContext);
|
||||||
const updated = await updateEnquiry(id, organization.id, session.user.id, payload, accessContext);
|
const updated = await updateOpportunity(id, organization.id, session.user.id, payload, accessContext);
|
||||||
|
|
||||||
await auditUpdate({
|
await auditUpdate({
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
branchId: updated.branchId,
|
branchId: updated.branchId,
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
entityType: 'crm_enquiry',
|
entityType: 'crm_opportunity',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
beforeData: before,
|
beforeData: before,
|
||||||
afterData: updated
|
afterData: updated
|
||||||
@@ -99,8 +99,8 @@ export async function PATCH(request: NextRequest, { params }: Params) {
|
|||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Enquiry updated successfully',
|
message: 'Opportunity updated successfully',
|
||||||
enquiry: updated
|
opportunity: updated
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
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: 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 {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { organization, session, membership } = await requireOrganizationAccess({
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmEnquiryDelete
|
permission: PERMISSIONS.crmOpportunityDelete
|
||||||
});
|
});
|
||||||
const accessContext = {
|
const accessContext = {
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
@@ -140,14 +140,14 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
|
|||||||
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
|
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
|
||||||
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
|
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
|
||||||
};
|
};
|
||||||
const before = await getEnquiryDetail(id, organization.id, accessContext);
|
const before = await getOpportunityDetail(id, organization.id, accessContext);
|
||||||
const updated = await softDeleteEnquiry(id, organization.id, session.user.id, accessContext);
|
const updated = await softDeleteOpportunity(id, organization.id, session.user.id, accessContext);
|
||||||
|
|
||||||
await auditDelete({
|
await auditDelete({
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
branchId: updated.branchId,
|
branchId: updated.branchId,
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
entityType: 'crm_enquiry',
|
entityType: 'crm_opportunity',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
beforeData: before,
|
beforeData: before,
|
||||||
afterData: updated
|
afterData: updated
|
||||||
@@ -155,7 +155,7 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
|
|||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Enquiry deleted successfully'
|
message: 'Opportunity deleted successfully'
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
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: error.message }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ message: 'Unable to delete enquiry' }, { status: 500 });
|
return NextResponse.json({ message: 'Unable to delete opportunity' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { auditAction, auditCreate } from '@/features/foundation/audit-log/service';
|
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 { 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 { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
const enquiryRequestSchema = enquirySchema.extend({
|
const opportunityRequestSchema = opportunitySchema.extend({
|
||||||
contactId: z.string().nullable().optional(),
|
contactId: z.string().nullable().optional(),
|
||||||
assignedToUserId: z.string().nullable().optional(),
|
assignedToUserId: z.string().nullable().optional(),
|
||||||
branchId: z.string().nullable().optional(),
|
branchId: z.string().nullable().optional(),
|
||||||
@@ -18,7 +18,7 @@ const enquiryRequestSchema = enquirySchema.extend({
|
|||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { organization, session, membership } = await requireOrganizationAccess({
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmEnquiryRead
|
permission: PERMISSIONS.crmOpportunityRead
|
||||||
});
|
});
|
||||||
const { searchParams } = request.nextUrl;
|
const { searchParams } = request.nextUrl;
|
||||||
const page = Number(searchParams.get('page') ?? 1);
|
const page = Number(searchParams.get('page') ?? 1);
|
||||||
@@ -42,13 +42,13 @@ export async function GET(request: NextRequest) {
|
|||||||
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
|
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
|
||||||
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
|
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
|
||||||
};
|
};
|
||||||
const result = await listEnquiries(
|
const result = await listOpportunities(
|
||||||
accessContext,
|
accessContext,
|
||||||
{
|
{
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
search,
|
search,
|
||||||
pipelineStage: pipelineStage as 'lead' | 'enquiry' | 'closed_won' | 'closed_lost' | undefined,
|
pipelineStage: pipelineStage as 'lead' | 'opportunity' | 'closed_won' | 'closed_lost' | undefined,
|
||||||
status,
|
status,
|
||||||
productType,
|
productType,
|
||||||
priority,
|
priority,
|
||||||
@@ -62,7 +62,7 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
time: new Date().toISOString(),
|
time: new Date().toISOString(),
|
||||||
message: 'Enquiries loaded successfully',
|
message: 'Opportunities loaded successfully',
|
||||||
totalItems: result.totalItems,
|
totalItems: result.totalItems,
|
||||||
offset,
|
offset,
|
||||||
limit,
|
limit,
|
||||||
@@ -77,16 +77,16 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
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) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { organization, session, membership } = await requireOrganizationAccess({
|
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 = {
|
const accessContext = {
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
@@ -98,7 +98,7 @@ export async function POST(request: NextRequest) {
|
|||||||
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
|
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
|
||||||
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
|
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
|
||||||
};
|
};
|
||||||
const created = await createEnquiry(
|
const created = await createOpportunity(
|
||||||
organization.id,
|
organization.id,
|
||||||
session.user.id,
|
session.user.id,
|
||||||
accessContext,
|
accessContext,
|
||||||
@@ -110,7 +110,7 @@ export async function POST(request: NextRequest) {
|
|||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
branchId: created.branchId,
|
branchId: created.branchId,
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
entityType: 'crm_enquiry',
|
entityType: 'crm_opportunity',
|
||||||
entityId: created.id,
|
entityId: created.id,
|
||||||
afterData: created
|
afterData: created
|
||||||
});
|
});
|
||||||
@@ -120,7 +120,7 @@ export async function POST(request: NextRequest) {
|
|||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
branchId: created.branchId,
|
branchId: created.branchId,
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
entityType: 'crm_enquiry',
|
entityType: 'crm_opportunity',
|
||||||
entityId: created.id,
|
entityId: created.id,
|
||||||
action:
|
action:
|
||||||
payload.assignedToUserId && payload.assignedToUserId !== customer.ownerUserId
|
payload.assignedToUserId && payload.assignedToUserId !== customer.ownerUserId
|
||||||
@@ -137,8 +137,8 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Enquiry created successfully',
|
message: 'Opportunity created successfully',
|
||||||
enquiry: created
|
opportunity: created
|
||||||
},
|
},
|
||||||
{ status: 201 }
|
{ status: 201 }
|
||||||
);
|
);
|
||||||
@@ -158,6 +158,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const quotationType = searchParams.get('quotationType') ?? undefined;
|
const quotationType = searchParams.get('quotationType') ?? undefined;
|
||||||
const branch = searchParams.get('branch') ?? undefined;
|
const branch = searchParams.get('branch') ?? undefined;
|
||||||
const customer = searchParams.get('customer') ?? 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 hot = searchParams.get('hot') ?? undefined;
|
||||||
const sort = searchParams.get('sort') ?? undefined;
|
const sort = searchParams.get('sort') ?? undefined;
|
||||||
const accessContext = buildCrmSecurityContext({
|
const accessContext = buildCrmSecurityContext({
|
||||||
@@ -36,7 +36,7 @@ export async function GET(request: NextRequest) {
|
|||||||
quotationType,
|
quotationType,
|
||||||
branch,
|
branch,
|
||||||
customer,
|
customer,
|
||||||
enquiry,
|
opportunity,
|
||||||
hot,
|
hot,
|
||||||
sort
|
sort
|
||||||
}, accessContext);
|
}, accessContext);
|
||||||
@@ -113,3 +113,4 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ message: 'Unable to create quotation' }, { status: 500 });
|
return NextResponse.json({ message: 'Unable to create quotation' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
} from '@/features/crm/reports/server/exports/service';
|
} from '@/features/crm/reports/server/exports/service';
|
||||||
import { parseSharedReportFilters } from '@/features/crm/reports/server/filters/shared';
|
import { parseSharedReportFilters } from '@/features/crm/reports/server/filters/shared';
|
||||||
import {
|
import {
|
||||||
getCrmEnquiryAgingReportResponse,
|
getCrmOpportunityAgingReportResponse,
|
||||||
getCrmLeadAgingReportResponse,
|
getCrmLeadAgingReportResponse,
|
||||||
getCrmPipelineReportResponse
|
getCrmPipelineReportResponse
|
||||||
} from '@/features/crm/reports/server/service';
|
} from '@/features/crm/reports/server/service';
|
||||||
@@ -18,7 +18,7 @@ function buildExportRows(input: {
|
|||||||
reportCode: string;
|
reportCode: string;
|
||||||
pipeline?: Awaited<ReturnType<typeof getCrmPipelineReportResponse>>;
|
pipeline?: Awaited<ReturnType<typeof getCrmPipelineReportResponse>>;
|
||||||
leadAging?: Awaited<ReturnType<typeof getCrmLeadAgingReportResponse>>;
|
leadAging?: Awaited<ReturnType<typeof getCrmLeadAgingReportResponse>>;
|
||||||
enquiryAging?: Awaited<ReturnType<typeof getCrmEnquiryAgingReportResponse>>;
|
opportunityAging?: Awaited<ReturnType<typeof getCrmOpportunityAgingReportResponse>>;
|
||||||
}) {
|
}) {
|
||||||
switch (input.reportCode) {
|
switch (input.reportCode) {
|
||||||
case 'lead_pipeline':
|
case 'lead_pipeline':
|
||||||
@@ -37,15 +37,15 @@ function buildExportRows(input: {
|
|||||||
String(row.convertedLeads)
|
String(row.convertedLeads)
|
||||||
])
|
])
|
||||||
];
|
];
|
||||||
case 'enquiry_pipeline':
|
case 'opportunity_pipeline':
|
||||||
return [
|
return [
|
||||||
['Salesman', 'Branch', 'Product Type', 'Customer Owner', 'Open Enquiries', 'Converted To Quotation', 'Won', 'Lost'],
|
['Salesman', 'Branch', 'Product Type', 'Customer Owner', 'Open Opportunities', 'Converted To Quotation', 'Won', 'Lost'],
|
||||||
...(input.pipeline?.enquiryPipeline ?? []).map((row) => [
|
...(input.pipeline?.opportunityPipeline ?? []).map((row) => [
|
||||||
row.salesmanName,
|
row.salesmanName,
|
||||||
row.branchLabel,
|
row.branchLabel,
|
||||||
row.productTypeLabel,
|
row.productTypeLabel,
|
||||||
row.customerOwnerName,
|
row.customerOwnerName,
|
||||||
String(row.openEnquiries),
|
String(row.openOpportunities),
|
||||||
String(row.convertedToQuotation),
|
String(row.convertedToQuotation),
|
||||||
String(row.won),
|
String(row.won),
|
||||||
String(row.lost)
|
String(row.lost)
|
||||||
@@ -53,26 +53,26 @@ function buildExportRows(input: {
|
|||||||
];
|
];
|
||||||
case 'lead_conversion':
|
case 'lead_conversion':
|
||||||
return [
|
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) => [
|
...(input.pipeline?.leadConversion ?? []).map((row) => [
|
||||||
row.leadSourceLabel,
|
row.leadSourceLabel,
|
||||||
row.branchLabel,
|
row.branchLabel,
|
||||||
row.productTypeLabel,
|
row.productTypeLabel,
|
||||||
row.marketingUserName,
|
row.marketingUserName,
|
||||||
String(row.totalLeads),
|
String(row.totalLeads),
|
||||||
String(row.convertedEnquiries),
|
String(row.convertedOpportunities),
|
||||||
String(row.conversionRate)
|
String(row.conversionRate)
|
||||||
])
|
])
|
||||||
];
|
];
|
||||||
case 'enquiry_conversion':
|
case 'opportunity_conversion':
|
||||||
return [
|
return [
|
||||||
['Salesman', 'Branch', 'Product Type', 'Total Enquiries', 'Enquiries With Quotation', 'Conversion Rate %'],
|
['Salesman', 'Branch', 'Product Type', 'Total Opportunities', 'Opportunities With Quotation', 'Conversion Rate %'],
|
||||||
...(input.pipeline?.enquiryConversion ?? []).map((row) => [
|
...(input.pipeline?.opportunityConversion ?? []).map((row) => [
|
||||||
row.salesmanName,
|
row.salesmanName,
|
||||||
row.branchLabel,
|
row.branchLabel,
|
||||||
row.productTypeLabel,
|
row.productTypeLabel,
|
||||||
String(row.totalEnquiries),
|
String(row.totalOpportunities),
|
||||||
String(row.enquiriesWithQuotation),
|
String(row.opportunitiesWithQuotation),
|
||||||
String(row.conversionRate)
|
String(row.conversionRate)
|
||||||
])
|
])
|
||||||
];
|
];
|
||||||
@@ -97,11 +97,11 @@ function buildExportRows(input: {
|
|||||||
String(row.agingDays)
|
String(row.agingDays)
|
||||||
])
|
])
|
||||||
];
|
];
|
||||||
case 'enquiry_aging':
|
case 'opportunity_aging':
|
||||||
return [
|
return [
|
||||||
['Enquiry No', 'Customer', 'Salesman', 'Last Follow-Up', 'Aging Days'],
|
['Opportunity No', 'Customer', 'Salesman', 'Last Follow-Up', 'Aging Days'],
|
||||||
...(input.enquiryAging?.rows ?? []).map((row) => [
|
...(input.opportunityAging?.rows ?? []).map((row) => [
|
||||||
row.enquiryCode,
|
row.opportunityCode,
|
||||||
row.customerName,
|
row.customerName,
|
||||||
row.salesmanName ?? '',
|
row.salesmanName ?? '',
|
||||||
row.lastFollowupDate ?? '',
|
row.lastFollowupDate ?? '',
|
||||||
@@ -117,23 +117,23 @@ function getRecordCount(input: {
|
|||||||
reportCode: string;
|
reportCode: string;
|
||||||
pipeline?: Awaited<ReturnType<typeof getCrmPipelineReportResponse>>;
|
pipeline?: Awaited<ReturnType<typeof getCrmPipelineReportResponse>>;
|
||||||
leadAging?: Awaited<ReturnType<typeof getCrmLeadAgingReportResponse>>;
|
leadAging?: Awaited<ReturnType<typeof getCrmLeadAgingReportResponse>>;
|
||||||
enquiryAging?: Awaited<ReturnType<typeof getCrmEnquiryAgingReportResponse>>;
|
opportunityAging?: Awaited<ReturnType<typeof getCrmOpportunityAgingReportResponse>>;
|
||||||
}) {
|
}) {
|
||||||
switch (input.reportCode) {
|
switch (input.reportCode) {
|
||||||
case 'lead_pipeline':
|
case 'lead_pipeline':
|
||||||
return input.pipeline?.leadPipeline.length ?? 0;
|
return input.pipeline?.leadPipeline.length ?? 0;
|
||||||
case 'enquiry_pipeline':
|
case 'opportunity_pipeline':
|
||||||
return input.pipeline?.enquiryPipeline.length ?? 0;
|
return input.pipeline?.opportunityPipeline.length ?? 0;
|
||||||
case 'lead_conversion':
|
case 'lead_conversion':
|
||||||
return input.pipeline?.leadConversion.length ?? 0;
|
return input.pipeline?.leadConversion.length ?? 0;
|
||||||
case 'enquiry_conversion':
|
case 'opportunity_conversion':
|
||||||
return input.pipeline?.enquiryConversion.length ?? 0;
|
return input.pipeline?.opportunityConversion.length ?? 0;
|
||||||
case 'pipeline_funnel':
|
case 'pipeline_funnel':
|
||||||
return input.pipeline?.funnel.length ?? 0;
|
return input.pipeline?.funnel.length ?? 0;
|
||||||
case 'lead_aging':
|
case 'lead_aging':
|
||||||
return input.leadAging?.rows.length ?? 0;
|
return input.leadAging?.rows.length ?? 0;
|
||||||
case 'enquiry_aging':
|
case 'opportunity_aging':
|
||||||
return input.enquiryAging?.rows.length ?? 0;
|
return input.opportunityAging?.rows.length ?? 0;
|
||||||
default:
|
default:
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -155,9 +155,9 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const pipelineCodes = new Set([
|
const pipelineCodes = new Set([
|
||||||
'lead_pipeline',
|
'lead_pipeline',
|
||||||
'enquiry_pipeline',
|
'opportunity_pipeline',
|
||||||
'lead_conversion',
|
'lead_conversion',
|
||||||
'enquiry_conversion',
|
'opportunity_conversion',
|
||||||
'pipeline_funnel'
|
'pipeline_funnel'
|
||||||
]);
|
]);
|
||||||
const pipeline = pipelineCodes.has(reportCode)
|
const pipeline = pipelineCodes.has(reportCode)
|
||||||
@@ -165,11 +165,11 @@ export async function GET(request: NextRequest) {
|
|||||||
: undefined;
|
: undefined;
|
||||||
const leadAging =
|
const leadAging =
|
||||||
reportCode === 'lead_aging' ? await getCrmLeadAgingReportResponse(context, filters) : undefined;
|
reportCode === 'lead_aging' ? await getCrmLeadAgingReportResponse(context, filters) : undefined;
|
||||||
const enquiryAging =
|
const opportunityAging =
|
||||||
reportCode === 'enquiry_aging'
|
reportCode === 'opportunity_aging'
|
||||||
? await getCrmEnquiryAgingReportResponse(context, filters)
|
? await getCrmOpportunityAgingReportResponse(context, filters)
|
||||||
: undefined;
|
: undefined;
|
||||||
const rows = buildExportRows({ reportCode, pipeline, leadAging, enquiryAging });
|
const rows = buildExportRows({ reportCode, pipeline, leadAging, opportunityAging });
|
||||||
|
|
||||||
if (!rows) {
|
if (!rows) {
|
||||||
return NextResponse.json({ message: 'Unsupported report code' }, { status: 400 });
|
return NextResponse.json({ message: 'Unsupported report code' }, { status: 400 });
|
||||||
@@ -186,7 +186,7 @@ export async function GET(request: NextRequest) {
|
|||||||
reportCode,
|
reportCode,
|
||||||
format,
|
format,
|
||||||
filters: { ...filters },
|
filters: { ...filters },
|
||||||
recordCount: getRecordCount({ reportCode, pipeline, leadAging, enquiryAging })
|
recordCount: getRecordCount({ reportCode, pipeline, leadAging, opportunityAging })
|
||||||
});
|
});
|
||||||
|
|
||||||
return new NextResponse(body, {
|
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 });
|
return NextResponse.json({ message: 'Unable to export CRM report' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { resolveCrmAccess } from '@/features/crm/reports/server/context';
|
import { resolveCrmAccess } from '@/features/crm/reports/server/context';
|
||||||
import { parseSharedReportFilters } from '@/features/crm/reports/server/filters/shared';
|
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 { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ export async function GET(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
await getCrmEnquiryAgingReportResponse(
|
await getCrmOpportunityAgingReportResponse(
|
||||||
resolveCrmAccess({
|
resolveCrmAccess({
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
@@ -27,8 +27,9 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ message: 'Unable to load CRM enquiry aging report' },
|
{ message: 'Unable to load CRM opportunity aging report' },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ export default async function CustomerDetailRoute({ params }: PageProps) {
|
|||||||
return (
|
return (
|
||||||
<PageContainer
|
<PageContainer
|
||||||
pageTitle='Customer Detail'
|
pageTitle='Customer Detail'
|
||||||
pageDescription='Overview, contacts, shared contacts, enquiries, quotations, and activity log'
|
pageDescription='Overview, contacts, shared contacts, opportunities, quotations, and activity log'
|
||||||
>
|
>
|
||||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
<CustomerDetailPage id={id} />
|
<CustomerDetailPage id={id} />
|
||||||
@@ -22,3 +22,4 @@ export default async function CustomerDetailRoute({ params }: PageProps) {
|
|||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,25 @@
|
|||||||
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
||||||
import PageContainer from '@/components/layout/page-container';
|
import PageContainer from '@/components/layout/page-container';
|
||||||
import { enquiryByIdOptions } from '@/features/crm-demo/api/queries';
|
import { opportunityByIdOptions } from '@/features/crm-demo/api/queries';
|
||||||
import { EnquiryDetailPage } from '@/features/crm-demo/components/enquiry-detail';
|
import { OpportunityDetailPage } from '@/features/crm-demo/components/opportunity-detail';
|
||||||
import { getQueryClient } from '@/lib/query-client';
|
import { getQueryClient } from '@/lib/query-client';
|
||||||
|
|
||||||
type PageProps = { params: Promise<{ id: string }> };
|
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 { id } = await params;
|
||||||
const queryClient = getQueryClient();
|
const queryClient = getQueryClient();
|
||||||
void queryClient.prefetchQuery(enquiryByIdOptions(id));
|
void queryClient.prefetchQuery(opportunityByIdOptions(id));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer
|
<PageContainer
|
||||||
pageTitle='Enquiry Detail'
|
pageTitle='Opportunity Detail'
|
||||||
pageDescription='Header summary, customer/contact, requirement summary, timeline, and related quotations'
|
pageDescription='Header summary, customer/contact, requirement summary, timeline, and related quotations'
|
||||||
>
|
>
|
||||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
<EnquiryDetailPage id={id} />
|
<OpportunityDetailPage id={id} />
|
||||||
</HydrationBoundary>
|
</HydrationBoundary>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
||||||
import type { SearchParams } from 'nuqs/server';
|
import type { SearchParams } from 'nuqs/server';
|
||||||
import PageContainer from '@/components/layout/page-container';
|
import PageContainer from '@/components/layout/page-container';
|
||||||
import { crmReferenceQueryOptions, enquiriesQueryOptions } from '@/features/crm-demo/api/queries';
|
import { crmReferenceQueryOptions, opportunitiesQueryOptions } from '@/features/crm-demo/api/queries';
|
||||||
import { EnquiriesTablePage } from '@/features/crm-demo/components/enquiries-table';
|
import { OpportunitiesTablePage } from '@/features/crm-demo/components/opportunities-table';
|
||||||
import { getQueryClient } from '@/lib/query-client';
|
import { getQueryClient } from '@/lib/query-client';
|
||||||
import { searchParamsCache } from '@/lib/searchparams';
|
import { searchParamsCache } from '@/lib/searchparams';
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Dashboard: CRM Enquiries'
|
title: 'Dashboard: CRM Opportunities'
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function EnquiriesRoute({
|
export default async function OpportunitiesRoute({
|
||||||
searchParams
|
searchParams
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<SearchParams>;
|
searchParams: Promise<SearchParams>;
|
||||||
@@ -29,7 +29,7 @@ export default async function EnquiriesRoute({
|
|||||||
|
|
||||||
void queryClient.prefetchQuery(crmReferenceQueryOptions());
|
void queryClient.prefetchQuery(crmReferenceQueryOptions());
|
||||||
void queryClient.prefetchQuery(
|
void queryClient.prefetchQuery(
|
||||||
enquiriesQueryOptions({
|
opportunitiesQueryOptions({
|
||||||
page,
|
page,
|
||||||
limit: perPage,
|
limit: perPage,
|
||||||
...(search && { search }),
|
...(search && { search }),
|
||||||
@@ -44,12 +44,13 @@ export default async function EnquiriesRoute({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer
|
<PageContainer
|
||||||
pageTitle='Enquiries'
|
pageTitle='Opportunities'
|
||||||
pageDescription='List ของ sales opportunity พร้อม filter, date range และ convert to quotation'
|
pageDescription='List ของ sales opportunity พร้อม filter, date range และ convert to quotation'
|
||||||
>
|
>
|
||||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
<EnquiriesTablePage />
|
<OpportunitiesTablePage />
|
||||||
</HydrationBoundary>
|
</HydrationBoundary>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export default function CrmDashboardRoute() {
|
|||||||
return (
|
return (
|
||||||
<PageContainer
|
<PageContainer
|
||||||
pageTitle='CRM Dashboard'
|
pageTitle='CRM Dashboard'
|
||||||
pageDescription='ภาพรวม workflow ของ Customer → Enquiry → Quotation → Approval → Won/Lost'
|
pageDescription='ภาพรวม workflow ของ Customer → Opportunity → Quotation → Approval → Won/Lost'
|
||||||
>
|
>
|
||||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
<CrmDashboardPage />
|
<CrmDashboardPage />
|
||||||
@@ -23,3 +23,4 @@ export default function CrmDashboardRoute() {
|
|||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
getCustomerDetail,
|
getCustomerDetail,
|
||||||
getCustomerReferenceData
|
getCustomerReferenceData
|
||||||
} from '@/features/crm/customers/server/service';
|
} 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 { listCustomerQuotationRelations } from '@/features/crm/quotations/server/service';
|
||||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { getQueryClient } from '@/lib/query-client';
|
import { getQueryClient } from '@/lib/query-client';
|
||||||
@@ -100,9 +100,9 @@ export default async function CustomerDetailRoute({ params }: PageProps) {
|
|||||||
resolvedCanRead && session?.user?.activeOrganizationId
|
resolvedCanRead && session?.user?.activeOrganizationId
|
||||||
? await getCustomerReferenceData(session.user.activeOrganizationId)
|
? await getCustomerReferenceData(session.user.activeOrganizationId)
|
||||||
: null;
|
: null;
|
||||||
const relatedEnquiries =
|
const relatedOpportunities =
|
||||||
resolvedCanRead && session?.user?.activeOrganizationId && accessContext
|
resolvedCanRead && session?.user?.activeOrganizationId && accessContext
|
||||||
? await listCustomerEnquiryRelations(id, session.user.activeOrganizationId, accessContext)
|
? await listCustomerOpportunityRelations(id, session.user.activeOrganizationId, accessContext)
|
||||||
: [];
|
: [];
|
||||||
const relatedQuotations =
|
const relatedQuotations =
|
||||||
resolvedCanRead && session?.user?.activeOrganizationId && accessContext
|
resolvedCanRead && session?.user?.activeOrganizationId && accessContext
|
||||||
@@ -135,7 +135,7 @@ export default async function CustomerDetailRoute({ params }: PageProps) {
|
|||||||
canReadContactShares={canContactShareRead}
|
canReadContactShares={canContactShareRead}
|
||||||
canManageContactShares={canContactShareManage}
|
canManageContactShares={canContactShareManage}
|
||||||
canManageOwner={canOwnerManage}
|
canManageOwner={canOwnerManage}
|
||||||
relatedEnquiries={relatedEnquiries}
|
relatedOpportunities={relatedOpportunities}
|
||||||
relatedQuotations={relatedQuotations}
|
relatedQuotations={relatedQuotations}
|
||||||
/>
|
/>
|
||||||
</HydrationBoundary>
|
</HydrationBoundary>
|
||||||
@@ -143,3 +143,4 @@ export default async function CustomerDetailRoute({ params }: PageProps) {
|
|||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<SearchParams>;
|
|
||||||
};
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<PageContainer access={false} pageTitle='CRM Opportunities'>
|
|
||||||
{null}
|
|
||||||
</PageContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const referenceData = await getEnquiryReferenceData(session.user.activeOrganizationId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContainer
|
|
||||||
pageTitle='CRM Opportunities'
|
|
||||||
pageDescription='Sales execution workspace for opportunities created directly or handed off from marketing leads.'
|
|
||||||
pageHeaderAction={canCreate ? <EnquiryFormSheetTrigger referenceData={referenceData} /> : null}
|
|
||||||
>
|
|
||||||
<EnquiryListing
|
|
||||||
referenceData={referenceData}
|
|
||||||
workspace='enquiry'
|
|
||||||
canUpdate={canUpdate}
|
|
||||||
canDelete={canDelete}
|
|
||||||
canAssign={canAssign}
|
|
||||||
canReassign={canReassign}
|
|
||||||
/>
|
|
||||||
</PageContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -64,7 +64,7 @@ export default async function LeadsRoute(props: PageProps) {
|
|||||||
return (
|
return (
|
||||||
<PageContainer
|
<PageContainer
|
||||||
pageTitle="CRM Leads"
|
pageTitle="CRM Leads"
|
||||||
pageDescription="Marketing-qualified lead workspace before handoff into sales enquiries."
|
pageDescription="Marketing-qualified lead workspace before handoff into sales opportunities."
|
||||||
pageHeaderAction={
|
pageHeaderAction={
|
||||||
canCreate ? <LeadFormTrigger referenceData={referenceData} /> : null
|
canCreate ? <LeadFormTrigger referenceData={referenceData} /> : null
|
||||||
}
|
}
|
||||||
@@ -80,3 +80,4 @@ export default async function LeadsRoute(props: PageProps) {
|
|||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import PageContainer from '@/components/layout/page-container';
|
import PageContainer from '@/components/layout/page-container';
|
||||||
import {
|
import {
|
||||||
enquiryByIdOptions,
|
opportunityByIdOptions,
|
||||||
enquiryFollowupsOptions,
|
opportunityFollowupsOptions,
|
||||||
enquiryProjectPartiesOptions
|
opportunityProjectPartiesOptions
|
||||||
} from '@/features/crm/enquiries/api/queries';
|
} from '@/features/crm/opportunities/api/queries';
|
||||||
import { EnquiryDetail } from '@/features/crm/enquiries/components/enquiry-detail';
|
import { OpportunityDetail } from '@/features/crm/opportunities/components/opportunity-detail';
|
||||||
import { getEnquiryDetail, getEnquiryReferenceData } from '@/features/crm/enquiries/server/service';
|
import { getOpportunityDetail, getOpportunityReferenceData } from '@/features/crm/opportunities/server/service';
|
||||||
import { getLeadById } from '@/features/crm/leads/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 { getQuotationReferenceData } from '@/features/crm/quotations/server/service';
|
||||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { getQueryClient } from '@/lib/query-client';
|
import { getQueryClient } from '@/lib/query-client';
|
||||||
@@ -19,7 +19,7 @@ type PageProps = {
|
|||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function EnquiryDetailRoute({ params }: PageProps) {
|
export default async function OpportunityDetailRoute({ params }: PageProps) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
@@ -27,61 +27,61 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
|
|||||||
session?.user?.systemRole === 'super_admin' ||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
(!!session?.user?.activeOrganizationId &&
|
(!!session?.user?.activeOrganizationId &&
|
||||||
(session.user.activeMembershipRole === 'admin' ||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryRead)));
|
session.user.activePermissions.includes(PERMISSIONS.crmOpportunityRead)));
|
||||||
|
|
||||||
const canUpdate =
|
const canUpdate =
|
||||||
session?.user?.systemRole === 'super_admin' ||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
(!!session?.user?.activeOrganizationId &&
|
(!!session?.user?.activeOrganizationId &&
|
||||||
(session.user.activeMembershipRole === 'admin' ||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryUpdate)));
|
session.user.activePermissions.includes(PERMISSIONS.crmOpportunityUpdate)));
|
||||||
|
|
||||||
const canAssign =
|
const canAssign =
|
||||||
session?.user?.systemRole === 'super_admin' ||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
(!!session?.user?.activeOrganizationId &&
|
(!!session?.user?.activeOrganizationId &&
|
||||||
(session.user.activeMembershipRole === 'admin' ||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryAssign)));
|
session.user.activePermissions.includes(PERMISSIONS.crmOpportunityAssign)));
|
||||||
|
|
||||||
const canReassign =
|
const canReassign =
|
||||||
session?.user?.systemRole === 'super_admin' ||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
(!!session?.user?.activeOrganizationId &&
|
(!!session?.user?.activeOrganizationId &&
|
||||||
(session.user.activeMembershipRole === 'admin' ||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryReassign)));
|
session.user.activePermissions.includes(PERMISSIONS.crmOpportunityReassign)));
|
||||||
|
|
||||||
const canManageFollowups = {
|
const canManageFollowups = {
|
||||||
create:
|
create:
|
||||||
session?.user?.systemRole === 'super_admin' ||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
(!!session?.user?.activeOrganizationId &&
|
(!!session?.user?.activeOrganizationId &&
|
||||||
(session.user.activeMembershipRole === 'admin' ||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryFollowupCreate))),
|
session.user.activePermissions.includes(PERMISSIONS.crmOpportunityFollowupCreate))),
|
||||||
update:
|
update:
|
||||||
session?.user?.systemRole === 'super_admin' ||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
(!!session?.user?.activeOrganizationId &&
|
(!!session?.user?.activeOrganizationId &&
|
||||||
(session.user.activeMembershipRole === 'admin' ||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryFollowupUpdate))),
|
session.user.activePermissions.includes(PERMISSIONS.crmOpportunityFollowupUpdate))),
|
||||||
delete:
|
delete:
|
||||||
session?.user?.systemRole === 'super_admin' ||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
(!!session?.user?.activeOrganizationId &&
|
(!!session?.user?.activeOrganizationId &&
|
||||||
(session.user.activeMembershipRole === 'admin' ||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryFollowupDelete)))
|
session.user.activePermissions.includes(PERMISSIONS.crmOpportunityFollowupDelete)))
|
||||||
};
|
};
|
||||||
|
|
||||||
const canMarkWon =
|
const canMarkWon =
|
||||||
session?.user?.systemRole === 'super_admin' ||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
(!!session?.user?.activeOrganizationId &&
|
(!!session?.user?.activeOrganizationId &&
|
||||||
(session.user.activeMembershipRole === 'admin' ||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryMarkWon)));
|
session.user.activePermissions.includes(PERMISSIONS.crmOpportunityMarkWon)));
|
||||||
|
|
||||||
const canMarkLost =
|
const canMarkLost =
|
||||||
session?.user?.systemRole === 'super_admin' ||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
(!!session?.user?.activeOrganizationId &&
|
(!!session?.user?.activeOrganizationId &&
|
||||||
(session.user.activeMembershipRole === 'admin' ||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryMarkLost)));
|
session.user.activePermissions.includes(PERMISSIONS.crmOpportunityMarkLost)));
|
||||||
|
|
||||||
const canReopen =
|
const canReopen =
|
||||||
session?.user?.systemRole === 'super_admin' ||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
(!!session?.user?.activeOrganizationId &&
|
(!!session?.user?.activeOrganizationId &&
|
||||||
(session.user.activeMembershipRole === 'admin' ||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryReopen)));
|
session.user.activePermissions.includes(PERMISSIONS.crmOpportunityReopen)));
|
||||||
|
|
||||||
const canViewRelatedQuotations =
|
const canViewRelatedQuotations =
|
||||||
session?.user?.systemRole === 'super_admin' ||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
@@ -118,17 +118,17 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
|
|||||||
|
|
||||||
const queryClient = getQueryClient();
|
const queryClient = getQueryClient();
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.prefetchQuery(enquiryByIdOptions(id)),
|
queryClient.prefetchQuery(opportunityByIdOptions(id)),
|
||||||
queryClient.prefetchQuery(enquiryFollowupsOptions(id)),
|
queryClient.prefetchQuery(opportunityFollowupsOptions(id)),
|
||||||
queryClient.prefetchQuery(enquiryProjectPartiesOptions(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([
|
const [referenceData, relatedQuotations, sourceLead, quotationReferenceData] = await Promise.all([
|
||||||
getEnquiryReferenceData(session.user.activeOrganizationId),
|
getOpportunityReferenceData(session.user.activeOrganizationId),
|
||||||
listEnquiryQuotationRelations(id, session.user.activeOrganizationId),
|
listOpportunityQuotationRelations(id, session.user.activeOrganizationId),
|
||||||
enquiryDetail.leadId
|
opportunityDetail.leadId
|
||||||
? getLeadById(enquiryDetail.leadId, session.user.activeOrganizationId).catch(() => null)
|
? getLeadById(opportunityDetail.leadId, session.user.activeOrganizationId).catch(() => null)
|
||||||
: Promise.resolve(null),
|
: Promise.resolve(null),
|
||||||
canCreateQuotation ? getQuotationReferenceData(session.user.activeOrganizationId) : 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.'
|
pageDescription='Sales execution workspace for follow-up continuity, quotation readiness, and outcome control.'
|
||||||
>
|
>
|
||||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
<EnquiryDetail
|
<OpportunityDetail
|
||||||
enquiryId={id}
|
opportunityId={id}
|
||||||
referenceData={referenceData}
|
referenceData={referenceData}
|
||||||
relatedQuotations={relatedQuotations}
|
relatedQuotations={relatedQuotations}
|
||||||
workspace='enquiry'
|
workspace='opportunity'
|
||||||
canUpdate={canUpdate}
|
canUpdate={canUpdate}
|
||||||
canAssign={canAssign}
|
canAssign={canAssign}
|
||||||
canReassign={canReassign}
|
canReassign={canReassign}
|
||||||
@@ -171,3 +171,4 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
|
|||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
93
src/app/dashboard/crm/opportunities/page.tsx
Normal file
93
src/app/dashboard/crm/opportunities/page.tsx
Normal file
@@ -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<SearchParams>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<PageContainer access={false} pageTitle="CRM Opportunities">
|
||||||
|
{null}
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
searchParamsCache.parse(searchParams);
|
||||||
|
const referenceData = await getOpportunityReferenceData(
|
||||||
|
session.user.activeOrganizationId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer
|
||||||
|
pageTitle="CRM Opportunities"
|
||||||
|
pageDescription="Sales execution workspace for opportunities created directly or handed off from marketing leads."
|
||||||
|
pageHeaderAction={
|
||||||
|
canCreate ? (
|
||||||
|
<OpportunityFormSheetTrigger referenceData={referenceData} />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<OpportunityListing
|
||||||
|
referenceData={referenceData}
|
||||||
|
workspace="opportunity"
|
||||||
|
canUpdate={canUpdate}
|
||||||
|
canDelete={canDelete}
|
||||||
|
canAssign={canAssign}
|
||||||
|
canReassign={canReassign}
|
||||||
|
/>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,7 +2,7 @@ import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
|||||||
import { auth } from '@/auth';
|
import { auth } from '@/auth';
|
||||||
import PageContainer from '@/components/layout/page-container';
|
import PageContainer from '@/components/layout/page-container';
|
||||||
import {
|
import {
|
||||||
crmEnquiryAgingReportQueryOptions,
|
crmOpportunityAgingReportQueryOptions,
|
||||||
crmReportFiltersQueryOptions
|
crmReportFiltersQueryOptions
|
||||||
} from '@/features/crm/reports/api/queries';
|
} from '@/features/crm/reports/api/queries';
|
||||||
import { AgingReportView } from '@/features/crm/reports/components/aging-report-view';
|
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';
|
import type { SearchParams } from 'nuqs/server';
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'CRM Enquiry Aging Report'
|
title: 'CRM Opportunity Aging Report'
|
||||||
};
|
};
|
||||||
|
|
||||||
type PageProps = {
|
type PageProps = {
|
||||||
@@ -22,7 +22,7 @@ function getFilterValue(value: string | string[] | undefined): string | null {
|
|||||||
return typeof value === 'string' ? value : 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 searchParams = await props.searchParams;
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const canRead =
|
const canRead =
|
||||||
@@ -48,25 +48,26 @@ export default async function CrmEnquiryAgingRoute(props: PageProps) {
|
|||||||
|
|
||||||
if (canRead) {
|
if (canRead) {
|
||||||
void queryClient.prefetchQuery(crmReportFiltersQueryOptions());
|
void queryClient.prefetchQuery(crmReportFiltersQueryOptions());
|
||||||
void queryClient.prefetchQuery(crmEnquiryAgingReportQueryOptions(filters));
|
void queryClient.prefetchQuery(crmOpportunityAgingReportQueryOptions(filters));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer
|
<PageContainer
|
||||||
pageTitle='Enquiry Aging Report'
|
pageTitle='Opportunity Aging Report'
|
||||||
pageDescription='Identify stalled enquiries that need follow-up or progression.'
|
pageDescription='Identify stalled opportunities that need follow-up or progression.'
|
||||||
access={canRead}
|
access={canRead}
|
||||||
accessFallback={
|
accessFallback={
|
||||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
||||||
You do not have access to CRM enquiry aging reports.
|
You do not have access to CRM opportunity aging reports.
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{canRead ? (
|
{canRead ? (
|
||||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
<AgingReportView variant='enquiry' canExport={canExport} />
|
<AgingReportView variant='opportunity' canExport={canExport} />
|
||||||
</HydrationBoundary>
|
</HydrationBoundary>
|
||||||
) : null}
|
) : null}
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ export default async function CrmPipelineReportsRoute(props: PageProps) {
|
|||||||
return (
|
return (
|
||||||
<PageContainer
|
<PageContainer
|
||||||
pageTitle='Pipeline Reports'
|
pageTitle='Pipeline Reports'
|
||||||
pageDescription='Lead, enquiry, conversion, and funnel analytics for the CRM pipeline lifecycle.'
|
pageDescription='Lead, opportunity, conversion, and funnel analytics for the CRM pipeline lifecycle.'
|
||||||
access={canRead}
|
access={canRead}
|
||||||
accessFallback={
|
accessFallback={
|
||||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
||||||
@@ -72,3 +72,4 @@ export default async function CrmPipelineReportsRoute(props: PageProps) {
|
|||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,211 +1,184 @@
|
|||||||
import { NavGroup } from "@/types";
|
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[] =
|
||||||
/**
|
[
|
||||||
* Navigation configuration with RBAC support
|
{
|
||||||
*
|
label: "Overview",
|
||||||
* This configuration is used for both the sidebar navigation and Cmd+K bar.
|
items: [
|
||||||
* Items are organized into groups, each rendered with a SidebarGroupLabel.
|
{
|
||||||
*
|
title: "Dashboard Overview",
|
||||||
* RBAC Access Control:
|
url: "/dashboard",
|
||||||
* Each navigation item can have an `access` property that controls visibility
|
icon: "dashboard",
|
||||||
* based on permissions, plans, features, roles, and organization context.
|
isActive: false,
|
||||||
*
|
shortcut: ["d", "d"],
|
||||||
* Examples:
|
items: [],
|
||||||
*
|
},
|
||||||
* 1. Require organization:
|
{
|
||||||
* access: { requireOrg: true }
|
title: "Workspaces",
|
||||||
*
|
url: "/dashboard/workspaces",
|
||||||
* 2. Require specific permission:
|
icon: "workspace",
|
||||||
* access: { requireOrg: true, permission: 'org:teams:manage' }
|
isActive: false,
|
||||||
*
|
items: [],
|
||||||
* 3. Require specific plan:
|
access: { systemRole: "super_admin" },
|
||||||
* access: { plan: 'pro' }
|
},
|
||||||
*
|
{
|
||||||
* 4. Require specific feature:
|
title: "Teams",
|
||||||
* access: { feature: 'premium_access' }
|
url: "/dashboard/workspaces/team",
|
||||||
*
|
icon: "teams",
|
||||||
* 5. Require specific role:
|
isActive: false,
|
||||||
* access: { role: 'admin' }
|
items: [],
|
||||||
*
|
access: { requireOrg: true, permission: "crm.quotation.read" },
|
||||||
* 6. Multiple conditions (all must be true):
|
},
|
||||||
* access: { requireOrg: true, permission: 'org:teams:manage', plan: 'pro' }
|
{
|
||||||
*
|
title: "Users",
|
||||||
* Note: The `visible` function is deprecated but still supported for backward compatibility.
|
url: "/dashboard/users",
|
||||||
* Use the `access` property for new items.
|
icon: "teams",
|
||||||
*/
|
shortcut: ["u", "u"],
|
||||||
export const navGroups: NavGroup[] = [
|
isActive: false,
|
||||||
{
|
items: [],
|
||||||
label: "Overview",
|
access: { requireOrg: true, permission: "users:manage" },
|
||||||
items: [
|
},
|
||||||
{
|
{
|
||||||
title: "Dashboard Overview",
|
title: "Kanban",
|
||||||
url: "/dashboard",
|
url: "/dashboard/kanban",
|
||||||
icon: "dashboard",
|
icon: "kanban",
|
||||||
isActive: false,
|
shortcut: ["k", "k"],
|
||||||
shortcut: ["d", "d"],
|
isActive: false,
|
||||||
items: [],
|
items: [],
|
||||||
},
|
},
|
||||||
{
|
],
|
||||||
title: "Workspaces",
|
},
|
||||||
url: "/dashboard/workspaces",
|
{
|
||||||
icon: "workspace",
|
label: "CRM",
|
||||||
isActive: false,
|
items: [
|
||||||
items: [],
|
{
|
||||||
access: { systemRole: "super_admin" },
|
title: "Dashboard",
|
||||||
},
|
url: "/dashboard/crm",
|
||||||
{
|
icon: "dashboard",
|
||||||
title: "Teams",
|
isActive: false,
|
||||||
url: "/dashboard/workspaces/team",
|
items: [],
|
||||||
icon: "teams",
|
access: { requireOrg: true, permission: "crm.dashboard.read" },
|
||||||
isActive: false,
|
},
|
||||||
items: [],
|
{
|
||||||
access: { requireOrg: true, permission: "crm.quotation.read" },
|
title: "ลูกค้า",
|
||||||
},
|
url: "/dashboard/crm/customers",
|
||||||
{
|
icon: "teams",
|
||||||
title: "Users",
|
isActive: false,
|
||||||
url: "/dashboard/users",
|
items: [],
|
||||||
icon: "teams",
|
access: { requireOrg: true, permission: "crm.customer.read" },
|
||||||
shortcut: ["u", "u"],
|
},
|
||||||
isActive: false,
|
{
|
||||||
items: [],
|
title: "ลีด",
|
||||||
access: { requireOrg: true, permission: "users:manage" },
|
url: "/dashboard/crm/leads",
|
||||||
},
|
icon: "forms",
|
||||||
{
|
isActive: false,
|
||||||
title: "Kanban",
|
items: [],
|
||||||
url: "/dashboard/kanban",
|
access: { requireOrg: true, permission: "crm.lead.read" },
|
||||||
icon: "kanban",
|
},
|
||||||
shortcut: ["k", "k"],
|
{
|
||||||
isActive: false,
|
title: "โอกาสขาย",
|
||||||
items: [],
|
url: "/dashboard/crm/opportunities",
|
||||||
},
|
icon: "forms",
|
||||||
],
|
isActive: false,
|
||||||
},
|
items: [],
|
||||||
{
|
access: { requireOrg: true, permission: "crm.opportunity.read" },
|
||||||
label: "CRM",
|
},
|
||||||
items: [
|
{
|
||||||
{
|
title: "ใบเสนอราคา",
|
||||||
title: "Dashboard",
|
url: "/dashboard/crm/quotations",
|
||||||
url: "/dashboard/crm",
|
icon: "post",
|
||||||
icon: "dashboard",
|
isActive: false,
|
||||||
isActive: false,
|
items: [],
|
||||||
items: [],
|
access: { requireOrg: true, role: "admin" },
|
||||||
access: { requireOrg: true, permission: "crm.dashboard.read" },
|
},
|
||||||
},
|
{
|
||||||
{
|
title: "อนุมัติเอกสาร",
|
||||||
title: "ลูกค้า",
|
url: "/dashboard/crm/approvals",
|
||||||
url: "/dashboard/crm/customers",
|
icon: "checks",
|
||||||
icon: "teams",
|
isActive: false,
|
||||||
isActive: false,
|
items: [],
|
||||||
items: [],
|
access: { requireOrg: true, permission: "crm.approval.read" },
|
||||||
access: { requireOrg: true, permission: "crm.customer.read" },
|
},
|
||||||
},
|
{
|
||||||
{
|
title: "Reports",
|
||||||
title: "ลีด",
|
url: "/dashboard/crm/reports",
|
||||||
url: "/dashboard/crm/leads",
|
icon: "dashboard",
|
||||||
icon: "forms",
|
isActive: false,
|
||||||
isActive: false,
|
items: [
|
||||||
items: [],
|
{
|
||||||
access: { requireOrg: true, permission: "crm.lead.read" },
|
title: "Pipeline Suite",
|
||||||
},
|
url: "/dashboard/crm/reports/pipeline",
|
||||||
{
|
access: { requireOrg: true, permission: "crm.report.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",
|
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
title: "Lead Aging",
|
||||||
title: "Master Options",
|
url: "/dashboard/crm/reports/lead-aging",
|
||||||
url: "/dashboard/crm/settings/master-options",
|
access: { requireOrg: true, permission: "crm.report.read" },
|
||||||
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: "Opportunity Aging",
|
||||||
title: "Approval Workflows",
|
url: "/dashboard/crm/reports/opportunity-aging",
|
||||||
url: "/dashboard/crm/settings/approval-workflows",
|
access: { requireOrg: true, permission: "crm.report.read" },
|
||||||
access: {
|
|
||||||
requireOrg: true,
|
|
||||||
permission: "crm.approval.workflow.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: "Templates",
|
{
|
||||||
url: "/dashboard/crm/settings/templates",
|
title: "Roles & Permissions",
|
||||||
access: {
|
url: "/dashboard/crm/settings/roles",
|
||||||
requireOrg: true,
|
access: { requireOrg: true, permission: "crm.role.read" },
|
||||||
permission: "crm.document_template.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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|||||||
@@ -325,8 +325,8 @@ export const crmContactShares = pgTable(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
export const crmEnquiries = pgTable(
|
export const crmOpportunities = pgTable(
|
||||||
'crm_enquiries',
|
'crm_opportunities',
|
||||||
{
|
{
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
organizationId: text('organization_id').notNull(),
|
organizationId: text('organization_id').notNull(),
|
||||||
@@ -374,17 +374,17 @@ export const crmEnquiries = pgTable(
|
|||||||
updatedBy: text('updated_by').notNull()
|
updatedBy: text('updated_by').notNull()
|
||||||
},
|
},
|
||||||
(table) => ({
|
(table) => ({
|
||||||
organizationCodeIdx: uniqueIndex('crm_enquiries_org_code_idx').on(
|
organizationCodeIdx: uniqueIndex('crm_opportunities_org_code_idx').on(
|
||||||
table.organizationId,
|
table.organizationId,
|
||||||
table.code
|
table.code
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
export const crmEnquiryFollowups = pgTable('crm_enquiry_followups', {
|
export const crmOpportunityFollowups = pgTable('crm_opportunity_followups', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
organizationId: text('organization_id').notNull(),
|
organizationId: text('organization_id').notNull(),
|
||||||
enquiryId: text('enquiry_id').notNull(),
|
opportunityId: text('opportunity_id').notNull(),
|
||||||
followupDate: timestamp('followup_date', { withTimezone: true }).notNull(),
|
followupDate: timestamp('followup_date', { withTimezone: true }).notNull(),
|
||||||
followupType: text('followup_type').notNull(),
|
followupType: text('followup_type').notNull(),
|
||||||
contactId: text('contact_id'),
|
contactId: text('contact_id'),
|
||||||
@@ -399,10 +399,10 @@ export const crmEnquiryFollowups = pgTable('crm_enquiry_followups', {
|
|||||||
updatedBy: text('updated_by').notNull()
|
updatedBy: text('updated_by').notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const crmEnquiryCustomers = pgTable('crm_enquiry_customers', {
|
export const crmOpportunityCustomers = pgTable('crm_opportunity_customers', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
organizationId: text('organization_id').notNull(),
|
organizationId: text('organization_id').notNull(),
|
||||||
enquiryId: text('enquiry_id').notNull(),
|
opportunityId: text('opportunity_id').notNull(),
|
||||||
customerId: text('customer_id').notNull(),
|
customerId: text('customer_id').notNull(),
|
||||||
role: text('role').notNull(),
|
role: text('role').notNull(),
|
||||||
remark: text('remark'),
|
remark: text('remark'),
|
||||||
@@ -411,10 +411,10 @@ export const crmEnquiryCustomers = pgTable('crm_enquiry_customers', {
|
|||||||
deletedAt: timestamp('deleted_at', { withTimezone: true })
|
deletedAt: timestamp('deleted_at', { withTimezone: true })
|
||||||
});
|
});
|
||||||
|
|
||||||
export const crmEnquiryAttachments = pgTable('crm_enquiry_attachments', {
|
export const crmOpportunityAttachments = pgTable('crm_opportunity_attachments', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
organizationId: text('organization_id').notNull(),
|
organizationId: text('organization_id').notNull(),
|
||||||
enquiryId: text('enquiry_id').notNull(),
|
opportunityId: text('opportunity_id').notNull(),
|
||||||
category: text('category').notNull(),
|
category: text('category').notNull(),
|
||||||
fileName: text('file_name').notNull(),
|
fileName: text('file_name').notNull(),
|
||||||
originalFileName: text('original_file_name').notNull(),
|
originalFileName: text('original_file_name').notNull(),
|
||||||
@@ -459,7 +459,7 @@ export const crmQuotations = pgTable(
|
|||||||
organizationId: text('organization_id').notNull(),
|
organizationId: text('organization_id').notNull(),
|
||||||
branchId: text('branch_id'),
|
branchId: text('branch_id'),
|
||||||
code: text('code').notNull(),
|
code: text('code').notNull(),
|
||||||
enquiryId: text('enquiry_id'),
|
opportunityId: text('opportunity_id'),
|
||||||
customerId: text('customer_id').notNull(),
|
customerId: text('customer_id').notNull(),
|
||||||
contactId: text('contact_id'),
|
contactId: text('contact_id'),
|
||||||
quotationDate: timestamp('quotation_date', { withTimezone: true }).notNull(),
|
quotationDate: timestamp('quotation_date', { withTimezone: true }).notNull(),
|
||||||
@@ -804,3 +804,4 @@ export const crmDocumentTemplateTableColumns = pgTable(
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ const FOUNDATION_OPTIONS = {
|
|||||||
parentCategory: 'crm_customer_group'
|
parentCategory: 'crm_customer_group'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
crm_enquiry_status: [
|
crm_opportunity_status: [
|
||||||
{ code: 'new', label: 'New', value: 'new', sortOrder: 1 },
|
{ code: 'new', label: 'New', value: 'new', sortOrder: 1 },
|
||||||
{ code: 'qualifying', label: 'Qualifying', value: 'qualifying', sortOrder: 2 },
|
{ code: 'qualifying', label: 'Qualifying', value: 'qualifying', sortOrder: 2 },
|
||||||
{ code: 'requirement', label: 'Requirement', value: 'requirement', sortOrder: 3 },
|
{ code: 'requirement', label: 'Requirement', value: 'requirement', sortOrder: 3 },
|
||||||
@@ -468,15 +468,15 @@ const REPORT_DEFINITIONS: ReportDefinitionSeed[] = [
|
|||||||
category: 'pipeline'
|
category: 'pipeline'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: 'enquiry_pipeline',
|
code: 'opportunity_pipeline',
|
||||||
name: 'Enquiry Pipeline',
|
name: 'Opportunity Pipeline',
|
||||||
description: 'Operational report for open enquiries, quotation progress, and sales pipeline health.',
|
description: 'Operational report for open opportunities, quotation progress, and sales pipeline health.',
|
||||||
category: 'pipeline'
|
category: 'pipeline'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: 'enquiry_aging',
|
code: 'opportunity_aging',
|
||||||
name: 'Enquiry Aging',
|
name: 'Opportunity Aging',
|
||||||
description: 'Operational report for stalled enquiries and aging opportunity follow-ups.',
|
description: 'Operational report for stalled opportunities and aging opportunity follow-ups.',
|
||||||
category: 'pipeline'
|
category: 'pipeline'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -488,13 +488,13 @@ const REPORT_DEFINITIONS: ReportDefinitionSeed[] = [
|
|||||||
{
|
{
|
||||||
code: 'lead_conversion',
|
code: 'lead_conversion',
|
||||||
name: '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'
|
category: 'pipeline'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: 'enquiry_conversion',
|
code: 'opportunity_conversion',
|
||||||
name: 'Enquiry Conversion',
|
name: 'Opportunity Conversion',
|
||||||
description: 'Sales conversion report from enquiry creation into quotation generation.',
|
description: 'Sales conversion report from opportunity creation into quotation generation.',
|
||||||
category: 'pipeline'
|
category: 'pipeline'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -520,9 +520,9 @@ const REPORT_DEFINITIONS: ReportDefinitionSeed[] = [
|
|||||||
const DOCUMENT_SEQUENCES = [
|
const DOCUMENT_SEQUENCES = [
|
||||||
{ documentType: 'customer', prefix: 'CUS' },
|
{ documentType: 'customer', prefix: 'CUS' },
|
||||||
{ documentType: 'contact', prefix: 'CON' },
|
{ documentType: 'contact', prefix: 'CON' },
|
||||||
{ documentType: 'enquiry', prefix: 'ENQ' },
|
{ documentType: 'opportunity', prefix: 'ENQ' },
|
||||||
{ documentType: 'crm_lead', prefix: 'LD' },
|
{ documentType: 'crm_lead', prefix: 'LD' },
|
||||||
{ documentType: 'crm_enquiry', prefix: 'EN' },
|
{ documentType: 'crm_opportunity', prefix: 'EN' },
|
||||||
{ documentType: 'quotation', prefix: 'QT' },
|
{ documentType: 'quotation', prefix: 'QT' },
|
||||||
{ documentType: 'approval', prefix: 'APV' }
|
{ documentType: 'approval', prefix: 'APV' }
|
||||||
];
|
];
|
||||||
@@ -1229,3 +1229,4 @@ if (require.main === module) {
|
|||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { mutationOptions } from '@tanstack/react-query';
|
|||||||
import { getQueryClient } from '@/lib/query-client';
|
import { getQueryClient } from '@/lib/query-client';
|
||||||
import {
|
import {
|
||||||
approveQuotation,
|
approveQuotation,
|
||||||
convertEnquiryToQuotation,
|
convertOpportunityToQuotation,
|
||||||
createCustomer,
|
createCustomer,
|
||||||
createQuotationRevision,
|
createQuotationRevision,
|
||||||
markQuotationStatus,
|
markQuotationStatus,
|
||||||
@@ -35,8 +35,8 @@ export const updateCustomerMutation = mutationOptions({
|
|||||||
onSuccess: invalidateCrm
|
onSuccess: invalidateCrm
|
||||||
});
|
});
|
||||||
|
|
||||||
export const convertEnquiryMutation = mutationOptions({
|
export const convertOpportunityMutation = mutationOptions({
|
||||||
mutationFn: (enquiryId: string) => convertEnquiryToQuotation(enquiryId),
|
mutationFn: (opportunityId: string) => convertOpportunityToQuotation(opportunityId),
|
||||||
onSuccess: invalidateCrm
|
onSuccess: invalidateCrm
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,3 +76,4 @@ export const revokeContactShareMutation = mutationOptions({
|
|||||||
revokeContactShare(contactId, shareId),
|
revokeContactShare(contactId, shareId),
|
||||||
onSuccess: invalidateCrm
|
onSuccess: invalidateCrm
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import {
|
|||||||
getCrmReferenceData,
|
getCrmReferenceData,
|
||||||
getCustomerById,
|
getCustomerById,
|
||||||
getCustomers,
|
getCustomers,
|
||||||
getEnquiries,
|
getOpportunities,
|
||||||
getEnquiryById,
|
getOpportunityById,
|
||||||
getQuotationById,
|
getQuotationById,
|
||||||
getQuotations
|
getQuotations
|
||||||
} from './service';
|
} from './service';
|
||||||
import type { ApprovalFilters, CustomerFilters, EnquiryFilters, QuotationFilters } from './types';
|
import type { ApprovalFilters, CustomerFilters, OpportunityFilters, QuotationFilters } from './types';
|
||||||
|
|
||||||
export const crmKeys = {
|
export const crmKeys = {
|
||||||
all: ['crm'] as const,
|
all: ['crm'] as const,
|
||||||
@@ -18,8 +18,8 @@ export const crmKeys = {
|
|||||||
dashboard: () => [...crmKeys.all, 'dashboard'] as const,
|
dashboard: () => [...crmKeys.all, 'dashboard'] as const,
|
||||||
customers: (filters: CustomerFilters) => [...crmKeys.all, 'customers', filters] as const,
|
customers: (filters: CustomerFilters) => [...crmKeys.all, 'customers', filters] as const,
|
||||||
customer: (id: string) => [...crmKeys.all, 'customer', id] as const,
|
customer: (id: string) => [...crmKeys.all, 'customer', id] as const,
|
||||||
enquiries: (filters: EnquiryFilters) => [...crmKeys.all, 'enquiries', filters] as const,
|
opportunities: (filters: OpportunityFilters) => [...crmKeys.all, 'opportunities', filters] as const,
|
||||||
enquiry: (id: string) => [...crmKeys.all, 'enquiry', id] as const,
|
opportunity: (id: string) => [...crmKeys.all, 'opportunity', id] as const,
|
||||||
quotations: (filters: QuotationFilters) => [...crmKeys.all, 'quotations', filters] as const,
|
quotations: (filters: QuotationFilters) => [...crmKeys.all, 'quotations', filters] as const,
|
||||||
quotation: (id: string) => [...crmKeys.all, 'quotation', id] as const,
|
quotation: (id: string) => [...crmKeys.all, 'quotation', id] as const,
|
||||||
approvals: (filters: ApprovalFilters) => [...crmKeys.all, 'approvals', filters] as const
|
approvals: (filters: ApprovalFilters) => [...crmKeys.all, 'approvals', filters] as const
|
||||||
@@ -49,16 +49,16 @@ export const customerByIdOptions = (id: string) =>
|
|||||||
queryFn: () => getCustomerById(id)
|
queryFn: () => getCustomerById(id)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const enquiriesQueryOptions = (filters: EnquiryFilters) =>
|
export const opportunitiesQueryOptions = (filters: OpportunityFilters) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: crmKeys.enquiries(filters),
|
queryKey: crmKeys.opportunities(filters),
|
||||||
queryFn: () => getEnquiries(filters)
|
queryFn: () => getOpportunities(filters)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const enquiryByIdOptions = (id: string) =>
|
export const opportunityByIdOptions = (id: string) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: crmKeys.enquiry(id),
|
queryKey: crmKeys.opportunity(id),
|
||||||
queryFn: () => getEnquiryById(id)
|
queryFn: () => getOpportunityById(id)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const quotationsQueryOptions = (filters: QuotationFilters) =>
|
export const quotationsQueryOptions = (filters: QuotationFilters) =>
|
||||||
@@ -78,3 +78,4 @@ export const approvalsQueryOptions = (filters: ApprovalFilters) =>
|
|||||||
queryKey: crmKeys.approvals(filters),
|
queryKey: crmKeys.approvals(filters),
|
||||||
queryFn: () => getApprovals(filters)
|
queryFn: () => getApprovals(filters)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import type {
|
|||||||
CustomerDetailResponse,
|
CustomerDetailResponse,
|
||||||
CustomerFilters,
|
CustomerFilters,
|
||||||
CustomerMutationPayload,
|
CustomerMutationPayload,
|
||||||
Enquiry,
|
Opportunity,
|
||||||
EnquiryDetailResponse,
|
OpportunityDetailResponse,
|
||||||
EnquiryFilters,
|
OpportunityFilters,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
Quotation,
|
Quotation,
|
||||||
QuotationDetailResponse,
|
QuotationDetailResponse,
|
||||||
@@ -70,8 +70,8 @@ export async function getCrmReferenceData(): Promise<CrmReferenceData> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getCrmDashboardData(): Promise<CrmDashboardData> {
|
export async function getCrmDashboardData(): Promise<CrmDashboardData> {
|
||||||
const openEnquiries = crmState.enquiries.filter((enquiry) =>
|
const openOpportunities = crmState.opportunities.filter((opportunity) =>
|
||||||
['new', 'qualifying', 'requirement', 'follow_up'].includes(enquiry.status)
|
['new', 'qualifying', 'requirement', 'follow_up'].includes(opportunity.status)
|
||||||
);
|
);
|
||||||
const pendingQuotations = crmState.quotations.filter(
|
const pendingQuotations = crmState.quotations.filter(
|
||||||
(quotation) => quotation.status === 'pending_approval'
|
(quotation) => quotation.status === 'pending_approval'
|
||||||
@@ -81,22 +81,22 @@ export async function getCrmDashboardData(): Promise<CrmDashboardData> {
|
|||||||
const hotQuotations = crmState.quotations.filter((quotation) => quotation.isHotProject);
|
const hotQuotations = crmState.quotations.filter((quotation) => quotation.isHotProject);
|
||||||
const dueFollowUps = crmState.quotations.flatMap((quotation) => quotation.followUps).slice(0, 5);
|
const dueFollowUps = crmState.quotations.flatMap((quotation) => quotation.followUps).slice(0, 5);
|
||||||
const conversionRate = Math.round(
|
const conversionRate = Math.round(
|
||||||
(wonQuotations.length / Math.max(1, crmState.enquiries.length)) * 100
|
(wonQuotations.length / Math.max(1, crmState.opportunities.length)) * 100
|
||||||
);
|
);
|
||||||
|
|
||||||
return clone({
|
return clone({
|
||||||
metrics: [
|
metrics: [
|
||||||
{
|
{
|
||||||
id: 'm-1',
|
id: 'm-1',
|
||||||
label: 'Total Enquiries',
|
label: 'Total Opportunities',
|
||||||
value: String(crmState.enquiries.length),
|
value: String(crmState.opportunities.length),
|
||||||
change: '+12%',
|
change: '+12%',
|
||||||
trend: 'up'
|
trend: 'up'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'm-2',
|
id: 'm-2',
|
||||||
label: 'Open Enquiries',
|
label: 'Open Opportunities',
|
||||||
value: String(openEnquiries.length),
|
value: String(openOpportunities.length),
|
||||||
change: '+3',
|
change: '+3',
|
||||||
trend: 'up'
|
trend: 'up'
|
||||||
},
|
},
|
||||||
@@ -150,23 +150,23 @@ export async function getCrmDashboardData(): Promise<CrmDashboardData> {
|
|||||||
trend: 'flat'
|
trend: 'flat'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
enquiryPipeline: [
|
opportunityPipeline: [
|
||||||
{ label: 'New', value: crmState.enquiries.filter((item) => item.status === 'new').length },
|
{ label: 'New', value: crmState.opportunities.filter((item) => item.status === 'new').length },
|
||||||
{
|
{
|
||||||
label: 'Qualifying',
|
label: 'Qualifying',
|
||||||
value: crmState.enquiries.filter((item) => item.status === 'qualifying').length
|
value: crmState.opportunities.filter((item) => item.status === 'qualifying').length
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Requirement',
|
label: 'Requirement',
|
||||||
value: crmState.enquiries.filter((item) => item.status === 'requirement').length
|
value: crmState.opportunities.filter((item) => item.status === 'requirement').length
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Follow Up',
|
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',
|
label: 'Converted',
|
||||||
value: crmState.enquiries.filter((item) => item.status === 'converted').length
|
value: crmState.opportunities.filter((item) => item.status === 'converted').length
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
quotationPipeline: [
|
quotationPipeline: [
|
||||||
@@ -253,7 +253,7 @@ export async function getCustomerById(id: string): Promise<CustomerDetailRespons
|
|||||||
shareLogs: crmState.contactShareLogs.filter((log) =>
|
shareLogs: crmState.contactShareLogs.filter((log) =>
|
||||||
crmState.contacts.some((contact) => contact.id === log.contactId && contact.customerId === id)
|
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) =>
|
quotations: crmState.quotations.filter((quotation) =>
|
||||||
quotation.customerRoles.some((role) => role.customerId === id)
|
quotation.customerRoles.some((role) => role.customerId === id)
|
||||||
),
|
),
|
||||||
@@ -262,8 +262,8 @@ export async function getCustomerById(id: string): Promise<CustomerDetailRespons
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getEnquiries(filters: EnquiryFilters): Promise<PaginatedResponse<Enquiry>> {
|
export async function getOpportunities(filters: OpportunityFilters): Promise<PaginatedResponse<Opportunity>> {
|
||||||
let items = [...crmState.enquiries];
|
let items = [...crmState.opportunities];
|
||||||
|
|
||||||
if (filters.search) {
|
if (filters.search) {
|
||||||
const search = filters.search.toLowerCase();
|
const search = filters.search.toLowerCase();
|
||||||
@@ -283,21 +283,21 @@ export async function getEnquiries(filters: EnquiryFilters): Promise<PaginatedRe
|
|||||||
return clone(paginate(items, filters.page, filters.limit));
|
return clone(paginate(items, filters.page, filters.limit));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getEnquiryById(id: string): Promise<EnquiryDetailResponse> {
|
export async function getOpportunityById(id: string): Promise<OpportunityDetailResponse> {
|
||||||
const enquiry = crmState.enquiries.find((item) => item.id === id);
|
const opportunity = crmState.opportunities.find((item) => item.id === id);
|
||||||
if (!enquiry) {
|
if (!opportunity) {
|
||||||
throw new Error('Enquiry not found');
|
throw new Error('Opportunity not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const customer = crmState.customers.find((item) => item.id === enquiry.customerId)!;
|
const customer = crmState.customers.find((item) => item.id === opportunity.customerId)!;
|
||||||
const contact = crmState.contacts.find((item) => item.id === enquiry.contactId)!;
|
const contact = crmState.contacts.find((item) => item.id === opportunity.contactId)!;
|
||||||
return clone({
|
return clone({
|
||||||
enquiry,
|
opportunity,
|
||||||
customer,
|
customer,
|
||||||
contact,
|
contact,
|
||||||
quotations: crmState.quotations.filter((quotation) => quotation.enquiryId === id),
|
quotations: crmState.quotations.filter((quotation) => quotation.opportunityId === id),
|
||||||
branch: findBranch(enquiry.branchId)!,
|
branch: findBranch(opportunity.branchId)!,
|
||||||
salesman: findSalesperson(enquiry.salesmanId)!
|
salesman: findSalesperson(opportunity.salesmanId)!
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,7 +338,7 @@ export async function getQuotationById(id: string): Promise<QuotationDetailRespo
|
|||||||
throw new Error('Quotation not found');
|
throw new Error('Quotation not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const enquiry = crmState.enquiries.find((item) => 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 customerIds = [...new Set(quotation.customerRoles.map((role) => role.customerId))];
|
||||||
const contactIds = [
|
const contactIds = [
|
||||||
...new Set(quotation.customerRoles.map((role) => role.contactId).filter(Boolean))
|
...new Set(quotation.customerRoles.map((role) => role.contactId).filter(Boolean))
|
||||||
@@ -346,7 +346,7 @@ export async function getQuotationById(id: string): Promise<QuotationDetailRespo
|
|||||||
|
|
||||||
return clone({
|
return clone({
|
||||||
quotation,
|
quotation,
|
||||||
enquiry,
|
opportunity,
|
||||||
customers: crmState.customers.filter((item) => customerIds.includes(item.id)),
|
customers: crmState.customers.filter((item) => customerIds.includes(item.id)),
|
||||||
contacts: crmState.contacts.filter((item) => contactIds.includes(item.id)),
|
contacts: crmState.contacts.filter((item) => contactIds.includes(item.id)),
|
||||||
branch: findBranch(quotation.branchId)!,
|
branch: findBranch(quotation.branchId)!,
|
||||||
@@ -376,40 +376,40 @@ export async function updateCustomer(id: string, values: CustomerMutationPayload
|
|||||||
return getCustomerById(id);
|
return getCustomerById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function convertEnquiryToQuotation(enquiryId: string) {
|
export async function convertOpportunityToQuotation(opportunityId: string) {
|
||||||
const enquiry = crmState.enquiries.find((item) => item.id === enquiryId);
|
const opportunity = crmState.opportunities.find((item) => item.id === opportunityId);
|
||||||
if (!enquiry) throw new Error('Enquiry not found');
|
if (!opportunity) throw new Error('Opportunity not found');
|
||||||
|
|
||||||
const nextId = `qt-${Date.now()}`;
|
const nextId = `qt-${Date.now()}`;
|
||||||
const newQuotation: Quotation = {
|
const newQuotation: Quotation = {
|
||||||
id: nextId,
|
id: nextId,
|
||||||
code: `QT2606-${String(crmState.quotations.length + 1).padStart(3, '0')}`,
|
code: `QT2606-${String(crmState.quotations.length + 1).padStart(3, '0')}`,
|
||||||
enquiryId,
|
opportunityId,
|
||||||
quotationDate: '2026-06-11',
|
quotationDate: '2026-06-11',
|
||||||
validUntil: '2026-07-11',
|
validUntil: '2026-07-11',
|
||||||
quotationType: 'official',
|
quotationType: 'official',
|
||||||
project: enquiry.title,
|
project: opportunity.title,
|
||||||
siteLocation: enquiry.projectLocation,
|
siteLocation: opportunity.projectLocation,
|
||||||
customerRoles: [
|
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',
|
saleAdminId: 'sp-2',
|
||||||
branchId: enquiry.branchId,
|
branchId: opportunity.branchId,
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
revision: 0,
|
revision: 0,
|
||||||
totalAmount: enquiry.expectedValue,
|
totalAmount: opportunity.expectedValue,
|
||||||
chancePercent: enquiry.chancePercent,
|
chancePercent: opportunity.chancePercent,
|
||||||
isHotProject: enquiry.chancePercent >= 70,
|
isHotProject: opportunity.chancePercent >= 70,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
id: `${nextId}-item-1`,
|
id: `${nextId}-item-1`,
|
||||||
topic: 'Scope',
|
topic: 'Scope',
|
||||||
description: enquiry.requirementSummary,
|
description: opportunity.requirementSummary,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
unit: 'lot',
|
unit: 'lot',
|
||||||
unitPrice: enquiry.expectedValue,
|
unitPrice: opportunity.expectedValue,
|
||||||
amount: enquiry.expectedValue
|
amount: opportunity.expectedValue
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
topics: [
|
topics: [
|
||||||
@@ -417,7 +417,7 @@ export async function convertEnquiryToQuotation(enquiryId: string) {
|
|||||||
id: `${nextId}-topic-1`,
|
id: `${nextId}-topic-1`,
|
||||||
type: 'scope',
|
type: 'scope',
|
||||||
label: 'Scope',
|
label: 'Scope',
|
||||||
items: [enquiry.requirementSummary]
|
items: [opportunity.requirementSummary]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: `${nextId}-topic-2`,
|
id: `${nextId}-topic-2`,
|
||||||
@@ -434,8 +434,8 @@ export async function convertEnquiryToQuotation(enquiryId: string) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
crmState.quotations.unshift(newQuotation);
|
crmState.quotations.unshift(newQuotation);
|
||||||
crmState.enquiries = crmState.enquiries.map((item) =>
|
crmState.opportunities = crmState.opportunities.map((item) =>
|
||||||
item.id === enquiryId
|
item.id === opportunityId
|
||||||
? {
|
? {
|
||||||
...item,
|
...item,
|
||||||
status: 'converted',
|
status: 'converted',
|
||||||
@@ -443,7 +443,7 @@ export async function convertEnquiryToQuotation(enquiryId: string) {
|
|||||||
activities: [
|
activities: [
|
||||||
...item.activities,
|
...item.activities,
|
||||||
{
|
{
|
||||||
id: `${enquiryId}-activity-${Date.now()}`,
|
id: `${opportunityId}-activity-${Date.now()}`,
|
||||||
type: 'CONVERT',
|
type: 'CONVERT',
|
||||||
title: 'Convert to quotation',
|
title: 'Convert to quotation',
|
||||||
detail: `สร้าง ${newQuotation.code}`,
|
detail: `สร้าง ${newQuotation.code}`,
|
||||||
@@ -591,3 +591,4 @@ export async function revokeContactShare(contactId: string, shareId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export interface Customer {
|
|||||||
contactIds: string[];
|
contactIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EnquiryStatus =
|
export type OpportunityStatus =
|
||||||
| 'new'
|
| 'new'
|
||||||
| 'qualifying'
|
| 'qualifying'
|
||||||
| 'requirement'
|
| 'requirement'
|
||||||
@@ -72,7 +72,7 @@ export type EnquiryStatus =
|
|||||||
| 'closed_lost'
|
| 'closed_lost'
|
||||||
| 'cancelled';
|
| 'cancelled';
|
||||||
|
|
||||||
export interface EnquiryActivity {
|
export interface OpportunityActivity {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'CALL' | 'MEETING' | 'VISIT' | 'NOTE' | 'UPDATE' | 'CONVERT';
|
type: 'CALL' | 'MEETING' | 'VISIT' | 'NOTE' | 'UPDATE' | 'CONVERT';
|
||||||
title: string;
|
title: string;
|
||||||
@@ -81,7 +81,7 @@ export interface EnquiryActivity {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Enquiry {
|
export interface Opportunity {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -90,7 +90,7 @@ export interface Enquiry {
|
|||||||
branchId: string;
|
branchId: string;
|
||||||
salesmanId: string;
|
salesmanId: string;
|
||||||
productType: 'crane' | 'dockdoor' | 'solarcell';
|
productType: 'crane' | 'dockdoor' | 'solarcell';
|
||||||
status: EnquiryStatus;
|
status: OpportunityStatus;
|
||||||
requirementSummary: string;
|
requirementSummary: string;
|
||||||
projectLocation: string;
|
projectLocation: string;
|
||||||
chancePercent: number;
|
chancePercent: number;
|
||||||
@@ -100,7 +100,7 @@ export interface Enquiry {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
quotationIds: string[];
|
quotationIds: string[];
|
||||||
activities: EnquiryActivity[];
|
activities: OpportunityActivity[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type QuotationStatus =
|
export type QuotationStatus =
|
||||||
@@ -165,7 +165,7 @@ export interface ApprovalStep {
|
|||||||
export interface Quotation {
|
export interface Quotation {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
enquiryId: string;
|
opportunityId: string;
|
||||||
quotationDate: string;
|
quotationDate: string;
|
||||||
validUntil: string;
|
validUntil: string;
|
||||||
quotationType: 'budgetary' | 'official' | 'service';
|
quotationType: 'budgetary' | 'official' | 'service';
|
||||||
@@ -204,7 +204,7 @@ export interface ApprovalItem {
|
|||||||
|
|
||||||
export interface AuditEvent {
|
export interface AuditEvent {
|
||||||
id: string;
|
id: string;
|
||||||
entityType: 'customer' | 'enquiry' | 'quotation' | 'approval';
|
entityType: 'customer' | 'opportunity' | 'quotation' | 'approval';
|
||||||
entityId: string;
|
entityId: string;
|
||||||
action: 'CREATE' | 'UPDATE' | 'SEND' | 'APPROVE' | 'REJECT' | 'CONVERT' | 'REVISE';
|
action: 'CREATE' | 'UPDATE' | 'SEND' | 'APPROVE' | 'REJECT' | 'CONVERT' | 'REVISE';
|
||||||
actorName: string;
|
actorName: string;
|
||||||
@@ -214,7 +214,7 @@ export interface AuditEvent {
|
|||||||
|
|
||||||
export interface DocumentSequence {
|
export interface DocumentSequence {
|
||||||
id: string;
|
id: string;
|
||||||
documentType: 'enquiry' | 'quotation' | 'quotation_revision';
|
documentType: 'opportunity' | 'quotation' | 'quotation_revision';
|
||||||
prefix: string;
|
prefix: string;
|
||||||
period: string;
|
period: string;
|
||||||
branchId: string;
|
branchId: string;
|
||||||
@@ -264,7 +264,7 @@ export interface CrmChartDatum {
|
|||||||
|
|
||||||
export interface CrmDashboardData {
|
export interface CrmDashboardData {
|
||||||
metrics: CrmDashboardMetric[];
|
metrics: CrmDashboardMetric[];
|
||||||
enquiryPipeline: CrmChartDatum[];
|
opportunityPipeline: CrmChartDatum[];
|
||||||
quotationPipeline: CrmChartDatum[];
|
quotationPipeline: CrmChartDatum[];
|
||||||
salesPerformance: CrmChartDatum[];
|
salesPerformance: CrmChartDatum[];
|
||||||
competitorAnalysis: CrmChartDatum[];
|
competitorAnalysis: CrmChartDatum[];
|
||||||
@@ -283,7 +283,7 @@ export interface CustomerFilters {
|
|||||||
sort?: string;
|
sort?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnquiryFilters {
|
export interface OpportunityFilters {
|
||||||
page?: number;
|
page?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
search?: string;
|
search?: string;
|
||||||
@@ -328,14 +328,14 @@ export interface CustomerDetailResponse {
|
|||||||
contacts: CustomerContact[];
|
contacts: CustomerContact[];
|
||||||
shares: ContactShare[];
|
shares: ContactShare[];
|
||||||
shareLogs: ContactShareLog[];
|
shareLogs: ContactShareLog[];
|
||||||
enquiries: Enquiry[];
|
opportunities: Opportunity[];
|
||||||
quotations: Quotation[];
|
quotations: Quotation[];
|
||||||
auditEvents: AuditEvent[];
|
auditEvents: AuditEvent[];
|
||||||
branch: CrmBranch;
|
branch: CrmBranch;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnquiryDetailResponse {
|
export interface OpportunityDetailResponse {
|
||||||
enquiry: Enquiry;
|
opportunity: Opportunity;
|
||||||
customer: Customer;
|
customer: Customer;
|
||||||
contact: CustomerContact;
|
contact: CustomerContact;
|
||||||
quotations: Quotation[];
|
quotations: Quotation[];
|
||||||
@@ -345,7 +345,7 @@ export interface EnquiryDetailResponse {
|
|||||||
|
|
||||||
export interface QuotationDetailResponse {
|
export interface QuotationDetailResponse {
|
||||||
quotation: Quotation;
|
quotation: Quotation;
|
||||||
enquiry: Enquiry;
|
opportunity: Opportunity;
|
||||||
customers: Customer[];
|
customers: Customer[];
|
||||||
contacts: CustomerContact[];
|
contacts: CustomerContact[];
|
||||||
branch: CrmBranch;
|
branch: CrmBranch;
|
||||||
@@ -390,3 +390,4 @@ export interface ContactSharePayload {
|
|||||||
sharedWithUserName: string;
|
sharedWithUserName: string;
|
||||||
permission: 'view' | 'edit';
|
permission: 'view' | 'edit';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,12 +45,12 @@ export function CrmDashboardPage() {
|
|||||||
<div className='grid gap-4 xl:grid-cols-12'>
|
<div className='grid gap-4 xl:grid-cols-12'>
|
||||||
<Card className='xl:col-span-5'>
|
<Card className='xl:col-span-5'>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Enquiry Pipeline</CardTitle>
|
<CardTitle>Opportunity Pipeline</CardTitle>
|
||||||
<CardDescription>สถานะ opportunity ปัจจุบัน</CardDescription>
|
<CardDescription>สถานะ opportunity ปัจจุบัน</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ChartContainer config={chartConfig}>
|
<ChartContainer config={chartConfig}>
|
||||||
<BarChart data={data.enquiryPipeline}>
|
<BarChart data={data.opportunityPipeline}>
|
||||||
<CartesianGrid vertical={false} />
|
<CartesianGrid vertical={false} />
|
||||||
<XAxis dataKey='label' tickLine={false} axisLine={false} />
|
<XAxis dataKey='label' tickLine={false} axisLine={false} />
|
||||||
<YAxis allowDecimals={false} />
|
<YAxis allowDecimals={false} />
|
||||||
@@ -217,3 +217,4 @@ export function CrmDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function CustomerDetailPage({ id }: { id: string }) {
|
|||||||
<TabsTrigger value='overview'>Overview</TabsTrigger>
|
<TabsTrigger value='overview'>Overview</TabsTrigger>
|
||||||
<TabsTrigger value='contacts'>Contacts</TabsTrigger>
|
<TabsTrigger value='contacts'>Contacts</TabsTrigger>
|
||||||
<TabsTrigger value='shared'>Shared Contacts</TabsTrigger>
|
<TabsTrigger value='shared'>Shared Contacts</TabsTrigger>
|
||||||
<TabsTrigger value='enquiries'>Enquiries</TabsTrigger>
|
<TabsTrigger value='opportunities'>Opportunities</TabsTrigger>
|
||||||
<TabsTrigger value='quotations'>Quotations</TabsTrigger>
|
<TabsTrigger value='quotations'>Quotations</TabsTrigger>
|
||||||
<TabsTrigger value='activity'>Activity Log</TabsTrigger>
|
<TabsTrigger value='activity'>Activity Log</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
@@ -177,13 +177,13 @@ export function CustomerDetailPage({ id }: { id: string }) {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value='enquiries'>
|
<TabsContent value='opportunities'>
|
||||||
<SectionCard title='Customer Enquiries'>
|
<SectionCard title='Customer Opportunities'>
|
||||||
<div className='space-y-3'>
|
<div className='space-y-3'>
|
||||||
{data.enquiries.map((enquiry) => (
|
{data.opportunities.map((opportunity) => (
|
||||||
<div key={enquiry.id} className='rounded-lg border p-3'>
|
<div key={opportunity.id} className='rounded-lg border p-3'>
|
||||||
<p className='font-medium'>{enquiry.code}</p>
|
<p className='font-medium'>{opportunity.code}</p>
|
||||||
<p className='text-muted-foreground text-sm'>{enquiry.title}</p>
|
<p className='text-muted-foreground text-sm'>{opportunity.title}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -203,3 +203,4 @@ export function CustomerDetailPage({ id }: { id: string }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,19 +10,19 @@ import { DataTable } from '@/components/ui/table/data-table';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { crmReferenceQueryOptions, enquiriesQueryOptions } from '../api/queries';
|
import { crmReferenceQueryOptions, opportunitiesQueryOptions } from '../api/queries';
|
||||||
import { convertEnquiryMutation } from '../api/mutations';
|
import { convertOpportunityMutation } from '../api/mutations';
|
||||||
import type { Enquiry } from '../api/types';
|
import type { Opportunity } from '../api/types';
|
||||||
import { ChancePill, EnquiryStatusBadge } from './shared';
|
import { ChancePill, OpportunityStatusBadge } from './shared';
|
||||||
|
|
||||||
const columns: ColumnDef<Enquiry>[] = [
|
const columns: ColumnDef<Opportunity>[] = [
|
||||||
{ accessorKey: 'code', header: 'Code' },
|
{ accessorKey: 'code', header: 'Code' },
|
||||||
{ accessorKey: 'title', header: 'Enquiry' },
|
{ accessorKey: 'title', header: 'Opportunity' },
|
||||||
{ accessorKey: 'productType', header: 'Product' },
|
{ accessorKey: 'productType', header: 'Product' },
|
||||||
{
|
{
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
header: 'Status',
|
header: 'Status',
|
||||||
cell: ({ row }) => <EnquiryStatusBadge status={row.original.status} />
|
cell: ({ row }) => <OpportunityStatusBadge status={row.original.status} />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'chancePercent',
|
accessorKey: 'chancePercent',
|
||||||
@@ -32,31 +32,31 @@ const columns: ColumnDef<Enquiry>[] = [
|
|||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
header: '',
|
header: '',
|
||||||
cell: ({ row }) => <RowActions enquiry={row.original} />
|
cell: ({ row }) => <RowActions opportunity={row.original} />
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const columnIds = ['code', 'title', 'productType', 'status', 'chancePercent', 'actions'];
|
const columnIds = ['code', 'title', 'productType', 'status', 'chancePercent', 'actions'];
|
||||||
|
|
||||||
function RowActions({ enquiry }: { enquiry: Enquiry }) {
|
function RowActions({ opportunity }: { opportunity: Opportunity }) {
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
...convertEnquiryMutation,
|
...convertOpportunityMutation,
|
||||||
onSuccess: () => toast.success('สร้าง quotation mock จาก enquiry แล้ว')
|
onSuccess: () => toast.success('สร้าง quotation mock จาก opportunity แล้ว')
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex justify-end gap-2'>
|
<div className='flex justify-end gap-2'>
|
||||||
<Button variant='outline' size='sm' asChild>
|
<Button variant='outline' size='sm' asChild>
|
||||||
<Link href={`/dashboard/crm-demo/enquiries/${enquiry.id}`}>View</Link>
|
<Link href={`/dashboard/crm-demo/opportunities/${opportunity.id}`}>View</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button size='sm' isLoading={mutation.isPending} onClick={() => mutation.mutate(enquiry.id)}>
|
<Button size='sm' isLoading={mutation.isPending} onClick={() => mutation.mutate(opportunity.id)}>
|
||||||
Convert
|
Convert
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EnquiriesTablePage() {
|
export function OpportunitiesTablePage() {
|
||||||
const [params, setParams] = useQueryStates({
|
const [params, setParams] = useQueryStates({
|
||||||
page: parseAsInteger.withDefault(1),
|
page: parseAsInteger.withDefault(1),
|
||||||
perPage: parseAsInteger.withDefault(10),
|
perPage: parseAsInteger.withDefault(10),
|
||||||
@@ -82,7 +82,7 @@ export function EnquiriesTablePage() {
|
|||||||
...(params.sort.length ? { sort: JSON.stringify(params.sort) } : {})
|
...(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 pageCount = Math.ceil(data.totalItems / params.perPage);
|
||||||
const { table } = useDataTable({
|
const { table } = useDataTable({
|
||||||
data: data.items,
|
data: data.items,
|
||||||
@@ -97,7 +97,7 @@ export function EnquiriesTablePage() {
|
|||||||
<div className='flex flex-1 flex-col space-y-4'>
|
<div className='flex flex-1 flex-col space-y-4'>
|
||||||
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-6'>
|
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-6'>
|
||||||
<Input
|
<Input
|
||||||
placeholder='ค้นหา enquiry หรือ code'
|
placeholder='ค้นหา opportunity หรือ code'
|
||||||
value={params.name ?? ''}
|
value={params.name ?? ''}
|
||||||
onChange={(event) => void setParams({ name: event.target.value || null, page: 1 })}
|
onChange={(event) => void setParams({ name: event.target.value || null, page: 1 })}
|
||||||
/>
|
/>
|
||||||
@@ -148,10 +148,11 @@ export function EnquiriesTablePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className='flex justify-end'>
|
<div className='flex justify-end'>
|
||||||
<Button variant='outline' asChild>
|
<Button variant='outline' asChild>
|
||||||
<Link href='/dashboard/crm-demo/customers'>Create Enquiry</Link>
|
<Link href='/dashboard/crm-demo/customers'>Create Opportunity</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<DataTable table={table} />
|
<DataTable table={table} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3,14 +3,14 @@
|
|||||||
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
|
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { enquiryByIdOptions } from '../api/queries';
|
import { opportunityByIdOptions } from '../api/queries';
|
||||||
import { convertEnquiryMutation } from '../api/mutations';
|
import { convertOpportunityMutation } from '../api/mutations';
|
||||||
import { InfoGrid, RelatedQuotationList, SectionCard, TimelineList } from './shared';
|
import { InfoGrid, RelatedQuotationList, SectionCard, TimelineList } from './shared';
|
||||||
import { formatCurrency } from '../utils/format';
|
import { formatCurrency } from '../utils/format';
|
||||||
|
|
||||||
export function EnquiryDetailPage({ id }: { id: string }) {
|
export function OpportunityDetailPage({ id }: { id: string }) {
|
||||||
const { data } = useSuspenseQuery(enquiryByIdOptions(id));
|
const { data } = useSuspenseQuery(opportunityByIdOptions(id));
|
||||||
const baseMutation = convertEnquiryMutation;
|
const baseMutation = convertOpportunityMutation;
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
...baseMutation,
|
...baseMutation,
|
||||||
onSuccess: async (...args) => {
|
onSuccess: async (...args) => {
|
||||||
@@ -22,10 +22,10 @@ export function EnquiryDetailPage({ id }: { id: string }) {
|
|||||||
return (
|
return (
|
||||||
<div className='space-y-4'>
|
<div className='space-y-4'>
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title={data.enquiry.title}
|
title={data.opportunity.title}
|
||||||
description={`${data.enquiry.code} / ${data.branch.name}`}
|
description={`${data.opportunity.code} / ${data.branch.name}`}
|
||||||
action={
|
action={
|
||||||
<Button isLoading={mutation.isPending} onClick={() => mutation.mutate(data.enquiry.id)}>
|
<Button isLoading={mutation.isPending} onClick={() => mutation.mutate(data.opportunity.id)}>
|
||||||
Convert to Quotation
|
Convert to Quotation
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
@@ -35,19 +35,19 @@ export function EnquiryDetailPage({ id }: { id: string }) {
|
|||||||
items={[
|
items={[
|
||||||
{ label: 'Customer', value: data.customer.name },
|
{ label: 'Customer', value: data.customer.name },
|
||||||
{ label: 'Contact', value: data.contact.name },
|
{ label: 'Contact', value: data.contact.name },
|
||||||
{ label: 'Chance', value: `${data.enquiry.chancePercent}%` },
|
{ label: 'Chance', value: `${data.opportunity.chancePercent}%` },
|
||||||
{ label: 'Potential', value: formatCurrency(data.enquiry.expectedValue) },
|
{ label: 'Potential', value: formatCurrency(data.opportunity.expectedValue) },
|
||||||
{ label: 'Project Location', value: data.enquiry.projectLocation },
|
{ label: 'Project Location', value: data.opportunity.projectLocation },
|
||||||
{ label: 'Competitor', value: data.enquiry.competitor },
|
{ label: 'Competitor', value: data.opportunity.competitor },
|
||||||
{ label: 'Salesman', value: data.salesman.name },
|
{ label: 'Salesman', value: data.salesman.name },
|
||||||
{ label: 'Requirement', value: data.enquiry.requirementSummary }
|
{ label: 'Requirement', value: data.opportunity.requirementSummary }
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard title='Timeline / Activity'>
|
<SectionCard title='Timeline / Activity'>
|
||||||
<TimelineList
|
<TimelineList
|
||||||
items={data.enquiry.activities.map((activity) => ({
|
items={data.opportunity.activities.map((activity) => ({
|
||||||
id: activity.id,
|
id: activity.id,
|
||||||
title: activity.title,
|
title: activity.title,
|
||||||
detail: activity.detail,
|
detail: activity.detail,
|
||||||
@@ -63,3 +63,4 @@ export function EnquiryDetailPage({ id }: { id: string }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +202,7 @@ export function QuotationsTablePage() {
|
|||||||
|
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-2'>
|
||||||
<Button variant='outline' asChild>
|
<Button variant='outline' asChild>
|
||||||
<Link href='/dashboard/crm-demo/enquiries'>Create quotation</Link>
|
<Link href='/dashboard/crm-demo/opportunities'>Create quotation</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={params.view === 'table' ? 'default' : 'outline'}
|
variant={params.view === 'table' ? 'default' : 'outline'}
|
||||||
@@ -223,3 +223,4 @@ export function QuotationsTablePage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,14 +12,14 @@ import type {
|
|||||||
CrmBranch,
|
CrmBranch,
|
||||||
Customer,
|
Customer,
|
||||||
CustomerContact,
|
CustomerContact,
|
||||||
Enquiry,
|
Opportunity,
|
||||||
Quotation,
|
Quotation,
|
||||||
QuotationCustomerRole
|
QuotationCustomerRole
|
||||||
} from '../api/types';
|
} from '../api/types';
|
||||||
import { formatCurrency, formatDate, formatPercent } from '../utils/format';
|
import { formatCurrency, formatDate, formatPercent } from '../utils/format';
|
||||||
import {
|
import {
|
||||||
getCustomerStatusLabel,
|
getCustomerStatusLabel,
|
||||||
getEnquiryStatusLabel,
|
getOpportunityStatusLabel,
|
||||||
getQuotationStatusLabel
|
getQuotationStatusLabel
|
||||||
} from '../utils/status';
|
} from '../utils/status';
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ export function CustomerStatusBadge({ status }: { status: Customer['customerStat
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EnquiryStatusBadge({ status }: { status: Enquiry['status'] }) {
|
export function OpportunityStatusBadge({ status }: { status: Opportunity['status'] }) {
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
variant={
|
variant={
|
||||||
@@ -107,7 +107,7 @@ export function EnquiryStatusBadge({ status }: { status: Enquiry['status'] }) {
|
|||||||
: 'outline'
|
: 'outline'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{getEnquiryStatusLabel(status)}
|
{getOpportunityStatusLabel(status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -393,3 +393,4 @@ export function EmptyState({ title, description }: { title: string; description:
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type {
|
|||||||
Customer,
|
Customer,
|
||||||
CustomerContact,
|
CustomerContact,
|
||||||
DocumentSequence,
|
DocumentSequence,
|
||||||
Enquiry,
|
Opportunity,
|
||||||
MasterOption,
|
MasterOption,
|
||||||
Quotation,
|
Quotation,
|
||||||
QuotationTemplate
|
QuotationTemplate
|
||||||
@@ -21,7 +21,7 @@ export interface CrmMockState {
|
|||||||
contacts: CustomerContact[];
|
contacts: CustomerContact[];
|
||||||
contactShares: ContactShare[];
|
contactShares: ContactShare[];
|
||||||
contactShareLogs: ContactShareLog[];
|
contactShareLogs: ContactShareLog[];
|
||||||
enquiries: Enquiry[];
|
opportunities: Opportunity[];
|
||||||
quotations: Quotation[];
|
quotations: Quotation[];
|
||||||
approvals: ApprovalItem[];
|
approvals: ApprovalItem[];
|
||||||
auditEvents: AuditEvent[];
|
auditEvents: AuditEvent[];
|
||||||
@@ -259,7 +259,7 @@ export const initialCrmState: CrmMockState = {
|
|||||||
createdAt: '2026-06-07T14:45:00.000Z'
|
createdAt: '2026-06-07T14:45:00.000Z'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
enquiries: [
|
opportunities: [
|
||||||
{
|
{
|
||||||
id: 'enq-1',
|
id: 'enq-1',
|
||||||
code: 'ENQ2606-001',
|
code: 'ENQ2606-001',
|
||||||
@@ -509,7 +509,7 @@ export const initialCrmState: CrmMockState = {
|
|||||||
{
|
{
|
||||||
id: 'enq-8-act-1',
|
id: 'enq-8-act-1',
|
||||||
type: 'CALL',
|
type: 'CALL',
|
||||||
title: 'รับ enquiry ใหม่',
|
title: 'รับ opportunity ใหม่',
|
||||||
detail: 'ต้องการเสนอราคาใน 7 วัน',
|
detail: 'ต้องการเสนอราคาใน 7 วัน',
|
||||||
actorName: 'Ton A.',
|
actorName: 'Ton A.',
|
||||||
createdAt: '2026-06-09T13:00:00.000Z'
|
createdAt: '2026-06-09T13:00:00.000Z'
|
||||||
@@ -521,7 +521,7 @@ export const initialCrmState: CrmMockState = {
|
|||||||
{
|
{
|
||||||
id: 'qt-1',
|
id: 'qt-1',
|
||||||
code: 'QT2606-001',
|
code: 'QT2606-001',
|
||||||
enquiryId: 'enq-1',
|
opportunityId: 'enq-1',
|
||||||
quotationDate: '2026-06-02',
|
quotationDate: '2026-06-02',
|
||||||
validUntil: '2026-07-02',
|
validUntil: '2026-07-02',
|
||||||
quotationType: 'official',
|
quotationType: 'official',
|
||||||
@@ -627,7 +627,7 @@ export const initialCrmState: CrmMockState = {
|
|||||||
{
|
{
|
||||||
id: 'qt-2',
|
id: 'qt-2',
|
||||||
code: 'QT2606-001-R01',
|
code: 'QT2606-001-R01',
|
||||||
enquiryId: 'enq-1',
|
opportunityId: 'enq-1',
|
||||||
quotationDate: '2026-06-07',
|
quotationDate: '2026-06-07',
|
||||||
validUntil: '2026-07-07',
|
validUntil: '2026-07-07',
|
||||||
quotationType: 'official',
|
quotationType: 'official',
|
||||||
@@ -721,7 +721,7 @@ export const initialCrmState: CrmMockState = {
|
|||||||
{
|
{
|
||||||
id: 'qt-3',
|
id: 'qt-3',
|
||||||
code: 'QT2606-002',
|
code: 'QT2606-002',
|
||||||
enquiryId: 'enq-2',
|
opportunityId: 'enq-2',
|
||||||
quotationDate: '2026-06-05',
|
quotationDate: '2026-06-05',
|
||||||
validUntil: '2026-07-05',
|
validUntil: '2026-07-05',
|
||||||
quotationType: 'budgetary',
|
quotationType: 'budgetary',
|
||||||
@@ -795,7 +795,7 @@ export const initialCrmState: CrmMockState = {
|
|||||||
{
|
{
|
||||||
id: 'qt-4',
|
id: 'qt-4',
|
||||||
code: 'QT2606-003',
|
code: 'QT2606-003',
|
||||||
enquiryId: 'enq-3',
|
opportunityId: 'enq-3',
|
||||||
quotationDate: '2026-06-03',
|
quotationDate: '2026-06-03',
|
||||||
validUntil: '2026-07-03',
|
validUntil: '2026-07-03',
|
||||||
quotationType: 'official',
|
quotationType: 'official',
|
||||||
@@ -876,7 +876,7 @@ export const initialCrmState: CrmMockState = {
|
|||||||
{
|
{
|
||||||
id: 'qt-5',
|
id: 'qt-5',
|
||||||
code: 'QT2606-004',
|
code: 'QT2606-004',
|
||||||
enquiryId: 'enq-5',
|
opportunityId: 'enq-5',
|
||||||
quotationDate: '2026-06-06',
|
quotationDate: '2026-06-06',
|
||||||
validUntil: '2026-07-06',
|
validUntil: '2026-07-06',
|
||||||
quotationType: 'service',
|
quotationType: 'service',
|
||||||
@@ -938,7 +938,7 @@ export const initialCrmState: CrmMockState = {
|
|||||||
{
|
{
|
||||||
id: 'qt-6',
|
id: 'qt-6',
|
||||||
code: 'QT2606-005',
|
code: 'QT2606-005',
|
||||||
enquiryId: 'enq-6',
|
opportunityId: 'enq-6',
|
||||||
quotationDate: '2026-05-26',
|
quotationDate: '2026-05-26',
|
||||||
validUntil: '2026-06-25',
|
validUntil: '2026-06-25',
|
||||||
quotationType: 'budgetary',
|
quotationType: 'budgetary',
|
||||||
@@ -977,7 +977,7 @@ export const initialCrmState: CrmMockState = {
|
|||||||
{
|
{
|
||||||
id: 'qt-7',
|
id: 'qt-7',
|
||||||
code: 'QT2606-006',
|
code: 'QT2606-006',
|
||||||
enquiryId: 'enq-4',
|
opportunityId: 'enq-4',
|
||||||
quotationDate: '2026-06-09',
|
quotationDate: '2026-06-09',
|
||||||
validUntil: '2026-07-09',
|
validUntil: '2026-07-09',
|
||||||
quotationType: 'official',
|
quotationType: 'official',
|
||||||
@@ -1037,7 +1037,7 @@ export const initialCrmState: CrmMockState = {
|
|||||||
{
|
{
|
||||||
id: 'qt-8',
|
id: 'qt-8',
|
||||||
code: 'QT2606-007',
|
code: 'QT2606-007',
|
||||||
enquiryId: 'enq-8',
|
opportunityId: 'enq-8',
|
||||||
quotationDate: '2026-06-10',
|
quotationDate: '2026-06-10',
|
||||||
validUntil: '2026-07-10',
|
validUntil: '2026-07-10',
|
||||||
quotationType: 'official',
|
quotationType: 'official',
|
||||||
@@ -1081,7 +1081,7 @@ export const initialCrmState: CrmMockState = {
|
|||||||
{
|
{
|
||||||
id: 'qt-9',
|
id: 'qt-9',
|
||||||
code: 'QT2606-008',
|
code: 'QT2606-008',
|
||||||
enquiryId: 'enq-2',
|
opportunityId: 'enq-2',
|
||||||
quotationDate: '2026-06-08',
|
quotationDate: '2026-06-08',
|
||||||
validUntil: '2026-07-08',
|
validUntil: '2026-07-08',
|
||||||
quotationType: 'official',
|
quotationType: 'official',
|
||||||
@@ -1135,7 +1135,7 @@ export const initialCrmState: CrmMockState = {
|
|||||||
{
|
{
|
||||||
id: 'qt-10',
|
id: 'qt-10',
|
||||||
code: 'QT2606-009',
|
code: 'QT2606-009',
|
||||||
enquiryId: 'enq-5',
|
opportunityId: 'enq-5',
|
||||||
quotationDate: '2026-06-10',
|
quotationDate: '2026-06-10',
|
||||||
validUntil: '2026-07-10',
|
validUntil: '2026-07-10',
|
||||||
quotationType: 'service',
|
quotationType: 'service',
|
||||||
@@ -1282,7 +1282,7 @@ export const initialCrmState: CrmMockState = {
|
|||||||
sequences: [
|
sequences: [
|
||||||
{
|
{
|
||||||
id: 'seq-1',
|
id: 'seq-1',
|
||||||
documentType: 'enquiry',
|
documentType: 'opportunity',
|
||||||
prefix: 'ENQ',
|
prefix: 'ENQ',
|
||||||
period: '2606',
|
period: '2606',
|
||||||
branchId: 'bkk',
|
branchId: 'bkk',
|
||||||
@@ -1375,3 +1375,4 @@ export const initialCrmState: CrmMockState = {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const enquiryStatusOptions = [
|
export const opportunityStatusOptions = [
|
||||||
{ value: 'new', label: 'New' },
|
{ value: 'new', label: 'New' },
|
||||||
{ value: 'qualifying', label: 'Qualifying' },
|
{ value: 'qualifying', label: 'Qualifying' },
|
||||||
{ value: 'requirement', label: 'Requirement' },
|
{ value: 'requirement', label: 'Requirement' },
|
||||||
@@ -37,3 +37,4 @@ export const quotationTypeOptions = [
|
|||||||
{ value: 'budgetary', label: 'Budgetary' },
|
{ value: 'budgetary', label: 'Budgetary' },
|
||||||
{ value: 'service', label: 'Service' }
|
{ value: 'service', label: 'Service' }
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -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']) {
|
export function getCustomerStatusLabel(status: Customer['customerStatus']) {
|
||||||
return (
|
return (
|
||||||
@@ -10,7 +10,7 @@ export function getCustomerStatusLabel(status: Customer['customerStatus']) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getEnquiryStatusLabel(status: EnquiryStatus) {
|
export function getOpportunityStatusLabel(status: OpportunityStatus) {
|
||||||
return (
|
return (
|
||||||
{
|
{
|
||||||
new: 'New',
|
new: 'New',
|
||||||
@@ -39,3 +39,4 @@ export function getQuotationStatusLabel(status: QuotationStatus) {
|
|||||||
}[status] ?? status
|
}[status] ?? status
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export interface CustomerActivityRecord {
|
|||||||
actorName: string | null;
|
actorName: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CustomerRelatedEnquiryItem {
|
export interface CustomerRelatedOpportunityItem {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -230,3 +230,4 @@ export interface MutationSuccessResponse {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { CustomerFormSheet } from './customer-form-sheet';
|
|||||||
import { CustomerContactsTab } from './customer-contacts-tab';
|
import { CustomerContactsTab } from './customer-contacts-tab';
|
||||||
import { CustomerOwnerCard } from './customer-owner-card';
|
import { CustomerOwnerCard } from './customer-owner-card';
|
||||||
import { CustomerStatusBadge } from './customer-status-badge';
|
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';
|
import type { QuotationRelationItem } from '@/features/crm/quotations/api/types';
|
||||||
|
|
||||||
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
|
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
|
||||||
@@ -37,7 +37,7 @@ export function CustomerDetail({
|
|||||||
canReadContactShares,
|
canReadContactShares,
|
||||||
canManageContactShares,
|
canManageContactShares,
|
||||||
canManageOwner,
|
canManageOwner,
|
||||||
relatedEnquiries,
|
relatedOpportunities,
|
||||||
relatedQuotations
|
relatedQuotations
|
||||||
}: {
|
}: {
|
||||||
customerId: string;
|
customerId: string;
|
||||||
@@ -52,7 +52,7 @@ export function CustomerDetail({
|
|||||||
canReadContactShares: boolean;
|
canReadContactShares: boolean;
|
||||||
canManageContactShares: boolean;
|
canManageContactShares: boolean;
|
||||||
canManageOwner: boolean;
|
canManageOwner: boolean;
|
||||||
relatedEnquiries: CustomerRelatedEnquiryItem[];
|
relatedOpportunities: CustomerRelatedOpportunityItem[];
|
||||||
relatedQuotations: QuotationRelationItem[];
|
relatedQuotations: QuotationRelationItem[];
|
||||||
}) {
|
}) {
|
||||||
const { data } = useSuspenseQuery(customerByIdOptions(customerId));
|
const { data } = useSuspenseQuery(customerByIdOptions(customerId));
|
||||||
@@ -239,23 +239,23 @@ export function CustomerDetail({
|
|||||||
<CardDescription>ลิงก์ไปยังโอกาสขายและใบเสนอราคาที่เกี่ยวข้องกับลูกค้ารายนี้</CardDescription>
|
<CardDescription>ลิงก์ไปยังโอกาสขายและใบเสนอราคาที่เกี่ยวข้องกับลูกค้ารายนี้</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{!relatedEnquiries.length && !relatedQuotations.length ? (
|
{!relatedOpportunities.length && !relatedQuotations.length ? (
|
||||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
||||||
ยังไม่มีโอกาสขายหรือใบเสนอราคาที่เชื่อมกับลูกค้ารายนี้
|
ยังไม่มีโอกาสขายหรือใบเสนอราคาที่เชื่อมกับลูกค้ารายนี้
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className='space-y-3'>
|
<div className='space-y-3'>
|
||||||
{relatedEnquiries.map((enquiry) => (
|
{relatedOpportunities.map((opportunity) => (
|
||||||
<Link
|
<Link
|
||||||
key={enquiry.id}
|
key={opportunity.id}
|
||||||
href={`/dashboard/crm/enquiries/${enquiry.id}`}
|
href={`/dashboard/crm/opportunities/${opportunity.id}`}
|
||||||
className='block rounded-lg border p-4 transition-colors hover:bg-muted/40'
|
className='block rounded-lg border p-4 transition-colors hover:bg-muted/40'
|
||||||
>
|
>
|
||||||
<div className='flex items-center justify-between gap-3'>
|
<div className='flex items-center justify-between gap-3'>
|
||||||
<div className='space-y-1'>
|
<div className='space-y-1'>
|
||||||
<div className='font-medium'>{enquiry.title}</div>
|
<div className='font-medium'>{opportunity.title}</div>
|
||||||
<div className='text-muted-foreground font-mono text-xs'>
|
<div className='text-muted-foreground font-mono text-xs'>
|
||||||
{enquiry.code}
|
{opportunity.code}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Icons.arrowRight className='text-muted-foreground h-4 w-4' />
|
<Icons.arrowRight className='text-muted-foreground h-4 w-4' />
|
||||||
@@ -297,7 +297,7 @@ export function CustomerDetail({
|
|||||||
/>
|
/>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>ข้อมูลระบบ</CardTitle>
|
<CardTitle>เธเนเธญเธกเธนเธฅเธฃเธฐเธเธ</CardTitle>
|
||||||
<CardDescription>ข้อมูลประกอบการติดตามและตรวจสอบของรายการลูกค้านี้</CardDescription>
|
<CardDescription>ข้อมูลประกอบการติดตามและตรวจสอบของรายการลูกค้านี้</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='space-y-4'>
|
<CardContent className='space-y-4'>
|
||||||
@@ -319,3 +319,4 @@ export function CustomerDetail({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
crmCustomerContacts,
|
crmCustomerContacts,
|
||||||
crmCustomerOwnerHistory,
|
crmCustomerOwnerHistory,
|
||||||
crmCustomers,
|
crmCustomers,
|
||||||
crmEnquiries,
|
crmOpportunities,
|
||||||
crmQuotations,
|
crmQuotations,
|
||||||
memberships,
|
memberships,
|
||||||
msOptions,
|
msOptions,
|
||||||
@@ -476,20 +476,20 @@ async function resolveAccessibleCustomerRelationSets(
|
|||||||
organizationId: string,
|
organizationId: string,
|
||||||
accessContext: CustomerAccessContext,
|
accessContext: CustomerAccessContext,
|
||||||
) {
|
) {
|
||||||
const [enquiries, quotations] = await Promise.all([
|
const [opportunities, quotations] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
customerId: crmEnquiries.customerId,
|
customerId: crmOpportunities.customerId,
|
||||||
createdBy: crmEnquiries.createdBy,
|
createdBy: crmOpportunities.createdBy,
|
||||||
assignedToUserId: crmEnquiries.assignedToUserId,
|
assignedToUserId: crmOpportunities.assignedToUserId,
|
||||||
branchId: crmEnquiries.branchId,
|
branchId: crmOpportunities.branchId,
|
||||||
productType: crmEnquiries.productType,
|
productType: crmOpportunities.productType,
|
||||||
})
|
})
|
||||||
.from(crmEnquiries)
|
.from(crmOpportunities)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(crmEnquiries.organizationId, organizationId),
|
eq(crmOpportunities.organizationId, organizationId),
|
||||||
isNull(crmEnquiries.deletedAt),
|
isNull(crmOpportunities.deletedAt),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
db
|
db
|
||||||
@@ -509,10 +509,10 @@ async function resolveAccessibleCustomerRelationSets(
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const enquiryCustomerIds = new Set<string>();
|
const opportunityCustomerIds = new Set<string>();
|
||||||
const quotationCustomerIds = new Set<string>();
|
const quotationCustomerIds = new Set<string>();
|
||||||
|
|
||||||
for (const row of enquiries) {
|
for (const row of opportunities) {
|
||||||
if (!hasBranchScopeAccess(accessContext, row.branchId)) {
|
if (!hasBranchScopeAccess(accessContext, row.branchId)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -529,7 +529,7 @@ async function resolveAccessibleCustomerRelationSets(
|
|||||||
row.createdBy === accessContext.userId ||
|
row.createdBy === accessContext.userId ||
|
||||||
row.assignedToUserId === 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(
|
async function canAccessCustomerRow(
|
||||||
@@ -585,7 +585,7 @@ async function canAccessCustomerRow(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { enquiryCustomerIds, quotationCustomerIds } =
|
const { opportunityCustomerIds, quotationCustomerIds } =
|
||||||
relationSets ??
|
relationSets ??
|
||||||
(await resolveAccessibleCustomerRelationSets(
|
(await resolveAccessibleCustomerRelationSets(
|
||||||
row.organizationId,
|
row.organizationId,
|
||||||
@@ -593,13 +593,13 @@ async function canAccessCustomerRow(
|
|||||||
));
|
));
|
||||||
|
|
||||||
if (accessContext.ownershipScope === "monitor") {
|
if (accessContext.ownershipScope === "monitor") {
|
||||||
return enquiryCustomerIds.has(row.id);
|
return opportunityCustomerIds.has(row.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
row.createdBy === accessContext.userId ||
|
row.createdBy === accessContext.userId ||
|
||||||
row.ownerUserId === accessContext.userId ||
|
row.ownerUserId === accessContext.userId ||
|
||||||
enquiryCustomerIds.has(row.id) ||
|
opportunityCustomerIds.has(row.id) ||
|
||||||
quotationCustomerIds.has(row.id)
|
quotationCustomerIds.has(row.id)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1615,3 +1615,4 @@ export async function resolveCustomerOptionLabel(
|
|||||||
|
|
||||||
return row?.label ?? null;
|
return row?.label ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,14 +32,14 @@ export interface CrmDashboardSummary {
|
|||||||
unassignedLeads: number;
|
unassignedLeads: number;
|
||||||
averageLeadAgingDays: number;
|
averageLeadAgingDays: number;
|
||||||
};
|
};
|
||||||
enquiry: {
|
opportunity: {
|
||||||
enquiryCount: number;
|
opportunityCount: number;
|
||||||
activeEnquiries: number;
|
activeOpportunities: number;
|
||||||
wonCount: number;
|
wonCount: number;
|
||||||
lostCount: number;
|
lostCount: number;
|
||||||
winRate: number;
|
winRate: number;
|
||||||
hotEnquiries: number;
|
hotOpportunities: number;
|
||||||
enquiryValue: number;
|
opportunityValue: number;
|
||||||
};
|
};
|
||||||
quotation: {
|
quotation: {
|
||||||
draftQuotations: number;
|
draftQuotations: number;
|
||||||
@@ -76,7 +76,7 @@ export interface CrmDashboardSalesRankingRow {
|
|||||||
salesPersonId: string;
|
salesPersonId: string;
|
||||||
salesPersonName: string;
|
salesPersonName: string;
|
||||||
leadCount: number;
|
leadCount: number;
|
||||||
enquiryCount: number;
|
opportunityCount: number;
|
||||||
quotationCount: number;
|
quotationCount: number;
|
||||||
approvedQuotations: number;
|
approvedQuotations: number;
|
||||||
wonRevenue: number;
|
wonRevenue: number;
|
||||||
@@ -92,10 +92,10 @@ export interface CrmDashboardFollowupSummary {
|
|||||||
|
|
||||||
export interface CrmDashboardFollowupRow {
|
export interface CrmDashboardFollowupRow {
|
||||||
id: string;
|
id: string;
|
||||||
sourceType: 'enquiry' | 'quotation';
|
sourceType: 'opportunity' | 'quotation';
|
||||||
followupDate: string;
|
followupDate: string;
|
||||||
customerName: string;
|
customerName: string;
|
||||||
enquiryName: string;
|
opportunityName: string;
|
||||||
assignedSalesName: string | null;
|
assignedSalesName: string | null;
|
||||||
outcome: string | null;
|
outcome: string | null;
|
||||||
}
|
}
|
||||||
@@ -118,8 +118,8 @@ export interface CrmDashboardApprovalRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CrmDashboardHotProjectRow {
|
export interface CrmDashboardHotProjectRow {
|
||||||
enquiryId: string;
|
opportunityId: string;
|
||||||
enquiryCode: string;
|
opportunityCode: string;
|
||||||
projectName: string;
|
projectName: string;
|
||||||
customerName: string;
|
customerName: string;
|
||||||
value: number;
|
value: number;
|
||||||
@@ -161,3 +161,4 @@ export interface CrmDashboardResponse {
|
|||||||
canViewApprovalData: boolean;
|
canViewApprovalData: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function DashboardFollowups({
|
|||||||
<TableHead>ลูกค้า</TableHead>
|
<TableHead>ลูกค้า</TableHead>
|
||||||
<TableHead>โอกาสขาย</TableHead>
|
<TableHead>โอกาสขาย</TableHead>
|
||||||
<TableHead>ผู้ดูแลโอกาสขาย</TableHead>
|
<TableHead>ผู้ดูแลโอกาสขาย</TableHead>
|
||||||
<TableHead>ที่มา</TableHead>
|
<TableHead>เธ—เธตเนเธกเธฒ</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -53,7 +53,7 @@ export function DashboardFollowups({
|
|||||||
<TableRow key={`${row.sourceType}-${row.id}`}>
|
<TableRow key={`${row.sourceType}-${row.id}`}>
|
||||||
<TableCell>{new Date(row.followupDate).toLocaleDateString()}</TableCell>
|
<TableCell>{new Date(row.followupDate).toLocaleDateString()}</TableCell>
|
||||||
<TableCell>{row.customerName}</TableCell>
|
<TableCell>{row.customerName}</TableCell>
|
||||||
<TableCell>{row.enquiryName}</TableCell>
|
<TableCell>{row.opportunityName}</TableCell>
|
||||||
<TableCell>{row.assignedSalesName ?? '-'}</TableCell>
|
<TableCell>{row.assignedSalesName ?? '-'}</TableCell>
|
||||||
<TableCell className='capitalize'>{row.sourceType}</TableCell>
|
<TableCell className='capitalize'>{row.sourceType}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -65,3 +65,4 @@ export function DashboardFollowups({
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function DashboardHotProjects({ rows }: { rows: CrmDashboardHotProjectRow
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>โครงการ</TableHead>
|
<TableHead>โครงการ</TableHead>
|
||||||
<TableHead>ลูกค้า</TableHead>
|
<TableHead>ลูกค้า</TableHead>
|
||||||
<TableHead className='text-right'>มูลค่า</TableHead>
|
<TableHead className='text-right'>เธกเธนเธฅเธเนเธฒ</TableHead>
|
||||||
<TableHead>ผู้ดูแลโอกาสขาย</TableHead>
|
<TableHead>ผู้ดูแลโอกาสขาย</TableHead>
|
||||||
<TableHead className='text-right'>ความน่าจะเป็น</TableHead>
|
<TableHead className='text-right'>ความน่าจะเป็น</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -35,10 +35,10 @@ export function DashboardHotProjects({ rows }: { rows: CrmDashboardHotProjectRow
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
rows.map((row) => (
|
rows.map((row) => (
|
||||||
<TableRow key={row.enquiryId}>
|
<TableRow key={row.opportunityId}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className='font-medium'>{row.projectName}</div>
|
<div className='font-medium'>{row.projectName}</div>
|
||||||
<div className='text-muted-foreground text-xs'>{row.enquiryCode}</div>
|
<div className='text-muted-foreground text-xs'>{row.opportunityCode}</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{row.customerName}</TableCell>
|
<TableCell>{row.customerName}</TableCell>
|
||||||
<TableCell className='text-right'>{formatNumber(row.value)}</TableCell>
|
<TableCell className='text-right'>{formatNumber(row.value)}</TableCell>
|
||||||
@@ -53,3 +53,4 @@ export function DashboardHotProjects({ rows }: { rows: CrmDashboardHotProjectRow
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function DashboardSalesRanking({ rows }: { rows: CrmDashboardSalesRanking
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>ฝ่ายขาย</TableHead>
|
<TableHead>ผู้ขาย</TableHead>
|
||||||
<TableHead className='text-right'>ลีด</TableHead>
|
<TableHead className='text-right'>ลีด</TableHead>
|
||||||
<TableHead className='text-right'>โอกาสขาย</TableHead>
|
<TableHead className='text-right'>โอกาสขาย</TableHead>
|
||||||
<TableHead className='text-right'>ใบเสนอราคา</TableHead>
|
<TableHead className='text-right'>ใบเสนอราคา</TableHead>
|
||||||
@@ -40,7 +40,7 @@ export function DashboardSalesRanking({ rows }: { rows: CrmDashboardSalesRanking
|
|||||||
<TableRow key={row.salesPersonId}>
|
<TableRow key={row.salesPersonId}>
|
||||||
<TableCell className='font-medium'>{row.salesPersonName}</TableCell>
|
<TableCell className='font-medium'>{row.salesPersonName}</TableCell>
|
||||||
<TableCell className='text-right'>{formatNumber(row.leadCount)}</TableCell>
|
<TableCell className='text-right'>{formatNumber(row.leadCount)}</TableCell>
|
||||||
<TableCell className='text-right'>{formatNumber(row.enquiryCount)}</TableCell>
|
<TableCell className='text-right'>{formatNumber(row.opportunityCount)}</TableCell>
|
||||||
<TableCell className='text-right'>{formatNumber(row.quotationCount)}</TableCell>
|
<TableCell className='text-right'>{formatNumber(row.quotationCount)}</TableCell>
|
||||||
<TableCell className='text-right'>{formatNumber(row.approvedQuotations)}</TableCell>
|
<TableCell className='text-right'>{formatNumber(row.approvedQuotations)}</TableCell>
|
||||||
<TableCell className='text-right'>{formatNumber(row.wonRevenue)}</TableCell>
|
<TableCell className='text-right'>{formatNumber(row.wonRevenue)}</TableCell>
|
||||||
|
|||||||
@@ -65,12 +65,12 @@ export function DashboardSummaryCards({
|
|||||||
<SummaryCard
|
<SummaryCard
|
||||||
title='โอกาสขาย'
|
title='โอกาสขาย'
|
||||||
description='รายการที่อยู่ในขั้นโอกาสขาย รวมถึงผลลัพธ์ชนะและแพ้'
|
description='รายการที่อยู่ในขั้นโอกาสขาย รวมถึงผลลัพธ์ชนะและแพ้'
|
||||||
href='/dashboard/crm/enquiries'
|
href='/dashboard/crm/opportunities'
|
||||||
items={[
|
items={[
|
||||||
{ label: 'จำนวนโอกาสขาย', value: summary.enquiry.enquiryCount },
|
{ label: 'จำนวนโอกาสขาย', value: summary.opportunity.opportunityCount },
|
||||||
{ label: 'โอกาสขายที่กำลังดำเนินการ', value: summary.enquiry.activeEnquiries },
|
{ label: 'โอกาสขายที่กำลังดำเนินการ', value: summary.opportunity.activeOpportunities },
|
||||||
{ label: 'จำนวนงานที่ชนะ', value: summary.enquiry.wonCount },
|
{ label: 'จำนวนงานที่ชนะ', value: summary.opportunity.wonCount },
|
||||||
{ label: 'Win Rate', value: summary.enquiry.winRate, suffix: '%' }
|
{ label: 'Win Rate', value: summary.opportunity.winRate, suffix: '%' }
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
{canViewCommercialData ? (
|
{canViewCommercialData ? (
|
||||||
@@ -99,3 +99,4 @@ export function DashboardSummaryCards({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
crmApprovalRequests,
|
crmApprovalRequests,
|
||||||
crmCustomers,
|
crmCustomers,
|
||||||
crmEnquiries,
|
crmOpportunities,
|
||||||
crmEnquiryFollowups,
|
crmOpportunityFollowups,
|
||||||
crmQuotationFollowups,
|
crmQuotationFollowups,
|
||||||
crmQuotations,
|
crmQuotations,
|
||||||
memberships,
|
memberships,
|
||||||
@@ -53,7 +53,7 @@ import type {
|
|||||||
CrmDashboardSummary
|
CrmDashboardSummary
|
||||||
} from '../api/types';
|
} 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 QUOTATION_STATUS_CATEGORY = 'crm_quotation_status';
|
||||||
const PRODUCT_TYPE_CATEGORY = 'crm_product_type';
|
const PRODUCT_TYPE_CATEGORY = 'crm_product_type';
|
||||||
const PROJECT_PARTY_ROLE_CATEGORY = 'crm_project_party_role';
|
const PROJECT_PARTY_ROLE_CATEGORY = 'crm_project_party_role';
|
||||||
@@ -81,7 +81,7 @@ type NormalizedFilters = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type StatusMaps = {
|
type StatusMaps = {
|
||||||
enquiryStatusById: Map<string, string>;
|
opportunityStatusById: Map<string, string>;
|
||||||
quotationStatusById: Map<string, string>;
|
quotationStatusById: Map<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -132,8 +132,8 @@ function hasDashboardFullScope(context: DashboardContext) {
|
|||||||
return context.membershipRole === 'admin' || context.ownershipScope !== 'own';
|
return context.membershipRole === 'admin' || context.ownershipScope !== 'own';
|
||||||
}
|
}
|
||||||
|
|
||||||
function canAccessDashboardEnquiry(
|
function canAccessDashboardOpportunity(
|
||||||
row: typeof crmEnquiries.$inferSelect,
|
row: typeof crmOpportunities.$inferSelect,
|
||||||
context: DashboardContext
|
context: DashboardContext
|
||||||
) {
|
) {
|
||||||
if (!hasBranchScopeAccess(context, row.branchId)) {
|
if (!hasBranchScopeAccess(context, row.branchId)) {
|
||||||
@@ -179,13 +179,13 @@ function canAccessDashboardQuotation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getStatusMaps(organizationId: string): Promise<StatusMaps> {
|
async function getStatusMaps(organizationId: string): Promise<StatusMaps> {
|
||||||
const [enquiryOptions, quotationOptions] = await Promise.all([
|
const [opportunityOptions, quotationOptions] = await Promise.all([
|
||||||
getActiveOptionsByCategory(ENQUIRY_STATUS_CATEGORY, { organizationId }),
|
getActiveOptionsByCategory(ENQUIRY_STATUS_CATEGORY, { organizationId }),
|
||||||
getActiveOptionsByCategory(QUOTATION_STATUS_CATEGORY, { organizationId })
|
getActiveOptionsByCategory(QUOTATION_STATUS_CATEGORY, { organizationId })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
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]))
|
quotationStatusById: new Map(quotationOptions.map((item) => [item.id, item.code]))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -219,8 +219,8 @@ async function getReferenceData(organizationId: string): Promise<CrmDashboardRef
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchesEnquiryFilters(
|
function matchesOpportunityFilters(
|
||||||
row: typeof crmEnquiries.$inferSelect,
|
row: typeof crmOpportunities.$inferSelect,
|
||||||
filters: NormalizedFilters
|
filters: NormalizedFilters
|
||||||
) {
|
) {
|
||||||
const { from, to } = toDayBounds(filters);
|
const { from, to } = toDayBounds(filters);
|
||||||
@@ -248,22 +248,22 @@ function matchesQuotationFilters(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isPipelineStage(
|
function isPipelineStage(
|
||||||
enquiry: typeof crmEnquiries.$inferSelect,
|
opportunity: typeof crmOpportunities.$inferSelect,
|
||||||
stage: 'lead' | 'enquiry' | 'closed_won' | 'closed_lost'
|
stage: 'lead' | 'opportunity' | 'closed_won' | 'closed_lost'
|
||||||
) {
|
) {
|
||||||
return enquiry.pipelineStage === stage;
|
return opportunity.pipelineStage === stage;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadScopedRows(
|
async function loadScopedRows(
|
||||||
context: DashboardContext,
|
context: DashboardContext,
|
||||||
filters: NormalizedFilters
|
filters: NormalizedFilters
|
||||||
) {
|
) {
|
||||||
const [enquiries, quotations] = await Promise.all([
|
const [opportunities, quotations] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(crmEnquiries)
|
.from(crmOpportunities)
|
||||||
.where(
|
.where(
|
||||||
and(eq(crmEnquiries.organizationId, context.organizationId), isNull(crmEnquiries.deletedAt))
|
and(eq(crmOpportunities.organizationId, context.organizationId), isNull(crmOpportunities.deletedAt))
|
||||||
),
|
),
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
@@ -274,9 +274,9 @@ async function loadScopedRows(
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enquiries: enquiries
|
opportunities: opportunities
|
||||||
.filter((row) => canAccessDashboardEnquiry(row, context))
|
.filter((row) => canAccessDashboardOpportunity(row, context))
|
||||||
.filter((row) => matchesEnquiryFilters(row, filters)),
|
.filter((row) => matchesOpportunityFilters(row, filters)),
|
||||||
quotations: quotations
|
quotations: quotations
|
||||||
.filter((row) => canAccessDashboardQuotation(row, context))
|
.filter((row) => canAccessDashboardQuotation(row, context))
|
||||||
.filter((row) => matchesQuotationFilters(row, filters))
|
.filter((row) => matchesQuotationFilters(row, filters))
|
||||||
@@ -284,16 +284,16 @@ async function loadScopedRows(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildSummary(
|
function buildSummary(
|
||||||
scopedEnquiries: Array<typeof crmEnquiries.$inferSelect>,
|
scopedOpportunities: Array<typeof crmOpportunities.$inferSelect>,
|
||||||
scopedQuotations: Array<typeof crmQuotations.$inferSelect>,
|
scopedQuotations: Array<typeof crmQuotations.$inferSelect>,
|
||||||
statusMaps: StatusMaps,
|
statusMaps: StatusMaps,
|
||||||
outcomeMetrics: { wonValue: number; lostValue: number; winRate: number }
|
outcomeMetrics: { wonValue: number; lostValue: number; winRate: number }
|
||||||
): CrmDashboardSummary {
|
): CrmDashboardSummary {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const leadRows = scopedEnquiries.filter((row) => isPipelineStage(row, 'lead'));
|
const leadRows = scopedOpportunities.filter((row) => isPipelineStage(row, 'lead'));
|
||||||
const enquiryRows = scopedEnquiries.filter((row) => isPipelineStage(row, 'enquiry'));
|
const opportunityRows = scopedOpportunities.filter((row) => isPipelineStage(row, 'opportunity'));
|
||||||
const wonRows = scopedEnquiries.filter((row) => isPipelineStage(row, 'closed_won'));
|
const wonRows = scopedOpportunities.filter((row) => isPipelineStage(row, 'closed_won'));
|
||||||
const lostRows = scopedEnquiries.filter((row) => isPipelineStage(row, 'closed_lost'));
|
const lostRows = scopedOpportunities.filter((row) => isPipelineStage(row, 'closed_lost'));
|
||||||
|
|
||||||
const leadAges = leadRows.map((row) => (now.getTime() - row.createdAt.getTime()) / (1000 * 60 * 60 * 24));
|
const leadAges = leadRows.map((row) => (now.getTime() - row.createdAt.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
const quotationStatusCodes = scopedQuotations.map((row) => statusMaps.quotationStatusById.get(row.status) ?? '');
|
const quotationStatusCodes = scopedQuotations.map((row) => statusMaps.quotationStatusById.get(row.status) ?? '');
|
||||||
@@ -305,14 +305,14 @@ function buildSummary(
|
|||||||
unassignedLeads: leadRows.filter((row) => row.assignedToUserId === null).length,
|
unassignedLeads: leadRows.filter((row) => row.assignedToUserId === null).length,
|
||||||
averageLeadAgingDays: average(leadAges)
|
averageLeadAgingDays: average(leadAges)
|
||||||
},
|
},
|
||||||
enquiry: {
|
opportunity: {
|
||||||
enquiryCount: enquiryRows.length,
|
opportunityCount: opportunityRows.length,
|
||||||
activeEnquiries: enquiryRows.length,
|
activeOpportunities: opportunityRows.length,
|
||||||
wonCount: wonRows.length,
|
wonCount: wonRows.length,
|
||||||
lostCount: lostRows.length,
|
lostCount: lostRows.length,
|
||||||
winRate: outcomeMetrics.winRate,
|
winRate: outcomeMetrics.winRate,
|
||||||
hotEnquiries: enquiryRows.filter((row) => row.isHotProject).length,
|
hotOpportunities: opportunityRows.filter((row) => row.isHotProject).length,
|
||||||
enquiryValue: round2(enquiryRows.reduce((sum, row) => sum + (row.estimatedValue ?? 0), 0))
|
opportunityValue: round2(opportunityRows.reduce((sum, row) => sum + (row.estimatedValue ?? 0), 0))
|
||||||
},
|
},
|
||||||
quotation: {
|
quotation: {
|
||||||
draftQuotations: quotationStatusCodes.filter((code) => code === 'draft').length,
|
draftQuotations: quotationStatusCodes.filter((code) => code === 'draft').length,
|
||||||
@@ -345,10 +345,10 @@ function buildFunnel(
|
|||||||
value: 0
|
value: 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'enquiry',
|
key: 'opportunity',
|
||||||
label: getPipelineStageThaiLabel('enquiry'),
|
label: getPipelineStageThaiLabel('opportunity'),
|
||||||
count: summary.enquiry.enquiryCount,
|
count: summary.opportunity.opportunityCount,
|
||||||
value: summary.enquiry.enquiryValue
|
value: summary.opportunity.opportunityValue
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'quotation',
|
key: 'quotation',
|
||||||
@@ -383,7 +383,7 @@ function buildFunnel(
|
|||||||
{
|
{
|
||||||
key: 'closed_won',
|
key: 'closed_won',
|
||||||
label: getPipelineStageThaiLabel('closed_won'),
|
label: getPipelineStageThaiLabel('closed_won'),
|
||||||
count: summary.enquiry.wonCount,
|
count: summary.opportunity.wonCount,
|
||||||
value: summary.revenue.wonValue
|
value: summary.revenue.wonValue
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@@ -456,7 +456,7 @@ async function buildRevenueAnalytics(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function buildSalesRanking(
|
async function buildSalesRanking(
|
||||||
scopedEnquiries: Array<typeof crmEnquiries.$inferSelect>,
|
scopedOpportunities: Array<typeof crmOpportunities.$inferSelect>,
|
||||||
scopedQuotations: Array<typeof crmQuotations.$inferSelect>,
|
scopedQuotations: Array<typeof crmQuotations.$inferSelect>,
|
||||||
statusMaps: StatusMaps,
|
statusMaps: StatusMaps,
|
||||||
wonRevenueRows: Array<{ assignedToUserId: string | null; revenue: number }>
|
wonRevenueRows: Array<{ assignedToUserId: string | null; revenue: number }>
|
||||||
@@ -464,7 +464,7 @@ async function buildSalesRanking(
|
|||||||
const userIds = [
|
const userIds = [
|
||||||
...new Set(
|
...new Set(
|
||||||
[
|
[
|
||||||
...scopedEnquiries.map((row) => row.assignedToUserId).filter(Boolean),
|
...scopedOpportunities.map((row) => row.assignedToUserId).filter(Boolean),
|
||||||
...scopedQuotations.map((row) => row.salesmanId).filter(Boolean)
|
...scopedQuotations.map((row) => row.salesmanId).filter(Boolean)
|
||||||
] as string[]
|
] as string[]
|
||||||
)
|
)
|
||||||
@@ -475,7 +475,7 @@ async function buildSalesRanking(
|
|||||||
const userMap = new Map(salesUsers.map((row) => [row.id, row.name]));
|
const userMap = new Map(salesUsers.map((row) => [row.id, row.name]));
|
||||||
const rankingMap = new Map<string, CrmDashboardSalesRankingRow>();
|
const rankingMap = new Map<string, CrmDashboardSalesRankingRow>();
|
||||||
|
|
||||||
for (const row of scopedEnquiries) {
|
for (const row of scopedOpportunities) {
|
||||||
if (!row.assignedToUserId) continue;
|
if (!row.assignedToUserId) continue;
|
||||||
const current =
|
const current =
|
||||||
rankingMap.get(row.assignedToUserId) ??
|
rankingMap.get(row.assignedToUserId) ??
|
||||||
@@ -483,7 +483,7 @@ async function buildSalesRanking(
|
|||||||
salesPersonId: row.assignedToUserId,
|
salesPersonId: row.assignedToUserId,
|
||||||
salesPersonName: userMap.get(row.assignedToUserId) ?? 'Unknown',
|
salesPersonName: userMap.get(row.assignedToUserId) ?? 'Unknown',
|
||||||
leadCount: 0,
|
leadCount: 0,
|
||||||
enquiryCount: 0,
|
opportunityCount: 0,
|
||||||
quotationCount: 0,
|
quotationCount: 0,
|
||||||
approvedQuotations: 0,
|
approvedQuotations: 0,
|
||||||
wonRevenue: 0,
|
wonRevenue: 0,
|
||||||
@@ -494,8 +494,8 @@ async function buildSalesRanking(
|
|||||||
current.leadCount += 1;
|
current.leadCount += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPipelineStage(row, 'enquiry')) {
|
if (isPipelineStage(row, 'opportunity')) {
|
||||||
current.enquiryCount += 1;
|
current.opportunityCount += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
rankingMap.set(row.assignedToUserId, current);
|
rankingMap.set(row.assignedToUserId, current);
|
||||||
@@ -509,7 +509,7 @@ async function buildSalesRanking(
|
|||||||
salesPersonId: row.salesmanId,
|
salesPersonId: row.salesmanId,
|
||||||
salesPersonName: userMap.get(row.salesmanId) ?? 'Unknown',
|
salesPersonName: userMap.get(row.salesmanId) ?? 'Unknown',
|
||||||
leadCount: 0,
|
leadCount: 0,
|
||||||
enquiryCount: 0,
|
opportunityCount: 0,
|
||||||
quotationCount: 0,
|
quotationCount: 0,
|
||||||
approvedQuotations: 0,
|
approvedQuotations: 0,
|
||||||
wonRevenue: 0,
|
wonRevenue: 0,
|
||||||
@@ -539,7 +539,7 @@ async function buildSalesRanking(
|
|||||||
salesPersonId: row.assignedToUserId,
|
salesPersonId: row.assignedToUserId,
|
||||||
salesPersonName: userMap.get(row.assignedToUserId) ?? 'Unknown',
|
salesPersonName: userMap.get(row.assignedToUserId) ?? 'Unknown',
|
||||||
leadCount: 0,
|
leadCount: 0,
|
||||||
enquiryCount: 0,
|
opportunityCount: 0,
|
||||||
quotationCount: 0,
|
quotationCount: 0,
|
||||||
approvedQuotations: 0,
|
approvedQuotations: 0,
|
||||||
wonRevenue: 0,
|
wonRevenue: 0,
|
||||||
@@ -570,17 +570,17 @@ async function buildFollowups(
|
|||||||
filters: NormalizedFilters
|
filters: NormalizedFilters
|
||||||
): Promise<{ summary: CrmDashboardFollowupSummary; upcoming: CrmDashboardFollowupRow[] }> {
|
): Promise<{ summary: CrmDashboardFollowupSummary; upcoming: CrmDashboardFollowupRow[] }> {
|
||||||
const allowQuotationFollowups = canViewCommercialData(context);
|
const allowQuotationFollowups = canViewCommercialData(context);
|
||||||
const [enquiryFollowups, quotationFollowups] = await Promise.all([
|
const [opportunityFollowups, quotationFollowups] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(crmEnquiryFollowups)
|
.from(crmOpportunityFollowups)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(crmEnquiryFollowups.organizationId, context.organizationId),
|
eq(crmOpportunityFollowups.organizationId, context.organizationId),
|
||||||
isNull(crmEnquiryFollowups.deletedAt)
|
isNull(crmOpportunityFollowups.deletedAt)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.orderBy(asc(crmEnquiryFollowups.followupDate)),
|
.orderBy(asc(crmOpportunityFollowups.followupDate)),
|
||||||
allowQuotationFollowups
|
allowQuotationFollowups
|
||||||
? db
|
? db
|
||||||
.select()
|
.select()
|
||||||
@@ -599,49 +599,49 @@ async function buildFollowups(
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const weekStart = startOfWeek(now, { weekStartsOn: 1 });
|
const weekStart = startOfWeek(now, { weekStartsOn: 1 });
|
||||||
const weekEnd = endOfWeek(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 quotationIds = [...new Set(quotationFollowups.map((row) => row.quotationId))];
|
||||||
const [enquiries, quotations] = await Promise.all([
|
const [opportunities, quotations] = await Promise.all([
|
||||||
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.select().from(crmQuotations).where(inArray(crmQuotations.id, quotationIds)) : []
|
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 = [
|
const salesIds = [
|
||||||
...new Set(
|
...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([
|
const [customers, salesUsers] = await Promise.all([
|
||||||
customerIds.length ? db.select().from(crmCustomers).where(inArray(crmCustomers.id, customerIds)) : [],
|
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)) : []
|
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 quotationMap = new Map(quotations.map((row) => [row.id, row]));
|
||||||
const customerMap = new Map(customers.map((row) => [row.id, row.name]));
|
const customerMap = new Map(customers.map((row) => [row.id, row.name]));
|
||||||
const salesMap = new Map(salesUsers.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) => {
|
.filter((row) => {
|
||||||
const enquiry = enquiryMap.get(row.enquiryId);
|
const opportunity = opportunityMap.get(row.opportunityId);
|
||||||
return enquiry ? canAccessDashboardEnquiry(enquiry, context) : false;
|
return opportunity ? canAccessDashboardOpportunity(opportunity, context) : false;
|
||||||
})
|
})
|
||||||
.filter((row) => !isBefore(row.followupDate, from) && !isAfter(row.followupDate, to))
|
.filter((row) => !isBefore(row.followupDate, from) && !isAfter(row.followupDate, to))
|
||||||
.flatMap<CrmDashboardFollowupRow>((row) => {
|
.flatMap<CrmDashboardFollowupRow>((row) => {
|
||||||
const enquiry = enquiryMap.get(row.enquiryId);
|
const opportunity = opportunityMap.get(row.opportunityId);
|
||||||
|
|
||||||
if (!enquiry) {
|
if (!opportunity) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: row.id,
|
id: row.id,
|
||||||
sourceType: 'enquiry',
|
sourceType: 'opportunity',
|
||||||
followupDate: row.followupDate.toISOString(),
|
followupDate: row.followupDate.toISOString(),
|
||||||
customerName: customerMap.get(enquiry.customerId) ?? 'Unknown customer',
|
customerName: customerMap.get(opportunity.customerId) ?? 'Unknown customer',
|
||||||
enquiryName: enquiry.projectName ?? enquiry.title,
|
opportunityName: opportunity.projectName ?? opportunity.title,
|
||||||
assignedSalesName: enquiry.assignedToUserId
|
assignedSalesName: opportunity.assignedToUserId
|
||||||
? (salesMap.get(enquiry.assignedToUserId) ?? null)
|
? (salesMap.get(opportunity.assignedToUserId) ?? null)
|
||||||
: null,
|
: null,
|
||||||
outcome: row.outcome
|
outcome: row.outcome
|
||||||
}
|
}
|
||||||
@@ -666,7 +666,7 @@ async function buildFollowups(
|
|||||||
sourceType: 'quotation',
|
sourceType: 'quotation',
|
||||||
followupDate: row.followupDate.toISOString(),
|
followupDate: row.followupDate.toISOString(),
|
||||||
customerName: customerMap.get(quotation.customerId) ?? 'Unknown customer',
|
customerName: customerMap.get(quotation.customerId) ?? 'Unknown customer',
|
||||||
enquiryName: quotation.projectName ?? quotation.code,
|
opportunityName: quotation.projectName ?? quotation.code,
|
||||||
assignedSalesName: quotation.salesmanId
|
assignedSalesName: quotation.salesmanId
|
||||||
? (salesMap.get(quotation.salesmanId) ?? null)
|
? (salesMap.get(quotation.salesmanId) ?? null)
|
||||||
: null,
|
: null,
|
||||||
@@ -674,7 +674,7 @@ async function buildFollowups(
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
const rows: CrmDashboardFollowupRow[] = [...enquiryRows, ...quotationRows];
|
const rows: CrmDashboardFollowupRow[] = [...opportunityRows, ...quotationRows];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
summary: {
|
summary: {
|
||||||
@@ -749,19 +749,19 @@ async function buildHotProjects(
|
|||||||
): Promise<CrmDashboardHotProjectRow[]> {
|
): Promise<CrmDashboardHotProjectRow[]> {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(crmEnquiries)
|
.from(crmOpportunities)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(crmEnquiries.organizationId, context.organizationId),
|
eq(crmOpportunities.organizationId, context.organizationId),
|
||||||
isNull(crmEnquiries.deletedAt),
|
isNull(crmOpportunities.deletedAt),
|
||||||
eq(crmEnquiries.isHotProject, true)
|
eq(crmOpportunities.isHotProject, true)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.orderBy(desc(crmEnquiries.updatedAt));
|
.orderBy(desc(crmOpportunities.updatedAt));
|
||||||
const scoped = rows
|
const scoped = rows
|
||||||
.filter((row) => canAccessDashboardEnquiry(row, context))
|
.filter((row) => canAccessDashboardOpportunity(row, context))
|
||||||
.filter((row) => matchesEnquiryFilters(row, filters))
|
.filter((row) => matchesOpportunityFilters(row, filters))
|
||||||
.filter((row) => isPipelineStage(row, 'enquiry'))
|
.filter((row) => isPipelineStage(row, 'opportunity'))
|
||||||
.slice(0, 10);
|
.slice(0, 10);
|
||||||
const customerIds = [...new Set(scoped.map((row) => row.customerId))];
|
const customerIds = [...new Set(scoped.map((row) => row.customerId))];
|
||||||
const salesIds = [...new Set(scoped.map((row) => row.assignedToUserId).filter(Boolean))] as string[];
|
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]));
|
const salesMap = new Map(salesUsers.map((row) => [row.id, row.name]));
|
||||||
|
|
||||||
return scoped.map((row) => ({
|
return scoped.map((row) => ({
|
||||||
enquiryId: row.id,
|
opportunityId: row.id,
|
||||||
enquiryCode: row.code,
|
opportunityCode: row.code,
|
||||||
projectName: row.projectName ?? row.title,
|
projectName: row.projectName ?? row.title,
|
||||||
customerName: customerMap.get(row.customerId) ?? 'Unknown customer',
|
customerName: customerMap.get(row.customerId) ?? 'Unknown customer',
|
||||||
value: row.estimatedValue ?? 0,
|
value: row.estimatedValue ?? 0,
|
||||||
@@ -812,7 +812,7 @@ export async function getCrmDashboardData(
|
|||||||
getLostByCompetitor(context.organizationId, outcomeFilters)
|
getLostByCompetitor(context.organizationId, outcomeFilters)
|
||||||
])
|
])
|
||||||
: [[], [], 0, [], []];
|
: [[], [], 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),
|
wonValue: wonRevenueRows.reduce((sum, row) => sum + row.revenue, 0),
|
||||||
lostValue: lostRevenueRows.reduce((sum, row) => sum + row.revenue, 0),
|
lostValue: lostRevenueRows.reduce((sum, row) => sum + row.revenue, 0),
|
||||||
winRate
|
winRate
|
||||||
@@ -827,7 +827,7 @@ export async function getCrmDashboardData(
|
|||||||
topConsultants: []
|
topConsultants: []
|
||||||
}),
|
}),
|
||||||
allowCommercialData
|
allowCommercialData
|
||||||
? buildSalesRanking(scoped.enquiries, scoped.quotations, statusMaps, wonRevenueRows)
|
? buildSalesRanking(scoped.opportunities, scoped.quotations, statusMaps, wonRevenueRows)
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
buildFollowups(context, filters),
|
buildFollowups(context, filters),
|
||||||
allowApprovalData
|
allowApprovalData
|
||||||
@@ -871,3 +871,4 @@ export async function getCrmDashboardData(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -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)
|
|
||||||
});
|
|
||||||
@@ -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<EnquiryListResponse> {
|
|
||||||
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<EnquiryListResponse>(`/crm/enquiries${query ? `?${query}` : ''}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getEnquiryById(id: string): Promise<EnquiryDetailResponse> {
|
|
||||||
return apiClient<EnquiryDetailResponse>(`/crm/enquiries/${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getEnquiryProjectParties(id: string): Promise<EnquiryProjectPartiesResponse> {
|
|
||||||
return apiClient<EnquiryProjectPartiesResponse>(`/crm/enquiries/${id}/customers`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createEnquiry(data: EnquiryMutationPayload) {
|
|
||||||
return apiClient<MutationSuccessResponse>('/crm/enquiries', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateEnquiry(id: string, data: EnquiryMutationPayload) {
|
|
||||||
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteEnquiry(id: string) {
|
|
||||||
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${id}`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function assignEnquiry(id: string, data: EnquiryAssignmentMutationPayload) {
|
|
||||||
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${id}/assign`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function reassignEnquiry(id: string, data: EnquiryAssignmentMutationPayload) {
|
|
||||||
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${id}/reassign`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getEnquiryFollowups(id: string): Promise<EnquiryFollowupsResponse> {
|
|
||||||
return apiClient<EnquiryFollowupsResponse>(`/crm/enquiries/${id}/followups`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getEnquiryAttachments(id: string): Promise<EnquiryAttachmentsResponse> {
|
|
||||||
return apiClient<EnquiryAttachmentsResponse>(`/crm/enquiries/${id}/po-attachments`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createEnquiryFollowup(
|
|
||||||
enquiryId: string,
|
|
||||||
data: EnquiryFollowupMutationPayload
|
|
||||||
) {
|
|
||||||
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${enquiryId}/followups`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateEnquiryFollowup(
|
|
||||||
enquiryId: string,
|
|
||||||
followupId: string,
|
|
||||||
data: EnquiryFollowupMutationPayload
|
|
||||||
) {
|
|
||||||
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${enquiryId}/followups/${followupId}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteEnquiryFollowup(enquiryId: string, followupId: string) {
|
|
||||||
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${enquiryId}/followups/${followupId}`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function markEnquiryWon(id: string, data: EnquiryMarkWonPayload) {
|
|
||||||
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${id}/mark-won`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function markEnquiryLost(id: string, data: EnquiryMarkLostPayload) {
|
|
||||||
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${id}/mark-lost`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function reopenEnquiry(id: string, data: EnquiryReopenPayload) {
|
|
||||||
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${id}/reopen`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,7 @@ export type {
|
|||||||
LeadDetail,
|
LeadDetail,
|
||||||
LeadDetailResponse,
|
LeadDetailResponse,
|
||||||
LeadFollowupSummary,
|
LeadFollowupSummary,
|
||||||
LeadLinkedEnquirySummary,
|
LeadLinkedOpportunitySummary,
|
||||||
LeadListFilters,
|
LeadListFilters,
|
||||||
LeadListResponse,
|
LeadListResponse,
|
||||||
LeadMutationSuccessResponse,
|
LeadMutationSuccessResponse,
|
||||||
@@ -18,3 +18,4 @@ export type {
|
|||||||
LeadSummary,
|
LeadSummary,
|
||||||
UpdateLeadInput
|
UpdateLeadInput
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
|
|||||||
@@ -54,9 +54,9 @@ export function LeadAssignmentDialog({
|
|||||||
...assignLeadMutation,
|
...assignLeadMutation,
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
toast.success(
|
toast.success(
|
||||||
result.reusedExistingEnquiry
|
result.reusedExistingOpportunity
|
||||||
? `มอบหมายสำเร็จและใช้ enquiry เดิม ${result.enquiryCode}`
|
? `มอบหมายสำเร็จและใช้ opportunity เดิม ${result.opportunityCode}`
|
||||||
: `มอบหมายสำเร็จและสร้าง enquiry ${result.enquiryCode}`
|
: `มอบหมายสำเร็จและสร้าง opportunity ${result.opportunityCode}`
|
||||||
);
|
);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
},
|
},
|
||||||
@@ -90,7 +90,7 @@ export function LeadAssignmentDialog({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Assign Lead to Sales</DialogTitle>
|
<DialogTitle>Assign Lead to Sales</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
มอบหมายลีดให้ฝ่ายขายและสร้าง enquiry workspace แยกจาก lead workspace
|
มอบหมายลีดให้ฝ่ายขายและสร้าง opportunity workspace แยกจาก lead workspace
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -127,17 +127,17 @@ export function LeadAssignmentDialog({
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|
||||||
{'linkedEnquiries' in lead && lead.linkedEnquiries.length > 0 ? (
|
{'linkedOpportunities' in lead && lead.linkedOpportunities.length > 0 ? (
|
||||||
<div className='border-border rounded-md border p-3 text-sm'>
|
<div className='border-border rounded-md border p-3 text-sm'>
|
||||||
<div className='mb-2 font-medium'>Linked enquiries</div>
|
<div className='mb-2 font-medium'>Linked opportunities</div>
|
||||||
<div className='space-y-1'>
|
<div className='space-y-1'>
|
||||||
{lead.linkedEnquiries.map((enquiry) => (
|
{lead.linkedOpportunities.map((opportunity) => (
|
||||||
<Link
|
<Link
|
||||||
key={enquiry.id}
|
key={opportunity.id}
|
||||||
href={`/dashboard/crm/enquiries/${enquiry.id}`}
|
href={`/dashboard/crm/opportunities/${opportunity.id}`}
|
||||||
className='block hover:underline'
|
className='block hover:underline'
|
||||||
>
|
>
|
||||||
{enquiry.code} - {enquiry.title}
|
{opportunity.code} - {opportunity.title}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -147,3 +147,4 @@ export function LeadAssignmentDialog({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function LeadDetail({
|
|||||||
<Badge variant='outline'>{lead.statusLabel ?? lead.status}</Badge>
|
<Badge variant='outline'>{lead.statusLabel ?? lead.status}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className='text-muted-foreground mt-2 text-sm'>
|
<p className='text-muted-foreground mt-2 text-sm'>
|
||||||
Lead workspace for marketing qualification before sales enquiry handoff
|
Lead workspace for marketing qualification before sales opportunity handoff
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ export function LeadDetail({
|
|||||||
<Tabs defaultValue='followups' className='space-y-4'>
|
<Tabs defaultValue='followups' className='space-y-4'>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value='followups'>Follow-ups</TabsTrigger>
|
<TabsTrigger value='followups'>Follow-ups</TabsTrigger>
|
||||||
<TabsTrigger value='linked-enquiries'>Linked Enquiries</TabsTrigger>
|
<TabsTrigger value='linked-opportunities'>Linked Opportunities</TabsTrigger>
|
||||||
<TabsTrigger value='activity'>Activity</TabsTrigger>
|
<TabsTrigger value='activity'>Activity</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
@@ -111,29 +111,29 @@ export function LeadDetail({
|
|||||||
<LeadFollowupPanel leadId={leadId} detail={data} />
|
<LeadFollowupPanel leadId={leadId} detail={data} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value='linked-enquiries'>
|
<TabsContent value='linked-opportunities'>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Linked Enquiries</CardTitle>
|
<CardTitle>Linked Opportunities</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='space-y-3'>
|
<CardContent className='space-y-3'>
|
||||||
{lead.linkedEnquiries.length === 0 ? (
|
{lead.linkedOpportunities.length === 0 ? (
|
||||||
<div className='text-muted-foreground text-sm'>No linked enquiries yet.</div>
|
<div className='text-muted-foreground text-sm'>No linked opportunities yet.</div>
|
||||||
) : (
|
) : (
|
||||||
lead.linkedEnquiries.map((enquiry) => (
|
lead.linkedOpportunities.map((opportunity) => (
|
||||||
<div key={enquiry.id} className='border-border rounded-md border p-3'>
|
<div key={opportunity.id} className='border-border rounded-md border p-3'>
|
||||||
<div className='flex items-center justify-between gap-3'>
|
<div className='flex items-center justify-between gap-3'>
|
||||||
<Link
|
<Link
|
||||||
href={`/dashboard/crm/enquiries/${enquiry.id}`}
|
href={`/dashboard/crm/opportunities/${opportunity.id}`}
|
||||||
className='font-medium hover:underline'
|
className='font-medium hover:underline'
|
||||||
>
|
>
|
||||||
{enquiry.code}
|
{opportunity.code}
|
||||||
</Link>
|
</Link>
|
||||||
<Badge variant='outline'>{enquiry.status}</Badge>
|
<Badge variant='outline'>{opportunity.status}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className='mt-1 text-sm'>{enquiry.title}</div>
|
<div className='mt-1 text-sm'>{opportunity.title}</div>
|
||||||
<div className='text-muted-foreground mt-2 text-xs'>
|
<div className='text-muted-foreground mt-2 text-xs'>
|
||||||
Owner: {enquiry.assignedToName ?? '-'}
|
Owner: {opportunity.assignedToName ?? '-'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
@@ -174,7 +174,7 @@ export function LeadDetail({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='space-y-3'>
|
<CardContent className='space-y-3'>
|
||||||
<FieldItem label='Current Workspace' value='Lead' />
|
<FieldItem label='Current Workspace' value='Lead' />
|
||||||
<FieldItem label='Related Enquiries' value={String(lead.relatedEnquiryCount)} />
|
<FieldItem label='Related Opportunities' value={String(lead.relatedOpportunityCount)} />
|
||||||
<FieldItem
|
<FieldItem
|
||||||
label='Assigned At'
|
label='Assigned At'
|
||||||
value={lead.assignedAt ? new Date(lead.assignedAt).toLocaleString() : null}
|
value={lead.assignedAt ? new Date(lead.assignedAt).toLocaleString() : null}
|
||||||
@@ -188,3 +188,4 @@ export function LeadDetail({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { and, asc, eq, inArray, isNull } from 'drizzle-orm';
|
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 { db } from '@/lib/db';
|
||||||
import { AuthError } from '@/lib/auth/session';
|
import { AuthError } from '@/lib/auth/session';
|
||||||
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
|
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
|
||||||
@@ -13,13 +13,13 @@ import {
|
|||||||
assertAssignableUserBelongsToOrganization,
|
assertAssignableUserBelongsToOrganization,
|
||||||
assertContactBelongsToOrganization,
|
assertContactBelongsToOrganization,
|
||||||
assertCustomerBelongsToOrganization
|
assertCustomerBelongsToOrganization
|
||||||
} from '@/features/crm/enquiries/server/service';
|
} from '@/features/crm/opportunities/server/service';
|
||||||
import type { AssignLeadInput, AssignLeadResult } from '../types';
|
import type { AssignLeadInput, AssignLeadResult } from '../types';
|
||||||
|
|
||||||
export type LeadAssignmentAccessContext = CrmSecurityContext;
|
export type LeadAssignmentAccessContext = CrmSecurityContext;
|
||||||
|
|
||||||
type LeadRow = typeof crmLeads.$inferSelect;
|
type LeadRow = typeof crmLeads.$inferSelect;
|
||||||
type EnquiryRow = typeof crmEnquiries.$inferSelect;
|
type OpportunityRow = typeof crmOpportunities.$inferSelect;
|
||||||
|
|
||||||
function canAccessLeadRow(row: LeadRow, accessContext: LeadAssignmentAccessContext) {
|
function canAccessLeadRow(row: LeadRow, accessContext: LeadAssignmentAccessContext) {
|
||||||
return canAccessScopedCrmRecord(accessContext, {
|
return canAccessScopedCrmRecord(accessContext, {
|
||||||
@@ -57,34 +57,34 @@ async function assertLeadBelongsToOrganization(
|
|||||||
return lead;
|
return lead;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveDefaultEnquiryStatusId(organizationId: string) {
|
async function resolveDefaultOpportunityStatusId(organizationId: string) {
|
||||||
const options = await getActiveOptionsByCategory('crm_enquiry_status', { organizationId });
|
const options = await getActiveOptionsByCategory('crm_opportunity_status', { organizationId });
|
||||||
const preferred = options.find((option) => option.code === 'new');
|
const preferred = options.find((option) => option.code === 'new');
|
||||||
const resolved = preferred ?? options[0];
|
const resolved = preferred ?? options[0];
|
||||||
|
|
||||||
if (!resolved) {
|
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;
|
return resolved.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findActiveEnquiryByLead(leadId: string, organizationId: string) {
|
async function findActiveOpportunityByLead(leadId: string, organizationId: string) {
|
||||||
const [enquiry] = await db
|
const [opportunity] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(crmEnquiries)
|
.from(crmOpportunities)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(crmEnquiries.leadId, leadId),
|
eq(crmOpportunities.leadId, leadId),
|
||||||
eq(crmEnquiries.organizationId, organizationId),
|
eq(crmOpportunities.organizationId, organizationId),
|
||||||
inArray(crmEnquiries.pipelineStage, ['lead', 'enquiry']),
|
inArray(crmOpportunities.pipelineStage, ['lead', 'opportunity']),
|
||||||
isNull(crmEnquiries.deletedAt)
|
isNull(crmOpportunities.deletedAt)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.orderBy(asc(crmEnquiries.createdAt))
|
.orderBy(asc(crmOpportunities.createdAt))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
return enquiry ?? null;
|
return opportunity ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncLeadAssignment(input: {
|
async function syncLeadAssignment(input: {
|
||||||
@@ -109,7 +109,7 @@ async function syncLeadAssignment(input: {
|
|||||||
.where(eq(crmLeads.id, input.leadId));
|
.where(eq(crmLeads.id, input.leadId));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createEnquiryFromLead(input: {
|
async function createOpportunityFromLead(input: {
|
||||||
lead: LeadRow;
|
lead: LeadRow;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
actorUserId: string;
|
actorUserId: string;
|
||||||
@@ -139,17 +139,17 @@ async function createEnquiryFromLead(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [statusId, documentCode] = await Promise.all([
|
const [statusId, documentCode] = await Promise.all([
|
||||||
resolveDefaultEnquiryStatusId(input.organizationId),
|
resolveDefaultOpportunityStatusId(input.organizationId),
|
||||||
generateNextDocumentCode({
|
generateNextDocumentCode({
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
documentType: 'crm_enquiry',
|
documentType: 'crm_opportunity',
|
||||||
branchId: input.lead.branchId ?? null
|
branchId: input.lead.branchId ?? null
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const [created] = await db
|
const [created] = await db
|
||||||
.insert(crmEnquiries)
|
.insert(crmOpportunities)
|
||||||
.values({
|
.values({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
organizationId: input.organizationId,
|
organizationId: input.organizationId,
|
||||||
@@ -175,7 +175,7 @@ async function createEnquiryFromLead(input: {
|
|||||||
notes: null,
|
notes: null,
|
||||||
isHotProject: false,
|
isHotProject: false,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
pipelineStage: 'enquiry',
|
pipelineStage: 'opportunity',
|
||||||
assignedToUserId: input.salesOwnerId,
|
assignedToUserId: input.salesOwnerId,
|
||||||
assignedAt: now,
|
assignedAt: now,
|
||||||
assignedBy: input.actorUserId,
|
assignedBy: input.actorUserId,
|
||||||
@@ -197,16 +197,16 @@ export async function assignLead(
|
|||||||
const lead = await assertLeadBelongsToOrganization(input.leadId, organizationId, accessContext);
|
const lead = await assertLeadBelongsToOrganization(input.leadId, organizationId, accessContext);
|
||||||
await assertAssignableUserBelongsToOrganization(input.salesOwnerId, organizationId);
|
await assertAssignableUserBelongsToOrganization(input.salesOwnerId, organizationId);
|
||||||
|
|
||||||
const existingEnquiry = await findActiveEnquiryByLead(input.leadId, organizationId);
|
const existingOpportunity = await findActiveOpportunityByLead(input.leadId, organizationId);
|
||||||
|
|
||||||
if (existingEnquiry) {
|
if (existingOpportunity) {
|
||||||
const resolvedSalesOwnerId = existingEnquiry.assignedToUserId ?? input.salesOwnerId;
|
const resolvedSalesOwnerId = existingOpportunity.assignedToUserId ?? input.salesOwnerId;
|
||||||
|
|
||||||
if (!existingEnquiry.assignedToUserId) {
|
if (!existingOpportunity.assignedToUserId) {
|
||||||
await db
|
await db
|
||||||
.update(crmEnquiries)
|
.update(crmOpportunities)
|
||||||
.set({
|
.set({
|
||||||
pipelineStage: 'enquiry',
|
pipelineStage: 'opportunity',
|
||||||
assignedToUserId: input.salesOwnerId,
|
assignedToUserId: input.salesOwnerId,
|
||||||
assignedAt: new Date(),
|
assignedAt: new Date(),
|
||||||
assignedBy: actorUserId,
|
assignedBy: actorUserId,
|
||||||
@@ -214,7 +214,7 @@ export async function assignLead(
|
|||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
updatedBy: actorUserId
|
updatedBy: actorUserId
|
||||||
})
|
})
|
||||||
.where(eq(crmEnquiries.id, existingEnquiry.id));
|
.where(eq(crmOpportunities.id, existingOpportunity.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
await syncLeadAssignment({
|
await syncLeadAssignment({
|
||||||
@@ -234,21 +234,21 @@ export async function assignLead(
|
|||||||
action: 'assign_lead',
|
action: 'assign_lead',
|
||||||
afterData: {
|
afterData: {
|
||||||
leadId: input.leadId,
|
leadId: input.leadId,
|
||||||
enquiryId: existingEnquiry.id,
|
opportunityId: existingOpportunity.id,
|
||||||
salesOwnerId: resolvedSalesOwnerId,
|
salesOwnerId: resolvedSalesOwnerId,
|
||||||
reusedExistingEnquiry: true
|
reusedExistingOpportunity: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
leadId: input.leadId,
|
leadId: input.leadId,
|
||||||
enquiryId: existingEnquiry.id,
|
opportunityId: existingOpportunity.id,
|
||||||
enquiryCode: existingEnquiry.code,
|
opportunityCode: existingOpportunity.code,
|
||||||
reusedExistingEnquiry: true
|
reusedExistingOpportunity: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const createdEnquiry = await createEnquiryFromLead({
|
const createdOpportunity = await createOpportunityFromLead({
|
||||||
lead,
|
lead,
|
||||||
organizationId,
|
organizationId,
|
||||||
actorUserId,
|
actorUserId,
|
||||||
@@ -268,12 +268,12 @@ export async function assignLead(
|
|||||||
organizationId,
|
organizationId,
|
||||||
branchId: lead.branchId,
|
branchId: lead.branchId,
|
||||||
userId: actorUserId,
|
userId: actorUserId,
|
||||||
entityType: 'crm_enquiry',
|
entityType: 'crm_opportunity',
|
||||||
entityId: createdEnquiry.id,
|
entityId: createdOpportunity.id,
|
||||||
action: 'create_enquiry_from_lead',
|
action: 'create_opportunity_from_lead',
|
||||||
afterData: {
|
afterData: {
|
||||||
leadId: input.leadId,
|
leadId: input.leadId,
|
||||||
enquiryId: createdEnquiry.id,
|
opportunityId: createdOpportunity.id,
|
||||||
salesOwnerId: input.salesOwnerId
|
salesOwnerId: input.salesOwnerId
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -287,16 +287,17 @@ export async function assignLead(
|
|||||||
action: 'assign_lead',
|
action: 'assign_lead',
|
||||||
afterData: {
|
afterData: {
|
||||||
leadId: input.leadId,
|
leadId: input.leadId,
|
||||||
enquiryId: createdEnquiry.id,
|
opportunityId: createdOpportunity.id,
|
||||||
salesOwnerId: input.salesOwnerId,
|
salesOwnerId: input.salesOwnerId,
|
||||||
reusedExistingEnquiry: false
|
reusedExistingOpportunity: false
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
leadId: input.leadId,
|
leadId: input.leadId,
|
||||||
enquiryId: createdEnquiry.id,
|
opportunityId: createdOpportunity.id,
|
||||||
enquiryCode: createdEnquiry.code,
|
opportunityCode: createdOpportunity.code,
|
||||||
reusedExistingEnquiry: false
|
reusedExistingOpportunity: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
crmCustomerContacts,
|
crmCustomerContacts,
|
||||||
crmCustomers,
|
crmCustomers,
|
||||||
crmEnquiries,
|
crmOpportunities,
|
||||||
crmLeads,
|
crmLeads,
|
||||||
memberships,
|
memberships,
|
||||||
trAuditLogs,
|
trAuditLogs,
|
||||||
@@ -41,7 +41,7 @@ import type {
|
|||||||
LeadCustomerLookup,
|
LeadCustomerLookup,
|
||||||
LeadDetail,
|
LeadDetail,
|
||||||
LeadFollowupSummary,
|
LeadFollowupSummary,
|
||||||
LeadLinkedEnquirySummary,
|
LeadLinkedOpportunitySummary,
|
||||||
LeadListFilters,
|
LeadListFilters,
|
||||||
LeadListResponse,
|
LeadListResponse,
|
||||||
LeadOption,
|
LeadOption,
|
||||||
@@ -187,28 +187,28 @@ async function listLeadContacts(organizationId: string): Promise<LeadContactLook
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listLinkedEnquiries(
|
async function listLinkedOpportunities(
|
||||||
leadId: string,
|
leadId: string,
|
||||||
organizationId: string
|
organizationId: string
|
||||||
): Promise<LeadLinkedEnquirySummary[]> {
|
): Promise<LeadLinkedOpportunitySummary[]> {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
id: crmEnquiries.id,
|
id: crmOpportunities.id,
|
||||||
code: crmEnquiries.code,
|
code: crmOpportunities.code,
|
||||||
title: crmEnquiries.title,
|
title: crmOpportunities.title,
|
||||||
status: crmEnquiries.status,
|
status: crmOpportunities.status,
|
||||||
assignedToUserId: crmEnquiries.assignedToUserId,
|
assignedToUserId: crmOpportunities.assignedToUserId,
|
||||||
updatedAt: crmEnquiries.updatedAt
|
updatedAt: crmOpportunities.updatedAt
|
||||||
})
|
})
|
||||||
.from(crmEnquiries)
|
.from(crmOpportunities)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(crmEnquiries.organizationId, organizationId),
|
eq(crmOpportunities.organizationId, organizationId),
|
||||||
eq(crmEnquiries.leadId, leadId),
|
eq(crmOpportunities.leadId, leadId),
|
||||||
isNull(crmEnquiries.deletedAt)
|
isNull(crmOpportunities.deletedAt)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.orderBy(desc(crmEnquiries.updatedAt));
|
.orderBy(desc(crmOpportunities.updatedAt));
|
||||||
|
|
||||||
const assigneeIds = [
|
const assigneeIds = [
|
||||||
...new Set(rows.map((row) => row.assignedToUserId).filter((value): value is string => Boolean(value)))
|
...new Set(rows.map((row) => row.assignedToUserId).filter((value): value is string => Boolean(value)))
|
||||||
@@ -340,13 +340,13 @@ function mapLeadDetail(
|
|||||||
row: LeadRow,
|
row: LeadRow,
|
||||||
lookups: LeadLookupMaps,
|
lookups: LeadLookupMaps,
|
||||||
suggestedSalesOwnerId: string | null,
|
suggestedSalesOwnerId: string | null,
|
||||||
relatedEnquiryCount: number
|
relatedOpportunityCount: number
|
||||||
): LeadDetail {
|
): LeadDetail {
|
||||||
return {
|
return {
|
||||||
...mapLeadSummary(row, lookups),
|
...mapLeadSummary(row, lookups),
|
||||||
suggestedSalesOwnerId,
|
suggestedSalesOwnerId,
|
||||||
relatedEnquiryCount,
|
relatedOpportunityCount,
|
||||||
linkedEnquiries: [],
|
linkedOpportunities: [],
|
||||||
activity: []
|
activity: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -766,32 +766,32 @@ export async function getLeadById(
|
|||||||
accessContext?: LeadAccessContext
|
accessContext?: LeadAccessContext
|
||||||
): Promise<LeadDetail> {
|
): Promise<LeadDetail> {
|
||||||
const lead = await assertLeadBelongsToOrganization(id, organizationId, accessContext);
|
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]),
|
buildLookupMaps(organizationId, [lead]),
|
||||||
db
|
db
|
||||||
.select({ value: count() })
|
.select({ value: count() })
|
||||||
.from(crmEnquiries)
|
.from(crmOpportunities)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(crmEnquiries.organizationId, organizationId),
|
eq(crmOpportunities.organizationId, organizationId),
|
||||||
eq(crmEnquiries.leadId, id),
|
eq(crmOpportunities.leadId, id),
|
||||||
isNull(crmEnquiries.deletedAt)
|
isNull(crmOpportunities.deletedAt)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
lead.customerId ? assertCustomerBelongsToOrganization(lead.customerId, organizationId) : Promise.resolve(null),
|
lead.customerId ? assertCustomerBelongsToOrganization(lead.customerId, organizationId) : Promise.resolve(null),
|
||||||
listLinkedEnquiries(id, organizationId),
|
listLinkedOpportunities(id, organizationId),
|
||||||
listLeadActivity(id, organizationId)
|
listLeadActivity(id, organizationId)
|
||||||
]);
|
]);
|
||||||
const detail = mapLeadDetail(
|
const detail = mapLeadDetail(
|
||||||
lead,
|
lead,
|
||||||
lookups,
|
lookups,
|
||||||
customer?.ownerUserId ?? null,
|
customer?.ownerUserId ?? null,
|
||||||
relatedEnquiryResult[0]?.value ?? 0
|
relatedOpportunityResult[0]?.value ?? 0
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...detail,
|
...detail,
|
||||||
linkedEnquiries,
|
linkedOpportunities,
|
||||||
activity
|
activity
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -926,7 +926,7 @@ export async function deleteLead(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...mapLeadDetail(deleted!, lookups, null, 0),
|
...mapLeadDetail(deleted!, lookups, null, 0),
|
||||||
linkedEnquiries: [],
|
linkedOpportunities: [],
|
||||||
activity: []
|
activity: []
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -1077,3 +1077,4 @@ export function buildLeadAccessContext(input: {
|
|||||||
}) {
|
}) {
|
||||||
return buildCrmSecurityContext(input);
|
return buildCrmSecurityContext(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export interface LeadContactLookup {
|
|||||||
isPrimary: boolean;
|
isPrimary: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LeadLinkedEnquirySummary {
|
export interface LeadLinkedOpportunitySummary {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -174,8 +174,8 @@ export interface LeadSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface LeadDetail extends LeadSummary {
|
export interface LeadDetail extends LeadSummary {
|
||||||
relatedEnquiryCount: number;
|
relatedOpportunityCount: number;
|
||||||
linkedEnquiries: LeadLinkedEnquirySummary[];
|
linkedOpportunities: LeadLinkedOpportunitySummary[];
|
||||||
activity: LeadActivityRecord[];
|
activity: LeadActivityRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,9 +194,9 @@ export interface LeadFollowupSummary {
|
|||||||
|
|
||||||
export interface AssignLeadResult {
|
export interface AssignLeadResult {
|
||||||
leadId: string;
|
leadId: string;
|
||||||
enquiryId: string;
|
opportunityId: string;
|
||||||
enquiryCode: string;
|
opportunityCode: string;
|
||||||
reusedExistingEnquiry: boolean;
|
reusedExistingOpportunity: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LeadListResponse {
|
export interface LeadListResponse {
|
||||||
@@ -225,3 +225,4 @@ export interface LeadMutationSuccessResponse {
|
|||||||
lead?: LeadDetail;
|
lead?: LeadDetail;
|
||||||
followup?: LeadFollowupSummary;
|
followup?: LeadFollowupSummary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
191
src/features/crm/opportunities/api/mutations.ts
Normal file
191
src/features/crm/opportunities/api/mutations.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
54
src/features/crm/opportunities/api/queries.ts
Normal file
54
src/features/crm/opportunities/api/queries.ts
Normal file
@@ -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)
|
||||||
|
});
|
||||||
|
|
||||||
134
src/features/crm/opportunities/api/service.ts
Normal file
134
src/features/crm/opportunities/api/service.ts
Normal file
@@ -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<OpportunityListResponse> {
|
||||||
|
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<OpportunityListResponse>(`/crm/opportunities${query ? `?${query}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOpportunityById(id: string): Promise<OpportunityDetailResponse> {
|
||||||
|
return apiClient<OpportunityDetailResponse>(`/crm/opportunities/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOpportunityProjectParties(id: string): Promise<OpportunityProjectPartiesResponse> {
|
||||||
|
return apiClient<OpportunityProjectPartiesResponse>(`/crm/opportunities/${id}/customers`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createOpportunity(data: OpportunityMutationPayload) {
|
||||||
|
return apiClient<MutationSuccessResponse>('/crm/opportunities', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateOpportunity(id: string, data: OpportunityMutationPayload) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/opportunities/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteOpportunity(id: string) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/opportunities/${id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assignOpportunity(id: string, data: OpportunityAssignmentMutationPayload) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/opportunities/${id}/assign`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reassignOpportunity(id: string, data: OpportunityAssignmentMutationPayload) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/opportunities/${id}/reassign`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOpportunityFollowups(id: string): Promise<OpportunityFollowupsResponse> {
|
||||||
|
return apiClient<OpportunityFollowupsResponse>(`/crm/opportunities/${id}/followups`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOpportunityAttachments(id: string): Promise<OpportunityAttachmentsResponse> {
|
||||||
|
return apiClient<OpportunityAttachmentsResponse>(`/crm/opportunities/${id}/po-attachments`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createOpportunityFollowup(
|
||||||
|
opportunityId: string,
|
||||||
|
data: OpportunityFollowupMutationPayload
|
||||||
|
) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/opportunities/${opportunityId}/followups`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateOpportunityFollowup(
|
||||||
|
opportunityId: string,
|
||||||
|
followupId: string,
|
||||||
|
data: OpportunityFollowupMutationPayload
|
||||||
|
) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/opportunities/${opportunityId}/followups/${followupId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteOpportunityFollowup(opportunityId: string, followupId: string) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/opportunities/${opportunityId}/followups/${followupId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markOpportunityWon(id: string, data: OpportunityMarkWonPayload) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/opportunities/${id}/mark-won`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markOpportunityLost(id: string, data: OpportunityMarkLostPayload) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/opportunities/${id}/mark-lost`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reopenOpportunity(id: string, data: OpportunityReopenPayload) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/opportunities/${id}/reopen`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
export interface EnquiryOption {
|
export interface OpportunityOption {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
label: string;
|
label: string;
|
||||||
value: string | null;
|
value: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnquiryProjectPartyRecord {
|
export interface OpportunityProjectPartyRecord {
|
||||||
id: string;
|
id: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
enquiryId: string;
|
opportunityId: string;
|
||||||
customerId: string;
|
customerId: string;
|
||||||
role: string;
|
role: string;
|
||||||
roleCode: string;
|
roleCode: string;
|
||||||
@@ -19,19 +19,19 @@ export interface EnquiryProjectPartyRecord {
|
|||||||
deletedAt: string | null;
|
deletedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnquiryProjectPartyListItem extends EnquiryProjectPartyRecord {
|
export interface OpportunityProjectPartyListItem extends OpportunityProjectPartyRecord {
|
||||||
customerName: string;
|
customerName: string;
|
||||||
customerCode: string;
|
customerCode: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnquiryBranchOption {
|
export interface OpportunityBranchOption {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
value: string | null;
|
value: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnquiryCustomerLookup {
|
export interface OpportunityCustomerLookup {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -40,7 +40,7 @@ export interface EnquiryCustomerLookup {
|
|||||||
ownerName: string | null;
|
ownerName: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnquiryContactLookup {
|
export interface OpportunityContactLookup {
|
||||||
id: string;
|
id: string;
|
||||||
customerId: string;
|
customerId: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -49,17 +49,17 @@ export interface EnquiryContactLookup {
|
|||||||
isPrimary: boolean;
|
isPrimary: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnquiryAssignableUserLookup {
|
export interface OpportunityAssignableUserLookup {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
membershipRole: 'admin' | 'user';
|
membershipRole: 'admin' | 'user';
|
||||||
businessRole: string;
|
businessRole: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnquiryAttachmentRecord {
|
export interface OpportunityAttachmentRecord {
|
||||||
id: string;
|
id: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
enquiryId: string;
|
opportunityId: string;
|
||||||
category: string;
|
category: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
originalFileName: string;
|
originalFileName: string;
|
||||||
@@ -75,9 +75,9 @@ export interface EnquiryAttachmentRecord {
|
|||||||
deletedAt: string | null;
|
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;
|
id: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
branchId: string | null;
|
branchId: string | null;
|
||||||
@@ -102,7 +102,7 @@ export interface EnquiryRecord {
|
|||||||
notes: string | null;
|
notes: string | null;
|
||||||
isHotProject: boolean;
|
isHotProject: boolean;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
pipelineStage: EnquiryPipelineStage;
|
pipelineStage: OpportunityPipelineStage;
|
||||||
closedWonAt: string | null;
|
closedWonAt: string | null;
|
||||||
closedLostAt: string | null;
|
closedLostAt: string | null;
|
||||||
closedByUserId: string | null;
|
closedByUserId: string | null;
|
||||||
@@ -129,7 +129,7 @@ export interface EnquiryRecord {
|
|||||||
updatedBy: string;
|
updatedBy: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnquiryListItem extends EnquiryRecord {
|
export interface OpportunityListItem extends OpportunityRecord {
|
||||||
customerName: string;
|
customerName: string;
|
||||||
contactName: string | null;
|
contactName: string | null;
|
||||||
sourceLeadCode?: string | null;
|
sourceLeadCode?: string | null;
|
||||||
@@ -141,10 +141,10 @@ export interface EnquiryListItem extends EnquiryRecord {
|
|||||||
createdByName: string | null;
|
createdByName: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnquiryFollowupRecord {
|
export interface OpportunityFollowupRecord {
|
||||||
id: string;
|
id: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
enquiryId: string;
|
opportunityId: string;
|
||||||
followupDate: string;
|
followupDate: string;
|
||||||
followupType: string;
|
followupType: string;
|
||||||
contactId: string | null;
|
contactId: string | null;
|
||||||
@@ -159,7 +159,7 @@ export interface EnquiryFollowupRecord {
|
|||||||
updatedBy: string;
|
updatedBy: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnquiryActivityRecord {
|
export interface OpportunityActivityRecord {
|
||||||
id: string;
|
id: string;
|
||||||
action: string;
|
action: string;
|
||||||
entityType: string;
|
entityType: string;
|
||||||
@@ -169,25 +169,25 @@ export interface EnquiryActivityRecord {
|
|||||||
actorName: string | null;
|
actorName: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnquiryReferenceData {
|
export interface OpportunityReferenceData {
|
||||||
branches: EnquiryBranchOption[];
|
branches: OpportunityBranchOption[];
|
||||||
statuses: EnquiryOption[];
|
statuses: OpportunityOption[];
|
||||||
productTypes: EnquiryOption[];
|
productTypes: OpportunityOption[];
|
||||||
priorities: EnquiryOption[];
|
priorities: OpportunityOption[];
|
||||||
leadChannels: EnquiryOption[];
|
leadChannels: OpportunityOption[];
|
||||||
followupTypes: EnquiryOption[];
|
followupTypes: OpportunityOption[];
|
||||||
lostReasons: EnquiryOption[];
|
lostReasons: OpportunityOption[];
|
||||||
projectPartyRoles: EnquiryOption[];
|
projectPartyRoles: OpportunityOption[];
|
||||||
customers: EnquiryCustomerLookup[];
|
customers: OpportunityCustomerLookup[];
|
||||||
contacts: EnquiryContactLookup[];
|
contacts: OpportunityContactLookup[];
|
||||||
assignableUsers: EnquiryAssignableUserLookup[];
|
assignableUsers: OpportunityAssignableUserLookup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnquiryFilters {
|
export interface OpportunityFilters {
|
||||||
page?: number;
|
page?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
search?: string;
|
search?: string;
|
||||||
pipelineStage?: EnquiryPipelineStage;
|
pipelineStage?: OpportunityPipelineStage;
|
||||||
status?: string;
|
status?: string;
|
||||||
productType?: string;
|
productType?: string;
|
||||||
priority?: string;
|
priority?: string;
|
||||||
@@ -196,52 +196,52 @@ export interface EnquiryFilters {
|
|||||||
sort?: string;
|
sort?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnquiryListResponse {
|
export interface OpportunityListResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
time: string;
|
time: string;
|
||||||
message: string;
|
message: string;
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
offset: number;
|
offset: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
items: EnquiryListItem[];
|
items: OpportunityListItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnquiryDetailResponse {
|
export interface OpportunityDetailResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
time: string;
|
time: string;
|
||||||
message: string;
|
message: string;
|
||||||
enquiry: EnquiryRecord;
|
opportunity: OpportunityRecord;
|
||||||
activity: EnquiryActivityRecord[];
|
activity: OpportunityActivityRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnquiryProjectPartiesResponse {
|
export interface OpportunityProjectPartiesResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
time: string;
|
time: string;
|
||||||
message: string;
|
message: string;
|
||||||
items: EnquiryProjectPartyListItem[];
|
items: OpportunityProjectPartyListItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnquiryFollowupsResponse {
|
export interface OpportunityFollowupsResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
time: string;
|
time: string;
|
||||||
message: string;
|
message: string;
|
||||||
items: EnquiryFollowupRecord[];
|
items: OpportunityFollowupRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnquiryAttachmentsResponse {
|
export interface OpportunityAttachmentsResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
time: string;
|
time: string;
|
||||||
message: string;
|
message: string;
|
||||||
items: EnquiryAttachmentRecord[];
|
items: OpportunityAttachmentRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnquiryProjectPartyMutationPayload {
|
export interface OpportunityProjectPartyMutationPayload {
|
||||||
customerId: string;
|
customerId: string;
|
||||||
role: string;
|
role: string;
|
||||||
remark?: string | null;
|
remark?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnquiryMutationPayload {
|
export interface OpportunityMutationPayload {
|
||||||
customerId: string;
|
customerId: string;
|
||||||
contactId?: string | null;
|
contactId?: string | null;
|
||||||
assignedToUserId?: string | null;
|
assignedToUserId?: string | null;
|
||||||
@@ -263,10 +263,10 @@ export interface EnquiryMutationPayload {
|
|||||||
isHotProject?: boolean;
|
isHotProject?: boolean;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
projectParties?: EnquiryProjectPartyMutationPayload[];
|
projectParties?: OpportunityProjectPartyMutationPayload[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnquiryFollowupMutationPayload {
|
export interface OpportunityFollowupMutationPayload {
|
||||||
followupDate: string;
|
followupDate: string;
|
||||||
followupType: string;
|
followupType: string;
|
||||||
contactId?: string | null;
|
contactId?: string | null;
|
||||||
@@ -276,12 +276,12 @@ export interface EnquiryFollowupMutationPayload {
|
|||||||
nextAction?: string;
|
nextAction?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnquiryAssignmentMutationPayload {
|
export interface OpportunityAssignmentMutationPayload {
|
||||||
assignedToUserId: string;
|
assignedToUserId: string;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnquiryMarkWonPayload {
|
export interface OpportunityMarkWonPayload {
|
||||||
poNumber: string;
|
poNumber: string;
|
||||||
poDate: string;
|
poDate: string;
|
||||||
poAmount?: number | null;
|
poAmount?: number | null;
|
||||||
@@ -289,17 +289,17 @@ export interface EnquiryMarkWonPayload {
|
|||||||
remark?: string | null;
|
remark?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnquiryMarkLostPayload {
|
export interface OpportunityMarkLostPayload {
|
||||||
lostReason: string;
|
lostReason: string;
|
||||||
lostCompetitor?: string | null;
|
lostCompetitor?: string | null;
|
||||||
lostRemark?: string | null;
|
lostRemark?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnquiryReopenPayload {
|
export interface OpportunityReopenPayload {
|
||||||
reopenReason: string;
|
reopenReason: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnquiryCustomerRelationItem {
|
export interface OpportunityCustomerRelationItem {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -312,3 +312,4 @@ export interface MutationSuccessResponse {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,15 +21,15 @@ import {
|
|||||||
SheetTitle
|
SheetTitle
|
||||||
} from '@/components/ui/sheet';
|
} from '@/components/ui/sheet';
|
||||||
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
|
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
|
||||||
import { createEnquiryFollowupMutation, updateEnquiryFollowupMutation } from '../api/mutations';
|
import { createOpportunityFollowupMutation, updateOpportunityFollowupMutation } from '../api/mutations';
|
||||||
import type {
|
import type {
|
||||||
EnquiryFollowupMutationPayload,
|
OpportunityFollowupMutationPayload,
|
||||||
EnquiryFollowupRecord,
|
OpportunityFollowupRecord,
|
||||||
EnquiryReferenceData
|
OpportunityReferenceData
|
||||||
} from '../api/types';
|
} 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 {
|
return {
|
||||||
followupDate: followup?.followupDate ? followup.followupDate.slice(0, 10) : '',
|
followupDate: followup?.followupDate ? followup.followupDate.slice(0, 10) : '',
|
||||||
followupType: followup?.followupType ?? '',
|
followupType: followup?.followupType ?? '',
|
||||||
@@ -42,28 +42,28 @@ function toDefaultValues(followup: EnquiryFollowupRecord | undefined): EnquiryFo
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FollowupFormSheet({
|
export function FollowupFormSheet({
|
||||||
enquiryId,
|
opportunityId,
|
||||||
followup,
|
followup,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
referenceData,
|
referenceData,
|
||||||
customerId
|
customerId
|
||||||
}: {
|
}: {
|
||||||
enquiryId: string;
|
opportunityId: string;
|
||||||
followup?: EnquiryFollowupRecord;
|
followup?: OpportunityFollowupRecord;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
referenceData: EnquiryReferenceData;
|
referenceData: OpportunityReferenceData;
|
||||||
customerId: string;
|
customerId: string;
|
||||||
}) {
|
}) {
|
||||||
const isEdit = !!followup;
|
const isEdit = !!followup;
|
||||||
const defaultValues = useMemo(() => toDefaultValues(followup), [followup]);
|
const defaultValues = useMemo(() => toDefaultValues(followup), [followup]);
|
||||||
const { FormTextField, FormTextareaField, FormDatePickerField } =
|
const { FormTextField, FormTextareaField, FormDatePickerField } =
|
||||||
useFormFields<EnquiryFollowupFormValues>();
|
useFormFields<OpportunityFollowupFormValues>();
|
||||||
const contactOptions = referenceData.contacts.filter((item) => item.customerId === customerId);
|
const contactOptions = referenceData.contacts.filter((item) => item.customerId === customerId);
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
...createEnquiryFollowupMutation,
|
...createOpportunityFollowupMutation,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Follow-up created successfully');
|
toast.success('Follow-up created successfully');
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
@@ -73,7 +73,7 @@ export function FollowupFormSheet({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
...updateEnquiryFollowupMutation,
|
...updateOpportunityFollowupMutation,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Follow-up updated successfully');
|
toast.success('Follow-up updated successfully');
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
@@ -85,10 +85,10 @@ export function FollowupFormSheet({
|
|||||||
const form = useAppForm({
|
const form = useAppForm({
|
||||||
defaultValues,
|
defaultValues,
|
||||||
validators: {
|
validators: {
|
||||||
onSubmit: enquiryFollowupSchema
|
onSubmit: opportunityFollowupSchema
|
||||||
},
|
},
|
||||||
onSubmit: async ({ value }) => {
|
onSubmit: async ({ value }) => {
|
||||||
const payload: EnquiryFollowupMutationPayload = {
|
const payload: OpportunityFollowupMutationPayload = {
|
||||||
followupDate: value.followupDate,
|
followupDate: value.followupDate,
|
||||||
followupType: value.followupType,
|
followupType: value.followupType,
|
||||||
contactId: value.contactId || null,
|
contactId: value.contactId || null,
|
||||||
@@ -100,7 +100,7 @@ export function FollowupFormSheet({
|
|||||||
|
|
||||||
if (isEdit && followup) {
|
if (isEdit && followup) {
|
||||||
await updateMutation.mutateAsync({
|
await updateMutation.mutateAsync({
|
||||||
enquiryId,
|
opportunityId,
|
||||||
followupId: followup.id,
|
followupId: followup.id,
|
||||||
values: payload
|
values: payload
|
||||||
});
|
});
|
||||||
@@ -108,7 +108,7 @@ export function FollowupFormSheet({
|
|||||||
}
|
}
|
||||||
|
|
||||||
await createMutation.mutateAsync({
|
await createMutation.mutateAsync({
|
||||||
enquiryId,
|
opportunityId,
|
||||||
values: payload
|
values: payload
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -130,13 +130,13 @@ export function FollowupFormSheet({
|
|||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
<SheetTitle>{isEdit ? 'Edit Follow-up' : 'New Follow-up'}</SheetTitle>
|
<SheetTitle>{isEdit ? 'Edit Follow-up' : 'New Follow-up'}</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
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.
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<div className='flex-1 overflow-auto'>
|
<div className='flex-1 overflow-auto'>
|
||||||
<form.AppForm>
|
<form.AppForm>
|
||||||
<form.Form id='enquiry-followup-form' className='grid gap-4 md:grid-cols-2'>
|
<form.Form id='opportunity-followup-form' className='grid gap-4 md:grid-cols-2'>
|
||||||
<FormDatePickerField
|
<FormDatePickerField
|
||||||
name='followupDate'
|
name='followupDate'
|
||||||
label='Follow-up Date'
|
label='Follow-up Date'
|
||||||
@@ -239,7 +239,7 @@ export function FollowupFormSheet({
|
|||||||
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type='submit' form='enquiry-followup-form' isLoading={isPending}>
|
<Button type='submit' form='opportunity-followup-form' isLoading={isPending}>
|
||||||
<Icons.check className='mr-2 h-4 w-4' />
|
<Icons.check className='mr-2 h-4 w-4' />
|
||||||
{isEdit ? 'Update Follow-up' : 'Create Follow-up'}
|
{isEdit ? 'Update Follow-up' : 'Create Follow-up'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -248,3 +248,4 @@ export function FollowupFormSheet({
|
|||||||
</Sheet>
|
</Sheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7,11 +7,11 @@ import { DataTable } from '@/components/ui/table/data-table';
|
|||||||
import { DataTableToolbar } from '@/components/ui/table/data-table-toolbar';
|
import { DataTableToolbar } from '@/components/ui/table/data-table-toolbar';
|
||||||
import { useDataTable } from '@/hooks/use-data-table';
|
import { useDataTable } from '@/hooks/use-data-table';
|
||||||
import { getSortingStateParser } from '@/lib/parsers';
|
import { getSortingStateParser } from '@/lib/parsers';
|
||||||
import { enquiriesQueryOptions } from '../api/queries';
|
import { opportunitiesQueryOptions } from '../api/queries';
|
||||||
import type { EnquiryReferenceData } from '../api/types';
|
import type { OpportunityReferenceData } from '../api/types';
|
||||||
import { getEnquiryColumns } from './enquiry-columns';
|
import { getOpportunityColumns } from './opportunity-columns';
|
||||||
|
|
||||||
export function EnquiriesTable({
|
export function OpportunitiesTable({
|
||||||
referenceData,
|
referenceData,
|
||||||
workspace,
|
workspace,
|
||||||
canUpdate,
|
canUpdate,
|
||||||
@@ -19,8 +19,8 @@ export function EnquiriesTable({
|
|||||||
canAssign,
|
canAssign,
|
||||||
canReassign
|
canReassign
|
||||||
}: {
|
}: {
|
||||||
referenceData: EnquiryReferenceData;
|
referenceData: OpportunityReferenceData;
|
||||||
workspace: 'lead' | 'enquiry';
|
workspace: 'lead' | 'opportunity';
|
||||||
canUpdate: boolean;
|
canUpdate: boolean;
|
||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
canAssign: boolean;
|
canAssign: boolean;
|
||||||
@@ -28,7 +28,7 @@ export function EnquiriesTable({
|
|||||||
}) {
|
}) {
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() =>
|
() =>
|
||||||
getEnquiryColumns({
|
getOpportunityColumns({
|
||||||
referenceData,
|
referenceData,
|
||||||
workspace,
|
workspace,
|
||||||
canUpdate,
|
canUpdate,
|
||||||
@@ -65,7 +65,7 @@ export function EnquiriesTable({
|
|||||||
...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) })
|
...(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 pageCount = Math.max(1, Math.ceil((data.totalItems || 0) / params.perPage));
|
||||||
|
|
||||||
const { table } = useDataTable({
|
const { table } = useDataTable({
|
||||||
@@ -83,3 +83,4 @@ export function EnquiriesTable({
|
|||||||
</DataTable>
|
</DataTable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,47 +15,47 @@ import {
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
|
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
|
||||||
import { Icons } from '@/components/icons';
|
import { Icons } from '@/components/icons';
|
||||||
import { assignEnquiryMutation, reassignEnquiryMutation } from '../api/mutations';
|
import { assignOpportunityMutation, reassignOpportunityMutation } from '../api/mutations';
|
||||||
import type { EnquiryRecord, EnquiryReferenceData } from '../api/types';
|
import type { OpportunityRecord, OpportunityReferenceData } from '../api/types';
|
||||||
|
|
||||||
const enquiryAssignmentSchema = z.object({
|
const opportunityAssignmentSchema = z.object({
|
||||||
assignedToUserId: z.string().min(1, 'Sales user is required'),
|
assignedToUserId: z.string().min(1, 'Sales user is required'),
|
||||||
remark: z.string().optional()
|
remark: z.string().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
type EnquiryAssignmentFormValues = z.infer<typeof enquiryAssignmentSchema>;
|
type OpportunityAssignmentFormValues = z.infer<typeof opportunityAssignmentSchema>;
|
||||||
|
|
||||||
function toDefaultValues(
|
function toDefaultValues(
|
||||||
enquiry: EnquiryRecord,
|
opportunity: OpportunityRecord,
|
||||||
referenceData: EnquiryReferenceData
|
referenceData: OpportunityReferenceData
|
||||||
): EnquiryAssignmentFormValues {
|
): OpportunityAssignmentFormValues {
|
||||||
return {
|
return {
|
||||||
assignedToUserId: enquiry.assignedToUserId ?? referenceData.assignableUsers[0]?.id ?? '',
|
assignedToUserId: opportunity.assignedToUserId ?? referenceData.assignableUsers[0]?.id ?? '',
|
||||||
remark: enquiry.assignmentRemark ?? ''
|
remark: opportunity.assignmentRemark ?? ''
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EnquiryAssignmentDialog({
|
export function OpportunityAssignmentDialog({
|
||||||
enquiry,
|
opportunity,
|
||||||
referenceData,
|
referenceData,
|
||||||
mode,
|
mode,
|
||||||
open,
|
open,
|
||||||
onOpenChange
|
onOpenChange
|
||||||
}: {
|
}: {
|
||||||
enquiry: EnquiryRecord;
|
opportunity: OpportunityRecord;
|
||||||
referenceData: EnquiryReferenceData;
|
referenceData: OpportunityReferenceData;
|
||||||
mode: 'assign' | 'reassign';
|
mode: 'assign' | 'reassign';
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const defaultValues = useMemo(
|
const defaultValues = useMemo(
|
||||||
() => toDefaultValues(enquiry, referenceData),
|
() => toDefaultValues(opportunity, referenceData),
|
||||||
[enquiry, referenceData]
|
[opportunity, referenceData]
|
||||||
);
|
);
|
||||||
const { FormSelectField, FormTextareaField } = useFormFields<EnquiryAssignmentFormValues>();
|
const { FormSelectField, FormTextareaField } = useFormFields<OpportunityAssignmentFormValues>();
|
||||||
|
|
||||||
const assignMutation = useMutation({
|
const assignMutation = useMutation({
|
||||||
...assignEnquiryMutation,
|
...assignOpportunityMutation,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('ส่งต่อลีดไปยังฝ่ายขายสำเร็จ');
|
toast.success('ส่งต่อลีดไปยังฝ่ายขายสำเร็จ');
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
@@ -65,7 +65,7 @@ export function EnquiryAssignmentDialog({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const reassignMutation = useMutation({
|
const reassignMutation = useMutation({
|
||||||
...reassignEnquiryMutation,
|
...reassignOpportunityMutation,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('เปลี่ยนผู้ดูแลโอกาสขายสำเร็จ');
|
toast.success('เปลี่ยนผู้ดูแลโอกาสขายสำเร็จ');
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
@@ -77,7 +77,7 @@ export function EnquiryAssignmentDialog({
|
|||||||
const form = useAppForm({
|
const form = useAppForm({
|
||||||
defaultValues,
|
defaultValues,
|
||||||
validators: {
|
validators: {
|
||||||
onSubmit: enquiryAssignmentSchema
|
onSubmit: opportunityAssignmentSchema
|
||||||
},
|
},
|
||||||
onSubmit: async ({ value }) => {
|
onSubmit: async ({ value }) => {
|
||||||
const values = {
|
const values = {
|
||||||
@@ -86,11 +86,11 @@ export function EnquiryAssignmentDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (mode === 'assign') {
|
if (mode === 'assign') {
|
||||||
await assignMutation.mutateAsync({ id: enquiry.id, values });
|
await assignMutation.mutateAsync({ id: opportunity.id, values });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await reassignMutation.mutateAsync({ id: enquiry.id, values });
|
await reassignMutation.mutateAsync({ id: opportunity.id, values });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -118,7 +118,7 @@ export function EnquiryAssignmentDialog({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form.AppForm>
|
<form.AppForm>
|
||||||
<form.Form id='enquiry-assignment-form' className='px-0 md:px-0'>
|
<form.Form id='opportunity-assignment-form' className='px-0 md:px-0'>
|
||||||
{noUsersAvailable ? (
|
{noUsersAvailable ? (
|
||||||
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'>
|
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'>
|
||||||
ยังไม่มีผู้ใช้ฝ่ายขายที่สามารถมอบหมายงานได้ใน workspace นี้
|
ยังไม่มีผู้ใช้ฝ่ายขายที่สามารถมอบหมายงานได้ใน workspace นี้
|
||||||
@@ -151,7 +151,7 @@ export function EnquiryAssignmentDialog({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type='submit'
|
type='submit'
|
||||||
form='enquiry-assignment-form'
|
form='opportunity-assignment-form'
|
||||||
isLoading={isPending}
|
isLoading={isPending}
|
||||||
disabled={noUsersAvailable}
|
disabled={noUsersAvailable}
|
||||||
>
|
>
|
||||||
@@ -163,3 +163,4 @@ export function EnquiryAssignmentDialog({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,11 +14,11 @@ import {
|
|||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { deleteEnquiryMutation } from '../api/mutations';
|
import { deleteOpportunityMutation } from '../api/mutations';
|
||||||
import type { EnquiryListItem, EnquiryReferenceData } from '../api/types';
|
import type { OpportunityListItem, OpportunityReferenceData } from '../api/types';
|
||||||
import { EnquiryFormSheet } from './enquiry-form-sheet';
|
import { OpportunityFormSheet } from './opportunity-form-sheet';
|
||||||
|
|
||||||
export function EnquiryCellAction({
|
export function OpportunityCellAction({
|
||||||
data,
|
data,
|
||||||
referenceData,
|
referenceData,
|
||||||
workspace,
|
workspace,
|
||||||
@@ -27,9 +27,9 @@ export function EnquiryCellAction({
|
|||||||
canAssign: _canAssign,
|
canAssign: _canAssign,
|
||||||
canReassign: _canReassign
|
canReassign: _canReassign
|
||||||
}: {
|
}: {
|
||||||
data: EnquiryListItem;
|
data: OpportunityListItem;
|
||||||
referenceData: EnquiryReferenceData;
|
referenceData: OpportunityReferenceData;
|
||||||
workspace: 'lead' | 'enquiry';
|
workspace: 'lead' | 'opportunity';
|
||||||
canUpdate: boolean;
|
canUpdate: boolean;
|
||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
canAssign: boolean;
|
canAssign: boolean;
|
||||||
@@ -40,7 +40,7 @@ export function EnquiryCellAction({
|
|||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
...deleteEnquiryMutation,
|
...deleteOpportunityMutation,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(`${workspace === 'lead' ? 'ลบลีดสำเร็จ' : 'ลบโอกาสขายสำเร็จ'}`);
|
toast.success(`${workspace === 'lead' ? 'ลบลีดสำเร็จ' : 'ลบโอกาสขายสำเร็จ'}`);
|
||||||
setDeleteOpen(false);
|
setDeleteOpen(false);
|
||||||
@@ -61,8 +61,8 @@ export function EnquiryCellAction({
|
|||||||
onConfirm={() => deleteMutation.mutate(data.id)}
|
onConfirm={() => deleteMutation.mutate(data.id)}
|
||||||
loading={deleteMutation.isPending}
|
loading={deleteMutation.isPending}
|
||||||
/>
|
/>
|
||||||
<EnquiryFormSheet
|
<OpportunityFormSheet
|
||||||
enquiry={data}
|
opportunity={data}
|
||||||
open={editOpen}
|
open={editOpen}
|
||||||
onOpenChange={setEditOpen}
|
onOpenChange={setEditOpen}
|
||||||
referenceData={referenceData}
|
referenceData={referenceData}
|
||||||
@@ -82,7 +82,7 @@ export function EnquiryCellAction({
|
|||||||
router.push(
|
router.push(
|
||||||
workspace === 'lead'
|
workspace === 'lead'
|
||||||
? `/dashboard/crm/leads/${data.id}`
|
? `/dashboard/crm/leads/${data.id}`
|
||||||
: `/dashboard/crm/enquiries/${data.id}`
|
: `/dashboard/crm/opportunities/${data.id}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -92,10 +92,11 @@ export function EnquiryCellAction({
|
|||||||
<Icons.edit className='mr-2 h-4 w-4' /> {workspace === 'lead' ? 'แก้ไขลีด' : 'แก้ไขโอกาสขาย'}
|
<Icons.edit className='mr-2 h-4 w-4' /> {workspace === 'lead' ? 'แก้ไขลีด' : 'แก้ไขโอกาสขาย'}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => setDeleteOpen(true)} disabled={!canDelete}>
|
<DropdownMenuItem onClick={() => setDeleteOpen(true)} disabled={!canDelete}>
|
||||||
<Icons.trash className='mr-2 h-4 w-4' /> ลบ
|
<Icons.trash className='mr-2 h-4 w-4' /> เธฅเธ
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5,12 +5,12 @@ import type { Column, ColumnDef } from '@tanstack/react-table';
|
|||||||
import { Icons } from '@/components/icons';
|
import { Icons } from '@/components/icons';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header';
|
import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header';
|
||||||
import type { EnquiryListItem, EnquiryReferenceData } from '../api/types';
|
import type { OpportunityListItem, OpportunityReferenceData } from '../api/types';
|
||||||
import { EnquiryCellAction } from './enquiry-cell-action';
|
import { OpportunityCellAction } from './opportunity-cell-action';
|
||||||
import { EnquiryStatusBadge } from './enquiry-status-badge';
|
import { OpportunityStatusBadge } from './opportunity-status-badge';
|
||||||
|
|
||||||
type SharedColumnsConfig = {
|
type SharedColumnsConfig = {
|
||||||
referenceData: EnquiryReferenceData;
|
referenceData: OpportunityReferenceData;
|
||||||
canUpdate: boolean;
|
canUpdate: boolean;
|
||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
canAssign: boolean;
|
canAssign: boolean;
|
||||||
@@ -23,7 +23,7 @@ function getLeadColumns({
|
|||||||
canDelete,
|
canDelete,
|
||||||
canAssign,
|
canAssign,
|
||||||
canReassign
|
canReassign
|
||||||
}: SharedColumnsConfig): ColumnDef<EnquiryListItem>[] {
|
}: SharedColumnsConfig): ColumnDef<OpportunityListItem>[] {
|
||||||
const statusMap = new Map(referenceData.statuses.map((item) => [item.id, item]));
|
const statusMap = new Map(referenceData.statuses.map((item) => [item.id, item]));
|
||||||
const leadChannelMap = new Map(referenceData.leadChannels.map((item) => [item.id, item.label]));
|
const leadChannelMap = new Map(referenceData.leadChannels.map((item) => [item.id, item.label]));
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ function getLeadColumns({
|
|||||||
{
|
{
|
||||||
id: 'code',
|
id: 'code',
|
||||||
accessorKey: 'code',
|
accessorKey: 'code',
|
||||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
|
||||||
<DataTableColumnHeader column={column} title='Code' />
|
<DataTableColumnHeader column={column} title='Code' />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
@@ -53,7 +53,7 @@ function getLeadColumns({
|
|||||||
{
|
{
|
||||||
id: 'leadChannel',
|
id: 'leadChannel',
|
||||||
accessorFn: (row) => row.leadChannel ?? '',
|
accessorFn: (row) => row.leadChannel ?? '',
|
||||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
|
||||||
<DataTableColumnHeader column={column} title='Lead Source' />
|
<DataTableColumnHeader column={column} title='Lead Source' />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => <span>{leadChannelMap.get(row.original.leadChannel ?? '') ?? '-'}</span>
|
cell: ({ row }) => <span>{leadChannelMap.get(row.original.leadChannel ?? '') ?? '-'}</span>
|
||||||
@@ -61,7 +61,7 @@ function getLeadColumns({
|
|||||||
{
|
{
|
||||||
id: 'customer',
|
id: 'customer',
|
||||||
accessorFn: (row) => row.customerId,
|
accessorFn: (row) => row.customerId,
|
||||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
|
||||||
<DataTableColumnHeader column={column} title='Customer' />
|
<DataTableColumnHeader column={column} title='Customer' />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => <span>{row.original.customerName}</span>,
|
cell: ({ row }) => <span>{row.original.customerName}</span>,
|
||||||
@@ -78,12 +78,12 @@ function getLeadColumns({
|
|||||||
{
|
{
|
||||||
id: 'status',
|
id: 'status',
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
|
||||||
<DataTableColumnHeader column={column} title='Status' />
|
<DataTableColumnHeader column={column} title='Status' />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const status = statusMap.get(row.original.status);
|
const status = statusMap.get(row.original.status);
|
||||||
return <EnquiryStatusBadge code={status?.code} label={status?.label ?? 'Unknown'} />;
|
return <OpportunityStatusBadge code={status?.code} label={status?.label ?? 'Unknown'} />;
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
label: 'Status',
|
label: 'Status',
|
||||||
@@ -98,7 +98,7 @@ function getLeadColumns({
|
|||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<EnquiryCellAction
|
<OpportunityCellAction
|
||||||
data={row.original}
|
data={row.original}
|
||||||
referenceData={referenceData}
|
referenceData={referenceData}
|
||||||
workspace='lead'
|
workspace='lead'
|
||||||
@@ -112,13 +112,13 @@ function getLeadColumns({
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEnquiryWorkspaceColumns({
|
function getOpportunityWorkspaceColumns({
|
||||||
referenceData,
|
referenceData,
|
||||||
canUpdate,
|
canUpdate,
|
||||||
canDelete,
|
canDelete,
|
||||||
canAssign,
|
canAssign,
|
||||||
canReassign
|
canReassign
|
||||||
}: SharedColumnsConfig): ColumnDef<EnquiryListItem>[] {
|
}: SharedColumnsConfig): ColumnDef<OpportunityListItem>[] {
|
||||||
const statusMap = new Map(referenceData.statuses.map((item) => [item.id, item]));
|
const statusMap = new Map(referenceData.statuses.map((item) => [item.id, item]));
|
||||||
const productTypeMap = new Map(referenceData.productTypes.map((item) => [item.id, item.label]));
|
const productTypeMap = new Map(referenceData.productTypes.map((item) => [item.id, item.label]));
|
||||||
|
|
||||||
@@ -126,13 +126,13 @@ function getEnquiryWorkspaceColumns({
|
|||||||
{
|
{
|
||||||
id: 'code',
|
id: 'code',
|
||||||
accessorKey: 'code',
|
accessorKey: 'code',
|
||||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
|
||||||
<DataTableColumnHeader column={column} title='Opportunity Code' />
|
<DataTableColumnHeader column={column} title='Opportunity Code' />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className='flex flex-col'>
|
<div className='flex flex-col'>
|
||||||
<Link
|
<Link
|
||||||
href={`/dashboard/crm/enquiries/${row.original.id}`}
|
href={`/dashboard/crm/opportunities/${row.original.id}`}
|
||||||
className='font-medium hover:underline'
|
className='font-medium hover:underline'
|
||||||
>
|
>
|
||||||
{row.original.code}
|
{row.original.code}
|
||||||
@@ -151,7 +151,7 @@ function getEnquiryWorkspaceColumns({
|
|||||||
{
|
{
|
||||||
id: 'sourceLead',
|
id: 'sourceLead',
|
||||||
accessorFn: (row) => (row.leadId ? 'linked' : 'direct'),
|
accessorFn: (row) => (row.leadId ? 'linked' : 'direct'),
|
||||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
|
||||||
<DataTableColumnHeader column={column} title='Source Lead' />
|
<DataTableColumnHeader column={column} title='Source Lead' />
|
||||||
),
|
),
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
@@ -164,7 +164,7 @@ function getEnquiryWorkspaceColumns({
|
|||||||
{
|
{
|
||||||
id: 'customer',
|
id: 'customer',
|
||||||
accessorFn: (row) => row.customerId,
|
accessorFn: (row) => row.customerId,
|
||||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
|
||||||
<DataTableColumnHeader column={column} title='Customer' />
|
<DataTableColumnHeader column={column} title='Customer' />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => <span>{row.original.customerName}</span>,
|
cell: ({ row }) => <span>{row.original.customerName}</span>,
|
||||||
@@ -181,7 +181,7 @@ function getEnquiryWorkspaceColumns({
|
|||||||
{
|
{
|
||||||
id: 'projectName',
|
id: 'projectName',
|
||||||
accessorFn: (row) => row.projectName ?? '',
|
accessorFn: (row) => row.projectName ?? '',
|
||||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
|
||||||
<DataTableColumnHeader column={column} title='Project Name' />
|
<DataTableColumnHeader column={column} title='Project Name' />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => <span>{row.original.projectName ?? '-'}</span>
|
cell: ({ row }) => <span>{row.original.projectName ?? '-'}</span>
|
||||||
@@ -189,7 +189,7 @@ function getEnquiryWorkspaceColumns({
|
|||||||
{
|
{
|
||||||
id: 'productType',
|
id: 'productType',
|
||||||
accessorFn: (row) => row.productType,
|
accessorFn: (row) => row.productType,
|
||||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
|
||||||
<DataTableColumnHeader column={column} title='Product Type' />
|
<DataTableColumnHeader column={column} title='Product Type' />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => <span>{productTypeMap.get(row.original.productType) ?? row.original.productType}</span>,
|
cell: ({ row }) => <span>{productTypeMap.get(row.original.productType) ?? row.original.productType}</span>,
|
||||||
@@ -206,7 +206,7 @@ function getEnquiryWorkspaceColumns({
|
|||||||
{
|
{
|
||||||
id: 'assignedToName',
|
id: 'assignedToName',
|
||||||
accessorFn: (row) => row.assignedToName ?? '',
|
accessorFn: (row) => row.assignedToName ?? '',
|
||||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
|
||||||
<DataTableColumnHeader column={column} title='Sales Owner' />
|
<DataTableColumnHeader column={column} title='Sales Owner' />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => <span>{row.original.assignedToName ?? '-'}</span>
|
cell: ({ row }) => <span>{row.original.assignedToName ?? '-'}</span>
|
||||||
@@ -214,12 +214,12 @@ function getEnquiryWorkspaceColumns({
|
|||||||
{
|
{
|
||||||
id: 'status',
|
id: 'status',
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
|
||||||
<DataTableColumnHeader column={column} title='Status' />
|
<DataTableColumnHeader column={column} title='Status' />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const status = statusMap.get(row.original.status);
|
const status = statusMap.get(row.original.status);
|
||||||
return <EnquiryStatusBadge code={status?.code} label={status?.label ?? 'Unknown'} />;
|
return <OpportunityStatusBadge code={status?.code} label={status?.label ?? 'Unknown'} />;
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
label: 'Status',
|
label: 'Status',
|
||||||
@@ -234,7 +234,7 @@ function getEnquiryWorkspaceColumns({
|
|||||||
{
|
{
|
||||||
id: 'estimatedValue',
|
id: 'estimatedValue',
|
||||||
accessorKey: 'estimatedValue',
|
accessorKey: 'estimatedValue',
|
||||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
|
||||||
<DataTableColumnHeader column={column} title='Estimated Value' />
|
<DataTableColumnHeader column={column} title='Estimated Value' />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
@@ -246,7 +246,7 @@ function getEnquiryWorkspaceColumns({
|
|||||||
{
|
{
|
||||||
id: 'chancePercent',
|
id: 'chancePercent',
|
||||||
accessorKey: 'chancePercent',
|
accessorKey: 'chancePercent',
|
||||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
|
||||||
<DataTableColumnHeader column={column} title='Chance %' />
|
<DataTableColumnHeader column={column} title='Chance %' />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => <span>{row.original.chancePercent !== null ? `${row.original.chancePercent}%` : '-'}</span>
|
cell: ({ row }) => <span>{row.original.chancePercent !== null ? `${row.original.chancePercent}%` : '-'}</span>
|
||||||
@@ -254,7 +254,7 @@ function getEnquiryWorkspaceColumns({
|
|||||||
{
|
{
|
||||||
id: 'expectedCloseDate',
|
id: 'expectedCloseDate',
|
||||||
accessorKey: 'expectedCloseDate',
|
accessorKey: 'expectedCloseDate',
|
||||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
|
||||||
<DataTableColumnHeader column={column} title='Expected Close' />
|
<DataTableColumnHeader column={column} title='Expected Close' />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
@@ -268,7 +268,7 @@ function getEnquiryWorkspaceColumns({
|
|||||||
{
|
{
|
||||||
id: 'nextFollowupDate',
|
id: 'nextFollowupDate',
|
||||||
accessorKey: 'nextFollowupDate',
|
accessorKey: 'nextFollowupDate',
|
||||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
|
||||||
<DataTableColumnHeader column={column} title='Next Follow-up' />
|
<DataTableColumnHeader column={column} title='Next Follow-up' />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
@@ -280,7 +280,7 @@ function getEnquiryWorkspaceColumns({
|
|||||||
{
|
{
|
||||||
id: 'createdAt',
|
id: 'createdAt',
|
||||||
accessorKey: 'createdAt',
|
accessorKey: 'createdAt',
|
||||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
|
||||||
<DataTableColumnHeader column={column} title='Created Date' />
|
<DataTableColumnHeader column={column} title='Created Date' />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => <span>{new Date(row.original.createdAt).toLocaleDateString()}</span>
|
cell: ({ row }) => <span>{new Date(row.original.createdAt).toLocaleDateString()}</span>
|
||||||
@@ -288,10 +288,10 @@ function getEnquiryWorkspaceColumns({
|
|||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<EnquiryCellAction
|
<OpportunityCellAction
|
||||||
data={row.original}
|
data={row.original}
|
||||||
referenceData={referenceData}
|
referenceData={referenceData}
|
||||||
workspace='enquiry'
|
workspace='opportunity'
|
||||||
canUpdate={canUpdate}
|
canUpdate={canUpdate}
|
||||||
canDelete={canDelete}
|
canDelete={canDelete}
|
||||||
canAssign={canAssign}
|
canAssign={canAssign}
|
||||||
@@ -302,14 +302,15 @@ function getEnquiryWorkspaceColumns({
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getEnquiryColumns({
|
export function getOpportunityColumns({
|
||||||
referenceData,
|
referenceData,
|
||||||
workspace,
|
workspace,
|
||||||
canUpdate,
|
canUpdate,
|
||||||
canDelete,
|
canDelete,
|
||||||
canAssign,
|
canAssign,
|
||||||
canReassign
|
canReassign
|
||||||
}: SharedColumnsConfig & { workspace: 'lead' | 'enquiry' }): ColumnDef<EnquiryListItem>[] {
|
}: SharedColumnsConfig & { workspace: 'lead' | 'opportunity' }): ColumnDef<OpportunityListItem>[] {
|
||||||
const sharedConfig = { referenceData, canUpdate, canDelete, canAssign, canReassign };
|
const sharedConfig = { referenceData, canUpdate, canDelete, canAssign, canReassign };
|
||||||
return workspace === 'lead' ? getLeadColumns(sharedConfig) : getEnquiryWorkspaceColumns(sharedConfig);
|
return workspace === 'lead' ? getLeadColumns(sharedConfig) : getOpportunityWorkspaceColumns(sharedConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11,13 +11,13 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|||||||
import type { QuotationReferenceData, QuotationRelationItem } from '@/features/crm/quotations/api/types';
|
import type { QuotationReferenceData, QuotationRelationItem } from '@/features/crm/quotations/api/types';
|
||||||
import { QuotationFormSheet } from '@/features/crm/quotations/components/quotation-form-sheet';
|
import { QuotationFormSheet } from '@/features/crm/quotations/components/quotation-form-sheet';
|
||||||
import { getPipelineStageThaiLabel } from '@/features/crm/shared/terminology';
|
import { getPipelineStageThaiLabel } from '@/features/crm/shared/terminology';
|
||||||
import { enquiryByIdOptions, enquiryProjectPartiesOptions } from '../api/queries';
|
import { opportunityByIdOptions, opportunityProjectPartiesOptions } from '../api/queries';
|
||||||
import type { EnquiryRecord, EnquiryReferenceData } from '../api/types';
|
import type { OpportunityRecord, OpportunityReferenceData } from '../api/types';
|
||||||
import { EnquiryAssignmentDialog } from './enquiry-assignment-dialog';
|
import { OpportunityAssignmentDialog } from './opportunity-assignment-dialog';
|
||||||
import { EnquiryFollowupsTab } from './enquiry-followups-tab';
|
import { OpportunityFollowupsTab } from './opportunity-followups-tab';
|
||||||
import { EnquiryFormSheet } from './enquiry-form-sheet';
|
import { OpportunityFormSheet } from './opportunity-form-sheet';
|
||||||
import { EnquiryOutcomeCard } from './enquiry-outcome-card';
|
import { OpportunityOutcomeCard } from './opportunity-outcome-card';
|
||||||
import { EnquiryStatusBadge } from './enquiry-status-badge';
|
import { OpportunityStatusBadge } from './opportunity-status-badge';
|
||||||
import { QuotationReadinessPanel } from './quotation-readiness-panel';
|
import { QuotationReadinessPanel } from './quotation-readiness-panel';
|
||||||
import { SourceLeadCard, type SourceLeadSummary } from './source-lead-card';
|
import { SourceLeadCard, type SourceLeadSummary } from './source-lead-card';
|
||||||
|
|
||||||
@@ -30,15 +30,15 @@ function FieldItem({ label, value }: { label: string; value: string | null | und
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPipelineStageLabel(stage: EnquiryRecord['pipelineStage']) {
|
function getPipelineStageLabel(stage: OpportunityRecord['pipelineStage']) {
|
||||||
return getPipelineStageThaiLabel(stage);
|
return getPipelineStageThaiLabel(stage);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EnquiryDetailProps {
|
interface OpportunityDetailProps {
|
||||||
enquiryId: string;
|
opportunityId: string;
|
||||||
referenceData: EnquiryReferenceData;
|
referenceData: OpportunityReferenceData;
|
||||||
relatedQuotations: QuotationRelationItem[];
|
relatedQuotations: QuotationRelationItem[];
|
||||||
workspace: 'lead' | 'enquiry';
|
workspace: 'lead' | 'opportunity';
|
||||||
canUpdate: boolean;
|
canUpdate: boolean;
|
||||||
canAssign: boolean;
|
canAssign: boolean;
|
||||||
canReassign: boolean;
|
canReassign: boolean;
|
||||||
@@ -57,8 +57,8 @@ interface EnquiryDetailProps {
|
|||||||
quotationReferenceData?: QuotationReferenceData | null;
|
quotationReferenceData?: QuotationReferenceData | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EnquiryDetail({
|
export function OpportunityDetail({
|
||||||
enquiryId,
|
opportunityId,
|
||||||
referenceData,
|
referenceData,
|
||||||
relatedQuotations,
|
relatedQuotations,
|
||||||
workspace,
|
workspace,
|
||||||
@@ -74,13 +74,13 @@ export function EnquiryDetail({
|
|||||||
canManageFollowups,
|
canManageFollowups,
|
||||||
sourceLead = null,
|
sourceLead = null,
|
||||||
quotationReferenceData = null
|
quotationReferenceData = null
|
||||||
}: EnquiryDetailProps) {
|
}: OpportunityDetailProps) {
|
||||||
const { data } = useSuspenseQuery(enquiryByIdOptions(enquiryId));
|
const { data } = useSuspenseQuery(opportunityByIdOptions(opportunityId));
|
||||||
const { data: projectPartiesData } = useSuspenseQuery(enquiryProjectPartiesOptions(enquiryId));
|
const { data: projectPartiesData } = useSuspenseQuery(opportunityProjectPartiesOptions(opportunityId));
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [assignmentOpen, setAssignmentOpen] = useState(false);
|
const [assignmentOpen, setAssignmentOpen] = useState(false);
|
||||||
const [quotationOpen, setQuotationOpen] = useState(false);
|
const [quotationOpen, setQuotationOpen] = useState(false);
|
||||||
const enquiry = data.enquiry;
|
const opportunity = data.opportunity;
|
||||||
|
|
||||||
const branchMap = useMemo(
|
const branchMap = useMemo(
|
||||||
() => new Map(referenceData.branches.map((item) => [item.id, item.name])),
|
() => new Map(referenceData.branches.map((item) => [item.id, item.name])),
|
||||||
@@ -107,24 +107,24 @@ export function EnquiryDetail({
|
|||||||
[referenceData]
|
[referenceData]
|
||||||
);
|
);
|
||||||
|
|
||||||
const customer = customerMap.get(enquiry.customerId);
|
const customer = customerMap.get(opportunity.customerId);
|
||||||
const contact = enquiry.contactId ? contactMap.get(enquiry.contactId) : null;
|
const contact = opportunity.contactId ? contactMap.get(opportunity.contactId) : null;
|
||||||
const status = statusMap.get(enquiry.status);
|
const status = statusMap.get(opportunity.status);
|
||||||
const pipelineStageLabel = getPipelineStageLabel(enquiry.pipelineStage);
|
const pipelineStageLabel = getPipelineStageLabel(opportunity.pipelineStage);
|
||||||
const canManageAssignment = enquiry.assignedToUserId ? canReassign : canAssign;
|
const canManageAssignment = opportunity.assignedToUserId ? canReassign : canAssign;
|
||||||
const assignmentMode = enquiry.assignedToUserId ? 'reassign' : 'assign';
|
const assignmentMode = opportunity.assignedToUserId ? 'reassign' : 'assign';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-6'>
|
<div className='space-y-6'>
|
||||||
<EnquiryFormSheet
|
<OpportunityFormSheet
|
||||||
open={editOpen}
|
open={editOpen}
|
||||||
onOpenChange={setEditOpen}
|
onOpenChange={setEditOpen}
|
||||||
referenceData={referenceData}
|
referenceData={referenceData}
|
||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
enquiry={enquiry}
|
opportunity={opportunity}
|
||||||
/>
|
/>
|
||||||
<EnquiryAssignmentDialog
|
<OpportunityAssignmentDialog
|
||||||
enquiry={enquiry}
|
opportunity={opportunity}
|
||||||
referenceData={referenceData}
|
referenceData={referenceData}
|
||||||
mode={assignmentMode}
|
mode={assignmentMode}
|
||||||
open={assignmentOpen}
|
open={assignmentOpen}
|
||||||
@@ -141,8 +141,8 @@ export function EnquiryDetail({
|
|||||||
<div className='flex items-start justify-between gap-4'>
|
<div className='flex items-start justify-between gap-4'>
|
||||||
<div>
|
<div>
|
||||||
<div className='flex items-center gap-3'>
|
<div className='flex items-center gap-3'>
|
||||||
<h2 className='text-2xl font-semibold'>{enquiry.code}</h2>
|
<h2 className='text-2xl font-semibold'>{opportunity.code}</h2>
|
||||||
<EnquiryStatusBadge code={status?.code} label={status?.label ?? enquiry.status} />
|
<OpportunityStatusBadge code={status?.code} label={status?.label ?? opportunity.status} />
|
||||||
</div>
|
</div>
|
||||||
<p className='text-muted-foreground mt-2 text-sm'>
|
<p className='text-muted-foreground mt-2 text-sm'>
|
||||||
Opportunity detail for sales execution, follow-up continuity, and quotation readiness.
|
Opportunity detail for sales execution, follow-up continuity, and quotation readiness.
|
||||||
@@ -184,22 +184,22 @@ export function EnquiryDetail({
|
|||||||
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
|
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
|
||||||
<FieldItem label='Customer' value={customer?.name} />
|
<FieldItem label='Customer' value={customer?.name} />
|
||||||
<FieldItem label='Contact' value={contact?.name} />
|
<FieldItem label='Contact' value={contact?.name} />
|
||||||
<FieldItem label='Project Name' value={enquiry.projectName} />
|
<FieldItem label='Project Name' value={opportunity.projectName} />
|
||||||
<FieldItem label='Project Location' value={enquiry.projectLocation} />
|
<FieldItem label='Project Location' value={opportunity.projectLocation} />
|
||||||
<FieldItem label='Pipeline Stage' value={pipelineStageLabel} />
|
<FieldItem label='Pipeline Stage' value={pipelineStageLabel} />
|
||||||
<FieldItem label='Sales Owner' value={enquiry.assignedToName} />
|
<FieldItem label='Sales Owner' value={opportunity.assignedToName} />
|
||||||
<FieldItem label='Status' value={status?.label ?? enquiry.status} />
|
<FieldItem label='Status' value={status?.label ?? opportunity.status} />
|
||||||
<FieldItem
|
<FieldItem
|
||||||
label='Expected Close Date'
|
label='Expected Close Date'
|
||||||
value={
|
value={
|
||||||
enquiry.expectedCloseDate
|
opportunity.expectedCloseDate
|
||||||
? new Date(enquiry.expectedCloseDate).toLocaleDateString()
|
? new Date(opportunity.expectedCloseDate).toLocaleDateString()
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<FieldItem
|
<FieldItem
|
||||||
label='Estimated Value'
|
label='Estimated Value'
|
||||||
value={enquiry.estimatedValue !== null ? enquiry.estimatedValue.toLocaleString() : null}
|
value={opportunity.estimatedValue !== null ? opportunity.estimatedValue.toLocaleString() : null}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -216,7 +216,7 @@ export function EnquiryDetail({
|
|||||||
<FieldItem label='Contact Name' value={contact?.name} />
|
<FieldItem label='Contact Name' value={contact?.name} />
|
||||||
<FieldItem label='Contact Email' value={contact?.email} />
|
<FieldItem label='Contact Email' value={contact?.email} />
|
||||||
<FieldItem label='Contact Mobile' value={contact?.mobile} />
|
<FieldItem label='Contact Mobile' value={contact?.mobile} />
|
||||||
<FieldItem label='Branch' value={enquiry.branchId ? branchMap.get(enquiry.branchId) : null} />
|
<FieldItem label='Branch' value={opportunity.branchId ? branchMap.get(opportunity.branchId) : null} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -225,12 +225,12 @@ export function EnquiryDetail({
|
|||||||
<CardTitle>Requirement</CardTitle>
|
<CardTitle>Requirement</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='space-y-4'>
|
<CardContent className='space-y-4'>
|
||||||
<FieldItem label='Title' value={enquiry.title} />
|
<FieldItem label='Title' value={opportunity.title} />
|
||||||
<FieldItem label='Description' value={enquiry.description} />
|
<FieldItem label='Description' value={opportunity.description} />
|
||||||
<FieldItem label='Requirement' value={enquiry.requirement} />
|
<FieldItem label='Requirement' value={opportunity.requirement} />
|
||||||
<div className='grid gap-4 md:grid-cols-2'>
|
<div className='grid gap-4 md:grid-cols-2'>
|
||||||
<FieldItem label='Source' value={enquiry.source} />
|
<FieldItem label='Source' value={opportunity.source} />
|
||||||
<FieldItem label='Assignment Remark' value={enquiry.assignmentRemark} />
|
<FieldItem label='Assignment Remark' value={opportunity.assignmentRemark} />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -240,18 +240,18 @@ export function EnquiryDetail({
|
|||||||
<CardTitle>Sales Qualification</CardTitle>
|
<CardTitle>Sales Qualification</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
|
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
|
||||||
<FieldItem label='Product Type' value={productTypeMap.get(enquiry.productType) ?? enquiry.productType} />
|
<FieldItem label='Product Type' value={productTypeMap.get(opportunity.productType) ?? opportunity.productType} />
|
||||||
<FieldItem label='Priority' value={priorityMap.get(enquiry.priority) ?? enquiry.priority} />
|
<FieldItem label='Priority' value={priorityMap.get(opportunity.priority) ?? opportunity.priority} />
|
||||||
<FieldItem
|
<FieldItem
|
||||||
label='Chance %'
|
label='Chance %'
|
||||||
value={enquiry.chancePercent !== null ? `${enquiry.chancePercent}%` : null}
|
value={opportunity.chancePercent !== null ? `${opportunity.chancePercent}%` : null}
|
||||||
/>
|
/>
|
||||||
<FieldItem label='Competitor' value={enquiry.competitor} />
|
<FieldItem label='Competitor' value={opportunity.competitor} />
|
||||||
<FieldItem
|
<FieldItem
|
||||||
label='Assigned At'
|
label='Assigned At'
|
||||||
value={enquiry.assignedAt ? new Date(enquiry.assignedAt).toLocaleString() : null}
|
value={opportunity.assignedAt ? new Date(opportunity.assignedAt).toLocaleString() : null}
|
||||||
/>
|
/>
|
||||||
<FieldItem label='Assigned By' value={enquiry.assignedByName} />
|
<FieldItem label='Assigned By' value={opportunity.assignedByName} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -263,9 +263,9 @@ export function EnquiryDetail({
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value='followups'>
|
<TabsContent value='followups'>
|
||||||
<EnquiryFollowupsTab
|
<OpportunityFollowupsTab
|
||||||
enquiryId={enquiryId}
|
opportunityId={opportunityId}
|
||||||
customerId={enquiry.customerId}
|
customerId={opportunity.customerId}
|
||||||
referenceData={referenceData}
|
referenceData={referenceData}
|
||||||
canCreate={canManageFollowups.create}
|
canCreate={canManageFollowups.create}
|
||||||
canUpdate={canManageFollowups.update}
|
canUpdate={canManageFollowups.update}
|
||||||
@@ -329,10 +329,10 @@ export function EnquiryDetail({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='space-y-6'>
|
<div className='space-y-6'>
|
||||||
<QuotationReadinessPanel enquiry={enquiry} />
|
<QuotationReadinessPanel opportunity={opportunity} />
|
||||||
|
|
||||||
<EnquiryOutcomeCard
|
<OpportunityOutcomeCard
|
||||||
enquiryId={enquiryId}
|
opportunityId={opportunityId}
|
||||||
referenceData={referenceData}
|
referenceData={referenceData}
|
||||||
canViewCommercialData={canViewOutcomeCommercialData}
|
canViewCommercialData={canViewOutcomeCommercialData}
|
||||||
canMarkWon={canMarkWon}
|
canMarkWon={canMarkWon}
|
||||||
@@ -350,7 +350,7 @@ export function EnquiryDetail({
|
|||||||
label='Project Parties'
|
label='Project Parties'
|
||||||
value={String(projectPartiesData.items.length)}
|
value={String(projectPartiesData.items.length)}
|
||||||
/>
|
/>
|
||||||
<FieldItem label='Record Type' value={workspace === 'enquiry' ? 'Opportunity' : 'Lead'} />
|
<FieldItem label='Record Type' value={workspace === 'opportunity' ? 'Opportunity' : 'Lead'} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -358,3 +358,4 @@ export function EnquiryDetail({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8,27 +8,27 @@ import { Icons } from '@/components/icons';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { deleteEnquiryFollowupMutation } from '../api/mutations';
|
import { deleteOpportunityFollowupMutation } from '../api/mutations';
|
||||||
import { enquiryFollowupsOptions } from '../api/queries';
|
import { opportunityFollowupsOptions } from '../api/queries';
|
||||||
import type { EnquiryReferenceData } from '../api/types';
|
import type { OpportunityReferenceData } from '../api/types';
|
||||||
import { FollowupFormSheet } from './followup-form-sheet';
|
import { FollowupFormSheet } from './followup-form-sheet';
|
||||||
|
|
||||||
export function EnquiryFollowupsTab({
|
export function OpportunityFollowupsTab({
|
||||||
enquiryId,
|
opportunityId,
|
||||||
customerId,
|
customerId,
|
||||||
referenceData,
|
referenceData,
|
||||||
canCreate,
|
canCreate,
|
||||||
canUpdate,
|
canUpdate,
|
||||||
canDelete
|
canDelete
|
||||||
}: {
|
}: {
|
||||||
enquiryId: string;
|
opportunityId: string;
|
||||||
customerId: string;
|
customerId: string;
|
||||||
referenceData: EnquiryReferenceData;
|
referenceData: OpportunityReferenceData;
|
||||||
canCreate: boolean;
|
canCreate: boolean;
|
||||||
canUpdate: boolean;
|
canUpdate: boolean;
|
||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { data } = useSuspenseQuery(enquiryFollowupsOptions(enquiryId));
|
const { data } = useSuspenseQuery(opportunityFollowupsOptions(opportunityId));
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
@@ -36,7 +36,7 @@ export function EnquiryFollowupsTab({
|
|||||||
const followupTypeMap = new Map(referenceData.followupTypes.map((item) => [item.id, item.label]));
|
const followupTypeMap = new Map(referenceData.followupTypes.map((item) => [item.id, item.label]));
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
...deleteEnquiryFollowupMutation,
|
...deleteOpportunityFollowupMutation,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('ลบงานติดตามสำเร็จ');
|
toast.success('ลบงานติดตามสำเร็จ');
|
||||||
setDeleteOpen(false);
|
setDeleteOpen(false);
|
||||||
@@ -117,7 +117,7 @@ export function EnquiryFollowupsTab({
|
|||||||
setDeleteOpen(true);
|
setDeleteOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icons.trash className='mr-2 h-4 w-4' /> ลบ
|
<Icons.trash className='mr-2 h-4 w-4' /> เธฅเธ
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,7 +126,7 @@ export function EnquiryFollowupsTab({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
<FollowupFormSheet
|
<FollowupFormSheet
|
||||||
enquiryId={enquiryId}
|
opportunityId={opportunityId}
|
||||||
followup={selected}
|
followup={selected}
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
@@ -138,7 +138,7 @@ export function EnquiryFollowupsTab({
|
|||||||
onClose={() => setDeleteOpen(false)}
|
onClose={() => setDeleteOpen(false)}
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
if (selectedId) {
|
if (selectedId) {
|
||||||
deleteMutation.mutate({ enquiryId, followupId: selectedId });
|
deleteMutation.mutate({ opportunityId, followupId: selectedId });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
loading={deleteMutation.isPending}
|
loading={deleteMutation.isPending}
|
||||||
@@ -146,3 +146,4 @@ export function EnquiryFollowupsTab({
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,58 +26,58 @@ import {
|
|||||||
SheetTitle
|
SheetTitle
|
||||||
} from '@/components/ui/sheet';
|
} from '@/components/ui/sheet';
|
||||||
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
|
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
|
||||||
import { enquiryProjectPartiesOptions } from '../api/queries';
|
import { opportunityProjectPartiesOptions } from '../api/queries';
|
||||||
import { createEnquiryMutation, updateEnquiryMutation } from '../api/mutations';
|
import { createOpportunityMutation, updateOpportunityMutation } from '../api/mutations';
|
||||||
import type { EnquiryMutationPayload, EnquiryRecord, EnquiryReferenceData } from '../api/types';
|
import type { OpportunityMutationPayload, OpportunityRecord, OpportunityReferenceData } from '../api/types';
|
||||||
import { enquirySchema, type EnquiryFormValues } from '../schemas/enquiry.schema';
|
import { opportunitySchema, type OpportunityFormValues } from '../schemas/opportunity.schema';
|
||||||
|
|
||||||
function toDefaultValues(
|
function toDefaultValues(
|
||||||
enquiry: EnquiryRecord | undefined,
|
opportunity: OpportunityRecord | undefined,
|
||||||
referenceData: EnquiryReferenceData
|
referenceData: OpportunityReferenceData
|
||||||
): EnquiryFormValues {
|
): OpportunityFormValues {
|
||||||
return {
|
return {
|
||||||
customerId: enquiry?.customerId ?? referenceData.customers[0]?.id ?? '',
|
customerId: opportunity?.customerId ?? referenceData.customers[0]?.id ?? '',
|
||||||
contactId: enquiry?.contactId ?? undefined,
|
contactId: opportunity?.contactId ?? undefined,
|
||||||
assignedToUserId: enquiry?.assignedToUserId ?? undefined,
|
assignedToUserId: opportunity?.assignedToUserId ?? undefined,
|
||||||
title: enquiry?.title ?? '',
|
title: opportunity?.title ?? '',
|
||||||
description: enquiry?.description ?? '',
|
description: opportunity?.description ?? '',
|
||||||
requirement: enquiry?.requirement ?? '',
|
requirement: opportunity?.requirement ?? '',
|
||||||
projectName: enquiry?.projectName ?? '',
|
projectName: opportunity?.projectName ?? '',
|
||||||
projectLocation: enquiry?.projectLocation ?? '',
|
projectLocation: opportunity?.projectLocation ?? '',
|
||||||
branchId: enquiry?.branchId ?? undefined,
|
branchId: opportunity?.branchId ?? undefined,
|
||||||
productType: enquiry?.productType ?? referenceData.productTypes[0]?.id ?? '',
|
productType: opportunity?.productType ?? referenceData.productTypes[0]?.id ?? '',
|
||||||
status: enquiry?.status ?? referenceData.statuses[0]?.id ?? '',
|
status: opportunity?.status ?? referenceData.statuses[0]?.id ?? '',
|
||||||
priority:
|
priority:
|
||||||
enquiry?.priority ?? referenceData.priorities[1]?.id ?? referenceData.priorities[0]?.id ?? '',
|
opportunity?.priority ?? referenceData.priorities[1]?.id ?? referenceData.priorities[0]?.id ?? '',
|
||||||
leadChannel: enquiry?.leadChannel ?? undefined,
|
leadChannel: opportunity?.leadChannel ?? undefined,
|
||||||
estimatedValue: enquiry?.estimatedValue ?? undefined,
|
estimatedValue: opportunity?.estimatedValue ?? undefined,
|
||||||
chancePercent: enquiry?.chancePercent ?? undefined,
|
chancePercent: opportunity?.chancePercent ?? undefined,
|
||||||
expectedCloseDate: enquiry?.expectedCloseDate ? enquiry.expectedCloseDate.slice(0, 10) : '',
|
expectedCloseDate: opportunity?.expectedCloseDate ? opportunity.expectedCloseDate.slice(0, 10) : '',
|
||||||
competitor: enquiry?.competitor ?? '',
|
competitor: opportunity?.competitor ?? '',
|
||||||
source: enquiry?.source ?? '',
|
source: opportunity?.source ?? '',
|
||||||
isHotProject: enquiry?.isHotProject ?? false,
|
isHotProject: opportunity?.isHotProject ?? false,
|
||||||
notes: enquiry?.notes ?? '',
|
notes: opportunity?.notes ?? '',
|
||||||
isActive: enquiry?.isActive ?? true
|
isActive: opportunity?.isActive ?? true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EnquiryFormSheet({
|
export function OpportunityFormSheet({
|
||||||
enquiry,
|
opportunity,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
referenceData,
|
referenceData,
|
||||||
workspace = 'enquiry'
|
workspace = 'opportunity'
|
||||||
}: {
|
}: {
|
||||||
enquiry?: EnquiryRecord;
|
opportunity?: OpportunityRecord;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
referenceData: EnquiryReferenceData;
|
referenceData: OpportunityReferenceData;
|
||||||
workspace?: 'lead' | 'enquiry';
|
workspace?: 'lead' | 'opportunity';
|
||||||
}) {
|
}) {
|
||||||
const isEdit = !!enquiry;
|
const isEdit = !!opportunity;
|
||||||
const defaultValues = useMemo(
|
const defaultValues = useMemo(
|
||||||
() => toDefaultValues(enquiry, referenceData),
|
() => toDefaultValues(opportunity, referenceData),
|
||||||
[enquiry, referenceData]
|
[opportunity, referenceData]
|
||||||
);
|
);
|
||||||
const [selectedCustomerId, setSelectedCustomerId] = useState(defaultValues.customerId);
|
const [selectedCustomerId, setSelectedCustomerId] = useState(defaultValues.customerId);
|
||||||
const [projectParties, setProjectParties] = useState<ProjectPartyEditorItem[]>([]);
|
const [projectParties, setProjectParties] = useState<ProjectPartyEditorItem[]>([]);
|
||||||
@@ -89,14 +89,14 @@ export function EnquiryFormSheet({
|
|||||||
FormDatePickerField,
|
FormDatePickerField,
|
||||||
FormCurrencyField,
|
FormCurrencyField,
|
||||||
FormPercentageField
|
FormPercentageField
|
||||||
} = useFormFields<EnquiryFormValues>();
|
} = useFormFields<OpportunityFormValues>();
|
||||||
const projectPartiesQuery = useQuery({
|
const projectPartiesQuery = useQuery({
|
||||||
...enquiryProjectPartiesOptions(enquiry?.id ?? ''),
|
...opportunityProjectPartiesOptions(opportunity?.id ?? ''),
|
||||||
enabled: open && !!enquiry?.id
|
enabled: open && !!opportunity?.id
|
||||||
});
|
});
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
...createEnquiryMutation,
|
...createOpportunityMutation,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(`สร้าง${recordLabel}สำเร็จ`);
|
toast.success(`สร้าง${recordLabel}สำเร็จ`);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
@@ -106,7 +106,7 @@ export function EnquiryFormSheet({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
...updateEnquiryMutation,
|
...updateOpportunityMutation,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(`อัปเดต${recordLabel}สำเร็จ`);
|
toast.success(`อัปเดต${recordLabel}สำเร็จ`);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
@@ -123,10 +123,10 @@ export function EnquiryFormSheet({
|
|||||||
const form = useAppForm({
|
const form = useAppForm({
|
||||||
defaultValues,
|
defaultValues,
|
||||||
validators: {
|
validators: {
|
||||||
onSubmit: enquirySchema
|
onSubmit: opportunitySchema
|
||||||
},
|
},
|
||||||
onSubmit: async ({ value }) => {
|
onSubmit: async ({ value }) => {
|
||||||
const payload: EnquiryMutationPayload = {
|
const payload: OpportunityMutationPayload = {
|
||||||
customerId: value.customerId,
|
customerId: value.customerId,
|
||||||
contactId: value.contactId || null,
|
contactId: value.contactId || null,
|
||||||
assignedToUserId: value.assignedToUserId || null,
|
assignedToUserId: value.assignedToUserId || null,
|
||||||
@@ -159,8 +159,8 @@ export function EnquiryFormSheet({
|
|||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEdit && enquiry) {
|
if (isEdit && opportunity) {
|
||||||
await updateMutation.mutateAsync({ id: enquiry.id, values: payload });
|
await updateMutation.mutateAsync({ id: opportunity.id, values: payload });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +176,7 @@ export function EnquiryFormSheet({
|
|||||||
setSelectedCustomerId(defaultValues.customerId);
|
setSelectedCustomerId(defaultValues.customerId);
|
||||||
form.reset(defaultValues);
|
form.reset(defaultValues);
|
||||||
setProjectParties(
|
setProjectParties(
|
||||||
enquiry
|
opportunity
|
||||||
? (projectPartiesQuery.data?.items ?? []).map((item) => ({
|
? (projectPartiesQuery.data?.items ?? []).map((item) => ({
|
||||||
key: item.id,
|
key: item.id,
|
||||||
customerId: item.customerId,
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open || isEdit) {
|
if (!open || isEdit) {
|
||||||
@@ -216,7 +216,7 @@ export function EnquiryFormSheet({
|
|||||||
|
|
||||||
<div className='flex-1 overflow-auto'>
|
<div className='flex-1 overflow-auto'>
|
||||||
<form.AppForm>
|
<form.AppForm>
|
||||||
<form.Form id='enquiry-form-sheet' className='grid gap-4 md:grid-cols-2'>
|
<form.Form id='opportunity-form-sheet' className='grid gap-4 md:grid-cols-2'>
|
||||||
<form.AppField
|
<form.AppField
|
||||||
name='customerId'
|
name='customerId'
|
||||||
children={(field) => (
|
children={(field) => (
|
||||||
@@ -289,7 +289,7 @@ export function EnquiryFormSheet({
|
|||||||
description={
|
description={
|
||||||
selectedCustomer?.ownerName
|
selectedCustomer?.ownerName
|
||||||
? `Suggested from customer owner: ${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) => ({
|
options={referenceData.assignableUsers.map((user) => ({
|
||||||
value: user.id,
|
value: user.id,
|
||||||
@@ -301,7 +301,7 @@ export function EnquiryFormSheet({
|
|||||||
name='title'
|
name='title'
|
||||||
label='ชื่อรายการ'
|
label='ชื่อรายการ'
|
||||||
required
|
required
|
||||||
placeholder='Warehouse crane upgrade enquiry'
|
placeholder='Warehouse crane upgrade opportunity'
|
||||||
/>
|
/>
|
||||||
<FormTextField
|
<FormTextField
|
||||||
name='projectName'
|
name='projectName'
|
||||||
@@ -429,7 +429,7 @@ export function EnquiryFormSheet({
|
|||||||
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
||||||
ยกเลิก
|
ยกเลิก
|
||||||
</Button>
|
</Button>
|
||||||
<Button type='submit' form='enquiry-form-sheet' isLoading={isPending}>
|
<Button type='submit' form='opportunity-form-sheet' isLoading={isPending}>
|
||||||
<Icons.check className='mr-2 h-4 w-4' />
|
<Icons.check className='mr-2 h-4 w-4' />
|
||||||
{isEdit ? `อัปเดต${recordLabel}` : `สร้าง${recordLabel}`}
|
{isEdit ? `อัปเดต${recordLabel}` : `สร้าง${recordLabel}`}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -439,12 +439,12 @@ export function EnquiryFormSheet({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EnquiryFormSheetTrigger({
|
export function OpportunityFormSheetTrigger({
|
||||||
referenceData,
|
referenceData,
|
||||||
workspace = 'enquiry'
|
workspace = 'opportunity'
|
||||||
}: {
|
}: {
|
||||||
referenceData: EnquiryReferenceData;
|
referenceData: OpportunityReferenceData;
|
||||||
workspace?: 'lead' | 'enquiry';
|
workspace?: 'lead' | 'opportunity';
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
|
|
||||||
@@ -453,7 +453,7 @@ export function EnquiryFormSheetTrigger({
|
|||||||
<Button onClick={() => setOpen(true)}>
|
<Button onClick={() => setOpen(true)}>
|
||||||
<Icons.add className='mr-2 h-4 w-4' /> {workspace === 'lead' ? 'เพิ่มลีด' : 'เพิ่มโอกาสขาย'}
|
<Icons.add className='mr-2 h-4 w-4' /> {workspace === 'lead' ? 'เพิ่มลีด' : 'เพิ่มโอกาสขาย'}
|
||||||
</Button>
|
</Button>
|
||||||
<EnquiryFormSheet
|
<OpportunityFormSheet
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
referenceData={referenceData}
|
referenceData={referenceData}
|
||||||
@@ -462,3 +462,4 @@ export function EnquiryFormSheetTrigger({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
||||||
import { getQueryClient } from '@/lib/query-client';
|
import { getQueryClient } from '@/lib/query-client';
|
||||||
import { searchParamsCache } from '@/lib/searchparams';
|
import { searchParamsCache } from '@/lib/searchparams';
|
||||||
import type { EnquiryReferenceData } from '../api/types';
|
import type { OpportunityReferenceData } from '../api/types';
|
||||||
import { enquiriesQueryOptions } from '../api/queries';
|
import { opportunitiesQueryOptions } from '../api/queries';
|
||||||
import { EnquiriesTable } from './enquiries-table';
|
import { OpportunitiesTable } from './opportunities-table';
|
||||||
|
|
||||||
export default function EnquiryListing({
|
export default function OpportunityListing({
|
||||||
referenceData,
|
referenceData,
|
||||||
workspace,
|
workspace,
|
||||||
canUpdate,
|
canUpdate,
|
||||||
@@ -13,8 +13,8 @@ export default function EnquiryListing({
|
|||||||
canAssign,
|
canAssign,
|
||||||
canReassign
|
canReassign
|
||||||
}: {
|
}: {
|
||||||
referenceData: EnquiryReferenceData;
|
referenceData: OpportunityReferenceData;
|
||||||
workspace: 'lead' | 'enquiry';
|
workspace: 'lead' | 'opportunity';
|
||||||
canUpdate: boolean;
|
canUpdate: boolean;
|
||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
canAssign: boolean;
|
canAssign: boolean;
|
||||||
@@ -44,11 +44,11 @@ export default function EnquiryListing({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const queryClient = getQueryClient();
|
const queryClient = getQueryClient();
|
||||||
void queryClient.prefetchQuery(enquiriesQueryOptions(filters));
|
void queryClient.prefetchQuery(opportunitiesQueryOptions(filters));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
<EnquiriesTable
|
<OpportunitiesTable
|
||||||
referenceData={referenceData}
|
referenceData={referenceData}
|
||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
canUpdate={canUpdate}
|
canUpdate={canUpdate}
|
||||||
@@ -59,3 +59,4 @@ export default function EnquiryListing({
|
|||||||
</HydrationBoundary>
|
</HydrationBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,13 +31,13 @@ import {
|
|||||||
CrmTextarea
|
CrmTextarea
|
||||||
} from '@/features/crm/components/crm-form-controls';
|
} from '@/features/crm/components/crm-form-controls';
|
||||||
import {
|
import {
|
||||||
invalidateEnquiryMutationQueries,
|
invalidateOpportunityMutationQueries,
|
||||||
markEnquiryLostMutation,
|
markOpportunityLostMutation,
|
||||||
markEnquiryWonMutation,
|
markOpportunityWonMutation,
|
||||||
reopenEnquiryMutation
|
reopenOpportunityMutation
|
||||||
} from '../api/mutations';
|
} from '../api/mutations';
|
||||||
import { enquiryAttachmentsOptions, enquiryByIdOptions } from '../api/queries';
|
import { opportunityAttachmentsOptions, opportunityByIdOptions } from '../api/queries';
|
||||||
import type { EnquiryReferenceData } from '../api/types';
|
import type { OpportunityReferenceData } from '../api/types';
|
||||||
|
|
||||||
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
|
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
|
||||||
return (
|
return (
|
||||||
@@ -54,29 +54,29 @@ function getOutcomeLabel(stage: string) {
|
|||||||
return 'Open';
|
return 'Open';
|
||||||
}
|
}
|
||||||
|
|
||||||
type EnquiryOutcomeCardProps = {
|
type OpportunityOutcomeCardProps = {
|
||||||
enquiryId: string;
|
opportunityId: string;
|
||||||
referenceData: EnquiryReferenceData;
|
referenceData: OpportunityReferenceData;
|
||||||
canViewCommercialData: boolean;
|
canViewCommercialData: boolean;
|
||||||
canMarkWon: boolean;
|
canMarkWon: boolean;
|
||||||
canMarkLost: boolean;
|
canMarkLost: boolean;
|
||||||
canReopen: boolean;
|
canReopen: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function EnquiryOutcomeCard({
|
export function OpportunityOutcomeCard({
|
||||||
enquiryId,
|
opportunityId,
|
||||||
referenceData,
|
referenceData,
|
||||||
canViewCommercialData,
|
canViewCommercialData,
|
||||||
canMarkWon,
|
canMarkWon,
|
||||||
canMarkLost,
|
canMarkLost,
|
||||||
canReopen
|
canReopen
|
||||||
}: EnquiryOutcomeCardProps) {
|
}: OpportunityOutcomeCardProps) {
|
||||||
const { data } = useSuspenseQuery(enquiryByIdOptions(enquiryId));
|
const { data } = useSuspenseQuery(opportunityByIdOptions(opportunityId));
|
||||||
const attachmentsQuery = useQuery({
|
const attachmentsQuery = useQuery({
|
||||||
...enquiryAttachmentsOptions(enquiryId),
|
...opportunityAttachmentsOptions(opportunityId),
|
||||||
enabled: canViewCommercialData
|
enabled: canViewCommercialData
|
||||||
});
|
});
|
||||||
const enquiry = data.enquiry;
|
const opportunity = data.opportunity;
|
||||||
const [wonOpen, setWonOpen] = useState(false);
|
const [wonOpen, setWonOpen] = useState(false);
|
||||||
const [lostOpen, setLostOpen] = useState(false);
|
const [lostOpen, setLostOpen] = useState(false);
|
||||||
const [reopenOpen, setReopenOpen] = useState(false);
|
const [reopenOpen, setReopenOpen] = useState(false);
|
||||||
@@ -93,31 +93,31 @@ export function EnquiryOutcomeCard({
|
|||||||
const [poFileDescription, setPoFileDescription] = useState('');
|
const [poFileDescription, setPoFileDescription] = useState('');
|
||||||
|
|
||||||
const wonMutation = useMutation({
|
const wonMutation = useMutation({
|
||||||
...markEnquiryWonMutation,
|
...markOpportunityWonMutation,
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
toast.success('Marked enquiry as won');
|
toast.success('Marked opportunity as won');
|
||||||
setWonOpen(false);
|
setWonOpen(false);
|
||||||
await invalidateEnquiryMutationQueries(enquiryId);
|
await invalidateOpportunityMutationQueries(opportunityId);
|
||||||
},
|
},
|
||||||
onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to mark won')
|
onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to mark won')
|
||||||
});
|
});
|
||||||
const lostMutation = useMutation({
|
const lostMutation = useMutation({
|
||||||
...markEnquiryLostMutation,
|
...markOpportunityLostMutation,
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
toast.success('Marked enquiry as lost');
|
toast.success('Marked opportunity as lost');
|
||||||
setLostOpen(false);
|
setLostOpen(false);
|
||||||
await invalidateEnquiryMutationQueries(enquiryId);
|
await invalidateOpportunityMutationQueries(opportunityId);
|
||||||
},
|
},
|
||||||
onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to mark lost')
|
onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to mark lost')
|
||||||
});
|
});
|
||||||
const reopenMutation = useMutation({
|
const reopenMutation = useMutation({
|
||||||
...reopenEnquiryMutation,
|
...reopenOpportunityMutation,
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
toast.success('Reopened enquiry');
|
toast.success('Reopened opportunity');
|
||||||
setReopenOpen(false);
|
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({
|
const uploadMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
@@ -128,7 +128,7 @@ export function EnquiryOutcomeCard({
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', poFile);
|
formData.append('file', poFile);
|
||||||
formData.append('description', poFileDescription);
|
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',
|
method: 'POST',
|
||||||
body: formData
|
body: formData
|
||||||
});
|
});
|
||||||
@@ -144,26 +144,26 @@ export function EnquiryOutcomeCard({
|
|||||||
toast.success('PO attachment uploaded');
|
toast.success('PO attachment uploaded');
|
||||||
setPoFile(null);
|
setPoFile(null);
|
||||||
setPoFileDescription('');
|
setPoFileDescription('');
|
||||||
await invalidateEnquiryMutationQueries(enquiryId);
|
await invalidateOpportunityMutationQueries(opportunityId);
|
||||||
},
|
},
|
||||||
onError: (error) =>
|
onError: (error) =>
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to upload PO attachment')
|
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(() => {
|
const lostReasonLabel = useMemo(() => {
|
||||||
if (!enquiry.lostReason) {
|
if (!opportunity.lostReason) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
enquiry.lostReasonLabel ??
|
opportunity.lostReasonLabel ??
|
||||||
referenceData.lostReasons.find(
|
referenceData.lostReasons.find(
|
||||||
(item) => item.id === enquiry.lostReason || item.code === enquiry.lostReason
|
(item) => item.id === opportunity.lostReason || item.code === opportunity.lostReason
|
||||||
)?.label ??
|
)?.label ??
|
||||||
enquiry.lostReason
|
opportunity.lostReason
|
||||||
);
|
);
|
||||||
}, [enquiry.lostReason, enquiry.lostReasonLabel, referenceData.lostReasons]);
|
}, [opportunity.lostReason, opportunity.lostReasonLabel, referenceData.lostReasons]);
|
||||||
const attachmentItems = attachmentsQuery.data?.items ?? [];
|
const attachmentItems = attachmentsQuery.data?.items ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -175,16 +175,16 @@ export function EnquiryOutcomeCard({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='space-y-4'>
|
<CardContent className='space-y-4'>
|
||||||
<div className='flex items-center justify-between gap-3'>
|
<div className='flex items-center justify-between gap-3'>
|
||||||
<Badge variant={enquiry.pipelineStage === 'closed_won' ? 'default' : enquiry.pipelineStage === 'closed_lost' ? 'destructive' : 'secondary'}>
|
<Badge variant={opportunity.pipelineStage === 'closed_won' ? 'default' : opportunity.pipelineStage === 'closed_lost' ? 'destructive' : 'secondary'}>
|
||||||
{getOutcomeLabel(enquiry.pipelineStage)}
|
{getOutcomeLabel(opportunity.pipelineStage)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<div className='flex flex-wrap gap-2'>
|
<div className='flex flex-wrap gap-2'>
|
||||||
{enquiry.pipelineStage === 'enquiry' && canMarkWon ? (
|
{opportunity.pipelineStage === 'opportunity' && canMarkWon ? (
|
||||||
<Button size='sm' onClick={() => {
|
<Button size='sm' onClick={() => {
|
||||||
setPoNumber(enquiry.poNumber ?? '');
|
setPoNumber(opportunity.poNumber ?? '');
|
||||||
setPoDate(enquiry.poDate ? enquiry.poDate.slice(0, 10) : '');
|
setPoDate(opportunity.poDate ? opportunity.poDate.slice(0, 10) : '');
|
||||||
setPoAmount(enquiry.poAmount !== null && enquiry.poAmount !== undefined ? String(enquiry.poAmount) : '');
|
setPoAmount(opportunity.poAmount !== null && opportunity.poAmount !== undefined ? String(opportunity.poAmount) : '');
|
||||||
setPoCurrency(enquiry.poCurrency ?? 'THB');
|
setPoCurrency(opportunity.poCurrency ?? 'THB');
|
||||||
setWonRemark('');
|
setWonRemark('');
|
||||||
setWonOpen(true);
|
setWonOpen(true);
|
||||||
}}>
|
}}>
|
||||||
@@ -192,18 +192,18 @@ export function EnquiryOutcomeCard({
|
|||||||
Mark Won
|
Mark Won
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{enquiry.pipelineStage === 'enquiry' && canMarkLost ? (
|
{opportunity.pipelineStage === 'opportunity' && canMarkLost ? (
|
||||||
<Button variant='destructive' size='sm' onClick={() => {
|
<Button variant='destructive' size='sm' onClick={() => {
|
||||||
setLostReason(enquiry.lostReason ?? '');
|
setLostReason(opportunity.lostReason ?? '');
|
||||||
setLostCompetitor(enquiry.lostCompetitor ?? '');
|
setLostCompetitor(opportunity.lostCompetitor ?? '');
|
||||||
setLostRemark(enquiry.lostRemark ?? '');
|
setLostRemark(opportunity.lostRemark ?? '');
|
||||||
setLostOpen(true);
|
setLostOpen(true);
|
||||||
}}>
|
}}>
|
||||||
<Icons.xCircle className='mr-2 h-4 w-4' />
|
<Icons.xCircle className='mr-2 h-4 w-4' />
|
||||||
Mark Lost
|
Mark Lost
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{enquiry.pipelineStage === 'closed_lost' && canReopen ? (
|
{opportunity.pipelineStage === 'closed_lost' && canReopen ? (
|
||||||
<Button variant='outline' size='sm' onClick={() => setReopenOpen(true)}>
|
<Button variant='outline' size='sm' onClick={() => setReopenOpen(true)}>
|
||||||
Reopen
|
Reopen
|
||||||
</Button>
|
</Button>
|
||||||
@@ -212,32 +212,32 @@ export function EnquiryOutcomeCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='grid gap-4 md:grid-cols-2'>
|
<div className='grid gap-4 md:grid-cols-2'>
|
||||||
<FieldItem label='Closed By' value={enquiry.closedByName} />
|
<FieldItem label='Closed By' value={opportunity.closedByName} />
|
||||||
<FieldItem
|
<FieldItem
|
||||||
label='Closed Date'
|
label='Closed Date'
|
||||||
value={closedDate ? new Date(closedDate).toLocaleString() : null}
|
value={closedDate ? new Date(closedDate).toLocaleString() : null}
|
||||||
/>
|
/>
|
||||||
<FieldItem label='PO Number' value={enquiry.poNumber} />
|
<FieldItem label='PO Number' value={opportunity.poNumber} />
|
||||||
<FieldItem
|
<FieldItem
|
||||||
label='PO Date'
|
label='PO Date'
|
||||||
value={enquiry.poDate ? new Date(enquiry.poDate).toLocaleDateString() : null}
|
value={opportunity.poDate ? new Date(opportunity.poDate).toLocaleDateString() : null}
|
||||||
/>
|
/>
|
||||||
<FieldItem
|
<FieldItem
|
||||||
label='PO Amount'
|
label='PO Amount'
|
||||||
value={
|
value={
|
||||||
canViewCommercialData && enquiry.poAmount !== null
|
canViewCommercialData && opportunity.poAmount !== null
|
||||||
? `${enquiry.poAmount.toLocaleString()} ${enquiry.poCurrency ?? ''}`.trim()
|
? `${opportunity.poAmount.toLocaleString()} ${opportunity.poCurrency ?? ''}`.trim()
|
||||||
: canViewCommercialData
|
: canViewCommercialData
|
||||||
? null
|
? null
|
||||||
: 'Restricted'
|
: 'Restricted'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<FieldItem label='Lost Reason' value={lostReasonLabel} />
|
<FieldItem label='Lost Reason' value={lostReasonLabel} />
|
||||||
<FieldItem label='Lost Competitor' value={enquiry.lostCompetitor} />
|
<FieldItem label='Lost Competitor' value={opportunity.lostCompetitor} />
|
||||||
<FieldItem label='Remark' value={enquiry.lostRemark} />
|
<FieldItem label='Remark' value={opportunity.lostRemark} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canViewCommercialData && enquiry.pipelineStage === 'closed_won' ? (
|
{canViewCommercialData && opportunity.pipelineStage === 'closed_won' ? (
|
||||||
<div className='space-y-4 rounded-lg border p-4'>
|
<div className='space-y-4 rounded-lg border p-4'>
|
||||||
<div className='space-y-1'>
|
<div className='space-y-1'>
|
||||||
<div className='font-medium'>Purchase Order Attachment</div>
|
<div className='font-medium'>Purchase Order Attachment</div>
|
||||||
@@ -295,12 +295,12 @@ export function EnquiryOutcomeCard({
|
|||||||
</div>
|
</div>
|
||||||
<div className='flex gap-2'>
|
<div className='flex gap-2'>
|
||||||
<Button asChild variant='outline' size='sm'>
|
<Button asChild variant='outline' size='sm'>
|
||||||
<Link href={`/api/crm/enquiries/${enquiryId}/po-attachments/${attachment.id}`}>
|
<Link href={`/api/crm/opportunities/${opportunityId}/po-attachments/${attachment.id}`}>
|
||||||
View
|
View
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild variant='outline' size='sm'>
|
<Button asChild variant='outline' size='sm'>
|
||||||
<Link href={`/api/crm/enquiries/${enquiryId}/po-attachments/${attachment.id}?download=1`}>
|
<Link href={`/api/crm/opportunities/${opportunityId}/po-attachments/${attachment.id}?download=1`}>
|
||||||
Download
|
Download
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -370,7 +370,7 @@ export function EnquiryOutcomeCard({
|
|||||||
isLoading={wonMutation.isPending}
|
isLoading={wonMutation.isPending}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
wonMutation.mutate({
|
wonMutation.mutate({
|
||||||
id: enquiryId,
|
id: opportunityId,
|
||||||
values: {
|
values: {
|
||||||
poNumber,
|
poNumber,
|
||||||
poDate,
|
poDate,
|
||||||
@@ -436,7 +436,7 @@ export function EnquiryOutcomeCard({
|
|||||||
isLoading={lostMutation.isPending}
|
isLoading={lostMutation.isPending}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
lostMutation.mutate({
|
lostMutation.mutate({
|
||||||
id: enquiryId,
|
id: opportunityId,
|
||||||
values: {
|
values: {
|
||||||
lostReason,
|
lostReason,
|
||||||
lostCompetitor,
|
lostCompetitor,
|
||||||
@@ -472,7 +472,7 @@ export function EnquiryOutcomeCard({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
isLoading={reopenMutation.isPending}
|
isLoading={reopenMutation.isPending}
|
||||||
onClick={() => reopenMutation.mutate({ id: enquiryId, reason: reopenReason })}
|
onClick={() => reopenMutation.mutate({ id: opportunityId, reason: reopenReason })}
|
||||||
>
|
>
|
||||||
Reopen
|
Reopen
|
||||||
</Button>
|
</Button>
|
||||||
@@ -482,3 +482,4 @@ export function EnquiryOutcomeCard({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Icons } from '@/components/icons';
|
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 normalized = code?.toLowerCase() ?? '';
|
||||||
const variant =
|
const variant =
|
||||||
normalized === 'closed_lost' || normalized === 'cancelled'
|
normalized === 'closed_lost' || normalized === 'cancelled'
|
||||||
@@ -29,3 +29,4 @@ export function EnquiryStatusBadge({ code, label }: { code?: string | null; labe
|
|||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,21 +1,21 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import type { EnquiryRecord } from '../api/types';
|
import type { OpportunityRecord } from '../api/types';
|
||||||
|
|
||||||
type ReadinessItem = {
|
type ReadinessItem = {
|
||||||
label: string;
|
label: string;
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function QuotationReadinessPanel({ enquiry }: { enquiry: EnquiryRecord }) {
|
export function QuotationReadinessPanel({ opportunity }: { opportunity: OpportunityRecord }) {
|
||||||
const checks: ReadinessItem[] = [
|
const checks: ReadinessItem[] = [
|
||||||
{ label: 'Customer selected', ready: Boolean(enquiry.customerId) },
|
{ label: 'Customer selected', ready: Boolean(opportunity.customerId) },
|
||||||
{ label: 'Contact selected', ready: Boolean(enquiry.contactId) },
|
{ label: 'Contact selected', ready: Boolean(opportunity.contactId) },
|
||||||
{ label: 'Project name present', ready: Boolean(enquiry.projectName?.trim()) },
|
{ label: 'Project name present', ready: Boolean(opportunity.projectName?.trim()) },
|
||||||
{ label: 'Product type present', ready: Boolean(enquiry.productType) },
|
{ label: 'Product type present', ready: Boolean(opportunity.productType) },
|
||||||
{ label: 'Requirement present', ready: Boolean(enquiry.requirement?.trim()) },
|
{ label: 'Requirement present', ready: Boolean(opportunity.requirement?.trim()) },
|
||||||
{ label: 'Estimated value present', ready: enquiry.estimatedValue !== null }
|
{ label: 'Estimated value present', ready: opportunity.estimatedValue !== null }
|
||||||
];
|
];
|
||||||
|
|
||||||
const readyCount = checks.filter((item) => item.ready).length;
|
const readyCount = checks.filter((item) => item.ready).length;
|
||||||
@@ -44,3 +44,4 @@ export function QuotationReadinessPanel({ enquiry }: { enquiry: EnquiryRecord })
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ function coerceOptionalNumber(message: string) {
|
|||||||
}, z.number().refine((value) => !Number.isNaN(value), message).optional());
|
}, 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'),
|
customerId: z.string().min(1, 'Please select a customer'),
|
||||||
contactId: z.string().optional().nullable(),
|
contactId: z.string().optional().nullable(),
|
||||||
assignedToUserId: z.string().optional().nullable(),
|
assignedToUserId: z.string().optional().nullable(),
|
||||||
@@ -63,7 +63,7 @@ export const enquirySchema = z.object({
|
|||||||
.optional()
|
.optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const enquiryFollowupSchema = z.object({
|
export const opportunityFollowupSchema = z.object({
|
||||||
followupDate: z.string().min(1, 'Follow-up date is required'),
|
followupDate: z.string().min(1, 'Follow-up date is required'),
|
||||||
followupType: z.string().min(1, 'Please select a follow-up type'),
|
followupType: z.string().min(1, 'Please select a follow-up type'),
|
||||||
contactId: z.string().optional().nullable(),
|
contactId: z.string().optional().nullable(),
|
||||||
@@ -73,7 +73,7 @@ export const enquiryFollowupSchema = z.object({
|
|||||||
nextAction: z.string().optional()
|
nextAction: z.string().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const enquiryMarkWonSchema = z.object({
|
export const opportunityMarkWonSchema = z.object({
|
||||||
poNumber: z.string().min(1, 'PO number is required'),
|
poNumber: z.string().min(1, 'PO number is required'),
|
||||||
poDate: z.string().min(1, 'PO date is required'),
|
poDate: z.string().min(1, 'PO date is required'),
|
||||||
poAmount: coerceOptionalNumber('PO amount must be a number').refine(
|
poAmount: coerceOptionalNumber('PO amount must be a number').refine(
|
||||||
@@ -84,18 +84,19 @@ export const enquiryMarkWonSchema = z.object({
|
|||||||
remark: z.string().optional().nullable()
|
remark: z.string().optional().nullable()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const enquiryMarkLostSchema = z.object({
|
export const opportunityMarkLostSchema = z.object({
|
||||||
lostReason: z.string().min(1, 'Lost reason is required'),
|
lostReason: z.string().min(1, 'Lost reason is required'),
|
||||||
lostCompetitor: z.string().optional().nullable(),
|
lostCompetitor: z.string().optional().nullable(),
|
||||||
lostRemark: 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')
|
reopenReason: z.string().min(3, 'Reopen reason is required')
|
||||||
});
|
});
|
||||||
|
|
||||||
export type EnquiryFormValues = z.input<typeof enquirySchema>;
|
export type OpportunityFormValues = z.input<typeof opportunitySchema>;
|
||||||
export type EnquiryFollowupFormValues = z.input<typeof enquiryFollowupSchema>;
|
export type OpportunityFollowupFormValues = z.input<typeof opportunityFollowupSchema>;
|
||||||
export type EnquiryMarkWonFormValues = z.input<typeof enquiryMarkWonSchema>;
|
export type OpportunityMarkWonFormValues = z.input<typeof opportunityMarkWonSchema>;
|
||||||
export type EnquiryMarkLostFormValues = z.input<typeof enquiryMarkLostSchema>;
|
export type OpportunityMarkLostFormValues = z.input<typeof opportunityMarkLostSchema>;
|
||||||
export type EnquiryReopenFormValues = z.input<typeof enquiryReopenSchema>;
|
export type OpportunityReopenFormValues = z.input<typeof opportunityReopenSchema>;
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -28,7 +28,7 @@ export async function getQuotations(filters: QuotationFilters): Promise<Quotatio
|
|||||||
if (filters.quotationType) searchParams.set('quotationType', filters.quotationType);
|
if (filters.quotationType) searchParams.set('quotationType', filters.quotationType);
|
||||||
if (filters.branch) searchParams.set('branch', filters.branch);
|
if (filters.branch) searchParams.set('branch', filters.branch);
|
||||||
if (filters.customer) searchParams.set('customer', filters.customer);
|
if (filters.customer) searchParams.set('customer', filters.customer);
|
||||||
if (filters.enquiry) searchParams.set('enquiry', filters.enquiry);
|
if (filters.opportunity) searchParams.set('opportunity', filters.opportunity);
|
||||||
if (filters.hot) searchParams.set('hot', filters.hot);
|
if (filters.hot) searchParams.set('hot', filters.hot);
|
||||||
if (filters.sort) searchParams.set('sort', filters.sort);
|
if (filters.sort) searchParams.set('sort', filters.sort);
|
||||||
|
|
||||||
@@ -212,3 +212,4 @@ export async function createQuotationRevision(id: string, revisionRemark?: strin
|
|||||||
body: JSON.stringify({ revisionRemark })
|
body: JSON.stringify({ revisionRemark })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export interface QuotationContactLookup {
|
|||||||
isPrimary: boolean;
|
isPrimary: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QuotationEnquiryLookup {
|
export interface QuotationOpportunityLookup {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
customerId: string;
|
customerId: string;
|
||||||
@@ -64,7 +64,7 @@ export interface QuotationRecord {
|
|||||||
organizationId: string;
|
organizationId: string;
|
||||||
branchId: string | null;
|
branchId: string | null;
|
||||||
code: string;
|
code: string;
|
||||||
enquiryId: string | null;
|
opportunityId: string | null;
|
||||||
customerId: string;
|
customerId: string;
|
||||||
contactId: string | null;
|
contactId: string | null;
|
||||||
quotationDate: string;
|
quotationDate: string;
|
||||||
@@ -115,7 +115,7 @@ export interface QuotationRecord {
|
|||||||
export interface QuotationListItem extends QuotationRecord {
|
export interface QuotationListItem extends QuotationRecord {
|
||||||
customerName: string;
|
customerName: string;
|
||||||
contactName: string | null;
|
contactName: string | null;
|
||||||
enquiryCode: string | null;
|
opportunityCode: string | null;
|
||||||
itemCount: number;
|
itemCount: number;
|
||||||
revisionLabel: string;
|
revisionLabel: string;
|
||||||
}
|
}
|
||||||
@@ -253,7 +253,7 @@ export interface QuotationReferenceData {
|
|||||||
productTypes: QuotationOption[];
|
productTypes: QuotationOption[];
|
||||||
customers: QuotationCustomerLookup[];
|
customers: QuotationCustomerLookup[];
|
||||||
contacts: QuotationContactLookup[];
|
contacts: QuotationContactLookup[];
|
||||||
enquiries: QuotationEnquiryLookup[];
|
opportunities: QuotationOpportunityLookup[];
|
||||||
salesmen: QuotationSalesmanLookup[];
|
salesmen: QuotationSalesmanLookup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,7 +265,7 @@ export interface QuotationFilters {
|
|||||||
quotationType?: string;
|
quotationType?: string;
|
||||||
branch?: string;
|
branch?: string;
|
||||||
customer?: string;
|
customer?: string;
|
||||||
enquiry?: string;
|
opportunity?: string;
|
||||||
hot?: string;
|
hot?: string;
|
||||||
sort?: string;
|
sort?: string;
|
||||||
}
|
}
|
||||||
@@ -331,7 +331,7 @@ export interface QuotationRevisionsResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface QuotationMutationPayload {
|
export interface QuotationMutationPayload {
|
||||||
enquiryId?: string | null;
|
opportunityId?: string | null;
|
||||||
customerId: string;
|
customerId: string;
|
||||||
contactId?: string | null;
|
contactId?: string | null;
|
||||||
quotationDate: string;
|
quotationDate: string;
|
||||||
@@ -420,3 +420,4 @@ export interface MutationSuccessResponse {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -114,16 +114,16 @@ export function getQuotationColumns({
|
|||||||
enableColumnFilter: true
|
enableColumnFilter: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'enquiry',
|
id: 'opportunity',
|
||||||
accessorFn: (row) => row.enquiryId ?? '',
|
accessorFn: (row) => row.opportunityId ?? '',
|
||||||
header: ({ column }: { column: Column<QuotationListItem, unknown> }) => (
|
header: ({ column }: { column: Column<QuotationListItem, unknown> }) => (
|
||||||
<DataTableColumnHeader column={column} title='โอกาสขาย' />
|
<DataTableColumnHeader column={column} title='โอกาสขาย' />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => row.original.enquiryCode ?? '-',
|
cell: ({ row }) => row.original.opportunityCode ?? '-',
|
||||||
meta: {
|
meta: {
|
||||||
label: 'โอกาสขาย',
|
label: 'โอกาสขาย',
|
||||||
variant: 'multiSelect' as const,
|
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
|
enableColumnFilter: true
|
||||||
},
|
},
|
||||||
@@ -165,3 +165,4 @@ export function getQuotationColumns({
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import { createQuotationMutation, updateQuotationMutation } from '../api/mutatio
|
|||||||
import type { QuotationMutationPayload, QuotationRecord, QuotationReferenceData } from '../api/types';
|
import type { QuotationMutationPayload, QuotationRecord, QuotationReferenceData } from '../api/types';
|
||||||
|
|
||||||
type FormState = {
|
type FormState = {
|
||||||
enquiryId: string;
|
opportunityId: string;
|
||||||
customerId: string;
|
customerId: string;
|
||||||
contactId: string;
|
contactId: string;
|
||||||
quotationDate: string;
|
quotationDate: string;
|
||||||
@@ -71,7 +71,7 @@ function toFormState(
|
|||||||
referenceData: QuotationReferenceData
|
referenceData: QuotationReferenceData
|
||||||
): FormState {
|
): FormState {
|
||||||
return {
|
return {
|
||||||
enquiryId: quotation?.enquiryId ?? '',
|
opportunityId: quotation?.opportunityId ?? '',
|
||||||
customerId: quotation?.customerId ?? referenceData.customers[0]?.id ?? '',
|
customerId: quotation?.customerId ?? referenceData.customers[0]?.id ?? '',
|
||||||
contactId: quotation?.contactId ?? '',
|
contactId: quotation?.contactId ?? '',
|
||||||
quotationDate:
|
quotationDate:
|
||||||
@@ -190,16 +190,16 @@ export function QuotationFormSheet({
|
|||||||
setState((current) => ({ ...current, [key]: value }));
|
setState((current) => ({ ...current, [key]: value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEnquiryChange(value: string) {
|
function handleOpportunityChange(value: string) {
|
||||||
const enquiry = referenceData.enquiries.find((item) => item.id === value);
|
const opportunity = referenceData.opportunities.find((item) => item.id === value);
|
||||||
setState((current) => ({
|
setState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
enquiryId: value === '__none__' ? '' : value,
|
opportunityId: value === '__none__' ? '' : value,
|
||||||
customerId: enquiry?.customerId ?? current.customerId,
|
customerId: opportunity?.customerId ?? current.customerId,
|
||||||
contactId: enquiry?.contactId ?? '',
|
contactId: opportunity?.contactId ?? '',
|
||||||
projectName: enquiry?.projectName ?? current.projectName,
|
projectName: opportunity?.projectName ?? current.projectName,
|
||||||
projectLocation: enquiry?.projectLocation ?? current.projectLocation,
|
projectLocation: opportunity?.projectLocation ?? current.projectLocation,
|
||||||
branchId: enquiry?.branchId ?? current.branchId
|
branchId: opportunity?.branchId ?? current.branchId
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +207,7 @@ export function QuotationFormSheet({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const payload: QuotationMutationPayload = {
|
const payload: QuotationMutationPayload = {
|
||||||
enquiryId: state.enquiryId || null,
|
opportunityId: state.opportunityId || null,
|
||||||
customerId: state.customerId,
|
customerId: state.customerId,
|
||||||
contactId: state.contactId || null,
|
contactId: state.contactId || null,
|
||||||
quotationDate: state.quotationDate,
|
quotationDate: state.quotationDate,
|
||||||
@@ -263,14 +263,14 @@ export function QuotationFormSheet({
|
|||||||
|
|
||||||
<form id='quotation-form-sheet' onSubmit={onSubmit} className='flex-1 overflow-auto'>
|
<form id='quotation-form-sheet' onSubmit={onSubmit} className='flex-1 overflow-auto'>
|
||||||
<div className='grid gap-4 py-4 md:grid-cols-2'>
|
<div className='grid gap-4 py-4 md:grid-cols-2'>
|
||||||
<Field label='Enquiry'>
|
<Field label='Opportunity'>
|
||||||
<Select value={state.enquiryId || '__none__'} onValueChange={handleEnquiryChange}>
|
<Select value={state.opportunityId || '__none__'} onValueChange={handleOpportunityChange}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder='Select enquiry' />
|
<SelectValue placeholder='Select opportunity' />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value='__none__'>No linked enquiry</SelectItem>
|
<SelectItem value='__none__'>No linked opportunity</SelectItem>
|
||||||
{referenceData.enquiries.map((item) => (
|
{referenceData.opportunities.map((item) => (
|
||||||
<SelectItem key={item.id} value={item.id}>
|
<SelectItem key={item.id} value={item.id}>
|
||||||
{item.code} - {item.title}
|
{item.code} - {item.title}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -541,3 +541,4 @@ export function QuotationFormSheetTrigger({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user