feat: add transaction labels feature (#24)

## Summary

Add a labeling system for transactions that differs from categories in
that labels are ephemeral and created on-the-fly when assigned (no
pre-creation required). A transaction can have multiple labels
(many-to-many relationship), and labels can be used for filtering in the
transaction table and as actions in automation rules.

<img width="1372" height="484" alt="image"
src="https://github.com/user-attachments/assets/fd342b11-dafb-44ed-b818-578e1ea856e6"
/>
<img width="1373" height="613" alt="image"
src="https://github.com/user-attachments/assets/aa5cfb8b-50b7-4101-a872-54904924f234"
/>
<img width="1035" height="594" alt="image"
src="https://github.com/user-attachments/assets/ffa946b2-b01f-496b-8cb8-ab55ab5b79ed"
/>


## Changes

### Backend (Laravel)

- Add `labels` table with user_id, name, color, and soft deletes
- Add `label_transaction` pivot table for transaction-label many-to-many
- Add `automation_rule_labels` pivot table for automation rule-label
many-to-many
- Create Label model with relationships to User, Transaction, and
AutomationRule
- Add LabelController for CRUD operations (returns JSON for on-the-fly
creation)
- Add LabelSyncController for frontend sync
- Add LabelPolicy for authorization
- Update TransactionController to handle bulk label updates
- Update AutomationRuleController to handle label actions
- Add validation for label_ids in transactions and automation rules

### Frontend (React/TypeScript)

- Add Label type and color utilities
- Add labels table to IndexedDB (Dexie version 6)
- Create label-sync service with findOrCreate functionality
- Create LabelCombobox component with multi-select and create-on-type
- Add labels column to transactions table
- Add labels filter to transaction filters
- Add bulk label assignment in bulk actions bar
- Add labels action in create automation rule dialog
- Update rule engine to include labels in evaluation results

## Testing

- 11 feature tests for label CRUD
- 3 tests for label sync
- All 241 feature tests pass

## Screenshots

N/A - UI changes can be reviewed in browser
This commit is contained in:
Víctor Falcón 2025-12-13 13:02:19 +01:00 committed by GitHub
parent ec6b6d04cf
commit 4b5d65ba03
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
51 changed files with 2615 additions and 63 deletions

27
app/Enums/LabelColor.php Normal file
View File

@ -0,0 +1,27 @@
<?php
namespace App\Enums;
enum LabelColor: string
{
case Amber = 'amber';
case Blue = 'blue';
case Cyan = 'cyan';
case Emerald = 'emerald';
case Fuchsia = 'fuchsia';
case Gray = 'gray';
case Green = 'green';
case Indigo = 'indigo';
case Lime = 'lime';
case Neutral = 'neutral';
case Orange = 'orange';
case Pink = 'pink';
case Purple = 'purple';
case Red = 'red';
case Rose = 'rose';
case Slate = 'slate';
case Stone = 'stone';
case Teal = 'teal';
case Violet = 'violet';
case Yellow = 'yellow';
}

View File

@ -22,7 +22,7 @@ class AutomationRuleController 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(['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();
}

View File

@ -0,0 +1,71 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\StoreLabelRequest;
use App\Http\Requests\Settings\UpdateLabelRequest;
use App\Models\Label;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class LabelController extends Controller
{
use AuthorizesRequests;
/**
* Show the user's labels settings page.
*/
public function index(): Response
{
$labels = auth()->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');
}
}

View File

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

View File

@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers\Sync;
use App\Http\Controllers\Controller;
use App\Models\Label;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class LabelSyncController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = Label::query()->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,
]);
}
}

View File

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

View File

@ -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',

View File

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

View File

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

View File

@ -0,0 +1,43 @@
<?php
namespace App\Http\Requests\Settings;
use App\Enums\LabelColor;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreLabelRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|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),
],
];
}
}

View File

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

View File

@ -0,0 +1,46 @@
<?php
namespace App\Http\Requests\Settings;
use App\Enums\LabelColor;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateLabelRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|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),
],
];
}
}

View File

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

View File

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

View File

@ -0,0 +1,17 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Relations\Pivot;
class AutomationRuleLabel extends Pivot
{
use HasUuids;
protected $table = 'automation_rule_labels';
public $incrementing = false;
protected $keyType = 'string';
}

40
app/Models/Label.php Normal file
View File

@ -0,0 +1,40 @@
<?php
namespace App\Models;
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 Label extends Model
{
/** @use HasFactory<\Database\Factories\LabelFactory> */
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);
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Relations\Pivot;
class LabelTransaction extends Pivot
{
use HasUuids;
protected $table = 'label_transaction';
public $incrementing = false;
protected $keyType = 'string';
}

View File

@ -7,6 +7,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 Transaction extends Model
@ -51,4 +52,11 @@ class Transaction extends Model
{
return $this->belongsTo(Category::class);
}
public function labels(): BelongsToMany
{
return $this->belongsToMany(Label::class)
->using(LabelTransaction::class)
->withTimestamps();
}
}

View File

@ -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')) {

View File

@ -0,0 +1,65 @@
<?php
namespace App\Policies;
use App\Models\Label;
use App\Models\User;
class LabelPolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return false;
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, Label $label): bool
{
return false;
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return false;
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, Label $label): bool
{
return $user->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;
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Label>
*/
class LabelFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
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(),
];
}
}

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('labels', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('label_transaction', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('automation_rule_labels', function (Blueprint $table) {
$table->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');
}
};

View File

@ -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
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('labels', function (Blueprint $table) {
$table->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();
});
}
};

View File

