Commit 802420fa authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/universal-amount-calculator' into 'develop'

feat(fe): integrate amount calculator modal across all forms and resolve type issues

See merge request !37
parents c99fd307 130901b6
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 CalculatorButtonProps {
onClick: () => void;
disabled?: boolean;
className?: string;
id?: string;
size?: number;
title?: string;
}
export const CalculatorButton: React.FC<CalculatorButtonProps> = ({
onClick,
disabled = false,
className = "",
id,
size = 22,
title,
}) => {
const { t } = useI18n();
const displayTitle = title || t("transaction.calculator") || "Máy tính";
return (
<button
id={id}
type="button"
onClick={onClick}
disabled={disabled}
title={displayTitle}
aria-label={displayTitle}
className={`flex h-[46px] w-[46px] shrink-0 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 ${className}`}
>
<CalculatorIcon size={size} />
</button>
);
};
export interface CalculatorModalProps {
isOpen: boolean;
onClose: () => void;
onApply: (amount: string) => void;
initialAmount?: string;
title?: 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 CalculatorModal: React.FC<CalculatorModalProps> = ({
isOpen,
onClose,
onApply,
initialAmount = "",
title,
}) => {
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">
{title || 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 CalculatorModal;
...@@ -1001,7 +1001,7 @@ ...@@ -1001,7 +1001,7 @@
}, },
"filters": { "filters": {
"title": "Report scope", "title": "Report scope",
"hint": "Filter by time, wallet, or currency", "hint": "Filter by time or wallet",
"reset": "Reset", "reset": "Reset",
"dateFrom": "From date", "dateFrom": "From date",
"dateTo": "Through date", "dateTo": "Through date",
......
...@@ -1035,7 +1035,7 @@ ...@@ -1035,7 +1035,7 @@
}, },
"filters": { "filters": {
"title": "Phạm vi báo cáo", "title": "Phạm vi báo cáo",
"hint": "Lọc theo thời gian, ví hoặc loại tiền tệ", "hint": "Lọc theo thời gian hoặc ví",
"reset": "Đặt lại", "reset": "Đặt lại",
"dateFrom": "Từ ngày", "dateFrom": "Từ ngày",
"dateTo": "Đến hết ngày", "dateTo": "Đến hết ngày",
......
...@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/Button"; ...@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
import { Select } from "@/components/ui/Select"; import { Select } from "@/components/ui/Select";
import { CalculatorButton, CalculatorModal } from "@/components/ui/CalculatorModal";
import { useEvaluateAnomaly } from "@/hooks/use-anomalies"; import { useEvaluateAnomaly } from "@/hooks/use-anomalies";
import { useCategoryTree } from "@/hooks/use-categories"; import { useCategoryTree } from "@/hooks/use-categories";
import { useWallets } from "@/hooks/use-wallets"; import { useWallets } from "@/hooks/use-wallets";
...@@ -49,6 +50,7 @@ export const AnomalyChecker: React.FC = () => { ...@@ -49,6 +50,7 @@ export const AnomalyChecker: React.FC = () => {
includeArchived: false, includeArchived: false,
}); });
const [result, setResult] = useState<AnomalyEvaluationResult | null>(null); const [result, setResult] = useState<AnomalyEvaluationResult | null>(null);
const [isCalculatorOpen, setIsCalculatorOpen] = useState(false);
const schema = useMemo(() => createSchema(t), [t]); const schema = useMemo(() => createSchema(t), [t]);
const categories = flattenCategories(categoriesQuery.data?.data || []); const categories = flattenCategories(categoriesQuery.data?.data || []);
const wallets = walletsQuery.data?.data || []; const wallets = walletsQuery.data?.data || [];
...@@ -56,6 +58,7 @@ export const AnomalyChecker: React.FC = () => { ...@@ -56,6 +58,7 @@ export const AnomalyChecker: React.FC = () => {
control, control,
handleSubmit, handleSubmit,
register, register,
setValue,
watch, watch,
formState: { errors }, formState: { errors },
} = useForm<AnomalyFormValues>({ } = useForm<AnomalyFormValues>({
...@@ -143,6 +146,8 @@ export const AnomalyChecker: React.FC = () => { ...@@ -143,6 +146,8 @@ export const AnomalyChecker: React.FC = () => {
</p> </p>
)} )}
<div className="flex items-end gap-2">
<div className="flex-1 min-w-0">
<Controller <Controller
name="amount" name="amount"
control={control} control={control}
...@@ -159,6 +164,15 @@ export const AnomalyChecker: React.FC = () => { ...@@ -159,6 +164,15 @@ export const AnomalyChecker: React.FC = () => {
/> />
)} )}
/> />
</div>
<div className="flex flex-col items-center justify-end pb-0.5">
<CalculatorButton
id="btn-anomaly-calculator"
onClick={() => setIsCalculatorOpen(true)}
disabled={optionsLoading || evaluateMutation.isPending}
/>
</div>
</div>
<Button <Button
type="submit" type="submit"
...@@ -205,6 +219,18 @@ export const AnomalyChecker: React.FC = () => { ...@@ -205,6 +219,18 @@ export const AnomalyChecker: React.FC = () => {
</div> </div>
</div> </div>
)} )}
<CalculatorModal
isOpen={isCalculatorOpen}
onClose={() => setIsCalculatorOpen(false)}
initialAmount={watch("amount")}
onApply={(calculatedAmount) => {
setValue("amount", calculatedAmount, {
shouldValidate: true,
shouldDirty: true,
});
}}
/>
</Card> </Card>
); );
}; };
import React, { useEffect, useMemo } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
...@@ -8,6 +8,7 @@ import { Input } from "@/components/ui/Input"; ...@@ -8,6 +8,7 @@ import { Input } from "@/components/ui/Input";
import { Modal } from "@/components/ui/Modal"; import { Modal } from "@/components/ui/Modal";
import { Select } from "@/components/ui/Select"; import { Select } from "@/components/ui/Select";
import { Slider } from "@/components/ui/Slider"; import { Slider } from "@/components/ui/Slider";
import { CalculatorButton, CalculatorModal } from "@/components/ui/CalculatorModal";
import { useCategoryTree } from "@/hooks/use-categories"; import { useCategoryTree } from "@/hooks/use-categories";
import { TranslationFunction, useI18n } from "@/i18n"; import { TranslationFunction, useI18n } from "@/i18n";
import { getCategoryDisplayName } from "@/lib/category-format"; import { getCategoryDisplayName } from "@/lib/category-format";
...@@ -149,6 +150,7 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({ ...@@ -149,6 +150,7 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({
const selectedPeriod = watch("period") as BudgetPeriod; const selectedPeriod = watch("period") as BudgetPeriod;
const selectedStartDate = watch("startDate"); const selectedStartDate = watch("startDate");
const selectedEndDate = watch("endDate"); const selectedEndDate = watch("endDate");
const [isCalculatorOpen, setIsCalculatorOpen] = useState(false);
useEffect(() => { useEffect(() => {
if (selectedType === "OVERALL") { if (selectedType === "OVERALL") {
...@@ -193,6 +195,7 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({ ...@@ -193,6 +195,7 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({
const formId = budget ? `edit-budget-${budget.id}` : "create-budget"; const formId = budget ? `edit-budget-${budget.id}` : "create-budget";
return ( return (
<>
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={onClose}
...@@ -215,7 +218,8 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({ ...@@ -215,7 +218,8 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({
{...register("name")} {...register("name")}
/> />
<div className="grid grid-cols-[minmax(0,1fr)_110px] gap-3"> <div className="flex items-end gap-2">
<div className="flex-1 min-w-0">
<Controller <Controller
name="amount" name="amount"
control={control} control={control}
...@@ -231,6 +235,8 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({ ...@@ -231,6 +235,8 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({
/> />
)} )}
/> />
</div>
<div className="w-[110px] shrink-0">
<Controller <Controller
name="currency" name="currency"
control={control} control={control}
...@@ -248,6 +254,14 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({ ...@@ -248,6 +254,14 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({
)} )}
/> />
</div> </div>
<div className="flex flex-col items-center justify-end pb-0.5">
<CalculatorButton
id="btn-budget-calculator"
onClick={() => setIsCalculatorOpen(true)}
disabled={isSubmitting}
/>
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Select <Select
...@@ -322,5 +336,18 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({ ...@@ -322,5 +336,18 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({
/> />
</form> </form>
</Modal> </Modal>
<CalculatorModal
isOpen={isCalculatorOpen}
onClose={() => setIsCalculatorOpen(false)}
initialAmount={watch("amount")}
onApply={(calculatedAmount) => {
setValue("amount", calculatedAmount, {
shouldValidate: true,
shouldDirty: true,
});
}}
/>
</>
); );
}; };
import React, { useEffect, useMemo } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
...@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/Button"; ...@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
import { Modal } from "@/components/ui/Modal"; import { Modal } from "@/components/ui/Modal";
import { Select } from "@/components/ui/Select"; import { Select } from "@/components/ui/Select";
import { CalculatorButton, CalculatorModal } from "@/components/ui/CalculatorModal";
import { LocalizedDateInput } from "@/components/shared/LocalizedDateInput"; import { LocalizedDateInput } from "@/components/shared/LocalizedDateInput";
import { useI18n, TranslationFunction } from "@/i18n"; import { useI18n, TranslationFunction } from "@/i18n";
import { useWallets } from "@/hooks/use-wallets"; import { useWallets } from "@/hooks/use-wallets";
...@@ -126,6 +127,7 @@ export const RecurringTransactionFormModal: React.FC<Props> = ({ ...@@ -126,6 +127,7 @@ export const RecurringTransactionFormModal: React.FC<Props> = ({
const anchorDate = watch("anchorDate"); const anchorDate = watch("anchorDate");
const endDate = watch("endDate"); const endDate = watch("endDate");
const formId = schedule ? `edit-recurring-${schedule.id}` : "create-recurring"; const formId = schedule ? `edit-recurring-${schedule.id}` : "create-recurring";
const [isCalculatorOpen, setIsCalculatorOpen] = useState(false);
useEffect(() => { if (isOpen) reset(defaultValues); }, [defaultValues, isOpen, reset]); useEffect(() => { if (isOpen) reset(defaultValues); }, [defaultValues, isOpen, reset]);
...@@ -161,6 +163,7 @@ export const RecurringTransactionFormModal: React.FC<Props> = ({ ...@@ -161,6 +163,7 @@ export const RecurringTransactionFormModal: React.FC<Props> = ({
}, [categoryId, categoryOptions, setValue]); }, [categoryId, categoryOptions, setValue]);
return ( return (
<>
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={onClose}
...@@ -184,14 +187,47 @@ export const RecurringTransactionFormModal: React.FC<Props> = ({ ...@@ -184,14 +187,47 @@ export const RecurringTransactionFormModal: React.FC<Props> = ({
missedRunPolicy: values.missedRunPolicy, missedRunPolicy: values.missedRunPolicy,
...(schedule ? {} : { isActive: true }), ...(schedule ? {} : { isActive: true }),
}))}> }))}>
<div className="grid grid-cols-2 gap-3"> <div className="flex items-end gap-2">
<Select label={t("recurringTransactions.form.type")} options={[ <div className="flex-[1] min-w-0">
<Select
label={t("recurringTransactions.form.type")}
options={[
{ value: "EXPENSE", label: t("transaction.typeExpense") }, { value: "EXPENSE", label: t("transaction.typeExpense") },
{ value: "INCOME", label: t("transaction.typeIncome") }, { value: "INCOME", label: t("transaction.typeIncome") },
]} disabled={isSubmitting} {...register("type")} /> ]}
<Controller name="amount" control={control} render={({ field }) => ( disabled={isSubmitting}
<Input {...field} label={t("recurringTransactions.form.amount")} inputMode="decimal" error={errors.amount?.message} disabled={isSubmitting} value={formatAmount(field.value, intlLocale)} onChange={(event) => field.onChange(parseAmount(event.target.value, intlLocale))} /> {...register("type")}
)} /> />
</div>
<div className="flex flex-col items-center justify-end pb-0.5">
<span className="font-nunito font-semibold text-sm px-1 invisible select-none" aria-hidden="true">
&nbsp;
</span>
<CalculatorButton
id="btn-recurring-calculator"
onClick={() => setIsCalculatorOpen(true)}
disabled={isSubmitting}
/>
</div>
<div className="flex-[1.2] min-w-0">
<Controller
name="amount"
control={control}
render={({ field }) => (
<Input
{...field}
label={t("recurringTransactions.form.amount")}
inputMode="decimal"
placeholder={t("transaction.amountPlaceholder")}
error={errors.amount?.message}
disabled={isSubmitting}
className="tabular-nums font-semibold"
value={formatAmount(field.value, intlLocale)}
onChange={(event) => field.onChange(parseAmount(event.target.value, intlLocale))}
/>
)}
/>
</div>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Select label={t("recurringTransactions.form.wallet")} options={walletOptions} error={errors.walletId?.message} disabled={isSubmitting || walletsQuery.isLoading} {...register("walletId")} /> <Select label={t("recurringTransactions.form.wallet")} options={walletOptions} error={errors.walletId?.message} disabled={isSubmitting || walletsQuery.isLoading} {...register("walletId")} />
...@@ -216,5 +252,18 @@ export const RecurringTransactionFormModal: React.FC<Props> = ({ ...@@ -216,5 +252,18 @@ export const RecurringTransactionFormModal: React.FC<Props> = ({
</p> </p>
</form> </form>
</Modal> </Modal>
<CalculatorModal
isOpen={isCalculatorOpen}
onClose={() => setIsCalculatorOpen(false)}
initialAmount={watch("amount")}
onApply={(calculatedAmount) => {
setValue("amount", calculatedAmount, {
shouldValidate: true,
shouldDirty: true,
});
}}
/>
</>
); );
}; };
...@@ -13,7 +13,6 @@ interface ReportFiltersProps { ...@@ -13,7 +13,6 @@ interface ReportFiltersProps {
dateFrom: string; dateFrom: string;
dateTo: string; dateTo: string;
walletId: string; walletId: string;
currency: string;
wallets: Wallet[]; wallets: Wallet[];
isLoadingWallets: boolean; isLoadingWallets: boolean;
dateError?: string; dateError?: string;
...@@ -21,7 +20,6 @@ interface ReportFiltersProps { ...@@ -21,7 +20,6 @@ interface ReportFiltersProps {
onDateFromChange: (value: string) => void; onDateFromChange: (value: string) => void;
onDateToChange: (value: string) => void; onDateToChange: (value: string) => void;
onWalletChange: (value: string) => void; onWalletChange: (value: string) => void;
onCurrencyChange: (value: string) => void;
onReset: () => void; onReset: () => void;
} }
...@@ -30,7 +28,6 @@ export const ReportFilters: React.FC<ReportFiltersProps> = ({ ...@@ -30,7 +28,6 @@ export const ReportFilters: React.FC<ReportFiltersProps> = ({
dateFrom, dateFrom,
dateTo, dateTo,
walletId, walletId,
currency,
wallets, wallets,
isLoadingWallets, isLoadingWallets,
dateError, dateError,
...@@ -38,11 +35,9 @@ export const ReportFilters: React.FC<ReportFiltersProps> = ({ ...@@ -38,11 +35,9 @@ export const ReportFilters: React.FC<ReportFiltersProps> = ({
onDateFromChange, onDateFromChange,
onDateToChange, onDateToChange,
onWalletChange, onWalletChange,
onCurrencyChange,
onReset, onReset,
}) => { }) => {
const { t } = useI18n(); const { t } = useI18n();
const currencies = Array.from(new Set(wallets.map((wallet) => wallet.currency))).sort();
return ( return (
<Card className="flex flex-col gap-4 p-4"> <Card className="flex flex-col gap-4 p-4">
...@@ -51,7 +46,7 @@ export const ReportFilters: React.FC<ReportFiltersProps> = ({ ...@@ -51,7 +46,7 @@ export const ReportFilters: React.FC<ReportFiltersProps> = ({
<h2 className="clay-title-h3">{t("report.filters.title")}</h2> <h2 className="clay-title-h3">{t("report.filters.title")}</h2>
<p className="clay-caption">{t("report.filters.hint")}</p> <p className="clay-caption">{t("report.filters.hint")}</p>
</div> </div>
{(period !== "MONTH" || walletId || currency) && ( {(period !== "MONTH" || walletId) && (
<Button variant="ghost" className="shrink-0 px-3 text-sm" onClick={onReset}> <Button variant="ghost" className="shrink-0 px-3 text-sm" onClick={onReset}>
{t("report.filters.reset")} {t("report.filters.reset")}
</Button> </Button>
...@@ -87,7 +82,7 @@ export const ReportFilters: React.FC<ReportFiltersProps> = ({ ...@@ -87,7 +82,7 @@ export const ReportFilters: React.FC<ReportFiltersProps> = ({
</div> </div>
)} )}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div>
<Select <Select
label={t("report.filters.wallet")} label={t("report.filters.wallet")}
value={walletId} value={walletId}
...@@ -98,16 +93,6 @@ export const ReportFilters: React.FC<ReportFiltersProps> = ({ ...@@ -98,16 +93,6 @@ export const ReportFilters: React.FC<ReportFiltersProps> = ({
...wallets.map((wallet) => ({ value: wallet.id, label: `${wallet.name} · ${wallet.currency}` })), ...wallets.map((wallet) => ({ value: wallet.id, label: `${wallet.name} · ${wallet.currency}` })),
]} ]}
/> />
<Select
label={t("report.filters.currency")}
value={currency}
disabled={Boolean(walletId) || isLoadingWallets}
onChange={(event) => onCurrencyChange(event.target.value)}
options={[
{ value: "", label: t("report.filters.allCurrencies") },
...currencies.map((code) => ({ value: code, label: code })),
]}
/>
</div> </div>
</Card> </Card>
); );
......
...@@ -51,7 +51,6 @@ const ReportsPage: React.FC = () => { ...@@ -51,7 +51,6 @@ const ReportsPage: React.FC = () => {
const [dateFrom, setDateFrom] = useState(initialDates.from); const [dateFrom, setDateFrom] = useState(initialDates.from);
const [dateTo, setDateTo] = useState(initialDates.to); const [dateTo, setDateTo] = useState(initialDates.to);
const [walletId, setWalletId] = useState(""); const [walletId, setWalletId] = useState("");
const [currency, setCurrency] = useState("");
const [activeCurrency, setActiveCurrency] = useState(""); const [activeCurrency, setActiveCurrency] = useState("");
const walletsQuery = useWalletSearch({ includeArchived: false, sortBy: "name", order: "asc" }, true); const walletsQuery = useWalletSearch({ includeArchived: false, sortBy: "name", order: "asc" }, true);
...@@ -71,9 +70,8 @@ const ReportsPage: React.FC = () => { ...@@ -71,9 +70,8 @@ const ReportsPage: React.FC = () => {
dateTo: toInclusiveBoundary(dateTo, true), dateTo: toInclusiveBoundary(dateTo, true),
} : {}), } : {}),
...(walletId ? { walletId } : {}), ...(walletId ? { walletId } : {}),
...(!walletId && currency ? { currency } : {}),
granularity: "AUTO", granularity: "AUTO",
}), [currency, customDateOrderValid, dateFrom, dateTo, period, walletId]); }), [customDateOrderValid, dateFrom, dateTo, period, walletId]);
const overviewQuery = useReportOverview(reportQuery, queryEnabled); const overviewQuery = useReportOverview(reportQuery, queryEnabled);
const cashFlowQuery = useCashFlowReport(reportQuery, queryEnabled); const cashFlowQuery = useCashFlowReport(reportQuery, queryEnabled);
...@@ -90,10 +88,6 @@ const ReportsPage: React.FC = () => { ...@@ -90,10 +88,6 @@ const ReportsPage: React.FC = () => {
}, [overview]); }, [overview]);
useEffect(() => { useEffect(() => {
if (currency && availableCurrencies.includes(currency)) {
setActiveCurrency(currency);
return;
}
if (selectedWallet && availableCurrencies.includes(selectedWallet.currency)) { if (selectedWallet && availableCurrencies.includes(selectedWallet.currency)) {
setActiveCurrency(selectedWallet.currency); setActiveCurrency(selectedWallet.currency);
return; return;
...@@ -101,7 +95,7 @@ const ReportsPage: React.FC = () => { ...@@ -101,7 +95,7 @@ const ReportsPage: React.FC = () => {
if (activeCurrency && availableCurrencies.includes(activeCurrency)) return; if (activeCurrency && availableCurrencies.includes(activeCurrency)) return;
const defaultCurrency = wallets.find((wallet) => wallet.isDefault)?.currency; const defaultCurrency = wallets.find((wallet) => wallet.isDefault)?.currency;
setActiveCurrency((defaultCurrency && availableCurrencies.includes(defaultCurrency)) ? defaultCurrency : (availableCurrencies[0] || "")); setActiveCurrency((defaultCurrency && availableCurrencies.includes(defaultCurrency)) ? defaultCurrency : (availableCurrencies[0] || ""));
}, [activeCurrency, availableCurrencies.join("|"), currency, selectedWallet, wallets]); }, [activeCurrency, availableCurrencies.join("|"), selectedWallet, wallets]);
const metric = overview?.metricsByCurrency.find((item) => item.currency === activeCurrency); const metric = overview?.metricsByCurrency.find((item) => item.currency === activeCurrency);
const currencyWallets = overview?.wallets.items.filter((wallet) => wallet.currency === activeCurrency) || []; const currencyWallets = overview?.wallets.items.filter((wallet) => wallet.currency === activeCurrency) || [];
...@@ -115,7 +109,6 @@ const ReportsPage: React.FC = () => { ...@@ -115,7 +109,6 @@ const ReportsPage: React.FC = () => {
setWalletId(value); setWalletId(value);
const wallet = wallets.find((item) => item.id === value); const wallet = wallets.find((item) => item.id === value);
if (wallet) { if (wallet) {
setCurrency("");
setActiveCurrency(wallet.currency); setActiveCurrency(wallet.currency);
} }
}; };
...@@ -125,7 +118,6 @@ const ReportsPage: React.FC = () => { ...@@ -125,7 +118,6 @@ const ReportsPage: React.FC = () => {
setDateFrom(initialDates.from); setDateFrom(initialDates.from);
setDateTo(initialDates.to); setDateTo(initialDates.to);
setWalletId(""); setWalletId("");
setCurrency("");
}; };
const retryAll = () => { const retryAll = () => {
...@@ -176,7 +168,6 @@ const ReportsPage: React.FC = () => { ...@@ -176,7 +168,6 @@ const ReportsPage: React.FC = () => {
dateFrom={dateFrom} dateFrom={dateFrom}
dateTo={dateTo} dateTo={dateTo}
walletId={walletId} walletId={walletId}
currency={currency}
wallets={wallets} wallets={wallets}
isLoadingWallets={walletsQuery.isLoading} isLoadingWallets={walletsQuery.isLoading}
dateError={dateError} dateError={dateError}
...@@ -184,11 +175,10 @@ const ReportsPage: React.FC = () => { ...@@ -184,11 +175,10 @@ const ReportsPage: React.FC = () => {
onDateFromChange={setDateFrom} onDateFromChange={setDateFrom}
onDateToChange={setDateTo} onDateToChange={setDateTo}
onWalletChange={handleWalletChange} onWalletChange={handleWalletChange}
onCurrencyChange={(value) => { setCurrency(value); setActiveCurrency(value); }}
onReset={resetFilters} onReset={resetFilters}
/> />
{availableCurrencies.length > 1 && !currency && !walletId && ( {availableCurrencies.length > 1 && !walletId && (
<div className="flex gap-2 overflow-x-auto px-1 pb-2" role="tablist" aria-label={t("report.currencyTabs")}> <div className="flex gap-2 overflow-x-auto px-1 pb-2" role="tablist" aria-label={t("report.currencyTabs")}>
{availableCurrencies.map((code) => ( {availableCurrencies.map((code) => (
<Button key={code} variant={activeCurrency === code ? "primary" : "secondary"} shape="pill" className="shrink-0 px-5 py-2 text-sm" onClick={() => setActiveCurrency(code)}> <Button key={code} variant={activeCurrency === code ? "primary" : "secondary"} shape="pill" className="shrink-0 px-5 py-2 text-sm" onClick={() => setActiveCurrency(code)}>
......
import React, { useEffect, useMemo } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
...@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/Button"; ...@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/Button";
import { LocalizedDateInput } from "@/components/shared/LocalizedDateInput"; import { LocalizedDateInput } from "@/components/shared/LocalizedDateInput";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
import { Modal } from "@/components/ui/Modal"; import { Modal } from "@/components/ui/Modal";
import { CalculatorButton, CalculatorModal } from "@/components/ui/CalculatorModal";
import { TranslationFunction, useI18n } from "@/i18n"; import { TranslationFunction, useI18n } from "@/i18n";
import { formatMoneyInput, parseMoneyInput, positiveAmountPattern, toLocalDateTime } from "@/lib/money-input"; import { formatMoneyInput, parseMoneyInput, positiveAmountPattern, toLocalDateTime } from "@/lib/money-input";
import { SavingContribution, SavingContributionInput } from "@/types/saving-goal"; import { SavingContribution, SavingContributionInput } from "@/types/saving-goal";
...@@ -51,7 +52,7 @@ export const ContributionFormModal: React.FC<ContributionFormModalProps> = ({ ...@@ -51,7 +52,7 @@ export const ContributionFormModal: React.FC<ContributionFormModalProps> = ({
const schema = useMemo(() => createSchema(t), [t]); const schema = useMemo(() => createSchema(t), [t]);
const defaultValues = useMemo(() => getDefaultValues(contribution), [contribution]); const defaultValues = useMemo(() => getDefaultValues(contribution), [contribution]);
const formId = contribution ? `edit-contribution-${contribution.id}` : "create-contribution"; const formId = contribution ? `edit-contribution-${contribution.id}` : "create-contribution";
const { control, register, handleSubmit, reset, watch, formState: { errors } } = useForm<ContributionFormValues>({ const { control, register, handleSubmit, reset, setValue, watch, formState: { errors } } = useForm<ContributionFormValues>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues, defaultValues,
}); });
...@@ -61,8 +62,10 @@ export const ContributionFormModal: React.FC<ContributionFormModalProps> = ({ ...@@ -61,8 +62,10 @@ export const ContributionFormModal: React.FC<ContributionFormModalProps> = ({
}, [defaultValues, isOpen, reset]); }, [defaultValues, isOpen, reset]);
const contributedAt = watch("contributedAt"); const contributedAt = watch("contributedAt");
const [isCalculatorOpen, setIsCalculatorOpen] = useState(false);
return ( return (
<>
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={onClose}
...@@ -86,6 +89,8 @@ export const ContributionFormModal: React.FC<ContributionFormModalProps> = ({ ...@@ -86,6 +89,8 @@ export const ContributionFormModal: React.FC<ContributionFormModalProps> = ({
}))} }))}
noValidate noValidate
> >
<div className="flex items-end gap-2">
<div className="flex-1 min-w-0">
<Controller <Controller
name="amount" name="amount"
control={control} control={control}
...@@ -102,6 +107,15 @@ export const ContributionFormModal: React.FC<ContributionFormModalProps> = ({ ...@@ -102,6 +107,15 @@ export const ContributionFormModal: React.FC<ContributionFormModalProps> = ({
/> />
)} )}
/> />
</div>
<div className="flex flex-col items-center justify-end pb-0.5">
<CalculatorButton
id="btn-contribution-calculator"
onClick={() => setIsCalculatorOpen(true)}
disabled={isSubmitting}
/>
</div>
</div>
<LocalizedDateInput <LocalizedDateInput
type="datetime-local" type="datetime-local"
value={contributedAt} value={contributedAt}
...@@ -126,5 +140,18 @@ export const ContributionFormModal: React.FC<ContributionFormModalProps> = ({ ...@@ -126,5 +140,18 @@ export const ContributionFormModal: React.FC<ContributionFormModalProps> = ({
</div> </div>
</form> </form>
</Modal> </Modal>
<CalculatorModal
isOpen={isCalculatorOpen}
onClose={() => setIsCalculatorOpen(false)}
initialAmount={watch("amount")}
onApply={(calculatedAmount) => {
setValue("amount", calculatedAmount, {
shouldValidate: true,
shouldDirty: true,
});
}}
/>
</>
); );
}; };
import React, { useEffect, useMemo } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
...@@ -7,6 +7,7 @@ import { LocalizedDateInput } from "@/components/shared/LocalizedDateInput"; ...@@ -7,6 +7,7 @@ import { LocalizedDateInput } from "@/components/shared/LocalizedDateInput";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
import { Modal } from "@/components/ui/Modal"; import { Modal } from "@/components/ui/Modal";
import { CalculatorButton, CalculatorModal } from "@/components/ui/CalculatorModal";
import { TranslationFunction, useI18n } from "@/i18n"; import { TranslationFunction, useI18n } from "@/i18n";
import { formatMoneyInput, parseMoneyInput, positiveAmountPattern, toLocalDate } from "@/lib/money-input"; import { formatMoneyInput, parseMoneyInput, positiveAmountPattern, toLocalDate } from "@/lib/money-input";
import { CreateSavingGoalInput, SavingGoal } from "@/types/saving-goal"; import { CreateSavingGoalInput, SavingGoal } from "@/types/saving-goal";
...@@ -84,6 +85,7 @@ export const SavingGoalFormModal: React.FC<SavingGoalFormModalProps> = ({ ...@@ -84,6 +85,7 @@ export const SavingGoalFormModal: React.FC<SavingGoalFormModalProps> = ({
const selectedColor = watch("color"); const selectedColor = watch("color");
const selectedTargetDate = watch("targetDate"); const selectedTargetDate = watch("targetDate");
const currencyLocked = Boolean(goal && goal.progress.contributionCount > 0); const currencyLocked = Boolean(goal && goal.progress.contributionCount > 0);
const [isCalculatorOpen, setIsCalculatorOpen] = useState(false);
const submitForm = (values: SavingGoalFormValues) => { const submitForm = (values: SavingGoalFormValues) => {
onSubmit({ onSubmit({
...@@ -98,6 +100,7 @@ export const SavingGoalFormModal: React.FC<SavingGoalFormModalProps> = ({ ...@@ -98,6 +100,7 @@ export const SavingGoalFormModal: React.FC<SavingGoalFormModalProps> = ({
}; };
return ( return (
<>
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={onClose}
...@@ -122,7 +125,8 @@ export const SavingGoalFormModal: React.FC<SavingGoalFormModalProps> = ({ ...@@ -122,7 +125,8 @@ export const SavingGoalFormModal: React.FC<SavingGoalFormModalProps> = ({
<Input label={t("savingGoal.form.name")} placeholder={t("savingGoal.form.namePlaceholder")} maxLength={100} disabled={isSubmitting} error={errors.name?.message} {...register("name")} /> <Input label={t("savingGoal.form.name")} placeholder={t("savingGoal.form.namePlaceholder")} maxLength={100} disabled={isSubmitting} error={errors.name?.message} {...register("name")} />
<div className="grid grid-cols-[minmax(0,1fr)_105px] gap-3"> <div className="flex items-end gap-2">
<div className="flex-1 min-w-0">
<Controller <Controller
name="targetAmount" name="targetAmount"
control={control} control={control}
...@@ -139,6 +143,8 @@ export const SavingGoalFormModal: React.FC<SavingGoalFormModalProps> = ({ ...@@ -139,6 +143,8 @@ export const SavingGoalFormModal: React.FC<SavingGoalFormModalProps> = ({
/> />
)} )}
/> />
</div>
<div className="w-[105px] shrink-0">
<Controller <Controller
name="currency" name="currency"
control={control} control={control}
...@@ -156,6 +162,14 @@ export const SavingGoalFormModal: React.FC<SavingGoalFormModalProps> = ({ ...@@ -156,6 +162,14 @@ export const SavingGoalFormModal: React.FC<SavingGoalFormModalProps> = ({
)} )}
/> />
</div> </div>
<div className="flex flex-col items-center justify-end pb-0.5">
<CalculatorButton
id="btn-saving-goal-calculator"
onClick={() => setIsCalculatorOpen(true)}
disabled={isSubmitting}
/>
</div>
</div>
{currencyLocked && <p className="-mt-2 px-1 clay-caption">{t("savingGoal.form.currencyLocked")}</p>} {currencyLocked && <p className="-mt-2 px-1 clay-caption">{t("savingGoal.form.currencyLocked")}</p>}
<LocalizedDateInput type="date" value={selectedTargetDate} min={goal ? undefined : tomorrow()} label={t("savingGoal.form.targetDate")} disabled={isSubmitting} error={errors.targetDate?.message} {...register("targetDate")} /> <LocalizedDateInput type="date" value={selectedTargetDate} min={goal ? undefined : tomorrow()} label={t("savingGoal.form.targetDate")} disabled={isSubmitting} error={errors.targetDate?.message} {...register("targetDate")} />
...@@ -212,5 +226,18 @@ export const SavingGoalFormModal: React.FC<SavingGoalFormModalProps> = ({ ...@@ -212,5 +226,18 @@ export const SavingGoalFormModal: React.FC<SavingGoalFormModalProps> = ({
</fieldset> </fieldset>
</form> </form>
</Modal> </Modal>
<CalculatorModal
isOpen={isCalculatorOpen}
onClose={() => setIsCalculatorOpen(false)}
initialAmount={watch("targetAmount")}
onApply={(calculatedAmount) => {
setValue("targetAmount", calculatedAmount, {
shouldValidate: true,
shouldDirty: true,
});
}}
/>
</>
); );
}; };
import React, { useMemo } from "react"; import React, { useMemo, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
...@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/Button"; ...@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
import { Slider } from "@/components/ui/Slider"; import { Slider } from "@/components/ui/Slider";
import { CalculatorButton, CalculatorModal } from "@/components/ui/CalculatorModal";
import { TranslationFunction, useI18n } from "@/i18n"; import { TranslationFunction, useI18n } from "@/i18n";
import { formatMoneyInput, parseMoneyInput, positiveAmountPattern } from "@/lib/money-input"; import { formatMoneyInput, parseMoneyInput, positiveAmountPattern } from "@/lib/money-input";
import { Perturbation } from "@/types/simulation"; import { Perturbation } from "@/types/simulation";
...@@ -53,6 +54,7 @@ export const SimulationControls: React.FC<SimulationControlsProps> = ({ ...@@ -53,6 +54,7 @@ export const SimulationControls: React.FC<SimulationControlsProps> = ({
defaultValues: { type: "RECURRING_EXPENSE", name: "", amount: "" }, defaultValues: { type: "RECURRING_EXPENSE", name: "", amount: "" },
}); });
const adjustmentType = watch("type"); const adjustmentType = watch("type");
const [isCalculatorOpen, setIsCalculatorOpen] = useState(false);
const submitPerturbation = (values: CustomPerturbationValues) => { const submitPerturbation = (values: CustomPerturbationValues) => {
onAddPerturbation({ onAddPerturbation({
...@@ -172,6 +174,8 @@ export const SimulationControls: React.FC<SimulationControlsProps> = ({ ...@@ -172,6 +174,8 @@ export const SimulationControls: React.FC<SimulationControlsProps> = ({
/> />
)} )}
/> />
<div className="flex items-end gap-2">
<div className="flex-1 min-w-0">
<Controller <Controller
name="amount" name="amount"
control={control} control={control}
...@@ -188,6 +192,15 @@ export const SimulationControls: React.FC<SimulationControlsProps> = ({ ...@@ -188,6 +192,15 @@ export const SimulationControls: React.FC<SimulationControlsProps> = ({
/> />
)} )}
/> />
</div>
<div className="flex flex-col items-center justify-end pb-0.5">
<CalculatorButton
id="btn-simulation-calculator"
onClick={() => setIsCalculatorOpen(true)}
disabled={isLoading}
/>
</div>
</div>
<Button type="submit" variant="secondary" fullWidth disabled={isLoading} className="py-2 text-sm"> <Button type="submit" variant="secondary" fullWidth disabled={isLoading} className="py-2 text-sm">
{t("simulations.addAdjustmentBtn")} {t("simulations.addAdjustmentBtn")}
</Button> </Button>
...@@ -203,6 +216,18 @@ export const SimulationControls: React.FC<SimulationControlsProps> = ({ ...@@ -203,6 +216,18 @@ export const SimulationControls: React.FC<SimulationControlsProps> = ({
> >
{isLoading ? t("common.processing") : t("simulations.runSimulationBtn")} {isLoading ? t("common.processing") : t("simulations.runSimulationBtn")}
</Button> </Button>
<CalculatorModal
isOpen={isCalculatorOpen}
onClose={() => setIsCalculatorOpen(false)}
initialAmount={watch("amount")}
onApply={(calculatedAmount) => {
setValue("amount", calculatedAmount, {
shouldValidate: true,
shouldDirty: true,
});
}}
/>
</Card> </Card>
); );
}; };
...@@ -229,7 +229,7 @@ const StyleGuidePage: React.FC = () => { ...@@ -229,7 +229,7 @@ const StyleGuidePage: React.FC = () => {
label={t("styleGuide.amount")} label={t("styleGuide.amount")}
placeholder={t("styleGuide.amountPlaceholder")} placeholder={t("styleGuide.amountPlaceholder")}
inputMode="decimal" inputMode="decimal"
className="text-right font-semibold tabular-nums" className="font-semibold tabular-nums"
value={formatAmountInput(amountValue, intlLocale)} value={formatAmountInput(amountValue, intlLocale)}
onChange={(event) => setAmountValue(parseAmountInput(event.target.value, intlLocale))} onChange={(event) => setAmountValue(parseAmountInput(event.target.value, intlLocale))}
/> />
...@@ -413,7 +413,7 @@ const StyleGuidePage: React.FC = () => { ...@@ -413,7 +413,7 @@ const StyleGuidePage: React.FC = () => {
label={t("styleGuide.amount")} label={t("styleGuide.amount")}
inputMode="decimal" inputMode="decimal"
placeholder={t("styleGuide.amountPlaceholder")} placeholder={t("styleGuide.amountPlaceholder")}
className="text-right font-semibold tabular-nums" className="font-semibold tabular-nums"
value={formatAmountInput(modalAmountValue, intlLocale)} value={formatAmountInput(modalAmountValue, intlLocale)}
onChange={(event) => setModalAmountValue(parseAmountInput(event.target.value, intlLocale))} onChange={(event) => setModalAmountValue(parseAmountInput(event.target.value, intlLocale))}
/> />
......
import React, { useEffect, useState } from "react"; export * from "@/components/ui/CalculatorModal";
import { CalculatorIcon, CheckIcon, CloseIcon } from "@/components/ui/icons"; export { CalculatorModal as default } from "@/components/ui/CalculatorModal";
import { Button } from "@/components/ui/Button"; export { CalculatorModal as TransactionCalculatorModal } from "@/components/ui/CalculatorModal";
import { useI18n } from "@/i18n"; export type { CalculatorModalProps as TransactionCalculatorModalProps } from "@/components/ui/CalculatorModal";
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,8 +7,7 @@ import { LocalizedDateInput } from "@/components/shared/LocalizedDateInput"; ...@@ -7,8 +7,7 @@ import { LocalizedDateInput } from "@/components/shared/LocalizedDateInput";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
import { Modal } from "@/components/ui/Modal"; import { Modal } from "@/components/ui/Modal";
import { Select } from "@/components/ui/Select"; import { Select } from "@/components/ui/Select";
import { CalculatorIcon } from "@/components/ui/icons"; import { CalculatorButton, CalculatorModal } from "@/components/ui/CalculatorModal";
import { TransactionCalculatorModal } from "./TransactionCalculatorModal";
import { TranslationFunction, useI18n } from "@/i18n"; import { TranslationFunction, useI18n } from "@/i18n";
import { useWallets } from "@/hooks/use-wallets"; import { useWallets } from "@/hooks/use-wallets";
import { useCategoryTree } from "@/hooks/use-categories"; import { useCategoryTree } from "@/hooks/use-categories";
...@@ -111,11 +110,15 @@ interface FlatCategoryOption { ...@@ -111,11 +110,15 @@ interface FlatCategoryOption {
depth: number; depth: number;
} }
function flattenTree(nodes: CategoryTreeNode[], depth = 0): FlatCategoryOption[] { function flattenTree(nodes: CategoryTreeNode[] = [], depth = 0): FlatCategoryOption[] {
return nodes.flatMap((node) => [ const result: FlatCategoryOption[] = [];
{ category: node, depth }, for (const node of nodes || []) {
...flattenTree(node.children, depth + 1), result.push({ category: node, depth });
]); if (node.children && node.children.length > 0) {
result.push(...flattenTree(node.children, depth + 1));
}
}
return result;
} }
const getLocalDateString = (dateInput?: string | Date) => { const getLocalDateString = (dateInput?: string | Date) => {
...@@ -409,17 +412,11 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({ ...@@ -409,17 +412,11 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
<span className="font-nunito font-semibold text-sm px-1 invisible select-none" aria-hidden="true"> <span className="font-nunito font-semibold text-sm px-1 invisible select-none" aria-hidden="true">
&nbsp; &nbsp;
</span> </span>
<button <CalculatorButton
id="btn-transaction-calculator" id="btn-transaction-calculator"
type="button"
onClick={() => setIsCalculatorOpen(true)} onClick={() => setIsCalculatorOpen(true)}
disabled={isSubmitting} 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> </div>
{/* Số tiền */} {/* Số tiền */}
...@@ -435,7 +432,7 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({ ...@@ -435,7 +432,7 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
placeholder={t("transaction.amountPlaceholder")} placeholder={t("transaction.amountPlaceholder")}
error={errors.amount?.message} error={errors.amount?.message}
disabled={isSubmitting} disabled={isSubmitting}
className="text-right tabular-nums font-semibold" className="tabular-nums font-semibold"
value={formatAmountInput(field.value, intlLocale)} value={formatAmountInput(field.value, intlLocale)}
onChange={(event) => field.onChange(parseAmountInput(event.target.value, intlLocale))} onChange={(event) => field.onChange(parseAmountInput(event.target.value, intlLocale))}
/> />
...@@ -632,7 +629,7 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({ ...@@ -632,7 +629,7 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
</form> </form>
</Modal> </Modal>
<TransactionCalculatorModal <CalculatorModal
isOpen={isCalculatorOpen} isOpen={isCalculatorOpen}
onClose={() => setIsCalculatorOpen(false)} onClose={() => setIsCalculatorOpen(false)}
initialAmount={watch("amount")} initialAmount={watch("amount")}
......
...@@ -684,7 +684,7 @@ const TransactionsPage: React.FC = () => { ...@@ -684,7 +684,7 @@ const TransactionsPage: React.FC = () => {
label={t("transaction.filterMinAmount")} label={t("transaction.filterMinAmount")}
inputMode="decimal" inputMode="decimal"
placeholder={t("transaction.amountPlaceholder")} placeholder={t("transaction.amountPlaceholder")}
className="text-right tabular-nums" className="tabular-nums"
value={formatAmountInput(minAmount, intlLocale)} value={formatAmountInput(minAmount, intlLocale)}
onChange={(e) => setMinAmount(parseAmountInput(e.target.value, intlLocale))} onChange={(e) => setMinAmount(parseAmountInput(e.target.value, intlLocale))}
/> />
...@@ -692,7 +692,7 @@ const TransactionsPage: React.FC = () => { ...@@ -692,7 +692,7 @@ const TransactionsPage: React.FC = () => {
label={t("transaction.filterMaxAmount")} label={t("transaction.filterMaxAmount")}
inputMode="decimal" inputMode="decimal"
placeholder={t("transaction.amountPlaceholder")} placeholder={t("transaction.amountPlaceholder")}
className="text-right tabular-nums" className="tabular-nums"
value={formatAmountInput(maxAmount, intlLocale)} value={formatAmountInput(maxAmount, intlLocale)}
onChange={(e) => setMaxAmount(parseAmountInput(e.target.value, intlLocale))} onChange={(e) => setMaxAmount(parseAmountInput(e.target.value, intlLocale))}
/> />
......
import React, { useEffect, useMemo } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
...@@ -7,6 +7,7 @@ import { LocalizedDateInput } from "@/components/shared/LocalizedDateInput"; ...@@ -7,6 +7,7 @@ import { LocalizedDateInput } from "@/components/shared/LocalizedDateInput";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
import { Modal } from "@/components/ui/Modal"; import { Modal } from "@/components/ui/Modal";
import { Select } from "@/components/ui/Select"; import { Select } from "@/components/ui/Select";
import { CalculatorButton, CalculatorModal } from "@/components/ui/CalculatorModal";
import { useWalletSearch } from "@/hooks/use-wallets"; import { useWalletSearch } from "@/hooks/use-wallets";
import { TranslationFunction, useI18n } from "@/i18n"; import { TranslationFunction, useI18n } from "@/i18n";
import { getErrorMessage } from "@/lib/error-message"; import { getErrorMessage } from "@/lib/error-message";
...@@ -175,6 +176,7 @@ export const TransferFormModal: React.FC<TransferFormModalProps> = ({ ...@@ -175,6 +176,7 @@ export const TransferFormModal: React.FC<TransferFormModalProps> = ({
const destinationWalletId = watch("destinationWalletId"); const destinationWalletId = watch("destinationWalletId");
const amount = watch("amount"); const amount = watch("amount");
const transferredAt = watch("transferredAt"); const transferredAt = watch("transferredAt");
const [isCalculatorOpen, setIsCalculatorOpen] = useState(false);
const sourceWallet = wallets.find((wallet) => wallet.id === sourceWalletId); const sourceWallet = wallets.find((wallet) => wallet.id === sourceWalletId);
const destinationWallet = wallets.find((wallet) => wallet.id === destinationWalletId); const destinationWallet = wallets.find((wallet) => wallet.id === destinationWalletId);
...@@ -227,6 +229,7 @@ export const TransferFormModal: React.FC<TransferFormModalProps> = ({ ...@@ -227,6 +229,7 @@ export const TransferFormModal: React.FC<TransferFormModalProps> = ({
const insufficientWallets = !walletsQuery.isLoading && !walletsQuery.isError && wallets.length < 2; const insufficientWallets = !walletsQuery.isLoading && !walletsQuery.isError && wallets.length < 2;
return ( return (
<>
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={onClose}
...@@ -288,6 +291,8 @@ export const TransferFormModal: React.FC<TransferFormModalProps> = ({ ...@@ -288,6 +291,8 @@ export const TransferFormModal: React.FC<TransferFormModalProps> = ({
{...register("destinationWalletId")} {...register("destinationWalletId")}
/> />
<div className="flex items-end gap-2">
<div className="flex-1 min-w-0">
<Controller <Controller
name="amount" name="amount"
control={control} control={control}
...@@ -299,7 +304,7 @@ export const TransferFormModal: React.FC<TransferFormModalProps> = ({ ...@@ -299,7 +304,7 @@ export const TransferFormModal: React.FC<TransferFormModalProps> = ({
placeholder={t("transfer.amountPlaceholder")} placeholder={t("transfer.amountPlaceholder")}
error={errors.amount?.message} error={errors.amount?.message}
disabled={walletsQuery.isLoading || insufficientWallets} disabled={walletsQuery.isLoading || insufficientWallets}
className="text-right font-semibold tabular-nums" className="font-semibold tabular-nums"
value={formatAmountInput(field.value, intlLocale)} value={formatAmountInput(field.value, intlLocale)}
onChange={(event) => field.onChange(parseAmountInput(event.target.value, intlLocale))} onChange={(event) => field.onChange(parseAmountInput(event.target.value, intlLocale))}
endAdornment={sourceWallet ? ( endAdornment={sourceWallet ? (
...@@ -308,6 +313,15 @@ export const TransferFormModal: React.FC<TransferFormModalProps> = ({ ...@@ -308,6 +313,15 @@ export const TransferFormModal: React.FC<TransferFormModalProps> = ({
/> />
)} )}
/> />
</div>
<div className="flex flex-col items-center justify-end pb-0.5">
<CalculatorButton
id="btn-transfer-calculator"
onClick={() => setIsCalculatorOpen(true)}
disabled={walletsQuery.isLoading || insufficientWallets}
/>
</div>
</div>
{sourceWallet && ( {sourceWallet && (
<div className="-mt-2 flex flex-wrap justify-between gap-1 px-1 text-xs font-semibold text-clay-text-muted"> <div className="-mt-2 flex flex-wrap justify-between gap-1 px-1 text-xs font-semibold text-clay-text-muted">
...@@ -345,5 +359,18 @@ export const TransferFormModal: React.FC<TransferFormModalProps> = ({ ...@@ -345,5 +359,18 @@ export const TransferFormModal: React.FC<TransferFormModalProps> = ({
/> />
</form> </form>
</Modal> </Modal>
<CalculatorModal
isOpen={isCalculatorOpen}
onClose={() => setIsCalculatorOpen(false)}
initialAmount={watch("amount")}
onApply={(calculatedAmount) => {
setValue("amount", calculatedAmount, {
shouldValidate: true,
shouldDirty: true,
});
}}
/>
</>
); );
}; };
import React, { useEffect, useMemo } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
import { Modal } from "@/components/ui/Modal"; import { Modal } from "@/components/ui/Modal";
import { CalculatorButton, CalculatorModal } from "@/components/ui/CalculatorModal";
import { WALLET_COLORS, WALLET_ICONS } from "@/lib/wallet-format"; import { WALLET_COLORS, WALLET_ICONS } from "@/lib/wallet-format";
import { Wallet, WalletInput } from "@/types/wallet"; import { Wallet, WalletInput } from "@/types/wallet";
import { WalletArtwork } from "@/components/shared/WalletArtwork"; import { WalletArtwork } from "@/components/shared/WalletArtwork";
...@@ -152,6 +153,7 @@ export const WalletFormModal: React.FC<WalletFormModalProps> = ({ ...@@ -152,6 +153,7 @@ export const WalletFormModal: React.FC<WalletFormModalProps> = ({
const selectedIcon = watch("icon"); const selectedIcon = watch("icon");
const selectedColor = watch("color"); const selectedColor = watch("color");
const currentCurrency = watch("currency")?.trim()?.toUpperCase() || "VND"; const currentCurrency = watch("currency")?.trim()?.toUpperCase() || "VND";
const [isCalculatorOpen, setIsCalculatorOpen] = useState(false);
const submitForm = (values: WalletFormValues) => { const submitForm = (values: WalletFormValues) => {
const currency = values.currency.trim().toUpperCase(); const currency = values.currency.trim().toUpperCase();
...@@ -170,6 +172,7 @@ export const WalletFormModal: React.FC<WalletFormModalProps> = ({ ...@@ -170,6 +172,7 @@ export const WalletFormModal: React.FC<WalletFormModalProps> = ({
}; };
return ( return (
<>
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={onClose}
...@@ -196,7 +199,8 @@ export const WalletFormModal: React.FC<WalletFormModalProps> = ({ ...@@ -196,7 +199,8 @@ export const WalletFormModal: React.FC<WalletFormModalProps> = ({
<Input label={t("wallet.form.name")} placeholder={t("wallet.form.namePlaceholder")} error={errors.name?.message} disabled={isSubmitting} {...register("name")} /> <Input label={t("wallet.form.name")} placeholder={t("wallet.form.namePlaceholder")} error={errors.name?.message} disabled={isSubmitting} {...register("name")} />
<div className="grid grid-cols-[1fr_96px] gap-3"> <div className="flex items-end gap-2">
<div className="flex-1 min-w-0">
<Controller <Controller
name="balance" name="balance"
control={control} control={control}
...@@ -208,14 +212,24 @@ export const WalletFormModal: React.FC<WalletFormModalProps> = ({ ...@@ -208,14 +212,24 @@ export const WalletFormModal: React.FC<WalletFormModalProps> = ({
placeholder="0" placeholder="0"
error={errors.balance?.message} error={errors.balance?.message}
disabled={isSubmitting} disabled={isSubmitting}
className="text-right tabular-nums" className="tabular-nums font-semibold"
value={formatBalanceInput(field.value, intlLocale, currentCurrency)} value={formatBalanceInput(field.value, intlLocale, currentCurrency)}
onChange={(event) => field.onChange(parseBalanceInput(event.target.value, intlLocale, currentCurrency))} onChange={(event) => field.onChange(parseBalanceInput(event.target.value, intlLocale, currentCurrency))}
/> />
)} )}
/> />
</div>
<div className="w-[96px] shrink-0">
<Input label={t("wallet.form.currency")} maxLength={3} placeholder="VND" error={errors.currency?.message} disabled={isSubmitting} className="uppercase" {...register("currency")} /> <Input label={t("wallet.form.currency")} maxLength={3} placeholder="VND" error={errors.currency?.message} disabled={isSubmitting} className="uppercase" {...register("currency")} />
</div> </div>
<div className="flex flex-col items-center justify-end pb-0.5">
<CalculatorButton
id="btn-wallet-calculator"
onClick={() => setIsCalculatorOpen(true)}
disabled={isSubmitting}
/>
</div>
</div>
<fieldset className="flex flex-col gap-2"> <fieldset className="flex flex-col gap-2">
<legend className="px-1 font-nunito text-sm font-semibold text-clay-text">{t("wallet.form.icon")}</legend> <legend className="px-1 font-nunito text-sm font-semibold text-clay-text">{t("wallet.form.icon")}</legend>
...@@ -277,5 +291,18 @@ export const WalletFormModal: React.FC<WalletFormModalProps> = ({ ...@@ -277,5 +291,18 @@ export const WalletFormModal: React.FC<WalletFormModalProps> = ({
)} )}
</form> </form>
</Modal> </Modal>
<CalculatorModal
isOpen={isCalculatorOpen}
onClose={() => setIsCalculatorOpen(false)}
initialAmount={watch("balance")}
onApply={(calculatedAmount) => {
setValue("balance", calculatedAmount, {
shouldValidate: true,
shouldDirty: true,
});
}}
/>
</>
); );
}; };
...@@ -6,7 +6,7 @@ ...@@ -6,7 +6,7 @@
"noImplicitAny": false, "noImplicitAny": false,
"preserveConstEnums": true, "preserveConstEnums": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"lib": ["dom", "es5", "es6", "es7", "es2017", "es2018"], "lib": ["dom", "dom.iterable", "esnext"],
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"esModuleInterop": true, "esModuleInterop": true,
"allowJs": 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