From 74583a1c733ac031435b924a7a89714447bcbed1 Mon Sep 17 00:00:00 2001
From: myrmidex
Date: Mon, 29 Dec 2025 23:32:05 +0100
Subject: [PATCH] Buckets crud
---
app/Actions/CreateBucketAction.php | 105 +++++++
app/Http/Controllers/BucketController.php | 217 ++++++++++++++
app/Http/Controllers/ScenarioController.php | 26 +-
app/Models/Bucket.php | 178 ++++++++++++
database/factories/BucketFactory.php | 123 ++++++++
...2025_12_29_205724_create_buckets_table.php | 32 +++
resources/js/pages/Scenarios/Show.tsx | 268 +++++++++++++++--
resources/views/app.blade.php | 1 +
routes/web.php | 8 +
tests/Unit/Actions/CreateBucketActionTest.php | 269 ++++++++++++++++++
10 files changed, 1203 insertions(+), 24 deletions(-)
create mode 100644 app/Actions/CreateBucketAction.php
create mode 100644 app/Http/Controllers/BucketController.php
create mode 100644 app/Models/Bucket.php
create mode 100644 database/factories/BucketFactory.php
create mode 100644 database/migrations/2025_12_29_205724_create_buckets_table.php
create mode 100644 tests/Unit/Actions/CreateBucketActionTest.php
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 */}
-
-
-
- Scenario Dashboard Coming Soon
-
-
- This will show buckets, streams, timeline, and calculation controls
-
-
-
+
+
Buckets
+ setIsModalOpen(true)}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500"
>
- Return to Scenarios
-
+ + Add Bucket
+
+
+
+
+ {buckets.map((bucket) => (
+
+
+
+
+ {bucket.name}
+
+
+ Priority {bucket.priority} • {bucket.allocation_type_label}
+
+
+
+ #{bucket.priority}
+
+
+
+
+
+ Current Balance
+
+ ${bucket.current_balance.toFixed(2)}
+
+
+
+
+
+ Allocation: {bucket.formatted_allocation_value}
+
+
+
+ {bucket.allocation_type === 'fixed_limit' && (
+
+
+ Progress
+
+ ${bucket.current_balance.toFixed(2)} / ${Number(bucket.allocation_value)?.toFixed(2)}
+
+
+
+
+ )}
+
+
+
+
+ Edit
+
+
+ Delete
+
+
+
+ ))}
+
+ {/* Virtual Overflow Bucket (placeholder for now) */}
+
+
+
+
+ Overflow
+
+
+ Unallocated funds
+
+
+ $0.00
+
+
+
+
+
+ {/* Placeholder for future features */}
+
+
+ Coming Next: Streams & Timeline
+
+
+ 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 --}}