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

372 lines
12 KiB
TypeScript
Raw Normal View History

2026-08-03 05:52:02 +00:00
/**
* CRM T574T575 + needs_follow_up
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { PageHeader } from "../components/layout/PageHeader";
import { Badge, Button, EmptyState, 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 STAGES: ContactStage[] = [
"new_found",
"engaged",
"dm_sent",
"replied",
"quoted",
"won",
"lost",
];
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 [counts, setCounts] = useState<Record<string, number>>({});
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: 1, pageSize: 100, sort: "last_touch_at" });
setList(res.list);
const m: Record<string, number> = {};
for (const s of res.stage_counts) m[s.stage] = s.count;
setCounts(m);
}, [repos.crm]);
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((d) => {
if (alive) setDetail(d);
})
.catch((e) => {
if (alive) setErr(formatErr(e));
});
return () => {
alive = false;
};
}, [selectedId, repos.crm]); // eslint-disable-line react-hooks/exhaustive-deps
const followUpList = useMemo(() => list.filter((c) => c.needs_follow_up), [list]);
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) {
setSelectedId(id);
setParams((p) => {
const next = new URLSearchParams(p);
next.set("contact", id);
return next;
});
}
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}
{loading && !list.length ? <p className="hb-radar-section__hint">{t("common.loading")}</p> : null}
{!loading && list.length === 0 ? (
<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-board">
<section className="crm-col">
<h2>
{t("crm.stage.needs_follow_up")}{" "}
<span className="radar-card__meta">{followUpList.length}</span>
</h2>
{followUpList.map((c) => (
<ContactCard key={`fu-${c.id}`} c={c} active={selectedId === c.id} onSelect={selectContact} t={t} />
))}
</section>
{STAGES.map((stage) => (
<section key={stage} className="crm-col">
<h2>
{t(`crm.stage.${stage}`)}{" "}
<span className="radar-card__meta">{counts[stage] ?? 0}</span>
</h2>
{list
.filter((c) => c.stage === stage)
.map((c) => (
<ContactCard key={c.id} c={c} active={selectedId === c.id} onSelect={selectContact} t={t} />
))}
</section>
))}
</div>
)}
{detail ? (
<aside className="crm-detail">
<header className="hb-opp-card__meta">
<h2 style={{ margin: 0 }}>@{detail.contact.author_handle}</h2>
<Badge tone="brand">{t(`crm.stage.${detail.contact.stage}`)}</Badge>
{detail.contact.needs_follow_up ? (
<Badge tone="warning">{t("crm.stage.needs_follow_up")}</Badge>
) : null}
</header>
<div className="hb-radar-actions" style={{ marginTop: "var(--hb-space-3)" }}>
{STAGES.map((s) => (
<Button
key={s}
type="button"
variant={detail.contact.stage === s ? "primary" : "ghost"}
disabled={busy === "stage" || detail.contact.stage === s}
onClick={() =>
void run(
"stage",
async () => {
await repos.crm.updateStage(detail.contact.id, s);
},
t("crm.board.msg.stage"),
)
}
>
{t(`crm.stage.${s}`)}
</Button>
))}
</div>
<div className="hb-radar-actions">
<Button
type="button"
variant="secondary"
disabled={busy === "fu"}
onClick={() =>
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"),
)
}
>
{detail.contact.needs_follow_up
? t("crm.board.clearFollowUp")
: t("crm.board.markFollowUp")}
</Button>
</div>
<div className="hb-radar-section" style={{ marginTop: "var(--hb-space-4)" }}>
<h3 className="hb-radar-section__title">{t("crm.board.conversion")}</h3>
<label className="hb-radar-section__hint">
{t("crm.board.amount")}
<input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
style={{ display: "block", width: "100%", marginTop: 4 }}
/>
</label>
<Button
type="button"
disabled={busy === "won"}
onClick={() =>
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"),
)
}
>
{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={(e) => setNote(e.target.value)}
/>
<Button
type="button"
variant="secondary"
disabled={busy === "note" || !note.trim()}
onClick={() =>
void run(
"note",
async () => {
await repos.crm.addNote(detail.contact.id, note.trim());
setNote("");
},
t("crm.board.msg.note"),
)
}
>
{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.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((o) => (
<li key={o.id} className="radar-card">
<Badge tone={o.intent_band === "high" ? "success" : "neutral"}>
{o.intent_band} · {o.intent_score}
</Badge>
<p className="hb-opp-card__text">{o.text.slice(0, 120)}</p>
<a href={o.permalink} target="_blank" rel="noreferrer">
{t("radar.today.action.open")}
</a>
</li>
))}
</ul>
</div>
) : null}
</aside>
) : null}
</>
);
}
function ContactCard({
c,
active,
onSelect,
t,
}: {
c: Contact;
active: boolean;
onSelect: (id: string) => void;
t: (k: string, p?: Record<string, string | number>) => string;
}) {
return (
<button
type="button"
className={`radar-card crm-card${active ? " is-active" : ""}`}
onClick={() => onSelect(c.id)}
>
<strong>@{c.author_handle}</strong>
{c.needs_follow_up ? <Badge tone="warning">{t("crm.stage.needs_follow_up")}</Badge> : null}
<p className="radar-card__meta">
{c.top_intent_band || "—"} · {c.opportunity_count} {t("crm.board.oppCount")}
</p>
</button>
);
}