From cd3080ec52fd2586f9920794559c7b500cbef6bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Sun, 21 Jun 2026 11:17:45 +0200 Subject: [PATCH] feat(accounts): reorder accounts with drag-and-drop (#575) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Let users reorder their accounts by drag-and-drop. The order is shared between the **dashboard** and the **accounts page**, and persisted server-side. ## Why The account order was fixed (by type, then name). Users want to put the accounts they care about first, consistently across both views. ## How **Backend** - New `position` column on `accounts`, backfilled per user from the previous type/name ordering so existing layouts are preserved. - `PATCH /accounts/reorder` (`AccountController@reorder` + `ReorderAccountsRequest`) persists the order and validates ownership of every id. - Dashboard and accounts queries now `orderBy('position')`. `position` is cast to int and hidden from the serialized payload (order is conveyed by array order). **Frontend** - Shared `SortableGrid` component built on `@dnd-kit` (new dependency). Pointer drag starts after a small move (clicks still work); touch drag starts on a long press, so quick swipes still scroll. - The drag handle swaps with the account type icon on hover — top-right on the dashboard card, bottom-left on the accounts card. - The accounts page is now a flat list (type grouping dropped) so its order matches the dashboard exactly. - Reorder is optimistic and avoids refetching the deferred dashboard metrics. - Haptic feedback (`'selection'`, same as the mobile menu) fires when a drag starts on touch. - On mobile the accounts card stacks vertically (name / amount / trend) and hides the redundant bank-name subtitle. ## Tests - `reorder` persists positions and rejects accounts the user doesn't own. - Index ordering updated to assert `position` order. - Existing account/dashboard/real-estate suites updated and green. ## Notes / follow-ups - New accounts get `position = 0` (appear first) — can add `position = max+1` on create later. - On mobile the whole subtitle is hidden, including "Mortgage at X" for real estate. - Mobile drag-and-drop discoverability (the handle only shows on hover) is still open — discussed but not yet decided. --- app/Http/Controllers/AccountController.php | 18 ++- app/Http/Controllers/DashboardController.php | 2 + app/Http/Requests/ReorderAccountsRequest.php | 28 ++++ app/Models/Account.php | 3 + bun.lock | 11 ++ ..._165235_add_position_to_accounts_table.php | 42 +++++ lang/es.json | 1 + package.json | 3 + .../components/accounts/account-list-card.tsx | 38 +++-- .../dashboard/account-balance-card.tsx | 19 ++- resources/js/components/sortable-grid.tsx | 139 ++++++++++++++++ resources/js/pages/Accounts/Index.tsx | 152 +++++++++--------- resources/js/pages/dashboard.tsx | 46 +++++- routes/web.php | 1 + tests/Feature/AccountControllerTest.php | 52 ++++-- tests/Feature/RealEstateTest.php | 3 + 16 files changed, 451 insertions(+), 107 deletions(-) create mode 100644 app/Http/Requests/ReorderAccountsRequest.php create mode 100644 database/migrations/2026_06_20_165235_add_position_to_accounts_table.php create mode 100644 resources/js/components/sortable-grid.tsx diff --git a/app/Http/Controllers/AccountController.php b/app/Http/Controllers/AccountController.php index db86921b..5314b41a 100644 --- a/app/Http/Controllers/AccountController.php +++ b/app/Http/Controllers/AccountController.php @@ -3,12 +3,14 @@ namespace App\Http\Controllers; use App\Enums\AccountType; +use App\Http\Requests\ReorderAccountsRequest; use App\Models\Account; use App\Models\AccountBalance; use App\Models\LoanDetail; use App\Services\AccountMetricsService; use App\Services\LoanAmortizationService; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Inertia; use Inertia\Response; @@ -29,7 +31,7 @@ class AccountController extends Controller $accounts = Account::query() ->where('user_id', $user->id) ->with(['bank', 'realEstateDetail:id,account_id,linked_loan_account_id']) - ->orderByRaw("FIELD(type, 'checking', 'savings', 'investment', 'retirement', 'real_estate', 'loan', 'credit_card', 'others')") + ->orderBy('position') ->orderBy('name') ->get(); @@ -43,6 +45,20 @@ class AccountController extends Controller ]); } + public function reorder(ReorderAccountsRequest $request): RedirectResponse + { + // ponytail: one update per account; fine for the handful of accounts a + // user has. Switch to a single CASE update if that ever grows large. + foreach (array_values($request->validated('ids')) as $position => $id) { + Account::query() + ->whereKey($id) + ->where('user_id', $request->user()->id) + ->update(['position' => $position]); + } + + return back(); + } + public function show(Request $request, Account $account): Response { $this->authorize('view', $account); diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index 546e3603..a69b9f9e 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -42,6 +42,8 @@ class DashboardController extends Controller $accounts = Account::query() ->where('user_id', $user->id) ->with(['bank:id,name,logo', 'realEstateDetail:account_id,linked_loan_account_id']) + ->orderBy('position') + ->orderBy('name') ->get(); return $this->accountMetricsService->getNetWorthEvolution($user->currency_code, $accounts, $start, $end); diff --git a/app/Http/Requests/ReorderAccountsRequest.php b/app/Http/Requests/ReorderAccountsRequest.php new file mode 100644 index 00000000..3cb4b8f6 --- /dev/null +++ b/app/Http/Requests/ReorderAccountsRequest.php @@ -0,0 +1,28 @@ + + */ + public function rules(): array + { + return [ + 'ids' => ['required', 'array'], + 'ids.*' => [ + 'string', + Rule::exists('accounts', 'id')->where('user_id', $this->user()->id), + ], + ]; + } +} diff --git a/app/Models/Account.php b/app/Models/Account.php index f7a92b55..13ccb08b 100644 --- a/app/Models/Account.php +++ b/app/Models/Account.php @@ -33,6 +33,7 @@ class Account extends Model 'external_account_id', 'iban', 'linked_at', + 'position', ]; /** @var list */ @@ -40,6 +41,7 @@ class Account extends Model 'user_id', 'bank_id', 'iban', + 'position', 'created_at', 'updated_at', 'deleted_at', @@ -56,6 +58,7 @@ class Account extends Model 'type' => AccountType::class, 'encrypted' => 'boolean', 'linked_at' => 'datetime', + 'position' => 'integer', ]; } diff --git a/bun.lock b/bun.lock index 093c64d9..8e9338dc 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,9 @@ "workspaces": { "": { "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@headlessui/react": "^2.2.9", "@inertiajs/react": "^2.3.17", "@radix-ui/react-alert-dialog": "^1.1.15", @@ -153,6 +156,14 @@ "@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="], + "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], + + "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], + + "@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="], + + "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], diff --git a/database/migrations/2026_06_20_165235_add_position_to_accounts_table.php b/database/migrations/2026_06_20_165235_add_position_to_accounts_table.php new file mode 100644 index 00000000..ae61d09a --- /dev/null +++ b/database/migrations/2026_06_20_165235_add_position_to_accounts_table.php @@ -0,0 +1,42 @@ +unsignedInteger('position')->default(0)->after('type'); + }); + + // Seed positions per user following the previous default ordering + // (by type, then name) so the existing visual order is preserved. + DB::table('accounts') + ->orderByRaw("FIELD(type, 'checking', 'savings', 'investment', 'retirement', 'real_estate', 'loan', 'credit_card', 'others')") + ->orderBy('name') + ->get(['id', 'user_id']) + ->groupBy('user_id') + ->each(function ($accounts): void { + $accounts->values()->each(function ($account, int $position): void { + DB::table('accounts')->where('id', $account->id)->update(['position' => $position]); + }); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('accounts', function (Blueprint $table) { + $table->dropColumn('position'); + }); + } +}; diff --git a/lang/es.json b/lang/es.json index a07f818a..6c6ac64c 100644 --- a/lang/es.json +++ b/lang/es.json @@ -1,4 +1,5 @@ { + "Drag to reorder": "Arrastra para reordenar", "Manage Accounts": "Gestionar cuentas", "Choose which accounts from this bank are synced and where their transactions go.": "Elige qué cuentas de este banco se sincronizan y a dónde van sus transacciones.", "No accounts are syncing yet.": "Todavía no se está sincronizando ninguna cuenta.", diff --git a/package.json b/package.json index f2191785..a46e0e0b 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,9 @@ "vitest": "^2.1.9" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@headlessui/react": "^2.2.9", "@inertiajs/react": "^2.3.17", "@radix-ui/react-alert-dialog": "^1.1.15", diff --git a/resources/js/components/accounts/account-list-card.tsx b/resources/js/components/accounts/account-list-card.tsx index 3dc35ec9..d5914ca3 100644 --- a/resources/js/components/accounts/account-list-card.tsx +++ b/resources/js/components/accounts/account-list-card.tsx @@ -1,15 +1,17 @@ import { show } from '@/actions/App/Http/Controllers/AccountController'; import { AccountName } from '@/components/accounts/account-name'; import { BankLogo } from '@/components/bank-logo'; +import { AccountTypeIcon } from '@/components/dashboard/account-type-icon'; import { AmountTrendIndicator } from '@/components/dashboard/amount-trend-indicator'; import { AmountDisplay } from '@/components/ui/amount-display'; import { Card, CardContent } 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 { formatAccountType, supportsInvestedAmount } from '@/types/account'; import { __ } from '@/utils/i18n'; import { Link } from '@inertiajs/react'; -import { useMemo, useState } from 'react'; +import { type ReactNode, useMemo, useState } from 'react'; import { Line, LineChart, ResponsiveContainer, Tooltip } from 'recharts'; import { Button } from '../ui/button'; import { UpdateBalanceDialog } from './update-balance-dialog'; @@ -34,6 +36,7 @@ interface AccountListCardProps { onBalanceUpdated?: () => void; linkedLoanMetrics?: LinkedLoanMetrics; displayCurrencyCode?: string; + dragHandle?: ReactNode; } export function AccountListCard({ @@ -42,6 +45,7 @@ export function AccountListCard({ onBalanceUpdated, linkedLoanMetrics, displayCurrencyCode, + dragHandle, }: AccountListCardProps) { const currencyCode = displayCurrencyCode ?? account.currency_code; const { accountMainLineColor, accountGainLineColor, mortgageLineColor } = @@ -159,7 +163,7 @@ export function AccountListCard({ /> -
+
{hasMortgage && linkedLoanMetrics.loanAccount ? ( @@ -195,9 +199,9 @@ export function AccountListCard({
-
+
{isConnected ? ( -
+
setUpdateBalanceOpen(true)} - className="-mr-2 cursor-pointer rounded-md px-2 py-1 transition-colors hover:bg-muted" + className="-ml-2 cursor-pointer rounded-md px-2 py-1 transition-colors hover:bg-muted sm:-mr-2 sm:ml-0" >
-
+
+
+ + {dragHandle && ( + // The grip glyph is narrower than the type icon; + // nudge it to the left edge to line up with it. + + {dragHandle} + + )} +
+ {!isConnected && ( diff --git a/resources/js/components/dashboard/account-balance-card.tsx b/resources/js/components/dashboard/account-balance-card.tsx index 6b00780d..317fc017 100644 --- a/resources/js/components/dashboard/account-balance-card.tsx +++ b/resources/js/components/dashboard/account-balance-card.tsx @@ -6,10 +6,11 @@ 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 { useEffect, useMemo, useRef, useState } from 'react'; +import { type ReactNode, 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'; @@ -34,6 +35,7 @@ interface AccountBalanceCardProps { onBalanceUpdated?: () => void; linkedLoanMetrics?: LinkedLoanMetrics; displayCurrencyCode?: string; + dragHandle?: ReactNode; } export function AccountBalanceCard({ @@ -42,6 +44,7 @@ export function AccountBalanceCard({ onBalanceUpdated, linkedLoanMetrics, displayCurrencyCode, + dragHandle, }: AccountBalanceCardProps) { const currencyCode = displayCurrencyCode ?? account.currency_code; const { accountMainLineColor, accountGainLineColor, mortgageLineColor } = @@ -193,11 +196,21 @@ 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/sortable-grid.tsx b/resources/js/components/sortable-grid.tsx new file mode 100644 index 00000000..cdb25f5f --- /dev/null +++ b/resources/js/components/sortable-grid.tsx @@ -0,0 +1,139 @@ +import { useWebHaptics } from '@/hooks/use-web-haptics'; +import { cn } from '@/lib/utils'; +import { __ } from '@/utils/i18n'; +import { + DndContext, + type DragEndEvent, + KeyboardSensor, + PointerSensor, + closestCenter, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + SortableContext, + arrayMove, + rectSortingStrategy, + sortableKeyboardCoordinates, + useSortable, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { GripVertical } from 'lucide-react'; +import type { ReactNode } from 'react'; + +interface SortableGridProps { + items: T[]; + getId: (item: T) => string; + /** + * Renders one item. The provided drag handle must be placed inside the + * card so it sits exactly where the card wants it (e.g. over the account + * icon); it only becomes visible on hover via the wrapper's `group`. + */ + renderItem: (item: T, dragHandle: ReactNode) => ReactNode; + onReorder: (orderedIds: string[]) => void; + className?: string; + /** Non-sortable content rendered inside the grid after the items. */ + footer?: ReactNode; +} + +export function SortableGrid({ + items, + getId, + renderItem, + onReorder, + className, + footer, +}: SortableGridProps) { + const ids = items.map(getId); + const { trigger } = useWebHaptics(); + + // A small move starts the drag, so taps/clicks still work. Touch is handled + // via pointer events and only the handle has touch-action: none, so the rest + // of the card scrolls normally on mobile (no long-press, which blocked it). + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + function handleDragEnd(event: DragEndEvent): void { + const { active, over } = event; + if (!over || active.id === over.id) { + return; + } + + const oldIndex = ids.indexOf(String(active.id)); + const newIndex = ids.indexOf(String(over.id)); + if (oldIndex === -1 || newIndex === -1) { + return; + } + + onReorder(arrayMove(ids, oldIndex, newIndex)); + } + + return ( + trigger('selection')} + onDragEnd={handleDragEnd} + > + +
+ {items.map((item) => ( + + {(dragHandle) => renderItem(item, dragHandle)} + + ))} + {footer} +
+
+
+ ); +} + +function SortableItem({ + id, + children, +}: { + id: string; + children: (dragHandle: ReactNode) => ReactNode; +}) { + const { + attributes, + listeners, + setNodeRef, + setActivatorNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }); + + const dragHandle = ( + + ); + + return ( +
+ {children(dragHandle)} +
+ ); +} diff --git a/resources/js/pages/Accounts/Index.tsx b/resources/js/pages/Accounts/Index.tsx index 7bb7bd4e..5c6eb49b 100644 --- a/resources/js/pages/Accounts/Index.tsx +++ b/resources/js/pages/Accounts/Index.tsx @@ -1,16 +1,20 @@ -import { index } from '@/actions/App/Http/Controllers/AccountController'; +import { + index, + reorder, +} from '@/actions/App/Http/Controllers/AccountController'; import { AccountListCard } from '@/components/accounts/account-list-card'; import { CreateAccountDialog } from '@/components/accounts/create-account-dialog'; import HeadingSmall from '@/components/heading-small'; +import { SortableGrid } from '@/components/sortable-grid'; import { Card, CardContent } from '@/components/ui/card'; import { AccountWithMetrics } from '@/hooks/use-dashboard-data'; import AppSidebarLayout from '@/layouts/app/app-sidebar-layout'; import { BreadcrumbItem, SharedData } from '@/types'; -import { Account, AccountType } from '@/types/account'; +import { Account } from '@/types/account'; import { __ } from '@/utils/i18n'; import { Head, router, usePage } from '@inertiajs/react'; import { Plus } from 'lucide-react'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useMemo, useState } from 'react'; const breadcrumbs: BreadcrumbItem[] = [ { @@ -19,17 +23,6 @@ const breadcrumbs: BreadcrumbItem[] = [ }, ]; -const ACCOUNT_TYPE_ORDER: AccountType[] = [ - 'checking', - 'savings', - 'investment', - 'retirement', - 'real_estate', - 'loan', - 'credit_card', - 'others', -]; - interface AccountMetrics { currentBalance: number; previousBalance: number; @@ -79,35 +72,49 @@ export default function AccountsIndex({ accounts, accountMetrics }: Props) { }); }, [accounts, accountMetrics]); - const groupedAccounts = useMemo(() => { - const groups: Record = { - checking: [], - savings: [], - investment: [], - retirement: [], - real_estate: [], - loan: [], - credit_card: [], - others: [], - }; + // Flat list in the user-defined order; loan accounts linked to a real + // estate account are surfaced inside that account instead. + const visibleAccounts = useMemo( + () => + accountsWithMetrics.filter( + (account) => + !( + account.type === 'loan' && + linkedLoanAccountIds.has(account.id) + ), + ), + [accountsWithMetrics, linkedLoanAccountIds], + ); - accountsWithMetrics.forEach((account) => { - const type = account.type as AccountType; + // 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; + } + const byId = new Map(visibleAccounts.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)); + return [...ordered, ...rest]; + }, [visibleAccounts, order]); - // Hide loan accounts that are linked to a real estate account - if (type === 'loan' && linkedLoanAccountIds.has(account.id)) { - return; - } - - if (groups[type]) { - groups[type].push(account); - } else { - groups.others.push(account); - } - }); - - return groups; - }, [accountsWithMetrics, linkedLoanAccountIds]); + const handleReorder = useCallback((ids: string[]) => { + setOrder(ids); + // Persist and re-sync the canonical order; the deferred accountMetrics + // prop is left untouched (kept from the current page). + router.patch( + reorder.url(), + { ids }, + { + preserveScroll: true, + preserveState: true, + only: ['accounts'], + }, + ); + }, []); // Build a map of linked loan metrics keyed by real estate account ID const linkedLoanMetricsMap = useMemo(() => { @@ -162,38 +169,37 @@ export default function AccountsIndex({ accounts, accountMetrics }: Props) {
-
- {ACCOUNT_TYPE_ORDER.map((type) => { - const accountsInGroup = groupedAccounts[type]; - if (accountsInGroup.length === 0) return null; - - return accountsInGroup.map((account) => ( - - )); - })} - - -
- - {__('Create Account')} -
-
- - } - /> -
+ account.id} + onReorder={handleReorder} + renderItem={(account, dragHandle) => ( + + )} + footer={ + + +
+ + {__('Create Account')} +
+
+ + } + /> + } + /> {accounts.length === 0 && !isLoading && (
diff --git a/resources/js/pages/dashboard.tsx b/resources/js/pages/dashboard.tsx index 2efbc5fb..b87ba3fa 100644 --- a/resources/js/pages/dashboard.tsx +++ b/resources/js/pages/dashboard.tsx @@ -1,9 +1,11 @@ +import { reorder } from '@/actions/App/Http/Controllers/AccountController'; import { AccountBalanceCard } from '@/components/dashboard/account-balance-card'; 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 UnlockMessageDialog from '@/components/unlock-message-dialog'; import { useEncryptionKey } from '@/contexts/encryption-key-context'; import { @@ -86,6 +88,36 @@ export default function Dashboard() { [accountMetrics, linkedLoanAccountIds], ); + // 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; + } + const byId = new Map(visibleAccounts.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)); + return [...ordered, ...rest]; + }, [visibleAccounts, order]); + + const handleReorder = useCallback((ids: string[]) => { + setOrder(ids); + // Persist only; keep the deferred netWorthEvolution prop in place by + // requesting an unrelated cheap prop so it isn't refetched (skeleton). + router.patch( + reorder.url(), + { ids }, + { + preserveScroll: true, + preserveState: true, + only: ['showEncryptionPrompt'], + }, + ); + }, []); + // Build linked loan metrics map keyed by real estate account ID const linkedLoanMetricsMap = useMemo(() => { const map: Record< @@ -223,11 +255,15 @@ export default function Dashboard() { > -
- {visibleAccounts.map((account) => ( + account.id} + onReorder={handleReorder} + renderItem={(account, dragHandle) => ( - ))} -
+ )} + />
diff --git a/routes/web.php b/routes/web.php index cb1746fb..ff21adac 100644 --- a/routes/web.php +++ b/routes/web.php @@ -138,6 +138,7 @@ Route::middleware(['auth', 'verified', 'onboarded', 'subscribed'])->group(functi Route::get('cashflow', CashflowController::class)->name('cashflow'); Route::get('accounts', [AccountController::class, 'index'])->name('accounts.list'); + Route::patch('accounts/reorder', [AccountController::class, 'reorder'])->name('accounts.reorder'); 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 fd8c460f..95a1a77c 100644 --- a/tests/Feature/AccountControllerTest.php +++ b/tests/Feature/AccountControllerTest.php @@ -58,21 +58,21 @@ test('accounts index returns accounts grouped by type', function () { ); }); -test('accounts are ordered by type then name', function () { - Account::factory()->create([ +test('accounts are ordered by position then name', function () { + $third = Account::factory()->create([ 'user_id' => $this->user->id, - 'type' => AccountType::Savings, - 'name' => 'A Savings', + 'name' => 'Third', + 'position' => 2, ]); - Account::factory()->create([ + $first = Account::factory()->create([ 'user_id' => $this->user->id, - 'type' => AccountType::Checking, - 'name' => 'B Checking', + 'name' => 'First', + 'position' => 0, ]); - Account::factory()->create([ + $second = Account::factory()->create([ 'user_id' => $this->user->id, - 'type' => AccountType::Checking, - 'name' => 'A Checking', + 'name' => 'Second', + 'position' => 1, ]); $response = $this->get(route('accounts.list')); @@ -81,14 +81,36 @@ test('accounts are ordered by type then name', function () { ->assertInertia(fn ($page) => $page ->component('Accounts/Index') ->has('accounts', 3) - ->where('accounts.0.type', 'checking') - ->where('accounts.0.name', 'A Checking') - ->where('accounts.1.type', 'checking') - ->where('accounts.1.name', 'B Checking') - ->where('accounts.2.type', 'savings') + ->where('accounts.0.id', $first->id) + ->where('accounts.1.id', $second->id) + ->where('accounts.2.id', $third->id) ); }); +test('users can reorder their accounts', function () { + $a = Account::factory()->create(['user_id' => $this->user->id, 'position' => 0]); + $b = Account::factory()->create(['user_id' => $this->user->id, 'position' => 1]); + $c = Account::factory()->create(['user_id' => $this->user->id, 'position' => 2]); + + $this->patch(route('accounts.reorder'), ['ids' => [$c->id, $a->id, $b->id]]) + ->assertRedirect(); + + expect($c->fresh()->position)->toBe(0); + expect($a->fresh()->position)->toBe(1); + expect($b->fresh()->position)->toBe(2); +}); + +test('users cannot reorder accounts they do not own', function () { + $other = Account::factory()->create([ + 'user_id' => User::factory()->create()->id, + ]); + + $this->patch(route('accounts.reorder'), ['ids' => [$other->id]]) + ->assertSessionHasErrors('ids.0'); + + expect($other->fresh()->position)->toBe(0); +}); + test('accounts index only shows user accounts', function () { $myAccount = Account::factory()->create([ 'user_id' => $this->user->id, diff --git a/tests/Feature/RealEstateTest.php b/tests/Feature/RealEstateTest.php index 357c1951..963ad0ac 100644 --- a/tests/Feature/RealEstateTest.php +++ b/tests/Feature/RealEstateTest.php @@ -348,17 +348,20 @@ it('includes real estate accounts in index ordered correctly', function () { 'user_id' => $this->user->id, 'type' => AccountType::Loan, 'name' => 'Mortgage', + 'position' => 2, ]); Account::factory()->realEstate()->create([ 'user_id' => $this->user->id, 'name' => 'Beach House', + 'position' => 1, ]); Account::factory()->create([ 'user_id' => $this->user->id, 'type' => AccountType::Checking, 'name' => 'Main Account', + 'position' => 0, ]); $response = $this->get(route('accounts.list'));