Add streams

This commit is contained in:
myrmidex 2025-12-31 00:02:54 +01:00
parent fdf6fb02d7
commit e23ee84ce8
23 changed files with 1021 additions and 67 deletions

View file

@ -62,15 +62,6 @@ public function execute(
});
}
/**
* Create default buckets for a new scenario.
*/
public function createDefaultBuckets(Scenario $scenario): void
{
$this->execute($scenario, 'Monthly Expenses', Bucket::TYPE_FIXED_LIMIT, 0, 1);
$this->execute($scenario, 'Emergency Fund', Bucket::TYPE_FIXED_LIMIT, 0, 2);
$this->execute($scenario, 'Investments', Bucket::TYPE_UNLIMITED, null, 3);
}
/**
* Validate allocation value based on allocation type.

View file

@ -0,0 +1,31 @@
<?php
namespace App\Actions;
use App\Models\Bucket;
use App\Models\Scenario;
use Illuminate\Support\Facades\DB;
readonly class CreateScenarioAction
{
public function __construct(
private CreateBucketAction $createBucketAction
) {}
public function execute(array $data): Scenario
{
return DB::transaction(function () use ($data) {
$scenario = Scenario::create($data);
$this->createDefaultBuckets($scenario);
return $scenario;
});
}
private function createDefaultBuckets(Scenario $scenario): void
{
$this->createBucketAction->execute($scenario, 'Monthly Expenses', Bucket::TYPE_FIXED_LIMIT, 0, 1);
$this->createBucketAction->execute($scenario, 'Emergency Fund', Bucket::TYPE_FIXED_LIMIT, 0, 2);
$this->createBucketAction->execute($scenario, 'Investments', Bucket::TYPE_UNLIMITED, null, 3);
}
}

View file

@ -0,0 +1,13 @@
<?php
namespace App\Actions;
use App\Models\Scenario;
readonly class DeleteScenarioAction
{
public function execute(Scenario $scenario): void
{
$scenario->delete();
}
}

View file

@ -0,0 +1,15 @@
<?php
namespace App\Actions;
use App\Models\Scenario;
readonly class UpdateScenarioAction
{
public function execute(Scenario $scenario, array $data): Scenario
{
$scenario->update($data);
return $scenario;
}
}

View file

@ -2,19 +2,37 @@
namespace App\Http\Controllers;
use App\Actions\CreateBucketAction;
use App\Actions\CreateScenarioAction;
use App\Actions\UpdateScenarioAction;
use App\Actions\DeleteScenarioAction;
use App\Http\Resources\BucketResource;
use App\Http\Resources\StreamResource;
use App\Http\Resources\ScenarioResource;
use App\Http\Requests\StoreScenarioRequest;
use App\Http\Requests\UpdateScenarioRequest;
use App\Models\Scenario;
use App\Repositories\StreamRepository;
use App\Repositories\ScenarioRepository;
use App\Services\Streams\StatsService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class ScenarioController extends Controller
{
public function __construct(
private readonly ScenarioRepository $scenarioRepository,
private readonly StreamRepository $streamRepository,
private readonly CreateScenarioAction $createScenarioAction,
private readonly UpdateScenarioAction $updateScenarioAction,
private readonly DeleteScenarioAction $deleteScenarioAction,
private readonly StatsService $statsService
) {}
public function index(): Response
{
return Inertia::render('Scenarios/Index', [
'scenarios' => Scenario::orderBy('created_at', 'desc')->get()
'scenarios' => ScenarioResource::collection($this->scenarioRepository->getAll())
]);
}
@ -25,22 +43,10 @@ public function show(Scenario $scenario): Response
}]);
return Inertia::render('Scenarios/Show', [
'scenario' => $scenario,
'buckets' => $scenario->buckets->map(function ($bucket) {
return [
'id' => $bucket->id,
'name' => $bucket->name,
'priority' => $bucket->priority,
'sort_order' => $bucket->sort_order,
'allocation_type' => $bucket->allocation_type,
'allocation_value' => $bucket->allocation_value,
'allocation_type_label' => $bucket->getAllocationTypeLabel(),
'formatted_allocation_value' => $bucket->getFormattedAllocationValue(),
'current_balance' => $bucket->getCurrentBalance(),
'has_available_space' => $bucket->hasAvailableSpace(),
'available_space' => $bucket->getAvailableSpace(),
];
})
'scenario' => new ScenarioResource($scenario),
'buckets' => BucketResource::collection($scenario->buckets),
'streams' => StreamResource::collection($this->streamRepository->getForScenario($scenario)),
'streamStats' => $this->statsService->getSummaryStats($scenario)
]);
}
@ -49,19 +55,9 @@ public function create(): Response
return Inertia::render('Scenarios/Create');
}
public function store(Request $request): RedirectResponse
public function store(StoreScenarioRequest $request): RedirectResponse
{
$request->validate([
'name' => 'required|string|max:255',
]);
$scenario = Scenario::create([
'name' => $request->name,
]);
// Create default buckets using the action
$createBucketAction = new CreateBucketAction();
$createBucketAction->createDefaultBuckets($scenario);
$scenario = $this->createScenarioAction->execute($request->validated());
return redirect()->route('scenarios.show', $scenario);
}
@ -69,30 +65,25 @@ public function store(Request $request): RedirectResponse
public function edit(Scenario $scenario): Response
{
return Inertia::render('Scenarios/Edit', [
'scenario' => $scenario
'scenario' => new ScenarioResource($scenario)
]);
}
public function update(Request $request, Scenario $scenario): RedirectResponse
public function update(UpdateScenarioRequest $request, Scenario $scenario): RedirectResponse
{
$request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'start_date' => 'nullable|date',
'end_date' => 'nullable|date|after_or_equal:start_date',
]);
$this->updateScenarioAction->execute($scenario, $request->validated());
$scenario->update($request->only(['name', 'description', 'start_date', 'end_date']));
return redirect()->route('scenarios.show', $scenario)
return redirect()
->route('scenarios.show', $scenario)
->with('success', 'Scenario updated successfully');
}
public function destroy(Scenario $scenario): RedirectResponse
{
$scenario->delete();
$this->deleteScenarioAction->execute($scenario);
return redirect()->route('scenarios.index')
return redirect()
->route('scenarios.index')
->with('success', 'Scenario deleted successfully');
}
}

View file

@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreStreamRequest;
use App\Http\Requests\UpdateStreamRequest;
use App\Models\Stream;
use App\Models\Scenario;
use App\Repositories\StreamRepository;
use App\Services\Streams\StatsService;
use App\Http\Resources\StreamResource;
use Illuminate\Http\JsonResponse;
class StreamController extends Controller
{
public function __construct(
private readonly StreamRepository $streamRepository,
private readonly StatsService $statsService
) {}
public function index(Scenario $scenario): JsonResponse
{
$streams = $this->streamRepository->getForScenario($scenario);
return response()->json([
'streams' => StreamResource::collection($streams),
'stats' => $this->statsService->getSummaryStats($scenario)
]);
}
public function store(StoreStreamRequest $request, Scenario $scenario): JsonResponse
{
$stream = $this->streamRepository->create($scenario, $request->validated());
return response()->json([
'stream' => new StreamResource($stream),
'message' => 'Stream created successfully.'
], 201);
}
public function update(UpdateStreamRequest $request, Stream $stream): JsonResponse
{
$stream = $this->streamRepository->update($stream, $request->validated());
return response()->json([
'stream' => new StreamResource($stream),
'message' => 'Stream updated successfully.'
]);
}
public function destroy(Stream $stream): JsonResponse
{
$this->streamRepository->delete($stream);
return response()->json([
'message' => 'Stream deleted successfully.'
]);
}
public function toggle(Stream $stream): JsonResponse
{
$stream = $this->streamRepository->toggleActive($stream);
return response()->json([
'stream' => new StreamResource($stream),
'message' => $stream->is_active ? 'Stream activated.' : 'Stream deactivated.'
]);
}
}

View file

@ -0,0 +1,42 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreScenarioRequest extends FormRequest
{
public function authorize(): bool
{
// In production, check if user is authenticated
// For now, allow all requests
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255', 'min:1'],
'description' => ['nullable', 'string', 'max:1000'],
];
}
public function messages(): array
{
return [
'name.required' => 'A scenario name is required.',
'name.min' => 'The scenario name must be at least 1 character.',
'name.max' => 'The scenario name cannot exceed 255 characters.',
];
}
protected function prepareForValidation(): void
{
// Trim the name
if ($this->has('name')) {
$this->merge([
'name' => trim($this->name),
]);
}
}
}

View file

@ -0,0 +1,107 @@
<?php
namespace App\Http\Requests;
use App\Models\Stream;
use App\Models\Scenario;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreStreamRequest extends FormRequest
{
public function authorize(): bool
{
// In production, check if user owns the scenario
// For now, allow all requests
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'type' => ['required', Rule::in([Stream::TYPE_INCOME, Stream::TYPE_EXPENSE])],
'amount' => ['required', 'numeric', 'min:0.01', 'max:999999999.99'],
'frequency' => [
'required',
Rule::in([
Stream::FREQUENCY_ONCE,
Stream::FREQUENCY_WEEKLY,
Stream::FREQUENCY_BIWEEKLY,
Stream::FREQUENCY_MONTHLY,
Stream::FREQUENCY_QUARTERLY,
Stream::FREQUENCY_YEARLY,
])
],
'start_date' => ['required', 'date', 'date_format:Y-m-d'],
'end_date' => ['nullable', 'date', 'date_format:Y-m-d', 'after_or_equal:start_date'],
'bucket_id' => ['nullable', 'exists:buckets,id'],
'description' => ['nullable', 'string', 'max:1000'],
];
}
public function messages(): array
{
return [
'type.in' => 'The type must be either income or expense.',
'frequency.in' => 'Invalid frequency selected.',
'amount.min' => 'The amount must be greater than zero.',
'amount.max' => 'The amount is too large.',
'end_date.after_or_equal' => 'The end date must be after or equal to the start date.',
'bucket_id.exists' => 'The selected bucket does not exist.',
];
}
public function withValidator($validator): void
{
$validator->after(function ($validator) {
// Validate that the bucket belongs to the scenario
if ($this->bucket_id) {
/** @var Scenario $scenario */
$scenario = $this->route('scenario');
$bucketBelongsToScenario = $scenario->buckets()
->where('id', $this->bucket_id)
->exists();
if (!$bucketBelongsToScenario) {
$validator->errors()->add('bucket_id', 'The selected bucket does not belong to this scenario.');
}
}
// For expense streams, bucket is required
if ($this->type === Stream::TYPE_EXPENSE && !$this->bucket_id) {
$validator->errors()->add('bucket_id', 'A bucket must be selected for expense streams.');
}
});
}
protected function prepareForValidation(): void
{
// Ensure dates are in the correct format
if ($this->has('start_date') && !empty($this->start_date)) {
$this->merge([
'start_date' => date('Y-m-d', strtotime($this->start_date)),
]);
}
if ($this->has('end_date') && !empty($this->end_date)) {
$this->merge([
'end_date' => date('Y-m-d', strtotime($this->end_date)),
]);
}
// Convert amount to float
if ($this->has('amount')) {
$this->merge([
'amount' => (float) $this->amount,
]);
}
}
}

