thread-master/apps/web/src/pages/CrmBoardPage.tsx

497 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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>
);
}