Commit 7e2478f6 authored by ThinhNC's avatar ThinhNC

Merge branch 'develop' into 'master'

Develop

See merge request !67
parents 22d539ad 5e2f2d78
Pipeline #53700 failed
......@@ -9,4 +9,9 @@
- [ ] Viết custom React Query hook để thực hiện query/mutation dữ liệu.
- [ ] Xử lý các trạng thái: Loading (sử dụng Skeleton bo tròn), Error (nổi bật đất sét), và Empty (thông báo thân thiện).
- [ ] Đảm bảo thiết kế responsive đầy đủ cho mọi kích thước màn hình điện thoại di động.
- [ ] Chạy build kiểm tra lỗi TypeScript: `npm run build`.
- [ ] **Tương thích Chế độ Khách (Guest Mode Compatibility - Quy định Zalo Mini App)**:
- [ ] Nếu route/hành động được bảo vệ bởi `PermissionGate`, đảm bảo permission đó đã có trong `GUEST_USER.permissions` (`src/stores/auth-store.ts`).
- [ ] Nếu trang gọi endpoint GET mới để lấy dữ liệu, bổ sung mock data/handler tương ứng trong `src/lib/guest-mock.ts` để khách trải nghiệm không bị lỗi mạng hay 401.
- [ ] Đảm bảo các query hook đọc dữ liệu an toàn bằng optional chaining (ví dụ: `query.data?.data?.items || []`) tránh null pointer crash khi data undefined.
- [ ] Chạy build kiểm tra lỗi TypeScript: `pnpm run build`.
This diff is collapsed.
......@@ -32,3 +32,12 @@
### Form State & Validation (Zod + React Hook Form)
- Biện kiểm soát dữ liệu đầu vào: Tất cả các form thêm/sửa giao dịch, thiết lập ví, đặt ngân sách phải được validate bằng Zod schema trước khi submit.
- Định nghĩa schema rõ ràng trong file component hoặc `<feature>.validation.ts`.
## 3. Chế độ Khách & Tuân thủ Kiểm duyệt Zalo Mini App (Guest Mode)
- **Quy định Zalo**: Mini App không được chặn cứng người dùng bằng màn hình đăng nhập nếu không có giải thích ngữ cảnh hoặc không có chế độ dùng thử.
- **Quy tắc khi tạo tính năng/trang mới**:
1. **Permissions**: Mọi tính năng mới cho người dùng thông thường (`USER`) phải được khai báo quyền trong `GUEST_USER.permissions` (`src/stores/auth-store.ts`) để `PermissionGate` không từ chối truy cập khách.
2. **Mock Data**: Bổ sung endpoint GET mới vào `handleGuestRequest` trong `src/lib/guest-mock.ts` với dữ liệu mẫu chân thực, tránh để khách gặp lỗi mạng hoặc 401.
3. **An toàn dữ liệu**: Đọc dữ liệu từ React Query luôn dùng optional chaining (`data?.data?.items || []`) để không gây crash ứng dụng khi dữ liệu rỗng hoặc chưa tải xong.
4. **Thông báo nhẹ nhàng**: Các thao tác ghi (POST/PUT/DELETE) ở chế độ khách không báo lỗi crash mà nhắc nhở thân thiện yêu cầu đăng nhập để lưu trữ vĩnh viễn trên đám mây.
......@@ -38,3 +38,6 @@ Thumbs.db
# Production build
www/
.vercel
.env*
stages:
- deploy
default:
image: node:20-alpine
# Tự động deploy Production khi push / merge vào nhánh master
deploy_production:
stage: deploy
only:
- master
before_script:
- npm install -g vercel
script:
- vercel pull --yes --environment=production --token=$VERCEL_TOKEN
- vercel build --prod --token=$VERCEL_TOKEN
- vercel deploy --prebuilt --prod --token=$VERCEL_TOKEN
# Deploy Preview khi push vào nhánh develop hoặc tạo Merge Request
deploy_preview:
stage: deploy
only:
- develop
- merge_requests
before_script:
- npm install -g vercel
script:
- vercel pull --yes --environment=preview --token=$VERCEL_TOKEN
- vercel build --token=$VERCEL_TOKEN
- vercel deploy --prebuilt --token=$VERCEL_TOKEN
......@@ -126,6 +126,25 @@ export const PERMISSIONS = {
// ASYNC JOB
JOB_READ: 'JOB_READ',
JOB_CREATE: 'JOB_CREATE',
// STATEMENT EXPORT
STATEMENT_READ: 'STATEMENT_READ',
STATEMENT_EXPORT: 'STATEMENT_EXPORT',
// DEBT & LOAN MANAGEMENT
DEBT_READ: 'DEBT_READ',
DEBT_CREATE: 'DEBT_CREATE',
DEBT_UPDATE: 'DEBT_UPDATE',
DEBT_DELETE: 'DEBT_DELETE',
DEBT_SETTLE: 'DEBT_SETTLE',
// DATA BACKUP & RESTORE
BACKUP_EXPORT: 'BACKUP_EXPORT',
BACKUP_IMPORT: 'BACKUP_IMPORT',
// ACCOUNT HANDOVER
HANDOVER_INITIATE: 'HANDOVER_INITIATE',
HANDOVER_CLAIM: 'HANDOVER_CLAIM',
} as const;
export type PermissionName = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
......@@ -9,7 +9,7 @@ import {
} from "zmp-ui";
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "@/lib/query-client";
import { useAuthStore } from "@/stores/auth-store";
import { useAuthStore, GUEST_STORAGE_KEY } from "@/stores/auth-store";
import { authService } from "@/services/auth.service";
import { safeStorage } from "@/lib/storage";
import { AuthGuard } from "@/components/shared/AuthGuard";
......@@ -58,6 +58,12 @@ const AdminSettingsPage = React.lazy(() => import("@/pages/admin/settings/index"
const AdminNotificationsPage = React.lazy(() => import("@/pages/admin/notifications/index"));
const AdminAiPage = React.lazy(() => import("@/pages/admin/ai/index"));
const IntegrationsPage = React.lazy(() => import("@/pages/integrations/index"));
const StatementsPage = React.lazy(() => import("@/pages/statements/index"));
const DataManagementPage = React.lazy(() => import("@/pages/data-management/index"));
const AccountHandoverPage = React.lazy(() => import("@/pages/account-handover/index"));
const DebtsPage = React.lazy(() => import("@/pages/debts/index"));
const DebtDetailPage = React.lazy(() => import("@/pages/debts/detail"));
const SavingsPage = React.lazy(() => import("@/pages/savings/index"));
const NotFoundPage = React.lazy(() => import("@/pages/not-found/index"));
import { PermissionGate } from "@/components/shared/PermissionGate";
......@@ -84,9 +90,15 @@ const AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children })
useEffect(() => {
const initAuth = async () => {
try {
const isGuest = safeStorage.getItem(GUEST_STORAGE_KEY) === "true";
const storedAccessToken = safeStorage.getItem("accessToken");
const storedRefreshToken = safeStorage.getItem("refreshToken");
// If in guest mode and no real token, maintain guest state and finish initializing
if (isGuest && !storedAccessToken) {
return;
}
// If no tokens exist in storage, user is not logged in
if (!storedAccessToken && !storedRefreshToken) {
clearAuth();
......@@ -180,12 +192,17 @@ export const Layout = () => {
<Route path="/transfers" element={<AuthGuard><PermissionGate permission={PERMISSIONS.TRANSFER_READ} fallback={<AccessDenied />}><TransfersPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/budgets" element={<AuthGuard><PermissionGate permission={PERMISSIONS.BUDGET_READ} fallback={<AccessDenied />}><BudgetsPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/budgets/:id" element={<AuthGuard><PermissionGate permission={PERMISSIONS.BUDGET_READ} fallback={<AccessDenied />}><BudgetDetailPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/savings" element={<AuthGuard><SavingsPage /></AuthGuard>}></Route>
<Route path="/saving-goals" element={<AuthGuard><PermissionGate permission={PERMISSIONS.SAVING_GOAL_READ} fallback={<AccessDenied />}><SavingGoalsPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/saving-goals/:id" element={<AuthGuard><PermissionGate permission={PERMISSIONS.SAVING_GOAL_READ} fallback={<AccessDenied />}><SavingGoalDetailPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/reports" element={<AuthGuard><PermissionGate permission={PERMISSIONS.REPORT_READ} fallback={<AccessDenied />}><ReportsPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/statements" element={<AuthGuard><PermissionGate permission={PERMISSIONS.STATEMENT_READ} fallback={<AccessDenied />}><StatementsPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/statements/jobs/:id" element={<AuthGuard><PermissionGate permission={PERMISSIONS.STATEMENT_READ} fallback={<AccessDenied />}><StatementsPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/forecast" element={<AuthGuard><PermissionGate permission={PERMISSIONS.FORECAST_READ} fallback={<AccessDenied />}><ForecastPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/simulations" element={<AuthGuard><PermissionGate permission={PERMISSIONS.SIMULATION_READ} fallback={<AccessDenied />}><SimulationsPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/anomalies" element={<AuthGuard><PermissionGate permission={PERMISSIONS.ANOMALY_READ} fallback={<AccessDenied />}><AnomaliesPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/debts" element={<AuthGuard><DebtsPage /></AuthGuard>}></Route>
<Route path="/debts/:id" element={<AuthGuard><DebtDetailPage /></AuthGuard>}></Route>
<Route path="/subscriptions" element={<SubscriptionsRedirect />}></Route>
<Route path="/recurring-transactions" element={<AuthGuard><PermissionGate permission={PERMISSIONS.RECURRING_TRANSACTION_READ} fallback={<AccessDenied />}><RecurringTransactionsPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/query" element={<AuthGuard><PermissionGate permission={PERMISSIONS.QUERY_EXECUTE} fallback={<AccessDenied />}><QueryPage /></PermissionGate></AuthGuard>}></Route>
......@@ -203,6 +220,8 @@ export const Layout = () => {
<Route path="/admin/roles" element={<AuthGuard><PermissionGate permission={PERMISSIONS.ROLE_READ} fallback={<AccessDenied />}><RolesPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/ai-assistant" element={<AuthGuard><PermissionGate permission={PERMISSIONS.AI_ASSISTANT_USE} fallback={<AccessDenied />}><AIAssistantPage /></PermissionGate></AuthGuard>}></Route>
<Route path="/style-guide" element={<AuthGuard><StyleGuidePage /></AuthGuard>}></Route>
<Route path="/data-management" element={<AuthGuard><DataManagementPage /></AuthGuard>}></Route>
<Route path="/account-handover" element={<AuthGuard><AccountHandoverPage /></AuthGuard>}></Route>
{/* 404 Catch-All Route */}
<Route path="*" element={<NotFoundPage />}></Route>
</AnimationRoutes>
......
......@@ -5,15 +5,15 @@ import { useI18n } from "@/i18n";
import { Logo } from "@/components/logo";
export const AuthGuard: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isAuthenticated, isInitialized } = useAuthStore();
const { isAuthenticated, isGuest, isInitialized } = useAuthStore();
const navigate = useNavigate();
const { t } = useI18n();
useEffect(() => {
if (isInitialized && !isAuthenticated) {
if (isInitialized && !isAuthenticated && !isGuest) {
navigate("/login", { replace: true });
}
}, [isInitialized, isAuthenticated, navigate]);
}, [isInitialized, isAuthenticated, isGuest, navigate]);
if (!isInitialized) {
return (
......@@ -24,7 +24,7 @@ export const AuthGuard: React.FC<{ children: React.ReactNode }> = ({ children })
);
}
if (!isAuthenticated) {
if (!isAuthenticated && !isGuest) {
return null;
}
......
......@@ -25,6 +25,7 @@ const PAGE_TITLES: ReadonlyArray<{
{ 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 === "/statements" || pathname.startsWith("/statements/"), key: "document.statements" },
{ matches: (pathname) => pathname === "/forecast", key: "document.forecast" },
{ matches: (pathname) => pathname === "/simulations", key: "document.simulations" },
{ matches: (pathname) => pathname === "/anomalies", key: "document.anomalies" },
......
import React from "react";
import { useNavigate } from "zmp-ui";
import { useAuthStore } from "@/stores/auth-store";
import { useI18n } from "@/i18n";
import { SparklesIcon } from "@/components/ui/icons";
export const GuestModeBanner: React.FC<{ className?: string }> = ({ className = "" }) => {
const { isGuest } = useAuthStore();
const navigate = useNavigate();
const { t, locale } = useI18n();
if (!isGuest) return null;
const isEn = locale === "en";
const badgeText =
t("guest.badge") !== "guest.badge"
? t("guest.badge")
: t("auth.guest.badge") !== "auth.guest.badge"
? t("auth.guest.badge")
: isEn
? "Demo Mode"
: "Chế độ Dùng thử";
const bannerText =
t("guest.bannerMessage") !== "guest.bannerMessage"
? t("guest.bannerMessage")
: t("auth.guest.bannerMessage") !== "auth.guest.bannerMessage"
? t("auth.guest.bannerMessage")
: isEn
? "You are exploring FinWise with sample data. Data is not synced to the cloud."
: "Bạn đang trải nghiệm FinWise với dữ liệu mẫu. Dữ liệu chưa được đồng bộ đám mây.";
const loginText =
t("guest.loginNow") !== "guest.loginNow"
? t("guest.loginNow")
: t("auth.guest.loginNow") !== "auth.guest.loginNow"
? t("auth.guest.loginNow")
: isEn
? "Sign in now"
: "Đăng nhập ngay";
return (
<div className={`w-full max-w-sm mx-auto my-2 rounded-2xl bg-amber-500/10 border border-amber-500/30 p-3.5 shadow-clay-card flex flex-col sm:flex-row sm:items-center justify-between gap-3 animate-fade-in ${className}`}>
<div className="flex items-start gap-2.5">
<div className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-xl bg-amber-500/20 text-amber-600 dark:text-amber-400">
<SparklesIcon size={16} />
</div>
<div className="space-y-1 text-left">
<div className="flex items-center gap-2">
<span className="inline-flex items-center justify-center rounded-md bg-amber-500/20 border border-amber-500/30 px-2 pt-0.5 font-nunito text-[11px] font-bold text-amber-700 dark:text-amber-300 leading-normal">
{badgeText}
</span>
</div>
<p className="font-nunito text-xs text-clay-text-muted leading-relaxed">
{bannerText}
</p>
</div>
</div>
<button
type="button"
onClick={() => navigate("/login")}
className="shrink-0 self-end sm:self-center px-3.5 py-1.5 rounded-xl bg-amber-500 hover:bg-amber-600 active:scale-95 text-white font-nunito text-xs font-bold shadow-sm transition-all duration-150"
>
{loginText}
</button>
</div>
);
};
import React, { useMemo } from "react";
import { useNavigate } from "zmp-ui";
import { useDebts } from "@/hooks/use-debts";
import { useWallets } from "@/hooks/use-wallets";
import { useI18n } from "@/i18n";
import { Card } from "@/components/ui/Card";
import { ChevronRightIcon, ScaleIcon } from "@/components/ui/icons";
export interface NetWorthWidgetProps {
clickable?: boolean;
}
export const NetWorthWidget: React.FC<NetWorthWidgetProps> = ({ clickable = true }) => {
const navigate = useNavigate();
const { t, formatCurrency } = useI18n();
// 1. Fetch Wallets balance (separating Liquid from Term Deposits)
const walletsQuery = useWallets({
includeArchived: false,
sortBy: "name",
order: "asc",
page: 1,
limit: 100,
});
const { liquidWalletsTotal, savingsTotal } = useMemo(() => {
const wallets = walletsQuery.data?.data || [];
let liquid = 0;
let savings = 0;
for (const w of wallets) {
if (w.isArchived) continue;
const bal = Number(w.balance || 0);
if (w.type === "SAVINGS_ACCOUNT") {
savings += bal;
} else {
liquid += bal;
}
}
return { liquidWalletsTotal: liquid, savingsTotal: savings };
}, [walletsQuery.data?.data]);
// 2. Fetch Debts Summary (Lending & Borrowing)
const debtsQuery = useDebts({ page: 1, limit: 1 });
const summary = debtsQuery.data?.summary || { totalBorrowing: 0, totalLending: 0 };
const totalLending = summary.totalLending || 0;
const totalBorrowing = summary.totalBorrowing || 0;
// Net Worth = Liquid Wallets + Savings Deposits + Lending - Borrowing
const netWorth = liquidWalletsTotal + savingsTotal + totalLending - totalBorrowing;
const isLoading = walletsQuery.isLoading || debtsQuery.isLoading;
if (isLoading) {
return (
<div className="w-full rounded-clay-lg bg-clay-surface p-4 sm:p-5 shadow-clay-raised border border-clay-highlight/40 animate-pulse flex flex-col gap-3">
<div className="flex items-center justify-between pb-2 border-b border-clay-text-muted/10">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-clay-primary/15" />
<div className="h-4 w-24 rounded-full bg-clay-primary/20" />
</div>
<div className="h-4 w-12 rounded-full bg-clay-primary/15" />
</div>
<div className="py-2 space-y-2">
<div className="h-8 w-44 rounded-full bg-clay-primary/25" />
<div className="h-3 w-3/4 rounded-full bg-clay-primary/10" />
</div>
<div className="grid grid-cols-4 gap-2 pt-1">
<div className="h-14 rounded-clay-sm bg-clay-bg/80" />
<div className="h-14 rounded-clay-sm bg-teal-500/10" />
<div className="h-14 rounded-clay-sm bg-clay-income/10" />
<div className="h-14 rounded-clay-sm bg-clay-expense/10" />
</div>
</div>
);
}
const isPositive = netWorth >= 0;
return (
<Card
hoverable={clickable}
onClick={clickable ? () => navigate("/debts") : undefined}
className="group relative overflow-hidden p-4 sm:p-5 flex flex-col gap-3"
>
{/* Decorative clay ambient circles */}
<div className="absolute -right-8 -top-8 h-28 w-28 rounded-full bg-clay-primary/10 pointer-events-none" />
{/* Header */}
<div className="relative flex items-center justify-between pb-2 border-b border-clay-text-muted/10">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-full bg-clay-primary/15 text-clay-primary flex items-center justify-center shadow-clay-pressed">
<ScaleIcon size={18} />
</div>
<div>
<span className="font-baloo font-bold text-xs uppercase tracking-wider text-clay-text-muted">
{t("debts.netWorthTitle")}
</span>
</div>
</div>
{clickable && (
<div className="flex items-center text-xs text-clay-primary font-semibold transition-transform duration-200 group-hover:translate-x-0.5">
<span>{t("common.detail")}</span>
<ChevronRightIcon size={16} />
</div>
)}
</div>
{/* Main Net Worth Amount */}
<div className="relative py-1">
<div
className={`font-baloo font-extrabold text-2xl sm:text-3xl tracking-tight ${
isPositive ? "text-clay-primary" : "text-clay-expense"
}`}
>
{formatCurrency(netWorth, "VND")}
</div>
<p className="text-[11px] font-nunito text-clay-text-muted mt-0.5">
{t("debts.netWorthDesc")}
</p>
</div>
{/* 4 Component Breakdown Cards */}
<div className="relative grid grid-cols-2 sm:grid-cols-4 gap-2 pt-1">
{/* Liquid Wallets */}
<div className="rounded-clay-sm bg-clay-bg/85 p-2.5 border border-clay-highlight/40 flex flex-col justify-between">
<span className="text-[10px] text-clay-text-muted font-bold truncate">
{t("debts.liquidWallets")}
</span>
<span className="font-nunito font-extrabold text-[12px] sm:text-xs text-clay-text mt-1 truncate">
{formatCurrency(liquidWalletsTotal, "VND")}
</span>
</div>
{/* Term Deposits */}
<div className="rounded-clay-sm bg-teal-500/10 p-2.5 border border-teal-500/25 flex flex-col justify-between">
<span className="text-[10px] text-teal-700 font-bold truncate">
{t("debts.savingsDeposits")}
</span>
<span className="font-nunito font-extrabold text-[12px] sm:text-xs text-teal-700 mt-1 truncate">
{formatCurrency(savingsTotal, "VND")}
</span>
</div>
{/* Lending */}
<div className="rounded-clay-sm bg-clay-income/10 p-2.5 border border-clay-income/25 flex flex-col justify-between">
<span className="text-[10px] text-clay-income font-bold truncate">
+ {t("debts.tabs.receivable")}
</span>
<span className="font-nunito font-extrabold text-[12px] sm:text-xs text-clay-income mt-1 truncate">
{formatCurrency(totalLending, "VND")}
</span>
</div>
{/* Borrowing */}
<div className="rounded-clay-sm bg-clay-expense/10 p-2.5 border border-clay-expense/25 flex flex-col justify-between">
<span className="text-[10px] text-clay-expense font-bold truncate">
- {t("debts.tabs.payable")}
</span>
<span className="font-nunito font-extrabold text-[12px] sm:text-xs text-clay-expense mt-1 truncate">
{formatCurrency(totalBorrowing, "VND")}
</span>
</div>
</div>
</Card>
);
};
This diff is collapsed.
......@@ -298,14 +298,14 @@ body {
var(--zaui-safe-area-inset-top, env(safe-area-inset-top, 0px)) + 8px
);
right: calc(
var(--zaui-safe-area-inset-right, env(safe-area-inset-right, 0px)) + 96px
var(--zaui-safe-area-inset-right, env(safe-area-inset-right, 0px)) + 108px
);
}
/* Reserve space for native right-buttons (96px), three controls, and gaps. */
.zaui-header {
padding-right: calc(
var(--zaui-safe-area-inset-right, env(safe-area-inset-right, 0px)) + 236px
var(--zaui-safe-area-inset-right, env(safe-area-inset-right, 0px)) + 248px
);
}
......@@ -313,12 +313,12 @@ body {
.finwise-header-controls {
gap: 4px;
right: calc(
var(--zaui-safe-area-inset-right, env(safe-area-inset-right, 0px)) + 88px
var(--zaui-safe-area-inset-right, env(safe-area-inset-right, 0px)) + 104px
);
}
.zaui-header {
padding-right: calc(
var(--zaui-safe-area-inset-right, env(safe-area-inset-right, 0px)) + 192px
var(--zaui-safe-area-inset-right, env(safe-area-inset-right, 0px)) + 224px
);
}
}
......
......@@ -102,3 +102,11 @@ export function useRestoreAdminUser() {
onSuccess: (_response, id) => invalidateAdminData(id),
});
}
export function useArchiveCleanupAuditLogs() {
const invalidateAdminData = useInvalidateAdminData();
return useMutation({
mutationFn: (retentionDays?: number) => rbacService.archiveAndCleanupAuditLogs(retentionDays),
onSuccess: () => invalidateAdminData(),
});
}
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { debtService } from "@/services/debt.service";
import {
CreateDebtInput,
DebtQuery,
EarlySettlementInput,
PayInstallmentInput,
PreviewAmortizationInput,
UpdateDebtInput,
} from "@/types/debt";
import { walletKeys } from "./use-wallets";
export const debtKeys = {
all: ["debts"] as const,
lists: () => [...debtKeys.all, "list"] as const,
list: (query: DebtQuery) => [...debtKeys.lists(), query] as const,
details: () => [...debtKeys.all, "detail"] as const,
detail: (id: string) => [...debtKeys.details(), id] as const,
preview: (input: PreviewAmortizationInput) => [...debtKeys.all, "preview", input] as const,
};
export function useDebts(query: DebtQuery = {}) {
return useQuery({
queryKey: debtKeys.list(query),
queryFn: () => debtService.getDebts(query),
placeholderData: keepPreviousData,
staleTime: 30_000,
});
}
export function useDebt(id: string) {
return useQuery({
queryKey: debtKeys.detail(id),
queryFn: () => debtService.getDebt(id),
enabled: Boolean(id),
staleTime: 30_000,
});
}
export function useDebtPreview(input: PreviewAmortizationInput, enabled = true) {
return useQuery({
queryKey: debtKeys.preview(input),
queryFn: () => debtService.previewAmortization(input),
enabled: enabled && input.principal > 0 && input.termMonths > 0,
staleTime: 60_000,
});
}
export function useCreateDebt() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateDebtInput) => debtService.createDebt(input),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: debtKeys.all });
await queryClient.invalidateQueries({ queryKey: walletKeys.all });
},
});
}
export function useUpdateDebt(id: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: UpdateDebtInput) => debtService.updateDebt(id, input),
onSuccess: async (response) => {
queryClient.setQueryData(debtKeys.detail(id), response);
await queryClient.invalidateQueries({ queryKey: debtKeys.all });
},
});
}
export function useArchiveDebt() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => debtService.archiveDebt(id),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: debtKeys.all });
},
});
}
export function usePayInstallment(debtId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ period, input }: { period: number; input?: PayInstallmentInput }) =>
debtService.payInstallment(debtId, period, input),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: debtKeys.detail(debtId) });
await queryClient.invalidateQueries({ queryKey: debtKeys.lists() });
await queryClient.invalidateQueries({ queryKey: walletKeys.all });
// Invalidate transactions so dashboard/transaction history updates
await queryClient.invalidateQueries({ queryKey: ["transactions"] });
},
});
}
export function useSettleEarly(debtId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input?: EarlySettlementInput) => debtService.settleEarly(debtId, input),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: debtKeys.detail(debtId) });
await queryClient.invalidateQueries({ queryKey: debtKeys.lists() });
await queryClient.invalidateQueries({ queryKey: walletKeys.all });
await queryClient.invalidateQueries({ queryKey: ["transactions"] });
},
});
}
......@@ -12,6 +12,7 @@ export const recurringTransactionKeys = {
all: ["recurring-transactions"] as const,
lists: () => [...recurringTransactionKeys.all, "list"] as const,
list: (query: RecurringTransactionQuery) => [...recurringTransactionKeys.lists(), query] as const,
detail: (id: string) => [...recurringTransactionKeys.all, id] as const,
preview: (id: string) => [...recurringTransactionKeys.all, id, "preview"] as const,
history: (id: string) => [...recurringTransactionKeys.all, id, "history"] as const,
};
......@@ -25,6 +26,14 @@ export function useRecurringTransactions(query: RecurringTransactionQuery) {
});
}
export function useRecurringTransaction(id: string, enabled = true) {
return useQuery({
queryKey: recurringTransactionKeys.detail(id),
queryFn: () => recurringTransactionService.getById(id),
enabled: enabled && Boolean(id),
});
}
export function useRecurringPreview(id: string, enabled: boolean) {
return useQuery({
queryKey: recurringTransactionKeys.preview(id),
......
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { reminderService } from "@/services/reminder.service";
import { CreateReminderInput, ReminderQuery, UpdateReminderInput } from "@/types/reminder";
import { CreateReminderInput, ReminderQuery, UpdateDailyTransactionReminderInput, UpdateReminderInput } from "@/types/reminder";
import { notificationKeys } from "./use-notifications";
export const reminderKeys = {
all: ["reminders"] as const,
lists: () => [...reminderKeys.all, "list"] as const,
list: (query: ReminderQuery) => [...reminderKeys.lists(), query] as const,
dailyTransaction: () => [...reminderKeys.all, "daily-transaction"] as const,
};
export function useReminders(query: ReminderQuery, enabled = true) {
......@@ -46,3 +47,29 @@ export function useUpdateReminder() {
export function useDeleteReminder() {
return useReminderMutation((id: string) => reminderService.remove(id));
}
// ─── Daily Transaction Reminder ──────────────────────────────────────────────
export function useDailyTransactionReminder(enabled = true) {
return useQuery({
queryKey: reminderKeys.dailyTransaction(),
queryFn: () => reminderService.getDailyTransactionReminder(),
enabled,
staleTime: 30_000,
refetchOnMount: "always",
refetchOnWindowFocus: true,
select: (res) => res.data,
});
}
export function useUpdateDailyTransactionReminder() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: UpdateDailyTransactionReminderInput) =>
reminderService.updateDailyTransactionReminder(input),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: reminderKeys.dailyTransaction() });
},
});
}
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { savingsService } from "@/services/savings.service";
import {
CreateTermDepositInput,
SettleTermDepositInput,
UpdateTermDepositInput,
} from "@/types/savings";
import { walletKeys } from "./use-wallets";
export const savingsKeys = {
all: ["savings"] as const,
lists: () => [...savingsKeys.all, "list"] as const,
list: (params?: Record<string, unknown>) => [...savingsKeys.lists(), params] as const,
details: () => [...savingsKeys.all, "detail"] as const,
detail: (id: string) => [...savingsKeys.details(), id] as const,
preview: (input: unknown) => [...savingsKeys.all, "preview", input] as const,
earlyWithdrawal: (id: string, date?: string) => [...savingsKeys.all, "earlyWithdrawal", id, date] as const,
};
export function useSavings(params?: {
status?: string;
search?: string;
sortBy?: string;
order?: string;
page?: number;
limit?: number;
}) {
return useQuery({
queryKey: savingsKeys.list(params),
queryFn: () => savingsService.getSavings(params),
placeholderData: keepPreviousData,
staleTime: 30_000,
});
}
export function useSavingDetail(id: string) {
return useQuery({
queryKey: savingsKeys.detail(id),
queryFn: () => savingsService.getSaving(id),
enabled: Boolean(id),
staleTime: 30_000,
});
}
export function useSavingsPreview(
input: {
principalAmount: string;
interestRate: string;
termMonths: number;
startDate?: string;
},
enabled = true,
) {
return useQuery({
queryKey: savingsKeys.preview(input),
queryFn: () => savingsService.previewSaving(input),
enabled:
enabled &&
Number(input.principalAmount) > 0 &&
Number(input.interestRate) > 0 &&
input.termMonths > 0,
staleTime: 60_000,
});
}
export function useEarlyWithdrawalPreview(id: string, date?: string, enabled = true) {
return useQuery({
queryKey: savingsKeys.earlyWithdrawal(id, date),
queryFn: () => savingsService.previewEarlyWithdrawal(id, date),
enabled: enabled && Boolean(id),
staleTime: 30_000,
});
}
export function useCreateSaving() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateTermDepositInput) => savingsService.createSaving(input),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: savingsKeys.all });
await queryClient.invalidateQueries({ queryKey: walletKeys.all });
},
});
}
export function useUpdateSaving(id: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: UpdateTermDepositInput) => savingsService.updateSaving(id, input),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: savingsKeys.all });
await queryClient.invalidateQueries({ queryKey: savingsKeys.detail(id) });
await queryClient.invalidateQueries({ queryKey: walletKeys.all });
},
});
}
export function useSettleSaving(id: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: SettleTermDepositInput) => savingsService.settleSaving(id, input),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: savingsKeys.all });
await queryClient.invalidateQueries({ queryKey: savingsKeys.detail(id) });
await queryClient.invalidateQueries({ queryKey: walletKeys.all });
},
});
}
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { statementService } from "@/services/statement.service";
import { CreateStatementExportDto } from "@/types/statement";
export const STATEMENT_QUERY_KEYS = {
all: ["statements"] as const,
history: (page = 1, limit = 10) => ["statements", "history", page, limit] as const,
job: (id: string) => ["statements", "job", id] as const,
verify: (code: string) => ["statements", "verify", code] as const,
};
export function useStatementHistory(page = 1, limit = 10) {
return useQuery({
queryKey: STATEMENT_QUERY_KEYS.history(page, limit),
queryFn: () => statementService.listHistory(page, limit),
staleTime: 30 * 1000,
// Automatically poll every 3s if any job in history is pending or processing
refetchInterval: (query) => {
const hasActiveJob = query.state.data?.data?.some(
(job) => job.status === "PENDING" || job.status === "PROCESSING",
);
return hasActiveJob ? 3000 : false;
},
});
}
export function useStatementJob(jobId: string, options?: { enabled?: boolean }) {
return useQuery({
queryKey: STATEMENT_QUERY_KEYS.job(jobId),
queryFn: () => statementService.getJob(jobId),
enabled: options?.enabled !== false && Boolean(jobId),
// Polling every 2.5s if job is still pending or processing
refetchInterval: (query) => {
const status = query.state.data?.status;
return status === "PENDING" || status === "PROCESSING" ? 2500 : false;
},
});
}
export function useInitiateStatementExport() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (dto: CreateStatementExportDto) => statementService.initiateExport(dto),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: STATEMENT_QUERY_KEYS.all });
},
});
}
export function useVerifyStatement(code: string) {
return useQuery({
queryKey: STATEMENT_QUERY_KEYS.verify(code),
queryFn: () => statementService.verifyStatement(code),
enabled: Boolean(code),
staleTime: 5 * 60 * 1000,
});
}
import { useState } from "react";
import { authorize, getAccessToken, getPhoneNumber, getUserInfo, getUserID } from "zmp-sdk";
import { useNavigate, useSnackbar } from "zmp-ui";
import { authService } from "@/services/auth.service";
import { useAuthStore } from "@/stores/auth-store";
import { getErrorMessage } from "@/lib/error-message";
import { useI18n } from "@/i18n";
interface UseZaloLoginReturn {
handleZaloLogin: () => Promise<void>;
isLoading: boolean;
}
export function useZaloLogin(): UseZaloLoginReturn {
const [isLoading, setIsLoading] = useState(false);
const navigate = useNavigate();
const { openSnackbar } = useSnackbar();
const setAuth = useAuthStore((state) => state.setAuth);
const { t } = useI18n();
const handleZaloLogin = async () => {
setIsLoading(true);
try {
// 1. Lấy Zalo access token (string trực tiếp)
const accessToken = await getAccessToken();
if (!accessToken) {
throw new Error(t("auth.zalo.tokenFailed"));
}
// 2. Lấy ID và thông tin người dùng từ client SDK
let zaloId: string | undefined;
let name: string | undefined;
let avatar: string | undefined;
try {
const id = await getUserID();
if (id) zaloId = id;
} catch (idErr) {
console.warn("getUserID error:", idErr);
}
try {
await authorize({ scopes: ["scope.userInfo"] });
} catch (authErr) {
console.warn("[ZaloLogin] authorize scope.userInfo error or dismissed:", authErr);
}
try {
const rawInfo = (await getUserInfo({ autoRequestPermission: true })) as unknown as {
userInfo?: { id?: string; name?: string; avatar?: string };
id?: string;
name?: string;
avatar?: string;
};
const userObj = rawInfo?.userInfo || rawInfo;
if (userObj) {
if (userObj.id) zaloId = userObj.id;
if (userObj.name) name = userObj.name;
if (userObj.avatar) avatar = userObj.avatar;
}
} catch (infoErr) {
console.warn("[ZaloLogin] getUserInfo error:", infoErr);
}
// 3. Lấy số điện thoại từ Zalo SDK (lấy mã token bảo mật 2 phút để backend giải mã)
let phoneToken: string | undefined;
let phoneNumber: string | undefined;
try {
const phoneResult = await getPhoneNumber();
phoneToken = phoneResult.token;
phoneNumber = phoneResult.number;
} catch (phoneErr) {
console.warn("getPhoneNumber error:", phoneErr);
}
if (!phoneToken && !phoneNumber) {
openSnackbar({
type: "warning",
text: t("auth.zalo.phoneDenied"),
});
return;
}
// 4. Gửi lên backend để xác thực + giải mã SĐT + đăng nhập/tạo tài khoản
const response = await authService.loginWithZalo({
accessToken,
phoneToken,
phoneNumber,
zaloId,
name,
avatar,
});
if (response.success && response.data) {
setAuth(
response.data.user,
response.data.accessToken || "",
response.data.refreshToken || ""
);
openSnackbar({
type: "success",
text: t("auth.zalo.loginSuccess"),
});
navigate("/", { replace: true });
} else {
throw new Error(t("auth.zalo.loginFailed"));
}
} catch (error: unknown) {
openSnackbar({
type: "error",
text: getErrorMessage(error, t("auth.zalo.loginFailed")),
});
} finally {
setIsLoading(false);
}
};
return { handleZaloLogin, isLoading };
}
This diff is collapsed.
This diff is collapsed.
......@@ -25,9 +25,25 @@ const refreshClient = axios.create({
},
});
import { handleGuestRequest } from "@/lib/guest-mock";
// Request interceptor to attach bearer token
apiClient.interceptors.request.use(
(config) => {
// If in Guest Mode and not an auth endpoint, serve rich sample data
const isGuest = useAuthStore.getState().isGuest;
const isAuthEndpoint =
config.url?.includes("/auth/login") ||
config.url?.includes("/auth/register") ||
config.url?.includes("/auth/zalo-login") ||
config.url?.includes("/auth/forgot-password") ||
config.url?.includes("/auth/reset-password");
if (isGuest && !isAuthEndpoint) {
config.adapter = async () => handleGuestRequest(config);
return config;
}
const accessToken = useAuthStore.getState().accessToken || safeStorage.getItem("accessToken");
if (accessToken && config.headers) {
config.headers.Authorization = `Bearer ${accessToken}`;
......
import { downloadFile, openDocument, openOutApp } from "zmp-sdk/apis";
export interface SaveStatementOptions {
blob: Blob;
filename: string;
format: string;
directUrl?: string | null;
/** Proxy URL with filename+extension in path and inline=true — used for Zalo openDocument (PDF) */
viewerUrl?: string | null;
/** Proxy URL with filename+extension in path and Content-Disposition: attachment — used for Zalo downloadFile (CSV, XLSX, PDF) */
downloadUrl?: string | null;
preferShare?: boolean;
}
export interface SaveStatementResult {
success: boolean;
method?: "zalo_download" | "direct_url" | "blob_download" | "open_document";
message?: string;
aborted?: boolean;
error?: unknown;
}
export function getStatementMimeType(format: string): string {
switch (format.toUpperCase()) {
case "XLSX":
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
case "PDF":
return "application/pdf";
case "CSV":
return "text/csv";
default:
return "application/octet-stream";
}
}
export function getStatementFileName(jobId: string, format: string): string {
const shortId = jobId ? jobId.slice(0, 8) : Date.now().toString(36);
return `finwise-statement-${shortId}.${format.toLowerCase()}`;
}
export function isMobileDevice(): boolean {
if (typeof window === "undefined" || !window.navigator) return false;
const ua = window.navigator.userAgent || "";
return /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua);
}
export function isZaloMiniApp(): boolean {
if (typeof window === "undefined") return false;
const w = window as any;
const ua = window.navigator?.userAgent || "";
const host = window.location?.hostname || "";
const path = window.location?.pathname || "";
return (
Boolean(w.ZaloJavaScriptInterface) ||
Boolean(w.ZaloMiniAppSDK) ||
Boolean(w.appEnv?.isMp) ||
Boolean(w.zmp) ||
/zalo/i.test(ua) ||
host.includes("zdn.vn") ||
host.includes("zalo.me") ||
path.startsWith("/zapps")
);
}
export function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
if (typeof reader.result === "string") {
const commaIndex = reader.result.indexOf(",");
const base64 = commaIndex >= 0 ? reader.result.slice(commaIndex + 1) : reader.result;
resolve(base64);
} else {
reject(new Error("Failed to convert blob to base64"));
}
};
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
}
/**
* Robust cross-platform file download:
* 1. In Zalo Mini App:
* - For PDF: opens native Zalo document viewer (openDocument) with preview and Save to Device.
* - For XLSX/CSV/PDF fallback: uses downloadFile({ url: targetUrl }) which saves directly to Downloads folder.
* 2. Desktop & Mobile Web:
* - Uses Blob URL with <a download> to save directly into device Downloads folder.
* - Fallback: navigates directly to authenticated directUrl.
*/
export async function downloadStatementFile(
options: SaveStatementOptions,
): Promise<SaveStatementResult> {
const { blob, filename, format, directUrl, viewerUrl, downloadUrl } = options;
const upperFormat = format.toUpperCase();
// ── 1. In Zalo Mini App ─────────────────────────────────────────────────────
if (isZaloMiniApp()) {
// 1a. PDF: try openDocument for native preview + "Lưu vào máy" button.
// Zalo openDocument officially only supports PDF documents.
// For XLSX, openDocument is not officially supported by Zalo SDK.
if ((upperFormat === "PDF" || upperFormat === "XLSX") && (viewerUrl || downloadUrl || directUrl)) {
const docPreviewUrl = viewerUrl || downloadUrl || directUrl;
try {
await openDocument({
url: docPreviewUrl!,
title: filename,
download: true,
share: true,
edit: false,
});
return {
success: true,
method: "open_document",
message:
upperFormat === "PDF"
? 'Đã mở sao kê PDF trong Zalo. Nhấn "Lưu vào máy" để tải về.'
: 'Đã mở sao kê Excel trong Zalo. Nhấn "Lưu vào máy" để tải về.',
};
} catch (zaloDocErr) {
console.warn("[file-download] Zalo openDocument failed, falling back to downloadFile:", zaloDocErr);
}
}
// 1b. Universal download for ALL formats (CSV, XLSX, PDF fallback):
// downloadFile({url}) — Zalo fetches the URL itself and saves to device Downloads folder.
// CRITICAL: Must prioritize downloadUrl (attachment disposition, no inline) over viewerUrl.
// If inline disposition is sent with text/csv, mobile download managers treat it as inline text rather than saving!
const targetDownloadUrl = downloadUrl || directUrl || viewerUrl;
if (targetDownloadUrl) {
try {
await downloadFile({ url: targetDownloadUrl });
return {
success: true,
method: "zalo_download",
message: `Đã tải "${filename}" vào thư mục Tải về trên thiết bị.`,
};
} catch (zaloDownloadErr) {
console.warn("[file-download] Zalo downloadFile({url}) failed:", zaloDownloadErr);
}
}
// 1c. Last resort: open in external browser
if (directUrl) {
try {
await openOutApp({ url: directUrl });
return {
success: true,
method: "direct_url",
message: `Đang mở trình duyệt để tải tệp ${filename}`,
};
} catch (outAppErr) {
console.warn("[file-download] Zalo openOutApp failed:", outAppErr);
}
}
}
// ── 2. Desktop & Mobile Web: Standard Blob Download ─────────────────────────
try {
const objectUrl = window.URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = filename;
anchor.style.display = "none";
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
// CRITICAL: Keep object URL alive for 60 seconds so browser download manager finishes
setTimeout(() => {
try {
window.URL.revokeObjectURL(objectUrl);
} catch {
// Ignore revocation error
}
}, 60000);
return {
success: true,
method: "blob_download",
message: `Đã tải xuống tệp ${filename} vào thư mục tải về`,
};
} catch (blobErr) {
console.error("[file-download] Blob download failed:", blobErr);
}
// ── 3. Direct URL Navigation Fallback ────────────────────────────────────────
if (directUrl) {
try {
const link = document.createElement("a");
link.href = directUrl;
link.setAttribute("download", filename);
link.target = "_blank";
link.rel = "noopener noreferrer";
document.body.appendChild(link);
link.click();
link.remove();
return {
success: true,
method: "direct_url",
message: `Đang tải xuống tệp ${filename}`,
};
} catch (urlErr) {
console.warn("[file-download] Direct URL fallback failed:", urlErr);
}
}
return {
success: false,
message: "Không thể tải hoặc lưu tệp vào thiết bị",
};
}
// Backward-compatible alias
export const saveOrShareStatement = downloadStatementFile;
This diff is collapsed.
......@@ -6,6 +6,7 @@ export interface FinWiseNavigationState {
fromAIRecommendations?: boolean;
fromReports?: boolean;
transactionId?: string;
scheduleId?: string;
transactionTypeFilter?: TransactionType;
categoryIdFilter?: string;
walletIdFilter?: string;
......@@ -43,6 +44,12 @@ export function getTransactionId(state: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value : undefined;
}
export function getScheduleId(state: unknown): string | undefined {
if (!state || typeof state !== "object") return undefined;
const value = (state as FinWiseNavigationState).scheduleId;
return typeof value === "string" && value.trim() ? value : undefined;
}
export function getCategoryIdFilter(state: unknown): string | undefined {
if (!state || typeof state !== "object") return undefined;
const value = (state as FinWiseNavigationState).categoryIdFilter;
......
import { TranslationFunction } from "@/i18n";
import { formatBusinessDate } from "@/lib/business-time";
import { addCalendarDays, formatBusinessDate, instantToBusinessDate } from "@/lib/business-time";
import { NotificationItem, NotificationType } from "@/types/notification";
interface NotificationFormatter {
......@@ -46,6 +46,36 @@ function extractBusinessDate(message: string): string | undefined {
return message.match(/\b\d{4}-\d{2}-\d{2}\b/)?.[0];
}
export function extractNotificationDueDate(notification: NotificationItem): string | undefined {
// 1. Explicit dueDate in data
if (typeof notification.data?.dueDate === "string" && notification.data.dueDate.trim()) {
return notification.data.dueDate.trim();
}
// 2. dueDate query param in actionUrl
const urlDueDate = notification.actionUrl?.match(/[?&]dueDate=([^&#]+)/)?.[1];
if (urlDueDate) return urlDueDate;
// 3. Business date directly in message (e.g. "vào ngày YYYY-MM-DD")
const msgDate = extractBusinessDate(notification.message);
if (msgDate) return msgDate;
// 4. Compute from remindDaysBefore in actionUrl + notification trigger/creation time
const remindDaysMatch = notification.actionUrl?.match(/[?&]remindDaysBefore=(\d+)/);
if (remindDaysMatch && notification.type === "RECURRING_PAYMENT_DUE") {
const days = parseInt(remindDaysMatch[1], 10);
const baseDate = notification.data?.scheduledAt
? String(notification.data.scheduledAt)
: notification.createdAt;
if (baseDate) {
const baseBusiness = instantToBusinessDate(baseDate);
return addCalendarDays(baseBusiness, days);
}
}
return undefined;
}
export function formatNotificationContent(
notification: NotificationItem,
formatter: NotificationFormatter,
......@@ -106,6 +136,25 @@ export function formatNotificationContent(
};
}
if (notification.type === "RECURRING_PAYMENT_DUE") {
const dueDate = extractNotificationDueDate(notification);
if (dueDate) {
const formattedDueDate = formatBusinessDate(dueDate, formatter.intlLocale, {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
const nameMatch = notification.title.match(/\((.+)\)/);
const recurringName = nameMatch ? nameMatch[1] : undefined;
return {
title: notification.title,
message: recurringName
? formatter.t("notification.content.recurringDueMessageWithName", { name: recurringName, date: formattedDueDate })
: formatter.t("notification.content.recurringDueMessage", { date: formattedDueDate }),
};
}
}
if (
(notification.type === "USER_REMINDER" || notification.type === "RECURRING_PAYMENT_DUE")
&& notification.message === "A scheduled reminder is due."
......
This diff is collapsed.
......@@ -122,11 +122,11 @@ export const AdminAiPage: React.FC = () => {
};
return (
<Page className="page min-h-screen bg-clay-bg pb-12">
<Page className="page">
<Header title={t('admin.ai.header')} showBackIcon onBackClick={() => navigate('/admin')} />
<IconGradients />
<main className="mx-auto w-full max-w-5xl space-y-6 px-4 pt-6">
<main className="mx-auto mt-4 flex w-full max-w-5xl flex-col gap-6 pb-12">
{/* Banner */}
<Card className="overflow-hidden bg-clay-primary/10">
<div className="flex items-center gap-4">
......
import React, { useMemo, useState } from 'react';
import { Header, Page, useNavigate } from 'zmp-ui';
import { Header, Page, useNavigate, useSnackbar } from 'zmp-ui';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Modal } from '@/components/ui/Modal';
import { Select } from '@/components/ui/Select';
import { IconGradients } from '@/components/ui/icons';
import { LocalizedDateInput } from '@/components/shared/LocalizedDateInput';
import { addCalendarDays, businessDateStartIso } from '@/lib/business-time';
import { useAdminAuditLogs } from '@/hooks/use-admin';
import { useAdminAuditLogs, useArchiveCleanupAuditLogs } from '@/hooks/use-admin';
import { useI18n } from '@/i18n';
import { AdminSkeleton } from '@/pages/admin/components/AdminSkeleton';
import { AuditLogCard } from '@/pages/admin/components/AuditLogCard';
......@@ -22,6 +24,7 @@ const AUDIT_ACTIONS = [
'USER_DEACTIVATE',
'USER_DELETE',
'USER_RESTORE',
'AUDIT_LOGS_ARCHIVE_CLEANUP',
] as const;
function endOfBusinessDateIso(date: string): string {
......@@ -31,14 +34,41 @@ function endOfBusinessDateIso(date: string): string {
const AdminAuditLogsPage: React.FC = () => {
const navigate = useNavigate();
const { openSnackbar } = useSnackbar();
const { t, formatNumber } = useI18n();
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [action, setAction] = useState('');
const [targetType, setTargetType] = useState('');
const [page, setPage] = useState(1);
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
const invalidRange = Boolean(dateFrom && dateTo && dateFrom > dateTo);
const archiveMutation = useArchiveCleanupAuditLogs();
const handleArchiveCleanup = async () => {
try {
const res = await archiveMutation.mutateAsync(30);
setIsConfirmOpen(false);
if (res.data?.archivedCount && res.data.archivedCount > 0) {
openSnackbar({
type: 'success',
text: t('admin.audit.archiveCleanupSuccess', { count: res.data.archivedCount }),
});
} else {
openSnackbar({
type: 'info',
text: t('admin.audit.archiveCleanupNoLogs'),
});
}
} catch {
openSnackbar({
type: 'error',
text: t('admin.audit.archiveCleanupFailed'),
});
}
};
const params = useMemo(() => ({
dateFrom: dateFrom ? businessDateStartIso(dateFrom) : undefined,
dateTo: dateTo ? endOfBusinessDateIso(dateTo) : undefined,
......@@ -60,9 +90,39 @@ const AdminAuditLogsPage: React.FC = () => {
};
return (
<Page className="page min-h-screen bg-clay-bg pb-12">
<Page className="page">
<Header title={t('admin.audit.title')} showBackIcon onBackClick={() => navigate('/admin')} />
<main className="mx-auto w-full max-w-5xl space-y-5 px-4 pt-6">
<IconGradients />
<main className="mx-auto mt-4 flex w-full max-w-5xl flex-col gap-5 pb-12">
{/* Retention Policy Banner */}
<Card className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 border border-clay-info/30 bg-clay-info/10 p-4 shadow-clay-raised">
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 flex-none items-center justify-center rounded-full bg-clay-info/20 text-clay-info shadow-clay-sm">
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="space-y-0.5">
<p className="font-semibold text-sm text-clay-text">
{t('admin.audit.title')} — 30 ngày
</p>
<p className="text-xs text-clay-text-muted leading-relaxed">
{t('admin.audit.retentionPolicyNotice')}
</p>
</div>
</div>
<Button
variant="secondary"
className="text-xs whitespace-nowrap self-end sm:self-center px-3 py-2"
disabled={archiveMutation.isPending}
onClick={() => setIsConfirmOpen(true)}
>
{archiveMutation.isPending
? t('admin.audit.archiveCleanupProcessing')
: t('admin.audit.archiveCleanupBtn')}
</Button>
</Card>
<Card>
<h1 className="clay-title-h2">{t('admin.audit.heading')}</h1>
<p className="mt-1 text-sm text-clay-text-muted">{t('admin.audit.description')}</p>
......@@ -138,6 +198,32 @@ const AdminAuditLogsPage: React.FC = () => {
</Button>
</nav>
)}
<Modal
isOpen={isConfirmOpen}
onClose={() => setIsConfirmOpen(false)}
title={t('admin.audit.archiveCleanupConfirmTitle')}
footer={
<>
<Button variant="ghost" onClick={() => setIsConfirmOpen(false)}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
disabled={archiveMutation.isPending}
onClick={handleArchiveCleanup}
>
{archiveMutation.isPending
? t('admin.audit.archiveCleanupProcessing')
: t('admin.audit.archiveCleanupBtn')}
</Button>
</>
}
>
<p className="text-sm text-clay-text-muted leading-relaxed">
{t('admin.audit.archiveCleanupConfirmDesc')}
</p>
</Modal>
</main>
</Page>
);
......
......@@ -39,10 +39,10 @@ const AdminDashboardPage: React.FC = () => {
] : [];
return (
<Page className="page min-h-screen bg-clay-bg pb-12">
<Page className="page">
<Header title={t('admin.dashboard.title')} showBackIcon onBackClick={() => navigate('/')} />
<IconGradients />
<main className="mx-auto w-full max-w-5xl space-y-6 px-4 pt-6">
<main className="mx-auto mt-4 flex w-full max-w-5xl flex-col gap-6 pb-12">
<Card className="overflow-hidden bg-clay-primary/10">
<div className="flex items-center gap-4">
<div className="rounded-clay bg-clay-primary p-3 text-clay-on-primary shadow-clay-raised">
......
......@@ -175,11 +175,11 @@ export const AdminNotificationsPage: React.FC = () => {
};
return (
<Page className="page min-h-screen bg-clay-bg pb-12">
<Page className="page">
<Header title={t('admin.notifications.header')} showBackIcon onBackClick={() => navigate('/admin')} />
<IconGradients />
<main className="mx-auto w-full max-w-5xl space-y-6 px-4 pt-6">
<main className="mx-auto mt-4 flex w-full max-w-5xl flex-col gap-6 pb-12">
{/* Banner */}
<Card className="overflow-hidden bg-clay-primary/10">
<div className="flex items-center gap-4">
......
......@@ -41,6 +41,11 @@ const ACTION_COLORS: Record<string, string> = {
TEMPLATE_READ: 'bg-blue-500/15 text-blue-600 border-blue-500/30',
TEMPLATE_UPDATE: 'bg-yellow-500/15 text-yellow-600 border-yellow-500/30',
RETRY: 'bg-emerald-500/15 text-emerald-600 border-emerald-500/30',
SETTLE: 'bg-emerald-500/15 text-emerald-600 border-emerald-500/30',
EXPORT: 'bg-cyan-500/15 text-cyan-600 border-cyan-500/30',
IMPORT: 'bg-fuchsia-500/15 text-fuchsia-600 border-fuchsia-500/30',
INITIATE: 'bg-sky-500/15 text-sky-600 border-sky-500/30',
CLAIM: 'bg-violet-500/15 text-violet-600 border-violet-500/30',
};
......@@ -272,7 +277,7 @@ const RolesPage: React.FC = () => {
/>
<IconGradients />
<div className="flex flex-col gap-5 mt-4 pb-20">
<main className="mx-auto mt-4 flex w-full max-w-5xl flex-col gap-5 pb-12">
{/* Banner Header Card */}
<Card className="flex items-center gap-4 py-4 bg-clay-surface border border-clay-highlight/60 shadow-clay-raised">
<div className="p-3 bg-clay-primary/10 rounded-full text-clay-primary flex-none">
......@@ -424,7 +429,7 @@ const RolesPage: React.FC = () => {
</div>
)}
</div>
</div>
</main>
{/* ======================================================== */}
{/* MODAL: PERMISSION MATRIX EDITOR */}
......
......@@ -189,11 +189,11 @@ export const AdminSettingsPage: React.FC = () => {
};
return (
<Page className="page min-h-screen bg-clay-bg pb-12">
<Page className="page">
<Header title={t('admin.settings.header')} showBackIcon onBackClick={() => navigate('/admin')} />
<IconGradients />
<main className="mx-auto w-full max-w-5xl space-y-6 px-4 pt-6">
<main className="mx-auto mt-4 flex w-full max-w-5xl flex-col gap-6 pb-12">
{/* Banner */}
<Card className="overflow-hidden bg-clay-primary/10">
<div className="flex items-center gap-4">
......
......@@ -6,9 +6,9 @@ import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Select } from '@/components/ui/Select';
import { PermissionGate } from '@/components/shared/PermissionGate';
import { IconGradients } from '@/components/ui/icons';
import { PERMISSIONS } from '@/common/constants';
import {
useAdminAuditLogs,
useAdminRoles,
useAdminUser,
useRestoreAdminUser,
......@@ -18,7 +18,6 @@ import { usePermission } from '@/hooks/use-permission';
import { useI18n } from '@/i18n';
import { getErrorMessage } from '@/lib/error-message';
import { AdminSkeleton } from '@/pages/admin/components/AdminSkeleton';
import { AuditLogCard } from '@/pages/admin/components/AuditLogCard';
import { useAuthStore } from '@/stores/auth-store';
const AdminUserDetailPage: React.FC = () => {
......@@ -28,10 +27,8 @@ const AdminUserDetailPage: React.FC = () => {
const { t, formatDate } = useI18n();
const currentUser = useAuthStore((state) => state.user);
const { hasPermission } = usePermission();
const canReadAuditLogs = hasPermission(PERMISSIONS.AUDIT_LOG_READ);
const userQuery = useAdminUser(id);
const rolesQuery = useAdminRoles();
const auditQuery = useAdminAuditLogs({ targetId: id, limit: 20 }, canReadAuditLogs && Boolean(id));
const updateMutation = useUpdateAdminUser();
const restoreMutation = useRestoreAdminUser();
const user = userQuery.data?.success ? userQuery.data.data : undefined;
......@@ -66,9 +63,10 @@ const AdminUserDetailPage: React.FC = () => {
};
return (
<Page className="page min-h-screen bg-clay-bg pb-12">
<Page className="page">
<Header title={t('admin.userDetail.title')} showBackIcon onBackClick={() => navigate('/admin/users')} />
<main className="mx-auto w-full max-w-4xl space-y-5 px-4 pt-6">
<IconGradients />
<main className="mx-auto mt-4 flex w-full max-w-4xl flex-col gap-5 pb-12">
{userQuery.isLoading ? (
<AdminSkeleton rows={3} />
) : userQuery.isError || !user ? (
......@@ -138,24 +136,6 @@ const AdminUserDetailPage: React.FC = () => {
</PermissionGate>
)}
</Card>
<PermissionGate permission={PERMISSIONS.AUDIT_LOG_READ}>
<section aria-labelledby="user-audit-heading">
<h2 id="user-audit-heading" className="clay-title-h3 mb-3">{t('admin.userDetail.history')}</h2>
{auditQuery.isLoading ? (
<AdminSkeleton rows={2} />
) : auditQuery.isError ? (
<Card className="text-center">
<p className="text-clay-expense">{t('admin.errors.auditLogs')}</p>
<Button className="mt-4" onClick={() => auditQuery.refetch()}>{t('common.retry')}</Button>
</Card>
) : (auditQuery.data?.data.length ?? 0) === 0 ? (
<Card className="text-center text-clay-text-muted">{t('admin.audit.empty')}</Card>
) : (
<div className="space-y-3">{auditQuery.data?.data.map((log) => <AuditLogCard key={log.id} log={log} />)}</div>
)}
</section>
</PermissionGate>
</>
)}
</main>
......
......@@ -11,6 +11,7 @@ import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { Select } from '@/components/ui/Select';
import { PermissionGate } from '@/components/shared/PermissionGate';
import { IconGradients } from '@/components/ui/icons';
import { PERMISSIONS } from '@/common/constants';
import {
useAdminRoles,
......@@ -123,9 +124,10 @@ const AdminUsersPage: React.FC = () => {
};
return (
<Page className="page min-h-screen bg-clay-bg pb-12">
<Page className="page">
<Header title={t('admin.users.title')} showBackIcon onBackClick={() => navigate('/admin')} />
<main className="mx-auto w-full max-w-5xl space-y-5 px-4 pt-6">
<IconGradients />
<main className="mx-auto mt-4 flex w-full max-w-5xl flex-col gap-5 pb-12">
<Card>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
......
......@@ -4,6 +4,7 @@ import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import {
AIAssistantIcon,
AlertTriangleIcon,
ChevronRightIcon,
ClockIcon,
LightbulbIcon,
......@@ -16,7 +17,11 @@ import { useAIChat } from "@/hooks/use-ai-assistant";
import { AIAssistantError } from "@/services/ai-assistant.service";
import { AIChatMessage, useAIChatStore } from "@/stores/ai-chat-store";
export const AIChatView: React.FC = () => {
export interface AIChatViewProps {
onOpenDisclaimer?: () => void;
}
export const AIChatView: React.FC<AIChatViewProps> = ({ onOpenDisclaimer }) => {
const { t } = useI18n();
const chatMutation = useAIChat();
......@@ -344,6 +349,23 @@ export const AIChatView: React.FC = () => {
{chatMutation.isPending ? t("common.processing") : t("ai.chat.send")}
</Button>
</Card>
{/* Permanent disclaimer notice for AI content accuracy compliant with Zalo review */}
<div className="flex items-center justify-center gap-1.5 px-2 text-center">
<AlertTriangleIcon size={12} className="shrink-0 text-clay-warning" />
<p className="font-nunito text-[11px] text-clay-text-muted leading-tight">
{t("ai.chat.disclaimer")}
{onOpenDisclaimer && (
<button
type="button"
onClick={onOpenDisclaimer}
className="ml-1 font-semibold text-clay-primary hover:underline inline-flex items-center"
>
{t("ai.disclaimer.learnMore")}
</button>
)}
</p>
</div>
</div>
</div>
);
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
......@@ -42,10 +42,11 @@ function activeAtForFilter(timeFilter: TimeFilter, date: string): string | undef
return date || undefined;
}
function flattenExpenseCategories(nodes: CategoryTreeNode[], depth = 0): Array<{ category: CategoryTreeNode; depth: number }> {
function flattenExpenseCategories(nodes: CategoryTreeNode[] = [], depth = 0): Array<{ category: CategoryTreeNode; depth: number }> {
if (!Array.isArray(nodes)) return [];
return nodes.flatMap((node) => [
...(node.type === "EXPENSE" && !node.isArchived ? [{ category: node, depth }] : []),
...flattenExpenseCategories(node.children, depth + 1),
...(node?.type === "EXPENSE" && !node?.isArchived ? [{ category: node, depth }] : []),
...flattenExpenseCategories(node?.children || [], depth + 1),
]);
}
......@@ -85,9 +86,9 @@ const BudgetsPage: React.FC = () => {
const categoriesQuery = useCategoryTree({ type: "EXPENSE", source: "ALL", includeArchived: false });
const createMutation = useCreateBudget();
const budgets = budgetsQuery.data?.data || [];
const totalItems = budgetsQuery.data?.meta.total || 0;
const totalPages = budgetsQuery.data?.meta.totalPages || 0;
const alertCount = budgets.filter((budget) => !budget.isArchived && budget.usage.status !== "ON_TRACK").length;
const totalItems = budgetsQuery.data?.meta?.total ?? budgets.length;
const totalPages = budgetsQuery.data?.meta?.totalPages ?? (budgets.length > 0 ? 1 : 0);
const alertCount = budgets.filter((budget) => !budget.isArchived && budget.usage?.status !== "ON_TRACK").length;
useEffect(() => {
setPage(1);
......
......@@ -53,8 +53,8 @@ function sortTree(
return (new Date(left[sortBy]).getTime() - new Date(right[sortBy]).getTime()) * direction;
};
return nodes
.map((node) => ({ ...node, children: sortTree(node.children, sortBy, order, locale, t) }))
return (nodes || [])
.map((node) => ({ ...node, children: sortTree(node.children || [], sortBy, order, locale, t) }))
.sort(compare);
}
......@@ -65,10 +65,10 @@ function filterTree(
t: TranslationFunction,
): CategoryTreeNode[] {
const normalizedSearch = normalizeCategorySearch(search, locale);
if (!normalizedSearch) return nodes;
if (!normalizedSearch) return nodes || [];
return nodes.reduce<CategoryTreeNode[]>((matches, node) => {
const children = filterTree(node.children, search, locale, t);
return (nodes || []).reduce<CategoryTreeNode[]>((matches, node) => {
const children = filterTree(node.children || [], search, locale, t);
const localizedName = normalizeCategorySearch(getCategoryDisplayName(node, t), locale);
const apiName = normalizeCategorySearch(node.name, locale);
......@@ -81,7 +81,7 @@ function filterTree(
}
function flattenTree(nodes: CategoryTreeNode[]): CategoryTreeNode[] {
return nodes.flatMap((node) => [node, ...flattenTree(node.children)]);
return (nodes || []).flatMap((node) => [node, ...flattenTree(node.children || [])]);
}
const CategoriesPage: React.FC = () => {
......
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.
......@@ -49,7 +49,7 @@ const ForecastPage: React.FC = () => {
{/* Error State */}
{isError && !isLoading && (
<Card className="p-5 text-center space-y-3 border border-clay-expense/30 bg-clay-expense-soft">
<Card className="p-5 text-center space-y-3 border border-clay-expense/30 bg-clay-expense/10">
<p className="text-clay-expense font-bold text-sm">{t("common.error")}</p>
<p className="text-xs text-clay-text-muted">{t("forecast.fetchError")}</p>
<Button
......
This diff is collapsed.
This diff is collapsed.
......@@ -29,6 +29,7 @@ const typeSurface: Record<NotificationType, string> = {
RECURRING_PAYMENT_DUE: "bg-clay-info/20",
UNUSUAL_TRANSACTION: "bg-clay-expense/20",
USER_REMINDER: "bg-clay-primary/20",
DEBT_PAYMENT_DUE: "bg-clay-warning/20",
SYSTEM: "bg-clay-info/20",
};
......
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