feat(accounts): reorder accounts with drag-and-drop (#575)

## 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.
This commit is contained in:
Víctor Falcón 2026-06-21 11:17:45 +02:00 committed by GitHub
parent 0ea30a3ce8
commit cd3080ec52
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 451 additions and 107 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

@ -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({
/>
</h3>
</Link>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="hidden items-center gap-2 text-sm text-muted-foreground sm:flex">
{hasMortgage &&
linkedLoanMetrics.loanAccount ? (
<span className="flex items-center gap-1">
@ -195,9 +199,9 @@ export function AccountListCard({
</div>
</div>
</div>
<div className="flex shrink-0 flex-col items-end">
<div className="flex shrink-0 flex-col items-start sm:items-end">
{isConnected ? (
<div className="-mr-2 px-2 py-1">
<div className="-ml-2 px-2 py-1 sm:-mr-2 sm:ml-0">
<AmountDisplay
amountInCents={displayBalance}
currencyCode={currencyCode}
@ -209,7 +213,7 @@ export function AccountListCard({
<button
type="button"
onClick={() => 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"
>
<AmountDisplay
amountInCents={displayBalance}
@ -426,7 +430,24 @@ export function AccountListCard({
</LineChart>
</ResponsiveContainer>
</div>
<div className="flex justify-between">
<div className="flex items-center gap-2">
<div className="relative size-5 shrink-0 text-muted-foreground">
<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 left edge to line up with it.
<span className="absolute inset-0 flex -translate-x-1 items-center justify-start opacity-0 transition-opacity group-hover:opacity-100">
{dragHandle}
</span>
)}
</div>
{!isConnected && (
<Button
className="cursor-pointer"
@ -441,10 +462,7 @@ export function AccountListCard({
</Button>
)}
<Link
href={show.url(account.id)}
className={isConnected ? 'ml-auto' : ''}
>
<Link href={show.url(account.id)} className="ml-auto">
<Button className="cursor-pointer" variant="ghost">
{__('Details')} &rarr;
</Button>

View File

@ -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({
</span>
)}
</div>
<div className="text-xs font-medium text-muted-foreground">
<div className="relative mr-1 size-5 shrink-0">
<AccountTypeIcon
type={account.type}
className="mr-1 inline-block"
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>
</CardHeader>
<CardContent>

View File

@ -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<T> {
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<T>({
items,
getId,
renderItem,
onReorder,
className,
footer,
}: SortableGridProps<T>) {
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 (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={() => trigger('selection')}
onDragEnd={handleDragEnd}
>
<SortableContext items={ids} strategy={rectSortingStrategy}>
<div className={className}>
{items.map((item) => (
<SortableItem key={getId(item)} id={getId(item)}>
{(dragHandle) => renderItem(item, dragHandle)}
</SortableItem>
))}
{footer}
</div>
</SortableContext>
</DndContext>
);
}
function SortableItem({
id,
children,
}: {
id: string;
children: (dragHandle: ReactNode) => ReactNode;
}) {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const dragHandle = (
<button
ref={setActivatorNodeRef}
type="button"
aria-label={__('Drag to reorder')}
className="cursor-grab touch-none text-muted-foreground transition-colors hover:text-foreground active:cursor-grabbing"
{...attributes}
{...listeners}
>
<GripVertical className="size-5" />
</button>
);
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 50 : undefined,
}}
className={cn('group relative', isDragging && 'opacity-60')}
>
{children(dragHandle)}
</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,37 @@ 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, dragHandle) => (
<AccountListCard
account={account}
dragHandle={dragHandle}
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,11 +255,15 @@ 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, dragHandle) => (
<AccountBalanceCard
key={account.id}
account={account}
dragHandle={dragHandle}
onBalanceUpdated={refetch}
linkedLoanMetrics={
linkedLoanMetricsMap[account.id]
@ -236,8 +272,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'));