Skip to content

Commit cc1168d

Browse files
digaomatiasRodrigo Leoteclaude
authored
fix: improve reconciliation view for mobile screens (#198)
* fix: improve reconciliation view for mobile screens The reconciliation feature was unusable on phone-sized screens with truncated tabs, cramped transaction cards, and overlapping action buttons. - Make tab bar horizontally scrollable with compact labels on mobile - Stack action buttons below card content instead of beside it on mobile - Hide drag-and-drop help text on mobile (not useful for touch) - Make bulk actions bar stack vertically on small screens - Make filters section full-width on mobile - Compact balance cards in 2-column grid with smaller text on mobile - Make transaction comparison header wrap on small screens - Add Playwright E2E tests for mobile viewport (375x812) Desktop layout unchanged. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: CI retries for mobile tests, cross-browser scrollbar hiding * fix: remove redundant jwt variable in mobile test --------- Co-authored-by: Rodrigo Leote <rodrigol@leapthought.co.nz> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c0b0279 commit cc1168d

7 files changed

Lines changed: 580 additions & 63 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { defineConfig, devices } from '@playwright/test';
2+
3+
/**
4+
* Lightweight Playwright config for mobile-responsive tests.
5+
* These tests mock all API calls, so no backend is needed.
6+
*/
7+
export default defineConfig({
8+
testDir: './tests/e2e',
9+
fullyParallel: true,
10+
forbidOnly: !!process.env.CI,
11+
retries: process.env.CI ? 1 : 0,
12+
reporter: [['line']],
13+
14+
use: {
15+
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
16+
trace: 'on-first-retry',
17+
screenshot: 'only-on-failure',
18+
ignoreHTTPSErrors: true,
19+
},
20+
21+
projects: [
22+
{
23+
name: 'Mobile Chrome',
24+
use: { ...devices['Pixel 5'] },
25+
},
26+
],
27+
28+
webServer: {
29+
command: 'npm run dev',
30+
url: 'http://localhost:3000',
31+
reuseExistingServer: true,
32+
timeout: 120 * 1000,
33+
},
34+
35+
timeout: 60 * 1000,
36+
expect: {
37+
timeout: 10 * 1000,
38+
},
39+
});

frontend/src/app/globals.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,5 +625,14 @@
625625
}
626626
}
627627

628+
/* Hide scrollbar cross-browser */
629+
.hide-scrollbar {
630+
-ms-overflow-style: none; /* IE/Edge */
631+
scrollbar-width: none; /* Firefox */
632+
}
633+
.hide-scrollbar::-webkit-scrollbar {
634+
display: none; /* Chrome/Safari/Opera */
635+
}
636+
628637
/* Import mobile-specific overrides */
629638
@import '../styles/mobile-overrides.css';