View file

@ -0,0 +1,50 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateScenarioRequest extends FormRequest
{
public function authorize(): bool
{
// In production, check if user owns the scenario
// For now, allow all requests
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255', 'min:1'],
'description' => ['nullable', 'string', 'max:1000'],
];
}
public function messages(): array
{
return [
'name.required' => 'A scenario name is required.',
'name.min' => 'The scenario name must be at least 1 character.',
'name.max' => 'The scenario name cannot exceed 255 characters.',
'description.max' => 'The description cannot exceed 1000 characters.',
];
}
protected function prepareForValidation(): void
{
// Trim the name
if ($this->has('name')) {
$this->merge([
'name' => trim($this->name),
]);
}
// Trim the description
if ($this->has('description')) {
$this->merge([
'description' => $this->description ? trim($this->description) : null,
]);
}
}
}

View file

@ -0,0 +1,111 @@
<?php
namespace App\Http\Requests;
use App\Models\Stream;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateStreamRequest extends FormRequest
{
public function authorize(): bool
{
// In production, check if user owns the stream/scenario
// For now, allow all requests
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'type' => ['required', Rule::in([Stream::TYPE_INCOME, Stream::TYPE_EXPENSE])],
'amount' => ['required', 'numeric', 'min:0.01', 'max:999999999.99'],
'frequency' => [
'required',
Rule::in([
Stream::FREQUENCY_ONCE,
Stream::FREQUENCY_WEEKLY,
Stream::FREQUENCY_BIWEEKLY,
Stream::FREQUENCY_MONTHLY,
Stream::FREQUENCY_QUARTERLY,
Stream::FREQUENCY_YEARLY,
])
],
'start_date' => ['required', 'date', 'date_format:Y-m-d'],
'end_date' => ['nullable', 'date', 'date_format:Y-m-d', 'after_or_equal:start_date'],
'bucket_id' => ['nullable', 'exists:buckets,id'],
'description' => ['nullable', 'string', 'max:1000'],
'is_active' => ['boolean'],
];
}
public function messages(): array
{
return [
'type.in' => 'The type must be either income or expense.',
'frequency.in' => 'Invalid frequency selected.',
'amount.min' => 'The amount must be greater than zero.',
'amount.max' => 'The amount is too large.',
'end_date.after_or_equal' => 'The end date must be after or equal to the start date.',
'bucket_id.exists' => 'The selected bucket does not exist.',
];
}
public function withValidator($validator): void
{
$validator->after(function ($validator) {
/** @var Stream $stream */
$stream = $this->route('stream');
// Validate that the bucket belongs to the stream's scenario
if ($this->bucket_id) {
$bucketBelongsToScenario = $stream->scenario->buckets()
->where('id', $this->bucket_id)
->exists();
if (!$bucketBelongsToScenario) {
$validator->errors()->add('bucket_id', 'The selected bucket does not belong to this scenario.');
}
}
// For expense streams, bucket is required
if ($this->type === Stream::TYPE_EXPENSE && !$this->bucket_id) {
$validator->errors()->add('bucket_id', 'A bucket must be selected for expense streams.');
}
});
}
protected function prepareForValidation(): void
{
// Ensure dates are in the correct format
if ($this->has('start_date') && !empty($this->start_date)) {
$this->merge([
'start_date' => date('Y-m-d', strtotime($this->start_date)),
]);
}
if ($this->has('end_date') && !empty($this->end_date)) {
$this->merge([
'end_date' => date('Y-m-d', strtotime($this->end_date)),
]);
}
// Convert amount to float
if ($this->has('amount')) {
$this->merge([
'amount' => (float) $this->amount,
]);
}
// Default is_active to current value if not provided
if (!$this->has('is_active')) {
/** @var Stream $stream */
$stream = $this->route('stream');
$this->merge([
'is_active' => $stream->is_active,
]);
}
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class BucketResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'priority' => $this->priority,
'sort_order' => $this->sort_order,
'allocation_type' => $this->allocation_type,
'allocation_value' => $this->allocation_value,
'allocation_type_label' => $this->getAllocationTypeLabel(),
'formatted_allocation_value' => $this->getFormattedAllocationValue(),
'current_balance' => $this->getCurrentBalance(),
'has_available_space' => $this->hasAvailableSpace(),
'available_space' => $this->getAvailableSpace(),
];
}
}

