diff --git a/app/Enums/LabelColor.php b/app/Enums/LabelColor.php new file mode 100644 index 00000000..e74a6a3f --- /dev/null +++ b/app/Enums/LabelColor.php @@ -0,0 +1,27 @@ +user() ->automationRules() - ->with('category:id,name,icon,color') + ->with(['category:id,name,icon,color', 'labels:id,name,color']) ->orderBy('priority') ->get(['id', 'title', 'priority', 'rules_json', 'action_category_id', 'action_note', 'action_note_iv']); @@ -36,7 +36,15 @@ class AutomationRuleController extends Controller */ public function store(StoreAutomationRuleRequest $request): RedirectResponse { - auth()->user()->automationRules()->create($request->validated()); + $validated = $request->validated(); + $labelIds = $validated['action_label_ids'] ?? []; + unset($validated['action_label_ids']); + + $rule = auth()->user()->automationRules()->create($validated); + + if (! empty($labelIds)) { + $rule->labels()->sync($labelIds); + } return back(); } @@ -48,7 +56,12 @@ class AutomationRuleController extends Controller { $this->authorize('update', $automationRule); - $automationRule->update($request->validated()); + $validated = $request->validated(); + $labelIds = $validated['action_label_ids'] ?? []; + unset($validated['action_label_ids']); + + $automationRule->update($validated); + $automationRule->labels()->sync($labelIds); return back(); } diff --git a/app/Http/Controllers/Settings/LabelController.php b/app/Http/Controllers/Settings/LabelController.php new file mode 100644 index 00000000..2e91deea --- /dev/null +++ b/app/Http/Controllers/Settings/LabelController.php @@ -0,0 +1,71 @@ +user() + ->labels() + ->orderBy('name') + ->get(['id', 'name', 'color']); + + return Inertia::render('settings/labels', [ + 'labels' => $labels, + ]); + } + + /** + * Store a newly created label. + */ + public function store(StoreLabelRequest $request): JsonResponse|RedirectResponse + { + $label = auth()->user()->labels()->create($request->validated()); + + if ($request->expectsJson()) { + return response()->json(['data' => $label], 201); + } + + return to_route('labels.index'); + } + + /** + * Update the specified label. + */ + public function update(UpdateLabelRequest $request, Label $label): RedirectResponse + { + $this->authorize('update', $label); + + $label->update($request->validated()); + + return to_route('labels.index'); + } + + /** + * Soft delete the specified label. + */ + public function destroy(Label $label): RedirectResponse + { + $this->authorize('delete', $label); + + $label->delete(); + + return to_route('labels.index'); + } +} diff --git a/app/Http/Controllers/Sync/AutomationRuleSyncController.php b/app/Http/Controllers/Sync/AutomationRuleSyncController.php index 5121ff51..a56741fd 100644 --- a/app/Http/Controllers/Sync/AutomationRuleSyncController.php +++ b/app/Http/Controllers/Sync/AutomationRuleSyncController.php @@ -14,7 +14,7 @@ class AutomationRuleSyncController extends Controller { $rules = auth()->user() ->automationRules() - ->with('category:id,name,icon,color') + ->with(['category:id,name,icon,color', 'labels:id,name,color']) ->orderBy('priority') ->get(); diff --git a/app/Http/Controllers/Sync/LabelSyncController.php b/app/Http/Controllers/Sync/LabelSyncController.php new file mode 100644 index 00000000..8ad10421 --- /dev/null +++ b/app/Http/Controllers/Sync/LabelSyncController.php @@ -0,0 +1,26 @@ +where('user_id', $request->user()->id); + + if ($request->has('since')) { + $query->where('updated_at', '>', $request->input('since')); + } + + $labels = $query->orderBy('name', 'asc')->get(); + + return response()->json([ + 'data' => $labels, + ]); + } +} diff --git a/app/Http/Controllers/Sync/TransactionSyncController.php b/app/Http/Controllers/Sync/TransactionSyncController.php index c2c33935..537f585b 100644 --- a/app/Http/Controllers/Sync/TransactionSyncController.php +++ b/app/Http/Controllers/Sync/TransactionSyncController.php @@ -20,6 +20,7 @@ class TransactionSyncController extends Controller } $transactions = $query + ->with('labels:id,name,color') ->orderBy('transaction_date', 'desc') ->orderBy('updated_at', 'desc') ->get(); @@ -32,6 +33,8 @@ class TransactionSyncController extends Controller public function store(StoreTransactionRequest $request): JsonResponse { $data = $request->validated(); + $labelIds = $data['label_ids'] ?? []; + unset($data['label_ids']); // Create transaction with provided ID if available $transaction = new Transaction([ @@ -47,8 +50,12 @@ class TransactionSyncController extends Controller $transaction->save(); + if (! empty($labelIds)) { + $transaction->labels()->sync($labelIds); + } + return response()->json([ - 'data' => $transaction, + 'data' => $transaction->load('labels:id,name,color'), ], 201); } @@ -62,10 +69,17 @@ class TransactionSyncController extends Controller $data = $request->validated(); unset($data['id']); // Don't allow ID changes + $labelIds = $data['label_ids'] ?? null; + unset($data['label_ids']); + $transaction->update($data); + if ($labelIds !== null) { + $transaction->labels()->sync($labelIds); + } + return response()->json([ - 'data' => $transaction->fresh(), + 'data' => $transaction->fresh()->load('labels:id,name,color'), ]); } diff --git a/app/Http/Controllers/TransactionController.php b/app/Http/Controllers/TransactionController.php index 27ab2bdb..c52062ba 100644 --- a/app/Http/Controllers/TransactionController.php +++ b/app/Http/Controllers/TransactionController.php @@ -8,6 +8,7 @@ use App\Http\Requests\UpdateTransactionRequest; use App\Models\Account; use App\Models\Bank; use App\Models\Category; +use App\Models\Label; use App\Models\Transaction; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Http\JsonResponse; @@ -42,10 +43,16 @@ class TransactionController 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('transactions/index', [ 'categories' => $categories, 'accounts' => $accounts, 'banks' => $banks, + 'labels' => $labels, ]); } @@ -72,10 +79,16 @@ class TransactionController 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('transactions/categorize', [ 'categories' => $categories, 'accounts' => $accounts, 'banks' => $banks, + 'labels' => $labels, ]); } @@ -149,16 +162,27 @@ class TransactionController extends Controller $updateData['notes_iv'] = $request->input('notes_iv'); } - if (empty($updateData)) { + $labelIds = $request->input('label_ids'); + $hasLabelUpdate = $request->has('label_ids'); + + if (empty($updateData) && ! $hasLabelUpdate) { return response()->json([ 'message' => 'No update data provided.', ], 400); } - Transaction::query() - ->whereIn('id', $transactionIds) - ->where('user_id', $user->id) - ->update($updateData); + if (! empty($updateData)) { + Transaction::query() + ->whereIn('id', $transactionIds) + ->where('user_id', $user->id) + ->update($updateData); + } + + if ($hasLabelUpdate) { + foreach ($transactions as $transaction) { + $transaction->labels()->sync($labelIds ?? []); + } + } return response()->json([ 'message' => 'Transactions updated successfully', diff --git a/app/Http/Requests/BulkUpdateTransactionsRequest.php b/app/Http/Requests/BulkUpdateTransactionsRequest.php index 5738f6f6..c59ae390 100644 --- a/app/Http/Requests/BulkUpdateTransactionsRequest.php +++ b/app/Http/Requests/BulkUpdateTransactionsRequest.php @@ -19,6 +19,8 @@ class BulkUpdateTransactionsRequest extends FormRequest 'category_id' => ['nullable', 'exists:categories,id'], 'notes' => ['nullable', 'string'], 'notes_iv' => ['nullable', 'string', 'size:16'], + 'label_ids' => ['nullable', 'array'], + 'label_ids.*' => ['required', 'string', 'uuid', 'exists:labels,id'], ]; } diff --git a/app/Http/Requests/Settings/StoreAutomationRuleRequest.php b/app/Http/Requests/Settings/StoreAutomationRuleRequest.php index e05b5c62..ae75e43a 100644 --- a/app/Http/Requests/Settings/StoreAutomationRuleRequest.php +++ b/app/Http/Requests/Settings/StoreAutomationRuleRequest.php @@ -41,6 +41,15 @@ class StoreAutomationRuleRequest extends FormRequest ], 'action_note' => ['nullable', 'string'], 'action_note_iv' => ['nullable', 'string', 'required_with:action_note'], + 'action_label_ids' => ['nullable', 'array'], + 'action_label_ids.*' => [ + 'required', + 'string', + 'uuid', + Rule::exists('labels', 'id')->where(function ($query) { + $query->where('user_id', auth()->id()); + }), + ], ]; } @@ -50,8 +59,9 @@ class StoreAutomationRuleRequest extends FormRequest public function withValidator($validator): void { $validator->after(function ($validator) { - if (! $this->action_category_id && ! $this->action_note) { - $validator->errors()->add('action_category_id', 'At least one action (category or note) must be provided.'); + $hasLabels = ! empty($this->action_label_ids); + if (! $this->action_category_id && ! $this->action_note && ! $hasLabels) { + $validator->errors()->add('action_category_id', 'At least one action (category, note, or labels) must be provided.'); } }); } diff --git a/app/Http/Requests/Settings/StoreLabelRequest.php b/app/Http/Requests/Settings/StoreLabelRequest.php new file mode 100644 index 00000000..659a5558 --- /dev/null +++ b/app/Http/Requests/Settings/StoreLabelRequest.php @@ -0,0 +1,43 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => [ + 'required', + 'string', + 'max:255', + Rule::unique('labels', 'name') + ->where('user_id', auth()->id()) + ->whereNull('deleted_at'), + ], + 'color' => [ + 'required', + 'string', + Rule::enum(LabelColor::class), + ], + ]; + } +} diff --git a/app/Http/Requests/Settings/UpdateAutomationRuleRequest.php b/app/Http/Requests/Settings/UpdateAutomationRuleRequest.php index ed27d67e..2d605817 100644 --- a/app/Http/Requests/Settings/UpdateAutomationRuleRequest.php +++ b/app/Http/Requests/Settings/UpdateAutomationRuleRequest.php @@ -41,6 +41,15 @@ class UpdateAutomationRuleRequest extends FormRequest ], 'action_note' => ['nullable', 'string'], 'action_note_iv' => ['nullable', 'string', 'required_with:action_note'], + 'action_label_ids' => ['nullable', 'array'], + 'action_label_ids.*' => [ + 'required', + 'string', + 'uuid', + Rule::exists('labels', 'id')->where(function ($query) { + $query->where('user_id', auth()->id()); + }), + ], ]; } @@ -50,8 +59,9 @@ class UpdateAutomationRuleRequest extends FormRequest public function withValidator($validator): void { $validator->after(function ($validator) { - if (! $this->action_category_id && ! $this->action_note) { - $validator->errors()->add('action_category_id', 'At least one action (category or note) must be provided.'); + $hasLabels = ! empty($this->action_label_ids); + if (! $this->action_category_id && ! $this->action_note && ! $hasLabels) { + $validator->errors()->add('action_category_id', 'At least one action (category, note, or labels) must be provided.'); } }); } diff --git a/app/Http/Requests/Settings/UpdateLabelRequest.php b/app/Http/Requests/Settings/UpdateLabelRequest.php new file mode 100644 index 00000000..f0b5e48e --- /dev/null +++ b/app/Http/Requests/Settings/UpdateLabelRequest.php @@ -0,0 +1,46 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => [ + 'sometimes', + 'required', + 'string', + 'max:255', + Rule::unique('labels', 'name') + ->where('user_id', auth()->id()) + ->whereNull('deleted_at') + ->ignore($this->route('label')), + ], + 'color' => [ + 'sometimes', + 'required', + 'string', + Rule::enum(LabelColor::class), + ], + ]; + } +} diff --git a/app/Http/Requests/StoreTransactionRequest.php b/app/Http/Requests/StoreTransactionRequest.php index 5538b1b2..764aae12 100644 --- a/app/Http/Requests/StoreTransactionRequest.php +++ b/app/Http/Requests/StoreTransactionRequest.php @@ -27,6 +27,8 @@ class StoreTransactionRequest extends FormRequest 'notes' => ['nullable', 'string'], 'notes_iv' => ['nullable', 'string', 'size:16'], 'source' => ['required', Rule::enum(TransactionSource::class)], + 'label_ids' => ['nullable', 'array'], + 'label_ids.*' => ['string', 'uuid', 'exists:labels,id'], ]; } diff --git a/app/Models/AutomationRule.php b/app/Models/AutomationRule.php index 5720e6bc..faefea94 100644 --- a/app/Models/AutomationRule.php +++ b/app/Models/AutomationRule.php @@ -6,6 +6,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\SoftDeletes; class AutomationRule extends Model @@ -40,4 +41,10 @@ class AutomationRule extends Model { return $this->belongsTo(Category::class, 'action_category_id'); } + + public function labels(): BelongsToMany + { + return $this->belongsToMany(Label::class, 'automation_rule_labels') + ->using(AutomationRuleLabel::class); + } } diff --git a/app/Models/AutomationRuleLabel.php b/app/Models/AutomationRuleLabel.php new file mode 100644 index 00000000..fdafacea --- /dev/null +++ b/app/Models/AutomationRuleLabel.php @@ -0,0 +1,17 @@ + */ + use HasFactory, HasUuids, SoftDeletes; + + protected $fillable = [ + 'name', + 'color', + 'user_id', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function transactions(): BelongsToMany + { + return $this->belongsToMany(Transaction::class) + ->using(LabelTransaction::class) + ->withTimestamps(); + } + + public function automationRules(): BelongsToMany + { + return $this->belongsToMany(AutomationRule::class, 'automation_rule_labels') + ->using(AutomationRuleLabel::class); + } +} diff --git a/app/Models/LabelTransaction.php b/app/Models/LabelTransaction.php new file mode 100644 index 00000000..c563b8d2 --- /dev/null +++ b/app/Models/LabelTransaction.php @@ -0,0 +1,17 @@ +belongsTo(Category::class); } + + public function labels(): BelongsToMany + { + return $this->belongsToMany(Label::class) + ->using(LabelTransaction::class) + ->withTimestamps(); + } } diff --git a/app/Models/User.php b/app/Models/User.php index 131f382d..75ae5fc3 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -87,6 +87,11 @@ class User extends Authenticatable return $this->hasMany(AutomationRule::class); } + public function labels(): HasMany + { + return $this->hasMany(Label::class); + } + public function hasProPlan(): bool { if (! config('subscriptions.enabled')) { diff --git a/app/Policies/LabelPolicy.php b/app/Policies/LabelPolicy.php new file mode 100644 index 00000000..98403610 --- /dev/null +++ b/app/Policies/LabelPolicy.php @@ -0,0 +1,65 @@ +id === $label->user_id; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Label $label): bool + { + return $user->id === $label->user_id; + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, Label $label): bool + { + return false; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, Label $label): bool + { + return false; + } +} diff --git a/database/factories/LabelFactory.php b/database/factories/LabelFactory.php new file mode 100644 index 00000000..08656762 --- /dev/null +++ b/database/factories/LabelFactory.php @@ -0,0 +1,26 @@ + + */ +class LabelFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->unique()->word(), + 'color' => fake()->randomElement(['amber', 'blue', 'cyan', 'emerald', 'gray', 'green', 'indigo', 'orange', 'pink', 'purple', 'red', 'slate', 'teal', 'yellow']), + 'user_id' => User::factory(), + ]; + } +} diff --git a/database/migrations/2025_12_12_092647_create_labels_table.php b/database/migrations/2025_12_12_092647_create_labels_table.php new file mode 100644 index 00000000..cccb5f0a --- /dev/null +++ b/database/migrations/2025_12_12_092647_create_labels_table.php @@ -0,0 +1,33 @@ +uuid('id')->primary(); + $table->foreignUuid('user_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('color', 50); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['user_id', 'name']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('labels'); + } +}; diff --git a/database/migrations/2025_12_12_092650_create_label_transaction_table.php b/database/migrations/2025_12_12_092650_create_label_transaction_table.php new file mode 100644 index 00000000..0ffaaff8 --- /dev/null +++ b/database/migrations/2025_12_12_092650_create_label_transaction_table.php @@ -0,0 +1,31 @@ +uuid('id')->primary(); + $table->foreignUuid('label_id')->constrained()->cascadeOnDelete(); + $table->foreignUuid('transaction_id')->constrained()->cascadeOnDelete(); + $table->timestamps(); + + $table->unique(['label_id', 'transaction_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('label_transaction'); + } +}; diff --git a/database/migrations/2025_12_12_092651_create_automation_rule_labels_table.php b/database/migrations/2025_12_12_092651_create_automation_rule_labels_table.php new file mode 100644 index 00000000..bfb2e4be --- /dev/null +++ b/database/migrations/2025_12_12_092651_create_automation_rule_labels_table.php @@ -0,0 +1,30 @@ +uuid('id')->primary(); + $table->foreignUuid('automation_rule_id')->constrained()->cascadeOnDelete(); + $table->foreignUuid('label_id')->constrained()->cascadeOnDelete(); + + $table->unique(['automation_rule_id', 'label_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('automation_rule_labels'); + } +}; diff --git a/database/migrations/2025_12_12_141955_update_labels_unique_constraint_include_deleted_at.php b/database/migrations/2025_12_12_141955_update_labels_unique_constraint_include_deleted_at.php new file mode 100644 index 00000000..dded1998 --- /dev/null +++ b/database/migrations/2025_12_12_141955_update_labels_unique_constraint_include_deleted_at.php @@ -0,0 +1,34 @@ +dropForeign(['user_id']); + $table->dropUnique(['user_id', 'name']); + $table->unique(['user_id', 'name', 'deleted_at']); + $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('labels', function (Blueprint $table) { + $table->dropForeign(['user_id']); + $table->dropUnique(['user_id', 'name', 'deleted_at']); + $table->unique(['user_id', 'name']); + $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete(); + }); + } +}; diff --git a/resources/js/components/automation-rules/create-automation-rule-dialog.tsx b/resources/js/components/automation-rules/create-automation-rule-dialog.tsx index fedf3ddd..fa3fd4d3 100644 --- a/resources/js/components/automation-rules/create-automation-rule-dialog.tsx +++ b/resources/js/components/automation-rules/create-automation-rule-dialog.tsx @@ -1,6 +1,7 @@ import { store } from '@/actions/App/Http/Controllers/Settings/AutomationRuleController'; import { RuleBuilder } from '@/components/automation-rules/rule-builder'; import { CategoryCombobox } from '@/components/shared/category-combobox'; +import { LabelCombobox } from '@/components/shared/label-combobox'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -11,7 +12,7 @@ import { DialogTrigger, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; +import { Label as FormLabel } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { encrypt, importKey } from '@/lib/crypto'; import { getStoredKey } from '@/lib/key-storage'; @@ -23,7 +24,9 @@ import { } from '@/lib/rule-builder-utils'; import { automationRuleSyncService } from '@/services/automation-rule-sync'; import { categorySyncService } from '@/services/category-sync'; +import { labelSyncService } from '@/services/label-sync'; import type { Category } from '@/types/category'; +import type { Label } from '@/types/label'; import { router } from '@inertiajs/react'; import { useEffect, useState } from 'react'; @@ -38,6 +41,7 @@ export function CreateAutomationRuleDialog({ }: CreateAutomationRuleDialogProps) { const [open, setOpen] = useState(false); const [categories, setCategories] = useState([]); + const [labels, setLabels] = useState([]); const [title, setTitle] = useState(''); const [priority, setPriority] = useState('10'); const [ruleStructure, setRuleStructure] = useState({ @@ -45,16 +49,21 @@ export function CreateAutomationRuleDialog({ groupOperator: 'or', }); const [categoryId, setCategoryId] = useState(''); + const [selectedLabelIds, setSelectedLabelIds] = useState([]); const [note, setNote] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); const [errors, setErrors] = useState>({}); useEffect(() => { - const loadCategories = async () => { - const data = await categorySyncService.getAll(); - setCategories(data); + const loadData = async () => { + const [categoriesData, labelsData] = await Promise.all([ + categorySyncService.getAll(), + labelSyncService.getAll(), + ]); + setCategories(categoriesData); + setLabels(labelsData); }; - loadCategories(); + loadData(); }, []); const handleSubmit = async (e: React.FormEvent) => { @@ -74,7 +83,7 @@ export function CreateAutomationRuleDialog({ return; } - if (!categoryId && !note.trim()) { + if (!categoryId && !note.trim() && selectedLabelIds.length === 0) { setErrors((prev) => ({ ...prev, action_category_id: 'At least one action is required', @@ -110,6 +119,8 @@ export function CreateAutomationRuleDialog({ action_category_id: categoryId || null, action_note: encryptedNote, action_note_iv: noteIv, + action_label_ids: + selectedLabelIds.length > 0 ? selectedLabelIds : null, }, { preserveState: true, @@ -123,6 +134,7 @@ export function CreateAutomationRuleDialog({ groupOperator: 'and', }); setCategoryId(''); + setSelectedLabelIds([]); setNote(''); setErrors({}); await automationRuleSyncService.sync(); @@ -157,7 +169,7 @@ export function CreateAutomationRuleDialog({
- + Title
- + Priority
- + + Set Category +
- + Add Labels + +
+ +
+ Add Note