51ee4ecb5a
Finished the planner end to end. Corrected rollover and yearly accounting, recurring targets, savings rules, and targeted dormant restorations. Added editing/deletion for income, expenses, targets, and categories. Rebuilt dashboard, analytics, calendar, settings, exports, authentication, and responsive mobile UI. Added original artwork at [financial-flow.png](/Users/tyemeclifford/Documents/GH/budget/public/assets/images/financial-flow.png), generated with built-in Imagegen using an abstract financial-momentum landscape prompt. Added [self-test.php](/Users/tyemeclifford/Documents/GH/budget/scripts/self-test.php). Verification passed: automated accounting/export tests, full browser workflow, responsive 390px layout, light/dark themes, and no console errors. Tested with FrankenPHP v1.12.4. Live Mailgun delivery was not attempted without credentials.
275 lines
20 KiB
PHP
275 lines
20 KiB
PHP
<?php
|
||
$monthLabel = date('F Y', strtotime($month . '-01'));
|
||
$daysInMonth = (int) date('t', strtotime($month . '-01'));
|
||
$currentMonth = date('Y-m');
|
||
$elapsedDays = $month < $currentMonth ? $daysInMonth : ($month > $currentMonth ? 0 : min($daysInMonth, (int) date('j')));
|
||
$forecast = $elapsedDays > 0 ? $summary['expenses'] / $elapsedDays * $daysInMonth : 0;
|
||
$spentPercent = $summary['gross_resources'] > 0 ? min(100, max(0, $summary['expenses'] / $summary['gross_resources'] * 100)) : 0;
|
||
$remainingPercent = $summary['gross_resources'] > 0 ? min(100, max(0, $summary['remaining'] / $summary['gross_resources'] * 100)) : 0;
|
||
$remainingDelta = $summary['remaining'] - $previousSummary['remaining'];
|
||
$incomeRule = (string) $settings->get('dead.income_mode', 'percent');
|
||
$incomeRuleValue = (float) $settings->get('dead.income_value', 0);
|
||
$transactionRule = (string) $settings->get('dead.transaction_mode', 'fixed');
|
||
$transactionRuleValue = (float) $settings->get('dead.transaction_value', 0);
|
||
?>
|
||
<section class="overview-hero card">
|
||
<div class="hero-art" aria-hidden="true"></div>
|
||
<div class="hero-copy">
|
||
<div class="status-line">
|
||
<span class="live-dot"></span>
|
||
<span><?= h($monthLabel) ?> plan</span>
|
||
<span class="status-divider"></span>
|
||
<span><?= $plan['rolling_enabled'] ? 'Rolling budget on' : 'Manual rollover' ?></span>
|
||
</div>
|
||
<p class="hero-label">Spendable balance</p>
|
||
<div class="hero-value <?= $summary['remaining'] >= 0 ? '' : 'negative' ?>"><?= h(Support::money($summary['remaining'], $symbol)) ?></div>
|
||
<p class="hero-summary">
|
||
<?= h(Support::money($summary['gross_resources'], $symbol)) ?> available
|
||
<span>·</span>
|
||
<?= number_format($remainingPercent, 0) ?>% remains
|
||
</p>
|
||
<div class="hero-actions">
|
||
<a class="button primary" href="<?= h(url('transactions', ['month' => $month, 'open' => 'expense-dialog'])) ?>"><?= icon('plus') ?> Record expense</a>
|
||
<button class="button glass" type="button" data-dialog-open="plan-dialog"><?= icon('wallet') ?> Edit monthly plan</button>
|
||
</div>
|
||
</div>
|
||
<div class="hero-gauge">
|
||
<div class="gauge-ring" style="--progress:<?= h((string) $remainingPercent) ?>">
|
||
<span><?= number_format($remainingPercent, 0) ?>%</span>
|
||
<small>remaining</small>
|
||
</div>
|
||
<div class="gauge-note">
|
||
<span>Month forecast</span>
|
||
<strong><?= h(Support::money($forecast, $symbol)) ?></strong>
|
||
<small><?= $elapsedDays === 0 ? 'Begins when spending is recorded' : 'At the current daily pace' ?></small>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="metric-grid">
|
||
<article class="metric-card card">
|
||
<div class="metric-top"><span class="metric-icon green"><?= icon('wallet') ?></span><span class="trend-tag">Fresh funds</span></div>
|
||
<p>Income this month</p>
|
||
<strong><?= h(Support::money($summary['fresh_income'], $symbol)) ?></strong>
|
||
<small><?= h(Support::money($summary['starting_income'], $symbol)) ?> starting + <?= h(Support::money($summary['additional_income'], $symbol)) ?> added</small>
|
||
</article>
|
||
<article class="metric-card card">
|
||
<div class="metric-top"><span class="metric-icon pink"><?= icon('expense') ?></span><span class="trend-tag <?= $spentPercent > 80 ? 'warning' : '' ?>"><?= number_format($spentPercent, 0) ?>% used</span></div>
|
||
<p>Total spent</p>
|
||
<strong><?= h(Support::money($summary['expenses'], $symbol)) ?></strong>
|
||
<small><?= count($items) ?> target<?= count($items) === 1 ? '' : 's' ?> · <?= h(Support::money($summary['planned'], $symbol)) ?> planned</small>
|
||
</article>
|
||
<article class="metric-card card">
|
||
<div class="metric-top"><span class="metric-icon purple"><?= icon('vault') ?></span><span class="trend-tag"><?= number_format($summary['savings_rate'], 1) ?>% rate</span></div>
|
||
<p>Moved dormant</p>
|
||
<strong><?= h(Support::money($summary['dead_deposits'], $symbol)) ?></strong>
|
||
<small><?= h(Support::money($summary['dead_restorations'], $symbol)) ?> restored this month</small>
|
||
</article>
|
||
<article class="metric-card card">
|
||
<div class="metric-top"><span class="metric-icon blue"><?= icon('data') ?></span><span class="trend-tag <?= $remainingDelta < 0 ? 'negative' : '' ?>"><?= $remainingDelta >= 0 ? '+' : '' ?><?= h(Support::money($remainingDelta, $symbol)) ?></span></div>
|
||
<p>Previous remainder</p>
|
||
<strong><?= h(Support::money($summary['rollover_in'], $symbol)) ?></strong>
|
||
<small>Compared with <?= h(date('F', strtotime($month . '-01 -1 month'))) ?>’s closing balance</small>
|
||
</article>
|
||
</section>
|
||
|
||
<section class="dashboard-layout">
|
||
<div class="dashboard-main stack">
|
||
<article class="card panel">
|
||
<div class="card-heading">
|
||
<div><p class="eyebrow">Progressional plan</p><h2>Budget targets</h2><p class="heading-note">Payments reduce the amount still owing in real time.</p></div>
|
||
<button class="button small primary" type="button" data-dialog-open="item-dialog"><?= icon('plus') ?> New target</button>
|
||
</div>
|
||
<?php if ($items === []): ?>
|
||
<div class="empty-state"><span class="empty-icon"><?= icon('chart') ?></span><strong>Build your first target</strong><p>Add rent, internet, groceries, or any amount you want to track.</p><button class="button small" type="button" data-dialog-open="item-dialog">Add a budget target</button></div>
|
||
<?php else: ?>
|
||
<div class="budget-list">
|
||
<?php foreach ($items as $item): ?>
|
||
<div class="budget-row">
|
||
<div class="budget-identity">
|
||
<span class="category-mark" style="--dot:<?= h($item['category_color'] ?: '#718096') ?>"><?= h(strtoupper(substr($item['name'], 0, 1))) ?></span>
|
||
<div><strong><?= h($item['name']) ?></strong><span><?= h($item['category_name'] ?: 'Uncategorized') ?><?php if ($item['due_date']): ?> · due <?= h(date('M j', strtotime($item['due_date']))) ?><?php endif; ?></span></div>
|
||
</div>
|
||
<div class="budget-progress">
|
||
<div class="progress-labels"><span><?= number_format((float) $item['progress'], 0) ?>% paid</span><strong><?= h(Support::money($item['remaining_amount'], $symbol)) ?> left</strong></div>
|
||
<div class="progress-track"><span style="width:<?= h((string) $item['progress']) ?>%;--bar:<?= h($item['category_color'] ?: '#7c5cff') ?>"></span></div>
|
||
<small><?= h(Support::money($item['paid_amount'], $symbol)) ?> of <?= h(Support::money($item['planned_amount'], $symbol)) ?></small>
|
||
</div>
|
||
<div class="row-actions">
|
||
<button class="icon-button" type="button" data-dialog-open="item-edit-<?= (int) $item['id'] ?>" aria-label="Edit <?= h($item['name']) ?>"><?= icon('edit') ?></button>
|
||
<form method="post" data-confirm="Remove this budget target? Existing expenses will remain.">
|
||
<?= csrf_field() ?><input type="hidden" name="action" value="item.delete"><input type="hidden" name="month" value="<?= h($month) ?>"><input type="hidden" name="id" value="<?= (int) $item['id'] ?>">
|
||
<button class="icon-button danger" type="submit" aria-label="Delete <?= h($item['name']) ?>"><?= icon('trash') ?></button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
</article>
|
||
|
||
<article class="card panel chart-panel">
|
||
<div class="card-heading">
|
||
<div><p class="eyebrow">Comparative movement</p><h2>Six-month cash flow</h2><p class="heading-note">Resources include rollover for each individual month.</p></div>
|
||
<a class="text-link" href="<?= h(url('comparisons', ['month' => $month])) ?>">Open analytics <span>→</span></a>
|
||
</div>
|
||
<canvas class="trend-chart" data-trend-chart='<?= h(json_encode($comparison)) ?>' data-symbol="<?= h($symbol) ?>" height="255"></canvas>
|
||
</article>
|
||
|
||
<article class="card panel">
|
||
<div class="card-heading">
|
||
<div><p class="eyebrow">Cash activity</p><h2>Recent movement</h2></div>
|
||
<a class="text-link" href="<?= h(url('transactions', ['month' => $month])) ?>">All transactions <span>→</span></a>
|
||
</div>
|
||
<?php if ($recentActivity === []): ?>
|
||
<div class="empty-state compact"><strong>No movement yet</strong><p>Income, expenses, and dormant transfers will collect here.</p></div>
|
||
<?php else: ?>
|
||
<div class="activity-list">
|
||
<?php foreach ($recentActivity as $activity): ?>
|
||
<div class="activity-row">
|
||
<span class="activity-icon <?= h($activity['type']) ?>"><?= icon($activity['type'] === 'expense' ? 'expense' : ($activity['type'] === 'saved' ? 'vault' : 'wallet')) ?></span>
|
||
<div><strong><?= h($activity['title']) ?></strong><small><?= h($activity['detail']) ?> · <?= h(date('M j', strtotime($activity['date']))) ?></small></div>
|
||
<b class="<?= $activity['amount'] >= 0 ? 'green' : '' ?>"><?= $activity['amount'] >= 0 ? '+' : '-' ?><?= h(Support::money(abs($activity['amount']), $symbol)) ?></b>
|
||
</div>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
</article>
|
||
</div>
|
||
|
||
<aside class="dashboard-side stack">
|
||
<article class="card panel plan-card">
|
||
<div class="card-heading">
|
||
<div><p class="eyebrow">Plan health</p><h2><?= h($monthLabel) ?></h2></div>
|
||
<button class="icon-button" type="button" data-dialog-open="plan-dialog" aria-label="Edit monthly plan"><?= icon('edit') ?></button>
|
||
</div>
|
||
<div class="health-score">
|
||
<span style="--score:<?= h((string) min(100, max(0, $summary['budget_coverage']))) ?>"><?= number_format($summary['budget_coverage'], 0) ?>%</span>
|
||
<div><strong>Target coverage</strong><small>Spendable funds compared with planned targets</small></div>
|
||
</div>
|
||
<dl class="plan-facts">
|
||
<div><dt>Starting point</dt><dd><?= h(Support::money($summary['starting_income'], $symbol)) ?></dd></div>
|
||
<div><dt>Rollover field</dt><dd><?= h(Support::money($summary['rollover_in'], $symbol)) ?></dd></div>
|
||
<div><dt>Still unassigned</dt><dd class="<?= $summary['unallocated'] < 0 ? 'red' : '' ?>"><?= h(Support::money($summary['unallocated'], $symbol)) ?></dd></div>
|
||
</dl>
|
||
<form method="post" class="rollover-refresh">
|
||
<?= csrf_field() ?><input type="hidden" name="action" value="plan.refresh_rollover"><input type="hidden" name="month" value="<?= h($month) ?>">
|
||
<button class="text-link" type="submit">Refresh from <?= h(date('F', strtotime($month . '-01 -1 month'))) ?> <span>↻</span></button>
|
||
</form>
|
||
<div class="rule-summary">
|
||
<span><?= icon('vault') ?></span>
|
||
<p><strong>Automatic set-aside</strong><small><?= h((string) $incomeRuleValue) ?><?= $incomeRule === 'percent' ? '% of income' : ' per income' ?> · <?= h((string) $transactionRuleValue) ?><?= $transactionRule === 'percent' ? '% per expense' : ' per expense' ?></small></p>
|
||
</div>
|
||
</article>
|
||
|
||
<article class="card panel">
|
||
<div class="card-heading">
|
||
<div><p class="eyebrow">Coming up</p><h2>Next bills</h2></div>
|
||
<a class="text-link" href="<?= h(url('calendar', ['month' => $month])) ?>">Calendar <span>→</span></a>
|
||
</div>
|
||
<?php if ($upcomingBills === []): ?>
|
||
<div class="empty-state compact"><strong>Nothing due</strong><p>Add due dates to targets to build the bill queue.</p></div>
|
||
<?php else: ?>
|
||
<div class="due-list">
|
||
<?php foreach ($upcomingBills as $bill): ?>
|
||
<?php $days = (int) floor((strtotime($bill['due_date']) - strtotime(date('Y-m-d'))) / 86400); ?>
|
||
<div>
|
||
<span class="date-tile"><b><?= h(date('d', strtotime($bill['due_date']))) ?></b><small><?= h(strtoupper(date('M', strtotime($bill['due_date'])))) ?></small></span>
|
||
<p><strong><?= h($bill['name']) ?></strong><small><?= h($bill['category_name'] ?: 'Uncategorized') ?> · <?= $days < 0 ? abs($days) . 'd overdue' : ($days === 0 ? 'due today' : 'in ' . $days . 'd') ?></small></p>
|
||
<b><?= h(Support::money($bill['remaining_amount'], $symbol)) ?></b>
|
||
</div>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
</article>
|
||
|
||
<?php if ($settings->module('income')): ?>
|
||
<article class="card panel">
|
||
<div class="card-heading">
|
||
<div><p class="eyebrow">Income stream</p><h2>Additional income</h2></div>
|
||
<button class="button small" type="button" data-dialog-open="income-dialog"><?= icon('plus') ?> Add</button>
|
||
</div>
|
||
<?php if ($incomes === []): ?>
|
||
<div class="empty-state compact"><strong>No additional income</strong><p>Your manual starting point still counts as the base income.</p></div>
|
||
<?php else: ?>
|
||
<div class="income-list">
|
||
<?php foreach (array_slice($incomes, 0, 5) as $income): ?>
|
||
<div>
|
||
<span class="activity-icon income"><?= icon('wallet') ?></span>
|
||
<p><strong><?= h($income['label']) ?></strong><small><?= h(date('M j', strtotime($income['received_on']))) ?> · <?= h(Support::money($income['withholding_amount'], $symbol)) ?> set aside</small></p>
|
||
<b>+<?= h(Support::money($income['amount'], $symbol)) ?></b>
|
||
<button class="micro-button" type="button" data-dialog-open="income-edit-<?= (int) $income['id'] ?>" aria-label="Edit <?= h($income['label']) ?>"><?= icon('edit') ?></button>
|
||
</div>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
</article>
|
||
<?php endif; ?>
|
||
|
||
<article class="usage-card card">
|
||
<p class="eyebrow">Private usage stats</p>
|
||
<div><span><strong><?= (int) $visitorStats['today'] ?></strong><small>today</small></span><span><strong><?= (int) $visitorStats['thirty_days'] ?></strong><small>30-day visitors</small></span><span><strong><?= (int) $visitorStats['views'] ?></strong><small>page views</small></span></div>
|
||
</article>
|
||
</aside>
|
||
</section>
|
||
|
||
<dialog id="plan-dialog">
|
||
<form method="post" class="dialog-card">
|
||
<?= csrf_field() ?><input type="hidden" name="action" value="plan.save"><input type="hidden" name="month" value="<?= h($month) ?>">
|
||
<div class="dialog-heading"><div><p class="eyebrow">Monthly foundation</p><h2>Edit <?= h($monthLabel) ?> plan</h2></div><button class="icon-button" type="button" data-dialog-close><?= icon('close') ?></button></div>
|
||
<div class="form-grid">
|
||
<label>Manual starting income<div class="money-input"><span><?= h($symbol) ?></span><input type="number" name="starting_income" min="0" step="0.01" value="<?= h((string) $plan['starting_income']) ?>"></div></label>
|
||
<label>Previous-month remainder<div class="money-input"><span><?= h($symbol) ?></span><input type="number" name="rollover_in" min="0" step="0.01" value="<?= h((string) $plan['rollover_in']) ?>"></div></label>
|
||
<label class="check-row span-2"><input type="checkbox" name="rolling_enabled" value="1" <?= $plan['rolling_enabled'] ? 'checked' : '' ?>><span><strong>Carry positive remainder forward</strong><small>The next month can use any money left at close.</small></span></label>
|
||
<label class="span-2">Monthly note<textarea name="notes" rows="3" placeholder="Intent, constraints, or focus for this month"><?= h($plan['notes']) ?></textarea></label>
|
||
</div>
|
||
<div class="dialog-actions"><button class="button" type="button" data-dialog-close>Cancel</button><button class="button primary" type="submit">Save monthly plan</button></div>
|
||
</form>
|
||
</dialog>
|
||
|
||
<dialog id="item-dialog">
|
||
<form method="post" class="dialog-card">
|
||
<?= csrf_field() ?><input type="hidden" name="action" value="item.add"><input type="hidden" name="month" value="<?= h($month) ?>">
|
||
<div class="dialog-heading"><div><p class="eyebrow">Progressional target</p><h2>Add budget item</h2></div><button class="icon-button" type="button" data-dialog-close><?= icon('close') ?></button></div>
|
||
<?php require APP_ROOT . '/views/partials/item-fields.php'; ?>
|
||
<div class="dialog-actions"><button class="button" type="button" data-dialog-close>Cancel</button><button class="button primary" type="submit">Add target</button></div>
|
||
</form>
|
||
</dialog>
|
||
|
||
<?php foreach ($items as $item): ?>
|
||
<dialog id="item-edit-<?= (int) $item['id'] ?>">
|
||
<form method="post" class="dialog-card">
|
||
<?= csrf_field() ?><input type="hidden" name="action" value="item.update"><input type="hidden" name="month" value="<?= h($month) ?>"><input type="hidden" name="id" value="<?= (int) $item['id'] ?>">
|
||
<div class="dialog-heading"><div><p class="eyebrow">Maintain target</p><h2>Edit <?= h($item['name']) ?></h2></div><button class="icon-button" type="button" data-dialog-close><?= icon('close') ?></button></div>
|
||
<?php $editingItem = $item; require APP_ROOT . '/views/partials/item-fields.php'; unset($editingItem); ?>
|
||
<p class="form-note">Changes apply to this month. Future recurring copies use the latest saved version.</p>
|
||
<div class="dialog-actions"><button class="button" type="button" data-dialog-close>Cancel</button><button class="button primary" type="submit">Save target</button></div>
|
||
</form>
|
||
</dialog>
|
||
<?php endforeach; ?>
|
||
|
||
<dialog id="income-dialog">
|
||
<form method="post" class="dialog-card">
|
||
<?= csrf_field() ?><input type="hidden" name="action" value="income.add"><input type="hidden" name="month" value="<?= h($month) ?>">
|
||
<div class="dialog-heading"><div><p class="eyebrow">Beyond the starting point</p><h2>Add income</h2></div><button class="icon-button" type="button" data-dialog-close><?= icon('close') ?></button></div>
|
||
<?php require APP_ROOT . '/views/partials/income-fields.php'; ?>
|
||
<p class="form-note">The configured withholding rule is applied automatically.</p>
|
||
<div class="dialog-actions"><button class="button" type="button" data-dialog-close>Cancel</button><button class="button primary" type="submit">Record income</button></div>
|
||
</form>
|
||
</dialog>
|
||
|
||
<?php foreach ($incomes as $income): ?>
|
||
<dialog id="income-edit-<?= (int) $income['id'] ?>">
|
||
<form method="post" class="dialog-card">
|
||
<?= csrf_field() ?><input type="hidden" name="action" value="income.update"><input type="hidden" name="month" value="<?= h($month) ?>"><input type="hidden" name="id" value="<?= (int) $income['id'] ?>">
|
||
<div class="dialog-heading"><div><p class="eyebrow">Maintain income</p><h2>Edit <?= h($income['label']) ?></h2></div><button class="icon-button" type="button" data-dialog-close><?= icon('close') ?></button></div>
|
||
<?php $editingIncome = $income; require APP_ROOT . '/views/partials/income-fields.php'; unset($editingIncome); ?>
|
||
<div class="dialog-actions">
|
||
<button class="text-link danger-link push-left" type="submit" name="action" value="income.delete" formnovalidate data-confirm-button="Delete this income entry and reverse its automatic withholding?"><?= icon('trash') ?> Delete</button>
|
||
<button class="button" type="button" data-dialog-close>Cancel</button>
|
||
<button class="button primary" type="submit">Save income</button>
|
||
</div>
|
||
</form>
|
||
</dialog>
|
||
<?php endforeach; ?>
|