/
keorlov
/
collsim
Обзор
Документация
Войти
/
keorlov
/
collsim
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
frontend/src/App.jsx
368 строк
14 KB
Konstantin Orlov
wip
14 июл 2026, 12:10
14 июл 2026, 12:10
c8f2d41
Код
Авторство
О чём код?
import { useState, useEffect } from "react"; import { simulate, getSimulation, listSimulations, disposeSimulation } from "./api.js"; import SummaryCards from "./components/SummaryCards.jsx"; import NPLChart from "./components/NPLChart.jsx"; import PortfolioChart from "./components/PortfolioChart.jsx"; import RecoveryDetailedChart from "./components/RecoveryDetailedChart.jsx"; import FlowChart from "./components/FlowChart.jsx"; import ChannelEconomicsChart from "./components/ChannelEconomicsChart.jsx"; import RollRateChart from "./components/RollRateChart.jsx"; import BudgetUtilChart from "./components/BudgetUtilChart.jsx"; import MetricsTable from "./components/MetricsTable.jsx"; import DailyTable from "./components/DailyTable.jsx"; import ParamForm from "./components/ParamForm.jsx"; import Collapsible from "./components/Collapsible.jsx"; import ClientDetailPanel from "./components/ClientDetailPanel.jsx"; const FULL_DEFAULT_CONFIG = { simulation: { days: 365, optimization_mode: "greedy", gini: 0.35, cure_recovery_rate: 0.05, hard_cure_rate: 0.002 }, portfolio: { initial_clients: 50000, daily_growth_rate: 0.00016 }, client_types: { chronic: { share: 0.35, hazard_multiplier: 2.5 }, forgetful: { share: 0.65, hazard_multiplier: 0.6 } }, products: { mortgage: { share: 0.45, avg_balance: 3500000, term_days: 7300, hazard_mult: 0.2, daily_interest_rate: 0.0003, nim_annual_rate: 0.08 }, potreb: { share: 0.30, avg_balance: 300000, term_days: 1825, hazard_mult: 1.2, daily_interest_rate: 0.0005, nim_annual_rate: 0.15 }, card: { share: 0.15, avg_balance: 80000, term_days: null, hazard_mult: 1.8, daily_close_prob: 0.0005, daily_interest_rate: 0.0006, nim_annual_rate: 0.20 }, auto: { share: 0.10, avg_balance: 1500000, term_days: 1825, hazard_mult: 0.5, daily_interest_rate: 0.0004, nim_annual_rate: 0.12 } }, hazard: { base_daily_rate: 0.0012 }, channels: { waiting: { cost: 0, p_base: 0.001 }, sms: { cost: 5, p_base: 0.004 }, call: { cost: 50, p_base: 0.015 } }, budgets: { daily_sms: 10000, daily_call: 25000 }, legal_limits: { max_calls_per_week: 2, max_sms_per_week: 4 }, hard_collection: { cession_dpd: 360, cession_rate: 0.04 }, initial_state: { soft_share: 0.038, hard_share: 0.048, hard_dpd_max: 350 } }; const BASELINE_CONFIG = { ...JSON.parse(JSON.stringify(FULL_DEFAULT_CONFIG)), budgets: { daily_sms: 0, daily_call: 0 }, }; export default function App() { const [view, setView] = useState("list"); const [simulations, setSimulations] = useState([]); const [loadingSims, setLoadingSims] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [ranAt, setRanAt] = useState(null); // Simulation detail state const [selectedSimId, setSelectedSimId] = useState(null); const [simData, setSimData] = useState(null); // Form state const [days, setDays] = useState(365); const [seed, setSeed] = useState(42); const [name, setName] = useState(""); const [userConfig, setUserConfig] = useState(FULL_DEFAULT_CONFIG); const [baselineConfig, setBaselineConfig] = useState(BASELINE_CONFIG); useEffect(() => { loadSimulations(); }, []); async function loadSimulations() { setLoadingSims(true); try { const res = await listSimulations(); setSimulations(res.simulations || []); } catch (e) { setError(e.message); } finally { setLoadingSims(false); } } async function runSimulation() { setLoading(true); setError(null); setSimData(null); try { const payload = { days, seed, name, user_params: userConfig, baseline_params: baselineConfig, }; if (selectedSimId) { payload.simulation_id = selectedSimId; } const res = await simulate(payload); setSimData({ user: res.user_metrics, baseline: res.baseline_metrics }); setSelectedSimId(res.simulation_id); setRanAt(new Date()); } catch (e) { setError(e.message); } finally { setLoading(false); loadSimulations(); } } async function loadSimulation(id) { setLoading(true); setError(null); try { const res = await getSimulation(id); setSimData({ user: res.user_metrics, baseline: res.baseline_metrics }); setSelectedSimId(id); setUserConfig(res.user_params || FULL_DEFAULT_CONFIG); setBaselineConfig(res.baseline_params || BASELINE_CONFIG); setName(res.name || ""); setRanAt(new Date()); setView("detail"); } catch (e) { setError(e.message); } finally { setLoading(false); } } async function deleteSimulation(id, e) { e.stopPropagation(); if (!confirm("Удалить симуляцию?")) return; try { await disposeSimulation(id); setSimulations(simulations.filter(s => s.simulation_id !== id)); if (selectedSimId === id) { setSelectedSimId(null); setSimData(null); } } catch (err) { setError(err.message); } } function newSimulation() { setSelectedSimId(null); setSimData(null); setUserConfig(FULL_DEFAULT_CONFIG); setBaselineConfig(BASELINE_CONFIG); setName(""); setView("detail"); } if (view === "list") { return ( <div className="app"> <header className="header"> <div className="title"> <h1>CollSim</h1> <p>Дашборд симуляции кредитного портфеля</p> </div> <button className="btn-primary" onClick={newSimulation}>Новая симуляция</button> </header> {error && <div className="banner error">Ошибка: {error}</div>} {loadingSims && <div className="banner">Загрузка списка…</div>} <div className="sim-list"> <table className="sim-table"> <thead> <tr> <th>ID</th> <th>Название</th> <th>Создана</th> <th>Дней</th> <th>Seed</th> <th>User NPL%</th> <th>Baseline NPL%</th> <th></th> </tr> </thead> <tbody> {simulations.map((s) => ( <tr key={s.simulation_id} onClick={() => loadSimulation(s.simulation_id)}> <td>{s.simulation_id}</td> <td>{s.name || "—"}</td> <td>{s.created_at ? new Date(s.created_at).toLocaleString("ru-RU") : "—"}</td> <td>{s.days || "—"}</td> <td>{s.seed ?? "—"}</td> <td>{s.user_npl != null ? s.user_npl.toFixed(2) + "%" : "—"}</td> <td>{s.baseline_npl != null ? s.baseline_npl.toFixed(2) + "%" : "—"}</td> <td> <button className="btn-danger" onClick={(e) => deleteSimulation(s.simulation_id, e)}>Удалить</button> </td> </tr> ))} </tbody> </table> {simulations.length === 0 && !loadingSims && ( <div className="empty-state">Нет сохранённых симуляций. Нажмите «Новая симуляция».</div> )} </div> </div> ); } const { user, baseline } = simData || { user: null, baseline: null }; return ( <div className="app"> <header className="header"> <div className="title"> <h1>CollSim</h1> <p>Дашборд симуляции кредитного портфеля</p> </div> <div className="header-actions"> <button onClick={() => setView("list")}>← Список</button> <button className="btn-primary" onClick={runSimulation} disabled={loading}> {loading ? "Симуляция…" : selectedSimId ? "Пересчитать" : "Запустить"} </button> </div> </header> {error && <div className="banner error">Ошибка: {error}</div>} <div className={`params-common ${loading ? "disabled" : ""}`}> <label> <span>Название</span> <input type="text" value={name} onChange={(e) => setName(e.target.value)} disabled={loading} placeholder="Без названия" style={{ width: 220 }} /> </label> <label> <span>Дней</span> <input type="number" min={1} max={1000} value={days} onChange={(e) => setDays(Number(e.target.value))} disabled={loading} style={{ width: 90 }} /> </label> <label> <span>Seed</span> <input type="number" value={seed} onChange={(e) => setSeed(Number(e.target.value))} disabled={loading} style={{ width: 90 }} /> </label> </div> <div className={`params-grid ${loading ? "disabled" : ""}`}> <Collapsible title="Параметры: Пользовательская стратегия" defaultOpen={true}> <ParamForm config={userConfig} onChange={setUserConfig} title="Пользовательская стратегия" disabled={loading} /> </Collapsible> <Collapsible title="Параметры: Базовая стратегия (без бюджета)" defaultOpen={false}> <div style={{ marginBottom: 8 }}> <button className="btn-secondary" onClick={() => setBaselineConfig(JSON.parse(JSON.stringify(userConfig)))} disabled={loading} > Скопировать из пользовательской </button> </div> <ParamForm config={baselineConfig} onChange={setBaselineConfig} title="Базовая стратегия" disabled={loading} /> </Collapsible> </div> {simData && ( <> <SummaryCards userMetrics={user} baselineMetrics={baseline} /> <section className="chart-grid"> <div className="card"> <h2>NPL — Пользовательская</h2> <NPLChart userMetrics={user} /> </div> <div className="card"> <h2>NPL — Базовая</h2> <NPLChart userMetrics={baseline} /> </div> <div className="card"> <h2>Структура портфеля — Пользовательская</h2> <PortfolioChart userMetrics={user} /> </div> <div className="card"> <h2>Структура портфеля — Базовая</h2> <PortfolioChart userMetrics={baseline} /> </div> <div className="card"> <h2>Roll Rates — Пользовательская</h2> <RollRateChart userMetrics={user} /> </div> <div className="card"> <h2>Roll Rates — Базовая</h2> <RollRateChart userMetrics={baseline} /> </div> <div className="card"> <h2>Поток просрочки — Пользовательская</h2> <FlowChart userMetrics={user} /> </div> <div className="card"> <h2>Поток просрочки — Базовая</h2> <FlowChart userMetrics={baseline} /> </div> <div className="card"> <h2>Экономика каналов — Пользовательская</h2> <ChannelEconomicsChart userMetrics={user} /> </div> <div className="card"> <h2>Экономика каналов — Базовая</h2> <ChannelEconomicsChart userMetrics={baseline} /> </div> <div className="card"> <h2>Утилизация бюджетов — Пользовательская</h2> <BudgetUtilChart userMetrics={user} smsBudget={userConfig.budgets.daily_sms} callBudget={userConfig.budgets.daily_call} /> </div> <div className="card"> <h2>Утилизация бюджетов — Базовая</h2> <BudgetUtilChart userMetrics={baseline} smsBudget={baselineConfig.budgets.daily_sms} callBudget={baselineConfig.budgets.daily_call} /> </div> <div className="card"> <h2>Взыскание / Затраты — Пользовательская</h2> <RecoveryDetailedChart userMetrics={user} /> </div> <div className="card"> <h2>Взыскание / Затраты — Базовая</h2> <RecoveryDetailedChart userMetrics={baseline} /> </div> </section> <Collapsible title="Детальные метрики по дням" defaultOpen={false}> <MetricsTable userMetrics={user} baselineMetrics={baseline} /> </Collapsible> <Collapsible title="Назначения по каналам по дням" defaultOpen={false}> <DailyTable userMetrics={user} baselineMetrics={baseline} /> </Collapsible> <ClientDetailPanel simulationId={selectedSimId} /> {ranAt && ( <footer className="footer"> Обновлено: {ranAt.toLocaleTimeString("ru-RU")} · дней: {user.length} · последний NPL:{" "} {user[user.length - 1].npl_pct.toFixed(2)}% (базовый: {baseline?.[baseline.length - 1]?.npl_pct?.toFixed(2)}%) </footer> )} </> )} </div> ); }