diff --git a/app/Http/Controllers/Api/SavedFilterController.php b/app/Http/Controllers/Api/SavedFilterController.php new file mode 100644 index 00000000..e0401e77 --- /dev/null +++ b/app/Http/Controllers/Api/SavedFilterController.php @@ -0,0 +1,44 @@ +where('user_id', $request->user()->id) + ->orderBy('name') + ->get(['id', 'name', 'filters']); + + return response()->json(['data' => $savedFilters]); + } + + public function store(StoreSavedFilterRequest $request): JsonResponse + { + $savedFilter = SavedFilter::query()->create([ + 'user_id' => $request->user()->id, + 'name' => $request->validated('name'), + 'filters' => $request->validated('filters'), + ]); + + return response()->json([ + 'data' => $savedFilter->only(['id', 'name', 'filters']), + ], 201); + } + + public function destroy(Request $request, SavedFilter $savedFilter): JsonResponse + { + abort_unless($savedFilter->user_id === $request->user()->id, 403); + + $savedFilter->delete(); + + return response()->json(['message' => 'Saved filter deleted']); + } +} diff --git a/app/Http/Requests/Api/StoreSavedFilterRequest.php b/app/Http/Requests/Api/StoreSavedFilterRequest.php new file mode 100644 index 00000000..7da8371a --- /dev/null +++ b/app/Http/Requests/Api/StoreSavedFilterRequest.php @@ -0,0 +1,41 @@ + */ + public function rules(): array + { + return [ + 'name' => [ + 'required', + 'string', + 'max:255', + Rule::unique('saved_filters', 'name')->where('user_id', $this->user()->id), + ], + 'filters' => ['required', 'array'], + 'filters.date_from' => ['nullable', 'date'], + 'filters.date_to' => ['nullable', 'date'], + 'filters.amount_min' => ['nullable', 'numeric'], + 'filters.amount_max' => ['nullable', 'numeric'], + 'filters.category_ids' => ['nullable', 'array'], + 'filters.category_ids.*' => ['string'], + 'filters.account_ids' => ['nullable', 'array'], + 'filters.account_ids.*' => ['string'], + 'filters.label_ids' => ['nullable', 'array'], + 'filters.label_ids.*' => ['string'], + 'filters.creditor_name' => ['nullable', 'string', 'max:255'], + 'filters.debtor_name' => ['nullable', 'string', 'max:255'], + 'filters.search' => ['nullable', 'string', 'max:200'], + ]; + } +} diff --git a/app/Models/SavedFilter.php b/app/Models/SavedFilter.php new file mode 100644 index 00000000..34f39163 --- /dev/null +++ b/app/Models/SavedFilter.php @@ -0,0 +1,34 @@ + */ + use HasFactory, HasUuids; + + protected $fillable = [ + 'user_id', + 'name', + 'filters', + ]; + + protected function casts(): array + { + return [ + 'filters' => 'array', + ]; + } + + /** @return BelongsTo */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/database/factories/SavedFilterFactory.php b/database/factories/SavedFilterFactory.php new file mode 100644 index 00000000..c755f077 --- /dev/null +++ b/database/factories/SavedFilterFactory.php @@ -0,0 +1,32 @@ + + */ +class SavedFilterFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'name' => fake()->unique()->words(2, true), + 'filters' => [ + 'categoryIds' => [], + 'accountIds' => [], + 'labelIds' => [], + 'searchText' => fake()->word(), + ], + ]; + } +} diff --git a/database/migrations/2026_06_01_131538_create_saved_filters_table.php b/database/migrations/2026_06_01_131538_create_saved_filters_table.php new file mode 100644 index 00000000..42a23a4d --- /dev/null +++ b/database/migrations/2026_06_01_131538_create_saved_filters_table.php @@ -0,0 +1,26 @@ +uuid('id')->primary(); + $table->foreignUuid('user_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->json('filters'); + $table->timestamps(); + + $table->unique(['user_id', 'name']); + }); + } + + public function down(): void + { + Schema::dropIfExists('saved_filters'); + } +}; diff --git a/resources/js/components/transactions/saved-filters.tsx b/resources/js/components/transactions/saved-filters.tsx new file mode 100644 index 00000000..75e3a961 --- /dev/null +++ b/resources/js/components/transactions/saved-filters.tsx @@ -0,0 +1,227 @@ +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Input } from '@/components/ui/input'; +import { + deserializeFilters, + hasActiveFilters, + type SerializedFilters, + serializeFilters, +} from '@/lib/transaction-filter-serialization'; +import { type TransactionFilters } from '@/types/transaction'; +import { type UUID } from '@/types/uuid'; +import { __ } from '@/utils/i18n'; +import axios from 'axios'; +import { Bookmark, Plus, Trash2 } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; + +interface SavedFilter { + id: UUID; + name: string; + filters: SerializedFilters; +} + +interface SavedFiltersProps { + filters: TransactionFilters; + onLoad: (filters: TransactionFilters) => void; +} + +export function SavedFilters({ filters, onLoad }: SavedFiltersProps) { + const [savedFilters, setSavedFilters] = useState([]); + const [saveDialogOpen, setSaveDialogOpen] = useState(false); + const [name, setName] = useState(''); + const [isSaving, setIsSaving] = useState(false); + + const canSave = hasActiveFilters(filters); + + useEffect(() => { + let active = true; + + axios + .get<{ data: SavedFilter[] }>('/api/saved-filters') + .then((response) => { + if (active) { + setSavedFilters(response.data.data); + } + }) + .catch((error) => { + console.error('Failed to load saved filters:', error); + }); + + return () => { + active = false; + }; + }, []); + + function handleLoad(savedFilter: SavedFilter) { + onLoad(deserializeFilters(savedFilter.filters)); + } + + async function handleDelete(savedFilter: SavedFilter) { + const previous = savedFilters; + setSavedFilters((current) => + current.filter((item) => item.id !== savedFilter.id), + ); + + try { + await axios.delete(`/api/saved-filters/${savedFilter.id}`); + } catch (error) { + console.error('Failed to delete saved filter:', error); + setSavedFilters(previous); + toast.error(__('Failed to delete the saved filter')); + } + } + + async function handleSave() { + const trimmedName = name.trim(); + if (!trimmedName || isSaving) { + return; + } + + setIsSaving(true); + try { + const response = await axios.post<{ data: SavedFilter }>( + '/api/saved-filters', + { + name: trimmedName, + filters: serializeFilters(filters), + }, + ); + + setSavedFilters((current) => + [...current, response.data.data].sort((a, b) => + a.name.localeCompare(b.name), + ), + ); + setSaveDialogOpen(false); + setName(''); + toast.success(__('Filter saved')); + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 422) { + toast.error(__('A filter with that name already exists')); + } else { + console.error('Failed to save filter:', error); + toast.error(__('Failed to save the filter')); + } + } finally { + setIsSaving(false); + } + } + + return ( + <> + + + + + + {__('Saved filters')} + + + {savedFilters.length === 0 ? ( +
+ {__('No saved filters yet')} +
+ ) : ( + savedFilters.map((savedFilter) => ( + handleLoad(savedFilter)} + className="group justify-between gap-2" + > + + {savedFilter.name} + + + + )) + )} + + + { + event.preventDefault(); + setSaveDialogOpen(true); + }} + > + + {__('Save current filters…')} + +
+
+ + + + + {__('Save filter')} + + {__( + 'Give this set of filters a name so you can reuse it later.', + )} + + + setName(event.target.value)} + placeholder={__('e.g. Japan trip, Utilities')} + autoFocus + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + handleSave(); + } + }} + /> + + + + + + + + ); +} diff --git a/resources/js/components/transactions/transaction-filters.tsx b/resources/js/components/transactions/transaction-filters.tsx index e4befaa7..335c8c5a 100644 --- a/resources/js/components/transactions/transaction-filters.tsx +++ b/resources/js/components/transactions/transaction-filters.tsx @@ -5,6 +5,7 @@ import { Check, ChevronsUpDown, Tag, X } from 'lucide-react'; import { type ReactNode, useEffect, useState } from 'react'; import { AccountName } from '@/components/accounts/account-name'; +import { SavedFilters } from '@/components/transactions/saved-filters'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { @@ -165,239 +166,268 @@ export function TransactionFilters({ categories={categories} selectedIds={filters.categoryIds} onToggle={handleCategoryToggle} - triggerClassName="w-[180px]" + triggerClassName="w-[230px]" /> )} - - - - - -
-
-

- {__('Filters')} -

-
- -
- {__('Date')} -
- - onFiltersChange({ - ...filters, - dateFrom: e.target.value - ? new Date( - e.target.value, - ) - : null, - }) - } - placeholder={__('From')} - /> - - - onFiltersChange({ - ...filters, - dateTo: e.target.value - ? new Date( - e.target.value, - ) - : null, - }) - } - placeholder={__('To')} - /> + {activeFilterCount > 0 && ( + + {activeFilterCount} + + )} + + + +
+
+

