/** * 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([]); const [total, setTotal] = useState(0); const [counts, setCounts] = useState>({}); const [page, setPage] = useState(1); const [stageFilter, setStageFilter] = useState(""); const [sort, setSort] = useState("last_touch_at"); const [searchInput, setSearchInput] = useState(""); const [query, setQuery] = 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, 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 = {}; 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, 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 ( <>
{t("crm.board.link.today")} {t("crm.board.link.followups")} {t("crm.board.link.stats")}
{err ?

{err}

: null} {msg ?

{msg}

: null}
setSearchInput(event.target.value)} />
{query || stageFilter || sort !== "last_touch_at" ? ( ) : null}
{loading && !list.length ?

{t("common.loading")}

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

{t("crm.board.results", { n: total })}

{!loading && list.length === 0 ? ( {t("crm.board.clearFilters")}} /> ) : (
{list.map((contact) => ( ))}
)}
{detail ? ( 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}
)} ); } function ContactCard({ contact, active, onSelect, t, }: { contact: Contact; active: boolean; onSelect: (id: string) => void; t: (key: string, params?: Record) => string; }) { return ( ); } 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; 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 (