Bicycle/App/view/Analytics/index.html
2026-08-15 10:39:22 +03:00

236 lines
14 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* @package Bicycle
* @description Analytics/index.html — аналитика, 5 панелей дизайн-хендоффа
* (App/design_handoff_personal_finance): доходы/расходы по месяцам, структура
* расходов, план vs факт, прогноз до конца года, год к году по кварталам.
* Без сторонней chart-библиотеки — сырой inline SVG/conic-gradient, как и в самом
* хендоффе, но на реальных данных вместо хардкода.
*
* @var array $user
* @var int $year
* @var int $month
* @var array $monthly [month => ['income'=>float, 'expense'=>float]] — текущий год
* @var array $monthly_prev То же — предыдущий год ($year - 1), для панели «год к году»
* @var object[] $expense_cats [{categorie_id, title, total}] — расходы текущего месяца по категориям
* @var array $plan_month [categorie_id => plan_summ] — план текущего месяца по тем же категориям
*/
$month_names = [
1 => 'Янв', 2 => 'Фев', 3 => 'Мар', 4 => 'Апр', 5 => 'Май', 6 => 'Июн',
7 => 'Июл', 8 => 'Авг', 9 => 'Сен', 10 => 'Окт', 11 => 'Ноя', 12 => 'Дек',
];
$month_names_gen = [
1 => 'январь', 2 => 'февраль', 3 => 'март', 4 => 'апрель', 5 => 'май', 6 => 'июнь',
7 => 'июль', 8 => 'август', 9 => 'сентябрь', 10 => 'октябрь', 11 => 'ноябрь', 12 => 'декабрь',
];
$cat_colors = ['var(--accent)', 'var(--blue)', 'var(--teal)', 'var(--green)', 'var(--orange)', 'var(--pink)', 'var(--amber)', 'var(--purple)'];
$total_income = array_sum(array_column($monthly, 'income'));
$total_expense = array_sum(array_column($monthly, 'expense'));
$expense_cat_total = array_sum(array_map(fn($c) => (float)$c->total, $expense_cats));
$prev_year = $year - 1;
$next_year = $year + 1;
// ---------- панель 1: доходы/расходы по месяцам (полилинии) ----------
$chart_max = 0.0;
for ($m = 1; $m <= 12; $m++) {
$chart_max = max($chart_max, $monthly[$m]['income'] ?? 0, $monthly[$m]['expense'] ?? 0);
}
$toPoints = static function (string $key) use ($monthly, $chart_max): string {
$points = [];
for ($m = 1; $m <= 12; $m++) {
$x = 10 + ($m - 1) / 11 * 700;
$y = $chart_max > 0 ? 150 - (($monthly[$m][$key] ?? 0) / $chart_max * 140) : 150;
$points[] = round($x, 1) . ',' . round($y, 1);
}
return implode(' ', $points);
};
$income_points = $toPoints('income');
$expense_points = $toPoints('expense');
// ---------- панель 4: прогноз до конца года — грубая линейная экстраполяция по среднему
// месячному балансу с начала года (не модель, честно подписано в тексте панели ниже) ----------
$is_current_year = $year === (int)date('Y');
$elapsed = $is_current_year ? $month : 12; // прошлый год — считаем законченным целиком
$ytd_balance = 0.0;
$running = [];
$running_balance = 0.0;
for ($m = 1; $m <= $elapsed; $m++) {
$running_balance += ($monthly[$m]['income'] ?? 0.0) - ($monthly[$m]['expense'] ?? 0.0);
$ytd_balance = $running_balance;
$running[] = $running_balance;
}
$avg_monthly = $elapsed > 0 ? $ytd_balance / $elapsed : 0.0;
$remaining = max(0, 12 - $elapsed);
$forecast_total = $ytd_balance + $avg_monthly * $remaining;
$spark_max = $running !== [] ? max(array_map('abs', $running)) : 0.0;
$spark_points = [];
$spark_n = count($running);
foreach ($running as $i => $value) {
$x = $spark_n > 1 ? 10 + $i / ($spark_n - 1) * 220 : 10;
$y = $spark_max > 0 ? 30 - ($value / $spark_max * 25) : 30;
$spark_points[] = round($x, 1) . ',' . round($y, 1);
}
// ---------- панель 5: год к году по кварталам (баланс доход-расход) ----------
$quarterBalance = static function (array $monthly, int $quarter): float {
$sum = 0.0;
for ($m = ($quarter - 1) * 3 + 1; $m <= $quarter * 3; $m++) {
$sum += ($monthly[$m]['income'] ?? 0) - ($monthly[$m]['expense'] ?? 0);
}
return $sum;
};
$quarters = [];
for ($q = 1; $q <= 4; $q++) {
$quarters[$q] = ['prev' => $quarterBalance($monthly_prev, $q), 'cur' => $quarterBalance($monthly, $q)];
}
$quarter_max = 1.0;
foreach ($quarters as $q) {
$quarter_max = max($quarter_max, abs($q['prev']), abs($q['cur']));
}
?>
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;margin-bottom:20px">
<h1 style="margin:0;font-size:24px">Аналитика</h1>
<div style="display:flex;align-items:center;gap:8px">
<a href="/analytics?year=<?= $prev_year ?>" class="btn btn-secondary btn-icon" style="width:32px;height:32px;padding:0;line-height:32px;text-align:center;text-decoration:none">‹</a>
<span style="font-size:15px;font-weight:600;min-width:3.5rem;text-align:center"><?= $year ?></span>
<a href="/analytics?year=<?= $next_year ?>" class="btn btn-secondary btn-icon" style="width:32px;height:32px;padding:0;line-height:32px;text-align:center;text-decoration:none">›</a>
</div>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:14px">
<!-- 1. Доходы и расходы по месяцам -->
<div class="card elev-sm" style="padding:18px 20px">
<div class="card-kicker" style="font-size:10px">Год к дате</div>
<div class="card-title" style="font-size:15px;margin:2px 0 12px">Доходы и расходы по месяцам</div>
<?php if ($total_income == 0 && $total_expense == 0): ?>
<div style="color:var(--muted);font-size:13px">Нет данных за <?= $year ?> год</div>
<?php else: ?>
<svg viewBox="0 0 720 160" style="width:100%;height:auto">
<polyline points="<?= $income_points ?>" fill="none" stroke="var(--positive-text)" stroke-width="2"/>
<polyline points="<?= $expense_points ?>" fill="none" stroke="var(--muted)" stroke-width="2" stroke-dasharray="4 3"/>
</svg>
<div style="display:flex;gap:16px;margin-top:8px;font-size:11px;color:var(--muted)">
<span style="display:flex;align-items:center;gap:5px"><span style="width:8px;height:8px;border-radius:50%;background:var(--positive-text);display:inline-block"></span>Доходы</span>
<span style="display:flex;align-items:center;gap:5px"><span style="width:8px;height:8px;border-radius:50%;background:var(--muted);display:inline-block"></span>Расходы</span>
</div>
<?php endif; ?>
</div>
<!-- 2. Структура расходов -->
<div class="card elev-sm" style="padding:18px 20px">
<div class="card-kicker" style="font-size:10px"><?= $month_names_gen[$month] ?></div>
<div class="card-title" style="font-size:15px;margin:2px 0 12px">Структура расходов</div>
<?php if ($expense_cat_total <= 0): ?>
<div style="color:var(--muted);font-size:13px">Нет расходов за месяц</div>
<?php else: ?>
<?php
$gradient_parts = [];
$cursor_pct = 0.0;
$legend = [];
foreach ($expense_cats as $i => $cat) {
$pct = round((float)$cat->total / $expense_cat_total * 100);
$color = $cat_colors[$i % count($cat_colors)];
$gradient_parts[] = "$color {$cursor_pct}% " . ($cursor_pct + $pct) . '%';
$cursor_pct += $pct;
$legend[] = ['title' => $cat->title, 'pct' => $pct, 'color' => $color];
}
?>
<div style="display:flex;gap:18px;align-items:center">
<div style="width:96px;height:96px;border-radius:50%;flex-shrink:0;background:conic-gradient(<?= implode(', ', $gradient_parts) ?>)"></div>
<div style="display:flex;flex-direction:column;gap:6px;flex:1;min-width:0">
<?php foreach ($legend as $item): ?>
<div style="display:flex;justify-content:space-between;font-size:12px;gap:8px">
<span style="display:flex;align-items:center;gap:6px;min-width:0">
<span style="width:8px;height:8px;border-radius:2px;background:<?= $item['color'] ?>;flex-shrink:0"></span>
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><?= htmlspecialchars($item['title'], ENT_QUOTES, 'UTF-8') ?></span>
</span>
<span class="num" style="color:var(--muted);flex-shrink:0"><?= $item['pct'] ?>%</span>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
<!-- 3. План vs факт -->
<div class="card elev-sm" style="padding:18px 20px">
<div class="card-kicker" style="font-size:10px"><?= $month_names_gen[$month] ?></div>
<div class="card-title" style="font-size:15px;margin:2px 0 12px">План vs факт</div>
<?php if (empty($expense_cats)): ?>
<div style="color:var(--muted);font-size:13px">Нет расходов за месяц</div>
<?php else: ?>
<div style="display:flex;flex-direction:column;gap:10px">
<?php foreach ($expense_cats as $cat): ?>
<?php
$plan = $plan_month[(int)$cat->categorie_id] ?? 0.0;
$fact = (float)$cat->total;
$pct = $plan > 0 ? round($fact / $plan * 100) : ($fact > 0 ? 100 : 0);
$over = $plan > 0 && $fact > $plan;
?>
<div>
<div style="display:flex;justify-content:space-between;font-size:12px;margin-bottom:3px">
<span><?= htmlspecialchars($cat->title, ENT_QUOTES, 'UTF-8') ?></span>
<span class="num" style="color:<?= $over ? 'var(--red)' : 'var(--muted)' ?>"><?= number_format($fact, 0, ',', ' ') ?> ₽</span>
</div>
<div style="height:6px;border-radius:3px;background:var(--surface-3);overflow:hidden">
<div style="height:100%;width:<?= min(100, $pct) ?>%;border-radius:3px;background:<?= $over ? 'var(--red)' : 'var(--accent)' ?>"></div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<!-- 4. Тренд до конца года -->
<div class="card elev-sm" style="padding:18px 20px">
<div class="card-kicker" style="font-size:10px">Прогноз</div>
<div class="card-title" style="font-size:15px;margin:2px 0 12px">Тренд до конца года</div>
<?php if ($elapsed === 0 || $running === []): ?>
<div style="color:var(--muted);font-size:13px">Недостаточно данных для прогноза</div>
<?php else: ?>
<svg viewBox="0 0 240 60" style="width:140px;height:auto">
<polyline points="<?= implode(' ', $spark_points) ?>" fill="none" stroke="var(--accent)" stroke-width="2"/>
</svg>
<div style="font-size:12px;color:var(--ink-soft);margin-top:8px;line-height:1.5">
При текущем темпе к декабрю
<?= $forecast_total >= 0 ? 'останется' : 'не хватит' ?>
≈ <span class="num" style="color:var(--ink)"><?= number_format(abs($forecast_total), 0, ',', ' ') ?> ₽</span>
<?= $forecast_total >= 0 ? 'сверх текущих трат' : '' ?>
— грубая оценка по среднему темпу <?= $elapsed ?> прошедших <?= $remaining > 0 ? 'месяцев' : 'месяцев года' ?>, не модель.
</div>
<?php endif; ?>
</div>
<!-- 5. Сравнение периодов (год к году) -->
<div class="card elev-sm" style="padding:18px 20px">
<div class="card-kicker" style="font-size:10px">Год к году</div>
<div class="card-title" style="font-size:15px;margin:2px 0 12px">Сравнение периодов</div>
<div style="display:flex;align-items:flex-end;gap:14px;height:100px">
<?php foreach ($quarters as $q => $val): ?>
<?php
$h_prev = $quarter_max > 0 ? max(2, abs($val['prev']) / $quarter_max * 90) : 2;
$h_cur = $quarter_max > 0 ? max(2, abs($val['cur']) / $quarter_max * 90) : 2;
?>
<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px">
<div style="display:flex;align-items:flex-end;gap:3px;height:90px">
<div style="width:14px;height:<?= round($h_prev) ?>px;background:var(--color-accent-700);border-radius:2px 2px 0 0"></div>
<div style="width:14px;height:<?= round($h_cur) ?>px;background:var(--color-accent-400);border-radius:2px 2px 0 0"></div>
</div>
<span style="font-size:11px;color:var(--muted)">Q<?= $q ?></span>
</div>
<?php endforeach; ?>
</div>
<div style="display:flex;gap:16px;margin-top:8px;font-size:11px;color:var(--muted)">
<span style="display:flex;align-items:center;gap:5px"><span style="width:8px;height:8px;border-radius:2px;background:var(--color-accent-700);display:inline-block"></span><?= $prev_year ?></span>
<span style="display:flex;align-items:center;gap:5px"><span style="width:8px;height:8px;border-radius:2px;background:var(--color-accent-400);display:inline-block"></span><?= $year ?></span>
</div>
</div>
</div>