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",
|
"overallStatus": "PASS",
|
||||||
"quotationCode": "CRA2606-1107",
|
"quotationCode": "CRA2606-1107",
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user