feat(dashboard): add accounts manager dialog with visibility toggle and reorder (#604)

## What

Adds an **Edit** button (icon-only, top-right) to the dashboard accounts
grid, next to a new **Accounts** section title. It opens a dialog for
managing accounts:

```
Accounts                     [edit]

[ acc 1        ] [ acc 2        ]
[              ] [              ]
```

Each dialog row shows:
- the bank logo + account name
- an **eye toggle** (open/closed) to hide or show the account on the
dashboard grid
- a **drag handle** to reorder

## Behavior

- **Visibility**: hidden accounts disappear from the grid but stay in
the net worth chart/totals and remain in the dialog so they can be
re-enabled. Backed by a new `hidden_on_dashboard` column.
- **Reorder**: moved entirely into the dialog (over *all* accounts,
including hidden) instead of dragging the cards. This keeps a single
source of truth for `position` — dragging visible-only cards would have
left hidden accounts' positions inconsistent. The card-level drag handle
was removed.
- Both actions use optimistic UI, mirroring the existing reorder flow.

## Changes

- Migration: `hidden_on_dashboard` boolean on `accounts` (hidden from
default serialization like `position`, surfaced explicitly in the net
worth evolution payload).
- `AccountController::updateVisibility` +
`UpdateAccountVisibilityRequest` (owner-authorized) and the
`accounts.visibility` route.
- New `AccountsManagerDialog` component; dashboard header + plain grid;
removed the now-unused `dragHandle` prop from `AccountBalanceCard`.
- New `es.json` keys.

## Tests

Added feature tests for the visibility endpoint (toggle, validation,
ownership). Full `AccountControllerTest` and `DashboardAnalyticsTest`
suites pass; lint and types are clean on the touched files.
This commit is contained in:
Víctor Falcón 2026-06-27 18:11:25 +02:00 committed by GitHub
parent 8bbff05b26
commit 777dfc07b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 292 additions and 34 deletions

View File

@ -4,6 +4,7 @@ namespace App\Http\Controllers;
use App\Enums\AccountType;
use App\Http\Requests\ReorderAccountsRequest;
use App\Http\Requests\UpdateAccountVisibilityRequest;
use App\Models\Account;
use App\Models\AccountBalance;
use App\Models\LoanDetail;
@ -59,6 +60,13 @@ class AccountController extends Controller
return back();
}
public function updateVisibility(UpdateAccountVisibilityRequest $request, Account $account): RedirectResponse
{
$account->update(['hidden_on_dashboard' => $request->validated('hidden')]);
return back();
}
public function show(Request $request, Account $account): Response
{
$this->authorize('view', $account);

View File

@ -0,0 +1,33 @@
<?php
namespace App\Http\Requests;
use App\Models\Account;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class UpdateAccountVisibilityRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
$account = $this->route('account');
return $account instanceof Account
&& $this->user()?->id === $account->user_id;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'hidden' => ['required', 'boolean'],
];
}
}

View File

@ -34,6 +34,7 @@ class Account extends Model
'iban',
'linked_at',
'position',
'hidden_on_dashboard',
];
/** @var list<string> */
@ -42,6 +43,7 @@ class Account extends Model
'bank_id',
'iban',
'position',
'hidden_on_dashboard',
'created_at',
'updated_at',
'deleted_at',
@ -59,6 +61,7 @@ class Account extends Model
'encrypted' => 'boolean',
'linked_at' => 'datetime',
'position' => 'integer',
'hidden_on_dashboard' => 'boolean',
];
}

View File

