task-d.6.1

This commit is contained in:
phaichayon
2026-07-01 10:29:00 +07:00
parent 6d6cd3a6df
commit 0a144f0f9e
14 changed files with 364 additions and 129 deletions

View File

@@ -1,5 +1,5 @@
{ {
"generatedAt": "2026-06-30T23:54:34.472Z", "generatedAt": "2026-07-01T02:01:56.976Z",
"overallStatus": "PASS", "overallStatus": "PASS",
"quotationCode": "CRA2606-1107", "quotationCode": "CRA2606-1107",
"artifacts": { "artifacts": {

View File

@@ -1,6 +1,6 @@
# PDF Integrity Summary # PDF Integrity Summary
- Generated At: 2026-06-30T23:54:34.472Z - Generated At: 2026-07-01T02:01:56.976Z
- Overall Status: PASS - Overall Status: PASS
- Quotation Code: CRA2606-1107 - Quotation Code: CRA2606-1107

View File

@@ -1,5 +1,5 @@
{ {
"generatedAt": "2026-06-30T23:54:26.582Z", "generatedAt": "2026-07-01T02:01:55.746Z",
"overallStatus": "PASS", "overallStatus": "PASS",
"quotationCode": "CRA2606-1107", "quotationCode": "CRA2606-1107",
"expectedFixtureCode": "QT-H5-AUDIT", "expectedFixtureCode": "QT-H5-AUDIT",

View File

@@ -1,6 +1,6 @@
# PDF Audit Report # PDF Audit Report
- Generated At: 2026-06-30T23:54:26.582Z - Generated At: 2026-07-01T02:01:55.746Z
- Overall Status: PASS - Overall Status: PASS
- Quotation Code: CRA2606-1107 - Quotation Code: CRA2606-1107
- Template: ALLA Demo Quotation Standard v1.0 - Template: ALLA Demo Quotation Standard v1.0

View File

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

View File

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

View File

@@ -8,6 +8,13 @@
"when": 1782833951256, "when": 1782833951256,
"tag": "0000_short_the_executioner", "tag": "0000_short_the_executioner",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1782896400000,
"tag": "0001_lead_channel_and_status_default",
"breakpoints": true
} }
] ]
} }

View File

