Buckets crud

This commit is contained in:
myrmidex 2025-12-29 23:32:05 +01:00
parent 8926ad6374
commit 74583a1c73
10 changed files with 1203 additions and 24 deletions

View 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;
}
}
}

View 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]);
}
}

View file

@ -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);
}

178
app/Models/Bucket.php Normal file
View 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'],
};
}
}

View 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,
};
}
}

View file

@ -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');
}
};

View file

@ -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 (
<>
<Head title={scenario.name} />
@ -39,30 +96,197 @@ export default function Show({ scenario }: Props) {
</div>
</div>
{/* Coming Soon Content */}
<div className="rounded-lg bg-white p-12 text-center shadow">
<div className="mx-auto h-16 w-16 text-blue-600">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</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="/"
{/* Bucket Dashboard */}
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-900">Buckets</h2>
<button
onClick={() => 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
</Link>
+ Add Bucket
</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>
{/* 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>
)}
</>
);
}

View file

@ -3,6 +3,7 @@
<head>
<meta charset="utf-8">
<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 --}}
<script>

View file

@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\BucketController;
use App\Http\Controllers\ScenarioController;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
@ -10,6 +11,13 @@
Route::get('/scenarios/{scenario}', [ScenarioController::class, 'show'])->name('scenarios.show');
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::get('dashboard', function () {
return Inertia::render('dashboard');

View 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
}
}