fix(ux): improve status badge, hide balance update for connected accounts, localize delete confirm (#159)

## Why

### Problem

Three UX issues were identified:

1. **Connection status badge looked like a button** — the `<Badge>`
component has interactive-looking styles that made it visually ambiguous
as a status indicator rather than a purely informational element.
2. **"Update balance" was shown for bank-connected accounts** — accounts
synced via Open Banking have their balances managed automatically;
showing a manual update button was misleading and could create
confusion.
3. **Delete confirmation word was not localized** — the dialog required
typing `DELETE` even when the UI was in Spanish, where the placeholder
correctly said `ELIMINAR`. The check was hardcoded to `'DELETE'`.

## What

### Changes

<img width="756" height="407" alt="image"
src="https://github.com/user-attachments/assets/4866edf7-5329-486a-9f72-fcd69429f784"
/>
<img width="757" height="424" alt="image"
src="https://github.com/user-attachments/assets/bc9bda54-e0a4-402b-be32-a9fe1897cfd5"
/>
This commit is contained in:
Víctor Falcón 2026-02-27 22:37:16 +00:00 committed by GitHub
parent a4d2100459
commit 79dd24b23e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 1233 additions and 1144 deletions

View File

@ -24,7 +24,7 @@ class AccountController extends Controller
->with('bank:id,name,logo')
->orderByRaw("FIELD(type, 'checking', 'savings', 'investment', 'retirement', 'loan', 'credit_card', 'others')")
->orderBy('name')
->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code']);
->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code', 'banking_connection_id']);
return Inertia::render('Accounts/Index', [
'accounts' => $accounts,
@ -39,7 +39,7 @@ class AccountController extends Controller
$account->load('bank:id,name,logo');
return Inertia::render('Accounts/Show', [
'account' => $account->only(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code', 'bank']),
'account' => $account->only(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code', 'banking_connection_id', 'bank']),
]);
}
}

View File

@ -146,6 +146,7 @@ class AccountMetricsService
'type' => $account->type,
'currency_code' => $account->currency_code,
'bank' => $account->bank,
'banking_connection_id' => $account->banking_connection_id,
];
if ($account->type->supportsInvestedAmount()) {

File diff suppressed because it is too large Load Diff

View File

@ -52,6 +52,7 @@ export function AccountListCard({
}
const isPositive = account.diff >= 0;
const isConnected = !!account.banking_connection_id;
return (
<Card className="w-full py-0">
@ -86,18 +87,29 @@ export function AccountListCard({
</div>
</div>
<div className="flex shrink-0 flex-col items-end">
<button
type="button"
onClick={() => setUpdateBalanceOpen(true)}
className="-mr-2 cursor-pointer rounded-md px-2 py-1 transition-colors hover:bg-muted"
>
<AmountDisplay
amountInCents={account.currentBalance}
currencyCode={account.currency_code}
size="2xl"
weight="bold"
/>
</button>
{isConnected ? (
<div className="-mr-2 px-2 py-1">
<AmountDisplay
amountInCents={account.currentBalance}
currencyCode={account.currency_code}
size="2xl"
weight="bold"
/>
</div>
) : (
<button
type="button"
onClick={() => setUpdateBalanceOpen(true)}
className="-mr-2 cursor-pointer rounded-md px-2 py-1 transition-colors hover:bg-muted"
>
<AmountDisplay
amountInCents={account.currentBalance}
currencyCode={account.currency_code}
size="2xl"
weight="bold"
/>
</button>
)}
<AmountTrendIndicator
isPositive={isPositive}
trend={Math.abs(account.diff)}
@ -176,7 +188,7 @@ export function AccountListCard({
)}
</span>
<span
className={`whitespace-nowrap text-right font-mono tabular-nums ${gain >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}
className={`text-right font-mono whitespace-nowrap tabular-nums ${gain >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}
>
{gain >= 0
? '+'
@ -232,15 +244,20 @@ export function AccountListCard({
</ResponsiveContainer>
</div>
<div className="flex justify-between">
<Button
className="cursor-pointer"
variant="secondary"
onClick={() => setUpdateBalanceOpen(true)}
>
{__('Update balance')}
</Button>
{!isConnected && (
<Button
className="cursor-pointer"
variant="secondary"
onClick={() => setUpdateBalanceOpen(true)}
>
{__('Update balance')}
</Button>
)}
<Link href={show.url(account.id)}>
<Link
href={show.url(account.id)}
className={isConnected ? 'ml-auto' : ''}
>
<Button className="cursor-pointer" variant="ghost">
{__('Details')} &rarr;
</Button>

View File

@ -32,6 +32,8 @@ export function DeleteAccountDialog({
}: DeleteAccountDialogProps) {
const [confirmText, setConfirmText] = useState('');
const confirmWord = __('DELETE');
function handleOpenChange(newOpen: boolean) {
if (!newOpen) {
setConfirmText('');
@ -39,7 +41,7 @@ export function DeleteAccountDialog({
onOpenChange(newOpen);
}
const isDeleteEnabled = confirmText === 'DELETE';
const isDeleteEnabled = confirmText === confirmWord;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
@ -49,14 +51,17 @@ export function DeleteAccountDialog({
<DialogDescription className="space-y-2">
<p>
{__(
'This action is irreversible. All transactions in\n this account will also be permanently deleted.',
'This action is irreversible. All transactions in this account will also be permanently deleted.',
)}
</p>
<p className="font-semibold">
{__('Type')}
<span className="text-red-600">DELETE</span>
{__('to\n confirm.')}
</p>
<p
className="font-semibold"
dangerouslySetInnerHTML={{
__html: __('Type :word to confirm.', {
word: `<span class="text-red-600">${confirmWord}</span>`,
}),
}}
/>
</DialogDescription>
</DialogHeader>
@ -99,7 +104,9 @@ export function DeleteAccountDialog({
variant="destructive"
disabled={processing || !isDeleteEnabled}
>
{processing ? 'Deleting...' : 'Delete'}
{processing
? __('Deleting...')
: __('Delete')}
</Button>
</DialogFooter>
)}

View File

@ -41,6 +41,7 @@ export function AccountBalanceCard({
}
const isPositive = account.diff >= 0;
const isConnected = !!account.banking_connection_id;
return (
<Card>
@ -72,18 +73,29 @@ export function AccountBalanceCard({
<CardContent>
<div className="flex items-center justify-between gap-6">
<div className="flex flex-col gap-1">
<button
type="button"
onClick={() => setUpdateBalanceOpen(true)}
className="-ml-2 cursor-pointer rounded-md px-2 py-1 text-left transition-colors hover:bg-muted"
>
<AmountDisplay
amountInCents={account.currentBalance}
currencyCode={account.currency_code}
size="2xl"
weight="medium"
/>
</button>
{isConnected ? (
<div className="-ml-2 px-2 py-1">
<AmountDisplay
amountInCents={account.currentBalance}
currencyCode={account.currency_code}
size="2xl"
weight="medium"
/>
</div>
) : (
<button
type="button"
onClick={() => setUpdateBalanceOpen(true)}
className="-ml-2 cursor-pointer rounded-md px-2 py-1 text-left transition-colors hover:bg-muted"
>
<AmountDisplay
amountInCents={account.currentBalance}
currencyCode={account.currency_code}
size="2xl"
weight="medium"
/>
</button>
)}
<AmountTrendIndicator
isPositive={isPositive}
trend={Math.abs(account.diff)}
@ -161,7 +173,7 @@ export function AccountBalanceCard({
)}
</span>
<span
className={`whitespace-nowrap text-right font-mono tabular-nums ${gain >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}
className={`text-right font-mono whitespace-nowrap tabular-nums ${gain >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}
>
{gain >= 0
? '+'

View File

@ -8,13 +8,37 @@ const statusConfig: Record<
{
label: string;
variant: 'default' | 'secondary' | 'destructive' | 'outline';
className?: string;
}
> = {
active: { label: 'Active', variant: 'default' },
awaiting_mapping: { label: 'Setup Required', variant: 'secondary' },
pending: { label: 'Pending', variant: 'secondary' },
expired: { label: 'Expired', variant: 'outline' },
revoked: { label: 'Revoked', variant: 'outline' },
active: {
label: 'Active',
variant: 'default',
className:
'bg-green-100 text-green-800 border-green-200 dark:bg-green-500/10 dark:text-green-400 dark:border-green-500/20',
},
awaiting_mapping: {
label: 'Setup Required',
variant: 'secondary',
className:
'dark:bg-yellow-900/60 dark:text-yellow-200 dark:border-yellow-700/50',
},
pending: {
label: 'Pending',
variant: 'secondary',
className:
'dark:bg-yellow-900/60 dark:text-yellow-200 dark:border-yellow-700/50',
},
expired: {
label: 'Expired',
variant: 'outline',
className: 'dark:text-muted-foreground',
},
revoked: {
label: 'Revoked',
variant: 'outline',
className: 'dark:text-muted-foreground',
},
error: { label: 'Error', variant: 'destructive' },
};
@ -27,7 +51,10 @@ export function ConnectionStatusBadge({
}) {
if (status === 'active' && !lastSyncedAt) {
return (
<Badge variant="secondary" className="gap-1">
<Badge
variant="secondary"
className="gap-1 border-green-200 bg-green-100 text-green-800 dark:border-yellow-700/50 dark:bg-yellow-900/60 dark:text-yellow-200"
>
<Spinner className="size-3" />
{__('Syncing')}
</Badge>
@ -36,5 +63,9 @@ export function ConnectionStatusBadge({
const config = statusConfig[status];
return <Badge variant={config.variant}>{__(config.label)}</Badge>;
return (
<Badge variant={config.variant} className={config.className}>
{__(config.label)}
</Badge>
);
}

View File

@ -11,6 +11,7 @@ export interface NetWorthEvolutionAccount {
type: AccountType;
currency_code: string;
bank: Bank;
banking_connection_id: string | null;
invested_amount?: number | null;
}
@ -80,6 +81,7 @@ export function deriveAccountMetrics(
type: account.type,
currency_code: account.currency_code,
bank: account.bank,
banking_connection_id: account.banking_connection_id,
currentBalance,
previousBalance,
diff: currentBalance - previousBalance,

View File

@ -56,6 +56,8 @@ export default function AccountShow({
setChartRefreshKey((prev) => prev + 1);
}
const isConnected = !!account.banking_connection_id;
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Accounts',
@ -94,60 +96,75 @@ export default function AccountShow({
/>
</div>
<ButtonGroup>
{isConnected ? (
<Button
variant="outline"
onClick={() => setEditOpen(true)}
>
{__('Edit account')}
</Button>
) : (
<ButtonGroup>
<Button
variant="outline"
onClick={() => setUpdateBalanceOpen(true)}
>
{__('Update balance')}
</Button>
<ButtonGroup>
<Button
variant="outline"
onClick={() => setUpdateBalanceOpen(true)}
>
{__('Update balance')}
</Button>
</ButtonGroup>
<ButtonGroup>
<Button
variant="outline"
onClick={() => setImportBalancesOpen(true)}
>
{__('Import balances')}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
aria-label={__('More options')}
>
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
setBalancesOpen(true)
}
>
{__('See balances')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEditOpen(true)}
>
{__('Edit account')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setDeleteOpen(true)}
variant="destructive"
>
{__('Delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
</ButtonGroup>
<ButtonGroup>
<Button
variant="outline"
onClick={() => setImportBalancesOpen(true)}
>
{__('Import balances')}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
aria-label={__('More options')}
>
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => setBalancesOpen(true)}
>
{__('See balances')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEditOpen(true)}
>
{__('Edit account')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setDeleteOpen(true)}
variant="destructive"
>
{__('Delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
</ButtonGroup>
)}
</div>
<AccountBalanceChart
account={account}
refreshKey={chartRefreshKey}
onBalanceClick={() => setUpdateBalanceOpen(true)}
onBalanceClick={
isConnected
? undefined
: () => setUpdateBalanceOpen(true)
}
/>
{isTransactionalAccount(account) && (