From 0a144f0f9ef6f54be6e3243ba236c2518f71b42c Mon Sep 17 00:00:00 2001 From: phaichayon Date: Wed, 1 Jul 2026 10:29:00 +0700 Subject: [PATCH] task-d.6.1 --- artifacts/pdf-audit/summary.json | 2 +- artifacts/pdf-audit/summary.md | 2 +- docs/implementation/pdf-audit-report.json | 2 +- docs/implementation/pdf-audit-report.md | 2 +- ...create-form-ux-default-rules-2026-07-01.md | 42 +++ .../0001_lead_channel_and_status_default.sql | 5 + drizzle/meta/_journal.json | 9 +- plans/task-d.6.1.md | 26 ++ src/db/schema.ts | 3 +- .../crm/leads/components/lead-detail.tsx | 1 + .../crm/leads/components/lead-form.tsx | 323 +++++++++++------- src/features/crm/leads/schemas/lead.schema.ts | 22 +- src/features/crm/leads/server/service.ts | 46 ++- src/features/crm/leads/types.ts | 8 +- 14 files changed, 364 insertions(+), 129 deletions(-) create mode 100644 docs/implementation/task-d.6.1-lead-create-form-ux-default-rules-2026-07-01.md create mode 100644 drizzle/0001_lead_channel_and_status_default.sql diff --git a/artifacts/pdf-audit/summary.json b/artifacts/pdf-audit/summary.json index 88af5d6..6ce7f9d 100644 --- a/artifacts/pdf-audit/summary.json +++ b/artifacts/pdf-audit/summary.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-06-30T23:54:34.472Z", + "generatedAt": "2026-07-01T02:01:56.976Z", "overallStatus": "PASS", "quotationCode": "CRA2606-1107", "artifacts": { diff --git a/artifacts/pdf-audit/summary.md b/artifacts/pdf-audit/summary.md index 098101b..990ee17 100644 --- a/artifacts/pdf-audit/summary.md +++ b/artifacts/pdf-audit/summary.md @@ -1,6 +1,6 @@ # PDF Integrity Summary -- Generated At: 2026-06-30T23:54:34.472Z +- Generated At: 2026-07-01T02:01:56.976Z - Overall Status: PASS - Quotation Code: CRA2606-1107 diff --git a/docs/implementation/pdf-audit-report.json b/docs/implementation/pdf-audit-report.json index 9491ec0..243b2cf 100644 --- a/docs/implementation/pdf-audit-report.json +++ b/docs/implementation/pdf-audit-report.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-06-30T23:54:26.582Z", + "generatedAt": "2026-07-01T02:01:55.746Z", "overallStatus": "PASS", "quotationCode": "CRA2606-1107", "expectedFixtureCode": "QT-H5-AUDIT", diff --git a/docs/implementation/pdf-audit-report.md b/docs/implementation/pdf-audit-report.md index c996a77..eb898b6 100644 --- a/docs/implementation/pdf-audit-report.md +++ b/docs/implementation/pdf-audit-report.md @@ -1,6 +1,6 @@ # PDF Audit Report -- Generated At: 2026-06-30T23:54:26.582Z +- Generated At: 2026-07-01T02:01:55.746Z - Overall Status: PASS - Quotation Code: CRA2606-1107 - Template: ALLA Demo Quotation Standard v1.0 diff --git a/docs/implementation/task-d.6.1-lead-create-form-ux-default-rules-2026-07-01.md b/docs/implementation/task-d.6.1-lead-create-form-ux-default-rules-2026-07-01.md new file mode 100644 index 0000000..affdbef --- /dev/null +++ b/docs/implementation/task-d.6.1-lead-create-form-ux-default-rules-2026-07-01.md @@ -0,0 +1,42 @@ +# Task D.6.1 Implementation Report - 2026-07-01 + +## Scope +- Improve Create Lead UX so the first-step form only shows fields needed to open a new lead. +- Enforce default create rules for lead status and marketing owner. +- Add a persisted `Lead Source` field backed by master options. + +## Review Summary +Reviewed before implementation: +- [AGENTS.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/AGENTS.md) +- [plans/task-d.6.1.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/plans/task-d.6.1.md) +- [docs/standards/project-foundations.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/standards/project-foundations.md) +- [docs/standards/architecture-rules.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/standards/architecture-rules.md) +- [docs/standards/ui-ux-rules.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/standards/ui-ux-rules.md) +- [docs/standards/task-review-checklist.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/standards/task-review-checklist.md) +- [docs/business/crm-terminology.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/business/crm-terminology.md) +- [docs/adr/0018-lead-enquiry-domain-separation.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/adr/0018-lead-enquiry-domain-separation.md) +- [task-d.6-crm-activity-foundation-audit-log-separation-2026-07-01.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/task-d.6-crm-activity-foundation-audit-log-separation-2026-07-01.md) +- Lead feature foundation under `src/features/crm/leads/**` +- Master-option foundation under `src/features/foundation/master-options/**` +- Session helper under `src/lib/auth/session.ts` + +## Changes +- Added `lead_channel` to [schema.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/db/schema.ts) and created [0001_lead_channel_and_status_default.sql](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/drizzle/0001_lead_channel_and_status_default.sql). +- Removed the old raw DB default for `crm_leads.status` and moved create-time default resolution into the lead service so new leads resolve `new_job` through master options. +- Extended lead contracts in [types.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/leads/types.ts) to carry `leadChannel`, `leadChannelLabel`, and `leadChannels`. +- Extended [lead.schema.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/leads/schemas/lead.schema.ts) with `leadChannel` and added a dedicated `leadFormSchema` for TanStack Form submit validation. +- Updated [service.ts](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/leads/server/service.ts) to validate `crm_lead_channel`, return `leadChannels` reference data, map `leadChannelLabel`, and enforce create defaults for status, outcome, lost reason, and marketing owner. +- Rebuilt [lead-form.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/leads/components/lead-form.tsx) so create mode hides `Status`, `Lost Reason`, and `Marketing Owner`, while edit mode still exposes the operational fields. +- Updated [lead-detail.tsx](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/leads/components/lead-detail.tsx) to display `Lead Source`. + +## Verification +- `npm run typecheck` -> PASS +- `npm run build` -> PASS +- `npm run audit:pdf` -> PASS + +## Known Follow-up +- Build still emits pre-existing Turbopack warnings about workspace root lockfiles and broad storage/template trace patterns. +- I did not add Lead Source to lead table filters or listing columns in this phase because the task focused on create UX and default rules. + +## Outcome +Task D.6.1 now gives Create Lead a cleaner first-step UX, persists `Lead Source` through the lead domain, and enforces the intended default business rules from the server boundary instead of relying on client behavior alone. diff --git a/drizzle/0001_lead_channel_and_status_default.sql b/drizzle/0001_lead_channel_and_status_default.sql new file mode 100644 index 0000000..66c3a46 --- /dev/null +++ b/drizzle/0001_lead_channel_and_status_default.sql @@ -0,0 +1,5 @@ +ALTER TABLE "crm_leads" + ADD COLUMN IF NOT EXISTS "lead_channel" text; + +ALTER TABLE "crm_leads" + ALTER COLUMN "status" DROP DEFAULT; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index b5a9599..903c277 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1782833951256, "tag": "0000_short_the_executioner", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1782896400000, + "tag": "0001_lead_channel_and_status_default", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/plans/task-d.6.1.md b/plans/task-d.6.1.md index 118d1f7..e35c6f6 100644 --- a/plans/task-d.6.1.md +++ b/plans/task-d.6.1.md @@ -125,3 +125,29 @@ Lead Source * Edit/Detail ยังรองรับ status/lost reason ตาม business rule * `npm run typecheck` * `npm run build` + +--- + +# Implementation Result - 2026-07-01 + +Status: Completed + +Implemented: +- Simplified Create Lead UX by hiding `Lost Reason`, `Status`, and `Marketing Owner` from create mode while keeping them available in edit mode. +- Forced create default rules server-side so new leads always open with `new_job` status, `open` outcome, no lost reason, and `ownerMarketingUserId = current user`. +- Added persisted `leadChannel` support on `crm_leads` and exposed it in the UI as `Lead Source`. +- Reused the existing `crm_lead_channel` master-option foundation for the new Lead Source select field. +- Updated lead reference data, DTOs, server mapping, and detail view to carry and display `Lead Source`. +- Rebuilt the lead form around the repo's TanStack Form pattern with explicit create-vs-edit behavior. + +Verification: +- `npm run typecheck` -> PASS +- `npm run build` -> PASS +- `npm run audit:pdf` -> PASS + +Notes: +- Build still reports existing Turbopack warnings about workspace-root lockfile detection and broad file tracing in storage/template infrastructure. The task did not introduce a blocking build issue. +- Added migration file `drizzle/0001_lead_channel_and_status_default.sql` plus a journal entry for the new `lead_channel` column and removal of the old raw `status` default on `crm_leads`. + +Implementation report: +- [task-d.6.1-lead-create-form-ux-default-rules-2026-07-01.md](/C:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/implementation/task-d.6.1-lead-create-form-ux-default-rules-2026-07-01.md) diff --git a/src/db/schema.ts b/src/db/schema.ts index 89ce574..ae63003 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -426,11 +426,12 @@ export const crmLeads = pgTable( description: text('description'), projectName: text('project_name'), projectLocation: text('project_location'), + leadChannel: text('lead_channel'), productType: text('product_type'), priority: text('priority'), estimatedValue: doublePrecision('estimated_value'), awarenessId: text('awareness_id'), - status: text('status').default('new_job').notNull(), + status: text('status').notNull(), followupStatus: text('followup_status'), lostReason: text('lost_reason'), outcome: text('outcome').default('open').notNull(), diff --git a/src/features/crm/leads/components/lead-detail.tsx b/src/features/crm/leads/components/lead-detail.tsx index 014b984..af04c5c 100644 --- a/src/features/crm/leads/components/lead-detail.tsx +++ b/src/features/crm/leads/components/lead-detail.tsx @@ -94,6 +94,7 @@ export function LeadDetail({ + diff --git a/src/features/crm/leads/components/lead-form.tsx b/src/features/crm/leads/components/lead-form.tsx index 1afde33..70f93ce 100644 --- a/src/features/crm/leads/components/lead-form.tsx +++ b/src/features/crm/leads/components/lead-form.tsx @@ -1,32 +1,39 @@ -'use client'; +"use client"; -import * as React from 'react'; -import { useEffect, useMemo, useState } from 'react'; -import { useMutation } from '@tanstack/react-query'; -import { toast } from 'sonner'; -import { Icons } from '@/components/icons'; -import { Button } from '@/components/ui/button'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import * as React from "react"; +import { useEffect, useMemo, useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { Icons } from "@/components/icons"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, - SheetTitle -} from '@/components/ui/sheet'; -import { useAppForm, useFormFields } from '@/components/ui/tanstack-form'; -import { createLeadMutation, updateLeadMutation } from '../api/mutations'; -import type { LeadReferenceData, LeadSummary } from '../api/types'; -import { leadSchema } from '../schemas/lead.schema'; + SheetTitle, +} from "@/components/ui/sheet"; +import { useAppForm, useFormFields } from "@/components/ui/tanstack-form"; +import { createLeadMutation, updateLeadMutation } from "../api/mutations"; +import type { LeadReferenceData, LeadSummary } from "../api/types"; +import { leadFormSchema } from "../schemas/lead.schema"; -type LeadFormValues = { +type LeadFormValues = Record & { branchId: string | null; customerId: string | null; contactId: string | null; description: string | null; projectName: string | null; projectLocation: string | null; + leadChannel: string | null; productType: string | null; priority: string | null; estimatedValue: number | null; @@ -34,11 +41,22 @@ type LeadFormValues = { status: string; followupStatus: string | null; lostReason: string | null; - outcome: 'open' | 'won' | 'lost'; + outcome: "open" | "won" | "lost"; ownerMarketingUserId: string | null; }; -function toDefaultValues(lead: LeadSummary | undefined, referenceData: LeadReferenceData): LeadFormValues { +function getNewJobStatusId(referenceData: LeadReferenceData) { + return ( + referenceData.statuses.find((item) => item.code === "new_job")?.id ?? + referenceData.statuses[0]?.id ?? + "" + ); +} + +function toDefaultValues( + lead: LeadSummary | undefined, + referenceData: LeadReferenceData, +): LeadFormValues { return { branchId: lead?.branchId ?? referenceData.branches[0]?.id ?? null, customerId: lead?.customerId ?? null, @@ -46,15 +64,19 @@ function toDefaultValues(lead: LeadSummary | undefined, referenceData: LeadRefer description: lead?.description ?? null, projectName: lead?.projectName ?? null, projectLocation: lead?.projectLocation ?? null, + leadChannel: lead?.leadChannel ?? null, productType: lead?.productType ?? referenceData.productTypes[0]?.id ?? null, priority: lead?.priority ?? referenceData.priorities[0]?.id ?? null, estimatedValue: lead?.estimatedValue ?? null, awarenessId: lead?.awarenessId ?? referenceData.awarenesses[0]?.id ?? null, - status: lead?.status ?? referenceData.statuses[0]?.id ?? '', + status: lead?.status ?? getNewJobStatusId(referenceData), followupStatus: lead?.followupStatus ?? null, lostReason: lead?.lostReason ?? null, - outcome: (lead?.outcome as 'open' | 'won' | 'lost' | undefined) ?? 'open', - ownerMarketingUserId: lead?.ownerMarketingUserId ?? null + outcome: + lead?.outcome === "won" || lead?.outcome === "lost" + ? lead.outcome + : "open", + ownerMarketingUserId: lead?.ownerMarketingUserId ?? null, }; } @@ -62,7 +84,7 @@ export function LeadForm({ open, onOpenChange, referenceData, - lead + lead, }: { open: boolean; onOpenChange: (open: boolean) => void; @@ -70,55 +92,73 @@ export function LeadForm({ lead?: LeadSummary; }) { const isEdit = Boolean(lead); - const defaultValues = useMemo(() => toDefaultValues(lead, referenceData), [lead, referenceData]); - const [selectedCustomerId, setSelectedCustomerId] = useState(defaultValues.customerId ?? ''); - const { FormTextField, FormTextareaField, FormSelectField, FormCurrencyField } = - useFormFields(); + const [selectedCustomerId, setSelectedCustomerId] = useState( + lead?.customerId ?? "", + ); + const defaultValues = useMemo( + () => toDefaultValues(lead, referenceData), + [lead, referenceData], + ); + const { + FormCurrencyField, + FormSelectField, + FormTextField, + FormTextareaField, + } = useFormFields(); const createMutation = useMutation({ ...createLeadMutation, onSuccess: () => { - toast.success('สร้างลีดสำเร็จ'); + toast.success("Lead created successfully"); onOpenChange(false); }, onError: (error) => { - toast.error(error instanceof Error ? error.message : 'ไม่สามารถสร้างลีดได้'); - } + toast.error( + error instanceof Error ? error.message : "Unable to create lead", + ); + }, }); const updateMutation = useMutation({ ...updateLeadMutation, onSuccess: () => { - toast.success('อัปเดตลีดสำเร็จ'); + toast.success("Lead updated successfully"); onOpenChange(false); }, onError: (error) => { - toast.error(error instanceof Error ? error.message : 'ไม่สามารถอัปเดตลีดได้'); - } + toast.error( + error instanceof Error ? error.message : "Unable to update lead", + ); + }, }); const contactsForCustomer = referenceData.contacts.filter( - (contact) => contact.customerId === selectedCustomerId + (contact) => contact.customerId === selectedCustomerId, ); const form = useAppForm({ defaultValues, + validators: { + onSubmit: leadFormSchema, + }, onSubmit: async ({ value }) => { const payload = { - ...value, branchId: value.branchId || null, customerId: value.customerId || null, contactId: value.contactId || null, description: value.description || null, projectName: value.projectName || null, projectLocation: value.projectLocation || null, + leadChannel: value.leadChannel || null, productType: value.productType || null, priority: value.priority || null, estimatedValue: value.estimatedValue ?? null, awarenessId: value.awarenessId || null, + status: value.status, followupStatus: value.followupStatus || null, lostReason: value.lostReason || null, - ownerMarketingUserId: value.ownerMarketingUserId || null + outcome: value.outcome, + ownerMarketingUserId: value.ownerMarketingUserId || null, }; if (isEdit && lead) { @@ -127,47 +167,62 @@ export function LeadForm({ } await createMutation.mutateAsync(payload); - } + }, }); useEffect(() => { - if (!open) return; + if (!open) { + return; + } form.reset(defaultValues); - setSelectedCustomerId(defaultValues.customerId ?? ''); + setSelectedCustomerId(defaultValues.customerId ?? ""); }, [defaultValues, form, open]); + const isPending = createMutation.isPending || updateMutation.isPending; + return ( - + - {isEdit ? 'แก้ไขลีด' : 'สร้างลีด'} - บันทึกข้อมูลลีดของฝ่ายการตลาดก่อนส่งต่อให้ฝ่ายขาย + {isEdit ? "Edit Lead" : "Create Lead"} + + Capture marketing lead information before assignment to the sales + workspace. + -
+
- + ( - ลูกค้า + Customer { - field.handleChange(nextValue === '__none__' ? null : nextValue); + field.handleChange( + nextValue === "__none__" ? null : nextValue, + ); field.handleBlur(); }} > - - + + - ไม่เลือก + + No contact selected + {contactsForCustomer.map((contact) => ( {contact.name} - {contact.isPrimary ? ' (หลัก)' : ''} + {contact.isPrimary ? " (Primary)" : ""} ))} @@ -212,95 +275,117 @@ export function LeadForm({ )} /> - - - - - ({ value: branch.id, - label: branch.name + label: branch.name, }))} /> + + + ({ value: item.id, - label: item.label + label: item.label, }))} /> ({ + name="leadChannel" + label="Lead Source" + options={referenceData.leadChannels.map((item) => ({ value: item.id, - label: item.label + label: item.label, }))} /> - ({ value: item.id, - label: item.label + label: item.label, }))} /> + ({ + name="priority" + label="Priority" + options={referenceData.priorities.map((item) => ({ value: item.id, - label: item.label + label: item.label, }))} /> + + + ({ value: item.id, - label: item.label - }))} - /> - ({ - value: item.id, - label: item.label - }))} - /> - ({ - value: user.id, - label: user.name + label: item.label, }))} /> + + {isEdit ? ( + <> + ({ + value: item.id, + label: item.label, + }))} + /> + ({ + value: item.id, + label: item.label, + }))} + /> + ({ + value: user.id, + label: user.name, + }))} + /> + + ) : null} + +
+ +
- @@ -308,16 +393,24 @@ export function LeadForm({ ); } -export function LeadFormTrigger({ referenceData }: { referenceData: LeadReferenceData }) { +export function LeadFormTrigger({ + referenceData, +}: { + referenceData: LeadReferenceData; +}) { const [open, setOpen] = React.useState(false); return ( <> - + ); } diff --git a/src/features/crm/leads/schemas/lead.schema.ts b/src/features/crm/leads/schemas/lead.schema.ts index dc2c328..240bbbc 100644 --- a/src/features/crm/leads/schemas/lead.schema.ts +++ b/src/features/crm/leads/schemas/lead.schema.ts @@ -7,17 +7,37 @@ export const leadSchema = z.object({ description: z.string().nullable().optional(), projectName: z.string().nullable().optional(), projectLocation: z.string().nullable().optional(), + leadChannel: 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 lead status'), + status: z.string().min(1, 'Please select lead status').default('new_job'), followupStatus: z.string().nullable().optional(), lostReason: z.string().nullable().optional(), outcome: z.enum(['open', 'won', 'lost']).default('open'), ownerMarketingUserId: z.string().nullable().optional() }); +export const leadFormSchema = z.object({ + branchId: z.string().nullable(), + customerId: z.string().nullable(), + contactId: z.string().nullable(), + description: z.string().nullable(), + projectName: z.string().nullable(), + projectLocation: z.string().nullable(), + leadChannel: z.string().nullable(), + productType: z.string().nullable(), + priority: z.string().nullable(), + estimatedValue: z.number().nullable(), + awarenessId: z.string().nullable(), + status: z.string().min(1, 'Please select lead status'), + followupStatus: z.string().nullable(), + lostReason: z.string().nullable(), + outcome: z.enum(['open', 'won', 'lost']), + ownerMarketingUserId: z.string().nullable() +}); + export const updateLeadSchema = leadSchema.partial(); export const assignLeadSchema = z.object({ diff --git a/src/features/crm/leads/server/service.ts b/src/features/crm/leads/server/service.ts index 0d8e3ba..7cfd7ee 100644 --- a/src/features/crm/leads/server/service.ts +++ b/src/features/crm/leads/server/service.ts @@ -52,6 +52,7 @@ import type { const LEAD_OPTION_CATEGORIES = { awareness: 'crm_lead_awareness', + leadChannel: 'crm_lead_channel', status: 'crm_lead_status', followupStatus: 'crm_lead_followup_status', lostReason: 'crm_lead_lost_reason', @@ -65,6 +66,7 @@ type LeadRow = typeof crmLeads.$inferSelect; type LeadLookupMaps = { awarenessLabels: Map; + leadChannelLabels: Map; statusLabels: Map; followupStatusLabels: Map; lostReasonLabels: Map; @@ -301,6 +303,10 @@ function mapLeadSummary(row: LeadRow, lookups: LeadLookupMaps): LeadSummary { description: row.description ?? null, projectName: row.projectName ?? null, projectLocation: row.projectLocation ?? null, + leadChannel: row.leadChannel ?? null, + leadChannelLabel: row.leadChannel + ? (lookups.leadChannelLabels.get(row.leadChannel) ?? null) + : null, productType: row.productType ?? null, priority: row.priority ?? null, estimatedValue: row.estimatedValue ?? null, @@ -394,6 +400,15 @@ async function resolveValidOptionIds(category: string, organizationId: string) { return new Set(options.map((option) => option.id)); } +async function resolveOptionIdByCode( + organizationId: string, + category: string, + code: string +) { + const options = await getActiveOptionsByCategory(category, { organizationId }); + return options.find((option) => option.code === code)?.id ?? null; +} + async function assertMasterOptionValue( organizationId: string, category: string, @@ -535,9 +550,10 @@ async function assertLeadBelongsToOrganization( } async function buildLookupMaps(organizationId: string, rows: LeadRow[]): Promise { - const [awarenesses, statuses, followupStatuses, lostReasons, productTypes, priorities] = + const [awarenesses, leadChannels, statuses, followupStatuses, lostReasons, productTypes, priorities] = await Promise.all([ getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.awareness, { organizationId }), + getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.leadChannel, { organizationId }), getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.status, { organizationId }), getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.followupStatus, { organizationId }), getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.lostReason, { organizationId }), @@ -595,6 +611,7 @@ async function buildLookupMaps(organizationId: string, rows: LeadRow[]): Promise return { awarenessLabels: new Map(awarenesses.map((item) => [item.id, item.label])), + leadChannelLabels: new Map(leadChannels.map((item) => [item.id, item.label])), 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])), @@ -615,6 +632,11 @@ async function validateLeadPayload( ) { await Promise.all([ assertMasterOptionValue(organizationId, LEAD_OPTION_CATEGORIES.awareness, payload.awarenessId), + assertMasterOptionValue( + organizationId, + LEAD_OPTION_CATEGORIES.leadChannel, + payload.leadChannel + ), assertMasterOptionValue( organizationId, LEAD_OPTION_CATEGORIES.productType, @@ -678,6 +700,7 @@ async function validateLeadPayload( export async function getLeadReferenceData(organizationId: string): Promise { const [ awarenesses, + leadChannels, statuses, followupStatuses, lostReasons, @@ -690,6 +713,7 @@ export async function getLeadReferenceData(organizationId: string): Promise { await validateLeadPayload(organizationId, userId, payload, accessContext); + const defaultStatusId = + (await resolveOptionIdByCode(organizationId, LEAD_OPTION_CATEGORIES.status, 'new_job')) ?? + payload.status; + + if (!defaultStatusId) { + throw new AuthError('Lead status option new_job is not configured', 400); + } + const codeResult = await generateNextDocumentCode({ organizationId, documentType: 'crm_lead', @@ -824,15 +857,16 @@ export async function createLead( description: payload.description?.trim() || null, projectName: payload.projectName?.trim() || null, projectLocation: payload.projectLocation?.trim() || null, + leadChannel: payload.leadChannel ?? null, productType: payload.productType ?? null, priority: payload.priority ?? null, estimatedValue: payload.estimatedValue ?? null, awarenessId: payload.awarenessId ?? null, - status: payload.status, + status: defaultStatusId, followupStatus: payload.followupStatus ?? null, - lostReason: payload.lostReason ?? null, - outcome: normalizeLeadOutcome(payload.outcome), - ownerMarketingUserId: payload.ownerMarketingUserId ?? userId, + lostReason: null, + outcome: 'open', + ownerMarketingUserId: userId, createdBy: userId }) .returning(); @@ -859,6 +893,7 @@ export async function updateLead( projectName: payload.projectName === undefined ? current.projectName : payload.projectName, projectLocation: payload.projectLocation === undefined ? current.projectLocation : payload.projectLocation, + leadChannel: payload.leadChannel === undefined ? current.leadChannel : payload.leadChannel, productType: payload.productType === undefined ? current.productType : payload.productType, priority: payload.priority === undefined ? current.priority : payload.priority, estimatedValue: @@ -886,6 +921,7 @@ export async function updateLead( description: nextPayload.description?.trim() || null, projectName: nextPayload.projectName?.trim() || null, projectLocation: nextPayload.projectLocation?.trim() || null, + leadChannel: nextPayload.leadChannel ?? null, productType: nextPayload.productType ?? null, priority: nextPayload.priority ?? null, estimatedValue: nextPayload.estimatedValue ?? null, diff --git a/src/features/crm/leads/types.ts b/src/features/crm/leads/types.ts index ace991b..d825ede 100644 --- a/src/features/crm/leads/types.ts +++ b/src/features/crm/leads/types.ts @@ -51,6 +51,7 @@ export type LeadActivityRecord = CrmAuditLogRecord; export interface LeadReferenceData { awarenesses: LeadOption[]; + leadChannels: LeadOption[]; statuses: LeadOption[]; followupStatuses: LeadOption[]; lostReasons: LeadOption[]; @@ -69,11 +70,12 @@ export interface CreateLeadInput { description?: string | null; projectName?: string | null; projectLocation?: string | null; + leadChannel?: string | null; productType?: string | null; priority?: string | null; estimatedValue?: number | null; awarenessId?: string | null; - status: string; + status?: string; followupStatus?: string | null; lostReason?: string | null; outcome?: 'open' | 'won' | 'lost'; @@ -87,6 +89,7 @@ export interface UpdateLeadInput { description?: string | null; projectName?: string | null; projectLocation?: string | null; + leadChannel?: string | null; productType?: string | null; priority?: string | null; estimatedValue?: number | null; @@ -137,6 +140,8 @@ export interface LeadSummary { description: string | null; projectName: string | null; projectLocation: string | null; + leadChannel: string | null; + leadChannelLabel: string | null; productType: string | null; priority: string | null; estimatedValue: number | null; @@ -218,4 +223,3 @@ export interface LeadMutationSuccessResponse { followup?: LeadFollowupSummary; } import type { CrmAuditLogRecord } from '@/features/crm/audit-log/types'; -