372 lines
12 KiB
TypeScript
372 lines
12 KiB
TypeScript
|
|
/**
|
|||
|
|
* CRM 看板(T574/T575):七階段 + 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>
|
|||
|
|
);
|
|||
|
|
}
|