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:
parent
0ea30a3ce8
commit
cd3080ec52
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
11
bun.lock
11
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=="],
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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')} →
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
Loading…
Reference in New Issue