快速新增回填功能升级

main
kangwenjing 2026-04-17 14:56:22 +08:00
parent 9f80246be4
commit d996a49a71
3 changed files with 1839 additions and 23 deletions

View File

@ -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>
);
}

View File

@ -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 ? (
<>

View File

@ -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 }}