快速新增回填功能升级
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 { createPortal } from "react-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 {
|
||||
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 { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -59,6 +100,7 @@ type OpportunityField =
|
|||
| "stage"
|
||||
| "competitorName"
|
||||
| "opportunityType";
|
||||
type QuickCreateType = "sales" | "channel";
|
||||
|
||||
const defaultForm: CreateOpportunityPayload = {
|
||||
opportunityName: "",
|
||||
|
|
@ -868,16 +910,22 @@ function SearchableSelect({
|
|||
placeholder,
|
||||
searchPlaceholder,
|
||||
emptyText,
|
||||
createActionLabel,
|
||||
className,
|
||||
onChange,
|
||||
onCreate,
|
||||
onQueryChange,
|
||||
}: {
|
||||
value?: number;
|
||||
options: SearchableOption[];
|
||||
placeholder: string;
|
||||
searchPlaceholder: string;
|
||||
emptyText: string;
|
||||
createActionLabel?: string;
|
||||
className?: string;
|
||||
onChange: (value?: number) => void;
|
||||
onCreate?: (query: string) => void;
|
||||
onQueryChange?: (query: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
|
|
@ -973,7 +1021,11 @@ function SearchableSelect({
|
|||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onChange={(event) => {
|
||||
const nextQuery = event.target.value;
|
||||
setQuery(nextQuery);
|
||||
onQueryChange?.(nextQuery);
|
||||
}}
|
||||
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"
|
||||
/>
|
||||
|
|
@ -1013,7 +1065,22 @@ function SearchableSelect({
|
|||
</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>
|
||||
</>
|
||||
|
|
@ -1381,7 +1448,27 @@ export default function Opportunities() {
|
|||
const [customCompetitorName, setCustomCompetitorName] = useState("");
|
||||
const [fieldErrors, setFieldErrors] = useState<Partial<Record<OpportunityField, string>>>({});
|
||||
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(() => {
|
||||
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(() => {
|
||||
let cancelled = false;
|
||||
|
||||
|
|
@ -1515,6 +1609,75 @@ export default function Opportunities() {
|
|||
}));
|
||||
}, [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 visibleItems = items.filter((item) => (archiveTab === "active" ? !item.archived : Boolean(item.archived)));
|
||||
const stageFilterOptions = [
|
||||
|
|
@ -1585,6 +1748,7 @@ export default function Opportunities() {
|
|||
label: item.name || `渠道#${item.id}`,
|
||||
keywords: [item.channelCode || "", item.province || "", item.primaryContactName || "", item.primaryContactMobile || ""],
|
||||
}));
|
||||
const quickChannelOtherOptionValue = quickChannelAttributeOptions.find(isSharedOtherOption)?.value;
|
||||
const omsPreSalesSearchOptions: SearchableOption[] = [
|
||||
...(pushPreSalesId && pushPreSalesName && !omsPreSalesOptions.some((item) => item.userId === pushPreSalesId)
|
||||
? [{ 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 = () => {
|
||||
setError("");
|
||||
setFieldErrors({});
|
||||
|
|
@ -2289,34 +2601,42 @@ export default function Opportunities() {
|
|||
{showSalesExpansionField ? (
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">新华三负责人<RequiredMark /></span>
|
||||
<SearchableSelect
|
||||
value={form.salesExpansionId}
|
||||
options={salesExpansionSearchOptions}
|
||||
placeholder="请选择新华三负责人"
|
||||
searchPlaceholder="搜索姓名、工号、办事处、电话"
|
||||
emptyText="未找到匹配的销售拓展人员"
|
||||
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" : "",
|
||||
)}
|
||||
<SearchableSelect
|
||||
value={form.salesExpansionId}
|
||||
options={salesExpansionSearchOptions}
|
||||
placeholder="请选择新华三负责人"
|
||||
searchPlaceholder="搜索姓名、工号、办事处、电话"
|
||||
emptyText="未找到匹配的销售拓展人员"
|
||||
createActionLabel={salesExpansionQuery.trim() ? `新增负责人“${salesExpansionQuery.trim()}”并选中` : "新增负责人并选中"}
|
||||
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" : "",
|
||||
)}
|
||||
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}
|
||||
</label>
|
||||
) : null}
|
||||
{showChannelExpansionField ? (
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">渠道名称<RequiredMark /></span>
|
||||
<SearchableSelect
|
||||
value={form.channelExpansionId}
|
||||
options={channelExpansionSearchOptions}
|
||||
placeholder="请选择渠道名称"
|
||||
searchPlaceholder="搜索渠道名称、编码、省份、联系人"
|
||||
emptyText="未找到匹配的渠道"
|
||||
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" : "",
|
||||
)}
|
||||
<SearchableSelect
|
||||
value={form.channelExpansionId}
|
||||
options={channelExpansionSearchOptions}
|
||||
placeholder="请选择渠道名称"
|
||||
searchPlaceholder="搜索渠道名称、编码、省份、联系人"
|
||||
emptyText="未找到匹配的渠道"
|
||||
createActionLabel={channelExpansionQuery.trim() ? `新增渠道“${channelExpansionQuery.trim()}”并选中` : "新增渠道并选中"}
|
||||
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" : "",
|
||||
)}
|
||||
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}
|
||||
</label>
|
||||
) : null}
|
||||
|
|
@ -2450,6 +2770,57 @@ export default function Opportunities() {
|
|||
)}
|
||||
</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>
|
||||
{pushConfirmOpen && selectedItem ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -6,8 +6,16 @@ import { ArrowUp, AtSign, Camera, CheckCircle2, ChevronDown, Download, FileText,
|
|||
import { flushSync } from "react-dom";
|
||||
import { Link, Navigate, useLocation } from "react-router-dom";
|
||||
import {
|
||||
checkChannelExpansionDuplicate,
|
||||
checkSalesExpansionDuplicate,
|
||||
createChannelExpansion,
|
||||
createOpportunity,
|
||||
createSalesExpansion,
|
||||
getCurrentUser,
|
||||
getExpansionCityOptions,
|
||||
getExpansionMeta,
|
||||
getExpansionOverview,
|
||||
getOpportunityMeta,
|
||||
getOpportunityOverview,
|
||||
getProfileOverview,
|
||||
getWorkCheckInExportData,
|
||||
|
|
@ -20,8 +28,14 @@ import {
|
|||
saveWorkDailyReport,
|
||||
uploadWorkCheckInPhoto,
|
||||
type ChannelExpansionItem,
|
||||
type ChannelExpansionContact,
|
||||
type CreateChannelExpansionPayload,
|
||||
type CreateOpportunityPayload,
|
||||
type CreateWorkCheckInPayload,
|
||||
type CreateWorkDailyReportPayload,
|
||||
type CreateSalesExpansionPayload,
|
||||
type ExpansionDictOption,
|
||||
type OpportunityDictOption,
|
||||
type OpportunityItem,
|
||||
type ProfileOverview,
|
||||
type SalesExpansionItem,
|
||||
|
|
@ -34,6 +48,20 @@ import {
|
|||
type WorkTomorrowPlanItem,
|
||||
} from "@/lib/auth";
|
||||
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 { useIsMobileViewport } from "@/hooks/useIsMobileViewport";
|
||||
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
|
||||
|
|
@ -47,6 +75,15 @@ const reportFieldLabels = {
|
|||
channel: ["沟通内容", "后续规划"],
|
||||
opportunity: ["项目最新进展", "后续规划"],
|
||||
} as const;
|
||||
const COMPETITOR_OPTIONS = [
|
||||
"深信服",
|
||||
"锐捷",
|
||||
"华为",
|
||||
"中兴",
|
||||
"噢易云",
|
||||
"无",
|
||||
"其他",
|
||||
] as const;
|
||||
|
||||
const workSectionItems = [
|
||||
{
|
||||
|
|
@ -108,6 +145,50 @@ type ObjectPickerState = {
|
|||
bizType: BizType;
|
||||
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 {
|
||||
return {
|
||||
|
|
@ -130,6 +211,167 @@ function createEmptyPlanItem(): WorkTomorrowPlanItem {
|
|||
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[]) {
|
||||
const ExcelJS = await import("exceljs");
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
|
|
@ -442,6 +684,33 @@ export default function Work() {
|
|||
const [opportunityOptions, setOpportunityOptions] = useState<WorkRelationOption[]>([]);
|
||||
const [reportTargetsLoaded, setReportTargetsLoaded] = 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 [reportForm, setReportForm] = useState<CreateWorkDailyReportPayload>(defaultReportForm);
|
||||
const [objectPicker, setObjectPicker] = useState<ObjectPickerState | null>(null);
|
||||
|
|
@ -466,6 +735,10 @@ export default function Work() {
|
|||
}
|
||||
return options.filter((option) => option.label.toLowerCase().includes(keyword));
|
||||
}, [objectPicker, salesOptions, channelOptions, opportunityOptions]);
|
||||
const quickOpportunityOperatorMode = useMemo(
|
||||
() => resolveOperatorMode(quickOpportunityForm.operatorName, quickOpportunityOperatorOptions),
|
||||
[quickOpportunityForm.operatorName, quickOpportunityOperatorOptions],
|
||||
);
|
||||
|
||||
const historyPresenterOptions = useMemo(() => {
|
||||
const presenterMap = new Map<string, { label: string; count: number }>();
|
||||
|
|
@ -537,6 +810,22 @@ export default function Work() {
|
|||
setReportTargetsLoading(false);
|
||||
}, [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(() => {
|
||||
void loadOverview();
|
||||
}, []);
|
||||
|
|
@ -916,6 +1205,265 @@ export default function Work() {
|
|||
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) => {
|
||||
if (!objectPicker) {
|
||||
return;
|
||||
|
|
@ -1446,7 +1994,20 @@ export default function Work() {
|
|||
</div>
|
||||
) : (
|
||||
<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>
|
||||
|
|
@ -1454,6 +2015,280 @@ export default function Work() {
|
|||
</div>
|
||||
) : 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 ? (
|
||||
<LocationAdjustModal
|
||||
origin={locationAdjustOrigin ?? { latitude: checkInForm.latitude, longitude: checkInForm.longitude }}
|
||||
|
|
|
|||
Loading…
Reference in New Issue