Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
F
finwise-miniapp-fe
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ThinhNC
finwise-miniapp-fe
Commits
18d622d4
Commit
18d622d4
authored
Aug 18, 2026
by
ThinhNC
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix(receipts): improve previews and localized scan results
parent
6f17fdd2
Changes
5
Show whitespace changes
Inline
Side-by-side
Showing
5 changed files
with
126 additions
and
41 deletions
+126
-41
memory.md
.agents/memory.md
+3
-0
index.html
index.html
+1
-1
ReceiptScannerView.tsx
src/pages/ai-assistant/components/ReceiptScannerView.tsx
+58
-20
TransactionDetailModal.tsx
src/pages/transactions/components/TransactionDetailModal.tsx
+13
-4
TransactionFormModal.tsx
src/pages/transactions/components/TransactionFormModal.tsx
+51
-16
No files found.
.agents/memory.md
View file @
18d622d4
...
...
@@ -37,6 +37,9 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
-
**Upload file**
: Browser tải file trực tiếp lên Cloudflare R2 bằng presigned PUT URL do backend
cấp; không proxy binary qua API và không đưa R2 credentials vào frontend. Hiện luồng này chỉ áp
dụng cho avatar JPEG/PNG/WebP tối đa 5 MB; chỉ cập nhật profile sau khi PUT thành công.
-
**Preview file local**
: CSP của Mini App cho phép scheme
`blob:`
vì ảnh/PDF người dùng vừa chọn
và hóa đơn tải qua API được hiển thị bằng object URL. Mọi object URL do component tạo phải được
revoke khi file thay đổi hoặc component unmount.
-
**Crop avatar**
: Trang hồ sơ mở trình chỉnh avatar khi chạm trực tiếp vào ảnh; điểm lấy nét X/Y
được lưu trên profile và dùng nhất quán ở mọi nơi hiển thị avatar.
Badge cây viết là lớp phủ không nhận pointer nằm ngoài button; vùng focus/active của button phải
...
...
index.html
View file @
18d622d4
...
...
@@ -4,7 +4,7 @@
<meta
charset=
"utf-8"
/>
<meta
http-equiv=
"Content-Security-Policy"
content=
"default-src * 'self' 'unsafe-inline' 'unsafe-eval' data: gap: content:"
content=
"default-src * 'self' 'unsafe-inline' 'unsafe-eval' data:
blob:
gap: content:"
/>
<meta
name=
"viewport"
...
...
src/pages/ai-assistant/components/ReceiptScannerView.tsx
View file @
18d622d4
...
...
@@ -5,12 +5,13 @@ import { Button } from "@/components/ui/Button";
import
{
Card
}
from
"@/components/ui/Card"
;
import
{
useI18n
}
from
"@/i18n"
;
import
{
useExtractReceipt
}
from
"@/hooks/use-ai-assistant"
;
import
{
formatBusinessDate
}
from
"@/lib/business-time"
;
import
{
ExtractReceiptData
}
from
"@/types/ai"
;
import
{
AIErrorState
}
from
"./AIErrorState"
;
export
const
ReceiptScannerView
:
React
.
FC
=
()
=>
{
const
navigate
=
useNavigate
();
const
{
t
}
=
useI18n
();
const
{
formatCurrency
,
formatNumber
,
intlLocale
,
t
}
=
useI18n
();
const
extractMutation
=
useExtractReceipt
();
const
fileInputRef
=
useRef
<
HTMLInputElement
>
(
null
);
...
...
@@ -20,10 +21,15 @@ export const ReceiptScannerView: React.FC = () => {
const
[
fileError
,
setFileError
]
=
useState
<
string
|
null
>
(
null
);
useEffect
(()
=>
{
return
()
=>
{
if
(
previewUrl
)
URL
.
revokeObjectURL
(
previewUrl
);
};
},
[
previewUrl
]);
if
(
!
selectedFile
?.
type
.
startsWith
(
"image/"
))
{
setPreviewUrl
(
null
);
return
;
}
const
objectUrl
=
URL
.
createObjectURL
(
selectedFile
);
setPreviewUrl
(
objectUrl
);
return
()
=>
URL
.
revokeObjectURL
(
objectUrl
);
},
[
selectedFile
]);
const
handleFileSelected
=
(
file
:
File
)
=>
{
extractMutation
.
reset
();
...
...
@@ -40,11 +46,6 @@ export const ReceiptScannerView: React.FC = () => {
}
setSelectedFile
(
file
);
if
(
file
.
type
.
startsWith
(
"image/"
))
{
setPreviewUrl
(
URL
.
createObjectURL
(
file
));
}
else
{
setPreviewUrl
(
null
);
}
setExtractedData
(
null
);
// Auto trigger extraction
...
...
@@ -126,6 +127,21 @@ export const ReceiptScannerView: React.FC = () => {
:
"border-clay-expense/30 bg-clay-expense/20 text-clay-expense"
:
""
;
const
receiptCurrency
=
extractedData
?.
currency
||
"VND"
;
const
formatReceiptMoney
=
(
value
:
string
|
null
):
string
=>
{
if
(
value
===
null
)
return
"—"
;
const
amount
=
Number
(
value
);
return
Number
.
isFinite
(
amount
)
?
formatCurrency
(
amount
,
receiptCurrency
)
:
"—"
;
};
const
formattedTransactionDate
=
extractedData
?.
transactionDate
?
formatBusinessDate
(
extractedData
.
transactionDate
,
intlLocale
,
{
day
:
"2-digit"
,
month
:
"2-digit"
,
year
:
"numeric"
,
})
:
"—"
;
return
(
<
div
className=
"flex flex-col gap-4 pb-8"
>
{
/* Upload Header Card */
}
...
...
@@ -182,7 +198,10 @@ export const ReceiptScannerView: React.FC = () => {
{
selectedFile
.
name
}
</
span
>
<
span
className=
"clay-caption text-[10px]"
>
{
(
selectedFile
.
size
/
1024
/
1024
).
toFixed
(
2
)
}
MB
{
formatNumber
(
selectedFile
.
size
/
1024
/
1024
,
{
minimumFractionDigits
:
2
,
maximumFractionDigits
:
2
,
})
}
MB
</
span
>
</
div
>
</
div
>
...
...
@@ -202,6 +221,16 @@ export const ReceiptScannerView: React.FC = () => {
{
fileError
}
</
p
>
)
}
{
previewUrl
&&
(
<
div
className=
"mt-3 overflow-hidden rounded-clay bg-clay-bg p-3 shadow-clay-pressed border border-clay-highlight/30"
>
<
img
src=
{
previewUrl
}
alt=
{
t
(
"ai.ocr.previewAlt"
)
}
className=
"mx-auto max-h-64 w-full rounded-clay-sm object-contain"
/>
</
div
>
)
}
</
Card
>
{
/* Extracting Loading Indicator */
}
...
...
@@ -233,7 +262,10 @@ export const ReceiptScannerView: React.FC = () => {
{
t
(
"ai.ocr.resultTitle"
)
}
</
h4
>
<
span
className=
{
`shrink-0 rounded-full border px-3 py-1 font-baloo text-xs font-bold shadow-clay-pressed ${confidenceClassName}`
}
>
{
t
(
"ai.ocr.confidence"
)
}
:
{
Math
.
round
(
extractedData
.
confidence
*
100
)
}
%
{
t
(
"ai.ocr.confidence"
)
}
:
{
formatNumber
(
extractedData
.
confidence
,
{
style
:
"percent"
,
maximumFractionDigits
:
0
,
})
}
</
span
>
</
div
>
...
...
@@ -252,8 +284,7 @@ export const ReceiptScannerView: React.FC = () => {
{
t
(
"ai.ocr.totalAmount"
)
}
</
span
>
<
span
className=
"break-words font-baloo text-base font-bold text-clay-expense"
>
{
extractedData
.
totalAmount
||
"0"
}{
" "
}
{
extractedData
.
currency
||
"VND"
}
{
formatReceiptMoney
(
extractedData
.
totalAmount
)
}
</
span
>
</
div
>
...
...
@@ -262,7 +293,7 @@ export const ReceiptScannerView: React.FC = () => {
{
t
(
"ai.ocr.date"
)
}
</
span
>
<
span
className=
"font-nunito text-sm font-bold text-clay-text"
>
{
extractedData
.
transactionDate
||
"—"
}
{
formattedTransactionDate
}
</
span
>
</
div
>
...
...
@@ -280,8 +311,7 @@ export const ReceiptScannerView: React.FC = () => {
{
t
(
"ai.ocr.taxAmount"
)
}
</
span
>
<
span
className=
"break-words font-baloo text-sm font-bold text-clay-text"
>
{
extractedData
.
taxAmount
||
"—"
}
{
extractedData
.
taxAmount
?
` ${extractedData.currency || "VND"}`
:
""
}
{
formatReceiptMoney
(
extractedData
.
taxAmount
)
}
</
span
>
</
div
>
</
div
>
...
...
@@ -306,9 +336,17 @@ export const ReceiptScannerView: React.FC = () => {
{
extractedData
.
lineItems
.
map
((
item
,
i
)
=>
(
<
tr
key=
{
i
}
>
<
td
className=
"py-2 text-clay-text font-medium"
>
{
item
.
name
}
</
td
>
<
td
className=
"py-2 text-center text-clay-text-muted"
>
{
item
.
quantity
??
1
}
</
td
>
<
td
className=
"py-2 text-right text-clay-text-muted"
>
{
item
.
unitPrice
||
"—"
}
</
td
>
<
td
className=
"py-2 text-right font-bold text-clay-text"
>
{
item
.
totalAmount
||
"—"
}
</
td
>
<
td
className=
"py-2 text-center text-clay-text-muted"
>
{
item
.
quantity
===
null
?
"—"
:
formatNumber
(
item
.
quantity
,
{
maximumFractionDigits
:
2
})
}
</
td
>
<
td
className=
"py-2 text-right text-clay-text-muted"
>
{
formatReceiptMoney
(
item
.
unitPrice
)
}
</
td
>
<
td
className=
"py-2 text-right font-bold text-clay-text"
>
{
formatReceiptMoney
(
item
.
totalAmount
)
}
</
td
>
</
tr
>
))
}
</
tbody
>
...
...
src/pages/transactions/components/TransactionDetailModal.tsx
View file @
18d622d4
...
...
@@ -37,6 +37,8 @@ export const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
const
[
receiptType
,
setReceiptType
]
=
useState
<
string
|
null
>
(
null
);
const
[
isReceiptLoading
,
setIsReceiptLoading
]
=
useState
(
false
);
const
[
receiptError
,
setReceiptError
]
=
useState
(
false
);
const
[
hasReceipt
,
setHasReceipt
]
=
useState
(
false
);
const
[
receiptRevision
,
setReceiptRevision
]
=
useState
(
0
);
// Receipt mutations
const
uploadReceiptMutation
=
useUploadReceipt
(
transaction
?.
id
||
""
);
...
...
@@ -45,13 +47,17 @@ export const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
useEffect
(()
=>
{
if
(
isOpen
)
{
setShowConfirmDelete
(
false
);
setHasReceipt
(
Boolean
(
transaction
?.
receiptUrl
));
setReceiptRevision
(
0
);
}
},
[
isOpen
]);
},
[
isOpen
,
transaction
?.
id
]);
useEffect
(()
=>
{
if
(
!
isOpen
||
!
transaction
?.
receiptUrl
)
{
if
(
!
isOpen
||
!
transaction
||
!
hasReceipt
)
{
setReceiptObjectURL
(
null
);
setReceiptType
(
null
);
setIsReceiptLoading
(
false
);
setReceiptError
(
false
);
return
;
}
...
...
@@ -82,7 +88,7 @@ export const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
URL
.
revokeObjectURL
(
objectUrl
);
}
};
},
[
transaction
?.
id
,
transaction
?.
receiptUrl
,
isOpe
n
]);
},
[
transaction
?.
id
,
hasReceipt
,
isOpen
,
receiptRevisio
n
]);
if
(
!
transaction
)
return
null
;
...
...
@@ -97,6 +103,8 @@ export const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
uploadReceiptMutation
.
mutate
(
file
,
{
onSuccess
:
()
=>
{
setHasReceipt
(
true
);
setReceiptRevision
((
revision
)
=>
revision
+
1
);
openSnackbar
({
type
:
"success"
,
text
:
t
(
"transaction.receiptUploadSuccess"
)
});
},
onError
:
(
error
)
=>
{
...
...
@@ -111,6 +119,7 @@ export const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
const
handleDeleteReceipt
=
()
=>
{
deleteReceiptMutation
.
mutate
(
undefined
,
{
onSuccess
:
()
=>
{
setHasReceipt
(
false
);
openSnackbar
({
type
:
"success"
,
text
:
t
(
"transaction.receiptDeleteSuccess"
)
});
},
onError
:
(
error
)
=>
{
...
...
@@ -274,7 +283,7 @@ export const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
disabled=
{
uploadReceiptMutation
.
isPending
}
/>
{
transaction
.
receiptUrl
&&
!
isReceiptLoading
&&
(
{
hasReceipt
&&
!
isReceiptLoading
&&
(
<
div
className=
"flex items-center gap-2"
>
<
button
type=
"button"
...
...
src/pages/transactions/components/TransactionFormModal.tsx
View file @
18d622d4
...
...
@@ -134,6 +134,7 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
const
{
t
,
intlLocale
}
=
useI18n
();
const
fileInputRef
=
useRef
<
HTMLInputElement
>
(
null
);
const
[
selectedFile
,
setSelectedFile
]
=
useState
<
File
|
null
>
(
initialReceiptFile
||
null
);
const
[
selectedFilePreviewUrl
,
setSelectedFilePreviewUrl
]
=
useState
<
string
|
null
>
(
null
);
const
[
fileError
,
setFileError
]
=
useState
<
string
|
null
>
(
null
);
const
[
deleteCurrentReceipt
,
setDeleteCurrentReceipt
]
=
useState
(
false
);
...
...
@@ -198,6 +199,17 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
}
},
[
defaultValues
,
initialReceiptFile
,
isOpen
,
reset
]);
useEffect
(()
=>
{
if
(
!
selectedFile
?.
type
.
startsWith
(
"image/"
))
{
setSelectedFilePreviewUrl
(
null
);
return
;
}
const
objectUrl
=
URL
.
createObjectURL
(
selectedFile
);
setSelectedFilePreviewUrl
(
objectUrl
);
return
()
=>
URL
.
revokeObjectURL
(
objectUrl
);
},
[
selectedFile
]);
const
selectedType
=
watch
(
"type"
)
as
TransactionType
;
const
selectedWalletId
=
watch
(
"walletId"
);
const
selectedCategoryId
=
watch
(
"categoryId"
);
...
...
@@ -466,24 +478,47 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
{
selectedFile
?
(
/* User selected a new file locally */
<
div
className=
"flex items-center justify-between rounded-clay bg-clay-primary/10 p-3 shadow-clay-pressed border border-clay-primary/20"
>
<
div
className=
"flex flex-col min-w-0 pr-2"
>
<
span
className=
"font-nunito text-sm font-bold text-clay-text truncate"
>
<
div
className=
"flex flex-col gap-3 rounded-clay bg-clay-primary/10 p-3 shadow-clay-pressed border border-clay-primary/20"
>
<
div
className=
"flex items-center justify-between"
>
<
div
className=
"flex min-w-0 items-center gap-3 pr-2"
>
{
selectedFilePreviewUrl
?
(
<
img
src=
{
selectedFilePreviewUrl
}
alt=
{
t
(
"transaction.receipt"
)
}
className=
"h-12 w-12 shrink-0 rounded-clay-sm border border-clay-primary/20 object-cover"
/>
)
:
(
<
div
className=
"flex h-12 w-12 shrink-0 items-center justify-center rounded-clay-sm bg-clay-primary/10 font-baloo text-xs font-bold text-clay-primary shadow-clay-pressed"
>
PDF
</
div
>
)
}
<
div
className=
"flex min-w-0 flex-col"
>
<
span
className=
"truncate font-nunito text-sm font-bold text-clay-text"
>
{
selectedFile
.
name
}
</
span
>
<
span
className=
"clay-caption"
>
{
(
selectedFile
.
size
/
1024
/
1024
).
toFixed
(
2
)
}
MB
</
span
>
</
div
>
</
div
>
<
button
type=
"button"
className=
"flex h-8 w-8 items-center justify-center rounded-full bg-clay-expense text-white shadow-clay-raised hover:scale-105 transition-all duration-200"
aria
-
label=
{
t
(
"common.delete"
)
}
className=
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-clay-expense text-white shadow-clay-raised hover:scale-105 transition-all duration-200"
onClick=
{
removeSelectedFile
}
disabled=
{
isSubmitting
}
>
✕
</
button
>
</
div
>
{
selectedFilePreviewUrl
&&
(
<
img
src=
{
selectedFilePreviewUrl
}
alt=
{
t
(
"transaction.receipt"
)
}
className=
"max-h-56 w-full rounded-clay-sm border border-clay-highlight/20 object-contain"
/>
)
}
</
div
>
)
:
transaction
?.
receiptUrl
&&
!
deleteCurrentReceipt
?
(
/* Transaction already has a receipt, show replace or delete controls */
<
div
className=
"flex items-center justify-between rounded-clay bg-clay-bg p-3 shadow-clay-pressed border border-clay-highlight/30"
>
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment