Commit 18d622d4 authored by ThinhNC's avatar ThinhNC

fix(receipts): improve previews and localized scan results

parent 6f17fdd2
...@@ -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 ...@@ -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 - **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 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. 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 - **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. đượ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 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
......
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta <meta
http-equiv="Content-Security-Policy" 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 <meta
name="viewport" name="viewport"
......
...@@ -5,12 +5,13 @@ import { Button } from "@/components/ui/Button"; ...@@ -5,12 +5,13 @@ import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { useExtractReceipt } from "@/hooks/use-ai-assistant"; import { useExtractReceipt } from "@/hooks/use-ai-assistant";
import { formatBusinessDate } from "@/lib/business-time";
import { ExtractReceiptData } from "@/types/ai"; import { ExtractReceiptData } from "@/types/ai";
import { AIErrorState } from "./AIErrorState"; import { AIErrorState } from "./AIErrorState";
export const ReceiptScannerView: React.FC = () => { export const ReceiptScannerView: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useI18n(); const { formatCurrency, formatNumber, intlLocale, t } = useI18n();
const extractMutation = useExtractReceipt(); const extractMutation = useExtractReceipt();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
...@@ -20,10 +21,15 @@ export const ReceiptScannerView: React.FC = () => { ...@@ -20,10 +21,15 @@ export const ReceiptScannerView: React.FC = () => {
const [fileError, setFileError] = useState<string | null>(null); const [fileError, setFileError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
return () => { if (!selectedFile?.type.startsWith("image/")) {
if (previewUrl) URL.revokeObjectURL(previewUrl); setPreviewUrl(null);
}; return;
}, [previewUrl]); }
const objectUrl = URL.createObjectURL(selectedFile);
setPreviewUrl(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
}, [selectedFile]);
const handleFileSelected = (file: File) => { const handleFileSelected = (file: File) => {
extractMutation.reset(); extractMutation.reset();
...@@ -40,11 +46,6 @@ export const ReceiptScannerView: React.FC = () => { ...@@ -40,11 +46,6 @@ export const ReceiptScannerView: React.FC = () => {
} }
setSelectedFile(file); setSelectedFile(file);
if (file.type.startsWith("image/")) {
setPreviewUrl(URL.createObjectURL(file));
} else {
setPreviewUrl(null);
}
setExtractedData(null); setExtractedData(null);
// Auto trigger extraction // Auto trigger extraction
...@@ -126,6 +127,21 @@ export const ReceiptScannerView: React.FC = () => { ...@@ -126,6 +127,21 @@ export const ReceiptScannerView: React.FC = () => {
: "border-clay-expense/30 bg-clay-expense/20 text-clay-expense" : "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 ( return (
<div className="flex flex-col gap-4 pb-8"> <div className="flex flex-col gap-4 pb-8">
{/* Upload Header Card */} {/* Upload Header Card */}
...@@ -182,7 +198,10 @@ export const ReceiptScannerView: React.FC = () => { ...@@ -182,7 +198,10 @@ export const ReceiptScannerView: React.FC = () => {
{selectedFile.name} {selectedFile.name}
</span> </span>
<span className="clay-caption text-[10px]"> <span className="clay-caption text-[10px]">
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB {formatNumber(selectedFile.size / 1024 / 1024, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})} MB
</span> </span>
</div> </div>
</div> </div>
...@@ -202,6 +221,16 @@ export const ReceiptScannerView: React.FC = () => { ...@@ -202,6 +221,16 @@ export const ReceiptScannerView: React.FC = () => {
{fileError} {fileError}
</p> </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> </Card>
{/* Extracting Loading Indicator */} {/* Extracting Loading Indicator */}
...@@ -233,7 +262,10 @@ export const ReceiptScannerView: React.FC = () => { ...@@ -233,7 +262,10 @@ export const ReceiptScannerView: React.FC = () => {
{t("ai.ocr.resultTitle")} {t("ai.ocr.resultTitle")}
</h4> </h4>
<span className={`shrink-0 rounded-full border px-3 py-1 font-baloo text-xs font-bold shadow-clay-pressed ${confidenceClassName}`}> <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> </span>
</div> </div>
...@@ -252,8 +284,7 @@ export const ReceiptScannerView: React.FC = () => { ...@@ -252,8 +284,7 @@ export const ReceiptScannerView: React.FC = () => {
{t("ai.ocr.totalAmount")} {t("ai.ocr.totalAmount")}
</span> </span>
<span className="break-words font-baloo text-base font-bold text-clay-expense"> <span className="break-words font-baloo text-base font-bold text-clay-expense">
{extractedData.totalAmount || "0"}{" "} {formatReceiptMoney(extractedData.totalAmount)}
{extractedData.currency || "VND"}
</span> </span>
</div> </div>
...@@ -262,7 +293,7 @@ export const ReceiptScannerView: React.FC = () => { ...@@ -262,7 +293,7 @@ export const ReceiptScannerView: React.FC = () => {
{t("ai.ocr.date")} {t("ai.ocr.date")}
</span> </span>
<span className="font-nunito text-sm font-bold text-clay-text"> <span className="font-nunito text-sm font-bold text-clay-text">
{extractedData.transactionDate || "—"} {formattedTransactionDate}
</span> </span>
</div> </div>
...@@ -280,8 +311,7 @@ export const ReceiptScannerView: React.FC = () => { ...@@ -280,8 +311,7 @@ export const ReceiptScannerView: React.FC = () => {
{t("ai.ocr.taxAmount")} {t("ai.ocr.taxAmount")}
</span> </span>
<span className="break-words font-baloo text-sm font-bold text-clay-text"> <span className="break-words font-baloo text-sm font-bold text-clay-text">
{extractedData.taxAmount || "—"} {formatReceiptMoney(extractedData.taxAmount)}
{extractedData.taxAmount ? ` ${extractedData.currency || "VND"}` : ""}
</span> </span>
</div> </div>
</div> </div>
...@@ -306,9 +336,17 @@ export const ReceiptScannerView: React.FC = () => { ...@@ -306,9 +336,17 @@ export const ReceiptScannerView: React.FC = () => {
{extractedData.lineItems.map((item, i) => ( {extractedData.lineItems.map((item, i) => (
<tr key={i}> <tr key={i}>
<td className="py-2 text-clay-text font-medium">{item.name}</td> <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-center text-clay-text-muted">
<td className="py-2 text-right text-clay-text-muted">{item.unitPrice || "—"}</td> {item.quantity === null
<td className="py-2 text-right font-bold text-clay-text">{item.totalAmount || "—"}</td> ? "—"
: 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> </tr>
))} ))}
</tbody> </tbody>
......
...@@ -37,6 +37,8 @@ export const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({ ...@@ -37,6 +37,8 @@ export const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
const [receiptType, setReceiptType] = useState<string | null>(null); const [receiptType, setReceiptType] = useState<string | null>(null);
const [isReceiptLoading, setIsReceiptLoading] = useState(false); const [isReceiptLoading, setIsReceiptLoading] = useState(false);
const [receiptError, setReceiptError] = useState(false); const [receiptError, setReceiptError] = useState(false);
const [hasReceipt, setHasReceipt] = useState(false);
const [receiptRevision, setReceiptRevision] = useState(0);
// Receipt mutations // Receipt mutations
const uploadReceiptMutation = useUploadReceipt(transaction?.id || ""); const uploadReceiptMutation = useUploadReceipt(transaction?.id || "");
...@@ -45,13 +47,17 @@ export const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({ ...@@ -45,13 +47,17 @@ export const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
setShowConfirmDelete(false); setShowConfirmDelete(false);
setHasReceipt(Boolean(transaction?.receiptUrl));
setReceiptRevision(0);
} }
}, [isOpen]); }, [isOpen, transaction?.id]);
useEffect(() => { useEffect(() => {
if (!isOpen || !transaction?.receiptUrl) { if (!isOpen || !transaction || !hasReceipt) {
setReceiptObjectURL(null); setReceiptObjectURL(null);
setReceiptType(null); setReceiptType(null);
setIsReceiptLoading(false);
setReceiptError(false);
return; return;
} }
...@@ -82,7 +88,7 @@ export const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({ ...@@ -82,7 +88,7 @@ export const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
URL.revokeObjectURL(objectUrl); URL.revokeObjectURL(objectUrl);
} }
}; };
}, [transaction?.id, transaction?.receiptUrl, isOpen]); }, [transaction?.id, hasReceipt, isOpen, receiptRevision]);
if (!transaction) return null; if (!transaction) return null;
...@@ -97,6 +103,8 @@ export const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({ ...@@ -97,6 +103,8 @@ export const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
uploadReceiptMutation.mutate(file, { uploadReceiptMutation.mutate(file, {
onSuccess: () => { onSuccess: () => {
setHasReceipt(true);
setReceiptRevision((revision) => revision + 1);
openSnackbar({ type: "success", text: t("transaction.receiptUploadSuccess") }); openSnackbar({ type: "success", text: t("transaction.receiptUploadSuccess") });
}, },
onError: (error) => { onError: (error) => {
...@@ -111,6 +119,7 @@ export const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({ ...@@ -111,6 +119,7 @@ export const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
const handleDeleteReceipt = () => { const handleDeleteReceipt = () => {
deleteReceiptMutation.mutate(undefined, { deleteReceiptMutation.mutate(undefined, {
onSuccess: () => { onSuccess: () => {
setHasReceipt(false);
openSnackbar({ type: "success", text: t("transaction.receiptDeleteSuccess") }); openSnackbar({ type: "success", text: t("transaction.receiptDeleteSuccess") });
}, },
onError: (error) => { onError: (error) => {
...@@ -274,7 +283,7 @@ export const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({ ...@@ -274,7 +283,7 @@ export const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
disabled={uploadReceiptMutation.isPending} disabled={uploadReceiptMutation.isPending}
/> />
{transaction.receiptUrl && !isReceiptLoading && ( {hasReceipt && !isReceiptLoading && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
type="button" type="button"
......
...@@ -134,6 +134,7 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({ ...@@ -134,6 +134,7 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
const { t, intlLocale } = useI18n(); const { t, intlLocale } = useI18n();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(initialReceiptFile || null); const [selectedFile, setSelectedFile] = useState<File | null>(initialReceiptFile || null);
const [selectedFilePreviewUrl, setSelectedFilePreviewUrl] = useState<string | null>(null);
const [fileError, setFileError] = useState<string | null>(null); const [fileError, setFileError] = useState<string | null>(null);
const [deleteCurrentReceipt, setDeleteCurrentReceipt] = useState(false); const [deleteCurrentReceipt, setDeleteCurrentReceipt] = useState(false);
...@@ -198,6 +199,17 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({ ...@@ -198,6 +199,17 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
} }
}, [defaultValues, initialReceiptFile, isOpen, reset]); }, [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 selectedType = watch("type") as TransactionType;
const selectedWalletId = watch("walletId"); const selectedWalletId = watch("walletId");
const selectedCategoryId = watch("categoryId"); const selectedCategoryId = watch("categoryId");
...@@ -466,23 +478,46 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({ ...@@ -466,23 +478,46 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
{selectedFile ? ( {selectedFile ? (
/* User selected a new file locally */ /* 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 gap-3 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"> <div className="flex items-center justify-between">
<span className="font-nunito text-sm font-bold text-clay-text truncate"> <div className="flex min-w-0 items-center gap-3 pr-2">
{selectedFile.name} {selectedFilePreviewUrl ? (
</span> <img
<span className="clay-caption"> src={selectedFilePreviewUrl}
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB alt={t("transaction.receipt")}
</span> 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"
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> </div>
<button {selectedFilePreviewUrl && (
type="button" <img
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" src={selectedFilePreviewUrl}
onClick={removeSelectedFile} alt={t("transaction.receipt")}
disabled={isSubmitting} className="max-h-56 w-full rounded-clay-sm border border-clay-highlight/20 object-contain"
> />
)}
</button>
</div> </div>
) : transaction?.receiptUrl && !deleteCurrentReceipt ? ( ) : transaction?.receiptUrl && !deleteCurrentReceipt ? (
/* Transaction already has a receipt, show replace or delete controls */ /* Transaction already has a receipt, show replace or delete controls */
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment