Commit db43d70d authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/move-receipt-scanner-and-refine-reports' into 'develop'

fix: move receipt scanner to transactions and refine reports

See merge request !27
parents 6e2cedba c544869c
......@@ -17,6 +17,7 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- Bóng đổ kép (dual-tone soft shadow) được định nghĩa qua các token: `shadow-clay-raised`, `shadow-clay-hover`, `shadow-clay-pressed`.
- Sử dụng hai phông chữ: `Baloo 2` (Heading & Số tiền) và `Nunito` (Nội dung chính).
- Bo góc tối thiểu 16px (`rounded-clay-sm`), tiêu chuẩn 24px (`rounded-clay`), và lớn 32px (`rounded-clay-lg`).
- **Vùng an toàn cho bóng button**: `shadow-clay-raised` lan ra ngoài button khoảng 8–16px. Khi đặt button trong modal, vùng `overflow` hoặc nhóm nhiều button, phải chừa padding quanh mép và gap đủ lớn để bóng không bị cắt hoặc chồng lên nhau. Không khai báo lại `shadow-clay-raised` trong `className` khi base `Button` đã cung cấp bóng theo variant; vùng nút cuối nội dung cuộn cần có padding đáy riêng.
- **Nguồn tài nguyên Fonts**: Load thông qua thẻ `<link>` của Google Fonts trực tiếp trong `index.html` để tối ưu thời gian tải trang.
- **Phong cách Icon**: Tự thiết kế các inline SVG dạng blob dày, tròn trịa, nhiều màu sắc pastel thay vì dùng icon nét mảnh phẳng thông thường.
- **Hệ thống Light/Dark Theme**: Màu nền, surface, chữ, border, trạng thái và bóng Claymorphism phải đi qua semantic CSS variables được ánh xạ trong `tailwind.config.js`; không gắn màu light-only trực tiếp trong component. Lựa chọn `light`/`dark` được lưu cục bộ bằng Zustand, áp dụng `data-theme` lên `<html>` và đồng bộ `zaui-theme` lên cả `<html>` lẫn `<body>` vì stylesheet của ZaUI dùng selector `body[zaui-theme]`; đồng thời mọi màn hình dùng toggle chung để chuyển đổi nhất quán.
......
......@@ -879,6 +879,7 @@
"transaction": {
"header": "Transaction Management",
"create": "Create transaction",
"scanReceipt": "Scan receipt",
"edit": "Edit transaction",
"detail": "Transaction Details",
"amount": "Amount",
......@@ -1014,6 +1015,7 @@
"net": "Net",
"expenseTrend": "Latest period expense: {{value}}",
"chartAria": "Cash-flow chart in {{currency}}",
"verticalAxis": "Height: income/expense amount ({{currency}})",
"totalIncome": "Total income",
"totalExpense": "Total expense",
"totalNet": "Net cash flow",
......@@ -1247,12 +1249,11 @@
"header": "FinWise AI Financial Assistant",
"shortHeader": "AI Assistant",
"liveBadge": "Live AI",
"subtitle": "Smart analysis, spending advice & OCR receipt extraction",
"subtitle": "Smart analysis and personal finance guidance",
"tabs": {
"label": "AI Assistant features",
"chat": "Advisor",
"insights": "Insights",
"ocr": "Receipt"
"insights": "Insights"
},
"chat": {
"title": "FinWise AI Advisor",
......
......@@ -913,6 +913,7 @@
"transaction": {
"header": "Quản lý giao dịch",
"create": "Tạo giao dịch",
"scanReceipt": "Quét hóa đơn",
"edit": "Chỉnh sửa giao dịch",
"detail": "Chi tiết giao dịch",
"amount": "Số tiền",
......@@ -1048,6 +1049,7 @@
"net": "Ròng",
"expenseTrend": "Chi tiêu kỳ gần nhất: {{value}}",
"chartAria": "Biểu đồ dòng tiền theo {{currency}}",
"verticalAxis": "Chiều cao: số tiền thu/chi ({{currency}})",
"totalIncome": "Tổng thu",
"totalExpense": "Tổng chi",
"totalNet": "Dòng tiền ròng",
......@@ -1348,12 +1350,11 @@
"header": "Trợ lý Tài chính FinWise AI",
"shortHeader": "Trợ lý AI",
"liveBadge": "AI trực tiếp",
"subtitle": "Phân tích thông minh, tư vấn chi tiêu & trích xuất hóa đơn OCR",
"subtitle": "Phân tích thông minh và tư vấn tài chính cá nhân",
"tabs": {
"label": "Các chức năng của Trợ lý AI",
"chat": "Tư vấn",
"insights": "Phân tích",
"ocr": "Hóa đơn"
"insights": "Phân tích"
},
"chat": {
"title": "FinWise AI Advisor",
......
......@@ -4,7 +4,7 @@ export interface FinWiseNavigationState {
fromNotifications?: boolean;
fromAIRecommendations?: boolean;
fromReports?: boolean;
tab?: "chat" | "insights" | "ocr";
tab?: "chat" | "insights";
insightsTab?: "insights" | "recommendations";
analysisScope?: AIAnalysisScope;
}
......
import React, { useEffect, useRef, useState } from "react";
import api from "zmp-sdk";
import { useNavigate } from "zmp-ui";
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();
export interface ReceiptTransactionPrefill {
amount: string;
type: "EXPENSE";
categoryId: string;
date: string;
description: string;
location: string;
}
interface ReceiptScannerViewProps {
onTransactionPrefill: (prefill: ReceiptTransactionPrefill, receiptFile: File | null) => void;
}
export const ReceiptScannerView: React.FC<ReceiptScannerViewProps> = ({ onTransactionPrefill }) => {
const { formatCurrency, formatNumber, intlLocale, t } = useI18n();
const extractMutation = useExtractReceipt();
......@@ -102,12 +112,7 @@ export const ReceiptScannerView: React.FC = () => {
location: extractedData.merchant || "",
};
navigate("/transactions", {
state: {
prefill: prefillData,
receiptFile: selectedFile,
},
});
onTransactionPrefill(prefillData, selectedFile);
};
const resetScan = () => {
......@@ -143,12 +148,8 @@ export const ReceiptScannerView: React.FC = () => {
: "—";
return (
<div className="flex flex-col gap-4 pb-8">
{/* Upload Header Card */}
<Card className="p-5 text-center border border-clay-highlight/40">
<h3 className="mb-1 text-center font-baloo text-lg font-bold leading-tight text-clay-text">
{t("ai.ocr.title")}
</h3>
<div className="flex flex-col gap-5 px-2 pb-4 pt-2">
<section className="text-center">
<p className="clay-caption max-w-sm mx-auto mb-4">{t("ai.ocr.subtitle")}</p>
<input
......@@ -161,7 +162,7 @@ export const ReceiptScannerView: React.FC = () => {
/>
{!selectedFile ? (
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
<div className="flex flex-col items-center justify-center gap-5 px-2 py-2 sm:flex-row">
<Button
variant="primary"
className="w-full px-5 py-2.5 text-xs font-bold sm:w-auto"
......@@ -231,16 +232,16 @@ export const ReceiptScannerView: React.FC = () => {
/>
</div>
)}
</Card>
</section>
{/* Extracting Loading Indicator */}
{extractMutation.isPending && (
<Card className="p-8 text-center animate-pulse border border-clay-primary/30">
<div className="animate-pulse rounded-clay bg-clay-primary/10 p-6 text-center">
<h4 className="font-baloo text-base font-bold text-clay-primary mb-1">
{t("ai.ocr.extracting")}
</h4>
<p className="clay-caption text-xs">{t("ai.ocr.extractingHint")}</p>
</Card>
</div>
)}
{/* Extraction Error */}
......@@ -255,8 +256,7 @@ export const ReceiptScannerView: React.FC = () => {
{/* OCR Result View */}
{extractedData && (
<div className="flex flex-col gap-4">
{/* Main Attributes Card */}
<Card className="p-5 border-2 border-clay-primary/30 relative">
<section className="relative border-t border-clay-highlight/40 pt-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<h4 className="min-w-0 font-baloo text-base font-bold text-clay-text">
{t("ai.ocr.resultTitle")}
......@@ -379,17 +379,17 @@ export const ReceiptScannerView: React.FC = () => {
)}
{/* Confirm Create Transaction Action */}
<div className="mt-5 pt-3 border-t border-clay-highlight/40 flex flex-col sm:flex-row items-center justify-between gap-3">
<div className="mt-5 flex flex-col items-center justify-between gap-4 border-t border-clay-highlight/40 px-2 pb-2 pt-4 sm:flex-row">
<span className="clay-caption text-xs text-left">{t("ai.ocr.confirmHint")}</span>
<Button
variant="primary"
className="w-full px-6 py-2.5 text-xs font-bold shadow-clay-raised sm:w-auto"
className="w-full px-6 py-2.5 text-xs font-bold sm:w-auto"
onClick={handleConfirmTransaction}
>
{t("ai.ocr.confirmTransaction")}
</Button>
</div>
</Card>
</section>
</div>
)}
</div>
......
......@@ -9,9 +9,8 @@ import { useI18n } from "@/i18n";
import { FinWiseNavigationState, isFromReports } from "@/lib/navigation-state";
import { AIChatView } from "./components/AIChatView";
import { AIInsightsView } from "./components/AIInsightsView";
import { ReceiptScannerView } from "./components/ReceiptScannerView";
type AITab = "chat" | "insights" | "ocr";
type AITab = "chat" | "insights";
const AIAssistantPage: React.FC = () => {
const navigate = useNavigate();
......@@ -19,7 +18,7 @@ const AIAssistantPage: React.FC = () => {
const { t } = useI18n();
const navigationState = location.state as FinWiseNavigationState | null;
const initialTab: AITab = navigationState?.tab || "chat";
const initialTab: AITab = navigationState?.tab === "insights" ? "insights" : "chat";
const [activeTab, setActiveTab] = useState<AITab>(initialTab);
const [visitedTabs, setVisitedTabs] = useState<Set<AITab>>(() => new Set([initialTab]));
......@@ -76,7 +75,6 @@ const AIAssistantPage: React.FC = () => {
[
{ key: "chat", label: t("ai.tabs.chat") },
{ key: "insights", label: t("ai.tabs.insights") },
{ key: "ocr", label: t("ai.tabs.ocr") },
] as const
).map((tab) => (
<button
......@@ -123,16 +121,6 @@ const AIAssistantPage: React.FC = () => {
/>
</div>
)}
{visitedTabs.has("ocr") && (
<div
id="ai-panel-ocr"
className={activeTab === "ocr" ? "mt-1" : "hidden"}
role="tabpanel"
aria-labelledby="ai-tab-ocr"
>
<ReceiptScannerView />
</div>
)}
</main>
</Page>
);
......
......@@ -17,7 +17,7 @@ export const CashFlowChart: React.FC<CashFlowChartProps> = ({ report, currency }
const { t, formatCurrency, formatDate, formatNumber } = useI18n();
const width = 360;
const height = 190;
const padding = { left: 12, right: 12, top: 18, bottom: 28 };
const padding = { left: 54, right: 12, top: 18, bottom: 28 };
const plotWidth = width - padding.left - padding.right;
const plotHeight = height - padding.top - padding.bottom;
const points = report.series.map((bucket) => {
......@@ -40,6 +40,10 @@ export const CashFlowChart: React.FC<CashFlowChartProps> = ({ report, currency }
const expenseTrend = previous > 0 ? ((latest - previous) / previous) * 100 : null;
const labelIndexes = Array.from(new Set([0, Math.floor((points.length - 1) / 2), Math.max(0, points.length - 1)]));
const compactMoney = (value: number) => formatCurrency(value, currency);
const compactAxisValue = (value: number) => formatNumber(value, {
notation: "compact",
maximumFractionDigits: 1,
});
return (
<Card className="overflow-hidden p-5">
......@@ -62,10 +66,21 @@ export const CashFlowChart: React.FC<CashFlowChartProps> = ({ report, currency }
</div>
<div className="mt-3 rounded-clay bg-clay-bg p-2 shadow-clay-pressed">
<p className="mb-1 px-1 font-nunito text-[11px] font-bold text-clay-text-muted">
{t("report.cashFlow.verticalAxis", { currency })}
</p>
<svg viewBox={`0 0 ${width} ${height}`} className="h-auto w-full" role="img" aria-label={t("report.cashFlow.chartAria", { currency })}>
{[0, 0.5, 1].map((ratio) => {
const y = padding.top + plotHeight * ratio;
return <line key={ratio} x1={padding.left} x2={width - padding.right} y1={y} y2={y} stroke="rgb(var(--color-clay-border))" strokeWidth="1" strokeDasharray="4 5" />;
const value = maxValue * (1 - ratio);
return (
<g key={ratio}>
<text x={padding.left - 7} y={y + 3} textAnchor="end" fill="rgb(var(--color-clay-text-muted))" fontSize="9" fontFamily="Nunito">
{compactAxisValue(value)}
</text>
<line x1={padding.left} x2={width - padding.right} y1={y} y2={y} stroke="rgb(var(--color-clay-border))" strokeWidth="1" strokeDasharray="4 5" />
</g>
);
})}
{points.length > 0 && (
<>
......
import React from "react";
import { Card } from "@/components/ui/Card";
import { ProgressBar } from "@/components/ui/ProgressBar";
import { useI18n } from "@/i18n";
import { BudgetTypeSummary, FinancialMetric, ReportWallet } from "@/types/report";
import { FinancialMetric, ReportWallet } from "@/types/report";
interface OverviewMetricsProps {
metric: FinancialMetric;
wallets: ReportWallet[];
budgetSummaries: BudgetTypeSummary[];
}
const safeNumber = (value: string | null | undefined): number => {
......@@ -15,33 +13,9 @@ const safeNumber = (value: string | null | undefined): number => {
return Number.isFinite(parsed) ? parsed : 0;
};
export const OverviewMetrics: React.FC<OverviewMetricsProps> = ({ metric, wallets, budgetSummaries }) => {
const { t, formatCurrency, formatNumber } = useI18n();
export const OverviewMetrics: React.FC<OverviewMetricsProps> = ({ metric, wallets }) => {
const { t, formatCurrency } = useI18n();
const money = (value: string) => formatCurrency(safeNumber(value), metric.currency);
const availableBudgetSummaries = budgetSummaries.filter(
(summary) => summary.currency === metric.currency && summary.budgetCount > 0
);
const budget = availableBudgetSummaries.find((summary) => summary.type === "OVERALL")
|| availableBudgetSummaries[0];
const usage = safeNumber(budget?.usagePercentage);
const budgetType = usage > 100 ? "expense" : usage >= 80 ? "warning" : "income";
const budgetStatuses = budget ? [
{
key: "on_track",
count: budget.onTrackCount,
tone: "bg-clay-income/15 text-clay-income",
},
{
key: "near_limit",
count: budget.nearLimitCount,
tone: "bg-clay-warning/15 text-clay-warning",
},
{
key: "exceeded",
count: budget.exceededCount,
tone: "bg-clay-expense/15 text-clay-expense",
},
] as const : [];
const metricCards = [
{ key: "balance", label: t("report.metrics.balance"), value: money(metric.currentBalance), tone: "bg-clay-info/15 text-clay-info", symbol: "=" },
{ key: "income", label: t("report.metrics.income"), value: money(metric.income), tone: "bg-clay-income/15 text-clay-income", symbol: "↑" },
......@@ -68,78 +42,6 @@ export const OverviewMetrics: React.FC<OverviewMetricsProps> = ({ metric, wallet
))}
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<Card className="p-5">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="clay-title-h3">{t("report.metrics.budgetUsage")}</h3>
<p className="clay-caption">{budget ? t("report.metrics.budgetCount", { count: budget.budgetCount }) : t("report.metrics.noBudget")}</p>
</div>
<span className={`rounded-full px-3 py-1 font-baloo text-sm font-bold ${budget ? "bg-clay-warning/15 text-clay-text" : "bg-clay-bg text-clay-text-muted"}`}>
{budget ? `${formatNumber(usage, { maximumFractionDigits: 1 })}%` : "—"}
</span>
</div>
<ProgressBar value={usage} type={budgetType} className="mt-4" />
{budget && (
<div className="mt-3 space-y-3">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
<div className="min-w-0 rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed">
<p className="clay-caption">{t("report.metrics.spent")}</p>
<p className="mt-1 break-words font-baloo text-sm font-bold text-clay-expense">
{money(budget.spentAmount)}
</p>
</div>
<div className="min-w-0 rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed">
<p className="clay-caption">{t("report.metrics.remaining")}</p>
<p className={`mt-1 break-words font-baloo text-sm font-bold ${safeNumber(budget.remainingAmount) >= 0 ? "text-clay-income" : "text-clay-expense"}`}>
{money(budget.remainingAmount)}
</p>
</div>
<div className="col-span-2 min-w-0 rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed sm:col-span-1">
<p className="clay-caption">{t("report.metrics.limit")}</p>
<p className="mt-1 break-words font-baloo text-sm font-bold text-clay-text">
{money(budget.budgetAmount)}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2" aria-label={t("report.metrics.budgetStatuses")}>
{budgetStatuses.map((status) => (
<span
key={status.key}
className={`rounded-full px-2.5 py-1 font-nunito text-[11px] font-bold ${status.tone}`}
>
{t(`report.budgets.status.${status.key}`)}: {formatNumber(status.count)}
</span>
))}
</div>
</div>
)}
</Card>
<Card className="p-5">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="clay-title-h3">{t("report.metrics.financialHealth")}</h3>
<p className="clay-caption">{t("report.metrics.healthHint")}</p>
</div>
<span className="rounded-full bg-clay-primary/15 px-3 py-1 font-baloo text-sm font-bold text-clay-primary">
{metric.savingsRate === null ? "—" : `${formatNumber(safeNumber(metric.savingsRate), { maximumFractionDigits: 1 })}%`}
</span>
</div>
<div className="mt-4 grid grid-cols-2 gap-3">
<div className="rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed">
<p className="clay-caption">{t("report.metrics.savingsRate")}</p>
<p className="mt-1 font-baloo text-lg font-bold text-clay-text">{metric.savingsRate === null ? "—" : `${formatNumber(safeNumber(metric.savingsRate), { maximumFractionDigits: 1 })}%`}</p>
</div>
<div className="rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed">
<p className="clay-caption">{t("report.metrics.transactions")}</p>
<p className="mt-1 font-baloo text-lg font-bold text-clay-text">{formatNumber(metric.transactionCount)}</p>
</div>
</div>
</Card>
</div>
{wallets.length > 1 && (
<Card className="p-5">
<div className="mb-4 flex items-center justify-between gap-3">
......
......@@ -191,7 +191,7 @@ const ReportsPage: React.FC = () => {
{queryEnabled && overviewQuery.isLoading && <ReportSectionSkeleton rows={4} />}
{queryEnabled && overviewQuery.isError && <ReportErrorState onRetry={() => void overviewQuery.refetch()} />}
{queryEnabled && overview && metric && (
<OverviewMetrics metric={metric} wallets={currencyWallets} budgetSummaries={overview.budgets.byType} />
<OverviewMetrics metric={metric} wallets={currencyWallets} />
)}
{queryEnabled && overview && !metric && <ReportEmptyState />}
......
......@@ -4,10 +4,13 @@ import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { Modal } from "@/components/ui/Modal";
import { Select } from "@/components/ui/Select";
import { CategoryArtwork } from "@/components/shared/CategoryArtwork";
import { LocalizedDateInput } from "@/components/shared/LocalizedDateInput";
import { IconGradients, PlusIcon, TransactionIcon } from "@/components/ui/icons";
import { PermissionGate } from "@/components/shared/PermissionGate";
import { CameraIcon, IconGradients, PlusIcon, TransactionIcon } from "@/components/ui/icons";
import { PERMISSIONS } from "@/common/constants";
import { useI18n } from "@/i18n";
import { formatWalletBalance } from "@/lib/wallet-format";
import { getCategoryDisplayName } from "@/lib/category-format";
......@@ -34,6 +37,7 @@ import { CategoryTreeNode } from "@/types/category";
import { PrefilledTransactionData, TransactionFormModal } from "./components/TransactionFormModal";
import { TransactionDetailModal } from "./components/TransactionDetailModal";
import { TransactionSkeleton } from "./components/TransactionSkeleton";
import { ReceiptScannerView } from "../ai-assistant/components/ReceiptScannerView";
import { transactionService } from "@/services/transaction.service";
import { addCalendarDays, instantToBusinessDate, todayInBusinessTime } from "@/lib/business-time";
......@@ -143,6 +147,7 @@ const TransactionsPage: React.FC = () => {
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [isEditOpen, setIsEditOpen] = useState(false);
const [isDetailOpen, setIsDetailOpen] = useState(false);
const [isReceiptScannerOpen, setIsReceiptScannerOpen] = useState(false);
// Selected transactions
const [selectedTransaction, setSelectedTransaction] = useState<Transaction | undefined>(undefined);
......@@ -512,19 +517,31 @@ const TransactionsPage: React.FC = () => {
</Card>
{/* Title area & Create button */}
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="clay-title-h2">{t("document.transactions")}</h1>
<p className="clay-caption">{t("common.results", { count: totalItems })}</p>
</div>
<div className="flex w-full gap-4 py-2 sm:w-auto">
<PermissionGate permission={PERMISSIONS.AI_ASSISTANT_USE}>
<Button
variant="secondary"
shape="pill"
className="flex-1 gap-2 px-4 text-sm sm:flex-none"
onClick={() => setIsReceiptScannerOpen(true)}
>
<CameraIcon size={18} /> {t("transaction.scanReceipt")}
</Button>
</PermissionGate>
<Button
shape="pill"
className="gap-2 px-4 text-sm"
className="flex-1 gap-2 px-4 text-sm sm:flex-none"
onClick={() => setIsCreateOpen(true)}
>
<PlusIcon size={18} /> {t("transaction.create")}
</Button>
</div>
</div>
{/* Search and Filters panel */}
<Card className="flex flex-col gap-3 p-4">
......@@ -810,6 +827,21 @@ const TransactionsPage: React.FC = () => {
)}
</main>
<Modal
isOpen={isReceiptScannerOpen}
onClose={() => setIsReceiptScannerOpen(false)}
title={t("ai.ocr.title")}
>
<ReceiptScannerView
onTransactionPrefill={(prefill, receiptFile) => {
setIsReceiptScannerOpen(false);
setPrefilledData(prefill);
setPrefilledFile(receiptFile);
setIsCreateOpen(true);
}}
/>
</Modal>
{/* Transaction Details Modal */}
<TransactionDetailModal
isOpen={isDetailOpen}
......
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