Fix ImportStatementDialog overflow on narrow viewports
CI / secrets-scan (push) Successful in 7s
CI / sast (push) Successful in 15s
CI / vuln-scan (push) Successful in 16s
CI / test (push) Successful in 28s
CI / lint (push) Successful in 34s
CI / build-images (push) Successful in 2m7s
CI / image-scan (push) Successful in 55s
CI / push (push) Has been skipped
CI / secrets-scan (pull_request) Successful in 7s
CI / sast (pull_request) Successful in 15s
CI / vuln-scan (pull_request) Successful in 16s
CI / test (pull_request) Successful in 26s
CI / lint (pull_request) Successful in 33s
CI / build-images (pull_request) Successful in 2m4s
CI / image-scan (pull_request) Successful in 53s
CI / push (pull_request) Has been skipped
CI / secrets-scan (push) Successful in 7s
CI / sast (push) Successful in 15s
CI / vuln-scan (push) Successful in 16s
CI / test (push) Successful in 28s
CI / lint (push) Successful in 34s
CI / build-images (push) Successful in 2m7s
CI / image-scan (push) Successful in 55s
CI / push (push) Has been skipped
CI / secrets-scan (pull_request) Successful in 7s
CI / sast (pull_request) Successful in 15s
CI / vuln-scan (pull_request) Successful in 16s
CI / test (pull_request) Successful in 26s
CI / lint (pull_request) Successful in 33s
CI / build-images (pull_request) Successful in 2m4s
CI / image-scan (pull_request) Successful in 53s
CI / push (pull_request) Has been skipped
The review and confirm steps were rendering as wide editable tables inside a `sm:max-w-3xl` dialog (~768px). The inputs alone consumed ~750px before padding, so on smaller desktop windows the content was clipped left/right, and on mobile it was effectively unusable. Two-pronged fix: - Widen the dialog itself to `md:max-w-5xl xl:max-w-6xl` so the table has room on typical desktops. Mobile stays full-width minus margin. - Split each step into responsive layouts gated by Tailwind's `md:` breakpoint. Desktop keeps the editable table (now with fixed-width columns, an inline status badge instead of a separate Status column, and `overflow-auto` as a graceful fallback at intermediate widths). Below `md:`, rows render as stacked cards — checkbox + date + status on top, description full-width, amount + type wrapped, transfer destination dropping below when applicable. Same data, no horizontal scrolling on phones. The Confirm step's expand-to-preview table gets the same treatment: table on desktop, summary cards on mobile. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "tehriehlbudget-backend",
|
"name": "tehriehlbudget-backend",
|
||||||
"version": "0.4.0",
|
"version": "0.4.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "tehriehlbudget-frontend",
|
"name": "tehriehlbudget-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.4.0",
|
"version": "0.4.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -335,7 +335,7 @@ export function ImportStatementDialog({ open, onOpenChange, defaultAccountId, on
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-3xl">
|
<DialogContent className="sm:max-w-3xl md:max-w-5xl xl:max-w-6xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Import statement</DialogTitle>
|
<DialogTitle>Import statement</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -638,12 +638,12 @@ function ReviewStep({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Review each row before import. Edit fields inline if needed; uncheck anything you don't
|
Review each row before import. Edit fields inline if needed; uncheck anything you don't
|
||||||
want to import.
|
want to import.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button size="sm" variant="outline" onClick={onIncludeAll}>
|
<Button size="sm" variant="outline" onClick={onIncludeAll}>
|
||||||
Include all
|
Include all
|
||||||
</Button>
|
</Button>
|
||||||
@@ -667,16 +667,17 @@ function ReviewStep({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="max-h-[55vh] overflow-y-auto rounded-md border">
|
|
||||||
|
{/* Desktop / tablet: editable table with horizontal scroll fallback */}
|
||||||
|
<div className="hidden max-h-[55vh] overflow-auto rounded-md border md:block">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-8" />
|
<TableHead className="w-8" />
|
||||||
<TableHead>Date</TableHead>
|
<TableHead className="w-[8.5rem]">Date</TableHead>
|
||||||
<TableHead>Description</TableHead>
|
<TableHead>Description</TableHead>
|
||||||
<TableHead className="text-right">Amount</TableHead>
|
<TableHead className="w-28 text-right">Amount</TableHead>
|
||||||
<TableHead>Type</TableHead>
|
<TableHead className="w-[11rem]">Type</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -685,7 +686,7 @@ function ReviewStep({
|
|||||||
key={r.sourceIndex}
|
key={r.sourceIndex}
|
||||||
className={r.status === 'duplicate' ? 'bg-red-50/50' : undefined}
|
className={r.status === 'duplicate' ? 'bg-red-50/50' : undefined}
|
||||||
>
|
>
|
||||||
<TableCell>
|
<TableCell className="align-top">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
aria-label={`Include row ${r.sourceIndex + 1}`}
|
aria-label={`Include row ${r.sourceIndex + 1}`}
|
||||||
@@ -693,20 +694,25 @@ function ReviewStep({
|
|||||||
onChange={(e) => onUpdateRow(r.sourceIndex, { included: e.target.checked })}
|
onChange={(e) => onUpdateRow(r.sourceIndex, { included: e.target.checked })}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="align-top">
|
||||||
<Input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
className="h-8 w-[8.5rem]"
|
className="h-8 w-[8rem]"
|
||||||
value={toDateInputValue(r.date)}
|
value={toDateInputValue(r.date)}
|
||||||
onChange={(e) => onUpdateRow(r.sourceIndex, { date: e.target.value })}
|
onChange={(e) => onUpdateRow(r.sourceIndex, { date: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="align-top">
|
||||||
<Input
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
className="h-8 min-w-[12rem]"
|
<Input
|
||||||
value={r.description}
|
className="h-8 min-w-0 flex-1"
|
||||||
onChange={(e) => onUpdateRow(r.sourceIndex, { description: e.target.value })}
|
value={r.description}
|
||||||
/>
|
onChange={(e) =>
|
||||||
|
onUpdateRow(r.sourceIndex, { description: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{statusBadge(r.status)}
|
||||||
|
</div>
|
||||||
{r.duplicateOf && (
|
{r.duplicateOf && (
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
Matches existing {formatDate(r.duplicateOf.date)} ·{' '}
|
Matches existing {formatDate(r.duplicateOf.date)} ·{' '}
|
||||||
@@ -720,7 +726,7 @@ function ReviewStep({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="align-top text-right">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
@@ -733,7 +739,7 @@ function ReviewStep({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="align-top">
|
||||||
<Select
|
<Select
|
||||||
value={r.type}
|
value={r.type}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
@@ -747,7 +753,7 @@ function ReviewStep({
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 w-[7rem]">
|
<SelectTrigger className="h-8 w-full">
|
||||||
<SelectValue>
|
<SelectValue>
|
||||||
{(v: string | undefined) =>
|
{(v: string | undefined) =>
|
||||||
(v ?? 'EXPENSE').charAt(0) + (v ?? 'EXPENSE').slice(1).toLowerCase()
|
(v ?? 'EXPENSE').charAt(0) + (v ?? 'EXPENSE').slice(1).toLowerCase()
|
||||||
@@ -769,7 +775,7 @@ function ReviewStep({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="mt-1 h-8 w-[10rem]">
|
<SelectTrigger className="mt-1 h-8 w-full">
|
||||||
<SelectValue placeholder="To account">
|
<SelectValue placeholder="To account">
|
||||||
{(v: string | undefined) =>
|
{(v: string | undefined) =>
|
||||||
accounts.find((a) => a.id === v)?.name ?? 'To account'
|
accounts.find((a) => a.id === v)?.name ?? 'To account'
|
||||||
@@ -786,12 +792,126 @@ function ReviewStep({
|
|||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{statusBadge(r.status)}</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile: stacked cards. Tables don't fit on a phone screen. */}
|
||||||
|
<div className="max-h-[60vh] space-y-2 overflow-y-auto md:hidden">
|
||||||
|
{rows.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.sourceIndex}
|
||||||
|
className={`rounded-md border p-3 ${
|
||||||
|
r.status === 'duplicate' ? 'bg-red-50/50' : 'bg-card'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label={`Include row ${r.sourceIndex + 1}`}
|
||||||
|
checked={r.included}
|
||||||
|
onChange={(e) => onUpdateRow(r.sourceIndex, { included: e.target.checked })}
|
||||||
|
className="mt-2"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
className="h-8 w-[8.5rem]"
|
||||||
|
value={toDateInputValue(r.date)}
|
||||||
|
onChange={(e) => onUpdateRow(r.sourceIndex, { date: e.target.value })}
|
||||||
|
/>
|
||||||
|
{statusBadge(r.status)}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
className="h-8 w-full"
|
||||||
|
value={r.description}
|
||||||
|
onChange={(e) => onUpdateRow(r.sourceIndex, { description: e.target.value })}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
className="h-8 w-28 text-right"
|
||||||
|
value={r.amount}
|
||||||
|
onChange={(e) =>
|
||||||
|
onUpdateRow(r.sourceIndex, {
|
||||||
|
amount: parseFloat(e.target.value) || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={r.type}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
const newType = (v ?? 'EXPENSE') as RowType;
|
||||||
|
onUpdateRow(r.sourceIndex, {
|
||||||
|
type: newType,
|
||||||
|
destinationAccountId:
|
||||||
|
newType === 'TRANSFER'
|
||||||
|
? (r.destinationAccountId ?? r.transferCandidate?.accountId ?? '')
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-[7.5rem]">
|
||||||
|
<SelectValue>
|
||||||
|
{(v: string | undefined) =>
|
||||||
|
(v ?? 'EXPENSE').charAt(0) + (v ?? 'EXPENSE').slice(1).toLowerCase()
|
||||||
|
}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="INCOME">Income</SelectItem>
|
||||||
|
<SelectItem value="EXPENSE">Expense</SelectItem>
|
||||||
|
<SelectItem value="TRANSFER">Transfer</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{r.type === 'TRANSFER' && (
|
||||||
|
<Select
|
||||||
|
value={r.destinationAccountId ?? ''}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
onUpdateRow(r.sourceIndex, {
|
||||||
|
destinationAccountId: v ?? '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-full">
|
||||||
|
<SelectValue placeholder="To account">
|
||||||
|
{(v: string | undefined) =>
|
||||||
|
accounts.find((a) => a.id === v)?.name ?? 'To account'
|
||||||
|
}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{otherAccounts.map((a) => (
|
||||||
|
<SelectItem key={a.id} value={a.id}>
|
||||||
|
{a.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
{r.duplicateOf && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Matches existing {formatDate(r.duplicateOf.date)} ·{' '}
|
||||||
|
{currency(r.duplicateOf.amount)} · {r.duplicateOf.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{r.transferCandidate && !r.destinationAccountId && (
|
||||||
|
<p className="text-xs text-sky-900">
|
||||||
|
Possible transfer with {r.transferCandidate.accountName}. Pick a destination
|
||||||
|
account above to mark as transfer.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{counts.selected} selected · {counts.duplicates} duplicates · {counts.needsReview} need
|
{counts.selected} selected · {counts.duplicates} duplicates · {counts.needsReview} need
|
||||||
review · {counts.possibleTransfers} possible transfers
|
review · {counts.possibleTransfers} possible transfers
|
||||||
@@ -902,39 +1022,65 @@ function ConfirmStep({
|
|||||||
{counts.selected === 1 ? '' : 's'} about to be imported
|
{counts.selected === 1 ? '' : 's'} about to be imported
|
||||||
</button>
|
</button>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="max-h-[40vh] overflow-y-auto rounded-md border">
|
<>
|
||||||
<Table>
|
{/* Desktop / tablet: read-only summary table */}
|
||||||
<TableHeader>
|
<div className="hidden max-h-[40vh] overflow-auto rounded-md border md:block">
|
||||||
<TableRow>
|
<Table>
|
||||||
<TableHead>Date</TableHead>
|
<TableHeader>
|
||||||
<TableHead>Description</TableHead>
|
<TableRow>
|
||||||
<TableHead className="text-right">Amount</TableHead>
|
<TableHead className="w-[7.5rem]">Date</TableHead>
|
||||||
<TableHead>Type</TableHead>
|
<TableHead>Description</TableHead>
|
||||||
<TableHead>Account</TableHead>
|
<TableHead className="w-28 text-right">Amount</TableHead>
|
||||||
</TableRow>
|
<TableHead className="w-24">Type</TableHead>
|
||||||
</TableHeader>
|
<TableHead>Account</TableHead>
|
||||||
<TableBody>
|
|
||||||
{selectedRows.map((r) => (
|
|
||||||
<TableRow key={r.sourceIndex}>
|
|
||||||
<TableCell>{formatDate(r.date)}</TableCell>
|
|
||||||
<TableCell>{r.description}</TableCell>
|
|
||||||
<TableCell className="text-right">{currency(r.amount)}</TableCell>
|
|
||||||
<TableCell>{r.type.charAt(0) + r.type.slice(1).toLowerCase()}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{sourceAccount?.name ?? '—'}
|
|
||||||
{r.destinationAccountId && (
|
|
||||||
<>
|
|
||||||
{' '}
|
|
||||||
<ArrowRight className="inline size-3" />{' '}
|
|
||||||
{accounts.find((a) => a.id === r.destinationAccountId)?.name ?? '—'}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
</TableHeader>
|
||||||
</TableBody>
|
<TableBody>
|
||||||
</Table>
|
{selectedRows.map((r) => (
|
||||||
</div>
|
<TableRow key={r.sourceIndex}>
|
||||||
|
<TableCell>{formatDate(r.date)}</TableCell>
|
||||||
|
<TableCell>{r.description}</TableCell>
|
||||||
|
<TableCell className="text-right">{currency(r.amount)}</TableCell>
|
||||||
|
<TableCell>{r.type.charAt(0) + r.type.slice(1).toLowerCase()}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{sourceAccount?.name ?? '—'}
|
||||||
|
{r.destinationAccountId && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
<ArrowRight className="inline size-3" />{' '}
|
||||||
|
{accounts.find((a) => a.id === r.destinationAccountId)?.name ?? '—'}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile: stacked cards */}
|
||||||
|
<div className="max-h-[40vh] space-y-2 overflow-y-auto md:hidden">
|
||||||
|
{selectedRows.map((r) => (
|
||||||
|
<div key={r.sourceIndex} className="rounded-md border bg-card p-3 text-sm">
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<span className="font-medium">{r.description}</span>
|
||||||
|
<span className="whitespace-nowrap">{currency(r.amount)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{formatDate(r.date)} · {r.type.charAt(0) + r.type.slice(1).toLowerCase()} ·{' '}
|
||||||
|
{sourceAccount?.name ?? '—'}
|
||||||
|
{r.destinationAccountId && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
<ArrowRight className="inline size-3" />{' '}
|
||||||
|
{accounts.find((a) => a.id === r.destinationAccountId)?.name ?? '—'}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
|||||||
Reference in New Issue
Block a user