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

497 lines
17 KiB
TypeScript
Raw Normal View History

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