497 lines
17 KiB
TypeScript
497 lines
17 KiB
TypeScript
/**
|
||
* CRM 名單:以搜尋/篩選/分頁的工作清單取代會重複 Contact 的八欄看板。
|
||
* 階段仍保留完整能力,選中 Contact 後在右側詳情操作。
|
||
*/
|
||
import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react";
|
||
import { Link, useSearchParams } from "react-router-dom";
|
||
import { PageHeader } from "../components/layout/PageHeader";
|
||
import { Badge, Button, EmptyState, Input, Pager, Select, Textarea } from "../components/ui";
|
||
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";
|
||
|
||
const PAGE_SIZE = 20;
|
||
|
||
const STAGES: ContactStage[] = [
|
||
"new_found",
|
||
"engaged",
|
||
"dm_sent",
|
||
"replied",
|
||
"quoted",
|
||
"won",
|
||
"lost",
|
||
];
|
||
|
||
type StageFilter = "" | "needs_follow_up" | ContactStage;
|
||
type SortMode = "last_touch_at" | "intent_score";
|
||
|
||
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[]>([]);
|
||
const [total, setTotal] = useState(0);
|
||
const [counts, setCounts] = useState<Record<string, number>>({});
|
||
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("");
|
||
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 () => {
|
||
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,
|
||
});
|
||
setList(res.list);
|
||
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]);
|
||
|
||
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)
|
||
.then((next) => {
|
||
if (alive) setDetail(next);
|
||
})
|
||
.catch((e) => {
|
||
if (alive) {
|
||
setErr(formatErr(e));
|
||
setSelectedId(null);
|
||
setDetail(null);
|
||
}
|
||
});
|
||
return () => {
|
||
alive = false;
|
||
};
|
||
}, [selectedId, repos.crm]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
const allCount = useMemo(
|
||
() => STAGES.reduce((sum, stage) => sum + (counts[stage] ?? 0), 0),
|
||
[counts],
|
||
);
|
||
|
||
async function run(key: string, action: () => Promise<void>, ok?: string) {
|
||
setBusy(key);
|
||
setErr(null);
|
||
setMsg(null);
|
||
try {
|
||
await action();
|
||
await load();
|
||
if (selectedId) setDetail(await repos.crm.getContact(selectedId));
|
||
if (ok) setMsg(ok);
|
||
} catch (e) {
|
||
setErr(formatErr(e));
|
||
} finally {
|
||
setBusy("");
|
||
}
|
||
}
|
||
|
||
function selectContact(id: string) {
|
||
setDetail(null);
|
||
setSelectedId(id);
|
||
setParams((current) => {
|
||
const next = new URLSearchParams(current);
|
||
next.set("contact", id);
|
||
return next;
|
||
});
|
||
}
|
||
|
||
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("");
|
||
}
|
||
}
|
||
|
||
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>
|
||
|
||
{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>
|
||
|
||
{loading && !list.length ? <p className="hb-radar-section__hint">{t("common.loading")}</p> : null}
|
||
|
||
{!loading && allCount === 0 && !query ? (
|
||
<EmptyState
|
||
title={t("crm.board.empty")}
|
||
description={t("crm.board.emptyHint")}
|
||
action={<Link className="hb-btn hb-btn--secondary" to="/app/radar/today">{t("crm.board.link.today")}</Link>}
|
||
/>
|
||
) : (
|
||
<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>}
|
||
/>
|
||
) : (
|
||
<div className="crm-contact-list">
|
||
{list.map((contact) => (
|
||
<ContactCard
|
||
key={contact.id}
|
||
contact={contact}
|
||
active={selectedId === contact.id}
|
||
onSelect={selectContact}
|
||
t={t}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
<Pager total={total} page={page} pageSize={PAGE_SIZE} onPageChange={setPage} />
|
||
</section>
|
||
|
||
{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"))}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function ContactCard({
|
||
contact,
|
||
active,
|
||
onSelect,
|
||
t,
|
||
}: {
|
||
contact: Contact;
|
||
active: boolean;
|
||
onSelect: (id: string) => void;
|
||
t: (key: string, params?: Record<string, string | number>) => string;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
className={`crm-contact-card${active ? " is-active" : ""}`}
|
||
onClick={() => onSelect(contact.id)}
|
||
>
|
||
<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>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|