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
......@@ -6,6 +6,8 @@
- Transaction, Budget, and Saving Goal calendar dates are sent unchanged as `YYYY-MM-DD`, without parsing through the device timezone.
- Datetime-local inputs such as transfers and contributions represent Vietnamese wall time and are converted to offset-aware UTC ISO strings before sending.
- Timestamp display uses `Intl` with `Asia/Ho_Chi_Minh`; report requests never send the device offset.
- Vietnamese date display, including date and datetime-local controls, uses `DD/MM/YYYY` and
`DD/MM/YYYY HH:mm`; control values and API payloads remain in their existing ISO formats.
File này lưu trữ các quyết định thiết kế dài hạn và trạng thái hiện tại của dự án để đảm bảo tính nhất quán qua các phiên làm việc của Agent.
......@@ -25,7 +27,7 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- **Hiển thị Category**: Trang quản lý danh mục tại `/categories` dùng `GET /categories/tree` làm nguồn hiển thị chính, giữ cấu trúc cha/con và sắp xếp đệ quy ở client vì tree endpoint không nhận tham số sort. Danh mục hệ thống là chỉ đọc và tên hiển thị được ánh xạ theo locale mà không thay đổi payload API; danh mục cá nhân giữ nguyên tên người dùng nhập và hỗ trợ tạo/sửa, archive cả nhánh, restore. Parent picker chỉ cho chọn danh mục đang hoạt động, cùng loại giao dịch và loại trừ chính node cùng toàn bộ hậu duệ để tránh chu trình.
- **Quản lý Budget**: Trang `/budgets``/budgets/:id` dùng trực tiếp usage do Budget API tính (`spentAmount`, `remainingAmount`, `usagePercentage`, `timeStatus`, `status`) để giữ một nguồn sự thật chung cho UI, báo cáo, thông báo và AI. `DELETE /budgets/:id` là archive/xóa mềm nhằm bảo toàn lịch sử; ngân sách đã archive chỉ đọc đến khi restore. Mọi mutation giao dịch phải invalidate cache `budgets` vì số tiền đã chi phụ thuộc giao dịch EXPENSE.
- **Quản lý Saving Goal**: Trang `/saving-goals``/saving-goals/:id` dùng trực tiếp progress do Saving Goal API tính (`savedAmount`, `remainingAmount`, `progressPercentage`, `daysRemaining`, `isOverdue`) để giữ một nguồn sự thật chung cho UI, báo cáo và AI. `DELETE /saving-goals/:id` là archive/xóa mềm nhằm bảo toàn lịch sử; mục tiêu archive chỉ đọc đến khi restore. Contribution được quản lý riêng và mọi mutation contribution phải làm mới cả detail, list goal và lịch sử contribution.
- **Báo cáo tài chính**: Trang `/reports` dùng trực tiếp bốn Report API (`overview`, `cash-flow`, `spending-by-category`, `budget-performance`) qua TanStack Query và luôn giữ số liệu tách theo tiền tệ; không cộng gộp các loại tiền tệ khác nhau. Bộ lọc khoảng ngày tùy chọn gửi mốc kết thúc dạng exclusive sang Backend nhưng hiển thị ngày kết thúc dạng inclusive cho người dùng. Biểu đồ dùng SVG/CSS với semantic Clay tokens, không phụ thuộc thư viện chart, và context `period`/`currency`/`walletId` được chuẩn bị tại khối AI Financial Insights cho giai đoạn tích hợp tiếp theo.
- **Báo cáo tài chính**: Trang `/reports` dùng trực tiếp bốn Report API (`overview`, `cash-flow`, `spending-by-category`, `budget-performance`) qua TanStack Query và luôn giữ số liệu tách theo tiền tệ; không cộng gộp các loại tiền tệ khác nhau. Bộ lọc khoảng ngày tùy chọn gửi mốc kết thúc dạng exclusive sang Backend nhưng hiển thị ngày kết thúc dạng inclusive cho người dùng. Biểu đồ dùng SVG/CSS với semantic Clay tokens, không phụ thuộc thư viện chart. Thẻ Tổng quan ngân sách phải thể hiện số liệu tổng hợp (đã chi, còn lại, hạn mức và số lượng theo trạng thái), còn Hiệu suất ngân sách trình bày từng ngân sách; tổng quan chỉ ưu tiên summary `OVERALL` khi `budgetCount > 0`, nếu không fallback sang `CATEGORY`, và hiển thị `—` thay vì `0%` khi không có ngân sách hợp lệ. Khối AI tại báo cáo phải hiển thị trọn bản tóm tắt cùng tối đa ba điểm đáng chú ý, sau đó mới dẫn sang Trợ lý AI để phân tích sâu; khi điều hướng phải giữ `dateFrom`/`dateTo`/`currency` của kỳ báo cáo và nút quay lại phải trở về báo cáo.
- **Thông báo & nhắc nhở**: Trang `/notifications` là trung tâm chung cho inbox thông báo, lịch nhắc và tùy chọn cảnh báo. Unread count dùng query key riêng và được hiển thị bằng badge chuông toàn cục sau đăng nhập; danh sách/count/reminder polling mỗi 30 giây khi đang hoạt động, refetch khi focus và mọi mutation invalidate namespace liên quan. Reminder datetime luôn dùng Vietnamese wall time rồi chuyển sang UTC ISO bằng helper business-time. Với cảnh báo tự động do Backend hiện tạo bằng tiếng Anh, frontend nhận diện đúng mẫu theo `type`, dùng metadata để dựng title/message theo locale; nội dung người dùng nhập, legacy không khớp mẫu và AI tương lai được giữ nguyên. Notification `data` giữ kiểu mở `Record<string, unknown>` để tiếp nhận metadata cảnh báo/AI mà UI không phụ thuộc cấu trúc riêng. Điều hướng từ nút xem chi tiết gắn `fromNotifications` trong route state; các trang đích hệ thống dùng history back khi có marker này và giữ fallback back cố định cho luồng truy cập trực tiếp.
- **Tiêu đề trang**: Mỗi route cập nhật `document.title` theo mẫu `<Tên trang> | FinWise` thông qua component dùng chung trong router; route chưa nhận diện dùng tiêu đề mô tả sản phẩm mặc định.
- **Khoảng trống và nút điều khiển trên header hệ thống**: Zalo Mini App mặc định hiển thị `zaui-header` ở phía trên. Mọi page/layout phải chừa đủ khoảng cách phía trên (tính cả safe area khi cần) để nội dung và phần tử tương tác không bị header che khuất. Cụm điều khiển riêng của app (hiện gồm theme và ngôn ngữ) phải nằm trong `.finwise-header-controls`, đặt về bên trái vùng native `right-buttons` rộng 96px; đồng thời `zaui-header` phải dành đủ `padding-right` cho cả `right-buttons` và cụm này. Không đặt từng nút bằng offset `right` rời rạc vì có thể làm switch ngôn ngữ bị che khuất.
......@@ -50,14 +52,14 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- Đã cấu hình React 18, Vite 5, ZMP SDK/UI, Tailwind và bộ base components Claymorphism.
- Đã có các màn hình đăng nhập, đăng ký, quên mật khẩu, đặt lại mật khẩu, trang chủ, hồ sơ và Style Guide.
- Đã kết nối frontend với API xác thực, React Query và Zustand auth store.
- Dev server đã được sửa để nội dung app tại cổng 2999 trả 200 và render đúng thay vì màn hình đen do sai Vite root.
- Dev server dùng cổng 13579 cho nội dung app và cổng 13580 cho khung mô phỏng; `index.html` tiếp tục nằm ở root để tránh iframe trả 404 hoặc màn hình đen.
- Module quản lý ví đã có frontend tại `/wallets`, kết nối đầy đủ Wallet API qua TanStack Query, gồm danh sách/chi tiết, tạo/sửa, đặt mặc định, lưu trữ/khôi phục, tìm kiếm, sắp xếp và phân trang. `DELETE /wallets/:id` được thể hiện trong UI là lưu trữ mềm, đúng quy tắc Backend bảo toàn lịch sử.
- Module quản lý danh mục đã có frontend tại `/categories`, kết nối Category API qua TanStack Query, gồm cây cha/con, tạo/sửa, tìm kiếm, lọc loại/nguồn/trạng thái, sắp xếp, lưu trữ và khôi phục; toàn bộ nội dung có bản dịch Việt/Anh.
- Module quản lý giao dịch đã có frontend tại `/transactions`, kết nối Transaction API qua TanStack Query, gồm danh sách lịch sử theo ngày, xem/tạo/sửa/xóa giao dịch, lọc nâng cao, sắp xếp, phân trang và đính kèm hóa đơn.
- Module quản lý chuyển tiền đã có frontend tại `/transfers`, dùng contract `GET/POST /transfers``DELETE /transfers/:id` qua TanStack Query; gồm form chọn ví nguồn/đích, validation số dư và hai ví khác nhau, xác nhận trước khi chuyển/xóa, lịch sử có tìm kiếm/lọc/sắp xếp/phân trang và đầy đủ loading/error/empty state. Sau thao tác tạo hoặc xóa, frontend làm mới cache transfer, wallet và transaction để lấy lại số dư do Backend tính toán.
- Module quản lý ngân sách đã có frontend tại `/budgets``/budgets/:id`, kết nối Budget API qua TanStack Query, gồm danh sách/chi tiết, tạo/sửa, archive/restore, tìm kiếm, lọc loại/chu kỳ/danh mục/thời điểm, sắp xếp, phân trang và cảnh báo trực quan theo tỷ lệ sử dụng; đầy đủ validation, loading/error/empty/confirmation và bản dịch Việt/Anh.
- Module mục tiêu tiết kiệm đã có frontend tại `/saving-goals``/saving-goals/:id`, kết nối Saving Goal API qua TanStack Query, gồm danh sách/chi tiết, tạo/sửa, pause/resume, archive/restore, tìm kiếm, lọc trạng thái/thời hạn, sắp xếp, phân trang và quản lý đầy đủ tạo/sửa/xóa lịch sử đóng góp; có validation, loading/error/empty/confirmation và bản dịch Việt/Anh.
- Module báo cáo tài chính đã có frontend tại `/reports`, kết nối đầy đủ Report API qua TanStack Query, gồm lọc ngày/tuần/tháng/năm/khoảng tùy chọn, ví và tiền tệ; KPI tổng quan, dòng tiền, cơ cấu chi tiêu, hiệu suất ngân sách, mục tiêu tiết kiệm, loading/error/empty state, responsive và bản dịch Việt/Anh. AI Assistant chưa được tích hợp; trang báo cáo hiện có khối preview/context làm nền tảng cho AI Financial Insights.
- Module báo cáo tài chính đã có frontend tại `/reports`, kết nối đầy đủ Report API qua TanStack Query, gồm lọc ngày/tuần/tháng/năm/khoảng tùy chọn, ví và tiền tệ; KPI tổng quan, dòng tiền, cơ cấu chi tiêu, hiệu suất ngân sách, mục tiêu tiết kiệm, loading/error/empty state, responsive và bản dịch Việt/Anh. Khối tóm tắt AI đã tích hợp AI Insights, hiển thị nhận định cùng các điểm nổi bật và điều hướng sang phân tích chi tiết với phạm vi báo cáo được giữ nguyên.
- Module thông báo và nhắc nhở đã có frontend tại `/notifications`, kết nối Notification/Reminder API qua TanStack Query, gồm badge chưa đọc toàn cục, danh sách/lọc/phân trang, đánh dấu đọc một/tất cả, xóa, điều hướng tới nguồn, CRUD và bật/tắt lịch nhắc, tùy chọn loại cảnh báo/kênh nhận; có polling gần thời gian thực, loading/error/empty state, xác nhận thao tác và bản dịch Việt/Anh.
## Khi cập nhật file này
......
......@@ -65,6 +65,6 @@ Component / Page
## 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.
- `npm run build` phải sinh output tại `www/` ở root repository, không phải `src/www/`.
......@@ -18,7 +18,7 @@
```bash
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/`.
- 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 @@
```
1. **Start** the dev server:
```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
......
......@@ -16,7 +16,7 @@
],
"scripts": {
"login": "zmp login",
"start": "zmp start",
"start": "zmp start --port 13580",
"deploy": "zmp deploy",
"build": "vite build"
},
......
......@@ -3,14 +3,13 @@ import { Text } from "zmp-ui";
import { useI18n } from "@/i18n";
function Clock() {
const { intlLocale } = useI18n();
const { formatDate } = useI18n();
const [time, setTime] = useState("");
useEffect(() => {
const updateClock = () => {
const now = new Date();
const formattedTime = now.toLocaleString(intlLocale, {
timeZone: "Asia/Ho_Chi_Minh",
const formattedTime = formatDate(now, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
......@@ -24,7 +23,7 @@ function Clock() {
updateClock();
const intervalId = setInterval(updateClock, 1000);
return () => clearInterval(intervalId);
}, [intlLocale]);
}, [formatDate]);
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>
label?: string;
error?: string;
endAdornment?: React.ReactNode;
displayValue?: string;
}
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 inputId = id || generatedId;
const errorId = `${inputId}-error`;
......@@ -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
disabled:opacity-60 disabled:cursor-not-allowed
${endAdornment ? "pr-12" : ""}
${displayValue !== undefined ? "finwise-localized-date" : ""}
${error ? "border-clay-expense focus:border-clay-expense focus:ring-clay-expense/20" : ""}
${className}
`}
placeholder={placeholder}
{...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 && (
<div className="absolute inset-y-0 right-3 flex items-center">
{endAdornment}
......
......@@ -282,3 +282,15 @@ body {
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 { normalizeDateTimeFormatOptions } from "@/lib/date-format";
import en from "./locales/en.json";
import vi from "./locales/vi.json";
......@@ -93,11 +94,15 @@ export const I18nProvider: React.FC<{ children: React.ReactNode }> = ({ children
}
}, [intlLocale]);
const formatDate = useCallback((value: string | number | Date, options?: Intl.DateTimeFormatOptions) =>
new Intl.DateTimeFormat(intlLocale, {
const formatDate = useCallback((value: string | number | Date, options?: Intl.DateTimeFormatOptions) => {
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",
...options,
}).format(new Date(value)), [intlLocale]);
...normalizeDateTimeFormatOptions(intlLocale, options),
}).format(date);
}, [intlLocale]);
const contextValue = useMemo<I18nContextValue>(() => ({
locale,
......
......@@ -960,11 +960,13 @@
"income": "Total income",
"expense": "Total expense",
"saving": "Net savings",
"budgetUsage": "Budget usage",
"budgetUsage": "Budget overview",
"budgetCount": "Combined from {{count}} budgets",
"noBudget": "No budgets in this period",
"spent": "Spent",
"remaining": "Remaining",
"limit": "Limit",
"budgetStatuses": "Budget statuses",
"financialHealth": "Financial health",
"healthHint": "Savings as a share of total income",
"savingsRate": "Savings rate",
......@@ -1323,6 +1325,12 @@
"reScan": "Scan Another Receipt"
},
"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"
},
"errors": {
......
......@@ -969,11 +969,13 @@
"income": "Tổng thu",
"expense": "Tổng chi",
"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",
"noBudget": "Chưa có ngân sách trong kỳ",
"spent": "Đã chi",
"remaining": "Còn lại",
"limit": "Hạn mức",
"budgetStatuses": "Tình trạng ngân sách",
"financialHealth": "Sức khỏe tài chính",
"healthHint": "Tỷ lệ tiết kiệm trên tổng thu",
"savingsRate": "Tỷ lệ tiết kiệm",
......@@ -1332,6 +1334,12 @@
"reScan": "Quét hóa đơn khác"
},
"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"
},
"errors": {
......
import { normalizeDateTimeFormatOptions } from '@/lib/date-format';
export const BUSINESS_TIME_ZONE = 'Asia/Ho_Chi_Minh' as const;
function zonedParts(instant: Date) {
......@@ -56,6 +58,9 @@ export function addCalendarDays(date: string, amount: number): string {
export function formatBusinessDate(date: string, locale: string, options?: Intl.DateTimeFormatOptions) {
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)));
}
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 {
fromNotifications?: boolean;
fromAIRecommendations?: boolean;
fromReports?: boolean;
tab?: "chat" | "insights" | "ocr";
insightsTab?: "insights" | "recommendations";
analysisScope?: AIAnalysisScope;
}
export function isFromNotifications(state: unknown): boolean {
......@@ -14,3 +18,8 @@ export function isFromAIRecommendations(state: unknown): boolean {
if (!state || typeof state !== "object") return false;
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";
import { useAIInsights, useAIRecommendations } from "@/hooks/use-ai-assistant";
import { FinWiseNavigationState } from "@/lib/navigation-state";
import {
AIAnalysisScope,
AIFocusArea,
AIRecommendationPriority,
PriorityLevel,
......@@ -28,10 +29,14 @@ type AIInsightsTab = NonNullable<FinWiseNavigationState["insightsTab"]>;
interface AIInsightsViewProps {
initialActiveTab?: AIInsightsTab;
analysisScope?: AIAnalysisScope;
navigationState?: FinWiseNavigationState | null;
}
export const AIInsightsView: React.FC<AIInsightsViewProps> = ({
initialActiveTab = "insights",
analysisScope,
navigationState,
}) => {
const navigate = useNavigate();
const { t, formatCurrency } = useI18n();
......@@ -44,13 +49,20 @@ export const AIInsightsView: React.FC<AIInsightsViewProps> = ({
setActiveTab(nextTab);
navigate("/ai-assistant", {
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(
{ priority },
{ ...analysisScope, priority },
activeTab === "recommendations"
);
......
......@@ -6,7 +6,7 @@ import {
IconGradients,
} from "@/components/ui/icons";
import { useI18n } from "@/i18n";
import { FinWiseNavigationState } from "@/lib/navigation-state";
import { FinWiseNavigationState, isFromReports } from "@/lib/navigation-state";
import { AIChatView } from "./components/AIChatView";
import { AIInsightsView } from "./components/AIInsightsView";
import { ReceiptScannerView } from "./components/ReceiptScannerView";
......@@ -39,7 +39,11 @@ const AIAssistantPage: React.FC = () => {
return (
<Page className="page">
<Header title={t("ai.shortHeader")} showBackIcon onBackClick={() => navigate("/")} />
<Header
title={t("ai.shortHeader")}
showBackIcon
onBackClick={() => isFromReports(navigationState) ? navigate(-1) : navigate("/")}
/>
<IconGradients />
<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 = () => {
role="tabpanel"
aria-labelledby="ai-tab-insights"
>
<AIInsightsView initialActiveTab={navigationState?.insightsTab || "insights"} />
<AIInsightsView
initialActiveTab={navigationState?.insightsTab || "insights"}
analysisScope={navigationState?.analysisScope}
navigationState={navigationState}
/>
</div>
)}
{visitedTabs.has("ocr") && (
......
......@@ -3,6 +3,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/Button";
import { LocalizedDateInput } from "@/components/shared/LocalizedDateInput";
import { Input } from "@/components/ui/Input";
import { Modal } from "@/components/ui/Modal";
import { Select } from "@/components/ui/Select";
......@@ -146,6 +147,8 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({
const selectedType = watch("type") as BudgetType;
const selectedPeriod = watch("period") as BudgetPeriod;
const selectedStartDate = watch("startDate");
const selectedEndDate = watch("endDate");
useEffect(() => {
if (selectedType === "OVERALL") {
......@@ -283,9 +286,9 @@ export const BudgetFormModal: React.FC<BudgetFormModalProps> = ({
)}
<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" && (
<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>
{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";
import { Button } from "@/components/ui/Button";
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 { Tabs } from "@/components/ui/Tabs";
import { BudgetIcon, IconGradients, PlusIcon } from "@/components/ui/icons";
......@@ -244,7 +245,7 @@ const BudgetsPage: React.FC = () => {
</div>
{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">
......
......@@ -3,6 +3,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/Button";
import { LocalizedDateInput } from "@/components/shared/LocalizedDateInput";
import { Input } from "@/components/ui/Input";
import { Modal } from "@/components/ui/Modal";
import { Select } from "@/components/ui/Select";
......@@ -63,6 +64,8 @@ export const ReminderFormModal: React.FC<ReminderFormModalProps> = ({ isOpen, re
const formId = reminder ? `edit-reminder-${reminder.id}` : "create-reminder";
const { register, handleSubmit, reset, watch, formState: { errors } } = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues });
const frequency = watch("frequency") as ReminderFrequency;
const remindAt = watch("remindAt");
const endAt = watch("endAt");
useEffect(() => { if (isOpen) reset(defaultValues); }, [defaultValues, isOpen, reset]);
......@@ -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")} />
<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>
<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")} />
{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>
</form>
</Modal>
......
......@@ -7,13 +7,18 @@ import {
ChevronRightIcon,
LightbulbIcon,
NotificationIcon,
ReportIcon,
} from "@/components/ui/icons";
import { useI18n } from "@/i18n";
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";
export interface AIInsightContext {
period: ReportPeriodPreset;
dateFrom: string;
dateTo: string;
currency: string;
walletId?: string;
}
......@@ -22,16 +27,90 @@ interface AIInsightsPreviewProps {
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 }) => {
const navigate = useNavigate();
const { t } = useI18n();
const insightsQuery = useAIInsights(
{ currency: context.currency },
Boolean(context.currency)
);
const analysisScope = {
dateFrom: context.dateFrom,
dateTo: context.dateTo,
currency: context.currency,
};
const insightsQuery = useAIInsights(analysisScope, Boolean(context.currency));
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 (
<Card
......@@ -46,36 +125,26 @@ export const AIInsightsPreview: React.FC<AIInsightsPreviewProps> = ({ context })
<AIAssistantIcon size={28} aria-hidden="true" />
</div>
<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">
<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">
{t("ai.liveBadge")}
</span>
</div>
<Button
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>
<p className="clay-caption mt-1">{t("ai.preview.subtitle")}</p>
</div>
</div>
{/* Summary Content */}
<div className="relative mt-4">
{insightsQuery.isLoading ? (
<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"
aria-label={t("common.loading")}
>
<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>
</div>
) : insightsQuery.isError ? (
......@@ -100,35 +169,66 @@ export const AIInsightsPreview: React.FC<AIInsightsPreviewProps> = ({ context })
</Button>
</div>
) : insights ? (
<div className="rounded-clay-sm bg-clay-bg p-3.5 shadow-clay-pressed border border-clay-highlight/30">
<p className="font-nunito text-xs text-clay-text leading-relaxed line-clamp-3">
<div className="space-y-4">
<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}
</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>
) : (
{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">
{["spending", "reduce", "anomalies"].map((key) => (
<button
key={key}
type="button"
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" } })}
{previewItems.map((item) => (
<div
key={item.key}
className={`rounded-clay-sm border p-3 ${itemStyles[item.kind]}`}
>
<LightbulbIcon size={17} aria-hidden="true" className="shrink-0 text-clay-warning" />
<p className="font-nunito text-xs font-bold text-clay-text">
{t(`ai.chat.quickPrompts.${key}`)}
<div className="mb-1 flex items-start gap-2">
{item.kind === "anomaly" ? (
<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>
</button>
</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>
</Card>
......
......@@ -18,10 +18,30 @@ const safeNumber = (value: string | null | undefined): number => {
export const OverviewMetrics: React.FC<OverviewMetricsProps> = ({ metric, wallets, budgetSummaries }) => {
const { t, formatCurrency, formatNumber } = useI18n();
const money = (value: string) => formatCurrency(safeNumber(value), metric.currency);
const budget = budgetSummaries.find((summary) => summary.currency === metric.currency && summary.type === "OVERALL")
|| budgetSummaries.find((summary) => summary.currency === metric.currency);
const availableBudgetSummaries = budgetSummaries.filter(
(summary) => summary.currency === metric.currency && summary.budgetCount > 0
);
const budget = availableBudgetSummaries.find((summary) => summary.type === "OVERALL")
|| availableBudgetSummaries[0];
const usage = safeNumber(budget?.usagePercentage);
const budgetType = usage > 100 ? "expense" : usage >= 80 ? "warning" : "income";
const budgetStatuses = budget ? [
{
key: "on_track",
count: budget.onTrackCount,
tone: "bg-clay-income/15 text-clay-income",
},
{
key: "near_limit",
count: budget.nearLimitCount,
tone: "bg-clay-warning/15 text-clay-warning",
},
{
key: "exceeded",
count: budget.exceededCount,
tone: "bg-clay-expense/15 text-clay-expense",
},
] as const : [];
const metricCards = [
{ key: "balance", label: t("report.metrics.balance"), value: money(metric.currentBalance), tone: "bg-clay-info/15 text-clay-info", symbol: "=" },
{ key: "income", label: t("report.metrics.income"), value: money(metric.income), tone: "bg-clay-income/15 text-clay-income", symbol: "↑" },
......@@ -55,15 +75,44 @@ export const OverviewMetrics: React.FC<OverviewMetricsProps> = ({ metric, wallet
<h3 className="clay-title-h3">{t("report.metrics.budgetUsage")}</h3>
<p className="clay-caption">{budget ? t("report.metrics.budgetCount", { count: budget.budgetCount }) : t("report.metrics.noBudget")}</p>
</div>
<span className="rounded-full bg-clay-warning/15 px-3 py-1 font-baloo text-sm font-bold text-clay-text">
{formatNumber(usage, { maximumFractionDigits: 1 })}%
<span className={`rounded-full px-3 py-1 font-baloo text-sm font-bold ${budget ? "bg-clay-warning/15 text-clay-text" : "bg-clay-bg text-clay-text-muted"}`}>
{budget ? `${formatNumber(usage, { maximumFractionDigits: 1 })}%` : "—"}
</span>
</div>
<ProgressBar value={usage} type={budgetType} className="mt-4" />
{budget && (
<div className="mt-3 flex justify-between gap-3 text-xs font-semibold text-clay-text-muted">
<span>{t("report.metrics.spent")}: {money(budget.spentAmount)}</span>
<span className="text-right">{t("report.metrics.limit")}: {money(budget.budgetAmount)}</span>
<div className="mt-3 space-y-3">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
<div className="min-w-0 rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed">
<p className="clay-caption">{t("report.metrics.spent")}</p>
<p className="mt-1 break-words font-baloo text-sm font-bold text-clay-expense">
{money(budget.spentAmount)}
</p>
</div>
<div className="min-w-0 rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed">
<p className="clay-caption">{t("report.metrics.remaining")}</p>
<p className={`mt-1 break-words font-baloo text-sm font-bold ${safeNumber(budget.remainingAmount) >= 0 ? "text-clay-income" : "text-clay-expense"}`}>
{money(budget.remainingAmount)}
</p>
</div>
<div className="col-span-2 min-w-0 rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed sm:col-span-1">
<p className="clay-caption">{t("report.metrics.limit")}</p>
<p className="mt-1 break-words font-baloo text-sm font-bold text-clay-text">
{money(budget.budgetAmount)}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2" aria-label={t("report.metrics.budgetStatuses")}>
{budgetStatuses.map((status) => (
<span
key={status.key}
className={`rounded-full px-2.5 py-1 font-nunito text-[11px] font-bold ${status.tone}`}
>
{t(`report.budgets.status.${status.key}`)}: {formatNumber(status.count)}
</span>
))}
</div>
</div>
)}
</Card>
......
import React from "react";
import { Button } from "@/components/ui/Button";
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 { Tabs } from "@/components/ui/Tabs";
import { useI18n } from "@/i18n";
......@@ -69,7 +69,7 @@ export const ReportFilters: React.FC<ReportFiltersProps> = ({
{period === "CUSTOM" && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Input
<LocalizedDateInput
type="date"
label={t("report.filters.dateFrom")}
value={dateFrom}
......@@ -77,7 +77,7 @@ export const ReportFilters: React.FC<ReportFiltersProps> = ({
error={dateError}
onChange={(event) => onDateFromChange(event.target.value)}
/>
<Input
<LocalizedDateInput
type="date"
label={t("report.filters.dateTo")}
value={dateTo}
......
......@@ -228,7 +228,17 @@ const ReportsPage: React.FC = () => {
</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) && (
<Button variant="secondary" fullWidth onClick={retryAll}>{t("report.retryAll")}</Button>
......
......@@ -3,6 +3,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/Button";
import { LocalizedDateInput } from "@/components/shared/LocalizedDateInput";
import { Input } from "@/components/ui/Input";
import { Modal } from "@/components/ui/Modal";
import { TranslationFunction, useI18n } from "@/i18n";
......@@ -50,7 +51,7 @@ export const ContributionFormModal: React.FC<ContributionFormModalProps> = ({
const schema = useMemo(() => createSchema(t), [t]);
const defaultValues = useMemo(() => getDefaultValues(contribution), [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),
defaultValues,
});
......@@ -59,6 +60,8 @@ export const ContributionFormModal: React.FC<ContributionFormModalProps> = ({
if (isOpen) reset(defaultValues);
}, [defaultValues, isOpen, reset]);
const contributedAt = watch("contributedAt");
return (
<Modal
isOpen={isOpen}
......@@ -99,8 +102,9 @@ export const ContributionFormModal: React.FC<ContributionFormModalProps> = ({
/>
)}
/>
<Input
<LocalizedDateInput
type="datetime-local"
value={contributedAt}
max={toLocalDateTime()}
label={t("savingGoal.contribution.date")}
disabled={isSubmitting}
......
......@@ -3,6 +3,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { CategoryArtwork } from "@/components/shared/CategoryArtwork";
import { LocalizedDateInput } from "@/components/shared/LocalizedDateInput";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Modal } from "@/components/ui/Modal";
......@@ -81,6 +82,7 @@ export const SavingGoalFormModal: React.FC<SavingGoalFormModalProps> = ({
const selectedIcon = watch("icon");
const selectedColor = watch("color");
const selectedTargetDate = watch("targetDate");
const currencyLocked = Boolean(goal && goal.progress.contributionCount > 0);
const submitForm = (values: SavingGoalFormValues) => {
......@@ -156,7 +158,7 @@ export const SavingGoalFormModal: React.FC<SavingGoalFormModalProps> = ({
</div>
{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">
<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";
import { Button } from "@/components/ui/Button";
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 { Tabs } from "@/components/ui/Tabs";
import { IconGradients, PlusIcon, SavingGoalIcon } from "@/components/ui/icons";
......@@ -183,8 +184,8 @@ const SavingGoalsPage: React.FC = () => {
</div>
{dueFilter === "CUSTOM" && (
<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); }} />
<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.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.dueTo")} min={dueFrom || undefined} value={dueTo} onChange={(event) => { const value = event.target.value; setDueTo(value); if (dueFrom && value < dueFrom) setDueFrom(value); }} />
</div>
)}
<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> = ({
onEdit,
onDelete,
}) => {
const { t, intlLocale } = useI18n();
const { t, intlLocale, formatDate } = useI18n();
const { openSnackbar } = useSnackbar();
const fileInputRef = useRef<HTMLInputElement>(null);
const [showConfirmDelete, setShowConfirmDelete] = useState(false);
......@@ -124,15 +124,15 @@ export const TransactionDetailModal: React.FC<TransactionDetailModalProps> = ({
const formattedDate = () => {
try {
return new Intl.DateTimeFormat(intlLocale, {
return formatDate(`${transaction.date}T00:00:00Z`, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
timeZone: "UTC",
}).format(new Date(`${transaction.date}T00:00:00Z`));
});
} catch {
return transaction.date;
return "—";
}
};
......
......@@ -3,6 +3,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/Button";
import { LocalizedDateInput } from "@/components/shared/LocalizedDateInput";
import { Input } from "@/components/ui/Input";
import { Modal } from "@/components/ui/Modal";
import { Select } from "@/components/ui/Select";
......@@ -200,6 +201,7 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
const selectedType = watch("type") as TransactionType;
const selectedWalletId = watch("walletId");
const selectedCategoryId = watch("categoryId");
const selectedDate = watch("date");
// Pre-select default wallet when creating a transaction
useEffect(() => {
......@@ -421,9 +423,10 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
</div>
<div className="grid grid-cols-2 gap-3">
<Input
<LocalizedDateInput
label={t("transaction.date")}
type="date"
value={selectedDate}
error={errors.date?.message}
disabled={isSubmitting}
{...register("date")}
......
......@@ -6,6 +6,7 @@ import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { Select } from "@/components/ui/Select";
import { CategoryArtwork } from "@/components/shared/CategoryArtwork";
import { LocalizedDateInput } from "@/components/shared/LocalizedDateInput";
import { IconGradients, PlusIcon, TransactionIcon } from "@/components/ui/icons";
import { useI18n } from "@/i18n";
import { formatWalletBalance } from "@/lib/wallet-format";
......@@ -120,7 +121,7 @@ const TransactionsPage: React.FC = () => {
const location = useLocation();
const queryClient = useQueryClient();
const { openSnackbar } = useSnackbar();
const { t, intlLocale } = useI18n();
const { t, intlLocale, formatDate } = useI18n();
// Search & Filter state
const [search, setSearch] = useState("");
......@@ -287,14 +288,15 @@ const TransactionsPage: React.FC = () => {
}
try {
return new Intl.DateTimeFormat(intlLocale, {
return formatDate(`${dateStr}T00:00:00Z`, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}).format(new Date(`${dateStr}T00:00:00Z`));
timeZone: "UTC",
});
} catch {
return dateStr;
return "—";
}
};
......@@ -624,13 +626,13 @@ const TransactionsPage: React.FC = () => {
</div>
<div className="grid grid-cols-2 gap-3">
<Input
<LocalizedDateInput
label={t("transaction.filterDateFrom")}
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
/>
<Input
<LocalizedDateInput
label={t("transaction.filterDateTo")}
type="date"
value={dateTo}
......
......@@ -3,6 +3,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/Button";
import { LocalizedDateInput } from "@/components/shared/LocalizedDateInput";
import { Input } from "@/components/ui/Input";
import { Modal } from "@/components/ui/Modal";
import { Select } from "@/components/ui/Select";
......@@ -173,6 +174,7 @@ export const TransferFormModal: React.FC<TransferFormModalProps> = ({
const sourceWalletId = watch("sourceWalletId");
const destinationWalletId = watch("destinationWalletId");
const amount = watch("amount");
const transferredAt = watch("transferredAt");
const sourceWallet = wallets.find((wallet) => wallet.id === sourceWalletId);
const destinationWallet = wallets.find((wallet) => wallet.id === destinationWalletId);
......@@ -325,9 +327,10 @@ export const TransferFormModal: React.FC<TransferFormModalProps> = ({
</div>
)}
<Input
<LocalizedDateInput
label={t("transfer.transferredAt")}
type="datetime-local"
value={transferredAt}
error={errors.transferredAt?.message}
disabled={walletsQuery.isLoading || insufficientWallets}
{...register("transferredAt")}
......
......@@ -4,6 +4,7 @@ import { WalletArtwork } from "@/components/shared/WalletArtwork";
import { Button } from "@/components/ui/Button";
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 { IconGradients, PlusIcon, TransferIcon } from "@/components/ui/icons";
import { useCreateTransfer, useDeleteTransfer, useTransfers } from "@/hooks/use-transfers";
......@@ -290,14 +291,14 @@ const TransfersPage: React.FC = () => {
/>
</div>
<div className="grid grid-cols-2 gap-3">
<Input
<LocalizedDateInput
label={t("transfer.dateFrom")}
type="date"
max={dateTo || undefined}
value={dateFrom}
onChange={(event) => setDateFrom(event.target.value)}
/>
<Input
<LocalizedDateInput
label={t("transfer.dateTo")}
type="date"
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