236 lines
14 KiB
HTML
236 lines
14 KiB
HTML
<?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>
|