diff --git a/frontend/src/features/crmQuickCreate/shared.tsx b/frontend/src/features/crmQuickCreate/shared.tsx new file mode 100644 index 00000000..d2420dce --- /dev/null +++ b/frontend/src/features/crmQuickCreate/shared.tsx @@ -0,0 +1,610 @@ +import type { ReactNode } from "react"; +import type { + ChannelExpansionContact, + CreateChannelExpansionPayload, + CreateSalesExpansionPayload, + ExpansionDictOption, +} from "@/lib/auth"; +import { AdaptiveSelect } from "@/components/AdaptiveSelect"; +import { cn } from "@/lib/utils"; + +export type SalesCreateField = + | "employeeNo" + | "officeName" + | "candidateName" + | "mobile" + | "targetDept" + | "industry" + | "title" + | "intentLevel" + | "employmentStatus"; + +export type ChannelField = + | "channelName" + | "province" + | "city" + | "officeAddress" + | "channelIndustry" + | "certificationLevel" + | "annualRevenue" + | "staffSize" + | "contactEstablishedDate" + | "intentLevel" + | "channelAttribute" + | "channelAttributeCustom" + | "internalAttribute" + | "contacts"; + +export const defaultQuickSalesForm: CreateSalesExpansionPayload = { + employeeNo: "", + candidateName: "", + officeName: "", + mobile: "", + industry: "", + title: "", + intentLevel: "medium", + hasDesktopExp: false, + employmentStatus: "active", +}; + +export const createEmptyChannelContact = (): ChannelExpansionContact => ({ + name: "", + mobile: "", + title: "", +}); + +export const defaultQuickChannelForm: CreateChannelExpansionPayload = { + channelCode: "", + channelName: "", + province: "", + city: "", + officeAddress: "", + channelIndustry: [], + certificationLevel: "", + contactEstablishedDate: "", + intentLevel: "medium", + hasDesktopExp: false, + channelAttribute: [], + channelAttributeCustom: "", + internalAttribute: [], + stage: "initial_contact", + remark: "", + contacts: [createEmptyChannelContact()], +}; + +export function normalizeOptionalText(value?: string) { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +export function isOtherOption(option?: ExpansionDictOption) { + const candidate = `${option?.label ?? ""}${option?.value ?? ""}`.toLowerCase(); + return candidate.includes("其他") || candidate.includes("其它") || candidate.includes("other"); +} + +export function getFieldInputClass(hasError: boolean) { + return cn( + "crm-input-box crm-input-text w-full border bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:bg-slate-900/50", + hasError + ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" + : "border-slate-200 dark:border-slate-800", + ); +} + +export function validateSalesCreateForm(form: CreateSalesExpansionPayload) { + const errors: Partial> = {}; + if (!form.employeeNo?.trim()) { + errors.employeeNo = "请填写工号"; + } + if (!form.officeName?.trim()) { + errors.officeName = "请选择代表处 / 办事处"; + } + if (!form.candidateName?.trim()) { + errors.candidateName = "请填写姓名"; + } + if (!form.mobile?.trim()) { + errors.mobile = "请填写联系方式"; + } + if (!form.targetDept?.trim()) { + errors.targetDept = "请填写所属部门"; + } + if (!form.industry?.trim()) { + errors.industry = "请选择所属行业"; + } + if (!form.title?.trim()) { + errors.title = "请填写职务"; + } + if (!form.intentLevel?.trim()) { + errors.intentLevel = "请选择合作意向"; + } + if (!form.employmentStatus?.trim()) { + errors.employmentStatus = "请选择销售是否在职"; + } + return errors; +} + +export function validateChannelForm(form: CreateChannelExpansionPayload, channelOtherOptionValue?: string) { + const errors: Partial> = {}; + const invalidContactRows: number[] = []; + + if (!form.channelName?.trim()) { + errors.channelName = "请填写渠道名称"; + } + if (!form.province?.trim()) { + errors.province = "请选择省份"; + } + if (!form.city?.trim()) { + errors.city = "请选择市"; + } + if (!form.officeAddress?.trim()) { + errors.officeAddress = "请填写办公地址"; + } + if ((form.channelIndustry?.length ?? 0) <= 0) { + errors.channelIndustry = "请选择聚焦行业"; + } + if (!form.certificationLevel?.trim()) { + errors.certificationLevel = "请选择认证级别"; + } + if (!form.annualRevenue || form.annualRevenue <= 0) { + errors.annualRevenue = "请填写年度营业额"; + } + if (!form.staffSize || form.staffSize <= 0) { + errors.staffSize = "请填写人员规模"; + } + if (!form.contactEstablishedDate?.trim()) { + errors.contactEstablishedDate = "请选择建立联系时间"; + } + if (!form.intentLevel?.trim()) { + errors.intentLevel = "请选择合作意向"; + } + if ((form.channelAttribute?.length ?? 0) <= 0) { + errors.channelAttribute = "请选择渠道属性"; + } + if (channelOtherOptionValue && form.channelAttribute?.includes(channelOtherOptionValue) && !form.channelAttributeCustom?.trim()) { + errors.channelAttributeCustom = "请选择“其它”后请补充具体渠道属性"; + } + if ((form.internalAttribute?.length ?? 0) <= 0) { + errors.internalAttribute = "请选择新华三内部属性"; + } + + const contacts = form.contacts ?? []; + if (contacts.length <= 0) { + errors.contacts = "请至少填写一位渠道联系人"; + invalidContactRows.push(0); + } else { + contacts.forEach((contact, index) => { + const hasName = Boolean(contact.name?.trim()); + const hasMobile = Boolean(contact.mobile?.trim()); + const hasTitle = Boolean(contact.title?.trim()); + if (!hasName || !hasMobile || !hasTitle) { + invalidContactRows.push(index); + } + }); + if (invalidContactRows.length > 0) { + errors.contacts = "请完整填写每位渠道联系人的姓名、联系电话和职位"; + } + } + + return { errors, invalidContactRows }; +} + +export function normalizeSalesPayload(payload: CreateSalesExpansionPayload): CreateSalesExpansionPayload { + return { + employeeNo: payload.employeeNo.trim(), + candidateName: payload.candidateName.trim(), + officeName: normalizeOptionalText(payload.officeName), + mobile: normalizeOptionalText(payload.mobile), + email: normalizeOptionalText(payload.email), + targetDept: normalizeOptionalText(payload.targetDept), + industry: normalizeOptionalText(payload.industry), + title: normalizeOptionalText(payload.title), + intentLevel: normalizeOptionalText(payload.intentLevel) ?? "medium", + stage: normalizeOptionalText(payload.stage) ?? "initial_contact", + hasDesktopExp: Boolean(payload.hasDesktopExp), + inProgress: payload.inProgress ?? true, + employmentStatus: normalizeOptionalText(payload.employmentStatus) ?? "active", + expectedJoinDate: normalizeOptionalText(payload.expectedJoinDate), + remark: normalizeOptionalText(payload.remark), + }; +} + +export function normalizeChannelPayload(payload: CreateChannelExpansionPayload): CreateChannelExpansionPayload { + return { + channelCode: normalizeOptionalText(payload.channelCode), + officeAddress: normalizeOptionalText(payload.officeAddress), + channelIndustry: Array.from(new Set((payload.channelIndustry ?? []).map((value) => value?.trim()).filter((value): value is string => Boolean(value)))), + channelName: payload.channelName.trim(), + province: normalizeOptionalText(payload.province), + city: normalizeOptionalText(payload.city), + certificationLevel: normalizeOptionalText(payload.certificationLevel), + annualRevenue: payload.annualRevenue || undefined, + staffSize: payload.staffSize || undefined, + contactEstablishedDate: normalizeOptionalText(payload.contactEstablishedDate), + intentLevel: normalizeOptionalText(payload.intentLevel) ?? "medium", + hasDesktopExp: Boolean(payload.hasDesktopExp), + channelAttribute: Array.from(new Set((payload.channelAttribute ?? []).map((value) => value?.trim()).filter((value): value is string => Boolean(value)))), + channelAttributeCustom: normalizeOptionalText(payload.channelAttributeCustom), + internalAttribute: Array.from(new Set((payload.internalAttribute ?? []).map((value) => value?.trim()).filter((value): value is string => Boolean(value)))), + stage: normalizeOptionalText(payload.stage) ?? "initial_contact", + remark: normalizeOptionalText(payload.remark), + contacts: (payload.contacts ?? []) + .map((contact) => ({ + name: normalizeOptionalText(contact.name), + mobile: normalizeOptionalText(contact.mobile), + title: normalizeOptionalText(contact.title), + })) + .filter((contact) => contact.name || contact.mobile || contact.title), + }; +} + +export function QuickSalesForm({ + form, + fieldErrors, + officeOptions, + industryOptions, + duplicateMessage, + requiredMark, + onChange, +}: { + form: CreateSalesExpansionPayload; + fieldErrors: Partial>; + officeOptions: ExpansionDictOption[]; + industryOptions: ExpansionDictOption[]; + duplicateMessage: string; + requiredMark: ReactNode; + onChange: (key: K, value: CreateSalesExpansionPayload[K]) => void; +}) { + return ( +
+ + + + + + + + + + +
+ ); +} + +export function QuickChannelForm({ + form, + fieldErrors, + invalidContactRows, + provinceOptions, + cityOptions, + industryOptions, + certificationLevelOptions, + channelAttributeOptions, + internalAttributeOptions, + channelOtherOptionValue, + duplicateMessage, + requiredMark, + onChange, + onContactChange, + onAddContact, + onRemoveContact, + onProvinceChange, +}: { + form: CreateChannelExpansionPayload; + fieldErrors: Partial>; + invalidContactRows: number[]; + provinceOptions: ExpansionDictOption[]; + cityOptions: ExpansionDictOption[]; + industryOptions: ExpansionDictOption[]; + certificationLevelOptions: ExpansionDictOption[]; + channelAttributeOptions: ExpansionDictOption[]; + internalAttributeOptions: ExpansionDictOption[]; + channelOtherOptionValue?: string; + duplicateMessage: string; + requiredMark: ReactNode; + onChange: (key: K, value: CreateChannelExpansionPayload[K]) => void; + onContactChange: (index: number, key: keyof ChannelExpansionContact, value: string) => void; + onAddContact: () => void; + onRemoveContact: (index: number) => void; + onProvinceChange: (value: string) => void; +}) { + const cityDisabled = !form.province?.trim(); + return ( +
+ + + + + + + + + + + + + {channelOtherOptionValue && (form.channelAttribute ?? []).includes(channelOtherOptionValue) ? ( + + ) : null} + + +
+
+ 人员信息{requiredMark} + +
+
+ {(form.contacts ?? []).map((contact, index) => ( +
+ onContactChange(index, "name", e.target.value)} placeholder="人员姓名" className={cn("w-full rounded-lg border bg-white px-3 py-2 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:bg-slate-900/50", invalidContactRows.includes(index) ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "border-slate-200 dark:border-slate-800")} /> + onContactChange(index, "mobile", e.target.value)} placeholder="联系电话" className={cn("w-full rounded-lg border bg-white px-3 py-2 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:bg-slate-900/50", invalidContactRows.includes(index) ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "border-slate-200 dark:border-slate-800")} /> + onContactChange(index, "title", e.target.value)} placeholder="职位" className={cn("w-full rounded-lg border bg-white px-3 py-2 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:bg-slate-900/50", invalidContactRows.includes(index) ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" : "border-slate-200 dark:border-slate-800")} /> + +
+ ))} +
+ {fieldErrors.contacts ?

{fieldErrors.contacts}

: null} +
+ +