Commit 22d539ad authored by ThinhNC's avatar ThinhNC

Merge branch 'develop' into 'master'

Develop

See merge request !47
parents 8621dab9 10d44cad
This diff is collapsed.
...@@ -14,36 +14,32 @@ ...@@ -14,36 +14,32 @@
- **State Management**: Zustand cho state toàn cục (auth, ui settings). - **State Management**: Zustand cho state toàn cục (auth, ui settings).
- **Form Validation**: Zod + React Hook Form. - **Form Validation**: Zod + React Hook Form.
## Cấu trúc thư mục (Feature-based) ## Cấu trúc thư mục hiện tại
```text ```text
src/ ├── index.html # HTML entry ở root; ZMP CLI yêu cầu vị trí này khi chạy dev
├── app.ts # Điểm vào chính của ứng dụng ├── app-config.json # Cấu hình khung hiển thị Zalo Mini App
├── components/ ├── vite.config.mts # Vite config; không đổi root sang ./src
│ ├── ui/ # Base components Claymorphism (Button, Card, Input, v.v.) ├── www/ # Build output được sinh bởi `npm run build`
│ └── shared/ # Components nghiệp vụ dùng chung (TransactionItem, WalletCard) └── src/
├── pages/ # Thư mục chứa các trang nghiệp vụ ├── app.ts # React entry, mount vào #app
│ ├── home/ # Trang chủ ├── components/
│ ├── wallet/ # Quản lý ví │ ├── ui/ # Base components Claymorphism
│ ├── transaction/ # Giao dịch │ └── shared/ # AuthGuard và component nghiệp vụ dùng chung
│ ├── budget/ # Ngân sách ├── pages/
│ ├── saving-goal/ # Mục tiêu tiết kiệm │ ├── auth/ # Đăng nhập, đăng ký, quên/đặt lại mật khẩu
│ ├── report/ # Báo cáo tài chính │ ├── profile/ # Trang hồ sơ người dùng
│ ├── ai-assistant/ # Trợ lý AI tài chính │ ├── index.tsx # Trang chủ sau đăng nhập
│ └── style-guide/ # Trang thử nghiệm & tài liệu Design System │ └── style-guide.tsx # Trang kiểm thử Design System
├── css/ ├── css/ # Tailwind directives và style bổ sung
│ ├── tailwind.scss # Import directives của Tailwind ├── lib/ # Axios client và React Query client
│ └── app.scss # CSS tùy chỉnh và các lớp phong cách bổ sung ├── services/ # Module gọi API, hiện có auth.service.ts
├── hooks/ # Custom hooks dùng chung ├── stores/ # Zustand stores, hiện có auth-store.ts
├── services/ # Module gọi API (auth.service.ts, wallet.service.ts) └── types/ # Kiểu dữ liệu dùng chung với backend
├── lib/
│ ├── api-client.ts # Axios instance có đính kèm Bearer Token & Interceptor tự refresh
│ └── query-client.ts # Cấu hình TanStack Query Client
├── stores/ # Zustand stores (ví dụ: auth-store.ts)
├── types/ # Kiểu dữ liệu TypeScript khớp với BE
└── utils/ # Hàm tiện ích (định dạng tiền tệ, ngày tháng)
``` ```
Các module ví, giao dịch, ngân sách, mục tiêu tiết kiệm, báo cáo và AI Assistant là phạm vi sản phẩm dự kiến, chưa có page trong code hiện tại.
## Luồng API & Dữ liệu ## Luồng API & Dữ liệu
```text ```text
...@@ -55,6 +51,8 @@ Component / Page ...@@ -55,6 +51,8 @@ Component / Page
``` ```
- **Base URL (Development)**: `http://localhost:7777/api/v1` (Port 7777 khớp với Backend `.env`). - **Base URL (Development)**: `http://localhost:7777/api/v1` (Port 7777 khớp với Backend `.env`).
- **Khởi tạo đăng nhập**: `AuthInitializer` gọi `GET /auth/me`; route riêng tư đi qua `AuthGuard` và chuyển về `/login` khi chưa xác thực.
- **Token**: Ưu tiên cookie HTTP-only; Axios vẫn hỗ trợ Bearer token từ auth store cho luồng tương thích hiện có.
- **Response Format**: - **Response Format**:
```json ```json
{ {
...@@ -64,3 +62,9 @@ Component / Page ...@@ -64,3 +62,9 @@ Component / Page
"errors": [] | null "errors": [] | null
} }
``` ```
## Dev server và build
- `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/`.
...@@ -16,8 +16,10 @@ ...@@ -16,8 +16,10 @@
``` ```
- Khởi động môi trường dev cục bộ để kiểm tra giao diện trực quan: - Khởi động môi trường dev cục bộ để kiểm tra giao diện trực quan:
```bash ```bash
zmp start npm run start
``` ```
- 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). - 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).
## 4. Trước khi bàn giao ## 4. Trước khi bàn giao
......
...@@ -14,6 +14,8 @@ pids ...@@ -14,6 +14,8 @@ pids
# Dependency directories # Dependency directories
node_modules/ node_modules/
.chrome-cdp/
.chrome-codex/
# Optional npm cache directory # Optional npm cache directory
.npm .npm
......
This diff is collapsed.
{ {
"app": { "app": {
"title": "zmp-blank-templates", "title": "FinWise",
"textColor": { "textColor": {
"light": "black", "light": "black",
"dark": "white" "dark": "white"
......
...@@ -4,25 +4,47 @@ ...@@ -4,25 +4,47 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta <meta
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src * 'self' 'unsafe-inline' 'unsafe-eval' data: gap: content:" content="default-src * 'self' 'unsafe-inline' 'unsafe-eval' data: blob: gap: content:"
/> />
<meta <meta
name="viewport" name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no, viewport-fit=cover" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover"
/> />
<meta name="theme-color" content="#F3F0FA" /> <meta name="theme-color" content="#F3F0FA" />
<meta name="format-detection" content="telephone=no" /> <meta name="format-detection" content="telephone=no" />
<meta name="msapplication-tap-highlight" content="no" /> <meta name="msapplication-tap-highlight" content="no" />
<title>Sổ tay Chi tiêu & Báo cáo Tài chính</title> <title>FinWise - Sổ tay Chi tiêu & Báo cáo Tài chính</title>
<link rel="icon" type="image/png" href="/src/static/logo.png" />
<link rel="shortcut icon" type="image/png" href="/src/static/logo.png" />
<link rel="apple-touch-icon" href="/src/static/logo.png" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@600;700;800&family=Nunito:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@600;700;800&family=Nunito:wght@400;500;600;700&display=swap" rel="stylesheet">
<script>
(() => {
try {
const savedTheme = localStorage.getItem("finwise.theme");
const prefersDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
const theme = savedTheme === "light" || savedTheme === "dark"
? savedTheme
: prefersDark ? "dark" : "light";
document.documentElement.dataset.theme = theme;
document.documentElement.setAttribute("zaui-theme", theme);
document.documentElement.style.colorScheme = theme;
const themeMeta = document.querySelector('meta[name="theme-color"]');
if (themeMeta) themeMeta.setAttribute("content", theme === "dark" ? "#181726" : "#F3F0FA");
} catch {
document.documentElement.dataset.theme = "light";
document.documentElement.setAttribute("zaui-theme", "light");
}
})();
</script>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<!-- built script files will be auto injected --> <!-- built script files will be auto injected -->
<script type="module" src="/app.ts"></script> <script type="module" src="/src/app.ts"></script>
</body> </body>
</html> </html>
...@@ -18,14 +18,21 @@ ...@@ -18,14 +18,21 @@
"login": "zmp login", "login": "zmp login",
"start": "zmp start", "start": "zmp start",
"deploy": "zmp deploy", "deploy": "zmp deploy",
"build": "vite build" "typecheck": "tsc --noEmit",
"build": "tsc --noEmit && vite build"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.7.1",
"@tanstack/react-query": "^5.101.4",
"axios": "^1.19.0",
"jotai": "^2.12.1", "jotai": "^2.12.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.84.0",
"zmp-sdk": "latest", "zmp-sdk": "latest",
"zmp-ui": "latest" "zmp-ui": "latest",
"zod": "^4.4.3",
"zustand": "^5.0.14"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.1", "@types/react": "^18.3.1",
...@@ -38,6 +45,7 @@ ...@@ -38,6 +45,7 @@
"postcss-preset-env": "^6.7.0", "postcss-preset-env": "^6.7.0",
"sass": "^1.76.0", "sass": "^1.76.0",
"tailwindcss": "^3.4.3", "tailwindcss": "^3.4.3",
"typescript": "^5.4.5",
"vite": "^5.2.13", "vite": "^5.2.13",
"zmp-vite-plugin": "latest" "zmp-vite-plugin": "latest"
} }
......
This diff is collapsed.
export * from './permission.constant';
export * from './system-role.constant';
export const PERMISSIONS = {
// USER
USER_READ: 'USER_READ',
USER_CREATE: 'USER_CREATE',
USER_UPDATE: 'USER_UPDATE',
USER_DELETE: 'USER_DELETE',
USER_RESTORE: 'USER_RESTORE',
// ROLE
ROLE_READ: 'ROLE_READ',
ROLE_CREATE: 'ROLE_CREATE',
ROLE_UPDATE: 'ROLE_UPDATE',
ROLE_DELETE: 'ROLE_DELETE',
// PERMISSION
PERMISSION_READ: 'PERMISSION_READ',
ROLE_PERMISSION_ASSIGN: 'ROLE_PERMISSION_ASSIGN',
// AUDIT LOG
AUDIT_LOG_READ: 'AUDIT_LOG_READ',
// WALLET
WALLET_READ: 'WALLET_READ',
WALLET_CREATE: 'WALLET_CREATE',
WALLET_UPDATE: 'WALLET_UPDATE',
WALLET_DELETE: 'WALLET_DELETE',
// TRANSACTION
TRANSACTION_READ: 'TRANSACTION_READ',
TRANSACTION_CREATE: 'TRANSACTION_CREATE',
TRANSACTION_UPDATE: 'TRANSACTION_UPDATE',
TRANSACTION_DELETE: 'TRANSACTION_DELETE',
// TRANSFER
TRANSFER_READ: 'TRANSFER_READ',
TRANSFER_CREATE: 'TRANSFER_CREATE',
TRANSFER_DELETE: 'TRANSFER_DELETE',
// CATEGORY
CATEGORY_READ: 'CATEGORY_READ',
CATEGORY_CREATE: 'CATEGORY_CREATE',
CATEGORY_UPDATE: 'CATEGORY_UPDATE',
CATEGORY_DELETE: 'CATEGORY_DELETE',
// BUDGET
BUDGET_READ: 'BUDGET_READ',
BUDGET_CREATE: 'BUDGET_CREATE',
BUDGET_UPDATE: 'BUDGET_UPDATE',
BUDGET_DELETE: 'BUDGET_DELETE',
// SAVING GOAL
SAVING_GOAL_READ: 'SAVING_GOAL_READ',
SAVING_GOAL_CREATE: 'SAVING_GOAL_CREATE',
SAVING_GOAL_UPDATE: 'SAVING_GOAL_UPDATE',
SAVING_GOAL_DELETE: 'SAVING_GOAL_DELETE',
// REPORT
REPORT_READ: 'REPORT_READ',
// FORECAST
FORECAST_READ: 'FORECAST_READ',
// SIMULATION
SIMULATION_READ: 'SIMULATION_READ',
SIMULATION_EXECUTE: 'SIMULATION_EXECUTE',
// ANOMALY
ANOMALY_READ: 'ANOMALY_READ',
ANOMALY_EVALUATE: 'ANOMALY_EVALUATE',
// SUBSCRIPTION
SUBSCRIPTION_READ: 'SUBSCRIPTION_READ',
SUBSCRIPTION_MANAGE: 'SUBSCRIPTION_MANAGE',
// QUERY
QUERY_EXECUTE: 'QUERY_EXECUTE',
// RECURRING TRANSACTION
RECURRING_TRANSACTION_READ: 'RECURRING_TRANSACTION_READ',
RECURRING_TRANSACTION_CREATE: 'RECURRING_TRANSACTION_CREATE',
RECURRING_TRANSACTION_UPDATE: 'RECURRING_TRANSACTION_UPDATE',
RECURRING_TRANSACTION_DELETE: 'RECURRING_TRANSACTION_DELETE',
// NOTIFICATION
NOTIFICATION_READ: 'NOTIFICATION_READ',
NOTIFICATION_UPDATE: 'NOTIFICATION_UPDATE',
NOTIFICATION_DELETE: 'NOTIFICATION_DELETE',
NOTIFICATION_ADMIN_READ: 'NOTIFICATION_ADMIN_READ',
NOTIFICATION_RETRY: 'NOTIFICATION_RETRY',
NOTIFICATION_TEMPLATE_READ: 'NOTIFICATION_TEMPLATE_READ',
NOTIFICATION_TEMPLATE_UPDATE: 'NOTIFICATION_TEMPLATE_UPDATE',
NOTIFICATION_CONFIG_UPDATE: 'NOTIFICATION_CONFIG_UPDATE',
// REMINDER
REMINDER_READ: 'REMINDER_READ',
REMINDER_CREATE: 'REMINDER_CREATE',
REMINDER_UPDATE: 'REMINDER_UPDATE',
REMINDER_DELETE: 'REMINDER_DELETE',
// AI ASSISTANT & AI ADMIN
AI_ASSISTANT_USE: 'AI_ASSISTANT_USE',
AI_ADMIN_READ: 'AI_ADMIN_READ',
AI_CONFIG_UPDATE: 'AI_CONFIG_UPDATE',
AI_USAGE_READ: 'AI_USAGE_READ',
// SYSTEM CONFIGURATION & MAINTENANCE
SYSTEM_CONFIG_READ: 'SYSTEM_CONFIG_READ',
SYSTEM_CONFIG_UPDATE: 'SYSTEM_CONFIG_UPDATE',
MAINTENANCE_MODE_UPDATE: 'MAINTENANCE_MODE_UPDATE',
// UPLOAD
UPLOAD_FILE: 'UPLOAD_FILE',
// API KEY INTEGRATION
API_KEY_READ: 'API_KEY_READ',
API_KEY_CREATE: 'API_KEY_CREATE',
API_KEY_DELETE: 'API_KEY_DELETE',
// WEBHOOK INTEGRATION
WEBHOOK_READ: 'WEBHOOK_READ',
WEBHOOK_CREATE: 'WEBHOOK_CREATE',
WEBHOOK_UPDATE: 'WEBHOOK_UPDATE',
WEBHOOK_DELETE: 'WEBHOOK_DELETE',
WEBHOOK_TEST: 'WEBHOOK_TEST',
// ASYNC JOB
JOB_READ: 'JOB_READ',
JOB_CREATE: 'JOB_CREATE',
} as const;
export type PermissionName = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
export const SYSTEM_ROLES = {
ADMIN: 'ADMIN',
USER: 'USER',
MANAGER: 'MANAGER',
SUPER_ADMIN: 'SUPER_ADMIN',
} as const;
export type SystemRole = (typeof SYSTEM_ROLES)[keyof typeof SYSTEM_ROLES];
import React, { Component, ErrorInfo, ReactNode } from "react";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
error: null,
};
public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("[ErrorBoundary caught an error]:", error, errorInfo);
}
private handleReset = () => {
this.setState({ hasError: false, error: null });
window.location.href = "/";
};
public render() {
if (this.state.hasError) {
return (
<div className="min-h-screen w-full bg-clay-bg flex items-center justify-center p-4">
<Card className="max-w-md w-full p-6 text-center flex flex-col items-center gap-4">
<div className="w-16 h-16 rounded-clay bg-clay-expense/15 text-clay-expense flex items-center justify-center shadow-clay-pressed text-2xl font-bold font-baloo">
!
</div>
<div>
<h2 className="clay-title-h2">Đã xảy ra lỗi</h2>
<p className="clay-caption mt-1.5 text-clay-text-muted">
{this.state.error?.message || "Ứng dụng gặp sự cố không mong muốn. Vui lòng tải lại hoặc quay về trang chủ."}
</p>
</div>
<div className="flex gap-3 mt-2 w-full">
<Button
variant="secondary"
fullWidth
onClick={() => window.location.reload()}
>
Tải lại
</Button>
<Button
variant="primary"
fullWidth
onClick={this.handleReset}
>
Về trang chủ
</Button>
</div>
</Card>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;
import { useEffect, useState } from "react";
import { Text } from "zmp-ui";
function Clock() {
const [time, setTime] = useState("");
useEffect(() => {
const updateClock = () => {
const now = new Date();
const formattedTime = now.toLocaleString("vi-VN", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
day: "2-digit",
month: "2-digit",
year: "numeric",
});
setTime(formattedTime);
};
updateClock();
const intervalId = setInterval(updateClock, 1000);
return () => clearInterval(intervalId);
}, []);
return <Text className="font-mono">{time}</Text>;
}
export default Clock;
import React from "react";
import { useI18n } from "@/i18n";
export const LanguageSwitcher: React.FC = () => {
const { locale, t, toggleLocale } = useI18n();
return (
<button
type="button"
aria-label={t("language.switchTo")}
title={t("language.switchTo")}
onClick={toggleLocale}
className="inline-flex h-9 min-w-9 items-center justify-center rounded-full border border-clay-border bg-clay-surface px-2 font-baloo text-xs font-bold text-clay-primary shadow-clay-raised transition-all duration-200 ease-in-out active:shadow-clay-pressed focus:outline-none focus:ring-2 focus:ring-clay-primary/35"
>
{locale === "vi" ? "VI" : "EN"}
</button>
);
};
This diff is collapsed.
This diff is collapsed.
import React from 'react';
import { Page, Header, useNavigate } from 'zmp-ui';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { LockIcon, IconGradients } from '@/components/ui/icons';
import { useI18n } from '@/i18n';
export interface AccessDeniedProps {
title?: string;
description?: string;
}
export const AccessDenied: React.FC<AccessDeniedProps> = ({
title,
description,
}) => {
const navigate = useNavigate();
const { t } = useI18n();
return (
<Page className="page">
<Header title={t('common.error') || 'Truy cập'} showBackIcon onBackClick={() => navigate('/')} />
<IconGradients />
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center">
<Card className="w-full max-w-sm flex flex-col items-center gap-4 py-8 px-6">
<div className="w-16 h-16 rounded-full bg-clay-expense/15 text-clay-expense flex items-center justify-center shadow-clay-pressed">
<LockIcon size={32} />
</div>
<div>
<h2 className="clay-title-h2 text-clay-expense mb-1">
{title || t('rbac.accessDenied') || 'Không có quyền truy cập'}
</h2>
<p className="clay-caption text-xs leading-relaxed">
{description ||
t('rbac.accessDeniedDesc') ||
'Bạn không có quyền hạn để truy cập hoặc thực hiện thao tác này. Vui lòng liên hệ Quản trị viên để được cấp quyền.'}
</p>
</div>
<Button
variant="primary"
fullWidth
onClick={() => navigate('/')}
className="mt-2"
>
{t('common.backToHome') || 'Về Trang chủ'}
</Button>
</Card>
</div>
</Page>
);
};
import React, { useEffect } from "react";
import { useNavigate } from "zmp-ui";
import { useAuthStore } from "@/stores/auth-store";
import { useI18n } from "@/i18n";
import { Logo } from "@/components/logo";
export const AuthGuard: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isAuthenticated, isInitialized } = useAuthStore();
const navigate = useNavigate();
const { t } = useI18n();
useEffect(() => {
if (isInitialized && !isAuthenticated) {
navigate("/login", { replace: true });
}
}, [isInitialized, isAuthenticated, navigate]);
if (!isInitialized) {
return (
<div className="page flex flex-col items-center justify-center min-h-screen gap-4">
<Logo size={72} className="animate-pulse" alt="FinWise" />
<p className="clay-caption animate-pulse text-clay-primary font-medium">{t("common.loading")}</p>
</div>
);
}
if (!isAuthenticated) {
return null;
}
return <>{children}</>;
};
This diff is collapsed.
import React from "react";
interface CategoryArtworkProps {
icon?: string | null;
color?: string | null;
size?: "sm" | "md" | "lg";
archived?: boolean;
className?: string;
}
const dimensions = {
sm: "h-10 w-10 rounded-clay-sm",
md: "h-12 w-12 rounded-clay",
lg: "h-16 w-16 rounded-clay-lg",
};
function CategoryGlyph({ icon }: { icon: string }) {
if (["laptop", "computer"].includes(icon)) {
return <><rect x="3" y="4" width="18" height="13" rx="2" /><path d="M2 20h20M9 17v3m6-3v3" /></>;
}
if (["plane", "travel"].includes(icon)) {
return <><path d="M22 2 9 15M22 2l-7 20-4-9-9-4 20-7Z" /></>;
}
if (["briefcase", "work"].includes(icon)) {
return <><rect x="3" y="7" width="18" height="13" rx="3" /><path d="M9 7V5h6v2M3 12h18M10 12v2h4v-2" /></>;
}
if (["trending-up", "investment"].includes(icon)) {
return <><path d="m4 17 6-6 4 4 6-8" /><path d="M15 7h5v5" /></>;
}
if (["utensils", "food"].includes(icon)) {
return <><path d="M7 3v8m-3-8v5a3 3 0 0 0 6 0V3M7 11v10M17 3c-2 2-3 5-3 8h6c0-3-1-6-3-8Zm0 8v10" /></>;
}
if (["shopping-basket", "groceries"].includes(icon)) {
return <><path d="m5 10 3-6m11 6-3-6M3 10h18l-2 10H5L3 10Z" /><path d="M9 14v3m6-3v3" /></>;
}
if (["chef-hat", "restaurant"].includes(icon)) {
return <><path d="M6 13a4 4 0 0 1 1-8 5 5 0 0 1 10 0 4 4 0 0 1 1 8v7H6v-7Z" /><path d="M6 16h12" /></>;
}
if (["car", "transport"].includes(icon)) {
return <><path d="m5 16-1-3 2-6h12l2 6-1 3H5Z" /><circle cx="7" cy="17" r="2" /><circle cx="17" cy="17" r="2" /><path d="M6 12h12" /></>;
}
if (["shopping-bag", "shopping"].includes(icon)) {
return <><path d="M5 8h14l1 13H4L5 8Z" /><path d="M9 9V6a3 3 0 0 1 6 0v3" /></>;
}
if (["receipt", "bills"].includes(icon)) {
return <><path d="M6 3h12v18l-3-2-3 2-3-2-3 2V3Z" /><path d="M9 8h6m-6 4h6m-6 4h4" /></>;
}
if (["heart-pulse", "health"].includes(icon)) {
return <><path d="M20 5c-2-2-6-2-8 1-2-3-6-3-8-1-3 3-1 8 8 15 9-7 11-12 8-15Z" /><path d="M6 12h3l1-3 3 6 1-3h4" /></>;
}
if (["gamepad-2", "entertainment"].includes(icon)) {
return <><path d="M7 8h10c3 0 5 3 4 7l-1 4c-1 2-3 2-4 0l-1-2H9l-1 2c-1 2-3 2-4 0l-1-4c-1-4 1-7 4-7Z" /><path d="M7 11v4m-2-2h4m7-1h.01m2 2h.01" /></>;
}
if (["graduation-cap", "education"].includes(icon)) {
return <><path d="m2 9 10-5 10 5-10 5L2 9Z" /><path d="M6 11v5c3 3 9 3 12 0v-5m4-2v7" /></>;
}
if (["home", "housing"].includes(icon)) {
return <><path d="m3 11 9-8 9 8v10H3V11Z" /><path d="M9 21v-6h6v6" /></>;
}
if (["gift", "bonus"].includes(icon)) {
return <><rect x="3" y="9" width="18" height="12" rx="2" /><path d="M12 9v12M3 13h18M7 9c-3 0-3-5 0-5 2 0 5 5 5 5m5 0c3 0 3-5 0-5-2 0-5 5-5 5" /></>;
}
return <><circle cx="12" cy="12" r="9" /><path d="M12 8v8m-4-4h8" /></>;
}
export const CategoryArtwork: React.FC<CategoryArtworkProps> = ({
icon = "circle-plus",
color = "#8B7CF6",
size = "md",
archived = false,
className = "",
}) => (
<div
aria-hidden="true"
className={`${dimensions[size]} flex shrink-0 items-center justify-center border-2 border-clay-highlight/50 text-white shadow-clay-raised transition-all duration-200 ease-in-out ${archived ? "grayscale opacity-55" : ""} ${className}`}
style={{ backgroundColor: color || "#8B7CF6" }}
>
<svg
width={size === "lg" ? 34 : size === "sm" ? 22 : 26}
height={size === "lg" ? 34 : size === "sm" ? 22 : 26}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<CategoryGlyph icon={icon || "circle-plus"} />
</svg>
</div>
);
import React, { useEffect } from "react";
import { useLocation } from "zmp-ui";
import { useI18n } from "@/i18n";
import finwiseLogo from "@/static/logo.png";
const APP_NAME = "FinWise";
const PAGE_TITLES: ReadonlyArray<{
matches: (pathname: string) => boolean;
key: string;
}> = [
{ matches: (pathname) => pathname === "/", key: "document.home" },
{ matches: (pathname) => pathname === "/login", key: "document.login" },
{ matches: (pathname) => pathname === "/register", key: "document.register" },
{ matches: (pathname) => pathname === "/forgot-password", key: "document.forgotPassword" },
{ matches: (pathname) => pathname === "/reset-password", key: "document.resetPassword" },
{ matches: (pathname) => pathname === "/profile", key: "document.profile" },
{ matches: (pathname) => pathname === "/wallets", key: "document.wallets" },
{ matches: (pathname) => pathname.startsWith("/wallets/"), key: "document.walletDetail" },
{ matches: (pathname) => pathname === "/categories", key: "document.categories" },
{ matches: (pathname) => pathname === "/transactions", key: "document.transactions" },
{ matches: (pathname) => pathname === "/transfers", key: "document.transfers" },
{ matches: (pathname) => pathname === "/budgets", key: "document.budgets" },
{ matches: (pathname) => pathname.startsWith("/budgets/"), key: "document.budgetDetail" },
{ matches: (pathname) => pathname === "/saving-goals", key: "document.savingGoals" },
{ matches: (pathname) => pathname.startsWith("/saving-goals/"), key: "document.savingGoalDetail" },
{ matches: (pathname) => pathname === "/reports", key: "document.reports" },
{ matches: (pathname) => pathname === "/forecast", key: "document.forecast" },
{ matches: (pathname) => pathname === "/simulations", key: "document.simulations" },
{ matches: (pathname) => pathname === "/anomalies", key: "document.anomalies" },
{ matches: (pathname) => pathname === "/subscriptions", key: "document.subscriptions" },
{ matches: (pathname) => pathname === "/recurring-transactions", key: "document.recurringTransactions" },
{ matches: (pathname) => pathname === "/query", key: "document.query" },
{ matches: (pathname) => pathname === "/notifications", key: "document.notifications" },
{ matches: (pathname) => pathname === "/admin/integrations", key: "document.integrations" },
{ matches: (pathname) => pathname === "/admin", key: "document.admin" },
{ matches: (pathname) => pathname === "/admin/users", key: "document.adminUsers" },
{ matches: (pathname) => pathname.startsWith("/admin/users/"), key: "document.adminUserDetail" },
{ matches: (pathname) => pathname === "/admin/audit-logs", key: "document.adminAuditLogs" },
{ matches: (pathname) => pathname === "/admin/settings", key: "document.adminSettings" },
{ matches: (pathname) => pathname === "/admin/notifications", key: "document.adminNotifications" },
{ matches: (pathname) => pathname === "/admin/ai", key: "document.adminAi" },
{ matches: (pathname) => pathname === "/roles" || pathname === "/admin/roles", key: "document.roles" },
{ matches: (pathname) => pathname === "/ai-assistant", key: "document.aiAssistant" },
{ matches: (pathname) => pathname === "/style-guide", key: "document.styleGuide" },
];
export const DocumentTitle: React.FC = () => {
const { pathname } = useLocation();
const { t } = useI18n();
useEffect(() => {
// Resolve absolute URL for FinWise logo
const fullLogoUrl = new URL(finwiseLogo, window.location.href).href;
const updateFaviconInDoc = (doc: Document) => {
try {
const iconRels = ["icon", "shortcut icon", "apple-touch-icon"];
iconRels.forEach((rel) => {
let link: HTMLLinkElement | null = doc.querySelector(`link[rel='${rel}']`);
if (!link) {
link = doc.createElement("link");
link.rel = rel;
doc.head.appendChild(link);
}
if (rel !== "apple-touch-icon") {
link.type = "image/png";
}
link.href = fullLogoUrl;
});
} catch {
// Fallback for security restrictions
}
};
// 1. Update current frame document
updateFaviconInDoc(document);
// 2. If running inside Zalo Mini App Simulator (parent iframe), sync to parent window
if (window.parent && window.parent !== window) {
try {
// Direct update if same origin
if (window.parent.document) {
updateFaviconInDoc(window.parent.document);
}
} catch {
// Different origin: send custom message to ZMP simulator
try {
window.parent.postMessage(
{
type: "custom",
data: `
try {
const iconRels = ["icon", "shortcut icon", "apple-touch-icon"];
iconRels.forEach(function(rel) {
var link = document.querySelector("link[rel='" + rel + "']");
if (!link) {
link = document.createElement("link");
link.rel = rel;
document.head.appendChild(link);
}
if (rel !== "apple-touch-icon") {
link.type = "image/png";
}
link.href = "${fullLogoUrl}";
});
} catch (e) {}
`,
},
"*"
);
} catch {
// Ignore
}
}
}
}, []);
useEffect(() => {
const key = PAGE_TITLES.find((page) => page.matches(pathname))?.key;
const fullTitle = `${key ? t(key) : t("document.default")} | ${APP_NAME}`;
document.title = fullTitle;
// If running in ZMP simulator iframe, sync the document title to the parent simulator
if (window.parent && window.parent !== window) {
try {
if (window.parent.document) {
window.parent.document.title = fullTitle;
}
} catch {
// Different origin: use simulator config-title message
try {
window.parent.postMessage(
{
type: "config-title",
data: fullTitle,
},
"*"
);
} catch {
// Ignore
}
}
}
}, [pathname, t]);
return null;
};
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";
import React, { useCallback } from "react";
import { useSnackbar } from "zmp-ui";
import { useNotificationSSE } from "@/hooks/use-notification-sse";
import { NotificationItem } from "@/types/notification";
export const NotificationRealtimeListener: React.FC = () => {
const { openSnackbar } = useSnackbar();
const handleNotification = useCallback(
(notification: NotificationItem) => {
openSnackbar({
type: notification.priority === "CRITICAL" ? "error" : "info",
text: `${notification.title}: ${notification.message}`,
duration: 4000,
});
},
[openSnackbar],
);
useNotificationSSE({
onNotification: handleNotification,
});
return null;
};
import React from "react";
import { useNavigate } from "zmp-ui";
import { NotificationIcon } from "@/components/ui/icons";
import { useUnreadNotificationCount } from "@/hooks/use-notifications";
import { useI18n } from "@/i18n";
import { useAuthStore } from "@/stores/auth-store";
export const NotificationShortcut: React.FC = () => {
const navigate = useNavigate();
const { t } = useI18n();
const isInitialized = useAuthStore((state) => state.isInitialized);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const countQuery = useUnreadNotificationCount(isInitialized && isAuthenticated);
const count = countQuery.data?.data.count || 0;
if (!isInitialized || !isAuthenticated) return null;
return (
<button type="button" aria-label={count > 0 ? t("notification.badgeLabel", { count }) : t("notification.open")} title={t("notification.open")} onClick={() => navigate("/notifications")} className="relative inline-flex h-9 w-9 items-center justify-center rounded-full border border-clay-border bg-clay-surface text-clay-text shadow-clay-raised transition-all duration-200 ease-in-out active:shadow-clay-pressed focus:outline-none focus:ring-2 focus:ring-clay-primary/35">
<NotificationIcon size={20} />
{count > 0 && <span className="absolute -right-1.5 -top-1.5 flex min-h-5 min-w-5 items-center justify-center rounded-full border-2 border-clay-bg bg-clay-expense px-1 font-nunito text-[10px] font-extrabold leading-none text-clay-on-primary">{count > 99 ? "99+" : count}</span>}
</button>
);
};
import React from 'react';
import { PermissionName } from '@/common/constants';
import { usePermission } from '@/hooks/use-permission';
export interface PermissionGateProps {
permission?: PermissionName | string;
permissions?: (PermissionName | string)[];
mode?: 'all' | 'any';
fallback?: React.ReactNode;
children: React.ReactNode;
}
export const PermissionGate: React.FC<PermissionGateProps> = ({
permission,
permissions,
mode = 'all',
fallback = null,
children,
}) => {
const { hasPermission, hasAnyPermission, hasAllPermissions } = usePermission();
let isAllowed = false;
if (permission) {
isAllowed = hasPermission(permission);
} else if (permissions && permissions.length > 0) {
isAllowed =
mode === 'any' ? hasAnyPermission(permissions) : hasAllPermissions(permissions);
} else {
// If no permission specified, allow by default
isAllowed = true;
}
if (!isAllowed) {
return <>{fallback}</>;
}
return <>{children}</>;
};
import React from "react";
import { ThemeToggle } from "@/components/ui/ThemeToggle";
import { useThemeStore } from "@/stores/theme-store";
export const ThemeControl: React.FC = () => {
const { theme, setTheme } = useThemeStore();
return <ThemeToggle theme={theme} onChange={setTheme} variant="compact" />;
};
import React from "react";
interface WalletArtworkProps {
icon?: string | null;
color?: string | null;
size?: "sm" | "md" | "lg";
archived?: boolean;
}
const dimensions = {
sm: "w-11 h-11 rounded-clay-sm",
md: "w-14 h-14 rounded-clay",
lg: "w-20 h-20 rounded-clay-lg",
};
function ArtworkIcon({ icon }: { icon: string }) {
if (icon === "bank") {
return <path d="M4 9 12 4l8 5M6 10v7m4-7v7m4-7v7m4-7v7M4 20h16" />;
}
if (icon === "card") {
return <><rect x="3" y="5" width="18" height="14" rx="3" /><path d="M3 10h18M7 15h4" /></>;
}
if (icon === "cash") {
return <><rect x="3" y="6" width="18" height="12" rx="3" /><circle cx="12" cy="12" r="3" /><path d="M7 9H6v1m11-1h1v1M7 15H6v-1m11 1h1v-1" /></>;
}
if (icon === "savings") {
return <><path d="M5 11c0-4 3-7 8-7 4 0 7 2 7 6 0 2-1 4-3 5v3h-3v-2H9v2H6v-3c-1-1-2-2-2-4H2" /><circle cx="15.5" cy="8.5" r=".5" fill="currentColor" /></>;
}
return <><path d="M4 7h15a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a3 3 0 0 1-3-3V7a3 3 0 0 1 3-3h13" /><path d="M16 12h5v4h-5a2 2 0 0 1 0-4Z" /></>;
}
export const WalletArtwork: React.FC<WalletArtworkProps> = ({
icon = "wallet",
color = "#8B7CF6",
size = "md",
archived = false,
}) => (
<div
className={`${dimensions[size]} shrink-0 flex items-center justify-center text-clay-on-primary shadow-clay-raised border-2 border-clay-highlight/50 transition-all duration-200 ease-in-out ${archived ? "grayscale opacity-60" : ""}`}
style={{ backgroundColor: color || "#8B7CF6" }}
aria-hidden="true"
>
<svg width={size === "lg" ? 40 : 28} height={size === "lg" ? 40 : 28} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<ArtworkIcon icon={icon || "wallet"} />
</svg>
</div>
);
import React from "react";
import { Badge } from "@/components/ui/Badge";
import { Card } from "@/components/ui/Card";
import { ChevronRightIcon } from "@/components/ui/icons";
import { formatWalletBalance } from "@/lib/wallet-format";
import { Wallet } from "@/types/wallet";
import { WalletArtwork } from "./WalletArtwork";
import { useI18n } from "@/i18n";
interface WalletCardProps {
wallet: Wallet;
onClick: () => void;
onSetDefault?: () => void;
isSettingDefault?: boolean;
}
export const WalletCard: React.FC<WalletCardProps> = ({
wallet,
onClick,
onSetDefault,
isSettingDefault = false,
}) => {
const { intlLocale, t } = useI18n();
return (
<Card
hoverable
className={`p-4 ${wallet.isArchived ? "opacity-75" : ""}`}
onClick={onClick}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onClick();
}
}}
>
<div className="flex items-center gap-3">
<WalletArtwork icon={wallet.icon} color={wallet.color} archived={wallet.isArchived} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-baloo text-lg font-bold text-clay-text truncate">{wallet.name}</h3>
{wallet.isDefault && <Badge type="primary">{t("common.default")}</Badge>}
{wallet.isArchived && <Badge type="warning">{t("common.archived")}</Badge>}
</div>
<p className="font-baloo text-xl font-bold text-clay-primary-dark truncate">
{formatWalletBalance(wallet.balance, wallet.currency, intlLocale)}
</p>
{wallet.description && <p className="clay-caption truncate mt-0.5">{wallet.description}</p>}
</div>
<ChevronRightIcon className="shrink-0 text-clay-text-muted" />
</div>
{!wallet.isDefault && !wallet.isArchived && onSetDefault && (
<button
type="button"
className="mt-3 w-full rounded-full bg-clay-bg px-3 py-2 font-nunito text-xs font-bold text-clay-primary-dark shadow-clay-pressed transition-all duration-200 ease-in-out hover:text-clay-primary disabled:opacity-50"
disabled={isSettingDefault}
onClick={(event) => {
event.stopPropagation();
onSetDefault();
}}
>
{isSettingDefault ? t("common.updating") : t("wallet.setDefault")}
</button>
)}
</Card>
);
};
import React from "react"; import React from "react";
import { useI18n } from "@/i18n";
export interface AvatarProps extends React.ImgHTMLAttributes<HTMLImageElement> { export interface AvatarProps extends React.ImgHTMLAttributes<HTMLImageElement> {
size?: "sm" | "md" | "lg"; size?: "sm" | "md" | "lg";
positionX?: number;
positionY?: number;
} }
export const Avatar: React.FC<AvatarProps> = ({ export const Avatar: React.FC<AvatarProps> = ({
size = "md", size = "md",
className = "", className = "",
src, src,
alt = "User Avatar", alt,
positionX = 50,
positionY = 50,
style,
...props ...props
}) => { }) => {
const { t } = useI18n();
const sizeStyles = { const sizeStyles = {
sm: "w-10 h-10 border-2", sm: "w-10 h-10 border-2",
md: "w-14 h-14 border-[3px]", md: "w-14 h-14 border-[3px]",
...@@ -22,15 +29,19 @@ export const Avatar: React.FC<AvatarProps> = ({ ...@@ -22,15 +29,19 @@ export const Avatar: React.FC<AvatarProps> = ({
return ( return (
<div <div
className={` className={`
rounded-full bg-clay-surface border-white shadow-clay-raised overflow-hidden inline-block flex-shrink-0 rounded-full bg-clay-surface border-clay-highlight shadow-clay-raised overflow-hidden inline-block flex-shrink-0
${sizeStyles[size]} ${sizeStyles[size]}
${className} ${className}
`} `}
> >
<img <img
src={src || defaultAvatar} src={src || defaultAvatar}
alt={alt} alt={alt || t("accessibility.userAvatar")}
className="w-full h-full object-cover rounded-full" className="w-full h-full object-cover rounded-full"
style={{
...style,
objectPosition: `${positionX}% ${positionY}%`,
}}
{...props} {...props}
/> />
</div> </div>
......
...@@ -12,19 +12,18 @@ export const Badge: React.FC<BadgeProps> = ({ ...@@ -12,19 +12,18 @@ export const Badge: React.FC<BadgeProps> = ({
className = "", className = "",
...props ...props
}) => { }) => {
// Pastel backgrounds and corresponding dark text colors
const typeStyles = { const typeStyles = {
primary: "bg-[#EBE9FE] text-[#5B21B6] border border-[#D9D6FE]", primary: "bg-clay-badge-primary-bg text-clay-badge-primary-text border border-clay-badge-primary-border",
income: "bg-[#E6FDF5] text-[#059669] border border-[#C6F6E5]", income: "bg-clay-badge-income-bg text-clay-badge-income-text border border-clay-badge-income-border",
expense: "bg-[#FFF1F2] text-[#E11D48] border border-[#FFE4E6]", expense: "bg-clay-badge-expense-bg text-clay-badge-expense-text border border-clay-badge-expense-border",
warning: "bg-[#FEF3C7] text-[#D97706] border border-[#FDE68A]", warning: "bg-clay-badge-warning-bg text-clay-badge-warning-text border border-clay-badge-warning-border",
info: "bg-[#EFF6FF] text-[#2563EB] border border-[#DBEAFE]", info: "bg-clay-badge-info-bg text-clay-badge-info-text border border-clay-badge-info-border",
}; };
return ( return (
<span <span
className={` className={`
inline-flex items-center px-3 py-1 font-nunito font-bold text-xs rounded-full inline-flex items-center px-2.5 py-0.5 font-nunito font-bold text-xs rounded-full
${typeStyles[type]} ${typeStyles[type]}
${className} ${className}
`} `}
......
import React from "react"; import React from "react";
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "ghost"; variant?: "primary" | "secondary" | "ghost" | "danger";
shape?: "clay" | "pill"; shape?: "clay" | "pill";
fullWidth?: boolean; fullWidth?: boolean;
} }
...@@ -17,9 +17,10 @@ export const Button: React.FC<ButtonProps> = ({ ...@@ -17,9 +17,10 @@ export const Button: React.FC<ButtonProps> = ({
}) => { }) => {
// Styles for different variants // Styles for different variants
const variantStyles = { const variantStyles = {
primary: "bg-clay-primary text-white border-2 border-clay-primary-dark/30 hover:bg-clay-primary/95 shadow-clay-raised active:shadow-clay-pressed active:translate-y-[2px]", primary: "bg-clay-primary text-clay-on-primary border-2 border-clay-primary-dark/30 hover:bg-clay-primary/95 shadow-clay-raised active:shadow-clay-pressed active:translate-y-[2px]",
secondary: "bg-clay-surface text-clay-text border-2 border-clay-text/10 hover:bg-clay-surface/90 shadow-clay-raised active:shadow-clay-pressed active:translate-y-[2px]", secondary: "bg-clay-surface text-clay-text border-2 border-clay-text/10 hover:bg-clay-surface/90 shadow-clay-raised active:shadow-clay-pressed active:translate-y-[2px]",
ghost: "bg-transparent text-clay-text hover:bg-clay-surface/50 active:translate-y-[1px]", ghost: "bg-transparent text-clay-text hover:bg-clay-surface/50 active:translate-y-[1px]",
danger: "bg-clay-expense text-white border-2 border-clay-expense/30 hover:bg-clay-expense/90 shadow-clay-raised active:shadow-clay-pressed active:translate-y-[2px]",
}; };
// Border radius shapes // Border radius shapes
...@@ -37,8 +38,8 @@ export const Button: React.FC<ButtonProps> = ({ ...@@ -37,8 +38,8 @@ export const Button: React.FC<ButtonProps> = ({
return ( return (
<button <button
className={` className={`
inline-flex items-center justify-center font-baloo font-semibold text-lg px-6 py-2.5 inline-flex items-center justify-center font-baloo font-semibold text-sm sm:text-base px-4 sm:px-5 py-2 sm:py-2.5
transition-all duration-150 ease-in-out focus:outline-none select-none transition-all duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-clay-primary/35 select-none
${variantStyles[variant]} ${variantStyles[variant]}
${shapeStyles[shape]} ${shapeStyles[shape]}
${disabledStyles} ${disabledStyles}
......
export * from "@/components/shared/CalculatorModal";
export { CalculatorModal as default } from "@/components/shared/CalculatorModal";
...@@ -8,14 +8,34 @@ export const Card: React.FC<CardProps> = ({ ...@@ -8,14 +8,34 @@ export const Card: React.FC<CardProps> = ({
children, children,
hoverable = false, hoverable = false,
className = "", className = "",
onClick,
onKeyDown,
role,
tabIndex,
...props ...props
}) => { }) => {
const isClickable = Boolean(onClick);
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (onKeyDown) {
onKeyDown(event);
}
if (!event.defaultPrevented && isClickable && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
onClick?.(event as unknown as React.MouseEvent<HTMLDivElement>);
}
};
return ( return (
<div <div
role={role ?? (isClickable ? "button" : undefined)}
tabIndex={tabIndex ?? (isClickable ? 0 : undefined)}
onClick={onClick}
onKeyDown={isClickable || onKeyDown ? handleKeyDown : undefined}
className={` className={`
bg-clay-surface rounded-clay-lg shadow-clay-raised p-6 bg-clay-surface rounded-clay-lg shadow-clay-raised p-4 sm:p-5
transition-all duration-200 ease-in-out border border-white/40 transition-all duration-200 ease-in-out border border-clay-highlight/40
${hoverable ? "hover:shadow-clay-hover hover:-translate-y-[2px] cursor-pointer" : ""} ${hoverable || isClickable ? "hover:shadow-clay-hover hover:-translate-y-[2px] cursor-pointer" : ""}
${className} ${className}
`} `}
{...props} {...props}
......
...@@ -14,11 +14,11 @@ export const IconWrapper: React.FC<IconWrapperProps> = ({ ...@@ -14,11 +14,11 @@ export const IconWrapper: React.FC<IconWrapperProps> = ({
...props ...props
}) => { }) => {
const typeStyles = { const typeStyles = {
primary: "bg-[#EBE9FE] text-[#8B7CF6] border border-[#D9D6FE]/50", primary: "bg-clay-badge-primary-bg text-clay-primary border border-clay-badge-primary-border/50",
income: "bg-[#E6FDF5] text-[#34D399] border border-[#C6F6E5]/50", income: "bg-clay-badge-income-bg text-clay-income border border-clay-badge-income-border/50",
expense: "bg-[#FFF1F2] text-[#FB7185] border border-[#FFE4E6]/50", expense: "bg-clay-badge-expense-bg text-clay-expense border border-clay-badge-expense-border/50",
warning: "bg-[#FEF3C7] text-[#FBBF24] border border-[#FDE68A]/50", warning: "bg-clay-badge-warning-bg text-clay-warning border border-clay-badge-warning-border/50",
info: "bg-[#EFF6FF] text-[#60A5FA] border border-[#DBEAFE]/50", info: "bg-clay-badge-info-bg text-clay-info border border-clay-badge-info-border/50",
}; };
const sizeStyles = { const sizeStyles = {
...@@ -30,7 +30,7 @@ export const IconWrapper: React.FC<IconWrapperProps> = ({ ...@@ -30,7 +30,7 @@ export const IconWrapper: React.FC<IconWrapperProps> = ({
return ( return (
<div <div
className={` className={`
inline-flex items-center justify-center shadow-clay-raised border-white inline-flex items-center justify-center shadow-clay-raised
transition-all duration-200 ease-in-out hover:scale-105 transition-all duration-200 ease-in-out hover:scale-105
${typeStyles[type]} ${typeStyles[type]}
${sizeStyles[size]} ${sizeStyles[size]}
......
...@@ -3,16 +3,15 @@ import React from "react"; ...@@ -3,16 +3,15 @@ import React from "react";
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> { export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string; label?: string;
error?: string; error?: string;
endAdornment?: React.ReactNode;
displayValue?: string;
} }
export const Input: React.FC<InputProps> = ({ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
label, ({ label, error, endAdornment, displayValue, className = "", id, placeholder, ...props }, ref) => {
error, const generatedId = React.useId();
className = "", const inputId = id || generatedId;
id, const errorId = `${inputId}-error`;
...props
}) => {
const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`;
return ( return (
<div className="flex flex-col gap-2 w-full"> <div className="flex flex-col gap-2 w-full">
...@@ -21,24 +20,50 @@ export const Input: React.FC<InputProps> = ({ ...@@ -21,24 +20,50 @@ export const Input: React.FC<InputProps> = ({
{label} {label}
</label> </label>
)} )}
<div className="relative w-full">
<input <input
id={inputId} id={inputId}
ref={ref}
aria-invalid={error ? true : undefined}
aria-describedby={error ? errorId : undefined}
className={` className={`
bg-clay-bg text-clay-text font-nunito text-base px-4 py-3 w-full bg-clay-bg text-clay-text font-nunito text-base px-4 py-3
rounded-clay-sm shadow-clay-pressed border border-transparent rounded-clay-sm shadow-clay-pressed border border-transparent
transition-all duration-150 ease-in-out placeholder-clay-text-muted/65 transition-all duration-200 ease-in-out placeholder-clay-text-muted/65
focus:outline-none focus:border-clay-primary focus:ring-2 focus:ring-clay-primary/20 focus:outline-none focus:border-clay-primary focus:ring-2 focus:ring-clay-primary/20
disabled:opacity-60 disabled:cursor-not-allowed disabled:opacity-60 disabled:cursor-not-allowed
${endAdornment ? "pr-12" : ""}
${displayValue !== undefined ? "finwise-localized-date" : ""}
${error ? "border-clay-expense focus:border-clay-expense focus:ring-clay-expense/20" : ""} ${error ? "border-clay-expense focus:border-clay-expense focus:ring-clay-expense/20" : ""}
${className} ${className}
`} `}
placeholder={placeholder}
{...props} {...props}
/> />
{displayValue !== undefined && (
<span
aria-hidden="true"
className={`pointer-events-none absolute inset-y-0 left-4 right-12 flex items-center font-nunito text-base ${
displayValue ? "text-clay-text" : "text-clay-text-muted/65"
} ${props.disabled ? "opacity-60" : ""}`}
>
{displayValue || placeholder}
</span>
)}
{endAdornment && (
<div className="absolute inset-y-0 right-3 flex items-center">
{endAdornment}
</div>
)}
</div>
{error && ( {error && (
<span className="font-nunito text-xs text-clay-expense px-1"> <span id={errorId} className="font-nunito text-xs text-clay-expense px-1">
{error} {error}
</span> </span>
)} )}
</div> </div>
); );
}; }
);
Input.displayName = "Input";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { CloseIcon } from "./icons"; import { CloseIcon } from "./icons";
import { Button } from "./Button"; import { Button } from "./Button";
import { useI18n } from "@/i18n";
export interface ModalProps { export interface ModalProps {
isOpen: boolean; isOpen: boolean;
...@@ -17,43 +18,55 @@ export const Modal: React.FC<ModalProps> = ({ ...@@ -17,43 +18,55 @@ export const Modal: React.FC<ModalProps> = ({
children, children,
footer, footer,
}) => { }) => {
// Prevent background scrolling when open const { t } = useI18n();
// Prevent background scrolling when open and handle Escape key
useEffect(() => { useEffect(() => {
if (isOpen) { if (!isOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "unset"; document.body.style.overflow = "unset";
return undefined;
} }
document.body.style.overflow = "hidden";
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => { return () => {
document.body.style.overflow = "unset"; document.body.style.overflow = "unset";
window.removeEventListener("keydown", handleKeyDown);
}; };
}, [isOpen]); }, [isOpen, onClose]);
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-[#2D2A45]/45 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}
/> />
{/* Modal Container */} {/* Modal Container */}
<div <div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
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-[15px_15px_30px_rgba(163,150,208,0.55),-10px_-10px_20px_rgba(255,255,255,0.9)] shadow-clay-modal border-t border-x sm:border border-clay-highlight/60 p-6 flex flex-col gap-4
border-t border-x sm:border border-white/60 p-6 flex flex-col gap-4 origin-bottom animate-modal-content-in transform motion-reduce:animate-none sm:origin-center
transform transition-all duration-300 ease-out translate-y-0 sm:scale-100
animate-slide-up
" "
> >
{/* Header */} {/* Header */}
<div className="flex justify-between items-center pb-2 border-b border-clay-text-muted/10"> <div className="flex justify-between items-center pb-2 border-b border-clay-text-muted/10">
<h3 className="clay-title-h3">{title}</h3> <h3 id="modal-title" className="clay-title-h3">{title}</h3>
<button <button
type="button"
onClick={onClose} onClick={onClose}
aria-label={t("accessibility.closeModal")}
className="w-8 h-8 rounded-full flex items-center justify-center bg-clay-bg text-clay-text-muted hover:text-clay-text shadow-clay-pressed active:translate-y-[1px]" className="w-8 h-8 rounded-full flex items-center justify-center bg-clay-bg text-clay-text-muted hover:text-clay-text shadow-clay-pressed active:translate-y-[1px]"
> >
<CloseIcon size={16} /> <CloseIcon size={16} />
......
...@@ -11,24 +11,29 @@ export interface ProgressBarProps { ...@@ -11,24 +11,29 @@ export interface ProgressBarProps {
export const ProgressBar: React.FC<ProgressBarProps> = ({ export const ProgressBar: React.FC<ProgressBarProps> = ({
value, value,
type = "primary", type = "primary",
height = "h-4.5", height = "h-4",
className = "", className = "",
}) => { }) => {
// Cap value between 0 and 100 // Keep malformed API values from producing an invalid inline width.
const percentage = Math.max(0, Math.min(100, value)); const normalizedValue = Number.isFinite(value) ? value : 0;
const percentage = Math.max(0, Math.min(100, normalizedValue));
// Gradient styles corresponding to semantic types // Gradient styles corresponding to semantic types
const gradientStyles = { const gradientStyles = {
primary: "bg-gradient-to-r from-[#A78BFA] to-[#8B7CF6] border-r border-white/30", primary: "bg-gradient-to-r from-clay-primary-soft to-clay-primary border-r border-clay-highlight/30",
income: "bg-gradient-to-r from-[#6EE7B7] to-[#34D399] border-r border-white/30", income: "bg-gradient-to-r from-clay-income-soft to-clay-income border-r border-clay-highlight/30",
expense: "bg-gradient-to-r from-[#FDA4AF] to-[#FB7185] border-r border-white/30", expense: "bg-gradient-to-r from-clay-expense-soft to-clay-expense border-r border-clay-highlight/30",
warning: "bg-gradient-to-r from-[#FDE047] to-[#FBBF24] border-r border-white/30", warning: "bg-gradient-to-r from-clay-warning-soft to-clay-warning border-r border-clay-highlight/30",
info: "bg-gradient-to-r from-[#93C5FD] to-[#60A5FA] border-r border-white/30", info: "bg-gradient-to-r from-clay-info-soft to-clay-info border-r border-clay-highlight/30",
}; };
return ( return (
<div className={`w-full flex flex-col gap-1.5 ${className}`}> <div className={`w-full flex flex-col gap-1.5 ${className}`}>
<div <div
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={percentage}
className={` className={`
w-full bg-clay-bg rounded-full shadow-clay-pressed overflow-hidden p-0.5 border border-clay-text/5 w-full bg-clay-bg rounded-full shadow-clay-pressed overflow-hidden p-0.5 border border-clay-text/5
${height} ${height}
...@@ -36,7 +41,7 @@ export const ProgressBar: React.FC<ProgressBarProps> = ({ ...@@ -36,7 +41,7 @@ export const ProgressBar: React.FC<ProgressBarProps> = ({
> >
<div <div
className={` className={`
h-full rounded-full transition-all duration-300 ease-out shadow-[inset_-2px_-2px_4px_rgba(0,0,0,0.15)] h-full rounded-full transition-all duration-300 ease-out shadow-clay-progress
${gradientStyles[type]} ${gradientStyles[type]}
`} `}
style={{ width: `${percentage}%` }} style={{ width: `${percentage}%` }}
......
...@@ -12,15 +12,18 @@ export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElemen ...@@ -12,15 +12,18 @@ export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElemen
error?: string; error?: string;
} }
export const Select: React.FC<SelectProps> = ({ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
({
label, label,
options, options,
error, error,
className = "", className = "",
id, id,
...props ...props
}) => { }, ref) => {
const selectId = id || `select-${Math.random().toString(36).substr(2, 9)}`; const generatedId = React.useId();
const selectId = id || generatedId;
const errorId = `${selectId}-error`;
return ( return (
<div className="flex flex-col gap-2 w-full relative"> <div className="flex flex-col gap-2 w-full relative">
...@@ -32,6 +35,9 @@ export const Select: React.FC<SelectProps> = ({ ...@@ -32,6 +35,9 @@ export const Select: React.FC<SelectProps> = ({
<div className="relative w-full"> <div className="relative w-full">
<select <select
id={selectId} id={selectId}
ref={ref}
aria-invalid={error ? true : undefined}
aria-describedby={error ? errorId : undefined}
className={` className={`
w-full bg-clay-bg text-clay-text font-nunito text-base px-4 py-3 pr-10 w-full bg-clay-bg text-clay-text font-nunito text-base px-4 py-3 pr-10
rounded-clay-sm shadow-clay-pressed border border-transparent appearance-none rounded-clay-sm shadow-clay-pressed border border-transparent appearance-none
...@@ -54,10 +60,13 @@ export const Select: React.FC<SelectProps> = ({ ...@@ -54,10 +60,13 @@ export const Select: React.FC<SelectProps> = ({
</div> </div>
</div> </div>
{error && ( {error && (
<span className="font-nunito text-xs text-clay-expense px-1"> <span id={errorId} className="font-nunito text-xs text-clay-expense px-1">
{error} {error}
</span> </span>
)} )}
</div> </div>
); );
}; }
);
Select.displayName = "Select";
import React from "react";
export interface SliderProps
extends Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"max" | "min" | "onChange" | "step" | "type" | "value"
> {
value: number;
min?: number;
max?: number;
step?: number;
buttonStep?: number;
decreaseLabel: string;
increaseLabel: string;
onValueChange: (value: number) => void;
}
type SliderStyle = React.CSSProperties & {
"--clay-slider-progress": string;
};
export const Slider = React.forwardRef<HTMLInputElement, SliderProps>(
(
{
className = "",
buttonStep,
decreaseLabel,
disabled,
increaseLabel,
max = 100,
min = 0,
onValueChange,
step = 1,
style,
value,
...props
},
ref,
) => {
const boundedValue = Math.max(min, Math.min(max, value));
const controlStep = buttonStep ?? step;
const range = max - min;
const progress = range > 0 ? ((boundedValue - min) / range) * 100 : 0;
const sliderStyle: SliderStyle = {
...style,
"--clay-slider-progress": `${progress}%`,
};
return (
<div className="flex w-full items-center gap-3">
<button
type="button"
aria-label={decreaseLabel}
disabled={disabled || boundedValue <= min}
onClick={() => onValueChange(Math.max(min, boundedValue - controlStep))}
className="flex h-11 w-11 flex-none items-center justify-center rounded-full bg-clay-surface font-baloo text-xl font-bold text-clay-primary shadow-clay-raised transition-all duration-200 ease-in-out active:scale-95 active:shadow-clay-pressed disabled:cursor-not-allowed disabled:opacity-40 disabled:shadow-none"
>
</button>
<input
{...props}
ref={ref}
type="range"
min={min}
max={max}
step={step}
value={boundedValue}
disabled={disabled}
style={sliderStyle}
onChange={(event) => onValueChange(Number(event.target.value))}
className={`clay-slider min-w-0 flex-1 ${className}`}
/>
<button
type="button"
aria-label={increaseLabel}
disabled={disabled || boundedValue >= max}
onClick={() => onValueChange(Math.min(max, boundedValue + controlStep))}
className="flex h-11 w-11 flex-none items-center justify-center rounded-full bg-clay-surface font-baloo text-xl font-bold text-clay-primary shadow-clay-raised transition-all duration-200 ease-in-out active:scale-95 active:shadow-clay-pressed disabled:cursor-not-allowed disabled:opacity-40 disabled:shadow-none"
>
+
</button>
</div>
);
},
);
Slider.displayName = "Slider";
...@@ -20,6 +20,7 @@ export const Tabs: React.FC<TabsProps> = ({ ...@@ -20,6 +20,7 @@ export const Tabs: React.FC<TabsProps> = ({
}) => { }) => {
return ( return (
<div <div
role="tablist"
className={` className={`
bg-clay-surface p-1.5 rounded-full shadow-clay-pressed flex w-full relative select-none bg-clay-surface p-1.5 rounded-full shadow-clay-pressed flex w-full relative select-none
${className} ${className}
...@@ -30,13 +31,16 @@ export const Tabs: React.FC<TabsProps> = ({ ...@@ -30,13 +31,16 @@ export const Tabs: React.FC<TabsProps> = ({
return ( return (
<button <button
key={tab.key} key={tab.key}
type="button"
role="tab"
aria-selected={isActive}
onClick={() => onChange(tab.key)} onClick={() => onChange(tab.key)}
className={` className={`
flex-1 text-center py-2 px-4 text-sm font-baloo font-semibold rounded-full flex-1 text-center py-2 px-2 sm:px-4 text-xs sm:text-sm leading-tight font-baloo font-semibold rounded-full
transition-all duration-200 ease-in-out focus:outline-none z-10 transition-all duration-200 ease-in-out focus:outline-none z-10
${ ${
isActive isActive
? "bg-clay-primary text-white shadow-clay-raised transform translate-y-0 border border-clay-primary-dark/20" ? "bg-clay-primary text-clay-on-primary shadow-clay-raised transform translate-y-0 border border-clay-primary-dark/20"
: "text-clay-text-muted hover:text-clay-text bg-transparent" : "text-clay-text-muted hover:text-clay-text bg-transparent"
} }
`} `}
......
import React from "react";
import { ThemeMode } from "@/stores/theme-store";
import { useI18n } from "@/i18n";
export interface ThemeToggleProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
theme: ThemeMode;
onChange: (theme: ThemeMode) => void;
variant?: "switch" | "compact";
}
const SunIcon: React.FC = () => (
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<circle cx="12" cy="12" r="3.5" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.42 1.42M17.65 17.65l1.42 1.42M2 12h2M20 12h2M4.93 19.07l1.42-1.42M17.65 6.35l1.42-1.42" />
</svg>
);
const MoonIcon: React.FC = () => (
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20.5 14.5A8.5 8.5 0 0 1 9.5 3.5 8.5 8.5 0 1 0 20.5 14.5Z" />
</svg>
);
export const ThemeToggle: React.FC<ThemeToggleProps> = ({
theme,
onChange,
variant = "switch",
className = "",
onClick,
...props
}) => {
const { t } = useI18n();
const isDark = theme === "dark";
const nextThemeLabel = t(isDark ? "theme.switchToLight" : "theme.switchToDark");
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onChange(isDark ? "light" : "dark");
onClick?.(event);
};
if (variant === "compact") {
return (
<button
type="button"
role="switch"
aria-checked={isDark}
aria-label={nextThemeLabel}
title={nextThemeLabel}
{...props}
className={`inline-flex h-9 w-9 items-center justify-center rounded-full border border-clay-border bg-clay-surface text-clay-primary shadow-clay-raised transition-all duration-200 ease-in-out active:shadow-clay-pressed focus:outline-none focus:ring-2 focus:ring-clay-primary/35 ${className}`}
onClick={handleClick}
>
{isDark ? <MoonIcon /> : <SunIcon />}
</button>
);
}
return (
<button
type="button"
role="switch"
aria-checked={isDark}
aria-label={nextThemeLabel}
title={nextThemeLabel}
className={`relative inline-flex h-8 w-14 flex-none items-center rounded-full border border-clay-border bg-clay-surface p-1 text-clay-text-muted shadow-clay-pressed transition-all duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-clay-primary/35 ${className}`}
{...props}
onClick={handleClick}
>
<span className="absolute left-1.5 text-clay-warning"><SunIcon /></span>
<span className="absolute right-1.5 text-clay-info"><MoonIcon /></span>
<span
aria-hidden="true"
className={`relative z-10 flex h-6 w-6 items-center justify-center rounded-full bg-clay-primary text-clay-on-primary shadow-clay-raised transition-all duration-200 ease-in-out ${isDark ? "translate-x-6" : "translate-x-0"}`}
>
{isDark ? <MoonIcon /> : <SunIcon />}
</span>
</button>
);
};
This diff is collapsed.
export * from '@/common/constants/permission.constant';
This diff is collapsed.
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer base {
*,
::before,
::after {
border-color: rgb(var(--color-clay-border));
}
}
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
adminAiService,
AiRateLimitConfig,
AiRequestLogQueryParams,
} from '@/services/admin-ai.service';
export const ADMIN_AI_KEYS = {
all: ['admin-ai'] as const,
status: ['admin-ai', 'status'] as const,
usage: (period?: string) => ['admin-ai', 'usage', period] as const,
logs: (params?: AiRequestLogQueryParams) => ['admin-ai', 'logs', params] as const,
rateLimit: ['admin-ai', 'rate-limit'] as const,
};
export function useAiFeatureStatuses() {
return useQuery({
queryKey: ADMIN_AI_KEYS.status,
queryFn: () => adminAiService.getFeatureStatuses(),
});
}
export function useAiUsageSummary(period: 'today' | 'week' | 'month' = 'today') {
return useQuery({
queryKey: ADMIN_AI_KEYS.usage(period),
queryFn: () => adminAiService.getUsageSummary({ period }),
});
}
export function useAiRequestLogs(params?: AiRequestLogQueryParams) {
return useQuery({
queryKey: ADMIN_AI_KEYS.logs(params),
queryFn: () => adminAiService.getLogs(params),
});
}
export function useAiRateLimitConfig() {
return useQuery({
queryKey: ADMIN_AI_KEYS.rateLimit,
queryFn: () => adminAiService.getRateLimitConfig(),
});
}
export function useToggleAiFeature() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ featureKey, enabled }: { featureKey: string; enabled: boolean }) =>
adminAiService.toggleFeature(featureKey, enabled),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ADMIN_AI_KEYS.status });
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
queryClient.invalidateQueries({ queryKey: ['public-system-config'] });
},
});
}
export function useUpdateAiRateLimit() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: AiRateLimitConfig) => adminAiService.updateRateLimitConfig(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ADMIN_AI_KEYS.rateLimit });
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
queryClient.invalidateQueries({ queryKey: ['public-system-config'] });
},
});
}
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
adminNotificationService,
AdminDeliveryQueryParams,
NotificationChannel,
NotificationChannelConfig,
NotificationType,
} from '@/services/admin-notification.service';
export const ADMIN_NOTIFICATION_KEYS = {
all: ['admin-notifications'] as const,
overview: (params?: { dateFrom?: string; dateTo?: string }) =>
['admin-notifications', 'overview', params] as const,
deliveries: (params?: AdminDeliveryQueryParams) =>
['admin-notifications', 'deliveries', params] as const,
templates: (params?: { type?: NotificationType; channel?: NotificationChannel; language?: string; isActive?: boolean }) =>
['admin-notifications', 'templates', params] as const,
channels: ['admin-notifications', 'channels'] as const,
};
export function useNotificationOverview(params?: { dateFrom?: string; dateTo?: string }) {
return useQuery({
queryKey: ADMIN_NOTIFICATION_KEYS.overview(params),
queryFn: () => adminNotificationService.getOverview(params),
});
}
export function useNotificationDeliveries(params?: AdminDeliveryQueryParams) {
return useQuery({
queryKey: ADMIN_NOTIFICATION_KEYS.deliveries(params),
queryFn: () => adminNotificationService.getDeliveries(params),
});
}
export function useNotificationTemplates(params?: {
type?: NotificationType;
channel?: NotificationChannel;
language?: string;
isActive?: boolean;
}) {
return useQuery({
queryKey: ADMIN_NOTIFICATION_KEYS.templates(params),
queryFn: () => adminNotificationService.getTemplates(params),
});
}
export function useNotificationChannels() {
return useQuery({
queryKey: ADMIN_NOTIFICATION_KEYS.channels,
queryFn: () => adminNotificationService.getChannelConfig(),
});
}
export function useRetryNotificationDelivery() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (deliveryId: string) => adminNotificationService.retryDelivery(deliveryId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ADMIN_NOTIFICATION_KEYS.all });
},
});
}
export function useUpdateNotificationTemplate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
data,
}: {
id: string;
data: { titleTemplate?: string; bodyTemplate?: string; isActive?: boolean };
}) => adminNotificationService.updateTemplate(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ADMIN_NOTIFICATION_KEYS.templates() });
},
});
}
export function useUpdateNotificationChannels() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: Partial<NotificationChannelConfig>) =>
adminNotificationService.updateChannelConfig(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ADMIN_NOTIFICATION_KEYS.all });
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
queryClient.invalidateQueries({ queryKey: ['public-system-config'] });
},
});
}
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
adminSettingsService,
MaintenanceModeConfig,
SettingCategory,
} from '@/services/admin-settings.service';
export const ADMIN_SETTINGS_KEYS = {
all: ['admin-settings'] as const,
list: (params?: { category?: SettingCategory; isPublic?: boolean; search?: string }) =>
['admin-settings', 'list', params] as const,
detail: (key: string) => ['admin-settings', 'detail', key] as const,
publicConfig: ['public-system-config'] as const,
};
export function useAdminSettings(params?: {
category?: SettingCategory;
isPublic?: boolean;
search?: string;
}) {
return useQuery({
queryKey: ADMIN_SETTINGS_KEYS.list(params),
queryFn: () => adminSettingsService.getSettings(params),
});
}
export function usePublicConfig() {
return useQuery({
queryKey: ADMIN_SETTINGS_KEYS.publicConfig,
queryFn: () => adminSettingsService.getPublicConfig(),
staleTime: 60 * 1000,
});
}
export function useUpdateSetting() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ key, data }: { key: string; data: { value: unknown; description?: string } }) =>
adminSettingsService.updateSetting(key, data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ADMIN_SETTINGS_KEYS.all });
queryClient.invalidateQueries({ queryKey: ADMIN_SETTINGS_KEYS.detail(variables.key) });
queryClient.invalidateQueries({ queryKey: ADMIN_SETTINGS_KEYS.publicConfig });
if (variables.key.startsWith('notifications.')) {
queryClient.invalidateQueries({ queryKey: ['admin-notifications'] });
}
if (variables.key.startsWith('ai.')) {
queryClient.invalidateQueries({ queryKey: ['admin-ai'] });
}
},
});
}
export function useUpdateMaintenanceMode() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: MaintenanceModeConfig) => adminSettingsService.updateMaintenanceMode(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ADMIN_SETTINGS_KEYS.all });
queryClient.invalidateQueries({ queryKey: ADMIN_SETTINGS_KEYS.publicConfig });
},
});
}
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AuditLogQueryParams, rbacService } from '@/services/rbac.service';
import { userService } from '@/services/user.service';
import { CreateAdminUserInput, UpdateAdminUserInput, UserListParams } from '@/types/admin';
export const adminQueryKeys = {
root: ['admin'] as const,
stats: () => [...adminQueryKeys.root, 'stats'] as const,
users: () => [...adminQueryKeys.root, 'users'] as const,
userList: (params: UserListParams) => [...adminQueryKeys.users(), params] as const,
user: (id: string) => [...adminQueryKeys.users(), 'detail', id] as const,
roles: () => ['rbac-roles'] as const,
auditLogs: () => [...adminQueryKeys.root, 'audit-logs'] as const,
auditLogList: (params: AuditLogQueryParams) => [...adminQueryKeys.auditLogs(), params] as const,
};
export function useAdminStats() {
return useQuery({
queryKey: adminQueryKeys.stats(),
queryFn: () => userService.getAdminStats(),
staleTime: 60_000,
});
}
export function useAdminUsers(params: UserListParams) {
return useQuery({
queryKey: adminQueryKeys.userList(params),
queryFn: () => userService.getUsers(params),
placeholderData: (previousData) => previousData,
});
}
export function useAdminUser(id: string) {
return useQuery({
queryKey: adminQueryKeys.user(id),
queryFn: () => userService.getUserById(id),
enabled: Boolean(id),
});
}
export function useAdminRoles() {
return useQuery({
queryKey: adminQueryKeys.roles(),
queryFn: () => rbacService.getRoles({ limit: 100 }),
staleTime: 60_000,
});
}
export function useAdminAuditLogs(params: AuditLogQueryParams, enabled = true) {
return useQuery({
queryKey: adminQueryKeys.auditLogList(params),
queryFn: () => rbacService.getAuditLogs(params),
placeholderData: (previousData) => previousData,
enabled,
});
}
function useInvalidateAdminData() {
const queryClient = useQueryClient();
return async (userId?: string) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: adminQueryKeys.users() }),
queryClient.invalidateQueries({ queryKey: adminQueryKeys.stats() }),
queryClient.invalidateQueries({ queryKey: adminQueryKeys.auditLogs() }),
...(userId
? [queryClient.invalidateQueries({ queryKey: adminQueryKeys.user(userId) })]
: []),
]);
};
}
export function useCreateAdminUser() {
const invalidateAdminData = useInvalidateAdminData();
return useMutation({
mutationFn: (data: CreateAdminUserInput) => userService.createUser(data),
onSuccess: () => invalidateAdminData(),
});
}
export function useUpdateAdminUser() {
const invalidateAdminData = useInvalidateAdminData();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateAdminUserInput }) =>
userService.updateUser(id, data),
onSuccess: (_response, variables) => invalidateAdminData(variables.id),
});
}
export function useDeleteAdminUser() {
const invalidateAdminData = useInvalidateAdminData();
return useMutation({
mutationFn: (id: string) => userService.deleteUser(id),
onSuccess: (_response, id) => invalidateAdminData(id),
});
}
export function useRestoreAdminUser() {
const invalidateAdminData = useInvalidateAdminData();
return useMutation({
mutationFn: (id: string) => userService.restoreUser(id),
onSuccess: (_response, id) => invalidateAdminData(id),
});
}
import { useMutation, useQuery } from "@tanstack/react-query";
import { aiAssistantService } from "@/services/ai-assistant.service";
import {
AIChatInput,
AIChatData,
AIInsightsInput,
AIInsightsData,
AIRecommendationsInput,
AIRecommendationsData,
CategorizeTransactionInput,
CategorizeTransactionData,
ExtractReceiptInput,
ExtractReceiptData,
AIServiceResponse,
} from "@/types/ai";
export function useAIChat() {
return useMutation<AIServiceResponse<AIChatData>, Error, AIChatInput>({
mutationFn: (input) => aiAssistantService.chat(input),
});
}
export function useAIInsights(input: AIInsightsInput, enabled = true) {
return useQuery<AIServiceResponse<AIInsightsData>, Error>({
queryKey: ["ai-insights", input],
queryFn: () => aiAssistantService.analyzeInsights(input),
enabled,
staleTime: 5 * 60 * 1000,
retry: false,
});
}
export function useAIRecommendations(input: AIRecommendationsInput, enabled = true) {
return useQuery<AIServiceResponse<AIRecommendationsData>, Error>({
queryKey: ["ai-recommendations", input],
queryFn: () => aiAssistantService.getRecommendations(input),
enabled,
staleTime: 10 * 60 * 1000,
retry: false,
});
}
export function useExtractReceipt() {
return useMutation<
AIServiceResponse<ExtractReceiptData>,
Error,
{ file: File; hints?: ExtractReceiptInput }
>({
mutationFn: ({ file, hints }) => aiAssistantService.extractReceipt(file, hints),
});
}
export function useCategorizeTransaction() {
return useMutation<AIServiceResponse<CategorizeTransactionData>, Error, CategorizeTransactionInput>({
mutationFn: (input) => aiAssistantService.categorizeTransaction(input),
});
}
import { useMutation, useQuery } from "@tanstack/react-query";
import { anomalyService } from "@/services/anomaly.service";
import { EvaluateAnomalyInput } from "@/types/anomaly";
export const ANOMALY_QUERY_KEYS = {
all: ["anomalies"] as const,
recent: ["anomalies", "recent"] as const,
};
export function useRecentAnomalies() {
return useQuery({
queryKey: ANOMALY_QUERY_KEYS.recent,
queryFn: () => anomalyService.getRecent(),
staleTime: 30 * 1000,
});
}
export function useEvaluateAnomaly() {
return useMutation({
mutationFn: (input: EvaluateAnomalyInput) => anomalyService.evaluate(input),
});
}
This diff is collapsed.
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { categoryService } from "@/services/category.service";
import { CategoryInput, CategoryTreeQuery, UpdateCategoryInput } from "@/types/category";
export const categoryKeys = {
all: ["categories"] as const,
trees: () => [...categoryKeys.all, "tree"] as const,
tree: (query: CategoryTreeQuery) => [...categoryKeys.trees(), query] as const,
};
export function useCategoryTree(query: CategoryTreeQuery) {
return useQuery({
queryKey: categoryKeys.tree(query),
queryFn: () => categoryService.getTree(query),
staleTime: 60_000,
});
}
export function useCreateCategory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CategoryInput) => categoryService.createCategory(input),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: categoryKeys.all });
},
});
}
export function useUpdateCategory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, input }: { id: string; input: UpdateCategoryInput }) => (
categoryService.updateCategory(id, input)
),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: categoryKeys.all });
},
});
}
function useCategoryAction(action: (id: string) => Promise<unknown>) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: action,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: categoryKeys.all });
},
});
}
export function useArchiveCategory() {
return useCategoryAction(categoryService.archiveCategory);
}
export function useRestoreCategory() {
return useCategoryAction(categoryService.restoreCategory);
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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