快速新增回填功能升级
parent
9f80246be4
commit
d996a49a71
|
|
@ -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<Record<SalesCreateField, string>> = {};
|
||||||
|
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<Record<ChannelField, string>> = {};
|
||||||
|
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<Record<SalesCreateField, string>>;
|
||||||
|
officeOptions: ExpansionDictOption[];
|
||||||
|
industryOptions: ExpansionDictOption[];
|
||||||
|
duplicateMessage: string;
|
||||||
|
requiredMark: ReactNode;
|
||||||
|
onChange: <K extends keyof CreateSalesExpansionPayload>(key: K, value: CreateSalesExpansionPayload[K]) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="crm-form-grid">
|
||||||
|
<label className="space-y-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">工号{requiredMark}</span>
|
||||||
|
<input value={form.employeeNo} onChange={(e) => onChange("employeeNo", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors.employeeNo))} />
|
||||||
|
{fieldErrors.employeeNo ? <p className="text-xs text-rose-500">{fieldErrors.employeeNo}</p> : null}
|
||||||
|
{!fieldErrors.employeeNo && duplicateMessage ? <p className="text-xs text-rose-500">{duplicateMessage}</p> : null}
|
||||||
|
</label>
|
||||||
|
<label className="space-y-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">代表处 / 办事处{requiredMark}</span>
|
||||||
|
<AdaptiveSelect
|
||||||
|
value={form.officeName || ""}
|
||||||
|
placeholder="请选择"
|
||||||
|
sheetTitle="代表处 / 办事处"
|
||||||
|
searchable
|
||||||
|
searchPlaceholder="搜索代表处 / 办事处"
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "请选择" },
|
||||||
|
...officeOptions.map((option) => ({
|
||||||
|
value: option.value ?? "",
|
||||||
|
label: option.label || "无",
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
className={cn(fieldErrors.officeName ? "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" : "")}
|
||||||
|
onChange={(value) => onChange("officeName", value || undefined)}
|
||||||
|
/>
|
||||||
|
{fieldErrors.officeName ? <p className="text-xs text-rose-500">{fieldErrors.officeName}</p> : null}
|
||||||
|
</label>
|
||||||
|
<label className="space-y-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">姓名{requiredMark}</span>
|
||||||
|
<input value={form.candidateName} onChange={(e) => onChange("candidateName", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors.candidateName))} />
|
||||||
|
{fieldErrors.candidateName ? <p className="text-xs text-rose-500">{fieldErrors.candidateName}</p> : null}
|
||||||
|
</label>
|
||||||
|
<label className="space-y-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">联系方式{requiredMark}</span>
|
||||||
|
<input value={form.mobile} onChange={(e) => onChange("mobile", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors.mobile))} />
|
||||||
|
{fieldErrors.mobile ? <p className="text-xs text-rose-500">{fieldErrors.mobile}</p> : null}
|
||||||
|
</label>
|
||||||
|
<label className="space-y-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">所属部门{requiredMark}</span>
|
||||||
|
<input value={form.targetDept || ""} onChange={(e) => onChange("targetDept", e.target.value)} placeholder="办事处/地市" className={getFieldInputClass(Boolean(fieldErrors.targetDept))} />
|
||||||
|
{fieldErrors.targetDept ? <p className="text-xs text-rose-500">{fieldErrors.targetDept}</p> : null}
|
||||||
|
</label>
|
||||||
|
<label className="space-y-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">所属行业{requiredMark}</span>
|
||||||
|
<AdaptiveSelect
|
||||||
|
value={form.industry || ""}
|
||||||
|
placeholder="请选择"
|
||||||
|
sheetTitle="所属行业"
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "请选择" },
|
||||||
|
...industryOptions.map((option) => ({
|
||||||
|
value: option.value ?? "",
|
||||||
|
label: option.label || "无",
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
className={cn(fieldErrors.industry ? "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" : "")}
|
||||||
|
onChange={(value) => onChange("industry", value || undefined)}
|
||||||
|
/>
|
||||||
|
{fieldErrors.industry ? <p className="text-xs text-rose-500">{fieldErrors.industry}</p> : null}
|
||||||
|
</label>
|
||||||
|
<label className="space-y-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">职务{requiredMark}</span>
|
||||||
|
<input value={form.title || ""} onChange={(e) => onChange("title", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors.title))} />
|
||||||
|
{fieldErrors.title ? <p className="text-xs text-rose-500">{fieldErrors.title}</p> : null}
|
||||||
|
</label>
|
||||||
|
<label className="space-y-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">合作意向{requiredMark}</span>
|
||||||
|
<AdaptiveSelect
|
||||||
|
value={form.intentLevel || "medium"}
|
||||||
|
sheetTitle="合作意向"
|
||||||
|
options={[
|
||||||
|
{ value: "high", label: "高" },
|
||||||
|
{ value: "medium", label: "中" },
|
||||||
|
{ value: "low", label: "低" },
|
||||||
|
]}
|
||||||
|
className={cn(fieldErrors.intentLevel ? "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" : "")}
|
||||||
|
onChange={(value) => onChange("intentLevel", value)}
|
||||||
|
/>
|
||||||
|
{fieldErrors.intentLevel ? <p className="text-xs text-rose-500">{fieldErrors.intentLevel}</p> : null}
|
||||||
|
</label>
|
||||||
|
<label className="space-y-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">销售是否在职{requiredMark}</span>
|
||||||
|
<AdaptiveSelect
|
||||||
|
value={form.employmentStatus || "active"}
|
||||||
|
sheetTitle="销售是否在职"
|
||||||
|
options={[
|
||||||
|
{ value: "active", label: "是" },
|
||||||
|
{ value: "left", label: "否" },
|
||||||
|
]}
|
||||||
|
className={cn(fieldErrors.employmentStatus ? "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" : "")}
|
||||||
|
onChange={(value) => onChange("employmentStatus", value)}
|
||||||
|
/>
|
||||||
|
{fieldErrors.employmentStatus ? <p className="text-xs text-rose-500">{fieldErrors.employmentStatus}</p> : null}
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3 dark:border-slate-800">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">销售以前是否做过云桌面项目</span>
|
||||||
|
<input type="checkbox" checked={Boolean(form.hasDesktopExp)} onChange={(e) => onChange("hasDesktopExp", e.target.checked)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuickChannelForm({
|
||||||
|
form,
|
||||||
|
fieldErrors,
|
||||||
|
invalidContactRows,
|
||||||
|
provinceOptions,
|
||||||
|
cityOptions,
|
||||||
|
industryOptions,
|
||||||
|
certificationLevelOptions,
|
||||||
|
channelAttributeOptions,
|
||||||
|
internalAttributeOptions,
|
||||||
|
channelOtherOptionValue,
|
||||||
|
duplicateMessage,
|
||||||
|
requiredMark,
|
||||||
|
onChange,
|
||||||
|
onContactChange,
|
||||||
|
onAddContact,
|
||||||
|
onRemoveContact,
|
||||||
|
onProvinceChange,
|
||||||
|
}: {
|
||||||
|
form: CreateChannelExpansionPayload;
|
||||||
|
fieldErrors: Partial<Record<ChannelField, string>>;
|
||||||
|
invalidContactRows: number[];
|
||||||
|
provinceOptions: ExpansionDictOption[];
|
||||||
|
cityOptions: ExpansionDictOption[];
|
||||||
|
industryOptions: ExpansionDictOption[];
|
||||||
|
certificationLevelOptions: ExpansionDictOption[];
|
||||||
|
channelAttributeOptions: ExpansionDictOption[];
|
||||||
|
internalAttributeOptions: ExpansionDictOption[];
|
||||||
|
channelOtherOptionValue?: string;
|
||||||
|
duplicateMessage: string;
|
||||||
|
requiredMark: ReactNode;
|
||||||
|
onChange: <K extends keyof CreateChannelExpansionPayload>(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 (
|
||||||
|
<div className="crm-form-grid">
|
||||||
|
<label className="space-y-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">编码</span>
|
||||||
|
<input
|
||||||
|
value={form.channelCode || "系统自动生成"}
|
||||||
|
readOnly
|
||||||
|
className="crm-input-box-readonly crm-input-text w-full border border-slate-200 bg-slate-50 text-slate-500 outline-none dark:border-slate-800 dark:bg-slate-900/30 dark:text-slate-400"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="space-y-2 sm:col-span-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">渠道名称{requiredMark}</span>
|
||||||
|
<input value={form.channelName} onChange={(e) => onChange("channelName", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors.channelName))} />
|
||||||
|
{fieldErrors.channelName ? <p className="text-xs text-rose-500">{fieldErrors.channelName}</p> : null}
|
||||||
|
{!fieldErrors.channelName && duplicateMessage ? <p className="text-xs text-rose-500">{duplicateMessage}</p> : null}
|
||||||
|
</label>
|
||||||
|
<label className="space-y-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">省份{requiredMark}</span>
|
||||||
|
<AdaptiveSelect
|
||||||
|
value={form.province || ""}
|
||||||
|
placeholder="请选择"
|
||||||
|
sheetTitle="省份"
|
||||||
|
searchable
|
||||||
|
searchPlaceholder="搜索省份"
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "请选择" },
|
||||||
|
...provinceOptions.map((option) => ({
|
||||||
|
value: option.value ?? "",
|
||||||
|
label: option.label || "无",
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
className={cn(fieldErrors.province ? "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" : "")}
|
||||||
|
onChange={(value) => onProvinceChange(value)}
|
||||||
|
/>
|
||||||
|
{fieldErrors.province ? <p className="text-xs text-rose-500">{fieldErrors.province}</p> : null}
|
||||||
|
</label>
|
||||||
|
<label className="space-y-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">市{requiredMark}</span>
|
||||||
|
<AdaptiveSelect
|
||||||
|
value={form.city || ""}
|
||||||
|
placeholder={cityDisabled ? "请先选择省份" : "请选择"}
|
||||||
|
sheetTitle="市"
|
||||||
|
searchable
|
||||||
|
searchPlaceholder="搜索市"
|
||||||
|
disabled={cityDisabled}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "请选择" },
|
||||||
|
...cityOptions.map((option) => ({
|
||||||
|
value: option.value ?? "",
|
||||||
|
label: option.label || "无",
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
className={cn(fieldErrors.city ? "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" : "")}
|
||||||
|
onChange={(value) => onChange("city", value || undefined)}
|
||||||
|
/>
|
||||||
|
{fieldErrors.city ? <p className="text-xs text-rose-500">{fieldErrors.city}</p> : null}
|
||||||
|
</label>
|
||||||
|
<label className="space-y-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">办公地址{requiredMark}</span>
|
||||||
|
<input value={form.officeAddress || ""} onChange={(e) => onChange("officeAddress", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors.officeAddress))} />
|
||||||
|
{fieldErrors.officeAddress ? <p className="text-xs text-rose-500">{fieldErrors.officeAddress}</p> : null}
|
||||||
|
</label>
|
||||||
|
<label className="space-y-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">聚焦行业{requiredMark}</span>
|
||||||
|
<AdaptiveSelect
|
||||||
|
multiple
|
||||||
|
value={form.channelIndustry || []}
|
||||||
|
placeholder="请选择"
|
||||||
|
sheetTitle="聚焦行业"
|
||||||
|
options={industryOptions.map((option) => ({
|
||||||
|
value: option.value ?? "",
|
||||||
|
label: option.label || "无",
|
||||||
|
}))}
|
||||||
|
className={cn(fieldErrors.channelIndustry ? "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" : "")}
|
||||||
|
onChange={(value) => onChange("channelIndustry", value)}
|
||||||
|
/>
|
||||||
|
{fieldErrors.channelIndustry ? <p className="text-xs text-rose-500">{fieldErrors.channelIndustry}</p> : null}
|
||||||
|
</label>
|
||||||
|
<label className="space-y-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">认证级别{requiredMark}</span>
|
||||||
|
<AdaptiveSelect
|
||||||
|
value={form.certificationLevel || ""}
|
||||||
|
placeholder="请选择"
|
||||||
|
sheetTitle="认证级别"
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "请选择" },
|
||||||
|
...certificationLevelOptions.map((option) => ({
|
||||||
|
value: option.value ?? "",
|
||||||
|
label: option.label || "无",
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
className={cn(fieldErrors.certificationLevel ? "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" : "")}
|
||||||
|
onChange={(value) => onChange("certificationLevel", value || undefined)}
|
||||||
|
/>
|
||||||
|
{fieldErrors.certificationLevel ? <p className="text-xs text-rose-500">{fieldErrors.certificationLevel}</p> : null}
|
||||||
|
</label>
|
||||||
|
<label className="space-y-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">年度营业额(万元){requiredMark}</span>
|
||||||
|
<input type="number" min="0.01" step="0.01" placeholder="请输入万元" value={form.annualRevenue ?? ""} onChange={(e) => onChange("annualRevenue", e.target.value ? Number(e.target.value) : undefined)} className={getFieldInputClass(Boolean(fieldErrors.annualRevenue))} />
|
||||||
|
{fieldErrors.annualRevenue ? <p className="text-xs text-rose-500">{fieldErrors.annualRevenue}</p> : null}
|
||||||
|
</label>
|
||||||
|
<label className="space-y-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">人员规模{requiredMark}</span>
|
||||||
|
<input type="number" value={form.staffSize ?? ""} onChange={(e) => onChange("staffSize", e.target.value ? Number(e.target.value) : undefined)} className={getFieldInputClass(Boolean(fieldErrors.staffSize))} />
|
||||||
|
{fieldErrors.staffSize ? <p className="text-xs text-rose-500">{fieldErrors.staffSize}</p> : null}
|
||||||
|
</label>
|
||||||
|
<label className="space-y-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">建立联系时间{requiredMark}</span>
|
||||||
|
<input type="date" value={form.contactEstablishedDate || ""} onChange={(e) => onChange("contactEstablishedDate", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors.contactEstablishedDate))} />
|
||||||
|
{fieldErrors.contactEstablishedDate ? <p className="text-xs text-rose-500">{fieldErrors.contactEstablishedDate}</p> : null}
|
||||||
|
</label>
|
||||||
|
<label className="space-y-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">合作意向{requiredMark}</span>
|
||||||
|
<AdaptiveSelect
|
||||||
|
value={form.intentLevel || "medium"}
|
||||||
|
sheetTitle="合作意向"
|
||||||
|
options={[
|
||||||
|
{ value: "high", label: "高" },
|
||||||
|
{ value: "medium", label: "中" },
|
||||||
|
{ value: "low", label: "低" },
|
||||||
|
]}
|
||||||
|
className={cn(fieldErrors.intentLevel ? "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" : "")}
|
||||||
|
onChange={(value) => onChange("intentLevel", value)}
|
||||||
|
/>
|
||||||
|
{fieldErrors.intentLevel ? <p className="text-xs text-rose-500">{fieldErrors.intentLevel}</p> : null}
|
||||||
|
</label>
|
||||||
|
<label className="space-y-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">渠道属性{requiredMark}</span>
|
||||||
|
<AdaptiveSelect
|
||||||
|
multiple
|
||||||
|
value={form.channelAttribute || []}
|
||||||
|
placeholder="请选择"
|
||||||
|
sheetTitle="渠道属性"
|
||||||
|
options={channelAttributeOptions.map((option) => ({
|
||||||
|
value: option.value ?? "",
|
||||||
|
label: option.label || "无",
|
||||||
|
}))}
|
||||||
|
className={cn(fieldErrors.channelAttribute ? "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" : "")}
|
||||||
|
onChange={(value) => {
|
||||||
|
onChange("channelAttribute", value);
|
||||||
|
if (!channelOtherOptionValue || !value.includes(channelOtherOptionValue)) {
|
||||||
|
onChange("channelAttributeCustom", "");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{fieldErrors.channelAttribute ? <p className="text-xs text-rose-500">{fieldErrors.channelAttribute}</p> : null}
|
||||||
|
</label>
|
||||||
|
{channelOtherOptionValue && (form.channelAttribute ?? []).includes(channelOtherOptionValue) ? (
|
||||||
|
<label className="space-y-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">其它渠道属性{requiredMark}</span>
|
||||||
|
<input value={form.channelAttributeCustom || ""} onChange={(e) => onChange("channelAttributeCustom", e.target.value)} placeholder="请输入具体渠道属性" className={getFieldInputClass(Boolean(fieldErrors.channelAttributeCustom))} />
|
||||||
|
{fieldErrors.channelAttributeCustom ? <p className="text-xs text-rose-500">{fieldErrors.channelAttributeCustom}</p> : null}
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
<label className="space-y-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">新华三内部属性{requiredMark}</span>
|
||||||
|
<AdaptiveSelect
|
||||||
|
multiple
|
||||||
|
value={form.internalAttribute || []}
|
||||||
|
placeholder="请选择"
|
||||||
|
sheetTitle="新华三内部属性"
|
||||||
|
options={internalAttributeOptions.map((option) => ({
|
||||||
|
value: option.value ?? "",
|
||||||
|
label: option.label || "无",
|
||||||
|
}))}
|
||||||
|
className={cn(fieldErrors.internalAttribute ? "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" : "")}
|
||||||
|
onChange={(value) => onChange("internalAttribute", value)}
|
||||||
|
/>
|
||||||
|
{fieldErrors.internalAttribute ? <p className="text-xs text-rose-500">{fieldErrors.internalAttribute}</p> : null}
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3 dark:border-slate-800">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">以前是否做过云桌面项目</span>
|
||||||
|
<input type="checkbox" checked={Boolean(form.hasDesktopExp)} onChange={(e) => onChange("hasDesktopExp", e.target.checked)} />
|
||||||
|
</label>
|
||||||
|
<div className="crm-form-section sm:col-span-2">
|
||||||
|
<div className="crm-form-section-header">
|
||||||
|
<span className="text-sm font-semibold text-slate-800 dark:text-slate-200">人员信息{requiredMark}</span>
|
||||||
|
<button type="button" onClick={onAddContact} className="rounded-lg bg-white px-3 py-1.5 text-xs font-medium text-violet-600 shadow-sm dark:bg-slate-800 dark:text-violet-400">
|
||||||
|
添加人员
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="crm-section-stack">
|
||||||
|
{(form.contacts ?? []).map((contact, index) => (
|
||||||
|
<div key={`quick-channel-${index}`} className="grid grid-cols-1 gap-3 rounded-xl border border-slate-200 bg-white p-3 sm:grid-cols-[1fr_1fr_1fr_auto] dark:border-slate-700 dark:bg-slate-900/50">
|
||||||
|
<input value={contact.name || ""} onChange={(e) => 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")} />
|
||||||
|
<input value={contact.mobile || ""} onChange={(e) => 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")} />
|
||||||
|
<input value={contact.title || ""} onChange={(e) => 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")} />
|
||||||
|
<button type="button" onClick={() => onRemoveContact(index)} className="crm-btn-danger rounded-lg px-3 py-2 text-sm font-medium">
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{fieldErrors.contacts ? <p className="text-xs text-rose-500">{fieldErrors.contacts}</p> : null}
|
||||||
|
</div>
|
||||||
|
<label className="space-y-2 sm:col-span-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">跟进的云桌面项目</span>
|
||||||
|
<div className="rounded-xl border border-dashed border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-500 dark:border-slate-800 dark:bg-slate-900/30 dark:text-slate-400">
|
||||||
|
通过商机里的“关联渠道”自动带入项目编码、项目名称和项目金额。
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label className="space-y-2 sm:col-span-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">备注说明</span>
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
value={form.remark || ""}
|
||||||
|
onChange={(e) => onChange("remark", e.target.value)}
|
||||||
|
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,8 +3,49 @@ import { Search, Plus, Download, ChevronDown, Check, Building, Calendar, DollarS
|
||||||
import { motion, AnimatePresence } from "motion/react";
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
import { createOpportunity, getExpansionOverview, getOpportunityMeta, getOpportunityOmsPreSalesOptions, getOpportunityOverview, getStoredCurrentUserId, pushOpportunityToOms, updateOpportunity, type ChannelExpansionItem, type CreateOpportunityPayload, type OmsPreSalesOption, type OpportunityDictOption, type OpportunityFollowUp, type OpportunityItem, type PushOpportunityToOmsPayload, type SalesExpansionItem } from "@/lib/auth";
|
import {
|
||||||
|
checkChannelExpansionDuplicate,
|
||||||
|
checkSalesExpansionDuplicate,
|
||||||
|
createChannelExpansion,
|
||||||
|
createOpportunity,
|
||||||
|
createSalesExpansion,
|
||||||
|
getExpansionCityOptions,
|
||||||
|
getExpansionMeta,
|
||||||
|
getExpansionOverview,
|
||||||
|
getOpportunityMeta,
|
||||||
|
getOpportunityOmsPreSalesOptions,
|
||||||
|
getOpportunityOverview,
|
||||||
|
getStoredCurrentUserId,
|
||||||
|
pushOpportunityToOms,
|
||||||
|
updateOpportunity,
|
||||||
|
type ChannelExpansionContact,
|
||||||
|
type ChannelExpansionItem,
|
||||||
|
type CreateChannelExpansionPayload,
|
||||||
|
type CreateOpportunityPayload,
|
||||||
|
type CreateSalesExpansionPayload,
|
||||||
|
type ExpansionDictOption,
|
||||||
|
type OmsPreSalesOption,
|
||||||
|
type OpportunityDictOption,
|
||||||
|
type OpportunityFollowUp,
|
||||||
|
type OpportunityItem,
|
||||||
|
type PushOpportunityToOmsPayload,
|
||||||
|
type SalesExpansionItem,
|
||||||
|
} from "@/lib/auth";
|
||||||
import { AdaptiveSelect } from "@/components/AdaptiveSelect";
|
import { AdaptiveSelect } from "@/components/AdaptiveSelect";
|
||||||
|
import {
|
||||||
|
QuickChannelForm as SharedQuickChannelForm,
|
||||||
|
QuickSalesForm as SharedQuickSalesForm,
|
||||||
|
type ChannelField,
|
||||||
|
type SalesCreateField,
|
||||||
|
createEmptyChannelContact as createSharedEmptyChannelContact,
|
||||||
|
defaultQuickChannelForm as sharedDefaultQuickChannelForm,
|
||||||
|
defaultQuickSalesForm as sharedDefaultQuickSalesForm,
|
||||||
|
isOtherOption as isSharedOtherOption,
|
||||||
|
normalizeChannelPayload as normalizeSharedChannelPayload,
|
||||||
|
normalizeSalesPayload as normalizeSharedSalesPayload,
|
||||||
|
validateChannelForm as validateSharedChannelForm,
|
||||||
|
validateSalesCreateForm as validateSharedSalesCreateForm,
|
||||||
|
} from "@/features/crmQuickCreate/shared";
|
||||||
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
|
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -59,6 +100,7 @@ type OpportunityField =
|
||||||
| "stage"
|
| "stage"
|
||||||
| "competitorName"
|
| "competitorName"
|
||||||
| "opportunityType";
|
| "opportunityType";
|
||||||
|
type QuickCreateType = "sales" | "channel";
|
||||||
|
|
||||||
const defaultForm: CreateOpportunityPayload = {
|
const defaultForm: CreateOpportunityPayload = {
|
||||||
opportunityName: "",
|
opportunityName: "",
|
||||||
|
|
@ -868,16 +910,22 @@ function SearchableSelect({
|
||||||
placeholder,
|
placeholder,
|
||||||
searchPlaceholder,
|
searchPlaceholder,
|
||||||
emptyText,
|
emptyText,
|
||||||
|
createActionLabel,
|
||||||
className,
|
className,
|
||||||
onChange,
|
onChange,
|
||||||
|
onCreate,
|
||||||
|
onQueryChange,
|
||||||
}: {
|
}: {
|
||||||
value?: number;
|
value?: number;
|
||||||
options: SearchableOption[];
|
options: SearchableOption[];
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
searchPlaceholder: string;
|
searchPlaceholder: string;
|
||||||
emptyText: string;
|
emptyText: string;
|
||||||
|
createActionLabel?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
onChange: (value?: number) => void;
|
onChange: (value?: number) => void;
|
||||||
|
onCreate?: (query: string) => void;
|
||||||
|
onQueryChange?: (query: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
|
@ -973,7 +1021,11 @@ function SearchableSelect({
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
onChange={(event) => {
|
||||||
|
const nextQuery = event.target.value;
|
||||||
|
setQuery(nextQuery);
|
||||||
|
onQueryChange?.(nextQuery);
|
||||||
|
}}
|
||||||
placeholder={searchPlaceholder}
|
placeholder={searchPlaceholder}
|
||||||
className="crm-input-text w-full rounded-xl border border-slate-200 bg-slate-50 py-2.5 pl-10 pr-3 text-slate-900 outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-800/60 dark:text-white"
|
className="crm-input-text w-full rounded-xl border border-slate-200 bg-slate-50 py-2.5 pl-10 pr-3 text-slate-900 outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-800/60 dark:text-white"
|
||||||
/>
|
/>
|
||||||
|
|
@ -1013,7 +1065,22 @@ function SearchableSelect({
|
||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="crm-empty-state px-3 py-6">{emptyText}</div>
|
<div className="crm-empty-state px-3 py-6">
|
||||||
|
<p>{emptyText}</p>
|
||||||
|
{onCreate ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onCreate(query);
|
||||||
|
setOpen(false);
|
||||||
|
setQuery("");
|
||||||
|
}}
|
||||||
|
className="mt-3 rounded-xl bg-violet-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-violet-700"
|
||||||
|
>
|
||||||
|
{createActionLabel || "新增并选中"}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
@ -1381,7 +1448,27 @@ export default function Opportunities() {
|
||||||
const [customCompetitorName, setCustomCompetitorName] = useState("");
|
const [customCompetitorName, setCustomCompetitorName] = useState("");
|
||||||
const [fieldErrors, setFieldErrors] = useState<Partial<Record<OpportunityField, string>>>({});
|
const [fieldErrors, setFieldErrors] = useState<Partial<Record<OpportunityField, string>>>({});
|
||||||
const [detailTab, setDetailTab] = useState<"sales" | "channel" | "followups">("sales");
|
const [detailTab, setDetailTab] = useState<"sales" | "channel" | "followups">("sales");
|
||||||
const hasForegroundModal = createOpen || editOpen || pushConfirmOpen || exportFilterOpen;
|
const [quickCreateOpen, setQuickCreateOpen] = useState(false);
|
||||||
|
const [quickCreateType, setQuickCreateType] = useState<QuickCreateType>("sales");
|
||||||
|
const [quickCreateSubmitting, setQuickCreateSubmitting] = useState(false);
|
||||||
|
const [quickCreateError, setQuickCreateError] = useState("");
|
||||||
|
const [quickSalesForm, setQuickSalesForm] = useState<CreateSalesExpansionPayload>(sharedDefaultQuickSalesForm);
|
||||||
|
const [quickChannelForm, setQuickChannelForm] = useState<CreateChannelExpansionPayload>(sharedDefaultQuickChannelForm);
|
||||||
|
const [quickSalesFieldErrors, setQuickSalesFieldErrors] = useState<Partial<Record<SalesCreateField, string>>>({});
|
||||||
|
const [quickChannelFieldErrors, setQuickChannelFieldErrors] = useState<Partial<Record<ChannelField, string>>>({});
|
||||||
|
const [quickInvalidChannelContactRows, setQuickInvalidChannelContactRows] = useState<number[]>([]);
|
||||||
|
const [quickOfficeOptions, setQuickOfficeOptions] = useState<ExpansionDictOption[]>([]);
|
||||||
|
const [quickIndustryOptions, setQuickIndustryOptions] = useState<ExpansionDictOption[]>([]);
|
||||||
|
const [quickProvinceOptions, setQuickProvinceOptions] = useState<ExpansionDictOption[]>([]);
|
||||||
|
const [quickCityOptions, setQuickCityOptions] = useState<ExpansionDictOption[]>([]);
|
||||||
|
const [quickCertificationLevelOptions, setQuickCertificationLevelOptions] = useState<ExpansionDictOption[]>([]);
|
||||||
|
const [quickChannelAttributeOptions, setQuickChannelAttributeOptions] = useState<ExpansionDictOption[]>([]);
|
||||||
|
const [quickInternalAttributeOptions, setQuickInternalAttributeOptions] = useState<ExpansionDictOption[]>([]);
|
||||||
|
const [quickSalesDuplicateMessage, setQuickSalesDuplicateMessage] = useState("");
|
||||||
|
const [quickChannelDuplicateMessage, setQuickChannelDuplicateMessage] = useState("");
|
||||||
|
const [salesExpansionQuery, setSalesExpansionQuery] = useState("");
|
||||||
|
const [channelExpansionQuery, setChannelExpansionQuery] = useState("");
|
||||||
|
const hasForegroundModal = createOpen || editOpen || pushConfirmOpen || exportFilterOpen || quickCreateOpen;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
@ -1452,6 +1539,13 @@ export default function Opportunities() {
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const refreshExpansionOptions = async () => {
|
||||||
|
const data = await getExpansionOverview("");
|
||||||
|
setSalesExpansionOptions(data.salesItems ?? []);
|
||||||
|
setChannelExpansionOptions(data.channelItems ?? []);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
|
|
@ -1515,6 +1609,75 @@ export default function Opportunities() {
|
||||||
}));
|
}));
|
||||||
}, [confidenceOptions]);
|
}, [confidenceOptions]);
|
||||||
|
|
||||||
|
const resetQuickCreateState = () => {
|
||||||
|
setQuickCreateOpen(false);
|
||||||
|
setQuickCreateSubmitting(false);
|
||||||
|
setQuickCreateError("");
|
||||||
|
setQuickSalesForm(sharedDefaultQuickSalesForm);
|
||||||
|
setQuickChannelForm(sharedDefaultQuickChannelForm);
|
||||||
|
setQuickSalesFieldErrors({});
|
||||||
|
setQuickChannelFieldErrors({});
|
||||||
|
setQuickInvalidChannelContactRows([]);
|
||||||
|
setQuickCityOptions([]);
|
||||||
|
setQuickSalesDuplicateMessage("");
|
||||||
|
setQuickChannelDuplicateMessage("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadQuickCreateMeta = async () => {
|
||||||
|
const data = await getExpansionMeta();
|
||||||
|
setQuickOfficeOptions(data.officeOptions ?? []);
|
||||||
|
setQuickIndustryOptions(data.industryOptions ?? []);
|
||||||
|
setQuickProvinceOptions(data.provinceOptions ?? []);
|
||||||
|
setQuickCertificationLevelOptions(data.certificationLevelOptions ?? []);
|
||||||
|
setQuickChannelAttributeOptions(data.channelAttributeOptions ?? []);
|
||||||
|
setQuickInternalAttributeOptions(data.internalAttributeOptions ?? []);
|
||||||
|
setQuickChannelForm((current) => ({
|
||||||
|
...current,
|
||||||
|
channelCode: current.channelCode || data.nextChannelCode || "",
|
||||||
|
}));
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadQuickCityOptions = async (provinceName?: string) => {
|
||||||
|
const normalizedProvinceName = provinceName?.trim();
|
||||||
|
if (!normalizedProvinceName) {
|
||||||
|
setQuickCityOptions([]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const cityOptions = await getExpansionCityOptions(normalizedProvinceName);
|
||||||
|
setQuickCityOptions(cityOptions ?? []);
|
||||||
|
return cityOptions ?? [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const openQuickCreateModal = async (type: QuickCreateType, initialKeyword?: string) => {
|
||||||
|
setQuickCreateType(type);
|
||||||
|
setQuickCreateError("");
|
||||||
|
setQuickSalesFieldErrors({});
|
||||||
|
setQuickChannelFieldErrors({});
|
||||||
|
setQuickInvalidChannelContactRows([]);
|
||||||
|
setQuickSalesDuplicateMessage("");
|
||||||
|
setQuickChannelDuplicateMessage("");
|
||||||
|
const normalizedKeyword = initialKeyword?.trim() || "";
|
||||||
|
try {
|
||||||
|
const meta = await loadQuickCreateMeta();
|
||||||
|
if (type === "channel") {
|
||||||
|
setQuickChannelForm({
|
||||||
|
...sharedDefaultQuickChannelForm,
|
||||||
|
channelCode: meta.nextChannelCode || "",
|
||||||
|
channelName: normalizedKeyword,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setQuickSalesForm({
|
||||||
|
...sharedDefaultQuickSalesForm,
|
||||||
|
candidateName: normalizedKeyword,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (metaError) {
|
||||||
|
setQuickCreateError(metaError instanceof Error ? metaError.message : "加载拓展配置失败");
|
||||||
|
}
|
||||||
|
setQuickCreateOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const followUpRecords: OpportunityFollowUp[] = selectedItem?.followUps ?? [];
|
const followUpRecords: OpportunityFollowUp[] = selectedItem?.followUps ?? [];
|
||||||
const visibleItems = items.filter((item) => (archiveTab === "active" ? !item.archived : Boolean(item.archived)));
|
const visibleItems = items.filter((item) => (archiveTab === "active" ? !item.archived : Boolean(item.archived)));
|
||||||
const stageFilterOptions = [
|
const stageFilterOptions = [
|
||||||
|
|
@ -1585,6 +1748,7 @@ export default function Opportunities() {
|
||||||
label: item.name || `渠道#${item.id}`,
|
label: item.name || `渠道#${item.id}`,
|
||||||
keywords: [item.channelCode || "", item.province || "", item.primaryContactName || "", item.primaryContactMobile || ""],
|
keywords: [item.channelCode || "", item.province || "", item.primaryContactName || "", item.primaryContactMobile || ""],
|
||||||
}));
|
}));
|
||||||
|
const quickChannelOtherOptionValue = quickChannelAttributeOptions.find(isSharedOtherOption)?.value;
|
||||||
const omsPreSalesSearchOptions: SearchableOption[] = [
|
const omsPreSalesSearchOptions: SearchableOption[] = [
|
||||||
...(pushPreSalesId && pushPreSalesName && !omsPreSalesOptions.some((item) => item.userId === pushPreSalesId)
|
...(pushPreSalesId && pushPreSalesName && !omsPreSalesOptions.some((item) => item.userId === pushPreSalesId)
|
||||||
? [{ value: pushPreSalesId, label: pushPreSalesName, keywords: [] }]
|
? [{ value: pushPreSalesId, label: pushPreSalesName, keywords: [] }]
|
||||||
|
|
@ -1786,6 +1950,154 @@ export default function Opportunities() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleQuickSalesChange = <K extends keyof CreateSalesExpansionPayload>(key: K, value: CreateSalesExpansionPayload[K]) => {
|
||||||
|
setQuickSalesForm((current) => ({ ...current, [key]: value }));
|
||||||
|
if (key in quickSalesFieldErrors) {
|
||||||
|
setQuickSalesFieldErrors((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[key as SalesCreateField];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (key === "employeeNo") {
|
||||||
|
setQuickSalesDuplicateMessage("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuickChannelChange = <K extends keyof CreateChannelExpansionPayload>(key: K, value: CreateChannelExpansionPayload[K]) => {
|
||||||
|
setQuickChannelForm((current) => ({ ...current, [key]: value }));
|
||||||
|
if (key in quickChannelFieldErrors) {
|
||||||
|
setQuickChannelFieldErrors((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[key as ChannelField];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (key === "channelName") {
|
||||||
|
setQuickChannelDuplicateMessage("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuickChannelContactChange = (index: number, key: keyof ChannelExpansionContact, value: string) => {
|
||||||
|
setQuickChannelForm((current) => {
|
||||||
|
const nextContacts = [...(current.contacts ?? [])];
|
||||||
|
nextContacts[index] = {
|
||||||
|
...(nextContacts[index] ?? createSharedEmptyChannelContact()),
|
||||||
|
[key]: value,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
contacts: nextContacts,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (quickInvalidChannelContactRows.includes(index)) {
|
||||||
|
setQuickInvalidChannelContactRows((current) => current.filter((rowIndex) => rowIndex !== index));
|
||||||
|
}
|
||||||
|
if (quickChannelFieldErrors.contacts) {
|
||||||
|
setQuickChannelFieldErrors((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
delete next.contacts;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addQuickChannelContact = () => {
|
||||||
|
setQuickChannelForm((current) => ({
|
||||||
|
...current,
|
||||||
|
contacts: [...(current.contacts ?? []), createSharedEmptyChannelContact()],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeQuickChannelContact = (index: number) => {
|
||||||
|
setQuickChannelForm((current) => {
|
||||||
|
const currentContacts = current.contacts ?? [];
|
||||||
|
const nextContacts = currentContacts.filter((_, contactIndex) => contactIndex !== index);
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
contacts: nextContacts.length > 0 ? nextContacts : [createSharedEmptyChannelContact()],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setQuickInvalidChannelContactRows((current) => current.filter((rowIndex) => rowIndex !== index).map((rowIndex) => (rowIndex > index ? rowIndex - 1 : rowIndex)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuickChannelProvinceChange = (value: string) => {
|
||||||
|
const nextProvince = value || "";
|
||||||
|
handleQuickChannelChange("province", nextProvince || undefined);
|
||||||
|
handleQuickChannelChange("city", undefined);
|
||||||
|
void loadQuickCityOptions(nextProvince);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuickCreateSubmit = async () => {
|
||||||
|
if (quickCreateSubmitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setQuickCreateError("");
|
||||||
|
setQuickSalesDuplicateMessage("");
|
||||||
|
setQuickChannelDuplicateMessage("");
|
||||||
|
|
||||||
|
if (quickCreateType === "sales") {
|
||||||
|
const validationErrors = validateSharedSalesCreateForm(quickSalesForm);
|
||||||
|
if (Object.keys(validationErrors).length > 0) {
|
||||||
|
setQuickSalesFieldErrors(validationErrors);
|
||||||
|
setQuickCreateError("请先完整填写销售人员拓展必填字段");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const channelOtherOptionValue = quickChannelAttributeOptions.find(isSharedOtherOption)?.value;
|
||||||
|
const { errors: validationErrors, invalidContactRows } = validateSharedChannelForm(quickChannelForm, channelOtherOptionValue);
|
||||||
|
if (Object.keys(validationErrors).length > 0) {
|
||||||
|
setQuickChannelFieldErrors(validationErrors);
|
||||||
|
setQuickInvalidChannelContactRows(invalidContactRows);
|
||||||
|
setQuickCreateError("请先完整填写渠道拓展必填字段");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setQuickCreateSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let createdId: number;
|
||||||
|
if (quickCreateType === "sales") {
|
||||||
|
const duplicateResult = await checkSalesExpansionDuplicate(quickSalesForm.employeeNo.trim());
|
||||||
|
if (duplicateResult.duplicated) {
|
||||||
|
const duplicateMessage = duplicateResult.message || "工号重复,请确认该人员是否已存在!";
|
||||||
|
setQuickSalesDuplicateMessage(duplicateMessage);
|
||||||
|
setQuickSalesFieldErrors((current) => ({ ...current, employeeNo: duplicateMessage }));
|
||||||
|
setQuickCreateError(duplicateMessage);
|
||||||
|
setQuickCreateSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createdId = await createSalesExpansion(normalizeSharedSalesPayload(quickSalesForm));
|
||||||
|
} else {
|
||||||
|
const duplicateResult = await checkChannelExpansionDuplicate(quickChannelForm.channelName.trim());
|
||||||
|
if (duplicateResult.duplicated) {
|
||||||
|
const duplicateMessage = duplicateResult.message || "渠道重复,请确认该渠道是否已存在!";
|
||||||
|
setQuickChannelDuplicateMessage(duplicateMessage);
|
||||||
|
setQuickChannelFieldErrors((current) => ({ ...current, channelName: duplicateMessage }));
|
||||||
|
setQuickCreateError(duplicateMessage);
|
||||||
|
setQuickCreateSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createdId = await createChannelExpansion(normalizeSharedChannelPayload(quickChannelForm));
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestOverview = await refreshExpansionOptions();
|
||||||
|
if (quickCreateType === "sales") {
|
||||||
|
const createdItem = (latestOverview.salesItems ?? []).find((item) => item.id === createdId);
|
||||||
|
handleChange("salesExpansionId", createdItem?.id ?? createdId);
|
||||||
|
} else {
|
||||||
|
const createdItem = (latestOverview.channelItems ?? []).find((item) => item.id === createdId);
|
||||||
|
handleChange("channelExpansionId", createdItem?.id ?? createdId);
|
||||||
|
}
|
||||||
|
resetQuickCreateState();
|
||||||
|
} catch (quickCreateSubmitError) {
|
||||||
|
setQuickCreateError(quickCreateSubmitError instanceof Error ? quickCreateSubmitError.message : "新增失败");
|
||||||
|
setQuickCreateSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleOpenCreate = () => {
|
const handleOpenCreate = () => {
|
||||||
setError("");
|
setError("");
|
||||||
setFieldErrors({});
|
setFieldErrors({});
|
||||||
|
|
@ -2295,11 +2607,15 @@ export default function Opportunities() {
|
||||||
placeholder="请选择新华三负责人"
|
placeholder="请选择新华三负责人"
|
||||||
searchPlaceholder="搜索姓名、工号、办事处、电话"
|
searchPlaceholder="搜索姓名、工号、办事处、电话"
|
||||||
emptyText="未找到匹配的销售拓展人员"
|
emptyText="未找到匹配的销售拓展人员"
|
||||||
|
createActionLabel={salesExpansionQuery.trim() ? `新增负责人“${salesExpansionQuery.trim()}”并选中` : "新增负责人并选中"}
|
||||||
className={cn(
|
className={cn(
|
||||||
fieldErrors.salesExpansionId ? "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" : "",
|
fieldErrors.salesExpansionId ? "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" : "",
|
||||||
)}
|
)}
|
||||||
onChange={(value) => handleChange("salesExpansionId", value)}
|
onChange={(value) => handleChange("salesExpansionId", value)}
|
||||||
|
onQueryChange={setSalesExpansionQuery}
|
||||||
|
onCreate={(query) => void openQuickCreateModal("sales", query)}
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-slate-400 dark:text-slate-500">未找到负责人时,可直接新增并自动回填。</p>
|
||||||
{fieldErrors.salesExpansionId ? <p className="text-xs text-rose-500">{fieldErrors.salesExpansionId}</p> : null}
|
{fieldErrors.salesExpansionId ? <p className="text-xs text-rose-500">{fieldErrors.salesExpansionId}</p> : null}
|
||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -2312,11 +2628,15 @@ export default function Opportunities() {
|
||||||
placeholder="请选择渠道名称"
|
placeholder="请选择渠道名称"
|
||||||
searchPlaceholder="搜索渠道名称、编码、省份、联系人"
|
searchPlaceholder="搜索渠道名称、编码、省份、联系人"
|
||||||
emptyText="未找到匹配的渠道"
|
emptyText="未找到匹配的渠道"
|
||||||
|
createActionLabel={channelExpansionQuery.trim() ? `新增渠道“${channelExpansionQuery.trim()}”并选中` : "新增渠道并选中"}
|
||||||
className={cn(
|
className={cn(
|
||||||
fieldErrors.channelExpansionId ? "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" : "",
|
fieldErrors.channelExpansionId ? "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" : "",
|
||||||
)}
|
)}
|
||||||
onChange={(value) => handleChange("channelExpansionId", value)}
|
onChange={(value) => handleChange("channelExpansionId", value)}
|
||||||
|
onQueryChange={setChannelExpansionQuery}
|
||||||
|
onCreate={(query) => void openQuickCreateModal("channel", query)}
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-slate-400 dark:text-slate-500">未找到渠道时,可直接新增并自动回填。</p>
|
||||||
{fieldErrors.channelExpansionId ? <p className="text-xs text-rose-500">{fieldErrors.channelExpansionId}</p> : null}
|
{fieldErrors.channelExpansionId ? <p className="text-xs text-rose-500">{fieldErrors.channelExpansionId}</p> : null}
|
||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -2450,6 +2770,57 @@ export default function Opportunities() {
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{quickCreateOpen ? (
|
||||||
|
<ModalShell
|
||||||
|
title={quickCreateType === "sales" ? "新增负责人并回填商机" : "新增渠道并回填商机"}
|
||||||
|
subtitle={quickCreateType === "sales" ? "保存成功后会自动选中当前负责人,并回填到商机表单。" : "保存成功后会自动选中当前渠道,并回填到商机表单。"}
|
||||||
|
onClose={resetQuickCreateState}
|
||||||
|
footer={(
|
||||||
|
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||||
|
<button onClick={resetQuickCreateState} className="crm-btn crm-btn-secondary">取消</button>
|
||||||
|
<button onClick={() => void handleQuickCreateSubmit()} disabled={quickCreateSubmitting} className="crm-btn crm-btn-primary disabled:cursor-not-allowed disabled:opacity-60">
|
||||||
|
{quickCreateSubmitting ? "提交中..." : quickCreateType === "sales" ? "确认新增负责人并回填" : "确认新增渠道并回填"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{quickCreateType === "sales" ? (
|
||||||
|
<SharedQuickSalesForm
|
||||||
|
form={quickSalesForm}
|
||||||
|
fieldErrors={quickSalesFieldErrors}
|
||||||
|
officeOptions={quickOfficeOptions}
|
||||||
|
industryOptions={quickIndustryOptions}
|
||||||
|
duplicateMessage={quickSalesDuplicateMessage}
|
||||||
|
requiredMark={<RequiredMark />}
|
||||||
|
onChange={handleQuickSalesChange}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<SharedQuickChannelForm
|
||||||
|
form={quickChannelForm}
|
||||||
|
fieldErrors={quickChannelFieldErrors}
|
||||||
|
invalidContactRows={quickInvalidChannelContactRows}
|
||||||
|
provinceOptions={quickProvinceOptions}
|
||||||
|
cityOptions={quickCityOptions}
|
||||||
|
industryOptions={quickIndustryOptions}
|
||||||
|
certificationLevelOptions={quickCertificationLevelOptions}
|
||||||
|
channelAttributeOptions={quickChannelAttributeOptions}
|
||||||
|
internalAttributeOptions={quickInternalAttributeOptions}
|
||||||
|
channelOtherOptionValue={quickChannelOtherOptionValue}
|
||||||
|
duplicateMessage={quickChannelDuplicateMessage}
|
||||||
|
requiredMark={<RequiredMark />}
|
||||||
|
onChange={handleQuickChannelChange}
|
||||||
|
onContactChange={handleQuickChannelContactChange}
|
||||||
|
onAddContact={addQuickChannelContact}
|
||||||
|
onRemoveContact={removeQuickChannelContact}
|
||||||
|
onProvinceChange={handleQuickChannelProvinceChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{quickCreateError ? <div className="crm-alert crm-alert-error mt-4">{quickCreateError}</div> : null}
|
||||||
|
</ModalShell>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{pushConfirmOpen && selectedItem ? (
|
{pushConfirmOpen && selectedItem ? (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,16 @@ import { ArrowUp, AtSign, Camera, CheckCircle2, ChevronDown, Download, FileText,
|
||||||
import { flushSync } from "react-dom";
|
import { flushSync } from "react-dom";
|
||||||
import { Link, Navigate, useLocation } from "react-router-dom";
|
import { Link, Navigate, useLocation } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
|
checkChannelExpansionDuplicate,
|
||||||
|
checkSalesExpansionDuplicate,
|
||||||
|
createChannelExpansion,
|
||||||
|
createOpportunity,
|
||||||
|
createSalesExpansion,
|
||||||
getCurrentUser,
|
getCurrentUser,
|
||||||
|
getExpansionCityOptions,
|
||||||
|
getExpansionMeta,
|
||||||
getExpansionOverview,
|
getExpansionOverview,
|
||||||
|
getOpportunityMeta,
|
||||||
getOpportunityOverview,
|
getOpportunityOverview,
|
||||||
getProfileOverview,
|
getProfileOverview,
|
||||||
getWorkCheckInExportData,
|
getWorkCheckInExportData,
|
||||||
|
|
@ -20,8 +28,14 @@ import {
|
||||||
saveWorkDailyReport,
|
saveWorkDailyReport,
|
||||||
uploadWorkCheckInPhoto,
|
uploadWorkCheckInPhoto,
|
||||||
type ChannelExpansionItem,
|
type ChannelExpansionItem,
|
||||||
|
type ChannelExpansionContact,
|
||||||
|
type CreateChannelExpansionPayload,
|
||||||
|
type CreateOpportunityPayload,
|
||||||
type CreateWorkCheckInPayload,
|
type CreateWorkCheckInPayload,
|
||||||
type CreateWorkDailyReportPayload,
|
type CreateWorkDailyReportPayload,
|
||||||
|
type CreateSalesExpansionPayload,
|
||||||
|
type ExpansionDictOption,
|
||||||
|
type OpportunityDictOption,
|
||||||
type OpportunityItem,
|
type OpportunityItem,
|
||||||
type ProfileOverview,
|
type ProfileOverview,
|
||||||
type SalesExpansionItem,
|
type SalesExpansionItem,
|
||||||
|
|
@ -34,6 +48,20 @@ import {
|
||||||
type WorkTomorrowPlanItem,
|
type WorkTomorrowPlanItem,
|
||||||
} from "@/lib/auth";
|
} from "@/lib/auth";
|
||||||
import { AdaptiveSelect } from "@/components/AdaptiveSelect";
|
import { AdaptiveSelect } from "@/components/AdaptiveSelect";
|
||||||
|
import {
|
||||||
|
QuickChannelForm as SharedQuickChannelForm,
|
||||||
|
QuickSalesForm as SharedQuickSalesForm,
|
||||||
|
type ChannelField,
|
||||||
|
type SalesCreateField,
|
||||||
|
createEmptyChannelContact as createSharedEmptyChannelContact,
|
||||||
|
defaultQuickChannelForm as sharedDefaultQuickChannelForm,
|
||||||
|
defaultQuickSalesForm as sharedDefaultQuickSalesForm,
|
||||||
|
isOtherOption as isSharedOtherOption,
|
||||||
|
normalizeChannelPayload as normalizeSharedChannelPayload,
|
||||||
|
normalizeSalesPayload as normalizeSharedSalesPayload,
|
||||||
|
validateChannelForm as validateSharedChannelForm,
|
||||||
|
validateSalesCreateForm as validateSharedSalesCreateForm,
|
||||||
|
} from "@/features/crmQuickCreate/shared";
|
||||||
import { ProtectedImage } from "@/components/ProtectedImage";
|
import { ProtectedImage } from "@/components/ProtectedImage";
|
||||||
import { useIsMobileViewport } from "@/hooks/useIsMobileViewport";
|
import { useIsMobileViewport } from "@/hooks/useIsMobileViewport";
|
||||||
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
|
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
|
||||||
|
|
@ -47,6 +75,15 @@ const reportFieldLabels = {
|
||||||
channel: ["沟通内容", "后续规划"],
|
channel: ["沟通内容", "后续规划"],
|
||||||
opportunity: ["项目最新进展", "后续规划"],
|
opportunity: ["项目最新进展", "后续规划"],
|
||||||
} as const;
|
} as const;
|
||||||
|
const COMPETITOR_OPTIONS = [
|
||||||
|
"深信服",
|
||||||
|
"锐捷",
|
||||||
|
"华为",
|
||||||
|
"中兴",
|
||||||
|
"噢易云",
|
||||||
|
"无",
|
||||||
|
"其他",
|
||||||
|
] as const;
|
||||||
|
|
||||||
const workSectionItems = [
|
const workSectionItems = [
|
||||||
{
|
{
|
||||||
|
|
@ -108,6 +145,50 @@ type ObjectPickerState = {
|
||||||
bizType: BizType;
|
bizType: BizType;
|
||||||
query: string;
|
query: string;
|
||||||
};
|
};
|
||||||
|
type QuickCreateType = "sales" | "channel" | "opportunity";
|
||||||
|
type CompetitorOption = (typeof COMPETITOR_OPTIONS)[number];
|
||||||
|
type OpportunityQuickField =
|
||||||
|
| "projectLocation"
|
||||||
|
| "opportunityName"
|
||||||
|
| "customerName"
|
||||||
|
| "operatorName"
|
||||||
|
| "salesExpansionId"
|
||||||
|
| "channelExpansionId"
|
||||||
|
| "amount"
|
||||||
|
| "expectedCloseDate"
|
||||||
|
| "confidencePct"
|
||||||
|
| "stage"
|
||||||
|
| "opportunityType"
|
||||||
|
| "competitorName";
|
||||||
|
type OpportunityOperatorMode = "none" | "h3c" | "channel" | "both";
|
||||||
|
|
||||||
|
const defaultQuickOpportunityForm: CreateOpportunityPayload = {
|
||||||
|
opportunityName: "",
|
||||||
|
customerName: "",
|
||||||
|
projectLocation: "",
|
||||||
|
operatorName: "",
|
||||||
|
amount: 0,
|
||||||
|
expectedCloseDate: "",
|
||||||
|
confidencePct: "C",
|
||||||
|
stage: "",
|
||||||
|
opportunityType: "",
|
||||||
|
productType: "VDI云桌面",
|
||||||
|
source: "主动开发",
|
||||||
|
salesExpansionId: undefined,
|
||||||
|
channelExpansionId: undefined,
|
||||||
|
competitorName: "",
|
||||||
|
description: "",
|
||||||
|
};
|
||||||
|
const FALLBACK_CONFIDENCE_OPTIONS = [
|
||||||
|
{ value: "A", label: "A" },
|
||||||
|
{ value: "B", label: "B" },
|
||||||
|
{ value: "C", label: "C" },
|
||||||
|
] as const;
|
||||||
|
const FALLBACK_OPPORTUNITY_TYPE_OPTIONS = [
|
||||||
|
{ value: "新建", label: "新建" },
|
||||||
|
{ value: "扩容", label: "扩容" },
|
||||||
|
{ value: "替换", label: "替换" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
function createEmptyReportLine(): WorkReportLineItem {
|
function createEmptyReportLine(): WorkReportLineItem {
|
||||||
return {
|
return {
|
||||||
|
|
@ -130,6 +211,167 @@ function createEmptyPlanItem(): WorkTomorrowPlanItem {
|
||||||
return { content: "" };
|
return { content: "" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeOperatorToken(value?: string) {
|
||||||
|
return (value || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, "")
|
||||||
|
.replace(/+/g, "+");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOperatorMode(operatorValue: string | undefined, operatorOptions: OpportunityDictOption[]): OpportunityOperatorMode {
|
||||||
|
const selectedOption = operatorOptions.find((item) => (item.value || "") === (operatorValue || ""));
|
||||||
|
const candidates = [selectedOption?.label, selectedOption?.value, operatorValue]
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((item) => normalizeOperatorToken(item));
|
||||||
|
if (!candidates.length || candidates.every((item) => !item)) {
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
const hasH3c = candidates.some((item) => item.includes("新华三") || item.includes("h3c"));
|
||||||
|
const hasChannel = candidates.some((item) => item.includes("渠道") || item.includes("channel"));
|
||||||
|
if (hasH3c && hasChannel) {
|
||||||
|
return "both";
|
||||||
|
}
|
||||||
|
if (hasH3c) {
|
||||||
|
return "h3c";
|
||||||
|
}
|
||||||
|
if (hasChannel) {
|
||||||
|
return "channel";
|
||||||
|
}
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEffectiveConfidenceOptions(confidenceOptions: OpportunityDictOption[]) {
|
||||||
|
return confidenceOptions.length > 0 ? confidenceOptions : [...FALLBACK_CONFIDENCE_OPTIONS];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeConfidenceValue(score: string | null | undefined, confidenceOptions: OpportunityDictOption[]) {
|
||||||
|
const rawValue = typeof score === "string" ? score.trim() : "";
|
||||||
|
const options = getEffectiveConfidenceOptions(confidenceOptions);
|
||||||
|
if (!rawValue) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const matchedByValue = options.find((item) => (item.value || "").trim() === rawValue);
|
||||||
|
if (matchedByValue?.value) {
|
||||||
|
return matchedByValue.value;
|
||||||
|
}
|
||||||
|
const matchedByLabel = options.find((item) => (item.label || "").trim() === rawValue);
|
||||||
|
if (matchedByLabel?.value) {
|
||||||
|
return matchedByLabel.value;
|
||||||
|
}
|
||||||
|
return rawValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCompetitorSelections(selected: CompetitorOption[]) {
|
||||||
|
const deduped = Array.from(new Set(selected));
|
||||||
|
if (deduped.includes("无")) {
|
||||||
|
return ["无"] as CompetitorOption[];
|
||||||
|
}
|
||||||
|
return deduped;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCompetitorValue(selected: CompetitorOption[], customName?: string) {
|
||||||
|
const normalizedSelections = normalizeCompetitorSelections(selected);
|
||||||
|
if (!normalizedSelections.length) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (normalizedSelections.includes("无")) {
|
||||||
|
return "无";
|
||||||
|
}
|
||||||
|
const values = normalizedSelections
|
||||||
|
.filter((item) => item !== "其他")
|
||||||
|
.map((item) => item.trim());
|
||||||
|
if (normalizedSelections.includes("其他")) {
|
||||||
|
const trimmedCustomName = customName?.trim();
|
||||||
|
if (trimmedCustomName) {
|
||||||
|
values.push(trimmedCustomName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const deduped = Array.from(new Set(values.filter(Boolean)));
|
||||||
|
return deduped.length ? deduped.join("、") : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RequiredMark() {
|
||||||
|
return <span className="ml-1 text-rose-500">*</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateOpportunityQuickForm(
|
||||||
|
form: CreateOpportunityPayload,
|
||||||
|
operatorMode: OpportunityOperatorMode,
|
||||||
|
selectedCompetitors: CompetitorOption[],
|
||||||
|
customCompetitorName: string,
|
||||||
|
) {
|
||||||
|
const errors: Partial<Record<OpportunityQuickField, string>> = {};
|
||||||
|
if (!form.projectLocation?.trim()) {
|
||||||
|
errors.projectLocation = "请选择项目地";
|
||||||
|
}
|
||||||
|
if (!form.opportunityName?.trim()) {
|
||||||
|
errors.opportunityName = "请填写项目名称";
|
||||||
|
}
|
||||||
|
if (!form.customerName?.trim()) {
|
||||||
|
errors.customerName = "请填写最终用户";
|
||||||
|
}
|
||||||
|
if (!form.operatorName?.trim()) {
|
||||||
|
errors.operatorName = "请选择运作方";
|
||||||
|
}
|
||||||
|
if (!form.amount || form.amount <= 0) {
|
||||||
|
errors.amount = "请填写预计金额";
|
||||||
|
}
|
||||||
|
if (!form.expectedCloseDate?.trim()) {
|
||||||
|
errors.expectedCloseDate = "请选择预计下单时间";
|
||||||
|
}
|
||||||
|
if (!form.confidencePct?.trim()) {
|
||||||
|
errors.confidencePct = "请选择项目把握度";
|
||||||
|
}
|
||||||
|
if (!form.stage?.trim()) {
|
||||||
|
errors.stage = "请选择项目阶段";
|
||||||
|
}
|
||||||
|
if (!form.opportunityType?.trim()) {
|
||||||
|
errors.opportunityType = "请选择建设类型";
|
||||||
|
}
|
||||||
|
if (selectedCompetitors.length === 0) {
|
||||||
|
errors.competitorName = "请至少选择一个竞争对手";
|
||||||
|
} else if (selectedCompetitors.includes("其他") && !customCompetitorName.trim()) {
|
||||||
|
errors.competitorName = "已选择“其他”,请填写其他竞争对手";
|
||||||
|
}
|
||||||
|
if (operatorMode === "h3c" && !form.salesExpansionId) {
|
||||||
|
errors.salesExpansionId = "请选择新华三负责人";
|
||||||
|
}
|
||||||
|
if (operatorMode === "channel" && !form.channelExpansionId) {
|
||||||
|
errors.channelExpansionId = "请选择渠道名称";
|
||||||
|
}
|
||||||
|
if (operatorMode === "both" && !form.salesExpansionId) {
|
||||||
|
errors.salesExpansionId = "请选择新华三负责人";
|
||||||
|
}
|
||||||
|
if (operatorMode === "both" && !form.channelExpansionId) {
|
||||||
|
errors.channelExpansionId = "请选择渠道名称";
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOpportunityQuickPayload(
|
||||||
|
form: CreateOpportunityPayload,
|
||||||
|
operatorMode: OpportunityOperatorMode,
|
||||||
|
selectedCompetitors: CompetitorOption[],
|
||||||
|
customCompetitorName: string,
|
||||||
|
): CreateOpportunityPayload {
|
||||||
|
return {
|
||||||
|
...form,
|
||||||
|
salesExpansionId: operatorMode === "h3c" || operatorMode === "both" ? form.salesExpansionId : undefined,
|
||||||
|
channelExpansionId: operatorMode === "channel" || operatorMode === "both" ? form.channelExpansionId : undefined,
|
||||||
|
competitorName: buildCompetitorValue(selectedCompetitors, customCompetitorName),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function exportCheckInRowsToExcel(rows: WorkCheckInExportRow[]) {
|
async function exportCheckInRowsToExcel(rows: WorkCheckInExportRow[]) {
|
||||||
const ExcelJS = await import("exceljs");
|
const ExcelJS = await import("exceljs");
|
||||||
const workbook = new ExcelJS.Workbook();
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
|
@ -442,6 +684,33 @@ export default function Work() {
|
||||||
const [opportunityOptions, setOpportunityOptions] = useState<WorkRelationOption[]>([]);
|
const [opportunityOptions, setOpportunityOptions] = useState<WorkRelationOption[]>([]);
|
||||||
const [reportTargetsLoaded, setReportTargetsLoaded] = useState(false);
|
const [reportTargetsLoaded, setReportTargetsLoaded] = useState(false);
|
||||||
const [reportTargetsLoading, setReportTargetsLoading] = useState(false);
|
const [reportTargetsLoading, setReportTargetsLoading] = useState(false);
|
||||||
|
const [quickCreateOpen, setQuickCreateOpen] = useState(false);
|
||||||
|
const [quickCreateType, setQuickCreateType] = useState<QuickCreateType>("sales");
|
||||||
|
const [quickCreateSubmitting, setQuickCreateSubmitting] = useState(false);
|
||||||
|
const [quickCreateError, setQuickCreateError] = useState("");
|
||||||
|
const [quickSalesForm, setQuickSalesForm] = useState<CreateSalesExpansionPayload>(sharedDefaultQuickSalesForm);
|
||||||
|
const [quickChannelForm, setQuickChannelForm] = useState<CreateChannelExpansionPayload>(sharedDefaultQuickChannelForm);
|
||||||
|
const [quickOpportunityForm, setQuickOpportunityForm] = useState<CreateOpportunityPayload>(defaultQuickOpportunityForm);
|
||||||
|
const [quickOpportunityCompetitors, setQuickOpportunityCompetitors] = useState<CompetitorOption[]>([]);
|
||||||
|
const [quickOpportunityCustomCompetitor, setQuickOpportunityCustomCompetitor] = useState("");
|
||||||
|
const [quickSalesFieldErrors, setQuickSalesFieldErrors] = useState<Partial<Record<SalesCreateField, string>>>({});
|
||||||
|
const [quickChannelFieldErrors, setQuickChannelFieldErrors] = useState<Partial<Record<ChannelField, string>>>({});
|
||||||
|
const [quickOpportunityFieldErrors, setQuickOpportunityFieldErrors] = useState<Partial<Record<OpportunityQuickField, string>>>({});
|
||||||
|
const [quickInvalidChannelContactRows, setQuickInvalidChannelContactRows] = useState<number[]>([]);
|
||||||
|
const [quickOfficeOptions, setQuickOfficeOptions] = useState<ExpansionDictOption[]>([]);
|
||||||
|
const [quickIndustryOptions, setQuickIndustryOptions] = useState<ExpansionDictOption[]>([]);
|
||||||
|
const [quickProvinceOptions, setQuickProvinceOptions] = useState<ExpansionDictOption[]>([]);
|
||||||
|
const [quickCityOptions, setQuickCityOptions] = useState<ExpansionDictOption[]>([]);
|
||||||
|
const [quickCertificationLevelOptions, setQuickCertificationLevelOptions] = useState<ExpansionDictOption[]>([]);
|
||||||
|
const [quickChannelAttributeOptions, setQuickChannelAttributeOptions] = useState<ExpansionDictOption[]>([]);
|
||||||
|
const [quickInternalAttributeOptions, setQuickInternalAttributeOptions] = useState<ExpansionDictOption[]>([]);
|
||||||
|
const [quickOpportunityOperatorOptions, setQuickOpportunityOperatorOptions] = useState<OpportunityDictOption[]>([]);
|
||||||
|
const [quickOpportunityProjectLocationOptions, setQuickOpportunityProjectLocationOptions] = useState<OpportunityDictOption[]>([]);
|
||||||
|
const [quickOpportunityTypeOptions, setQuickOpportunityTypeOptions] = useState<OpportunityDictOption[]>([]);
|
||||||
|
const [quickOpportunityStageOptions, setQuickOpportunityStageOptions] = useState<OpportunityDictOption[]>([]);
|
||||||
|
const [quickOpportunityConfidenceOptions, setQuickOpportunityConfidenceOptions] = useState<OpportunityDictOption[]>([]);
|
||||||
|
const [quickSalesDuplicateMessage, setQuickSalesDuplicateMessage] = useState("");
|
||||||
|
const [quickChannelDuplicateMessage, setQuickChannelDuplicateMessage] = useState("");
|
||||||
const [checkInForm, setCheckInForm] = useState<CreateWorkCheckInPayload>(defaultCheckInForm);
|
const [checkInForm, setCheckInForm] = useState<CreateWorkCheckInPayload>(defaultCheckInForm);
|
||||||
const [reportForm, setReportForm] = useState<CreateWorkDailyReportPayload>(defaultReportForm);
|
const [reportForm, setReportForm] = useState<CreateWorkDailyReportPayload>(defaultReportForm);
|
||||||
const [objectPicker, setObjectPicker] = useState<ObjectPickerState | null>(null);
|
const [objectPicker, setObjectPicker] = useState<ObjectPickerState | null>(null);
|
||||||
|
|
@ -466,6 +735,10 @@ export default function Work() {
|
||||||
}
|
}
|
||||||
return options.filter((option) => option.label.toLowerCase().includes(keyword));
|
return options.filter((option) => option.label.toLowerCase().includes(keyword));
|
||||||
}, [objectPicker, salesOptions, channelOptions, opportunityOptions]);
|
}, [objectPicker, salesOptions, channelOptions, opportunityOptions]);
|
||||||
|
const quickOpportunityOperatorMode = useMemo(
|
||||||
|
() => resolveOperatorMode(quickOpportunityForm.operatorName, quickOpportunityOperatorOptions),
|
||||||
|
[quickOpportunityForm.operatorName, quickOpportunityOperatorOptions],
|
||||||
|
);
|
||||||
|
|
||||||
const historyPresenterOptions = useMemo(() => {
|
const historyPresenterOptions = useMemo(() => {
|
||||||
const presenterMap = new Map<string, { label: string; count: number }>();
|
const presenterMap = new Map<string, { label: string; count: number }>();
|
||||||
|
|
@ -537,6 +810,22 @@ export default function Work() {
|
||||||
setReportTargetsLoading(false);
|
setReportTargetsLoading(false);
|
||||||
}, [reportTargetsLoaded, reportTargetsLoading]);
|
}, [reportTargetsLoaded, reportTargetsLoading]);
|
||||||
|
|
||||||
|
const refreshReportTargets = useCallback(async () => {
|
||||||
|
const [expansionData, opportunityData] = await Promise.all([
|
||||||
|
getExpansionOverview(""),
|
||||||
|
getOpportunityOverview(),
|
||||||
|
]);
|
||||||
|
setSalesOptions(buildSalesOptions(expansionData.salesItems ?? []));
|
||||||
|
setChannelOptions(buildChannelOptions(expansionData.channelItems ?? []));
|
||||||
|
setOpportunityOptions(buildOpportunityOptions(opportunityData.items ?? []));
|
||||||
|
setReportTargetsLoaded(true);
|
||||||
|
return {
|
||||||
|
salesItems: expansionData.salesItems ?? [],
|
||||||
|
channelItems: expansionData.channelItems ?? [],
|
||||||
|
opportunityItems: opportunityData.items ?? [],
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadOverview();
|
void loadOverview();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -916,6 +1205,265 @@ export default function Work() {
|
||||||
setObjectPicker({ mode, lineIndex, bizType, query: "" });
|
setObjectPicker({ mode, lineIndex, bizType, query: "" });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resetQuickCreateState = () => {
|
||||||
|
setQuickCreateOpen(false);
|
||||||
|
setQuickCreateSubmitting(false);
|
||||||
|
setQuickCreateError("");
|
||||||
|
setQuickSalesForm(sharedDefaultQuickSalesForm);
|
||||||
|
setQuickChannelForm(sharedDefaultQuickChannelForm);
|
||||||
|
setQuickOpportunityForm(defaultQuickOpportunityForm);
|
||||||
|
setQuickOpportunityCompetitors([]);
|
||||||
|
setQuickOpportunityCustomCompetitor("");
|
||||||
|
setQuickSalesFieldErrors({});
|
||||||
|
setQuickChannelFieldErrors({});
|
||||||
|
setQuickOpportunityFieldErrors({});
|
||||||
|
setQuickInvalidChannelContactRows([]);
|
||||||
|
setQuickCityOptions([]);
|
||||||
|
setQuickSalesDuplicateMessage("");
|
||||||
|
setQuickChannelDuplicateMessage("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadQuickCreateMeta = async () => {
|
||||||
|
const [expansionMeta, opportunityMeta] = await Promise.all([
|
||||||
|
getExpansionMeta(),
|
||||||
|
getOpportunityMeta(),
|
||||||
|
]);
|
||||||
|
setQuickOfficeOptions(expansionMeta.officeOptions ?? []);
|
||||||
|
setQuickIndustryOptions(expansionMeta.industryOptions ?? []);
|
||||||
|
setQuickProvinceOptions(expansionMeta.provinceOptions ?? []);
|
||||||
|
setQuickCertificationLevelOptions(expansionMeta.certificationLevelOptions ?? []);
|
||||||
|
setQuickChannelAttributeOptions(expansionMeta.channelAttributeOptions ?? []);
|
||||||
|
setQuickInternalAttributeOptions(expansionMeta.internalAttributeOptions ?? []);
|
||||||
|
setQuickOpportunityOperatorOptions((opportunityMeta.operatorOptions ?? []).filter((item) => item.value));
|
||||||
|
setQuickOpportunityProjectLocationOptions((opportunityMeta.projectLocationOptions ?? []).filter((item) => item.value));
|
||||||
|
setQuickOpportunityTypeOptions((opportunityMeta.opportunityTypeOptions ?? []).filter((item) => item.value));
|
||||||
|
setQuickOpportunityStageOptions((opportunityMeta.stageOptions ?? []).filter((item) => item.value));
|
||||||
|
setQuickOpportunityConfidenceOptions((opportunityMeta.confidenceOptions ?? []).filter((item) => item.value));
|
||||||
|
return { expansionMeta, opportunityMeta };
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadQuickCityOptions = async (provinceName?: string) => {
|
||||||
|
const normalizedProvinceName = provinceName?.trim();
|
||||||
|
if (!normalizedProvinceName) {
|
||||||
|
setQuickCityOptions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cityOptions = await getExpansionCityOptions(normalizedProvinceName);
|
||||||
|
setQuickCityOptions(cityOptions ?? []);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openQuickCreateModal = async (type: QuickCreateType, initialKeyword?: string) => {
|
||||||
|
const normalizedKeyword = initialKeyword?.trim() || "";
|
||||||
|
setQuickCreateType(type);
|
||||||
|
setQuickCreateError("");
|
||||||
|
setQuickSalesFieldErrors({});
|
||||||
|
setQuickChannelFieldErrors({});
|
||||||
|
setQuickInvalidChannelContactRows([]);
|
||||||
|
setQuickSalesDuplicateMessage("");
|
||||||
|
setQuickChannelDuplicateMessage("");
|
||||||
|
try {
|
||||||
|
const { expansionMeta, opportunityMeta } = await loadQuickCreateMeta();
|
||||||
|
if (type === "sales") {
|
||||||
|
setQuickSalesForm({
|
||||||
|
...sharedDefaultQuickSalesForm,
|
||||||
|
candidateName: normalizedKeyword,
|
||||||
|
});
|
||||||
|
} else if (type === "channel") {
|
||||||
|
setQuickChannelForm({
|
||||||
|
...sharedDefaultQuickChannelForm,
|
||||||
|
channelCode: expansionMeta.nextChannelCode || "",
|
||||||
|
channelName: normalizedKeyword,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const effectiveOpportunityTypes = (opportunityMeta.opportunityTypeOptions ?? []).filter((item) => item.value);
|
||||||
|
const effectiveStages = (opportunityMeta.stageOptions ?? []).filter((item) => item.value);
|
||||||
|
const effectiveConfidence = getEffectiveConfidenceOptions((opportunityMeta.confidenceOptions ?? []).filter((item) => item.value));
|
||||||
|
setQuickOpportunityForm({
|
||||||
|
...defaultQuickOpportunityForm,
|
||||||
|
opportunityName: normalizedKeyword,
|
||||||
|
stage: effectiveStages[0]?.value || "",
|
||||||
|
opportunityType: (effectiveOpportunityTypes.length > 0 ? effectiveOpportunityTypes : [...FALLBACK_OPPORTUNITY_TYPE_OPTIONS])[0]?.value || "",
|
||||||
|
confidencePct: effectiveConfidence[0]?.value || defaultQuickOpportunityForm.confidencePct,
|
||||||
|
});
|
||||||
|
setQuickOpportunityCompetitors([]);
|
||||||
|
setQuickOpportunityCustomCompetitor("");
|
||||||
|
}
|
||||||
|
setQuickCreateOpen(true);
|
||||||
|
} catch (error) {
|
||||||
|
setCheckInError(error instanceof Error ? error.message : "加载新增表单失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuickSalesChange = <K extends keyof CreateSalesExpansionPayload>(key: K, value: CreateSalesExpansionPayload[K]) => {
|
||||||
|
setQuickSalesForm((current) => ({ ...current, [key]: value }));
|
||||||
|
if (key in quickSalesFieldErrors) {
|
||||||
|
setQuickSalesFieldErrors((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[key as SalesCreateField];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (key === "employeeNo") {
|
||||||
|
setQuickSalesDuplicateMessage("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuickChannelChange = <K extends keyof CreateChannelExpansionPayload>(key: K, value: CreateChannelExpansionPayload[K]) => {
|
||||||
|
setQuickChannelForm((current) => ({ ...current, [key]: value }));
|
||||||
|
if (key in quickChannelFieldErrors) {
|
||||||
|
setQuickChannelFieldErrors((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[key as ChannelField];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (key === "channelName") {
|
||||||
|
setQuickChannelDuplicateMessage("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuickOpportunityChange = <K extends keyof CreateOpportunityPayload>(key: K, value: CreateOpportunityPayload[K]) => {
|
||||||
|
setQuickOpportunityForm((current) => ({ ...current, [key]: value }));
|
||||||
|
if (key in quickOpportunityFieldErrors) {
|
||||||
|
setQuickOpportunityFieldErrors((current) => {
|
||||||
|
const next = { ...current };
|
||||||
|
delete next[key as OpportunityQuickField];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuickChannelContactChange = (index: number, key: keyof ChannelExpansionContact, value: string) => {
|
||||||
|
setQuickChannelForm((current) => {
|
||||||
|
const nextContacts = [...(current.contacts ?? [])];
|
||||||
|
nextContacts[index] = {
|
||||||
|
...(nextContacts[index] ?? createSharedEmptyChannelContact()),
|
||||||
|
[key]: value,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
contacts: nextContacts,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addQuickChannelContact = () => {
|
||||||
|
setQuickChannelForm((current) => ({
|
||||||
|
...current,
|
||||||
|
contacts: [...(current.contacts ?? []), createSharedEmptyChannelContact()],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeQuickChannelContact = (index: number) => {
|
||||||
|
setQuickChannelForm((current) => {
|
||||||
|
const nextContacts = (current.contacts ?? []).filter((_, contactIndex) => contactIndex !== index);
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
contacts: nextContacts.length > 0 ? nextContacts : [createSharedEmptyChannelContact()],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setQuickInvalidChannelContactRows((current) => current.filter((rowIndex) => rowIndex !== index).map((rowIndex) => (rowIndex > index ? rowIndex - 1 : rowIndex)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuickChannelProvinceChange = (value: string) => {
|
||||||
|
const nextProvince = value || "";
|
||||||
|
handleQuickChannelChange("province", nextProvince || undefined);
|
||||||
|
handleQuickChannelChange("city", undefined);
|
||||||
|
void loadQuickCityOptions(nextProvince);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuickCreateSubmit = async () => {
|
||||||
|
if (quickCreateSubmitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setQuickCreateError("");
|
||||||
|
setQuickSalesDuplicateMessage("");
|
||||||
|
setQuickChannelDuplicateMessage("");
|
||||||
|
|
||||||
|
if (quickCreateType === "sales") {
|
||||||
|
const validationErrors = validateSharedSalesCreateForm(quickSalesForm);
|
||||||
|
if (Object.keys(validationErrors).length > 0) {
|
||||||
|
setQuickSalesFieldErrors(validationErrors);
|
||||||
|
setQuickCreateError("请先完整填写销售人员拓展必填字段");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (quickCreateType === "channel") {
|
||||||
|
const channelOtherOptionValue = quickChannelAttributeOptions.find(isSharedOtherOption)?.value;
|
||||||
|
const { errors, invalidContactRows } = validateSharedChannelForm(quickChannelForm, channelOtherOptionValue);
|
||||||
|
if (Object.keys(errors).length > 0) {
|
||||||
|
setQuickChannelFieldErrors(errors);
|
||||||
|
setQuickInvalidChannelContactRows(invalidContactRows);
|
||||||
|
setQuickCreateError("请先完整填写渠道拓展必填字段");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const validationErrors = validateOpportunityQuickForm(
|
||||||
|
quickOpportunityForm,
|
||||||
|
quickOpportunityOperatorMode,
|
||||||
|
quickOpportunityCompetitors,
|
||||||
|
quickOpportunityCustomCompetitor,
|
||||||
|
);
|
||||||
|
if (Object.keys(validationErrors).length > 0) {
|
||||||
|
setQuickOpportunityFieldErrors(validationErrors);
|
||||||
|
setQuickCreateError("请先完整填写商机必填字段");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setQuickCreateSubmitting(true);
|
||||||
|
try {
|
||||||
|
let createdOption: WorkRelationOption | null = null;
|
||||||
|
if (quickCreateType === "sales") {
|
||||||
|
const duplicateResult = await checkSalesExpansionDuplicate(quickSalesForm.employeeNo.trim());
|
||||||
|
if (duplicateResult.duplicated) {
|
||||||
|
const message = duplicateResult.message || "工号重复,请确认该人员是否已存在!";
|
||||||
|
setQuickSalesDuplicateMessage(message);
|
||||||
|
setQuickSalesFieldErrors((current) => ({ ...current, employeeNo: message }));
|
||||||
|
setQuickCreateError(message);
|
||||||
|
setQuickCreateSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const createdId = await createSalesExpansion(normalizeSharedSalesPayload(quickSalesForm));
|
||||||
|
const latestTargets = await refreshReportTargets();
|
||||||
|
const createdItem = latestTargets.salesItems.find((item) => item.id === createdId);
|
||||||
|
createdOption = { id: createdId, label: createdItem?.name || quickSalesForm.candidateName.trim() };
|
||||||
|
} else if (quickCreateType === "channel") {
|
||||||
|
const duplicateResult = await checkChannelExpansionDuplicate(quickChannelForm.channelName.trim());
|
||||||
|
if (duplicateResult.duplicated) {
|
||||||
|
const message = duplicateResult.message || "渠道重复,请确认该渠道是否已存在!";
|
||||||
|
setQuickChannelDuplicateMessage(message);
|
||||||
|
setQuickChannelFieldErrors((current) => ({ ...current, channelName: message }));
|
||||||
|
setQuickCreateError(message);
|
||||||
|
setQuickCreateSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const createdId = await createChannelExpansion(normalizeSharedChannelPayload(quickChannelForm));
|
||||||
|
const latestTargets = await refreshReportTargets();
|
||||||
|
const createdItem = latestTargets.channelItems.find((item) => item.id === createdId);
|
||||||
|
createdOption = { id: createdId, label: createdItem?.name || quickChannelForm.channelName.trim() };
|
||||||
|
} else {
|
||||||
|
const createdId = await createOpportunity(
|
||||||
|
buildOpportunityQuickPayload(
|
||||||
|
quickOpportunityForm,
|
||||||
|
quickOpportunityOperatorMode,
|
||||||
|
quickOpportunityCompetitors,
|
||||||
|
quickOpportunityCustomCompetitor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const latestTargets = await refreshReportTargets();
|
||||||
|
const createdItem = latestTargets.opportunityItems.find((item) => item.id === createdId);
|
||||||
|
createdOption = { id: createdId, label: createdItem?.name || quickOpportunityForm.opportunityName.trim() };
|
||||||
|
}
|
||||||
|
resetQuickCreateState();
|
||||||
|
if (createdOption) {
|
||||||
|
handleSelectObject(createdOption);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setQuickCreateError(error instanceof Error ? error.message : "新增失败");
|
||||||
|
setQuickCreateSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSelectObject = (option: WorkRelationOption) => {
|
const handleSelectObject = (option: WorkRelationOption) => {
|
||||||
if (!objectPicker) {
|
if (!objectPicker) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -1446,7 +1994,20 @@ export default function Work() {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="crm-empty-panel px-4 py-8">
|
<div className="crm-empty-panel px-4 py-8">
|
||||||
没有找到匹配对象,请换个关键词试试
|
<p>没有找到匹配对象,请换个关键词试试</p>
|
||||||
|
{(objectPicker.bizType === "sales" || objectPicker.bizType === "channel" || objectPicker.bizType === "opportunity") ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void openQuickCreateModal(objectPicker.bizType as QuickCreateType, objectPicker.query)}
|
||||||
|
className="mt-3 rounded-xl bg-violet-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-violet-700"
|
||||||
|
>
|
||||||
|
{objectPicker.bizType === "sales"
|
||||||
|
? (objectPicker.query.trim() ? `新增负责人“${objectPicker.query.trim()}”并选中` : "新增负责人并选中")
|
||||||
|
: objectPicker.bizType === "channel"
|
||||||
|
? (objectPicker.query.trim() ? `新增渠道“${objectPicker.query.trim()}”并选中` : "新增渠道并选中")
|
||||||
|
: (objectPicker.query.trim() ? `新增商机“${objectPicker.query.trim()}”并选中` : "新增商机并选中")}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1454,6 +2015,280 @@ export default function Work() {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{quickCreateOpen ? (
|
||||||
|
<div className="fixed inset-0 z-[96]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={resetQuickCreateState}
|
||||||
|
className={cn("absolute inset-0 bg-slate-900/35", !disableMobileMotion && "backdrop-blur-sm")}
|
||||||
|
aria-label="关闭新增对象"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-x-0 bottom-0 max-h-[88dvh] overflow-hidden rounded-t-3xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-900 md:inset-x-auto md:left-1/2 md:top-1/2 md:bottom-auto md:w-[min(760px,88vw)] md:max-h-[78vh] md:-translate-x-1/2 md:-translate-y-1/2 md:rounded-3xl">
|
||||||
|
<div className="border-b border-slate-100 px-5 py-4 dark:border-slate-800 md:px-6">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold text-slate-900 dark:text-white">
|
||||||
|
{quickCreateType === "sales"
|
||||||
|
? "新增负责人并选中"
|
||||||
|
: quickCreateType === "channel"
|
||||||
|
? "新增渠道并选中"
|
||||||
|
: "新增商机并选中"}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-slate-500 dark:text-slate-400">
|
||||||
|
{quickCreateType === "sales"
|
||||||
|
? "保存成功后会自动选中当前负责人。"
|
||||||
|
: quickCreateType === "channel"
|
||||||
|
? "保存成功后会自动选中当前渠道。"
|
||||||
|
: "保存成功后会自动选中当前商机。"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={resetQuickCreateState}
|
||||||
|
className="inline-flex h-9 w-9 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
<X className="crm-icon-md" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[calc(88dvh-132px)] overflow-y-auto px-5 py-5 pb-[calc(env(safe-area-inset-bottom)+16px)] md:max-h-[calc(78vh-132px)] md:px-6">
|
||||||
|
<div className="crm-form-grid">
|
||||||
|
{quickCreateType === "sales" ? (
|
||||||
|
<SharedQuickSalesForm
|
||||||
|
form={quickSalesForm}
|
||||||
|
fieldErrors={quickSalesFieldErrors}
|
||||||
|
officeOptions={quickOfficeOptions}
|
||||||
|
industryOptions={quickIndustryOptions}
|
||||||
|
duplicateMessage={quickSalesDuplicateMessage}
|
||||||
|
requiredMark={<RequiredMark />}
|
||||||
|
onChange={handleQuickSalesChange}
|
||||||
|
/>
|
||||||
|
) : quickCreateType === "channel" ? (
|
||||||
|
<SharedQuickChannelForm
|
||||||
|
form={quickChannelForm}
|
||||||
|
fieldErrors={quickChannelFieldErrors}
|
||||||
|
invalidContactRows={quickInvalidChannelContactRows}
|
||||||
|
provinceOptions={quickProvinceOptions}
|
||||||
|
cityOptions={quickCityOptions}
|
||||||
|
industryOptions={quickIndustryOptions}
|
||||||
|
certificationLevelOptions={quickCertificationLevelOptions}
|
||||||
|
channelAttributeOptions={quickChannelAttributeOptions}
|
||||||
|
internalAttributeOptions={quickInternalAttributeOptions}
|
||||||
|
channelOtherOptionValue={quickChannelAttributeOptions.find(isSharedOtherOption)?.value}
|
||||||
|
duplicateMessage={quickChannelDuplicateMessage}
|
||||||
|
requiredMark={<RequiredMark />}
|
||||||
|
onChange={handleQuickChannelChange}
|
||||||
|
onContactChange={handleQuickChannelContactChange}
|
||||||
|
onAddContact={addQuickChannelContact}
|
||||||
|
onRemoveContact={removeQuickChannelContact}
|
||||||
|
onProvinceChange={handleQuickChannelProvinceChange}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<label className="space-y-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">项目地<RequiredMark /></span>
|
||||||
|
<AdaptiveSelect
|
||||||
|
value={quickOpportunityForm.projectLocation || ""}
|
||||||
|
placeholder="请选择"
|
||||||
|
sheetTitle="项目地"
|
||||||
|
searchable
|
||||||
|
searchPlaceholder="搜索项目地"
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "请选择" },
|
||||||
|
...quickOpportunityProjectLocationOptions.map((item) => ({
|
||||||
|
value: item.value || "",
|
||||||
|
label: item.label || item.value || "",
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
className={getFieldInputClass(Boolean(quickOpportunityFieldErrors.projectLocation))}
|
||||||
|
onChange={(value) => handleQuickOpportunityChange("projectLocation", value)}
|
||||||
|
/>
|
||||||
|
{quickOpportunityFieldErrors.projectLocation ? <p className="text-xs text-rose-500">{quickOpportunityFieldErrors.projectLocation}</p> : null}
|
||||||
|
</label>
|
||||||
|
<label className="space-y-2 sm:col-span-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">项目名称<RequiredMark /></span>
|
||||||
|
<input value={quickOpportunityForm.opportunityName} onChange={(e) => handleQuickOpportunityChange("opportunityName", e.target.value)} className={getFieldInputClass(Boolean(quickOpportunityFieldErrors.opportunityName))} />
|
||||||
|
{quickOpportunityFieldErrors.opportunityName ? <p className="text-xs text-rose-500">{quickOpportunityFieldErrors.opportunityName}</p> : null}
|
||||||
|
</label>
|
||||||
|
<label className="space-y-2 sm:col-span-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">最终用户<RequiredMark /></span>
|
||||||
|
<input value={quickOpportunityForm.customerName} onChange={(e) => handleQuickOpportunityChange("customerName", e.target.value)} className={getFieldInputClass(Boolean(quickOpportunityFieldErrors.customerName))} />
|
||||||
|
{quickOpportunityFieldErrors.customerName ? <p className="text-xs text-rose-500">{quickOpportunityFieldErrors.customerName}</p> : null}
|
||||||
|
</label>
|
||||||
|
<label className="space-y-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">运作方<RequiredMark /></span>
|
||||||
|
<AdaptiveSelect
|
||||||
|
value={quickOpportunityForm.operatorName || ""}
|
||||||
|
placeholder="请选择"
|
||||||
|
sheetTitle="运作方"
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "请选择" },
|
||||||
|
...quickOpportunityOperatorOptions.map((item) => ({
|
||||||
|
value: item.value || "",
|
||||||
|
label: item.label || item.value || "",
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
className={getFieldInputClass(Boolean(quickOpportunityFieldErrors.operatorName))}
|
||||||
|
onChange={(value) => handleQuickOpportunityChange("operatorName", value)}
|
||||||
|
/>
|
||||||
|
{quickOpportunityFieldErrors.operatorName ? <p className="text-xs text-rose-500">{quickOpportunityFieldErrors.operatorName}</p> : null}
|
||||||
|
</label>
|
||||||
|
{(quickOpportunityOperatorMode === "h3c" || quickOpportunityOperatorMode === "both") ? (
|
||||||
|
<label className="space-y-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">新华三负责人<RequiredMark /></span>
|
||||||
|
<AdaptiveSelect
|
||||||
|
value={quickOpportunityForm.salesExpansionId ? String(quickOpportunityForm.salesExpansionId) : ""}
|
||||||
|
placeholder="请选择"
|
||||||
|
sheetTitle="新华三负责人"
|
||||||
|
searchable
|
||||||
|
searchPlaceholder="搜索负责人"
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "请选择" },
|
||||||
|
...salesOptions.map((item) => ({ value: String(item.id), label: item.label })),
|
||||||
|
]}
|
||||||
|
className={getFieldInputClass(Boolean(quickOpportunityFieldErrors.salesExpansionId))}
|
||||||
|
onChange={(value) => handleQuickOpportunityChange("salesExpansionId", value ? Number(value) : undefined)}
|
||||||
|
/>
|
||||||
|
{quickOpportunityFieldErrors.salesExpansionId ? <p className="text-xs text-rose-500">{quickOpportunityFieldErrors.salesExpansionId}</p> : null}
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
{(quickOpportunityOperatorMode === "channel" || quickOpportunityOperatorMode === "both") ? (
|
||||||
|
<label className="space-y-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">渠道名称<RequiredMark /></span>
|
||||||
|
<AdaptiveSelect
|
||||||
|
value={quickOpportunityForm.channelExpansionId ? String(quickOpportunityForm.channelExpansionId) : ""}
|
||||||
|
placeholder="请选择"
|
||||||
|
sheetTitle="渠道名称"
|
||||||
|
searchable
|
||||||
|
searchPlaceholder="搜索渠道"
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "请选择" },
|
||||||
|
...channelOptions.map((item) => ({ value: String(item.id), label: item.label })),
|
||||||
|
]}
|
||||||
|
className={getFieldInputClass(Boolean(quickOpportunityFieldErrors.channelExpansionId))}
|
||||||
|
onChange={(value) => handleQuickOpportunityChange("channelExpansionId", value ? Number(value) : undefined)}
|
||||||
|
/>
|
||||||
|
{quickOpportunityFieldErrors.channelExpansionId ? <p className="text-xs text-rose-500">{quickOpportunityFieldErrors.channelExpansionId}</p> : null}
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
<label className="space-y-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">预计金额(元)<RequiredMark /></span>
|
||||||
|
<input type="number" min="0" value={quickOpportunityForm.amount || ""} onChange={(e) => handleQuickOpportunityChange("amount", Number(e.target.value) || 0)} className={getFieldInputClass(Boolean(quickOpportunityFieldErrors.amount))} />
|
||||||
|
{quickOpportunityFieldErrors.amount ? <p className="text-xs text-rose-500">{quickOpportunityFieldErrors.amount}</p> : null}
|
||||||
|
</label>
|
||||||
|
<label className="space-y-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">预计下单时间<RequiredMark /></span>
|
||||||
|
<input type="date" value={quickOpportunityForm.expectedCloseDate} onChange={(e) => handleQuickOpportunityChange("expectedCloseDate", e.target.value)} className={getFieldInputClass(Boolean(quickOpportunityFieldErrors.expectedCloseDate))} />
|
||||||
|
{quickOpportunityFieldErrors.expectedCloseDate ? <p className="text-xs text-rose-500">{quickOpportunityFieldErrors.expectedCloseDate}</p> : null}
|
||||||
|
</label>
|
||||||
|
<label className="space-y-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">项目把握度<RequiredMark /></span>
|
||||||
|
<AdaptiveSelect
|
||||||
|
value={normalizeConfidenceValue(quickOpportunityForm.confidencePct, quickOpportunityConfidenceOptions)}
|
||||||
|
placeholder="请选择"
|
||||||
|
sheetTitle="项目把握度"
|
||||||
|
options={getEffectiveConfidenceOptions(quickOpportunityConfidenceOptions).map((item) => ({
|
||||||
|
value: item.value || "",
|
||||||
|
label: item.label || item.value || "",
|
||||||
|
}))}
|
||||||
|
className={getFieldInputClass(Boolean(quickOpportunityFieldErrors.confidencePct))}
|
||||||
|
onChange={(value) => handleQuickOpportunityChange("confidencePct", normalizeConfidenceValue(value, quickOpportunityConfidenceOptions))}
|
||||||
|
/>
|
||||||
|
{quickOpportunityFieldErrors.confidencePct ? <p className="text-xs text-rose-500">{quickOpportunityFieldErrors.confidencePct}</p> : null}
|
||||||
|
</label>
|
||||||
|
<label className="space-y-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">项目阶段<RequiredMark /></span>
|
||||||
|
<AdaptiveSelect
|
||||||
|
value={quickOpportunityForm.stage || ""}
|
||||||
|
placeholder="请选择"
|
||||||
|
sheetTitle="项目阶段"
|
||||||
|
options={quickOpportunityStageOptions.map((item) => ({
|
||||||
|
value: item.value || "",
|
||||||
|
label: item.label || item.value || "",
|
||||||
|
}))}
|
||||||
|
className={getFieldInputClass(Boolean(quickOpportunityFieldErrors.stage))}
|
||||||
|
onChange={(value) => handleQuickOpportunityChange("stage", value)}
|
||||||
|
/>
|
||||||
|
{quickOpportunityFieldErrors.stage ? <p className="text-xs text-rose-500">{quickOpportunityFieldErrors.stage}</p> : null}
|
||||||
|
</label>
|
||||||
|
<label className="space-y-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">建设类型<RequiredMark /></span>
|
||||||
|
<AdaptiveSelect
|
||||||
|
value={quickOpportunityForm.opportunityType || ""}
|
||||||
|
placeholder="请选择"
|
||||||
|
sheetTitle="建设类型"
|
||||||
|
options={(quickOpportunityTypeOptions.length > 0 ? quickOpportunityTypeOptions : [...FALLBACK_OPPORTUNITY_TYPE_OPTIONS]).map((item) => ({
|
||||||
|
value: item.value || "",
|
||||||
|
label: item.label || item.value || "",
|
||||||
|
}))}
|
||||||
|
className={getFieldInputClass(Boolean(quickOpportunityFieldErrors.opportunityType))}
|
||||||
|
onChange={(value) => handleQuickOpportunityChange("opportunityType", value)}
|
||||||
|
/>
|
||||||
|
{quickOpportunityFieldErrors.opportunityType ? <p className="text-xs text-rose-500">{quickOpportunityFieldErrors.opportunityType}</p> : null}
|
||||||
|
</label>
|
||||||
|
<label className="space-y-2 sm:col-span-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">竞争对手<RequiredMark /></span>
|
||||||
|
<AdaptiveSelect
|
||||||
|
multiple
|
||||||
|
value={quickOpportunityCompetitors}
|
||||||
|
placeholder="请选择竞争对手"
|
||||||
|
sheetTitle="竞争对手"
|
||||||
|
options={COMPETITOR_OPTIONS.map((option) => ({ value: option, label: option }))}
|
||||||
|
className={getFieldInputClass(Boolean(quickOpportunityFieldErrors.competitorName))}
|
||||||
|
onChange={(value) => {
|
||||||
|
setQuickOpportunityFieldErrors((current) => {
|
||||||
|
if (!current.competitorName) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
const next = { ...current };
|
||||||
|
delete next.competitorName;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
const nextValue = normalizeCompetitorSelections(value as CompetitorOption[]);
|
||||||
|
setQuickOpportunityCompetitors(nextValue);
|
||||||
|
if (!nextValue.includes("其他")) {
|
||||||
|
setQuickOpportunityCustomCompetitor("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{quickOpportunityFieldErrors.competitorName && !quickOpportunityCompetitors.includes("其他") ? <p className="text-xs text-rose-500">{quickOpportunityFieldErrors.competitorName}</p> : null}
|
||||||
|
</label>
|
||||||
|
{quickOpportunityCompetitors.includes("其他") ? (
|
||||||
|
<label className="space-y-2 sm:col-span-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">其他竞争对手<RequiredMark /></span>
|
||||||
|
<input value={quickOpportunityCustomCompetitor} onChange={(e) => setQuickOpportunityCustomCompetitor(e.target.value)} placeholder="请输入其他竞争对手" className={getFieldInputClass(Boolean(quickOpportunityFieldErrors.competitorName))} />
|
||||||
|
{quickOpportunityFieldErrors.competitorName ? <p className="text-xs text-rose-500">{quickOpportunityFieldErrors.competitorName}</p> : null}
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
<label className="space-y-2 sm:col-span-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">备注说明</span>
|
||||||
|
<textarea rows={4} value={quickOpportunityForm.description || ""} onChange={(e) => handleQuickOpportunityChange("description", e.target.value)} className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" />
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{quickCreateError ? <p className="mt-4 text-sm text-rose-500 dark:text-rose-300">{quickCreateError}</p> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3 border-t border-slate-100 px-5 py-4 dark:border-slate-800 md:px-6">
|
||||||
|
<button type="button" onClick={resetQuickCreateState} className="h-10 rounded-xl border border-slate-200 bg-white px-4 text-sm font-medium text-slate-600 transition-colors hover:border-violet-200 hover:text-violet-600 dark:border-slate-700 dark:bg-slate-900/60 dark:text-slate-300 dark:hover:border-violet-500/40 dark:hover:text-violet-200">
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => void handleQuickCreateSubmit()} disabled={quickCreateSubmitting} className="inline-flex h-10 items-center justify-center rounded-xl bg-violet-600 px-4 text-sm font-semibold text-white transition-colors hover:bg-violet-700 disabled:cursor-not-allowed disabled:bg-violet-300 dark:bg-violet-500 dark:hover:bg-violet-400 dark:disabled:bg-violet-500/40">
|
||||||
|
{quickCreateSubmitting
|
||||||
|
? "提交中..."
|
||||||
|
: quickCreateType === "sales"
|
||||||
|
? "确认新增负责人并选中"
|
||||||
|
: quickCreateType === "channel"
|
||||||
|
? "确认新增渠道并选中"
|
||||||
|
: "确认新增商机并选中"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{locationAdjustOpen && checkInForm.latitude && checkInForm.longitude ? (
|
{locationAdjustOpen && checkInForm.latitude && checkInForm.longitude ? (
|
||||||
<LocationAdjustModal
|
<LocationAdjustModal
|
||||||
origin={locationAdjustOrigin ?? { latitude: checkInForm.latitude, longitude: checkInForm.longitude }}
|
origin={locationAdjustOrigin ?? { latitude: checkInForm.latitude, longitude: checkInForm.longitude }}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue