Add Cashflow Analytics Feature (#49)
Summary - Add new Cashflow page with income/expense analytics, Sankey chart visualization, and trend analysis - Add Cashflow summary widget to the dashboard - Implement feature flag system using Laravel Pennant to control feature visibility per user - Add artisan commands to enable/disable features: `php artisan feature:enable/disable [feature] [email|all]` ## New Features - **Cashflow Page**: Period-based income/expense breakdown with category analysis - **Sankey Chart**: Visual flow diagram showing money movement between categories - **Trend Chart**: 12-month historical cashflow visualization - **Dashboard Widget**: Quick cashflow summary card - **Feature Flags**: Cashflow feature hidden behind feature flag, controllable per-user ## How to enable it This feature it's under a feature flag until we are sure it's working fine. If you want to test it, [send us a message on the general channel in our discord](https://discord.gg/9UQWZECDDv). If you're self hosting just run `php artisan feature:enable CashflowFeature {your_email}`. ## New Cashflow Page <img width="1278" height="1185" alt="image" src="https://github.com/user-attachments/assets/83469f24-9f96-4e5f-bef9-86c076d31243" /> <img width="1274" height="1035" alt="image" src="https://github.com/user-attachments/assets/8949ff9d-c370-4fc4-81c6-268307c587a5" /> ## New Dashboard Widget <img width="1180" height="289" alt="image" src="https://github.com/user-attachments/assets/ad984a66-9ffd-4079-85ce-0b2d28278d76" /> <img width="1179" height="286" alt="image" src="https://github.com/user-attachments/assets/bff60286-8ea2-4e9c-8fa6-22755566648f" />
This commit is contained in:
parent
2634a501b5
commit
1cec4a6421
|
|
@ -0,0 +1,37 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands\Concerns;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
|
||||||
|
trait ResolvesFeatures
|
||||||
|
{
|
||||||
|
private function resolveFeatureClass(string $name): ?string
|
||||||
|
{
|
||||||
|
$featureClass = "App\\Features\\{$name}";
|
||||||
|
|
||||||
|
if (class_exists($featureClass)) {
|
||||||
|
return $featureClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getAvailableFeatures(): string
|
||||||
|
{
|
||||||
|
$featuresPath = app_path('Features');
|
||||||
|
|
||||||
|
if (! File::isDirectory($featuresPath)) {
|
||||||
|
return 'None';
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = File::files($featuresPath);
|
||||||
|
$features = [];
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$features[] = $file->getFilenameWithoutExtension();
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(', ', $features) ?: 'None';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Console\Commands\Concerns\ResolvesFeatures;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Laravel\Pennant\Feature;
|
||||||
|
|
||||||
|
class FeatureDisableCommand extends Command
|
||||||
|
{
|
||||||
|
use ResolvesFeatures;
|
||||||
|
|
||||||
|
protected $signature = 'feature:disable {feature : The feature class name (e.g., CashflowFeature)} {target : User email or "all" for everyone}';
|
||||||
|
|
||||||
|
protected $description = 'Disable a feature for a specific user or all users';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$featureName = $this->argument('feature');
|
||||||
|
$target = $this->argument('target');
|
||||||
|
|
||||||
|
$featureClass = $this->resolveFeatureClass($featureName);
|
||||||
|
|
||||||
|
if (! $featureClass) {
|
||||||
|
$this->error("Feature '{$featureName}' not found.");
|
||||||
|
$this->line('Available features: '.$this->getAvailableFeatures());
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($target === 'all') {
|
||||||
|
Feature::deactivateForEveryone($featureClass);
|
||||||
|
$this->info("Feature '{$featureName}' disabled for all users.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = User::where('email', $target)->first();
|
||||||
|
|
||||||
|
if (! $user) {
|
||||||
|
$this->error("User with email '{$target}' not found.");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
Feature::for($user)->deactivate($featureClass);
|
||||||
|
$this->info("Feature '{$featureName}' disabled for user '{$user->email}'.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Console\Commands\Concerns\ResolvesFeatures;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Laravel\Pennant\Feature;
|
||||||
|
|
||||||
|
class FeatureEnableCommand extends Command
|
||||||
|
{
|
||||||
|
use ResolvesFeatures;
|
||||||
|
|
||||||
|
protected $signature = 'feature:enable {feature : The feature class name (e.g., CashflowFeature)} {target : User email or "all" for everyone}';
|
||||||
|
|
||||||
|
protected $description = 'Enable a feature for a specific user or all users';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$featureName = $this->argument('feature');
|
||||||
|
$target = $this->argument('target');
|
||||||
|
|
||||||
|
$featureClass = $this->resolveFeatureClass($featureName);
|
||||||
|
|
||||||
|
if (! $featureClass) {
|
||||||
|
$this->error("Feature '{$featureName}' not found.");
|
||||||
|
$this->line('Available features: '.$this->getAvailableFeatures());
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($target === 'all') {
|
||||||
|
Feature::activateForEveryone($featureClass);
|
||||||
|
$this->info("Feature '{$featureName}' enabled for all users.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = User::where('email', $target)->first();
|
||||||
|
|
||||||
|
if (! $user) {
|
||||||
|
$this->error("User with email '{$target}' not found.");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
Feature::for($user)->activate($featureClass);
|
||||||
|
$this->info("Feature '{$featureName}' enabled for user '{$user->email}'.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Features;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class CashflowFeature
|
||||||
|
{
|
||||||
|
public function resolve(User $user): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,216 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Enums\CategoryType;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Transaction;
|
||||||
|
use App\Services\PeriodComparator;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class CashflowAnalyticsController extends Controller
|
||||||
|
{
|
||||||
|
public function summary(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'from' => 'required|date',
|
||||||
|
'to' => 'required|date',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$period = PeriodComparator::fromRequest($validated);
|
||||||
|
$previousPeriod = $period->previous();
|
||||||
|
|
||||||
|
$current = $this->calculateCashflowSummary($request->user()->id, $period->from, $period->to);
|
||||||
|
$previous = $this->calculateCashflowSummary($request->user()->id, $previousPeriod->from, $previousPeriod->to);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'current' => $current,
|
||||||
|
'previous' => $previous,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sankey(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'from' => 'required|date',
|
||||||
|
'to' => 'required|date',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$from = Carbon::parse($validated['from']);
|
||||||
|
$to = Carbon::parse($validated['to']);
|
||||||
|
$userId = $request->user()->id;
|
||||||
|
|
||||||
|
$incomeCategories = $this->getCategoryBreakdown($userId, $from, $to, CategoryType::Income);
|
||||||
|
$expenseCategories = $this->getCategoryBreakdown($userId, $from, $to, CategoryType::Expense);
|
||||||
|
|
||||||
|
$totalIncome = $incomeCategories->sum('amount');
|
||||||
|
$totalExpense = $expenseCategories->sum('amount');
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'income_categories' => $incomeCategories->values(),
|
||||||
|
'expense_categories' => $expenseCategories->values(),
|
||||||
|
'total_income' => $totalIncome,
|
||||||
|
'total_expense' => $totalExpense,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function trend(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'months' => 'nullable|integer|min:1|max:24',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$months = $validated['months'] ?? 12;
|
||||||
|
$userId = $request->user()->id;
|
||||||
|
|
||||||
|
$end = Carbon::now()->endOfMonth();
|
||||||
|
$start = Carbon::now()->subMonths($months - 1)->startOfMonth();
|
||||||
|
|
||||||
|
$data = [];
|
||||||
|
$current = $start->copy();
|
||||||
|
|
||||||
|
while ($current->lte($end)) {
|
||||||
|
$monthStart = $current->copy()->startOfMonth();
|
||||||
|
$monthEnd = $current->copy()->endOfMonth();
|
||||||
|
|
||||||
|
$income = $this->getTransactionSum($userId, $monthStart, $monthEnd, CategoryType::Income);
|
||||||
|
$expense = $this->getTransactionSum($userId, $monthStart, $monthEnd, CategoryType::Expense);
|
||||||
|
|
||||||
|
$data[] = [
|
||||||
|
'month' => $current->format('Y-m'),
|
||||||
|
'income' => $income,
|
||||||
|
'expense' => abs($expense),
|
||||||
|
'net' => $income + $expense, // expense is negative, so this gives net
|
||||||
|
];
|
||||||
|
|
||||||
|
$current->addMonth();
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $data,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function breakdown(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'from' => 'required|date',
|
||||||
|
'to' => 'required|date',
|
||||||
|
'type' => 'required|in:income,expense',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$period = PeriodComparator::fromRequest($validated);
|
||||||
|
$previousPeriod = $period->previous();
|
||||||
|
$userId = $request->user()->id;
|
||||||
|
|
||||||
|
$categoryType = $validated['type'] === 'income' ? CategoryType::Income : CategoryType::Expense;
|
||||||
|
|
||||||
|
$current = $this->getCategoryBreakdown($userId, $period->from, $period->to, $categoryType);
|
||||||
|
$previous = $this->getCategoryBreakdown($userId, $previousPeriod->from, $previousPeriod->to, $categoryType);
|
||||||
|
|
||||||
|
$currentTotal = $current->sum('amount');
|
||||||
|
$previousTotal = $previous->sum('amount');
|
||||||
|
|
||||||
|
// Add percentage and previous amount to current
|
||||||
|
$currentWithPercentage = $current->map(function ($item) use ($currentTotal, $previous) {
|
||||||
|
$previousAmount = $previous->firstWhere('category_id', $item['category_id'])['amount'] ?? 0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'category' => $item['category'],
|
||||||
|
'category_id' => $item['category_id'],
|
||||||
|
'amount' => $item['amount'],
|
||||||
|
'percentage' => $currentTotal > 0 ? round(($item['amount'] / $currentTotal) * 100, 1) : 0,
|
||||||
|
'previous_amount' => $previousAmount,
|
||||||
|
];
|
||||||
|
})->sortByDesc('amount')->values();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $currentWithPercentage,
|
||||||
|
'total' => $currentTotal,
|
||||||
|
'previous_total' => $previousTotal,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function calculateCashflowSummary(string $userId, Carbon $from, Carbon $to): array
|
||||||
|
{
|
||||||
|
$income = $this->getTransactionSum($userId, $from, $to, CategoryType::Income);
|
||||||
|
$expense = abs($this->getTransactionSum($userId, $from, $to, CategoryType::Expense));
|
||||||
|
|
||||||
|
$net = $income - $expense;
|
||||||
|
$savingsRate = $income > 0 ? round((($income - $expense) / $income) * 100, 1) : 0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'income' => $income,
|
||||||
|
'expense' => $expense,
|
||||||
|
'net' => $net,
|
||||||
|
'savings_rate' => $savingsRate,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getTransactionSum(string $userId, Carbon $from, Carbon $to, CategoryType $type): int
|
||||||
|
{
|
||||||
|
return Transaction::query()
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->whereBetween('transaction_date', [$from, $to])
|
||||||
|
->where(function ($q) use ($type) {
|
||||||
|
$q->whereHas('category', function ($q) use ($type) {
|
||||||
|
$q->where('type', $type);
|
||||||
|
})
|
||||||
|
->orWhere(function ($q) use ($type) {
|
||||||
|
$q->whereNull('category_id')
|
||||||
|
->where('amount', $type === CategoryType::Income ? '>' : '<', 0);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->sum('amount');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getCategoryBreakdown(string $userId, Carbon $from, Carbon $to, CategoryType $type)
|
||||||
|
{
|
||||||
|
// Get categorized transactions
|
||||||
|
$categorized = Transaction::query()
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->whereBetween('transaction_date', [$from, $to])
|
||||||
|
->whereHas('category', function ($q) use ($type) {
|
||||||
|
$q->where('type', $type);
|
||||||
|
})
|
||||||
|
->select('category_id', DB::raw('sum(amount) as total_amount'))
|
||||||
|
->groupBy('category_id')
|
||||||
|
->with('category')
|
||||||
|
->get()
|
||||||
|
->map(function ($item) {
|
||||||
|
return [
|
||||||
|
'category_id' => $item->category_id,
|
||||||
|
'category' => $item->category,
|
||||||
|
'amount' => abs($item->total_amount),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get uncategorized transactions
|
||||||
|
$uncategorized = Transaction::query()
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->whereBetween('transaction_date', [$from, $to])
|
||||||
|
->whereNull('category_id')
|
||||||
|
->where('amount', $type === CategoryType::Income ? '>' : '<', 0)
|
||||||
|
->sum('amount');
|
||||||
|
|
||||||
|
// Add uncategorized as a special category if there are any
|
||||||
|
if ($uncategorized != 0) {
|
||||||
|
$categorized->push([
|
||||||
|
'category_id' => null,
|
||||||
|
'category' => (object) [
|
||||||
|
'id' => null,
|
||||||
|
'name' => $type === CategoryType::Income ? 'Unknown Income' : 'Unknown Expense',
|
||||||
|
'type' => $type,
|
||||||
|
'color' => 'gray',
|
||||||
|
'icon' => 'HelpCircle',
|
||||||
|
],
|
||||||
|
'amount' => abs($uncategorized),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $categorized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Account;
|
||||||
|
use App\Models\Bank;
|
||||||
|
use App\Models\Category;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class CashflowController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request): Response
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
$categories = Category::query()
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->orderBy('name')
|
||||||
|
->get(['id', 'name', 'type', 'icon', 'color']);
|
||||||
|
|
||||||
|
$accounts = Account::query()
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->with('bank:id,name,logo')
|
||||||
|
->orderBy('name')
|
||||||
|
->get(['id', 'name', 'name_iv', 'bank_id', 'type', 'currency_code']);
|
||||||
|
|
||||||
|
$banks = Bank::query()
|
||||||
|
->where(function ($q) use ($user) {
|
||||||
|
$q->whereNull('user_id')
|
||||||
|
->orWhere('user_id', $user->id);
|
||||||
|
})
|
||||||
|
->orderBy('name')
|
||||||
|
->get(['id', 'name', 'logo']);
|
||||||
|
|
||||||
|
return Inertia::render('cashflow/index', [
|
||||||
|
'categories' => $categories,
|
||||||
|
'accounts' => $accounts,
|
||||||
|
'banks' => $banks,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,9 +2,11 @@
|
||||||
|
|
||||||
namespace App\Http\Middleware;
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Features\CashflowFeature;
|
||||||
use Illuminate\Foundation\Inspiring;
|
use Illuminate\Foundation\Inspiring;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Middleware;
|
use Inertia\Middleware;
|
||||||
|
use Laravel\Pennant\Feature;
|
||||||
|
|
||||||
class HandleInertiaRequests extends Middleware
|
class HandleInertiaRequests extends Middleware
|
||||||
{
|
{
|
||||||
|
|
@ -58,6 +60,9 @@ class HandleInertiaRequests extends Middleware
|
||||||
'promo' => config('subscriptions.promo', []),
|
'promo' => config('subscriptions.promo', []),
|
||||||
],
|
],
|
||||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||||
|
'features' => [
|
||||||
|
'cashflow' => $user ? Feature::for($user)->active(CashflowFeature::class) : false,
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
"laravel/cashier": "^16.1",
|
"laravel/cashier": "^16.1",
|
||||||
"laravel/fortify": "^1.30",
|
"laravel/fortify": "^1.30",
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
|
"laravel/pennant": "^1.18",
|
||||||
"laravel/tinker": "^2.10.1",
|
"laravel/tinker": "^2.10.1",
|
||||||
"laravel/wayfinder": "^0.1.9",
|
"laravel/wayfinder": "^0.1.9",
|
||||||
"resend/resend-php": "^1.1",
|
"resend/resend-php": "^1.1",
|
||||||
|
|
|
||||||
|
|
@ -1658,6 +1658,82 @@
|
||||||
},
|
},
|
||||||
"time": "2025-11-04T15:39:33+00:00"
|
"time": "2025-11-04T15:39:33+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "laravel/pennant",
|
||||||
|
"version": "v1.18.5",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/laravel/pennant.git",
|
||||||
|
"reference": "c7d824a46b6fa801925dd3b93470382bcc5b2b58"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/laravel/pennant/zipball/c7d824a46b6fa801925dd3b93470382bcc5b2b58",
|
||||||
|
"reference": "c7d824a46b6fa801925dd3b93470382bcc5b2b58",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"illuminate/console": "^10.0|^11.0|^12.0",
|
||||||
|
"illuminate/container": "^10.0|^11.0|^12.0",
|
||||||
|
"illuminate/contracts": "^10.0|^11.0|^12.0",
|
||||||
|
"illuminate/database": "^10.0|^11.0|^12.0",
|
||||||
|
"illuminate/queue": "^10.0|^11.0|^12.0",
|
||||||
|
"illuminate/support": "^10.0|^11.0|^12.0",
|
||||||
|
"php": "^8.1",
|
||||||
|
"symfony/console": "^6.0|^7.0",
|
||||||
|
"symfony/finder": "^6.0|^7.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"laravel/octane": "^1.4|^2.0",
|
||||||
|
"orchestra/testbench": "^8.36|^9.15|^10.8",
|
||||||
|
"phpstan/phpstan": "^1.10"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"aliases": {
|
||||||
|
"Feature": "Laravel\\Pennant\\Feature"
|
||||||
|
},
|
||||||
|
"providers": [
|
||||||
|
"Laravel\\Pennant\\PennantServiceProvider"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "1.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"src/helpers.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"Laravel\\Pennant\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Taylor Otwell",
|
||||||
|
"email": "taylor@laravel.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "A simple, lightweight library for managing feature flags.",
|
||||||
|
"homepage": "https://github.com/laravel/pennant",
|
||||||
|
"keywords": [
|
||||||
|
"feature",
|
||||||
|
"flags",
|
||||||
|
"laravel",
|
||||||
|
"pennant"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/laravel/pennant/issues",
|
||||||
|
"source": "https://github.com/laravel/pennant"
|
||||||
|
},
|
||||||
|
"time": "2025-11-27T16:22:11+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/prompts",
|
"name": "laravel/prompts",
|
||||||
"version": "v0.3.7",
|
"version": "v0.3.7",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Pennant Store
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you will specify the default store that Pennant should use when
|
||||||
|
| storing and resolving feature flag values. Pennant ships with the
|
||||||
|
| ability to store flag values in an in-memory array or database.
|
||||||
|
|
|
||||||
|
| Supported: "array", "database"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('PENNANT_STORE', 'database'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Pennant Stores
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure each of the stores that should be available to
|
||||||
|
| Pennant. These stores shall be used to store resolved feature flag
|
||||||
|
| values - you may configure as many as your application requires.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'stores' => [
|
||||||
|
|
||||||
|
'array' => [
|
||||||
|
'driver' => 'array',
|
||||||
|
],
|
||||||
|
|
||||||
|
'database' => [
|
||||||
|
'driver' => 'database',
|
||||||
|
'connection' => null,
|
||||||
|
'table' => 'features',
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Laravel\Pennant\Migrations\PennantMigration;
|
||||||
|
|
||||||
|
return new class extends PennantMigration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('features', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('scope');
|
||||||
|
$table->text('value');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['name', 'scope']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('features');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -11,13 +11,22 @@ import {
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from '@/components/ui/sidebar';
|
} from '@/components/ui/sidebar';
|
||||||
import { cn, resolveUrl } from '@/lib/utils';
|
import { cn, resolveUrl } from '@/lib/utils';
|
||||||
import { footerNavItems, mainNavItems } from '@/providers/menu-item-provider';
|
import {
|
||||||
|
footerNavItems,
|
||||||
|
getMainNavItems,
|
||||||
|
} from '@/providers/menu-item-provider';
|
||||||
import { dashboard } from '@/routes';
|
import { dashboard } from '@/routes';
|
||||||
|
import { SharedData } from '@/types';
|
||||||
import { Link, usePage } from '@inertiajs/react';
|
import { Link, usePage } from '@inertiajs/react';
|
||||||
|
import { useMemo } from 'react';
|
||||||
import AppLogo from './app-logo';
|
import AppLogo from './app-logo';
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
const page = usePage();
|
const page = usePage<SharedData>();
|
||||||
|
const mainNavItems = useMemo(
|
||||||
|
() => getMainNavItems(page.props.features),
|
||||||
|
[page.props.features],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
import { PercentageTrendIndicator } from '@/components/dashboard/percentage-trend-indicator';
|
||||||
|
import { AmountDisplay } from '@/components/ui/amount-display';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { BreakdownData } from '@/hooks/use-cashflow-data';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { getCategoryColorClasses } from '@/types/category';
|
||||||
|
import * as Icons from 'lucide-react';
|
||||||
|
import { LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
interface BreakdownCardProps {
|
||||||
|
type: 'income' | 'expense';
|
||||||
|
data: BreakdownData;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHART_COLORS = [
|
||||||
|
'var(--chart-1)',
|
||||||
|
'var(--chart-2)',
|
||||||
|
'var(--chart-3)',
|
||||||
|
'var(--chart-4)',
|
||||||
|
'var(--chart-5)',
|
||||||
|
'var(--chart-6)',
|
||||||
|
'var(--chart-7)',
|
||||||
|
'var(--chart-8)',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function BreakdownCard({ type, data, loading }: BreakdownCardProps) {
|
||||||
|
const title = type === 'income' ? 'Income Sources' : 'Expense Categories';
|
||||||
|
const description =
|
||||||
|
type === 'income'
|
||||||
|
? 'Where your money comes from'
|
||||||
|
: 'Where your money goes';
|
||||||
|
const emptyMessage =
|
||||||
|
type === 'income' ? 'No income this period' : 'No expenses this period';
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<div key={i} className="space-y-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="size-6 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700" />
|
||||||
|
<div className="h-4 w-24 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
||||||
|
<div className="ml-auto h-4 w-16 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
||||||
|
</div>
|
||||||
|
<div className="h-2 w-full animate-pulse rounded-full bg-gray-200 dark:bg-gray-700" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="gap-1 pb-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-base">{title}</CardTitle>
|
||||||
|
<AmountDisplay
|
||||||
|
amountInCents={data.total}
|
||||||
|
currencyCode="USD"
|
||||||
|
minimumFractionDigits={0}
|
||||||
|
maximumFractionDigits={0}
|
||||||
|
weight="semibold"
|
||||||
|
highlightPositive
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<CardDescription>{description}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{data.data.map((item, index) => {
|
||||||
|
const Icon = (Icons[
|
||||||
|
item.category.icon as keyof typeof Icons
|
||||||
|
] || Icons.HelpCircle) as LucideIcon;
|
||||||
|
|
||||||
|
const percentageChange =
|
||||||
|
item.previous_amount > 0
|
||||||
|
? ((item.amount - item.previous_amount) /
|
||||||
|
item.previous_amount) *
|
||||||
|
100
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const categoryColor = getCategoryColorClasses(
|
||||||
|
item.category.color,
|
||||||
|
);
|
||||||
|
const chartColor =
|
||||||
|
CHART_COLORS[index % CHART_COLORS.length];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.category_id} className="space-y-1.5">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn([
|
||||||
|
'flex size-6 shrink-0 items-center justify-center rounded-full',
|
||||||
|
`${categoryColor.bg} ${categoryColor.text}`,
|
||||||
|
])}
|
||||||
|
>
|
||||||
|
<Icon className="size-3.5" />
|
||||||
|
</div>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||||
|
{item.category.name}
|
||||||
|
</span>
|
||||||
|
{percentageChange !== null && (
|
||||||
|
<PercentageTrendIndicator
|
||||||
|
trend={percentageChange}
|
||||||
|
label=""
|
||||||
|
previousAmount={
|
||||||
|
item.previous_amount
|
||||||
|
}
|
||||||
|
currentAmount={item.amount}
|
||||||
|
currencyCode="USD"
|
||||||
|
invertColors={type === 'expense'}
|
||||||
|
className="shrink-0 text-xs"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{item.percentage.toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
<AmountDisplay
|
||||||
|
amountInCents={item.amount}
|
||||||
|
currencyCode="USD"
|
||||||
|
variant="compact"
|
||||||
|
minimumFractionDigits={0}
|
||||||
|
maximumFractionDigits={0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={item.percentage}
|
||||||
|
className="h-1.5"
|
||||||
|
indicatorColor={chartColor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{data.data.length === 0 && (
|
||||||
|
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
{emptyMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
import { AmountDisplay } from '@/components/ui/amount-display';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { CashflowSummary } from '@/hooks/use-cashflow-data';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { ArrowDown, ArrowUp, TrendingDown, TrendingUp } from 'lucide-react';
|
||||||
|
|
||||||
|
interface NetCashflowCardProps {
|
||||||
|
current: CashflowSummary;
|
||||||
|
previous: CashflowSummary;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NetCashflowCard({
|
||||||
|
current,
|
||||||
|
previous,
|
||||||
|
loading,
|
||||||
|
}: NetCashflowCardProps) {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Net Cashflow
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-12 w-32 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPositive = current.net >= 0;
|
||||||
|
const diff = current.net - previous.net;
|
||||||
|
const diffIsPositive = diff >= 0;
|
||||||
|
const hasPreviousData = previous.income > 0 || previous.expense > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Net Cashflow
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Income minus expenses</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<div className={cn('flex items-center gap-1')}>
|
||||||
|
{isPositive ? (
|
||||||
|
<ArrowUp
|
||||||
|
className={cn(
|
||||||
|
'size-4',
|
||||||
|
'text-green-600 dark:text-green-400',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ArrowDown
|
||||||
|
className={cn(
|
||||||
|
'size-4',
|
||||||
|
'text-red-600 dark:text-red-400',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<AmountDisplay
|
||||||
|
amountInCents={Math.abs(current.net)}
|
||||||
|
currencyCode="USD"
|
||||||
|
size="2xl"
|
||||||
|
weight="bold"
|
||||||
|
minimumFractionDigits={0}
|
||||||
|
maximumFractionDigits={0}
|
||||||
|
highlightPositive
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{hasPreviousData && (
|
||||||
|
<div className={cn('mt-2 flex items-center gap-1 text-sm')}>
|
||||||
|
{diffIsPositive ? (
|
||||||
|
<TrendingUp
|
||||||
|
className={cn(
|
||||||
|
'size-4',
|
||||||
|
'text-green-600 dark:text-green-400',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TrendingDown
|
||||||
|
className={cn(
|
||||||
|
'size-4',
|
||||||
|
'text-red-600 dark:text-red-400',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{diffIsPositive ? '+' : ''}
|
||||||
|
<AmountDisplay
|
||||||
|
amountInCents={diff}
|
||||||
|
currencyCode="USD"
|
||||||
|
minimumFractionDigits={0}
|
||||||
|
maximumFractionDigits={0}
|
||||||
|
className="text-sm"
|
||||||
|
highlightPositive
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
vs last period
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-4 border-t pt-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Income</p>
|
||||||
|
<AmountDisplay
|
||||||
|
amountInCents={current.income}
|
||||||
|
currencyCode="USD"
|
||||||
|
minimumFractionDigits={0}
|
||||||
|
maximumFractionDigits={0}
|
||||||
|
weight="medium"
|
||||||
|
highlightPositive
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Expenses
|
||||||
|
</p>
|
||||||
|
<AmountDisplay
|
||||||
|
amountInCents={current.expense}
|
||||||
|
currencyCode="USD"
|
||||||
|
minimumFractionDigits={0}
|
||||||
|
maximumFractionDigits={0}
|
||||||
|
weight="medium"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { addMonths, format, isSameMonth, subMonths } from 'date-fns';
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
interface PeriodNavigationProps {
|
||||||
|
currentDate: Date;
|
||||||
|
onDateChange: (date: Date) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PeriodNavigation({
|
||||||
|
currentDate,
|
||||||
|
onDateChange,
|
||||||
|
}: PeriodNavigationProps) {
|
||||||
|
const now = new Date();
|
||||||
|
const isCurrentMonth = isSameMonth(currentDate, now);
|
||||||
|
|
||||||
|
const handlePrevMonth = () => {
|
||||||
|
onDateChange(subMonths(currentDate, 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNextMonth = () => {
|
||||||
|
onDateChange(addMonths(currentDate, 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCurrentMonth = () => {
|
||||||
|
onDateChange(now);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={handlePrevMonth}
|
||||||
|
aria-label="Previous month"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleCurrentMonth}
|
||||||
|
className="min-w-[140px] rounded-md px-3 py-1.5 text-center text-sm font-medium hover:bg-accent"
|
||||||
|
>
|
||||||
|
{format(currentDate, 'MMMM yyyy')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={handleNextMonth}
|
||||||
|
disabled={isCurrentMonth}
|
||||||
|
aria-label="Next month"
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { CashflowSummary } from '@/hooks/use-cashflow-data';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { TrendingDown, TrendingUp } from 'lucide-react';
|
||||||
|
|
||||||
|
interface SavingsRateCardProps {
|
||||||
|
current: CashflowSummary;
|
||||||
|
previous: CashflowSummary;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SavingsRateCard({
|
||||||
|
current,
|
||||||
|
previous,
|
||||||
|
loading,
|
||||||
|
}: SavingsRateCardProps) {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Savings Rate
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-12 w-24 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = current.savings_rate - previous.savings_rate;
|
||||||
|
const isPositive = diff >= 0;
|
||||||
|
const hasPreviousData = previous.income > 0;
|
||||||
|
|
||||||
|
// Determine color based on savings rate
|
||||||
|
const rateColor =
|
||||||
|
current.savings_rate >= 20
|
||||||
|
? 'text-green-600 dark:text-green-400'
|
||||||
|
: current.savings_rate >= 10
|
||||||
|
? 'text-yellow-600 dark:text-yellow-400'
|
||||||
|
: current.savings_rate >= 0
|
||||||
|
? 'text-orange-600 dark:text-orange-400'
|
||||||
|
: 'text-red-600 dark:text-red-400';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Savings Rate
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Percentage of income saved</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-3xl font-bold tabular-nums',
|
||||||
|
rateColor,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{current.savings_rate.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
{hasPreviousData && (
|
||||||
|
<div className={cn('flex items-center gap-1 text-sm')}>
|
||||||
|
{isPositive ? (
|
||||||
|
<TrendingUp className="size-4 text-green-600 dark:text-green-400" />
|
||||||
|
) : (
|
||||||
|
<TrendingDown className="size-4 text-red-600 dark:text-red-400" />
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{isPositive ? '+' : ''}
|
||||||
|
{diff.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
{current.savings_rate >= 20
|
||||||
|
? "Great job! You're saving well."
|
||||||
|
: current.savings_rate >= 10
|
||||||
|
? 'Good progress on your savings.'
|
||||||
|
: current.savings_rate >= 0
|
||||||
|
? 'Consider saving more if possible.'
|
||||||
|
: 'Spending exceeds income this period.'}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,260 @@
|
||||||
|
import { AmountDisplay } from '@/components/ui/amount-display';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { ChartConfig, ChartContainer } from '@/components/ui/chart';
|
||||||
|
import { TrendDataPoint } from '@/hooks/use-cashflow-data';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Line,
|
||||||
|
ReferenceLine,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
|
interface CashflowTrendChartProps {
|
||||||
|
data: TrendDataPoint[];
|
||||||
|
loading?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartConfig: ChartConfig = {
|
||||||
|
income: {
|
||||||
|
label: 'Income',
|
||||||
|
color: 'var(--color-chart-2)',
|
||||||
|
},
|
||||||
|
expense: {
|
||||||
|
label: 'Expenses',
|
||||||
|
color: 'var(--color-chart-5)',
|
||||||
|
},
|
||||||
|
net: {
|
||||||
|
label: 'Net',
|
||||||
|
color: 'var(--color-chart-1)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TooltipPayloadItem {
|
||||||
|
dataKey?: string;
|
||||||
|
value?: number;
|
||||||
|
color?: string;
|
||||||
|
name?: string;
|
||||||
|
payload?: TrendDataPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomTooltipProps {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: TooltipPayloadItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMonth(yearMonth: string): string {
|
||||||
|
const [year, month] = yearMonth.split('-');
|
||||||
|
const date = new Date(parseInt(year), parseInt(month) - 1);
|
||||||
|
const isCurrentYear = date.getFullYear() === new Date().getFullYear();
|
||||||
|
|
||||||
|
return date.toLocaleDateString(
|
||||||
|
'en-US',
|
||||||
|
isCurrentYear
|
||||||
|
? { month: 'short' }
|
||||||
|
: { year: '2-digit', month: 'short' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CustomTooltip({ active, payload }: CustomTooltipProps) {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
|
||||||
|
const data = payload[0].payload;
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border/50 bg-background px-3 py-2 text-xs shadow-xl">
|
||||||
|
<div className="mb-2 font-medium">{formatMonth(data.month)}</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="size-2 rounded-full bg-[var(--color-chart-2)]" />
|
||||||
|
Income
|
||||||
|
</span>
|
||||||
|
<AmountDisplay
|
||||||
|
amountInCents={data.income}
|
||||||
|
currencyCode="USD"
|
||||||
|
minimumFractionDigits={0}
|
||||||
|
maximumFractionDigits={0}
|
||||||
|
className="font-mono font-medium tabular-nums"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="size-2 rounded-full bg-[var(--color-chart-5)]" />
|
||||||
|
Expenses
|
||||||
|
</span>
|
||||||
|
<AmountDisplay
|
||||||
|
amountInCents={data.expense}
|
||||||
|
currencyCode="USD"
|
||||||
|
minimumFractionDigits={0}
|
||||||
|
maximumFractionDigits={0}
|
||||||
|
className="font-mono font-medium tabular-nums"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4 border-t pt-1">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="size-2 rounded-full bg-[var(--color-chart-1)]" />
|
||||||
|
Net
|
||||||
|
</span>
|
||||||
|
<AmountDisplay
|
||||||
|
amountInCents={data.net}
|
||||||
|
currencyCode="USD"
|
||||||
|
minimumFractionDigits={0}
|
||||||
|
maximumFractionDigits={0}
|
||||||
|
className={cn(
|
||||||
|
'font-mono font-medium tabular-nums',
|
||||||
|
data.net >= 0 ? 'text-green-600' : 'text-red-600',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CashflowTrendChart({
|
||||||
|
data,
|
||||||
|
loading,
|
||||||
|
className,
|
||||||
|
}: CashflowTrendChartProps) {
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const minChartWidth = data.length * 60;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollContainerRef.current) {
|
||||||
|
scrollContainerRef.current.scrollLeft =
|
||||||
|
scrollContainerRef.current.scrollWidth;
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Cashflow Trend</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[250px] animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader className="gap-1 pb-4">
|
||||||
|
<CardTitle className="text-base">Cashflow Trend</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Monthly income, expenses, and net cashflow over the last 12
|
||||||
|
months
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div ref={scrollContainerRef} className="overflow-x-auto">
|
||||||
|
<ChartContainer
|
||||||
|
config={chartConfig}
|
||||||
|
className="h-[250px] w-full"
|
||||||
|
style={{ minWidth: `${minChartWidth}px` }}
|
||||||
|
>
|
||||||
|
<BarChart accessibilityLayer data={data}>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
vertical={false}
|
||||||
|
stroke="var(--color-border)"
|
||||||
|
opacity={0.3}
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="month"
|
||||||
|
tickLine={false}
|
||||||
|
tickMargin={10}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={formatMonth}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={(value: number) => {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
notation: 'compact',
|
||||||
|
compactDisplay: 'short',
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(value / 100);
|
||||||
|
}}
|
||||||
|
width={60}
|
||||||
|
/>
|
||||||
|
<ReferenceLine
|
||||||
|
y={0}
|
||||||
|
stroke="var(--color-border)"
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
content={<CustomTooltip />}
|
||||||
|
cursor={{
|
||||||
|
fill: 'var(--color-muted)',
|
||||||
|
opacity: 0.3,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="income"
|
||||||
|
fill="var(--color-chart-2)"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
stackId="a"
|
||||||
|
name="Income"
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="expense"
|
||||||
|
fill="var(--color-chart-5)"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
stackId="b"
|
||||||
|
name="Expenses"
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="net"
|
||||||
|
stroke="var(--color-chart-1)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{
|
||||||
|
fill: 'var(--color-chart-1)',
|
||||||
|
strokeWidth: 0,
|
||||||
|
r: 3,
|
||||||
|
}}
|
||||||
|
activeDot={{ r: 5 }}
|
||||||
|
name="Net"
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-center justify-center gap-6 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="size-3 rounded bg-[var(--color-chart-2)]" />
|
||||||
|
<span>Income</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="size-3 rounded bg-[var(--color-chart-5)]" />
|
||||||
|
<span>Expenses</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="size-3 rounded-full bg-[var(--color-chart-1)]" />
|
||||||
|
<span>Net</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
export { CashflowTrendChart } from './cashflow-trend-chart';
|
||||||
export { ChartViewToggle } from './chart-view-toggle';
|
export { ChartViewToggle } from './chart-view-toggle';
|
||||||
export { MoMChart } from './mom-chart';
|
export { MoMChart } from './mom-chart';
|
||||||
export { MoMPercentChart } from './mom-percent-chart';
|
export { MoMPercentChart } from './mom-percent-chart';
|
||||||
|
export { SankeyChart } from './sankey-chart';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,339 @@
|
||||||
|
import { SankeyData } from '@/hooks/use-cashflow-data';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Category } from '@/types/category';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
interface SankeyChartProps {
|
||||||
|
data: SankeyData;
|
||||||
|
height?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NodeData {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
color: string;
|
||||||
|
y: number;
|
||||||
|
height: number;
|
||||||
|
column: 0 | 1 | 2;
|
||||||
|
category?: Category;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LinkData {
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
value: number;
|
||||||
|
sourceY: number;
|
||||||
|
targetY: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLUMN_POSITIONS = [0.25, 0.5, 0.75];
|
||||||
|
const NODE_WIDTH = 12;
|
||||||
|
const NODE_PADDING = 6;
|
||||||
|
const MIN_NODE_HEIGHT = 20;
|
||||||
|
|
||||||
|
function formatAmount(amountInCents: number): string {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(amountInCents / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SankeyChart({
|
||||||
|
data,
|
||||||
|
height = 400,
|
||||||
|
className,
|
||||||
|
}: SankeyChartProps) {
|
||||||
|
const [hoveredNode, setHoveredNode] = useState<string | null>(null);
|
||||||
|
const [hoveredLink, setHoveredLink] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { nodes, links, isEmpty } = useMemo(() => {
|
||||||
|
const {
|
||||||
|
income_categories,
|
||||||
|
expense_categories,
|
||||||
|
total_income,
|
||||||
|
total_expense,
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
if (total_income === 0 && total_expense === 0) {
|
||||||
|
return { nodes: [], links: [], isEmpty: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeMap: Record<string, NodeData> = {};
|
||||||
|
const linkList: LinkData[] = [];
|
||||||
|
|
||||||
|
// Calculate available height for nodes
|
||||||
|
const availableHeight = height - 40; // padding
|
||||||
|
const maxTotal = Math.max(total_income, total_expense);
|
||||||
|
|
||||||
|
// Create income nodes (left column)
|
||||||
|
let incomeY = 20;
|
||||||
|
const incomeNodes = income_categories
|
||||||
|
.sort((a, b) => b.amount - a.amount)
|
||||||
|
.map((item) => {
|
||||||
|
const nodeHeight = Math.max(
|
||||||
|
MIN_NODE_HEIGHT,
|
||||||
|
(item.amount / maxTotal) * availableHeight * 0.5,
|
||||||
|
);
|
||||||
|
const node: NodeData = {
|
||||||
|
id: `income-${item.category_id}`,
|
||||||
|
label: item.category.name,
|
||||||
|
value: item.amount,
|
||||||
|
color: item.category.color || 'var(--color-chart-2)',
|
||||||
|
y: incomeY,
|
||||||
|
height: nodeHeight,
|
||||||
|
column: 0,
|
||||||
|
category: item.category,
|
||||||
|
};
|
||||||
|
incomeY += nodeHeight + NODE_PADDING;
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create center node (total cashflow)
|
||||||
|
const centerHeight = Math.max(
|
||||||
|
MIN_NODE_HEIGHT * 1.5,
|
||||||
|
(Math.max(total_income, total_expense) / maxTotal) *
|
||||||
|
availableHeight *
|
||||||
|
0.6,
|
||||||
|
);
|
||||||
|
const centerY = (height - centerHeight) / 2;
|
||||||
|
const centerNode: NodeData = {
|
||||||
|
id: 'center',
|
||||||
|
label: 'Cashflow',
|
||||||
|
value: total_income - total_expense,
|
||||||
|
color: 'var(--color-chart-1)',
|
||||||
|
y: centerY,
|
||||||
|
height: centerHeight,
|
||||||
|
column: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create expense nodes (right column)
|
||||||
|
let expenseY = 20;
|
||||||
|
const expenseNodes = expense_categories
|
||||||
|
.sort((a, b) => b.amount - a.amount)
|
||||||
|
.map((item) => {
|
||||||
|
const nodeHeight = Math.max(
|
||||||
|
MIN_NODE_HEIGHT,
|
||||||
|
(item.amount / maxTotal) * availableHeight * 0.5,
|
||||||
|
);
|
||||||
|
const node: NodeData = {
|
||||||
|
id: `expense-${item.category_id}`,
|
||||||
|
label: item.category.name,
|
||||||
|
value: item.amount,
|
||||||
|
color: item.category.color || 'var(--color-chart-3)',
|
||||||
|
y: expenseY,
|
||||||
|
height: nodeHeight,
|
||||||
|
column: 2,
|
||||||
|
category: item.category,
|
||||||
|
};
|
||||||
|
expenseY += nodeHeight + NODE_PADDING;
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add all nodes to map
|
||||||
|
incomeNodes.forEach((n) => (nodeMap[n.id] = n));
|
||||||
|
nodeMap[centerNode.id] = centerNode;
|
||||||
|
expenseNodes.forEach((n) => (nodeMap[n.id] = n));
|
||||||
|
|
||||||
|
// Create links from income to center
|
||||||
|
let incomeLinkY = centerY;
|
||||||
|
incomeNodes.forEach((incomeNode) => {
|
||||||
|
const linkHeight = (incomeNode.value / total_income) * centerHeight;
|
||||||
|
linkList.push({
|
||||||
|
source: incomeNode.id,
|
||||||
|
target: 'center',
|
||||||
|
value: incomeNode.value,
|
||||||
|
sourceY: incomeNode.y + incomeNode.height / 2,
|
||||||
|
targetY: incomeLinkY + linkHeight / 2,
|
||||||
|
height: linkHeight,
|
||||||
|
});
|
||||||
|
incomeLinkY += linkHeight;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create links from center to expenses
|
||||||
|
let expenseLinkY = centerY;
|
||||||
|
expenseNodes.forEach((expenseNode) => {
|
||||||
|
const linkHeight =
|
||||||
|
(expenseNode.value / total_expense) * centerHeight;
|
||||||
|
linkList.push({
|
||||||
|
source: 'center',
|
||||||
|
target: expenseNode.id,
|
||||||
|
value: expenseNode.value,
|
||||||
|
sourceY: expenseLinkY + linkHeight / 2,
|
||||||
|
targetY: expenseNode.y + expenseNode.height / 2,
|
||||||
|
height: linkHeight,
|
||||||
|
});
|
||||||
|
expenseLinkY += linkHeight;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: Object.values(nodeMap),
|
||||||
|
links: linkList,
|
||||||
|
isEmpty: false,
|
||||||
|
};
|
||||||
|
}, [data, height]);
|
||||||
|
|
||||||
|
if (isEmpty) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-center text-muted-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={{ height }}
|
||||||
|
>
|
||||||
|
No cashflow data for this period
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = 600; // SVG viewBox width
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('w-full overflow-x-auto', className)}>
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
|
className="w-full"
|
||||||
|
style={{ minWidth: 400, maxWidth: '100%' }}
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
>
|
||||||
|
{/* Links */}
|
||||||
|
<g className="links">
|
||||||
|
{links.map((link) => {
|
||||||
|
const sourceNode = nodes.find(
|
||||||
|
(n) => n.id === link.source,
|
||||||
|
);
|
||||||
|
const targetNode = nodes.find(
|
||||||
|
(n) => n.id === link.target,
|
||||||
|
);
|
||||||
|
if (!sourceNode || !targetNode) return null;
|
||||||
|
|
||||||
|
const sourceX =
|
||||||
|
COLUMN_POSITIONS[sourceNode.column] * width +
|
||||||
|
NODE_WIDTH / 2;
|
||||||
|
const targetX =
|
||||||
|
COLUMN_POSITIONS[targetNode.column] * width -
|
||||||
|
NODE_WIDTH / 2;
|
||||||
|
|
||||||
|
const linkId = `${link.source}-${link.target}`;
|
||||||
|
const isHovered =
|
||||||
|
hoveredLink === linkId ||
|
||||||
|
hoveredNode === link.source ||
|
||||||
|
hoveredNode === link.target;
|
||||||
|
|
||||||
|
// Create a curved path
|
||||||
|
const path = `
|
||||||
|
M ${sourceX} ${link.sourceY - link.height / 2}
|
||||||
|
C ${(sourceX + targetX) / 2} ${link.sourceY - link.height / 2},
|
||||||
|
${(sourceX + targetX) / 2} ${link.targetY - link.height / 2},
|
||||||
|
${targetX} ${link.targetY - link.height / 2}
|
||||||
|
L ${targetX} ${link.targetY + link.height / 2}
|
||||||
|
C ${(sourceX + targetX) / 2} ${link.targetY + link.height / 2},
|
||||||
|
${(sourceX + targetX) / 2} ${link.sourceY + link.height / 2},
|
||||||
|
${sourceX} ${link.sourceY + link.height / 2}
|
||||||
|
Z
|
||||||
|
`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
key={linkId}
|
||||||
|
d={path}
|
||||||
|
fill={
|
||||||
|
sourceNode.column === 0
|
||||||
|
? 'var(--color-chart-2)'
|
||||||
|
: 'var(--color-chart-3)'
|
||||||
|
}
|
||||||
|
fillOpacity={isHovered ? 0.6 : 0.3}
|
||||||
|
className="transition-all duration-200"
|
||||||
|
onMouseEnter={() => setHoveredLink(linkId)}
|
||||||
|
onMouseLeave={() => setHoveredLink(null)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Nodes */}
|
||||||
|
<g className="nodes">
|
||||||
|
{nodes.map((node) => {
|
||||||
|
const x =
|
||||||
|
COLUMN_POSITIONS[node.column] * width -
|
||||||
|
NODE_WIDTH / 2;
|
||||||
|
const isHovered = hoveredNode === node.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={node.id}
|
||||||
|
onMouseEnter={() => setHoveredNode(node.id)}
|
||||||
|
onMouseLeave={() => setHoveredNode(null)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x={x}
|
||||||
|
y={node.y}
|
||||||
|
width={NODE_WIDTH}
|
||||||
|
height={node.height}
|
||||||
|
rx={4}
|
||||||
|
fill={
|
||||||
|
node.id === 'center'
|
||||||
|
? 'var(--color-chart-1)'
|
||||||
|
: node.color
|
||||||
|
}
|
||||||
|
fillOpacity={isHovered ? 1 : 0.8}
|
||||||
|
className="transition-all duration-200"
|
||||||
|
/>
|
||||||
|
{/* Label */}
|
||||||
|
<text
|
||||||
|
x={
|
||||||
|
node.column === 0
|
||||||
|
? x - 6
|
||||||
|
: node.column === 2
|
||||||
|
? x + NODE_WIDTH + 6
|
||||||
|
: x + NODE_WIDTH / 2
|
||||||
|
}
|
||||||
|
y={node.y + node.height / 2 - 6}
|
||||||
|
textAnchor={
|
||||||
|
node.column === 0
|
||||||
|
? 'end'
|
||||||
|
: node.column === 2
|
||||||
|
? 'start'
|
||||||
|
: 'middle'
|
||||||
|
}
|
||||||
|
dominantBaseline="middle"
|
||||||
|
className="fill-foreground text-[9px] font-medium"
|
||||||
|
>
|
||||||
|
{node.label}
|
||||||
|
</text>
|
||||||
|
{/* Amount */}
|
||||||
|
<text
|
||||||
|
x={
|
||||||
|
node.column === 0
|
||||||
|
? x - 6
|
||||||
|
: node.column === 2
|
||||||
|
? x + NODE_WIDTH + 6
|
||||||
|
: x + NODE_WIDTH / 2
|
||||||
|
}
|
||||||
|
y={node.y + node.height / 2 + 6}
|
||||||
|
textAnchor={
|
||||||
|
node.column === 0
|
||||||
|
? 'end'
|
||||||
|
: node.column === 2
|
||||||
|
? 'start'
|
||||||
|
: 'middle'
|
||||||
|
}
|
||||||
|
dominantBaseline="middle"
|
||||||
|
className="fill-muted-foreground text-[9px]"
|
||||||
|
>
|
||||||
|
{formatAmount(node.value)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,172 @@
|
||||||
|
import { AmountDisplay } from '@/components/ui/amount-display';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { cashflow } from '@/routes';
|
||||||
|
import { Link } from '@inertiajs/react';
|
||||||
|
import { endOfMonth, format, startOfMonth } from 'date-fns';
|
||||||
|
import { ArrowRight, TrendingDown, TrendingUp } from 'lucide-react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface CashflowSummary {
|
||||||
|
income: number;
|
||||||
|
expense: number;
|
||||||
|
net: number;
|
||||||
|
savings_rate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CashflowSummaryCardProps {
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CashflowSummaryCard({ loading }: CashflowSummaryCardProps) {
|
||||||
|
const [data, setData] = useState<{
|
||||||
|
current: CashflowSummary;
|
||||||
|
previous: CashflowSummary;
|
||||||
|
} | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const from = format(startOfMonth(now), 'yyyy-MM-dd');
|
||||||
|
const to = format(endOfMonth(now), 'yyyy-MM-dd');
|
||||||
|
const params = new URLSearchParams({ from, to });
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/cashflow/summary?${params.toString()}`,
|
||||||
|
);
|
||||||
|
const result = await response.json();
|
||||||
|
setData(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch cashflow summary:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isLoading || loading) {
|
||||||
|
return (
|
||||||
|
<Card className="col-span-3">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Cashflow</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div key={i} className="space-y-2">
|
||||||
|
<div className="h-3 w-16 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
||||||
|
<div className="h-6 w-24 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { current } = data;
|
||||||
|
const isPositiveNet = current.net >= 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="col-span-3">
|
||||||
|
<CardHeader className="gap-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle>Cashflow</CardTitle>
|
||||||
|
<Link
|
||||||
|
href={cashflow().url}
|
||||||
|
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
View details
|
||||||
|
<ArrowRight className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
This month's income and expenses
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{/* Income */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Income</p>
|
||||||
|
<AmountDisplay
|
||||||
|
amountInCents={current.income}
|
||||||
|
currencyCode="USD"
|
||||||
|
minimumFractionDigits={0}
|
||||||
|
maximumFractionDigits={0}
|
||||||
|
weight="semibold"
|
||||||
|
highlightPositive
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expenses */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Expenses
|
||||||
|
</p>
|
||||||
|
<AmountDisplay
|
||||||
|
amountInCents={current.expense}
|
||||||
|
currencyCode="USD"
|
||||||
|
minimumFractionDigits={0}
|
||||||
|
maximumFractionDigits={0}
|
||||||
|
weight="semibold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Net */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Net</p>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{isPositiveNet ? (
|
||||||
|
<TrendingUp className="size-4 text-green-600 dark:text-green-400" />
|
||||||
|
) : (
|
||||||
|
<TrendingDown className="size-4 text-red-600 dark:text-red-400" />
|
||||||
|
)}
|
||||||
|
<AmountDisplay
|
||||||
|
amountInCents={Math.abs(current.net)}
|
||||||
|
currencyCode="USD"
|
||||||
|
minimumFractionDigits={0}
|
||||||
|
maximumFractionDigits={0}
|
||||||
|
weight="semibold"
|
||||||
|
highlightPositive
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Savings rate footer */}
|
||||||
|
<div className="mt-4 flex items-center justify-between border-t pt-3">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Savings rate
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-sm font-medium',
|
||||||
|
current.savings_rate >= 20
|
||||||
|
? 'text-green-600 dark:text-green-400'
|
||||||
|
: current.savings_rate >= 0
|
||||||
|
? 'text-yellow-600 dark:text-yellow-400'
|
||||||
|
: 'text-red-600 dark:text-red-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{current.savings_rate.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
import { Category } from '@/types/category';
|
||||||
|
import { endOfMonth, format, startOfMonth } from 'date-fns';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export interface CashflowSummary {
|
||||||
|
income: number;
|
||||||
|
expense: number;
|
||||||
|
net: number;
|
||||||
|
savings_rate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SankeyCategory {
|
||||||
|
category: Category;
|
||||||
|
category_id: string;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SankeyData {
|
||||||
|
income_categories: SankeyCategory[];
|
||||||
|
expense_categories: SankeyCategory[];
|
||||||
|
total_income: number;
|
||||||
|
total_expense: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrendDataPoint {
|
||||||
|
month: string;
|
||||||
|
income: number;
|
||||||
|
expense: number;
|
||||||
|
net: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BreakdownItem {
|
||||||
|
category: Category;
|
||||||
|
category_id: string;
|
||||||
|
amount: number;
|
||||||
|
percentage: number;
|
||||||
|
previous_amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BreakdownData {
|
||||||
|
data: BreakdownItem[];
|
||||||
|
total: number;
|
||||||
|
previous_total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CashflowData {
|
||||||
|
summary: {
|
||||||
|
current: CashflowSummary;
|
||||||
|
previous: CashflowSummary;
|
||||||
|
};
|
||||||
|
sankey: SankeyData;
|
||||||
|
trend: TrendDataPoint[];
|
||||||
|
incomeBreakdown: BreakdownData;
|
||||||
|
expenseBreakdown: BreakdownData;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseCashflowDataOptions {
|
||||||
|
from: Date;
|
||||||
|
to: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyBreakdown: BreakdownData = {
|
||||||
|
data: [],
|
||||||
|
total: 0,
|
||||||
|
previous_total: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptySummary: CashflowSummary = {
|
||||||
|
income: 0,
|
||||||
|
expense: 0,
|
||||||
|
net: 0,
|
||||||
|
savings_rate: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useCashflowData({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
}: UseCashflowDataOptions): CashflowData & { refetch: () => void } {
|
||||||
|
const [data, setData] = useState<Omit<CashflowData, 'isLoading'>>({
|
||||||
|
summary: { current: emptySummary, previous: emptySummary },
|
||||||
|
sankey: {
|
||||||
|
income_categories: [],
|
||||||
|
expense_categories: [],
|
||||||
|
total_income: 0,
|
||||||
|
total_expense: 0,
|
||||||
|
},
|
||||||
|
trend: [],
|
||||||
|
incomeBreakdown: emptyBreakdown,
|
||||||
|
expenseBreakdown: emptyBreakdown,
|
||||||
|
});
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const fromStr = format(from, 'yyyy-MM-dd');
|
||||||
|
const toStr = format(to, 'yyyy-MM-dd');
|
||||||
|
|
||||||
|
const periodParams = new URLSearchParams({
|
||||||
|
from: fromStr,
|
||||||
|
to: toStr,
|
||||||
|
});
|
||||||
|
const periodQuery = `?${periodParams.toString()}`;
|
||||||
|
|
||||||
|
const [summary, sankey, trend, incomeBreakdown, expenseBreakdown] =
|
||||||
|
await Promise.all([
|
||||||
|
fetch(`/api/cashflow/summary${periodQuery}`).then((r) =>
|
||||||
|
r.json(),
|
||||||
|
),
|
||||||
|
fetch(`/api/cashflow/sankey${periodQuery}`).then((r) =>
|
||||||
|
r.json(),
|
||||||
|
),
|
||||||
|
fetch('/api/cashflow/trend?months=12').then((r) =>
|
||||||
|
r.json(),
|
||||||
|
),
|
||||||
|
fetch(
|
||||||
|
`/api/cashflow/breakdown${periodQuery}&type=income`,
|
||||||
|
).then((r) => r.json()),
|
||||||
|
fetch(
|
||||||
|
`/api/cashflow/breakdown${periodQuery}&type=expense`,
|
||||||
|
).then((r) => r.json()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setData({
|
||||||
|
summary,
|
||||||
|
sankey,
|
||||||
|
trend: trend.data,
|
||||||
|
incomeBreakdown,
|
||||||
|
expenseBreakdown,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch cashflow data:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [from, to]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
return { ...data, isLoading, refetch: fetchData };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultPeriod(): { from: Date; to: Date } {
|
||||||
|
const now = new Date();
|
||||||
|
return {
|
||||||
|
from: startOfMonth(now),
|
||||||
|
to: endOfMonth(now),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { BreakdownCard } from '@/components/cashflow/breakdown-card';
|
||||||
|
import { NetCashflowCard } from '@/components/cashflow/net-cashflow-card';
|
||||||
|
import { PeriodNavigation } from '@/components/cashflow/period-navigation';
|
||||||
|
import { SavingsRateCard } from '@/components/cashflow/savings-rate-card';
|
||||||
|
import { CashflowTrendChart, SankeyChart } from '@/components/charts';
|
||||||
|
import HeadingSmall from '@/components/heading-small';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { useCashflowData } from '@/hooks/use-cashflow-data';
|
||||||
|
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
|
||||||
|
import { cashflow } from '@/routes';
|
||||||
|
import { BreadcrumbItem } from '@/types';
|
||||||
|
import { Head } from '@inertiajs/react';
|
||||||
|
import { endOfMonth, startOfMonth } from 'date-fns';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
const breadcrumbs: BreadcrumbItem[] = [
|
||||||
|
{
|
||||||
|
title: 'Cashflow',
|
||||||
|
href: cashflow().url,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function CashflowPage() {
|
||||||
|
const [currentDate, setCurrentDate] = useState(new Date());
|
||||||
|
|
||||||
|
const period = {
|
||||||
|
from: startOfMonth(currentDate),
|
||||||
|
to: endOfMonth(currentDate),
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
summary,
|
||||||
|
sankey,
|
||||||
|
trend,
|
||||||
|
incomeBreakdown,
|
||||||
|
expenseBreakdown,
|
||||||
|
isLoading,
|
||||||
|
} = useCashflowData(period);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppSidebarLayout breadcrumbs={breadcrumbs}>
|
||||||
|
<Head title="Cashflow" />
|
||||||
|
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<HeadingSmall
|
||||||
|
title="Cashflow"
|
||||||
|
description="Track your income, expenses, and savings"
|
||||||
|
/>
|
||||||
|
<PeriodNavigation
|
||||||
|
currentDate={currentDate}
|
||||||
|
onDateChange={setCurrentDate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<NetCashflowCard
|
||||||
|
current={summary.current}
|
||||||
|
previous={summary.previous}
|
||||||
|
loading={isLoading}
|
||||||
|
/>
|
||||||
|
<SavingsRateCard
|
||||||
|
current={summary.current}
|
||||||
|
previous={summary.previous}
|
||||||
|
loading={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sankey Diagram */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<CardTitle className="text-base">Money Flow</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="h-[400px] animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
||||||
|
) : (
|
||||||
|
<SankeyChart data={sankey} height={400} />
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Breakdown Cards */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<BreakdownCard
|
||||||
|
type="income"
|
||||||
|
data={incomeBreakdown}
|
||||||
|
loading={isLoading}
|
||||||
|
/>
|
||||||
|
<BreakdownCard
|
||||||
|
type="expense"
|
||||||
|
data={expenseBreakdown}
|
||||||
|
loading={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trend Chart */}
|
||||||
|
<CashflowTrendChart data={trend} loading={isLoading} />
|
||||||
|
</div>
|
||||||
|
</AppSidebarLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import { AccountBalanceCard } from '@/components/dashboard/account-balance-card';
|
import { AccountBalanceCard } from '@/components/dashboard/account-balance-card';
|
||||||
|
import { CashflowSummaryCard } from '@/components/dashboard/cashflow-summary-card';
|
||||||
import { NetWorthChart as NetWorthChartComponent } from '@/components/dashboard/net-worth-chart';
|
import { NetWorthChart as NetWorthChartComponent } from '@/components/dashboard/net-worth-chart';
|
||||||
import { TopCategoriesCard } from '@/components/dashboard/top-categories-card';
|
import { TopCategoriesCard } from '@/components/dashboard/top-categories-card';
|
||||||
import HeadingSmall from '@/components/heading-small';
|
import HeadingSmall from '@/components/heading-small';
|
||||||
import { useDashboardData } from '@/hooks/use-dashboard-data';
|
import { useDashboardData } from '@/hooks/use-dashboard-data';
|
||||||
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
|
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
|
||||||
import { dashboard } from '@/routes';
|
import { dashboard } from '@/routes';
|
||||||
import { BreadcrumbItem } from '@/types';
|
import { BreadcrumbItem, SharedData } from '@/types';
|
||||||
import { Head } from '@inertiajs/react';
|
import { Head, usePage } from '@inertiajs/react';
|
||||||
|
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
const breadcrumbs: BreadcrumbItem[] = [
|
||||||
{
|
{
|
||||||
|
|
@ -16,6 +17,7 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
|
const { props } = usePage<SharedData>();
|
||||||
const {
|
const {
|
||||||
netWorthEvolution,
|
netWorthEvolution,
|
||||||
accounts: accountMetrics,
|
accounts: accountMetrics,
|
||||||
|
|
@ -58,11 +60,14 @@ export default function Dashboard() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<TopCategoriesCard
|
<TopCategoriesCard
|
||||||
categories={topCategories}
|
categories={topCategories}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
/>
|
/>
|
||||||
|
{props.features.cashflow && (
|
||||||
|
<CashflowSummaryCard loading={isLoading} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AppSidebarLayout>
|
</AppSidebarLayout>
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,52 @@
|
||||||
import { index as accountsIndex } from '@/actions/App/Http/Controllers/AccountController';
|
import { index as accountsIndex } from '@/actions/App/Http/Controllers/AccountController';
|
||||||
import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
|
import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
|
||||||
import DiscordIcon from '@/components/icons/DiscordIcon';
|
import DiscordIcon from '@/components/icons/DiscordIcon';
|
||||||
import { dashboard } from '@/routes';
|
import { cashflow, dashboard } from '@/routes';
|
||||||
import { NavItem } from '@/types';
|
import { Features, NavItem } from '@/types';
|
||||||
import { CreditCard, Github, LayoutGrid, Receipt } from 'lucide-react';
|
import {
|
||||||
|
CreditCard,
|
||||||
|
Github,
|
||||||
|
LayoutGrid,
|
||||||
|
Receipt,
|
||||||
|
TrendingUp,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
export const mainNavItems: NavItem[] = [
|
export function getMainNavItems(features: Features): NavItem[] {
|
||||||
{
|
const items: NavItem[] = [
|
||||||
type: 'nav-item',
|
{
|
||||||
title: 'Dashboard',
|
type: 'nav-item',
|
||||||
href: dashboard(),
|
title: 'Dashboard',
|
||||||
icon: LayoutGrid,
|
href: dashboard(),
|
||||||
},
|
icon: LayoutGrid,
|
||||||
{
|
},
|
||||||
type: 'nav-item',
|
];
|
||||||
title: 'Accounts',
|
|
||||||
href: accountsIndex(),
|
if (features.cashflow) {
|
||||||
icon: CreditCard,
|
items.push({
|
||||||
},
|
type: 'nav-item',
|
||||||
{
|
title: 'Cashflow',
|
||||||
type: 'nav-item',
|
href: cashflow(),
|
||||||
title: 'Transactions',
|
icon: TrendingUp,
|
||||||
href: transactionsIndex(),
|
});
|
||||||
icon: Receipt,
|
}
|
||||||
},
|
|
||||||
];
|
items.push(
|
||||||
|
{
|
||||||
|
type: 'nav-item',
|
||||||
|
title: 'Accounts',
|
||||||
|
href: accountsIndex(),
|
||||||
|
icon: CreditCard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'nav-item',
|
||||||
|
title: 'Transactions',
|
||||||
|
href: transactionsIndex(),
|
||||||
|
icon: Receipt,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
export const footerNavItems: NavItem[] = [
|
export const footerNavItems: NavItem[] = [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,10 @@ export interface NavDivider {
|
||||||
type: 'divider';
|
type: 'divider';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Features {
|
||||||
|
cashflow: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SharedData {
|
export interface SharedData {
|
||||||
name: string;
|
name: string;
|
||||||
appUrl: string;
|
appUrl: string;
|
||||||
|
|
@ -46,6 +50,7 @@ export interface SharedData {
|
||||||
subscriptionsEnabled: boolean;
|
subscriptionsEnabled: boolean;
|
||||||
pricing: PricingConfig;
|
pricing: PricingConfig;
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
|
features: Features;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\AccountBalanceController;
|
use App\Http\Controllers\AccountBalanceController;
|
||||||
|
use App\Http\Controllers\Api\CashflowAnalyticsController;
|
||||||
use App\Http\Controllers\Api\DashboardAnalyticsController;
|
use App\Http\Controllers\Api\DashboardAnalyticsController;
|
||||||
use App\Http\Controllers\EncryptionController;
|
use App\Http\Controllers\EncryptionController;
|
||||||
use App\Http\Controllers\Sync\AccountBalanceSyncController;
|
use App\Http\Controllers\Sync\AccountBalanceSyncController;
|
||||||
|
|
@ -49,4 +50,12 @@ Route::middleware(['web', 'auth'])->group(function () {
|
||||||
Route::get('top-categories', [DashboardAnalyticsController::class, 'topCategories']);
|
Route::get('top-categories', [DashboardAnalyticsController::class, 'topCategories']);
|
||||||
Route::get('account/{account}/balance-evolution', [DashboardAnalyticsController::class, 'accountBalanceEvolution']);
|
Route::get('account/{account}/balance-evolution', [DashboardAnalyticsController::class, 'accountBalanceEvolution']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Cashflow Analytics
|
||||||
|
Route::prefix('cashflow')->group(function () {
|
||||||
|
Route::get('summary', [CashflowAnalyticsController::class, 'summary']);
|
||||||
|
Route::get('sankey', [CashflowAnalyticsController::class, 'sankey']);
|
||||||
|
Route::get('trend', [CashflowAnalyticsController::class, 'trend']);
|
||||||
|
Route::get('breakdown', [CashflowAnalyticsController::class, 'breakdown']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\AccountController;
|
use App\Http\Controllers\AccountController;
|
||||||
|
use App\Http\Controllers\CashflowController;
|
||||||
use App\Http\Controllers\DashboardController;
|
use App\Http\Controllers\DashboardController;
|
||||||
use App\Http\Controllers\OnboardingController;
|
use App\Http\Controllers\OnboardingController;
|
||||||
use App\Http\Controllers\RobotsController;
|
use App\Http\Controllers\RobotsController;
|
||||||
|
|
@ -46,6 +47,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
||||||
|
|
||||||
Route::middleware(['auth', 'verified', 'onboarded', 'subscribed'])->group(function () {
|
Route::middleware(['auth', 'verified', 'onboarded', 'subscribed'])->group(function () {
|
||||||
Route::get('dashboard', DashboardController::class)->name('dashboard');
|
Route::get('dashboard', DashboardController::class)->name('dashboard');
|
||||||
|
Route::get('cashflow', CashflowController::class)->name('cashflow');
|
||||||
|
|
||||||
Route::get('accounts', [AccountController::class, 'index'])->name('accounts.list');
|
Route::get('accounts', [AccountController::class, 'index'])->name('accounts.list');
|
||||||
Route::get('accounts/{account}', [AccountController::class, 'show'])->name('accounts.show');
|
Route::get('accounts/{account}', [AccountController::class, 'show'])->name('accounts.show');
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,532 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\CategoryType;
|
||||||
|
use App\Models\Account;
|
||||||
|
use App\Models\Category;
|
||||||
|
use App\Models\Transaction;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->user = User::factory()->create();
|
||||||
|
$this->actingAs($this->user);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cashflow summary returns income, expense, net, and savings rate', function () {
|
||||||
|
$incomeCategory = Category::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'type' => CategoryType::Income,
|
||||||
|
]);
|
||||||
|
$expenseCategory = Category::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'type' => CategoryType::Expense,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
||||||
|
|
||||||
|
// Income: $1000
|
||||||
|
Transaction::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'account_id' => $account->id,
|
||||||
|
'category_id' => $incomeCategory->id,
|
||||||
|
'amount' => 100000,
|
||||||
|
'transaction_date' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Expense: $400
|
||||||
|
Transaction::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'account_id' => $account->id,
|
||||||
|
'category_id' => $expenseCategory->id,
|
||||||
|
'amount' => -40000,
|
||||||
|
'transaction_date' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/cashflow/summary?'.http_build_query([
|
||||||
|
'from' => now()->startOfMonth()->toDateString(),
|
||||||
|
'to' => now()->endOfMonth()->toDateString(),
|
||||||
|
]));
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertJson([
|
||||||
|
'current' => [
|
||||||
|
'income' => 100000,
|
||||||
|
'expense' => 40000,
|
||||||
|
'net' => 60000,
|
||||||
|
'savings_rate' => 60.0, // (1000 - 400) / 1000 * 100 = 60%
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cashflow summary handles zero income for savings rate', function () {
|
||||||
|
$expenseCategory = Category::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'type' => CategoryType::Expense,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
||||||
|
|
||||||
|
// Only expense, no income
|
||||||
|
Transaction::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'account_id' => $account->id,
|
||||||
|
'category_id' => $expenseCategory->id,
|
||||||
|
'amount' => -10000,
|
||||||
|
'transaction_date' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/cashflow/summary?'.http_build_query([
|
||||||
|
'from' => now()->startOfMonth()->toDateString(),
|
||||||
|
'to' => now()->endOfMonth()->toDateString(),
|
||||||
|
]));
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertJson([
|
||||||
|
'current' => [
|
||||||
|
'income' => 0,
|
||||||
|
'expense' => 10000,
|
||||||
|
'net' => -10000,
|
||||||
|
'savings_rate' => 0, // Avoid division by zero
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cashflow summary returns previous period data', function () {
|
||||||
|
$incomeCategory = Category::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'type' => CategoryType::Income,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
||||||
|
|
||||||
|
// Current period income
|
||||||
|
Transaction::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'account_id' => $account->id,
|
||||||
|
'category_id' => $incomeCategory->id,
|
||||||
|
'amount' => 50000,
|
||||||
|
'transaction_date' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Previous period income
|
||||||
|
Transaction::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'account_id' => $account->id,
|
||||||
|
'category_id' => $incomeCategory->id,
|
||||||
|
'amount' => 30000,
|
||||||
|
'transaction_date' => now()->subMonthNoOverflow(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/cashflow/summary?'.http_build_query([
|
||||||
|
'from' => now()->startOfMonth()->toDateString(),
|
||||||
|
'to' => now()->endOfMonth()->toDateString(),
|
||||||
|
]));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$data = $response->json();
|
||||||
|
|
||||||
|
expect($data['current']['income'])->toBe(50000);
|
||||||
|
expect($data['previous']['income'])->toBe(30000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sankey returns income and expense categories with amounts', function () {
|
||||||
|
$incomeCategory1 = Category::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'type' => CategoryType::Income,
|
||||||
|
'name' => 'Salary',
|
||||||
|
]);
|
||||||
|
$incomeCategory2 = Category::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'type' => CategoryType::Income,
|
||||||
|
'name' => 'Freelance',
|
||||||
|
]);
|
||||||
|
$expenseCategory1 = Category::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'type' => CategoryType::Expense,
|
||||||
|
'name' => 'Housing',
|
||||||
|
]);
|
||||||
|
$expenseCategory2 = Category::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'type' => CategoryType::Expense,
|
||||||
|
'name' => 'Food',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
||||||
|
|
||||||
|
// Income transactions
|
||||||
|
Transaction::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'account_id' => $account->id,
|
||||||
|
'category_id' => $incomeCategory1->id,
|
||||||
|
'amount' => 500000, // $5000
|
||||||
|
'transaction_date' => now(),
|
||||||
|
]);
|
||||||
|
Transaction::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'account_id' => $account->id,
|
||||||
|
'category_id' => $incomeCategory2->id,
|
||||||
|
'amount' => 100000, // $1000
|
||||||
|
'transaction_date' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Expense transactions
|
||||||
|
Transaction::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'account_id' => $account->id,
|
||||||
|
'category_id' => $expenseCategory1->id,
|
||||||
|
'amount' => -150000, // $1500
|
||||||
|
'transaction_date' => now(),
|
||||||
|
]);
|
||||||
|
Transaction::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'account_id' => $account->id,
|
||||||
|
'category_id' => $expenseCategory2->id,
|
||||||
|
'amount' => -50000, // $500
|
||||||
|
'transaction_date' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/cashflow/sankey?'.http_build_query([
|
||||||
|
'from' => now()->startOfMonth()->toDateString(),
|
||||||
|
'to' => now()->endOfMonth()->toDateString(),
|
||||||
|
]));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$data = $response->json();
|
||||||
|
|
||||||
|
expect($data)->toHaveKeys(['income_categories', 'expense_categories', 'total_income', 'total_expense']);
|
||||||
|
expect($data['total_income'])->toBe(600000);
|
||||||
|
expect($data['total_expense'])->toBe(200000);
|
||||||
|
expect($data['income_categories'])->toHaveCount(2);
|
||||||
|
expect($data['expense_categories'])->toHaveCount(2);
|
||||||
|
|
||||||
|
// Check that categories include required data
|
||||||
|
$incomeIds = collect($data['income_categories'])->pluck('category.id')->toArray();
|
||||||
|
expect($incomeIds)->toContain($incomeCategory1->id);
|
||||||
|
expect($incomeIds)->toContain($incomeCategory2->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cashflow trend returns monthly data for specified months', function () {
|
||||||
|
$incomeCategory = Category::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'type' => CategoryType::Income,
|
||||||
|
]);
|
||||||
|
$expenseCategory = Category::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'type' => CategoryType::Expense,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
||||||
|
|
||||||
|
// Create transactions for 3 months
|
||||||
|
for ($i = 0; $i < 3; $i++) {
|
||||||
|
Transaction::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'account_id' => $account->id,
|
||||||
|
'category_id' => $incomeCategory->id,
|
||||||
|
'amount' => 100000 + ($i * 10000),
|
||||||
|
'transaction_date' => now()->subMonths($i),
|
||||||
|
]);
|
||||||
|
Transaction::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'account_id' => $account->id,
|
||||||
|
'category_id' => $expenseCategory->id,
|
||||||
|
'amount' => -(50000 + ($i * 5000)),
|
||||||
|
'transaction_date' => now()->subMonths($i),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/cashflow/trend?months=3');
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$data = $response->json();
|
||||||
|
|
||||||
|
expect($data['data'])->toHaveCount(3);
|
||||||
|
expect($data['data'][0])->toHaveKeys(['month', 'income', 'expense', 'net']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cashflow trend defaults to 12 months', function () {
|
||||||
|
$response = $this->getJson('/api/cashflow/trend');
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$data = $response->json();
|
||||||
|
|
||||||
|
expect($data['data'])->toHaveCount(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('breakdown returns category amounts with percentages for expenses', function () {
|
||||||
|
$cat1 = Category::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'type' => CategoryType::Expense,
|
||||||
|
'name' => 'Housing',
|
||||||
|
]);
|
||||||
|
$cat2 = Category::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'type' => CategoryType::Expense,
|
||||||
|
'name' => 'Food',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
||||||
|
|
||||||
|
// Housing: 60% of total
|
||||||
|
Transaction::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'account_id' => $account->id,
|
||||||
|
'category_id' => $cat1->id,
|
||||||
|
'amount' => -60000,
|
||||||
|
'transaction_date' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Food: 40% of total
|
||||||
|
Transaction::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'account_id' => $account->id,
|
||||||
|
'category_id' => $cat2->id,
|
||||||
|
'amount' => -40000,
|
||||||
|
'transaction_date' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/cashflow/breakdown?'.http_build_query([
|
||||||
|
'from' => now()->startOfMonth()->toDateString(),
|
||||||
|
'to' => now()->endOfMonth()->toDateString(),
|
||||||
|
'type' => 'expense',
|
||||||
|
]));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$data = $response->json();
|
||||||
|
|
||||||
|
expect($data)->toHaveKeys(['data', 'total', 'previous_total']);
|
||||||
|
expect($data['total'])->toBe(100000);
|
||||||
|
expect($data['data'])->toHaveCount(2);
|
||||||
|
|
||||||
|
// Should be sorted by amount desc (Housing first)
|
||||||
|
expect($data['data'][0]['category']['name'])->toBe('Housing');
|
||||||
|
expect($data['data'][0]['amount'])->toBe(60000);
|
||||||
|
expect($data['data'][0]['percentage'])->toEqual(60.0);
|
||||||
|
expect($data['data'][0])->toHaveKey('previous_amount');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('breakdown returns category amounts for income', function () {
|
||||||
|
$cat1 = Category::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'type' => CategoryType::Income,
|
||||||
|
'name' => 'Salary',
|
||||||
|
]);
|
||||||
|
$cat2 = Category::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'type' => CategoryType::Income,
|
||||||
|
'name' => 'Freelance',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
||||||
|
|
||||||
|
Transaction::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'account_id' => $account->id,
|
||||||
|
'category_id' => $cat1->id,
|
||||||
|
'amount' => 500000,
|
||||||
|
'transaction_date' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Transaction::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'account_id' => $account->id,
|
||||||
|
'category_id' => $cat2->id,
|
||||||
|
'amount' => 100000,
|
||||||
|
'transaction_date' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/cashflow/breakdown?'.http_build_query([
|
||||||
|
'from' => now()->startOfMonth()->toDateString(),
|
||||||
|
'to' => now()->endOfMonth()->toDateString(),
|
||||||
|
'type' => 'income',
|
||||||
|
]));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$data = $response->json();
|
||||||
|
|
||||||
|
expect($data['total'])->toBe(600000);
|
||||||
|
expect($data['data'])->toHaveCount(2);
|
||||||
|
expect($data['data'][0]['category']['name'])->toBe('Salary');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('breakdown requires type parameter', function () {
|
||||||
|
$response = $this->getJson('/api/cashflow/breakdown?'.http_build_query([
|
||||||
|
'from' => now()->startOfMonth()->toDateString(),
|
||||||
|
'to' => now()->endOfMonth()->toDateString(),
|
||||||
|
]));
|
||||||
|
|
||||||
|
$response->assertUnprocessable();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cashflow page is accessible', function () {
|
||||||
|
$user = User::factory()->onboarded()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$response = $this->get('/cashflow');
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertInertia(fn ($page) => $page->component('cashflow/index'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cashflow page returns categories with type', function () {
|
||||||
|
$user = User::factory()->onboarded()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Category::factory()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'type' => CategoryType::Income,
|
||||||
|
'name' => 'Salary',
|
||||||
|
]);
|
||||||
|
Category::factory()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'type' => CategoryType::Expense,
|
||||||
|
'name' => 'Food',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->get('/cashflow');
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertInertia(fn ($page) => $page
|
||||||
|
->component('cashflow/index')
|
||||||
|
->has('categories', 2)
|
||||||
|
->has('categories.0.type')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cashflow summary includes uncategorized income', function () {
|
||||||
|
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
||||||
|
|
||||||
|
// Uncategorized positive amount = income
|
||||||
|
Transaction::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'account_id' => $account->id,
|
||||||
|
'category_id' => null,
|
||||||
|
'amount' => 50000, // $500
|
||||||
|
'transaction_date' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/cashflow/summary?'.http_build_query([
|
||||||
|
'from' => now()->startOfMonth()->toDateString(),
|
||||||
|
'to' => now()->endOfMonth()->toDateString(),
|
||||||
|
]));
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertJson([
|
||||||
|
'current' => [
|
||||||
|
'income' => 50000,
|
||||||
|
'expense' => 0,
|
||||||
|
'net' => 50000,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cashflow summary includes uncategorized expenses', function () {
|
||||||
|
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
||||||
|
|
||||||
|
// Uncategorized negative amount = expense
|
||||||
|
Transaction::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'account_id' => $account->id,
|
||||||
|
'category_id' => null,
|
||||||
|
'amount' => -30000, // $300
|
||||||
|
'transaction_date' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/cashflow/summary?'.http_build_query([
|
||||||
|
'from' => now()->startOfMonth()->toDateString(),
|
||||||
|
'to' => now()->endOfMonth()->toDateString(),
|
||||||
|
]));
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertJson([
|
||||||
|
'current' => [
|
||||||
|
'income' => 0,
|
||||||
|
'expense' => 30000,
|
||||||
|
'net' => -30000,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sankey includes unknown income and expense categories', function () {
|
||||||
|
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
||||||
|
|
||||||
|
// Uncategorized income
|
||||||
|
Transaction::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'account_id' => $account->id,
|
||||||
|
'category_id' => null,
|
||||||
|
'amount' => 100000,
|
||||||
|
'transaction_date' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Uncategorized expense
|
||||||
|
Transaction::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'account_id' => $account->id,
|
||||||
|
'category_id' => null,
|
||||||
|
'amount' => -50000,
|
||||||
|
'transaction_date' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/cashflow/sankey?'.http_build_query([
|
||||||
|
'from' => now()->startOfMonth()->toDateString(),
|
||||||
|
'to' => now()->endOfMonth()->toDateString(),
|
||||||
|
]));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$data = $response->json();
|
||||||
|
|
||||||
|
expect($data['income_categories'])->toHaveCount(1);
|
||||||
|
expect($data['expense_categories'])->toHaveCount(1);
|
||||||
|
expect($data['income_categories'][0]['category']['name'])->toBe('Unknown Income');
|
||||||
|
expect($data['income_categories'][0]['amount'])->toBe(100000);
|
||||||
|
expect($data['expense_categories'][0]['category']['name'])->toBe('Unknown Expense');
|
||||||
|
expect($data['expense_categories'][0]['amount'])->toBe(50000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('breakdown includes unknown income category', function () {
|
||||||
|
$incomeCategory = Category::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'type' => CategoryType::Income,
|
||||||
|
'name' => 'Salary',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
||||||
|
|
||||||
|
// Categorized income
|
||||||
|
Transaction::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'account_id' => $account->id,
|
||||||
|
'category_id' => $incomeCategory->id,
|
||||||
|
'amount' => 200000,
|
||||||
|
'transaction_date' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Uncategorized income
|
||||||
|
Transaction::factory()->create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'account_id' => $account->id,
|
||||||
|
'category_id' => null,
|
||||||
|
'amount' => 50000,
|
||||||
|
'transaction_date' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/cashflow/breakdown?'.http_build_query([
|
||||||
|
'from' => now()->startOfMonth()->toDateString(),
|
||||||
|
'to' => now()->endOfMonth()->toDateString(),
|
||||||
|
'type' => 'income',
|
||||||
|
]));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$data = $response->json();
|
||||||
|
|
||||||
|
expect($data['total'])->toBe(250000);
|
||||||
|
expect($data['data'])->toHaveCount(2);
|
||||||
|
|
||||||
|
$unknownCategory = collect($data['data'])->firstWhere('category.name', 'Unknown Income');
|
||||||
|
expect($unknownCategory)->not->toBeNull();
|
||||||
|
expect($unknownCategory['amount'])->toBe(50000);
|
||||||
|
expect($unknownCategory['percentage'])->toEqual(20.0); // 50k / 250k = 20%
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue