Commit 6db4e922 authored by ThinhNC's avatar ThinhNC

feat(transactions): add transaction calculator modal and restore dual account login

parent d8cbd2b9
......@@ -63,12 +63,12 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
## Trạng thái đã biết
- Đã cấu hình React 18, Vite 5, ZMP SDK/UI, Tailwind và bộ base components Claymorphism.
- Đã có các màn hình đăng nhập, đăng ký, quên mật khẩu, đặt lại mật khẩu, trang chủ, hồ sơ và Style Guide.
- Đã có các màn hình đăng nhập, đăng ký, quên mật khẩu, đặt lại mật khẩu, trang chủ, hồ sơ và Style Guide; màn hình đăng nhập kết hợp form tài khoản/mật khẩu với validation Zod, ghi nhớ email và nút đăng nhập Zalo SDK 1-click theo chuẩn Claymorphism.
- Đã kết nối frontend với API xác thực, React Query và Zustand auth store.
- Dev server dùng cổng 13579 cho nội dung app và cổng 13580 cho khung mô phỏng; `index.html` tiếp tục nằm ở root để tránh iframe trả 404 hoặc màn hình đen.
- Module quản lý ví đã có frontend tại `/wallets`, kết nối đầy đủ Wallet API qua TanStack Query, gồm danh sách/chi tiết, tạo/sửa, đặt mặc định, lưu trữ/khôi phục, tìm kiếm, sắp xếp và phân trang. `DELETE /wallets/:id` được thể hiện trong UI là lưu trữ mềm, đúng quy tắc Backend bảo toàn lịch sử.
- Module quản lý danh mục đã có frontend tại `/categories`, kết nối Category API qua TanStack Query, gồm cây cha/con, tạo/sửa, tìm kiếm, lọc loại/nguồn/trạng thái, sắp xếp, lưu trữ và khôi phục; toàn bộ nội dung có bản dịch Việt/Anh.
- Module quản lý giao dịch đã có frontend tại `/transactions`, kết nối Transaction API qua TanStack Query, gồm danh sách lịch sử theo ngày, xem/tạo/sửa/xóa giao dịch, lọc nâng cao, sắp xếp, phân trang và đính kèm hóa đơn.
- Module quản lý giao dịch đã có frontend tại `/transactions`, kết nối Transaction API qua TanStack Query, gồm danh sách lịch sử theo ngày, xem/tạo/sửa/xóa giao dịch, lọc nâng cao, sắp xếp, phân trang, đính kèm hóa đơn và tích hợp nút máy tính tính toán số tiền nhanh (+, −, ×, ÷, 000) ngay giữa Loại giao dịch và Số tiền.
- Module quản lý chuyển tiền đã có frontend tại `/transfers`, dùng contract `GET/POST /transfers``DELETE /transfers/:id` qua TanStack Query; gồm form chọn ví nguồn/đích, validation số dư và hai ví khác nhau, xác nhận trước khi chuyển/xóa, lịch sử có tìm kiếm/lọc/sắp xếp/phân trang và đầy đủ loading/error/empty state. Sau thao tác tạo hoặc xóa, frontend làm mới cache transfer, wallet và transaction để lấy lại số dư do Backend tính toán.
- Module quản lý ngân sách đã có frontend tại `/budgets``/budgets/:id`, kết nối Budget API qua TanStack Query, gồm danh sách/chi tiết, tạo/sửa, archive/restore, tìm kiếm, lọc loại/chu kỳ/danh mục/thời điểm, sắp xếp, phân trang và cảnh báo trực quan theo tỷ lệ sử dụng; đầy đủ validation, loading/error/empty/confirmation và bản dịch Việt/Anh.
- Module mục tiêu tiết kiệm đã có frontend tại `/saving-goals``/saving-goals/:id`, kết nối Saving Goal API qua TanStack Query, gồm danh sách/chi tiết, tạo/sửa, pause/resume, archive/restore, tìm kiếm, lọc trạng thái/thời hạn, sắp xếp, phân trang và quản lý đầy đủ tạo/sửa/xóa lịch sử đóng góp; có validation, loading/error/empty/confirmation và bản dịch Việt/Anh.
......
......@@ -327,3 +327,31 @@ export const CopyIcon: React.FC<IconProps> = ({ size = 16, ...props }) => (
</svg>
);
// Calculator
export const CalculatorIcon: React.FC<IconProps> = ({ size = 24, ...props }) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
filter="url(#clay-3d-shadow)"
{...props}
>
<rect x="4" y="2" width="16" height="20" rx="4" fill="url(#clay-grad-primary)" stroke="rgb(var(--color-clay-primary-dark))" />
<rect x="7" y="5" width="10" height="4" rx="1.5" fill="rgb(var(--color-clay-surface))" stroke="rgb(var(--color-clay-highlight))" strokeWidth="1" />
<circle cx="8.5" cy="12" r="1" fill="rgb(var(--color-clay-text))" stroke="none" />
<circle cx="12" cy="12" r="1" fill="rgb(var(--color-clay-text))" stroke="none" />
<circle cx="15.5" cy="12" r="1" fill="rgb(var(--color-clay-text))" stroke="none" />
<circle cx="8.5" cy="15.5" r="1" fill="rgb(var(--color-clay-text))" stroke="none" />
<circle cx="12" cy="15.5" r="1" fill="rgb(var(--color-clay-text))" stroke="none" />
<circle cx="15.5" cy="15.5" r="1" fill="rgb(var(--color-clay-text))" stroke="none" />
<circle cx="8.5" cy="18.5" r="1" fill="rgb(var(--color-clay-text))" stroke="none" />
<rect x="11" y="17.7" width="5.5" height="1.6" rx="0.8" fill="rgb(var(--color-clay-expense))" stroke="none" />
</svg>
);
......@@ -161,6 +161,9 @@
"success": "Signed in successfully!",
"failed": "Sign-in failed",
"failedDetail": "Sign-in failed. Please check your details and try again.",
"or": "Or",
"account": "Account",
"accountPlaceholder": "Email or phone number",
"feature": {
"wallet": "Manage Wallets",
"report": "Reports",
......@@ -908,6 +911,11 @@
"edit": "Edit transaction",
"detail": "Transaction Details",
"amount": "Amount",
"calculator": "Calculator",
"calculatorTitle": "Transaction Calculator",
"calcDone": "Done",
"calcClear": "Clear All",
"calcDelete": "Delete",
"type": "Transaction Type",
"wallet": "Wallet",
"category": "Category",
......
......@@ -158,9 +158,12 @@
"submit": "Đăng Nhập",
"noAccount": "Chưa có tài khoản?",
"registerNow": "Đăng ký ngay",
"success": "Đăng nhập thành công! 🎉",
"success": "Đăng nhập thành công!",
"failed": "Đăng nhập thất bại",
"failedDetail": "Đăng nhập thất bại, vui lòng kiểm tra lại thông tin.",
"or": "Hoặc",
"account": "Tài khoản",
"accountPlaceholder": "Email hoặc số điện thoại",
"feature": {
"wallet": "Quản lý ví",
"report": "Báo cáo",
......@@ -942,6 +945,11 @@
"edit": "Chỉnh sửa giao dịch",
"detail": "Chi tiết giao dịch",
"amount": "Số tiền",
"calculator": "Máy tính",
"calculatorTitle": "Máy tính giao dịch",
"calcDone": "Xong",
"calcClear": "Xóa tất cả",
"calcDelete": "Xóa lùi",
"type": "Loại giao dịch",
"wallet": "Ví sử dụng",
"category": "Danh mục",
......
import React from "react";
import { Page } from "zmp-ui";
import React, { useMemo, useState } from "react";
import { Page, Header, useNavigate, useSnackbar } from "zmp-ui";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import {
IconGradients,
WalletIcon,
ReportIcon,
AIAssistantIcon,
EyeIcon,
EyeOffIcon,
} from "@/components/ui/icons";
import { useZaloLogin } from "@/hooks/use-zalo-login";
import { useI18n } from "@/i18n";
import { useAuthStore } from "@/stores/auth-store";
import { authService } from "@/services/auth.service";
import { getErrorMessage } from "@/lib/error-message";
import { TranslationFunction, useI18n } from "@/i18n";
const REMEMBERED_EMAIL_KEY = "finwise.rememberedEmail";
const getRememberedEmail = (): string => {
try {
return localStorage.getItem(REMEMBERED_EMAIL_KEY) || "";
} catch {
return "";
}
};
const updateRememberedEmail = (email: string, shouldRemember: boolean): void => {
try {
if (shouldRemember) {
localStorage.setItem(REMEMBERED_EMAIL_KEY, email);
} else {
localStorage.removeItem(REMEMBERED_EMAIL_KEY);
}
} catch {
// Storage can be unavailable in restricted webviews; login should still succeed.
}
};
const createLoginSchema = (t: TranslationFunction) =>
z.object({
email: z.string().min(1, t("validation.emailRequired")),
password: z.string().min(1, t("validation.currentPasswordRequired")),
rememberMe: z.boolean().optional(),
});
type LoginFormValues = z.infer<ReturnType<typeof createLoginSchema>>;
const LoginPage: React.FC = () => {
const { handleZaloLogin, isLoading } = useZaloLogin();
const navigate = useNavigate();
const { openSnackbar } = useSnackbar();
const setAuth = useAuthStore((state) => state.setAuth);
const { handleZaloLogin, isLoading: isZaloLoading } = useZaloLogin();
const { t } = useI18n();
const features = [
{
Icon: WalletIcon,
label: t("auth.login.feature.wallet"),
},
{
Icon: ReportIcon,
label: t("auth.login.feature.report"),
},
{
Icon: AIAssistantIcon,
label: t("auth.login.feature.ai"),
const [isSubmitting, setIsSubmitting] = useState(false);
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [rememberedEmail] = useState(getRememberedEmail);
const loginSchema = useMemo(() => createLoginSchema(t), [t]);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: {
email: rememberedEmail,
password: "",
rememberMe: Boolean(rememberedEmail),
},
];
});
const onSubmit = async (values: LoginFormValues) => {
setIsSubmitting(true);
try {
const response = await authService.login({
email: values.email,
password: values.password,
});
if (response.success && response.data) {
updateRememberedEmail(values.email, Boolean(values.rememberMe));
setAuth(
response.data.user,
response.data.accessToken || "",
response.data.refreshToken || ""
);
openSnackbar({
type: "success",
text: t("auth.login.success"),
});
navigate("/", { replace: true });
} else {
openSnackbar({
type: "error",
text: t("auth.login.failed"),
});
}
} catch (error: unknown) {
openSnackbar({
type: "error",
text: getErrorMessage(error, t("auth.login.failedDetail")),
});
} finally {
setIsSubmitting(false);
}
};
const isBusy = isSubmitting || isZaloLoading;
return (
<Page className="page flex flex-col items-center justify-between py-12 px-6">
<Page className="page flex flex-col justify-start py-6 px-4">
<Header title={t("auth.login.header")} showBackIcon={false} />
<IconGradients />
{/* Top: Logo + branding */}
<div className="flex-1 flex flex-col items-center justify-center gap-8 w-full max-w-sm">
<div className="flex flex-col items-center gap-4">
{/* App icon with Claymorphism */}
<div className="flex h-20 w-20 items-center justify-center rounded-clay-lg bg-clay-primary text-clay-on-primary shadow-clay-hover border border-clay-highlight/30">
<WalletIcon size={40} />
<div className="w-full max-w-sm mx-auto flex flex-col gap-5 pt-1">
{/* Top: Branding */}
<div className="flex flex-col items-center gap-2.5 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-clay-lg bg-clay-primary text-clay-on-primary shadow-clay-hover border border-clay-highlight/30">
<WalletIcon size={34} />
</div>
<div className="text-center space-y-1">
<div className="space-y-1">
<h1 className="clay-title-h1 text-clay-primary">FinWise</h1>
<p className="clay-caption">{t("auth.login.subtitle")}</p>
</div>
</div>
{/* Feature highlights with Claymorphic icons */}
<div className="w-full grid grid-cols-3 gap-3">
{features.map(({ Icon, label }) => (
<div
key={label}
className="flex flex-col items-center gap-2 rounded-clay border border-clay-highlight/40 bg-clay-surface p-3 text-center shadow-clay-raised backdrop-blur-sm transition-all duration-200 ease-in-out hover:shadow-clay-hover hover:-translate-y-0.5"
{/* Card: Login Form */}
<Card className="p-5 flex flex-col gap-4 shadow-clay-raised">
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<Input
id="login-email-input"
label={t("auth.login.account") || t("auth.email")}
placeholder={t("auth.login.accountPlaceholder") || "user@gmail.com"}
type="text"
autoComplete="username email"
error={errors.email?.message}
{...register("email")}
disabled={isBusy}
/>
<Input
id="login-password-input"
label={t("auth.password")}
placeholder="••••••••"
type={isPasswordVisible ? "text" : "password"}
autoComplete="current-password"
error={errors.password?.message}
{...register("password")}
disabled={isBusy}
endAdornment={
<button
type="button"
aria-label={t(
isPasswordVisible
? "auth.login.hidePassword"
: "auth.login.showPassword"
)}
aria-pressed={isPasswordVisible}
onClick={() => setIsPasswordVisible((visible) => !visible)}
disabled={isBusy}
className="flex h-8 w-8 items-center justify-center rounded-full text-clay-text-muted transition-all duration-200 ease-in-out hover:bg-clay-primary/10 hover:text-clay-primary focus:outline-none focus:ring-2 focus:ring-clay-primary/30 disabled:cursor-not-allowed disabled:opacity-60"
>
<div className="flex h-10 w-10 items-center justify-center">
<Icon size={28} />
</div>
<span className="font-nunito text-xs font-semibold text-clay-text-muted leading-tight">
{label}
{isPasswordVisible ? <EyeOffIcon /> : <EyeIcon />}
</button>
}
/>
<div className="flex items-center justify-between gap-3 px-1">
<label className="flex cursor-pointer items-center gap-2 font-nunito text-xs font-semibold text-clay-text-muted select-none">
<input
type="checkbox"
{...register("rememberMe")}
disabled={isBusy}
className="h-4 w-4 cursor-pointer rounded border-clay-text-muted accent-clay-primary transition-all duration-200 ease-in-out focus:ring-2 focus:ring-clay-primary/30 disabled:cursor-not-allowed disabled:opacity-60"
/>
<span>{t("auth.login.rememberEmail")}</span>
</label>
<span
onClick={() => navigate("/forgot-password")}
className="font-nunito text-xs text-clay-primary font-semibold hover:underline cursor-pointer select-none"
>
{t("auth.login.forgotPassword")}
</span>
</div>
))}
<Button
id="btn-login-submit"
variant="primary"
type="submit"
fullWidth
disabled={isBusy}
className="mt-1"
>
{isSubmitting ? (
<div className="flex items-center gap-2">
<div className="h-5 w-5 border-2 border-clay-on-primary border-t-transparent rounded-full animate-spin"></div>
<span>{t("auth.login.submitting")}</span>
</div>
) : (
t("auth.login.submit")
)}
</Button>
</form>
{/* Divider */}
<div className="relative my-1 flex items-center justify-center">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-clay-highlight/50" />
</div>
<div className="relative bg-clay-surface px-3 font-nunito text-xs font-semibold text-clay-text-muted uppercase tracking-wider">
{t("auth.login.or") || "Hoặc"}
</div>
</div>
{/* Bottom: Login button */}
<div className="w-full max-w-sm flex flex-col gap-3">
{/* Zalo 1-Click Login Button */}
<button
id="btn-zalo-login"
type="button"
onClick={handleZaloLogin}
disabled={isLoading}
className="relative flex w-full items-center justify-center gap-3 rounded-clay bg-[#0068FF] px-6 py-4 font-nunito text-base font-bold text-white shadow-clay-raised transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-clay-hover active:translate-y-0 active:shadow-clay-pressed disabled:cursor-not-allowed disabled:opacity-70"
disabled={isBusy}
className="relative flex w-full items-center justify-center gap-2.5 rounded-clay bg-[#0068FF] px-4 py-3 font-nunito text-sm font-bold text-white shadow-clay-raised transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-clay-hover active:translate-y-0 active:shadow-clay-pressed disabled:cursor-not-allowed disabled:opacity-70 select-none"
>
{isLoading ? (
{isZaloLoading ? (
<>
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent" />
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
<span>{t("auth.zalo.loggingIn")}</span>
</>
) : (
<>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<rect width="24" height="24" rx="6" fill="white" />
<text
x="12"
y="12"
textAnchor="middle"
dominantBaseline="central"
fontSize="14"
fontWeight="900"
fontFamily="Nunito, system-ui, sans-serif"
fill="#0068FF"
>
Z
</text>
</svg>
<span>{t("auth.zalo.loginButton")}</span>
</>
)}
</button>
</Card>
{/* Bottom: Link to Register */}
<div className="text-center text-sm font-nunito text-clay-text-muted">
{t("auth.login.noAccount")}{" "}
<span
onClick={() => navigate("/register")}
className="text-clay-primary font-bold hover:underline cursor-pointer select-none"
>
{t("auth.login.registerNow")}
</span>
</div>
{/* Terms note */}
<p className="text-center font-nunito text-xs text-clay-text-muted px-4">
{t("auth.zalo.termsNote")}
</p>
......@@ -91,4 +281,3 @@ const LoginPage: React.FC = () => {
};
export default LoginPage;
import React, { useEffect, useState } from "react";
import { CalculatorIcon, CheckIcon, CloseIcon } from "@/components/ui/icons";
import { Button } from "@/components/ui/Button";
import { useI18n } from "@/i18n";
export interface TransactionCalculatorModalProps {
isOpen: boolean;
onClose: () => void;
onApply: (amount: string) => void;
initialAmount?: string;
}
type Operator = "+" | "-" | "*" | "/";
function getNumberSeparators(locale: string): { group: string; decimal: string } {
const defaultGroup = locale.startsWith("vi") ? "." : ",";
const defaultDecimal = locale.startsWith("vi") ? "," : ".";
try {
const formatter = new Intl.NumberFormat(locale);
if (typeof formatter.formatToParts === "function") {
const parts = formatter.formatToParts(1234.5);
const group = parts.find((p) => p.type === "group")?.value;
const decimal = parts.find((p) => p.type === "decimal")?.value;
if (group && decimal) {
return { group, decimal };
}
}
const nonDigits = formatter.format(1234.5).match(/[^\d]/g);
const g = nonDigits?.[0];
const d = nonDigits?.[1];
if (g && d) {
return { group: g, decimal: d };
}
} catch {
// Fallback if Intl is unavailable or fails
}
return {
group: defaultGroup,
decimal: defaultDecimal,
};
}
function formatDisplayValue(raw: string, locale: string): string {
if (!raw) return "0";
const [intPart, decPart] = raw.split(".");
const { group, decimal } = getNumberSeparators(locale);
const formattedInt = (intPart || "0").replace(/\B(?=(\d{3})+(?!\d))/g, group);
return decPart !== undefined ? `${formattedInt}${decimal}${decPart}` : formattedInt;
}
function opSymbol(op: Operator): string {
switch (op) {
case "+":
return "+";
case "-":
return "−";
case "*":
return "×";
case "/":
return "÷";
}
}
export const TransactionCalculatorModal: React.FC<TransactionCalculatorModalProps> = ({
isOpen,
onClose,
onApply,
initialAmount = "",
}) => {
const { t, intlLocale } = useI18n();
const [display, setDisplay] = useState<string>("0");
const [expression, setExpression] = useState<string>("");
const [prevValue, setPrevValue] = useState<number | null>(null);
const [operator, setOperator] = useState<Operator | null>(null);
const [waitingForOperand, setWaitingForOperand] = useState<boolean>(false);
const [isCalculated, setIsCalculated] = useState<boolean>(false);
// Initialize or reset when modal opens
useEffect(() => {
if (isOpen) {
const sanitized = initialAmount ? String(parseFloat(initialAmount) || 0) : "0";
setDisplay(sanitized === "0" ? "0" : sanitized);
setExpression("");
setPrevValue(null);
setOperator(null);
setWaitingForOperand(false);
setIsCalculated(false);
}
}, [isOpen, initialAmount]);
if (!isOpen) return null;
const calculateResult = (prev: number, current: number, op: Operator): number => {
let res = 0;
switch (op) {
case "+":
res = prev + current;
break;
case "-":
res = prev - current;
break;
case "*":
res = prev * current;
break;
case "/":
res = current === 0 ? 0 : prev / current;
break;
}
// Round to 2 decimal places and ensure non-negative
const rounded = Math.round(res * 100) / 100;
return Math.max(0, rounded);
};
const handleDigit = (digit: string) => {
if (isCalculated) {
setIsCalculated(false);
if (digit === "0") {
if (display !== "0") {
const current = parseFloat(display) || 0;
const nextVal = display.includes(".")
? Math.round(current * 10 * 100) / 100
: display + "0";
const nextStr = String(nextVal);
if (nextStr.length <= 14) {
setDisplay(nextStr);
setExpression("");
}
}
return;
}
// Digit 1-9: starts fresh number
setDisplay(digit);
setExpression("");
return;
}
if (waitingForOperand) {
setDisplay(digit);
setWaitingForOperand(false);
} else {
if (display === "0") {
setDisplay(digit);
} else if (display.length < 14) {
setDisplay(display + digit);
}
}
};
const handleTripleZero = () => {
if (isCalculated) {
setIsCalculated(false);
if (display !== "0") {
const current = parseFloat(display) || 0;
const nextVal = display.includes(".")
? Math.round(current * 1000 * 100) / 100
: display + "000";
const nextStr = String(nextVal);
if (nextStr.length <= 14) {
setDisplay(nextStr);
setExpression("");
}
}
return;
}
if (waitingForOperand) {
setDisplay("0");
setWaitingForOperand(false);
} else {
if (display !== "0" && display.length <= 11) {
setDisplay(display + "000");
}
}
};
const handleDecimal = () => {
if (isCalculated) {
setIsCalculated(false);
setDisplay("0.");
setExpression("");
return;
}
if (waitingForOperand) {
setDisplay("0.");
setWaitingForOperand(false);
} else if (!display.includes(".")) {
setDisplay(display + ".");
}
};
const handleOperator = (nextOp: Operator) => {
setIsCalculated(false);
const currentNum = parseFloat(display) || 0;
if (prevValue !== null && operator && !waitingForOperand) {
const computed = calculateResult(prevValue, currentNum, operator);
setPrevValue(computed);
setDisplay(String(computed));
setExpression(`${formatDisplayValue(String(computed), intlLocale)} ${opSymbol(nextOp)}`);
} else {
setPrevValue(currentNum);
setExpression(`${formatDisplayValue(display, intlLocale)} ${opSymbol(nextOp)}`);
}
setOperator(nextOp);
setWaitingForOperand(true);
};
const handleEquals = () => {
if (prevValue === null || !operator) return;
const currentNum = parseFloat(display) || 0;
const computed = calculateResult(prevValue, currentNum, operator);
setExpression(
`${formatDisplayValue(String(prevValue), intlLocale)} ${opSymbol(operator)} ${formatDisplayValue(display, intlLocale)} =`
);
setDisplay(String(computed));
setPrevValue(null);
setOperator(null);
setWaitingForOperand(false);
setIsCalculated(true);
};
const handleClear = () => {
setDisplay("0");
setExpression("");
setPrevValue(null);
setOperator(null);
setWaitingForOperand(false);
setIsCalculated(false);
};
const handleBackspace = () => {
if (isCalculated) {
setIsCalculated(false);
setExpression("");
}
if (waitingForOperand) return;
if (display.length > 1) {
setDisplay(display.slice(0, -1));
} else {
setDisplay("0");
}
};
const handleDone = () => {
let finalNum = parseFloat(display) || 0;
// If there's an uncompleted operation, calculate it
if (prevValue !== null && operator && !waitingForOperand) {
finalNum = calculateResult(prevValue, finalNum, operator);
}
finalNum = Math.max(0, finalNum);
const resultStr = Number.isInteger(finalNum)
? String(finalNum)
: String(Number(finalNum.toFixed(2)));
onApply(resultStr);
onClose();
};
return (
<div className="fixed inset-0 z-[1050] flex items-end justify-center p-0 sm:items-center sm:p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-clay-overlay/60 backdrop-blur-[6px] transition-opacity"
onClick={onClose}
/>
{/* Calculator Container */}
<div className="relative w-full max-w-xs bg-clay-surface rounded-t-clay-lg sm:rounded-clay-lg shadow-clay-modal border-t border-x sm:border border-clay-highlight/60 p-5 flex flex-col gap-3.5 select-none animate-modal-content-in">
{/* Header */}
<div className="flex items-center justify-between pb-1 border-b border-clay-text-muted/10">
<div className="flex items-center gap-2">
<CalculatorIcon size={22} className="text-clay-primary" />
<h3 className="clay-title-h3 text-base">
{t("transaction.calculatorTitle") || "Máy tính giao dịch"}
</h3>
</div>
<button
type="button"
onClick={onClose}
aria-label={t("accessibility.closeModal") || "Đóng"}
className="w-7 h-7 rounded-full flex items-center justify-center bg-clay-bg text-clay-text-muted hover:text-clay-text shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
<CloseIcon size={16} />
</button>
</div>
{/* Display Screen */}
<div className="bg-clay-bg rounded-clay-sm p-3 shadow-clay-pressed border border-clay-highlight/30 flex flex-col justify-center min-h-[68px] text-right">
<div className="text-xs font-nunito font-semibold text-clay-text-muted/80 tracking-wide h-4 truncate">
{expression || "\u00A0"}
</div>
<div className="text-2xl font-baloo font-bold text-clay-text tracking-tight mt-0.5 truncate">
{formatDisplayValue(display, intlLocale)}
</div>
</div>
{/* Keypad Grid */}
<div className="grid grid-cols-4 gap-2">
{/* Row 1 */}
<button
type="button"
onClick={handleClear}
className="h-11 rounded-clay-sm bg-clay-expense/15 text-clay-expense border border-clay-expense/30 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
C
</button>
<button
type="button"
onClick={handleBackspace}
className="h-11 rounded-clay-sm bg-clay-expense/15 text-clay-expense border border-clay-expense/30 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
</button>
<button
type="button"
onClick={handleTripleZero}
className="h-11 rounded-clay-sm bg-clay-surface text-clay-primary border border-clay-highlight/50 font-baloo font-bold text-xs shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
000
</button>
<button
type="button"
onClick={() => handleOperator("/")}
className="h-11 rounded-clay-sm bg-clay-primary/15 text-clay-primary border border-clay-primary/30 font-baloo font-bold text-xl shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
÷
</button>
{/* Row 2 */}
<button
type="button"
onClick={() => handleDigit("7")}
className="h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
7
</button>
<button
type="button"
onClick={() => handleDigit("8")}
className="h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
8
</button>
<button
type="button"
onClick={() => handleDigit("9")}
className="h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
9
</button>
<button
type="button"
onClick={() => handleOperator("*")}
className="h-11 rounded-clay-sm bg-clay-primary/15 text-clay-primary border border-clay-primary/30 font-baloo font-bold text-xl shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
×
</button>
{/* Row 3 */}
<button
type="button"
onClick={() => handleDigit("4")}
className="h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
4
</button>
<button
type="button"
onClick={() => handleDigit("5")}
className="h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
5
</button>
<button
type="button"
onClick={() => handleDigit("6")}
className="h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
6
</button>
<button
type="button"
onClick={() => handleOperator("-")}
className="h-11 rounded-clay-sm bg-clay-primary/15 text-clay-primary border border-clay-primary/30 font-baloo font-bold text-xl shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
</button>
{/* Row 4 */}
<button
type="button"
onClick={() => handleDigit("1")}
className="h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
1
</button>
<button
type="button"
onClick={() => handleDigit("2")}
className="h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
2
</button>
<button
type="button"
onClick={() => handleDigit("3")}
className="h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
3
</button>
<button
type="button"
onClick={() => handleOperator("+")}
className="h-11 rounded-clay-sm bg-clay-primary/15 text-clay-primary border border-clay-primary/30 font-baloo font-bold text-xl shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
+
</button>
{/* Row 5 */}
<button
type="button"
onClick={() => handleDigit("0")}
className="col-span-2 h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-base shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
0
</button>
<button
type="button"
onClick={handleDecimal}
className="h-11 rounded-clay-sm bg-clay-surface text-clay-text border border-clay-highlight/50 font-baloo font-bold text-lg shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
.
</button>
<button
type="button"
onClick={handleEquals}
className="h-11 rounded-clay-sm bg-clay-primary text-clay-on-primary border border-clay-primary-dark/30 font-baloo font-bold text-xl shadow-clay-raised hover:shadow-clay-hover active:shadow-clay-pressed active:translate-y-[1px] transition-all duration-150"
>
=
</button>
</div>
{/* Done Action Button */}
<Button
id="btn-calc-done"
type="button"
variant="primary"
fullWidth
onClick={handleDone}
className="mt-1 py-3 text-base font-baloo font-bold shadow-clay-raised"
>
<span className="flex items-center justify-center gap-2">
<CheckIcon size={18} />
<span>{t("transaction.calcDone") || "Xong"}</span>
</span>
</Button>
</div>
</div>
);
};
export default TransactionCalculatorModal;
......@@ -7,6 +7,8 @@ import { LocalizedDateInput } from "@/components/shared/LocalizedDateInput";
import { Input } from "@/components/ui/Input";
import { Modal } from "@/components/ui/Modal";
import { Select } from "@/components/ui/Select";
import { CalculatorIcon } from "@/components/ui/icons";
import { TransactionCalculatorModal } from "./TransactionCalculatorModal";
import { TranslationFunction, useI18n } from "@/i18n";
import { useWallets } from "@/hooks/use-wallets";
import { useCategoryTree } from "@/hooks/use-categories";
......@@ -137,6 +139,7 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
const [selectedFilePreviewUrl, setSelectedFilePreviewUrl] = useState<string | null>(null);
const [fileError, setFileError] = useState<string | null>(null);
const [deleteCurrentReceipt, setDeleteCurrentReceipt] = useState(false);
const [isCalculatorOpen, setIsCalculatorOpen] = useState(false);
const walletsQuery = useWallets({
includeArchived: false,
......@@ -359,6 +362,7 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
};
return (
<>
<Modal
isOpen={isOpen}
onClose={onClose}
......@@ -386,7 +390,9 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
}
>
<form id={formId} className="flex flex-col gap-4" onSubmit={handleSubmit(submitForm)}>
<div className="grid grid-cols-2 gap-3">
<div className="flex items-end gap-2.5">
{/* Loại giao dịch */}
<div className="flex-1 min-w-0">
<Select
label={t("transaction.type")}
options={[
......@@ -396,7 +402,28 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
disabled={isSubmitting}
{...register("type")}
/>
</div>
{/* Nút Máy tính (sau Loại giao dịch, trước Số tiền) */}
<div className="flex flex-col gap-2 shrink-0">
<span className="font-nunito font-semibold text-sm px-1 invisible select-none" aria-hidden="true">
&nbsp;
</span>
<button
id="btn-transaction-calculator"
type="button"
onClick={() => setIsCalculatorOpen(true)}
disabled={isSubmitting}
title={t("transaction.calculator") || "Máy tính"}
aria-label={t("transaction.calculator") || "Máy tính"}
className="flex h-[46px] w-[46px] items-center justify-center rounded-clay-sm bg-clay-surface text-clay-primary border border-clay-highlight/60 shadow-clay-raised transition-all duration-200 ease-in-out hover:shadow-clay-hover hover:scale-105 active:shadow-clay-pressed active:scale-95 disabled:opacity-50 disabled:pointer-events-none"
>
<CalculatorIcon size={22} />
</button>
</div>
{/* Số tiền */}
<div className="flex-[1.2] min-w-0">
<Controller
name="amount"
control={control}
......@@ -415,6 +442,7 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<Select
......@@ -603,5 +631,18 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
</div>
</form>
</Modal>
<TransactionCalculatorModal
isOpen={isCalculatorOpen}
onClose={() => setIsCalculatorOpen(false)}
initialAmount={watch("amount")}
onApply={(calculatedAmount) => {
setValue("amount", calculatedAmount, {
shouldValidate: true,
shouldDirty: true,
});
}}
/>
</>
);
};
......@@ -6,7 +6,7 @@
"noImplicitAny": false,
"preserveConstEnums": true,
"jsx": "react-jsx",
"lib": ["dom", "es5", "es6", "es7", "es2017"],
"lib": ["dom", "es5", "es6", "es7", "es2017", "es2018"],
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"allowJs": true,
......
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