|
|
|
|
@ -137,6 +137,7 @@ type OpportunityExportFieldKey =
|
|
|
|
|
| "owner"
|
|
|
|
|
| "createdAt"
|
|
|
|
|
| "updatedAt"
|
|
|
|
|
| "archivedAt"
|
|
|
|
|
| "archived"
|
|
|
|
|
| "pushedToOms";
|
|
|
|
|
type OpportunityExportColumn = {
|
|
|
|
|
@ -328,6 +329,7 @@ const opportunityExportColumns: OpportunityExportColumn[] = [
|
|
|
|
|
{ key: "owner", label: "创建人", value: (item) => normalizeOpportunityExportText(item.owner) },
|
|
|
|
|
{ key: "createdAt", label: "创建时间", value: (item) => normalizeOpportunityExportText(item.createdAt) },
|
|
|
|
|
{ key: "updatedAt", label: "更新修改时间", value: (item) => normalizeOpportunityExportText(item.updatedAt) },
|
|
|
|
|
{ key: "archivedAt", label: "签单时间", value: (item) => normalizeOpportunityExportText(item.archivedAt) },
|
|
|
|
|
{ key: "archived", label: "是否签单", value: (item) => formatOpportunityBoolean(item.archived, "已签单", "未签单") },
|
|
|
|
|
{ key: "pushedToOms", label: "是否推送OMS", value: (item) => formatOpportunityBoolean(item.pushedToOms, "已推送", "未推送") },
|
|
|
|
|
{ key: "actualSignedAmount", label: "实际签约金额(元)", numFmt: "#,##0.00", value: (item) => normalizeOpportunityExportNumber(item.actualSignedAmount) ?? "" },
|
|
|
|
|
@ -811,6 +813,9 @@ function validateOpportunityForm(
|
|
|
|
|
if (!form.projectLocation?.trim()) {
|
|
|
|
|
errors.projectLocation = "请选择项目地";
|
|
|
|
|
}
|
|
|
|
|
if (!form.projectOwnershipLocation?.trim()) {
|
|
|
|
|
errors.projectOwnershipLocation = "请选择业绩归属地";
|
|
|
|
|
}
|
|
|
|
|
if (!form.opportunityName?.trim()) {
|
|
|
|
|
errors.opportunityName = "请填写项目名称";
|
|
|
|
|
}
|
|
|
|
|
@ -1900,6 +1905,8 @@ export default function Opportunities() {
|
|
|
|
|
const [items, setItems] = useState<OpportunityItem[]>([]);
|
|
|
|
|
const [salesExpansionOptions, setSalesExpansionOptions] = useState<SalesExpansionItem[]>([]);
|
|
|
|
|
const [channelExpansionOptions, setChannelExpansionOptions] = useState<ChannelExpansionItem[]>([]);
|
|
|
|
|
const [selectedSalesExpansionOption, setSelectedSalesExpansionOption] = useState<SearchableOption | null>(null);
|
|
|
|
|
const [selectedChannelExpansionOption, setSelectedChannelExpansionOption] = useState<SearchableOption | null>(null);
|
|
|
|
|
const [omsPreSalesOptions, setOmsPreSalesOptions] = useState<OmsPreSalesOption[]>([]);
|
|
|
|
|
const [stageOptions, setStageOptions] = useState<OpportunityDictOption[]>([]);
|
|
|
|
|
const [operatorOptions, setOperatorOptions] = useState<OpportunityDictOption[]>([]);
|
|
|
|
|
@ -2390,11 +2397,29 @@ export default function Opportunities() {
|
|
|
|
|
const showSalesExpansionField = operatorMode === "h3c" || operatorMode === "both";
|
|
|
|
|
const showChannelExpansionField = operatorMode === "channel" || operatorMode === "both";
|
|
|
|
|
const showCustomCompetitorInput = selectedCompetitors.includes("其他");
|
|
|
|
|
const selectedSalesExpansionFallbackOption = form.salesExpansionId && selectedSalesExpansionName
|
|
|
|
|
? { value: form.salesExpansionId, label: selectedSalesExpansionName, keywords: [] }
|
|
|
|
|
const formSalesExpansionOption = form.salesExpansionId
|
|
|
|
|
? salesExpansionOptions.find((item) => item.id === form.salesExpansionId) ?? null
|
|
|
|
|
: null;
|
|
|
|
|
const selectedChannelExpansionFallbackOption = form.channelExpansionId && selectedChannelExpansionName
|
|
|
|
|
? { value: form.channelExpansionId, label: selectedChannelExpansionName, keywords: [] }
|
|
|
|
|
const formChannelExpansionOption = form.channelExpansionId
|
|
|
|
|
? channelExpansionOptions.find((item) => item.id === form.channelExpansionId) ?? null
|
|
|
|
|
: null;
|
|
|
|
|
const selectedSalesExpansionFallbackOption = formSalesExpansionOption
|
|
|
|
|
? {
|
|
|
|
|
value: formSalesExpansionOption.id,
|
|
|
|
|
label: formatSalesExpansionOptionLabel(formSalesExpansionOption),
|
|
|
|
|
keywords: [formSalesExpansionOption.employeeNo || "", formSalesExpansionOption.officeName || "", formSalesExpansionOption.phone || "", formSalesExpansionOption.title || ""],
|
|
|
|
|
}
|
|
|
|
|
: form.salesExpansionId && selectedSalesExpansionOption?.value === form.salesExpansionId
|
|
|
|
|
? selectedSalesExpansionOption
|
|
|
|
|
: null;
|
|
|
|
|
const selectedChannelExpansionFallbackOption = formChannelExpansionOption
|
|
|
|
|
? {
|
|
|
|
|
value: formChannelExpansionOption.id,
|
|
|
|
|
label: formChannelExpansionOption.name || `渠道#${formChannelExpansionOption.id}`,
|
|
|
|
|
keywords: [formChannelExpansionOption.channelCode || "", formChannelExpansionOption.province || "", formChannelExpansionOption.primaryContactName || "", formChannelExpansionOption.primaryContactMobile || ""],
|
|
|
|
|
}
|
|
|
|
|
: form.channelExpansionId && selectedChannelExpansionOption?.value === form.channelExpansionId
|
|
|
|
|
? selectedChannelExpansionOption
|
|
|
|
|
: null;
|
|
|
|
|
const salesExpansionSearchOptions: SearchableOption[] = appendSearchableOptionIfMissing(salesExpansionOptions.map((item) => ({
|
|
|
|
|
value: item.id,
|
|
|
|
|
@ -2569,6 +2594,20 @@ export default function Opportunities() {
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleSalesExpansionChange = (value?: number) => {
|
|
|
|
|
setSelectedSalesExpansionOption(
|
|
|
|
|
value ? salesExpansionSearchOptions.find((option) => option.value === value) ?? null : null,
|
|
|
|
|
);
|
|
|
|
|
handleChange("salesExpansionId", value);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleChannelExpansionChange = (value?: number) => {
|
|
|
|
|
setSelectedChannelExpansionOption(
|
|
|
|
|
value ? channelExpansionSearchOptions.find((option) => option.value === value) ?? null : null,
|
|
|
|
|
);
|
|
|
|
|
handleChange("channelExpansionId", value);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleQuickSalesChange = <K extends keyof CreateSalesExpansionPayload>(key: K, value: CreateSalesExpansionPayload[K]) => {
|
|
|
|
|
setQuickSalesForm((current) => ({ ...current, [key]: value }));
|
|
|
|
|
if (key in quickSalesFieldErrors) {
|
|
|
|
|
@ -2705,9 +2744,25 @@ export default function Opportunities() {
|
|
|
|
|
const latestOverview = await refreshExpansionOptions();
|
|
|
|
|
if (quickCreateType === "sales") {
|
|
|
|
|
const createdItem = (latestOverview.salesItems ?? []).find((item) => item.id === createdId);
|
|
|
|
|
const createdOption = createdItem
|
|
|
|
|
? {
|
|
|
|
|
value: createdItem.id,
|
|
|
|
|
label: formatSalesExpansionOptionLabel(createdItem),
|
|
|
|
|
keywords: [createdItem.employeeNo || "", createdItem.officeName || "", createdItem.phone || "", createdItem.title || ""],
|
|
|
|
|
}
|
|
|
|
|
: { value: createdId, label: quickSalesForm.candidateName?.trim() || `拓展人员#${createdId}`, keywords: [] };
|
|
|
|
|
setSelectedSalesExpansionOption(createdOption);
|
|
|
|
|
handleChange("salesExpansionId", createdItem?.id ?? createdId);
|
|
|
|
|
} else {
|
|
|
|
|
const createdItem = (latestOverview.channelItems ?? []).find((item) => item.id === createdId);
|
|
|
|
|
const createdOption = createdItem
|
|
|
|
|
? {
|
|
|
|
|
value: createdItem.id,
|
|
|
|
|
label: createdItem.name || `渠道#${createdItem.id}`,
|
|
|
|
|
keywords: [createdItem.channelCode || "", createdItem.province || "", createdItem.primaryContactName || "", createdItem.primaryContactMobile || ""],
|
|
|
|
|
}
|
|
|
|
|
: { value: createdId, label: quickChannelForm.channelName?.trim() || `渠道#${createdId}`, keywords: [] };
|
|
|
|
|
setSelectedChannelExpansionOption(createdOption);
|
|
|
|
|
handleChange("channelExpansionId", createdItem?.id ?? createdId);
|
|
|
|
|
}
|
|
|
|
|
resetQuickCreateState();
|
|
|
|
|
@ -2721,6 +2776,8 @@ export default function Opportunities() {
|
|
|
|
|
setError("");
|
|
|
|
|
setFieldErrors({});
|
|
|
|
|
setForm(buildEmptyForm());
|
|
|
|
|
setSelectedSalesExpansionOption(null);
|
|
|
|
|
setSelectedChannelExpansionOption(null);
|
|
|
|
|
setSelectedCompetitors([]);
|
|
|
|
|
setCustomCompetitorName("");
|
|
|
|
|
setCreateOpen(true);
|
|
|
|
|
@ -2733,6 +2790,8 @@ export default function Opportunities() {
|
|
|
|
|
setError("");
|
|
|
|
|
setFieldErrors({});
|
|
|
|
|
setForm(buildEmptyForm());
|
|
|
|
|
setSelectedSalesExpansionOption(null);
|
|
|
|
|
setSelectedChannelExpansionOption(null);
|
|
|
|
|
setSelectedCompetitors([]);
|
|
|
|
|
setCustomCompetitorName("");
|
|
|
|
|
};
|
|
|
|
|
@ -2787,6 +2846,16 @@ export default function Opportunities() {
|
|
|
|
|
setError("");
|
|
|
|
|
setFieldErrors({});
|
|
|
|
|
setForm(toFormFromItem(sourceItem, effectiveConfidenceOptions));
|
|
|
|
|
setSelectedSalesExpansionOption(sourceItem.salesExpansionId && sourceItem.salesExpansionName ? {
|
|
|
|
|
value: sourceItem.salesExpansionId,
|
|
|
|
|
label: sourceItem.salesExpansionName,
|
|
|
|
|
keywords: [],
|
|
|
|
|
} : null);
|
|
|
|
|
setSelectedChannelExpansionOption(sourceItem.channelExpansionId && sourceItem.channelExpansionName ? {
|
|
|
|
|
value: sourceItem.channelExpansionId,
|
|
|
|
|
label: sourceItem.channelExpansionName,
|
|
|
|
|
keywords: [],
|
|
|
|
|
} : null);
|
|
|
|
|
const competitorState = parseCompetitorState(sourceItem.competitorName);
|
|
|
|
|
setSelectedCompetitors(competitorState.selections);
|
|
|
|
|
setCustomCompetitorName(competitorState.customName);
|
|
|
|
|
@ -3034,6 +3103,8 @@ export default function Opportunities() {
|
|
|
|
|
{visibleItems.length > 0 ? (
|
|
|
|
|
visibleItems.map((opp, i) => {
|
|
|
|
|
const isOwnedByCurrentUser = currentUserId !== undefined && opp.ownerUserId === currentUserId;
|
|
|
|
|
const amountLabel = archiveTab === "archived" ? "实际签约金额" : "预计金额";
|
|
|
|
|
const displayAmount = archiveTab === "archived" ? opp.actualSignedAmount : opp.amount;
|
|
|
|
|
return (
|
|
|
|
|
<motion.div
|
|
|
|
|
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
|
|
|
|
|
@ -3082,8 +3153,8 @@ export default function Opportunities() {
|
|
|
|
|
<span className="min-w-0 flex-1 truncate font-medium text-slate-900 dark:text-white">{opp.date || "待定"}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex min-w-0 items-center gap-2 text-slate-600 dark:text-slate-300">
|
|
|
|
|
<span className="shrink-0 text-slate-400 dark:text-slate-500">预计金额:</span>
|
|
|
|
|
<span className="min-w-0 flex-1 truncate font-medium text-slate-900 dark:text-white">¥{formatAmount(opp.amount)}</span>
|
|
|
|
|
<span className="shrink-0 text-slate-400 dark:text-slate-500">{amountLabel}:</span>
|
|
|
|
|
<span className="min-w-0 flex-1 truncate font-medium text-slate-900 dark:text-white">¥{formatAmount(displayAmount)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex min-w-0 items-center gap-2 text-slate-600 dark:text-slate-300">
|
|
|
|
|
<span className="shrink-0 text-slate-400 dark:text-slate-500">项目最新进展:</span>
|
|
|
|
|
@ -3212,7 +3283,7 @@ export default function Opportunities() {
|
|
|
|
|
{fieldErrors.projectLocation ? <p className="text-xs text-rose-500">{fieldErrors.projectLocation}</p> : null}
|
|
|
|
|
</label>
|
|
|
|
|
<label className="space-y-2">
|
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">业绩归属地</span>
|
|
|
|
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">业绩归属地<RequiredMark /></span>
|
|
|
|
|
<AdaptiveSelect
|
|
|
|
|
value={form.projectOwnershipLocation || ""}
|
|
|
|
|
placeholder="请选择"
|
|
|
|
|
@ -3269,7 +3340,7 @@ export default function Opportunities() {
|
|
|
|
|
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)}
|
|
|
|
|
onChange={handleSalesExpansionChange}
|
|
|
|
|
onQueryChange={setSalesExpansionQuery}
|
|
|
|
|
onCreate={(query) => void openQuickCreateModal("sales", query)}
|
|
|
|
|
/>
|
|
|
|
|
@ -3291,7 +3362,7 @@ export default function Opportunities() {
|
|
|
|
|
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)}
|
|
|
|
|
onChange={handleChannelExpansionChange}
|
|
|
|
|
onQueryChange={setChannelExpansionQuery}
|
|
|
|
|
onCreate={(query) => void openQuickCreateModal("channel", query)}
|
|
|
|
|
/>
|
|
|
|
|
|