@ -142,6 +142,7 @@ class AccountMetricsService
'currency_code' => $account->currency_code,
'bank' => $account->bank,
'banking_connection_id' => $account->banking_connection_id,
'hidden_on_dashboard' => $account->hidden_on_dashboard,
];
if ($account->type === AccountType::RealEstate && $account->relationLoaded('realEstateDetail') && $account->realEstateDetail?->linked_loan_account_id) {

View File

@ -0,0 +1,28 @@
<?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('accounts', function (Blueprint $table) {
$table->boolean('hidden_on_dashboard')->default(false)->after('position');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('accounts', function (Blueprint $table) {
$table->dropColumn('hidden_on_dashboard');
});
}
};

View File

@ -665,6 +665,10 @@
"Each recovery code can be used once to\\n access your account and will be removed\\n after use. If you need more, click": "Cada código de recuperación se puede usar una vez para acceder a tu cuenta y se eliminará después de su uso. Si necesitas más, haz clic",
"Edit": "Editar",
"Edit Account": "Editar Cuenta",
"Edit accounts": "Editar cuentas",
"Toggle visibility and drag to reorder.": "Cambia la visibilidad y arrastra para reordenar.",
"Show on dashboard": "Mostrar en el panel",
"Hide from dashboard": "Ocultar del panel",
"Edit Automation Rule": "Editar Regla de Automatización",
"Edit Balance": "Editar Balance",
"Edit Budget": "Editar Presupuesto",

View File

@ -6,11 +6,10 @@ import { AmountDisplay } from '@/components/ui/amount-display';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useChartColors } from '@/hooks/use-chart-color-scheme';
import { AccountWithMetrics } from '@/hooks/use-dashboard-data';
import { cn } from '@/lib/utils';
import { supportsInvestedAmount } from '@/types/account';
import { __ } from '@/utils/i18n';
import { Link } from '@inertiajs/react';
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Line, LineChart, ResponsiveContainer, Tooltip } from 'recharts';
import { AccountTypeIcon } from './account-type-icon';
import { AmountTrendIndicator } from './amount-trend-indicator';
@ -35,7 +34,6 @@ interface AccountBalanceCardProps {
onBalanceUpdated?: () => void;
linkedLoanMetrics?: LinkedLoanMetrics;
displayCurrencyCode?: string;
dragHandle?: ReactNode;
}
export function AccountBalanceCard({
@ -44,7 +42,6 @@ export function AccountBalanceCard({
onBalanceUpdated,
linkedLoanMetrics,
displayCurrencyCode,
dragHandle,
}: AccountBalanceCardProps) {
const currencyCode = displayCurrencyCode ?? account.currency_code;
const { accountMainLineColor, accountGainLineColor, mortgageLineColor } =
@ -196,21 +193,8 @@ export function AccountBalanceCard({
</span>
)}
</div>
<div className="relative mr-1 size-5 shrink-0">
<AccountTypeIcon
type={account.type}
className={cn(
'transition-opacity',
dragHandle && 'group-hover:opacity-0',
)}
/>
{dragHandle && (
// The grip glyph is narrower than the type icon; nudge it
// to the right edge so it lines up with the icon it replaces.
<span className="absolute inset-0 flex translate-x-1 items-center justify-end opacity-0 transition-opacity group-hover:opacity-100">
{dragHandle}
</span>
)}
<div className="mr-1 size-5 shrink-0">
<AccountTypeIcon type={account.type} />
</div>
</CardHeader>
<CardContent>

View File

@ -0,0 +1,96 @@
import { AccountName } from '@/components/accounts/account-name';
import { BankLogo } from '@/components/bank-logo';
import { SortableGrid } from '@/components/sortable-grid';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { AccountWithMetrics } from '@/hooks/use-dashboard-data';
import { cn } from '@/lib/utils';
import { __ } from '@/utils/i18n';
import { Eye, EyeOff } from 'lucide-react';
interface AccountsManagerDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** All manageable accounts, in display order. */
accounts: AccountWithMetrics[];
isHidden: (account: AccountWithMetrics) => boolean;
onReorder: (orderedIds: string[]) => void;
onToggleVisibility: (id: string, hidden: boolean) => void;
}
export function AccountsManagerDialog({
open,
onOpenChange,
accounts,
isHidden,
onReorder,
onToggleVisibility,
}: AccountsManagerDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{__('Edit accounts')}</DialogTitle>
<DialogDescription>
{__('Toggle visibility and drag to reorder.')}
</DialogDescription>
</DialogHeader>
<SortableGrid
className="flex flex-col gap-1"
items={accounts}
getId={(account) => account.id}
onReorder={onReorder}
renderItem={(account, dragHandle) => {
const hidden = isHidden(account);
return (
<div className="flex items-center gap-3 rounded-md px-2 py-2 hover:bg-muted">
<BankLogo
src={account.bank?.logo ?? null}
name={account.bank?.name}
fallback="icon"
className={cn(
'size-7 shrink-0',
hidden && 'opacity-40',
)}
/>
<AccountName
account={account}
className={cn(
'flex-1 truncate text-sm',
hidden && 'text-muted-foreground',
)}
/>
<button
type="button"
onClick={() =>
onToggleVisibility(account.id, !hidden)
}
aria-label={
hidden
? __('Show on dashboard')
: __('Hide from dashboard')
}
aria-pressed={!hidden}
className="text-muted-foreground transition-colors hover:text-foreground"
>
{hidden ? (
<EyeOff className="size-5" />
) : (
<Eye className="size-5" />
)}
</button>
{dragHandle}
</div>
);
}}
/>
</DialogContent>
</Dialog>
);
}

View File

@ -17,6 +17,7 @@ export interface NetWorthEvolutionAccount {
banking_connection_id: string | null;
invested_amount?: number | null;
linked_loan_account_id?: string | null;
hidden_on_dashboard?: boolean;
}
export interface OriginalAmount {
@ -40,6 +41,7 @@ export interface AccountWithMetrics extends Account {
investedAmount?: number | null;
}>;
investedAmount: number | null;
hidden_on_dashboard: boolean;
}
export interface DashboardData {
@ -100,6 +102,7 @@ export function deriveAccountMetrics(
diff: currentBalance - previousBalance,
history,
investedAmount: account.invested_amount ?? null,
hidden_on_dashboard: account.hidden_on_dashboard ?? false,
} as AccountWithMetrics;
});
}

