Add streams
This commit is contained in:
parent
fdf6fb02d7
commit
e23ee84ce8
23 changed files with 1021 additions and 67 deletions
|
|
@ -62,15 +62,6 @@ public function execute(
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default buckets for a new scenario.
|
||||
*/
|
||||
public function createDefaultBuckets(Scenario $scenario): void
|
||||
{
|
||||
$this->execute($scenario, 'Monthly Expenses', Bucket::TYPE_FIXED_LIMIT, 0, 1);
|
||||
$this->execute($scenario, 'Emergency Fund', Bucket::TYPE_FIXED_LIMIT, 0, 2);
|
||||
$this->execute($scenario, 'Investments', Bucket::TYPE_UNLIMITED, null, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate allocation value based on allocation type.
|
||||
|
|
|
|||
31
app/Actions/CreateScenarioAction.php
Normal file
31
app/Actions/CreateScenarioAction.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Models\Bucket;
|
||||
use App\Models\Scenario;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
readonly class CreateScenarioAction
|
||||
{
|
||||
public function __construct(
|
||||
private CreateBucketAction $createBucketAction
|
||||
) {}
|
||||
|
||||
public function execute(array $data): Scenario
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
$scenario = Scenario::create($data);
|
||||
$this->createDefaultBuckets($scenario);
|
||||
|
||||
return $scenario;
|
||||
});
|
||||
}
|
||||
|
||||
private function createDefaultBuckets(Scenario $scenario): void
|
||||
{
|
||||
$this->createBucketAction->execute($scenario, 'Monthly Expenses', Bucket::TYPE_FIXED_LIMIT, 0, 1);
|
||||
$this->createBucketAction->execute($scenario, 'Emergency Fund', Bucket::TYPE_FIXED_LIMIT, 0, 2);
|
||||
$this->createBucketAction->execute($scenario, 'Investments', Bucket::TYPE_UNLIMITED, null, 3);
|
||||
}
|
||||
}
|
||||
13
app/Actions/DeleteScenarioAction.php
Normal file
13
app/Actions/DeleteScenarioAction.php
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Models\Scenario;
|
||||
|
||||
readonly class DeleteScenarioAction
|
||||
{
|
||||
public function execute(Scenario $scenario): void
|
||||
{
|
||||
$scenario->delete();
|
||||
}
|
||||
}
|
||||
15
app/Actions/UpdateScenarioAction.php
Normal file
15
app/Actions/UpdateScenarioAction.php
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Models\Scenario;
|
||||
|
||||
readonly class UpdateScenarioAction
|
||||
{
|
||||
public function execute(Scenario $scenario, array $data): Scenario
|
||||
{
|
||||
$scenario->update($data);
|
||||
|
||||
return $scenario;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,19 +2,37 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\CreateBucketAction;
|
||||
use App\Actions\CreateScenarioAction;
|
||||
use App\Actions\UpdateScenarioAction;
|
||||
use App\Actions\DeleteScenarioAction;
|
||||
use App\Http\Resources\BucketResource;
|
||||
use App\Http\Resources\StreamResource;
|
||||
use App\Http\Resources\ScenarioResource;
|
||||
use App\Http\Requests\StoreScenarioRequest;
|
||||
use App\Http\Requests\UpdateScenarioRequest;
|
||||
use App\Models\Scenario;
|
||||
use App\Repositories\StreamRepository;
|
||||
use App\Repositories\ScenarioRepository;
|
||||
use App\Services\Streams\StatsService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ScenarioController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ScenarioRepository $scenarioRepository,
|
||||
private readonly StreamRepository $streamRepository,
|
||||
private readonly CreateScenarioAction $createScenarioAction,
|
||||
private readonly UpdateScenarioAction $updateScenarioAction,
|
||||
private readonly DeleteScenarioAction $deleteScenarioAction,
|
||||
private readonly StatsService $statsService
|
||||
) {}
|
||||
|
||||
public function index(): Response
|
||||
{
|
||||
return Inertia::render('Scenarios/Index', [
|
||||
'scenarios' => Scenario::orderBy('created_at', 'desc')->get()
|
||||
'scenarios' => ScenarioResource::collection($this->scenarioRepository->getAll())
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -25,22 +43,10 @@ public function show(Scenario $scenario): Response
|
|||
}]);
|
||||
|
||||
return Inertia::render('Scenarios/Show', [
|
||||
'scenario' => $scenario,
|
||||
'buckets' => $scenario->buckets->map(function ($bucket) {
|
||||
return [
|
||||
'id' => $bucket->id,
|
||||
'name' => $bucket->name,
|
||||
'priority' => $bucket->priority,
|
||||
'sort_order' => $bucket->sort_order,
|
||||
'allocation_type' => $bucket->allocation_type,
|
||||
'allocation_value' => $bucket->allocation_value,
|
||||
'allocation_type_label' => $bucket->getAllocationTypeLabel(),
|
||||
'formatted_allocation_value' => $bucket->getFormattedAllocationValue(),
|
||||
'current_balance' => $bucket->getCurrentBalance(),
|
||||
'has_available_space' => $bucket->hasAvailableSpace(),
|
||||
'available_space' => $bucket->getAvailableSpace(),
|
||||
];
|
||||
})
|
||||
'scenario' => new ScenarioResource($scenario),
|
||||
'buckets' => BucketResource::collection($scenario->buckets),
|
||||
'streams' => StreamResource::collection($this->streamRepository->getForScenario($scenario)),
|
||||
'streamStats' => $this->statsService->getSummaryStats($scenario)
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -49,19 +55,9 @@ public function create(): Response
|
|||
return Inertia::render('Scenarios/Create');
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
public function store(StoreScenarioRequest $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
]);
|
||||
|
||||
$scenario = Scenario::create([
|
||||
'name' => $request->name,
|
||||
]);
|
||||
|
||||
// Create default buckets using the action
|
||||
$createBucketAction = new CreateBucketAction();
|
||||
$createBucketAction->createDefaultBuckets($scenario);
|
||||
$scenario = $this->createScenarioAction->execute($request->validated());
|
||||
|
||||
return redirect()->route('scenarios.show', $scenario);
|
||||
}
|
||||
|
|
@ -69,30 +65,25 @@ public function store(Request $request): RedirectResponse
|
|||
public function edit(Scenario $scenario): Response
|
||||
{
|
||||
return Inertia::render('Scenarios/Edit', [
|
||||
'scenario' => $scenario
|
||||
'scenario' => new ScenarioResource($scenario)
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Scenario $scenario): RedirectResponse
|
||||
public function update(UpdateScenarioRequest $request, Scenario $scenario): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'start_date' => 'nullable|date',
|
||||
'end_date' => 'nullable|date|after_or_equal:start_date',
|
||||
]);
|
||||
$this->updateScenarioAction->execute($scenario, $request->validated());
|
||||
|
||||
$scenario->update($request->only(['name', 'description', 'start_date', 'end_date']));
|
||||
|
||||
return redirect()->route('scenarios.show', $scenario)
|
||||
return redirect()
|
||||
->route('scenarios.show', $scenario)
|
||||
->with('success', 'Scenario updated successfully');
|
||||
}
|
||||
|
||||
public function destroy(Scenario $scenario): RedirectResponse
|
||||
{
|
||||
$scenario->delete();
|
||||
$this->deleteScenarioAction->execute($scenario);
|
||||
|
||||
return redirect()->route('scenarios.index')
|
||||
return redirect()
|
||||
->route('scenarios.index')
|
||||
->with('success', 'Scenario deleted successfully');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
69
app/Http/Controllers/StreamController.php
Normal file
69
app/Http/Controllers/StreamController.php
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\StoreStreamRequest;
|
||||
use App\Http\Requests\UpdateStreamRequest;
|
||||
use App\Models\Stream;
|
||||
use App\Models\Scenario;
|
||||
use App\Repositories\StreamRepository;
|
||||
use App\Services\Streams\StatsService;
|
||||
use App\Http\Resources\StreamResource;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class StreamController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly StreamRepository $streamRepository,
|
||||
private readonly StatsService $statsService
|
||||
) {}
|
||||
|
||||
public function index(Scenario $scenario): JsonResponse
|
||||
{
|
||||
$streams = $this->streamRepository->getForScenario($scenario);
|
||||
|
||||
return response()->json([
|
||||
'streams' => StreamResource::collection($streams),
|
||||
'stats' => $this->statsService->getSummaryStats($scenario)
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreStreamRequest $request, Scenario $scenario): JsonResponse
|
||||
{
|
||||
$stream = $this->streamRepository->create($scenario, $request->validated());
|
||||
|
||||
return response()->json([
|
||||
'stream' => new StreamResource($stream),
|
||||
'message' => 'Stream created successfully.'
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function update(UpdateStreamRequest $request, Stream $stream): JsonResponse
|
||||
{
|
||||
$stream = $this->streamRepository->update($stream, $request->validated());
|
||||
|
||||
return response()->json([
|
||||
'stream' => new StreamResource($stream),
|
||||
'message' => 'Stream updated successfully.'
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Stream $stream): JsonResponse
|
||||
{
|
||||
$this->streamRepository->delete($stream);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Stream deleted successfully.'
|
||||
]);
|
||||
}
|
||||
|
||||
public function toggle(Stream $stream): JsonResponse
|
||||
{
|
||||
$stream = $this->streamRepository->toggleActive($stream);
|
||||
|
||||
return response()->json([
|
||||
'stream' => new StreamResource($stream),
|
||||
'message' => $stream->is_active ? 'Stream activated.' : 'Stream deactivated.'
|
||||
]);
|
||||
}
|
||||
}
|
||||
42
app/Http/Requests/StoreScenarioRequest.php
Normal file
42
app/Http/Requests/StoreScenarioRequest.php
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreScenarioRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
// In production, check if user is authenticated
|
||||
// For now, allow all requests
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255', 'min:1'],
|
||||
'description' => ['nullable', 'string', 'max:1000'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'name.required' => 'A scenario name is required.',
|
||||
'name.min' => 'The scenario name must be at least 1 character.',
|
||||
'name.max' => 'The scenario name cannot exceed 255 characters.',
|
||||
];
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
// Trim the name
|
||||
if ($this->has('name')) {
|
||||
$this->merge([
|
||||
'name' => trim($this->name),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
107
app/Http/Requests/StoreStreamRequest.php
Normal file
107
app/Http/Requests/StoreStreamRequest.php
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\Stream;
|
||||
use App\Models\Scenario;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreStreamRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
// In production, check if user owns the scenario
|
||||
// For now, allow all requests
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'type' => ['required', Rule::in([Stream::TYPE_INCOME, Stream::TYPE_EXPENSE])],
|
||||
'amount' => ['required', 'numeric', 'min:0.01', 'max:999999999.99'],
|
||||
'frequency' => [
|
||||
'required',
|
||||
Rule::in([
|
||||
Stream::FREQUENCY_ONCE,
|
||||
Stream::FREQUENCY_WEEKLY,
|
||||
Stream::FREQUENCY_BIWEEKLY,
|
||||
Stream::FREQUENCY_MONTHLY,
|
||||
Stream::FREQUENCY_QUARTERLY,
|
||||
Stream::FREQUENCY_YEARLY,
|
||||
])
|
||||
],
|
||||
'start_date' => ['required', 'date', 'date_format:Y-m-d'],
|
||||
'end_date' => ['nullable', 'date', 'date_format:Y-m-d', 'after_or_equal:start_date'],
|
||||
'bucket_id' => ['nullable', 'exists:buckets,id'],
|
||||
'description' => ['nullable', 'string', 'max:1000'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'type.in' => 'The type must be either income or expense.',
|
||||
'frequency.in' => 'Invalid frequency selected.',
|
||||
'amount.min' => 'The amount must be greater than zero.',
|
||||
'amount.max' => 'The amount is too large.',
|
||||
'end_date.after_or_equal' => 'The end date must be after or equal to the start date.',
|
||||
'bucket_id.exists' => 'The selected bucket does not exist.',
|
||||
];
|
||||
}
|
||||
|
||||
public function withValidator($validator): void
|
||||
{
|
||||
$validator->after(function ($validator) {
|
||||
// Validate that the bucket belongs to the scenario
|
||||
if ($this->bucket_id) {
|
||||
/** @var Scenario $scenario */
|
||||
$scenario = $this->route('scenario');
|
||||
|
||||
$bucketBelongsToScenario = $scenario->buckets()
|
||||
->where('id', $this->bucket_id)
|
||||
->exists();
|
||||
|
||||
if (!$bucketBelongsToScenario) {
|
||||
$validator->errors()->add('bucket_id', 'The selected bucket does not belong to this scenario.');
|
||||
}
|
||||
}
|
||||
|
||||
// For expense streams, bucket is required
|
||||
if ($this->type === Stream::TYPE_EXPENSE && !$this->bucket_id) {
|
||||
$validator->errors()->add('bucket_id', 'A bucket must be selected for expense streams.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
// Ensure dates are in the correct format
|
||||
if ($this->has('start_date') && !empty($this->start_date)) {
|
||||
$this->merge([
|
||||
'start_date' => date('Y-m-d', strtotime($this->start_date)),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($this->has('end_date') && !empty($this->end_date)) {
|
||||
$this->merge([
|
||||
'end_date' => date('Y-m-d', strtotime($this->end_date)),
|
||||
]);
|
||||
}
|
||||
|
||||
// Convert amount to float
|
||||
if ($this->has('amount')) {
|
||||
$this->merge([
|
||||
'amount' => (float) $this->amount,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
50
app/Http/Requests/UpdateScenarioRequest.php
Normal file
50
app/Http/Requests/UpdateScenarioRequest.php
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateScenarioRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
// In production, check if user owns the scenario
|
||||
// For now, allow all requests
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255', 'min:1'],
|
||||
'description' => ['nullable', 'string', 'max:1000'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'name.required' => 'A scenario name is required.',
|
||||
'name.min' => 'The scenario name must be at least 1 character.',
|
||||
'name.max' => 'The scenario name cannot exceed 255 characters.',
|
||||
'description.max' => 'The description cannot exceed 1000 characters.',
|
||||
];
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
// Trim the name
|
||||
if ($this->has('name')) {
|
||||
$this->merge([
|
||||
'name' => trim($this->name),
|
||||
]);
|
||||
}
|
||||
|
||||
// Trim the description
|
||||
if ($this->has('description')) {
|
||||
$this->merge([
|
||||
'description' => $this->description ? trim($this->description) : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
111
app/Http/Requests/UpdateStreamRequest.php
Normal file
111
app/Http/Requests/UpdateStreamRequest.php
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\Stream;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateStreamRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
// In production, check if user owns the stream/scenario
|
||||
// For now, allow all requests
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'type' => ['required', Rule::in([Stream::TYPE_INCOME, Stream::TYPE_EXPENSE])],
|
||||
'amount' => ['required', 'numeric', 'min:0.01', 'max:999999999.99'],
|
||||
'frequency' => [
|
||||
'required',
|
||||
Rule::in([
|
||||
Stream::FREQUENCY_ONCE,
|
||||
Stream::FREQUENCY_WEEKLY,
|
||||
Stream::FREQUENCY_BIWEEKLY,
|
||||
Stream::FREQUENCY_MONTHLY,
|
||||
Stream::FREQUENCY_QUARTERLY,
|
||||
Stream::FREQUENCY_YEARLY,
|
||||
])
|
||||
],
|
||||
'start_date' => ['required', 'date', 'date_format:Y-m-d'],
|
||||
'end_date' => ['nullable', 'date', 'date_format:Y-m-d', 'after_or_equal:start_date'],
|
||||
'bucket_id' => ['nullable', 'exists:buckets,id'],
|
||||
'description' => ['nullable', 'string', 'max:1000'],
|
||||
'is_active' => ['boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'type.in' => 'The type must be either income or expense.',
|
||||
'frequency.in' => 'Invalid frequency selected.',
|
||||
'amount.min' => 'The amount must be greater than zero.',
|
||||
'amount.max' => 'The amount is too large.',
|
||||
'end_date.after_or_equal' => 'The end date must be after or equal to the start date.',
|
||||
'bucket_id.exists' => 'The selected bucket does not exist.',
|
||||
];
|
||||
}
|
||||
|
||||
public function withValidator($validator): void
|
||||
{
|
||||
$validator->after(function ($validator) {
|
||||
/** @var Stream $stream */
|
||||
$stream = $this->route('stream');
|
||||
|
||||
// Validate that the bucket belongs to the stream's scenario
|
||||
if ($this->bucket_id) {
|
||||
$bucketBelongsToScenario = $stream->scenario->buckets()
|
||||
->where('id', $this->bucket_id)
|
||||
->exists();
|
||||
|
||||
if (!$bucketBelongsToScenario) {
|
||||
$validator->errors()->add('bucket_id', 'The selected bucket does not belong to this scenario.');
|
||||
}
|
||||
}
|
||||
|
||||
// For expense streams, bucket is required
|
||||
if ($this->type === Stream::TYPE_EXPENSE && !$this->bucket_id) {
|
||||
$validator->errors()->add('bucket_id', 'A bucket must be selected for expense streams.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
// Ensure dates are in the correct format
|
||||
if ($this->has('start_date') && !empty($this->start_date)) {
|
||||
$this->merge([
|
||||
'start_date' => date('Y-m-d', strtotime($this->start_date)),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($this->has('end_date') && !empty($this->end_date)) {
|
||||
$this->merge([
|
||||
'end_date' => date('Y-m-d', strtotime($this->end_date)),
|
||||
]);
|
||||
}
|
||||
|
||||
// Convert amount to float
|
||||
if ($this->has('amount')) {
|
||||
$this->merge([
|
||||
'amount' => (float) $this->amount,
|
||||
]);
|
||||
}
|
||||
|
||||
// Default is_active to current value if not provided
|
||||
if (!$this->has('is_active')) {
|
||||
/** @var Stream $stream */
|
||||
$stream = $this->route('stream');
|
||||
$this->merge([
|
||||
'is_active' => $stream->is_active,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
26
app/Http/Resources/BucketResource.php
Normal file
26
app/Http/Resources/BucketResource.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class BucketResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'priority' => $this->priority,
|
||||
'sort_order' => $this->sort_order,
|
||||
'allocation_type' => $this->allocation_type,
|
||||
'allocation_value' => $this->allocation_value,
|
||||
'allocation_type_label' => $this->getAllocationTypeLabel(),
|
||||
'formatted_allocation_value' => $this->getFormattedAllocationValue(),
|
||||
'current_balance' => $this->getCurrentBalance(),
|
||||
'has_available_space' => $this->hasAvailableSpace(),
|
||||
'available_space' => $this->getAvailableSpace(),
|
||||
];
|
||||
}
|
||||
}
|
||||
20
app/Http/Resources/ScenarioResource.php
Normal file
20
app/Http/Resources/ScenarioResource.php
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class ScenarioResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'created_at' => $this->created_at,
|
||||
'updated_at' => $this->updated_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
29
app/Http/Resources/StreamResource.php
Normal file
29
app/Http/Resources/StreamResource.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class StreamResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'type' => $this->type,
|
||||
'type_label' => $this->getTypeLabel(),
|
||||
'amount' => $this->amount,
|
||||
'frequency' => $this->frequency,
|
||||
'frequency_label' => $this->getFrequencyLabel(),
|
||||
'start_date' => $this->start_date->format('Y-m-d'),
|
||||
'end_date' => $this->end_date?->format('Y-m-d'),
|
||||
'bucket_id' => $this->bucket_id,
|
||||
'bucket_name' => $this->bucket?->name,
|
||||
'description' => $this->description,
|
||||
'is_active' => $this->is_active,
|
||||
'monthly_equivalent' => $this->getMonthlyEquivalent(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -3,10 +3,15 @@
|
|||
namespace App\Models;
|
||||
|
||||
use Database\Factories\ScenarioFactory;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* @property Collection<Bucket> $buckets
|
||||
* @method static create(array $data)
|
||||
*/
|
||||
class Scenario extends Model
|
||||
{
|
||||
/** @use HasFactory<ScenarioFactory> */
|
||||
|
|
@ -14,34 +19,24 @@ class Scenario extends Model
|
|||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'description',
|
||||
];
|
||||
|
||||
|
||||
|
||||
public function buckets(): HasMany
|
||||
{
|
||||
return $this->hasMany(Bucket::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the streams for this scenario.
|
||||
*/
|
||||
public function streams(): HasMany
|
||||
{
|
||||
return $this->hasMany(Stream::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the inflows for this scenario.
|
||||
*/
|
||||
public function inflows(): HasMany
|
||||
{
|
||||
return $this->hasMany(Inflow::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the outflows for this scenario.
|
||||
*/
|
||||
public function outflows(): HasMany
|
||||
{
|
||||
return $this->hasMany(Outflow::class);
|
||||
|
|
|
|||
105
app/Models/Stream.php
Normal file
105
app/Models/Stream.php
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Stream extends Model
|
||||
{
|
||||
const TYPE_INCOME = 'income';
|
||||
const TYPE_EXPENSE = 'expense';
|
||||
|
||||
const FREQUENCY_ONCE = 'once';
|
||||
const FREQUENCY_WEEKLY = 'weekly';
|
||||
const FREQUENCY_BIWEEKLY = 'biweekly';
|
||||
const FREQUENCY_MONTHLY = 'monthly';
|
||||
const FREQUENCY_QUARTERLY = 'quarterly';
|
||||
const FREQUENCY_YEARLY = 'yearly';
|
||||
|
||||
protected $fillable = [
|
||||
'scenario_id',
|
||||
'name',
|
||||
'type',
|
||||
'amount',
|
||||
'frequency',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'bucket_id',
|
||||
'description',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'amount' => 'decimal:2',
|
||||
'start_date' => 'date',
|
||||
'end_date' => 'date',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
public function scenario(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Scenario::class);
|
||||
}
|
||||
|
||||
public function bucket(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Bucket::class);
|
||||
}
|
||||
|
||||
public static function getTypes(): array
|
||||
{
|
||||
return [
|
||||
self::TYPE_INCOME => 'Income',
|
||||
self::TYPE_EXPENSE => 'Expense',
|
||||
];
|
||||
}
|
||||
|
||||
public static function getFrequencies(): array
|
||||
{
|
||||
return [
|
||||
self::FREQUENCY_ONCE => 'One-time',
|
||||
self::FREQUENCY_WEEKLY => 'Weekly',
|
||||
self::FREQUENCY_BIWEEKLY => 'Bi-weekly',
|
||||
self::FREQUENCY_MONTHLY => 'Monthly',
|
||||
self::FREQUENCY_QUARTERLY => 'Quarterly',
|
||||
self::FREQUENCY_YEARLY => 'Yearly',
|
||||
];
|
||||
}
|
||||
|
||||
public function getTypeLabel(): string
|
||||
{
|
||||
return self::getTypes()[$this->type] ?? $this->type;
|
||||
}
|
||||
|
||||
public function getFrequencyLabel(): string
|
||||
{
|
||||
return self::getFrequencies()[$this->frequency] ?? $this->frequency;
|
||||
}
|
||||
|
||||
public function getMonthlyEquivalent(): float
|
||||
{
|
||||
switch ($this->frequency) {
|
||||
case self::FREQUENCY_WEEKLY:
|
||||
return $this->amount * 4.33; // Average weeks per month
|
||||
case self::FREQUENCY_BIWEEKLY:
|
||||
return $this->amount * 2.17;
|
||||
case self::FREQUENCY_MONTHLY:
|
||||
return $this->amount;
|
||||
case self::FREQUENCY_QUARTERLY:
|
||||
return $this->amount / 3;
|
||||
case self::FREQUENCY_YEARLY:
|
||||
return $this->amount / 12;
|
||||
case self::FREQUENCY_ONCE:
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* SCOPES */
|
||||
|
||||
public function scopeByType($query, string $type)
|
||||
{
|
||||
return $query->where('type', $type);
|
||||
}
|
||||
}
|
||||
16
app/Repositories/ScenarioRepository.php
Normal file
16
app/Repositories/ScenarioRepository.php
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\Models\Scenario;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class ScenarioRepository
|
||||
{
|
||||
public function getAll(): Collection
|
||||
{
|
||||
return Scenario::query()
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
}
|
||||
}
|
||||
90
app/Repositories/StreamRepository.php
Normal file
90
app/Repositories/StreamRepository.php
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<?php
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\Models\Stream;
|
||||
use App\Models\Scenario;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class StreamRepository
|
||||
{
|
||||
public function getForScenario(Scenario $scenario): Collection
|
||||
{
|
||||
return $scenario->streams()
|
||||
->with('bucket:id,name')
|
||||
->orderBy('type')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
}
|
||||
|
||||
public function create(Scenario $scenario, array $data): Stream
|
||||
{
|
||||
return $scenario->streams()->create($data);
|
||||
}
|
||||
|
||||
public function update(Stream $stream, array $data): Stream
|
||||
{
|
||||
$stream->update($data);
|
||||
|
||||
return $stream->fresh('bucket');
|
||||
}
|
||||
|
||||
public function delete(Stream $stream): bool
|
||||
{
|
||||
return $stream->delete();
|
||||
}
|
||||
|
||||
public function toggleActive(Stream $stream): Stream
|
||||
{
|
||||
$stream->update([
|
||||
'is_active' => !$stream->is_active
|
||||
]);
|
||||
|
||||
return $stream->fresh('bucket');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a bucket belongs to the scenario
|
||||
*/
|
||||
public function bucketBelongsToScenario(Scenario $scenario, ?int $bucketId): bool
|
||||
{
|
||||
if (! $bucketId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $scenario->buckets()
|
||||
->where('id', $bucketId)
|
||||
->exists();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Get streams grouped by type
|
||||
*/
|
||||
public function getGroupedByType(Scenario $scenario): array
|
||||
{
|
||||
return [
|
||||
'income' => $scenario->streams()
|
||||
->with('bucket:id,name')
|
||||
->byType(Stream::TYPE_INCOME)
|
||||
->orderBy('name')
|
||||
->get(),
|
||||
'expense' => $scenario->streams()
|
||||
->with('bucket:id,name')
|
||||
->byType(Stream::TYPE_EXPENSE)
|
||||
->orderBy('name')
|
||||
->get(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get streams for a specific bucket
|
||||
*/
|
||||
public function getForBucket(int $bucketId): Collection
|
||||
{
|
||||
return Stream::where('bucket_id', $bucketId)
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
}
|
||||
}
|
||||
34
app/Services/Streams/StatsService.php
Normal file
34
app/Services/Streams/StatsService.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Streams;
|
||||
|
||||
use App\Models\Stream;
|
||||
use App\Models\Scenario;
|
||||
|
||||
readonly class StatsService
|
||||
{
|
||||
public function getSummaryStats(Scenario $scenario): array
|
||||
{
|
||||
$streams = $scenario->streams()
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
|
||||
$totalMonthlyIncome = $streams
|
||||
->where('type', Stream::TYPE_INCOME)
|
||||
->sum(fn($stream) => $stream->getMonthlyEquivalent());
|
||||
|
||||
$totalMonthlyExpenses = $streams
|
||||
->where('type', Stream::TYPE_EXPENSE)
|
||||
->sum(fn($stream) => $stream->getMonthlyEquivalent());
|
||||
|
||||
return [
|
||||
'total_streams' => $streams->count(),
|
||||
'active_streams' => $streams->where('is_active', true)->count(),
|
||||
'income_streams' => $streams->where('type', Stream::TYPE_INCOME)->count(),
|
||||
'expense_streams' => $streams->where('type', Stream::TYPE_EXPENSE)->count(),
|
||||
'monthly_income' => $totalMonthlyIncome,
|
||||
'monthly_expenses' => $totalMonthlyExpenses,
|
||||
'monthly_net' => $totalMonthlyIncome - $totalMonthlyExpenses,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ public function definition(): array
|
|||
{
|
||||
return [
|
||||
'name' => $this->faker->words(2, true) . ' Budget',
|
||||
'description' => $this->faker->text,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ public function up(): void
|
|||
Schema::create('scenarios', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->text('description')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('streams', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('scenario_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('bucket_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->string('name');
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->decimal('amount', 12, 2);
|
||||
$table->enum('type', ['income', 'expense']);
|
||||
$table->enum('frequency', ['once', 'weekly', 'biweekly', 'monthly', 'quarterly', 'yearly']);
|
||||
$table->date('start_date');
|
||||
$table->date('end_date')->nullable();
|
||||
$table->text('description')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['scenario_id', 'is_active']);
|
||||
$table->index('start_date');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('streams');
|
||||
}
|
||||
};
|
||||
|
|
@ -22,12 +22,41 @@ interface Bucket {
|
|||
available_space: number;
|
||||
}
|
||||
|
||||
interface Stream {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'income' | 'expense';
|
||||
type_label: string;
|
||||
amount: number;
|
||||
frequency: string;
|
||||
frequency_label: string;
|
||||
start_date: string;
|
||||
end_date: string | null;
|
||||
bucket_id: number | null;
|
||||
bucket_name: string | null;
|
||||
description: string | null;
|
||||
is_active: boolean;
|
||||
monthly_equivalent: number;
|
||||
}
|
||||
|
||||
interface StreamStats {
|
||||
total_streams: number;
|
||||
active_streams: number;
|
||||
income_streams: number;
|
||||
expense_streams: number;
|
||||
monthly_income: number;
|
||||
monthly_expenses: number;
|
||||
monthly_net: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
scenario: Scenario;
|
||||
buckets: Bucket[];
|
||||
streams: Stream[];
|
||||
streamStats?: StreamStats;
|
||||
}
|
||||
|
||||
export default function Show({ scenario, buckets }: Props) {
|
||||
export default function Show({ scenario, buckets, streams = [], streamStats }: Props) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [editingBucket, setEditingBucket] = useState<Bucket | null>(null);
|
||||
|
|
@ -302,13 +331,159 @@ export default function Show({ scenario, buckets }: Props) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Streams Section */}
|
||||
<div className="mt-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Income & Expense Streams</h2>
|
||||
<button
|
||||
className="rounded-md bg-green-600 px-4 py-2 text-sm font-semibold text-white hover:bg-green-500"
|
||||
>
|
||||
+ Add Stream
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stream Statistics */}
|
||||
{streamStats && (
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4 mb-6">
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">
|
||||
Monthly Income
|
||||
</dt>
|
||||
<dd className="mt-1 text-3xl font-semibold text-green-600">
|
||||
${streamStats.monthly_income.toFixed(2)}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">
|
||||
Monthly Expenses
|
||||
</dt>
|
||||
<dd className="mt-1 text-3xl font-semibold text-red-600">
|
||||
${streamStats.monthly_expenses.toFixed(2)}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">
|
||||
Net Cash Flow
|
||||
</dt>
|
||||
<dd className={`mt-1 text-3xl font-semibold ${streamStats.monthly_net >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
${streamStats.monthly_net.toFixed(2)}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">
|
||||
Active Streams
|
||||
</dt>
|
||||
<dd className="mt-1 text-3xl font-semibold text-gray-900">
|
||||
{streamStats.active_streams} / {streamStats.total_streams}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{streams.length === 0 ? (
|
||||
<div className="rounded-lg bg-white p-8 text-center shadow">
|
||||
<p className="text-gray-600">No streams yet. Add income or expense streams to start tracking cash flow.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
|
||||
<table className="min-w-full divide-y divide-gray-300">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Type
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Amount
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Frequency
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Bucket
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Start Date
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
|
||||
Status
|
||||
</th>
|
||||
<th className="relative px-6 py-3">
|
||||
<span className="sr-only">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 bg-white">
|
||||
{streams.map((stream) => (
|
||||
<tr key={stream.id}>
|
||||
<td className="whitespace-nowrap px-6 py-4 text-sm font-medium text-gray-900">
|
||||
{stream.name}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
||||
<span className={`inline-flex rounded-full px-2 text-xs font-semibold leading-5 ${
|
||||
stream.type === 'income'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{stream.type_label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
||||
${stream.amount.toFixed(2)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
||||
{stream.frequency_label}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
||||
{stream.bucket_name || '-'}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
||||
{new Date(stream.start_date).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
||||
<button
|
||||
className={`inline-flex rounded-full px-2 text-xs font-semibold leading-5 ${
|
||||
stream.is_active
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{stream.is_active ? 'Active' : 'Inactive'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
|
||||
<button className="text-indigo-600 hover:text-indigo-900 mr-3">
|
||||
Edit
|
||||
</button>
|
||||
<button className="text-red-600 hover:text-red-900">
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Placeholder for future features */}
|
||||
<div className="rounded-lg bg-blue-50 p-6 text-center">
|
||||
<h3 className="text-lg font-medium text-blue-900">
|
||||
Coming Next: Streams & Timeline
|
||||
Coming Next: Timeline & Projections
|
||||
</h3>
|
||||
<p className="text-sm text-blue-700 mt-2">
|
||||
Add income and expense streams, then calculate projections to see your money flow through these buckets over time.
|
||||
Calculate projections to see your money flow through these buckets over time.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
use App\Http\Controllers\BucketController;
|
||||
use App\Http\Controllers\ScenarioController;
|
||||
use App\Http\Controllers\StreamController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Inertia\Inertia;
|
||||
use Laravel\Fortify\Features;
|
||||
|
|
@ -22,6 +23,13 @@
|
|||
Route::delete('/buckets/{bucket}', [BucketController::class, 'destroy'])->name('buckets.destroy');
|
||||
Route::patch('/scenarios/{scenario}/buckets/priorities', [BucketController::class, 'updatePriorities'])->name('buckets.update-priorities');
|
||||
|
||||
// Stream routes (no auth required for MVP)
|
||||
Route::get('/scenarios/{scenario}/streams', [StreamController::class, 'index'])->name('streams.index');
|
||||
Route::post('/scenarios/{scenario}/streams', [StreamController::class, 'store'])->name('streams.store');
|
||||
Route::patch('/streams/{stream}', [StreamController::class, 'update'])->name('streams.update');
|
||||
Route::delete('/streams/{stream}', [StreamController::class, 'destroy'])->name('streams.destroy');
|
||||
Route::patch('/streams/{stream}/toggle', [StreamController::class, 'toggle'])->name('streams.toggle');
|
||||
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::get('dashboard', function () {
|
||||
return Inertia::render('dashboard');
|
||||
|
|
|
|||
Loading…
Reference in a new issue