diff --git a/app/Features/TransactionAnalysis.php b/app/Features/TransactionAnalysis.php new file mode 100644 index 00000000..260c4ff7 --- /dev/null +++ b/app/Features/TransactionAnalysis.php @@ -0,0 +1,19 @@ +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 update(UpdateSavedFilterRequest $request, SavedFilter $savedFilter): JsonResponse + { + abort_unless($savedFilter->user_id === $request->user()->id, 403); + + $savedFilter->update(['filters' => $request->validated('filters')]); + + return response()->json([ + 'data' => $savedFilter->only(['id', 'name', 'filters']), + ]); + } + + 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/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 23fe66fe..02a8a020 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -4,6 +4,7 @@ namespace App\Http\Middleware; use App\Enums\BankingConnectionStatus; use App\Features\CalculateBalancesOnImport; +use App\Features\TransactionAnalysis; use App\Models\BankingConnection; use App\Services\CurrencyOptions; use Illuminate\Foundation\Inspiring; @@ -168,6 +169,9 @@ class HandleInertiaRequests extends Middleware 'calculateBalancesOnImport' => $user ? Feature::for($user)->active(CalculateBalancesOnImport::class) : false, + 'transactionAnalysis' => $user + ? Feature::for($user)->active(TransactionAnalysis::class) + : false, ]; } 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/Http/Requests/Api/UpdateSavedFilterRequest.php b/app/Http/Requests/Api/UpdateSavedFilterRequest.php new file mode 100644 index 00000000..5ac652fb --- /dev/null +++ b/app/Http/Requests/Api/UpdateSavedFilterRequest.php @@ -0,0 +1,34 @@ + */ + public function rules(): array + { + return [ + '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..ef97a3c2 --- /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' => [ + 'category_ids' => [], + 'account_ids' => [], + 'label_ids' => [], + 'search' => fake()->word(), + ], + ]; + } +} diff --git a/database/migrations/2026_06_05_120000_create_saved_filters_table.php b/database/migrations/2026_06_05_120000_create_saved_filters_table.php new file mode 100644 index 00000000..42a23a4d --- /dev/null +++ b/database/migrations/2026_06_05_120000_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/lang/es.json b/lang/es.json index ec4ee31b..2f6f9f5d 100644 --- a/lang/es.json +++ b/lang/es.json @@ -1,4 +1,20 @@ { + "A filter with that name already exists": "Ya existe un filtro con ese nombre", + "Delete saved filter": "Eliminar filtro guardado", + "Failed to delete the saved filter": "No se pudo eliminar el filtro guardado", + "Failed to save the filter": "No se pudo guardar el filtro", + "Failed to update the saved filter": "No se pudo actualizar el filtro guardado", + "Filter saved": "Filtro guardado", + "Filter updated": "Filtro actualizado", + "No saved filters yet": "Aún no hay filtros guardados", + "Save as new filter…": "Guardar como nuevo filtro…", + "Save filter": "Guardar filtro", + "Saved filters": "Filtros guardados", + "Saving…": "Guardando…", + "Unsaved changes": "Cambios sin guardar", + "Update “:name”": "Actualizar «:name»", + "e.g. Japan trip, Utilities": "p. ej. Viaje a Japón, Servicios", + "Give this set of filters a name so you can reuse it later.": "Dale un nombre a este conjunto de filtros para reutilizarlo más tarde.", "+:count": "+:count", ":count selected": ":count seleccionadas", "Categories": "Categorías", diff --git a/resources/js/components/transactions/saved-filters.tsx b/resources/js/components/transactions/saved-filters.tsx new file mode 100644 index 00000000..7514e9f7 --- /dev/null +++ b/resources/js/components/transactions/saved-filters.tsx @@ -0,0 +1,337 @@ +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, + filtersFingerprint, + hasActiveFilters, + type SerializedFilters, + serializeFilters, +} from '@/lib/transaction-filter-serialization'; +import { cn } from '@/lib/utils'; +import { type TransactionFilters } from '@/types/transaction'; +import { type UUID } from '@/types/uuid'; +import { __ } from '@/utils/i18n'; +import axios from 'axios'; +import { + Bookmark, + BookmarkCheck, + Check, + Plus, + Save, + Trash2, +} from 'lucide-react'; +import { useEffect, useMemo, 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 [activeId, setActiveId] = useState(null); + const [saveDialogOpen, setSaveDialogOpen] = useState(false); + const [name, setName] = useState(''); + const [isSaving, setIsSaving] = useState(false); + + const canSave = hasActiveFilters(filters); + + const currentFingerprint = useMemo( + () => filtersFingerprint(serializeFilters(filters)), + [filters], + ); + + // The saved filter that exactly matches the current filters, if any. + const matchingSaved = useMemo( + () => + savedFilters.find( + (savedFilter) => + filtersFingerprint(savedFilter.filters) === + currentFingerprint, + ) ?? null, + [savedFilters, currentFingerprint], + ); + + // Keep track of the saved filter we're working from: it follows an exact + // match, and otherwise sticks around (so edits can be saved over it) until + // the filters are cleared. + useEffect(() => { + if (matchingSaved) { + setActiveId(matchingSaved.id); + } else if (!canSave) { + setActiveId(null); + } + }, [matchingSaved, canSave]); + + const activeFilter = + savedFilters.find((savedFilter) => savedFilter.id === activeId) ?? null; + const isDirty = activeFilter !== null && matchingSaved === null; + + 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) { + setActiveId(savedFilter.id); + onLoad(deserializeFilters(savedFilter.filters)); + } + + async function handleDelete(savedFilter: SavedFilter) { + const previous = savedFilters; + setSavedFilters((current) => + current.filter((item) => item.id !== savedFilter.id), + ); + if (activeId === savedFilter.id) { + setActiveId(null); + } + + 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 handleUpdate(savedFilter: SavedFilter) { + try { + const response = await axios.patch<{ data: SavedFilter }>( + `/api/saved-filters/${savedFilter.id}`, + { filters: serializeFilters(filters) }, + ); + + setSavedFilters((current) => + current.map((item) => + item.id === savedFilter.id ? response.data.data : item, + ), + ); + setActiveId(savedFilter.id); + toast.success(__('Filter updated')); + } catch (error) { + console.error('Failed to update saved filter:', error); + toast.error(__('Failed to update 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), + ), + ); + setActiveId(response.data.data.id); + 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 ( + <> + + + {activeFilter ? ( + + ) : ( + + )} + + + {__('Saved filters')} + + + {savedFilters.length === 0 ? ( +
+ {__('No saved filters yet')} +
+ ) : ( + savedFilters.map((savedFilter) => ( + handleLoad(savedFilter)} + className="group justify-between gap-2" + > + + + + {savedFilter.name} + + + + + )) + )} + + + + {isDirty && activeFilter && ( + { + event.preventDefault(); + handleUpdate(activeFilter); + }} + > + + {__('Update “:name”', { name: activeFilter.name })} + + )} + + { + event.preventDefault(); + setSaveDialogOpen(true); + }} + > + + {__('Save as new filter…')} + +
+
+ + + + + {__('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 b0a45fbd..9e377898 100644 --- a/resources/js/components/transactions/transaction-filters.tsx +++ b/resources/js/components/transactions/transaction-filters.tsx @@ -1,10 +1,12 @@ import { __ } from '@/utils/i18n'; +import { usePage } from '@inertiajs/react'; import { format } from 'date-fns'; import * as Icons from 'lucide-react'; import { ChevronsUpDown, Tag, X } from 'lucide-react'; import { type ReactNode, useEffect, useMemo, 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 { Checkbox } from '@/components/ui/checkbox'; @@ -29,6 +31,7 @@ import { toggleCategorySelection, } from '@/lib/category-tree'; import { cn } from '@/lib/utils'; +import { type SharedData } from '@/types'; import { type Account } from '@/types/account'; import { type Category, getCategoryColorClasses } from '@/types/category'; import { getLabelColorClasses, type Label } from '@/types/label'; @@ -44,6 +47,7 @@ interface TransactionFiltersProps { isKeySet: boolean; actions?: ReactNode; hideAccountFilter?: boolean; + enableSavedFilters?: boolean; } export function TransactionFilters({ @@ -54,7 +58,9 @@ export function TransactionFilters({ accounts, actions, hideAccountFilter = false, + enableSavedFilters = false, }: TransactionFiltersProps) { + const { features } = usePage().props; const [isOpen, setIsOpen] = useState(false); const [categoryDropdownOpen, setCategoryDropdownOpen] = useState(false); const [categorySearch, setCategorySearch] = useState(''); @@ -231,13 +237,13 @@ export function TransactionFilters({ return (
-
+
setSearchText(e.target.value)} - className="min-w-0 flex-1 md:min-w-[350px]" + className="hidden min-w-0 flex-1 md:block md:min-w-[350px]" /> @@ -266,6 +272,19 @@ export function TransactionFilters({
+
+ {__('Search')} + + setSearchText(e.target.value) + } + /> +
+
{__('Date')}
@@ -685,6 +704,13 @@ export function TransactionFilters({ + {enableSavedFilters && features.transactionAnalysis && ( + + )} + {activeFilterCount > 0 && (