diff --git a/app/Http/Controllers/BudgetController.php b/app/Http/Controllers/BudgetController.php index c87f3de7..8587192b 100644 --- a/app/Http/Controllers/BudgetController.php +++ b/app/Http/Controllers/BudgetController.php @@ -9,6 +9,7 @@ use App\Models\Account; use App\Models\Bank; use App\Models\Budget; use App\Models\Category; +use App\Models\Label; use App\Services\BudgetPeriodService; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Http\RedirectResponse; @@ -28,7 +29,7 @@ class BudgetController extends Controller $user = $request->user(); $budgets = $user ->budgets() - ->with(['category', 'label', 'periods' => function ($query) { + ->with(['categories', 'labels', 'periods' => function ($query) { $query->where('start_date', '<=', today()) ->where('end_date', '>=', today()) ->with(['budgetTransactions']); @@ -80,7 +81,7 @@ class BudgetController extends Controller ->orderBy('start_date', 'asc') ->first(); - $budget->load(['category', 'label']); + $budget->load(['categories', 'labels']); $categories = Category::query() ->where('user_id', $user->id) @@ -101,6 +102,11 @@ class BudgetController extends Controller ->orderBy('name') ->get(['id', 'name', 'logo']); + $labels = Label::query() + ->where('user_id', $user->id) + ->orderBy('name') + ->get(['id', 'name', 'color']); + return Inertia::render('budgets/show', [ 'budget' => $budget, 'currentPeriod' => $viewedPeriod, @@ -109,6 +115,7 @@ class BudgetController extends Controller 'categories' => $categories, 'accounts' => $accounts, 'banks' => $banks, + 'labels' => $labels, 'currencyCode' => $user->currency_code ?? 'USD', ]); } @@ -120,18 +127,21 @@ class BudgetController extends Controller 'name' => $request->name, 'period_type' => $request->period_type, 'period_start_day' => $request->period_start_day, - 'category_id' => $request->category_id, - 'label_id' => $request->label_id, 'rollover_type' => $request->rollover_type, ]); - $period = $this->budgetPeriodService->generatePeriod($budget, $request->allocated_amount, null, true); + $budget->categories()->sync($request->category_ids ?? []); + $budget->labels()->sync($request->label_ids ?? []); - return ['budget' => $budget, 'period' => $period]; + $period = $this->budgetPeriodService->generatePeriod($budget, $request->allocated_amount, null, true); + $previousPeriod = $this->budgetPeriodService->generatePreviousPeriod($budget, $period, $request->allocated_amount, true); + + return ['budget' => $budget, 'period' => $period, 'previousPeriod' => $previousPeriod]; }); - // Dispatch job to assign historical transactions + // Dispatch jobs to assign historical transactions for the current and previous periods AssignHistoricalTransactionsToBudget::dispatch($result['budget'], $result['period']); + AssignHistoricalTransactionsToBudget::dispatch($result['budget'], $result['previousPeriod']); return redirect()->route('budgets.show', $result['budget']); } @@ -145,8 +155,6 @@ class BudgetController extends Controller 'name', 'period_type', 'period_start_day', - 'category_id', - 'label_id', 'rollover_type', ])); diff --git a/app/Http/Requests/StoreBudgetRequest.php b/app/Http/Requests/StoreBudgetRequest.php index 9fdd3bb8..447a0b05 100644 --- a/app/Http/Requests/StoreBudgetRequest.php +++ b/app/Http/Requests/StoreBudgetRequest.php @@ -22,8 +22,10 @@ class StoreBudgetRequest extends FormRequest 'name' => ['required', 'string', 'max:255'], 'period_type' => ['required', Rule::enum(BudgetPeriodType::class)], 'period_start_day' => ['nullable', 'integer', 'min:0', 'max:31'], - 'category_id' => ['nullable', Rule::exists('categories', 'id')->where('user_id', $userId)], - 'label_id' => ['nullable', Rule::exists('labels', 'id')->where('user_id', $userId)], + 'category_ids' => ['nullable', 'array'], + 'category_ids.*' => [Rule::exists('categories', 'id')->where('user_id', $userId)], + 'label_ids' => ['nullable', 'array'], + 'label_ids.*' => [Rule::exists('labels', 'id')->where('user_id', $userId)], 'rollover_type' => ['required', Rule::enum(RolloverType::class)], 'allocated_amount' => ['required', 'integer', 'min:0'], ]; @@ -32,13 +34,13 @@ class StoreBudgetRequest extends FormRequest public function withValidator($validator): void { $validator->after(function ($validator) { - $hasCategoryId = ! empty($this->category_id); - $hasLabelId = ! empty($this->label_id); + $hasCategories = ! empty($this->category_ids); + $hasLabels = ! empty($this->label_ids); - if (! $hasCategoryId && ! $hasLabelId) { + if (! $hasCategories && ! $hasLabels) { $validator->errors()->add( 'selection', - 'You must select either a category or a label.' + 'You must select at least one category or label.' ); } }); diff --git a/app/Http/Requests/UpdateBudgetRequest.php b/app/Http/Requests/UpdateBudgetRequest.php index 7cd5dca7..a67b05ca 100644 --- a/app/Http/Requests/UpdateBudgetRequest.php +++ b/app/Http/Requests/UpdateBudgetRequest.php @@ -16,14 +16,10 @@ class UpdateBudgetRequest extends FormRequest public function rules(): array { - $userId = $this->user()->id; - return [ 'name' => ['sometimes', 'string', 'max:255'], 'period_type' => ['sometimes', Rule::enum(BudgetPeriodType::class)], 'period_start_day' => ['nullable', 'integer', 'min:0', 'max:31'], - 'category_id' => ['nullable', Rule::exists('categories', 'id')->where('user_id', $userId)], - 'label_id' => ['nullable', Rule::exists('labels', 'id')->where('user_id', $userId)], 'rollover_type' => ['sometimes', Rule::enum(RolloverType::class)], 'allocated_amount' => ['sometimes', 'integer', 'min:0'], ]; diff --git a/app/Models/Budget.php b/app/Models/Budget.php index 9d87dde5..d70f6a0e 100644 --- a/app/Models/Budget.php +++ b/app/Models/Budget.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; @@ -24,8 +25,6 @@ class Budget extends Model 'name', 'period_type', 'period_start_day', - 'category_id', - 'label_id', 'rollover_type', ]; @@ -44,16 +43,16 @@ class Budget extends Model return $this->belongsTo(User::class); } - /** @return BelongsTo */ - public function category(): BelongsTo + /** @return BelongsToMany */ + public function categories(): BelongsToMany { - return $this->belongsTo(Category::class); + return $this->belongsToMany(Category::class); } - /** @return BelongsTo */ - public function label(): BelongsTo + /** @return BelongsToMany */ + public function labels(): BelongsToMany { - return $this->belongsTo(Label::class); + return $this->belongsToMany(Label::class); } /** @return HasMany */ diff --git a/app/Services/BudgetPeriodService.php b/app/Services/BudgetPeriodService.php index 7b7a2bb1..38c283fb 100644 --- a/app/Services/BudgetPeriodService.php +++ b/app/Services/BudgetPeriodService.php @@ -33,6 +33,13 @@ class BudgetPeriodService ]); } + public function generatePreviousPeriod(Budget $budget, BudgetPeriod $period, ?int $allocatedAmount = null, bool $processHistorical = false): BudgetPeriod + { + $referenceDate = $period->start_date->copy()->subDay(); + + return $this->generatePeriod($budget, $allocatedAmount ?? $period->allocated_amount, $referenceDate, $processHistorical); + } + public function closePeriod(BudgetPeriod $period): void { $budget = $period->budget; diff --git a/app/Services/BudgetTransactionService.php b/app/Services/BudgetTransactionService.php index 9afd1f37..befc7f34 100644 --- a/app/Services/BudgetTransactionService.php +++ b/app/Services/BudgetTransactionService.php @@ -21,22 +21,24 @@ class BudgetTransactionService // Ensure labels are available for matching (safe if already loaded). $transaction->loadMissing('labels'); + $transactionLabelIds = $transaction->labels->pluck('id'); + // Find budget periods that potentially match this transaction. $budgetPeriods = BudgetPeriod::query() - ->whereHas('budget', function ($query) use ($transaction, $userId) { + ->whereHas('budget', function ($query) use ($transaction, $transactionLabelIds, $userId) { $query->where('user_id', $userId) - ->where(function ($q) use ($transaction) { - $q->where('category_id', $transaction->category_id) - ->orWhere(function ($labelQuery) use ($transaction) { - $labelQuery->whereHas('label', function ($lq) use ($transaction) { - $lq->whereIn('id', $transaction->labels->pluck('id')); - }); + ->where(function ($q) use ($transaction, $transactionLabelIds) { + $q->whereHas('categories', function ($cq) use ($transaction) { + $cq->whereKey($transaction->category_id); + }) + ->orWhereHas('labels', function ($lq) use ($transactionLabelIds) { + $lq->whereIn('labels.id', $transactionLabelIds); }); }); }) ->where('start_date', '<=', $transaction->transaction_date) ->where('end_date', '>=', $transaction->transaction_date) - ->with('budget') + ->with('budget.categories:id', 'budget.labels:id') ->get(); // Narrow down to periods whose budget actually matches the transaction. @@ -45,8 +47,12 @@ class BudgetTransactionService foreach ($budgetPeriods as $period) { $budget = $period->budget; - $matchesCategory = $budget->category_id && $budget->category_id === $transaction->category_id; - $matchesLabel = $budget->label_id && $transaction->labels->contains('id', $budget->label_id); + $matchesCategory = $transaction->category_id + && $budget->categories->contains('id', $transaction->category_id); + $matchesLabel = $budget->labels + ->pluck('id') + ->intersect($transactionLabelIds) + ->isNotEmpty(); if ($matchesCategory || $matchesLabel) { $matchingPeriodIds[] = $period->id; @@ -91,7 +97,7 @@ class BudgetTransactionService public function assignHistoricalTransactionsToPeriod(BudgetPeriod $period): int { // Load the budget with its relationships - $budget = $period->budget()->with(['category', 'label'])->first(); + $budget = $period->budget()->with(['categories:id', 'labels:id'])->first(); if (! $budget) { return 0; @@ -99,10 +105,13 @@ class BudgetTransactionService $assignedCount = 0; + $categoryIds = $budget->categories->pluck('id'); + $labelIds = $budget->labels->pluck('id'); + Log::info('Building query for historical transactions', [ 'user_id' => $budget->user_id, - 'category_id' => $budget->category_id, - 'label_id' => $budget->label_id, + 'category_ids' => $categoryIds->all(), + 'label_ids' => $labelIds->all(), 'start_date' => $period->start_date->toDateString(), 'end_date' => $period->end_date->toDateString(), ]); @@ -113,15 +122,15 @@ class BudgetTransactionService ->whereBetween('transaction_date', [$period->start_date, $period->end_date]) ->withoutTrashed(); - // Filter by category OR label - $query->where(function ($q) use ($budget) { - if ($budget->category_id) { - $q->where('category_id', $budget->category_id); + // Filter by any tracked category OR label + $query->where(function ($q) use ($categoryIds, $labelIds) { + if ($categoryIds->isNotEmpty()) { + $q->whereIn('category_id', $categoryIds); } - if ($budget->label_id) { - $q->orWhereHas('labels', function ($labelQuery) use ($budget) { - $labelQuery->where('labels.id', $budget->label_id); + if ($labelIds->isNotEmpty()) { + $q->orWhereHas('labels', function ($labelQuery) use ($labelIds) { + $labelQuery->whereIn('labels.id', $labelIds); }); } }); diff --git a/database/factories/BudgetFactory.php b/database/factories/BudgetFactory.php index 2707e200..4285146f 100644 --- a/database/factories/BudgetFactory.php +++ b/database/factories/BudgetFactory.php @@ -4,8 +4,11 @@ namespace Database\Factories; use App\Enums\BudgetPeriodType; use App\Models\Budget; +use App\Models\Category; +use App\Models\Label; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Support\Arr; /** * @extends Factory @@ -50,4 +53,36 @@ class BudgetFactory extends Factory 'period_start_day' => 1, ]); } + + /** + * Attach one or more categories to the budget after creation. + * + * @param Category|array $categories + */ + public function forCategories(Category|array $categories): static + { + $ids = collect(Arr::wrap($categories)) + ->map(fn ($category) => $category instanceof Category ? $category->id : $category) + ->all(); + + return $this->afterCreating(function (Budget $budget) use ($ids) { + $budget->categories()->syncWithoutDetaching($ids); + }); + } + + /** + * Attach one or more labels to the budget after creation. + * + * @param Label|array $labels + */ + public function forLabels(Label|array $labels): static + { + $ids = collect(Arr::wrap($labels)) + ->map(fn ($label) => $label instanceof Label ? $label->id : $label) + ->all(); + + return $this->afterCreating(function (Budget $budget) use ($ids) { + $budget->labels()->syncWithoutDetaching($ids); + }); + } } diff --git a/database/migrations/2026_06_01_085554_create_budget_category_table.php b/database/migrations/2026_06_01_085554_create_budget_category_table.php new file mode 100644 index 00000000..302cb484 --- /dev/null +++ b/database/migrations/2026_06_01_085554_create_budget_category_table.php @@ -0,0 +1,28 @@ +foreignUuid('budget_id')->constrained()->cascadeOnDelete(); + $table->foreignUuid('category_id')->constrained()->cascadeOnDelete(); + $table->unique(['budget_id', 'category_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('budget_category'); + } +}; diff --git a/database/migrations/2026_06_01_085554_create_budget_label_table.php b/database/migrations/2026_06_01_085554_create_budget_label_table.php new file mode 100644 index 00000000..bf4a9780 --- /dev/null +++ b/database/migrations/2026_06_01_085554_create_budget_label_table.php @@ -0,0 +1,28 @@ +foreignUuid('budget_id')->constrained()->cascadeOnDelete(); + $table->foreignUuid('label_id')->constrained()->cascadeOnDelete(); + $table->unique(['budget_id', 'label_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('budget_label'); + } +}; diff --git a/database/migrations/2026_06_01_085554_migrate_budget_category_label_to_pivots.php b/database/migrations/2026_06_01_085554_migrate_budget_category_label_to_pivots.php new file mode 100644 index 00000000..c1a1921f --- /dev/null +++ b/database/migrations/2026_06_01_085554_migrate_budget_category_label_to_pivots.php @@ -0,0 +1,65 @@ +whereNotNull('category_id') + ->orderBy('id') + ->each(function ($budget) { + DB::table('budget_category')->insertOrIgnore([ + 'budget_id' => $budget->id, + 'category_id' => $budget->category_id, + ]); + }); + + DB::table('budgets') + ->whereNotNull('label_id') + ->orderBy('id') + ->each(function ($budget) { + DB::table('budget_label')->insertOrIgnore([ + 'budget_id' => $budget->id, + 'label_id' => $budget->label_id, + ]); + }); + + Schema::table('budgets', function (Blueprint $table) { + $table->dropConstrainedForeignId('category_id'); + $table->dropConstrainedForeignId('label_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('budgets', function (Blueprint $table) { + $table->foreignUuid('category_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignUuid('label_id')->nullable()->constrained()->nullOnDelete(); + }); + + DB::table('budget_category')->orderBy('budget_id')->each(function ($row) { + DB::table('budgets') + ->where('id', $row->budget_id) + ->whereNull('category_id') + ->update(['category_id' => $row->category_id]); + }); + + DB::table('budget_label')->orderBy('budget_id')->each(function ($row) { + DB::table('budgets') + ->where('id', $row->budget_id) + ->whereNull('label_id') + ->update(['label_id' => $row->label_id]); + }); + } +}; diff --git a/lang/es.json b/lang/es.json index 1f9c2f43..aabf9dba 100644 --- a/lang/es.json +++ b/lang/es.json @@ -1,4 +1,19 @@ { + "+:count": "+:count", + ":count selected": ":count seleccionadas", + "Categories": "Categorías", + "Labels": "Etiquetas", + "No results.": "Sin resultados.", + "Remove :label": "Eliminar :label", + "Search categories…": "Buscar categorías…", + "Search labels…": "Buscar etiquetas…", + "Search…": "Buscar…", + "Select at least one category or label to track.": "Selecciona al menos una categoría o etiqueta para seguir.", + "Select categories": "Seleccionar categorías", + "Select…": "Seleccionar…", + "Set up a spending limit across one or more categories or labels.": "Configura un límite de gasto en una o más categorías o etiquetas.", + "Tracked categories and labels cannot be changed after creation.": "Las categorías y etiquetas seguidas no se pueden cambiar después de la creación.", + "You must select at least one category or label.": "Debes seleccionar al menos una categoría o etiqueta.", " All communications between your device and our servers are protected using TLS (Transport Layer Security)": " Todas las comunicaciones entre tu dispositivo y nuestros servidores están protegidas con TLS (Transport Layer Security)", " All data is stored on secure servers with encryption at rest, protecting your information from unauthorized access": " Todos los datos se almacenan en servidores seguros con cifrado en reposo, protegiendo tu información del acceso no autorizado", " Strict access controls and authentication mechanisms protect against unauthorized access": " Controles de acceso estrictos y mecanismos de autenticación protegen contra el acceso no autorizado", diff --git a/resources/js/components/budgets/budget-list-card.tsx b/resources/js/components/budgets/budget-list-card.tsx index 1f93c24a..5d553a9c 100644 --- a/resources/js/components/budgets/budget-list-card.tsx +++ b/resources/js/components/budgets/budget-list-card.tsx @@ -73,10 +73,11 @@ export function BudgetListCard({ budget, currencyCode }: Props) { return 'text-green-600 dark:text-green-400'; }, [stats.percentageUsed]); - const trackingLabel = useMemo(() => { - if (budget.category) return budget.category.name; - if (budget.label) return budget.label.name; - return __('No tracking'); + const trackingNames = useMemo(() => { + return [ + ...(budget.categories?.map((category) => category.name) ?? []), + ...(budget.labels?.map((label) => label.name) ?? []), + ]; }, [budget]); return ( @@ -138,10 +139,32 @@ export function BudgetListCard({ budget, currencyCode }: Props) { -
- - {__('Tracking:')} {trackingLabel} - +
+
+ + {__('Tracking:')} + + {trackingNames.length > 0 ? ( + <> + {trackingNames.slice(0, 2).map((name) => ( + + {name} + + ))} + {trackingNames.length > 2 && ( + + {__('+:count', { + count: trackingNames.length - 2, + })} + + )} + + ) : ( + + {__('No tracking')} + + )} +
- )} -
+ { + const colorClasses = + getCategoryColorClasses( + category.color, + ); + const IconComponent = Icons[ + category.icon as keyof typeof Icons + ] as Icons.LucideIcon | undefined; + + return { + value: category.id, + label: category.name, + icon: IconComponent ? ( + + ) : undefined, + badgeClassName: cn( + colorClasses.bg, + colorClasses.text, + ), + }; + })} + selected={selectedCategoryIds} + onChange={setSelectedCategoryIds} + placeholder={__('Select categories')} + searchPlaceholder={__('Search categories…')} + emptyText={__('No categories found.')} + />
- - {__('Label (Optional)')} + + {__('Labels')} -
- - {selectedLabelId && ( - - )} -
+ { + const colorClasses = + getLabelColorClasses(label.color); + + return { + value: label.id, + label: label.name, + icon: ( + + ), + badgeClassName: cn( + colorClasses.bg, + colorClasses.text, + ), + }; + })} + selected={selectedLabelIds} + onChange={setSelectedLabelIds} + placeholder={__('Select labels')} + searchPlaceholder={__('Search labels…')} + emptyText={__('No labels found.')} + />

