feat(accounts): drag-and-drop reordering on dashboard and accounts page

Add a user-defined account order, shared between the dashboard and the
accounts page, persisted server-side.

- accounts table gets a `position` column, backfilled per user from the
  previous type/name ordering so existing layouts are preserved
- both views order by `position` and expose a drag handle (GripVertical)
  via a shared SortableGrid component built on @dnd-kit
- pointer drag starts after a small move; touch drag starts on a long
  press, so quick swipes still scroll on mobile (single-column there)
- the accounts page is now a flat list (type grouping dropped) so the
  order matches the dashboard exactly
- PATCH accounts/reorder persists the order; reorder is optimistic and
  avoids refetching the deferred dashboard metrics
This commit is contained in:
Víctor Falcón 2026-06-20 19:01:50 +02:00
parent 0ea30a3ce8
commit fa4369db38
14 changed files with 391 additions and 94 deletions

View File

@ -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);

View File

@ -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);

View File

@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ReorderAccountsRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'ids' => ['required', 'array'],
'ids.*' => [
'string',
Rule::exists('accounts', 'id')->where('user_id', $this->user()->id),
],
];
}
}

View File

@ -33,6 +33,7 @@ class Account extends Model
'external_account_id',
'iban',
'linked_at',
'position',
];
/** @var list<string> */
@ -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',
];
}

View File

@ -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=="],

View File

@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('accounts', function (Blueprint $table) {
$table->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');
});
}
};

View File

@ -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.",

View File

@ -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",

View File

@ -0,0 +1,125 @@
import { cn } from '@/lib/utils';
import { __ } from '@/utils/i18n';
import {
DndContext,
type DragEndEvent,
KeyboardSensor,
PointerSensor,
TouchSensor,
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<T> {
items: T[];
getId: (item: T) => string;
renderItem: (item: T) => ReactNode;
onReorder: (orderedIds: string[]) => void;
className?: string;
/** Non-sortable content rendered inside the grid after the items. */
footer?: ReactNode;
}
export function SortableGrid<T>({
items,
getId,
renderItem,
onReorder,
className,
footer,
}: SortableGridProps<T>) {
const ids = items.map(getId);
// Pointer drag starts after a small move (so clicks still work); touch drag
// starts on a long press, leaving quick swipes free to scroll the page.
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(TouchSensor, {
activationConstraint: { delay: 200, tolerance: 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 (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={ids} strategy={rectSortingStrategy}>
<div className={className}>
{items.map((item) => (
<SortableItem key={getId(item)} id={getId(item)}>
{renderItem(item)}
</SortableItem>
))}
{footer}
</div>
</SortableContext>
</DndContext>
);
}
function SortableItem({ id, children }: { id: string; children: ReactNode }) {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 50 : undefined,
}}
className={cn('relative', isDragging && 'opacity-60')}
>
{children}
<button
ref={setActivatorNodeRef}
type="button"
aria-label={__('Drag to reorder')}
className="absolute top-1 left-1/2 -translate-x-1/2 cursor-grab touch-none rounded p-1.5 text-muted-foreground/40 transition-colors hover:text-foreground active:cursor-grabbing"
{...attributes}
{...listeners}
>
<GripVertical className="size-4" />
</button>
</div>
);
}

View File

@ -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<AccountType, AccountWithMetrics[]> = {
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<string[] | null>(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,36 @@ export default function AccountsIndex({ accounts, accountMetrics }: Props) {
<CreateAccountDialog onSuccess={handleAccountCreated} />
</div>
<div className="grid gap-4 lg:grid-cols-2">
{ACCOUNT_TYPE_ORDER.map((type) => {
const accountsInGroup = groupedAccounts[type];
if (accountsInGroup.length === 0) return null;
return accountsInGroup.map((account) => (
<AccountListCard
key={account.id}
account={account}
loading={isLoading}
onBalanceUpdated={handleBalanceUpdated}
linkedLoanMetrics={
linkedLoanMetricsMap[account.id]
}
displayCurrencyCode={auth.user.currency_code}
/>
));
})}
<CreateAccountDialog
onSuccess={handleAccountCreated}
trigger={
<Card className="cursor-pointer opacity-50 transition-opacity duration-200 hover:opacity-100">
<CardContent className="flex h-full items-center justify-center">
<div className="flex flex-row items-center justify-center gap-1">
<Plus className="mr-2 h-4 w-4" />
{__('Create Account')}
</div>
</CardContent>
</Card>
}
/>
</div>
<SortableGrid
className="grid gap-4 lg:grid-cols-2"
items={orderedAccounts}
getId={(account) => account.id}
onReorder={handleReorder}
renderItem={(account) => (
<AccountListCard
account={account}
loading={isLoading}
onBalanceUpdated={handleBalanceUpdated}
linkedLoanMetrics={linkedLoanMetricsMap[account.id]}
displayCurrencyCode={auth.user.currency_code}
/>
)}
footer={
<CreateAccountDialog
onSuccess={handleAccountCreated}
trigger={
<Card className="cursor-pointer opacity-50 transition-opacity duration-200 hover:opacity-100">
<CardContent className="flex h-full items-center justify-center">
<div className="flex flex-row items-center justify-center gap-1">
<Plus className="mr-2 h-4 w-4" />
{__('Create Account')}
</div>
</CardContent>
</Card>
}
/>
}
/>
{accounts.length === 0 && !isLoading && (
<div className="flex h-[300px] items-center justify-center text-muted-foreground">

View File

@ -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<string[] | null>(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,10 +255,13 @@ export default function Dashboard() {
>
<NetWorthChartComponent data={netWorthEvolution} />
<div className="grid gap-4 md:grid-cols-2">
{visibleAccounts.map((account) => (
<SortableGrid
className="grid gap-4 md:grid-cols-2"
items={orderedAccounts}
getId={(account) => account.id}
onReorder={handleReorder}
renderItem={(account) => (
<AccountBalanceCard
key={account.id}
account={account}
onBalanceUpdated={refetch}
linkedLoanMetrics={
@ -236,8 +271,8 @@ export default function Dashboard() {
netWorthEvolution.currency_code
}
/>
))}
</div>
)}
/>
</Deferred>
<div className="flex flex-col gap-6">

View File

@ -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');

View File

@ -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,

View File

@ -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'));