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;
|
||||
|
||||
use App\Features\CashflowFeature;
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Middleware;
|
||||
use Laravel\Pennant\Feature;
|
||||
|
||||
class HandleInertiaRequests extends Middleware
|
||||
{
|
||||
|
|
@ -58,6 +60,9 @@ class HandleInertiaRequests extends Middleware
|
|||
'promo' => config('subscriptions.promo', []),
|
||||
],
|
||||
'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/fortify": "^1.30",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/pennant": "^1.18",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"laravel/wayfinder": "^0.1.9",
|
||||
"resend/resend-php": "^1.1",
|
||||
|
|
|
|||
|
|
@ -1658,6 +1658,82 @@
|
|||
},
|
||||
"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",
|
||||
"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,
|
||||
} from '@/components/ui/sidebar';
|
||||
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 { SharedData } from '@/types';
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
import { useMemo } from 'react';
|
||||
import AppLogo from './app-logo';
|
||||
|
||||
export function AppSidebar() {
|
||||
const page = usePage();
|
||||
const page = usePage<SharedData>();
|
||||
const mainNavItems = useMemo(
|
||||
() => getMainNavItems(page.props.features),
|
||||
[page.props.features],
|
||||
);
|
||||
|
||||
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 { MoMChart } from './mom-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 { CashflowSummaryCard } from '@/components/dashboard/cashflow-summary-card';
|
||||
import { NetWorthChart as NetWorthChartComponent } from '@/components/dashboard/net-worth-chart';
|
||||
import { TopCategoriesCard } from '@/components/dashboard/top-categories-card';
|
||||
import HeadingSmall from '@/components/heading-small';
|
||||
import { useDashboardData } from '@/hooks/use-dashboard-data';
|
||||
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
|
||||
import { dashboard } from '@/routes';
|
||||
import { BreadcrumbItem } from '@/types';
|
||||
import { Head } from '@inertiajs/react';
|
||||
import { BreadcrumbItem, SharedData } from '@/types';
|
||||
import { Head, usePage } from '@inertiajs/react';
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
|
|
@ -16,6 +17,7 @@ const breadcrumbs: BreadcrumbItem[] = [
|
|||
];
|
||||
|
||||
export default function Dashboard() {
|
||||
const { props } = usePage<SharedData>();
|
||||
const {
|
||||
netWorthEvolution,
|
||||
accounts: accountMetrics,
|
||||
|
|
@ -58,11 +60,14 @@ export default function Dashboard() {
|
|||
))}
|
||||
</div>
|
||||
|
||||
<div className="">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<TopCategoriesCard
|
||||
categories={topCategories}
|
||||
loading={isLoading}
|
||||
/>
|
||||
{props.features.cashflow && (
|
||||
<CashflowSummaryCard loading={isLoading} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AppSidebarLayout>
|
||||
|
|
|
|||
|
|
@ -1,30 +1,52 @@
|
|||
import { index as accountsIndex } from '@/actions/App/Http/Controllers/AccountController';
|
||||
import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
|
||||
import DiscordIcon from '@/components/icons/DiscordIcon';
|
||||
import { dashboard } from '@/routes';
|
||||
import { NavItem } from '@/types';
|
||||
import { CreditCard, Github, LayoutGrid, Receipt } from 'lucide-react';
|
||||
import { cashflow, dashboard } from '@/routes';
|
||||
import { Features, NavItem } from '@/types';
|
||||
import {
|
||||
CreditCard,
|
||||
Github,
|
||||
LayoutGrid,
|
||||
Receipt,
|
||||
TrendingUp,
|
||||
} from 'lucide-react';
|
||||
|
||||
export const mainNavItems: NavItem[] = [
|
||||
{
|
||||
type: 'nav-item',
|
||||
title: 'Dashboard',
|
||||
href: dashboard(),
|
||||
icon: LayoutGrid,
|
||||
},
|
||||
{
|
||||
type: 'nav-item',
|
||||
title: 'Accounts',
|
||||
href: accountsIndex(),
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
type: 'nav-item',
|
||||
title: 'Transactions',
|
||||
href: transactionsIndex(),
|
||||
icon: Receipt,
|
||||
},
|
||||
];
|
||||
export function getMainNavItems(features: Features): NavItem[] {
|
||||
const items: NavItem[] = [
|
||||
{
|
||||
type: 'nav-item',
|
||||
title: 'Dashboard',
|
||||
href: dashboard(),
|
||||
icon: LayoutGrid,
|
||||
},
|
||||
];
|
||||
|
||||
if (features.cashflow) {
|
||||
items.push({
|
||||
type: 'nav-item',
|
||||
title: 'Cashflow',
|
||||
href: cashflow(),
|
||||
icon: TrendingUp,
|
||||
});
|
||||
}
|
||||
|
||||
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[] = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -37,6 +37,10 @@ export interface NavDivider {
|
|||
type: 'divider';
|
||||
}
|
||||
|
||||
export interface Features {
|
||||
cashflow: boolean;
|
||||
}
|
||||
|
||||
export interface SharedData {
|
||||
name: string;
|
||||
appUrl: string;
|
||||
|
|
@ -46,6 +50,7 @@ export interface SharedData {
|
|||
subscriptionsEnabled: boolean;
|
||||
pricing: PricingConfig;
|
||||
sidebarOpen: boolean;
|
||||
features: Features;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\AccountBalanceController;
|
||||
use App\Http\Controllers\Api\CashflowAnalyticsController;
|
||||
use App\Http\Controllers\Api\DashboardAnalyticsController;
|
||||
use App\Http\Controllers\EncryptionController;
|
||||
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('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
|
||||
|
||||
use App\Http\Controllers\AccountController;
|
||||
use App\Http\Controllers\CashflowController;
|
||||
use App\Http\Controllers\DashboardController;
|
||||
use App\Http\Controllers\OnboardingController;
|
||||
use App\Http\Controllers\RobotsController;
|
||||
|
|
@ -46,6 +47,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||
|
||||
Route::middleware(['auth', 'verified', 'onboarded', 'subscribed'])->group(function () {
|
||||
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/{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