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([]); 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((params.get("review_state") as OpportunityReviewState) || "pending"); const [timeScope, setTimeScope] = useState((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([]); const [products, setProducts] = useState([]); const [selected, setSelected] = useState(null); const [busyId, setBusyId] = useState(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; listProducts: (id: string) => Promise } }).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) { 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 ( <>
每張商機只要做一個決定

值得繼續接觸就加入名單;已自行看完就標示已處理;不是你的客戶就標示不適合。這些整理動作都不扣點。

  1. 1
    先看需求閱讀原文與「為什麼推薦」。
  2. 2
    值得跟進加入名單,後續做備註與追蹤。
  3. 3
    不需要跟進只標示已處理,或選原因標示不適合。
{(["pending", "completed", "removed"] as OpportunityReviewState[]).map((value) => ( ))} 共 {total} 筆
{filtered ? : 預設先顯示最值得跟進的結果}
{advancedOpen ?
{brands.length ? ( ) : null} {products.length ? ( ) : null}
: null}
{error ?

{error}

: null} {notice ?
{notice.text}{notice.contactId ? 現在前往名單 : null}
: null} {loading ?

正在整理商機結果…

: null} {!loading && !error && !list.length ? ( 清除篩選 : reviewState === "pending" ? 管理巡邏 : undefined} /> ) : null} {!error ? (
{list.map((o) => 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 ? (
第 {page} 頁/共 {pageCount} 頁
) : null}
) : null} {selected ? setSelected(null)} onAccept={(item) => void acceptOpportunity(item)} onComplete={(item) => void updateReviewState(item, { state: "completed" }, "已標示為已處理;沒有建立聯絡人名單。")} /> : null} ); }