/
keorlov
/
collsim
Обзор
Документация
Войти
/
keorlov
/
collsim
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
frontend/src/components/ClientDetailPanel.jsx
308 строк
11 KB
Konstantin Orlov
wip
14 июл 2026, 13:16
14 июл 2026, 13:16
a03c721
Код
Авторство
О чём код?
import { useState, useEffect, useCallback } from "react"; import { getSimulationClients, getSimulationClientEvents } from "../api.js"; const PRODUCT_LABELS = { mortgage: "Ипотека", potreb: "Потребительский", card: "Кредитная карта", auto: "Автокредит", }; const STATUS_LABELS = { current: "Текущий", soft: "Просрочка", hard: "Hard", closed: "Закрыт", sold: "Продан", }; const CLIENT_TYPE_LABELS = { chronic: "Хронический", forgetful: "Забывчивый", }; const EVENT_LABELS = { hazard: "Выход в просрочку", contact: "Контакт", cure: "Cure", soft_to_hard: "Переход soft → hard", hard_to_soft: "Реструктуризация", sold: "Продажа долга", }; const CHANNEL_LABELS = { sms: "SMS", call: "Звонок", waiting: "Ожидание", }; const EVENT_COLORS = { hazard: "var(--bad)", contact: "var(--accent)", cure: "var(--good)", soft_to_hard: "var(--bad)", hard_to_soft: "var(--warn)", sold: "var(--muted)", }; function fmtMoney(v) { if (v == null) return "—"; return new Intl.NumberFormat("ru-RU", { style: "currency", currency: "RUB", maximumFractionDigits: 0 }).format(v); } function fmtPct(v) { if (v == null) return "—"; return (v * 100).toFixed(1) + "%"; } function EventRow({ ev }) { const color = EVENT_COLORS[ev.type] || "var(--muted)"; let detail = ""; if (ev.type === "contact") { detail = `${CHANNEL_LABELS[ev.channel] || ev.channel}, p=${ev.p_adj}`; if (ev.balance != null) detail += `, ${fmtMoney(ev.balance)}`; } else if (ev.type === "cure") { detail = `+${fmtMoney(ev.cure_amount)}, баланс ${fmtMoney(ev.balance)}`; } else if (ev.type === "soft_to_hard") { detail = `DPD ${ev.dpd}, ${fmtMoney(ev.balance)}`; } else if (ev.type === "hard_to_soft") { detail = `DPD ${ev.dpd}, ${fmtMoney(ev.balance)}`; } else if (ev.type === "hazard") { detail = fmtMoney(ev.balance); } else if (ev.type === "sold") { detail = `DPD ${ev.dpd}`; } return ( <div className="event-row"> <span className="event-day">День {ev.day}</span> <span className="event-dot" style={{ background: color }} /> <span className="event-type">{EVENT_LABELS[ev.type] || ev.type}</span> <span className="event-detail">{detail}</span> </div> ); } export default function ClientDetailPanel({ simulationId }) { const [strategy, setStrategy] = useState("user"); const [clients, setClients] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(0); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [events, setEvents] = useState([]); const [eventsLoading, setEventsLoading] = useState(false); const [eventsMessage, setEventsMessage] = useState(null); const pageSize = 1; const loadClient = useCallback(async (pageIndex) => { if (!simulationId) return; setLoading(true); setError(null); setEvents([]); try { const res = await getSimulationClients(simulationId, strategy, pageIndex, pageSize); setClients(res.clients || []); setTotal(res.total || 0); } catch (e) { setError(e.message); setClients([]); setTotal(0); } finally { setLoading(false); } }, [simulationId, strategy]); useEffect(() => { setPage(0); loadClient(0); }, [simulationId, strategy]); useEffect(() => { loadClient(page); }, [page, loadClient]); useEffect(() => { if (!simulationId || !clients[0]) return; let cancelled = false; async function fetchEvents() { setEventsLoading(true); setEventsMessage(null); try { const res = await getSimulationClientEvents(simulationId, clients[0].client_id, strategy); if (!cancelled) { setEvents(res.events || []); if (res.status === "no_data") { setEventsMessage(res.message); } } } catch { if (!cancelled) { setEvents([]); setEventsMessage(null); } } finally { if (!cancelled) setEventsLoading(false); } } fetchEvents(); return () => { cancelled = true; }; }, [simulationId, clients, strategy]); if (!simulationId) return null; const client = clients[0] || null; const currentPage = page + 1; return ( <div className="card"> <h2>Детали клиента с просрочкой</h2> <div className="client-panel-controls"> <div className="client-panel-toggle"> <button className={`tab-btn ${strategy === "user" ? "active" : ""}`} onClick={() => setStrategy("user")} > Пользовательская </button> <button className={`tab-btn ${strategy === "baseline" ? "active" : ""}`} onClick={() => setStrategy("baseline")} > Базовая </button> </div> <div className="client-panel-nav"> <button className="btn-nav" onClick={() => setPage(page <= 0 ? total - 1 : page - 1)} disabled={loading || total === 0} > ← Назад </button> <span className="client-panel-counter"> {total > 0 ? `Клиент ${currentPage} из ${total}` : "Нет данных"} </span> <button className="btn-nav" onClick={() => setPage(page >= total - 1 ? 0 : page + 1)} disabled={loading || total === 0} > Вперёд → </button> </div> </div> {error && <div className="banner error">Ошибка: {error}</div>} {loading ? ( <div className="loading"> <div className="spinner" /> Загрузка… </div> ) : client ? ( <> <div className="client-detail-grid"> <div className="client-detail-section"> <div className="client-detail-title">Основная информация</div> <div className="client-detail-rows"> <div className="client-detail-row"> <span className="client-detail-label">ID клиента</span> <span className="client-detail-value">{client.client_id}</span> </div> <div className="client-detail-row"> <span className="client-detail-label">Тип клиента</span> <span className="client-detail-value">{CLIENT_TYPE_LABELS[client.client_type] || client.client_type}</span> </div> <div className="client-detail-row"> <span className="client-detail-label">Продукт</span> <span className="client-detail-value">{PRODUCT_LABELS[client.product] || client.product}</span> </div> <div className="client-detail-row"> <span className="client-detail-label">Gini Score</span> <span className="client-detail-value">{fmtPct(client.gini_score)}</span> </div> </div> </div> <div className="client-detail-section"> <div className="client-detail-title">Текущее состояние</div> <div className="client-detail-rows"> <div className="client-detail-row"> <span className="client-detail-label">Статус</span> <span className={`client-detail-value client-detail-status-${client.status}`}> {STATUS_LABELS[client.status] || client.status} </span> </div> <div className="client-detail-row"> <span className="client-detail-label">DPD</span> <span className="client-detail-value">{client.dpd ?? "—"}</span> </div> <div className="client-detail-row"> <span className="client-detail-label">Баланс</span> <span className="client-detail-value">{fmtMoney(client.balance)}</span> </div> <div className="client-detail-row"> <span className="client-detail-label">Начальный баланс</span> <span className="client-detail-value">{fmtMoney(client.original_balance)}</span> </div> </div> </div> <div className="client-detail-section"> <div className="client-detail-title">История просрочки</div> <div className="client-detail-rows"> <div className="client-detail-row"> <span className="client-detail-label">День первого выхода в просрочку</span> <span className="client-detail-value">{client.first_delinquent_day}</span> </div> <div className="client-detail-row"> <span className="client-detail-label">День последнего выхода в просрочку</span> <span className="client-detail-value">{client.last_delinquent_day}</span> </div> <div className="client-detail-row"> <span className="client-detail-label">Максимальный DPD</span> <span className="client-detail-value">{client.max_dpd}</span> </div> <div className="client-detail-row"> <span className="client-detail-label">Всего контактов</span> <span className="client-detail-value">{client.total_contacts}</span> </div> <div className="client-detail-row"> <span className="client-detail-label">SMS</span> <span className="client-detail-value">{client.contacts_by_channel?.sms ?? 0}</span> </div> <div className="client-detail-row"> <span className="client-detail-label">Звонки</span> <span className="client-detail-value">{client.contacts_by_channel?.call ?? 0}</span> </div> </div> </div> </div> <div className="client-events-section"> <div className="client-detail-title"> События по дням {eventsLoading && <span className="client-events-loading"> загрузка…</span>} </div> {eventsMessage ? ( <div className="client-events-message">{eventsMessage}</div> ) : events.length > 0 ? ( <div className="client-events-timeline"> {events.map((ev, i) => ( <EventRow key={i} ev={ev} /> ))} </div> ) : ( !eventsLoading && <div className="empty-state">Нет событий</div> )} </div> </> ) : ( <div className="empty-state">Нет клиентов с просрочкой</div> )} </div> ); }