frontend/src/components/reconciliation/draggable-transaction-card.tsx

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ export function DraggableTransactionCard({
199199
};
200200

201201
const cardClasses = `
202-
relative p-4 border rounded-lg transition-all duration-200
202+
relative p-3 sm:p-4 border rounded-lg transition-all duration-200
203203
${bgColor} ${borderColor}
204204
${isDragging ? 'opacity-60 scale-95 rotate-1 shadow-2xl z-50 cursor-grabbing' : onDragStart ? 'cursor-grab hover:shadow-lg' : 'cursor-pointer'}
205205
${isDragOver && canReceiveDrop ? 'ring-2 ring-primary-500 ring-opacity-75 scale-105 bg-primary-50 border-primary-300' : ''}
@@ -268,10 +268,10 @@ export function DraggableTransactionCard({
268268
</div>
269269
)}
270270

271-
<div className="flex items-start justify-between">
272-
<div className="flex-1">
273-
<div className="flex items-center gap-2 mb-2">
274-
<span className="font-medium text-ink-900">{transaction.description}</span>
271+
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between">
272+
<div className="flex-1 min-w-0">
273+
<div className="flex items-start sm:items-center gap-2 mb-2 flex-wrap">
274+
<span className="font-medium text-ink-900 break-words">{transaction.description}</span>
275275
{showPreviewButton && !previewedDescription && !isPreviewing && onPreview && (
276276
<button
277277
onClick={(e) => {
@@ -305,27 +305,27 @@ export function DraggableTransactionCard({
305305
</div>
306306
)}
307307

308-
<div className="grid grid-cols-2 gap-4 text-sm">
308+
<div className="grid grid-cols-2 gap-2 sm:gap-4 text-xs sm:text-sm">
309309
<div>
310310
<span className="text-ink-500">{tCommon('amount')}:</span>
311-
<span className={`ml-2 font-medium ${
311+
<span className={`ml-1 sm:ml-2 font-medium ${
312312
transaction.amount >= 0 ? 'text-green-600' : 'text-red-600'
313313
}`}>
314314
{formatCurrency(transaction.amount)}
315315
</span>
316316
</div>
317-
317+
318318
<div>
319319
<span className="text-ink-500">{tCommon('date')}:</span>
320-
<span className="ml-2 text-ink-900">
320+
<span className="ml-1 sm:ml-2 text-ink-900">
321321
{new Date(transaction.transactionDate).toLocaleDateString()}
322322
</span>
323323
</div>
324324
</div>
325325
</div>
326326

327327
{/* Action buttons */}
328-
<div className="flex flex-col gap-2 ml-4">
328+
<div className="flex flex-row sm:flex-col gap-2 mt-3 sm:mt-0 sm:ml-4 pt-3 sm:pt-0 border-t sm:border-t-0 border-ink-200">
329329
{showMatchButton && onMatch && (
330330
<Button
331331
variant="secondary"

frontend/src/components/reconciliation/reconciliation-balance-cards.tsx

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -64,18 +64,18 @@ export function ReconciliationBalanceCards({
6464
const hasSignificantDiscrepancy = discrepancy > 1.00; // More than $1 difference
6565

6666
return (
67-
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
67+
<div className="grid grid-cols-2 md:grid-cols-2 lg:grid-cols-4 gap-2 sm:gap-4 mb-6">
6868
{/* Statement Balance Card */}
6969
<Card className="bg-blue-50 border-blue-200">
70-
<CardContent className="p-4">
70+
<CardContent className="p-2.5 sm:p-4">
7171
<div className="flex items-center justify-between">
7272
<div>
73-
<p className="text-sm text-blue-600 font-medium">{t('balanceCards.statementBalance')}</p>
74-
<p className="text-2xl font-bold text-blue-800">
73+
<p className="text-xs sm:text-sm text-blue-600 font-medium">{t('balanceCards.statementBalance')}</p>
74+
<p className="text-lg sm:text-2xl font-bold text-blue-800">
7575
{formatCurrency(statementEndBalance)}
7676
</p>
7777
</div>
78-
<BanknotesIcon className="h-8 w-8 text-blue-600" />
78+
<BanknotesIcon className="h-6 w-6 sm:h-8 sm:w-8 text-blue-600 shrink-0" />
7979
</div>
8080
</CardContent>
8181
</Card>
@@ -87,7 +87,7 @@ export function ReconciliationBalanceCards({
8787
? "bg-red-50 border-red-200"
8888
: "bg-green-50 border-green-200"
8989
)}>
90-
<CardContent className="p-4">
90+
<CardContent className="p-2.5 sm:p-4">
9191
<div className="flex items-center justify-between">
9292
<div>
9393
<p className={cn(
@@ -97,17 +97,17 @@ export function ReconciliationBalanceCards({
9797
{t('balanceCards.unmatchedBank')}
9898
</p>
9999
<p className={cn(
100-
"text-2xl font-bold",
100+
"text-lg sm:text-2xl font-bold",
101101
unmatchedBankTotal !== 0 ? "text-red-800" : "text-green-800"
102102
)}>
103103
{formatCurrency(unmatchedBankTotal)}
104104
</p>
105-
<p className="text-xs text-ink-500 mt-1">
105+
<p className="text-xs text-ink-500 mt-1 hidden sm:block">
106106
{t('balanceCards.expectedBalance', { amount: formatCurrency(expectedBalance) })}
107107
</p>
108108
</div>
109109
<div className={cn(
110-
"h-8 w-8",
110+
"h-6 w-6 sm:h-8 sm:w-8 shrink-0",
111111
unmatchedBankTotal !== 0 ? "text-red-600" : "text-green-600"
112112
)}>
113113
{unmatchedBankTotal !== 0 ? (
@@ -127,7 +127,7 @@ export function ReconciliationBalanceCards({
127127
? "bg-yellow-50 border-yellow-200"
128128
: "bg-green-50 border-green-200"
129129
)}>
130-
<CardContent className="p-4">
130+
<CardContent className="p-2.5 sm:p-4">
131131
<div className="flex items-center justify-between">
132132
<div>
133133
<p className={cn(
@@ -137,17 +137,17 @@ export function ReconciliationBalanceCards({
137137
{t('balanceCards.unmatchedSystem')}
138138
</p>
139139
<p className={cn(
140-
"text-2xl font-bold",
140+
"text-lg sm:text-2xl font-bold",
141141
unmatchedSystemTotal !== 0 ? "text-yellow-800" : "text-green-800"
142142
)}>
143143
{formatCurrency(unmatchedSystemTotal)}
144144
</p>
145-
<p className="text-xs text-ink-500 mt-1">
145+
<p className="text-xs text-ink-500 mt-1 hidden sm:block">
146146
{t('balanceCards.systemBalance', { amount: formatCurrency(systemBalance) })}
147147
</p>
148148
</div>
149149
<ComputerDesktopIcon className={cn(
150-
"h-8 w-8",
150+
"h-6 w-6 sm:h-8 sm:w-8 shrink-0",
151151
unmatchedSystemTotal !== 0 ? "text-yellow-600" : "text-green-600"
152152
)} />
153153
</div>
@@ -163,7 +163,7 @@ export function ReconciliationBalanceCards({
163163
? "bg-red-50 border-red-200"
164164
: "bg-yellow-50 border-yellow-200"
165165
)}>
166-
<CardContent className="p-4">
166+
<CardContent className="p-2.5 sm:p-4">
167167
<div className="flex items-center justify-between">
168168
<div>
169169
<p className={cn(
@@ -177,24 +177,24 @@ export function ReconciliationBalanceCards({
177177
{t('balanceCards.discrepancy')}
178178
</p>
179179
<p className={cn(
180-
"text-2xl font-bold",
181-
isReconciled
180+
"text-lg sm:text-2xl font-bold",
181+
isReconciled
182182
? "text-green-800"
183-
: hasSignificantDiscrepancy
183+
: hasSignificantDiscrepancy
184184
? "text-red-800"
185185
: "text-yellow-800"
186186
)}>
187187
{formatCurrency(discrepancy)}
188188
</p>
189-
<p className="text-xs text-ink-500 mt-1">
189+
<p className="text-xs text-ink-500 mt-1 hidden sm:block">
190190
{isReconciled ? t('balanceCards.reconciled') : t('balanceCards.needsReview')}
191191
</p>
192192
</div>
193193
<ScaleIcon className={cn(
194-
"h-8 w-8",
195-
isReconciled
194+
"h-6 w-6 sm:h-8 sm:w-8 shrink-0",
195+
isReconciled
196196
? "text-green-600"
197-
: hasSignificantDiscrepancy
197+
: hasSignificantDiscrepancy
198198
? "text-red-600"
199199
: "text-yellow-600"
200200
)} />

frontend/src/components/reconciliation/reconciliation-details-view.tsx

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -863,10 +863,10 @@ export function ReconciliationDetailsView({
863863

864864
// For other transaction types, use the standard card display (exact matches, etc.)
865865
return (
866-
<div key={item.id} className={`p-4 border rounded-lg ${config.bgColor} ${config.borderColor} mb-3`}>
867-
<div className="flex items-start justify-between">
868-
<div className="flex-1">
869-
<div className="flex items-center gap-2 mb-2">
866+
<div key={item.id} className={`p-3 sm:p-4 border rounded-lg ${config.bgColor} ${config.borderColor} mb-3`}>
867+
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between">
868+
<div className="flex-1 min-w-0">
869+
<div className="flex items-start sm:items-center gap-2 mb-2 flex-wrap">
870870
<config.icon className={`w-4 h-4 ${config.color}`} />
871871
<span className="font-medium text-ink-900">{item.displayDescription}</span>
872872
<span className={`text-xs px-2 py-1 rounded-full ${config.bgColor} ${config.color} font-medium`}>
@@ -926,7 +926,7 @@ export function ReconciliationDetailsView({
926926

927927
{/* Unlink button for exact matches */}
928928
{activeTab === 'exact' && item.systemTransaction && item.bankTransaction && (
929-
<div className="flex flex-col gap-2 ml-4">
929+
<div className="flex flex-col gap-2 mt-2 sm:mt-0 sm:ml-4">
930930
<Button
931931
variant="ghost"
932932
size="sm"
@@ -1012,8 +1012,8 @@ export function ReconciliationDetailsView({
10121012
{/* Filters */}
10131013
<Card className="bg-white/90 backdrop-blur-xs border-0 shadow-lg">
10141014
<CardContent className="p-4">
1015-
<div className="flex flex-wrap gap-4 items-end">
1016-
<div className="flex-1 min-w-64">
1015+
<div className="flex flex-wrap gap-3 sm:gap-4 items-end">
1016+
<div className="flex-1 min-w-0 w-full sm:min-w-64 sm:w-auto">
10171017
<label className="block text-sm font-medium text-ink-700 mb-1">
10181018
{t('searchTransactions')}
10191019
</label>
@@ -1029,7 +1029,7 @@ export function ReconciliationDetailsView({
10291029
</div>
10301030
</div>
10311031

1032-
<div className="w-32">
1032+
<div className="w-[calc(50%-0.375rem)] sm:w-32">
10331033
<label className="block text-sm font-medium text-ink-700 mb-1">
10341034
{t('minAmount')}
10351035
</label>
@@ -1042,8 +1042,8 @@ export function ReconciliationDetailsView({
10421042
className="w-full px-3 py-2 border border-ink-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
10431043
/>
10441044
</div>
1045-
1046-
<div className="w-32">
1045+
1046+
<div className="w-[calc(50%-0.375rem)] sm:w-32">
10471047
<label className="block text-sm font-medium text-ink-700 mb-1">
10481048
{t('maxAmount')}
10491049
</label>
@@ -1077,11 +1077,11 @@ export function ReconciliationDetailsView({
10771077
<Card className="bg-white/90 backdrop-blur-xs border-0 shadow-lg">
10781078
<CardContent className="p-0">
10791079
{/* Tab Headers */}
1080-
<div className="flex border-b border-ink-200">
1080+
<div className="flex overflow-x-auto border-b border-ink-200 hide-scrollbar">
10811081
{Object.entries(TAB_CONFIG).map(([key, config]) => {
10821082
const count = getTabData(key as TabType).length;
10831083
const isActive = activeTab === key;
1084-
1084+
10851085
return (
10861086
<button
10871087
key={key}
@@ -1091,15 +1091,15 @@ export function ReconciliationDetailsView({
10911091
setSelectedItems(new Set());
10921092
setShowBulkActions(false);
10931093
}}
1094-
className={`flex items-center gap-2 px-4 py-3 border-b-2 transition-colors ${
1094+
className={`flex items-center gap-1.5 sm:gap-2 px-3 sm:px-4 py-3 border-b-2 transition-colors whitespace-nowrap ${
10951095
isActive
10961096
? `${config.color} border-current bg-white`
10971097
: 'text-ink-500 border-transparent hover:text-ink-700 hover:border-ink-300'
10981098
}`}
10991099
>
1100-
<config.icon className="w-4 h-4" />
1101-
<span className="font-medium">{config.label}</span>
1102-
<span className={`px-2 py-1 text-xs rounded-full ${
1100+
<config.icon className="w-4 h-4 hidden sm:block" />
1101+
<span className="font-medium text-xs sm:text-sm">{config.label}</span>
1102+
<span className={`px-1.5 sm:px-2 py-0.5 sm:py-1 text-xs rounded-full ${
11031103
isActive ? config.bgColor : 'bg-ink-100'
11041104
}`}>
11051105
{count}
@@ -1111,8 +1111,8 @@ export function ReconciliationDetailsView({
11111111

11121112
{/* Bulk Actions Bar */}
11131113
{(activeTab === 'fuzzy' || activeTab === 'unmatched-system' || activeTab === 'unmatched-bank') && (showBulkActions || tabData.length > 0) && (
1114-
<div className="bg-ink-50 border-b border-ink-200 p-4">
1115-
<div className="flex items-center justify-between">
1114+
<div className="bg-ink-50 border-b border-ink-200 p-3 sm:p-4">
1115+
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
11161116
<div className="flex items-center gap-4">
11171117
{/* Select All Checkbox */}
11181118
<label className="flex items-center gap-2 cursor-pointer">
@@ -1126,7 +1126,7 @@ export function ReconciliationDetailsView({
11261126
{t('selectAllCount', { count: tabData.length })}
11271127
</span>
11281128
</label>
1129-
1129+
11301130
{selectedItems.size > 0 && (
11311131
<span className="text-sm text-ink-600">
11321132
{t('itemsSelected', { count: selectedItems.size })}
@@ -1136,7 +1136,7 @@ export function ReconciliationDetailsView({
11361136

11371137
{/* Bulk Action Buttons */}
11381138
{showBulkActions && (
1139-
<div className="flex items-center gap-2">
1139+
<div className="flex flex-wrap items-center gap-2">
11401140
{activeTab === 'fuzzy' && (
11411141
<>
11421142
{/* Quick Approve Buttons for Fuzzy Matches */}
@@ -1240,10 +1240,10 @@ export function ReconciliationDetailsView({
12401240
)}
12411241

12421242
{/* Tab Content */}
1243-
<div className="p-6">
1244-
{/* Drag and Drop Instructions for Unmatched Tabs */}
1243+
<div className="p-3 sm:p-6">
1244+
{/* Drag and Drop Instructions for Unmatched Tabs - hidden on mobile */}
12451245
{(activeTab === 'unmatched-bank' || activeTab === 'unmatched-system') && tabData.length > 0 && (
1246-
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
1246+
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg hidden md:block">
12471247
<div className="flex items-center gap-2 text-blue-800 text-sm">
12481248
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
12491249
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
@@ -1302,14 +1302,14 @@ export function ReconciliationDetailsView({
13021302
{/* Actions */}
13031303
<Card className="bg-white/90 backdrop-blur-xs border-0 shadow-lg">
13041304
<CardContent className="p-6">
1305-
<div className="flex justify-end gap-3">
1305+
<div className="flex flex-col-reverse sm:flex-row justify-end gap-3">
13061306
<Button variant="secondary" onClick={onBack}>
13071307
{t('backToMatching')}
13081308
</Button>
13091309
<Button
13101310
onClick={onCompleteReconciliation}
13111311
disabled={loading}
1312-
className="flex items-center gap-2"
1312+
className="flex items-center justify-center gap-2"
13131313
>
13141314
{loading && <div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />}
13151315
{t('completeReconciliation')}

0 commit comments

Comments
 (0)