@ -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<Category[]>([]);
const [labels, setLabels] = useState<Label[]>([]);
const [title, setTitle] = useState('');
const [priority, setPriority] = useState('10');
const [ruleStructure, setRuleStructure] = useState<RuleStructure>({
@ -45,16 +49,21 @@ export function CreateAutomationRuleDialog({
groupOperator: 'or',
});
const [categoryId, setCategoryId] = useState<string>('');
const [selectedLabelIds, setSelectedLabelIds] = useState<string[]>([]);
const [note, setNote] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
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({
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title</Label>
<FormLabel htmlFor="title">Title</FormLabel>
<Input
id="title"
value={title}
@ -173,7 +185,7 @@ export function CreateAutomationRuleDialog({
</div>
<div className="space-y-2">
<Label htmlFor="priority">Priority</Label>
<FormLabel htmlFor="priority">Priority</FormLabel>
<Input
id="priority"
type="number"
@ -206,7 +218,9 @@ export function CreateAutomationRuleDialog({
</p>
<div className="space-y-2">
<Label htmlFor="category">Set Category</Label>
<FormLabel htmlFor="category">
Set Category
</FormLabel>
<CategoryCombobox
value={categoryId}
onValueChange={setCategoryId}
@ -217,7 +231,19 @@ export function CreateAutomationRuleDialog({
</div>
<div className="space-y-2">
<Label htmlFor="note">Add Note</Label>
<FormLabel>Add Labels</FormLabel>
<LabelCombobox
value={selectedLabelIds}
onValueChange={setSelectedLabelIds}
labels={labels}
onLabelsChange={setLabels}
placeholder="Select labels (optional)"
allowCreate={true}
/>
</div>
<div className="space-y-2">
<FormLabel htmlFor="note">Add Note</FormLabel>
<Textarea
id="note"
value={note}

View File

@ -0,0 +1,120 @@
import { store } from '@/actions/App/Http/Controllers/Settings/LabelController';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { labelSyncService } from '@/services/label-sync';
import { getLabelColorClasses, LABEL_COLORS } from '@/types/label';
import { Form } from '@inertiajs/react';
import { useState } from 'react';
export function CreateLabelDialog({ onSuccess }: { onSuccess?: () => void }) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>Create Label</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Create Label</DialogTitle>
<DialogDescription>
Add a new label to tag your transactions.
</DialogDescription>
</DialogHeader>
<Form
{...store.form()}
onSuccess={async () => {
await labelSyncService.sync();
setOpen(false);
onSuccess?.();
}}
className="space-y-4"
>
{({ errors, processing }) => (
<>
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
name="name"
placeholder="Label name"
required
/>
{errors.name && (
<p className="text-sm text-red-500">
{errors.name}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="color">Color</Label>
<Select name="color" required>
<SelectTrigger>
<SelectValue placeholder="Select a color" />
</SelectTrigger>
<SelectContent>
{LABEL_COLORS.map((color) => {
const colorClasses =
getLabelColorClasses(color);
return (
<SelectItem
key={color}
value={color}
>
<div className="flex items-center gap-2">
<Badge
className={`${colorClasses.bg} ${colorClasses.text}`}
>
{color}
</Badge>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
{errors.color && (
<p className="text-sm text-red-500">
{errors.color}
</p>
)}
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={processing}
>
Cancel
</Button>
<Button type="submit" disabled={processing}>
{processing ? 'Creating...' : 'Create'}
</Button>
</div>
</>
)}
</Form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,69 @@
import { destroy } from '@/actions/App/Http/Controllers/Settings/LabelController';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { labelSyncService } from '@/services/label-sync';
import { type Label } from '@/types/label';
import { Form } from '@inertiajs/react';
interface DeleteLabelDialogProps {
label: Label;
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
}
export function DeleteLabelDialog({
label,
open,
onOpenChange,
onSuccess,
}: DeleteLabelDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Delete Label</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{label.name}"? This
action cannot be undone.
</DialogDescription>
</DialogHeader>
<Form
{...destroy.form.delete(label.id)}
onSuccess={async () => {
await labelSyncService.delete(label.id);
onOpenChange(false);
onSuccess?.();
}}
>
{({ processing }) => (
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={processing}
>
Cancel
</Button>
<Button
type="submit"
variant="destructive"
disabled={processing}
>
{processing ? 'Deleting...' : 'Delete'}
</Button>
</DialogFooter>
)}
</Form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,134 @@
import { update } from '@/actions/App/Http/Controllers/Settings/LabelController';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { labelSyncService } from '@/services/label-sync';
import {
getLabelColorClasses,
LABEL_COLORS,
type Label as LabelType,
} from '@/types/label';
import { Form } from '@inertiajs/react';
interface EditLabelDialogProps {
label: LabelType;
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
}
export function EditLabelDialog({
label,
open,
onOpenChange,
onSuccess,
}: EditLabelDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Edit Label</DialogTitle>
<DialogDescription>
Update the label information.
</DialogDescription>
</DialogHeader>
<Form
{...update.form.patch(label.id)}
onSuccess={async () => {
await labelSyncService.sync();
onOpenChange(false);
onSuccess?.();
}}
className="space-y-4"
>
{({ errors, processing }) => (
<>
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
name="name"
defaultValue={label.name}
placeholder="Label name"
required
/>
{errors.name && (
<p className="text-sm text-red-500">
{errors.name}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="color">Color</Label>
<Select
name="color"
defaultValue={label.color}
required
>
<SelectTrigger>
<SelectValue placeholder="Select a color" />
</SelectTrigger>
<SelectContent>
{LABEL_COLORS.map((color) => {
const colorClasses =
getLabelColorClasses(color);
return (
<SelectItem
key={color}
value={color}
>
<div className="flex items-center gap-2">
<Badge
className={`${colorClasses.bg} ${colorClasses.text}`}
>
{color}
</Badge>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
{errors.color && (
<p className="text-sm text-red-500">
{errors.color}
</p>
)}
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={processing}
>
Cancel
</Button>
<Button type="submit" disabled={processing}>
{processing ? 'Updating...' : 'Update'}
</Button>
</div>
</>
)}
</Form>
</DialogContent>
</Dialog>
);
}

View File

@ -300,6 +300,7 @@ export function StepCreateAccount({
<StepButton
type="submit"
className="w-full sm:w-full"
disabled={isSubmitting}
loading={isSubmitting}
loadingText="Creating..."

View File

@ -0,0 +1,282 @@
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { labelSyncService } from '@/services/label-sync';
import { getLabelColorClasses, LABEL_COLORS, type Label } from '@/types/label';
import { Check, ChevronsUpDown, Plus, Tag, X } from 'lucide-react';
import { useState } from 'react';
interface LabelComboboxProps {
value: string[];
onValueChange: (value: string[]) => void;
labels: Label[];
disabled?: boolean;
placeholder?: string;
triggerClassName?: string;
allowCreate?: boolean;
}
export function LabelCombobox({
value,
onValueChange,
labels,
disabled = false,
placeholder = 'Add labels...',
triggerClassName,
allowCreate = true,
}: LabelComboboxProps) {
const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState('');
const [isCreating, setIsCreating] = useState(false);
const selectedLabels = labels.filter((l) => value.includes(l.id));
const sortedLabels = [...labels].sort((a, b) =>
a.name.localeCompare(b.name),
);
const handleSelect = (labelId: string) => {
if (value.includes(labelId)) {
onValueChange(value.filter((id) => id !== labelId));
} else {
onValueChange([...value, labelId]);
}
};
const handleRemove = (labelId: string, e: React.MouseEvent) => {
e.stopPropagation();
onValueChange(value.filter((id) => id !== labelId));
};
const handleCreate = async () => {
if (!inputValue.trim() || isCreating) return;
const existingLabel = labels.find(
(l) => l.name.toLowerCase() === inputValue.toLowerCase(),
);
if (existingLabel) {
handleSelect(existingLabel.id);
setInputValue('');
return;
}
setIsCreating(true);
try {
const randomColor =
LABEL_COLORS[Math.floor(Math.random() * LABEL_COLORS.length)];
const newLabel = await labelSyncService.findOrCreate(
inputValue.trim(),
randomColor,
);
if (newLabel) {
onValueChange([...value, newLabel.id]);
setInputValue('');
}
} catch (error) {
console.error('Failed to create label:', error);
} finally {
setIsCreating(false);
}
};
const showCreateOption =
allowCreate &&
inputValue.trim() &&
!labels.some((l) => l.name.toLowerCase() === inputValue.toLowerCase());
return (
<Popover modal open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn(
'min-h-10 w-full justify-between',
triggerClassName,
)}
disabled={disabled}
onClick={(e) => e.stopPropagation()}
>
{selectedLabels.length > 0 ? (
<div className="flex flex-wrap gap-1">
{selectedLabels.map((label) => {
const colorClasses = getLabelColorClasses(
label.color,
);
return (
<Badge
key={label.id}
className={cn(
'gap-1 px-2 py-0.5',
colorClasses.bg,
colorClasses.text,
)}
>
<Tag className="h-3 w-3" />
{label.name}
<span
role="button"
tabIndex={0}
onClick={(e) =>
handleRemove(label.id, e)
}
onKeyDown={(e) => {
if (
e.key === 'Enter' ||
e.key === ' '
) {
handleRemove(
label.id,
e as unknown as React.MouseEvent,
);
}
}}
className="ml-0.5 cursor-pointer rounded-full hover:bg-black/10"
>
<X className="h-3 w-3" />
</span>
</Badge>
);
})}
</div>
) : (
<span className="text-muted-foreground">
{placeholder}
</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<Command shouldFilter={false}>
<CommandInput
placeholder="Search or create labels..."
value={inputValue}
onValueChange={setInputValue}
/>
<CommandList>
{sortedLabels.length === 0 && !showCreateOption && (
<CommandEmpty>No labels found.</CommandEmpty>
)}
{showCreateOption && (
<CommandItem
onSelect={handleCreate}
disabled={isCreating}
className="gap-2"
>
<Plus className="h-4 w-4" />
{isCreating
? 'Creating...'
: `Create "${inputValue.trim()}"`}
</CommandItem>
)}
{sortedLabels
.filter(
(label) =>
!inputValue ||
label.name
.toLowerCase()
.includes(inputValue.toLowerCase()),
)
.map((label) => {
const colorClasses = getLabelColorClasses(
label.color,
);
const isSelected = value.includes(label.id);
return (
<CommandItem
key={label.id}
value={label.name}
onSelect={() => handleSelect(label.id)}
>
<div className="flex items-center gap-2">
<div
className={cn(
'flex h-5 w-5 items-center justify-center rounded-full',
colorClasses.bg,
)}
>
<Tag
className={cn(
'h-3 w-3',
colorClasses.text,
)}
/>
</div>
<span className="truncate">
{label.name}
</span>
</div>
<Check
className={cn(
'ml-auto h-4 w-4',
isSelected
? 'opacity-100'
: 'opacity-0',
)}
/>
</CommandItem>
);
})}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
export function LabelBadge({ label }: { label: Label }) {
const colorClasses = getLabelColorClasses(label.color);
return (
<Badge
className={cn(
'gap-1 px-2 py-0.5',
colorClasses.bg,
colorClasses.text,
)}
>
<Tag className="h-3 w-3" />
{label.name}
</Badge>
);
}
export function LabelBadges({
labels,
max = 3,
}: {
labels: Label[];
max?: number;
}) {
if (!labels || labels.length === 0) return null;
const displayLabels = labels.slice(0, max);
const remainingCount = labels.length - max;
return (
<div className="flex flex-wrap gap-1">
{displayLabels.map((label) => (
<LabelBadge key={label.id} label={label} />
))}
{remainingCount > 0 && (
<Badge variant="outline" className="px-2 py-0.5">
+{remainingCount}
</Badge>
)}
</div>
);
}

View File

@ -1,4 +1,5 @@
import { BulkCategorySelect } from '@/components/transactions/bulk-category-select';
import { BulkLabelSelect } from '@/components/transactions/bulk-label-select';
import { Button } from '@/components/ui/button';
import { ButtonGroup } from '@/components/ui/button-group';
import {
@ -9,12 +10,15 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { type Category } from '@/types/category';
import { type Label } from '@/types/label';
import { MoreHorizontal, Trash2, WandSparkles, X } from 'lucide-react';
interface BulkActionsBarProps {
selectedCount: number;
categories: Category[];
labels: Label[];
onCategoryChange: (categoryId: number | null) => void;
onLabelsChange: (labelIds: string[]) => void;
onDelete: () => void;
onReEvaluateRules: () => void;
onClear: () => void;
@ -24,7 +28,9 @@ interface BulkActionsBarProps {
export function BulkActionsBar({
selectedCount,
categories,
labels,
onCategoryChange,
onLabelsChange,
onDelete,
onReEvaluateRules,
onClear,
@ -50,6 +56,12 @@ export function BulkActionsBar({
disabled={isUpdating}
/>
<BulkLabelSelect
labels={labels}
onLabelsChange={onLabelsChange}
disabled={isUpdating}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button

View File

@ -0,0 +1,34 @@
import { LabelCombobox } from '@/components/shared/label-combobox';
import { type Label } from '@/types/label';
import { useState } from 'react';
interface BulkLabelSelectProps {
labels: Label[];
onLabelsChange: (labelIds: string[]) => void;
disabled?: boolean;
}
export function BulkLabelSelect({
labels,
onLabelsChange,
disabled = false,
}: BulkLabelSelectProps) {
const [value, setValue] = useState<string[]>([]);
function handleChange(newValue: string[]) {
setValue(newValue);
onLabelsChange(newValue);
}
return (
<LabelCombobox
value={value}
onValueChange={handleChange}
labels={labels}
disabled={disabled}
placeholder="Add labels"
triggerClassName="h-9 w-[180px] min-h-9"
allowCreate={true}
/>
);
}

View File

@ -1,3 +1,4 @@
import { LabelCombobox } from '@/components/shared/label-combobox';
import { CategorySelect } from '@/components/transactions/category-select';
import { AmountInput } from '@/components/ui/amount-input';
import { Button } from '@/components/ui/button';
@ -10,7 +11,7 @@ import {
DialogTitle,
} 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 {
Select,
SelectContent,
@ -33,6 +34,7 @@ import {
} from '@/types/account';
import { type AutomationRule } from '@/types/automation-rule';
import { type Category } from '@/types/category';
import { type Label } from '@/types/label';
import { type DecryptedTransaction } from '@/types/transaction';
import { format, getYear, parseISO } from 'date-fns';
import { useEffect, useState } from 'react';
@ -43,6 +45,7 @@ interface EditTransactionDialogProps {
categories: Category[];
accounts: Account[];
banks: Bank[];
labels: Label[];
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: (transaction: DecryptedTransaction) => void;
@ -54,6 +57,7 @@ export function EditTransactionDialog({
categories,
accounts,
banks,
labels,
open,
onOpenChange,
onSuccess,
@ -65,6 +69,7 @@ export function EditTransactionDialog({
const [amount, setAmount] = useState<number>(0);
const [accountId, setAccountId] = useState<string>('');
const [categoryId, setCategoryId] = useState<string>('null');
const [selectedLabelIds, setSelectedLabelIds] = useState<string[]>([]);
const [notes, setNotes] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [decryptedAccountNames, setDecryptedAccountNames] = useState<
@ -81,6 +86,11 @@ export function EditTransactionDialog({
setAmount(transaction.amount);
setAccountId(transaction.account_id);
setCategoryId(transaction.category_id || 'null');
setSelectedLabelIds(
transaction.label_ids ||
transaction.labels?.map((l) => l.id) ||
[],
);
setNotes(transaction.decryptedNotes || '');
} else if (mode === 'create' && open) {
const today = new Date().toISOString().split('T')[0];
@ -92,6 +102,7 @@ export function EditTransactionDialog({
availableAccounts.length > 0 ? availableAccounts[0].id : '',
);
setCategoryId('null');
setSelectedLabelIds([]);
setNotes('');
}
}, [mode, transaction, open, accounts]);
@ -366,10 +377,12 @@ export function EditTransactionDialog({
notes_iv: string | null;
description?: string;
description_iv?: string;
label_ids?: string[];
} = {
category_id: selectedCategoryId,
notes: encryptedNotes,
notes_iv: notesIv,
label_ids: selectedLabelIds,
};
let finalDecryptedDescription =
@ -399,6 +412,10 @@ export function EditTransactionDialog({
) || null
: null;
const selectedLabels = labels.filter((label) =>
selectedLabelIds.includes(label.id),
);
const updatedTransaction: DecryptedTransaction = {
...transaction,
category_id: selectedCategoryId,
@ -411,6 +428,8 @@ export function EditTransactionDialog({
decryptedNotes: trimmedNotes || null,
notes: encryptedNotes,
notes_iv: notesIv,
label_ids: selectedLabelIds,
labels: selectedLabels,
updated_at:
updatedRecord?.updated_at ?? transaction.updated_at,
};
@ -451,7 +470,7 @@ export function EditTransactionDialog({
<form onSubmit={handleSubmit}>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label
<FormLabel
htmlFor="date"
className={
mode === 'edit'
@ -460,7 +479,7 @@ export function EditTransactionDialog({
}
>
Date
</Label>
</FormLabel>
{mode === 'create' ? (
<Input
id="date"
@ -495,7 +514,7 @@ export function EditTransactionDialog({
</div>
<div className="space-y-2">
<Label
<FormLabel
htmlFor="description"
className={
mode === 'edit' &&
@ -505,7 +524,7 @@ export function EditTransactionDialog({
}
>
Description
</Label>
</FormLabel>
{mode === 'create' ||
(mode === 'edit' &&
transaction?.source === 'manually_created') ? (
@ -542,7 +561,7 @@ export function EditTransactionDialog({
</div>
<div className="space-y-2">
<Label
<FormLabel
htmlFor="amount"
className={
mode === 'edit'
@ -551,7 +570,7 @@ export function EditTransactionDialog({
}
>
Amount
</Label>
</FormLabel>
{mode === 'create' ? (
<AmountInput
id="amount"
@ -576,7 +595,7 @@ export function EditTransactionDialog({
{mode === 'create' && (
<div className="space-y-2">
<Label htmlFor="account">Account</Label>
<FormLabel htmlFor="account">Account</FormLabel>
<Select
value={accountId}
onValueChange={setAccountId}
@ -604,7 +623,7 @@ export function EditTransactionDialog({
)}
<div className="space-y-2">
<Label htmlFor="category">Category</Label>
<FormLabel htmlFor="category">Category</FormLabel>
<CategorySelect
value={categoryId}
onValueChange={setCategoryId}
@ -617,7 +636,19 @@ export function EditTransactionDialog({
</div>
<div className="space-y-2">
<Label htmlFor="notes">Notes</Label>
<FormLabel>Labels</FormLabel>
<LabelCombobox
value={selectedLabelIds}
onValueChange={setSelectedLabelIds}
labels={labels}
disabled={isSubmitting}
placeholder="Add labels..."
allowCreate={true}
/>
</div>
<div className="space-y-2">
<FormLabel htmlFor="notes">Notes</FormLabel>
<Textarea
id="notes"
placeholder="Add notes..."

View File

@ -3,6 +3,7 @@ import { format, getYear, parseISO } from 'date-fns';
import { ArrowDown, MoreHorizontal } from 'lucide-react';
import { EncryptedText } from '@/components/encrypted-text';
import { LabelBadges } from '@/components/shared/label-combobox';
import { CategoryCell } from '@/components/transactions/category-cell';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
@ -15,12 +16,14 @@ import {
} from '@/components/ui/dropdown-menu';
import { type Account, type Bank } from '@/types/account';
import { type Category } from '@/types/category';
import { type Label } from '@/types/label';
import { type DecryptedTransaction } from '@/types/transaction';
interface CreateColumnsOptions {
categories: Category[];
accounts: Account[];
banks: Bank[];
labels: Label[];
onEdit: (transaction: DecryptedTransaction) => void;
onDelete: (transaction: DecryptedTransaction) => void;
onUpdate: (transaction: DecryptedTransaction) => void;
@ -31,6 +34,7 @@ export function createTransactionColumns({
categories,
accounts,
banks,
labels,
onEdit,
onDelete,
onUpdate,
@ -128,6 +132,24 @@ export function createTransactionColumns({
);
},
},
{
id: 'labels',
accessorKey: 'label_ids',
meta: { label: 'Labels' },
header: 'Labels',
cell: ({ row }) => {
const transaction = row.original;
// Resolve labels from label_ids using the labels map
const transactionLabels = (transaction.label_ids || [])
.map((id) => labels.find((l) => l.id === id))
.filter(Boolean) as Label[];
if (transactionLabels.length === 0) {
return null;
}
return <LabelBadges labels={transactionLabels} max={2} />;
},
enableHiding: true,
},
{
accessorKey: 'bank',
meta: { label: 'Bank' },

View File

@ -1,6 +1,6 @@
import { format } from 'date-fns';
import * as Icons from 'lucide-react';
import { Check, ChevronsUpDown, X } from 'lucide-react';
import { Check, ChevronsUpDown, Tag, X } from 'lucide-react';
import { type ReactNode, useState } from 'react';
import { EncryptedText } from '@/components/encrypted-text';
@ -14,7 +14,7 @@ import {
CommandList,
} from '@/components/ui/command';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Label as FormLabel } from '@/components/ui/label';
import {
Popover,
PopoverContent,
@ -23,6 +23,7 @@ import {
import { cn } from '@/lib/utils';
import { type Account } from '@/types/account';
import { type Category, getCategoryColorClasses } from '@/types/category';
import { getLabelColorClasses, type Label } from '@/types/label';
import { type TransactionFilters as FiltersType } from '@/types/transaction';
import { CategoryIcon } from '../shared/category-combobox';
@ -30,6 +31,7 @@ interface TransactionFiltersProps {
filters: FiltersType;
onFiltersChange: (filters: FiltersType) => void;
categories: Category[];
labels: Label[];
accounts: Account[];
isKeySet: boolean;
actions?: ReactNode;
@ -40,6 +42,7 @@ export function TransactionFilters({
filters,
onFiltersChange,
categories,
labels,
accounts,
isKeySet,
actions,
@ -47,6 +50,7 @@ export function TransactionFilters({
}: TransactionFiltersProps) {
const [isOpen, setIsOpen] = useState(false);
const [categoryDropdownOpen, setCategoryDropdownOpen] = useState(false);
const [labelDropdownOpen, setLabelDropdownOpen] = useState(false);
const isUncategorizedSelected = filters.categoryIds.includes(
UNCATEGORIZED_CATEGORY_ID,
);
@ -67,6 +71,14 @@ export function TransactionFilters({
onFiltersChange({ ...filters, accountIds: newAccountIds });
}
function handleLabelToggle(labelId: string) {
const newLabelIds = filters.labelIds.includes(labelId)
? filters.labelIds.filter((id) => id !== labelId)
: [...filters.labelIds, labelId];
onFiltersChange({ ...filters, labelIds: newLabelIds });
}
function clearFilters() {
onFiltersChange({
dateFrom: null,
@ -75,6 +87,7 @@ export function TransactionFilters({
amountMax: null,
categoryIds: [],
accountIds: [],
labelIds: [],
searchText: '',
});
}
@ -85,6 +98,7 @@ export function TransactionFilters({
(filters.amountMin !== null ? 1 : 0) +
(filters.amountMax !== null ? 1 : 0) +
filters.categoryIds.length +
filters.labelIds.length +
(hideAccountFilter ? 0 : filters.accountIds.length);
return (
@ -132,7 +146,7 @@ export function TransactionFilters({
</div>
<div className="space-y-2">
<Label>Date</Label>
<FormLabel>Date</FormLabel>
<div className="grid grid-cols-2 gap-2 pt-2">
<Input
type="date"
@ -182,7 +196,7 @@ export function TransactionFilters({
</div>
<div className="space-y-2">
<Label>Amount</Label>
<FormLabel>Amount</FormLabel>
<div className="grid grid-cols-2 gap-2 pt-2">
<Input
type="number"
@ -220,7 +234,7 @@ export function TransactionFilters({
</div>
<div className="space-y-2">
<Label>Categories</Label>
<FormLabel>Categories</FormLabel>
<div className="pt-2">
<Popover
open={categoryDropdownOpen}
@ -350,9 +364,109 @@ export function TransactionFilters({
</div>
</div>
<div className="space-y-2">
<FormLabel>Labels</FormLabel>
<div className="pt-2">
<Popover
open={labelDropdownOpen}
onOpenChange={setLabelDropdownOpen}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between"
>
{filters.labelIds.length >
0 ? (
<span className="truncate">
{
filters.labelIds
.length
}{' '}
selected
</span>
) : (
<span className="text-muted-foreground">
Select labels...
</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-full p-0"
align="start"
>
<Command>
<CommandInput placeholder="Search labels..." />
<CommandList>
<CommandEmpty>
No labels found.
</CommandEmpty>
{labels.map((label) => {
const isSelected =
filters.labelIds.includes(
label.id,
);
const colorClasses =
getLabelColorClasses(
label.color,
);
return (
<CommandItem
key={
label.id
}
onSelect={() =>
handleLabelToggle(
label.id,
)
}
>
<div
className={cn(
'mr-1 flex size-4 items-center justify-center rounded-sm border border-primary p-1',
isSelected
? 'bg-primary/10 text-primary-foreground'
: 'opacity-50 [&_svg]:invisible',
)}
>
<Check className="size-3" />
</div>
<div className="flex items-center gap-2">
<div
className={cn(
'flex h-5 w-5 items-center justify-center rounded-full',
colorClasses.bg,
)}
>
<Tag
className={cn(
'h-3 w-3',
colorClasses.text,
)}
/>
</div>
<span>
{
label.name
}
</span>
</div>
</CommandItem>
);
})}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
{!hideAccountFilter && (
<div className="space-y-2">
<Label>Accounts</Label>
<FormLabel>Accounts</FormLabel>
<div className="flex flex-wrap gap-2 pt-2">
{accounts.map((account) => {
const isSelected =

View File

@ -7,6 +7,7 @@ import { accountSyncService } from '@/services/account-sync';
import { automationRuleSyncService } from '@/services/automation-rule-sync';
import { bankSyncService } from '@/services/bank-sync';
import { categorySyncService } from '@/services/category-sync';
import { labelSyncService } from '@/services/label-sync';
import { transactionSyncService } from '@/services/transaction-sync';
import type { User } from '@/types/index.d';
import type { Page } from '@inertiajs/core';
@ -149,6 +150,7 @@ export function SyncProvider({
accountBalancesResult,
banksResult,
automationRulesResult,
labelsResult,
transactionsResult,
] = await Promise.all([
categorySyncService.sync(),
@ -156,6 +158,7 @@ export function SyncProvider({
accountBalanceSyncService.sync(),
bankSyncService.sync(),
automationRuleSyncService.sync(),
labelSyncService.sync(),
transactionSyncService.sync(),
]);
@ -165,6 +168,7 @@ export function SyncProvider({
...accountBalancesResult.errors,
...banksResult.errors,
...automationRulesResult.errors,
...labelsResult.errors,
...transactionsResult.errors,
];

View File

@ -1,6 +1,7 @@
import { index as accountsIndex } from '@/actions/App/Http/Controllers/Settings/AccountController';
import { index as automationRulesIndex } from '@/actions/App/Http/Controllers/Settings/AutomationRuleController';
import { index as categoriesIndex } from '@/actions/App/Http/Controllers/Settings/CategoryController';
import { index as labelsIndex } from '@/actions/App/Http/Controllers/Settings/LabelController';
import Heading from '@/components/heading';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
@ -39,6 +40,12 @@ const getNavItems = (
href: categoriesIndex(),
icon: null,
},
{
type: 'nav-item',
title: 'Labels',
href: labelsIndex(),
icon: null,
},
{ type: 'divider' },
{
type: 'section-header',

View File

@ -2,6 +2,7 @@ import type { Transaction } from '@/services/transaction-sync';
import type { Account, AccountBalance, Bank } from '@/types/account';
import type { AutomationRule } from '@/types/automation-rule';
import type { Category } from '@/types/category';
import type { Label } from '@/types/label';
import Dexie, { type EntityTable } from 'dexie';
export interface SyncMetadata {
@ -21,6 +22,7 @@ const db = new Dexie('whisper_money') as Dexie & {
transactions: EntityTable<Transaction, 'id'>;
accounts: EntityTable<Account, 'id'>;
categories: EntityTable<Category, 'id'>;
labels: EntityTable<Label, 'id'>;
banks: EntityTable<Bank, 'id'>;
automation_rules: EntityTable<AutomationRule, 'id'>;
account_balances: EntityTable<AccountBalance, 'id'>;
@ -39,4 +41,16 @@ db.version(5).stores({
pending_changes: '++id, store, timestamp',
});
db.version(6).stores({
transactions: 'id, user_id, account_id, updated_at',
accounts: 'id, user_id, bank_id, updated_at',
categories: 'id, user_id, updated_at',
labels: 'id, user_id, updated_at',
banks: 'id, user_id, updated_at',
automation_rules: 'id, user_id, priority, updated_at',
account_balances: 'id, account_id, balance_date, updated_at',
sync_metadata: 'key',
pending_changes: '++id, store, timestamp',
});
export { db };

View File

@ -3,6 +3,7 @@ import { consoleDebug } from '@/lib/debug';
import type { Account, Bank } from '@/types/account';
import type { AutomationRule } from '@/types/automation-rule';
import type { Category } from '@/types/category';
import type { Label } from '@/types/label';
import type { DecryptedTransaction } from '@/types/transaction';
import type { UUID } from '@/types/uuid';
import jsonLogic from 'json-logic-js';
@ -10,6 +11,8 @@ import jsonLogic from 'json-logic-js';
export interface RuleEvaluationResult {
rule: AutomationRule;
categoryId: UUID | null;
labelIds: UUID[];
labels: Label[];
note: string | null;
noteIv: string | null;
}
@ -157,6 +160,8 @@ export async function evaluateRules(
return {
rule,
categoryId: rule.action_category_id,
labelIds: rule.labels?.map((l) => l.id) || [],
labels: rule.labels || [],
note: rule.action_note,
noteIv: rule.action_note_iv,
};
@ -288,6 +293,8 @@ export async function evaluateRulesForNewTransaction(
return {
rule,
categoryId: rule.action_category_id,
labelIds: rule.labels?.map((l) => l.id) || [],
labels: rule.labels || [],
note: rule.action_note,
noteIv: rule.action_note_iv,
};

View File

@ -0,0 +1,335 @@
import { Head } from '@inertiajs/react';
import {
Cell,
ColumnDef,
ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
Row,
SortingState,
useReactTable,
VisibilityState,
} from '@tanstack/react-table';
import { useLiveQuery } from 'dexie-react-hooks';
import { ArrowUpDown, MoreHorizontal, Tag } from 'lucide-react';
import { useState } from 'react';
import { index as labelsIndex } from '@/actions/App/Http/Controllers/Settings/LabelController';
import HeadingSmall from '@/components/heading-small';
import { CreateLabelDialog } from '@/components/labels/create-label-dialog';
import { DeleteLabelDialog } from '@/components/labels/delete-label-dialog';
import { EditLabelDialog } from '@/components/labels/edit-label-dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuLabel,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import AppLayout from '@/layouts/app-layout';
import SettingsLayout from '@/layouts/settings/layout';
import { db } from '@/lib/dexie-db';
import { type BreadcrumbItem } from '@/types';
import { getLabelColorClasses, type Label } from '@/types/label';
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Labels settings',
href: labelsIndex().url,
},
];
function LabelActions({ label }: { label: Label }) {
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={() => setEditOpen(true)}>
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setDeleteOpen(true)}
variant="destructive"
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<EditLabelDialog
label={label}
open={editOpen}
onOpenChange={setEditOpen}
onSuccess={() => {}}
/>
<DeleteLabelDialog
label={label}
open={deleteOpen}
onOpenChange={setDeleteOpen}
onSuccess={() => {}}
/>
</>
);
}
function LabelRow({ row }: { row: Row<Label> }) {
const label = row.original;
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [contextMenuOpen, setContextMenuOpen] = useState(false);
return (
<>
<ContextMenu onOpenChange={setContextMenuOpen}>
<ContextMenuTrigger asChild>
<TableRow
data-state={
(row.getIsSelected() || contextMenuOpen) &&
'selected'
}
>
{row
.getVisibleCells()
.map((cell: Cell<Label, unknown>) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuLabel>Actions</ContextMenuLabel>
<ContextMenuItem onClick={() => setEditOpen(true)}>
Edit
</ContextMenuItem>
<ContextMenuItem
onClick={() => setDeleteOpen(true)}
variant="destructive"
>
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<EditLabelDialog
label={label}
open={editOpen}
onOpenChange={setEditOpen}
onSuccess={() => {}}
/>
<DeleteLabelDialog
label={label}
open={deleteOpen}
onOpenChange={setDeleteOpen}
onSuccess={() => {}}
/>
</>
);
}
export default function Labels() {
const labels = useLiveQuery(() => db.labels.toArray(), []) || [];
const [sorting, setSorting] = useState<SortingState>([
{ id: 'name', desc: false },
]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
{},
);
const columns: ColumnDef<Label>[] = [
{
accessorKey: 'name',
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return (
<div className="flex items-center gap-3 pl-3">
<Tag className="h-4 w-4 opacity-80" />
<div className="font-medium">
{row.getValue('name')}
</div>
</div>
);
},
},
{
accessorKey: 'color',
header: 'Color',
cell: ({ row }) => {
const color = row.getValue('color') as Label['color'];
if (!color) {
return null;
}
const colorClasses = getLabelColorClasses(color);
return (
<Badge
className={`${colorClasses.bg} ${colorClasses.text} text-[10px] tracking-widest`}
>
{color.toLocaleUpperCase()}
</Badge>
);
},
},
{
id: 'actions',
enableHiding: false,
cell: ({ row }) => <LabelActions label={row.original} />,
},
];
const table = useReactTable({
data: labels,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
state: {
sorting,
columnFilters,
columnVisibility,
},
});
return (
<AppLayout breadcrumbs={breadcrumbs}>
<Head title="Labels settings" />
<SettingsLayout>
<div className="space-y-6">
<HeadingSmall
title="Labels settings"
description="Manage your transaction labels"
/>
<div className="space-y-4">
<div className="flex items-center justify-between">
<Input
placeholder="Filter labels..."
value={
(table
.getColumn('name')
?.getFilterValue() as string) ?? ''
}
onChange={(event) =>
table
.getColumn('name')
?.setFilterValue(event.target.value)
}
className="max-w-sm"
/>
<CreateLabelDialog onSuccess={() => {}} />
</div>
<div className="overflow-hidden rounded-md border">
<Table>
<TableHeader>
{table
.getHeaderGroups()
.map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map(
(header) => {
return (
<TableHead
key={header.id}
>
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext(),
)}
</TableHead>
);
},
)}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table
.getRowModel()
.rows.map((row) => (
<LabelRow
key={row.id}
row={row}
/>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No labels found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-end">
<div className="text-sm text-muted-foreground">
{table.getFilteredRowModel().rows.length}{' '}
label(s) total.
</div>
</div>
</div>
</div>
</SettingsLayout>
</AppLayout>
);
}

View File

@ -61,6 +61,7 @@ import { transactionSyncService } from '@/services/transaction-sync';
import { type BreadcrumbItem } from '@/types';
import { type Account, type Bank } from '@/types/account';
import { type Category } from '@/types/category';
import { type Label } from '@/types/label';
import {
type DecryptedTransaction,
type TransactionFilters as Filters,
@ -77,6 +78,7 @@ interface Props {
categories: Category[];
accounts: Account[];
banks: Bank[];
labels: Label[];
}
const COLUMN_VISIBILITY_KEY = 'transactions-column-visibility';
@ -157,8 +159,9 @@ function TransactionRowComponent({
}
function getInitialColumnVisibility(): VisibilityState {
const defaultVisibility = { bank: false };
if (typeof window === 'undefined') {
return { account: false };
return defaultVisibility;
}
try {
@ -172,10 +175,15 @@ function getInitialColumnVisibility(): VisibilityState {
error,
);
}
return { account: false };
return defaultVisibility;
}
export default function Transactions({ categories, accounts, banks }: Props) {
export default function Transactions({
categories,
accounts,
banks,
labels: initialLabels,
}: Props) {
const { isKeySet } = useEncryptionKey();
const transactionIds = useLiveQuery(
@ -209,8 +217,10 @@ export default function Transactions({ categories, accounts, banks }: Props) {
amountMax: null,
categoryIds: [],
accountIds: [],
labelIds: [],
searchText: '',
});
const labels = useLiveQuery(() => db.labels.toArray(), [], initialLabels);
const [editTransaction, setEditTransaction] =
useState<DecryptedTransaction | null>(null);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
@ -504,6 +514,17 @@ export default function Transactions({ categories, accounts, banks }: Props) {
return false;
}
if (filters.labelIds.length > 0) {
const transactionLabelIds =
transaction.labels?.map((l) => l.id) || [];
const hasMatchingLabel = filters.labelIds.some((labelId) =>
transactionLabelIds.includes(labelId),
);
if (!hasMatchingLabel) {
return false;
}
}
if (filters.searchText && isKeySet) {
const searchLower = filters.searchText.toLowerCase();
const matchesDescription = transaction.decryptedDescription
@ -607,6 +628,7 @@ export default function Transactions({ categories, accounts, banks }: Props) {
categories,
accounts,
banks,
labels,
key,
);
@ -693,7 +715,7 @@ export default function Transactions({ categories, accounts, banks }: Props) {
consoleDebug('=== Re-evaluation complete ===');
}
},
[isKeySet, categories, accounts, banks, updateTransaction],
[isKeySet, categories, accounts, banks, labels, updateTransaction],
);
async function handleBulkReEvaluateRules() {
@ -757,6 +779,7 @@ export default function Transactions({ categories, accounts, banks }: Props) {
categories,
accounts,
banks,
labels,
key,
);
@ -884,12 +907,20 @@ export default function Transactions({ categories, accounts, banks }: Props) {
categories,
accounts,
banks,
labels,
onEdit: setEditTransaction,
onDelete: setDeleteTransaction,
onUpdate: updateTransaction,
onReEvaluateRules: handleReEvaluateRules,
}),
[accounts, banks, categories, updateTransaction, handleReEvaluateRules],
[
accounts,
banks,
categories,
labels,
updateTransaction,
handleReEvaluateRules,
],
);
const table = useReactTable({
@ -1041,6 +1072,52 @@ export default function Transactions({ categories, accounts, banks }: Props) {
}
}
async function handleBulkLabelsChange(labelIds: string[]) {
const selectedIds = Object.keys(rowSelection);
if (selectedIds.length === 0 || labelIds.length === 0) {
return;
}
setIsBulkUpdating(true);
try {
await transactionSyncService.updateMany(selectedIds, {
label_ids: labelIds,
});
const selectedLabels = labels.filter((l) =>
labelIds.includes(l.id),
);
setTransactions((previous) =>
previous.map((transaction) => {
if (selectedIds.includes(transaction.id.toString())) {
const existingLabels = transaction.labels || [];
const newLabels = [
...existingLabels,
...selectedLabels.filter(
(l) =>
!existingLabels.some(
(el) => el.id === l.id,
),
),
];
return {
...transaction,
labels: newLabels,
};
}
return transaction;
}),
);
setRowSelection({});
} catch (error) {
console.error('Failed to update transactions with labels:', error);
} finally {
setIsBulkUpdating(false);
}
}
function handleClearSelection() {
setRowSelection({});
}
@ -1081,6 +1158,7 @@ export default function Transactions({ categories, accounts, banks }: Props) {
filters={filters}
onFiltersChange={setFilters}
categories={categories}
labels={labels}
accounts={accounts}
isKeySet={isKeySet}
actions={
@ -1180,6 +1258,7 @@ export default function Transactions({ categories, accounts, banks }: Props) {
categories={categories}
accounts={accounts}
banks={banks}
labels={labels}
open={!!editTransaction}
onOpenChange={(open) => !open && setEditTransaction(null)}
onSuccess={updateTransaction}
@ -1191,6 +1270,7 @@ export default function Transactions({ categories, accounts, banks }: Props) {
categories={categories}
accounts={accounts}
banks={banks}
labels={labels}
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
onSuccess={() => {}}
@ -1244,7 +1324,9 @@ export default function Transactions({ categories, accounts, banks }: Props) {
<BulkActionsBar
selectedCount={Object.keys(rowSelection).length}
categories={categories}
labels={labels}
onCategoryChange={handleBulkCategoryChange}
onLabelsChange={handleBulkLabelsChange}
onDelete={handleBulkDeleteClick}
onReEvaluateRules={handleBulkReEvaluateRules}
onClear={handleClearSelection}

View File

@ -0,0 +1,102 @@
import { store } from '@/actions/App/Http/Controllers/Settings/LabelController';
import { SyncManager } from '@/lib/sync-manager';
import type { Label } from '@/types/label';
import type { UUID } from '@/types/uuid';
class LabelSyncService {
private syncManager: SyncManager;
constructor() {
this.syncManager = new SyncManager({
storeName: 'labels',
endpoint: '/api/sync/labels',
});
}
async sync() {
return await this.syncManager.sync();
}
async getAll(): Promise<Label[]> {
return await this.syncManager.getAll<Label>();
}
async getById(id: UUID): Promise<Label | null> {
return await this.syncManager.getById<Label>(id);
}
async create(data: { name: string; color: string }): Promise<Label> {
const csrfToken = decodeURIComponent(
document.cookie
.split('; ')
.find((row) => row.startsWith('XSRF-TOKEN='))
?.split('=')[1] || '',
);
const response = await fetch(store().url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-XSRF-TOKEN': csrfToken,
},
credentials: 'same-origin',
body: JSON.stringify(data),
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
console.error('Failed to create label:', errorData);
throw new Error(errorData?.message || 'Failed to create label');
}
const result = await response.json();
const label = result.data as Label;
// Store the label in IndexedDB
const { db } = await import('@/lib/dexie-db');
await db.labels.put(label);
return label;
}
async update(id: UUID, data: Partial<Label>): Promise<void> {
await this.syncManager.updateLocal<Label>(id, data);
}
async delete(id: UUID): Promise<void> {
await this.syncManager.deleteLocal(id);
}
async getLastSyncTime(): Promise<string | null> {
return await this.syncManager.getLastSyncTime();
}
isSyncing(): boolean {
return this.syncManager.isSyncing();
}
async findOrCreate(
name: string,
color: string = 'blue',
): Promise<Label | null> {
const labels = await this.getAll();
const existing = labels.find(
(l) => l.name.toLowerCase() === name.toLowerCase(),
);
if (existing) {
return existing;
}
try {
return await this.create({ name, color });
} catch (error) {
console.error('Failed to create label:', error);
return null;
}
}
}
export const labelSyncService = new LabelSyncService();

View File

@ -17,10 +17,15 @@ export interface Transaction {
currency_code: string;
notes: string | null;
notes_iv: string | null;
label_ids?: UUID[];
created_at: string;
updated_at: string;
}
interface TransactionUpdateData extends Partial<Transaction> {
label_ids?: string[];
}
class TransactionSyncService {
private syncManager: SyncManager;
@ -28,10 +33,20 @@ class TransactionSyncService {
this.syncManager = new SyncManager({
storeName: 'transactions',
endpoint: '/api/sync/transactions',
transformFromServer: (data) => ({
...data,
transaction_date: String(data.transaction_date).slice(0, 10),
}),
transformFromServer: (data) => {
// Extract label_ids from labels array if present
const label_ids = data.labels?.map((l: { id: string }) => l.id);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { labels, ...rest } = data;
return {
...rest,
transaction_date: String(data.transaction_date).slice(
0,
10,
),
label_ids: label_ids || [],
};
},
});
}
@ -104,17 +119,77 @@ class TransactionSyncService {
}
}
async update(id: string, data: Partial<Transaction>): Promise<void> {
async update(
id: string,
data: TransactionUpdateData,
): Promise<Transaction | void> {
const existing = await this.getById(id);
if (!existing) {
throw new Error('Transaction not found');
}
const { label_ids, ...transactionData } = data;
const timestamp = new Date().toISOString();
// If label_ids are provided, we need to sync with the server immediately
if (label_ids !== undefined) {
const csrfToken = decodeURIComponent(
document.cookie
.split('; ')
.find((row) => row.startsWith('XSRF-TOKEN='))
?.split('=')[1] || '',
);
const response = await fetch(`/api/sync/transactions/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-XSRF-TOKEN': csrfToken,
},
credentials: 'same-origin',
body: JSON.stringify({
...existing,
...transactionData,
label_ids,
}),
});
if (!response.ok) {
throw new Error('Failed to update transaction');
}
const result = await response.json();
const serverData = result.data;
// Extract label_ids from labels array
const serverLabelIds = serverData.labels?.map(
(l: { id: string }) => l.id,
);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { labels: _labels, ...restServerData } = serverData;
const updatedTransaction: Transaction = {
...restServerData,
transaction_date: String(serverData.transaction_date).slice(
0,
10,
),
label_ids: serverLabelIds || [],
};
// Update local storage with transformed data (label_ids instead of labels)
await db.transactions.put(updatedTransaction);
return updatedTransaction;
}
// No label_ids, use the normal offline-first approach
const updated = {
...existing,
...data,
...transactionData,
updated_at: timestamp,
};
@ -127,8 +202,38 @@ class TransactionSyncService {
});
}
async updateMany(ids: string[], data: Partial<Transaction>): Promise<void> {
async updateMany(
ids: string[],
data: TransactionUpdateData,
): Promise<void> {
const timestamp = new Date().toISOString();
const { label_ids, ...transactionData } = data;
if (label_ids !== undefined) {
try {
const response = await fetch('/transactions/bulk', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
credentials: 'same-origin',
body: JSON.stringify({
transaction_ids: ids,
label_ids: label_ids,
...transactionData,
}),
});
if (!response.ok) {
throw new Error('Failed to bulk update transactions');
}
} catch (error) {
console.error('Failed to update transactions via API:', error);
throw error;
}
}
for (const id of ids) {
const existing = await this.getById(id);
@ -140,7 +245,7 @@ class TransactionSyncService {
const updated = {
...existing,
...data,
...transactionData,
updated_at: timestamp,
};

View File

@ -1,4 +1,5 @@
import type { Category } from './category';
import type { Label } from './label';
import { UUID } from './uuid';
export interface AutomationRule {
@ -11,26 +12,35 @@ export interface AutomationRule {
action_note: string | null;
action_note_iv: string | null;
category?: Category;
labels?: Label[];
created_at: string;
updated_at: string;
deleted_at: string | null;
}
export interface RuleAction {
type: 'category' | 'note' | 'both';
type: 'category' | 'note' | 'labels' | 'multiple';
category?: Category;
labels?: Label[];
hasNote: boolean;
hasLabels: boolean;
}
export function getRuleActions(rule: AutomationRule): RuleAction {
const hasCategory = rule.action_category_id !== null;
const hasNote = rule.action_note !== null;
const hasLabels = (rule.labels?.length ?? 0) > 0;
if (hasCategory && hasNote) {
const actionCount =
(hasCategory ? 1 : 0) + (hasNote ? 1 : 0) + (hasLabels ? 1 : 0);
if (actionCount > 1) {
return {
type: 'both',
type: 'multiple',
category: rule.category,
hasNote: true,
labels: rule.labels,
hasNote,
hasLabels,
};
}
@ -39,25 +49,42 @@ export function getRuleActions(rule: AutomationRule): RuleAction {
type: 'category',
category: rule.category,
hasNote: false,
hasLabels: false,
};
}
if (hasLabels) {
return {
type: 'labels',
labels: rule.labels,
hasNote: false,
hasLabels: true,
};
}
return {
type: 'note',
hasNote: true,
hasLabels: false,
};
}
export function formatRuleActions(rule: AutomationRule): string {
const actions = getRuleActions(rule);
const parts: string[] = [];
if (actions.type === 'both') {
return `${actions.category?.name} and add note`;
if (actions.category) {
parts.push(actions.category.name);
}
if (actions.type === 'category') {
return actions.category?.name || '';
if (actions.hasLabels && actions.labels) {
const labelNames = actions.labels.map((l) => l.name).join(', ');
parts.push(`labels: ${labelNames}`);
}
return 'Add note';
if (actions.hasNote) {
parts.push('add note');
}
return parts.length > 0 ? parts.join(' + ') : 'No actions';
}

156
resources/js/types/label.ts Normal file
View File

@ -0,0 +1,156 @@
import { UUID } from './uuid';
export interface Label {
id: UUID;
user_id: UUID;
name: string;
color: string;
created_at: string;
updated_at: string;
deleted_at: string | null;
}
export const LABEL_COLORS = [
'amber',
'blue',
'cyan',
'emerald',
'fuchsia',
'gray',
'green',
'indigo',
'lime',
'neutral',
'orange',
'pink',
'purple',
'red',
'rose',
'slate',
'stone',
'teal',
'violet',
'yellow',
] as const;
export type LabelColor = (typeof LABEL_COLORS)[number];
export function getLabelColorClasses(color: string): {
bg: string;
text: string;
border: string;
} {
const colorMap: Record<
string,
{ bg: string; text: string; border: string }
> = {
amber: {
bg: 'bg-amber-100 dark:bg-amber-900/30',
text: 'text-amber-700 dark:text-amber-300',
border: 'border-amber-300 dark:border-amber-700',
},
blue: {
bg: 'bg-blue-100 dark:bg-blue-900/30',
text: 'text-blue-700 dark:text-blue-300',
border: 'border-blue-300 dark:border-blue-700',
},
cyan: {
bg: 'bg-cyan-100 dark:bg-cyan-900/30',
text: 'text-cyan-700 dark:text-cyan-300',
border: 'border-cyan-300 dark:border-cyan-700',
},
emerald: {
bg: 'bg-emerald-100 dark:bg-emerald-900/30',
text: 'text-emerald-700 dark:text-emerald-300',
border: 'border-emerald-300 dark:border-emerald-700',
},
fuchsia: {
bg: 'bg-fuchsia-100 dark:bg-fuchsia-900/30',
text: 'text-fuchsia-700 dark:text-fuchsia-300',
border: 'border-fuchsia-300 dark:border-fuchsia-700',
},
gray: {
bg: 'bg-gray-100 dark:bg-gray-900/30',
text: 'text-gray-700 dark:text-gray-300',
border: 'border-gray-300 dark:border-gray-700',
},
green: {
bg: 'bg-green-100 dark:bg-green-900/30',
text: 'text-green-700 dark:text-green-300',
border: 'border-green-300 dark:border-green-700',
},
indigo: {
bg: 'bg-indigo-100 dark:bg-indigo-900/30',
text: 'text-indigo-700 dark:text-indigo-300',
border: 'border-indigo-300 dark:border-indigo-700',
},
lime: {
bg: 'bg-lime-100 dark:bg-lime-900/30',
text: 'text-lime-700 dark:text-lime-300',
border: 'border-lime-300 dark:border-lime-700',
},
neutral: {
bg: 'bg-neutral-100 dark:bg-neutral-900/30',
text: 'text-neutral-700 dark:text-neutral-300',
border: 'border-neutral-300 dark:border-neutral-700',
},
orange: {
bg: 'bg-orange-100 dark:bg-orange-900/30',
text: 'text-orange-700 dark:text-orange-300',
border: 'border-orange-300 dark:border-orange-700',
},
pink: {
bg: 'bg-pink-100 dark:bg-pink-900/30',
text: 'text-pink-700 dark:text-pink-300',
border: 'border-pink-300 dark:border-pink-700',
},
purple: {
bg: 'bg-purple-100 dark:bg-purple-900/30',
text: 'text-purple-700 dark:text-purple-300',
border: 'border-purple-300 dark:border-purple-700',
},
red: {
bg: 'bg-red-100 dark:bg-red-900/30',
text: 'text-red-700 dark:text-red-300',
border: 'border-red-300 dark:border-red-700',
},
rose: {
bg: 'bg-rose-100 dark:bg-rose-900/30',
text: 'text-rose-700 dark:text-rose-300',
border: 'border-rose-300 dark:border-rose-700',
},
slate: {
bg: 'bg-slate-100 dark:bg-slate-900/30',
text: 'text-slate-700 dark:text-slate-300',
border: 'border-slate-300 dark:border-slate-700',
},
stone: {
bg: 'bg-stone-100 dark:bg-stone-900/30',
text: 'text-stone-700 dark:text-stone-300',
border: 'border-stone-300 dark:border-stone-700',
},
teal: {
bg: 'bg-teal-100 dark:bg-teal-900/30',
text: 'text-teal-700 dark:text-teal-300',
border: 'border-teal-300 dark:border-teal-700',
},
violet: {
bg: 'bg-violet-100 dark:bg-violet-900/30',
text: 'text-violet-700 dark:text-violet-300',
border: 'border-violet-300 dark:border-violet-700',
},
yellow: {
bg: 'bg-yellow-100 dark:bg-yellow-900/30',
text: 'text-yellow-700 dark:text-yellow-300',
border: 'border-yellow-300 dark:border-yellow-700',
},
};
return (
colorMap[color] || {
bg: 'bg-gray-100 dark:bg-gray-900/30',
text: 'text-gray-700 dark:text-gray-300',
border: 'border-gray-300 dark:border-gray-700',
}
);
}

View File

@ -1,5 +1,6 @@
import { type Account, type Bank } from './account';
import { type Category } from './category';
import { type Label } from './label';
import { UUID } from './uuid';
export type TransactionSource = 'manually_created' | 'imported';
@ -17,6 +18,7 @@ export interface Transaction {
notes: string | null;
notes_iv: string | null;
source: TransactionSource;
label_ids?: UUID[];
created_at: string;
updated_at: string;
}
@ -27,6 +29,7 @@ export interface DecryptedTransaction extends Transaction {
account?: Account;
category?: Category | null;
bank?: Bank;
labels?: Label[];
}
export interface TransactionFilters {
@ -36,5 +39,6 @@ export interface TransactionFilters {
amountMax: number | null;
categoryIds: UUID[];
accountIds: UUID[];
labelIds: UUID[];
searchText: string;
}

View File

@ -8,6 +8,7 @@ use App\Http\Controllers\Sync\AccountSyncController;
use App\Http\Controllers\Sync\AutomationRuleSyncController;
use App\Http\Controllers\Sync\BankSyncController;
use App\Http\Controllers\Sync\CategorySyncController;
use App\Http\Controllers\Sync\LabelSyncController;
use App\Http\Controllers\Sync\TransactionSyncController;
use Illuminate\Support\Facades\Route;
@ -19,6 +20,7 @@ Route::middleware(['web', 'auth'])->group(function () {
// Sync
Route::prefix('sync')->group(function () {
Route::get('categories', [CategorySyncController::class, 'index']);
Route::get('labels', [LabelSyncController::class, 'index']);
Route::get('accounts', [AccountSyncController::class, 'index']);
Route::get('banks', [BankSyncController::class, 'index']);
Route::get('automation-rules', [AutomationRuleSyncController::class, 'index']);

View File

@ -3,6 +3,7 @@
use App\Http\Controllers\Settings\AccountController;
use App\Http\Controllers\Settings\BankController;
use App\Http\Controllers\Settings\CategoryController;
use App\Http\Controllers\Settings\LabelController;
use App\Http\Controllers\Settings\PasswordController;
use App\Http\Controllers\Settings\ProfileController;
use App\Http\Controllers\Settings\TwoFactorAuthenticationController;
@ -36,6 +37,11 @@ Route::middleware('auth')->group(function () {
Route::patch('settings/categories/{category}', [CategoryController::class, 'update'])->name('categories.update');
Route::delete('settings/categories/{category}', [CategoryController::class, 'destroy'])->name('categories.destroy');
Route::get('settings/labels', [LabelController::class, 'index'])->name('labels.index');
Route::post('settings/labels', [LabelController::class, 'store'])->name('labels.store');
Route::patch('settings/labels/{label}', [LabelController::class, 'update'])->name('labels.update');
Route::delete('settings/labels/{label}', [LabelController::class, 'destroy'])->name('labels.destroy');
Route::get('settings/automation-rules', [\App\Http\Controllers\Settings\AutomationRuleController::class, 'index'])->name('automation-rules.index');
Route::post('settings/automation-rules', [\App\Http\Controllers\Settings\AutomationRuleController::class, 'store'])->name('automation-rules.store');
Route::patch('settings/automation-rules/{automationRule}', [\App\Http\Controllers\Settings\AutomationRuleController::class, 'update'])->name('automation-rules.update');

201
tests/Feature/LabelTest.php Normal file
View File

@ -0,0 +1,201 @@
<?php
use App\Models\Label;
use App\Models\User;
test('authenticated users can create a label', function () {
$user = User::factory()->create();
$labelData = [
'name' => 'Important',
'color' => 'blue',
];
$response = $this->actingAs($user)->post(route('labels.store'), $labelData);
$response->assertRedirect(route('labels.index'));
$this->assertDatabaseHas('labels', [
'user_id' => $user->id,
'name' => 'Important',
'color' => 'blue',
]);
});
test('label name is required', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)->postJson(route('labels.store'), [
'color' => 'blue',
]);
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['name']);
});
test('label color is required', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)->postJson(route('labels.store'), [
'name' => 'Important',
]);
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['color']);
});
test('label color must be valid', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)->postJson(route('labels.store'), [
'name' => 'Important',
'color' => 'invalid-color',
]);
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['color']);
});
test('label names are unique per user', function () {
$user = User::factory()->create();
Label::factory()->create([
'user_id' => $user->id,
'name' => 'Urgent',
]);
$response = $this->actingAs($user)->postJson(route('labels.store'), [
'name' => 'Urgent',
'color' => 'red',
]);
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['name']);
});
test('different users can have labels with the same name', function () {
$user1 = User::factory()->create();
$user2 = User::factory()->create();
Label::factory()->create([
'user_id' => $user1->id,
'name' => 'Important',
]);
$response = $this->actingAs($user2)->post(route('labels.store'), [
'name' => 'Important',
'color' => 'blue',
]);
$response->assertRedirect(route('labels.index'));
$this->assertDatabaseHas('labels', [
'user_id' => $user2->id,
'name' => 'Important',
'color' => 'blue',
]);
});
test('authenticated users can update their own label', function () {
$user = User::factory()->create();
$label = Label::factory()->create(['user_id' => $user->id]);
$updateData = [
'name' => 'Updated Name',
'color' => 'green',
];
$response = $this->actingAs($user)->patch(
route('labels.update', $label),
$updateData
);
$response->assertRedirect(route('labels.index'));
$this->assertDatabaseHas('labels', [
'id' => $label->id,
'name' => 'Updated Name',
'color' => 'green',
]);
});
test('users cannot update labels they do not own', function () {
$user = User::factory()->create();
$otherUser = User::factory()->create();
$label = Label::factory()->create(['user_id' => $otherUser->id]);
$response = $this->actingAs($user)->patch(
route('labels.update', $label),
[
'name' => 'Updated Name',
'color' => 'green',
]
);
$response->assertForbidden();
});
test('authenticated users can delete their own label', function () {
$user = User::factory()->create();
$label = Label::factory()->create(['user_id' => $user->id]);
$response = $this->actingAs($user)->delete(route('labels.destroy', $label));
$response->assertRedirect(route('labels.index'));
$this->assertSoftDeleted('labels', [
'id' => $label->id,
]);
});
test('users cannot delete labels they do not own', function () {
$user = User::factory()->create();
$otherUser = User::factory()->create();
$label = Label::factory()->create(['user_id' => $otherUser->id]);
$response = $this->actingAs($user)->delete(route('labels.destroy', $label));
$response->assertForbidden();
$this->assertDatabaseHas('labels', [
'id' => $label->id,
'deleted_at' => null,
]);
});
test('guests cannot access label management', function () {
$response = $this->postJson(route('labels.store'), []);
$response->assertUnauthorized();
});
test('creating a label with the same name as a deleted label creates a new one', function () {
$user = User::factory()->create();
$label = Label::factory()->create([
'user_id' => $user->id,
'name' => 'Wedding',
'color' => 'blue',
]);
$originalId = $label->id;
$this->actingAs($user)->delete(route('labels.destroy', $label));
$this->assertSoftDeleted('labels', ['id' => $originalId]);
$response = $this->actingAs($user)->postJson(route('labels.store'), [
'name' => 'Wedding',
'color' => 'teal',
]);
$response->assertCreated();
$newLabel = Label::where('user_id', $user->id)
->where('name', 'Wedding')
->whereNull('deleted_at')
->first();
expect($newLabel)->not->toBeNull();
expect($newLabel->id)->not->toBe($originalId);
expect($newLabel->color)->toBe('teal');
$this->assertSoftDeleted('labels', ['id' => $originalId]);
});

View File

@ -0,0 +1,54 @@
<?php
use App\Models\Label;
use App\Models\User;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\getJson;
beforeEach(function () {
$this->user = User::factory()->create();
});
it('returns all labels for authenticated user', function () {
actingAs($this->user);
Label::factory()
->count(3)
->create(['user_id' => $this->user->id]);
Label::factory()
->count(2)
->create();
$response = getJson('/api/sync/labels');
$response->assertSuccessful();
$response->assertJsonCount(3, 'data');
});
it('returns only labels updated after specified timestamp', function () {
actingAs($this->user);
$oldLabel = Label::factory()->create([
'user_id' => $this->user->id,
'updated_at' => now()->subDays(2),
]);
$newLabel = Label::factory()->create([
'user_id' => $this->user->id,
'updated_at' => now(),
]);
$response = getJson('/api/sync/labels?since='.now()->subDay()->toIso8601String());
$response->assertSuccessful();
$response->assertJsonCount(1, 'data');
$response->assertJsonPath('data.0.id', $newLabel->id);
});
it('requires authentication', function () {
$response = getJson('/api/sync/labels');
$response->assertUnauthorized();
});