task-d.5.3
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
# Task D.5.3: Lead Assignment -> Create Enquiry Foundation
|
||||
|
||||
## Objective
|
||||
|
||||
Establish the first production lead-assignment flow that preserves `crm_leads` as the
|
||||
marketing-owned source record while creating or reusing a linked `crm_enquiries` record for the
|
||||
sales execution workspace.
|
||||
|
||||
## Review Completed
|
||||
|
||||
- `AGENTS.md`
|
||||
- `docs/standards/project-foundations.md`
|
||||
- `docs/standards/task-catalog.md`
|
||||
- `docs/adr/0018-lead-enquiry-domain-separation.md`
|
||||
- `docs/implementation/task-d5-lead-enquiry-domain-separation.md`
|
||||
- `docs/implementation/task-d52-lead-domain-service-api-foundation.md`
|
||||
- `docs/security/crm-authorization-boundaries.md`
|
||||
- `src/features/crm/leads/server/service.ts`
|
||||
- `src/features/crm/enquiries/server/service.ts`
|
||||
- `src/features/crm/customers/server/service.ts`
|
||||
- `src/features/foundation/audit-log/service.ts`
|
||||
- `src/features/foundation/document-sequence/service.ts`
|
||||
|
||||
## What Changed
|
||||
|
||||
- Added lead bootstrap and assignment fields to `crm_leads`:
|
||||
- `description`
|
||||
- `product_type`
|
||||
- `priority`
|
||||
- `estimated_value`
|
||||
- `assigned_sales_owner_id`
|
||||
- `assigned_at`
|
||||
- `assigned_by`
|
||||
- `assignment_remark`
|
||||
- Added [assignment.service.ts](C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/leads/server/assignment.service.ts)
|
||||
with:
|
||||
- `assignLead()`
|
||||
- internal `createEnquiryFromLead()`
|
||||
- Added [route.ts](C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/leads/[id]/assign/route.ts)
|
||||
for `POST /api/crm/leads/[id]/assign`
|
||||
- Extended lead DTOs and schemas so new lead records can carry the data needed to bootstrap an
|
||||
enquiry
|
||||
- Added `assigned` to seeded `crm_lead_status`
|
||||
- Tightened enquiry-owner validation so assignees must be sales-assignable members, not merely any
|
||||
organization member
|
||||
|
||||
## Assignment Behavior
|
||||
|
||||
- route permission is `crm.lead.assign`
|
||||
- service validates:
|
||||
- lead exists and is active
|
||||
- actor can access the lead through resolved CRM scope
|
||||
- assignee belongs to the same organization and is a sales-assignable member
|
||||
- if an active enquiry already exists for the lead:
|
||||
- no second enquiry is created
|
||||
- the existing enquiry is returned
|
||||
- lead assignment metadata is synchronized
|
||||
- if no active enquiry exists:
|
||||
- enquiry code generation reuses `generateNextDocumentCode()` with `documentType = crm_enquiry`
|
||||
- enquiry is created with `lead_id = crm_leads.id`
|
||||
- enquiry owner compatibility fields use the existing enquiry model:
|
||||
- `assigned_to_user_id`
|
||||
- `assigned_at`
|
||||
- `assigned_by`
|
||||
- `assignment_remark`
|
||||
|
||||
## Lead -> Enquiry Bootstrap Mapping
|
||||
|
||||
When creating the enquiry from a lead, the flow copies:
|
||||
|
||||
- `customerId`
|
||||
- `contactId`
|
||||
- `description`
|
||||
- `projectName`
|
||||
- `projectLocation`
|
||||
- `productType`
|
||||
- `estimatedValue`
|
||||
- `priority`
|
||||
|
||||
The enquiry title is derived from `lead.projectName` and falls back to `Lead <code>` when the
|
||||
project name is empty.
|
||||
|
||||
## Audit Actions
|
||||
|
||||
This task records:
|
||||
|
||||
- `assign_lead` on `crm_lead`
|
||||
- `create_enquiry_from_lead` on `crm_enquiry`
|
||||
|
||||
Captured linkage includes:
|
||||
|
||||
- `leadId`
|
||||
- `enquiryId`
|
||||
- `salesOwnerId`
|
||||
|
||||
## Compatibility Notes
|
||||
|
||||
This task intentionally does not change:
|
||||
|
||||
- quotation flows
|
||||
- approval flows
|
||||
- dashboard datasets
|
||||
- reporting datasets
|
||||
- won/lost lifecycle flows
|
||||
- legacy enquiry list/detail APIs outside the stricter assignee validation
|
||||
|
||||
`crm_enquiries.pipeline_stage` remains in place for compatibility, but lead assignment now creates a
|
||||
dedicated enquiry record instead of mutating a lead record in place.
|
||||
657
drizzle/0000_swift_prism.sql
Normal file
657
drizzle/0000_swift_prism.sql
Normal file
@@ -0,0 +1,657 @@
|
||||
CREATE TABLE "crm_approval_actions" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"approval_request_id" text NOT NULL,
|
||||
"step_number" integer NOT NULL,
|
||||
"action" text NOT NULL,
|
||||
"remark" text,
|
||||
"acted_by" text NOT NULL,
|
||||
"acted_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"deleted_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "crm_approval_requests" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"workflow_id" text NOT NULL,
|
||||
"entity_type" text NOT NULL,
|
||||
"entity_id" text NOT NULL,
|
||||
"current_step" integer DEFAULT 1 NOT NULL,
|
||||
"status" text NOT NULL,
|
||||
"requested_by" text NOT NULL,
|
||||
"requested_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"completed_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"deleted_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "crm_approval_steps" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"workflow_id" text NOT NULL,
|
||||
"step_number" integer NOT NULL,
|
||||
"role_code" text NOT NULL,
|
||||
"role_name" text NOT NULL,
|
||||
"is_required" 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
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "crm_approval_workflows" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"code" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"entity_type" text 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
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "crm_contact_shares" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"contact_id" text NOT NULL,
|
||||
"shared_to_user_id" text NOT NULL,
|
||||
"shared_by_user_id" text NOT NULL,
|
||||
"shared_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"is_active" boolean DEFAULT true NOT NULL,
|
||||
"remark" 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
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "crm_customer_contacts" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"customer_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"position" text,
|
||||
"department" text,
|
||||
"phone" text,
|
||||
"mobile" text,
|
||||
"email" text,
|
||||
"is_primary" boolean DEFAULT false NOT NULL,
|
||||
"notes" text,
|
||||
"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_customer_owner_history" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"customer_id" text NOT NULL,
|
||||
"old_owner_user_id" text,
|
||||
"new_owner_user_id" text,
|
||||
"changed_by" text NOT NULL,
|
||||
"changed_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"remark" 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
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "crm_customers" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"branch_id" text,
|
||||
"code" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"abbr" text,
|
||||
"tax_id" text,
|
||||
"customer_type" text NOT NULL,
|
||||
"customer_status" text NOT NULL,
|
||||
"address" text,
|
||||
"province" text,
|
||||
"district" text,
|
||||
"sub_district" text,
|
||||
"postal_code" text,
|
||||
"country" text,
|
||||
"phone" text,
|
||||
"fax" text,
|
||||
"email" text,
|
||||
"website" text,
|
||||
"lead_channel" text,
|
||||
"customer_group" text,
|
||||
"customer_sub_group" text,
|
||||
"owner_user_id" text,
|
||||
"owner_assigned_at" timestamp with time zone,
|
||||
"owner_assigned_by" text,
|
||||
"notes" text,
|
||||
"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_document_artifacts" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"entity_type" text NOT NULL,
|
||||
"entity_id" text NOT NULL,
|
||||
"document_type" text NOT NULL,
|
||||
"artifact_type" text NOT NULL,
|
||||
"template_version_id" text,
|
||||
"storage_provider" text NOT NULL,
|
||||
"storage_key" text NOT NULL,
|
||||
"file_name" text NOT NULL,
|
||||
"content_type" text NOT NULL,
|
||||
"file_size" integer,
|
||||
"checksum" text,
|
||||
"status" text DEFAULT 'active' NOT NULL,
|
||||
"generated_by" text NOT NULL,
|
||||
"generated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"locked_at" timestamp with time zone,
|
||||
"locked_by" text,
|
||||
"voided_at" timestamp with time zone,
|
||||
"voided_by" text,
|
||||
"void_reason" text,
|
||||
"metadata" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"deleted_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "crm_document_template_mappings" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"template_version_id" text NOT NULL,
|
||||
"placeholder_key" text NOT NULL,
|
||||
"source_path" text NOT NULL,
|
||||
"data_type" text NOT NULL,
|
||||
"sheet_name" text,
|
||||
"default_value" text,
|
||||
"format_mask" text,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"deleted_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "crm_document_template_table_columns" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"mapping_id" text NOT NULL,
|
||||
"column_name" text NOT NULL,
|
||||
"source_field" text NOT NULL,
|
||||
"column_letter" text,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"format_mask" 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
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "crm_document_template_versions" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"template_id" text NOT NULL,
|
||||
"version" text NOT NULL,
|
||||
"file_path" text,
|
||||
"schema_json" jsonb NOT NULL,
|
||||
"preview_image_url" text,
|
||||
"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
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "crm_document_templates" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"document_type" text NOT NULL,
|
||||
"product_type" text NOT NULL,
|
||||
"file_type" text NOT NULL,
|
||||
"template_name" text NOT NULL,
|
||||
"description" text,
|
||||
"is_default" 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_enquiries" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"branch_id" text,
|
||||
"lead_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,
|
||||
"pipeline_stage" text DEFAULT 'lead' NOT NULL,
|
||||
"closed_won_at" timestamp with time zone,
|
||||
"closed_lost_at" timestamp with time zone,
|
||||
"closed_by_user_id" text,
|
||||
"po_number" text,
|
||||
"po_date" timestamp with time zone,
|
||||
"po_amount" double precision,
|
||||
"po_currency" text,
|
||||
"lost_reason" text,
|
||||
"lost_competitor" text,
|
||||
"lost_remark" text,
|
||||
"assigned_to_user_id" text,
|
||||
"assigned_at" timestamp with time zone,
|
||||
"assigned_by" text,
|
||||
"assignment_remark" 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 TABLE "crm_enquiry_attachments" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"enquiry_id" text NOT NULL,
|
||||
"category" text NOT NULL,
|
||||
"file_name" text NOT NULL,
|
||||
"original_file_name" text NOT NULL,
|
||||
"storage_provider" text NOT NULL,
|
||||
"storage_key" text NOT NULL,
|
||||
"file_size" integer,
|
||||
"file_type" text,
|
||||
"description" text,
|
||||
"uploaded_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"uploaded_by" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"deleted_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "crm_enquiry_customers" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"enquiry_id" text NOT NULL,
|
||||
"customer_id" text NOT NULL,
|
||||
"role" text NOT NULL,
|
||||
"remark" 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
|
||||
);
|
||||
--> 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 TABLE "crm_leads" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"branch_id" text,
|
||||
"code" text NOT NULL,
|
||||
"customer_id" text,
|
||||
"contact_id" text,
|
||||
"description" text,
|
||||
"project_name" text,
|
||||
"project_location" text,
|
||||
"product_type" text,
|
||||
"priority" text,
|
||||
"estimated_value" double precision,
|
||||
"awareness_id" text,
|
||||
"status" text DEFAULT 'new_job' NOT NULL,
|
||||
"followup_status" text,
|
||||
"lost_reason" text,
|
||||
"outcome" text DEFAULT 'open' NOT NULL,
|
||||
"owner_marketing_user_id" text,
|
||||
"assigned_sales_owner_id" text,
|
||||
"assigned_at" timestamp with time zone,
|
||||
"assigned_by" text,
|
||||
"assignment_remark" text,
|
||||
"created_by" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"deleted_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "crm_quotation_attachments" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"quotation_id" text NOT NULL,
|
||||
"file_name" text NOT NULL,
|
||||
"original_file_name" text NOT NULL,
|
||||
"file_path" text NOT NULL,
|
||||
"file_size" integer,
|
||||
"file_type" text,
|
||||
"description" text,
|
||||
"uploaded_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"uploaded_by" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"deleted_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "crm_quotation_customers" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"quotation_id" text NOT NULL,
|
||||
"customer_id" text NOT NULL,
|
||||
"role" text NOT NULL,
|
||||
"is_primary" boolean DEFAULT false NOT NULL,
|
||||
"remark" 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
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "crm_quotation_followups" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"quotation_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 TABLE "crm_quotation_items" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"quotation_id" text NOT NULL,
|
||||
"item_number" integer NOT NULL,
|
||||
"product_type" text NOT NULL,
|
||||
"description" text NOT NULL,
|
||||
"quantity" double precision DEFAULT 0 NOT NULL,
|
||||
"unit" text,
|
||||
"unit_price" double precision DEFAULT 0 NOT NULL,
|
||||
"discount" double precision DEFAULT 0 NOT NULL,
|
||||
"discount_type" text,
|
||||
"tax_rate" double precision DEFAULT 0 NOT NULL,
|
||||
"total_price" double precision DEFAULT 0 NOT NULL,
|
||||
"notes" text,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"deleted_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "crm_quotation_topic_items" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"topic_id" text NOT NULL,
|
||||
"content" text NOT NULL,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"deleted_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "crm_quotation_topics" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"quotation_id" text NOT NULL,
|
||||
"topic_type" text NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"deleted_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "crm_quotations" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"branch_id" text,
|
||||
"code" text NOT NULL,
|
||||
"enquiry_id" text,
|
||||
"customer_id" text NOT NULL,
|
||||
"contact_id" text,
|
||||
"quotation_date" timestamp with time zone NOT NULL,
|
||||
"valid_until" timestamp with time zone,
|
||||
"quotation_type" text NOT NULL,
|
||||
"project_name" text,
|
||||
"project_location" text,
|
||||
"attention" text,
|
||||
"reference" text,
|
||||
"notes" text,
|
||||
"status" text NOT NULL,
|
||||
"revision" integer DEFAULT 0 NOT NULL,
|
||||
"parent_quotation_id" text,
|
||||
"revision_remark" text,
|
||||
"currency" text NOT NULL,
|
||||
"exchange_rate" double precision DEFAULT 1 NOT NULL,
|
||||
"subtotal" double precision DEFAULT 0 NOT NULL,
|
||||
"discount" double precision DEFAULT 0 NOT NULL,
|
||||
"discount_type" text,
|
||||
"tax_rate" double precision DEFAULT 0 NOT NULL,
|
||||
"tax_amount" double precision DEFAULT 0 NOT NULL,
|
||||
"total_amount" double precision DEFAULT 0 NOT NULL,
|
||||
"chance_percent" integer,
|
||||
"is_hot_project" boolean DEFAULT false NOT NULL,
|
||||
"competitor" text,
|
||||
"salesman_id" text,
|
||||
"is_sent" boolean DEFAULT false NOT NULL,
|
||||
"sent_at" timestamp with time zone,
|
||||
"sent_via" text,
|
||||
"approved_at" timestamp with time zone,
|
||||
"approved_artifact_id" text,
|
||||
"approved_pdf_url" text,
|
||||
"approved_snapshot" jsonb,
|
||||
"approved_template_version_id" text,
|
||||
"accepted_at" timestamp with time zone,
|
||||
"rejected_at" timestamp with time zone,
|
||||
"rejection_reason" text,
|
||||
"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_report_definitions" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"code" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"category" text NOT NULL,
|
||||
"is_system" boolean DEFAULT true 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
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "crm_role_profiles" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"code" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"permissions" text[] DEFAULT '{}' NOT NULL,
|
||||
"branch_scope_mode" text DEFAULT 'assigned' NOT NULL,
|
||||
"product_scope_mode" text DEFAULT 'assigned' NOT NULL,
|
||||
"ownership_scope" text DEFAULT 'own' NOT NULL,
|
||||
"approval_authority" text DEFAULT 'none' NOT NULL,
|
||||
"is_system" 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_user_role_assignments" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"role_profile_id" text NOT NULL,
|
||||
"branch_scope_mode" text DEFAULT 'inherit' NOT NULL,
|
||||
"branch_scope_ids" text[] DEFAULT '{}' NOT NULL,
|
||||
"product_type_scope_mode" text DEFAULT 'inherit' NOT NULL,
|
||||
"product_type_scope_ids" text[] DEFAULT '{}' NOT NULL,
|
||||
"is_primary" boolean DEFAULT false NOT NULL,
|
||||
"is_active" boolean DEFAULT true NOT NULL,
|
||||
"assigned_by" text NOT NULL,
|
||||
"assigned_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"deleted_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "document_sequences" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"branch_id" text DEFAULT '' NOT NULL,
|
||||
"document_type" text NOT NULL,
|
||||
"prefix" text NOT NULL,
|
||||
"period" text NOT NULL,
|
||||
"current_number" integer DEFAULT 0 NOT NULL,
|
||||
"padding_length" integer DEFAULT 3 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
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "memberships" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"role" text DEFAULT 'user' NOT NULL,
|
||||
"business_role" text DEFAULT 'sales_support' NOT NULL,
|
||||
"permissions" text[] DEFAULT '{}' NOT NULL,
|
||||
"branch_scope_ids" text[] DEFAULT '{}' NOT NULL,
|
||||
"product_type_scope_ids" text[] DEFAULT '{}' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "ms_options" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"category" text NOT NULL,
|
||||
"code" text NOT NULL,
|
||||
"label" text NOT NULL,
|
||||
"value" text,
|
||||
"parent_id" text,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||
"is_active" boolean DEFAULT true NOT NULL,
|
||||
"metadata" jsonb,
|
||||
"deleted_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "organizations" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"image_url" text,
|
||||
"plan" text DEFAULT 'free' NOT NULL,
|
||||
"created_by" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "products" (
|
||||
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "products_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||
"organization_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"category" text NOT NULL,
|
||||
"description" text NOT NULL,
|
||||
"photo_url" text NOT NULL,
|
||||
"price" double precision NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "tr_audit_logs" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"organization_id" text NOT NULL,
|
||||
"branch_id" text,
|
||||
"user_id" text NOT NULL,
|
||||
"entity_type" text NOT NULL,
|
||||
"entity_id" text NOT NULL,
|
||||
"action" text NOT NULL,
|
||||
"before_data" jsonb,
|
||||
"after_data" jsonb,
|
||||
"request_id" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "users" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"password_hash" text NOT NULL,
|
||||
"system_role" text DEFAULT 'user' NOT NULL,
|
||||
"image" text,
|
||||
"active_organization_id" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "crm_approval_steps_workflow_step_idx" ON "crm_approval_steps" USING btree ("workflow_id","step_number");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "crm_approval_workflows_org_code_idx" ON "crm_approval_workflows" USING btree ("organization_id","code");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "crm_contact_shares_org_contact_shared_user_idx" ON "crm_contact_shares" USING btree ("organization_id","contact_id","shared_to_user_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "crm_customers_org_code_idx" ON "crm_customers" USING btree ("organization_id","code");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "crm_document_template_mappings_version_placeholder_idx" ON "crm_document_template_mappings" USING btree ("template_version_id","placeholder_key");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "crm_document_template_table_columns_mapping_name_idx" ON "crm_document_template_table_columns" USING btree ("mapping_id","column_name");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "crm_document_template_versions_template_version_idx" ON "crm_document_template_versions" USING btree ("template_id","version");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "crm_document_templates_org_doc_product_file_name_idx" ON "crm_document_templates" USING btree ("organization_id","document_type","product_type","file_type","template_name");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "crm_enquiries_org_code_idx" ON "crm_enquiries" USING btree ("organization_id","code");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "crm_leads_org_code_idx" ON "crm_leads" USING btree ("organization_id","code");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "crm_quotations_org_code_idx" ON "crm_quotations" USING btree ("organization_id","code");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "crm_report_definitions_org_code_idx" ON "crm_report_definitions" USING btree ("organization_id","code");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "crm_role_profiles_org_code_idx" ON "crm_role_profiles" USING btree ("organization_id","code");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "crm_user_role_assignments_org_user_role_idx" ON "crm_user_role_assignments" USING btree ("organization_id","user_id","role_profile_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "document_sequences_org_doc_period_branch_idx" ON "document_sequences" USING btree ("organization_id","document_type","period","branch_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "ms_options_org_category_code_idx" ON "ms_options" USING btree ("organization_id","category","code");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "organizations_slug_idx" ON "organizations" USING btree ("slug");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "users_email_idx" ON "users" USING btree ("email");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,155 +5,8 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1781148450992,
|
||||
"tag": "0000_icy_lizard",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1781171200406,
|
||||
"tag": "0001_orange_mandarin",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1781498575433,
|
||||
"tag": "0002_plain_anthem",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1781505431077,
|
||||
"tag": "0003_blushing_bruce_banner",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1781511534267,
|
||||
"tag": "0004_worthless_ender_wiggin",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1781575976324,
|
||||
"tag": "0005_numerous_blob",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1781579746622,
|
||||
"tag": "0006_worried_mordo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1781589458455,
|
||||
"tag": "0007_luxuriant_malice",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1781595962545,
|
||||
"tag": "0008_clean_justin_hammer",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1781603421415,
|
||||
"tag": "0009_lazy_shard",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1781675409847,
|
||||
"tag": "0010_calm_wendell_rand",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1781747812297,
|
||||
"tag": "0011_goofy_the_hood",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1781836261952,
|
||||
"tag": "0012_simple_bucky",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1782097963376,
|
||||
"tag": "0013_great_nicolaos",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1782099743528,
|
||||
"tag": "0014_bored_valkyrie",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1782549600000,
|
||||
"tag": "0015_customer_ownership_contact_shares",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1782795600000,
|
||||
"tag": "0016_won_lost_lifecycle_governance",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1782121929212,
|
||||
"tag": "0017_short_swordsman",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "7",
|
||||
"when": 1782799200000,
|
||||
"tag": "0018_report_foundation",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "7",
|
||||
"when": 1782137095907,
|
||||
"tag": "0019_sleepy_ultron",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "7",
|
||||
"when": 1782262800000,
|
||||
"tag": "0020_lead_foundation_schema",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 21,
|
||||
"version": "7",
|
||||
"when": 1782279100730,
|
||||
"tag": "0021_worried_nebula",
|
||||
"when": 1782285058938,
|
||||
"tag": "0000_swift_prism",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
9
drizzle_bk/0022_lead_assignment_foundation.sql
Normal file
9
drizzle_bk/0022_lead_assignment_foundation.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
ALTER TABLE "crm_leads"
|
||||
ADD COLUMN IF NOT EXISTS "description" text,
|
||||
ADD COLUMN IF NOT EXISTS "product_type" text,
|
||||
ADD COLUMN IF NOT EXISTS "priority" text,
|
||||
ADD COLUMN IF NOT EXISTS "estimated_value" double precision,
|
||||
ADD COLUMN IF NOT EXISTS "assigned_sales_owner_id" text,
|
||||
ADD COLUMN IF NOT EXISTS "assigned_at" timestamp with time zone,
|
||||
ADD COLUMN IF NOT EXISTS "assigned_by" text,
|
||||
ADD COLUMN IF NOT EXISTS "assignment_remark" text;
|
||||
8
drizzle_bk/0023_yellow_carlie_cooper.sql
Normal file
8
drizzle_bk/0023_yellow_carlie_cooper.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
ALTER TABLE "crm_leads" ADD COLUMN "description" text;--> statement-breakpoint
|
||||
ALTER TABLE "crm_leads" ADD COLUMN "product_type" text;--> statement-breakpoint
|
||||
ALTER TABLE "crm_leads" ADD COLUMN "priority" text;--> statement-breakpoint
|
||||
ALTER TABLE "crm_leads" ADD COLUMN "estimated_value" double precision;--> statement-breakpoint
|
||||
ALTER TABLE "crm_leads" ADD COLUMN "assigned_sales_owner_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "crm_leads" ADD COLUMN "assigned_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "crm_leads" ADD COLUMN "assigned_by" text;--> statement-breakpoint
|
||||
ALTER TABLE "crm_leads" ADD COLUMN "assignment_remark" text;
|
||||
331
drizzle_bk/meta/0000_snapshot.json
Normal file
331
drizzle_bk/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,331 @@
|
||||
{
|
||||
"id": "1e236d6a-ca42-4830-b230-67a7c233d87b",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"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.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.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": {}
|
||||
}
|
||||
}
|
||||
4263
drizzle_bk/meta/0023_snapshot.json
Normal file
4263
drizzle_bk/meta/0023_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
174
drizzle_bk/meta/_journal.json
Normal file
174
drizzle_bk/meta/_journal.json
Normal file
@@ -0,0 +1,174 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1781148450992,
|
||||
"tag": "0000_icy_lizard",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1781171200406,
|
||||
"tag": "0001_orange_mandarin",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1781498575433,
|
||||
"tag": "0002_plain_anthem",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1781505431077,
|
||||
"tag": "0003_blushing_bruce_banner",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1781511534267,
|
||||
"tag": "0004_worthless_ender_wiggin",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1781575976324,
|
||||
"tag": "0005_numerous_blob",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1781579746622,
|
||||
"tag": "0006_worried_mordo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1781589458455,
|
||||
"tag": "0007_luxuriant_malice",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1781595962545,
|
||||
"tag": "0008_clean_justin_hammer",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1781603421415,
|
||||
"tag": "0009_lazy_shard",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1781675409847,
|
||||
"tag": "0010_calm_wendell_rand",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1781747812297,
|
||||
"tag": "0011_goofy_the_hood",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1781836261952,
|
||||
"tag": "0012_simple_bucky",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1782097963376,
|
||||
"tag": "0013_great_nicolaos",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1782099743528,
|
||||
"tag": "0014_bored_valkyrie",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1782549600000,
|
||||
"tag": "0015_customer_ownership_contact_shares",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1782795600000,
|
||||
"tag": "0016_won_lost_lifecycle_governance",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1782121929212,
|
||||
"tag": "0017_short_swordsman",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "7",
|
||||
"when": 1782799200000,
|
||||
"tag": "0018_report_foundation",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "7",
|
||||
"when": 1782137095907,
|
||||
"tag": "0019_sleepy_ultron",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "7",
|
||||
"when": 1782262800000,
|
||||
"tag": "0020_lead_foundation_schema",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 21,
|
||||
"version": "7",
|
||||
"when": 1782279100730,
|
||||
"tag": "0021_worried_nebula",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 22,
|
||||
"version": "7",
|
||||
"when": 1782286200000,
|
||||
"tag": "0022_lead_assignment_foundation",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 23,
|
||||
"version": "7",
|
||||
"when": 1782284237696,
|
||||
"tag": "0023_yellow_carlie_cooper",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
537
plans/task-d.5.3.md
Normal file
537
plans/task-d.5.3.md
Normal file
@@ -0,0 +1,537 @@
|
||||
# Task D.5.3: Lead Assignment → Create Enquiry Foundation
|
||||
|
||||
## Status
|
||||
|
||||
Planned
|
||||
|
||||
## Objective
|
||||
|
||||
Implement the first production transition from Lead into Enquiry.
|
||||
|
||||
This task establishes the business boundary between:
|
||||
|
||||
```txt
|
||||
Marketing Domain
|
||||
↓
|
||||
Lead
|
||||
|
||||
Sales Domain
|
||||
↓
|
||||
Enquiry
|
||||
```
|
||||
|
||||
A Lead remains the marketing-owned source record.
|
||||
|
||||
An Enquiry becomes the sales execution workspace.
|
||||
|
||||
This task introduces:
|
||||
|
||||
* Lead assignment workflow
|
||||
* Enquiry creation from Lead
|
||||
* Lead ↔ Enquiry relationship
|
||||
* Initial ownership transfer
|
||||
* Enquiry bootstrap generation
|
||||
|
||||
without changing quotation, dashboard, reporting, or approval behavior.
|
||||
|
||||
---
|
||||
|
||||
# Mandatory Review
|
||||
|
||||
Review:
|
||||
|
||||
* AGENTS.md
|
||||
* Project Foundations Registry
|
||||
* Task Catalog
|
||||
* ADR-0018 Lead / Enquiry Domain Separation
|
||||
* Task D.5
|
||||
* Task D.5.1
|
||||
* Task D.5.2
|
||||
* Customer Ownership Foundation
|
||||
* CRM Security Foundation
|
||||
* Audit Foundation
|
||||
* Document Sequence Foundation
|
||||
|
||||
---
|
||||
|
||||
# Existing Foundation
|
||||
|
||||
Verified available:
|
||||
|
||||
```txt
|
||||
crm_leads
|
||||
crm_enquiries
|
||||
crm_enquiries.lead_id
|
||||
|
||||
crm_lead sequence
|
||||
crm_enquiry sequence
|
||||
|
||||
Lead Service
|
||||
Lead API
|
||||
```
|
||||
|
||||
Do not recreate:
|
||||
|
||||
```txt
|
||||
tables
|
||||
migrations
|
||||
seed data
|
||||
master-option categories
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Business Model
|
||||
|
||||
## Before Assignment
|
||||
|
||||
Owner:
|
||||
|
||||
```txt
|
||||
Marketing
|
||||
```
|
||||
|
||||
Workspace:
|
||||
|
||||
```txt
|
||||
Lead
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## After Assignment
|
||||
|
||||
Owner:
|
||||
|
||||
```txt
|
||||
Sales
|
||||
```
|
||||
|
||||
Workspace:
|
||||
|
||||
```txt
|
||||
Enquiry
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Relationship
|
||||
|
||||
```txt
|
||||
1 Lead
|
||||
↓
|
||||
0..N Enquiries
|
||||
```
|
||||
|
||||
Lead remains immutable historical source.
|
||||
|
||||
Enquiries become sales execution records.
|
||||
|
||||
---
|
||||
|
||||
# Scope D5.3.1 Lead Assignment Service
|
||||
|
||||
Create:
|
||||
|
||||
```txt
|
||||
src/features/crm/leads/server/assignment.service.ts
|
||||
```
|
||||
|
||||
Required operation:
|
||||
|
||||
```txt
|
||||
assignLead()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Input:
|
||||
|
||||
```ts
|
||||
AssignLeadInput
|
||||
```
|
||||
|
||||
Fields:
|
||||
|
||||
```ts
|
||||
leadId
|
||||
salesOwnerId
|
||||
assignmentRemark
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Validation:
|
||||
|
||||
```txt
|
||||
lead exists
|
||||
lead active
|
||||
salesOwnerId valid
|
||||
same organization
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Scope D5.3.2 Create Enquiry From Lead
|
||||
|
||||
Create:
|
||||
|
||||
```txt
|
||||
createEnquiryFromLead()
|
||||
```
|
||||
|
||||
inside assignment service.
|
||||
|
||||
---
|
||||
|
||||
Rules
|
||||
|
||||
Assignment automatically creates:
|
||||
|
||||
```txt
|
||||
crm_enquiry
|
||||
```
|
||||
|
||||
linked to:
|
||||
|
||||
```txt
|
||||
crm_leads.id
|
||||
```
|
||||
|
||||
through:
|
||||
|
||||
```txt
|
||||
crm_enquiries.lead_id
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Reuse:
|
||||
|
||||
```txt
|
||||
generateNextDocumentCode()
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```txt
|
||||
crm_enquiry
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Expected:
|
||||
|
||||
```txt
|
||||
EN2606-001
|
||||
```
|
||||
|
||||
or existing enquiry format.
|
||||
|
||||
No custom numbering logic.
|
||||
|
||||
---
|
||||
|
||||
# Scope D5.3.3 Enquiry Bootstrap Mapping
|
||||
|
||||
When enquiry is created:
|
||||
|
||||
Copy:
|
||||
|
||||
```txt
|
||||
customerId
|
||||
contactId
|
||||
projectName
|
||||
projectLocation
|
||||
productType
|
||||
description
|
||||
estimatedValue
|
||||
priority
|
||||
```
|
||||
|
||||
from Lead.
|
||||
|
||||
---
|
||||
|
||||
Do NOT copy:
|
||||
|
||||
```txt
|
||||
marketing-only notes
|
||||
internal marketing activity history
|
||||
audit events
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Lead remains unchanged.
|
||||
|
||||
---
|
||||
|
||||
# Scope D5.3.4 Lead Assignment API
|
||||
|
||||
Create:
|
||||
|
||||
```txt
|
||||
POST /api/crm/leads/[id]/assign
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Handler must call:
|
||||
|
||||
```txt
|
||||
Lead Assignment Service
|
||||
```
|
||||
|
||||
No direct DB access.
|
||||
|
||||
---
|
||||
|
||||
Response:
|
||||
|
||||
```ts
|
||||
{
|
||||
leadId: string;
|
||||
enquiryId: string;
|
||||
enquiryCode: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Scope D5.3.5 Ownership Transfer
|
||||
|
||||
After assignment:
|
||||
|
||||
Lead:
|
||||
|
||||
```txt
|
||||
status = assigned
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Lead:
|
||||
|
||||
```txt
|
||||
assigned_sales_owner_id
|
||||
assigned_at
|
||||
assigned_by
|
||||
```
|
||||
|
||||
updated.
|
||||
|
||||
---
|
||||
|
||||
Enquiry:
|
||||
|
||||
```txt
|
||||
owner_user_id
|
||||
```
|
||||
|
||||
set to assigned salesperson.
|
||||
|
||||
---
|
||||
|
||||
Marketing remains able to:
|
||||
|
||||
```txt
|
||||
view lead
|
||||
view assignment result
|
||||
```
|
||||
|
||||
but not manage enquiry.
|
||||
|
||||
---
|
||||
|
||||
# Scope D5.3.6 CRM Security
|
||||
|
||||
Reuse:
|
||||
|
||||
```txt
|
||||
resolveCrmMembershipAccess()
|
||||
```
|
||||
|
||||
Assignment permission:
|
||||
|
||||
```txt
|
||||
lead.assign
|
||||
```
|
||||
|
||||
Only:
|
||||
|
||||
```txt
|
||||
Marketing Manager
|
||||
CRM Manager
|
||||
Admin
|
||||
```
|
||||
|
||||
or equivalent resolved permission.
|
||||
|
||||
---
|
||||
|
||||
Do not check role strings directly.
|
||||
|
||||
---
|
||||
|
||||
# Scope D5.3.7 Duplicate Protection
|
||||
|
||||
Prevent:
|
||||
|
||||
```txt
|
||||
multiple enquiry creation
|
||||
```
|
||||
|
||||
for the same assignment action.
|
||||
|
||||
---
|
||||
|
||||
Rules:
|
||||
|
||||
If assignment already produced an active enquiry:
|
||||
|
||||
```txt
|
||||
return existing enquiry
|
||||
```
|
||||
|
||||
instead of creating a second one.
|
||||
|
||||
---
|
||||
|
||||
# Scope D5.3.8 Audit Logging
|
||||
|
||||
Reuse audit foundation.
|
||||
|
||||
Required actions:
|
||||
|
||||
```txt
|
||||
assign_lead
|
||||
create_enquiry_from_lead
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Audit must capture:
|
||||
|
||||
```txt
|
||||
leadId
|
||||
enquiryId
|
||||
salesOwnerId
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Scope D5.3.9 Legacy Compatibility
|
||||
|
||||
Do not modify:
|
||||
|
||||
```txt
|
||||
quotation flow
|
||||
approval flow
|
||||
dashboard
|
||||
reports
|
||||
won/lost process
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Current enquiry APIs must continue functioning.
|
||||
|
||||
---
|
||||
|
||||
# Deliverables
|
||||
|
||||
New:
|
||||
|
||||
```txt
|
||||
src/features/crm/leads/server/assignment.service.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
New API:
|
||||
|
||||
```txt
|
||||
POST /api/crm/leads/[id]/assign
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Documentation:
|
||||
|
||||
```txt
|
||||
docs/implementation/task-d53-lead-assignment-create-enquiry-foundation.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Verification
|
||||
|
||||
Run:
|
||||
|
||||
```txt
|
||||
npm exec tsc --noEmit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Verify:
|
||||
|
||||
```txt
|
||||
Assign Lead
|
||||
Create Enquiry
|
||||
Reuse Existing Enquiry
|
||||
Lead Status Update
|
||||
Audit Logging
|
||||
Security Enforcement
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Verify:
|
||||
|
||||
```txt
|
||||
Quotation APIs still work
|
||||
Dashboard still works
|
||||
Reports still work
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Explicit Non-Scope
|
||||
|
||||
Do not:
|
||||
|
||||
* create Lead UI
|
||||
* create Opportunity UI
|
||||
* create quotation conversion
|
||||
* change approval workflow
|
||||
* change reports
|
||||
* change dashboard
|
||||
* migrate historical data
|
||||
* implement won/lost synchronization
|
||||
|
||||
These belong to later D.5 phases.
|
||||
|
||||
---
|
||||
|
||||
# Definition of Done
|
||||
|
||||
Task is complete when:
|
||||
|
||||
* Lead assignment API exists
|
||||
* Assignment creates or reuses enquiry
|
||||
* crm_enquiries.lead_id is populated
|
||||
* Enquiry owner is assigned salesperson
|
||||
* Lead assignment is audited
|
||||
* Existing CRM functionality remains unchanged
|
||||
|
||||
Result:
|
||||
|
||||
```txt
|
||||
Lead Assignment = Established
|
||||
|
||||
Lead → Enquiry Transition = Established
|
||||
|
||||
Ready for Opportunity Workspace Separation
|
||||
```
|
||||
506
plans/task-d.5.4.md
Normal file
506
plans/task-d.5.4.md
Normal file
@@ -0,0 +1,506 @@
|
||||
# Task D.5.4: Lead / Enquiry Workspace Separation UI Foundation
|
||||
|
||||
## Status
|
||||
|
||||
Planned
|
||||
|
||||
## Objective
|
||||
|
||||
Separate the user-facing CRM workspace between Lead and Enquiry.
|
||||
|
||||
This task makes Lead and Enquiry visible as different working areas while preserving existing enquiry, quotation, dashboard, and reporting behavior.
|
||||
|
||||
Business boundary:
|
||||
|
||||
```txt
|
||||
Lead = Marketing-owned source record
|
||||
Enquiry = Sales execution workspace
|
||||
```
|
||||
|
||||
This task focuses on UI routing, navigation, data-source separation, and safe compatibility wiring.
|
||||
|
||||
---
|
||||
|
||||
## Mandatory Review
|
||||
|
||||
Review before implementation:
|
||||
|
||||
* `AGENTS.md`
|
||||
* `docs/standards/project-foundations.md`
|
||||
* `docs/standards/task-catalog.md`
|
||||
* `docs/adr/0018-lead-enquiry-domain-separation.md`
|
||||
* `docs/implementation/task-d5-lead-enquiry-domain-separation.md`
|
||||
* `docs/implementation/task-d51-lead-foundation-stabilization.md`
|
||||
* `docs/implementation/task-d52-lead-domain-service-api-foundation.md`
|
||||
* `docs/implementation/task-d53-lead-assignment-create-enquiry-foundation.md`
|
||||
* `docs/security/crm-authorization-boundaries.md`
|
||||
* Existing CRM navigation/sidebar configuration
|
||||
* Existing enquiry list/detail/form UI
|
||||
* Existing lead API routes
|
||||
* Existing enquiry API routes
|
||||
|
||||
---
|
||||
|
||||
## Existing Foundation
|
||||
|
||||
Already available:
|
||||
|
||||
```txt
|
||||
crm_leads
|
||||
crm_enquiries
|
||||
crm_enquiries.lead_id
|
||||
|
||||
/api/crm/leads
|
||||
/api/crm/leads/[id]
|
||||
/api/crm/leads/[id]/followups
|
||||
/api/crm/leads/[id]/assign
|
||||
|
||||
existing enquiry APIs
|
||||
existing quotation APIs
|
||||
```
|
||||
|
||||
Do not recreate:
|
||||
|
||||
```txt
|
||||
tables
|
||||
migrations
|
||||
seed data
|
||||
service foundation
|
||||
assignment service
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Workspace Rules
|
||||
|
||||
### Lead Workspace
|
||||
|
||||
Audience:
|
||||
|
||||
```txt
|
||||
Marketing
|
||||
Marketing Manager
|
||||
CRM Manager
|
||||
Admin
|
||||
```
|
||||
|
||||
Purpose:
|
||||
|
||||
```txt
|
||||
capture lead
|
||||
qualify lead
|
||||
record awareness/status/lost reason
|
||||
assign lead to sales
|
||||
view linked enquiry result
|
||||
```
|
||||
|
||||
Source:
|
||||
|
||||
```txt
|
||||
crm_leads
|
||||
/api/crm/leads
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Enquiry Workspace
|
||||
|
||||
Audience:
|
||||
|
||||
```txt
|
||||
Sales
|
||||
Sales Manager
|
||||
CRM Manager
|
||||
Admin
|
||||
```
|
||||
|
||||
Purpose:
|
||||
|
||||
```txt
|
||||
work assigned opportunity
|
||||
prepare for quotation
|
||||
track sales execution
|
||||
continue existing enquiry lifecycle
|
||||
```
|
||||
|
||||
Source:
|
||||
|
||||
```txt
|
||||
crm_enquiries
|
||||
existing /api/crm/enquiries
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scope D5.4.1 Navigation Separation
|
||||
|
||||
Update CRM navigation so Lead and Enquiry are separate menu items.
|
||||
|
||||
Add:
|
||||
|
||||
```txt
|
||||
CRM > Leads
|
||||
CRM > Enquiries
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
* Lead menu uses lead permission/scope
|
||||
* Enquiry menu keeps existing permission/scope
|
||||
* Do not rename Enquiry menu globally if it breaks existing routes
|
||||
* Do not remove old enquiry route yet
|
||||
|
||||
Suggested routes:
|
||||
|
||||
```txt
|
||||
/dashboard/crm/leads
|
||||
/dashboard/crm/leads/[id]
|
||||
|
||||
/dashboard/crm/enquiries
|
||||
/dashboard/crm/enquiries/[id]
|
||||
```
|
||||
|
||||
If project uses another route prefix, follow existing app route convention.
|
||||
|
||||
---
|
||||
|
||||
## Scope D5.4.2 Lead List Page
|
||||
|
||||
Create Lead list page using `/api/crm/leads`.
|
||||
|
||||
Required columns:
|
||||
|
||||
```txt
|
||||
Lead Code
|
||||
Customer
|
||||
Project Name
|
||||
Status
|
||||
Awareness
|
||||
Estimated Value
|
||||
Suggested Sales Owner
|
||||
Assigned Sales Owner
|
||||
Created Date
|
||||
```
|
||||
|
||||
Required actions:
|
||||
|
||||
```txt
|
||||
View
|
||||
Edit
|
||||
Assign
|
||||
Delete
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
* Assign action appears only when user has assignment permission
|
||||
* Delete action appears only when user has delete permission
|
||||
* No direct enquiry mutation from Lead list
|
||||
* Use existing shadcn/table/list conventions
|
||||
* Use design tokens only
|
||||
* Follow existing CRM page layout pattern
|
||||
|
||||
---
|
||||
|
||||
## Scope D5.4.3 Lead Detail Page
|
||||
|
||||
Create Lead detail page using `/api/crm/leads/[id]`.
|
||||
|
||||
Sections:
|
||||
|
||||
```txt
|
||||
Lead Summary
|
||||
Customer Information
|
||||
Qualification Information
|
||||
Follow-up History
|
||||
Assignment Information
|
||||
Linked Enquiries
|
||||
Audit-safe Activity View
|
||||
```
|
||||
|
||||
Actions:
|
||||
|
||||
```txt
|
||||
Edit Lead
|
||||
Assign Lead
|
||||
Create Follow-up
|
||||
Delete Lead
|
||||
Open Linked Enquiry
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
* Assignment creates or reuses enquiry through `/api/crm/leads/[id]/assign`
|
||||
* After assignment, show linked enquiry
|
||||
* Marketing can see assignment result but must not edit enquiry unless permitted
|
||||
* Follow-up history uses D.5.2 audit-backed implementation until future ADR decides final persistence
|
||||
|
||||
---
|
||||
|
||||
## Scope D5.4.4 Lead Create / Edit Form
|
||||
|
||||
Create or adapt Lead form components.
|
||||
|
||||
Required fields:
|
||||
|
||||
```txt
|
||||
Customer
|
||||
Contact
|
||||
Project Name
|
||||
Project Location
|
||||
Description
|
||||
Product Type
|
||||
Priority
|
||||
Estimated Value
|
||||
Awareness
|
||||
Status
|
||||
Lost Reason
|
||||
Notes
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
* Use master options for awareness, status, lost reason
|
||||
* No hardcoded labels
|
||||
* Product type must use existing option/source convention
|
||||
* Validate with D.5.2 lead schemas
|
||||
* On create, call `POST /api/crm/leads`
|
||||
* On edit, call `PATCH /api/crm/leads/[id]`
|
||||
|
||||
---
|
||||
|
||||
## Scope D5.4.5 Lead Assignment UI
|
||||
|
||||
Create assignment dialog or sheet.
|
||||
|
||||
Input:
|
||||
|
||||
```txt
|
||||
Sales Owner
|
||||
Assignment Remark
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
```txt
|
||||
POST /api/crm/leads/[id]/assign
|
||||
```
|
||||
|
||||
After success:
|
||||
|
||||
```txt
|
||||
show enquiry code
|
||||
show Open Enquiry action
|
||||
refresh lead detail
|
||||
refresh lead list
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
* Sales owner list must use assignable sales members only
|
||||
* If API returns existing enquiry, show reuse message instead of duplicate-created message
|
||||
* Do not create enquiry client-side
|
||||
* Do not mutate `crm_enquiries` directly from UI
|
||||
|
||||
---
|
||||
|
||||
## Scope D5.4.6 Enquiry UI Compatibility
|
||||
|
||||
Keep current enquiry UI operational.
|
||||
|
||||
Add optional linked-lead display if `lead_id` exists:
|
||||
|
||||
```txt
|
||||
Source Lead
|
||||
Lead Code
|
||||
Open Lead
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
* Do not rewrite enquiry workflow
|
||||
* Do not change quotation conversion behavior
|
||||
* Do not change dashboard/report queries
|
||||
* Do not remove `pipeline_stage`
|
||||
* Existing enquiry list/detail must still work
|
||||
|
||||
---
|
||||
|
||||
## Scope D5.4.7 Permission / Visibility Handling
|
||||
|
||||
UI must respect backend permissions but not replace backend enforcement.
|
||||
|
||||
Lead UI:
|
||||
|
||||
```txt
|
||||
crm.lead.read
|
||||
crm.lead.create
|
||||
crm.lead.update
|
||||
crm.lead.delete
|
||||
crm.lead.assign
|
||||
```
|
||||
|
||||
Enquiry UI:
|
||||
|
||||
```txt
|
||||
existing enquiry permissions
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
* Hide unauthorized actions
|
||||
* Handle 403 gracefully
|
||||
* Do not check role strings directly if access helpers exist
|
||||
* Follow existing CRM access resolver/client conventions
|
||||
|
||||
---
|
||||
|
||||
## Scope D5.4.8 Empty / Loading / Error States
|
||||
|
||||
Implement consistent states:
|
||||
|
||||
```txt
|
||||
loading
|
||||
empty
|
||||
not authorized
|
||||
not found
|
||||
server error
|
||||
validation error
|
||||
```
|
||||
|
||||
Messaging:
|
||||
|
||||
* Lead empty state should guide Marketing to create first Lead
|
||||
* Enquiry empty state should remain sales-oriented
|
||||
* Assignment success should clearly identify created/reused enquiry
|
||||
|
||||
---
|
||||
|
||||
## Scope D5.4.9 Documentation
|
||||
|
||||
Create:
|
||||
|
||||
```txt
|
||||
docs/implementation/task-d54-lead-enquiry-workspace-separation-ui-foundation.md
|
||||
```
|
||||
|
||||
Document:
|
||||
|
||||
```txt
|
||||
routes added
|
||||
components added
|
||||
APIs consumed
|
||||
permission mapping
|
||||
compatibility notes
|
||||
known limitations
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deliverables
|
||||
|
||||
New or updated pages:
|
||||
|
||||
```txt
|
||||
src/app/**/crm/leads/page.tsx
|
||||
src/app/**/crm/leads/[id]/page.tsx
|
||||
```
|
||||
|
||||
New or updated components:
|
||||
|
||||
```txt
|
||||
src/features/crm/leads/components/lead-list.tsx
|
||||
src/features/crm/leads/components/lead-form.tsx
|
||||
src/features/crm/leads/components/lead-detail.tsx
|
||||
src/features/crm/leads/components/lead-assignment-dialog.tsx
|
||||
src/features/crm/leads/components/lead-followup-panel.tsx
|
||||
```
|
||||
|
||||
Updated:
|
||||
|
||||
```txt
|
||||
CRM navigation/sidebar
|
||||
Enquiry detail optional Source Lead display
|
||||
```
|
||||
|
||||
Documentation:
|
||||
|
||||
```txt
|
||||
docs/implementation/task-d54-lead-enquiry-workspace-separation-ui-foundation.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Explicit Non-Scope
|
||||
|
||||
Do not:
|
||||
|
||||
* create new tables
|
||||
* create migrations
|
||||
* change lead assignment service behavior
|
||||
* change enquiry service behavior beyond read-only linked lead display
|
||||
* change quotation flow
|
||||
* change approval flow
|
||||
* change dashboard datasets
|
||||
* change reports
|
||||
* implement won/lost synchronization
|
||||
* replace audit-backed lead follow-up persistence
|
||||
* migrate historical enquiry records into leads
|
||||
|
||||
These belong to later D.5 phases.
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
Run:
|
||||
|
||||
```txt
|
||||
npm exec tsc --noEmit
|
||||
```
|
||||
|
||||
Optional if project supports:
|
||||
|
||||
```txt
|
||||
npm run lint
|
||||
npm run build
|
||||
```
|
||||
|
||||
Manual verification:
|
||||
|
||||
```txt
|
||||
Lead menu appears for permitted users
|
||||
Lead menu hidden or blocked for unauthorized users
|
||||
Lead list loads from /api/crm/leads
|
||||
Lead create works
|
||||
Lead edit works
|
||||
Lead detail works
|
||||
Lead assign creates or reuses enquiry
|
||||
Linked enquiry opens from lead detail
|
||||
Existing enquiry page still works
|
||||
Existing quotation flow still works
|
||||
Dashboard still works
|
||||
Reports still work
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Definition of Done
|
||||
|
||||
Task is complete when:
|
||||
|
||||
* Lead and Enquiry appear as separate workspaces
|
||||
* Lead UI uses Lead APIs
|
||||
* Enquiry UI remains compatible
|
||||
* Lead assignment UI creates or reuses enquiry through API
|
||||
* Linked enquiry is visible from Lead detail
|
||||
* Existing enquiry, quotation, dashboard, and reporting flows remain unchanged
|
||||
|
||||
Result:
|
||||
|
||||
```txt
|
||||
Lead Workspace = Established
|
||||
Enquiry Workspace = Preserved
|
||||
Lead / Enquiry UI Boundary = Established
|
||||
Ready for D.5.5 Opportunity / Sales Workspace Refinement
|
||||
```
|
||||
62
src/app/api/crm/leads/[id]/assign/route.ts
Normal file
62
src/app/api/crm/leads/[id]/assign/route.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { assignLeadSchema } from '@/features/crm/leads/schemas/lead.schema';
|
||||
import { assignLead } from '@/features/crm/leads/server/assignment.service';
|
||||
import { buildLeadAccessContext } from '@/features/crm/leads/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmLeadAssign
|
||||
});
|
||||
const payload = assignLeadSchema.parse(await request.json());
|
||||
const accessContext = buildLeadAccessContext({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membership
|
||||
});
|
||||
|
||||
const result = await assignLead(
|
||||
organization.id,
|
||||
session.user.id,
|
||||
{
|
||||
leadId: id,
|
||||
salesOwnerId: payload.salesOwnerId,
|
||||
assignmentRemark: payload.assignmentRemark
|
||||
},
|
||||
accessContext
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Lead assigned successfully',
|
||||
leadId: result.leadId,
|
||||
enquiryId: result.enquiryId,
|
||||
enquiryCode: result.enquiryCode
|
||||
});
|
||||
} 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 assign lead' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -275,14 +275,22 @@ export const crmLeads = pgTable(
|
||||
code: text('code').notNull(),
|
||||
customerId: text('customer_id'),
|
||||
contactId: text('contact_id'),
|
||||
description: text('description'),
|
||||
projectName: text('project_name'),
|
||||
projectLocation: text('project_location'),
|
||||
productType: text('product_type'),
|
||||
priority: text('priority'),
|
||||
estimatedValue: doublePrecision('estimated_value'),
|
||||
awarenessId: text('awareness_id'),
|
||||
status: text('status').default('new_job').notNull(),
|
||||
followupStatus: text('followup_status'),
|
||||
lostReason: text('lost_reason'),
|
||||
outcome: text('outcome').default('open').notNull(),
|
||||
ownerMarketingUserId: text('owner_marketing_user_id'),
|
||||
assignedSalesOwnerId: text('assigned_sales_owner_id'),
|
||||
assignedAt: timestamp('assigned_at', { withTimezone: true }),
|
||||
assignedBy: text('assigned_by'),
|
||||
assignmentRemark: text('assignment_remark'),
|
||||
createdBy: text('created_by').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
|
||||
@@ -384,8 +384,9 @@ const FOUNDATION_OPTIONS = {
|
||||
crm_lead_status: [
|
||||
{ code: 'new_job', label: 'New Job', value: 'new_job', sortOrder: 1 },
|
||||
{ code: 'follow_up', label: 'Follow Up', value: 'follow_up', sortOrder: 2 },
|
||||
{ code: 'closed_lost', label: 'Closed Lost', value: 'closed_lost', sortOrder: 3 },
|
||||
{ code: 'cancel', label: 'Cancel', value: 'cancel', sortOrder: 4 }
|
||||
{ code: 'assigned', label: 'Assigned', value: 'assigned', sortOrder: 3 },
|
||||
{ code: 'closed_lost', label: 'Closed Lost', value: 'closed_lost', sortOrder: 4 },
|
||||
{ code: 'cancel', label: 'Cancel', value: 'cancel', sortOrder: 5 }
|
||||
],
|
||||
crm_lead_followup_status: [
|
||||
{
|
||||
|
||||
@@ -448,7 +448,9 @@ export async function assertAssignableUserBelongsToOrganization(
|
||||
) {
|
||||
const [membership] = await db
|
||||
.select({
|
||||
userId: memberships.userId
|
||||
userId: memberships.userId,
|
||||
role: memberships.role,
|
||||
businessRole: memberships.businessRole
|
||||
})
|
||||
.from(memberships)
|
||||
.where(and(eq(memberships.organizationId, organizationId), eq(memberships.userId, userId)))
|
||||
@@ -458,6 +460,10 @@ export async function assertAssignableUserBelongsToOrganization(
|
||||
throw new AuthError('Assignee not found in organization', 404);
|
||||
}
|
||||
|
||||
if (membership.role !== 'admin' && !ASSIGNABLE_BUSINESS_ROLES.has(membership.businessRole)) {
|
||||
throw new AuthError('Assignee must be a sales owner', 400);
|
||||
}
|
||||
|
||||
return membership;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,14 @@ export const leadSchema = z.object({
|
||||
branchId: z.string().nullable().optional(),
|
||||
customerId: z.string().nullable().optional(),
|
||||
contactId: z.string().nullable().optional(),
|
||||
description: z.string().nullable().optional(),
|
||||
projectName: z.string().nullable().optional(),
|
||||
projectLocation: z.string().nullable().optional(),
|
||||
productType: z.string().nullable().optional(),
|
||||
priority: z.string().nullable().optional(),
|
||||
estimatedValue: z.number().nullable().optional(),
|
||||
awarenessId: z.string().nullable().optional(),
|
||||
status: z.string().min(1, 'Please select a lead status'),
|
||||
status: z.string().min(1, 'Please select lead status'),
|
||||
followupStatus: z.string().nullable().optional(),
|
||||
lostReason: z.string().nullable().optional(),
|
||||
outcome: z.enum(['open', 'won', 'lost']).default('open'),
|
||||
@@ -16,9 +20,14 @@ export const leadSchema = z.object({
|
||||
|
||||
export const updateLeadSchema = leadSchema.partial();
|
||||
|
||||
export const assignLeadSchema = z.object({
|
||||
salesOwnerId: z.string().min(1, 'Sales owner is required'),
|
||||
assignmentRemark: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
export const leadFollowupSchema = z.object({
|
||||
followupDate: z.string().min(1, 'Follow-up date is required'),
|
||||
followupStatus: z.string().min(1, 'Please select a follow-up status'),
|
||||
followupDate: z.string().min(1, 'Follow-up date required'),
|
||||
followupStatus: z.string().min(1, 'Please select follow-up status'),
|
||||
note: z.string().nullable().optional(),
|
||||
nextFollowupDate: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
300
src/features/crm/leads/server/assignment.service.ts
Normal file
300
src/features/crm/leads/server/assignment.service.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import { and, asc, eq, inArray, isNull } from 'drizzle-orm';
|
||||
import { crmEnquiries, crmLeads } from '@/db/schema';
|
||||
import { db } from '@/lib/db';
|
||||
import { AuthError } from '@/lib/auth/session';
|
||||
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
|
||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||
import {
|
||||
canAccessScopedCrmRecord,
|
||||
type CrmSecurityContext
|
||||
} from '@/features/crm/security/server/service';
|
||||
import {
|
||||
assertAssignableUserBelongsToOrganization,
|
||||
assertContactBelongsToOrganization,
|
||||
assertCustomerBelongsToOrganization
|
||||
} from '@/features/crm/enquiries/server/service';
|
||||
import type { AssignLeadInput, AssignLeadResult } from '../types';
|
||||
|
||||
export type LeadAssignmentAccessContext = CrmSecurityContext;
|
||||
|
||||
type LeadRow = typeof crmLeads.$inferSelect;
|
||||
type EnquiryRow = typeof crmEnquiries.$inferSelect;
|
||||
|
||||
function canAccessLeadRow(row: LeadRow, accessContext: LeadAssignmentAccessContext) {
|
||||
return canAccessScopedCrmRecord(accessContext, {
|
||||
branchId: row.branchId,
|
||||
createdBy: row.createdBy,
|
||||
ownerUserId: row.ownerMarketingUserId
|
||||
});
|
||||
}
|
||||
|
||||
async function assertLeadBelongsToOrganization(
|
||||
leadId: string,
|
||||
organizationId: string,
|
||||
accessContext?: LeadAssignmentAccessContext
|
||||
) {
|
||||
const [lead] = await db
|
||||
.select()
|
||||
.from(crmLeads)
|
||||
.where(
|
||||
and(
|
||||
eq(crmLeads.id, leadId),
|
||||
eq(crmLeads.organizationId, organizationId),
|
||||
isNull(crmLeads.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!lead) {
|
||||
throw new AuthError('Lead not found', 404);
|
||||
}
|
||||
|
||||
if (accessContext && !canAccessLeadRow(lead, accessContext)) {
|
||||
throw new AuthError('Forbidden', 403);
|
||||
}
|
||||
|
||||
return lead;
|
||||
}
|
||||
|
||||
async function resolveDefaultEnquiryStatusId(organizationId: string) {
|
||||
const options = await getActiveOptionsByCategory('crm_enquiry_status', { organizationId });
|
||||
const preferred = options.find((option) => option.code === 'new');
|
||||
const resolved = preferred ?? options[0];
|
||||
|
||||
if (!resolved) {
|
||||
throw new AuthError('Enquiry status options are not configured', 400);
|
||||
}
|
||||
|
||||
return resolved.id;
|
||||
}
|
||||
|
||||
async function findActiveEnquiryByLead(leadId: string, organizationId: string) {
|
||||
const [enquiry] = await db
|
||||
.select()
|
||||
.from(crmEnquiries)
|
||||
.where(
|
||||
and(
|
||||
eq(crmEnquiries.leadId, leadId),
|
||||
eq(crmEnquiries.organizationId, organizationId),
|
||||
inArray(crmEnquiries.pipelineStage, ['lead', 'enquiry']),
|
||||
isNull(crmEnquiries.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(crmEnquiries.createdAt))
|
||||
.limit(1);
|
||||
|
||||
return enquiry ?? null;
|
||||
}
|
||||
|
||||
async function syncLeadAssignment(input: {
|
||||
leadId: string;
|
||||
status: string;
|
||||
salesOwnerId: string;
|
||||
assignedBy: string;
|
||||
assignmentRemark?: string | null;
|
||||
}) {
|
||||
const now = new Date();
|
||||
|
||||
await db
|
||||
.update(crmLeads)
|
||||
.set({
|
||||
status: input.status,
|
||||
assignedSalesOwnerId: input.salesOwnerId,
|
||||
assignedAt: now,
|
||||
assignedBy: input.assignedBy,
|
||||
assignmentRemark: input.assignmentRemark?.trim() || null,
|
||||
updatedAt: now
|
||||
})
|
||||
.where(eq(crmLeads.id, input.leadId));
|
||||
}
|
||||
|
||||
async function createEnquiryFromLead(input: {
|
||||
lead: LeadRow;
|
||||
organizationId: string;
|
||||
actorUserId: string;
|
||||
salesOwnerId: string;
|
||||
assignmentRemark?: string | null;
|
||||
}) {
|
||||
if (!input.lead.customerId) {
|
||||
throw new AuthError('Lead requires a customer before assignment', 400);
|
||||
}
|
||||
|
||||
if (!input.lead.productType) {
|
||||
throw new AuthError('Lead requires a product type before assignment', 400);
|
||||
}
|
||||
|
||||
if (!input.lead.priority) {
|
||||
throw new AuthError('Lead requires a priority before assignment', 400);
|
||||
}
|
||||
|
||||
await assertCustomerBelongsToOrganization(input.lead.customerId, input.organizationId);
|
||||
|
||||
if (input.lead.contactId) {
|
||||
await assertContactBelongsToOrganization(
|
||||
input.lead.contactId,
|
||||
input.organizationId,
|
||||
input.lead.customerId
|
||||
);
|
||||
}
|
||||
|
||||
const [statusId, documentCode] = await Promise.all([
|
||||
resolveDefaultEnquiryStatusId(input.organizationId),
|
||||
generateNextDocumentCode({
|
||||
organizationId: input.organizationId,
|
||||
documentType: 'crm_enquiry',
|
||||
branchId: input.lead.branchId ?? null
|
||||
})
|
||||
]);
|
||||
|
||||
const now = new Date();
|
||||
const [created] = await db
|
||||
.insert(crmEnquiries)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId: input.organizationId,
|
||||
branchId: input.lead.branchId ?? null,
|
||||
leadId: input.lead.id,
|
||||
code: documentCode.code,
|
||||
customerId: input.lead.customerId,
|
||||
contactId: input.lead.contactId ?? null,
|
||||
title: input.lead.projectName?.trim() || `Lead ${input.lead.code}`,
|
||||
description: input.lead.description?.trim() || null,
|
||||
requirement: null,
|
||||
projectName: input.lead.projectName?.trim() || null,
|
||||
projectLocation: input.lead.projectLocation?.trim() || null,
|
||||
productType: input.lead.productType,
|
||||
status: statusId,
|
||||
priority: input.lead.priority,
|
||||
leadChannel: null,
|
||||
estimatedValue: input.lead.estimatedValue ?? null,
|
||||
chancePercent: null,
|
||||
expectedCloseDate: null,
|
||||
competitor: null,
|
||||
source: `lead:${input.lead.code}`,
|
||||
notes: null,
|
||||
isHotProject: false,
|
||||
isActive: true,
|
||||
pipelineStage: 'enquiry',
|
||||
assignedToUserId: input.salesOwnerId,
|
||||
assignedAt: now,
|
||||
assignedBy: input.actorUserId,
|
||||
assignmentRemark: input.assignmentRemark?.trim() || null,
|
||||
createdBy: input.actorUserId,
|
||||
updatedBy: input.actorUserId
|
||||
})
|
||||
.returning();
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function assignLead(
|
||||
organizationId: string,
|
||||
actorUserId: string,
|
||||
input: AssignLeadInput,
|
||||
accessContext?: LeadAssignmentAccessContext
|
||||
): Promise<AssignLeadResult> {
|
||||
const lead = await assertLeadBelongsToOrganization(input.leadId, organizationId, accessContext);
|
||||
await assertAssignableUserBelongsToOrganization(input.salesOwnerId, organizationId);
|
||||
|
||||
const existingEnquiry = await findActiveEnquiryByLead(input.leadId, organizationId);
|
||||
|
||||
if (existingEnquiry) {
|
||||
const resolvedSalesOwnerId = existingEnquiry.assignedToUserId ?? input.salesOwnerId;
|
||||
|
||||
if (!existingEnquiry.assignedToUserId) {
|
||||
await db
|
||||
.update(crmEnquiries)
|
||||
.set({
|
||||
pipelineStage: 'enquiry',
|
||||
assignedToUserId: input.salesOwnerId,
|
||||
assignedAt: new Date(),
|
||||
assignedBy: actorUserId,
|
||||
assignmentRemark: input.assignmentRemark?.trim() || null,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: actorUserId
|
||||
})
|
||||
.where(eq(crmEnquiries.id, existingEnquiry.id));
|
||||
}
|
||||
|
||||
await syncLeadAssignment({
|
||||
leadId: input.leadId,
|
||||
status: 'assigned',
|
||||
salesOwnerId: resolvedSalesOwnerId,
|
||||
assignedBy: actorUserId,
|
||||
assignmentRemark: input.assignmentRemark
|
||||
});
|
||||
|
||||
await auditAction({
|
||||
organizationId,
|
||||
branchId: lead.branchId,
|
||||
userId: actorUserId,
|
||||
entityType: 'crm_lead',
|
||||
entityId: input.leadId,
|
||||
action: 'assign_lead',
|
||||
afterData: {
|
||||
leadId: input.leadId,
|
||||
enquiryId: existingEnquiry.id,
|
||||
salesOwnerId: resolvedSalesOwnerId,
|
||||
reusedExistingEnquiry: true
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
leadId: input.leadId,
|
||||
enquiryId: existingEnquiry.id,
|
||||
enquiryCode: existingEnquiry.code
|
||||
};
|
||||
}
|
||||
|
||||
const createdEnquiry = await createEnquiryFromLead({
|
||||
lead,
|
||||
organizationId,
|
||||
actorUserId,
|
||||
salesOwnerId: input.salesOwnerId,
|
||||
assignmentRemark: input.assignmentRemark
|
||||
});
|
||||
|
||||
await syncLeadAssignment({
|
||||
leadId: input.leadId,
|
||||
status: 'assigned',
|
||||
salesOwnerId: input.salesOwnerId,
|
||||
assignedBy: actorUserId,
|
||||
assignmentRemark: input.assignmentRemark
|
||||
});
|
||||
|
||||
await auditAction({
|
||||
organizationId,
|
||||
branchId: lead.branchId,
|
||||
userId: actorUserId,
|
||||
entityType: 'crm_enquiry',
|
||||
entityId: createdEnquiry.id,
|
||||
action: 'create_enquiry_from_lead',
|
||||
afterData: {
|
||||
leadId: input.leadId,
|
||||
enquiryId: createdEnquiry.id,
|
||||
salesOwnerId: input.salesOwnerId
|
||||
}
|
||||
});
|
||||
|
||||
await auditAction({
|
||||
organizationId,
|
||||
branchId: lead.branchId,
|
||||
userId: actorUserId,
|
||||
entityType: 'crm_lead',
|
||||
entityId: input.leadId,
|
||||
action: 'assign_lead',
|
||||
afterData: {
|
||||
leadId: input.leadId,
|
||||
enquiryId: createdEnquiry.id,
|
||||
salesOwnerId: input.salesOwnerId,
|
||||
reusedExistingEnquiry: false
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
leadId: input.leadId,
|
||||
enquiryId: createdEnquiry.id,
|
||||
enquiryCode: createdEnquiry.code
|
||||
};
|
||||
}
|
||||
@@ -49,7 +49,9 @@ const LEAD_OPTION_CATEGORIES = {
|
||||
awareness: 'crm_lead_awareness',
|
||||
status: 'crm_lead_status',
|
||||
followupStatus: 'crm_lead_followup_status',
|
||||
lostReason: 'crm_lead_lost_reason'
|
||||
lostReason: 'crm_lead_lost_reason',
|
||||
productType: 'crm_product_type',
|
||||
priority: 'crm_priority'
|
||||
} as const;
|
||||
|
||||
export type LeadAccessContext = CrmSecurityContext;
|
||||
@@ -61,6 +63,8 @@ type LeadLookupMaps = {
|
||||
statusLabels: Map<string, string>;
|
||||
followupStatusLabels: Map<string, string>;
|
||||
lostReasonLabels: Map<string, string>;
|
||||
productTypeLabels: Map<string, string>;
|
||||
priorityLabels: Map<string, string>;
|
||||
customerNames: Map<string, string>;
|
||||
contactNames: Map<string, string>;
|
||||
userNames: Map<string, string>;
|
||||
@@ -121,8 +125,12 @@ function mapLeadSummary(row: LeadRow, lookups: LeadLookupMaps): LeadSummary {
|
||||
customerName: row.customerId ? (lookups.customerNames.get(row.customerId) ?? null) : null,
|
||||
contactId: row.contactId ?? null,
|
||||
contactName: row.contactId ? (lookups.contactNames.get(row.contactId) ?? null) : null,
|
||||
description: row.description ?? null,
|
||||
projectName: row.projectName ?? null,
|
||||
projectLocation: row.projectLocation ?? null,
|
||||
productType: row.productType ?? null,
|
||||
priority: row.priority ?? null,
|
||||
estimatedValue: row.estimatedValue ?? null,
|
||||
awarenessId: row.awarenessId ?? null,
|
||||
awarenessLabel: row.awarenessId ? (lookups.awarenessLabels.get(row.awarenessId) ?? null) : null,
|
||||
status: row.status,
|
||||
@@ -138,6 +146,14 @@ function mapLeadSummary(row: LeadRow, lookups: LeadLookupMaps): LeadSummary {
|
||||
ownerMarketingName: row.ownerMarketingUserId
|
||||
? (lookups.userNames.get(row.ownerMarketingUserId) ?? null)
|
||||
: null,
|
||||
assignedSalesOwnerId: row.assignedSalesOwnerId ?? null,
|
||||
assignedSalesOwnerName: row.assignedSalesOwnerId
|
||||
? (lookups.userNames.get(row.assignedSalesOwnerId) ?? null)
|
||||
: null,
|
||||
assignedAt: toIsoDateTime(row.assignedAt),
|
||||
assignedBy: row.assignedBy ?? null,
|
||||
assignedByName: row.assignedBy ? (lookups.userNames.get(row.assignedBy) ?? null) : null,
|
||||
assignmentRemark: row.assignmentRemark ?? null,
|
||||
createdBy: row.createdBy,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
@@ -339,12 +355,15 @@ async function assertLeadBelongsToOrganization(
|
||||
}
|
||||
|
||||
async function buildLookupMaps(organizationId: string, rows: LeadRow[]): Promise<LeadLookupMaps> {
|
||||
const [awarenesses, statuses, followupStatuses, lostReasons] = await Promise.all([
|
||||
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.awareness, { organizationId }),
|
||||
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.status, { organizationId }),
|
||||
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.followupStatus, { organizationId }),
|
||||
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.lostReason, { organizationId })
|
||||
]);
|
||||
const [awarenesses, statuses, followupStatuses, lostReasons, productTypes, priorities] =
|
||||
await Promise.all([
|
||||
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.awareness, { organizationId }),
|
||||
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.status, { organizationId }),
|
||||
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.followupStatus, { organizationId }),
|
||||
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.lostReason, { organizationId }),
|
||||
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.productType, { organizationId }),
|
||||
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.priority, { organizationId })
|
||||
]);
|
||||
const customerIds = [
|
||||
...new Set(rows.map((row) => row.customerId).filter((value): value is string => Boolean(value)))
|
||||
];
|
||||
@@ -354,7 +373,12 @@ async function buildLookupMaps(organizationId: string, rows: LeadRow[]): Promise
|
||||
const userIds = [
|
||||
...new Set(
|
||||
rows
|
||||
.flatMap((row) => [row.ownerMarketingUserId, row.createdBy])
|
||||
.flatMap((row) => [
|
||||
row.ownerMarketingUserId,
|
||||
row.assignedSalesOwnerId,
|
||||
row.assignedBy,
|
||||
row.createdBy
|
||||
])
|
||||
.filter((value): value is string => Boolean(value))
|
||||
)
|
||||
];
|
||||
@@ -385,6 +409,8 @@ async function buildLookupMaps(organizationId: string, rows: LeadRow[]): Promise
|
||||
statusLabels: new Map(statuses.map((item) => [item.id, item.label])),
|
||||
followupStatusLabels: new Map(followupStatuses.map((item) => [item.id, item.label])),
|
||||
lostReasonLabels: new Map(lostReasons.map((item) => [item.id, item.label])),
|
||||
productTypeLabels: new Map(productTypes.map((item) => [item.id, item.label])),
|
||||
priorityLabels: new Map(priorities.map((item) => [item.id, item.label])),
|
||||
customerNames: new Map(customerRows.map((item) => [item.id, item.name])),
|
||||
contactNames: new Map(contactRows.map((item) => [item.id, item.name])),
|
||||
userNames: new Map(userRows.map((item) => [item.id, item.name]))
|
||||
@@ -399,6 +425,12 @@ async function validateLeadPayload(
|
||||
) {
|
||||
await Promise.all([
|
||||
assertMasterOptionValue(organizationId, LEAD_OPTION_CATEGORIES.awareness, payload.awarenessId),
|
||||
assertMasterOptionValue(
|
||||
organizationId,
|
||||
LEAD_OPTION_CATEGORIES.productType,
|
||||
payload.productType
|
||||
),
|
||||
assertMasterOptionValue(organizationId, LEAD_OPTION_CATEGORIES.priority, payload.priority),
|
||||
payload.status
|
||||
? assertMasterOptionValue(organizationId, LEAD_OPTION_CATEGORIES.status, payload.status)
|
||||
: Promise.resolve(),
|
||||
@@ -454,19 +486,24 @@ async function validateLeadPayload(
|
||||
}
|
||||
|
||||
export async function getLeadReferenceData(organizationId: string): Promise<LeadReferenceData> {
|
||||
const [awarenesses, statuses, followupStatuses, lostReasons, branches] = await Promise.all([
|
||||
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.awareness, { organizationId }),
|
||||
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.status, { organizationId }),
|
||||
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.followupStatus, { organizationId }),
|
||||
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.lostReason, { organizationId }),
|
||||
getUserBranches()
|
||||
]);
|
||||
const [awarenesses, statuses, followupStatuses, lostReasons, productTypes, priorities, branches] =
|
||||
await Promise.all([
|
||||
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.awareness, { organizationId }),
|
||||
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.status, { organizationId }),
|
||||
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.followupStatus, { organizationId }),
|
||||
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.lostReason, { organizationId }),
|
||||
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.productType, { organizationId }),
|
||||
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.priority, { organizationId }),
|
||||
getUserBranches()
|
||||
]);
|
||||
|
||||
return {
|
||||
awarenesses: awarenesses.map(mapOption),
|
||||
statuses: statuses.map(mapOption),
|
||||
followupStatuses: followupStatuses.map(mapOption),
|
||||
lostReasons: lostReasons.map(mapOption),
|
||||
productTypes: productTypes.map(mapOption),
|
||||
priorities: priorities.map(mapOption),
|
||||
branches: branches.map(mapBranch)
|
||||
};
|
||||
}
|
||||
@@ -570,8 +607,12 @@ export async function createLead(
|
||||
code: codeResult.code,
|
||||
customerId: payload.customerId ?? null,
|
||||
contactId: payload.contactId ?? null,
|
||||
description: payload.description?.trim() || null,
|
||||
projectName: payload.projectName?.trim() || null,
|
||||
projectLocation: payload.projectLocation?.trim() || null,
|
||||
productType: payload.productType ?? null,
|
||||
priority: payload.priority ?? null,
|
||||
estimatedValue: payload.estimatedValue ?? null,
|
||||
awarenessId: payload.awarenessId ?? null,
|
||||
status: payload.status,
|
||||
followupStatus: payload.followupStatus ?? null,
|
||||
@@ -600,9 +641,14 @@ export async function updateLead(
|
||||
payload.contactId === undefined
|
||||
? current.contactId
|
||||
: payload.contactId,
|
||||
description: payload.description === undefined ? current.description : payload.description,
|
||||
projectName: payload.projectName === undefined ? current.projectName : payload.projectName,
|
||||
projectLocation:
|
||||
payload.projectLocation === undefined ? current.projectLocation : payload.projectLocation,
|
||||
productType: payload.productType === undefined ? current.productType : payload.productType,
|
||||
priority: payload.priority === undefined ? current.priority : payload.priority,
|
||||
estimatedValue:
|
||||
payload.estimatedValue === undefined ? current.estimatedValue : payload.estimatedValue,
|
||||
awarenessId: payload.awarenessId === undefined ? current.awarenessId : payload.awarenessId,
|
||||
status: payload.status ?? current.status,
|
||||
followupStatus:
|
||||
@@ -623,8 +669,12 @@ export async function updateLead(
|
||||
branchId: nextPayload.branchId ?? null,
|
||||
customerId: nextPayload.customerId ?? null,
|
||||
contactId: nextPayload.contactId ?? null,
|
||||
description: nextPayload.description?.trim() || null,
|
||||
projectName: nextPayload.projectName?.trim() || null,
|
||||
projectLocation: nextPayload.projectLocation?.trim() || null,
|
||||
productType: nextPayload.productType ?? null,
|
||||
priority: nextPayload.priority ?? null,
|
||||
estimatedValue: nextPayload.estimatedValue ?? null,
|
||||
awarenessId: nextPayload.awarenessId ?? null,
|
||||
status: nextPayload.status,
|
||||
followupStatus: nextPayload.followupStatus ?? null,
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface LeadReferenceData {
|
||||
statuses: LeadOption[];
|
||||
followupStatuses: LeadOption[];
|
||||
lostReasons: LeadOption[];
|
||||
productTypes: LeadOption[];
|
||||
priorities: LeadOption[];
|
||||
branches: LeadBranchOption[];
|
||||
}
|
||||
|
||||
@@ -24,8 +26,12 @@ export interface CreateLeadInput {
|
||||
branchId?: string | null;
|
||||
customerId?: string | null;
|
||||
contactId?: string | null;
|
||||
description?: string | null;
|
||||
projectName?: string | null;
|
||||
projectLocation?: string | null;
|
||||
productType?: string | null;
|
||||
priority?: string | null;
|
||||
estimatedValue?: number | null;
|
||||
awarenessId?: string | null;
|
||||
status: string;
|
||||
followupStatus?: string | null;
|
||||
@@ -38,8 +44,12 @@ export interface UpdateLeadInput {
|
||||
branchId?: string | null;
|
||||
customerId?: string | null;
|
||||
contactId?: string | null;
|
||||
description?: string | null;
|
||||
projectName?: string | null;
|
||||
projectLocation?: string | null;
|
||||
productType?: string | null;
|
||||
priority?: string | null;
|
||||
estimatedValue?: number | null;
|
||||
awarenessId?: string | null;
|
||||
status?: string;
|
||||
followupStatus?: string | null;
|
||||
@@ -69,6 +79,12 @@ export interface CreateLeadFollowupInput {
|
||||
nextFollowupDate?: string | null;
|
||||
}
|
||||
|
||||
export interface AssignLeadInput {
|
||||
leadId: string;
|
||||
salesOwnerId: string;
|
||||
assignmentRemark?: string | null;
|
||||
}
|
||||
|
||||
export interface LeadSummary {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
@@ -78,8 +94,12 @@ export interface LeadSummary {
|
||||
customerName: string | null;
|
||||
contactId: string | null;
|
||||
contactName: string | null;
|
||||
description: string | null;
|
||||
projectName: string | null;
|
||||
projectLocation: string | null;
|
||||
productType: string | null;
|
||||
priority: string | null;
|
||||
estimatedValue: number | null;
|
||||
awarenessId: string | null;
|
||||
awarenessLabel: string | null;
|
||||
status: string;
|
||||
@@ -91,6 +111,12 @@ export interface LeadSummary {
|
||||
outcome: string;
|
||||
ownerMarketingUserId: string | null;
|
||||
ownerMarketingName: string | null;
|
||||
assignedSalesOwnerId: string | null;
|
||||
assignedSalesOwnerName: string | null;
|
||||
assignedAt: string | null;
|
||||
assignedBy: string | null;
|
||||
assignedByName: string | null;
|
||||
assignmentRemark: string | null;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -119,3 +145,9 @@ export interface LeadFollowupSummary {
|
||||
createdBy: string;
|
||||
createdByName: string | null;
|
||||
}
|
||||
|
||||
export interface AssignLeadResult {
|
||||
leadId: string;
|
||||
enquiryId: string;
|
||||
enquiryCode: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user