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:
parent
0ea30a3ce8
commit
fa4369db38
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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