274 lines
14 KiB
TypeScript
274 lines
14 KiB
TypeScript
|
|
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}
|
|||
|
|
</>
|
|||
|
|
);
|
|||
|
|
}
|