Commit 29c7ecf9 authored by ThinhNC's avatar ThinhNC

feat(frontend): improve role-aware navigation and report drill-downs

parent 7f6b0ec4
...@@ -18,6 +18,7 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th ...@@ -18,6 +18,7 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- Sử dụng hai phông chữ: `Baloo 2` (Heading & Số tiền) và `Nunito` (Nội dung chính). - Sử dụng hai phông chữ: `Baloo 2` (Heading & Số tiền) và `Nunito` (Nội dung chính).
- Bo góc tối thiểu 16px (`rounded-clay-sm`), tiêu chuẩn 24px (`rounded-clay`), và lớn 32px (`rounded-clay-lg`). - Bo góc tối thiểu 16px (`rounded-clay-sm`), tiêu chuẩn 24px (`rounded-clay`), và lớn 32px (`rounded-clay-lg`).
- **Vùng an toàn cho bóng button**: `shadow-clay-raised` lan ra ngoài button khoảng 8–16px. Khi đặt button trong modal, vùng `overflow` hoặc nhóm nhiều button, phải chừa padding quanh mép và gap đủ lớn để bóng không bị cắt hoặc chồng lên nhau. Không khai báo lại `shadow-clay-raised` trong `className` khi base `Button` đã cung cấp bóng theo variant; vùng nút cuối nội dung cuộn cần có padding đáy riêng. - **Vùng an toàn cho bóng button**: `shadow-clay-raised` lan ra ngoài button khoảng 8–16px. Khi đặt button trong modal, vùng `overflow` hoặc nhóm nhiều button, phải chừa padding quanh mép và gap đủ lớn để bóng không bị cắt hoặc chồng lên nhau. Không khai báo lại `shadow-clay-raised` trong `className` khi base `Button` đã cung cấp bóng theo variant; vùng nút cuối nội dung cuộn cần có padding đáy riêng.
- **Hiệu ứng Modal**: Base `Modal` dùng hiệu ứng xuất hiện chung gồm backdrop mờ dần và hộp thoại nổi lên/co giãn nhẹ theo Claymorphism; phải tắt animation qua `motion-reduce` khi thiết bị yêu cầu giảm chuyển động.
- **Nguồn tài nguyên Fonts**: Load thông qua thẻ `<link>` của Google Fonts trực tiếp trong `index.html` để tối ưu thời gian tải trang. - **Nguồn tài nguyên Fonts**: Load thông qua thẻ `<link>` của Google Fonts trực tiếp trong `index.html` để tối ưu thời gian tải trang.
- **Phong cách Icon**: Tự thiết kế các inline SVG dạng blob dày, tròn trịa, nhiều màu sắc pastel thay vì dùng icon nét mảnh phẳng thông thường. - **Phong cách Icon**: Tự thiết kế các inline SVG dạng blob dày, tròn trịa, nhiều màu sắc pastel thay vì dùng icon nét mảnh phẳng thông thường.
- **Hệ thống Light/Dark Theme**: Màu nền, surface, chữ, border, trạng thái và bóng Claymorphism phải đi qua semantic CSS variables được ánh xạ trong `tailwind.config.js`; không gắn màu light-only trực tiếp trong component. Lựa chọn `light`/`dark` được lưu cục bộ bằng Zustand, áp dụng `data-theme` lên `<html>` và đồng bộ `zaui-theme` lên cả `<html>` lẫn `<body>` vì stylesheet của ZaUI dùng selector `body[zaui-theme]`; đồng thời mọi màn hình dùng toggle chung để chuyển đổi nhất quán. - **Hệ thống Light/Dark Theme**: Màu nền, surface, chữ, border, trạng thái và bóng Claymorphism phải đi qua semantic CSS variables được ánh xạ trong `tailwind.config.js`; không gắn màu light-only trực tiếp trong component. Lựa chọn `light`/`dark` được lưu cục bộ bằng Zustand, áp dụng `data-theme` lên `<html>` và đồng bộ `zaui-theme` lên cả `<html>` lẫn `<body>` vì stylesheet của ZaUI dùng selector `body[zaui-theme]`; đồng thời mọi màn hình dùng toggle chung để chuyển đổi nhất quán.
...@@ -29,7 +30,8 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th ...@@ -29,7 +30,8 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- **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ý 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. - **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. 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. - **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. - Các bảng/thẻ tổng hợp trên `/reports` liên kết tới module tương ứng: số dư và từng ví tới Ví, thu/chi và dòng tiền tới Giao dịch, phân bổ chi tiêu tới Danh mục, ngân sách tới chi tiết ngân sách, mục tiêu tới danh sách mục tiêu. Chỉ bật trạng thái tương tác khi người dùng có quyền đọc route đích; route đích nhận marker `fromReports` để nút quay lại trở về đúng trang báo cáo. Thẻ Tổng thu/Tổng chi mở danh sách giao dịch với bộ lọc loại `INCOME`/`EXPENSE` tương ứng và tự mở bảng lọc để người dùng nhận biết trạng thái đang áp dụng.
- **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. Thông báo giao dịch bất thường phải truyền `transactionId` (fallback lần lượt từ `data`, `sourceId`, URL) để trang Giao dịch gọi chi tiết và tự mở đúng giao dịch, không chỉ chuyển tới danh sách chung.
- **Giao dịch tự động định kỳ**: Trang `/recurring-transactions` quản lý lịch thu/chi tự động qua - **Giao dịch tự động định kỳ**: Trang `/recurring-transactions` quản lý lịch thu/chi tự động qua
TanStack Query, gồm tạo/sửa, pause/resume, xóa mềm, preview ngày chạy và lịch sử occurrence. TanStack Query, gồm tạo/sửa, pause/resume, xóa mềm, preview ngày chạy và lịch sử occurrence.
Trên trang chủ, đây là một mục thuộc **Danh mục Nghiệp vụ**, đặt cạnh giao dịch thường; không xếp trong Trên trang chủ, đây là một mục thuộc **Danh mục Nghiệp vụ**, đặt cạnh giao dịch thường; không xếp trong
...@@ -88,6 +90,7 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th ...@@ -88,6 +90,7 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- Các vai trò hệ thống mặc định/bất biến được định nghĩa tập trung qua `SYSTEM_ROLES` trong `src/common/constants/system-role.constant.ts`. - Các vai trò hệ thống mặc định/bất biến được định nghĩa tập trung qua `SYSTEM_ROLES` trong `src/common/constants/system-role.constant.ts`.
- Màn hình quản lý phân quyền Claymorphism tại `/roles``/admin/roles` gồm: Danh sách Roles, Ma trận phân quyền theo Resource/Action, Thêm/Sửa/Xóa vai trò tùy chỉnh và Nhật ký kiểm toán (Audit Logs). - Màn hình quản lý phân quyền Claymorphism tại `/roles``/admin/roles` gồm: Danh sách Roles, Ma trận phân quyền theo Resource/Action, Thêm/Sửa/Xóa vai trò tùy chỉnh và Nhật ký kiểm toán (Audit Logs).
- Admin Control Center tại `/admin` dùng TanStack Query và permission động, gồm dashboard KPI/hoạt động gần đây, danh sách người dùng có tìm kiếm-lọc-phân trang, tạo/đổi trạng thái/xóa mềm, chi tiết người dùng có đổi vai trò/khôi phục và lịch sử kiểm toán, cùng audit viewer lọc theo khoảng ngày Việt Nam/action/target và so sánh JSON trước-sau. Trang chủ chỉ hiện lối vào khi có `USER_READ`. - Admin Control Center tại `/admin` dùng TanStack Query và permission động, gồm dashboard KPI/hoạt động gần đây, danh sách người dùng có tìm kiếm-lọc-phân trang, tạo/đổi trạng thái/xóa mềm, chi tiết người dùng có đổi vai trò/khôi phục và lịch sử kiểm toán, cùng audit viewer lọc theo khoảng ngày Việt Nam/action/target và so sánh JSON trước-sau. Trang chủ chỉ hiện lối vào khi có `USER_READ`.
- Mọi lối vào module trên trang chủ phải được ẩn khi người dùng thiếu quyền đang bảo vệ route tương ứng; tiêu đề nhóm Tính năng Nâng cao cũng ẩn khi không có bất kỳ module con nào khả dụng. Lối vào Hồ sơ vẫn hiển thị vì route này không có permission riêng; lối vào Style Guide mặc định ẩn với vai trò hệ thống `USER` và hiển thị cho các vai trò còn lại.
- Contract Backend hiện chưa cho list hoặc đọc user đã soft-delete (`GET /users``GET /users/:id` đều loại `deletedAt != null`), nên nút restore trên detail đã sẵn sàng theo type/permission nhưng chỉ khả dụng khi Backend trả được bản ghi đã xóa. - Contract Backend hiện chưa cho list hoặc đọc user đã soft-delete (`GET /users``GET /users/:id` đều loại `deletedAt != null`), nên nút restore trên detail đã sẵn sàng theo type/permission nhưng chỉ khả dụng khi Backend trả được bản ghi đã xóa.
## Khi cập nhật file này ## Khi cập nhật file này
......
...@@ -34,10 +34,10 @@ export const Modal: React.FC<ModalProps> = ({ ...@@ -34,10 +34,10 @@ export const Modal: React.FC<ModalProps> = ({
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 z-[999] flex items-end sm:items-center justify-center p-0 sm:p-4 animate-fade-in"> <div className="fixed inset-0 z-[999] flex items-end justify-center p-0 sm:items-center sm:p-4">
{/* Backdrop */} {/* Backdrop */}
<div <div
className="absolute inset-0 bg-clay-overlay/55 backdrop-blur-[6px] transition-opacity" className="absolute inset-0 animate-modal-backdrop-in bg-clay-overlay/55 backdrop-blur-[6px] motion-reduce:animate-none"
onClick={onClose} onClick={onClose}
/> />
...@@ -46,8 +46,7 @@ export const Modal: React.FC<ModalProps> = ({ ...@@ -46,8 +46,7 @@ export const Modal: React.FC<ModalProps> = ({
className=" className="
relative w-full max-w-lg bg-clay-surface rounded-t-clay-lg sm:rounded-clay-lg relative w-full max-w-lg bg-clay-surface rounded-t-clay-lg sm:rounded-clay-lg
shadow-clay-modal border-t border-x sm:border border-clay-highlight/60 p-6 flex flex-col gap-4 shadow-clay-modal border-t border-x sm:border border-clay-highlight/60 p-6 flex flex-col gap-4
transform transition-all duration-300 ease-out translate-y-0 sm:scale-100 origin-bottom animate-modal-content-in transform motion-reduce:animate-none sm:origin-center
animate-slide-up
" "
> >
{/* Header */} {/* Header */}
......
...@@ -124,6 +124,16 @@ export const UserIcon: React.FC<IconProps> = ({ size = 24, ...props }) => ( ...@@ -124,6 +124,16 @@ export const UserIcon: React.FC<IconProps> = ({ size = 24, ...props }) => (
</svg> </svg>
); );
// Role / identity badge
export const RoleIcon: React.FC<IconProps> = ({ size = 24, ...props }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" filter="url(#clay-3d-shadow)" {...props}>
<rect x="3" y="5" width="18" height="16" rx="3" fill="url(#clay-grad-info)" fillOpacity="0.3" stroke="rgb(var(--color-clay-info))" />
<path d="M9 5V3h6v2" stroke="rgb(var(--color-clay-text))" />
<circle cx="9" cy="12" r="2.5" fill="url(#clay-grad-primary)" stroke="rgb(var(--color-clay-primary))" />
<path d="M5.5 18c.7-2 2-3 3.5-3s2.8 1 3.5 3M15 11h3M15 15h3" stroke="rgb(var(--color-clay-text))" />
</svg>
);
// Plus / Add // Plus / Add
export const PlusIcon: React.FC<IconProps> = ({ size = 24, ...props }) => ( export const PlusIcon: React.FC<IconProps> = ({ size = 24, ...props }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" {...props}> <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" {...props}>
...@@ -241,10 +251,12 @@ export const LockIcon: React.FC<IconProps> = ({ size = 20, ...props }) => ( ...@@ -241,10 +251,12 @@ export const LockIcon: React.FC<IconProps> = ({ size = 20, ...props }) => (
</svg> </svg>
); );
// Key (Permission / Authorization) // Key (API key / Authorization)
export const KeyIcon: React.FC<IconProps> = ({ size = 20, ...props }) => ( export const KeyIcon: React.FC<IconProps> = ({ size = 20, ...props }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" {...props}> <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" {...props}>
<path d="M21 2l-2 2m-1.5 1.5L14 9l-1.5-1.5L11 9l1.5 1.5-1.5 1.5L9.5 10.5 8 12l2 2-6.5 6.5a3 3 0 1 1-4.24-4.24L5.76 9.76a7 7 0 1 1 9.9 0L17.5 7.9" /> <circle cx="7.5" cy="16.5" r="4.5" fill="url(#clay-grad-warning)" fillOpacity="0.35" />
<path d="M10.7 13.3 21 3" />
<path d="M15.5 8.5 18.5 11.5 22 8 19 5" />
</svg> </svg>
); );
...@@ -315,5 +327,3 @@ export const CopyIcon: React.FC<IconProps> = ({ size = 16, ...props }) => ( ...@@ -315,5 +327,3 @@ export const CopyIcon: React.FC<IconProps> = ({ size = 16, ...props }) => (
</svg> </svg>
); );
...@@ -1398,7 +1398,7 @@ ...@@ -1398,7 +1398,7 @@
"subscriptions": "Recurring Bills", "subscriptions": "Recurring Bills",
"recurringTransactions": "Auto Transactions", "recurringTransactions": "Auto Transactions",
"coreModules": "Core Financials", "coreModules": "Core Financials",
"wallets": "Wallets & Balances", "wallets": "My Wallets",
"transactions": "Transactions", "transactions": "Transactions",
"transfers": "Transfers", "transfers": "Transfers",
"budgets": "Budgets", "budgets": "Budgets",
......
...@@ -1499,7 +1499,7 @@ ...@@ -1499,7 +1499,7 @@
"subscriptions": "Hóa đơn Định kỳ", "subscriptions": "Hóa đơn Định kỳ",
"recurringTransactions": "Giao dịch Tự động", "recurringTransactions": "Giao dịch Tự động",
"coreModules": "Danh mục Nghiệp vụ", "coreModules": "Danh mục Nghiệp vụ",
"wallets": "Ví & Số dư", "wallets": "Ví của tôi",
"transactions": "Giao dịch", "transactions": "Giao dịch",
"transfers": "Chuyển tiền", "transfers": "Chuyển tiền",
"budgets": "Ngân sách", "budgets": "Ngân sách",
......
import type { AIAnalysisScope } from "@/types/ai"; import type { AIAnalysisScope } from "@/types/ai";
import type { TransactionType } from "@/types/category";
export interface FinWiseNavigationState { export interface FinWiseNavigationState {
fromNotifications?: boolean; fromNotifications?: boolean;
fromAIRecommendations?: boolean; fromAIRecommendations?: boolean;
fromReports?: boolean; fromReports?: boolean;
transactionId?: string;
transactionTypeFilter?: TransactionType;
tab?: "chat" | "insights"; tab?: "chat" | "insights";
insightsTab?: "insights" | "recommendations"; insightsTab?: "insights" | "recommendations";
analysisScope?: AIAnalysisScope; analysisScope?: AIAnalysisScope;
...@@ -23,3 +26,15 @@ export function isFromReports(state: unknown): boolean { ...@@ -23,3 +26,15 @@ export function isFromReports(state: unknown): boolean {
if (!state || typeof state !== "object") return false; if (!state || typeof state !== "object") return false;
return (state as FinWiseNavigationState).fromReports === true; return (state as FinWiseNavigationState).fromReports === true;
} }
export function getTransactionTypeFilter(state: unknown): TransactionType | undefined {
if (!state || typeof state !== "object") return undefined;
const value = (state as FinWiseNavigationState).transactionTypeFilter;
return value === "INCOME" || value === "EXPENSE" ? value : undefined;
}
export function getTransactionId(state: unknown): string | undefined {
if (!state || typeof state !== "object") return undefined;
const value = (state as FinWiseNavigationState).transactionId;
return typeof value === "string" && value.trim() ? value : undefined;
}
...@@ -10,6 +10,7 @@ import { ...@@ -10,6 +10,7 @@ import {
IconGradients, IconGradients,
KeyIcon, KeyIcon,
NotificationIcon, NotificationIcon,
RoleIcon,
SettingsIcon, SettingsIcon,
ShieldIcon, ShieldIcon,
UserIcon, UserIcon,
...@@ -92,7 +93,7 @@ const AdminDashboardPage: React.FC = () => { ...@@ -92,7 +93,7 @@ const AdminDashboardPage: React.FC = () => {
onClick={() => navigate('/admin/roles')} onClick={() => navigate('/admin/roles')}
className="flex w-full items-center justify-between rounded-clay-lg border border-clay-highlight/40 bg-clay-surface p-5 text-left shadow-clay-raised transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-clay-hover active:translate-y-0 active:shadow-clay-pressed" className="flex w-full items-center justify-between rounded-clay-lg border border-clay-highlight/40 bg-clay-surface p-5 text-left shadow-clay-raised transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-clay-hover active:translate-y-0 active:shadow-clay-pressed"
> >
<span className="flex items-center gap-3"><KeyIcon /><span className="font-baloo font-bold text-clay-text">{t('admin.actions.roles')}</span></span> <span className="flex items-center gap-3"><RoleIcon /><span className="font-baloo font-bold text-clay-text">{t('admin.actions.roles')}</span></span>
<ChevronRightIcon /> <ChevronRightIcon />
</button> </button>
</PermissionGate> </PermissionGate>
......
...@@ -16,7 +16,7 @@ import { ...@@ -16,7 +16,7 @@ import {
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { getCategoryDisplayName } from "@/lib/category-format"; import { getCategoryDisplayName } from "@/lib/category-format";
import { getErrorMessage } from "@/lib/error-message"; import { getErrorMessage } from "@/lib/error-message";
import { isFromNotifications } from "@/lib/navigation-state"; import { isFromNotifications, isFromReports } from "@/lib/navigation-state";
import { BudgetUsageStatus, CreateBudgetInput } from "@/types/budget"; import { BudgetUsageStatus, CreateBudgetInput } from "@/types/budget";
import { BudgetFormModal } from "./components/BudgetFormModal"; import { BudgetFormModal } from "./components/BudgetFormModal";
import { BudgetSkeleton } from "./components/BudgetSkeleton"; import { BudgetSkeleton } from "./components/BudgetSkeleton";
...@@ -71,7 +71,7 @@ const BudgetDetailPage: React.FC = () => { ...@@ -71,7 +71,7 @@ const BudgetDetailPage: React.FC = () => {
return ( return (
<Page className="page"> <Page className="page">
<Header title={t("budget.detailHeader")} showBackIcon onBackClick={() => isFromNotifications(location.state) ? navigate(-1) : navigate("/budgets")} /> <Header title={t("budget.detailHeader")} showBackIcon onBackClick={() => isFromNotifications(location.state) || isFromReports(location.state) ? navigate(-1) : navigate("/budgets")} />
<IconGradients /> <IconGradients />
<main className="mx-auto mt-4 flex w-full max-w-lg flex-col gap-5 pb-12"> <main className="mx-auto mt-4 flex w-full max-w-lg flex-col gap-5 pb-12">
......
...@@ -12,7 +12,7 @@ import { useCategoryTree } from "@/hooks/use-categories"; ...@@ -12,7 +12,7 @@ import { useCategoryTree } from "@/hooks/use-categories";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { getCategoryDisplayName } from "@/lib/category-format"; import { getCategoryDisplayName } from "@/lib/category-format";
import { getErrorMessage } from "@/lib/error-message"; import { getErrorMessage } from "@/lib/error-message";
import { isFromAIRecommendations } from "@/lib/navigation-state"; import { isFromAIRecommendations, isFromReports } from "@/lib/navigation-state";
import { import {
BudgetPeriod, BudgetPeriod,
BudgetQuery, BudgetQuery,
...@@ -147,7 +147,7 @@ const BudgetsPage: React.FC = () => { ...@@ -147,7 +147,7 @@ const BudgetsPage: React.FC = () => {
<Header <Header
title={t("budget.header")} title={t("budget.header")}
showBackIcon showBackIcon
onBackClick={() => isFromAIRecommendations(location.state) ? navigate(-1) : navigate("/")} onBackClick={() => isFromAIRecommendations(location.state) || isFromReports(location.state) ? navigate(-1) : navigate("/")}
/> />
<IconGradients /> <IconGradients />
......
import React, { useDeferredValue, useMemo, useState } from "react"; import React, { useDeferredValue, useMemo, useState } from "react";
import { Header, Page, useNavigate, useSnackbar } from "zmp-ui"; import { Header, Page, useLocation, useNavigate, useSnackbar } from "zmp-ui";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
...@@ -17,6 +17,7 @@ import { ...@@ -17,6 +17,7 @@ import {
import { TranslationFunction, useI18n } from "@/i18n"; import { TranslationFunction, useI18n } from "@/i18n";
import { getCategoryDisplayName, normalizeCategorySearch } from "@/lib/category-format"; import { getCategoryDisplayName, normalizeCategorySearch } from "@/lib/category-format";
import { getErrorMessage } from "@/lib/error-message"; import { getErrorMessage } from "@/lib/error-message";
import { isFromReports } from "@/lib/navigation-state";
import { import {
Category, Category,
CategoryInput, CategoryInput,
...@@ -85,6 +86,7 @@ function flattenTree(nodes: CategoryTreeNode[]): CategoryTreeNode[] { ...@@ -85,6 +86,7 @@ function flattenTree(nodes: CategoryTreeNode[]): CategoryTreeNode[] {
const CategoriesPage: React.FC = () => { const CategoriesPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const { openSnackbar } = useSnackbar(); const { openSnackbar } = useSnackbar();
const { intlLocale, t } = useI18n(); const { intlLocale, t } = useI18n();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
...@@ -169,7 +171,7 @@ const CategoriesPage: React.FC = () => { ...@@ -169,7 +171,7 @@ const CategoriesPage: React.FC = () => {
return ( return (
<Page className="page"> <Page className="page">
<Header title={t("category.header")} showBackIcon onBackClick={() => navigate("/")} /> <Header title={t("category.header")} showBackIcon onBackClick={() => isFromReports(location.state) ? navigate(-1) : navigate("/")} />
<IconGradients /> <IconGradients />
<main className="mx-auto mt-4 flex w-full max-w-lg flex-col gap-5 pb-12"> <main className="mx-auto mt-4 flex w-full max-w-lg flex-col gap-5 pb-12">
......
...@@ -6,22 +6,24 @@ import { Avatar } from "@/components/ui/Avatar"; ...@@ -6,22 +6,24 @@ import { Avatar } from "@/components/ui/Avatar";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { import {
AIAssistantIcon, AIAssistantIcon,
AlertTriangleIcon,
ChevronRightIcon, ChevronRightIcon,
ClockIcon, ClockIcon,
IconGradients, IconGradients,
LightbulbIcon, LightbulbIcon,
NotificationIcon,
ReportIcon, ReportIcon,
ShieldIcon, ShieldIcon,
} from "@/components/ui/icons"; } from "@/components/ui/icons";
import { PermissionGate } from "@/components/shared/PermissionGate"; import { PermissionGate } from "@/components/shared/PermissionGate";
import { PERMISSIONS } from "@/common/constants"; import { PERMISSIONS } from "@/common/constants";
import { SYSTEM_ROLES } from "@/common/constants/system-role.constant";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
function HomePage() { function HomePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuthStore(); const { user } = useAuthStore();
const { t } = useI18n(); const { t } = useI18n();
const showStyleGuide = user?.role.name !== SYSTEM_ROLES.USER;
return ( return (
<Page className="page flex flex-col justify-between py-8"> <Page className="page flex flex-col justify-between py-8">
...@@ -83,64 +85,56 @@ function HomePage() { ...@@ -83,64 +85,56 @@ function HomePage() {
</PermissionGate> </PermissionGate>
{/* Core AI & Query */} {/* Core AI & Query */}
<Button <PermissionGate permission={PERMISSIONS.AI_ASSISTANT_USE}>
variant="primary" <Button variant="primary" fullWidth onClick={() => navigate("/ai-assistant")} className="gap-2">
fullWidth
onClick={() => navigate("/ai-assistant")}
className="gap-2"
>
{t("home.aiAssistant")} {t("home.aiAssistant")}
</Button> </Button>
</PermissionGate>
<Button <PermissionGate permission={PERMISSIONS.QUERY_EXECUTE}>
variant="secondary" <Button variant="secondary" fullWidth onClick={() => navigate("/query")} className="gap-2 bg-clay-info-soft">
fullWidth
onClick={() => navigate("/query")}
className="gap-2 bg-clay-info-soft"
>
{t("home.smartQuery")} {t("home.smartQuery")}
</Button> </Button>
</PermissionGate>
{/* Upgraded Advanced Intelligence Suite */} {/* Upgraded Advanced Intelligence Suite */}
<PermissionGate
permissions={[
PERMISSIONS.FORECAST_READ,
PERMISSIONS.SIMULATION_READ,
PERMISSIONS.ANOMALY_READ,
PERMISSIONS.SUBSCRIPTION_READ,
]}
mode="any"
>
<div className="pt-2 pb-1"> <div className="pt-2 pb-1">
<span className="text-[11px] font-bold text-clay-text-muted uppercase tracking-wider block px-1 mb-2"> <span className="text-[11px] font-bold text-clay-text-muted uppercase tracking-wider block px-1 mb-2">
{t("home.advancedFeatures")} {t("home.advancedFeatures")}
</span> </span>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<Button <PermissionGate permission={PERMISSIONS.FORECAST_READ}>
variant="secondary" <Button variant="secondary" onClick={() => navigate("/forecast")} className="text-xs py-2 px-2.5 flex flex-col items-center justify-center gap-1 h-auto">
onClick={() => navigate("/forecast")}
className="text-xs py-2 px-2.5 flex flex-col items-center justify-center gap-1 h-auto"
>
<ReportIcon size={20} />
<span>{t("home.forecast")}</span> <span>{t("home.forecast")}</span>
</Button> </Button>
<Button </PermissionGate>
variant="secondary" <PermissionGate permission={PERMISSIONS.SIMULATION_READ}>
onClick={() => navigate("/simulations")} <Button variant="secondary" onClick={() => navigate("/simulations")} className="text-xs py-2 px-2.5 flex flex-col items-center justify-center gap-1 h-auto">
className="text-xs py-2 px-2.5 flex flex-col items-center justify-center gap-1 h-auto"
>
<LightbulbIcon size={20} />
<span>{t("home.simulations")}</span> <span>{t("home.simulations")}</span>
</Button> </Button>
<Button </PermissionGate>
variant="secondary" <PermissionGate permission={PERMISSIONS.ANOMALY_READ}>
onClick={() => navigate("/anomalies")} <Button variant="secondary" onClick={() => navigate("/anomalies")} className="text-xs py-2 px-2.5 flex flex-col items-center justify-center gap-1 h-auto">
className="text-xs py-2 px-2.5 flex flex-col items-center justify-center gap-1 h-auto"
>
<NotificationIcon size={20} />
<span>{t("home.anomalies")}</span> <span>{t("home.anomalies")}</span>
</Button> </Button>
<Button </PermissionGate>
variant="secondary" <PermissionGate permission={PERMISSIONS.SUBSCRIPTION_READ}>
onClick={() => navigate("/subscriptions")} <Button variant="secondary" onClick={() => navigate("/subscriptions")} className="text-xs py-2 px-2.5 flex flex-col items-center justify-center gap-1 h-auto">
className="text-xs py-2 px-2.5 flex flex-col items-center justify-center gap-1 h-auto"
>
<ClockIcon size={20} />
<span>{t("home.subscriptions")}</span> <span>{t("home.subscriptions")}</span>
</Button> </Button>
</PermissionGate>
</div> </div>
</div> </div>
</PermissionGate>
{/* Core Financial Modules */} {/* Core Financial Modules */}
<div className="pt-1 pb-1"> <div className="pt-1 pb-1">
...@@ -148,85 +142,59 @@ function HomePage() { ...@@ -148,85 +142,59 @@ function HomePage() {
{t("home.coreModules")} {t("home.coreModules")}
</span> </span>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Button <PermissionGate permission={PERMISSIONS.REPORT_READ}>
variant="secondary" <Button variant="secondary" fullWidth onClick={() => navigate("/reports")}>
fullWidth {t("home.reports")}
onClick={() => navigate("/wallets")} </Button>
className="gap-2" </PermissionGate>
> <PermissionGate permission={PERMISSIONS.WALLET_READ}>
<Button variant="secondary" fullWidth onClick={() => navigate("/wallets")} className="gap-2">
{t("home.wallets")} {t("home.wallets")}
</Button> </Button>
<Button </PermissionGate>
variant="secondary" <PermissionGate permission={PERMISSIONS.TRANSACTION_READ}>
fullWidth <Button variant="secondary" fullWidth onClick={() => navigate("/transactions")}>
onClick={() => navigate("/transactions")}
>
{t("home.transactions")} {t("home.transactions")}
</Button> </Button>
<Button </PermissionGate>
variant="secondary" <PermissionGate permission={PERMISSIONS.RECURRING_TRANSACTION_READ}>
fullWidth <Button variant="secondary" fullWidth onClick={() => navigate("/recurring-transactions")} className="gap-2">
onClick={() => navigate("/recurring-transactions")}
className="gap-2"
>
{t("home.recurringTransactions")} {t("home.recurringTransactions")}
</Button> </Button>
<Button </PermissionGate>
variant="secondary" <PermissionGate permission={PERMISSIONS.TRANSFER_READ}>
fullWidth <Button variant="secondary" fullWidth onClick={() => navigate("/transfers")}>
onClick={() => navigate("/transfers")}
>
{t("home.transfers")} {t("home.transfers")}
</Button> </Button>
<Button </PermissionGate>
variant="secondary" <PermissionGate permission={PERMISSIONS.BUDGET_READ}>
fullWidth <Button variant="secondary" fullWidth onClick={() => navigate("/budgets")}>
onClick={() => navigate("/budgets")}
>
{t("home.budgets")} {t("home.budgets")}
</Button> </Button>
<Button </PermissionGate>
variant="secondary" <PermissionGate permission={PERMISSIONS.SAVING_GOAL_READ}>
fullWidth <Button variant="secondary" fullWidth onClick={() => navigate("/saving-goals")}>
onClick={() => navigate("/saving-goals")}
>
{t("home.savingGoals")} {t("home.savingGoals")}
</Button> </Button>
<Button </PermissionGate>
variant="secondary" <PermissionGate permission={PERMISSIONS.CATEGORY_READ}>
fullWidth <Button variant="secondary" fullWidth onClick={() => navigate("/categories")}>
onClick={() => navigate("/categories")}
>
{t("home.categories")} {t("home.categories")}
</Button> </Button>
<Button </PermissionGate>
variant="secondary" <PermissionGate permission={PERMISSIONS.NOTIFICATION_READ}>
fullWidth <Button variant="secondary" fullWidth onClick={() => navigate("/notifications")}>
onClick={() => navigate("/reports")}
>
{t("home.reports")}
</Button>
<Button
variant="secondary"
fullWidth
onClick={() => navigate("/notifications")}
>
{t("home.notifications")} {t("home.notifications")}
</Button> </Button>
<Button </PermissionGate>
variant="secondary" <Button variant="secondary" fullWidth onClick={() => navigate("/profile")}>
fullWidth
onClick={() => navigate("/profile")}
>
{t("home.profile")} {t("home.profile")}
</Button> </Button>
<Button {showStyleGuide && (
variant="secondary" <Button variant="secondary" fullWidth onClick={() => navigate("/style-guide")}>
fullWidth
onClick={() => navigate("/style-guide")}
>
{t("home.styleGuide")} {t("home.styleGuide")}
</Button> </Button>
)}
</div> </div>
</div> </div>
</div> </div>
......
...@@ -92,10 +92,20 @@ const NotificationsPage: React.FC = () => { ...@@ -92,10 +92,20 @@ const NotificationsPage: React.FC = () => {
const go = () => { const go = () => {
if (!item.actionUrl) return; if (!item.actionUrl) return;
if (/^https:\/\//i.test(item.actionUrl)) window.open(item.actionUrl, "_blank", "noopener,noreferrer"); if (/^https:\/\//i.test(item.actionUrl)) window.open(item.actionUrl, "_blank", "noopener,noreferrer");
else navigate( else {
item.actionUrl.startsWith("/transactions/") ? "/transactions" : item.actionUrl, const transactionPathMatch = item.actionUrl.match(/^\/transactions\/([^/?#]+)/);
{ state: { fromNotifications: true } }, const dataTransactionId = item.data?.transactionId;
const transactionId = typeof dataTransactionId === "string"
? dataTransactionId
: item.sourceType === "TRANSACTION" && item.sourceId
? item.sourceId
: transactionPathMatch?.[1];
navigate(
transactionPathMatch ? "/transactions" : item.actionUrl,
{ state: { fromNotifications: true, ...(transactionId ? { transactionId } : {}) } },
); );
}
}; };
if (item.readAt) go(); else markRead(item.id, go); if (item.readAt) go(); else markRead(item.id, go);
}; };
......
import React from "react"; import React from "react";
import { Badge } from "@/components/ui/Badge"; import { Badge } from "@/components/ui/Badge";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { ChevronRightIcon } from "@/components/ui/icons";
import { ProgressBar } from "@/components/ui/ProgressBar"; import { ProgressBar } from "@/components/ui/ProgressBar";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { BudgetPerformanceItem, BudgetPerformanceStatus } from "@/types/report"; import { BudgetPerformanceItem, BudgetPerformanceStatus } from "@/types/report";
...@@ -8,7 +9,7 @@ import { BudgetPerformanceItem, BudgetPerformanceStatus } from "@/types/report"; ...@@ -8,7 +9,7 @@ import { BudgetPerformanceItem, BudgetPerformanceStatus } from "@/types/report";
interface BudgetAnalyticsProps { interface BudgetAnalyticsProps {
budgets: BudgetPerformanceItem[]; budgets: BudgetPerformanceItem[];
currency: string; currency: string;
onOpenBudget: (id: string) => void; onOpenBudget?: (id: string) => void;
} }
const statusType: Record<BudgetPerformanceStatus, "income" | "warning" | "expense"> = { const statusType: Record<BudgetPerformanceStatus, "income" | "warning" | "expense"> = {
...@@ -42,17 +43,21 @@ export const BudgetAnalytics: React.FC<BudgetAnalyticsProps> = ({ budgets, curre ...@@ -42,17 +43,21 @@ export const BudgetAnalytics: React.FC<BudgetAnalyticsProps> = ({ budgets, curre
<button <button
key={budget.id} key={budget.id}
type="button" type="button"
onClick={() => onOpenBudget(budget.id)} disabled={!onOpenBudget}
className="w-full rounded-clay bg-clay-bg p-4 text-left shadow-clay-pressed transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-clay-raised focus:outline-none focus:ring-2 focus:ring-clay-primary/30" onClick={() => onOpenBudget?.(budget.id)}
className="w-full rounded-clay bg-clay-bg p-4 text-left shadow-clay-pressed transition-all duration-200 ease-in-out enabled:hover:-translate-y-0.5 enabled:hover:shadow-clay-raised enabled:focus:outline-none enabled:focus:ring-2 enabled:focus:ring-clay-primary/30 disabled:cursor-default"
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate font-nunito text-sm font-bold text-clay-text">{budget.name}</p> <p className="truncate font-nunito text-sm font-bold text-clay-text">{budget.name}</p>
<p className="clay-caption">{formatCurrency(Number(budget.spentAmount), currency)} / {formatCurrency(Number(budget.budgetAmount), currency)}</p> <p className="clay-caption">{formatCurrency(Number(budget.spentAmount), currency)} / {formatCurrency(Number(budget.budgetAmount), currency)}</p>
</div> </div>
<Badge type={statusType[budget.status]} className="shrink-0"> <div className="flex shrink-0 items-center gap-1.5">
<Badge type={statusType[budget.status]}>
{t(`report.budgets.status.${budget.status.toLowerCase()}`)} {t(`report.budgets.status.${budget.status.toLowerCase()}`)}
</Badge> </Badge>
{onOpenBudget && <ChevronRightIcon size={15} aria-hidden="true" />}
</div>
</div> </div>
<div className="mt-3 flex items-center gap-3"> <div className="mt-3 flex items-center gap-3">
<ProgressBar value={usage} type={statusType[budget.status]} className="flex-1" /> <ProgressBar value={usage} type={statusType[budget.status]} className="flex-1" />
......
import React from "react"; import React from "react";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { ChevronRightIcon } from "@/components/ui/icons";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { CashFlowReport } from "@/types/report"; import { CashFlowReport } from "@/types/report";
interface CashFlowChartProps { interface CashFlowChartProps {
report: CashFlowReport; report: CashFlowReport;
currency: string; currency: string;
onOpenTransactions?: () => void;
} }
const safeNumber = (value?: string): number => { const safeNumber = (value?: string): number => {
...@@ -13,7 +15,7 @@ const safeNumber = (value?: string): number => { ...@@ -13,7 +15,7 @@ const safeNumber = (value?: string): number => {
return Number.isFinite(parsed) ? parsed : 0; return Number.isFinite(parsed) ? parsed : 0;
}; };
export const CashFlowChart: React.FC<CashFlowChartProps> = ({ report, currency }) => { export const CashFlowChart: React.FC<CashFlowChartProps> = ({ report, currency, onOpenTransactions }) => {
const { t, formatCurrency, formatDate, formatNumber } = useI18n(); const { t, formatCurrency, formatDate, formatNumber } = useI18n();
const width = 360; const width = 360;
const height = 190; const height = 190;
...@@ -46,17 +48,33 @@ export const CashFlowChart: React.FC<CashFlowChartProps> = ({ report, currency } ...@@ -46,17 +48,33 @@ export const CashFlowChart: React.FC<CashFlowChartProps> = ({ report, currency }
}); });
return ( return (
<Card className="overflow-hidden p-5"> <Card
hoverable={Boolean(onOpenTransactions)}
className="overflow-hidden p-5 focus:outline-none focus:ring-2 focus:ring-clay-primary/30"
role={onOpenTransactions ? "button" : undefined}
tabIndex={onOpenTransactions ? 0 : undefined}
aria-label={onOpenTransactions ? `${t("report.cashFlow.title")}. ${t("common.viewDetails")}` : undefined}
onClick={onOpenTransactions}
onKeyDown={onOpenTransactions ? (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onOpenTransactions();
}
} : undefined}
>
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div>
<h2 className="clay-title-h2">{t("report.cashFlow.title")}</h2> <h2 className="clay-title-h2">{t("report.cashFlow.title")}</h2>
<p className="clay-caption">{t("report.cashFlow.subtitle")}</p> <p className="clay-caption">{t("report.cashFlow.subtitle")}</p>
</div> </div>
<div className="flex shrink-0 items-center gap-2">
{expenseTrend !== null && ( {expenseTrend !== null && (
<span className={`rounded-full px-3 py-1 font-nunito text-xs font-bold ${expenseTrend > 0 ? "bg-clay-expense/15 text-clay-expense" : "bg-clay-income/15 text-clay-income"}`}> <span className={`rounded-full px-3 py-1 font-nunito text-xs font-bold ${expenseTrend > 0 ? "bg-clay-expense/15 text-clay-expense" : "bg-clay-income/15 text-clay-income"}`}>
{t("report.cashFlow.expenseTrend", { value: `${expenseTrend > 0 ? "+" : ""}${formatNumber(expenseTrend, { maximumFractionDigits: 1 })}%` })} {t("report.cashFlow.expenseTrend", { value: `${expenseTrend > 0 ? "+" : ""}${formatNumber(expenseTrend, { maximumFractionDigits: 1 })}%` })}
</span> </span>
)} )}
{onOpenTransactions && <ChevronRightIcon size={18} aria-hidden="true" />}
</div>
</div> </div>
<div className="mt-4 flex flex-wrap gap-4 text-xs font-bold"> <div className="mt-4 flex flex-wrap gap-4 text-xs font-bold">
......
import React from "react"; import React from "react";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { ChevronRightIcon } from "@/components/ui/icons";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { getCategoryDisplayName } from "@/lib/category-format"; import { getCategoryDisplayName } from "@/lib/category-format";
import { SpendingCategoryCurrency } from "@/types/report"; import { SpendingCategoryCurrency } from "@/types/report";
interface CategoryDistributionProps { interface CategoryDistributionProps {
data: SpendingCategoryCurrency; data: SpendingCategoryCurrency;
onOpenCategories?: () => void;
} }
const chartColors = [ const chartColors = [
...@@ -17,18 +19,34 @@ const chartColors = [ ...@@ -17,18 +19,34 @@ const chartColors = [
"rgb(var(--color-clay-text-muted))", "rgb(var(--color-clay-text-muted))",
]; ];
export const CategoryDistribution: React.FC<CategoryDistributionProps> = ({ data }) => { export const CategoryDistribution: React.FC<CategoryDistributionProps> = ({ data, onOpenCategories }) => {
const { t, formatCurrency, formatNumber } = useI18n(); const { t, formatCurrency, formatNumber } = useI18n();
const radius = 46; const radius = 46;
const circumference = 2 * Math.PI * radius; const circumference = 2 * Math.PI * radius;
let cumulativePercentage = 0; let cumulativePercentage = 0;
return ( return (
<Card className="p-5"> <Card
hoverable={Boolean(onOpenCategories)}
className="p-5 focus:outline-none focus:ring-2 focus:ring-clay-primary/30"
role={onOpenCategories ? "button" : undefined}
tabIndex={onOpenCategories ? 0 : undefined}
aria-label={onOpenCategories ? `${t("report.categories.title")}. ${t("common.viewDetails")}` : undefined}
onClick={onOpenCategories}
onKeyDown={onOpenCategories ? (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onOpenCategories();
}
} : undefined}
>
<div className="flex items-start justify-between gap-3">
<div> <div>
<h2 className="clay-title-h2">{t("report.categories.title")}</h2> <h2 className="clay-title-h2">{t("report.categories.title")}</h2>
<p className="clay-caption">{t("report.categories.subtitle")}</p> <p className="clay-caption">{t("report.categories.subtitle")}</p>
</div> </div>
{onOpenCategories && <ChevronRightIcon size={18} aria-hidden="true" />}
</div>
<div className="mt-5 grid grid-cols-1 items-center gap-5 sm:grid-cols-[160px_1fr]"> <div className="mt-5 grid grid-cols-1 items-center gap-5 sm:grid-cols-[160px_1fr]">
<div className="relative mx-auto h-40 w-40"> <div className="relative mx-auto h-40 w-40">
......
import React from "react"; import React from "react";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { ChevronRightIcon } from "@/components/ui/icons";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { TransactionType } from "@/types/category";
import { FinancialMetric, ReportWallet } from "@/types/report"; import { FinancialMetric, ReportWallet } from "@/types/report";
interface OverviewMetricsProps { interface OverviewMetricsProps {
metric: FinancialMetric; metric: FinancialMetric;
wallets: ReportWallet[]; wallets: ReportWallet[];
onOpenBalance?: () => void;
onOpenTransactions?: (type?: TransactionType) => void;
onOpenWallet?: (id: string) => void;
} }
const safeNumber = (value: string | null | undefined): number => { const safeNumber = (value: string | null | undefined): number => {
...@@ -13,14 +18,20 @@ const safeNumber = (value: string | null | undefined): number => { ...@@ -13,14 +18,20 @@ const safeNumber = (value: string | null | undefined): number => {
return Number.isFinite(parsed) ? parsed : 0; return Number.isFinite(parsed) ? parsed : 0;
}; };
export const OverviewMetrics: React.FC<OverviewMetricsProps> = ({ metric, wallets }) => { export const OverviewMetrics: React.FC<OverviewMetricsProps> = ({
metric,
wallets,
onOpenBalance,
onOpenTransactions,
onOpenWallet,
}) => {
const { t, formatCurrency } = useI18n(); const { t, formatCurrency } = useI18n();
const money = (value: string) => formatCurrency(safeNumber(value), metric.currency); const money = (value: string) => formatCurrency(safeNumber(value), metric.currency);
const metricCards = [ const metricCards = [
{ key: "balance", label: t("report.metrics.balance"), value: money(metric.currentBalance), tone: "bg-clay-info/15 text-clay-info", symbol: "=" }, { key: "balance", label: t("report.metrics.balance"), value: money(metric.currentBalance), tone: "bg-clay-info/15 text-clay-info", symbol: "=", onOpen: onOpenBalance },
{ key: "income", label: t("report.metrics.income"), value: money(metric.income), tone: "bg-clay-income/15 text-clay-income", symbol: "↑" }, { key: "income", label: t("report.metrics.income"), value: money(metric.income), tone: "bg-clay-income/15 text-clay-income", symbol: "↑", onOpen: onOpenTransactions ? () => onOpenTransactions("INCOME") : undefined },
{ key: "expense", label: t("report.metrics.expense"), value: money(metric.expense), tone: "bg-clay-expense/15 text-clay-expense", symbol: "↓" }, { key: "expense", label: t("report.metrics.expense"), value: money(metric.expense), tone: "bg-clay-expense/15 text-clay-expense", symbol: "↓", onOpen: onOpenTransactions ? () => onOpenTransactions("EXPENSE") : undefined },
{ key: "saving", label: t("report.metrics.saving"), value: money(metric.netCashFlow), tone: safeNumber(metric.netCashFlow) >= 0 ? "bg-clay-primary/15 text-clay-primary" : "bg-clay-expense/15 text-clay-expense", symbol: "◆" }, { key: "saving", label: t("report.metrics.saving"), value: money(metric.netCashFlow), tone: safeNumber(metric.netCashFlow) >= 0 ? "bg-clay-primary/15 text-clay-primary" : "bg-clay-expense/15 text-clay-expense", symbol: "◆", onOpen: onOpenTransactions ? () => onOpenTransactions() : undefined },
]; ];
return ( return (
...@@ -32,10 +43,27 @@ export const OverviewMetrics: React.FC<OverviewMetricsProps> = ({ metric, wallet ...@@ -32,10 +43,27 @@ export const OverviewMetrics: React.FC<OverviewMetricsProps> = ({ metric, wallet
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4"> <div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
{metricCards.map((item) => ( {metricCards.map((item) => (
<Card key={item.key} className="min-w-0 p-4"> <Card
<div className={`mb-3 flex h-9 w-9 items-center justify-center rounded-clay-sm font-baloo text-lg font-bold shadow-clay-pressed ${item.tone}`} aria-hidden="true"> key={item.key}
hoverable={Boolean(item.onOpen)}
className="min-w-0 p-4 focus:outline-none focus:ring-2 focus:ring-clay-primary/30"
role={item.onOpen ? "button" : undefined}
tabIndex={item.onOpen ? 0 : undefined}
aria-label={item.onOpen ? `${item.label}: ${item.value}. ${t("common.viewDetails")}` : undefined}
onClick={item.onOpen}
onKeyDown={item.onOpen ? (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
item.onOpen?.();
}
} : undefined}
>
<div className="mb-3 flex items-start justify-between gap-2">
<div className={`flex h-9 w-9 items-center justify-center rounded-clay-sm font-baloo text-lg font-bold shadow-clay-pressed ${item.tone}`} aria-hidden="true">
{item.symbol} {item.symbol}
</div> </div>
{item.onOpen && <ChevronRightIcon size={16} aria-hidden="true" />}
</div>
<p className="font-nunito text-xs font-bold text-clay-text-muted">{item.label}</p> <p className="font-nunito text-xs font-bold text-clay-text-muted">{item.label}</p>
<p className="mt-1 break-words font-baloo text-lg font-bold leading-tight text-clay-text">{item.value}</p> <p className="mt-1 break-words font-baloo text-lg font-bold leading-tight text-clay-text">{item.value}</p>
</Card> </Card>
...@@ -53,13 +81,23 @@ export const OverviewMetrics: React.FC<OverviewMetricsProps> = ({ metric, wallet ...@@ -53,13 +81,23 @@ export const OverviewMetrics: React.FC<OverviewMetricsProps> = ({ metric, wallet
</div> </div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{wallets.map((wallet) => ( {wallets.map((wallet) => (
<div key={wallet.id} className="flex items-center justify-between gap-3 rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed"> <button
key={wallet.id}
type="button"
disabled={!onOpenWallet}
onClick={() => onOpenWallet?.(wallet.id)}
aria-label={onOpenWallet ? `${wallet.name}: ${money(wallet.balance)}. ${t("common.viewDetails")}` : undefined}
className="flex items-center justify-between gap-3 rounded-clay-sm bg-clay-bg p-3 text-left shadow-clay-pressed transition-all duration-200 ease-in-out enabled:hover:-translate-y-0.5 enabled:hover:shadow-clay-raised enabled:focus:outline-none enabled:focus:ring-2 enabled:focus:ring-clay-primary/30 disabled:cursor-default"
>
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate font-nunito text-sm font-bold text-clay-text">{wallet.name}</p> <p className="truncate font-nunito text-sm font-bold text-clay-text">{wallet.name}</p>
<p className="clay-caption">{wallet.isDefault ? t("report.wallets.default") : wallet.currency}</p> <p className="clay-caption">{wallet.isDefault ? t("report.wallets.default") : wallet.currency}</p>
</div> </div>
<p className="shrink-0 font-baloo text-sm font-bold text-clay-text">{money(wallet.balance)}</p> <div className="flex shrink-0 items-center gap-1.5">
<p className="font-baloo text-sm font-bold text-clay-text">{money(wallet.balance)}</p>
{onOpenWallet && <ChevronRightIcon size={15} aria-hidden="true" />}
</div> </div>
</button>
))} ))}
</div> </div>
</Card> </Card>
......
import React from "react"; import React from "react";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { ChevronRightIcon } from "@/components/ui/icons";
import { ProgressBar } from "@/components/ui/ProgressBar"; import { ProgressBar } from "@/components/ui/ProgressBar";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { SavingGoalSummary } from "@/types/report"; import { SavingGoalSummary } from "@/types/report";
...@@ -7,7 +8,7 @@ import { SavingGoalSummary } from "@/types/report"; ...@@ -7,7 +8,7 @@ import { SavingGoalSummary } from "@/types/report";
interface SavingGoalsAnalyticsProps { interface SavingGoalsAnalyticsProps {
summary: SavingGoalSummary; summary: SavingGoalSummary;
currency: string; currency: string;
onOpenGoals: () => void; onOpenGoals?: () => void;
} }
export const SavingGoalsAnalytics: React.FC<SavingGoalsAnalyticsProps> = ({ summary, currency, onOpenGoals }) => { export const SavingGoalsAnalytics: React.FC<SavingGoalsAnalyticsProps> = ({ summary, currency, onOpenGoals }) => {
...@@ -16,17 +17,31 @@ export const SavingGoalsAnalytics: React.FC<SavingGoalsAnalyticsProps> = ({ summ ...@@ -16,17 +17,31 @@ export const SavingGoalsAnalytics: React.FC<SavingGoalsAnalyticsProps> = ({ summ
const progress = Number(totals?.progressPercentage) || 0; const progress = Number(totals?.progressPercentage) || 0;
return ( return (
<Card hoverable className="p-5" role="button" tabIndex={0} onClick={onOpenGoals} onKeyDown={(event) => { <Card
if (event.key === "Enter" || event.key === " ") onOpenGoals(); hoverable={Boolean(onOpenGoals)}
}}> className="p-5 focus:outline-none focus:ring-2 focus:ring-clay-primary/30"
role={onOpenGoals ? "button" : undefined}
tabIndex={onOpenGoals ? 0 : undefined}
aria-label={onOpenGoals ? `${t("report.goals.title")}. ${t("common.viewDetails")}` : undefined}
onClick={onOpenGoals}
onKeyDown={onOpenGoals ? (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onOpenGoals();
}
} : undefined}
>
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div> <div>
<h2 className="clay-title-h2">{t("report.goals.title")}</h2> <h2 className="clay-title-h2">{t("report.goals.title")}</h2>
<p className="clay-caption">{t("report.goals.subtitle")}</p> <p className="clay-caption">{t("report.goals.subtitle")}</p>
</div> </div>
<div className="flex shrink-0 items-center gap-1.5">
<span className="rounded-full bg-clay-income/15 px-3 py-1 font-nunito text-xs font-bold text-clay-income"> <span className="rounded-full bg-clay-income/15 px-3 py-1 font-nunito text-xs font-bold text-clay-income">
{t("report.goals.active", { count: summary.activeCount })} {t("report.goals.active", { count: summary.activeCount })}
</span> </span>
{onOpenGoals && <ChevronRightIcon size={16} aria-hidden="true" />}
</div>
</div> </div>
<div className="mt-5 rounded-clay bg-clay-bg p-4 shadow-clay-pressed"> <div className="mt-5 rounded-clay bg-clay-bg p-4 shadow-clay-pressed">
......
...@@ -3,6 +3,7 @@ import { Header, Page, useNavigate } from "zmp-ui"; ...@@ -3,6 +3,7 @@ import { Header, Page, useNavigate } from "zmp-ui";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { IconGradients, ReportIcon } from "@/components/ui/icons"; import { IconGradients, ReportIcon } from "@/components/ui/icons";
import { PERMISSIONS } from "@/common/constants";
import { import {
useBudgetPerformanceReport, useBudgetPerformanceReport,
useCashFlowReport, useCashFlowReport,
...@@ -10,8 +11,10 @@ import { ...@@ -10,8 +11,10 @@ import {
useSpendingReport, useSpendingReport,
} from "@/hooks/use-reports"; } from "@/hooks/use-reports";
import { useWalletSearch } from "@/hooks/use-wallets"; import { useWalletSearch } from "@/hooks/use-wallets";
import { usePermission } from "@/hooks/use-permission";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { ReportPeriodPreset, ReportQuery } from "@/types/report"; import { ReportPeriodPreset, ReportQuery } from "@/types/report";
import { TransactionType } from "@/types/category";
import { AIInsightsPreview } from "./components/AIInsightsPreview"; import { AIInsightsPreview } from "./components/AIInsightsPreview";
import { BudgetAnalytics } from "./components/BudgetAnalytics"; import { BudgetAnalytics } from "./components/BudgetAnalytics";
import { CashFlowChart } from "./components/CashFlowChart"; import { CashFlowChart } from "./components/CashFlowChart";
...@@ -37,6 +40,12 @@ function toInclusiveBoundary(value: string, endBoundary: boolean): string { ...@@ -37,6 +40,12 @@ function toInclusiveBoundary(value: string, endBoundary: boolean): string {
const ReportsPage: React.FC = () => { const ReportsPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { t, formatDate } = useI18n(); const { t, formatDate } = useI18n();
const { hasPermission } = usePermission();
const canReadWallets = hasPermission(PERMISSIONS.WALLET_READ);
const canReadTransactions = hasPermission(PERMISSIONS.TRANSACTION_READ);
const canReadCategories = hasPermission(PERMISSIONS.CATEGORY_READ);
const canReadBudgets = hasPermission(PERMISSIONS.BUDGET_READ);
const canReadSavingGoals = hasPermission(PERMISSIONS.SAVING_GOAL_READ);
const initialDates = useMemo(initialCustomDates, []); const initialDates = useMemo(initialCustomDates, []);
const [period, setPeriod] = useState<ReportPeriodPreset>("MONTH"); const [period, setPeriod] = useState<ReportPeriodPreset>("MONTH");
const [dateFrom, setDateFrom] = useState(initialDates.from); const [dateFrom, setDateFrom] = useState(initialDates.from);
...@@ -126,6 +135,14 @@ const ReportsPage: React.FC = () => { ...@@ -126,6 +135,14 @@ const ReportsPage: React.FC = () => {
void budgetQuery.refetch(); void budgetQuery.refetch();
}; };
const openFromReports = (path: string) => {
navigate(path, { state: { fromReports: true } });
};
const openTransactionsFromReports = (transactionTypeFilter?: TransactionType) => {
navigate("/transactions", { state: { fromReports: true, transactionTypeFilter } });
};
const periodLabel = overview const periodLabel = overview
? `${formatDate(overview.period.from, { day: "2-digit", month: "short", year: "numeric" })}${formatDate(new Date(new Date(overview.period.to).getTime() - 1), { day: "2-digit", month: "short", year: "numeric" })}` ? `${formatDate(overview.period.from, { day: "2-digit", month: "short", year: "numeric" })}${formatDate(new Date(new Date(overview.period.to).getTime() - 1), { day: "2-digit", month: "short", year: "numeric" })}`
: t(`report.period.${period.toLowerCase()}`); : t(`report.period.${period.toLowerCase()}`);
...@@ -191,7 +208,13 @@ const ReportsPage: React.FC = () => { ...@@ -191,7 +208,13 @@ const ReportsPage: React.FC = () => {
{queryEnabled && overviewQuery.isLoading && <ReportSectionSkeleton rows={4} />} {queryEnabled && overviewQuery.isLoading && <ReportSectionSkeleton rows={4} />}
{queryEnabled && overviewQuery.isError && <ReportErrorState onRetry={() => void overviewQuery.refetch()} />} {queryEnabled && overviewQuery.isError && <ReportErrorState onRetry={() => void overviewQuery.refetch()} />}
{queryEnabled && overview && metric && ( {queryEnabled && overview && metric && (
<OverviewMetrics metric={metric} wallets={currencyWallets} /> <OverviewMetrics
metric={metric}
wallets={currencyWallets}
onOpenBalance={canReadWallets ? () => openFromReports("/wallets") : undefined}
onOpenTransactions={canReadTransactions ? openTransactionsFromReports : undefined}
onOpenWallet={canReadWallets ? (id) => openFromReports(`/wallets/${id}`) : undefined}
/>
)} )}
{queryEnabled && overview && !metric && <ReportEmptyState />} {queryEnabled && overview && !metric && <ReportEmptyState />}
...@@ -200,13 +223,13 @@ const ReportsPage: React.FC = () => { ...@@ -200,13 +223,13 @@ const ReportsPage: React.FC = () => {
<div> <div>
{cashFlowQuery.isLoading && <ReportSectionSkeleton />} {cashFlowQuery.isLoading && <ReportSectionSkeleton />}
{cashFlowQuery.isError && <ReportErrorState onRetry={() => void cashFlowQuery.refetch()} />} {cashFlowQuery.isError && <ReportErrorState onRetry={() => void cashFlowQuery.refetch()} />}
{cashFlow && activeCurrency && cashFlowTotal && cashFlowTotal.transactionCount > 0 && <CashFlowChart report={cashFlow} currency={activeCurrency} />} {cashFlow && activeCurrency && cashFlowTotal && cashFlowTotal.transactionCount > 0 && <CashFlowChart report={cashFlow} currency={activeCurrency} onOpenTransactions={canReadTransactions ? () => openTransactionsFromReports() : undefined} />}
{cashFlow && (!cashFlowTotal || cashFlowTotal.transactionCount === 0) && <ReportEmptyState title={t("report.cashFlow.empty")} hint={t("report.cashFlow.emptyHint")} />} {cashFlow && (!cashFlowTotal || cashFlowTotal.transactionCount === 0) && <ReportEmptyState title={t("report.cashFlow.empty")} hint={t("report.cashFlow.emptyHint")} />}
</div> </div>
<div> <div>
{spendingQuery.isLoading && <ReportSectionSkeleton />} {spendingQuery.isLoading && <ReportSectionSkeleton />}
{spendingQuery.isError && <ReportErrorState onRetry={() => void spendingQuery.refetch()} />} {spendingQuery.isError && <ReportErrorState onRetry={() => void spendingQuery.refetch()} />}
{categoryData && categoryData.categories.length > 0 && <CategoryDistribution data={categoryData} />} {categoryData && categoryData.categories.length > 0 && <CategoryDistribution data={categoryData} onOpenCategories={canReadCategories ? () => openFromReports("/categories") : undefined} />}
{spendingQuery.data && (!categoryData || categoryData.categories.length === 0) && <ReportEmptyState title={t("report.categories.empty")} hint={t("report.categories.emptyHint")} />} {spendingQuery.data && (!categoryData || categoryData.categories.length === 0) && <ReportEmptyState title={t("report.categories.empty")} hint={t("report.categories.emptyHint")} />}
</div> </div>
</div> </div>
...@@ -217,12 +240,12 @@ const ReportsPage: React.FC = () => { ...@@ -217,12 +240,12 @@ const ReportsPage: React.FC = () => {
<div> <div>
{budgetQuery.isLoading && <ReportSectionSkeleton />} {budgetQuery.isLoading && <ReportSectionSkeleton />}
{budgetQuery.isError && <ReportErrorState onRetry={() => void budgetQuery.refetch()} />} {budgetQuery.isError && <ReportErrorState onRetry={() => void budgetQuery.refetch()} />}
{budgetQuery.data && currencyBudgets.length > 0 && <BudgetAnalytics budgets={currencyBudgets} currency={activeCurrency} onOpenBudget={(id) => navigate(`/budgets/${id}`)} />} {budgetQuery.data && currencyBudgets.length > 0 && <BudgetAnalytics budgets={currencyBudgets} currency={activeCurrency} onOpenBudget={canReadBudgets ? (id) => openFromReports(`/budgets/${id}`) : undefined} />}
{budgetQuery.data && currencyBudgets.length === 0 && <ReportEmptyState title={t("report.budgets.empty")} hint={t("report.budgets.emptyHint")} />} {budgetQuery.data && currencyBudgets.length === 0 && <ReportEmptyState title={t("report.budgets.empty")} hint={t("report.budgets.emptyHint")} />}
</div> </div>
<div> <div>
{overviewQuery.isLoading && <ReportSectionSkeleton />} {overviewQuery.isLoading && <ReportSectionSkeleton />}
{overview && overview.savingGoals.totalGoals > 0 && <SavingGoalsAnalytics summary={overview.savingGoals} currency={activeCurrency} onOpenGoals={() => navigate("/saving-goals")} />} {overview && overview.savingGoals.totalGoals > 0 && <SavingGoalsAnalytics summary={overview.savingGoals} currency={activeCurrency} onOpenGoals={canReadSavingGoals ? () => openFromReports("/saving-goals") : undefined} />}
{overview && overview.savingGoals.totalGoals === 0 && <ReportEmptyState title={t("report.goals.empty")} hint={t("report.goals.emptyHint")} />} {overview && overview.savingGoals.totalGoals === 0 && <ReportEmptyState title={t("report.goals.empty")} hint={t("report.goals.emptyHint")} />}
</div> </div>
</div> </div>
......
...@@ -10,7 +10,7 @@ import { IconGradients, PlusIcon, SavingGoalIcon } from "@/components/ui/icons"; ...@@ -10,7 +10,7 @@ import { IconGradients, PlusIcon, SavingGoalIcon } from "@/components/ui/icons";
import { useCreateSavingGoal, useSavingGoals } from "@/hooks/use-saving-goals"; import { useCreateSavingGoal, useSavingGoals } from "@/hooks/use-saving-goals";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { getErrorMessage } from "@/lib/error-message"; import { getErrorMessage } from "@/lib/error-message";
import { isFromAIRecommendations } from "@/lib/navigation-state"; import { isFromAIRecommendations, isFromReports } from "@/lib/navigation-state";
import { toLocalDate } from "@/lib/money-input"; import { toLocalDate } from "@/lib/money-input";
import { import {
CreateSavingGoalInput, CreateSavingGoalInput,
...@@ -117,7 +117,7 @@ const SavingGoalsPage: React.FC = () => { ...@@ -117,7 +117,7 @@ const SavingGoalsPage: React.FC = () => {
<Header <Header
title={t("savingGoal.header")} title={t("savingGoal.header")}
showBackIcon showBackIcon
onBackClick={() => isFromAIRecommendations(location.state) ? navigate(-1) : navigate("/")} onBackClick={() => isFromAIRecommendations(location.state) || isFromReports(location.state) ? navigate(-1) : navigate("/")}
/> />
<IconGradients /> <IconGradients />
......
import React, { useDeferredValue, useEffect, useMemo, useState } from "react"; import React, { useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
import { Header, Page, useLocation, useNavigate, useSnackbar } from "zmp-ui"; import { Header, Page, useLocation, useNavigate, useSnackbar } from "zmp-ui";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
...@@ -17,6 +17,7 @@ import { getCategoryDisplayName } from "@/lib/category-format"; ...@@ -17,6 +17,7 @@ import { getCategoryDisplayName } from "@/lib/category-format";
import { import {
useCreateTransaction, useCreateTransaction,
useDeleteTransaction, useDeleteTransaction,
useTransaction,
useTransactions, useTransactions,
useUpdateTransaction, useUpdateTransaction,
transactionKeys, transactionKeys,
...@@ -24,7 +25,7 @@ import { ...@@ -24,7 +25,7 @@ import {
import { useWallets, walletKeys } from "@/hooks/use-wallets"; import { useWallets, walletKeys } from "@/hooks/use-wallets";
import { useCategoryTree } from "@/hooks/use-categories"; import { useCategoryTree } from "@/hooks/use-categories";
import { getErrorMessage } from "@/lib/error-message"; import { getErrorMessage } from "@/lib/error-message";
import { isFromNotifications } from "@/lib/navigation-state"; import { getTransactionId, getTransactionTypeFilter, isFromNotifications, isFromReports } from "@/lib/navigation-state";
import { import {
CreateTransactionInput, CreateTransactionInput,
Transaction, Transaction,
...@@ -126,13 +127,17 @@ const TransactionsPage: React.FC = () => { ...@@ -126,13 +127,17 @@ const TransactionsPage: React.FC = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { openSnackbar } = useSnackbar(); const { openSnackbar } = useSnackbar();
const { t, intlLocale, formatDate } = useI18n(); const { t, intlLocale, formatDate } = useI18n();
const initialTransactionType = getTransactionTypeFilter(location.state);
const notificationTransactionId = getTransactionId(location.state);
const openedNotificationTransactionRef = useRef<string>();
const notifiedDetailErrorRef = useRef(false);
// Search & Filter state // Search & Filter state
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const deferredSearch = useDeferredValue(search.trim()); const deferredSearch = useDeferredValue(search.trim());
const [walletId, setWalletId] = useState(""); const [walletId, setWalletId] = useState("");
const [categoryId, setCategoryId] = useState(""); const [categoryId, setCategoryId] = useState("");
const [type, setType] = useState(""); const [type, setType] = useState(initialTransactionType || "");
const [dateFrom, setDateFrom] = useState(""); const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState(""); const [dateTo, setDateTo] = useState("");
const [minAmount, setMinAmount] = useState(""); const [minAmount, setMinAmount] = useState("");
...@@ -141,7 +146,7 @@ const TransactionsPage: React.FC = () => { ...@@ -141,7 +146,7 @@ const TransactionsPage: React.FC = () => {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
// Filter Panel Visibility // Filter Panel Visibility
const [isFilterExpanded, setIsFilterExpanded] = useState(false); const [isFilterExpanded, setIsFilterExpanded] = useState(Boolean(initialTransactionType));
// Modals Visibility // Modals Visibility
const [isCreateOpen, setIsCreateOpen] = useState(false); const [isCreateOpen, setIsCreateOpen] = useState(false);
...@@ -210,6 +215,23 @@ const TransactionsPage: React.FC = () => { ...@@ -210,6 +215,23 @@ const TransactionsPage: React.FC = () => {
// Main Transactions Query // Main Transactions Query
const transactionsQuery = useTransactions(query); const transactionsQuery = useTransactions(query);
const notificationTransactionQuery = useTransaction(notificationTransactionId || "");
useEffect(() => {
const transaction = notificationTransactionQuery.data?.data;
if (!transaction || openedNotificationTransactionRef.current === transaction.id) return;
openedNotificationTransactionRef.current = transaction.id;
setSelectedTransaction(transaction);
setIsDetailOpen(true);
}, [notificationTransactionQuery.data?.data]);
useEffect(() => {
if (!notificationTransactionQuery.isError || notifiedDetailErrorRef.current) return;
notifiedDetailErrorRef.current = true;
openSnackbar({ type: "error", text: t("transaction.loadFailed") });
}, [notificationTransactionQuery.isError, openSnackbar, t]);
// Mutations // Mutations
const createMutation = useCreateTransaction(); const createMutation = useCreateTransaction();
...@@ -468,7 +490,7 @@ const TransactionsPage: React.FC = () => { ...@@ -468,7 +490,7 @@ const TransactionsPage: React.FC = () => {
<Header <Header
title={t("transaction.header")} title={t("transaction.header")}
showBackIcon showBackIcon
onBackClick={() => isFromNotifications(location.state) ? navigate(-1) : navigate("/")} onBackClick={() => isFromNotifications(location.state) || isFromReports(location.state) ? navigate(-1) : navigate("/")}
/> />
<IconGradients /> <IconGradients />
......
import React, { useState } from "react"; import React, { useState } from "react";
import { Header, Page, useNavigate, useParams, useSnackbar } from "zmp-ui"; import { Header, Page, useLocation, useNavigate, useParams, useSnackbar } from "zmp-ui";
import { WalletArtwork } from "@/components/shared/WalletArtwork"; import { WalletArtwork } from "@/components/shared/WalletArtwork";
import { Badge } from "@/components/ui/Badge"; import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
...@@ -14,6 +14,7 @@ import { ...@@ -14,6 +14,7 @@ import {
useWallet, useWallet,
} from "@/hooks/use-wallets"; } from "@/hooks/use-wallets";
import { getErrorMessage } from "@/lib/error-message"; import { getErrorMessage } from "@/lib/error-message";
import { isFromReports } from "@/lib/navigation-state";
import { formatWalletBalance } from "@/lib/wallet-format"; import { formatWalletBalance } from "@/lib/wallet-format";
import { WalletInput } from "@/types/wallet"; import { WalletInput } from "@/types/wallet";
import { WalletFormModal } from "./components/WalletFormModal"; import { WalletFormModal } from "./components/WalletFormModal";
...@@ -24,6 +25,7 @@ const WalletDetailPage: React.FC = () => { ...@@ -24,6 +25,7 @@ const WalletDetailPage: React.FC = () => {
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const walletId = params.id || ""; const walletId = params.id || "";
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const { openSnackbar } = useSnackbar(); const { openSnackbar } = useSnackbar();
const { formatDate, intlLocale, t } = useI18n(); const { formatDate, intlLocale, t } = useI18n();
const [isEditOpen, setIsEditOpen] = useState(false); const [isEditOpen, setIsEditOpen] = useState(false);
...@@ -76,7 +78,7 @@ const WalletDetailPage: React.FC = () => { ...@@ -76,7 +78,7 @@ const WalletDetailPage: React.FC = () => {
return ( return (
<Page className="page"> <Page className="page">
<Header title={t("wallet.detailHeader")} showBackIcon onBackClick={() => navigate("/wallets")} /> <Header title={t("wallet.detailHeader")} showBackIcon onBackClick={() => isFromReports(location.state) ? navigate(-1) : navigate("/wallets")} />
<IconGradients /> <IconGradients />
<main className="mx-auto mt-4 flex w-full max-w-lg flex-col gap-5 pb-12"> <main className="mx-auto mt-4 flex w-full max-w-lg flex-col gap-5 pb-12">
......
import React, { useDeferredValue, useEffect, useMemo, useState } from "react"; import React, { useDeferredValue, useEffect, useMemo, useState } from "react";
import { Header, Page, useNavigate, useSnackbar } from "zmp-ui"; import { Header, Page, useLocation, useNavigate, useSnackbar } from "zmp-ui";
import { WalletCard } from "@/components/shared/WalletCard"; import { WalletCard } from "@/components/shared/WalletCard";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
...@@ -8,6 +8,7 @@ import { Select } from "@/components/ui/Select"; ...@@ -8,6 +8,7 @@ import { Select } from "@/components/ui/Select";
import { IconGradients, PlusIcon, WalletIcon } from "@/components/ui/icons"; import { IconGradients, PlusIcon, WalletIcon } from "@/components/ui/icons";
import { useCreateWallet, useSetDefaultWallet, useWalletSearch, useWallets } from "@/hooks/use-wallets"; import { useCreateWallet, useSetDefaultWallet, useWalletSearch, useWallets } from "@/hooks/use-wallets";
import { getErrorMessage } from "@/lib/error-message"; import { getErrorMessage } from "@/lib/error-message";
import { isFromReports } from "@/lib/navigation-state";
import { formatWalletBalance, groupBalancesByCurrency } from "@/lib/wallet-format"; import { formatWalletBalance, groupBalancesByCurrency } from "@/lib/wallet-format";
import { SortOrder, WalletInput, WalletQuery, WalletSortField } from "@/types/wallet"; import { SortOrder, WalletInput, WalletQuery, WalletSortField } from "@/types/wallet";
import { WalletFormModal } from "./components/WalletFormModal"; import { WalletFormModal } from "./components/WalletFormModal";
...@@ -18,6 +19,7 @@ const PAGE_SIZE = 6; ...@@ -18,6 +19,7 @@ const PAGE_SIZE = 6;
const WalletsPage: React.FC = () => { const WalletsPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const { openSnackbar } = useSnackbar(); const { openSnackbar } = useSnackbar();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const { intlLocale, t } = useI18n(); const { intlLocale, t } = useI18n();
...@@ -115,7 +117,7 @@ const WalletsPage: React.FC = () => { ...@@ -115,7 +117,7 @@ const WalletsPage: React.FC = () => {
return ( return (
<Page className="page"> <Page className="page">
<Header title={t("wallet.header")} showBackIcon onBackClick={() => navigate("/")} /> <Header title={t("wallet.header")} showBackIcon onBackClick={() => isFromReports(location.state) ? navigate(-1) : navigate("/")} />
<IconGradients /> <IconGradients />
<main className="mx-auto mt-4 flex w-full max-w-lg flex-col gap-5 pb-12"> <main className="mx-auto mt-4 flex w-full max-w-lg flex-col gap-5 pb-12">
......
...@@ -60,6 +60,30 @@ module.exports = { ...@@ -60,6 +60,30 @@ module.exports = {
baloo: ["'Baloo 2'", "sans-serif"], baloo: ["'Baloo 2'", "sans-serif"],
nunito: ["Nunito", "sans-serif"], nunito: ["Nunito", "sans-serif"],
}, },
keyframes: {
"modal-backdrop-in": {
"0%": { opacity: "0" },
"100%": { opacity: "1" },
},
"modal-content-in": {
"0%": {
opacity: "0",
transform: "translateY(24px) scale(0.96)",
},
"70%": {
opacity: "1",
transform: "translateY(-3px) scale(1.01)",
},
"100%": {
opacity: "1",
transform: "translateY(0) scale(1)",
},
},
},
animation: {
"modal-backdrop-in": "modal-backdrop-in 180ms ease-out both",
"modal-content-in": "modal-content-in 300ms cubic-bezier(0.22, 1, 0.36, 1) both",
},
}, },
}, },
plugins: [], plugins: [],
......
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