2026-08-03 05:52:02 +00:00
|
|
|
|
/**
|
2026-08-13 02:22:24 +00:00
|
|
|
|
* CRM 名單:以搜尋/篩選/分頁的工作清單取代會重複 Contact 的八欄看板。
|
|
|
|
|
|
* 階段仍保留完整能力,選中 Contact 後在右側詳情操作。
|
2026-08-03 05:52:02 +00:00
|
|
|
|
*/
|
2026-08-13 02:22:24 +00:00
|
|
|
|
import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react";
|
2026-08-03 05:52:02 +00:00
|
|
|
|
import { Link, useSearchParams } from "react-router-dom";
|
|
|
|
|
|
import { PageHeader } from "../components/layout/PageHeader";
|
2026-08-13 02:22:24 +00:00
|
|
|
|
import { Badge, Button, EmptyState, Input, Pager, Select, Textarea } from "../components/ui";
|
2026-08-03 05:52:02 +00:00
|
|
|
|
import { useRepos } from "../data/DataContext";
|
|
|
|
|
|
import type { Contact, ContactDetail, ContactStage } from "../domain/types";
|
|
|
|
|
|
import { useI18n } from "../i18n/I18nContext";
|
|
|
|
|
|
import { useFormatApiError } from "../lib/apiErrors";
|
|
|
|
|
|
import { formatTimeAgo } from "../lib/time";
|
|
|
|
|
|
import "../styles/radar.css";
|
|
|
|
|
|
|
2026-08-13 02:22:24 +00:00
|
|
|
|
const PAGE_SIZE = 20;
|
|
|
|
|
|
|
2026-08-03 05:52:02 +00:00
|
|
|
|
const STAGES: ContactStage[] = [
|
|
|
|
|
|
"new_found",
|
|
|
|
|
|
"engaged",
|
|
|
|
|
|
"dm_sent",
|
|
|
|
|
|
"replied",
|
|
|
|
|
|
"quoted",
|
|
|
|
|
|
"won",
|
|
|
|
|
|
"lost",
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-08-13 02:22:24 +00:00
|
|
|
|
type StageFilter = "" | "needs_follow_up" | ContactStage;
|
|
|
|
|
|
type SortMode = "last_touch_at" | "intent_score";
|
|
|
|
|
|
|
2026-08-03 05:52:02 +00:00
|
|
|
|
export function CrmBoardPage() {
|
|
|
|
|
|
const { t } = useI18n();
|
|
|
|
|
|
const repos = useRepos();
|
|
|
|
|
|
const formatErr = useFormatApiError();
|
|
|
|
|
|
const [params, setParams] = useSearchParams();
|
|
|
|
|
|
const focusId = params.get("contact") || "";
|
|
|
|
|
|
|
|
|
|
|
|
const [list, setList] = useState<Contact[]>([]);
|
2026-08-13 02:22:24 +00:00
|
|
|
|
const [total, setTotal] = useState(0);
|
2026-08-03 05:52:02 +00:00
|
|
|
|
const [counts, setCounts] = useState<Record<string, number>>({});
|
2026-08-13 02:22:24 +00:00
|
|
|
|
const [page, setPage] = useState(1);
|
|
|
|
|
|
const [stageFilter, setStageFilter] = useState<StageFilter>("");
|
|
|
|
|
|
const [sort, setSort] = useState<SortMode>("last_touch_at");
|
|
|
|
|
|
const [searchInput, setSearchInput] = useState("");
|
|
|
|
|
|
const [query, setQuery] = useState("");
|
2026-08-03 05:52:02 +00:00
|
|
|
|
const [err, setErr] = useState<string | null>(null);
|
|
|
|
|
|
const [msg, setMsg] = useState<string | null>(null);
|
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
const [selectedId, setSelectedId] = useState<string | null>(focusId || null);
|
|
|
|
|
|
const [detail, setDetail] = useState<ContactDetail | null>(null);
|
|
|
|
|
|
const [note, setNote] = useState("");
|
|
|
|
|
|
const [amount, setAmount] = useState("");
|
|
|
|
|
|
const [busy, setBusy] = useState("");
|
|
|
|
|
|
|
|
|
|
|
|
const load = useCallback(async () => {
|
2026-08-13 02:22:24 +00:00
|
|
|
|
const res = await repos.crm.listContacts({
|
|
|
|
|
|
page,
|
|
|
|
|
|
pageSize: PAGE_SIZE,
|
|
|
|
|
|
query: query || undefined,
|
|
|
|
|
|
stage: stageFilter && stageFilter !== "needs_follow_up" ? stageFilter : undefined,
|
|
|
|
|
|
follow_up: stageFilter === "needs_follow_up" ? true : undefined,
|
|
|
|
|
|
sort,
|
|
|
|
|
|
});
|
2026-08-03 05:52:02 +00:00
|
|
|
|
setList(res.list);
|
2026-08-13 02:22:24 +00:00
|
|
|
|
setTotal(res.total);
|
|
|
|
|
|
const nextCounts: Record<string, number> = {};
|
|
|
|
|
|
for (const item of res.stage_counts) nextCounts[item.stage] = item.count;
|
|
|
|
|
|
setCounts(nextCounts);
|
|
|
|
|
|
}, [repos.crm, page, query, stageFilter, sort]);
|
2026-08-03 05:52:02 +00:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (focusId) setSelectedId(focusId);
|
|
|
|
|
|
}, [focusId]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!selectedId) {
|
|
|
|
|
|
setDetail(null);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
let alive = true;
|
|
|
|
|
|
repos.crm
|
|
|
|
|
|
.getContact(selectedId)
|
2026-08-13 02:22:24 +00:00
|
|
|
|
.then((next) => {
|
|
|
|
|
|
if (alive) setDetail(next);
|
2026-08-03 05:52:02 +00:00
|
|
|
|
})
|
|
|
|
|
|
.catch((e) => {
|
2026-08-13 02:22:24 +00:00
|
|
|
|
if (alive) {
|
|
|
|
|
|
setErr(formatErr(e));
|
|
|
|
|
|
setSelectedId(null);
|
|
|
|
|
|
setDetail(null);
|
|
|
|
|
|
}
|
2026-08-03 05:52:02 +00:00
|
|
|
|
});
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
alive = false;
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [selectedId, repos.crm]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
|
|
|
2026-08-13 02:22:24 +00:00
|
|
|
|
const allCount = useMemo(
|
|
|
|
|
|
() => STAGES.reduce((sum, stage) => sum + (counts[stage] ?? 0), 0),
|
|
|
|
|
|
[counts],
|
|
|
|
|
|
);
|
2026-08-03 05:52:02 +00:00
|
|
|
|
|
|
|
|
|
|
async function run(key: string, action: () => Promise<void>, ok?: string) {
|
|
|
|
|
|
setBusy(key);
|
|
|
|
|
|
setErr(null);
|
|
|
|
|
|
setMsg(null);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await action();
|
|
|
|
|
|
await load();
|
2026-08-13 02:22:24 +00:00
|
|
|
|
if (selectedId) setDetail(await repos.crm.getContact(selectedId));
|
2026-08-03 05:52:02 +00:00
|
|
|
|
if (ok) setMsg(ok);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setErr(formatErr(e));
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setBusy("");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function selectContact(id: string) {
|
2026-08-13 02:22:24 +00:00
|
|
|
|
setDetail(null);
|
2026-08-03 05:52:02 +00:00
|
|
|
|
setSelectedId(id);
|
2026-08-13 02:22:24 +00:00
|
|
|
|
setParams((current) => {
|
|
|
|
|
|
const next = new URLSearchParams(current);
|
2026-08-03 05:52:02 +00:00
|
|
|
|
next.set("contact", id);
|
|
|
|
|
|
return next;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-13 02:22:24 +00:00
|
|
|
|
function closeDetail() {
|
|
|
|
|
|
setSelectedId(null);
|
|
|
|
|
|
setDetail(null);
|
|
|
|
|
|
setParams((current) => {
|
|
|
|
|
|
const next = new URLSearchParams(current);
|
|
|
|
|
|
next.delete("contact");
|
|
|
|
|
|
return next;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function submitSearch(event: FormEvent) {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
setPage(1);
|
|
|
|
|
|
setQuery(searchInput.trim());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function clearFilters() {
|
|
|
|
|
|
setSearchInput("");
|
|
|
|
|
|
setQuery("");
|
|
|
|
|
|
setStageFilter("");
|
|
|
|
|
|
setSort("last_touch_at");
|
|
|
|
|
|
setPage(1);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function removeSelected() {
|
|
|
|
|
|
if (!detail) return;
|
|
|
|
|
|
const contact = detail.contact;
|
|
|
|
|
|
if (!window.confirm(t("crm.board.confirmDelete", { name: contact.display_name || `@${contact.author_handle}` }))) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setBusy("delete");
|
|
|
|
|
|
setErr(null);
|
|
|
|
|
|
setMsg(null);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await repos.crm.deleteContact(contact.id);
|
|
|
|
|
|
closeDetail();
|
|
|
|
|
|
if (list.length === 1 && page > 1) {
|
|
|
|
|
|
setPage((current) => current - 1);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
await load();
|
|
|
|
|
|
}
|
|
|
|
|
|
setMsg(t("crm.board.msg.deleted"));
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setErr(formatErr(e));
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setBusy("");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 05:52:02 +00:00
|
|
|
|
return (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<PageHeader title={t("crm.board.title")} />
|
|
|
|
|
|
<div className="hb-radar-actions hb-radar-actions--toolbar">
|
|
|
|
|
|
<Link className="hb-btn hb-btn--secondary" to="/app/radar/today">
|
|
|
|
|
|
{t("crm.board.link.today")}
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
<Link className="hb-btn hb-btn--ghost" to="/app/crm/followups">
|
|
|
|
|
|
{t("crm.board.link.followups")}
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
<Link className="hb-btn hb-btn--ghost" to="/app/crm/stats">
|
|
|
|
|
|
{t("crm.board.link.stats")}
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-08-13 02:22:24 +00:00
|
|
|
|
{err ? <p className="hb-banner-error" role="alert">{err}</p> : null}
|
|
|
|
|
|
{msg ? <p className="hb-banner-ok" role="status">{msg}</p> : null}
|
|
|
|
|
|
|
|
|
|
|
|
<section className="crm-controls" aria-label={t("crm.board.filters")}>
|
|
|
|
|
|
<form className="crm-search" onSubmit={submitSearch}>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
name="crm-contact-search"
|
|
|
|
|
|
type="search"
|
|
|
|
|
|
label={t("crm.board.search")}
|
|
|
|
|
|
placeholder={t("crm.board.searchPlaceholder")}
|
|
|
|
|
|
value={searchInput}
|
|
|
|
|
|
maxLength={80}
|
|
|
|
|
|
onChange={(event) => setSearchInput(event.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Button type="submit" variant="secondary">{t("common.search")}</Button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
<Select
|
|
|
|
|
|
name="crm-stage-filter"
|
|
|
|
|
|
label={t("crm.board.stageFilter")}
|
|
|
|
|
|
value={stageFilter}
|
|
|
|
|
|
onChange={(event) => {
|
|
|
|
|
|
setStageFilter(event.target.value as StageFilter);
|
|
|
|
|
|
setPage(1);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="">{t("crm.board.allStages")} ({allCount})</option>
|
|
|
|
|
|
<option value="needs_follow_up">{t("crm.stage.needs_follow_up")} ({counts.needs_follow_up ?? 0})</option>
|
|
|
|
|
|
{STAGES.map((stage) => (
|
|
|
|
|
|
<option key={stage} value={stage}>{t(`crm.stage.${stage}`)} ({counts[stage] ?? 0})</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
<Select
|
|
|
|
|
|
name="crm-sort"
|
|
|
|
|
|
label={t("crm.board.sort")}
|
|
|
|
|
|
value={sort}
|
|
|
|
|
|
onChange={(event) => {
|
|
|
|
|
|
setSort(event.target.value as SortMode);
|
|
|
|
|
|
setPage(1);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="last_touch_at">{t("crm.board.sortRecent")}</option>
|
|
|
|
|
|
<option value="intent_score">{t("crm.board.sortIntent")}</option>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
{query || stageFilter || sort !== "last_touch_at" ? (
|
|
|
|
|
|
<Button type="button" variant="ghost" onClick={clearFilters}>{t("crm.board.clearFilters")}</Button>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</section>
|
2026-08-03 05:52:02 +00:00
|
|
|
|
|
|
|
|
|
|
{loading && !list.length ? <p className="hb-radar-section__hint">{t("common.loading")}</p> : null}
|
|
|
|
|
|
|
2026-08-13 02:22:24 +00:00
|
|
|
|
{!loading && allCount === 0 && !query ? (
|
2026-08-03 05:52:02 +00:00
|
|
|
|
<EmptyState
|
|
|
|
|
|
title={t("crm.board.empty")}
|
|
|
|
|
|
description={t("crm.board.emptyHint")}
|
2026-08-13 02:22:24 +00:00
|
|
|
|
action={<Link className="hb-btn hb-btn--secondary" to="/app/radar/today">{t("crm.board.link.today")}</Link>}
|
2026-08-03 05:52:02 +00:00
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
2026-08-13 02:22:24 +00:00
|
|
|
|
<div className={`crm-workspace${detail ? " crm-workspace--detail" : ""}`}>
|
|
|
|
|
|
<section className="crm-list-panel">
|
|
|
|
|
|
<p className="crm-result-count">{t("crm.board.results", { n: total })}</p>
|
|
|
|
|
|
{!loading && list.length === 0 ? (
|
|
|
|
|
|
<EmptyState
|
|
|
|
|
|
title={t("crm.board.noResults")}
|
|
|
|
|
|
description={t("crm.board.noResultsHint")}
|
|
|
|
|
|
action={<Button type="button" variant="ghost" onClick={clearFilters}>{t("crm.board.clearFilters")}</Button>}
|
2026-08-03 05:52:02 +00:00
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
2026-08-13 02:22:24 +00:00
|
|
|
|
<div className="crm-contact-list">
|
|
|
|
|
|
{list.map((contact) => (
|
|
|
|
|
|
<ContactCard
|
|
|
|
|
|
key={contact.id}
|
|
|
|
|
|
contact={contact}
|
|
|
|
|
|
active={selectedId === contact.id}
|
|
|
|
|
|
onSelect={selectContact}
|
|
|
|
|
|
t={t}
|
|
|
|
|
|
/>
|
2026-08-03 05:52:02 +00:00
|
|
|
|
))}
|
2026-08-13 02:22:24 +00:00
|
|
|
|
</div>
|
2026-08-03 05:52:02 +00:00
|
|
|
|
)}
|
2026-08-13 02:22:24 +00:00
|
|
|
|
<Pager total={total} page={page} pageSize={PAGE_SIZE} onPageChange={setPage} />
|
|
|
|
|
|
</section>
|
2026-08-03 05:52:02 +00:00
|
|
|
|
|
2026-08-13 02:22:24 +00:00
|
|
|
|
{detail ? (
|
|
|
|
|
|
<ContactDetailPanel
|
|
|
|
|
|
detail={detail}
|
|
|
|
|
|
amount={amount}
|
|
|
|
|
|
note={note}
|
|
|
|
|
|
busy={busy}
|
|
|
|
|
|
t={t}
|
|
|
|
|
|
onAmountChange={setAmount}
|
|
|
|
|
|
onNoteChange={setNote}
|
|
|
|
|
|
onClose={closeDetail}
|
|
|
|
|
|
onDelete={() => void removeSelected()}
|
|
|
|
|
|
onStage={(stage) => void run("stage", async () => {
|
|
|
|
|
|
await repos.crm.updateStage(detail.contact.id, stage);
|
|
|
|
|
|
}, t("crm.board.msg.stage"))}
|
|
|
|
|
|
onFollowUp={() => void run("fu", async () => {
|
|
|
|
|
|
await repos.crm.setFollowUp(
|
|
|
|
|
|
detail.contact.id,
|
|
|
|
|
|
!detail.contact.needs_follow_up,
|
|
|
|
|
|
detail.contact.follow_up_days || 3,
|
|
|
|
|
|
);
|
|
|
|
|
|
}, t("crm.board.msg.followUp"))}
|
|
|
|
|
|
onWon={() => void run("won", async () => {
|
|
|
|
|
|
await repos.crm.reportConversion(detail.contact.id, {
|
|
|
|
|
|
amount: Number(amount) || 0,
|
|
|
|
|
|
currency: "TWD",
|
|
|
|
|
|
note: note.trim() || undefined,
|
|
|
|
|
|
});
|
|
|
|
|
|
}, t("crm.board.msg.won"))}
|
|
|
|
|
|
onAddNote={() => void run("note", async () => {
|
|
|
|
|
|
await repos.crm.addNote(detail.contact.id, note.trim());
|
|
|
|
|
|
setNote("");
|
|
|
|
|
|
}, t("crm.board.msg.note"))}
|
|
|
|
|
|
/>
|
2026-08-03 05:52:02 +00:00
|
|
|
|
) : null}
|
2026-08-13 02:22:24 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-08-03 05:52:02 +00:00
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function ContactCard({
|
2026-08-13 02:22:24 +00:00
|
|
|
|
contact,
|
2026-08-03 05:52:02 +00:00
|
|
|
|
active,
|
|
|
|
|
|
onSelect,
|
|
|
|
|
|
t,
|
|
|
|
|
|
}: {
|
2026-08-13 02:22:24 +00:00
|
|
|
|
contact: Contact;
|
2026-08-03 05:52:02 +00:00
|
|
|
|
active: boolean;
|
|
|
|
|
|
onSelect: (id: string) => void;
|
2026-08-13 02:22:24 +00:00
|
|
|
|
t: (key: string, params?: Record<string, string | number>) => string;
|
2026-08-03 05:52:02 +00:00
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
2026-08-13 02:22:24 +00:00
|
|
|
|
className={`crm-contact-card${active ? " is-active" : ""}`}
|
|
|
|
|
|
onClick={() => onSelect(contact.id)}
|
2026-08-03 05:52:02 +00:00
|
|
|
|
>
|
2026-08-13 02:22:24 +00:00
|
|
|
|
<span className="crm-contact-card__head">
|
|
|
|
|
|
<span>
|
|
|
|
|
|
<strong>{contact.display_name || `@${contact.author_handle}`}</strong>
|
|
|
|
|
|
{contact.display_name ? <small>@{contact.author_handle}</small> : null}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<Badge tone={contact.stage === "won" ? "success" : contact.stage === "lost" ? "neutral" : "brand"}>
|
|
|
|
|
|
{t(`crm.stage.${contact.stage}`)}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="crm-contact-card__meta">
|
|
|
|
|
|
<span>{contact.top_intent_score ?? "—"} · {contact.opportunity_count} {t("crm.board.oppCount")}</span>
|
|
|
|
|
|
{contact.needs_follow_up ? <Badge tone="warning">{t("crm.stage.needs_follow_up")}</Badge> : null}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<small className="crm-contact-card__time">
|
|
|
|
|
|
{contact.last_touch_at ? t("crm.board.lastTouch", { time: formatTimeAgo(contact.last_touch_at) }) : t("crm.board.noTouch")}
|
|
|
|
|
|
</small>
|
2026-08-03 05:52:02 +00:00
|
|
|
|
</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-08-13 02:22:24 +00:00
|
|
|
|
|
|
|
|
|
|
function ContactDetailPanel({
|
|
|
|
|
|
detail,
|
|
|
|
|
|
amount,
|
|
|
|
|
|
note,
|
|
|
|
|
|
busy,
|
|
|
|
|
|
t,
|
|
|
|
|
|
onAmountChange,
|
|
|
|
|
|
onNoteChange,
|
|
|
|
|
|
onClose,
|
|
|
|
|
|
onDelete,
|
|
|
|
|
|
onStage,
|
|
|
|
|
|
onFollowUp,
|
|
|
|
|
|
onWon,
|
|
|
|
|
|
onAddNote,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
detail: ContactDetail;
|
|
|
|
|
|
amount: string;
|
|
|
|
|
|
note: string;
|
|
|
|
|
|
busy: string;
|
|
|
|
|
|
t: (key: string, params?: Record<string, string | number>) => string;
|
|
|
|
|
|
onAmountChange: (value: string) => void;
|
|
|
|
|
|
onNoteChange: (value: string) => void;
|
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
|
onDelete: () => void;
|
|
|
|
|
|
onStage: (stage: ContactStage) => void;
|
|
|
|
|
|
onFollowUp: () => void;
|
|
|
|
|
|
onWon: () => void;
|
|
|
|
|
|
onAddNote: () => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const contact = detail.contact;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<aside className="crm-detail">
|
|
|
|
|
|
<header className="crm-detail__header">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2>{contact.display_name || `@${contact.author_handle}`}</h2>
|
|
|
|
|
|
{contact.display_name ? <p>@{contact.author_handle}</p> : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Button type="button" variant="ghost" onClick={onClose}>{t("common.close")}</Button>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="crm-detail__primary-actions">
|
|
|
|
|
|
<Select
|
|
|
|
|
|
name="crm-contact-stage"
|
|
|
|
|
|
label={t("crm.board.stage")}
|
|
|
|
|
|
value={contact.stage}
|
|
|
|
|
|
disabled={busy === "stage"}
|
|
|
|
|
|
onChange={(event) => onStage(event.target.value as ContactStage)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{STAGES.map((stage) => <option key={stage} value={stage}>{t(`crm.stage.${stage}`)}</option>)}
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
<Button type="button" variant="secondary" disabled={busy === "fu"} onClick={onFollowUp}>
|
|
|
|
|
|
{contact.needs_follow_up ? t("crm.board.clearFollowUp") : t("crm.board.markFollowUp")}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="hb-radar-section">
|
|
|
|
|
|
<h3 className="hb-radar-section__title">{t("crm.board.conversion")}</h3>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
name="crm-conversion-amount"
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min="0"
|
|
|
|
|
|
label={t("crm.board.amount")}
|
|
|
|
|
|
value={amount}
|
|
|
|
|
|
onChange={(event) => onAmountChange(event.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Button type="button" disabled={busy === "won"} onClick={onWon}>{t("crm.board.reportWon")}</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="hb-radar-section">
|
|
|
|
|
|
<h3 className="hb-radar-section__title">{t("crm.board.notes")}</h3>
|
|
|
|
|
|
<Textarea name="crm-note" label={t("crm.board.noteLabel")} rows={3} value={note} onChange={(event) => onNoteChange(event.target.value)} />
|
|
|
|
|
|
<Button type="button" variant="secondary" disabled={busy === "note" || !note.trim()} onClick={onAddNote}>
|
|
|
|
|
|
{t("crm.board.addNote")}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="hb-radar-section">
|
|
|
|
|
|
<h3 className="hb-radar-section__title">{t("crm.board.timeline")}</h3>
|
|
|
|
|
|
{detail.touches.length === 0 ? <p className="hb-radar-section__hint">{t("crm.board.timelineEmpty")}</p> : (
|
|
|
|
|
|
<ul className="hb-crm-timeline">
|
|
|
|
|
|
{detail.touches.map((touch) => (
|
|
|
|
|
|
<li key={touch.id} className="hb-crm-touch">
|
|
|
|
|
|
<span className="hb-crm-touch__at">{formatTimeAgo(touch.created_at)}</span>
|
|
|
|
|
|
<span>
|
|
|
|
|
|
{touch.type}
|
|
|
|
|
|
{touch.product_label_snapshot ? ` · 產品:${touch.product_label_snapshot}` : ""}
|
|
|
|
|
|
{touch.to_stage ? ` → ${t(`crm.stage.${touch.to_stage}`)}` : ""}
|
|
|
|
|
|
{touch.body ? ` · ${touch.body}` : ""}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{detail.opportunities.length > 0 ? (
|
|
|
|
|
|
<div className="hb-radar-section">
|
|
|
|
|
|
<h3 className="hb-radar-section__title">{t("crm.board.opps")}</h3>
|
|
|
|
|
|
<ul className="radar-list">
|
|
|
|
|
|
{detail.opportunities.map((opportunity) => (
|
|
|
|
|
|
<li key={opportunity.id} className="radar-card">
|
|
|
|
|
|
<Badge tone={opportunity.intent_band === "high" ? "success" : "neutral"}>
|
|
|
|
|
|
{opportunity.intent_band} · {opportunity.intent_score}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
<p className="hb-opp-card__text">{opportunity.text.slice(0, 120)}</p>
|
|
|
|
|
|
{opportunity.primary_product_label ? (
|
|
|
|
|
|
<span className="radar-card__meta">主推產品:{opportunity.primary_product_label}{opportunity.primary_brand_name ? `(${opportunity.primary_brand_name})` : ""}</span>
|
|
|
|
|
|
) : <span className="radar-card__meta">未指定產品</span>}
|
|
|
|
|
|
<a href={opportunity.permalink} target="_blank" rel="noreferrer">{t("radar.today.action.open")}</a>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
<div className="crm-detail__danger">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<strong>{t("crm.board.deleteTitle")}</strong>
|
|
|
|
|
|
<p>{t("crm.board.deleteHint")}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Button type="button" variant="danger" disabled={busy === "delete"} onClick={onDelete}>
|
|
|
|
|
|
{busy === "delete" ? t("crm.board.deleting") : t("crm.board.delete")}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</aside>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|