diff --git a/app/Actions/CreateBucketAction.php b/app/Actions/CreateBucketAction.php new file mode 100644 index 0000000..d37be7f --- /dev/null +++ b/app/Actions/CreateBucketAction.php @@ -0,0 +1,105 @@ +validateAllocationValue($allocationType, $allocationValue); + + // Set allocation_value to null for unlimited buckets + if ($allocationType === Bucket::TYPE_UNLIMITED) { + $allocationValue = null; + } + + return DB::transaction(function () use ($scenario, $name, $allocationType, $allocationValue, $priority) { + // Determine priority (append to end if not specified) + if ($priority === null) { + $maxPriority = $scenario->buckets()->max('priority') ?? 0; + $priority = $maxPriority + 1; + } else { + // Validate priority is positive + if ($priority < 1) { + throw new InvalidArgumentException("Priority must be at least 1"); + } + + // Check if priority already exists and shift others if needed + $existingBucket = $scenario->buckets()->where('priority', $priority)->first(); + if ($existingBucket) { + // Shift priorities to make room + $scenario->buckets() + ->where('priority', '>=', $priority) + ->increment('priority'); + } + } + + // Create the bucket + return $scenario->buckets()->create([ + 'name' => $name, + 'priority' => $priority, + 'sort_order' => $priority, // Start with sort_order matching priority + 'allocation_type' => $allocationType, + 'allocation_value' => $allocationValue, + ]); + }); + } + + /** + * 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. + */ + private function validateAllocationValue(string $allocationType, ?float $allocationValue): void + { + switch ($allocationType) { + case Bucket::TYPE_FIXED_LIMIT: + if ($allocationValue === null) { + throw new InvalidArgumentException('Fixed limit buckets require an allocation value'); + } + if ($allocationValue < 0) { + throw new InvalidArgumentException('Fixed limit allocation value must be non-negative'); + } + break; + + case Bucket::TYPE_PERCENTAGE: + if ($allocationValue === null) { + throw new InvalidArgumentException('Percentage buckets require an allocation value'); + } + if ($allocationValue < 0.01 || $allocationValue > 100) { + throw new InvalidArgumentException('Percentage allocation value must be between 0.01 and 100'); + } + break; + + case Bucket::TYPE_UNLIMITED: + // Unlimited buckets should not have an allocation value + // We'll set it to null in the main method regardless + break; + } + } +} \ No newline at end of file diff --git a/app/Http/Controllers/BucketController.php b/app/Http/Controllers/BucketController.php new file mode 100644 index 0000000..6142a05 --- /dev/null +++ b/app/Http/Controllers/BucketController.php @@ -0,0 +1,217 @@ +buckets() + ->orderedBySortOrder() + ->get() + ->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(), + ]; + }); + + return response()->json([ + 'buckets' => $buckets + ]); + } + + public function store(Request $request, Scenario $scenario): JsonResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'allocation_type' => 'required|in:' . implode(',', [ + Bucket::TYPE_FIXED_LIMIT, + Bucket::TYPE_PERCENTAGE, + Bucket::TYPE_UNLIMITED, + ]), + 'allocation_value' => 'nullable|numeric', + 'priority' => 'nullable|integer|min:1', + ]); + + try { + $createBucketAction = new CreateBucketAction(); + $bucket = $createBucketAction->execute( + $scenario, + $validated['name'], + $validated['allocation_type'], + $validated['allocation_value'], + $validated['priority'] ?? null + ); + + return response()->json([ + 'bucket' => $this->formatBucketResponse($bucket), + 'message' => 'Bucket created successfully.' + ], 201); + } catch (InvalidArgumentException $e) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['allocation_value' => [$e->getMessage()]] + ], 422); + } + } + + public function update(Request $request, Bucket $bucket): JsonResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'allocation_type' => 'required|in:' . implode(',', [ + Bucket::TYPE_FIXED_LIMIT, + Bucket::TYPE_PERCENTAGE, + Bucket::TYPE_UNLIMITED, + ]), + 'allocation_value' => 'nullable|numeric', + 'priority' => 'nullable|integer|min:1', + ]); + + // Validate allocation_value based on allocation_type + $allocationValueRules = Bucket::allocationValueRules($validated['allocation_type']); + $request->validate([ + 'allocation_value' => $allocationValueRules, + ]); + + // Set allocation_value to null for unlimited buckets + if ($validated['allocation_type'] === Bucket::TYPE_UNLIMITED) { + $validated['allocation_value'] = null; + } + + // Handle priority change if needed + if (isset($validated['priority']) && $validated['priority'] !== $bucket->priority) { + $this->updateBucketPriority($bucket, $validated['priority']); + $validated['sort_order'] = $validated['priority']; + } + + $bucket->update($validated); + + return response()->json([ + 'bucket' => $this->formatBucketResponse($bucket), + 'message' => 'Bucket updated successfully.' + ]); + } + + /** + * Remove the specified bucket. + */ + public function destroy(Bucket $bucket): JsonResponse + { + $scenarioId = $bucket->scenario_id; + $deletedPriority = $bucket->priority; + + $bucket->delete(); + + // Shift remaining priorities down to fill the gap + $this->shiftPrioritiesDown($scenarioId, $deletedPriority); + + return response()->json([ + 'message' => 'Bucket deleted successfully.' + ]); + } + + /** + * Update bucket priorities (for drag-and-drop reordering). + */ + public function updatePriorities(Request $request, Scenario $scenario): JsonResponse + { + $validated = $request->validate([ + 'bucket_priorities' => 'required|array', + 'bucket_priorities.*.id' => 'required|exists:buckets,id', + 'bucket_priorities.*.priority' => 'required|integer|min:1', + ]); + + foreach ($validated['bucket_priorities'] as $bucketData) { + $bucket = Bucket::find($bucketData['id']); + if ($bucket && $bucket->scenario_id === $scenario->id) { + $bucket->update([ + 'priority' => $bucketData['priority'], + 'sort_order' => $bucketData['priority'], + ]); + } + } + + return response()->json([ + 'message' => 'Bucket priorities updated successfully.' + ]); + } + + /** + * Format bucket data for JSON response. + */ + private function formatBucketResponse(Bucket $bucket): array + { + 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(), + ]; + } + + + /** + * Shift priorities down to fill gap after deletion. + */ + private function shiftPrioritiesDown(int $scenarioId, int $deletedPriority): void + { + Bucket::query() + ->where('scenario_id', $scenarioId) + ->where('priority', '>', $deletedPriority) + ->decrement('priority'); + } + + /** + * Update a bucket's priority and adjust other buckets accordingly. + */ + private function updateBucketPriority(Bucket $bucket, int $newPriority): void + { + $oldPriority = $bucket->priority; + $scenario = $bucket->scenario; + + if ($newPriority === $oldPriority) { + return; + } + + if ($newPriority < $oldPriority) { + // Moving up - shift others down + $scenario->buckets() + ->where('id', '!=', $bucket->id) + ->whereBetween('priority', [$newPriority, $oldPriority - 1]) + ->increment('priority'); + } else { + // Moving down - shift others up + $scenario->buckets() + ->where('id', '!=', $bucket->id) + ->whereBetween('priority', [$oldPriority + 1, $newPriority]) + ->decrement('priority'); + } + + $bucket->update(['priority' => $newPriority]); + } +} diff --git a/app/Http/Controllers/ScenarioController.php b/app/Http/Controllers/ScenarioController.php index 5808802..4e3054e 100644 --- a/app/Http/Controllers/ScenarioController.php +++ b/app/Http/Controllers/ScenarioController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Actions\CreateBucketAction; use App\Models\Scenario; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -19,8 +20,27 @@ public function index(): Response public function show(Scenario $scenario): Response { + $scenario->load(['buckets' => function ($query) { + $query->orderedBySortOrder(); + }]); + return Inertia::render('Scenarios/Show', [ - 'scenario' => $scenario + '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(), + ]; + }) ]); } @@ -34,7 +54,9 @@ public function store(Request $request): RedirectResponse 'name' => $request->name, ]); - // TODO: Create default buckets here (will implement later with bucket model) + // Create default buckets using the action + $createBucketAction = new CreateBucketAction(); + $createBucketAction->createDefaultBuckets($scenario); return redirect()->route('scenarios.show', $scenario); } diff --git a/app/Models/Bucket.php b/app/Models/Bucket.php new file mode 100644 index 0000000..d29f91e --- /dev/null +++ b/app/Models/Bucket.php @@ -0,0 +1,178 @@ + */ + use HasFactory; + + protected $fillable = [ + 'scenario_id', + 'name', + 'priority', + 'sort_order', + 'allocation_type', + 'allocation_value', + ]; + + protected $casts = [ + 'priority' => 'integer', + 'sort_order' => 'integer', + 'allocation_value' => 'decimal:2', + ]; + + // TODO Extract to Enum + const string TYPE_FIXED_LIMIT = 'fixed_limit'; + const string TYPE_PERCENTAGE = 'percentage'; + const string TYPE_UNLIMITED = 'unlimited'; + + public function scenario(): BelongsTo + { + return $this->belongsTo(Scenario::class); + } + + /** + * Get the draws for the bucket. + * (Will be implemented when Draw model is created) + */ + public function draws(): HasMany + { + // TODO: Implement when Draw model is created + return $this->hasMany(Draw::class); + } + + /** + * Get the outflows for the bucket. + * (Will be implemented when Outflow model is created) + */ + public function outflows(): HasMany + { + // TODO: Implement when Outflow model is created + return $this->hasMany(Outflow::class); + } + + /** + * Scope to get buckets ordered by priority. + */ + public function scopeOrderedByPriority($query) + { + return $query->orderBy('priority'); + } + + /** + * Scope to get buckets ordered by sort order for UI display. + */ + public function scopeOrderedBySortOrder($query) + { + return $query->orderBy('sort_order')->orderBy('priority'); + } + + /** + * Get the current balance of the bucket. + * For MVP, this will always return 0 as we don't have transactions yet. + */ + public function getCurrentBalance(): float + { + // TODO: Calculate from draws minus outflows when those features are implemented + return 0.0; + } + + /** + * Check if the bucket can accept more money (for fixed_limit buckets). + */ + public function hasAvailableSpace(): bool + { + if ($this->allocation_type !== self::TYPE_FIXED_LIMIT) { + return true; + } + + return $this->getCurrentBalance() < $this->allocation_value; + } + + /** + * Get available space for fixed_limit buckets. + */ + public function getAvailableSpace(): float + { + if ($this->allocation_type !== self::TYPE_FIXED_LIMIT) { + return PHP_FLOAT_MAX; + } + + return max(0, $this->allocation_value - $this->getCurrentBalance()); + } + + /** + * Get display label for allocation type. + */ + public function getAllocationTypeLabel(): string + { + return match($this->allocation_type) { + self::TYPE_FIXED_LIMIT => 'Fixed Limit', + self::TYPE_PERCENTAGE => 'Percentage', + self::TYPE_UNLIMITED => 'Unlimited', + default => 'Unknown', + }; + } + + /** + * Get formatted allocation value for display. + */ + public function getFormattedAllocationValue(): string + { + return match($this->allocation_type) { + self::TYPE_FIXED_LIMIT => '$' . number_format($this->allocation_value, 2), + self::TYPE_PERCENTAGE => number_format($this->allocation_value, 2) . '%', + self::TYPE_UNLIMITED => 'All remaining', + default => '-', + }; + } + + /** + * Validation rules for bucket creation/update. + */ + public static function validationRules($scenarioId = null): array + { + $rules = [ + 'name' => 'required|string|max:255', + 'allocation_type' => 'required|in:' . implode(',', [ + self::TYPE_FIXED_LIMIT, + self::TYPE_PERCENTAGE, + self::TYPE_UNLIMITED, + ]), + 'priority' => 'required|integer|min:1', + ]; + + // Add scenario-specific priority uniqueness if scenario ID provided + if ($scenarioId) { + $rules['priority'] .= '|unique:buckets,priority,NULL,id,scenario_id,' . $scenarioId; + } + + return $rules; + } + + /** + * Get allocation value validation rules based on type. + */ + public static function allocationValueRules($allocationType): array + { + return match($allocationType) { + self::TYPE_FIXED_LIMIT => ['required', 'numeric', 'min:0'], + self::TYPE_PERCENTAGE => ['required', 'numeric', 'min:0.01', 'max:100'], + self::TYPE_UNLIMITED => ['nullable'], + default => ['nullable'], + }; + } +} diff --git a/database/factories/BucketFactory.php b/database/factories/BucketFactory.php new file mode 100644 index 0000000..b390035 --- /dev/null +++ b/database/factories/BucketFactory.php @@ -0,0 +1,123 @@ + + */ +class BucketFactory extends Factory +{ + public function definition(): array + { + $allocationType = $this->faker->randomElement([ + Bucket::TYPE_FIXED_LIMIT, + Bucket::TYPE_PERCENTAGE, + Bucket::TYPE_UNLIMITED, + ]); + + return [ + 'scenario_id' => Scenario::factory(), + 'name' => $this->faker->randomElement([ + 'Monthly Expenses', + 'Emergency Fund', + 'Investments', + 'Vacation Fund', + 'Car Maintenance', + 'Home Repairs', + 'Health & Medical', + 'Education', + 'Entertainment', + 'Groceries', + 'Clothing', + 'Gifts', + ]), + 'priority' => $this->faker->numberBetween(1, 10), + 'sort_order' => $this->faker->numberBetween(0, 10), + 'allocation_type' => $allocationType, + 'allocation_value' => $this->getAllocationValueForType($allocationType), + ]; + } + + /** + * Create a fixed limit bucket. + */ + public function fixedLimit($amount = null): Factory + { + $amount = $amount ?? $this->faker->numberBetween(500, 5000); + + return $this->state([ + 'allocation_type' => Bucket::TYPE_FIXED_LIMIT, + 'allocation_value' => $amount, + ]); + } + + /** + * Create a percentage bucket. + */ + public function percentage($percentage = null): Factory + { + $percentage = $percentage ?? $this->faker->numberBetween(10, 50); + + return $this->state([ + 'allocation_type' => Bucket::TYPE_PERCENTAGE, + 'allocation_value' => $percentage, + ]); + } + + /** + * Create an unlimited bucket. + */ + public function unlimited(): Factory + { + return $this->state([ + 'allocation_type' => Bucket::TYPE_UNLIMITED, + 'allocation_value' => null, + ]); + } + + /** + * Create default buckets set (Monthly Expenses, Emergency Fund, Investments). + */ + public function defaultSet(): array + { + return [ + $this->state([ + 'name' => 'Monthly Expenses', + 'priority' => 1, + 'sort_order' => 1, + 'allocation_type' => Bucket::TYPE_FIXED_LIMIT, + 'allocation_value' => 0, + ]), + $this->state([ + 'name' => 'Emergency Fund', + 'priority' => 2, + 'sort_order' => 2, + 'allocation_type' => Bucket::TYPE_FIXED_LIMIT, + 'allocation_value' => 0, + ]), + $this->state([ + 'name' => 'Investments', + 'priority' => 3, + 'sort_order' => 3, + 'allocation_type' => Bucket::TYPE_UNLIMITED, + 'allocation_value' => null, + ]), + ]; + } + + /** + * Get allocation value based on type. + */ + private function getAllocationValueForType(string $type): ?float + { + return match($type) { + Bucket::TYPE_FIXED_LIMIT => $this->faker->numberBetween(100, 10000), + Bucket::TYPE_PERCENTAGE => $this->faker->numberBetween(5, 50), + Bucket::TYPE_UNLIMITED => null, + }; + } +} \ No newline at end of file diff --git a/database/migrations/2025_12_29_205724_create_buckets_table.php b/database/migrations/2025_12_29_205724_create_buckets_table.php new file mode 100644 index 0000000..03e74cd --- /dev/null +++ b/database/migrations/2025_12_29_205724_create_buckets_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('scenario_id')->constrained()->onDelete('cascade'); + $table->string('name'); + $table->integer('priority')->comment('Lower number = higher priority, 1 = first'); + $table->integer('sort_order')->default(0)->comment('For UI display ordering'); + $table->enum('allocation_type', ['fixed_limit', 'percentage', 'unlimited']); + $table->decimal('allocation_value', 10, 2)->nullable() + ->comment('Limit amount for fixed_limit, percentage for percentage type, NULL for unlimited'); + $table->timestamps(); + + // Indexes for performance + $table->index(['scenario_id', 'priority']); + $table->unique(['scenario_id', 'priority'], 'unique_scenario_priority'); + }); + } + + public function down(): void + { + Schema::dropIfExists('buckets'); + } +}; diff --git a/resources/js/pages/Scenarios/Show.tsx b/resources/js/pages/Scenarios/Show.tsx index f0ce8b4..bd61c21 100644 --- a/resources/js/pages/Scenarios/Show.tsx +++ b/resources/js/pages/Scenarios/Show.tsx @@ -1,4 +1,5 @@ -import { Head, Link } from '@inertiajs/react'; +import { Head, Link, router } from '@inertiajs/react'; +import { useState } from 'react'; interface Scenario { id: number; @@ -7,11 +8,67 @@ interface Scenario { updated_at: string; } -interface Props { - scenario: Scenario; +interface Bucket { + id: number; + name: string; + priority: number; + sort_order: number; + allocation_type: string; + allocation_value: number | null; + allocation_type_label: string; + formatted_allocation_value: string; + current_balance: number; + has_available_space: boolean; + available_space: number; } -export default function Show({ scenario }: Props) { +interface Props { + scenario: Scenario; + buckets: Bucket[]; +} + +export default function Show({ scenario, buckets }: Props) { + const [isModalOpen, setIsModalOpen] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [formData, setFormData] = useState({ + name: '', + allocation_type: 'fixed_limit', + allocation_value: '' + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsSubmitting(true); + + try { + const response = await fetch(`/scenarios/${scenario.id}/buckets`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '', + }, + body: JSON.stringify({ + name: formData.name, + allocation_type: formData.allocation_type, + allocation_value: formData.allocation_value ? parseFloat(formData.allocation_value) : null + }), + }); + + if (response.ok) { + setIsModalOpen(false); + setFormData({ name: '', allocation_type: 'fixed_limit', allocation_value: '' }); + router.reload({ only: ['buckets'] }); + } else { + const errorData = await response.json(); + console.error('Failed to create bucket:', errorData); + } + } catch (error) { + console.error('Error creating bucket:', error); + } finally { + setIsSubmitting(false); + } + }; + return ( <>
@@ -39,30 +96,197 @@ export default function Show({ scenario }: Props) { - {/* Coming Soon Content */} -
- This will show buckets, streams, timeline, and calculation controls -
-+ Priority {bucket.priority} • {bucket.allocation_type_label} +
++ Unallocated funds +
++ $0.00 +
++ Add income and expense streams, then calculate projections to see your money flow through these buckets over time. +
+ + {/* Add Bucket Modal */} + {isModalOpen && ( +
+ )} > ); } \ No newline at end of file diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index c84bff7..8fbdc96 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -3,6 +3,7 @@
+ {{-- Inline script to detect system dark mode preference and apply it immediately --}}