/** * 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([]); const [counts, setCounts] = useState>({}); const [err, setErr] = useState(null); const [msg, setMsg] = useState(null); const [loading, setLoading] = useState(true); const [selectedId, setSelectedId] = useState(focusId || null); const [detail, setDetail] = useState(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 = {}; 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, 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 ( <>
{t("crm.board.link.today")} {t("crm.board.link.followups")} {t("crm.board.link.stats")}
{err ? (

{err}

) : null} {msg ? (

{msg}

) : null} {loading && !list.length ?

{t("common.loading")}

: null} {!loading && list.length === 0 ? ( {t("crm.board.link.today")} } /> ) : (

{t("crm.stage.needs_follow_up")}{" "} {followUpList.length}

{followUpList.map((c) => ( ))}
{STAGES.map((stage) => (

{t(`crm.stage.${stage}`)}{" "} {counts[stage] ?? 0}

{list .filter((c) => c.stage === stage) .map((c) => ( ))}
))}
)} {detail ? (