Buckets crud
This commit is contained in:
parent
8926ad6374
commit
74583a1c73
10 changed files with 1203 additions and 24 deletions
105
app/Actions/CreateBucketAction.php
Normal file
105
app/Actions/CreateBucketAction.php
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions;
|
||||||
|
|
||||||
|
use App\Models\Bucket;
|
||||||
|
use App\Models\Scenario;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
class CreateBucketAction
|
||||||
|
{
|
||||||
|
public function execute(
|
||||||
|
Scenario $scenario,
|
||||||
|
string $name,
|
||||||
|
string $allocationType,
|
||||||
|
?float $allocationValue = null,
|
||||||
|
?int $priority = null
|
||||||
|
): Bucket {
|
||||||
|
// Validate allocation type
|
||||||
|
$validTypes = [Bucket::TYPE_FIXED_LIMIT, Bucket::TYPE_PERCENTAGE, Bucket::TYPE_UNLIMITED];
|
||||||
|
if (!in_array($allocationType, $validTypes)) {
|
||||||
|
throw new InvalidArgumentException("Invalid allocation type: {$allocationType}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate allocation value based on type
|
||||||
|
$this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
217
app/Http/Controllers/BucketController.php
Normal file
217
app/Http/Controllers/BucketController.php
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Actions\CreateBucketAction;
|
||||||
|
use App\Models\Bucket;
|
||||||
|
use App\Models\Scenario;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
class BucketController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Scenario $scenario): JsonResponse
|
||||||
|
{
|
||||||
|
$buckets = $scenario->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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Actions\CreateBucketAction;
|
||||||
use App\Models\Scenario;
|
use App\Models\Scenario;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
@ -19,8 +20,27 @@ public function index(): Response
|
||||||
|
|
||||||
public function show(Scenario $scenario): Response
|
public function show(Scenario $scenario): Response
|
||||||
{
|
{
|
||||||
|
$scenario->load(['buckets' => function ($query) {
|
||||||
|
$query->orderedBySortOrder();
|
||||||
|
}]);
|
||||||
|
|
||||||
return Inertia::render('Scenarios/Show', [
|
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,
|
'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);
|
return redirect()->route('scenarios.show', $scenario);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
178
app/Models/Bucket.php
Normal file
178
app/Models/Bucket.php
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Database\Factories\BucketFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property int $scenario_id
|
||||||
|
* @property Scenario $scenario
|
||||||
|
* @property string $name
|
||||||
|
* @property int $priority
|
||||||
|
*/
|
||||||
|
class Bucket extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<BucketFactory> */
|
||||||
|
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'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
123
database/factories/BucketFactory.php
Normal file
123
database/factories/BucketFactory.php
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Bucket;
|
||||||
|
use App\Models\Scenario;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<Bucket>
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
<?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('buckets', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Head, Link } from '@inertiajs/react';
|
import { Head, Link, router } from '@inertiajs/react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
interface Scenario {
|
interface Scenario {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -7,11 +8,67 @@ interface Scenario {
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Bucket {
|
||||||
scenario: Scenario;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head title={scenario.name} />
|
<Head title={scenario.name} />
|
||||||
|
|
@ -39,30 +96,197 @@ export default function Show({ scenario }: Props) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Coming Soon Content */}
|
{/* Bucket Dashboard */}
|
||||||
<div className="rounded-lg bg-white p-12 text-center shadow">
|
<div className="space-y-6">
|
||||||
<div className="mx-auto h-16 w-16 text-blue-600">
|
<div className="flex items-center justify-between">
|
||||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<h2 className="text-xl font-semibold text-gray-900">Buckets</h2>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
<button
|
||||||
</svg>
|
onClick={() => setIsModalOpen(true)}
|
||||||
</div>
|
|
||||||
<h2 className="mt-4 text-xl font-semibold text-gray-900">
|
|
||||||
Scenario Dashboard Coming Soon
|
|
||||||
</h2>
|
|
||||||
<p className="mt-2 text-gray-600">
|
|
||||||
This will show buckets, streams, timeline, and calculation controls
|
|
||||||
</p>
|
|
||||||
<div className="mt-6">
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500"
|
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
|
||||||
</Link>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{buckets.map((bucket) => (
|
||||||
|
<div
|
||||||
|
key={bucket.id}
|
||||||
|
className="rounded-lg bg-white p-6 shadow transition-shadow hover:shadow-lg border-l-4 border-blue-500"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">
|
||||||
|
{bucket.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
|
Priority {bucket.priority} • {bucket.allocation_type_label}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800">
|
||||||
|
#{bucket.priority}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-600">Current Balance</span>
|
||||||
|
<span className="text-lg font-semibold text-gray-900">
|
||||||
|
${bucket.current_balance.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2">
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
Allocation: {bucket.formatted_allocation_value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bucket.allocation_type === 'fixed_limit' && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span>Progress</span>
|
||||||
|
<span>
|
||||||
|
${bucket.current_balance.toFixed(2)} / ${Number(bucket.allocation_value)?.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 h-2 bg-gray-200 rounded-full">
|
||||||
|
<div
|
||||||
|
className="h-2 bg-blue-600 rounded-full transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${bucket.allocation_value ? Math.min((bucket.current_balance / Number(bucket.allocation_value)) * 100, 100) : 0}%`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex gap-2">
|
||||||
|
<button className="flex-1 text-sm text-blue-600 hover:text-blue-500">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button className="flex-1 text-sm text-red-600 hover:text-red-500">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Virtual Overflow Bucket (placeholder for now) */}
|
||||||
|
<div className="rounded-lg bg-gray-50 p-6 border-2 border-dashed border-gray-300">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mx-auto h-8 w-8 text-gray-400">
|
||||||
|
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-2 text-sm font-medium text-gray-500">
|
||||||
|
Overflow
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
Unallocated funds
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-semibold text-gray-600 mt-2">
|
||||||
|
$0.00
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</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
|
||||||
|
</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.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Add Bucket Modal */}
|
||||||
|
{isModalOpen && (
|
||||||
|
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||||
|
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||||||
|
<div className="mt-3">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-4">Add New Bucket</h3>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||||
|
Bucket Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-gray-900"
|
||||||
|
placeholder="e.g., Travel Fund"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="allocation_type" className="block text-sm font-medium text-gray-700">
|
||||||
|
Allocation Type
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="allocation_type"
|
||||||
|
value={formData.allocation_type}
|
||||||
|
onChange={(e) => setFormData({ ...formData, allocation_type: e.target.value })}
|
||||||
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-gray-900"
|
||||||
|
>
|
||||||
|
<option value="fixed_limit">Fixed Limit</option>
|
||||||
|
<option value="percentage">Percentage</option>
|
||||||
|
<option value="unlimited">Unlimited</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.allocation_type !== 'unlimited' && (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="allocation_value" className="block text-sm font-medium text-gray-700">
|
||||||
|
{formData.allocation_type === 'percentage' ? 'Percentage (%)' : 'Amount ($)'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="allocation_value"
|
||||||
|
value={formData.allocation_value}
|
||||||
|
onChange={(e) => setFormData({ ...formData, allocation_value: e.target.value })}
|
||||||
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-gray-900"
|
||||||
|
placeholder={formData.allocation_type === 'percentage' ? '25' : '1000'}
|
||||||
|
step={formData.allocation_type === 'percentage' ? '0.01' : '0.01'}
|
||||||
|
min={formData.allocation_type === 'percentage' ? '0.01' : '0'}
|
||||||
|
max={formData.allocation_type === 'percentage' ? '100' : undefined}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-md hover:bg-gray-300"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Creating...' : 'Create Bucket'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||||
|
|
||||||
{{-- Inline script to detect system dark mode preference and apply it immediately --}}
|
{{-- Inline script to detect system dark mode preference and apply it immediately --}}
|
||||||
<script>
|
<script>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\BucketController;
|
||||||
use App\Http\Controllers\ScenarioController;
|
use App\Http\Controllers\ScenarioController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
|
|
@ -10,6 +11,13 @@
|
||||||
Route::get('/scenarios/{scenario}', [ScenarioController::class, 'show'])->name('scenarios.show');
|
Route::get('/scenarios/{scenario}', [ScenarioController::class, 'show'])->name('scenarios.show');
|
||||||
Route::post('/scenarios', [ScenarioController::class, 'store'])->name('scenarios.store');
|
Route::post('/scenarios', [ScenarioController::class, 'store'])->name('scenarios.store');
|
||||||
|
|
||||||
|
// Bucket routes (no auth required for MVP)
|
||||||
|
Route::get('/scenarios/{scenario}/buckets', [BucketController::class, 'index'])->name('buckets.index');
|
||||||
|
Route::post('/scenarios/{scenario}/buckets', [BucketController::class, 'store'])->name('buckets.store');
|
||||||
|
Route::patch('/buckets/{bucket}', [BucketController::class, 'update'])->name('buckets.update');
|
||||||
|
Route::delete('/buckets/{bucket}', [BucketController::class, 'destroy'])->name('buckets.destroy');
|
||||||
|
Route::patch('/scenarios/{scenario}/buckets/priorities', [BucketController::class, 'updatePriorities'])->name('buckets.update-priorities');
|
||||||
|
|
||||||
Route::middleware(['auth', 'verified'])->group(function () {
|
Route::middleware(['auth', 'verified'])->group(function () {
|
||||||
Route::get('dashboard', function () {
|
Route::get('dashboard', function () {
|
||||||
return Inertia::render('dashboard');
|
return Inertia::render('dashboard');
|
||||||
|
|
|
||||||
269
tests/Unit/Actions/CreateBucketActionTest.php
Normal file
269
tests/Unit/Actions/CreateBucketActionTest.php
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Actions;
|
||||||
|
|
||||||
|
use App\Actions\CreateBucketAction;
|
||||||
|
use App\Models\Bucket;
|
||||||
|
use App\Models\Scenario;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class CreateBucketActionTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private CreateBucketAction $action;
|
||||||
|
private Scenario $scenario;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->action = new CreateBucketAction();
|
||||||
|
$this->scenario = Scenario::factory()->create();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_can_create_fixed_limit_bucket(): void
|
||||||
|
{
|
||||||
|
$bucket = $this->action->execute(
|
||||||
|
$this->scenario,
|
||||||
|
'Test Bucket',
|
||||||
|
Bucket::TYPE_FIXED_LIMIT,
|
||||||
|
1000.00
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertInstanceOf(Bucket::class, $bucket);
|
||||||
|
$this->assertEquals('Test Bucket', $bucket->name);
|
||||||
|
$this->assertEquals(Bucket::TYPE_FIXED_LIMIT, $bucket->allocation_type);
|
||||||
|
$this->assertEquals(1000.00, $bucket->allocation_value);
|
||||||
|
$this->assertEquals(1, $bucket->priority);
|
||||||
|
$this->assertEquals(1, $bucket->sort_order);
|
||||||
|
$this->assertEquals($this->scenario->id, $bucket->scenario_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_can_create_percentage_bucket(): void
|
||||||
|
{
|
||||||
|
$bucket = $this->action->execute(
|
||||||
|
$this->scenario,
|
||||||
|
'Percentage Bucket',
|
||||||
|
Bucket::TYPE_PERCENTAGE,
|
||||||
|
25.5
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(Bucket::TYPE_PERCENTAGE, $bucket->allocation_type);
|
||||||
|
$this->assertEquals(25.5, $bucket->allocation_value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_can_create_unlimited_bucket(): void
|
||||||
|
{
|
||||||
|
$bucket = $this->action->execute(
|
||||||
|
$this->scenario,
|
||||||
|
'Unlimited Bucket',
|
||||||
|
Bucket::TYPE_UNLIMITED
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(Bucket::TYPE_UNLIMITED, $bucket->allocation_type);
|
||||||
|
$this->assertNull($bucket->allocation_value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_unlimited_bucket_ignores_allocation_value(): void
|
||||||
|
{
|
||||||
|
$bucket = $this->action->execute(
|
||||||
|
$this->scenario,
|
||||||
|
'Unlimited Bucket',
|
||||||
|
Bucket::TYPE_UNLIMITED,
|
||||||
|
999.99 // This should be ignored and set to null
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(Bucket::TYPE_UNLIMITED, $bucket->allocation_type);
|
||||||
|
$this->assertNull($bucket->allocation_value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_priority_auto_increments_when_not_specified(): void
|
||||||
|
{
|
||||||
|
$bucket1 = $this->action->execute(
|
||||||
|
$this->scenario,
|
||||||
|
'First Bucket',
|
||||||
|
Bucket::TYPE_FIXED_LIMIT,
|
||||||
|
100
|
||||||
|
);
|
||||||
|
|
||||||
|
$bucket2 = $this->action->execute(
|
||||||
|
$this->scenario,
|
||||||
|
'Second Bucket',
|
||||||
|
Bucket::TYPE_FIXED_LIMIT,
|
||||||
|
200
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(1, $bucket1->priority);
|
||||||
|
$this->assertEquals(2, $bucket2->priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_can_specify_custom_priority(): void
|
||||||
|
{
|
||||||
|
$bucket = $this->action->execute(
|
||||||
|
$this->scenario,
|
||||||
|
'Priority Bucket',
|
||||||
|
Bucket::TYPE_FIXED_LIMIT,
|
||||||
|
100,
|
||||||
|
5
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(5, $bucket->priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_existing_priorities_are_shifted_when_inserting(): void
|
||||||
|
{
|
||||||
|
// Create initial buckets
|
||||||
|
$bucket1 = $this->action->execute($this->scenario, 'Bucket 1', Bucket::TYPE_FIXED_LIMIT, 100, 1);
|
||||||
|
$bucket2 = $this->action->execute($this->scenario, 'Bucket 2', Bucket::TYPE_FIXED_LIMIT, 200, 2);
|
||||||
|
$bucket3 = $this->action->execute($this->scenario, 'Bucket 3', Bucket::TYPE_FIXED_LIMIT, 300, 3);
|
||||||
|
|
||||||
|
// Insert a bucket at priority 2
|
||||||
|
$newBucket = $this->action->execute($this->scenario, 'New Bucket', Bucket::TYPE_FIXED_LIMIT, 150, 2);
|
||||||
|
|
||||||
|
// Refresh models from database
|
||||||
|
$bucket1->refresh();
|
||||||
|
$bucket2->refresh();
|
||||||
|
$bucket3->refresh();
|
||||||
|
|
||||||
|
// Check that priorities were shifted correctly
|
||||||
|
$this->assertEquals(1, $bucket1->priority);
|
||||||
|
$this->assertEquals(2, $newBucket->priority);
|
||||||
|
$this->assertEquals(3, $bucket2->priority); // Shifted from 2 to 3
|
||||||
|
$this->assertEquals(4, $bucket3->priority); // Shifted from 3 to 4
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_throws_exception_for_invalid_allocation_type(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Invalid allocation type: invalid_type');
|
||||||
|
|
||||||
|
$this->action->execute(
|
||||||
|
$this->scenario,
|
||||||
|
'Test Bucket',
|
||||||
|
'invalid_type',
|
||||||
|
100
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_throws_exception_for_fixed_limit_without_allocation_value(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Fixed limit buckets require an allocation value');
|
||||||
|
|
||||||
|
$this->action->execute(
|
||||||
|
$this->scenario,
|
||||||
|
'Test Bucket',
|
||||||
|
Bucket::TYPE_FIXED_LIMIT,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_throws_exception_for_negative_fixed_limit_value(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Fixed limit allocation value must be non-negative');
|
||||||
|
|
||||||
|
$this->action->execute(
|
||||||
|
$this->scenario,
|
||||||
|
'Test Bucket',
|
||||||
|
Bucket::TYPE_FIXED_LIMIT,
|
||||||
|
-100
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_throws_exception_for_percentage_without_allocation_value(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Percentage buckets require an allocation value');
|
||||||
|
|
||||||
|
$this->action->execute(
|
||||||
|
$this->scenario,
|
||||||
|
'Test Bucket',
|
||||||
|
Bucket::TYPE_PERCENTAGE,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_throws_exception_for_percentage_below_minimum(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Percentage allocation value must be between 0.01 and 100');
|
||||||
|
|
||||||
|
$this->action->execute(
|
||||||
|
$this->scenario,
|
||||||
|
'Test Bucket',
|
||||||
|
Bucket::TYPE_PERCENTAGE,
|
||||||
|
0.005
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_throws_exception_for_percentage_above_maximum(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Percentage allocation value must be between 0.01 and 100');
|
||||||
|
|
||||||
|
$this->action->execute(
|
||||||
|
$this->scenario,
|
||||||
|
'Test Bucket',
|
||||||
|
Bucket::TYPE_PERCENTAGE,
|
||||||
|
101
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_throws_exception_for_negative_priority(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Priority must be at least 1');
|
||||||
|
|
||||||
|
$this->action->execute(
|
||||||
|
$this->scenario,
|
||||||
|
'Test Bucket',
|
||||||
|
Bucket::TYPE_FIXED_LIMIT,
|
||||||
|
100,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_create_default_buckets(): void
|
||||||
|
{
|
||||||
|
$this->action->createDefaultBuckets($this->scenario);
|
||||||
|
|
||||||
|
$buckets = $this->scenario->buckets()->orderBy('priority')->get();
|
||||||
|
|
||||||
|
$this->assertCount(3, $buckets);
|
||||||
|
|
||||||
|
// Monthly Expenses
|
||||||
|
$this->assertEquals('Monthly Expenses', $buckets[0]->name);
|
||||||
|
$this->assertEquals(1, $buckets[0]->priority);
|
||||||
|
$this->assertEquals(Bucket::TYPE_FIXED_LIMIT, $buckets[0]->allocation_type);
|
||||||
|
$this->assertEquals(0, $buckets[0]->allocation_value);
|
||||||
|
|
||||||
|
// Emergency Fund
|
||||||
|
$this->assertEquals('Emergency Fund', $buckets[1]->name);
|
||||||
|
$this->assertEquals(2, $buckets[1]->priority);
|
||||||
|
$this->assertEquals(Bucket::TYPE_FIXED_LIMIT, $buckets[1]->allocation_type);
|
||||||
|
$this->assertEquals(0, $buckets[1]->allocation_value);
|
||||||
|
|
||||||
|
// Investments
|
||||||
|
$this->assertEquals('Investments', $buckets[2]->name);
|
||||||
|
$this->assertEquals(3, $buckets[2]->priority);
|
||||||
|
$this->assertEquals(Bucket::TYPE_UNLIMITED, $buckets[2]->allocation_type);
|
||||||
|
$this->assertNull($buckets[2]->allocation_value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_creates_buckets_in_database_transaction(): void
|
||||||
|
{
|
||||||
|
// This test ensures database consistency by creating multiple buckets
|
||||||
|
// and verifying they all exist with correct priorities
|
||||||
|
$this->action->execute($this->scenario, 'Bucket 1', Bucket::TYPE_FIXED_LIMIT, 100, 1);
|
||||||
|
$this->action->execute($this->scenario, 'Bucket 2', Bucket::TYPE_FIXED_LIMIT, 200, 1); // Insert at priority 1
|
||||||
|
|
||||||
|
// Both buckets should exist with correct priorities
|
||||||
|
$buckets = $this->scenario->buckets()->orderBy('priority')->get();
|
||||||
|
$this->assertCount(2, $buckets);
|
||||||
|
$this->assertEquals('Bucket 2', $buckets[0]->name); // New bucket at priority 1
|
||||||
|
$this->assertEquals('Bucket 1', $buckets[1]->name); // Original bucket shifted to priority 2
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue