diff --git a/app/Http/Controllers/Api/SavedFilterController.php b/app/Http/Controllers/Api/SavedFilterController.php index e0401e77..9dfaf81d 100644 --- a/app/Http/Controllers/Api/SavedFilterController.php +++ b/app/Http/Controllers/Api/SavedFilterController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Http\Requests\Api\StoreSavedFilterRequest; +use App\Http\Requests\Api\UpdateSavedFilterRequest; use App\Models\SavedFilter; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -33,6 +34,17 @@ class SavedFilterController extends Controller ], 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); 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/resources/js/components/transactions/saved-filters.tsx b/resources/js/components/transactions/saved-filters.tsx index 75e3a961..9ecc1e09 100644 --- a/resources/js/components/transactions/saved-filters.tsx +++ b/resources/js/components/transactions/saved-filters.tsx @@ -18,16 +18,25 @@ import { 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, Plus, Trash2 } from 'lucide-react'; -import { useEffect, useState } from 'react'; +import { + Bookmark, + BookmarkCheck, + Check, + Plus, + Save, + Trash2, +} from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; import { toast } from 'sonner'; interface SavedFilter { @@ -43,12 +52,44 @@ interface SavedFiltersProps { 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; @@ -69,6 +110,7 @@ export function SavedFilters({ filters, onLoad }: SavedFiltersProps) { }, []); function handleLoad(savedFilter: SavedFilter) { + setActiveId(savedFilter.id); onLoad(deserializeFilters(savedFilter.filters)); } @@ -77,6 +119,9 @@ export function SavedFilters({ filters, onLoad }: SavedFiltersProps) { setSavedFilters((current) => current.filter((item) => item.id !== savedFilter.id), ); + if (activeId === savedFilter.id) { + setActiveId(null); + } try { await axios.delete(`/api/saved-filters/${savedFilter.id}`); @@ -87,6 +132,26 @@ export function SavedFilters({ filters, onLoad }: SavedFiltersProps) { } } + 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) { @@ -108,6 +173,7 @@ export function SavedFilters({ filters, onLoad }: SavedFiltersProps) { a.name.localeCompare(b.name), ), ); + setActiveId(response.data.data.id); setSaveDialogOpen(false); setName(''); toast.success(__('Filter saved')); @@ -127,13 +193,34 @@ export function SavedFilters({ filters, onLoad }: SavedFiltersProps) { <> - + {activeFilter ? ( + + ) : ( + + )} {__('Saved filters')} @@ -150,8 +237,18 @@ export function SavedFilters({ filters, onLoad }: SavedFiltersProps) { onSelect={() => handleLoad(savedFilter)} className="group justify-between gap-2" > - - {savedFilter.name} + + + + {savedFilter.name} +