Commit 7cb89e5e authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/ai-financial-assistant-ui' into 'develop'

feat(reports): enhance AI insights and localize date inputs

See merge request !19
parents 96f51679 6f17fdd2
This diff is collapsed.
...@@ -65,6 +65,6 @@ Component / Page ...@@ -65,6 +65,6 @@ Component / Page
## Dev server và build ## Dev server và build
- `npm run start` chạy ZMP CLI: khung mô phỏng ở `http://localhost:3000`, nội dung app ở `http://localhost:2999`. - `npm run start` chạy ZMP CLI: khung mô phỏng ở `http://localhost:13580`, nội dung app ở `http://localhost:13579`.
- `index.html` phải nằm ở root repository. Nếu đặt trong `src/`, iframe nội dung sẽ trả 404 và giao diện có thể chỉ hiện màn hình đen. - `index.html` phải nằm ở root repository. Nếu đặt trong `src/`, iframe nội dung sẽ trả 404 và giao diện có thể chỉ hiện màn hình đen.
- `npm run build` phải sinh output tại `www/` ở root repository, không phải `src/www/`. - `npm run build` phải sinh output tại `www/` ở root repository, không phải `src/www/`.
...@@ -18,7 +18,7 @@ ...@@ -18,7 +18,7 @@
```bash ```bash
npm run start npm run start
``` ```
- Xác nhận khung mô phỏng `http://localhost:3000` và iframe app `http://localhost:2999` đều phản hồi. Cổng 2999 trả 404 thường có nghĩa `index.html` không còn ở root hoặc Vite `root` bị cấu hình sai. - Xác nhận khung mô phỏng `http://localhost:13580` và iframe app `http://localhost:13579` đều phản hồi. Cổng 13579 trả 404 thường có nghĩa `index.html` không còn ở root hoặc Vite `root` bị cấu hình sai.
- Xác nhận build output nằm trong `www/` ở root repository; không chấp nhận output nhầm tại `src/www/`. - Xác nhận build output nằm trong `www/` ở root repository; không chấp nhận output nhầm tại `src/www/`.
- Kiểm tra độ tương thích responsive trên các kích thước màn hình thiết bị di động (tối thiểu là tỷ lệ màn hình 375x812 tiêu chuẩn). - Kiểm tra độ tương thích responsive trên các kích thước màn hình thiết bị di động (tối thiểu là tỷ lệ màn hình 375x812 tiêu chuẩn).
......
...@@ -18,9 +18,9 @@ ...@@ -18,9 +18,9 @@
``` ```
1. **Start** the dev server: 1. **Start** the dev server:
```bash ```bash
zmp start npm run start
``` ```
1. **Open** `localhost:3000` in your browser. 1. **Open** `localhost:13580` in your browser. The Mini App content runs at `localhost:13579`.
## Deployment ## Deployment
......
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
], ],
"scripts": { "scripts": {
"login": "zmp login", "login": "zmp login",
"start": "zmp start", "start": "zmp start --port 13580",
"deploy": "zmp deploy", "deploy": "zmp deploy",
"build": "vite build" "build": "vite build"
}, },
......
...@@ -3,14 +3,13 @@ import { Text } from "zmp-ui"; ...@@ -3,14 +3,13 @@ import { Text } from "zmp-ui";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
function Clock() { function Clock() {
const { intlLocale } = useI18n(); const { formatDate } = useI18n();
const [time, setTime] = useState(""); const [time, setTime] = useState("");
useEffect(() => { useEffect(() => {
const updateClock = () => { const updateClock = () => {
const now = new Date(); const now = new Date();
const formattedTime = now.toLocaleString(intlLocale, { const formattedTime = formatDate(now, {
timeZone: "Asia/Ho_Chi_Minh",
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
second: "2-digit", second: "2-digit",
...@@ -24,7 +23,7 @@ function Clock() { ...@@ -24,7 +23,7 @@ function Clock() {
updateClock(); updateClock();
const intervalId = setInterval(updateClock, 1000); const intervalId = setInterval(updateClock, 1000);
return () => clearInterval(intervalId); return () => clearInterval(intervalId);
}, [intlLocale]); }, [formatDate]);
return <Text className="font-mono">{time}</Text>; return <Text className="font-mono">{time}</Text>;
} }
......
import React, { useEffect, useState } from "react";
import { Input, InputProps } from "@/components/ui/Input";
import { useI18n } from "@/i18n";
import { formatVietnameseDateInputValue } from "@/lib/date-format";
type LocalizedDateInputType = "date" | "datetime-local";
export interface LocalizedDateInputProps extends Omit<InputProps, "type"> {
type?: LocalizedDateInputType;
}
function stringValue(value: LocalizedDateInputProps["value"] | LocalizedDateInputProps["defaultValue"]): string {
return typeof value === "string" || typeof value === "number" ? String(value) : "";
}
export const LocalizedDateInput = React.forwardRef<HTMLInputElement, LocalizedDateInputProps>(
({ type = "date", value, defaultValue, onChange, placeholder, ...props }, ref) => {
const { locale, intlLocale } = useI18n();
const isControlled = value !== undefined;
const [uncontrolledValue, setUncontrolledValue] = useState(() => stringValue(defaultValue));
const rawValue = isControlled ? stringValue(value) : uncontrolledValue;
const isVietnamese = locale === "vi";
const includeTime = type === "datetime-local";
useEffect(() => {
if (!isControlled) setUncontrolledValue(stringValue(defaultValue));
}, [defaultValue, isControlled]);
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (!isControlled) setUncontrolledValue(event.target.value);
onChange?.(event);
};
return (
<Input
{...props}
ref={ref}
type={type}
value={value}
defaultValue={defaultValue}
lang={intlLocale}
placeholder={placeholder || (includeTime ? "DD/MM/YYYY HH:mm" : "DD/MM/YYYY")}
displayValue={isVietnamese
? formatVietnameseDateInputValue(rawValue, includeTime)
: undefined}
onChange={handleChange}
/>
);
},
);
LocalizedDateInput.displayName = "LocalizedDateInput";
...@@ -4,10 +4,11 @@ export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> ...@@ -4,10 +4,11 @@ export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement>
label?: string; label?: string;
error?: string; error?: string;
endAdornment?: React.ReactNode; endAdornment?: React.ReactNode;
displayValue?: string;
} }
export const Input = React.forwardRef<HTMLInputElement, InputProps>( export const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ label, error, endAdornment, className = "", id, ...props }, ref) => { ({ label, error, endAdornment, displayValue, className = "", id, placeholder, ...props }, ref) => {
const generatedId = React.useId(); const generatedId = React.useId();
const inputId = id || generatedId; const inputId = id || generatedId;
const errorId = `${inputId}-error`; const errorId = `${inputId}-error`;
...@@ -32,11 +33,23 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>( ...@@ -32,11 +33,23 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
focus:outline-none focus:border-clay-primary focus:ring-2 focus:ring-clay-primary/20 focus:outline-none focus:border-clay-primary focus:ring-2 focus:ring-clay-primary/20
disabled:opacity-60 disabled:cursor-not-allowed disabled:opacity-60 disabled:cursor-not-allowed
${endAdornment ? "pr-12" : ""} ${endAdornment ? "pr-12" : ""}
${displayValue !== undefined ? "finwise-localized-date" : ""}
${error ? "border-clay-expense focus:border-clay-expense focus:ring-clay-expense/20" : ""} ${error ? "border-clay-expense focus:border-clay-expense focus:ring-clay-expense/20" : ""}
${className} ${className}
`} `}
placeholder={placeholder}
{...props} {...props}
/> />
{displayValue !== undefined && (
<span
aria-hidden="true"
className={`pointer-events-none absolute inset-y-0 left-4 right-12 flex items-center font-nunito text-base ${
displayValue ? "text-clay-text" : "text-clay-text-muted/65"
} ${props.disabled ? "opacity-60" : ""}`}
>
{displayValue || placeholder}
</span>
)}
{endAdornment && ( {endAdornment && (
<div className="absolute inset-y-0 right-3 flex items-center"> <div className="absolute inset-y-0 right-3 flex items-center">
{endAdornment} {endAdornment}
......
...@@ -282,3 +282,15 @@ body { ...@@ -282,3 +282,15 @@ body {
var(--zaui-safe-area-inset-right, env(safe-area-inset-right, 0px)) + 236px var(--zaui-safe-area-inset-right, env(safe-area-inset-right, 0px)) + 236px
); );
} }
input.finwise-localized-date {
color: transparent;
-webkit-text-fill-color: transparent;
}
input.finwise-localized-date::-webkit-datetime-edit {
color: transparent;
}
input.finwise-localized-date::-webkit-calendar-picker-indicator {
opacity: 1;
}
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { normalizeDateTimeFormatOptions } from "@/lib/date-format";
import en from "./locales/en.json"; import en from "./locales/en.json";
import vi from "./locales/vi.json"; import vi from "./locales/vi.json";
...@@ -93,11 +94,15 @@ export const I18nProvider: React.FC<{ children: React.ReactNode }> = ({ children ...@@ -93,11 +94,15 @@ export const I18nProvider: React.FC<{ children: React.ReactNode }> = ({ children
} }
}, [intlLocale]); }, [intlLocale]);
const formatDate = useCallback((value: string | number | Date, options?: Intl.DateTimeFormatOptions) => const formatDate = useCallback((value: string | number | Date, options?: Intl.DateTimeFormatOptions) => {
new Intl.DateTimeFormat(intlLocale, { const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return new Intl.DateTimeFormat(intlLocale, {
timeZone: "Asia/Ho_Chi_Minh", timeZone: "Asia/Ho_Chi_Minh",
...options, ...normalizeDateTimeFormatOptions(intlLocale, options),
}).format(new Date(value)), [intlLocale]); }).format(date);
}, [intlLocale]);
const contextValue = useMemo<I18nContextValue>(() => ({ const contextValue = useMemo<I18nContextValue>(() => ({
locale, locale,
......
...@@ -960,11 +960,13 @@ ...@@ -960,11 +960,13 @@
"income": "Total income", "income": "Total income",
"expense": "Total expense", "expense": "Total expense",
"saving": "Net savings", "saving": "Net savings",
"budgetUsage": "Budget usage", "budgetUsage": "Budget overview",
"budgetCount": "Combined from {{count}} budgets", "budgetCount": "Combined from {{count}} budgets",
"noBudget": "No budgets in this period", "noBudget": "No budgets in this period",
"spent": "Spent", "spent": "Spent",
"remaining": "Remaining",
"limit": "Limit", "limit": "Limit",
"budgetStatuses": "Budget statuses",
"financialHealth": "Financial health", "financialHealth": "Financial health",
"healthHint": "Savings as a share of total income", "healthHint": "Savings as a share of total income",
"savingsRate": "Savings rate", "savingsRate": "Savings rate",
...@@ -1323,6 +1325,12 @@ ...@@ -1323,6 +1325,12 @@
"reScan": "Scan Another Receipt" "reScan": "Scan Another Receipt"
}, },
"preview": { "preview": {
"title": "AI summary and suggestions",
"subtitle": "The most important takeaways from the current report period",
"summaryLabel": "Overview",
"highlightsLabel": "{{count}} notable insights",
"scopeHint": "Detailed analysis will keep this report's date range and currency.",
"viewDetails": "View detailed analysis",
"errorTitle": "Unable to load AI insights" "errorTitle": "Unable to load AI insights"
}, },
"errors": { "errors": {
......
...@@ -969,11 +969,13 @@ ...@@ -969,11 +969,13 @@
"income": "Tổng thu", "income": "Tổng thu",
"expense": "Tổng chi", "expense": "Tổng chi",
"saving": "Tiết kiệm ròng", "saving": "Tiết kiệm ròng",
"budgetUsage": "Sử dụng ngân sách", "budgetUsage": "Tổng quan ngân sách",
"budgetCount": "Tổng hợp từ {{count}} ngân sách", "budgetCount": "Tổng hợp từ {{count}} ngân sách",
"noBudget": "Chưa có ngân sách trong kỳ", "noBudget": "Chưa có ngân sách trong kỳ",
"spent": "Đã chi", "spent": "Đã chi",
"remaining": "Còn lại",
"limit": "Hạn mức", "limit": "Hạn mức",
"budgetStatuses": "Tình trạng ngân sách",
"financialHealth": "Sức khỏe tài chính", "financialHealth": "Sức khỏe tài chính",
"healthHint": "Tỷ lệ tiết kiệm trên tổng thu", "healthHint": "Tỷ lệ tiết kiệm trên tổng thu",
"savingsRate": "Tỷ lệ tiết kiệm", "savingsRate": "Tỷ lệ tiết kiệm",
...@@ -1332,6 +1334,12 @@ ...@@ -1332,6 +1334,12 @@
"reScan": "Quét hóa đơn khác" "reScan": "Quét hóa đơn khác"
}, },
"preview": { "preview": {
"title": "Tóm tắt và đề xuất từ AI",
"subtitle": "Những điểm quan trọng nhất trong kỳ báo cáo hiện tại",
"summaryLabel": "Nhận định tổng quan",
"highlightsLabel": "{{count}} điểm đáng chú ý",
"scopeHint": "Phân tích chi tiết sẽ tiếp tục sử dụng khoảng thời gian và tiền tệ của báo cáo này.",
"viewDetails": "Xem phân tích chi tiết",
"errorTitle": "Không thể tải phân tích AI" "errorTitle": "Không thể tải phân tích AI"
}, },
"errors": { "errors": {
......
import { normalizeDateTimeFormatOptions } from '@/lib/date-format';
export const BUSINESS_TIME_ZONE = 'Asia/Ho_Chi_Minh' as const; export const BUSINESS_TIME_ZONE = 'Asia/Ho_Chi_Minh' as const;
function zonedParts(instant: Date) { function zonedParts(instant: Date) {
...@@ -56,6 +58,9 @@ export function addCalendarDays(date: string, amount: number): string { ...@@ -56,6 +58,9 @@ export function addCalendarDays(date: string, amount: number): string {
export function formatBusinessDate(date: string, locale: string, options?: Intl.DateTimeFormatOptions) { export function formatBusinessDate(date: string, locale: string, options?: Intl.DateTimeFormatOptions) {
const [year, month, day] = date.slice(0, 10).split('-').map(Number); const [year, month, day] = date.slice(0, 10).split('-').map(Number);
return new Intl.DateTimeFormat(locale, { timeZone: 'UTC', ...options }) return new Intl.DateTimeFormat(locale, {
timeZone: 'UTC',
...normalizeDateTimeFormatOptions(locale, options),
})
.format(new Date(Date.UTC(year, month - 1, day))); .format(new Date(Date.UTC(year, month - 1, day)));
} }
const VIETNAMESE_LOCALE_PATTERN = /^vi(?:-|$)/i;
const DEFAULT_DATE_OPTIONS: Intl.DateTimeFormatOptions = {
day: "2-digit",
month: "2-digit",
year: "numeric",
};
const DATE_OPTION_KEYS: ReadonlyArray<keyof Intl.DateTimeFormatOptions> = [
"weekday",
"era",
"year",
"month",
"day",
"dateStyle",
];
const TIME_OPTION_KEYS: ReadonlyArray<keyof Intl.DateTimeFormatOptions> = [
"hour",
"minute",
"second",
"timeStyle",
];
export function normalizeDateTimeFormatOptions(
locale: string,
options?: Intl.DateTimeFormatOptions,
): Intl.DateTimeFormatOptions {
const normalized = options ? { ...options } : { ...DEFAULT_DATE_OPTIONS };
const hasDateOptions = DATE_OPTION_KEYS.some((key) => normalized[key] !== undefined);
const hasTimeOptions = TIME_OPTION_KEYS.some((key) => normalized[key] !== undefined);
if (!hasDateOptions && !hasTimeOptions) {
return { ...DEFAULT_DATE_OPTIONS };
}
if (!VIETNAMESE_LOCALE_PATTERN.test(locale) || normalized.dateStyle) {
return normalized;
}
// Vietnamese dates consistently use day/month/year, with numeric date parts.
if (normalized.day !== undefined) normalized.day = "2-digit";
if (normalized.month !== undefined) normalized.month = "2-digit";
if (normalized.year !== undefined) normalized.year = "numeric";
return normalized;
}
export function formatVietnameseDateInputValue(
value: string,
includeTime = false,
): string {
const pattern = includeTime
? /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/
: /^(\d{4})-(\d{2})-(\d{2})$/;
const match = pattern.exec(value);
if (!match) return "";
const [, year, month, day, hour, minute] = match;
return `${day}/${month}/${year}${includeTime ? ` ${hour}:${minute}` : ""}`;
}
import type { AIAnalysisScope } from "@/types/ai";
export interface FinWiseNavigationState { export interface FinWiseNavigationState {
fromNotifications?: boolean; fromNotifications?: boolean;
fromAIRecommendations?: boolean; fromAIRecommendations?: boolean;
fromReports?: boolean;
tab?: "chat" | "insights" | "ocr"; tab?: "chat" | "insights" | "ocr";
insightsTab?: "insights" | "recommendations"; insightsTab?: "insights" | "recommendations";
analysisScope?: AIAnalysisScope;
} }
export function isFromNotifications(state: unknown): boolean { export function isFromNotifications(state: unknown): boolean {
...@@ -14,3 +18,8 @@ export function isFromAIRecommendations(state: unknown): boolean { ...@@ -14,3 +18,8 @@ export function isFromAIRecommendations(state: unknown): boolean {
if (!state || typeof state !== "object") return false; if (!state || typeof state !== "object") return false;
return (state as FinWiseNavigationState).fromAIRecommendations === true; return (state as FinWiseNavigationState).fromAIRecommendations === true;
} }
export function isFromReports(state: unknown): boolean {
if (!state || typeof state !== "object") return false;
return (state as FinWiseNavigationState).fromReports === true;
}
...@@ -15,6 +15,7 @@ import { useI18n } from "@/i18n"; ...@@ -15,6 +15,7 @@ import { useI18n } from "@/i18n";
import { useAIInsights, useAIRecommendations } from "@/hooks/use-ai-assistant"; import { useAIInsights, useAIRecommendations } from "@/hooks/use-ai-assistant";
import { FinWiseNavigationState } from "@/lib/navigation-state"; import { FinWiseNavigationState } from "@/lib/navigation-state";
import { import {
AIAnalysisScope,
AIFocusArea, AIFocusArea,
AIRecommendationPriority, AIRecommendationPriority,
PriorityLevel, PriorityLevel,
...@@ -28,10 +29,14 @@ type AIInsightsTab = NonNullable<FinWiseNavigationState["insightsTab"]>; ...@@ -28,10 +29,14 @@ type AIInsightsTab = NonNullable<FinWiseNavigationState["insightsTab"]>;
interface AIInsightsViewProps { interface AIInsightsViewProps {
initialActiveTab?: AIInsightsTab; initialActiveTab?: AIInsightsTab;
analysisScope?: AIAnalysisScope;
navigationState?: FinWiseNavigationState | null;
} }
export const AIInsightsView: React.FC<AIInsightsViewProps> = ({ export const AIInsightsView: React.FC<AIInsightsViewProps> = ({
initialActiveTab = "insights", initialActiveTab = "insights",
analysisScope,
navigationState,
}) => { }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { t, formatCurrency } = useI18n(); const { t, formatCurrency } = useI18n();
...@@ -44,13 +49,20 @@ export const AIInsightsView: React.FC<AIInsightsViewProps> = ({ ...@@ -44,13 +49,20 @@ export const AIInsightsView: React.FC<AIInsightsViewProps> = ({
setActiveTab(nextTab); setActiveTab(nextTab);
navigate("/ai-assistant", { navigate("/ai-assistant", {
replace: true, replace: true,
state: { tab: "insights", insightsTab: nextTab } satisfies FinWiseNavigationState, state: {
...navigationState,
tab: "insights",
insightsTab: nextTab,
} satisfies FinWiseNavigationState,
}); });
}; };
const insightsQuery = useAIInsights({ focus }, activeTab === "insights"); const insightsQuery = useAIInsights(
{ ...analysisScope, focus },
activeTab === "insights"
);
const recommendationsQuery = useAIRecommendations( const recommendationsQuery = useAIRecommendations(
{ priority }, { ...analysisScope, priority },
activeTab === "recommendations" activeTab === "recommendations"
); );
......
...@@ -6,7 +6,7 @@ import { ...@@ -6,7 +6,7 @@ import {
IconGradients, IconGradients,
} from "@/components/ui/icons"; } from "@/components/ui/icons";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { FinWiseNavigationState } from "@/lib/navigation-state"; import { FinWiseNavigationState, isFromReports } from "@/lib/navigation-state";
import { AIChatView } from "./components/AIChatView"; import { AIChatView } from "./components/AIChatView";
import { AIInsightsView } from "./components/AIInsightsView"; import { AIInsightsView } from "./components/AIInsightsView";
import { ReceiptScannerView } from "./components/ReceiptScannerView"; import { ReceiptScannerView } from "./components/ReceiptScannerView";
...@@ -39,7 +39,11 @@ const AIAssistantPage: React.FC = () => { ...@@ -39,7 +39,11 @@ const AIAssistantPage: React.FC = () => {
return ( return (
<Page className="page"> <Page className="page">
<Header title={t("ai.shortHeader")} showBackIcon onBackClick={() => navigate("/")} /> <Header
title={t("ai.shortHeader")}
showBackIcon
onBackClick={() => isFromReports(navigationState) ? navigate(-1) : navigate("/")}
/>
<IconGradients /> <IconGradients />
<main className="mx-auto mt-4 flex w-full max-w-4xl flex-col gap-4 px-0 pb-12 sm:px-4"> <main className="mx-auto mt-4 flex w-full max-w-4xl flex-col gap-4 px-0 pb-12 sm:px-4">
...@@ -112,7 +116,11 @@ const AIAssistantPage: React.FC = () => { ...@@ -112,7 +116,11 @@ const AIAssistantPage: React.FC = () => {
role="tabpanel" role="tabpanel"
aria-labelledby="ai-tab-insights" aria-labelledby="ai-tab-insights"
> >
<AIInsightsView initialActiveTab={navigationState?.insightsTab || "insights"} /> <AIInsightsView
initialActiveTab={navigationState?.insightsTab || "insights"}
analysisScope={navigationState?.analysisScope}
navigationState={navigationState}
/>
</div> </div>
)} )}
{visitedTabs.has("ocr") && ( {visitedTabs.has("ocr") && (
......
...@@ -3,6 +3,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; ...@@ -3,6 +3,7 @@ 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 { 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";
...@@ -146,6 +147,8 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({ ...@@ -146,6 +147,8 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({
const selectedType = watch("type") as BudgetType; const selectedType = watch("type") as BudgetType;
const selectedPeriod = watch("period") as BudgetPeriod; const selectedPeriod = watch("period") as BudgetPeriod;
const selectedStartDate = watch("startDate");
const selectedEndDate = watch("endDate");
useEffect(() => { useEffect(() => {
if (selectedType === "OVERALL") { if (selectedType === "OVERALL") {
...@@ -283,9 +286,9 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({ ...@@ -283,9 +286,9 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({
)} )}
<div className={`grid grid-cols-1 gap-3 ${selectedPeriod === "CUSTOM" ? "sm:grid-cols-2" : ""}`}> <div className={`grid grid-cols-1 gap-3 ${selectedPeriod === "CUSTOM" ? "sm:grid-cols-2" : ""}`}>
<Input type="date" label={t("budget.form.startDate")} error={errors.startDate?.message} {...register("startDate")} /> <LocalizedDateInput type="date" value={selectedStartDate} label={t("budget.form.startDate")} error={errors.startDate?.message} {...register("startDate")} />
{selectedPeriod === "CUSTOM" && ( {selectedPeriod === "CUSTOM" && (
<Input type="date" label={t("budget.form.endDate")} error={errors.endDate?.message} {...register("endDate")} /> <LocalizedDateInput type="date" value={selectedEndDate} label={t("budget.form.endDate")} error={errors.endDate?.message} {...register("endDate")} />
)} )}
</div> </div>
{selectedPeriod !== "CUSTOM" && <p className="-mt-2 px-1 clay-caption">{t("budget.form.autoEndDateHint")}</p>} {selectedPeriod !== "CUSTOM" && <p className="-mt-2 px-1 clay-caption">{t("budget.form.autoEndDateHint")}</p>}
......
...@@ -3,6 +3,7 @@ import { Header, Page, useLocation, useNavigate, useSnackbar } from "zmp-ui"; ...@@ -3,6 +3,7 @@ import { Header, Page, useLocation, useNavigate, useSnackbar } from "zmp-ui";
import { Button } from "@/components/ui/Button"; 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 { LocalizedDateInput } from "@/components/shared/LocalizedDateInput";
import { Select } from "@/components/ui/Select"; import { Select } from "@/components/ui/Select";
import { Tabs } from "@/components/ui/Tabs"; import { Tabs } from "@/components/ui/Tabs";
import { BudgetIcon, IconGradients, PlusIcon } from "@/components/ui/icons"; import { BudgetIcon, IconGradients, PlusIcon } from "@/components/ui/icons";
...@@ -244,7 +245,7 @@ const BudgetsPage: React.FC = () => { ...@@ -244,7 +245,7 @@ const BudgetsPage: React.FC = () => {
</div> </div>
{timeFilter === "DATE" && ( {timeFilter === "DATE" && (
<Input type="date" label={t("budget.filters.activeDate")} value={filterDate} onChange={(event) => setFilterDate(event.target.value)} /> <LocalizedDateInput type="date" label={t("budget.filters.activeDate")} value={filterDate} onChange={(event) => setFilterDate(event.target.value)} />
)} )}
<label className="flex cursor-pointer items-center gap-3 rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed"> <label className="flex cursor-pointer items-center gap-3 rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed">
......
...@@ -3,6 +3,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; ...@@ -3,6 +3,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { 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 { 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";
...@@ -63,6 +64,8 @@ export const ReminderFormModal: React.FC<ReminderFormModalProps> = ({ isOpen, re ...@@ -63,6 +64,8 @@ export const ReminderFormModal: React.FC<ReminderFormModalProps> = ({ isOpen, re
const formId = reminder ? `edit-reminder-${reminder.id}` : "create-reminder"; const formId = reminder ? `edit-reminder-${reminder.id}` : "create-reminder";
const { register, handleSubmit, reset, watch, formState: { errors } } = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues }); const { register, handleSubmit, reset, watch, formState: { errors } } = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues });
const frequency = watch("frequency") as ReminderFrequency; const frequency = watch("frequency") as ReminderFrequency;
const remindAt = watch("remindAt");
const endAt = watch("endAt");
useEffect(() => { if (isOpen) reset(defaultValues); }, [defaultValues, isOpen, reset]); useEffect(() => { if (isOpen) reset(defaultValues); }, [defaultValues, isOpen, reset]);
...@@ -82,9 +85,9 @@ export const ReminderFormModal: React.FC<ReminderFormModalProps> = ({ isOpen, re ...@@ -82,9 +85,9 @@ export const ReminderFormModal: React.FC<ReminderFormModalProps> = ({ isOpen, re
<Select label={t("reminder.form.type")} disabled={isSubmitting} options={[{ value: "GENERAL", label: t("reminder.type.GENERAL") }, { value: "RECURRING_PAYMENT", label: t("reminder.type.RECURRING_PAYMENT") }]} error={errors.type?.message} {...register("type")} /> <Select label={t("reminder.form.type")} disabled={isSubmitting} options={[{ value: "GENERAL", label: t("reminder.type.GENERAL") }, { value: "RECURRING_PAYMENT", label: t("reminder.type.RECURRING_PAYMENT") }]} error={errors.type?.message} {...register("type")} />
<Input label={t("reminder.form.title")} maxLength={160} disabled={isSubmitting} placeholder={t("reminder.form.titlePlaceholder")} error={errors.title?.message} {...register("title")} /> <Input label={t("reminder.form.title")} maxLength={160} disabled={isSubmitting} placeholder={t("reminder.form.titlePlaceholder")} error={errors.title?.message} {...register("title")} />
<div className="flex flex-col gap-2"><label htmlFor={`${formId}-message`} className="px-1 font-nunito text-sm font-semibold text-clay-text">{t("reminder.form.message")}</label><textarea id={`${formId}-message`} rows={3} maxLength={2000} disabled={isSubmitting} placeholder={t("reminder.form.messagePlaceholder")} className="w-full resize-none rounded-clay-sm border border-transparent bg-clay-bg px-4 py-3 font-nunito text-base text-clay-text shadow-clay-pressed transition-all duration-200 ease-in-out placeholder-clay-text-muted/65 focus:border-clay-primary focus:outline-none focus:ring-2 focus:ring-clay-primary/20 disabled:opacity-60" {...register("message")} />{errors.message?.message && <span className="px-1 font-nunito text-xs text-clay-expense">{errors.message.message}</span>}</div> <div className="flex flex-col gap-2"><label htmlFor={`${formId}-message`} className="px-1 font-nunito text-sm font-semibold text-clay-text">{t("reminder.form.message")}</label><textarea id={`${formId}-message`} rows={3} maxLength={2000} disabled={isSubmitting} placeholder={t("reminder.form.messagePlaceholder")} className="w-full resize-none rounded-clay-sm border border-transparent bg-clay-bg px-4 py-3 font-nunito text-base text-clay-text shadow-clay-pressed transition-all duration-200 ease-in-out placeholder-clay-text-muted/65 focus:border-clay-primary focus:outline-none focus:ring-2 focus:ring-clay-primary/20 disabled:opacity-60" {...register("message")} />{errors.message?.message && <span className="px-1 font-nunito text-xs text-clay-expense">{errors.message.message}</span>}</div>
<Input type="datetime-local" label={t("reminder.form.remindAt")} disabled={isSubmitting} error={errors.remindAt?.message} {...register("remindAt")} /> <LocalizedDateInput type="datetime-local" value={remindAt} label={t("reminder.form.remindAt")} disabled={isSubmitting} error={errors.remindAt?.message} {...register("remindAt")} />
<Select label={t("reminder.form.frequency")} disabled={isSubmitting} options={["ONCE", "DAILY", "WEEKLY", "MONTHLY", "YEARLY"].map((value) => ({ value, label: t(`reminder.frequencyOption.${value}`) }))} error={errors.frequency?.message} {...register("frequency")} /> <Select label={t("reminder.form.frequency")} disabled={isSubmitting} options={["ONCE", "DAILY", "WEEKLY", "MONTHLY", "YEARLY"].map((value) => ({ value, label: t(`reminder.frequencyOption.${value}`) }))} error={errors.frequency?.message} {...register("frequency")} />
{frequency !== "ONCE" && <><Input type="number" min={1} max={365} label={t("reminder.form.repeatInterval")} disabled={isSubmitting} error={errors.repeatInterval?.message} {...register("repeatInterval", { valueAsNumber: true })} /><Input type="datetime-local" label={t("reminder.form.endAt")} disabled={isSubmitting} error={errors.endAt?.message} {...register("endAt")} /></>} {frequency !== "ONCE" && <><Input type="number" min={1} max={365} label={t("reminder.form.repeatInterval")} disabled={isSubmitting} error={errors.repeatInterval?.message} {...register("repeatInterval", { valueAsNumber: true })} /><LocalizedDateInput type="datetime-local" value={endAt} label={t("reminder.form.endAt")} disabled={isSubmitting} error={errors.endAt?.message} {...register("endAt")} /></>}
<label className="flex cursor-pointer items-center gap-3 rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed"><input type="checkbox" className="h-4 w-4 accent-clay-primary" disabled={isSubmitting} {...register("isActive")} /><span><span className="block font-nunito text-sm font-bold text-clay-text">{t("reminder.form.active")}</span><span className="clay-caption block">{t("reminder.form.activeHint")}</span></span></label> <label className="flex cursor-pointer items-center gap-3 rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed"><input type="checkbox" className="h-4 w-4 accent-clay-primary" disabled={isSubmitting} {...register("isActive")} /><span><span className="block font-nunito text-sm font-bold text-clay-text">{t("reminder.form.active")}</span><span className="clay-caption block">{t("reminder.form.activeHint")}</span></span></label>
</form> </form>
</Modal> </Modal>
......
...@@ -7,13 +7,18 @@ import { ...@@ -7,13 +7,18 @@ import {
ChevronRightIcon, ChevronRightIcon,
LightbulbIcon, LightbulbIcon,
NotificationIcon, NotificationIcon,
ReportIcon,
} from "@/components/ui/icons"; } from "@/components/ui/icons";
import { useI18n } from "@/i18n";
import { useAIInsights } from "@/hooks/use-ai-assistant"; import { useAIInsights } from "@/hooks/use-ai-assistant";
import { useI18n } from "@/i18n";
import { FinWiseNavigationState } from "@/lib/navigation-state";
import { AIInsightsData } from "@/types/ai";
import { ReportPeriodPreset } from "@/types/report"; import { ReportPeriodPreset } from "@/types/report";
export interface AIInsightContext { export interface AIInsightContext {
period: ReportPeriodPreset; period: ReportPeriodPreset;
dateFrom: string;
dateTo: string;
currency: string; currency: string;
walletId?: string; walletId?: string;
} }
...@@ -22,16 +27,90 @@ interface AIInsightsPreviewProps { ...@@ -22,16 +27,90 @@ interface AIInsightsPreviewProps {
context: AIInsightContext; context: AIInsightContext;
} }
type PreviewItemKind = "trend" | "anomaly" | "recommendation";
interface PreviewItem {
key: string;
kind: PreviewItemKind;
title: string;
description: string;
}
function getPreviewItems(insights: AIInsightsData): PreviewItem[] {
const primaryItems: PreviewItem[] = [
insights.anomalies[0] && {
key: "anomaly-0",
kind: "anomaly",
title: insights.anomalies[0].title,
description: insights.anomalies[0].description,
},
insights.trends[0] && {
key: "trend-0",
kind: "trend",
title: insights.trends[0].title,
description: insights.trends[0].description,
},
insights.recommendations[0] && {
key: "recommendation-0",
kind: "recommendation",
title: insights.recommendations[0].title,
description: insights.recommendations[0].description,
},
].filter((item): item is PreviewItem => Boolean(item));
const remainingItems: PreviewItem[] = [
...insights.anomalies.slice(1).map((item, index) => ({
key: `anomaly-${index + 1}`,
kind: "anomaly" as const,
title: item.title,
description: item.description,
})),
...insights.trends.slice(1).map((item, index) => ({
key: `trend-${index + 1}`,
kind: "trend" as const,
title: item.title,
description: item.description,
})),
...insights.recommendations.slice(1).map((item, index) => ({
key: `recommendation-${index + 1}`,
kind: "recommendation" as const,
title: item.title,
description: item.description,
})),
];
return [...primaryItems, ...remainingItems].slice(0, 3);
}
export const AIInsightsPreview: React.FC<AIInsightsPreviewProps> = ({ context }) => { export const AIInsightsPreview: React.FC<AIInsightsPreviewProps> = ({ context }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useI18n(); const { t } = useI18n();
const insightsQuery = useAIInsights( const analysisScope = {
{ currency: context.currency }, dateFrom: context.dateFrom,
Boolean(context.currency) dateTo: context.dateTo,
); currency: context.currency,
};
const insightsQuery = useAIInsights(analysisScope, Boolean(context.currency));
const insights = insightsQuery.data?.data; const insights = insightsQuery.data?.data;
const previewItems = insights ? getPreviewItems(insights) : [];
const openDetailedInsights = () => {
navigate("/ai-assistant", {
state: {
fromReports: true,
tab: "insights",
insightsTab: "insights",
analysisScope,
} satisfies FinWiseNavigationState,
});
};
const itemStyles: Record<PreviewItemKind, string> = {
trend: "border-clay-info/30 bg-clay-info/10",
anomaly: "border-clay-warning/35 bg-clay-warning/10",
recommendation: "border-clay-income/35 bg-clay-income/10",
};
return ( return (
<Card <Card
...@@ -46,36 +125,26 @@ export const AIInsightsPreview: React.FC<AIInsightsPreviewProps> = ({ context }) ...@@ -46,36 +125,26 @@ export const AIInsightsPreview: React.FC<AIInsightsPreviewProps> = ({ context })
<AIAssistantIcon size={28} aria-hidden="true" /> <AIAssistantIcon size={28} aria-hidden="true" />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2"> <div className="flex min-w-0 flex-wrap items-center gap-2">
<h2 className="min-w-0 clay-title-h2">{t("ai.insights.title")}</h2> <h2 className="min-w-0 clay-title-h2">{t("ai.preview.title")}</h2>
<span className="rounded-full bg-clay-income/20 px-3 py-0.5 font-nunito text-[10px] font-bold uppercase tracking-wide text-clay-income"> <span className="rounded-full bg-clay-income/20 px-3 py-0.5 font-nunito text-[10px] font-bold uppercase tracking-wide text-clay-income">
{t("ai.liveBadge")} {t("ai.liveBadge")}
</span> </span>
</div> </div>
<Button <p className="clay-caption mt-1">{t("ai.preview.subtitle")}</p>
variant="secondary"
className="gap-1 px-3 py-1 text-xs font-bold"
onClick={() => navigate("/ai-assistant", { state: { tab: "insights" } })}
>
{t("ai.shortHeader")}
<ChevronRightIcon size={14} aria-hidden="true" />
</Button>
</div>
<p className="clay-caption mt-1">{t("ai.insights.subtitle")}</p>
</div> </div>
</div> </div>
{/* Summary Content */}
<div className="relative mt-4"> <div className="relative mt-4">
{insightsQuery.isLoading ? ( {insightsQuery.isLoading ? (
<div <div
className="animate-pulse space-y-2 rounded-clay-sm border border-clay-highlight/30 bg-clay-bg/60 p-3" className="animate-pulse space-y-3 rounded-clay-sm border border-clay-highlight/30 bg-clay-bg/60 p-4"
role="status" role="status"
aria-label={t("common.loading")} aria-label={t("common.loading")}
> >
<div className="h-3 w-full rounded-full bg-clay-text/10" /> <div className="h-3 w-full rounded-full bg-clay-text/10" />
<div className="h-3 w-4/5 rounded-full bg-clay-text/10" /> <div className="h-3 w-5/6 rounded-full bg-clay-text/10" />
<div className="h-12 w-full rounded-clay-sm bg-clay-text/10" />
<span className="sr-only">{t("common.loading")}</span> <span className="sr-only">{t("common.loading")}</span>
</div> </div>
) : insightsQuery.isError ? ( ) : insightsQuery.isError ? (
...@@ -100,35 +169,66 @@ export const AIInsightsPreview: React.FC<AIInsightsPreviewProps> = ({ context }) ...@@ -100,35 +169,66 @@ export const AIInsightsPreview: React.FC<AIInsightsPreviewProps> = ({ context })
</Button> </Button>
</div> </div>
) : insights ? ( ) : insights ? (
<div className="rounded-clay-sm bg-clay-bg p-3.5 shadow-clay-pressed border border-clay-highlight/30"> <div className="space-y-4">
<p className="font-nunito text-xs text-clay-text leading-relaxed line-clamp-3"> <div className="rounded-clay-sm border border-clay-highlight/30 bg-clay-bg p-4 shadow-clay-pressed">
<div className="mb-2 flex items-center gap-2 text-clay-primary">
<ReportIcon size={16} aria-hidden="true" />
<h3 className="font-baloo text-sm font-bold">{t("ai.preview.summaryLabel")}</h3>
</div>
<p className="whitespace-pre-wrap font-nunito text-xs leading-relaxed text-clay-text">
{insights.summary} {insights.summary}
</p> </p>
{insights.anomalies.length > 0 && (
<div className="mt-2 text-[11px] font-bold text-clay-warning flex items-center gap-1">
<NotificationIcon size={14} aria-hidden="true" />
<span>
{insights.anomalies.length} {t("ai.insights.sections.anomalies").toLowerCase()}
</span>
</div>
)}
</div> </div>
) : (
{previewItems.length > 0 && (
<div>
<h3 className="mb-2 px-1 font-baloo text-sm font-bold text-clay-text">
{t("ai.preview.highlightsLabel", { count: previewItems.length })}
</h3>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3"> <div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{["spending", "reduce", "anomalies"].map((key) => ( {previewItems.map((item) => (
<button <div
key={key} key={item.key}
type="button" className={`rounded-clay-sm border p-3 ${itemStyles[item.kind]}`}
className="flex items-center gap-2 rounded-clay-sm bg-clay-bg p-3 text-left shadow-clay-pressed transition-all duration-200 ease-in-out hover:bg-clay-primary/5 focus:outline-none focus:ring-2 focus:ring-clay-primary/35"
onClick={() => navigate("/ai-assistant", { state: { tab: "chat" } })}
> >
<LightbulbIcon size={17} aria-hidden="true" className="shrink-0 text-clay-warning" /> <div className="mb-1 flex items-start gap-2">
<p className="font-nunito text-xs font-bold text-clay-text"> {item.kind === "anomaly" ? (
{t(`ai.chat.quickPrompts.${key}`)} <NotificationIcon size={16} aria-hidden="true" className="mt-0.5 shrink-0 text-clay-warning" />
) : (
<LightbulbIcon size={16} aria-hidden="true" className="mt-0.5 shrink-0 text-clay-primary" />
)}
<p className="font-nunito text-xs font-bold leading-snug text-clay-text">
{item.title}
</p>
</div>
<p className="font-nunito text-[11px] leading-relaxed text-clay-text-muted">
{item.description}
</p> </p>
</button> </div>
))} ))}
</div> </div>
</div>
)}
<div className="border-t border-clay-text/10 pt-1">
<p className="mb-3 text-center font-nunito text-[11px] font-semibold text-clay-text-muted">
{t("ai.preview.scopeHint")}
</p>
<Button
fullWidth
className="gap-2 text-sm font-bold"
onClick={openDetailedInsights}
>
{t("ai.preview.viewDetails")}
<ChevronRightIcon size={16} aria-hidden="true" />
</Button>
</div>
</div>
) : (
<div className="rounded-clay-sm border border-clay-highlight/30 bg-clay-bg p-4 text-center shadow-clay-pressed">
<p className="font-nunito text-xs font-bold text-clay-text">{t("ai.insights.empty")}</p>
<p className="mt-1 font-nunito text-[11px] text-clay-text-muted">{t("ai.insights.emptyHint")}</p>
</div>
)} )}
</div> </div>
</Card> </Card>
......
...@@ -18,10 +18,30 @@ const safeNumber = (value: string | null | undefined): number => { ...@@ -18,10 +18,30 @@ const safeNumber = (value: string | null | undefined): number => {
export const OverviewMetrics: React.FC<OverviewMetricsProps> = ({ metric, wallets, budgetSummaries }) => { export const OverviewMetrics: React.FC<OverviewMetricsProps> = ({ metric, wallets, budgetSummaries }) => {
const { t, formatCurrency, formatNumber } = useI18n(); const { t, formatCurrency, formatNumber } = useI18n();
const money = (value: string) => formatCurrency(safeNumber(value), metric.currency); const money = (value: string) => formatCurrency(safeNumber(value), metric.currency);
const budget = budgetSummaries.find((summary) => summary.currency === metric.currency && summary.type === "OVERALL") const availableBudgetSummaries = budgetSummaries.filter(
|| budgetSummaries.find((summary) => summary.currency === metric.currency); (summary) => summary.currency === metric.currency && summary.budgetCount > 0
);
const budget = availableBudgetSummaries.find((summary) => summary.type === "OVERALL")
|| availableBudgetSummaries[0];
const usage = safeNumber(budget?.usagePercentage); const usage = safeNumber(budget?.usagePercentage);
const budgetType = usage > 100 ? "expense" : usage >= 80 ? "warning" : "income"; const budgetType = usage > 100 ? "expense" : usage >= 80 ? "warning" : "income";
const budgetStatuses = budget ? [
{
key: "on_track",
count: budget.onTrackCount,
tone: "bg-clay-income/15 text-clay-income",
},
{
key: "near_limit",
count: budget.nearLimitCount,
tone: "bg-clay-warning/15 text-clay-warning",
},
{
key: "exceeded",
count: budget.exceededCount,
tone: "bg-clay-expense/15 text-clay-expense",
},
] as const : [];
const metricCards = [ const metricCards = [
{ key: "balance", label: t("report.metrics.balance"), value: money(metric.currentBalance), tone: "bg-clay-info/15 text-clay-info", symbol: "=" }, { key: "balance", label: t("report.metrics.balance"), value: money(metric.currentBalance), tone: "bg-clay-info/15 text-clay-info", symbol: "=" },
{ key: "income", label: t("report.metrics.income"), value: money(metric.income), tone: "bg-clay-income/15 text-clay-income", symbol: "↑" }, { key: "income", label: t("report.metrics.income"), value: money(metric.income), tone: "bg-clay-income/15 text-clay-income", symbol: "↑" },
...@@ -55,15 +75,44 @@ export const OverviewMetrics: React.FC<OverviewMetricsProps> = ({ metric, wallet ...@@ -55,15 +75,44 @@ export const OverviewMetrics: React.FC<OverviewMetricsProps> = ({ metric, wallet
<h3 className="clay-title-h3">{t("report.metrics.budgetUsage")}</h3> <h3 className="clay-title-h3">{t("report.metrics.budgetUsage")}</h3>
<p className="clay-caption">{budget ? t("report.metrics.budgetCount", { count: budget.budgetCount }) : t("report.metrics.noBudget")}</p> <p className="clay-caption">{budget ? t("report.metrics.budgetCount", { count: budget.budgetCount }) : t("report.metrics.noBudget")}</p>
</div> </div>
<span className="rounded-full bg-clay-warning/15 px-3 py-1 font-baloo text-sm font-bold text-clay-text"> <span className={`rounded-full px-3 py-1 font-baloo text-sm font-bold ${budget ? "bg-clay-warning/15 text-clay-text" : "bg-clay-bg text-clay-text-muted"}`}>
{formatNumber(usage, { maximumFractionDigits: 1 })}% {budget ? `${formatNumber(usage, { maximumFractionDigits: 1 })}%` : "—"}
</span> </span>
</div> </div>
<ProgressBar value={usage} type={budgetType} className="mt-4" /> <ProgressBar value={usage} type={budgetType} className="mt-4" />
{budget && ( {budget && (
<div className="mt-3 flex justify-between gap-3 text-xs font-semibold text-clay-text-muted"> <div className="mt-3 space-y-3">
<span>{t("report.metrics.spent")}: {money(budget.spentAmount)}</span> <div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
<span className="text-right">{t("report.metrics.limit")}: {money(budget.budgetAmount)}</span> <div className="min-w-0 rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed">
<p className="clay-caption">{t("report.metrics.spent")}</p>
<p className="mt-1 break-words font-baloo text-sm font-bold text-clay-expense">
{money(budget.spentAmount)}
</p>
</div>
<div className="min-w-0 rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed">
<p className="clay-caption">{t("report.metrics.remaining")}</p>
<p className={`mt-1 break-words font-baloo text-sm font-bold ${safeNumber(budget.remainingAmount) >= 0 ? "text-clay-income" : "text-clay-expense"}`}>
{money(budget.remainingAmount)}
</p>
</div>
<div className="col-span-2 min-w-0 rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed sm:col-span-1">
<p className="clay-caption">{t("report.metrics.limit")}</p>
<p className="mt-1 break-words font-baloo text-sm font-bold text-clay-text">
{money(budget.budgetAmount)}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2" aria-label={t("report.metrics.budgetStatuses")}>
{budgetStatuses.map((status) => (
<span
key={status.key}
className={`rounded-full px-2.5 py-1 font-nunito text-[11px] font-bold ${status.tone}`}
>
{t(`report.budgets.status.${status.key}`)}: {formatNumber(status.count)}
</span>
))}
</div>
</div> </div>
)} )}
</Card> </Card>
......
import React from "react"; import React from "react";
import { Button } from "@/components/ui/Button"; 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 { LocalizedDateInput } from "@/components/shared/LocalizedDateInput";
import { Select } from "@/components/ui/Select"; import { Select } from "@/components/ui/Select";
import { Tabs } from "@/components/ui/Tabs"; import { Tabs } from "@/components/ui/Tabs";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
...@@ -69,7 +69,7 @@ export const ReportFilters: React.FC<ReportFiltersProps> = ({ ...@@ -69,7 +69,7 @@ export const ReportFilters: React.FC<ReportFiltersProps> = ({
{period === "CUSTOM" && ( {period === "CUSTOM" && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Input <LocalizedDateInput
type="date" type="date"
label={t("report.filters.dateFrom")} label={t("report.filters.dateFrom")}
value={dateFrom} value={dateFrom}
...@@ -77,7 +77,7 @@ export const ReportFilters: React.FC<ReportFiltersProps> = ({ ...@@ -77,7 +77,7 @@ export const ReportFilters: React.FC<ReportFiltersProps> = ({
error={dateError} error={dateError}
onChange={(event) => onDateFromChange(event.target.value)} onChange={(event) => onDateFromChange(event.target.value)}
/> />
<Input <LocalizedDateInput
type="date" type="date"
label={t("report.filters.dateTo")} label={t("report.filters.dateTo")}
value={dateTo} value={dateTo}
......
...@@ -228,7 +228,17 @@ const ReportsPage: React.FC = () => { ...@@ -228,7 +228,17 @@ const ReportsPage: React.FC = () => {
</div> </div>
)} )}
{queryEnabled && activeCurrency && <AIInsightsPreview context={{ period, currency: activeCurrency, ...(walletId ? { walletId } : {}) }} />} {queryEnabled && activeCurrency && overview && (
<AIInsightsPreview
context={{
period,
dateFrom: overview.period.from,
dateTo: overview.period.to,
currency: activeCurrency,
...(walletId ? { walletId } : {}),
}}
/>
)}
{queryEnabled && [overviewQuery, cashFlowQuery, spendingQuery, budgetQuery].every((query) => query.isError) && ( {queryEnabled && [overviewQuery, cashFlowQuery, spendingQuery, budgetQuery].every((query) => query.isError) && (
<Button variant="secondary" fullWidth onClick={retryAll}>{t("report.retryAll")}</Button> <Button variant="secondary" fullWidth onClick={retryAll}>{t("report.retryAll")}</Button>
......
...@@ -3,6 +3,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; ...@@ -3,6 +3,7 @@ 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 { 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 { TranslationFunction, useI18n } from "@/i18n"; import { TranslationFunction, useI18n } from "@/i18n";
...@@ -50,7 +51,7 @@ export const ContributionFormModal: React.FC<ContributionFormModalProps> = ({ ...@@ -50,7 +51,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, formState: { errors } } = useForm<ContributionFormValues>({ const { control, register, handleSubmit, reset, watch, formState: { errors } } = useForm<ContributionFormValues>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues, defaultValues,
}); });
...@@ -59,6 +60,8 @@ export const ContributionFormModal: React.FC<ContributionFormModalProps> = ({ ...@@ -59,6 +60,8 @@ export const ContributionFormModal: React.FC<ContributionFormModalProps> = ({
if (isOpen) reset(defaultValues); if (isOpen) reset(defaultValues);
}, [defaultValues, isOpen, reset]); }, [defaultValues, isOpen, reset]);
const contributedAt = watch("contributedAt");
return ( return (
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}
...@@ -99,8 +102,9 @@ export const ContributionFormModal: React.FC<ContributionFormModalProps> = ({ ...@@ -99,8 +102,9 @@ export const ContributionFormModal: React.FC<ContributionFormModalProps> = ({
/> />
)} )}
/> />
<Input <LocalizedDateInput
type="datetime-local" type="datetime-local"
value={contributedAt}
max={toLocalDateTime()} max={toLocalDateTime()}
label={t("savingGoal.contribution.date")} label={t("savingGoal.contribution.date")}
disabled={isSubmitting} disabled={isSubmitting}
......
...@@ -3,6 +3,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; ...@@ -3,6 +3,7 @@ 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 { CategoryArtwork } from "@/components/shared/CategoryArtwork"; import { CategoryArtwork } from "@/components/shared/CategoryArtwork";
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";
...@@ -81,6 +82,7 @@ export const SavingGoalFormModal: React.FC<SavingGoalFormModalProps> = ({ ...@@ -81,6 +82,7 @@ export const SavingGoalFormModal: React.FC<SavingGoalFormModalProps> = ({
const selectedIcon = watch("icon"); const selectedIcon = watch("icon");
const selectedColor = watch("color"); const selectedColor = watch("color");
const selectedTargetDate = watch("targetDate");
const currencyLocked = Boolean(goal && goal.progress.contributionCount > 0); const currencyLocked = Boolean(goal && goal.progress.contributionCount > 0);
const submitForm = (values: SavingGoalFormValues) => { const submitForm = (values: SavingGoalFormValues) => {
...@@ -156,7 +158,7 @@ export const SavingGoalFormModal: React.FC<SavingGoalFormModalProps> = ({ ...@@ -156,7 +158,7 @@ export const SavingGoalFormModal: React.FC<SavingGoalFormModalProps> = ({
</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>}
<Input type="date" 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")} />
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor={`${formId}-description`} className="px-1 font-nunito text-sm font-semibold text-clay-text">{t("savingGoal.form.description")}</label> <label htmlFor={`${formId}-description`} className="px-1 font-nunito text-sm font-semibold text-clay-text">{t("savingGoal.form.description")}</label>
......
...@@ -3,6 +3,7 @@ import { Header, Page, useLocation, useNavigate, useSnackbar } from "zmp-ui"; ...@@ -3,6 +3,7 @@ import { Header, Page, useLocation, useNavigate, useSnackbar } from "zmp-ui";
import { Button } from "@/components/ui/Button"; 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 { LocalizedDateInput } from "@/components/shared/LocalizedDateInput";
import { Select } from "@/components/ui/Select"; import { Select } from "@/components/ui/Select";
import { Tabs } from "@/components/ui/Tabs"; import { Tabs } from "@/components/ui/Tabs";
import { IconGradients, PlusIcon, SavingGoalIcon } from "@/components/ui/icons"; import { IconGradients, PlusIcon, SavingGoalIcon } from "@/components/ui/icons";
...@@ -183,8 +184,8 @@ const SavingGoalsPage: React.FC = () => { ...@@ -183,8 +184,8 @@ const SavingGoalsPage: React.FC = () => {
</div> </div>
{dueFilter === "CUSTOM" && ( {dueFilter === "CUSTOM" && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Input type="date" label={t("savingGoal.filters.dueFrom")} max={dueTo || undefined} value={dueFrom} onChange={(event) => { const value = event.target.value; setDueFrom(value); if (dueTo && value > dueTo) setDueTo(value); }} /> <LocalizedDateInput type="date" label={t("savingGoal.filters.dueFrom")} max={dueTo || undefined} value={dueFrom} onChange={(event) => { const value = event.target.value; setDueFrom(value); if (dueTo && value > dueTo) setDueTo(value); }} />
<Input type="date" label={t("savingGoal.filters.dueTo")} min={dueFrom || undefined} value={dueTo} onChange={(event) => { const value = event.target.value; setDueTo(value); if (dueFrom && value < dueFrom) setDueFrom(value); }} /> <LocalizedDateInput type="date" label={t("savingGoal.filters.dueTo")} min={dueFrom || undefined} value={dueTo} onChange={(event) => { const value = event.target.value; setDueTo(value); if (dueFrom && value < dueFrom) setDueFrom(value); }} />
</div> </div>
)} )}
<label className="flex cursor-pointer items-center gap-3 rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed"> <label className="flex cursor-pointer items-center gap-3 rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed">
......
...@@ -27,7 +27,7 @@ export const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({ ...@@ -27,7 +27,7 @@ export const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
onEdit, onEdit,
onDelete, onDelete,
}) => { }) => {
const { t, intlLocale } = useI18n(); const { t, intlLocale, formatDate } = useI18n();
const { openSnackbar } = useSnackbar(); const { openSnackbar } = useSnackbar();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [showConfirmDelete, setShowConfirmDelete] = useState(false); const [showConfirmDelete, setShowConfirmDelete] = useState(false);
...@@ -124,15 +124,15 @@ export const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({ ...@@ -124,15 +124,15 @@ export const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
const formattedDate = () => { const formattedDate = () => {
try { try {
return new Intl.DateTimeFormat(intlLocale, { return formatDate(`${transaction.date}T00:00:00Z`, {
weekday: "long", weekday: "long",
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric", day: "numeric",
timeZone: "UTC", timeZone: "UTC",
}).format(new Date(`${transaction.date}T00:00:00Z`)); });
} catch { } catch {
return transaction.date; return "—";
} }
}; };
......
...@@ -3,6 +3,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; ...@@ -3,6 +3,7 @@ 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 { 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";
...@@ -200,6 +201,7 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({ ...@@ -200,6 +201,7 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
const selectedType = watch("type") as TransactionType; const selectedType = watch("type") as TransactionType;
const selectedWalletId = watch("walletId"); const selectedWalletId = watch("walletId");
const selectedCategoryId = watch("categoryId"); const selectedCategoryId = watch("categoryId");
const selectedDate = watch("date");
// Pre-select default wallet when creating a transaction // Pre-select default wallet when creating a transaction
useEffect(() => { useEffect(() => {
...@@ -421,9 +423,10 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({ ...@@ -421,9 +423,10 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Input <LocalizedDateInput
label={t("transaction.date")} label={t("transaction.date")}
type="date" type="date"
value={selectedDate}
error={errors.date?.message} error={errors.date?.message}
disabled={isSubmitting} disabled={isSubmitting}
{...register("date")} {...register("date")}
......
...@@ -6,6 +6,7 @@ import { Card } from "@/components/ui/Card"; ...@@ -6,6 +6,7 @@ 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 { CategoryArtwork } from "@/components/shared/CategoryArtwork"; import { CategoryArtwork } from "@/components/shared/CategoryArtwork";
import { LocalizedDateInput } from "@/components/shared/LocalizedDateInput";
import { IconGradients, PlusIcon, TransactionIcon } from "@/components/ui/icons"; import { IconGradients, PlusIcon, TransactionIcon } from "@/components/ui/icons";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { formatWalletBalance } from "@/lib/wallet-format"; import { formatWalletBalance } from "@/lib/wallet-format";
...@@ -120,7 +121,7 @@ const TransactionsPage: React.FC = () => { ...@@ -120,7 +121,7 @@ const TransactionsPage: React.FC = () => {
const location = useLocation(); const location = useLocation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { openSnackbar } = useSnackbar(); const { openSnackbar } = useSnackbar();
const { t, intlLocale } = useI18n(); const { t, intlLocale, formatDate } = useI18n();
// Search & Filter state // Search & Filter state
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
...@@ -287,14 +288,15 @@ const TransactionsPage: React.FC = () => { ...@@ -287,14 +288,15 @@ const TransactionsPage: React.FC = () => {
} }
try { try {
return new Intl.DateTimeFormat(intlLocale, { return formatDate(`${dateStr}T00:00:00Z`, {
weekday: "long", weekday: "long",
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric", day: "numeric",
}).format(new Date(`${dateStr}T00:00:00Z`)); timeZone: "UTC",
});
} catch { } catch {
return dateStr; return "—";
} }
}; };
...@@ -624,13 +626,13 @@ const TransactionsPage: React.FC = () => { ...@@ -624,13 +626,13 @@ const TransactionsPage: React.FC = () => {
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Input <LocalizedDateInput
label={t("transaction.filterDateFrom")} label={t("transaction.filterDateFrom")}
type="date" type="date"
value={dateFrom} value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)} onChange={(e) => setDateFrom(e.target.value)}
/> />
<Input <LocalizedDateInput
label={t("transaction.filterDateTo")} label={t("transaction.filterDateTo")}
type="date" type="date"
value={dateTo} value={dateTo}
......
...@@ -3,6 +3,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; ...@@ -3,6 +3,7 @@ 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 { 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";
...@@ -173,6 +174,7 @@ export const TransferFormModal: React.FC<TransferFormModalProps> = ({ ...@@ -173,6 +174,7 @@ export const TransferFormModal: React.FC<TransferFormModalProps> = ({
const sourceWalletId = watch("sourceWalletId"); const sourceWalletId = watch("sourceWalletId");
const destinationWalletId = watch("destinationWalletId"); const destinationWalletId = watch("destinationWalletId");
const amount = watch("amount"); const amount = watch("amount");
const transferredAt = watch("transferredAt");
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);
...@@ -325,9 +327,10 @@ export const TransferFormModal: React.FC<TransferFormModalProps> = ({ ...@@ -325,9 +327,10 @@ export const TransferFormModal: React.FC<TransferFormModalProps> = ({
</div> </div>
)} )}
<Input <LocalizedDateInput
label={t("transfer.transferredAt")} label={t("transfer.transferredAt")}
type="datetime-local" type="datetime-local"
value={transferredAt}
error={errors.transferredAt?.message} error={errors.transferredAt?.message}
disabled={walletsQuery.isLoading || insufficientWallets} disabled={walletsQuery.isLoading || insufficientWallets}
{...register("transferredAt")} {...register("transferredAt")}
......
...@@ -4,6 +4,7 @@ import { WalletArtwork } from "@/components/shared/WalletArtwork"; ...@@ -4,6 +4,7 @@ import { WalletArtwork } from "@/components/shared/WalletArtwork";
import { Button } from "@/components/ui/Button"; 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 { LocalizedDateInput } from "@/components/shared/LocalizedDateInput";
import { Select } from "@/components/ui/Select"; import { Select } from "@/components/ui/Select";
import { IconGradients, PlusIcon, TransferIcon } from "@/components/ui/icons"; import { IconGradients, PlusIcon, TransferIcon } from "@/components/ui/icons";
import { useCreateTransfer, useDeleteTransfer, useTransfers } from "@/hooks/use-transfers"; import { useCreateTransfer, useDeleteTransfer, useTransfers } from "@/hooks/use-transfers";
...@@ -290,14 +291,14 @@ const TransfersPage: React.FC = () => { ...@@ -290,14 +291,14 @@ const TransfersPage: React.FC = () => {
/> />
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Input <LocalizedDateInput
label={t("transfer.dateFrom")} label={t("transfer.dateFrom")}
type="date" type="date"
max={dateTo || undefined} max={dateTo || undefined}
value={dateFrom} value={dateFrom}
onChange={(event) => setDateFrom(event.target.value)} onChange={(event) => setDateFrom(event.target.value)}
/> />
<Input <LocalizedDateInput
label={t("transfer.dateTo")} label={t("transfer.dateTo")}
type="date" type="date"
min={dateFrom || undefined} min={dateFrom || undefined}
......
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