diff --git a/app/Http/Controllers/AccountController.php b/app/Http/Controllers/AccountController.php index 5314b41a..2d975029 100644 --- a/app/Http/Controllers/AccountController.php +++ b/app/Http/Controllers/AccountController.php @@ -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); diff --git a/app/Http/Requests/UpdateAccountVisibilityRequest.php b/app/Http/Requests/UpdateAccountVisibilityRequest.php new file mode 100644 index 00000000..3d4bc8b5 --- /dev/null +++ b/app/Http/Requests/UpdateAccountVisibilityRequest.php @@ -0,0 +1,33 @@ +route('account'); + + return $account instanceof Account + && $this->user()?->id === $account->user_id; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'hidden' => ['required', 'boolean'], + ]; + } +} diff --git a/app/Models/Account.php b/app/Models/Account.php index 13ccb08b..fbc7536f 100644 --- a/app/Models/Account.php +++ b/app/Models/Account.php @@ -34,6 +34,7 @@ class Account extends Model 'iban', 'linked_at', 'position', + 'hidden_on_dashboard', ]; /** @var list */ @@ -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', ]; } diff --git a/app/Services/AccountMetricsService.php b/app/Services/AccountMetricsService.php index 588e0fc7..f3254996 100644 --- a/app/Services/AccountMetricsService.php +++ b/app/Services/AccountMetricsService.php @@ -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) { diff --git a/database/migrations/2026_06_27_154041_add_hidden_on_dashboard_to_accounts_table.php b/database/migrations/2026_06_27_154041_add_hidden_on_dashboard_to_accounts_table.php new file mode 100644 index 00000000..1b180396 --- /dev/null +++ b/database/migrations/2026_06_27_154041_add_hidden_on_dashboard_to_accounts_table.php @@ -0,0 +1,28 @@ +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'); + }); + } +}; diff --git a/lang/es.json b/lang/es.json index b8a9e52a..78b490af 100644 --- a/lang/es.json +++ b/lang/es.json @@ -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", diff --git a/resources/js/components/dashboard/account-balance-card.tsx b/resources/js/components/dashboard/account-balance-card.tsx index 317fc017..8698ff48 100644 --- a/resources/js/components/dashboard/account-balance-card.tsx +++ b/resources/js/components/dashboard/account-balance-card.tsx @@ -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({ )} -
- - {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. - - {dragHandle} - - )} +
+
diff --git a/resources/js/components/dashboard/accounts-manager-dialog.tsx b/resources/js/components/dashboard/accounts-manager-dialog.tsx new file mode 100644 index 00000000..6098b290 --- /dev/null +++ b/resources/js/components/dashboard/accounts-manager-dialog.tsx @@ -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 ( + + + + {__('Edit accounts')} + + {__('Toggle visibility and drag to reorder.')} + + + + account.id} + onReorder={onReorder} + renderItem={(account, dragHandle) => { + const hidden = isHidden(account); + return ( +
+
+ ); + }} + /> +
+
+ ); +} diff --git a/resources/js/hooks/use-dashboard-data.ts b/resources/js/hooks/use-dashboard-data.ts index ff03a393..39b0ce96 100644 --- a/resources/js/hooks/use-dashboard-data.ts +++ b/resources/js/hooks/use-dashboard-data.ts @@ -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; }); } diff --git a/resources/js/pages/dashboard.tsx b/resources/js/pages/dashboard.tsx index b87ba3fa..50942758 100644 --- a/resources/js/pages/dashboard.tsx +++ b/resources/js/pages/dashboard.tsx @@ -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(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 + >({}); + 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() { > - account.id} - onReorder={handleReorder} - renderItem={(account, dragHandle) => ( +
+

+ {__('Accounts')} +

+ {orderedAccounts.length > 0 && ( + + )} +
+ +
+ {gridAccounts.map((account) => ( - )} + ))} +
+ + diff --git a/routes/web.php b/routes/web.php index 6a2486b1..4ef1ef79 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'); diff --git a/tests/Feature/AccountControllerTest.php b/tests/Feature/AccountControllerTest.php index 95a1a77c..af5b1a69 100644 --- a/tests/Feature/AccountControllerTest.php +++ b/tests/Feature/AccountControllerTest.php @@ -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,