feat(filters): add saved filters shared across transactions and analysis
Persist named filter sets per user (model, migration, API CRUD) and a
Saved dropdown to load/delete them; available on both the transactions
and analysis screens via the shared filter bar.
Also refine the filter bar: icon-only Saved button, wider category/label
dropdowns, Filters/Saved pushed to the right, and clearer trigger text
("N categories/labels selected").
This commit is contained in:
parent
3f01cc60c6
commit
f18c3ba61e
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\StoreSavedFilterRequest;
|
||||
use App\Models\SavedFilter;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SavedFilterController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$savedFilters = SavedFilter::query()
|
||||
->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']);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreSavedFilterRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\SavedFilterFactory;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class SavedFilter extends Model
|
||||
{
|
||||
/** @use HasFactory<SavedFilterFactory> */
|
||||
use HasFactory, HasUuids;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'name',
|
||||
'filters',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'filters' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<User, $this> */
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\SavedFilter;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<SavedFilter>
|
||||
*/
|
||||
class SavedFilterFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => User::factory(),
|
||||
'name' => fake()->unique()->words(2, true),
|
||||
'filters' => [
|
||||
'categoryIds' => [],
|
||||
'accountIds' => [],
|
||||
'labelIds' => [],
|
||||
'searchText' => fake()->word(),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('saved_filters', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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<SavedFilter[]>([]);
|
||||
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 (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label={__('Saved filters')}
|
||||
>
|
||||
<Bookmark className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
<DropdownMenuLabel>{__('Saved filters')}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{savedFilters.length === 0 ? (
|
||||
<div className="px-2 py-2 text-sm text-muted-foreground">
|
||||
{__('No saved filters yet')}
|
||||
</div>
|
||||
) : (
|
||||
savedFilters.map((savedFilter) => (
|
||||
<DropdownMenuItem
|
||||
key={savedFilter.id}
|
||||
onSelect={() => handleLoad(savedFilter)}
|
||||
className="group justify-between gap-2"
|
||||
>
|
||||
<span className="truncate">
|
||||
{savedFilter.name}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={__('Delete saved filter')}
|
||||
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 hover:text-destructive"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleDelete(savedFilter);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
disabled={!canSave}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
setSaveDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
{__('Save current filters…')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Dialog open={saveDialogOpen} onOpenChange={setSaveDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{__('Save filter')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{__(
|
||||
'Give this set of filters a name so you can reuse it later.',
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder={__('e.g. Japan trip, Utilities')}
|
||||
autoFocus
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSaveDialogOpen(false)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{__('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !name.trim()}
|
||||
>
|
||||
{isSaving ? __('Saving…') : __('Save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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]"
|
||||
/>
|
||||
<LabelMultiSelect
|
||||
labels={labels}
|
||||
selectedIds={filters.labelIds}
|
||||
onToggle={handleLabelToggle}
|
||||
triggerClassName="w-[160px]"
|
||||
triggerClassName="w-[210px]"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline">
|
||||
{__('Filters')}
|
||||
<div className="flex items-center gap-2 lg:ml-auto">
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline">
|
||||
{__('Filters')}
|
||||
|
||||
{activeFilterCount > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-2 rounded-full px-1.5 py-0.5"
|
||||
>
|
||||
{activeFilterCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="max-h-[600px] w-96 overflow-y-auto"
|
||||
align="start"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium">
|
||||
{__('Filters')}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{__('Date')}</FormLabel>
|
||||
<div className="grid grid-cols-2 gap-2 pt-2">
|
||||
<Input
|
||||
type="date"
|
||||
value={
|
||||
filters.dateFrom
|
||||
? format(
|
||||
filters.dateFrom,
|
||||
'yyyy-MM-dd',
|
||||
)
|
||||
: ''
|
||||
}
|
||||
onChange={(e) =>
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
dateFrom: e.target.value
|
||||
? new Date(
|
||||
e.target.value,
|
||||
)
|
||||
: null,
|
||||
})
|
||||
}
|
||||
placeholder={__('From')}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="date"
|
||||
value={
|
||||
filters.dateTo
|
||||
? format(
|
||||
filters.dateTo,
|
||||
'yyyy-MM-dd',
|
||||
)
|
||||
: ''
|
||||
}
|
||||
onChange={(e) =>
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
dateTo: e.target.value
|
||||
? new Date(
|
||||
e.target.value,
|
||||
)
|
||||
: null,
|
||||
})
|
||||
}
|
||||
placeholder={__('To')}
|
||||
/>
|
||||
{activeFilterCount > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-2 rounded-full px-1.5 py-0.5"
|
||||
>
|
||||
{activeFilterCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="max-h-[600px] w-96 overflow-y-auto"
|
||||
align="start"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium">
|
||||
{__('Filters')}
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{__('Amount')}</FormLabel>
|
||||
<div className="grid grid-cols-2 gap-2 pt-2">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={filters.amountMin ?? ''}
|
||||
onChange={(e) =>
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
amountMin: e.target.value
|
||||
? parseFloat(
|
||||
e.target.value,
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{__('Date')}</FormLabel>
|
||||
<div className="grid grid-cols-2 gap-2 pt-2">
|
||||
<Input
|
||||
type="date"
|
||||
value={
|
||||
filters.dateFrom
|
||||
? format(
|
||||
filters.dateFrom,
|
||||
'yyyy-MM-dd',
|
||||
)
|
||||
: null,
|
||||
})
|
||||
}
|
||||
placeholder={__('Min')}
|
||||
/>
|
||||
: ''
|
||||
}
|
||||
onChange={(e) =>
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
dateFrom: e.target.value
|
||||
? new Date(
|
||||
e.target
|
||||
.value,
|
||||
)
|
||||
: null,
|
||||
})
|
||||
}
|
||||
placeholder={__('From')}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={filters.amountMax ?? ''}
|
||||
onChange={(e) =>
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
amountMax: e.target.value
|
||||
? parseFloat(
|
||||
e.target.value,
|
||||
<Input
|
||||
type="date"
|
||||
value={
|
||||
filters.dateTo
|
||||
? format(
|
||||
filters.dateTo,
|
||||
'yyyy-MM-dd',
|
||||
)
|
||||
: null,
|
||||
})
|
||||
}
|
||||
placeholder={__('Max')}
|
||||
/>
|
||||
: ''
|
||||
}
|
||||
onChange={(e) =>
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
dateTo: e.target.value
|
||||
? new Date(
|
||||
e.target
|
||||
.value,
|
||||
)
|
||||
: null,
|
||||
})
|
||||
}
|
||||
placeholder={__('To')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel>
|
||||
{__('Counterparties')}
|
||||
</FormLabel>
|
||||
<div className="grid grid-cols-2 gap-2 pt-2">
|
||||
<Input
|
||||
value={creditorName}
|
||||
onChange={(e) =>
|
||||
setCreditorName(e.target.value)
|
||||
}
|
||||
placeholder={__('Creditor name')}
|
||||
/>
|
||||
<Input
|
||||
value={debtorName}
|
||||
onChange={(e) =>
|
||||
setDebtorName(e.target.value)
|
||||
}
|
||||
placeholder={__('Debtor name')}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{__('Amount')}</FormLabel>
|
||||
<div className="grid grid-cols-2 gap-2 pt-2">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={filters.amountMin ?? ''}
|
||||
onChange={(e) =>
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
amountMin: e.target
|
||||
.value
|
||||
? parseFloat(
|
||||
e.target
|
||||
.value,
|
||||
)
|
||||
: null,
|
||||
})
|
||||
}
|
||||
placeholder={__('Min')}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={filters.amountMax ?? ''}
|
||||
onChange={(e) =>
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
amountMax: e.target
|
||||
.value
|
||||
? parseFloat(
|
||||
e.target
|
||||
.value,
|
||||
)
|
||||
: null,
|
||||
})
|
||||
}
|
||||
placeholder={__('Max')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!inlineCategoryLabel && (
|
||||
<div className="space-y-2">
|
||||
<FormLabel>
|
||||
{__('Categories')}
|
||||
{__('Counterparties')}
|
||||
</FormLabel>
|
||||
<div className="pt-2">
|
||||
<CategoryMultiSelect
|
||||
categories={categories}
|
||||
selectedIds={
|
||||
filters.categoryIds
|
||||
<div className="grid grid-cols-2 gap-2 pt-2">
|
||||
<Input
|
||||
value={creditorName}
|
||||
onChange={(e) =>
|
||||
setCreditorName(
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
onToggle={handleCategoryToggle}
|
||||
triggerClassName="w-full"
|
||||
placeholder={__(
|
||||
'Creditor name',
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
value={debtorName}
|
||||
onChange={(e) =>
|
||||
setDebtorName(
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
placeholder={__('Debtor name')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!inlineCategoryLabel && (
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{__('Labels')}</FormLabel>
|
||||
<div className="pt-2">
|
||||
<LabelMultiSelect
|
||||
labels={labels}
|
||||
selectedIds={filters.labelIds}
|
||||
onToggle={handleLabelToggle}
|
||||
triggerClassName="w-full"
|
||||
/>
|
||||
{!inlineCategoryLabel && (
|
||||
<div className="space-y-2">
|
||||
<FormLabel>
|
||||
{__('Categories')}
|
||||
</FormLabel>
|
||||
<div className="pt-2">
|
||||
<CategoryMultiSelect
|
||||
categories={categories}
|
||||
selectedIds={
|
||||
filters.categoryIds
|
||||
}
|
||||
onToggle={
|
||||
handleCategoryToggle
|
||||
}
|
||||
triggerClassName="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{!hideAccountFilter && (
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{__('Accounts')}</FormLabel>
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{accounts.map((account) => {
|
||||
const isSelected =
|
||||
filters.accountIds.includes(
|
||||
account.id,
|
||||
{!inlineCategoryLabel && (
|
||||
<div className="space-y-2">
|
||||
<FormLabel>
|
||||
{__('Labels')}
|
||||
</FormLabel>
|
||||
<div className="pt-2">
|
||||
<LabelMultiSelect
|
||||
labels={labels}
|
||||
selectedIds={
|
||||
filters.labelIds
|
||||
}
|
||||
onToggle={handleLabelToggle}
|
||||
triggerClassName="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hideAccountFilter && (
|
||||
<div className="space-y-2">
|
||||
<FormLabel>
|
||||
{__('Accounts')}
|
||||
</FormLabel>
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{accounts.map((account) => {
|
||||
const isSelected =
|
||||
filters.accountIds.includes(
|
||||
account.id,
|
||||
);
|
||||
return (
|
||||
<Badge
|
||||
key={account.id}
|
||||
variant={
|
||||
isSelected
|
||||
? 'default'
|
||||
: 'outline'
|
||||
}
|
||||
className="cursor-pointer px-2 py-1"
|
||||
onClick={() =>
|
||||
handleAccountToggle(
|
||||
account.id,
|
||||
)
|
||||
}
|
||||
>
|
||||
<AccountName
|
||||
account={
|
||||
account
|
||||
}
|
||||
length={{
|
||||
min: 6,
|
||||
max: 28,
|
||||
}}
|
||||
/>
|
||||
</Badge>
|
||||
);
|
||||
return (
|
||||
<Badge
|
||||
key={account.id}
|
||||
variant={
|
||||
isSelected
|
||||
? 'default'
|
||||
: 'outline'
|
||||
}
|
||||
className="cursor-pointer px-2 py-1"
|
||||
onClick={() =>
|
||||
handleAccountToggle(
|
||||
account.id,
|
||||
)
|
||||
}
|
||||
>
|
||||
<AccountName
|
||||
account={account}
|
||||
length={{
|
||||
min: 6,
|
||||
max: 28,
|
||||
}}
|
||||
/>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{activeFilterCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearFilters}
|
||||
className="h-9"
|
||||
>
|
||||
<X className="mr-1 h-4 w-4" />
|
||||
{__('Clear')}
|
||||
</Button>
|
||||
)}
|
||||
<SavedFilters
|
||||
filters={filters}
|
||||
onLoad={onFiltersChange}
|
||||
/>
|
||||
|
||||
{activeFilterCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearFilters}
|
||||
className="h-9"
|
||||
>
|
||||
<X className="mr-1 h-4 w-4" />
|
||||
{__('Clear')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{actions ? <div className="w-full">{actions}</div> : null}
|
||||
|
|
@ -446,7 +476,11 @@ function CategoryMultiSelect({
|
|||
>
|
||||
{selectedIds.length > 0 ? (
|
||||
<span className="truncate">
|
||||
{selectedIds.length} {__('selected')}
|
||||
{selectedIds.length === 1
|
||||
? __('1 category selected')
|
||||
: __(':count categories selected', {
|
||||
count: selectedIds.length,
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
|
|
@ -561,7 +595,11 @@ function LabelMultiSelect({
|
|||
>
|
||||
{selectedIds.length > 0 ? (
|
||||
<span className="truncate">
|
||||
{selectedIds.length} {__('selected')}
|
||||
{selectedIds.length === 1
|
||||
? __('1 label selected')
|
||||
: __(':count labels selected', {
|
||||
count: selectedIds.length,
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
|
||||
use App\Models\SavedFilter;
|
||||
use App\Models\User;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->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]);
|
||||
});
|
||||
Loading…
Reference in New Issue