+
-
+
(
- ลูกค้า
+ Customer
-
@@ -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 (
<>
setOpen(true)}>
-
- เพิ่มลีด
+
+ Add Lead
-
+
>
);
}
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';
-