task-d.6.1
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"generatedAt": "2026-06-30T23:54:34.472Z",
|
||||
"generatedAt": "2026-07-01T02:01:56.976Z",
|
||||
"overallStatus": "PASS",
|
||||
"quotationCode": "CRA2606-1107",
|
||||
"artifacts": {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
5
drizzle/0001_lead_channel_and_status_default.sql
Normal file
5
drizzle/0001_lead_channel_and_status_default.sql
Normal 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;
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -94,6 +94,7 @@ export function LeadDetail({
|
||||
<FieldItem label='Contact' value={lead.contactName} />
|
||||
<FieldItem label='Project Name' value={lead.projectName} />
|
||||
<FieldItem label='Project Location' value={lead.projectLocation} />
|
||||
<FieldItem label='Lead Source' value={lead.leadChannelLabel} />
|
||||
<FieldItem label='Awareness' value={lead.awarenessLabel} />
|
||||
<FieldItem label='Priority' value={lead.priority} />
|
||||
<FieldItem label='Marketing Owner' value={lead.ownerMarketingName} />
|
||||
|
||||
@@ -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<string, unknown> & {
|
||||
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<LeadFormValues>();
|
||||
const [selectedCustomerId, setSelectedCustomerId] = useState(
|
||||
lead?.customerId ?? "",
|
||||
);
|
||||
const defaultValues = useMemo(
|
||||
() => toDefaultValues(lead, referenceData),
|
||||
[lead, referenceData],
|
||||
);
|
||||
const {
|
||||
FormCurrencyField,
|
||||
FormSelectField,
|
||||
FormTextField,
|
||||
FormTextareaField,
|
||||
} = useFormFields<LeadFormValues>();
|
||||
|
||||
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 (
|
||||
<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>
|
||||
<SheetTitle>{isEdit ? 'แก้ไขลีด' : 'สร้างลีด'}</SheetTitle>
|
||||
<SheetDescription>บันทึกข้อมูลลีดของฝ่ายการตลาดก่อนส่งต่อให้ฝ่ายขาย</SheetDescription>
|
||||
<SheetTitle>{isEdit ? "Edit Lead" : "Create Lead"}</SheetTitle>
|
||||
<SheetDescription>
|
||||
Capture marketing lead information before assignment to the sales
|
||||
workspace.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className='flex-1 overflow-auto'>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<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
|
||||
name='customerId'
|
||||
name="customerId"
|
||||
children={(field) => (
|
||||
<field.FieldSet>
|
||||
<field.Field>
|
||||
<field.FieldLabel>ลูกค้า</field.FieldLabel>
|
||||
<field.FieldLabel>Customer</field.FieldLabel>
|
||||
<Select
|
||||
value={field.state.value ?? ''}
|
||||
value={
|
||||
typeof field.state.value === "string"
|
||||
? field.state.value
|
||||
: "__none__"
|
||||
}
|
||||
onValueChange={(nextValue) => {
|
||||
field.handleChange(nextValue === '__none__' ? null : nextValue);
|
||||
const customerId =
|
||||
nextValue === "__none__" ? null : nextValue;
|
||||
field.handleChange(customerId);
|
||||
field.handleBlur();
|
||||
setSelectedCustomerId(nextValue === '__none__' ? '' : nextValue);
|
||||
form.setFieldValue('contactId', null);
|
||||
setSelectedCustomerId(customerId ?? "");
|
||||
form.setFieldValue("contactId", null);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger aria-label='ลูกค้า'>
|
||||
<SelectValue placeholder='เลือกลูกค้า' />
|
||||
<SelectTrigger aria-label="Customer">
|
||||
<SelectValue placeholder="Select customer" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='__none__'>ไม่เลือก</SelectItem>
|
||||
<SelectItem value="__none__">
|
||||
No customer selected
|
||||
</SelectItem>
|
||||
{referenceData.customers.map((customer) => (
|
||||
<SelectItem key={customer.id} value={customer.id}>
|
||||
{customer.name} ({customer.code})
|
||||
@@ -182,27 +237,35 @@ export function LeadForm({
|
||||
/>
|
||||
|
||||
<form.AppField
|
||||
name='contactId'
|
||||
name="contactId"
|
||||
children={(field) => (
|
||||
<field.FieldSet>
|
||||
<field.Field>
|
||||
<field.FieldLabel>ผู้ติดต่อ</field.FieldLabel>
|
||||
<field.FieldLabel>Contact</field.FieldLabel>
|
||||
<Select
|
||||
value={field.state.value ?? '__none__'}
|
||||
value={
|
||||
typeof field.state.value === "string"
|
||||
? field.state.value
|
||||
: "__none__"
|
||||
}
|
||||
onValueChange={(nextValue) => {
|
||||
field.handleChange(nextValue === '__none__' ? null : nextValue);
|
||||
field.handleChange(
|
||||
nextValue === "__none__" ? null : nextValue,
|
||||
);
|
||||
field.handleBlur();
|
||||
}}
|
||||
>
|
||||
<SelectTrigger aria-label='ผู้ติดต่อ'>
|
||||
<SelectValue placeholder='เลือกผู้ติดต่อ' />
|
||||
<SelectTrigger aria-label="Contact">
|
||||
<SelectValue placeholder="Select contact" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='__none__'>ไม่เลือก</SelectItem>
|
||||
<SelectItem value="__none__">
|
||||
No contact selected
|
||||
</SelectItem>
|
||||
{contactsForCustomer.map((contact) => (
|
||||
<SelectItem key={contact.id} value={contact.id}>
|
||||
{contact.name}
|
||||
{contact.isPrimary ? ' (หลัก)' : ''}
|
||||
{contact.isPrimary ? " (Primary)" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -212,95 +275,117 @@ export function LeadForm({
|
||||
</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
|
||||
name='branchId'
|
||||
label='สาขา'
|
||||
name="branchId"
|
||||
label="Branch"
|
||||
options={referenceData.branches.map((branch) => ({
|
||||
value: branch.id,
|
||||
label: branch.name
|
||||
label: branch.name,
|
||||
}))}
|
||||
/>
|
||||
<FormTextField name="projectName" label="Project Name" />
|
||||
<FormTextField name="projectLocation" label="Project Location" />
|
||||
|
||||
<FormSelectField
|
||||
name='productType'
|
||||
label='Product Type'
|
||||
name="productType"
|
||||
label="Product Type"
|
||||
options={referenceData.productTypes.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
label: item.label,
|
||||
}))}
|
||||
/>
|
||||
<FormSelectField
|
||||
name='priority'
|
||||
label='Priority'
|
||||
options={referenceData.priorities.map((item) => ({
|
||||
name="leadChannel"
|
||||
label="Lead Source"
|
||||
options={referenceData.leadChannels.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
label: item.label,
|
||||
}))}
|
||||
/>
|
||||
<FormCurrencyField name='estimatedValue' label='Estimated Value' currencyLabel='THB' />
|
||||
<FormSelectField
|
||||
name='awarenessId'
|
||||
label='Awareness'
|
||||
name="awarenessId"
|
||||
label="Awareness"
|
||||
options={referenceData.awarenesses.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
label: item.label,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<FormSelectField
|
||||
name='status'
|
||||
label='Status'
|
||||
options={referenceData.statuses.map((item) => ({
|
||||
name="priority"
|
||||
label="Priority"
|
||||
options={referenceData.priorities.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
label: item.label,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<FormCurrencyField
|
||||
name="estimatedValue"
|
||||
label="Estimated Value"
|
||||
currencyLabel="THB"
|
||||
/>
|
||||
|
||||
<FormSelectField
|
||||
name='followupStatus'
|
||||
label='Follow-up Status'
|
||||
name="followupStatus"
|
||||
label="Follow-up Status"
|
||||
options={referenceData.followupStatuses.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
}))}
|
||||
/>
|
||||
<FormSelectField
|
||||
name='lostReason'
|
||||
label='Lost Reason'
|
||||
options={referenceData.lostReasons.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
}))}
|
||||
/>
|
||||
<FormSelectField
|
||||
name='ownerMarketingUserId'
|
||||
label='Marketing Owner'
|
||||
options={referenceData.assignableUsers.map((user) => ({
|
||||
value: user.id,
|
||||
label: user.name
|
||||
label: item.label,
|
||||
}))}
|
||||
/>
|
||||
|
||||
{isEdit ? (
|
||||
<>
|
||||
<FormSelectField
|
||||
name="status"
|
||||
label="Status"
|
||||
options={referenceData.statuses.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label,
|
||||
}))}
|
||||
/>
|
||||
<FormSelectField
|
||||
name="lostReason"
|
||||
label="Lost Reason"
|
||||
options={referenceData.lostReasons.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label,
|
||||
}))}
|
||||
/>
|
||||
<FormSelectField
|
||||
name="ownerMarketingUserId"
|
||||
label="Marketing Owner"
|
||||
options={referenceData.assignableUsers.map((user) => ({
|
||||
value: user.id,
|
||||
label: user.name,
|
||||
}))}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<FormTextareaField
|
||||
name="description"
|
||||
label="Description"
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
</form.Form>
|
||||
</form.AppForm>
|
||||
</div>
|
||||
|
||||
<SheetFooter>
|
||||
<Button
|
||||
variant='outline'
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
disabled={isPending}
|
||||
>
|
||||
ยกเลิก
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
form='lead-form'
|
||||
isLoading={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
<Icons.add className='mr-2 h-4 w-4' />
|
||||
{isEdit ? 'อัปเดตลีด' : 'สร้างลีด'}
|
||||
<Button type="submit" form="lead-form" isLoading={isPending}>
|
||||
<Icons.add className="mr-2 h-4 w-4" />
|
||||
{isEdit ? "Update Lead" : "Create Lead"}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</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);
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
<LeadForm open={open} onOpenChange={setOpen} referenceData={referenceData} />
|
||||
<LeadForm
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
referenceData={referenceData}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<string, string>;
|
||||
leadChannelLabels: Map<string, string>;
|
||||
statusLabels: Map<string, string>;
|
||||
followupStatusLabels: Map<string, string>;
|
||||
lostReasonLabels: Map<string, string>;
|
||||
@@ -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<LeadLookupMaps> {
|
||||
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<LeadReferenceData> {
|
||||
const [
|
||||
awarenesses,
|
||||
leadChannels,
|
||||
statuses,
|
||||
followupStatuses,
|
||||
lostReasons,
|
||||
@@ -690,6 +713,7 @@ export async function getLeadReferenceData(organizationId: string): Promise<Lead
|
||||
] =
|
||||
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 }),
|
||||
@@ -703,6 +727,7 @@ export async function getLeadReferenceData(organizationId: string): Promise<Lead
|
||||
|
||||
return {
|
||||
awarenesses: awarenesses.map(mapOption),
|
||||
leadChannels: leadChannels.map(mapOption),
|
||||
statuses: statuses.map(mapOption),
|
||||
followupStatuses: followupStatuses.map(mapOption),
|
||||
lostReasons: lostReasons.map(mapOption),
|
||||
@@ -806,6 +831,14 @@ export async function createLead(
|
||||
accessContext?: LeadAccessContext
|
||||
): Promise<LeadDetail> {
|
||||
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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user