View file

@ -0,0 +1,20 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ScenarioResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class StreamResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'type' => $this->type,
'type_label' => $this->getTypeLabel(),
'amount' => $this->amount,
'frequency' => $this->frequency,
'frequency_label' => $this->getFrequencyLabel(),
'start_date' => $this->start_date->format('Y-m-d'),
'end_date' => $this->end_date?->format('Y-m-d'),
'bucket_id' => $this->bucket_id,
'bucket_name' => $this->bucket?->name,
'description' => $this->description,
'is_active' => $this->is_active,
'monthly_equivalent' => $this->getMonthlyEquivalent(),
];
}
}

View file

@ -3,10 +3,15 @@
namespace App\Models;
use Database\Factories\ScenarioFactory;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property Collection<Bucket> $buckets
* @method static create(array $data)
*/
class Scenario extends Model
{
/** @use HasFactory<ScenarioFactory> */
@ -14,34 +19,24 @@ class Scenario extends Model
protected $fillable = [
'name',
'description',
];
public function buckets(): HasMany
{
return $this->hasMany(Bucket::class);
}
/**
* Get the streams for this scenario.
*/
public function streams(): HasMany
{
return $this->hasMany(Stream::class);
}
/**
* Get the inflows for this scenario.
*/
public function inflows(): HasMany
{
return $this->hasMany(Inflow::class);
}
/**
* Get the outflows for this scenario.
*/
public function outflows(): HasMany
{
return $this->hasMany(Outflow::class);

105
app/Models/Stream.php Normal file
View file

@ -0,0 +1,105 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Stream extends Model
{
const TYPE_INCOME = 'income';
const TYPE_EXPENSE = 'expense';
const FREQUENCY_ONCE = 'once';
const FREQUENCY_WEEKLY = 'weekly';
const FREQUENCY_BIWEEKLY = 'biweekly';
const FREQUENCY_MONTHLY = 'monthly';
const FREQUENCY_QUARTERLY = 'quarterly';
const FREQUENCY_YEARLY = 'yearly';
protected $fillable = [
'scenario_id',
'name',
'type',
'amount',
'frequency',
'start_date',
'end_date',
'bucket_id',
'description',
'is_active',
];
protected $casts = [
'amount' => 'decimal:2',
'start_date' => 'date',
'end_date' => 'date',
'is_active' => 'boolean',
];
public function scenario(): BelongsTo
{
return $this->belongsTo(Scenario::class);
}
public function bucket(): BelongsTo
{
return $this->belongsTo(Bucket::class);
}
public static function getTypes(): array
{
return [
self::TYPE_INCOME => 'Income',
self::TYPE_EXPENSE => 'Expense',
];
}
public static function getFrequencies(): array
{
return [
self::FREQUENCY_ONCE => 'One-time',
self::FREQUENCY_WEEKLY => 'Weekly',
self::FREQUENCY_BIWEEKLY => 'Bi-weekly',
self::FREQUENCY_MONTHLY => 'Monthly',
self::FREQUENCY_QUARTERLY => 'Quarterly',
self::FREQUENCY_YEARLY => 'Yearly',
];
}
public function getTypeLabel(): string
{
return self::getTypes()[$this->type] ?? $this->type;
}
public function getFrequencyLabel(): string
{
return self::getFrequencies()[$this->frequency] ?? $this->frequency;
}
public function getMonthlyEquivalent(): float
{
switch ($this->frequency) {
case self::FREQUENCY_WEEKLY:
return $this->amount * 4.33; // Average weeks per month
case self::FREQUENCY_BIWEEKLY:
return $this->amount * 2.17;
case self::FREQUENCY_MONTHLY:
return $this->amount;
case self::FREQUENCY_QUARTERLY:
return $this->amount / 3;
case self::FREQUENCY_YEARLY:
return $this->amount / 12;
case self::FREQUENCY_ONCE:
default:
return 0;
}
}
/* SCOPES */
public function scopeByType($query, string $type)
{
return $query->where('type', $type);
}
}

View file

@ -0,0 +1,16 @@
<?php
namespace App\Repositories;
use App\Models\Scenario;
use Illuminate\Support\Collection;
class ScenarioRepository
{
public function getAll(): Collection
{
return Scenario::query()
->orderBy('created_at', 'desc')
->get();
}
}

View file

@ -0,0 +1,90 @@
<?php
namespace App\Repositories;
use App\Models\Stream;
use App\Models\Scenario;
use Illuminate\Support\Collection;
class StreamRepository
{
public function getForScenario(Scenario $scenario): Collection
{
return $scenario->streams()
->with('bucket:id,name')
->orderBy('type')
->orderBy('name')
->get();
}
public function create(Scenario $scenario, array $data): Stream
{
return $scenario->streams()->create($data);
}
public function update(Stream $stream, array $data): Stream
{
$stream->update($data);
return $stream->fresh('bucket');
}
public function delete(Stream $stream): bool
{
return $stream->delete();
}
public function toggleActive(Stream $stream): Stream
{
$stream->update([
'is_active' => !$stream->is_active
]);
return $stream->fresh('bucket');
}
/**
* Check if a bucket belongs to the scenario
*/
public function bucketBelongsToScenario(Scenario $scenario, ?int $bucketId): bool
{
if (! $bucketId) {
return true;
}
return $scenario->buckets()
->where('id', $bucketId)
->exists();
}
/**
* Get streams grouped by type
*/
public function getGroupedByType(Scenario $scenario): array
{
return [
'income' => $scenario->streams()
->with('bucket:id,name')
->byType(Stream::TYPE_INCOME)
->orderBy('name')
->get(),
'expense' => $scenario->streams()
->with('bucket:id,name')
->byType(Stream::TYPE_EXPENSE)
->orderBy('name')
->get(),
];
}
/**
* Get streams for a specific bucket
*/
public function getForBucket(int $bucketId): Collection
{
return Stream::where('bucket_id', $bucketId)
->where('is_active', true)
->get();
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace App\Services\Streams;
use App\Models\Stream;
use App\Models\Scenario;
readonly class StatsService
{
public function getSummaryStats(Scenario $scenario): array
{
$streams = $scenario->streams()
->where('is_active', true)
->get();
$totalMonthlyIncome = $streams
->where('type', Stream::TYPE_INCOME)
->sum(fn($stream) => $stream->getMonthlyEquivalent());
$totalMonthlyExpenses = $streams
->where('type', Stream::TYPE_EXPENSE)
->sum(fn($stream) => $stream->getMonthlyEquivalent());
return [
'total_streams' => $streams->count(),
'active_streams' => $streams->where('is_active', true)->count(),
'income_streams' => $streams->where('type', Stream::TYPE_INCOME)->count(),
'expense_streams' => $streams->where('type', Stream::TYPE_EXPENSE)->count(),
'monthly_income' => $totalMonthlyIncome,
'monthly_expenses' => $totalMonthlyExpenses,
'monthly_net' => $totalMonthlyIncome - $totalMonthlyExpenses,
];
}
}

View file

@ -14,6 +14,7 @@ public function definition(): array
{
return [
'name' => $this->faker->words(2, true) . ' Budget',
'description' => $this->faker->text,
];
}
}

View file

@ -11,6 +11,7 @@ public function up(): void
Schema::create('scenarios', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('description')->nullable();
$table->timestamps();
});
}

View file

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('streams', function (Blueprint $table) {
$table->id();
$table->foreignId('scenario_id')->constrained()->cascadeOnDelete();
$table->foreignId('bucket_id')->nullable()->constrained()->nullOnDelete();
$table->string('name');
$table->boolean('is_active')->default(true);
$table->decimal('amount', 12, 2);
$table->enum('type', ['income', 'expense']);
$table->enum('frequency', ['once', 'weekly', 'biweekly', 'monthly', 'quarterly', 'yearly']);
$table->date('start_date');
$table->date('end_date')->nullable();
$table->text('description')->nullable();
$table->timestamps();
$table->index(['scenario_id', 'is_active']);
$table->index('start_date');
});
}
public function down(): void
{
Schema::dropIfExists('streams');
}
};

