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:
parent
8bbff05b26
commit
777dfc07b2
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue