Commit f2184b00 authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/receipt-preview-and-localization' into 'develop'

fix(receipts): improve previews and localized scan results

See merge request !20
parents 7cb89e5e 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
......
......@@ -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"
......
......@@ -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>
......
......@@ -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, isOpen]);
}, [transaction?.id, hasReceipt, isOpen, receiptRevision]);
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"
......
......@@ -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">
......
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