diff --git a/docs/implementation/task-d-enquiry-production.md b/docs/implementation/task-d-enquiry-production.md new file mode 100644 index 0000000..afa20ad --- /dev/null +++ b/docs/implementation/task-d-enquiry-production.md @@ -0,0 +1,118 @@ +# Task D: Enquiry Production Module + +## 1. Files Added + +- `src/app/api/crm/enquiries/route.ts` +- `src/app/api/crm/enquiries/[id]/route.ts` +- `src/app/api/crm/enquiries/[id]/followups/route.ts` +- `src/app/api/crm/enquiries/[id]/followups/[followupId]/route.ts` +- `src/features/crm/enquiries/api/types.ts` +- `src/features/crm/enquiries/api/service.ts` +- `src/features/crm/enquiries/api/queries.ts` +- `src/features/crm/enquiries/api/mutations.ts` +- `src/features/crm/enquiries/schemas/enquiry.schema.ts` +- `src/features/crm/enquiries/server/service.ts` +- `src/features/crm/enquiries/components/enquiry-status-badge.tsx` +- `src/features/crm/enquiries/components/enquiry-form-sheet.tsx` +- `src/features/crm/enquiries/components/followup-form-sheet.tsx` +- `src/features/crm/enquiries/components/enquiry-cell-action.tsx` +- `src/features/crm/enquiries/components/enquiry-columns.tsx` +- `src/features/crm/enquiries/components/enquiries-table.tsx` +- `src/features/crm/enquiries/components/enquiry-listing.tsx` +- `src/features/crm/enquiries/components/enquiry-followups-tab.tsx` +- `src/features/crm/enquiries/components/enquiry-detail.tsx` +- `drizzle/0003_blushing_bruce_banner.sql` +- `drizzle/meta/0003_snapshot.json` + +## 2. Files Modified + +- `src/app/dashboard/crm/enquiries/page.tsx` +- `src/app/dashboard/crm/enquiries/[id]/page.tsx` +- `src/app/dashboard/crm/customers/[id]/page.tsx` +- `src/features/crm/customers/api/types.ts` +- `src/features/crm/customers/components/customer-detail.tsx` +- `src/db/schema.ts` +- `src/db/seeds/foundation.seed.ts` +- `src/lib/auth/rbac.ts` +- `src/lib/searchparams.ts` +- `drizzle/meta/_journal.json` + +## 3. Schema Added + +- `crm_enquiries` + - organization-scoped sales opportunity master + - customer/contact linkage, project context, pricing expectation, probability, hot-project flag, and soft-delete columns + - unique `(organization_id, code)` for document-sequenced enquiry codes +- `crm_enquiry_followups` + - organization-scoped timeline items under enquiry + - follow-up type, contact linkage, outcome, next follow-up date, next action, and soft-delete columns + +## 4. API Routes Added + +- `GET /api/crm/enquiries` +- `POST /api/crm/enquiries` +- `GET /api/crm/enquiries/[id]` +- `PATCH /api/crm/enquiries/[id]` +- `DELETE /api/crm/enquiries/[id]` +- `GET /api/crm/enquiries/[id]/followups` +- `POST /api/crm/enquiries/[id]/followups` +- `PATCH /api/crm/enquiries/[id]/followups/[followupId]` +- `DELETE /api/crm/enquiries/[id]/followups/[followupId]` + +## 5. UI Routes Completed + +- `/dashboard/crm/enquiries` + - production list with React Query, nuqs filters, create button, edit/view/delete actions + - filters: search, status, product type, priority, branch, customer +- `/dashboard/crm/enquiries/[id]` + - detail page with tabs: Overview, Follow-ups, Activity, Related Quotations Placeholder +- `/dashboard/crm/customers/[id]` + - related documents tab now shows real linked enquiries for that customer + +## 6. Permissions Used + +- `crm.enquiry.read` +- `crm.enquiry.create` +- `crm.enquiry.update` +- `crm.enquiry.delete` +- `crm.enquiry.followup.read` +- `crm.enquiry.followup.create` +- `crm.enquiry.followup.update` +- `crm.enquiry.followup.delete` + +Admin defaults now include enquiry and follow-up permissions. Regular users inherit read permissions only unless additional permissions are granted. + +## 7. Audit Integration + +- enquiry create/update/delete writes to `tr_audit_logs` with `entityType = crm_enquiry` +- follow-up create/update/delete writes to `tr_audit_logs` with `entityType = crm_enquiry_followup` +- enquiry detail activity tab merges enquiry audit and follow-up audit entries, then resolves actor names from `users` + +## 8. Document Sequence Integration + +- enquiry create uses `generateNextDocumentCode({ documentType: 'enquiry' })` +- branch-specific sequence is honored when `branchId` is provided +- foundation seed now includes `crm_followup_type` options so follow-up forms do not hardcode those values + +## 9. Customer/Contact Integration + +- enquiry create/update validates that customer belongs to the active organization +- enquiry contact must belong to the same organization and selected customer +- follow-up contact must belong to the same organization and the enquiry's customer +- enquiry form customer dropdown is sourced from production customer data +- contact dropdown is filtered by selected customer +- customer detail route now shows related enquiries in the related-documents tab + +## 10. Remaining Risks + +- there are still no database foreign keys between enquiry tables and customer/contact tables; organization scoping is enforced in application logic +- form date fields currently use `YYYY-MM-DD` text inputs because the project field wrapper does not expose a native date mode +- customer detail shows linked enquiries, but there is not yet a dedicated customer-side enquiry table with pagination +- follow-up activity relies on audit payload contents for relation tracing, not a dedicated combined activity view + +## 11. Task E Readiness + +- enquiry production backbone is ready for quotation creation and linkage +- customer, contact, and enquiry lifecycles now share the same foundation patterns: org scope, branch validation, master options, document sequence, and audit logging +- enquiry detail already has a stable placeholder tab for quotation integration +- follow-up timeline and customer-related enquiry surfacing are in place for downstream sales workflow expansion diff --git a/drizzle/0003_blushing_bruce_banner.sql b/drizzle/0003_blushing_bruce_banner.sql new file mode 100644 index 0000000..9abc1cc --- /dev/null +++ b/drizzle/0003_blushing_bruce_banner.sql @@ -0,0 +1,50 @@ +CREATE TABLE "crm_enquiries" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "branch_id" text, + "code" text NOT NULL, + "customer_id" text NOT NULL, + "contact_id" text, + "title" text NOT NULL, + "description" text, + "requirement" text, + "project_name" text, + "project_location" text, + "product_type" text NOT NULL, + "status" text NOT NULL, + "priority" text NOT NULL, + "lead_channel" text, + "estimated_value" double precision, + "chance_percent" integer, + "expected_close_date" timestamp with time zone, + "competitor" text, + "source" text, + "notes" text, + "is_hot_project" boolean DEFAULT false NOT NULL, + "is_active" boolean DEFAULT true 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, + "created_by" text NOT NULL, + "updated_by" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE "crm_enquiry_followups" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "enquiry_id" text NOT NULL, + "followup_date" timestamp with time zone NOT NULL, + "followup_type" text NOT NULL, + "contact_id" text, + "outcome" text, + "notes" text, + "next_followup_date" timestamp with time zone, + "next_action" text, + "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, + "created_by" text NOT NULL, + "updated_by" text NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "crm_enquiries_org_code_idx" ON "crm_enquiries" USING btree ("organization_id","code"); \ No newline at end of file diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..b68640c --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,1290 @@ +{ + "id": "7f3502c2-ddb7-47cd-bc78-0510a67ceded", + "prevId": "9f3af9b8-d774-40bb-8b24-29b06332b453", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.crm_customer_contacts": { + "name": "crm_customer_contacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "department": { + "name": "department", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mobile": { + "name": "mobile", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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 + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_customers": { + "name": "crm_customers", + "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 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "abbr": { + "name": "abbr", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tax_id": { + "name": "tax_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_type": { + "name": "customer_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_status": { + "name": "customer_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "province": { + "name": "province", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sub_district": { + "name": "sub_district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "postal_code": { + "name": "postal_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fax": { + "name": "fax", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lead_channel": { + "name": "lead_channel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_group": { + "name": "customer_group", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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 + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_customers_org_code_idx": { + "name": "crm_customers_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_enquiries": { + "name": "crm_enquiries", + "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": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requirement": { + "name": "requirement", + "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": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lead_channel": { + "name": "lead_channel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "estimated_value": { + "name": "estimated_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "chance_percent": { + "name": "chance_percent", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "expected_close_date": { + "name": "expected_close_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "competitor": { + "name": "competitor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_hot_project": { + "name": "is_hot_project", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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 + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_enquiries_org_code_idx": { + "name": "crm_enquiries_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_enquiry_followups": { + "name": "crm_enquiry_followups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enquiry_id": { + "name": "enquiry_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "followup_date": { + "name": "followup_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "followup_type": { + "name": "followup_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_followup_date": { + "name": "next_followup_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action": { + "name": "next_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_sequences": { + "name": "document_sequences", + "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": true, + "default": "''" + }, + "document_type": { + "name": "document_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period": { + "name": "period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_number": { + "name": "current_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "padding_length": { + "name": "padding_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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()" + } + }, + "indexes": { + "document_sequences_org_doc_period_branch_idx": { + "name": "document_sequences_org_doc_period_branch_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "branch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "business_role": { + "name": "business_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'viewer'" + }, + "permissions": { + "name": "permissions", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "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()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ms_options": { + "name": "ms_options", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "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()" + } + }, + "indexes": { + "ms_options_org_category_code_idx": { + "name": "ms_options_org_category_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "category", + "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.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "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()" + } + }, + "indexes": { + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.products": { + "name": "products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "products_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "photo_url": { + "name": "photo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "double precision", + "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()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tr_audit_logs": { + "name": "tr_audit_logs", + "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 + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "before_data": { + "name": "before_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after_data": { + "name": "after_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "system_role": { + "name": "system_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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()" + } + }, + "indexes": { + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index cadfc07..88f0f6c 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1781498575433, "tag": "0002_plain_anthem", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1781505431077, + "tag": "0003_blushing_bruce_banner", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/api/crm/enquiries/[id]/followups/[followupId]/route.ts b/src/app/api/crm/enquiries/[id]/followups/[followupId]/route.ts new file mode 100644 index 0000000..ef6466e --- /dev/null +++ b/src/app/api/crm/enquiries/[id]/followups/[followupId]/route.ts @@ -0,0 +1,124 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service'; +import { + listEnquiryFollowups, + softDeleteEnquiryFollowup, + updateEnquiryFollowup +} from '@/features/crm/enquiries/server/service'; +import { enquiryFollowupSchema } from '@/features/crm/enquiries/schemas/enquiry.schema'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { + params: Promise<{ id: string; followupId: string }>; +}; + +const followupRequestSchema = enquiryFollowupSchema.extend({ + contactId: z.string().nullable().optional(), + nextFollowupDate: z.string().nullable().optional() +}); + +export async function PATCH(request: NextRequest, { params }: Params) { + try { + const { id, followupId } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmEnquiryFollowupUpdate + }); + const payload = followupRequestSchema.parse(await request.json()); + const before = (await listEnquiryFollowups(id, organization.id)).find( + (item) => item.id === followupId + ); + + if (!before) { + return NextResponse.json({ message: 'Follow-up not found' }, { status: 404 }); + } + + const updated = await updateEnquiryFollowup( + id, + followupId, + organization.id, + session.user.id, + payload + ); + + await auditUpdate({ + organizationId: organization.id, + userId: session.user.id, + entityType: 'crm_enquiry_followup', + entityId: followupId, + beforeData: before, + afterData: updated + }); + + return NextResponse.json({ + success: true, + message: 'Enquiry follow-up updated successfully', + followup: updated + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof z.ZodError) { + return NextResponse.json( + { message: error.issues[0]?.message ?? 'Invalid payload' }, + { status: 400 } + ); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to update enquiry follow-up' }, { status: 500 }); + } +} + +export async function DELETE(_request: NextRequest, { params }: Params) { + try { + const { id, followupId } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmEnquiryFollowupDelete + }); + const before = (await listEnquiryFollowups(id, organization.id)).find( + (item) => item.id === followupId + ); + + if (!before) { + return NextResponse.json({ message: 'Follow-up not found' }, { status: 404 }); + } + + const updated = await softDeleteEnquiryFollowup( + id, + followupId, + organization.id, + session.user.id + ); + + await auditDelete({ + organizationId: organization.id, + userId: session.user.id, + entityType: 'crm_enquiry_followup', + entityId: followupId, + beforeData: before, + afterData: updated + }); + + return NextResponse.json({ + success: true, + message: 'Enquiry follow-up deleted successfully' + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to delete enquiry follow-up' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/enquiries/[id]/followups/route.ts b/src/app/api/crm/enquiries/[id]/followups/route.ts new file mode 100644 index 0000000..0cd69b4 --- /dev/null +++ b/src/app/api/crm/enquiries/[id]/followups/route.ts @@ -0,0 +1,91 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { auditCreate } from '@/features/foundation/audit-log/service'; +import { + createEnquiryFollowup, + listEnquiryFollowups +} from '@/features/crm/enquiries/server/service'; +import { enquiryFollowupSchema } from '@/features/crm/enquiries/schemas/enquiry.schema'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { + params: Promise<{ id: string }>; +}; + +const followupRequestSchema = enquiryFollowupSchema.extend({ + contactId: z.string().nullable().optional(), + nextFollowupDate: z.string().nullable().optional() +}); + +export async function GET(_request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmEnquiryFollowupRead + }); + const items = await listEnquiryFollowups(id, organization.id); + + return NextResponse.json({ + success: true, + time: new Date().toISOString(), + message: 'Enquiry follow-ups loaded successfully', + items + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to load enquiry follow-ups' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmEnquiryFollowupCreate + }); + const payload = followupRequestSchema.parse(await request.json()); + const created = await createEnquiryFollowup(id, organization.id, session.user.id, payload); + + await auditCreate({ + organizationId: organization.id, + userId: session.user.id, + entityType: 'crm_enquiry_followup', + entityId: created.id, + afterData: created + }); + + return NextResponse.json( + { + success: true, + message: 'Enquiry follow-up created successfully', + followup: created + }, + { status: 201 } + ); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof z.ZodError) { + return NextResponse.json( + { message: error.issues[0]?.message ?? 'Invalid payload' }, + { status: 400 } + ); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to create enquiry follow-up' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/enquiries/[id]/route.ts b/src/app/api/crm/enquiries/[id]/route.ts new file mode 100644 index 0000000..510465e --- /dev/null +++ b/src/app/api/crm/enquiries/[id]/route.ts @@ -0,0 +1,137 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service'; +import { + getEnquiryActivity, + getEnquiryDetail, + softDeleteEnquiry, + updateEnquiry +} from '@/features/crm/enquiries/server/service'; +import { enquirySchema } from '@/features/crm/enquiries/schemas/enquiry.schema'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { + params: Promise<{ id: string }>; +}; + +const enquiryRequestSchema = enquirySchema.extend({ + contactId: z.string().nullable().optional(), + branchId: z.string().nullable().optional(), + leadChannel: z.string().nullable().optional(), + estimatedValue: z.number().nullable().optional(), + chancePercent: z.number().nullable().optional(), + expectedCloseDate: z.string().nullable().optional() +}); + +export async function GET(_request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmEnquiryRead + }); + const [enquiry, activity] = await Promise.all([ + getEnquiryDetail(id, organization.id), + getEnquiryActivity(id, organization.id) + ]); + + return NextResponse.json({ + success: true, + time: new Date().toISOString(), + message: 'Enquiry loaded successfully', + enquiry, + activity + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to load enquiry' }, { status: 500 }); + } +} + +export async function PATCH(request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmEnquiryUpdate + }); + const payload = enquiryRequestSchema.parse(await request.json()); + const before = await getEnquiryDetail(id, organization.id); + const updated = await updateEnquiry(id, organization.id, session.user.id, payload); + + await auditUpdate({ + organizationId: organization.id, + branchId: updated.branchId, + userId: session.user.id, + entityType: 'crm_enquiry', + entityId: id, + beforeData: before, + afterData: updated + }); + + return NextResponse.json({ + success: true, + message: 'Enquiry updated successfully', + enquiry: updated + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof z.ZodError) { + return NextResponse.json( + { message: error.issues[0]?.message ?? 'Invalid payload' }, + { status: 400 } + ); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to update enquiry' }, { status: 500 }); + } +} + +export async function DELETE(_request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmEnquiryDelete + }); + const before = await getEnquiryDetail(id, organization.id); + const updated = await softDeleteEnquiry(id, organization.id, session.user.id); + + await auditDelete({ + organizationId: organization.id, + branchId: updated.branchId, + userId: session.user.id, + entityType: 'crm_enquiry', + entityId: id, + beforeData: before, + afterData: updated + }); + + return NextResponse.json({ + success: true, + message: 'Enquiry deleted successfully' + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to delete enquiry' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/enquiries/route.ts b/src/app/api/crm/enquiries/route.ts new file mode 100644 index 0000000..08f5999 --- /dev/null +++ b/src/app/api/crm/enquiries/route.ts @@ -0,0 +1,111 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { auditCreate } from '@/features/foundation/audit-log/service'; +import { createEnquiry, listEnquiries } from '@/features/crm/enquiries/server/service'; +import { enquirySchema } from '@/features/crm/enquiries/schemas/enquiry.schema'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +const enquiryRequestSchema = enquirySchema.extend({ + contactId: z.string().nullable().optional(), + branchId: z.string().nullable().optional(), + leadChannel: z.string().nullable().optional(), + estimatedValue: z.number().nullable().optional(), + chancePercent: z.number().nullable().optional(), + expectedCloseDate: z.string().nullable().optional() +}); + +export async function GET(request: NextRequest) { + try { + const { organization } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmEnquiryRead + }); + const { searchParams } = request.nextUrl; + const page = Number(searchParams.get('page') ?? 1); + const limit = Number(searchParams.get('limit') ?? 10); + const search = searchParams.get('search') ?? undefined; + const status = searchParams.get('status') ?? undefined; + const productType = searchParams.get('productType') ?? undefined; + const priority = searchParams.get('priority') ?? undefined; + const branch = searchParams.get('branch') ?? undefined; + const customer = searchParams.get('customer') ?? undefined; + const sort = searchParams.get('sort') ?? undefined; + const result = await listEnquiries(organization.id, { + page, + limit, + search, + status, + productType, + priority, + branch, + customer, + sort + }); + const offset = (page - 1) * limit; + + return NextResponse.json({ + success: true, + time: new Date().toISOString(), + message: 'Enquiries loaded successfully', + totalItems: result.totalItems, + offset, + limit, + items: result.items + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to load enquiries' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmEnquiryCreate + }); + const payload = enquiryRequestSchema.parse(await request.json()); + const created = await createEnquiry(organization.id, session.user.id, payload); + + await auditCreate({ + organizationId: organization.id, + branchId: created.branchId, + userId: session.user.id, + entityType: 'crm_enquiry', + entityId: created.id, + afterData: created + }); + + return NextResponse.json( + { + success: true, + message: 'Enquiry created successfully', + enquiry: created + }, + { status: 201 } + ); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof z.ZodError) { + return NextResponse.json( + { message: error.issues[0]?.message ?? 'Invalid payload' }, + { status: 400 } + ); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to create enquiry' }, { status: 500 }); + } +} diff --git a/src/app/dashboard/crm/customers/[id]/page.tsx b/src/app/dashboard/crm/customers/[id]/page.tsx index 0c4cdef..9f9c996 100644 --- a/src/app/dashboard/crm/customers/[id]/page.tsx +++ b/src/app/dashboard/crm/customers/[id]/page.tsx @@ -4,6 +4,7 @@ import PageContainer from '@/components/layout/page-container'; import { CustomerDetail } from '@/features/crm/customers/components/customer-detail'; import { customerByIdOptions, customerContactsOptions } from '@/features/crm/customers/api/queries'; import { getCustomerReferenceData } from '@/features/crm/customers/server/service'; +import { listCustomerEnquiryRelations } from '@/features/crm/enquiries/server/service'; import { PERMISSIONS } from '@/lib/auth/rbac'; import { getQueryClient } from '@/lib/query-client'; @@ -50,6 +51,10 @@ export default async function CustomerDetailRoute({ params }: PageProps) { canRead && session?.user?.activeOrganizationId ? await getCustomerReferenceData(session.user.activeOrganizationId) : null; + const relatedEnquiries = + canRead && session?.user?.activeOrganizationId + ? await listCustomerEnquiryRelations(id, session.user.activeOrganizationId) + : []; return ( ) : null} diff --git a/src/app/dashboard/crm/enquiries/[id]/page.tsx b/src/app/dashboard/crm/enquiries/[id]/page.tsx index fa9e30d..afd6a05 100644 --- a/src/app/dashboard/crm/enquiries/[id]/page.tsx +++ b/src/app/dashboard/crm/enquiries/[id]/page.tsx @@ -1,5 +1,11 @@ +import { auth } from '@/auth'; +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; import PageContainer from '@/components/layout/page-container'; -import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder'; +import { enquiryByIdOptions, enquiryFollowupsOptions } from '@/features/crm/enquiries/api/queries'; +import { EnquiryDetail } from '@/features/crm/enquiries/components/enquiry-detail'; +import { getEnquiryReferenceData } from '@/features/crm/enquiries/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { getQueryClient } from '@/lib/query-client'; type PageProps = { params: Promise<{ id: string }>; @@ -7,24 +13,69 @@ type PageProps = { export default async function EnquiryDetailRoute({ params }: PageProps) { const { id } = await params; + 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 canUpdate = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmEnquiryUpdate))); + const canFollowupCreate = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmEnquiryFollowupCreate))); + const canFollowupUpdate = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmEnquiryFollowupUpdate))); + const canFollowupDelete = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmEnquiryFollowupDelete))); + const queryClient = getQueryClient(); + + if (canRead) { + void queryClient.prefetchQuery(enquiryByIdOptions(id)); + void queryClient.prefetchQuery(enquiryFollowupsOptions(id)); + } + + const referenceData = + canRead && session?.user?.activeOrganizationId + ? await getEnquiryReferenceData(session.user.activeOrganizationId) + : null; return ( + You do not have access to this enquiry. + + } > - + {referenceData ? ( + + + + ) : null} ); } diff --git a/src/app/dashboard/crm/enquiries/page.tsx b/src/app/dashboard/crm/enquiries/page.tsx index e9ff830..7dff8e4 100644 --- a/src/app/dashboard/crm/enquiries/page.tsx +++ b/src/app/dashboard/crm/enquiries/page.tsx @@ -1,29 +1,70 @@ +import { auth } from '@/auth'; import PageContainer from '@/components/layout/page-container'; -import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder'; +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 { searchParamsCache } from '@/lib/searchparams'; +import type { SearchParams } from 'nuqs/server'; export const metadata = { title: 'Dashboard: CRM Enquiries' }; -export default function EnquiriesRoute() { +type PageProps = { + searchParams: Promise; +}; + +export default async function EnquiriesRoute(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.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))); + + searchParamsCache.parse(searchParams); + + const referenceData = + canRead && session?.user?.activeOrganizationId + ? await getEnquiryReferenceData(session.user.activeOrganizationId) + : null; + return ( + You do not have access to CRM enquiries. + + } + pageHeaderAction={ + canCreate && referenceData ? ( + + ) : null + } > - + {referenceData ? ( + + ) : null} ); } diff --git a/src/db/schema.ts b/src/db/schema.ts index 70fe911..54d1102 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -188,3 +188,61 @@ export const crmCustomerContacts = pgTable('crm_customer_contacts', { createdBy: text('created_by').notNull(), updatedBy: text('updated_by').notNull() }); + +export const crmEnquiries = pgTable( + 'crm_enquiries', + { + id: text('id').primaryKey(), + organizationId: text('organization_id').notNull(), + branchId: text('branch_id'), + code: text('code').notNull(), + customerId: text('customer_id').notNull(), + contactId: text('contact_id'), + title: text('title').notNull(), + description: text('description'), + requirement: text('requirement'), + projectName: text('project_name'), + projectLocation: text('project_location'), + productType: text('product_type').notNull(), + status: text('status').notNull(), + priority: text('priority').notNull(), + leadChannel: text('lead_channel'), + estimatedValue: doublePrecision('estimated_value'), + chancePercent: integer('chance_percent'), + expectedCloseDate: timestamp('expected_close_date', { withTimezone: true }), + competitor: text('competitor'), + source: text('source'), + notes: text('notes'), + isHotProject: boolean('is_hot_project').default(false).notNull(), + isActive: boolean('is_active').default(true).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + deletedAt: timestamp('deleted_at', { withTimezone: true }), + createdBy: text('created_by').notNull(), + updatedBy: text('updated_by').notNull() + }, + (table) => ({ + organizationCodeIdx: uniqueIndex('crm_enquiries_org_code_idx').on( + table.organizationId, + table.code + ) + }) +); + +export const crmEnquiryFollowups = pgTable('crm_enquiry_followups', { + id: text('id').primaryKey(), + organizationId: text('organization_id').notNull(), + enquiryId: text('enquiry_id').notNull(), + followupDate: timestamp('followup_date', { withTimezone: true }).notNull(), + followupType: text('followup_type').notNull(), + contactId: text('contact_id'), + outcome: text('outcome'), + notes: text('notes'), + nextFollowupDate: timestamp('next_followup_date', { withTimezone: true }), + nextAction: text('next_action'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + deletedAt: timestamp('deleted_at', { withTimezone: true }), + createdBy: text('created_by').notNull(), + updatedBy: text('updated_by').notNull() +}); diff --git a/src/db/seeds/foundation.seed.ts b/src/db/seeds/foundation.seed.ts index 0a6fc83..cae1921 100644 --- a/src/db/seeds/foundation.seed.ts +++ b/src/db/seeds/foundation.seed.ts @@ -142,6 +142,13 @@ const FOUNDATION_OPTIONS = { { code: 'high', label: 'High', value: 'high', sortOrder: 3 }, { code: 'urgent', label: 'Urgent', value: 'urgent', sortOrder: 4 } ], + crm_followup_type: [ + { code: 'call', label: 'Call', value: 'call', sortOrder: 1 }, + { code: 'meeting', label: 'Meeting', value: 'meeting', sortOrder: 2 }, + { code: 'site_visit', label: 'Site Visit', value: 'site_visit', sortOrder: 3 }, + { code: 'email', label: 'Email', value: 'email', sortOrder: 4 }, + { code: 'line', label: 'LINE', value: 'line', sortOrder: 5 } + ], crm_lead_channel: [ { code: 'website', label: 'Website', value: 'website', sortOrder: 1 }, { code: 'referral', label: 'Referral', value: 'referral', sortOrder: 2 }, diff --git a/src/features/crm/customers/api/types.ts b/src/features/crm/customers/api/types.ts index b21c6b3..c3819e5 100644 --- a/src/features/crm/customers/api/types.ts +++ b/src/features/crm/customers/api/types.ts @@ -77,6 +77,15 @@ export interface CustomerActivityRecord { actorName: string | null; } +export interface CustomerRelatedEnquiryItem { + id: string; + code: string; + title: string; + status: string; + productType: string; + updatedAt: string; +} + export interface CustomerReferenceData { branches: BranchOption[]; customerTypes: CustomerOption[]; diff --git a/src/features/crm/customers/components/customer-detail.tsx b/src/features/crm/customers/components/customer-detail.tsx index f0ae6d2..531c957 100644 --- a/src/features/crm/customers/components/customer-detail.tsx +++ b/src/features/crm/customers/components/customer-detail.tsx @@ -14,6 +14,7 @@ import type { CustomerReferenceData } from '../api/types'; import { CustomerFormSheet } from './customer-form-sheet'; import { CustomerContactsTab } from './customer-contacts-tab'; import { CustomerStatusBadge } from './customer-status-badge'; +import type { CustomerRelatedEnquiryItem } from '../api/types'; function FieldItem({ label, value }: { label: string; value: string | null | undefined }) { return ( @@ -28,7 +29,8 @@ export function CustomerDetail({ customerId, referenceData, canUpdate, - canManageContacts + canManageContacts, + relatedEnquiries }: { customerId: string; referenceData: CustomerReferenceData; @@ -38,6 +40,7 @@ export function CustomerDetail({ update: boolean; delete: boolean; }; + relatedEnquiries: CustomerRelatedEnquiryItem[]; }) { const { data } = useSuspenseQuery(customerByIdOptions(customerId)); const [editOpen, setEditOpen] = useState(false); @@ -212,14 +215,36 @@ export function CustomerDetail({ Related Documents - Task C stops at customer and contact scope, so downstream CRM documents stay - intentionally deferred. + Production enquiry links are now visible here, while quotation and approval + modules stay deferred. -
- Enquiry, quotation, and approval links will land here in Task D and later. -
+ {!relatedEnquiries.length ? ( +
+ No enquiries have been linked to this customer yet. +
+ ) : ( +
+ {relatedEnquiries.map((enquiry) => ( + +
+
+
{enquiry.title}
+
+ {enquiry.code} +
+
+ +
+ + ))} +
+ )}
diff --git a/src/features/crm/enquiries/api/mutations.ts b/src/features/crm/enquiries/api/mutations.ts new file mode 100644 index 0000000..5f64a5b --- /dev/null +++ b/src/features/crm/enquiries/api/mutations.ts @@ -0,0 +1,74 @@ +import { mutationOptions } from '@tanstack/react-query'; +import { getQueryClient } from '@/lib/query-client'; +import { + createEnquiry, + createEnquiryFollowup, + deleteEnquiry, + deleteEnquiryFollowup, + updateEnquiry, + updateEnquiryFollowup +} from './service'; +import { enquiryKeys } from './queries'; +import type { EnquiryFollowupMutationPayload, EnquiryMutationPayload } from './types'; + +export const createEnquiryMutation = mutationOptions({ + mutationFn: (data: EnquiryMutationPayload) => createEnquiry(data), + onSuccess: () => { + getQueryClient().invalidateQueries({ queryKey: enquiryKeys.all }); + } +}); + +export const updateEnquiryMutation = mutationOptions({ + mutationFn: ({ id, values }: { id: string; values: EnquiryMutationPayload }) => + updateEnquiry(id, values), + onSuccess: (_data, variables) => { + getQueryClient().invalidateQueries({ queryKey: enquiryKeys.all }); + getQueryClient().invalidateQueries({ queryKey: enquiryKeys.detail(variables.id) }); + } +}); + +export const deleteEnquiryMutation = mutationOptions({ + mutationFn: (id: string) => deleteEnquiry(id), + onSuccess: () => { + getQueryClient().invalidateQueries({ queryKey: enquiryKeys.all }); + } +}); + +export const createEnquiryFollowupMutation = mutationOptions({ + mutationFn: ({ + enquiryId, + values + }: { + enquiryId: string; + values: EnquiryFollowupMutationPayload; + }) => createEnquiryFollowup(enquiryId, values), + onSuccess: (_data, variables) => { + getQueryClient().invalidateQueries({ queryKey: enquiryKeys.followups(variables.enquiryId) }); + getQueryClient().invalidateQueries({ queryKey: enquiryKeys.detail(variables.enquiryId) }); + } +}); + +export const updateEnquiryFollowupMutation = mutationOptions({ + mutationFn: ({ + enquiryId, + followupId, + values + }: { + enquiryId: string; + followupId: string; + values: EnquiryFollowupMutationPayload; + }) => updateEnquiryFollowup(enquiryId, followupId, values), + onSuccess: (_data, variables) => { + getQueryClient().invalidateQueries({ queryKey: enquiryKeys.followups(variables.enquiryId) }); + getQueryClient().invalidateQueries({ queryKey: enquiryKeys.detail(variables.enquiryId) }); + } +}); + +export const deleteEnquiryFollowupMutation = mutationOptions({ + mutationFn: ({ enquiryId, followupId }: { enquiryId: string; followupId: string }) => + deleteEnquiryFollowup(enquiryId, followupId), + onSuccess: (_data, variables) => { + getQueryClient().invalidateQueries({ queryKey: enquiryKeys.followups(variables.enquiryId) }); + getQueryClient().invalidateQueries({ queryKey: enquiryKeys.detail(variables.enquiryId) }); + } +}); diff --git a/src/features/crm/enquiries/api/queries.ts b/src/features/crm/enquiries/api/queries.ts new file mode 100644 index 0000000..47c9e5d --- /dev/null +++ b/src/features/crm/enquiries/api/queries.ts @@ -0,0 +1,28 @@ +import { queryOptions } from '@tanstack/react-query'; +import { getEnquiries, getEnquiryById, getEnquiryFollowups } from './service'; +import type { EnquiryFilters } from './types'; + +export const enquiryKeys = { + all: ['crm-enquiries'] as const, + list: (filters: EnquiryFilters) => [...enquiryKeys.all, 'list', filters] as const, + detail: (id: string) => [...enquiryKeys.all, 'detail', id] as const, + followups: (id: string) => [...enquiryKeys.all, 'followups', 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 enquiryFollowupsOptions = (id: string) => + queryOptions({ + queryKey: enquiryKeys.followups(id), + queryFn: () => getEnquiryFollowups(id) + }); diff --git a/src/features/crm/enquiries/api/service.ts b/src/features/crm/enquiries/api/service.ts new file mode 100644 index 0000000..4e5c488 --- /dev/null +++ b/src/features/crm/enquiries/api/service.ts @@ -0,0 +1,83 @@ +import { apiClient } from '@/lib/api-client'; +import type { + EnquiryDetailResponse, + EnquiryFilters, + EnquiryFollowupMutationPayload, + EnquiryFollowupsResponse, + EnquiryListResponse, + EnquiryMutationPayload, + MutationSuccessResponse +} from './types'; + +export async function getEnquiries(filters: EnquiryFilters): Promise { + const searchParams = new URLSearchParams(); + + if (filters.page) searchParams.set('page', String(filters.page)); + if (filters.limit) searchParams.set('limit', String(filters.limit)); + if (filters.search) searchParams.set('search', filters.search); + if (filters.status) searchParams.set('status', filters.status); + if (filters.productType) searchParams.set('productType', filters.productType); + if (filters.priority) searchParams.set('priority', filters.priority); + if (filters.branch) searchParams.set('branch', filters.branch); + if (filters.customer) searchParams.set('customer', filters.customer); + if (filters.sort) searchParams.set('sort', filters.sort); + + const query = searchParams.toString(); + + return apiClient(`/crm/enquiries${query ? `?${query}` : ''}`); +} + +export async function getEnquiryById(id: string): Promise { + return apiClient(`/crm/enquiries/${id}`); +} + +export async function createEnquiry(data: EnquiryMutationPayload) { + return apiClient('/crm/enquiries', { + method: 'POST', + body: JSON.stringify(data) + }); +} + +export async function updateEnquiry(id: string, data: EnquiryMutationPayload) { + return apiClient(`/crm/enquiries/${id}`, { + method: 'PATCH', + body: JSON.stringify(data) + }); +} + +export async function deleteEnquiry(id: string) { + return apiClient(`/crm/enquiries/${id}`, { + method: 'DELETE' + }); +} + +export async function getEnquiryFollowups(id: string): Promise { + return apiClient(`/crm/enquiries/${id}/followups`); +} + +export async function createEnquiryFollowup( + enquiryId: string, + data: EnquiryFollowupMutationPayload +) { + return apiClient(`/crm/enquiries/${enquiryId}/followups`, { + method: 'POST', + body: JSON.stringify(data) + }); +} + +export async function updateEnquiryFollowup( + enquiryId: string, + followupId: string, + data: EnquiryFollowupMutationPayload +) { + return apiClient(`/crm/enquiries/${enquiryId}/followups/${followupId}`, { + method: 'PATCH', + body: JSON.stringify(data) + }); +} + +export async function deleteEnquiryFollowup(enquiryId: string, followupId: string) { + return apiClient(`/crm/enquiries/${enquiryId}/followups/${followupId}`, { + method: 'DELETE' + }); +} diff --git a/src/features/crm/enquiries/api/types.ts b/src/features/crm/enquiries/api/types.ts new file mode 100644 index 0000000..2a54c84 --- /dev/null +++ b/src/features/crm/enquiries/api/types.ts @@ -0,0 +1,189 @@ +export interface EnquiryOption { + id: string; + code: string; + label: string; + value: string | null; +} + +export interface EnquiryBranchOption { + id: string; + code: string; + name: string; + value: string | null; +} + +export interface EnquiryCustomerLookup { + id: string; + code: string; + name: string; + branchId: string | null; +} + +export interface EnquiryContactLookup { + id: string; + customerId: string; + name: string; + email: string | null; + mobile: string | null; + isPrimary: boolean; +} + +export interface EnquiryRecord { + id: string; + organizationId: string; + branchId: string | null; + code: string; + customerId: string; + contactId: string | null; + title: string; + description: string | null; + requirement: string | null; + projectName: string | null; + projectLocation: string | null; + productType: string; + status: string; + priority: string; + leadChannel: string | null; + estimatedValue: number | null; + chancePercent: number | null; + expectedCloseDate: string | null; + competitor: string | null; + source: string | null; + notes: string | null; + isHotProject: boolean; + isActive: boolean; + createdAt: string; + updatedAt: string; + deletedAt: string | null; + createdBy: string; + updatedBy: string; +} + +export interface EnquiryListItem extends EnquiryRecord { + customerName: string; + contactName: string | null; + followupCount: number; +} + +export interface EnquiryFollowupRecord { + id: string; + organizationId: string; + enquiryId: string; + followupDate: string; + followupType: string; + contactId: string | null; + outcome: string | null; + notes: string | null; + nextFollowupDate: string | null; + nextAction: string | null; + createdAt: string; + updatedAt: string; + deletedAt: string | null; + createdBy: string; + updatedBy: string; +} + +export interface EnquiryActivityRecord { + id: string; + action: string; + entityType: string; + entityId: string; + createdAt: string; + userId: string; + actorName: string | null; +} + +export interface EnquiryReferenceData { + branches: EnquiryBranchOption[]; + statuses: EnquiryOption[]; + productTypes: EnquiryOption[]; + priorities: EnquiryOption[]; + leadChannels: EnquiryOption[]; + followupTypes: EnquiryOption[]; + customers: EnquiryCustomerLookup[]; + contacts: EnquiryContactLookup[]; +} + +export interface EnquiryFilters { + page?: number; + limit?: number; + search?: string; + status?: string; + productType?: string; + priority?: string; + branch?: string; + customer?: string; + sort?: string; +} + +export interface EnquiryListResponse { + success: boolean; + time: string; + message: string; + totalItems: number; + offset: number; + limit: number; + items: EnquiryListItem[]; +} + +export interface EnquiryDetailResponse { + success: boolean; + time: string; + message: string; + enquiry: EnquiryRecord; + activity: EnquiryActivityRecord[]; +} + +export interface EnquiryFollowupsResponse { + success: boolean; + time: string; + message: string; + items: EnquiryFollowupRecord[]; +} + +export interface EnquiryMutationPayload { + customerId: string; + contactId?: string | null; + title: string; + description?: string; + requirement?: string; + projectName?: string; + projectLocation?: string; + branchId?: string | null; + productType: string; + status: string; + priority: string; + leadChannel?: string | null; + estimatedValue?: number | null; + chancePercent?: number | null; + expectedCloseDate?: string | null; + competitor?: string; + source?: string; + isHotProject?: boolean; + notes?: string; + isActive?: boolean; +} + +export interface EnquiryFollowupMutationPayload { + followupDate: string; + followupType: string; + contactId?: string | null; + outcome?: string; + notes?: string; + nextFollowupDate?: string | null; + nextAction?: string; +} + +export interface EnquiryCustomerRelationItem { + id: string; + code: string; + title: string; + status: string; + productType: string; + updatedAt: string; +} + +export interface MutationSuccessResponse { + success: boolean; + message: string; +} diff --git a/src/features/crm/enquiries/components/enquiries-table.tsx b/src/features/crm/enquiries/components/enquiries-table.tsx new file mode 100644 index 0000000..4bf7a45 --- /dev/null +++ b/src/features/crm/enquiries/components/enquiries-table.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { useMemo } from 'react'; +import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs'; +import { useSuspenseQuery } from '@tanstack/react-query'; +import { DataTable } from '@/components/ui/table/data-table'; +import { DataTableToolbar } from '@/components/ui/table/data-table-toolbar'; +import { useDataTable } from '@/hooks/use-data-table'; +import { getSortingStateParser } from '@/lib/parsers'; +import { enquiriesQueryOptions } from '../api/queries'; +import type { EnquiryReferenceData } from '../api/types'; +import { getEnquiryColumns } from './enquiry-columns'; + +export function EnquiriesTable({ + referenceData, + canUpdate, + canDelete +}: { + referenceData: EnquiryReferenceData; + canUpdate: boolean; + canDelete: boolean; +}) { + const columns = useMemo( + () => getEnquiryColumns({ referenceData, canUpdate, canDelete }), + [referenceData, canUpdate, canDelete] + ); + const columnIds = columns.map((column) => column.id).filter(Boolean) as string[]; + const [params] = useQueryStates({ + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + name: parseAsString, + status: parseAsString, + productType: parseAsString, + priority: parseAsString, + branch: parseAsString, + customer: parseAsString, + sort: getSortingStateParser(columnIds).withDefault([]) + }); + + const filters = { + page: params.page, + limit: params.perPage, + ...(params.name && { search: params.name }), + ...(params.status && { status: params.status }), + ...(params.productType && { productType: params.productType }), + ...(params.priority && { priority: params.priority }), + ...(params.branch && { branch: params.branch }), + ...(params.customer && { customer: params.customer }), + ...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) }) + }; + + const { data } = useSuspenseQuery(enquiriesQueryOptions(filters)); + const pageCount = Math.ceil(data.totalItems / params.perPage); + const { table } = useDataTable({ + data: data.items, + columns, + pageCount, + shallow: true, + debounceMs: 500, + initialState: { + columnPinning: { right: ['actions'] } + } + }); + + return ( + + + + ); +} diff --git a/src/features/crm/enquiries/components/enquiry-cell-action.tsx b/src/features/crm/enquiries/components/enquiry-cell-action.tsx new file mode 100644 index 0000000..4c4c17a --- /dev/null +++ b/src/features/crm/enquiries/components/enquiry-cell-action.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { AlertModal } from '@/components/modal/alert-modal'; +import { Icons } from '@/components/icons'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu'; +import { deleteEnquiryMutation } from '../api/mutations'; +import type { EnquiryListItem, EnquiryReferenceData } from '../api/types'; +import { EnquiryFormSheet } from './enquiry-form-sheet'; + +export function EnquiryCellAction({ + data, + referenceData, + canUpdate, + canDelete +}: { + data: EnquiryListItem; + referenceData: EnquiryReferenceData; + canUpdate: boolean; + canDelete: boolean; +}) { + const router = useRouter(); + const [deleteOpen, setDeleteOpen] = useState(false); + const [editOpen, setEditOpen] = useState(false); + + const deleteMutation = useMutation({ + ...deleteEnquiryMutation, + onSuccess: () => { + toast.success('Enquiry deleted successfully'); + setDeleteOpen(false); + }, + onError: (error) => + toast.error(error instanceof Error ? error.message : 'Failed to delete enquiry') + }); + + return ( + <> + setDeleteOpen(false)} + onConfirm={() => deleteMutation.mutate(data.id)} + loading={deleteMutation.isPending} + /> + + + + + + + Actions + router.push(`/dashboard/crm/enquiries/${data.id}`)}> + View + + setEditOpen(true)} disabled={!canUpdate}> + Edit + + setDeleteOpen(true)} disabled={!canDelete}> + Delete + + + + + ); +} diff --git a/src/features/crm/enquiries/components/enquiry-columns.tsx b/src/features/crm/enquiries/components/enquiry-columns.tsx new file mode 100644 index 0000000..56caf02 --- /dev/null +++ b/src/features/crm/enquiries/components/enquiry-columns.tsx @@ -0,0 +1,176 @@ +'use client'; + +import Link from 'next/link'; +import type { Column, ColumnDef } from '@tanstack/react-table'; +import { Badge } from '@/components/ui/badge'; +import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header'; +import { Icons } from '@/components/icons'; +import type { EnquiryListItem, EnquiryReferenceData } from '../api/types'; +import { EnquiryCellAction } from './enquiry-cell-action'; +import { EnquiryStatusBadge } from './enquiry-status-badge'; + +export function getEnquiryColumns({ + referenceData, + canUpdate, + canDelete +}: { + referenceData: EnquiryReferenceData; + canUpdate: boolean; + canDelete: boolean; +}): ColumnDef[] { + const statusMap = new Map(referenceData.statuses.map((item) => [item.id, item])); + const productTypeMap = new Map(referenceData.productTypes.map((item) => [item.id, item])); + const priorityMap = new Map(referenceData.priorities.map((item) => [item.id, item])); + const branchMap = new Map(referenceData.branches.map((item) => [item.id, item])); + + return [ + { + id: 'title', + accessorKey: 'title', + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => ( +
+ + {row.original.title} + + + {row.original.code} • {row.original.customerName} + +
+ ), + meta: { + label: 'Enquiry', + placeholder: 'Search enquiries...', + variant: 'text' as const, + icon: Icons.search + }, + enableColumnFilter: true + }, + { + id: 'status', + accessorKey: 'status', + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => { + const status = statusMap.get(row.original.status); + return ; + }, + meta: { + label: 'Status', + variant: 'multiSelect' as const, + options: referenceData.statuses.map((item) => ({ + value: item.id, + label: item.label + })) + }, + enableColumnFilter: true + }, + { + id: 'productType', + accessorKey: 'productType', + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => ( + + {productTypeMap.get(row.original.productType)?.label ?? 'Unknown'} + + ), + meta: { + label: 'Product Type', + variant: 'multiSelect' as const, + options: referenceData.productTypes.map((item) => ({ + value: item.id, + label: item.label + })) + }, + enableColumnFilter: true + }, + { + id: 'priority', + accessorKey: 'priority', + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => ( + + {priorityMap.get(row.original.priority)?.label ?? 'Unknown'} + + ), + meta: { + label: 'Priority', + variant: 'multiSelect' as const, + options: referenceData.priorities.map((item) => ({ + value: item.id, + label: item.label + })) + }, + enableColumnFilter: true + }, + { + id: 'branch', + accessorFn: (row) => row.branchId ?? '', + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.branchId + ? (branchMap.get(row.original.branchId)?.name ?? 'Unknown') + : 'Unassigned'} + + ), + meta: { + label: 'Branch', + variant: 'multiSelect' as const, + options: referenceData.branches.map((item) => ({ + value: item.id, + label: item.name + })) + }, + enableColumnFilter: true + }, + { + id: 'customer', + accessorFn: (row) => row.customerId, + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => {row.original.customerName}, + meta: { + label: 'Customer', + variant: 'multiSelect' as const, + options: referenceData.customers.map((item) => ({ + value: item.id, + label: item.name + })) + }, + enableColumnFilter: true + }, + { + id: 'followupCount', + accessorKey: 'followupCount', + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => {row.original.followupCount} + }, + { + id: 'actions', + cell: ({ row }) => ( + + ) + } + ]; +} diff --git a/src/features/crm/enquiries/components/enquiry-detail.tsx b/src/features/crm/enquiries/components/enquiry-detail.tsx new file mode 100644 index 0000000..da3a500 --- /dev/null +++ b/src/features/crm/enquiries/components/enquiry-detail.tsx @@ -0,0 +1,288 @@ +'use client'; + +import Link from 'next/link'; +import { useMemo, useState } from 'react'; +import { useSuspenseQuery } from '@tanstack/react-query'; +import { Icons } from '@/components/icons'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { enquiryByIdOptions } from '../api/queries'; +import type { EnquiryReferenceData } from '../api/types'; +import { EnquiryFormSheet } from './enquiry-form-sheet'; +import { EnquiryFollowupsTab } from './enquiry-followups-tab'; +import { EnquiryStatusBadge } from './enquiry-status-badge'; + +function FieldItem({ label, value }: { label: string; value: string | null | undefined }) { + return ( +
+
{label}
+
{value || '-'}
+
+ ); +} + +export function EnquiryDetail({ + enquiryId, + referenceData, + canUpdate, + canManageFollowups +}: { + enquiryId: string; + referenceData: EnquiryReferenceData; + canUpdate: boolean; + canManageFollowups: { + create: boolean; + update: boolean; + delete: boolean; + }; +}) { + const { data } = useSuspenseQuery(enquiryByIdOptions(enquiryId)); + const [editOpen, setEditOpen] = useState(false); + const enquiry = data.enquiry; + const branchMap = useMemo( + () => new Map(referenceData.branches.map((item) => [item.id, item.name])), + [referenceData] + ); + const customerMap = useMemo( + () => new Map(referenceData.customers.map((item) => [item.id, item])), + [referenceData] + ); + const contactMap = useMemo( + () => new Map(referenceData.contacts.map((item) => [item.id, item])), + [referenceData] + ); + const statusMap = useMemo( + () => new Map(referenceData.statuses.map((item) => [item.id, item])), + [referenceData] + ); + const productTypeMap = useMemo( + () => new Map(referenceData.productTypes.map((item) => [item.id, item])), + [referenceData] + ); + const priorityMap = useMemo( + () => new Map(referenceData.priorities.map((item) => [item.id, item])), + [referenceData] + ); + const leadChannelMap = useMemo( + () => new Map(referenceData.leadChannels.map((item) => [item.id, item])), + [referenceData] + ); + const customer = customerMap.get(enquiry.customerId); + const contact = enquiry.contactId ? contactMap.get(enquiry.contactId) : null; + const status = statusMap.get(enquiry.status); + + return ( +
+
+
+
+ + {enquiry.code} + + {enquiry.isHotProject ? Hot : null} +
+
+

{enquiry.title}

+

+ {customer?.name ?? 'Unknown customer'} + {contact ? ` • ${contact.name}` : ''} +

+
+
+
+ {canUpdate ? ( + + ) : null} +
+
+ +
+
+ + + + + Overview + Follow-ups + Activity + Related Quotations + + + + + Overview + + Opportunity scope, project context, and commercial direction. + + + + + + + + + + + + + + + +
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ + + + + + + Activity + + Audit events for the enquiry and its follow-up records. + + + + {!data.activity.length ? ( +
+ No activity recorded yet. +
+ ) : ( + data.activity.map((item, index) => ( +
+
+
+
+
+ + {item.action} + + + {item.entityType.replaceAll('_', ' ')} + + + {item.actorName ?? item.userId} + +
+
+ {new Date(item.createdAt).toLocaleString()} +
+
+
+ {index < data.activity.length - 1 ? : null} +
+ )) + )} + + + + + + + Related Quotations + + Task D ends at enquiry readiness, so quotation production stays + intentionally deferred. + + + +
+ Quotation linkage will land here in Task E. +
+
+
+
+ + + +
+ +
+ + + Record Snapshot + Operational metadata for this enquiry row. + + + + + + + + +
+
+ + +
+ ); +} diff --git a/src/features/crm/enquiries/components/enquiry-followups-tab.tsx b/src/features/crm/enquiries/components/enquiry-followups-tab.tsx new file mode 100644 index 0000000..e225cf0 --- /dev/null +++ b/src/features/crm/enquiries/components/enquiry-followups-tab.tsx @@ -0,0 +1,150 @@ +'use client'; + +import { useState } from 'react'; +import { useMutation, useSuspenseQuery } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { AlertModal } from '@/components/modal/alert-modal'; +import { Icons } from '@/components/icons'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { deleteEnquiryFollowupMutation } from '../api/mutations'; +import { enquiryFollowupsOptions } from '../api/queries'; +import type { EnquiryReferenceData } from '../api/types'; +import { FollowupFormSheet } from './followup-form-sheet'; + +export function EnquiryFollowupsTab({ + enquiryId, + customerId, + referenceData, + canCreate, + canUpdate, + canDelete +}: { + enquiryId: string; + customerId: string; + referenceData: EnquiryReferenceData; + canCreate: boolean; + canUpdate: boolean; + canDelete: boolean; +}) { + const { data } = useSuspenseQuery(enquiryFollowupsOptions(enquiryId)); + const [selectedId, setSelectedId] = useState(null); + const [open, setOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const selected = data.items.find((item) => item.id === selectedId); + const followupTypeMap = new Map(referenceData.followupTypes.map((item) => [item.id, item.label])); + + const deleteMutation = useMutation({ + ...deleteEnquiryFollowupMutation, + onSuccess: () => { + toast.success('Follow-up deleted successfully'); + setDeleteOpen(false); + setSelectedId(null); + }, + onError: (error) => + toast.error(error instanceof Error ? error.message : 'Failed to delete follow-up') + }); + + return ( + + +
+ Follow-ups + + Track the latest contact moments, outcomes, and next steps for this enquiry. + +
+ {canCreate ? ( + + ) : null} +
+ + {!data.items.length ? ( +
+ No follow-ups recorded yet. +
+ ) : ( + data.items.map((item) => ( +
+
+
+ + {followupTypeMap.get(item.followupType) ?? 'Unknown type'} + + + {new Date(item.followupDate).toLocaleDateString()} + +
+ {item.outcome ?

{item.outcome}

: null} + {item.nextAction ? ( +

Next action: {item.nextAction}

+ ) : null} + {item.nextFollowupDate ? ( +

+ Next follow-up: {new Date(item.nextFollowupDate).toLocaleDateString()} +

+ ) : null} + {item.notes ?

{item.notes}

: null} +
+
+ + +
+
+ )) + )} +
+ + + setDeleteOpen(false)} + onConfirm={() => { + if (selectedId) { + deleteMutation.mutate({ enquiryId, followupId: selectedId }); + } + }} + loading={deleteMutation.isPending} + /> +
+ ); +} diff --git a/src/features/crm/enquiries/components/enquiry-form-sheet.tsx b/src/features/crm/enquiries/components/enquiry-form-sheet.tsx new file mode 100644 index 0000000..8c776e8 --- /dev/null +++ b/src/features/crm/enquiries/components/enquiry-form-sheet.tsx @@ -0,0 +1,377 @@ +'use client'; + +import * as React from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { Icons } from '@/components/icons'; +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select'; +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle +} from '@/components/ui/sheet'; +import { useAppForm, useFormFields } from '@/components/ui/tanstack-form'; +import { createEnquiryMutation, updateEnquiryMutation } from '../api/mutations'; +import type { EnquiryMutationPayload, EnquiryRecord, EnquiryReferenceData } from '../api/types'; +import { enquirySchema, type EnquiryFormValues } from '../schemas/enquiry.schema'; + +function toDefaultValues( + enquiry: EnquiryRecord | undefined, + referenceData: EnquiryReferenceData +): EnquiryFormValues { + return { + customerId: enquiry?.customerId ?? referenceData.customers[0]?.id ?? '', + contactId: enquiry?.contactId ?? undefined, + title: enquiry?.title ?? '', + description: enquiry?.description ?? '', + requirement: enquiry?.requirement ?? '', + projectName: enquiry?.projectName ?? '', + projectLocation: enquiry?.projectLocation ?? '', + branchId: enquiry?.branchId ?? undefined, + productType: enquiry?.productType ?? referenceData.productTypes[0]?.id ?? '', + status: enquiry?.status ?? referenceData.statuses[0]?.id ?? '', + priority: + enquiry?.priority ?? referenceData.priorities[1]?.id ?? referenceData.priorities[0]?.id ?? '', + leadChannel: enquiry?.leadChannel ?? undefined, + estimatedValue: enquiry?.estimatedValue ? String(enquiry.estimatedValue) : '', + chancePercent: + enquiry?.chancePercent !== null && enquiry?.chancePercent !== undefined + ? String(enquiry.chancePercent) + : '', + expectedCloseDate: enquiry?.expectedCloseDate ? enquiry.expectedCloseDate.slice(0, 10) : '', + competitor: enquiry?.competitor ?? '', + source: enquiry?.source ?? '', + isHotProject: enquiry?.isHotProject ?? false, + notes: enquiry?.notes ?? '', + isActive: enquiry?.isActive ?? true + }; +} + +export function EnquiryFormSheet({ + enquiry, + open, + onOpenChange, + referenceData +}: { + enquiry?: EnquiryRecord; + open: boolean; + onOpenChange: (open: boolean) => void; + referenceData: EnquiryReferenceData; +}) { + const isEdit = !!enquiry; + const defaultValues = useMemo( + () => toDefaultValues(enquiry, referenceData), + [enquiry, referenceData] + ); + const [selectedCustomerId, setSelectedCustomerId] = useState(defaultValues.customerId); + const { FormTextField, FormTextareaField, FormSelectField, FormSwitchField } = + useFormFields(); + + const createMutation = useMutation({ + ...createEnquiryMutation, + onSuccess: () => { + toast.success('Enquiry created successfully'); + onOpenChange(false); + }, + onError: (error) => + toast.error(error instanceof Error ? error.message : 'Failed to create enquiry') + }); + + const updateMutation = useMutation({ + ...updateEnquiryMutation, + onSuccess: () => { + toast.success('Enquiry updated successfully'); + onOpenChange(false); + }, + onError: (error) => + toast.error(error instanceof Error ? error.message : 'Failed to update enquiry') + }); + + const contactsForCustomer = referenceData.contacts.filter( + (contact) => contact.customerId === selectedCustomerId + ); + + const form = useAppForm({ + defaultValues, + validators: { + onSubmit: enquirySchema + }, + onSubmit: async ({ value }) => { + const payload: EnquiryMutationPayload = { + customerId: value.customerId, + contactId: value.contactId || null, + title: value.title, + description: value.description, + requirement: value.requirement, + projectName: value.projectName, + projectLocation: value.projectLocation, + branchId: value.branchId || null, + productType: value.productType, + status: value.status, + priority: value.priority, + leadChannel: value.leadChannel || null, + estimatedValue: value.estimatedValue ? Number(value.estimatedValue) : null, + chancePercent: value.chancePercent ? Number(value.chancePercent) : null, + expectedCloseDate: value.expectedCloseDate || null, + competitor: value.competitor, + source: value.source, + isHotProject: value.isHotProject ?? false, + notes: value.notes, + isActive: value.isActive ?? true + }; + + if (isEdit && enquiry) { + await updateMutation.mutateAsync({ id: enquiry.id, values: payload }); + return; + } + + await createMutation.mutateAsync(payload); + } + }); + + useEffect(() => { + if (!open) { + return; + } + + setSelectedCustomerId(defaultValues.customerId); + form.reset(defaultValues); + }, [defaultValues, form, open]); + + const isPending = createMutation.isPending || updateMutation.isPending; + + return ( + + + + {isEdit ? 'Edit Enquiry' : 'New Enquiry'} + + Capture the opportunity details before quotation and approval stages begin. + + + +
+ + + ( + + + Customer * + + + + + )} + /> + + ( + + + Contact + + + + + )} + /> + + + + + + ({ + value: branch.id, + label: branch.name + }))} + /> + ({ + value: item.id, + label: item.label + }))} + /> + ({ + value: item.id, + label: item.label + }))} + /> + ({ + value: item.id, + label: item.label + }))} + /> + ({ + value: item.id, + label: item.label + }))} + /> + + + + +
+ +
+
+ +
+
+ +
+
+ + +
+
+
+
+ + + + + +
+
+ ); +} + +export function EnquiryFormSheetTrigger({ + referenceData +}: { + referenceData: EnquiryReferenceData; +}) { + const [open, setOpen] = React.useState(false); + + return ( + <> + + + + ); +} diff --git a/src/features/crm/enquiries/components/enquiry-listing.tsx b/src/features/crm/enquiries/components/enquiry-listing.tsx new file mode 100644 index 0000000..da1ea38 --- /dev/null +++ b/src/features/crm/enquiries/components/enquiry-listing.tsx @@ -0,0 +1,46 @@ +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; +import { getQueryClient } from '@/lib/query-client'; +import { searchParamsCache } from '@/lib/searchparams'; +import type { EnquiryReferenceData } from '../api/types'; +import { enquiriesQueryOptions } from '../api/queries'; +import { EnquiriesTable } from './enquiries-table'; + +export default function EnquiryListing({ + referenceData, + canUpdate, + canDelete +}: { + referenceData: EnquiryReferenceData; + canUpdate: boolean; + canDelete: boolean; +}) { + const page = searchParamsCache.get('page'); + const limit = searchParamsCache.get('perPage'); + const search = searchParamsCache.get('name'); + const status = searchParamsCache.get('status'); + const productType = searchParamsCache.get('productType'); + const priority = searchParamsCache.get('priority'); + const branch = searchParamsCache.get('branch'); + const customer = searchParamsCache.get('customer'); + const sort = searchParamsCache.get('sort'); + const filters = { + page, + limit, + ...(search && { search }), + ...(status && { status }), + ...(productType && { productType }), + ...(priority && { priority }), + ...(branch && { branch }), + ...(customer && { customer }), + ...(sort && { sort }) + }; + const queryClient = getQueryClient(); + + void queryClient.prefetchQuery(enquiriesQueryOptions(filters)); + + return ( + + + + ); +} diff --git a/src/features/crm/enquiries/components/enquiry-status-badge.tsx b/src/features/crm/enquiries/components/enquiry-status-badge.tsx new file mode 100644 index 0000000..1558f7b --- /dev/null +++ b/src/features/crm/enquiries/components/enquiry-status-badge.tsx @@ -0,0 +1,31 @@ +'use client'; + +import { Badge } from '@/components/ui/badge'; +import { Icons } from '@/components/icons'; + +export function EnquiryStatusBadge({ code, label }: { code?: string | null; label: string }) { + const normalized = code?.toLowerCase() ?? ''; + const variant = + normalized === 'closed_lost' || normalized === 'cancelled' + ? 'outline' + : normalized === 'converted' + ? 'default' + : normalized === 'follow_up' + ? 'secondary' + : 'secondary'; + const Icon = + normalized === 'converted' + ? Icons.circleCheck + : normalized === 'closed_lost' || normalized === 'cancelled' + ? Icons.warning + : normalized === 'follow_up' + ? Icons.clock + : Icons.circle; + + return ( + + + {label} + + ); +} diff --git a/src/features/crm/enquiries/components/followup-form-sheet.tsx b/src/features/crm/enquiries/components/followup-form-sheet.tsx new file mode 100644 index 0000000..d8f6816 --- /dev/null +++ b/src/features/crm/enquiries/components/followup-form-sheet.tsx @@ -0,0 +1,249 @@ +'use client'; + +import { useEffect, useMemo } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { Icons } from '@/components/icons'; +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select'; +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle +} from '@/components/ui/sheet'; +import { useAppForm, useFormFields } from '@/components/ui/tanstack-form'; +import { createEnquiryFollowupMutation, updateEnquiryFollowupMutation } from '../api/mutations'; +import type { + EnquiryFollowupMutationPayload, + EnquiryFollowupRecord, + EnquiryReferenceData +} from '../api/types'; +import { enquiryFollowupSchema, type EnquiryFollowupFormValues } from '../schemas/enquiry.schema'; + +function toDefaultValues(followup: EnquiryFollowupRecord | undefined): EnquiryFollowupFormValues { + return { + followupDate: followup?.followupDate ? followup.followupDate.slice(0, 10) : '', + followupType: followup?.followupType ?? '', + contactId: followup?.contactId ?? undefined, + outcome: followup?.outcome ?? '', + notes: followup?.notes ?? '', + nextFollowupDate: followup?.nextFollowupDate ? followup.nextFollowupDate.slice(0, 10) : '', + nextAction: followup?.nextAction ?? '' + }; +} + +export function FollowupFormSheet({ + enquiryId, + followup, + open, + onOpenChange, + referenceData, + customerId +}: { + enquiryId: string; + followup?: EnquiryFollowupRecord; + open: boolean; + onOpenChange: (open: boolean) => void; + referenceData: EnquiryReferenceData; + customerId: string; +}) { + const isEdit = !!followup; + const defaultValues = useMemo(() => toDefaultValues(followup), [followup]); + const { FormTextField, FormTextareaField } = useFormFields(); + const contactOptions = referenceData.contacts.filter((item) => item.customerId === customerId); + + const createMutation = useMutation({ + ...createEnquiryFollowupMutation, + onSuccess: () => { + toast.success('Follow-up created successfully'); + onOpenChange(false); + }, + onError: (error) => + toast.error(error instanceof Error ? error.message : 'Failed to create follow-up') + }); + + const updateMutation = useMutation({ + ...updateEnquiryFollowupMutation, + onSuccess: () => { + toast.success('Follow-up updated successfully'); + onOpenChange(false); + }, + onError: (error) => + toast.error(error instanceof Error ? error.message : 'Failed to update follow-up') + }); + + const form = useAppForm({ + defaultValues, + validators: { + onSubmit: enquiryFollowupSchema + }, + onSubmit: async ({ value }) => { + const payload: EnquiryFollowupMutationPayload = { + followupDate: value.followupDate, + followupType: value.followupType, + contactId: value.contactId || null, + outcome: value.outcome, + notes: value.notes, + nextFollowupDate: value.nextFollowupDate || null, + nextAction: value.nextAction + }; + + if (isEdit && followup) { + await updateMutation.mutateAsync({ + enquiryId, + followupId: followup.id, + values: payload + }); + return; + } + + await createMutation.mutateAsync({ + enquiryId, + values: payload + }); + } + }); + + useEffect(() => { + if (!open) { + return; + } + + form.reset(defaultValues); + }, [defaultValues, form, open]); + + const isPending = createMutation.isPending || updateMutation.isPending; + + return ( + + + + {isEdit ? 'Edit Follow-up' : 'New Follow-up'} + + Keep the enquiry timeline current with the latest customer interaction and next step. + + + +
+ + + + ( + + + Follow-up Type * + + + + + )} + /> + ( + + + Contact + + + + + )} + /> + +
+ +
+
+ +
+
+ +
+
+
+
+ + + + + +
+
+ ); +} diff --git a/src/features/crm/enquiries/schemas/enquiry.schema.ts b/src/features/crm/enquiries/schemas/enquiry.schema.ts new file mode 100644 index 0000000..05c7419 --- /dev/null +++ b/src/features/crm/enquiries/schemas/enquiry.schema.ts @@ -0,0 +1,47 @@ +import * as z from 'zod'; + +export const enquirySchema = z.object({ + customerId: z.string().min(1, 'Please select a customer'), + contactId: z.string().optional().nullable(), + title: z.string().min(2, 'Title must be at least 2 characters'), + description: z.string().optional(), + requirement: z.string().optional(), + projectName: z.string().optional(), + projectLocation: z.string().optional(), + branchId: z.string().optional().nullable(), + productType: z.string().min(1, 'Please select a product type'), + status: z.string().min(1, 'Please select a status'), + priority: z.string().min(1, 'Please select a priority'), + leadChannel: z.string().optional().nullable(), + estimatedValue: z + .string() + .optional() + .refine((value) => !value || !Number.isNaN(Number(value)), 'Estimated value must be a number'), + chancePercent: z + .string() + .optional() + .refine((value) => !value || !Number.isNaN(Number(value)), 'Chance percent must be a number') + .refine( + (value) => !value || (Number(value) >= 0 && Number(value) <= 100), + 'Chance percent must be between 0 and 100' + ), + expectedCloseDate: z.string().optional().nullable(), + competitor: z.string().optional(), + source: z.string().optional(), + isHotProject: z.boolean().default(false), + notes: z.string().optional(), + isActive: z.boolean().default(true) +}); + +export const enquiryFollowupSchema = z.object({ + followupDate: z.string().min(1, 'Follow-up date is required'), + followupType: z.string().min(1, 'Please select a follow-up type'), + contactId: z.string().optional().nullable(), + outcome: z.string().optional(), + notes: z.string().optional(), + nextFollowupDate: z.string().optional().nullable(), + nextAction: z.string().optional() +}); + +export type EnquiryFormValues = z.input; +export type EnquiryFollowupFormValues = z.input; diff --git a/src/features/crm/enquiries/server/service.ts b/src/features/crm/enquiries/server/service.ts new file mode 100644 index 0000000..b573b88 --- /dev/null +++ b/src/features/crm/enquiries/server/service.ts @@ -0,0 +1,734 @@ +import { and, asc, count, desc, eq, ilike, inArray, isNull, or, type SQL } from 'drizzle-orm'; +import { + crmCustomerContacts, + crmCustomers, + crmEnquiries, + crmEnquiryFollowups, + users +} from '@/db/schema'; +import { db } from '@/lib/db'; +import { AuthError } from '@/lib/auth/session'; +import { listAuditLogs } from '@/features/foundation/audit-log/service'; +import { getUserBranches, validateBranchAccess } from '@/features/foundation/branch-scope/service'; +import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service'; +import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service'; +import type { + EnquiryActivityRecord, + EnquiryBranchOption, + EnquiryContactLookup, + EnquiryCustomerLookup, + EnquiryCustomerRelationItem, + EnquiryFilters, + EnquiryFollowupMutationPayload, + EnquiryFollowupRecord, + EnquiryListItem, + EnquiryMutationPayload, + EnquiryOption, + EnquiryRecord, + EnquiryReferenceData +} from '../api/types'; + +const ENQUIRY_OPTION_CATEGORIES = { + status: 'crm_enquiry_status', + productType: 'crm_product_type', + priority: 'crm_priority', + leadChannel: 'crm_lead_channel', + followupType: 'crm_followup_type' +} as const; + +function mapOption( + option: Awaited>[number] +): EnquiryOption { + return { + id: option.id, + code: option.code, + label: option.label, + value: option.value + }; +} + +function mapBranch( + branch: Awaited>[number] +): EnquiryBranchOption { + return { + id: branch.id, + code: branch.code, + name: branch.name, + value: branch.value + }; +} + +function mapEnquiryRecord(row: typeof crmEnquiries.$inferSelect): EnquiryRecord { + return { + id: row.id, + organizationId: row.organizationId, + branchId: row.branchId, + code: row.code, + customerId: row.customerId, + contactId: row.contactId, + title: row.title, + description: row.description, + requirement: row.requirement, + projectName: row.projectName, + projectLocation: row.projectLocation, + productType: row.productType, + status: row.status, + priority: row.priority, + leadChannel: row.leadChannel, + estimatedValue: row.estimatedValue, + chancePercent: row.chancePercent, + expectedCloseDate: row.expectedCloseDate?.toISOString() ?? null, + competitor: row.competitor, + source: row.source, + notes: row.notes, + isHotProject: row.isHotProject, + isActive: row.isActive, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + deletedAt: row.deletedAt?.toISOString() ?? null, + createdBy: row.createdBy, + updatedBy: row.updatedBy + }; +} + +function mapFollowupRecord(row: typeof crmEnquiryFollowups.$inferSelect): EnquiryFollowupRecord { + return { + id: row.id, + organizationId: row.organizationId, + enquiryId: row.enquiryId, + followupDate: row.followupDate.toISOString(), + followupType: row.followupType, + contactId: row.contactId, + outcome: row.outcome, + notes: row.notes, + nextFollowupDate: row.nextFollowupDate?.toISOString() ?? null, + nextAction: row.nextAction, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + deletedAt: row.deletedAt?.toISOString() ?? null, + createdBy: row.createdBy, + updatedBy: row.updatedBy + }; +} + +function parseSort(sort?: string) { + if (!sort) { + return desc(crmEnquiries.updatedAt); + } + + try { + const parsed = JSON.parse(sort) as Array<{ id: string; desc?: boolean }>; + const [rule] = parsed; + + if (!rule) { + return desc(crmEnquiries.updatedAt); + } + + const sortMap = { + code: crmEnquiries.code, + title: crmEnquiries.title, + status: crmEnquiries.status, + priority: crmEnquiries.priority, + productType: crmEnquiries.productType, + branch: crmEnquiries.branchId, + customer: crmEnquiries.customerId, + updatedAt: crmEnquiries.updatedAt + } as const; + + const column = sortMap[rule.id as keyof typeof sortMap]; + + if (!column) { + return desc(crmEnquiries.updatedAt); + } + + return rule.desc ? desc(column) : asc(column); + } catch { + return desc(crmEnquiries.updatedAt); + } +} + +function splitFilterValue(value?: string) { + return value + ? value + .split(/[.,]/) + .map((item) => item.trim()) + .filter(Boolean) + : []; +} + +async function resolveValidOptionIds(category: string, organizationId: string) { + const options = await getActiveOptionsByCategory(category, { organizationId }); + return new Set(options.map((option) => option.id)); +} + +async function assertMasterOptionValue( + organizationId: string, + category: string, + optionId?: string | null +) { + if (!optionId) { + return; + } + + const validIds = await resolveValidOptionIds(category, organizationId); + + if (!validIds.has(optionId)) { + throw new AuthError(`Invalid option for ${category}`, 400); + } +} + +export async function assertCustomerBelongsToOrganization(id: string, organizationId: string) { + const customer = await db.query.crmCustomers.findFirst({ + where: and( + eq(crmCustomers.id, id), + eq(crmCustomers.organizationId, organizationId), + isNull(crmCustomers.deletedAt) + ) + }); + + if (!customer) { + throw new AuthError('Customer not found', 404); + } + + return customer; +} + +export async function assertContactBelongsToOrganization( + contactId: string, + organizationId: string, + customerId?: string | null +) { + const contact = await db.query.crmCustomerContacts.findFirst({ + where: and( + eq(crmCustomerContacts.id, contactId), + eq(crmCustomerContacts.organizationId, organizationId), + isNull(crmCustomerContacts.deletedAt), + ...(customerId ? [eq(crmCustomerContacts.customerId, customerId)] : []) + ) + }); + + if (!contact) { + throw new AuthError('Contact not found', 404); + } + + return contact; +} + +async function assertEnquiryBelongsToOrganization(id: string, organizationId: string) { + const enquiry = await db.query.crmEnquiries.findFirst({ + where: and( + eq(crmEnquiries.id, id), + eq(crmEnquiries.organizationId, organizationId), + isNull(crmEnquiries.deletedAt) + ) + }); + + if (!enquiry) { + throw new AuthError('Enquiry not found', 404); + } + + return enquiry; +} + +async function assertFollowupBelongsToEnquiry( + followupId: string, + enquiryId: string, + organizationId: string +) { + const followup = await db.query.crmEnquiryFollowups.findFirst({ + where: and( + eq(crmEnquiryFollowups.id, followupId), + eq(crmEnquiryFollowups.enquiryId, enquiryId), + eq(crmEnquiryFollowups.organizationId, organizationId), + isNull(crmEnquiryFollowups.deletedAt) + ) + }); + + if (!followup) { + throw new AuthError('Follow-up not found', 404); + } + + return followup; +} + +async function validateEnquiryPayload(organizationId: string, payload: EnquiryMutationPayload) { + await assertCustomerBelongsToOrganization(payload.customerId, organizationId); + + if (payload.contactId) { + await assertContactBelongsToOrganization(payload.contactId, organizationId, payload.customerId); + } + + await assertMasterOptionValue(organizationId, ENQUIRY_OPTION_CATEGORIES.status, payload.status); + await assertMasterOptionValue( + organizationId, + ENQUIRY_OPTION_CATEGORIES.productType, + payload.productType + ); + await assertMasterOptionValue( + organizationId, + ENQUIRY_OPTION_CATEGORIES.priority, + payload.priority + ); + await assertMasterOptionValue( + organizationId, + ENQUIRY_OPTION_CATEGORIES.leadChannel, + payload.leadChannel ?? null + ); + + if (payload.branchId) { + await validateBranchAccess(payload.branchId); + } +} + +async function validateFollowupPayload( + organizationId: string, + enquiryId: string, + payload: EnquiryFollowupMutationPayload +) { + const enquiry = await assertEnquiryBelongsToOrganization(enquiryId, organizationId); + + await assertMasterOptionValue( + organizationId, + ENQUIRY_OPTION_CATEGORIES.followupType, + payload.followupType + ); + + if (payload.contactId) { + await assertContactBelongsToOrganization(payload.contactId, organizationId, enquiry.customerId); + } +} + +function buildEnquiryFilters(organizationId: string, filters: EnquiryFilters): SQL[] { + const statuses = splitFilterValue(filters.status); + const productTypes = splitFilterValue(filters.productType); + const priorities = splitFilterValue(filters.priority); + const branches = splitFilterValue(filters.branch); + const customers = splitFilterValue(filters.customer); + + return [ + eq(crmEnquiries.organizationId, organizationId), + isNull(crmEnquiries.deletedAt), + ...(statuses.length ? [inArray(crmEnquiries.status, statuses)] : []), + ...(productTypes.length ? [inArray(crmEnquiries.productType, productTypes)] : []), + ...(priorities.length ? [inArray(crmEnquiries.priority, priorities)] : []), + ...(branches.length ? [inArray(crmEnquiries.branchId, branches)] : []), + ...(customers.length ? [inArray(crmEnquiries.customerId, customers)] : []), + ...(filters.search + ? [ + or( + ilike(crmEnquiries.code, `%${filters.search}%`), + ilike(crmEnquiries.title, `%${filters.search}%`), + ilike(crmEnquiries.projectName, `%${filters.search}%`), + ilike(crmEnquiries.competitor, `%${filters.search}%`) + )! + ] + : []) + ]; +} + +export async function getEnquiryReferenceData( + organizationId: string +): Promise { + const [ + branches, + statuses, + productTypes, + priorities, + leadChannels, + followupTypes, + customers, + contacts + ] = await Promise.all([ + getUserBranches(), + getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.status, { organizationId }), + getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.productType, { organizationId }), + getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.priority, { organizationId }), + getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.leadChannel, { organizationId }), + getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.followupType, { organizationId }), + db.query.crmCustomers.findMany({ + where: and(eq(crmCustomers.organizationId, organizationId), isNull(crmCustomers.deletedAt)), + orderBy: [asc(crmCustomers.name)] + }), + db.query.crmCustomerContacts.findMany({ + where: and( + eq(crmCustomerContacts.organizationId, organizationId), + isNull(crmCustomerContacts.deletedAt) + ), + orderBy: [desc(crmCustomerContacts.isPrimary), asc(crmCustomerContacts.name)] + }) + ]); + + return { + branches: branches.map(mapBranch), + statuses: statuses.map(mapOption), + productTypes: productTypes.map(mapOption), + priorities: priorities.map(mapOption), + leadChannels: leadChannels.map(mapOption), + followupTypes: followupTypes.map(mapOption), + customers: customers.map((customer) => ({ + id: customer.id, + code: customer.code, + name: customer.name, + branchId: customer.branchId + })), + contacts: contacts.map((contact) => ({ + id: contact.id, + customerId: contact.customerId, + name: contact.name, + email: contact.email, + mobile: contact.mobile, + isPrimary: contact.isPrimary + })) + }; +} + +export async function listEnquiries( + organizationId: string, + filters: EnquiryFilters +): Promise<{ items: EnquiryListItem[]; totalItems: number }> { + const page = filters.page ?? 1; + const limit = filters.limit ?? 10; + const whereFilters = buildEnquiryFilters(organizationId, filters); + const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters); + const offset = (page - 1) * limit; + + const [totalResult] = await db.select({ value: count() }).from(crmEnquiries).where(where); + const rows = await db + .select() + .from(crmEnquiries) + .where(where) + .orderBy(parseSort(filters.sort)) + .limit(limit) + .offset(offset); + + const customerIds = [...new Set(rows.map((row) => row.customerId))]; + const contactIds = [...new Set(rows.map((row) => row.contactId).filter(Boolean) as string[])]; + const enquiryIds = rows.map((row) => row.id); + + const [customers, contacts, followupCounts] = await Promise.all([ + customerIds.length + ? db.query.crmCustomers.findMany({ + where: and( + eq(crmCustomers.organizationId, organizationId), + inArray(crmCustomers.id, customerIds) + ) + }) + : [], + contactIds.length + ? db.query.crmCustomerContacts.findMany({ + where: and( + eq(crmCustomerContacts.organizationId, organizationId), + inArray(crmCustomerContacts.id, contactIds) + ) + }) + : [], + enquiryIds.length + ? db + .select({ + enquiryId: crmEnquiryFollowups.enquiryId, + value: count() + }) + .from(crmEnquiryFollowups) + .where( + and( + eq(crmEnquiryFollowups.organizationId, organizationId), + inArray(crmEnquiryFollowups.enquiryId, enquiryIds), + isNull(crmEnquiryFollowups.deletedAt) + ) + ) + .groupBy(crmEnquiryFollowups.enquiryId) + : [] + ]); + + const customerMap = new Map(customers.map((customer) => [customer.id, customer.name])); + const contactMap = new Map(contacts.map((contact) => [contact.id, contact.name])); + const followupCountMap = new Map(followupCounts.map((item) => [item.enquiryId, item.value])); + + return { + items: rows.map((row) => ({ + ...mapEnquiryRecord(row), + customerName: customerMap.get(row.customerId) ?? 'Unknown customer', + contactName: row.contactId ? (contactMap.get(row.contactId) ?? null) : null, + followupCount: followupCountMap.get(row.id) ?? 0 + })), + totalItems: totalResult?.value ?? 0 + }; +} + +export async function getEnquiryDetail(id: string, organizationId: string): Promise { + const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId); + return mapEnquiryRecord(enquiry); +} + +export async function getEnquiryActivity( + id: string, + organizationId: string +): Promise { + const auditLogs = await listAuditLogs({ + organizationId, + entityId: id, + limit: 30 + }); + const filteredLogs = auditLogs.filter( + (log) => + (log.entityType === 'crm_enquiry' && log.entityId === id) || + (log.entityType === 'crm_enquiry_followup' && + ((log.afterData as { enquiryId?: string } | null)?.enquiryId === id || + (log.beforeData as { enquiryId?: string } | null)?.enquiryId === id)) + ); + const userIds = [...new Set(filteredLogs.map((log) => log.userId))]; + const actorRows = userIds.length + ? await db + .select({ id: users.id, name: users.name }) + .from(users) + .where(inArray(users.id, userIds)) + : []; + const actorMap = new Map(actorRows.map((row) => [row.id, row.name])); + + return filteredLogs.map((log) => ({ + id: log.id, + action: log.action, + entityType: log.entityType, + entityId: log.entityId, + createdAt: log.createdAt, + userId: log.userId, + actorName: actorMap.get(log.userId) ?? null + })); +} + +export async function createEnquiry( + organizationId: string, + userId: string, + payload: EnquiryMutationPayload +) { + await validateEnquiryPayload(organizationId, payload); + + const documentCode = await generateNextDocumentCode({ + organizationId, + documentType: 'enquiry', + branchId: payload.branchId ?? '' + }); + + const [created] = await db + .insert(crmEnquiries) + .values({ + id: crypto.randomUUID(), + organizationId, + branchId: payload.branchId ?? null, + code: documentCode.code, + customerId: payload.customerId, + contactId: payload.contactId ?? null, + title: payload.title.trim(), + description: payload.description?.trim() || null, + requirement: payload.requirement?.trim() || null, + projectName: payload.projectName?.trim() || null, + projectLocation: payload.projectLocation?.trim() || null, + productType: payload.productType, + status: payload.status, + priority: payload.priority, + leadChannel: payload.leadChannel ?? null, + estimatedValue: payload.estimatedValue ?? null, + chancePercent: payload.chancePercent ?? null, + expectedCloseDate: payload.expectedCloseDate ? new Date(payload.expectedCloseDate) : null, + competitor: payload.competitor?.trim() || null, + source: payload.source?.trim() || null, + notes: payload.notes?.trim() || null, + isHotProject: payload.isHotProject ?? false, + isActive: payload.isActive ?? true, + createdBy: userId, + updatedBy: userId + }) + .returning(); + + return created; +} + +export async function updateEnquiry( + id: string, + organizationId: string, + userId: string, + payload: EnquiryMutationPayload +) { + await validateEnquiryPayload(organizationId, payload); + await assertEnquiryBelongsToOrganization(id, organizationId); + + const [updated] = await db + .update(crmEnquiries) + .set({ + branchId: payload.branchId ?? null, + customerId: payload.customerId, + contactId: payload.contactId ?? null, + title: payload.title.trim(), + description: payload.description?.trim() || null, + requirement: payload.requirement?.trim() || null, + projectName: payload.projectName?.trim() || null, + projectLocation: payload.projectLocation?.trim() || null, + productType: payload.productType, + status: payload.status, + priority: payload.priority, + leadChannel: payload.leadChannel ?? null, + estimatedValue: payload.estimatedValue ?? null, + chancePercent: payload.chancePercent ?? null, + expectedCloseDate: payload.expectedCloseDate ? new Date(payload.expectedCloseDate) : null, + competitor: payload.competitor?.trim() || null, + source: payload.source?.trim() || null, + notes: payload.notes?.trim() || null, + isHotProject: payload.isHotProject ?? false, + isActive: payload.isActive ?? true, + updatedBy: userId, + updatedAt: new Date() + }) + .where(eq(crmEnquiries.id, id)) + .returning(); + + return updated; +} + +export async function softDeleteEnquiry(id: string, organizationId: string, userId: string) { + await assertEnquiryBelongsToOrganization(id, organizationId); + const now = new Date(); + + const [updated] = await db + .update(crmEnquiries) + .set({ + deletedAt: now, + updatedAt: now, + updatedBy: userId, + isActive: false + }) + .where(eq(crmEnquiries.id, id)) + .returning(); + + await db + .update(crmEnquiryFollowups) + .set({ + deletedAt: now, + updatedAt: now, + updatedBy: userId + }) + .where( + and( + eq(crmEnquiryFollowups.enquiryId, id), + eq(crmEnquiryFollowups.organizationId, organizationId) + ) + ); + + return updated; +} + +export async function listEnquiryFollowups(id: string, organizationId: string) { + await assertEnquiryBelongsToOrganization(id, organizationId); + const rows = await db.query.crmEnquiryFollowups.findMany({ + where: and( + eq(crmEnquiryFollowups.enquiryId, id), + eq(crmEnquiryFollowups.organizationId, organizationId), + isNull(crmEnquiryFollowups.deletedAt) + ), + orderBy: [desc(crmEnquiryFollowups.followupDate)] + }); + + return rows.map(mapFollowupRecord); +} + +export async function createEnquiryFollowup( + enquiryId: string, + organizationId: string, + userId: string, + payload: EnquiryFollowupMutationPayload +) { + await validateFollowupPayload(organizationId, enquiryId, payload); + + const [created] = await db + .insert(crmEnquiryFollowups) + .values({ + id: crypto.randomUUID(), + organizationId, + enquiryId, + followupDate: new Date(payload.followupDate), + followupType: payload.followupType, + contactId: payload.contactId ?? null, + outcome: payload.outcome?.trim() || null, + notes: payload.notes?.trim() || null, + nextFollowupDate: payload.nextFollowupDate ? new Date(payload.nextFollowupDate) : null, + nextAction: payload.nextAction?.trim() || null, + createdBy: userId, + updatedBy: userId + }) + .returning(); + + return created; +} + +export async function updateEnquiryFollowup( + enquiryId: string, + followupId: string, + organizationId: string, + userId: string, + payload: EnquiryFollowupMutationPayload +) { + await validateFollowupPayload(organizationId, enquiryId, payload); + await assertFollowupBelongsToEnquiry(followupId, enquiryId, organizationId); + + const [updated] = await db + .update(crmEnquiryFollowups) + .set({ + followupDate: new Date(payload.followupDate), + followupType: payload.followupType, + contactId: payload.contactId ?? null, + outcome: payload.outcome?.trim() || null, + notes: payload.notes?.trim() || null, + nextFollowupDate: payload.nextFollowupDate ? new Date(payload.nextFollowupDate) : null, + nextAction: payload.nextAction?.trim() || null, + updatedAt: new Date(), + updatedBy: userId + }) + .where(eq(crmEnquiryFollowups.id, followupId)) + .returning(); + + return updated; +} + +export async function softDeleteEnquiryFollowup( + enquiryId: string, + followupId: string, + organizationId: string, + userId: string +) { + await assertFollowupBelongsToEnquiry(followupId, enquiryId, organizationId); + + const [updated] = await db + .update(crmEnquiryFollowups) + .set({ + deletedAt: new Date(), + updatedAt: new Date(), + updatedBy: userId + }) + .where(eq(crmEnquiryFollowups.id, followupId)) + .returning(); + + return updated; +} + +export async function listCustomerEnquiryRelations( + customerId: string, + organizationId: string +): Promise { + const rows = await db.query.crmEnquiries.findMany({ + where: and( + eq(crmEnquiries.customerId, customerId), + eq(crmEnquiries.organizationId, organizationId), + isNull(crmEnquiries.deletedAt) + ), + orderBy: [desc(crmEnquiries.updatedAt)], + limit: 10 + }); + + return rows.map((row) => ({ + id: row.id, + code: row.code, + title: row.title, + status: row.status, + productType: row.productType, + updatedAt: row.updatedAt.toISOString() + })); +} diff --git a/src/lib/auth/rbac.ts b/src/lib/auth/rbac.ts index 6f05b4a..e406f17 100644 --- a/src/lib/auth/rbac.ts +++ b/src/lib/auth/rbac.ts @@ -26,7 +26,15 @@ export const PERMISSIONS = { crmContactRead: 'crm.contact.read', crmContactCreate: 'crm.contact.create', crmContactUpdate: 'crm.contact.update', - crmContactDelete: 'crm.contact.delete' + crmContactDelete: 'crm.contact.delete', + crmEnquiryRead: 'crm.enquiry.read', + crmEnquiryCreate: 'crm.enquiry.create', + crmEnquiryUpdate: 'crm.enquiry.update', + crmEnquiryDelete: 'crm.enquiry.delete', + crmEnquiryFollowupRead: 'crm.enquiry.followup.read', + crmEnquiryFollowupCreate: 'crm.enquiry.followup.create', + crmEnquiryFollowupUpdate: 'crm.enquiry.followup.update', + crmEnquiryFollowupDelete: 'crm.enquiry.followup.delete' } as const; export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS]; @@ -45,11 +53,25 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] { PERMISSIONS.crmContactRead, PERMISSIONS.crmContactCreate, PERMISSIONS.crmContactUpdate, - PERMISSIONS.crmContactDelete + PERMISSIONS.crmContactDelete, + PERMISSIONS.crmEnquiryRead, + PERMISSIONS.crmEnquiryCreate, + PERMISSIONS.crmEnquiryUpdate, + PERMISSIONS.crmEnquiryDelete, + PERMISSIONS.crmEnquiryFollowupRead, + PERMISSIONS.crmEnquiryFollowupCreate, + PERMISSIONS.crmEnquiryFollowupUpdate, + PERMISSIONS.crmEnquiryFollowupDelete ]; } - return [PERMISSIONS.productsRead, PERMISSIONS.crmCustomerRead, PERMISSIONS.crmContactRead]; + return [ + PERMISSIONS.productsRead, + PERMISSIONS.crmCustomerRead, + PERMISSIONS.crmContactRead, + PERMISSIONS.crmEnquiryRead, + PERMISSIONS.crmEnquiryFollowupRead + ]; } export function getBusinessRolePermissions(role: BusinessRole): Permission[] { diff --git a/src/lib/searchparams.ts b/src/lib/searchparams.ts index 1dae4c3..d5d711d 100644 --- a/src/lib/searchparams.ts +++ b/src/lib/searchparams.ts @@ -13,8 +13,10 @@ export const searchParams = { category: parseAsString, customerType: parseAsString, customerStatus: parseAsString, + customer: parseAsString, branch: parseAsString, productType: parseAsString, + priority: parseAsString, salesman: parseAsString, dateFrom: parseAsString, dateTo: parseAsString,