{__( - 'Select at least a category or a label to track.', + 'Select at least one category or label to track.', )}

@@ -385,7 +362,8 @@ export function CreateBudgetDialog({ disabled={ isSubmitting || !name || - (!selectedCategoryId && !selectedLabelId) || + (selectedCategoryIds.length === 0 && + selectedLabelIds.length === 0) || allocatedAmount <= 0 } > diff --git a/resources/js/components/budgets/edit-budget-dialog.test.tsx b/resources/js/components/budgets/edit-budget-dialog.test.tsx index 3b9e8974..4823d81c 100644 --- a/resources/js/components/budgets/edit-budget-dialog.test.tsx +++ b/resources/js/components/budgets/edit-budget-dialog.test.tsx @@ -24,8 +24,8 @@ function makeBudget(): Budget { name: 'Monthly budget', period_type: 'monthly', period_start_day: 1, - category_id: null, - label_id: null, + categories: [], + labels: [], rollover_type: 'carry_over', created_at: '2026-05-26T00:00:00.000000Z', updated_at: '2026-05-26T00:00:00.000000Z', diff --git a/resources/js/components/budgets/edit-budget-dialog.tsx b/resources/js/components/budgets/edit-budget-dialog.tsx index b02205e1..83c4c955 100644 --- a/resources/js/components/budgets/edit-budget-dialog.tsx +++ b/resources/js/components/budgets/edit-budget-dialog.tsx @@ -1,4 +1,6 @@ import { update } from '@/actions/App/Http/Controllers/BudgetController'; +import { CategoryBadge } from '@/components/shared/category-combobox'; +import { LabelBadge } from '@/components/shared/label-combobox'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { AmountInput } from '@/components/ui/amount-input'; import { Button } from '@/components/ui/button'; @@ -130,6 +132,26 @@ export function EditBudgetDialog({ /> +
+ +
+ {budget.categories?.map((category) => ( + + ))} + {budget.labels?.map((label) => ( + + ))} +
+

+ {__( + 'Tracked categories and labels cannot be changed after creation.', + )} +

+
+