task-d5.5.1

This commit is contained in:
phaichayon
2026-06-25 12:16:41 +07:00
parent f2c7156851
commit c2a74b6764
121 changed files with 3407 additions and 2679 deletions

12
.editorconfig Normal file
View 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
View File

@@ -0,0 +1,8 @@
* text=auto eol=lf
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.webp binary
*.pdf binary

View File

@@ -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.

View File

@@ -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 |

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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

View File

@@ -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
View File

@@ -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.

View File

@@ -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
View 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
```

View 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.`);

View File

@@ -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 });
} }
} }

View File

@@ -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 });
} }
} }

View File

@@ -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()
}); });

View File

@@ -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 });
} }
} }

View File

@@ -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 }
); );
} }
} }

View File

@@ -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 });
} }
} }

View File

@@ -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 });
} }
} }

View File

@@ -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 });
} }
} }

View File

@@ -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 });
} }
} }

View File

@@ -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 });
} }
} }

View File

@@ -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 });
} }
} }

View File

@@ -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 });
} }
} }

View File

@@ -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 });
} }
} }

View File

@@ -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 });
} }
} }

View File

@@ -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 });
} }
} }

View File

@@ -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 });
} }
} }

View File

@@ -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 });
} }
} }

View File

@@ -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 }
); );
} }
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
);
}

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View 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>
);
}

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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",
},
},
],
},
],
},
];

View File

@@ -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(
) )
}) })
); );

View File

@@ -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;
}); });
} }

View File

@@ -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
}); });

View File

@@ -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)
}); });

View File

@@ -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) {
}); });
} }
} }

View File

@@ -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';
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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 = {
} }
] ]
}; };

View File

@@ -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;

View File

@@ -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
); );
} }

View File

@@ -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;
} }

View File

@@ -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>
); );
} }

View File

@@ -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;
} }

View File

@@ -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;
}; };
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>

View File

@@ -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>
); );
} }

View File

@@ -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(
} }
}; };
} }

View File

@@ -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);
}
}
});

View File

@@ -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)
});

View File

@@ -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)
});
}

View File

@@ -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';

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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
}; };
} }

View File

@@ -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);
} }

View File

@@ -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;
} }

View 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);
}
}
});

View 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)
});

View 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)
});
}

View File

@@ -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;
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
</> </>
); );
} }

View File

@@ -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);
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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({
</> </>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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({
</> </>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>;

View File

@@ -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 })
}); });
} }

View File

@@ -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;
} }

View File

@@ -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({
} }
]; ];
} }

View File

@@ -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