2026-08-03 05:52:02 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* 今日商機頁(demand-radar T551/T552)。
|
|
|
|
|
|
* 統計列+高/中/低分組;低意向預設收合;空狀態必顯示 empty_reason 與下一步。
|
|
|
|
|
|
*/
|
|
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
|
|
|
|
import { Link } from "react-router-dom";
|
|
|
|
|
|
import { PageHeader } from "../components/layout/PageHeader";
|
|
|
|
|
|
import { Badge, Button, EmptyState } from "../components/ui";
|
|
|
|
|
|
import type { BadgeTone } from "../components/ui";
|
|
|
|
|
|
import { useRepos } from "../data/DataContext";
|
|
|
|
|
|
import type {
|
|
|
|
|
|
IntentBand,
|
|
|
|
|
|
Opportunity,
|
|
|
|
|
|
RadarEmptyReason,
|
|
|
|
|
|
RadarToday,
|
|
|
|
|
|
ReplyVariant,
|
|
|
|
|
|
ReplyVariantKind,
|
|
|
|
|
|
} from "../domain/types";
|
|
|
|
|
|
import { useI18n } from "../i18n/I18nContext";
|
|
|
|
|
|
import { useFormatApiError } from "../lib/apiErrors";
|
|
|
|
|
|
import { formatTimeAgo } from "../lib/time";
|
|
|
|
|
|
import "../styles/radar.css";
|
|
|
|
|
|
|
|
|
|
|
|
const REPLY_VARIANTS: ReplyVariantKind[] = [
|
|
|
|
|
|
"public_comment",
|
|
|
|
|
|
"dm",
|
|
|
|
|
|
"no_sales",
|
|
|
|
|
|
"professional",
|
|
|
|
|
|
"humorous",
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
function bandTone(band: string): BadgeTone {
|
|
|
|
|
|
if (band === "high") return "success";
|
|
|
|
|
|
if (band === "mid") return "warning";
|
|
|
|
|
|
return "neutral";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function statusTone(status: string): BadgeTone {
|
|
|
|
|
|
if (status === "accepted") return "brand";
|
|
|
|
|
|
if (status === "dismissed") return "neutral";
|
|
|
|
|
|
return "success";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function emptyAction(reason?: RadarEmptyReason): { to: string; labelKey: string } | null {
|
|
|
|
|
|
switch (reason) {
|
|
|
|
|
|
case "no_profile":
|
2026-08-06 14:50:36 +00:00
|
|
|
|
return { to: "/app/brands?tab=serviceProfile", labelKey: "radar.today.empty.goProfile" };
|
2026-08-03 05:52:02 +00:00
|
|
|
|
case "no_watch":
|
|
|
|
|
|
case "all_watches_paused":
|
|
|
|
|
|
case "not_swept_yet":
|
|
|
|
|
|
case "sweep_failed":
|
|
|
|
|
|
case "no_hit":
|
|
|
|
|
|
return { to: "/app/radar/watches", labelKey: "radar.today.empty.goWatches" };
|
|
|
|
|
|
default:
|
|
|
|
|
|
return { to: "/app/radar/watches", labelKey: "radar.today.empty.goWatches" };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function OppCard({
|
|
|
|
|
|
o,
|
|
|
|
|
|
reply,
|
|
|
|
|
|
busy,
|
|
|
|
|
|
onAccept,
|
|
|
|
|
|
onDismiss,
|
|
|
|
|
|
onGenerateReply,
|
|
|
|
|
|
onCopyReply,
|
|
|
|
|
|
onMarkUsed,
|
|
|
|
|
|
onOverrideBand,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
o: Opportunity;
|
|
|
|
|
|
reply?: ReplyVariant;
|
|
|
|
|
|
busy: string;
|
|
|
|
|
|
onAccept: () => void;
|
|
|
|
|
|
onDismiss: () => void;
|
|
|
|
|
|
onGenerateReply: (variant: ReplyVariantKind) => void;
|
|
|
|
|
|
onCopyReply: () => void;
|
|
|
|
|
|
onMarkUsed: (channel: "outbox" | "manual_copy") => void;
|
|
|
|
|
|
onOverrideBand: (band: IntentBand) => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const { t } = useI18n();
|
|
|
|
|
|
const [reasonsOpen, setReasonsOpen] = useState(false);
|
|
|
|
|
|
const [replyOpen, setReplyOpen] = useState(Boolean(reply?.text || o.default_reply?.text));
|
|
|
|
|
|
const [overrideOpen, setOverrideOpen] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
const activeReply = reply || o.default_reply;
|
|
|
|
|
|
const shownReply = activeReply?.text;
|
|
|
|
|
|
const isDm = activeReply?.variant === "dm";
|
|
|
|
|
|
const isDone = o.status === "accepted" || o.status === "dismissed";
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<article className={`hb-opp-card hb-opp-card--${o.intent_band}`}>
|
|
|
|
|
|
<header className="hb-opp-card__head">
|
|
|
|
|
|
<div className="hb-opp-card__meta">
|
|
|
|
|
|
<Badge tone={bandTone(o.intent_band)}>
|
|
|
|
|
|
{t(`radar.today.band.${o.intent_band}`)} · {o.intent_score}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
{o.status !== "qualified" ? (
|
|
|
|
|
|
<Badge tone={statusTone(o.status)}>{t(`radar.today.status.${o.status}`)}</Badge>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{o.region_detected ? (
|
|
|
|
|
|
<span
|
|
|
|
|
|
className={
|
|
|
|
|
|
o.region_match === "mismatch"
|
|
|
|
|
|
? "hb-opp-region--mismatch"
|
|
|
|
|
|
: o.region_match === "unknown"
|
|
|
|
|
|
? "hb-opp-region--unknown"
|
|
|
|
|
|
: undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
{o.region_detected}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span className="hb-opp-region--unknown">{t("radar.today.regionUnknown")}</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<span className="radar-card__meta">{formatTimeAgo(o.posted_at)}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
|
|
<p className="hb-opp-card__text">{o.text}</p>
|
|
|
|
|
|
<p className="radar-card__meta">
|
|
|
|
|
|
@{o.author_handle}
|
|
|
|
|
|
{o.matched_service ? ` · ${o.matched_service}` : ""}
|
|
|
|
|
|
{o.matched_terms?.length ? ` · ${o.matched_terms.join("、")}` : ""}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
|
|
{shownReply ? (
|
|
|
|
|
|
<pre className="radar-card__reply">{shownReply}</pre>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
{reasonsOpen ? (
|
|
|
|
|
|
<ul className="hb-opp-reasons">
|
|
|
|
|
|
{(o.reasons ?? []).map((r) => (
|
|
|
|
|
|
<li key={r.dimension} className="hb-opp-reason">
|
|
|
|
|
|
<strong>{t(`radar.today.dim.${r.dimension}`)}</strong>
|
|
|
|
|
|
<span>{r.score}</span>
|
|
|
|
|
|
<span>{r.reason}</span>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
{replyOpen ? (
|
|
|
|
|
|
<div className="hb-reply-variants">
|
|
|
|
|
|
<p className="hb-radar-section__hint">{t("radar.today.reply.hint")}</p>
|
|
|
|
|
|
<div className="hb-radar-actions">
|
|
|
|
|
|
{REPLY_VARIANTS.map((v) => (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
key={v}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="secondary"
|
|
|
|
|
|
disabled={busy === `reply-${o.id}`}
|
|
|
|
|
|
onClick={() => onGenerateReply(v)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{t(`radar.today.reply.variant.${v}`)}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
{shownReply ? (
|
|
|
|
|
|
<Button type="button" variant="ghost" onClick={onCopyReply}>
|
|
|
|
|
|
{t("radar.today.reply.copy")}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{activeReply && !activeReply.used_at ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
disabled={busy === `mark-${o.id}`}
|
|
|
|
|
|
onClick={() => onMarkUsed("manual_copy")}
|
|
|
|
|
|
>
|
|
|
|
|
|
{t("radar.today.reply.markCopy")}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
{!isDm ? (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="secondary"
|
|
|
|
|
|
disabled={busy === `mark-${o.id}`}
|
|
|
|
|
|
onClick={() => onMarkUsed("outbox")}
|
|
|
|
|
|
>
|
|
|
|
|
|
{t("radar.today.reply.markOutbox")}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{activeReply?.used_at ? (
|
|
|
|
|
|
<span className="hb-radar-section__hint">{t("radar.today.reply.used")}</span>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
{overrideOpen ? (
|
|
|
|
|
|
<div className="hb-radar-actions">
|
|
|
|
|
|
{(["high", "mid", "low"] as IntentBand[]).map((b) => (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
key={b}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="secondary"
|
|
|
|
|
|
disabled={busy === `override-${o.id}` || o.intent_band === b}
|
|
|
|
|
|
onClick={() => onOverrideBand(b)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{t(`radar.today.band.${b}`)}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
<div className="radar-card__actions">
|
|
|
|
|
|
<a className="hb-btn hb-btn--ghost" href={o.permalink} target="_blank" rel="noreferrer">
|
|
|
|
|
|
{t("radar.today.action.open")}
|
|
|
|
|
|
</a>
|
|
|
|
|
|
{!isDone ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
disabled={busy === `accept-${o.id}`}
|
|
|
|
|
|
onClick={onAccept}
|
|
|
|
|
|
>
|
|
|
|
|
|
{t("radar.today.action.accept")}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
disabled={busy === `dismiss-${o.id}`}
|
|
|
|
|
|
onClick={onDismiss}
|
|
|
|
|
|
>
|
|
|
|
|
|
{t("radar.today.action.dismiss")}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
<Button type="button" variant="secondary" onClick={() => setReplyOpen((v) => !v)}>
|
|
|
|
|
|
{replyOpen ? t("radar.today.action.hideReply") : t("radar.today.action.reply")}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button type="button" variant="ghost" onClick={() => setReasonsOpen((v) => !v)}>
|
|
|
|
|
|
{reasonsOpen ? t("radar.today.action.hideReasons") : t("radar.today.action.reasons")}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button type="button" variant="ghost" onClick={() => setOverrideOpen((v) => !v)}>
|
|
|
|
|
|
{t("radar.today.action.override")}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function RadarTodayPage() {
|
|
|
|
|
|
const { t } = useI18n();
|
|
|
|
|
|
const repos = useRepos();
|
|
|
|
|
|
const formatErr = useFormatApiError();
|
|
|
|
|
|
const [data, setData] = useState<RadarToday | null>(null);
|
|
|
|
|
|
const [err, setErr] = useState<string | null>(null);
|
|
|
|
|
|
const [msg, setMsg] = useState<string | null>(null);
|
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
const [lowOpen, setLowOpen] = useState(false);
|
|
|
|
|
|
const [busy, setBusy] = useState("");
|
|
|
|
|
|
const [replies, setReplies] = useState<Record<string, ReplyVariant>>({});
|
|
|
|
|
|
|
|
|
|
|
|
const load = useCallback(async () => {
|
|
|
|
|
|
const next = await repos.radar.getToday();
|
|
|
|
|
|
setData(next);
|
|
|
|
|
|
}, [repos.radar]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
let alive = true;
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
load()
|
|
|
|
|
|
.then(() => {
|
|
|
|
|
|
if (alive) setErr(null);
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch((e) => {
|
|
|
|
|
|
if (alive) setErr(formatErr(e));
|
|
|
|
|
|
})
|
|
|
|
|
|
.finally(() => {
|
|
|
|
|
|
if (alive) setLoading(false);
|
|
|
|
|
|
});
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
alive = false;
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [load]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
|
|
|
|
|
|
|
|
async function run(key: string, action: () => Promise<void>, okMessage?: string) {
|
|
|
|
|
|
setBusy(key);
|
|
|
|
|
|
setErr(null);
|
|
|
|
|
|
setMsg(null);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await action();
|
|
|
|
|
|
if (okMessage) setMsg(okMessage);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setErr(formatErr(e));
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setBusy("");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function accept(id: string) {
|
|
|
|
|
|
await run(`accept-${id}`, async () => {
|
|
|
|
|
|
await repos.radar.acceptOpportunity(id);
|
|
|
|
|
|
await load();
|
|
|
|
|
|
}, t("radar.today.msg.accepted"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function dismiss(id: string) {
|
|
|
|
|
|
await run(`dismiss-${id}`, async () => {
|
|
|
|
|
|
await repos.radar.dismissOpportunity(id);
|
|
|
|
|
|
await load();
|
|
|
|
|
|
}, t("radar.today.msg.dismissed"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function generateReply(id: string, variant: ReplyVariantKind) {
|
|
|
|
|
|
await run(`reply-${id}`, async () => {
|
|
|
|
|
|
const r = await repos.radar.createReply(id, variant);
|
|
|
|
|
|
setReplies((m) => ({ ...m, [id]: r }));
|
|
|
|
|
|
}, t("radar.today.msg.replyReady"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function markUsed(id: string, channel: "outbox" | "manual_copy") {
|
|
|
|
|
|
const r = replies[id] || data?.high.concat(data.mid, data.low).find((o) => o.id === id)?.default_reply;
|
|
|
|
|
|
if (!r?.id) {
|
|
|
|
|
|
setErr(t("radar.today.msg.needReply"));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
await run(`mark-${id}`, async () => {
|
|
|
|
|
|
const res = await repos.radar.markReplyUsed(id, r.id, channel);
|
|
|
|
|
|
setReplies((m) => ({ ...m, [id]: res.reply }));
|
|
|
|
|
|
if (res.health_advice) setMsg(res.health_advice);
|
|
|
|
|
|
}, t("radar.today.msg.marked"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function overrideBand(id: string, band: IntentBand) {
|
|
|
|
|
|
await run(`override-${id}`, async () => {
|
|
|
|
|
|
await repos.radar.overrideOpportunity(id, { band });
|
|
|
|
|
|
await load();
|
|
|
|
|
|
}, t("radar.today.msg.overridden"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function copyReply(text: string) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await navigator.clipboard.writeText(text);
|
|
|
|
|
|
setMsg(t("radar.today.msg.copied"));
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
setErr(t("radar.today.msg.copyFail"));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const action = data ? emptyAction(data.empty_reason) : null;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<PageHeader title={t("radar.today.title")} />
|
|
|
|
|
|
|
|
|
|
|
|
<div className="hb-radar-actions hb-radar-actions--toolbar">
|
|
|
|
|
|
<Link className="hb-btn hb-btn--secondary" to="/app/radar/watches">
|
|
|
|
|
|
{t("radar.today.link.watches")}
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
<Link className="hb-btn hb-btn--ghost" to="/app/crm">
|
|
|
|
|
|
{t("radar.today.link.crm")}
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{err ? (
|
|
|
|
|
|
<p className="hb-banner-error" role="alert">
|
|
|
|
|
|
{err}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{msg ? (
|
|
|
|
|
|
<p className="hb-banner-ok" role="status">
|
|
|
|
|
|
{msg}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
{loading && !data ? <p className="hb-radar-section__hint">{t("common.loading")}</p> : null}
|
|
|
|
|
|
|
|
|
|
|
|
{data ? (
|
|
|
|
|
|
<section className="hb-radar-page">
|
|
|
|
|
|
<div className="hb-radar-stats">
|
|
|
|
|
|
<div className="hb-radar-stat">
|
|
|
|
|
|
<span className="hb-radar-stat__value">{data.stats.total}</span>
|
|
|
|
|
|
<span className="hb-radar-stat__label">{t("radar.today.stats.total")}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="hb-radar-stat">
|
|
|
|
|
|
<span className="hb-radar-stat__value">{data.stats.high}</span>
|
|
|
|
|
|
<span className="hb-radar-stat__label">{t("radar.today.stats.high")}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="hb-radar-stat">
|
|
|
|
|
|
<span className="hb-radar-stat__value">{data.stats.mid}</span>
|
|
|
|
|
|
<span className="hb-radar-stat__label">{t("radar.today.stats.mid")}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="hb-radar-stat">
|
|
|
|
|
|
<span className="hb-radar-stat__value">{data.stats.low}</span>
|
|
|
|
|
|
<span className="hb-radar-stat__label">{t("radar.today.stats.low")}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{data.truncated_count > 0 ? (
|
|
|
|
|
|
<p className="hb-radar-quota hb-radar-quota--full" role="status">
|
|
|
|
|
|
{t("radar.today.truncated", { n: data.truncated_count })}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
{data.last_swept_at ? (
|
|
|
|
|
|
<p className="hb-radar-section__hint">
|
|
|
|
|
|
{t("radar.today.lastSwept", { at: formatTimeAgo(data.last_swept_at) })}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
{data.stats.total === 0 ? (
|
|
|
|
|
|
<EmptyState
|
|
|
|
|
|
title={data.empty_hint || t("radar.today.empty.title")}
|
|
|
|
|
|
description={
|
|
|
|
|
|
data.empty_reason
|
|
|
|
|
|
? t(`radar.today.empty.reason.${data.empty_reason}`)
|
|
|
|
|
|
: t("radar.today.empty.fallback")
|
|
|
|
|
|
}
|
|
|
|
|
|
action={
|
|
|
|
|
|
action ? (
|
|
|
|
|
|
<Link className="hb-btn hb-btn--secondary" to={action.to}>
|
|
|
|
|
|
{t(action.labelKey)}
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
) : null
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="hb-radar-section">
|
|
|
|
|
|
<h2 className="hb-radar-section__title">
|
|
|
|
|
|
{t("radar.today.group.high")} ({data.high.length})
|
|
|
|
|
|
</h2>
|
|
|
|
|
|
{data.high.length === 0 ? (
|
|
|
|
|
|
<p className="hb-radar-section__hint">{t("radar.today.group.empty")}</p>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
data.high.map((o) => (
|
|
|
|
|
|
<OppCard
|
|
|
|
|
|
key={o.id}
|
|
|
|
|
|
o={o}
|
|
|
|
|
|
reply={replies[o.id]}
|
|
|
|
|
|
busy={busy}
|
|
|
|
|
|
onAccept={() => void accept(o.id)}
|
|
|
|
|
|
onDismiss={() => void dismiss(o.id)}
|
|
|
|
|
|
onGenerateReply={(v) => void generateReply(o.id, v)}
|
|
|
|
|
|
onMarkUsed={(ch) => void markUsed(o.id, ch)}
|
|
|
|
|
|
onCopyReply={() =>
|
|
|
|
|
|
void copyReply(replies[o.id]?.text || o.default_reply?.text || "")
|
|
|
|
|
|
}
|
|
|
|
|
|
onOverrideBand={(b) => void overrideBand(o.id, b)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="hb-radar-section">
|
|
|
|
|
|
<h2 className="hb-radar-section__title">
|
|
|
|
|
|
{t("radar.today.group.mid")} ({data.mid.length})
|
|
|
|
|
|
</h2>
|
|
|
|
|
|
{data.mid.length === 0 ? (
|
|
|
|
|
|
<p className="hb-radar-section__hint">{t("radar.today.group.empty")}</p>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
data.mid.map((o) => (
|
|
|
|
|
|
<OppCard
|
|
|
|
|
|
key={o.id}
|
|
|
|
|
|
o={o}
|
|
|
|
|
|
reply={replies[o.id]}
|
|
|
|
|
|
busy={busy}
|
|
|
|
|
|
onAccept={() => void accept(o.id)}
|
|
|
|
|
|
onDismiss={() => void dismiss(o.id)}
|
|
|
|
|
|
onGenerateReply={(v) => void generateReply(o.id, v)}
|
|
|
|
|
|
onMarkUsed={(ch) => void markUsed(o.id, ch)}
|
|
|
|
|
|
onCopyReply={() =>
|
|
|
|
|
|
void copyReply(replies[o.id]?.text || o.default_reply?.text || "")
|
|
|
|
|
|
}
|
|
|
|
|
|
onOverrideBand={(b) => void overrideBand(o.id, b)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="hb-radar-section">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="hb-btn hb-btn--ghost"
|
|
|
|
|
|
onClick={() => setLowOpen((v) => !v)}
|
|
|
|
|
|
aria-expanded={lowOpen}
|
|
|
|
|
|
>
|
|
|
|
|
|
{t("radar.today.group.low")} ({data.low.length}) ·{" "}
|
|
|
|
|
|
{lowOpen ? t("radar.today.group.collapse") : t("radar.today.group.expand")}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{lowOpen
|
|
|
|
|
|
? data.low.map((o) => (
|
|
|
|
|
|
<OppCard
|
|
|
|
|
|
key={o.id}
|
|
|
|
|
|
o={o}
|
|
|
|
|
|
reply={replies[o.id]}
|
|
|
|
|
|
busy={busy}
|
|
|
|
|
|
onAccept={() => void accept(o.id)}
|
|
|
|
|
|
onDismiss={() => void dismiss(o.id)}
|
|
|
|
|
|
onGenerateReply={(v) => void generateReply(o.id, v)}
|
|
|
|
|
|
onMarkUsed={(ch) => void markUsed(o.id, ch)}
|
|
|
|
|
|
onCopyReply={() =>
|
|
|
|
|
|
void copyReply(replies[o.id]?.text || o.default_reply?.text || "")
|
|
|
|
|
|
}
|
|
|
|
|
|
onOverrideBand={(b) => void overrideBand(o.id, b)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
))
|
|
|
|
|
|
: null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|