View file

@ -22,12 +22,41 @@ interface Bucket {
available_space: number;
}
interface Stream {
id: number;
name: string;
type: 'income' | 'expense';
type_label: string;
amount: number;
frequency: string;
frequency_label: string;
start_date: string;
end_date: string | null;
bucket_id: number | null;
bucket_name: string | null;
description: string | null;
is_active: boolean;
monthly_equivalent: number;
}
interface StreamStats {
total_streams: number;
active_streams: number;
income_streams: number;
expense_streams: number;
monthly_income: number;
monthly_expenses: number;
monthly_net: number;
}
interface Props {
scenario: Scenario;
buckets: Bucket[];
streams: Stream[];
streamStats?: StreamStats;
}
export default function Show({ scenario, buckets }: Props) {
export default function Show({ scenario, buckets, streams = [], streamStats }: Props) {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [editingBucket, setEditingBucket] = useState<Bucket | null>(null);
@ -302,13 +331,159 @@ export default function Show({ scenario, buckets }: Props) {
</div>
</div>
{/* Streams Section */}
<div className="mt-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-gray-900">Income & Expense Streams</h2>
<button
className="rounded-md bg-green-600 px-4 py-2 text-sm font-semibold text-white hover:bg-green-500"
>
+ Add Stream
</button>
</div>
{/* Stream Statistics */}
{streamStats && (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4 mb-6">
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<dt className="text-sm font-medium text-gray-500 truncate">
Monthly Income
</dt>
<dd className="mt-1 text-3xl font-semibold text-green-600">
${streamStats.monthly_income.toFixed(2)}
</dd>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<dt className="text-sm font-medium text-gray-500 truncate">
Monthly Expenses
</dt>
<dd className="mt-1 text-3xl font-semibold text-red-600">
${streamStats.monthly_expenses.toFixed(2)}
</dd>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<dt className="text-sm font-medium text-gray-500 truncate">
Net Cash Flow
</dt>
<dd className={`mt-1 text-3xl font-semibold ${streamStats.monthly_net >= 0 ? 'text-green-600' : 'text-red-600'}`}>
${streamStats.monthly_net.toFixed(2)}
</dd>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<dt className="text-sm font-medium text-gray-500 truncate">
Active Streams
</dt>
<dd className="mt-1 text-3xl font-semibold text-gray-900">
{streamStats.active_streams} / {streamStats.total_streams}
</dd>
</div>
</div>
</div>
)}
{streams.length === 0 ? (
<div className="rounded-lg bg-white p-8 text-center shadow">
<p className="text-gray-600">No streams yet. Add income or expense streams to start tracking cash flow.</p>
</div>
) : (
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
<table className="min-w-full divide-y divide-gray-300">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
Type
</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
Amount
</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
Frequency
</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
Bucket
</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
Start Date
</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
Status
</th>
<th className="relative px-6 py-3">
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
{streams.map((stream) => (
<tr key={stream.id}>
<td className="whitespace-nowrap px-6 py-4 text-sm font-medium text-gray-900">
{stream.name}
</td>
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
<span className={`inline-flex rounded-full px-2 text-xs font-semibold leading-5 ${
stream.type === 'income'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
{stream.type_label}
</span>
</td>
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
${stream.amount.toFixed(2)}
</td>
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
{stream.frequency_label}
</td>
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
{stream.bucket_name || '-'}
</td>
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
{new Date(stream.start_date).toLocaleDateString()}
</td>
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
<button
className={`inline-flex rounded-full px-2 text-xs font-semibold leading-5 ${
stream.is_active
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{stream.is_active ? 'Active' : 'Inactive'}
</button>
</td>
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<button className="text-indigo-600 hover:text-indigo-900 mr-3">
Edit
</button>
<button className="text-red-600 hover:text-red-900">
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Placeholder for future features */}
<div className="rounded-lg bg-blue-50 p-6 text-center">
<h3 className="text-lg font-medium text-blue-900">
Coming Next: Streams & Timeline
Coming Next: Timeline & Projections
</h3>
<p className="text-sm text-blue-700 mt-2">
Add income and expense streams, then calculate projections to see your money flow through these buckets over time.
Calculate projections to see your money flow through these buckets over time.
</p>
</div>
</div>

View file

@ -2,6 +2,7 @@
use App\Http\Controllers\BucketController;
use App\Http\Controllers\ScenarioController;
use App\Http\Controllers\StreamController;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
use Laravel\Fortify\Features;
@ -22,6 +23,13 @@
Route::delete('/buckets/{bucket}', [BucketController::class, 'destroy'])->name('buckets.destroy');
Route::patch('/scenarios/{scenario}/buckets/priorities', [BucketController::class, 'updatePriorities'])->name('buckets.update-priorities');
// Stream routes (no auth required for MVP)
Route::get('/scenarios/{scenario}/streams', [StreamController::class, 'index'])->name('streams.index');
Route::post('/scenarios/{scenario}/streams', [StreamController::class, 'store'])->name('streams.store');
Route::patch('/streams/{stream}', [StreamController::class, 'update'])->name('streams.update');
Route::delete('/streams/{stream}', [StreamController::class, 'destroy'])->name('streams.destroy');
Route::patch('/streams/{stream}/toggle', [StreamController::class, 'toggle'])->name('streams.toggle');
Route::middleware(['auth', 'verified'])->group(function () {
Route::get('dashboard', function () {
return Inertia::render('dashboard');