View File

@ -1,14 +1,19 @@
import { reorder } from '@/actions/App/Http/Controllers/AccountController';
import {
reorder,
updateVisibility,
} from '@/actions/App/Http/Controllers/AccountController';
import { AccountBalanceCard } from '@/components/dashboard/account-balance-card';
import { AccountsManagerDialog } from '@/components/dashboard/accounts-manager-dialog';
import { CashflowSummaryCard } from '@/components/dashboard/cashflow-summary-card';
import { NetWorthChart as NetWorthChartComponent } from '@/components/dashboard/net-worth-chart';
import { TopCategoriesCard } from '@/components/dashboard/top-categories-card';
import HeadingSmall from '@/components/heading-small';
import { IntegrationRequestsDrawer } from '@/components/integration-requests/integration-requests-drawer';
import { SortableGrid } from '@/components/sortable-grid';
import { Button } from '@/components/ui/button';
import UnlockMessageDialog from '@/components/unlock-message-dialog';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import {
type AccountWithMetrics,
type NetWorthEvolutionData,
deriveAccountMetrics,
} from '@/hooks/use-dashboard-data';
@ -19,6 +24,7 @@ import { BreadcrumbItem, SharedData } from '@/types';
import { Category } from '@/types/category';
import { __ } from '@/utils/i18n';
import { Deferred, Head, router, usePage } from '@inertiajs/react';
import { Pencil } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
interface CashflowSummary {
@ -83,25 +89,43 @@ export default function Dashboard() {
return ids;
}, [accountMetrics]);
const visibleAccounts = useMemo(
const manageableAccounts = useMemo(
() => accountMetrics.filter((a) => !linkedLoanAccountIds.has(a.id)),
[accountMetrics, linkedLoanAccountIds],
);
const [editOpen, setEditOpen] = useState(false);
// Optimistic ordering layered on top of the server order. Null means "use
// the server order"; a drag sets the new id order and persists it.
const [order, setOrder] = useState<string[] | null>(null);
const orderedAccounts = useMemo(() => {
if (!order) {
return visibleAccounts;
return manageableAccounts;
}
const byId = new Map(visibleAccounts.map((a) => [a.id, a]));
const byId = new Map(manageableAccounts.map((a) => [a.id, a]));
const ordered = order
.map((id) => byId.get(id))
.filter((a) => a !== undefined);
const rest = visibleAccounts.filter((a) => !order.includes(a.id));
const rest = manageableAccounts.filter((a) => !order.includes(a.id));
return [...ordered, ...rest];
}, [visibleAccounts, order]);
}, [manageableAccounts, order]);
// Optimistic visibility overrides keyed by account id; falls back to the
// server flag until the next full reload.
const [hiddenOverrides, setHiddenOverrides] = useState<
Record<string, boolean>
>({});
const isHidden = useCallback(
(account: AccountWithMetrics) =>
hiddenOverrides[account.id] ?? account.hidden_on_dashboard,
[hiddenOverrides],
);
const gridAccounts = useMemo(
() => orderedAccounts.filter((a) => !isHidden(a)),
[orderedAccounts, isHidden],
);
const handleReorder = useCallback((ids: string[]) => {
setOrder(ids);
@ -118,6 +142,22 @@ export default function Dashboard() {
);
}, []);
const handleToggleVisibility = useCallback(
(id: string, hidden: boolean) => {
setHiddenOverrides((prev) => ({ ...prev, [id]: hidden }));
router.patch(
updateVisibility.url(id),
{ hidden },
{
preserveScroll: true,
preserveState: true,
only: ['showEncryptionPrompt'],
},
);
},
[],
);
// Build linked loan metrics map keyed by real estate account ID
const linkedLoanMetricsMap = useMemo(() => {
const map: Record<
@ -255,15 +295,27 @@ export default function Dashboard() {
>
<NetWorthChartComponent data={netWorthEvolution} />
<SortableGrid
className="grid gap-4 md:grid-cols-2"
items={orderedAccounts}
getId={(account) => account.id}
onReorder={handleReorder}
renderItem={(account, dragHandle) => (
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">
{__('Accounts')}
</h2>
{orderedAccounts.length > 0 && (
<Button
variant="ghost"
size="icon-sm"
onClick={() => setEditOpen(true)}
aria-label={__('Edit accounts')}
>
<Pencil className="size-4" />
</Button>
)}
</div>
<div className="grid gap-4 md:grid-cols-2">
{gridAccounts.map((account) => (
<AccountBalanceCard
key={account.id}
account={account}
dragHandle={dragHandle}
onBalanceUpdated={refetch}
linkedLoanMetrics={
linkedLoanMetricsMap[account.id]
@ -272,7 +324,16 @@ export default function Dashboard() {
netWorthEvolution.currency_code
}
/>
)}
))}
</div>
<AccountsManagerDialog
open={editOpen}
onOpenChange={setEditOpen}
accounts={orderedAccounts}
isHidden={isHidden}
onReorder={handleReorder}
onToggleVisibility={handleToggleVisibility}
/>
</Deferred>

View File

@ -142,6 +142,7 @@ Route::middleware(['auth', 'verified', 'onboarded', 'subscribed'])->group(functi
Route::get('accounts', [AccountController::class, 'index'])->name('accounts.list');
Route::patch('accounts/reorder', [AccountController::class, 'reorder'])->name('accounts.reorder');
Route::patch('accounts/{account}/visibility', [AccountController::class, 'updateVisibility'])->name('accounts.visibility');
Route::get('accounts/{account}', [AccountController::class, 'show'])->name('accounts.show');
Route::patch('accounts/{account}/real-estate-detail', [RealEstateDetailController::class, 'update'])->name('accounts.real-estate-detail.update');
Route::patch('accounts/{account}/loan-detail', [LoanDetailController::class, 'update'])->name('accounts.loan-detail.update');

View File

@ -111,6 +111,42 @@ test('users cannot reorder accounts they do not own', function () {
expect($other->fresh()->position)->toBe(0);
});
test('users can toggle dashboard visibility of their accounts', function () {
$account = Account::factory()->create([
'user_id' => $this->user->id,
'hidden_on_dashboard' => false,
]);
$this->patch(route('accounts.visibility', $account), ['hidden' => true])
->assertRedirect();
expect($account->fresh()->hidden_on_dashboard)->toBeTrue();
$this->patch(route('accounts.visibility', $account), ['hidden' => false])
->assertRedirect();
expect($account->fresh()->hidden_on_dashboard)->toBeFalse();
});
test('the hidden flag is required when toggling visibility', function () {
$account = Account::factory()->create(['user_id' => $this->user->id]);
$this->patch(route('accounts.visibility', $account), [])
->assertSessionHasErrors('hidden');
});
test('users cannot toggle visibility of accounts they do not own', function () {
$other = Account::factory()->create([
'user_id' => User::factory()->create()->id,
'hidden_on_dashboard' => false,
]);
$this->patch(route('accounts.visibility', $other), ['hidden' => true])
->assertForbidden();
expect($other->fresh()->hidden_on_dashboard)->toBeFalse();
});
test('accounts index only shows user accounts', function () {
$myAccount = Account::factory()->create([
'user_id' => $this->user->id,