Commit 6f17fdd2 authored by ThinhNC's avatar ThinhNC

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

parent a1c7ad10
This diff is collapsed.
......@@ -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>
......
......@@ -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