bug处理优化页面

main
kangwenjing 2026-07-10 16:13:20 +08:00
parent 3250a9da24
commit b56d509cc9
9 changed files with 101 additions and 11 deletions

View File

@ -22,6 +22,7 @@ public class CreateOpportunityRequest {
@Size(max = 100, message = "项目地不能超过100字符")
private String projectLocation;
@NotBlank(message = "业绩归属地不能为空")
@Size(max = 100, message = "业绩归属地不能超过100字符")
private String projectOwnershipLocation;

View File

@ -14,6 +14,7 @@ public class OpportunityItemDTO {
private String owner;
private String createdAt;
private String updatedAt;
private String archivedAt;
private String projectLocation;
private String projectOwnershipLocation;
private String projectOwnershipLocationName;
@ -111,6 +112,14 @@ public class OpportunityItemDTO {
this.updatedAt = updatedAt;
}
public String getArchivedAt() {
return archivedAt;
}
public void setArchivedAt(String archivedAt) {
this.archivedAt = archivedAt;
}
public String getProjectLocation() {
return projectLocation;
}

View File

@ -533,7 +533,8 @@ public class OpportunityServiceImpl implements OpportunityService {
request.setCustomerName(normalizeRequiredText(request.getCustomerName(), "最终客户不能为空"));
request.setOpportunityName(normalizeRequiredText(request.getOpportunityName(), "项目名称不能为空"));
request.setProjectLocation(normalizeRequiredText(request.getProjectLocation(), "请选择项目地"));
request.setProjectOwnershipLocation(normalizeProjectOwnershipLocationCode(request.getProjectOwnershipLocation()));
request.setProjectOwnershipLocation(normalizeProjectOwnershipLocationCode(
normalizeRequiredText(request.getProjectOwnershipLocation(), "请选择业绩归属地")));
request.setOperatorName(normalizeRequiredText(request.getOperatorName(), "请选择运作方"));
request.setAmount(requirePositiveAmount(request.getAmount(), "请填写预计金额"));
request.setDescription(normalizeOptionalText(request.getDescription()));

View File

@ -134,6 +134,7 @@
),
'无'
) as updatedAt,
coalesce(to_char(o.archived_at, 'YYYY-MM-DD HH24:MI'), '') as archivedAt,
coalesce(o.project_location, '') as projectLocation,
coalesce(o.project_ownership_location, '') as projectOwnershipLocation,
coalesce(project_ownership_area.short_name, nullif(o.project_ownership_location, ''), '') as projectOwnershipLocationName,
@ -296,6 +297,7 @@
),
'无'
) as updatedAt,
coalesce(to_char(o.archived_at, 'YYYY-MM-DD HH24:MI'), '') as archivedAt,
coalesce(o.project_location, '') as projectLocation,
coalesce(o.project_ownership_location, '') as projectOwnershipLocation,
coalesce(project_ownership_area.short_name, nullif(o.project_ownership_location, ''), '') as projectOwnershipLocationName,

View File

@ -297,6 +297,7 @@ class OpportunityServiceImplTest {
request.setCustomerName(customerName);
request.setOpportunityName("商机A");
request.setProjectLocation("上海");
request.setProjectOwnershipLocation("上海");
request.setOperatorName("直营");
request.setAmount(new BigDecimal("100"));
request.setExpectedCloseDate(LocalDate.of(2026, 4, 30));

View File

@ -472,6 +472,7 @@ export interface OpportunityItem {
owner?: string;
createdAt?: string;
updatedAt?: string;
archivedAt?: string;
projectLocation?: string;
projectOwnershipLocation?: string;
projectOwnershipLocationName?: string;

View File

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

View File

@ -356,6 +356,9 @@ function validateOpportunityQuickForm(
if (!form.projectLocation?.trim()) {
errors.projectLocation = "请选择项目地";
}
if (!form.projectOwnershipLocation?.trim()) {
errors.projectOwnershipLocation = "请选择业绩归属地";
}
if (!form.opportunityName?.trim()) {
errors.opportunityName = "请填写项目名称";
}
@ -2688,7 +2691,7 @@ export default function Work() {
{quickOpportunityFieldErrors.projectLocation ? <p className="text-xs text-rose-500">{quickOpportunityFieldErrors.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={quickOpportunityForm.projectOwnershipLocation || ""}
placeholder="请选择"