@@ -125,3 +125,29 @@ Lead Source
* Edit/Detail ยังรองรับ status/lost reason ตาม business rule * Edit/Detail ยังรองรับ status/lost reason ตาม business rule
* `npm run typecheck` * `npm run typecheck`
* `npm run build` * `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)

View File

@@ -426,11 +426,12 @@ export const crmLeads = pgTable(
description: text('description'), description: text('description'),
projectName: text('project_name'), projectName: text('project_name'),
projectLocation: text('project_location'), projectLocation: text('project_location'),
leadChannel: text('lead_channel'),
productType: text('product_type'), productType: text('product_type'),
priority: text('priority'), priority: text('priority'),
estimatedValue: doublePrecision('estimated_value'), estimatedValue: doublePrecision('estimated_value'),
awarenessId: text('awareness_id'), awarenessId: text('awareness_id'),
status: text('status').default('new_job').notNull(), status: text('status').notNull(),
followupStatus: text('followup_status'), followupStatus: text('followup_status'),
lostReason: text('lost_reason'), lostReason: text('lost_reason'),
outcome: text('outcome').default('open').notNull(), outcome: text('outcome').default('open').notNull(),

View File

@@ -94,6 +94,7 @@ export function LeadDetail({
<FieldItem label='Contact' value={lead.contactName} /> <FieldItem label='Contact' value={lead.contactName} />
<FieldItem label='Project Name' value={lead.projectName} /> <FieldItem label='Project Name' value={lead.projectName} />
<FieldItem label='Project Location' value={lead.projectLocation} /> <FieldItem label='Project Location' value={lead.projectLocation} />
<FieldItem label='Lead Source' value={lead.leadChannelLabel} />
<FieldItem label='Awareness' value={lead.awarenessLabel} /> <FieldItem label='Awareness' value={lead.awarenessLabel} />
<FieldItem label='Priority' value={lead.priority} /> <FieldItem label='Priority' value={lead.priority} />
<FieldItem label='Marketing Owner' value={lead.ownerMarketingName} /> <FieldItem label='Marketing Owner' value={lead.ownerMarketingName} />

View File

@@ -1,32 +1,39 @@
'use client'; "use client";
import * as React from 'react'; import * as React from "react";
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from "react";
import { useMutation } from '@tanstack/react-query'; import { useMutation } from "@tanstack/react-query";
import { toast } from 'sonner'; import { toast } from "sonner";
import { Icons } from '@/components/icons'; import { Icons } from "@/components/icons";
import { Button } from '@/components/ui/button'; import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
SheetDescription, SheetDescription,
SheetFooter, SheetFooter,
SheetHeader, SheetHeader,
SheetTitle SheetTitle,
} from '@/components/ui/sheet'; } from "@/components/ui/sheet";
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form'; import { useAppForm, useFormFields } from "@/components/ui/tanstack-form";
import { createLeadMutation, updateLeadMutation } from '../api/mutations'; import { createLeadMutation, updateLeadMutation } from "../api/mutations";
import type { LeadReferenceData, LeadSummary } from '../api/types'; import type { LeadReferenceData, LeadSummary } from "../api/types";
import { leadSchema } from '../schemas/lead.schema'; import { leadFormSchema } from "../schemas/lead.schema";
type LeadFormValues = { type LeadFormValues = Record<string, unknown> & {
branchId: string | null; branchId: string | null;
customerId: string | null; customerId: string | null;
contactId: string | null; contactId: string | null;
description: string | null; description: string | null;
projectName: string | null; projectName: string | null;
projectLocation: string | null; projectLocation: string | null;
leadChannel: string | null;
productType: string | null; productType: string | null;
priority: string | null; priority: string | null;
estimatedValue: number | null; estimatedValue: number | null;
@@ -34,11 +41,22 @@ type LeadFormValues = {
status: string; status: string;
followupStatus: string | null; followupStatus: string | null;
lostReason: string | null; lostReason: string | null;
outcome: 'open' | 'won' | 'lost'; outcome: "open" | "won" | "lost";
ownerMarketingUserId: string | null; 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 { return {
branchId: lead?.branchId ?? referenceData.branches[0]?.id ?? null, branchId: lead?.branchId ?? referenceData.branches[0]?.id ?? null,
customerId: lead?.customerId ?? null, customerId: lead?.customerId ?? null,
@@ -46,15 +64,19 @@ function toDefaultValues(lead: LeadSummary | undefined, referenceData: LeadRefer
description: lead?.description ?? null, description: lead?.description ?? null,
projectName: lead?.projectName ?? null, projectName: lead?.projectName ?? null,
projectLocation: lead?.projectLocation ?? null, projectLocation: lead?.projectLocation ?? null,
leadChannel: lead?.leadChannel ?? null,
productType: lead?.productType ?? referenceData.productTypes[0]?.id ?? null, productType: lead?.productType ?? referenceData.productTypes[0]?.id ?? null,
priority: lead?.priority ?? referenceData.priorities[0]?.id ?? null, priority: lead?.priority ?? referenceData.priorities[0]?.id ?? null,
estimatedValue: lead?.estimatedValue ?? null, estimatedValue: lead?.estimatedValue ?? null,
awarenessId: lead?.awarenessId ?? referenceData.awarenesses[0]?.id ?? 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, followupStatus: lead?.followupStatus ?? null,
lostReason: lead?.lostReason ?? null, lostReason: lead?.lostReason ?? null,
outcome: (lead?.outcome as 'open' | 'won' | 'lost' | undefined) ?? 'open', outcome:
ownerMarketingUserId: lead?.ownerMarketingUserId ?? null lead?.outcome === "won" || lead?.outcome === "lost"
? lead.outcome
: "open",
ownerMarketingUserId: lead?.ownerMarketingUserId ?? null,
}; };
} }
@@ -62,7 +84,7 @@ export function LeadForm({
open, open,
onOpenChange, onOpenChange,
referenceData, referenceData,
lead lead,
}: { }: {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
@@ -70,55 +92,73 @@ export function LeadForm({
lead?: LeadSummary; lead?: LeadSummary;
}) { }) {
const isEdit = Boolean(lead); const isEdit = Boolean(lead);
const defaultValues = useMemo(() => toDefaultValues(lead, referenceData), [lead, referenceData]); const [selectedCustomerId, setSelectedCustomerId] = useState(
const [selectedCustomerId, setSelectedCustomerId] = useState(defaultValues.customerId ?? ''); lead?.customerId ?? "",
const { FormTextField, FormTextareaField, FormSelectField, FormCurrencyField } = );
useFormFields<LeadFormValues>(); const defaultValues = useMemo(
() => toDefaultValues(lead, referenceData),
[lead, referenceData],
);
const {
FormCurrencyField,
FormSelectField,
FormTextField,
FormTextareaField,
} = useFormFields<LeadFormValues>();
const createMutation = useMutation({ const createMutation = useMutation({
...createLeadMutation, ...createLeadMutation,
onSuccess: () => { onSuccess: () => {
toast.success('สร้างลีดสำเร็จ'); toast.success("Lead created successfully");
onOpenChange(false); onOpenChange(false);
}, },
onError: (error) => { onError: (error) => {
toast.error(error instanceof Error ? error.message : 'ไม่สามารถสร้างลีดได้'); toast.error(
} error instanceof Error ? error.message : "Unable to create lead",
);
},
}); });
const updateMutation = useMutation({ const updateMutation = useMutation({
...updateLeadMutation, ...updateLeadMutation,
onSuccess: () => { onSuccess: () => {
toast.success('อัปเดตลีดสำเร็จ'); toast.success("Lead updated successfully");
onOpenChange(false); onOpenChange(false);
}, },
onError: (error) => { 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( const contactsForCustomer = referenceData.contacts.filter(
(contact) => contact.customerId === selectedCustomerId (contact) => contact.customerId === selectedCustomerId,
); );
const form = useAppForm({ const form = useAppForm({
defaultValues, defaultValues,
validators: {
onSubmit: leadFormSchema,
},
onSubmit: async ({ value }) => { onSubmit: async ({ value }) => {
const payload = { const payload = {
...value,
branchId: value.branchId || null, branchId: value.branchId || null,
customerId: value.customerId || null, customerId: value.customerId || null,
contactId: value.contactId || null, contactId: value.contactId || null,
description: value.description || null, description: value.description || null,
projectName: value.projectName || null, projectName: value.projectName || null,
projectLocation: value.projectLocation || null, projectLocation: value.projectLocation || null,
leadChannel: value.leadChannel || null,
productType: value.productType || null, productType: value.productType || null,
priority: value.priority || null, priority: value.priority || null,
estimatedValue: value.estimatedValue ?? null, estimatedValue: value.estimatedValue ?? null,
awarenessId: value.awarenessId || null, awarenessId: value.awarenessId || null,
status: value.status,
followupStatus: value.followupStatus || null, followupStatus: value.followupStatus || null,
lostReason: value.lostReason || null, lostReason: value.lostReason || null,
ownerMarketingUserId: value.ownerMarketingUserId || null outcome: value.outcome,
ownerMarketingUserId: value.ownerMarketingUserId || null,
}; };
if (isEdit && lead) { if (isEdit && lead) {
@@ -127,47 +167,62 @@ export function LeadForm({
} }
await createMutation.mutateAsync(payload); await createMutation.mutateAsync(payload);
} },
}); });
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) {
return;
}
form.reset(defaultValues); form.reset(defaultValues);
setSelectedCustomerId(defaultValues.customerId ?? ''); setSelectedCustomerId(defaultValues.customerId ?? "");
}, [defaultValues, form, open]); }, [defaultValues, form, open]);
const isPending = createMutation.isPending || updateMutation.isPending;
return ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className='flex w-full flex-col sm:max-w-3xl'> <SheetContent className="flex w-full flex-col sm:max-w-3xl">
<SheetHeader> <SheetHeader>
<SheetTitle>{isEdit ? 'แก้ไขลีด' : 'สร้างลีด'}</SheetTitle> <SheetTitle>{isEdit ? "Edit Lead" : "Create Lead"}</SheetTitle>
<SheetDescription></SheetDescription> <SheetDescription>
Capture marketing lead information before assignment to the sales
workspace.
</SheetDescription>
</SheetHeader> </SheetHeader>
<div className='flex-1 overflow-auto'> <div className="flex-1 overflow-auto">
<form.AppForm> <form.AppForm>
<form.Form id='lead-form' className='grid gap-4 md:grid-cols-2'> <form.Form id="lead-form" className="grid gap-4 md:grid-cols-2">
<form.AppField <form.AppField
name='customerId' name="customerId"
children={(field) => ( children={(field) => (
<field.FieldSet> <field.FieldSet>
<field.Field> <field.Field>
<field.FieldLabel></field.FieldLabel> <field.FieldLabel>Customer</field.FieldLabel>
<Select <Select
value={field.state.value ?? ''} value={
typeof field.state.value === "string"
? field.state.value
: "__none__"
}
onValueChange={(nextValue) => { onValueChange={(nextValue) => {
field.handleChange(nextValue === '__none__' ? null : nextValue); const customerId =
nextValue === "__none__" ? null : nextValue;
field.handleChange(customerId);
field.handleBlur(); field.handleBlur();
setSelectedCustomerId(nextValue === '__none__' ? '' : nextValue); setSelectedCustomerId(customerId ?? "");
form.setFieldValue('contactId', null); form.setFieldValue("contactId", null);
}} }}
> >
<SelectTrigger aria-label='ลูกค้า'> <SelectTrigger aria-label="Customer">
<SelectValue placeholder='เลือกลูกค้า' /> <SelectValue placeholder="Select customer" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value='__none__'></SelectItem> <SelectItem value="__none__">
No customer selected
</SelectItem>
{referenceData.customers.map((customer) => ( {referenceData.customers.map((customer) => (
<SelectItem key={customer.id} value={customer.id}> <SelectItem key={customer.id} value={customer.id}>
{customer.name} ({customer.code}) {customer.name} ({customer.code})
@@ -182,27 +237,35 @@ export function LeadForm({
/> />
<form.AppField <form.AppField
name='contactId' name="contactId"
children={(field) => ( children={(field) => (
<field.FieldSet> <field.FieldSet>
<field.Field> <field.Field>
<field.FieldLabel></field.FieldLabel> <field.FieldLabel>Contact</field.FieldLabel>
<Select <Select
value={field.state.value ?? '__none__'} value={
typeof field.state.value === "string"
? field.state.value
: "__none__"
}
onValueChange={(nextValue) => { onValueChange={(nextValue) => {
field.handleChange(nextValue === '__none__' ? null : nextValue); field.handleChange(
nextValue === "__none__" ? null : nextValue,
);
field.handleBlur(); field.handleBlur();
}} }}
> >
<SelectTrigger aria-label='ผู้ติดต่อ'> <SelectTrigger aria-label="Contact">
<SelectValue placeholder='เลือกผู้ติดต่อ' /> <SelectValue placeholder="Select contact" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value='__none__'></SelectItem> <SelectItem value="__none__">
No contact selected
</SelectItem>
{contactsForCustomer.map((contact) => ( {contactsForCustomer.map((contact) => (
<SelectItem key={contact.id} value={contact.id}> <SelectItem key={contact.id} value={contact.id}>
{contact.name} {contact.name}
{contact.isPrimary ? ' (หลัก)' : ''} {contact.isPrimary ? " (Primary)" : ""}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -212,95 +275,117 @@ export function LeadForm({
</field.FieldSet> </field.FieldSet>
)} )}
/> />
<FormTextField name='projectName' label='Project Name' />
<FormTextField name='projectLocation' label='Project Location' />
<FormTextareaField name='description' label='Description' className='md:col-span-2' />
<FormSelectField <FormSelectField
name='branchId' name="branchId"
label='สาขา' label="Branch"
options={referenceData.branches.map((branch) => ({ options={referenceData.branches.map((branch) => ({
value: branch.id, value: branch.id,
label: branch.name label: branch.name,
}))} }))}
/> />
<FormTextField name="projectName" label="Project Name" />
<FormTextField name="projectLocation" label="Project Location" />
<FormSelectField <FormSelectField
name='productType' name="productType"
label='Product Type' label="Product Type"
options={referenceData.productTypes.map((item) => ({ options={referenceData.productTypes.map((item) => ({
value: item.id, value: item.id,
label: item.label label: item.label,
}))} }))}
/> />
<FormSelectField <FormSelectField
name='priority' name="leadChannel"
label='Priority' label="Lead Source"
options={referenceData.priorities.map((item) => ({ options={referenceData.leadChannels.map((item) => ({
value: item.id, value: item.id,
label: item.label label: item.label,
}))} }))}
/> />
<FormCurrencyField name='estimatedValue' label='Estimated Value' currencyLabel='THB' />
<FormSelectField <FormSelectField
name='awarenessId' name="awarenessId"
label='Awareness' label="Awareness"
options={referenceData.awarenesses.map((item) => ({ options={referenceData.awarenesses.map((item) => ({
value: item.id, value: item.id,
label: item.label label: item.label,
}))} }))}
/> />
<FormSelectField <FormSelectField
name='status' name="priority"
label='Status' label="Priority"
options={referenceData.statuses.map((item) => ({ options={referenceData.priorities.map((item) => ({
value: item.id, value: item.id,
label: item.label label: item.label,
}))} }))}
/> />
<FormCurrencyField
name="estimatedValue"
label="Estimated Value"
currencyLabel="THB"
/>
<FormSelectField <FormSelectField
name='followupStatus' name="followupStatus"
label='Follow-up Status' label="Follow-up Status"
options={referenceData.followupStatuses.map((item) => ({ options={referenceData.followupStatuses.map((item) => ({
value: item.id, value: item.id,
label: item.label label: item.label,
}))}
/>
{isEdit ? (
<>
<FormSelectField
name="status"
label="Status"
options={referenceData.statuses.map((item) => ({
value: item.id,
label: item.label,
}))} }))}
/> />
<FormSelectField <FormSelectField
name='lostReason' name="lostReason"
label='Lost Reason' label="Lost Reason"
options={referenceData.lostReasons.map((item) => ({ options={referenceData.lostReasons.map((item) => ({
value: item.id, value: item.id,
label: item.label label: item.label,
}))} }))}
/> />
<FormSelectField <FormSelectField
name='ownerMarketingUserId' name="ownerMarketingUserId"
label='Marketing Owner' label="Marketing Owner"
options={referenceData.assignableUsers.map((user) => ({ options={referenceData.assignableUsers.map((user) => ({
value: user.id, value: user.id,
label: user.name label: user.name,
}))} }))}
/> />
</>
) : null}
<div className="md:col-span-2">
<FormTextareaField
name="description"
label="Description"
size="md"
/>
</div>
</form.Form> </form.Form>
</form.AppForm> </form.AppForm>
</div> </div>
<SheetFooter> <SheetFooter>
<Button <Button
variant='outline' variant="outline"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
disabled={createMutation.isPending || updateMutation.isPending} disabled={isPending}
> >
Cancel
</Button> </Button>
<Button <Button type="submit" form="lead-form" isLoading={isPending}>
type='submit' <Icons.add className="mr-2 h-4 w-4" />
form='lead-form' {isEdit ? "Update Lead" : "Create Lead"}
isLoading={createMutation.isPending || updateMutation.isPending}
>
<Icons.add className='mr-2 h-4 w-4' />
{isEdit ? 'อัปเดตลีด' : 'สร้างลีด'}
</Button> </Button>
</SheetFooter> </SheetFooter>
</SheetContent> </SheetContent>
@@ -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); const [open, setOpen] = React.useState(false);
return ( return (
<> <>
<Button onClick={() => setOpen(true)}> <Button onClick={() => setOpen(true)}>
<Icons.add className='mr-2 h-4 w-4' /> <Icons.add className="mr-2 h-4 w-4" />
Add Lead
</Button> </Button>
<LeadForm open={open} onOpenChange={setOpen} referenceData={referenceData} /> <LeadForm
open={open}
onOpenChange={setOpen}
referenceData={referenceData}
/>
</> </>
); );
} }

View File

@@ -7,17 +7,37 @@ export const leadSchema = z.object({
description: z.string().nullable().optional(), description: z.string().nullable().optional(),
projectName: z.string().nullable().optional(), projectName: z.string().nullable().optional(),
projectLocation: z.string().nullable().optional(), projectLocation: z.string().nullable().optional(),
leadChannel: z.string().nullable().optional(),
productType: z.string().nullable().optional(), productType: z.string().nullable().optional(),
priority: z.string().nullable().optional(), priority: z.string().nullable().optional(),
estimatedValue: z.number().nullable().optional(), estimatedValue: z.number().nullable().optional(),
awarenessId: z.string().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(), followupStatus: z.string().nullable().optional(),
lostReason: z.string().nullable().optional(), lostReason: z.string().nullable().optional(),
outcome: z.enum(['open', 'won', 'lost']).default('open'), outcome: z.enum(['open', 'won', 'lost']).default('open'),
ownerMarketingUserId: z.string().nullable().optional() 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 updateLeadSchema = leadSchema.partial();
export const assignLeadSchema = z.object({ export const assignLeadSchema = z.object({

View File

@@ -52,6 +52,7 @@ import type {
const LEAD_OPTION_CATEGORIES = { const LEAD_OPTION_CATEGORIES = {
awareness: 'crm_lead_awareness', awareness: 'crm_lead_awareness',
leadChannel: 'crm_lead_channel',
status: 'crm_lead_status', status: 'crm_lead_status',
followupStatus: 'crm_lead_followup_status', followupStatus: 'crm_lead_followup_status',
lostReason: 'crm_lead_lost_reason', lostReason: 'crm_lead_lost_reason',
@@ -65,6 +66,7 @@ type LeadRow = typeof crmLeads.$inferSelect;
type LeadLookupMaps = { type LeadLookupMaps = {
awarenessLabels: Map<string, string>; awarenessLabels: Map<string, string>;
leadChannelLabels: Map<string, string>;
statusLabels: Map<string, string>; statusLabels: Map<string, string>;
followupStatusLabels: Map<string, string>; followupStatusLabels: Map<string, string>;
lostReasonLabels: Map<string, string>; lostReasonLabels: Map<string, string>;
@@ -301,6 +303,10 @@ function mapLeadSummary(row: LeadRow, lookups: LeadLookupMaps): LeadSummary {
description: row.description ?? null, description: row.description ?? null,
projectName: row.projectName ?? null, projectName: row.projectName ?? null,
projectLocation: row.projectLocation ?? null, projectLocation: row.projectLocation ?? null,
leadChannel: row.leadChannel ?? null,
leadChannelLabel: row.leadChannel
? (lookups.leadChannelLabels.get(row.leadChannel) ?? null)
: null,
productType: row.productType ?? null, productType: row.productType ?? null,
priority: row.priority ?? null, priority: row.priority ?? null,
estimatedValue: row.estimatedValue ?? null, estimatedValue: row.estimatedValue ?? null,
@@ -394,6 +400,15 @@ async function resolveValidOptionIds(category: string, organizationId: string) {
return new Set(options.map((option) => option.id)); 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( async function assertMasterOptionValue(
organizationId: string, organizationId: string,
category: string, category: string,
@@ -535,9 +550,10 @@ async function assertLeadBelongsToOrganization(
} }
async function buildLookupMaps(organizationId: string, rows: LeadRow[]): Promise<LeadLookupMaps> { async function buildLookupMaps(organizationId: string, rows: LeadRow[]): Promise<LeadLookupMaps> {
const [awarenesses, statuses, followupStatuses, lostReasons, productTypes, priorities] = const [awarenesses, leadChannels, statuses, followupStatuses, lostReasons, productTypes, priorities] =
await Promise.all([ await Promise.all([
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.awareness, { organizationId }), getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.awareness, { organizationId }),
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.leadChannel, { organizationId }),
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.status, { organizationId }), getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.status, { organizationId }),
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.followupStatus, { organizationId }), getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.followupStatus, { organizationId }),
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.lostReason, { organizationId }), getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.lostReason, { organizationId }),
@@ -595,6 +611,7 @@ async function buildLookupMaps(organizationId: string, rows: LeadRow[]): Promise
return { return {
awarenessLabels: new Map(awarenesses.map((item) => [item.id, item.label])), 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])), statusLabels: new Map(statuses.map((item) => [item.id, item.label])),
followupStatusLabels: new Map(followupStatuses.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])), lostReasonLabels: new Map(lostReasons.map((item) => [item.id, item.label])),
@@ -615,6 +632,11 @@ async function validateLeadPayload(
) { ) {
await Promise.all([ await Promise.all([
assertMasterOptionValue(organizationId, LEAD_OPTION_CATEGORIES.awareness, payload.awarenessId), assertMasterOptionValue(organizationId, LEAD_OPTION_CATEGORIES.awareness, payload.awarenessId),
assertMasterOptionValue(
organizationId,
LEAD_OPTION_CATEGORIES.leadChannel,
payload.leadChannel
),
assertMasterOptionValue( assertMasterOptionValue(
organizationId, organizationId,
LEAD_OPTION_CATEGORIES.productType, LEAD_OPTION_CATEGORIES.productType,
@@ -678,6 +700,7 @@ async function validateLeadPayload(
export async function getLeadReferenceData(organizationId: string): Promise<LeadReferenceData> { export async function getLeadReferenceData(organizationId: string): Promise<LeadReferenceData> {
const [ const [
awarenesses, awarenesses,
leadChannels,
statuses, statuses,
followupStatuses, followupStatuses,
lostReasons, lostReasons,
@@ -690,6 +713,7 @@ export async function getLeadReferenceData(organizationId: string): Promise<Lead
] = ] =
await Promise.all([ await Promise.all([
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.awareness, { organizationId }), getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.awareness, { organizationId }),
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.leadChannel, { organizationId }),
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.status, { organizationId }), getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.status, { organizationId }),
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.followupStatus, { organizationId }), getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.followupStatus, { organizationId }),
getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.lostReason, { organizationId }), getActiveOptionsByCategory(LEAD_OPTION_CATEGORIES.lostReason, { organizationId }),
@@ -703,6 +727,7 @@ export async function getLeadReferenceData(organizationId: string): Promise<Lead
return { return {
awarenesses: awarenesses.map(mapOption), awarenesses: awarenesses.map(mapOption),
leadChannels: leadChannels.map(mapOption),
statuses: statuses.map(mapOption), statuses: statuses.map(mapOption),
followupStatuses: followupStatuses.map(mapOption), followupStatuses: followupStatuses.map(mapOption),
lostReasons: lostReasons.map(mapOption), lostReasons: lostReasons.map(mapOption),
@@ -806,6 +831,14 @@ export async function createLead(
accessContext?: LeadAccessContext accessContext?: LeadAccessContext
): Promise<LeadDetail> { ): Promise<LeadDetail> {
await validateLeadPayload(organizationId, userId, payload, accessContext); 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({ const codeResult = await generateNextDocumentCode({
organizationId, organizationId,
documentType: 'crm_lead', documentType: 'crm_lead',
@@ -824,15 +857,16 @@ export async function createLead(
description: payload.description?.trim() || null, description: payload.description?.trim() || null,
projectName: payload.projectName?.trim() || null, projectName: payload.projectName?.trim() || null,
projectLocation: payload.projectLocation?.trim() || null, projectLocation: payload.projectLocation?.trim() || null,
leadChannel: payload.leadChannel ?? null,
productType: payload.productType ?? null, productType: payload.productType ?? null,
priority: payload.priority ?? null, priority: payload.priority ?? null,
estimatedValue: payload.estimatedValue ?? null, estimatedValue: payload.estimatedValue ?? null,
awarenessId: payload.awarenessId ?? null, awarenessId: payload.awarenessId ?? null,
status: payload.status, status: defaultStatusId,
followupStatus: payload.followupStatus ?? null, followupStatus: payload.followupStatus ?? null,
lostReason: payload.lostReason ?? null, lostReason: null,
outcome: normalizeLeadOutcome(payload.outcome), outcome: 'open',
ownerMarketingUserId: payload.ownerMarketingUserId ?? userId, ownerMarketingUserId: userId,
createdBy: userId createdBy: userId
}) })
.returning(); .returning();
@@ -859,6 +893,7 @@ export async function updateLead(
projectName: payload.projectName === undefined ? current.projectName : payload.projectName, projectName: payload.projectName === undefined ? current.projectName : payload.projectName,
projectLocation: projectLocation:
payload.projectLocation === undefined ? current.projectLocation : payload.projectLocation, payload.projectLocation === undefined ? current.projectLocation : payload.projectLocation,
leadChannel: payload.leadChannel === undefined ? current.leadChannel : payload.leadChannel,
productType: payload.productType === undefined ? current.productType : payload.productType, productType: payload.productType === undefined ? current.productType : payload.productType,
priority: payload.priority === undefined ? current.priority : payload.priority, priority: payload.priority === undefined ? current.priority : payload.priority,
estimatedValue: estimatedValue:
@@ -886,6 +921,7 @@ export async function updateLead(
description: nextPayload.description?.trim() || null, description: nextPayload.description?.trim() || null,
projectName: nextPayload.projectName?.trim() || null, projectName: nextPayload.projectName?.trim() || null,
projectLocation: nextPayload.projectLocation?.trim() || null, projectLocation: nextPayload.projectLocation?.trim() || null,
leadChannel: nextPayload.leadChannel ?? null,
productType: nextPayload.productType ?? null, productType: nextPayload.productType ?? null,
priority: nextPayload.priority ?? null, priority: nextPayload.priority ?? null,
estimatedValue: nextPayload.estimatedValue ?? null, estimatedValue: nextPayload.estimatedValue ?? null,

View File

@@ -51,6 +51,7 @@ export type LeadActivityRecord = CrmAuditLogRecord;
export interface LeadReferenceData { export interface LeadReferenceData {
awarenesses: LeadOption[]; awarenesses: LeadOption[];
leadChannels: LeadOption[];
statuses: LeadOption[]; statuses: LeadOption[];
followupStatuses: LeadOption[]; followupStatuses: LeadOption[];
lostReasons: LeadOption[]; lostReasons: LeadOption[];
@@ -69,11 +70,12 @@ export interface CreateLeadInput {
description?: string | null; description?: string | null;
projectName?: string | null; projectName?: string | null;
projectLocation?: string | null; projectLocation?: string | null;
leadChannel?: string | null;
productType?: string | null; productType?: string | null;
priority?: string | null; priority?: string | null;
estimatedValue?: number | null; estimatedValue?: number | null;
awarenessId?: string | null; awarenessId?: string | null;
status: string; status?: string;
followupStatus?: string | null; followupStatus?: string | null;
lostReason?: string | null; lostReason?: string | null;
outcome?: 'open' | 'won' | 'lost'; outcome?: 'open' | 'won' | 'lost';
@@ -87,6 +89,7 @@ export interface UpdateLeadInput {
description?: string | null; description?: string | null;
projectName?: string | null; projectName?: string | null;
projectLocation?: string | null; projectLocation?: string | null;
leadChannel?: string | null;
productType?: string | null; productType?: string | null;
priority?: string | null; priority?: string | null;
estimatedValue?: number | null; estimatedValue?: number | null;
@@ -137,6 +140,8 @@ export interface LeadSummary {
description: string | null; description: string | null;
projectName: string | null; projectName: string | null;
projectLocation: string | null; projectLocation: string | null;
leadChannel: string | null;
leadChannelLabel: string | null;
productType: string | null; productType: string | null;
priority: string | null; priority: string | null;
estimatedValue: number | null; estimatedValue: number | null;
@@ -218,4 +223,3 @@ export interface LeadMutationSuccessResponse {
followup?: LeadFollowupSummary; followup?: LeadFollowupSummary;
} }
import type { CrmAuditLogRecord } from '@/features/crm/audit-log/types'; import type { CrmAuditLogRecord } from '@/features/crm/audit-log/types';