+ {__('Filters')} +

-
-
- {__('Amount')} -
- - onFiltersChange({ - ...filters, - amountMin: e.target.value - ? parseFloat( - e.target.value, +
+ {__('Date')} +
+ + : '' + } + onChange={(e) => + onFiltersChange({ + ...filters, + dateFrom: e.target.value + ? new Date( + e.target + .value, + ) + : null, + }) + } + placeholder={__('From')} + /> - - onFiltersChange({ - ...filters, - amountMax: e.target.value - ? parseFloat( - e.target.value, + + : '' + } + onChange={(e) => + onFiltersChange({ + ...filters, + dateTo: e.target.value + ? new Date( + e.target + .value, + ) + : null, + }) + } + placeholder={__('To')} + /> +
-
-
- - {__('Counterparties')} - -
- - setCreditorName(e.target.value) - } - placeholder={__('Creditor name')} - /> - - setDebtorName(e.target.value) - } - placeholder={__('Debtor name')} - /> +
+ {__('Amount')} +
+ + onFiltersChange({ + ...filters, + amountMin: e.target + .value + ? parseFloat( + e.target + .value, + ) + : null, + }) + } + placeholder={__('Min')} + /> + + + onFiltersChange({ + ...filters, + amountMax: e.target + .value + ? parseFloat( + e.target + .value, + ) + : null, + }) + } + placeholder={__('Max')} + /> +
-
- {!inlineCategoryLabel && (
- {__('Categories')} + {__('Counterparties')} -
- + + setCreditorName( + e.target.value, + ) } - onToggle={handleCategoryToggle} - triggerClassName="w-full" + placeholder={__( + 'Creditor name', + )} + /> + + setDebtorName( + e.target.value, + ) + } + placeholder={__('Debtor name')} />
- )} - {!inlineCategoryLabel && ( -
- {__('Labels')} -
- + {!inlineCategoryLabel && ( +
+ + {__('Categories')} + +
+ +
-
- )} + )} - {!hideAccountFilter && ( -
- {__('Accounts')} -
- {accounts.map((account) => { - const isSelected = - filters.accountIds.includes( - account.id, + {!inlineCategoryLabel && ( +
+ + {__('Labels')} + +
+ +
+
+ )} + + {!hideAccountFilter && ( +
+ + {__('Accounts')} + +
+ {accounts.map((account) => { + const isSelected = + filters.accountIds.includes( + account.id, + ); + return ( + + handleAccountToggle( + account.id, + ) + } + > + + ); - return ( - - handleAccountToggle( - account.id, - ) - } - > - - - ); - })} + })} +
-
- )} -
- - + )} +
+ + - {activeFilterCount > 0 && ( - - )} + + + {activeFilterCount > 0 && ( + + )} +
{actions ?
{actions}
: null} @@ -446,7 +476,11 @@ function CategoryMultiSelect({ > {selectedIds.length > 0 ? ( - {selectedIds.length} {__('selected')} + {selectedIds.length === 1 + ? __('1 category selected') + : __(':count categories selected', { + count: selectedIds.length, + })} ) : ( @@ -561,7 +595,11 @@ function LabelMultiSelect({ > {selectedIds.length > 0 ? ( - {selectedIds.length} {__('selected')} + {selectedIds.length === 1 + ? __('1 label selected') + : __(':count labels selected', { + count: selectedIds.length, + })} ) : ( diff --git a/resources/js/lib/transaction-filter-serialization.ts b/resources/js/lib/transaction-filter-serialization.ts new file mode 100644 index 00000000..34f1dacc --- /dev/null +++ b/resources/js/lib/transaction-filter-serialization.ts @@ -0,0 +1,78 @@ +import { type TransactionFilters } from '@/types/transaction'; +import { format } from 'date-fns'; + +/** Persisted, snake_case representation of a filter set (matches the backend query params). */ +export interface SerializedFilters { + date_from?: string; + date_to?: string; + amount_min?: number; + amount_max?: number; + category_ids?: string[]; + account_ids?: string[]; + label_ids?: string[]; + creditor_name?: string; + debtor_name?: string; + search?: string; +} + +export function serializeFilters( + filters: TransactionFilters, +): SerializedFilters { + const result: SerializedFilters = {}; + + if (filters.dateFrom) { + result.date_from = format(filters.dateFrom, 'yyyy-MM-dd'); + } + if (filters.dateTo) { + result.date_to = format(filters.dateTo, 'yyyy-MM-dd'); + } + if (filters.amountMin !== null) { + result.amount_min = filters.amountMin; + } + if (filters.amountMax !== null) { + result.amount_max = filters.amountMax; + } + if (filters.categoryIds.length > 0) { + result.category_ids = filters.categoryIds; + } + if (filters.accountIds.length > 0) { + result.account_ids = filters.accountIds; + } + if (filters.labelIds.length > 0) { + result.label_ids = filters.labelIds; + } + if (filters.creditorName) { + result.creditor_name = filters.creditorName; + } + if (filters.debtorName) { + result.debtor_name = filters.debtorName; + } + if (filters.searchText) { + result.search = filters.searchText; + } + + return result; +} + +export function deserializeFilters( + data: SerializedFilters, +): TransactionFilters { + return { + dateFrom: data.date_from + ? new Date(data.date_from + 'T00:00:00') + : null, + dateTo: data.date_to ? new Date(data.date_to + 'T00:00:00') : null, + amountMin: data.amount_min ?? null, + amountMax: data.amount_max ?? null, + categoryIds: data.category_ids ?? [], + accountIds: data.account_ids ?? [], + labelIds: data.label_ids ?? [], + creditorName: data.creditor_name ?? '', + debtorName: data.debtor_name ?? '', + searchText: data.search ?? '', + }; +} + +export function hasActiveFilters(filters: TransactionFilters): boolean { + return Object.keys(serializeFilters(filters)).length > 0; +} diff --git a/routes/api.php b/routes/api.php index 09263b59..ad966520 100644 --- a/routes/api.php +++ b/routes/api.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Api\AnalysisController; use App\Http\Controllers\Api\CashflowAnalyticsController; use App\Http\Controllers\Api\DashboardAnalyticsController; use App\Http\Controllers\Api\ImportDataController; +use App\Http\Controllers\Api\SavedFilterController; use App\Http\Controllers\Api\TransactionController; use App\Http\Controllers\EncryptionController; use App\Http\Controllers\Sync\TransactionSyncController; @@ -60,4 +61,9 @@ Route::middleware(['web', 'auth'])->group(function () { // Expense / income analysis Route::get('analysis', [AnalysisController::class, 'index'])->name('api.analysis.index'); + + // Saved filters (shared between transactions and analysis screens) + Route::get('saved-filters', [SavedFilterController::class, 'index'])->name('api.saved-filters.index'); + Route::post('saved-filters', [SavedFilterController::class, 'store'])->name('api.saved-filters.store'); + Route::delete('saved-filters/{savedFilter}', [SavedFilterController::class, 'destroy'])->name('api.saved-filters.destroy'); }); diff --git a/tests/Feature/SavedFilterTest.php b/tests/Feature/SavedFilterTest.php new file mode 100644 index 00000000..38e4e92b --- /dev/null +++ b/tests/Feature/SavedFilterTest.php @@ -0,0 +1,89 @@ +user = User::factory()->create(); + $this->actingAs($this->user); +}); + +test('saved filters endpoints require authentication', function () { + auth()->logout(); + + $this->getJson('/api/saved-filters')->assertUnauthorized(); +}); + +test('lists only the current user saved filters ordered by name', function () { + SavedFilter::factory()->create(['user_id' => $this->user->id, 'name' => 'Zurich trip']); + SavedFilter::factory()->create(['user_id' => $this->user->id, 'name' => 'Apartment bills']); + SavedFilter::factory()->create(['name' => 'Someone else']); + + $response = $this->getJson('/api/saved-filters')->assertOk(); + + expect($response->json('data'))->toHaveCount(2); + expect($response->json('data.0.name'))->toBe('Apartment bills'); + expect($response->json('data.1.name'))->toBe('Zurich trip'); +}); + +test('stores a saved filter for the current user', function () { + $payload = [ + 'name' => 'Trip to Japan', + 'filters' => [ + 'label_ids' => ['11111111-1111-1111-1111-111111111111'], + 'category_ids' => ['food'], + 'date_from' => '2026-01-01', + ], + ]; + + $response = $this->postJson('/api/saved-filters', $payload)->assertCreated(); + + expect($response->json('data.name'))->toBe('Trip to Japan'); + expect($response->json('data.filters.category_ids'))->toBe(['food']); + + $this->assertDatabaseHas('saved_filters', [ + 'user_id' => $this->user->id, + 'name' => 'Trip to Japan', + ]); +}); + +test('name is required and unique per user', function () { + SavedFilter::factory()->create(['user_id' => $this->user->id, 'name' => 'Duplicate']); + + $this->postJson('/api/saved-filters', ['filters' => []]) + ->assertStatus(422) + ->assertJsonValidationErrors(['name', 'filters']); + + $this->postJson('/api/saved-filters', ['name' => 'Duplicate', 'filters' => []]) + ->assertStatus(422) + ->assertJsonValidationErrors(['name']); +}); + +test('the same name can be reused by a different user', function () { + SavedFilter::factory()->create(['user_id' => $this->user->id, 'name' => 'Shared name']); + + $other = User::factory()->create(); + + $this->actingAs($other) + ->postJson('/api/saved-filters', [ + 'name' => 'Shared name', + 'filters' => ['search' => 'groceries'], + ]) + ->assertCreated(); +}); + +test('a user can delete their own saved filter', function () { + $savedFilter = SavedFilter::factory()->create(['user_id' => $this->user->id]); + + $this->deleteJson("/api/saved-filters/{$savedFilter->id}")->assertOk(); + + $this->assertDatabaseMissing('saved_filters', ['id' => $savedFilter->id]); +}); + +test('a user cannot delete another user saved filter', function () { + $savedFilter = SavedFilter::factory()->create(); + + $this->deleteJson("/api/saved-filters/{$savedFilter->id}")->assertForbidden(); + + $this->assertDatabaseHas('saved_filters', ['id' => $savedFilter->id]); +});