thread-master/apps/web/src/pages/RadarOpportunitiesPage.tsx

274 lines
14 KiB
TypeScript
Raw Normal View History

2026-08-13 02:22:24 +00:00
import { useCallback, useEffect, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { PageHeader } from "../components/layout/PageHeader";
import { OpportunityInboxCard } from "../components/radar/OpportunityInboxCard";
import { OpportunityDetailDrawer } from "../components/radar/OpportunityDetailDrawer";
import { Button, EmptyState, Select } from "../components/ui";
import { useRepos } from "../data/DataContext";
import type { Brand, BrandProduct, Opportunity, OpportunityRemovalReason, OpportunityReviewState, OpportunityTimeScope } from "../domain/types";
import { useFormatApiError } from "../lib/apiErrors";
import "../styles/radar.css";
const PAGE_SIZE = 20;
function normalizeSort(value: string | null): string {
if (value === "posted") return "newest";
if (value === "score") return "recommended";
return value || "recommended";
}
export function RadarOpportunitiesPage() {
const repos = useRepos();
const formatError = useFormatApiError();
const [params, setParams] = useSearchParams();
const [list, setList] = useState<Opportunity[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(() => Math.max(1, Number(params.get("page")) || 1));
const [band, setBand] = useState(params.get("band") || "");
const [state, setState] = useState(params.get("match_state") || "");
const [brandId, setBrandId] = useState(params.get("brand_id") || "");
const [productId, setProductId] = useState(params.get("product_id") || "");
const [sort, setSort] = useState(() => normalizeSort(params.get("sort")));
const [reviewState, setReviewState] = useState<OpportunityReviewState>((params.get("review_state") as OpportunityReviewState) || "pending");
const [timeScope, setTimeScope] = useState<OpportunityTimeScope>((params.get("time_scope") as OpportunityTimeScope) || "today");
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [notice, setNotice] = useState<{ text: string; contactId?: string } | null>(null);
const [brands, setBrands] = useState<Brand[]>([]);
const [products, setProducts] = useState<BrandProduct[]>([]);
const [selected, setSelected] = useState<Opportunity | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [advancedOpen, setAdvancedOpen] = useState(() => Boolean(params.get("band") || params.get("match_state") || params.get("brand_id") || params.get("product_id")));
const scout = (repos as unknown as { scout?: { listBrands: () => Promise<Brand[]>; listProducts: (id: string) => Promise<BrandProduct[]> } }).scout;
useEffect(() => {
if (scout) void scout.listBrands().then(setBrands).catch(() => setBrands([]));
}, [scout]);
useEffect(() => {
if (!scout || !brandId) {
setProducts([]);
return;
}
void scout.listProducts(brandId).then(setProducts).catch(() => setProducts([]));
}, [scout, brandId]);
const load = useCallback(async () => {
setLoading(true);
try {
const result = await repos.radar.listOpportunities({
page,
pageSize: PAGE_SIZE,
band: band || undefined,
match_state: state || undefined,
brand_id: brandId || undefined,
product_id: productId || undefined,
review_state: reviewState,
time_scope: timeScope,
sort,
});
setList(result.list);
setTotal(result.total);
setError("");
} catch (e) {
setError(formatError(e));
} finally {
setLoading(false);
}
}, [repos.radar, page, band, state, brandId, productId, sort, reviewState, timeScope, formatError]);
useEffect(() => { void load(); }, [load]);
async function updateReviewState(
opportunity: Opportunity,
patch: { state: OpportunityReviewState; removal_reason?: OpportunityRemovalReason; removal_note?: string },
successText: string,
) {
setBusyId(opportunity.id);
setNotice(null);
try {
const updated = await repos.radar.updateOpportunityReviewState(opportunity.id, patch);
setSelected((current) => current?.id === updated.id ? null : current);
await load();
setError("");
setNotice({ text: successText });
} catch (e) {
setError(formatError(e));
} finally {
setBusyId(null);
}
}
async function acceptOpportunity(opportunity: Opportunity) {
setBusyId(opportunity.id);
setNotice(null);
try {
const result = await repos.radar.acceptOpportunity(opportunity.id);
setSelected((current) => current?.id === opportunity.id ? null : current);
await load();
setError("");
setNotice({
text: "已加入名單並移到已處理。接下來可到名單安排追蹤、備註或回報成交。",
contactId: result.contact_id,
});
} catch (e) {
setError(formatError(e));
} finally {
setBusyId(null);
}
}
function writeParams(changes: Record<string, string>) {
const next = new URLSearchParams(params);
for (const [name, value] of Object.entries(changes)) {
if (value) next.set(name, value);
else next.delete(name);
}
setParams(next, { replace: true });
}
function resetFilters() {
setBand("");
setState("");
setBrandId("");
setProductId("");
setSort("recommended");
setTimeScope("today");
setAdvancedOpen(false);
setPage(1);
const next = new URLSearchParams();
next.set("review_state", reviewState);
next.set("time_scope", "today");
setParams(next, { replace: true });
}
const advancedFilterCount = [band, state, brandId, productId].filter(Boolean).length;
const filtered = Boolean(advancedFilterCount || sort !== "recommended" || timeScope !== "today");
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
return (
<>
<PageHeader title="商機收件匣" />
<section className="hb-radar-intro">
<div>
<strong></strong>
<p></p>
</div>
<nav className="hb-radar-intro__actions" aria-label="商機結果導覽">
<Link className="hb-btn hb-btn--ghost" to="/app/radar/watches"></Link>
</nav>
</section>
<ol className="hb-inbox-decision-guide" aria-label="商機收件匣操作方式">
<li><span>1</span><div><strong></strong><small></small></div></li>
<li><span>2</span><div><strong></strong><small></small></div></li>
<li><span>3</span><div><strong></strong><small></small></div></li>
</ol>
<section className="hb-radar-filter-panel" aria-label="篩選商機收件匣">
<div className="hb-radar-filter-panel__head">
<div className="hb-radar-inbox-tabs" role="tablist" aria-label="商機工作狀態">
{(["pending", "completed", "removed"] as OpportunityReviewState[]).map((value) => (
<Button key={value} type="button" variant={reviewState === value ? "primary" : "ghost"} aria-pressed={reviewState === value} onClick={() => {
setReviewState(value); setPage(1); writeParams({ review_state: value, page: "" });
}}>
{value === "pending" ? "待決定" : value === "completed" ? "已處理" : "已移除"}
</Button>
))}
<span> {total} </span>
</div>
{filtered ? <Button type="button" variant="ghost" onClick={resetFilters}></Button> : <span></span>}
</div>
<div className="hb-radar-inbox-essential-filters">
<Select name="all-time-scope" label="看哪段時間" value={timeScope} onChange={(e) => {
const value = e.target.value as OpportunityTimeScope;
setTimeScope(value); setPage(1); writeParams({ time_scope: value, page: "" });
}}>
<option value="today"></option><option value="7d"> 7 </option><option value="all"></option>
</Select>
<Select name="all-sort" label="先看哪些" value={sort} onChange={(e) => {
setSort(e.target.value); setPage(1); writeParams({ sort: e.target.value === "recommended" ? "" : e.target.value, page: "" });
}}>
<option value="recommended"></option><option value="newest"></option><option value="oldest"></option><option value="product_fit"></option><option value="demand_intent"></option>
</Select>
<Button type="button" variant="ghost" aria-expanded={advancedOpen} onClick={() => setAdvancedOpen((value) => !value)}>
{advancedOpen ? "收起更多篩選" : `更多篩選${advancedFilterCount ? `${advancedFilterCount}` : ""}`}
</Button>
</div>
{advancedOpen ? <div className="hb-radar-filter-grid hb-radar-filter-grid--all" aria-label="更多篩選">
{brands.length ? (
<Select name="all-brand" label="品牌" value={brandId} onChange={(e) => {
const value = e.target.value;
setBrandId(value); setProductId(""); setPage(1);
writeParams({ brand_id: value, product_id: "", page: "" });
}}>
<option value=""></option>
{brands.map((b) => <option key={b.id} value={b.id}>{b.display_name}</option>)}
</Select>
) : null}
{products.length ? (
<Select name="all-product" label="產品" value={productId} onChange={(e) => {
setProductId(e.target.value); setPage(1);
writeParams({ product_id: e.target.value, page: "" });
}}>
<option value=""></option>
{products.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}
</Select>
) : null}
<Select name="all-band" label="商機意向" value={band} onChange={(e) => {
setBand(e.target.value); setPage(1); writeParams({ band: e.target.value, page: "" });
}}>
<option value=""></option><option value="high"></option><option value="mid"></option><option value="low"></option>
</Select>
<Select name="all-state" label="產品匹配" value={state} onChange={(e) => {
setState(e.target.value); setPage(1); writeParams({ match_state: e.target.value, page: "" });
}}>
<option value=""></option><option value="eligible"></option><option value="weak"></option><option value="excluded"></option><option value="generic"></option><option value="stale"> 14 </option>
</Select>
</div> : null}
</section>
{error ? <p className="hb-banner-error" role="alert">{error}</p> : null}
{notice ? <div className="hb-banner-ok" role="status"><span>{notice.text}</span>{notice.contactId ? <Link to={`/app/crm?contact=${encodeURIComponent(notice.contactId)}`}></Link> : null}</div> : null}
{loading ? <p className="hb-radar-section__hint" role="status"></p> : null}
{!loading && !error && !list.length ? (
<EmptyState
title={reviewState === "pending" ? "目前沒有待決定的商機" : reviewState === "completed" ? "目前沒有已處理的商機" : "目前沒有已移除的商機"}
description={filtered ? "清除篩選或改看其他時間範圍。" : reviewState === "pending" ? "等待下一輪巡邏,或先建立產品巡邏。" : "切換到「待決定」繼續處理商機。"}
action={filtered ? <Button type="button" variant="ghost" onClick={resetFilters}></Button> : reviewState === "pending" ? <Link className="hb-btn hb-btn--secondary" to="/app/radar/watches"></Link> : undefined}
/>
) : null}
{!error ? (
<section className="hb-radar-page hb-radar-all-results">
{list.map((o) => <OpportunityInboxCard
key={o.id}
opportunity={o}
busy={busyId === o.id}
onOpen={setSelected}
onAccept={(item) => void acceptOpportunity(item)}
onComplete={(item) => void updateReviewState(item, { state: "completed" }, "已標示為已處理;沒有建立聯絡人名單。")}
onRemove={(item, input) => void updateReviewState(item, { state: "removed", removal_reason: input.reason, removal_note: input.note }, "已標示為不適合,可從「已移除」還原。")}
onRestore={(item) => void updateReviewState(item, { state: item.previous_review_state === "completed" ? "completed" : "pending" }, "已還原商機。")}
/>)}
{total > PAGE_SIZE ? (
<div className="hb-radar-pager">
<Button variant="ghost" disabled={page <= 1} onClick={() => { const next = page - 1; setPage(next); writeParams({ page: String(next) }); }}></Button>
<span> {page} {pageCount} </span>
<Button variant="ghost" disabled={page >= pageCount} onClick={() => { const next = page + 1; setPage(next); writeParams({ page: String(next) }); }}></Button>
</div>
) : null}
</section>
) : null}
{selected ? <OpportunityDetailDrawer
opportunity={selected}
busy={busyId === selected.id}
onClose={() => setSelected(null)}
onAccept={(item) => void acceptOpportunity(item)}
onComplete={(item) => void updateReviewState(item, { state: "completed" }, "已標示為已處理;沒有建立聯絡人名單。")}
/> : null}
</>
);
}