Commit 7d1e7a92 authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/daily-transaction-reminder-ui' into 'develop'

Feat/daily transaction reminder ui

See merge request !55
parents 27dac325 42aea742
......@@ -187,6 +187,7 @@ export const Layout = () => {
<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>
......
......@@ -25,7 +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", key: "document.statements" },
{ 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" },
......
......@@ -409,6 +409,28 @@ export const MailIcon: React.FC<IconProps> = ({ size = 20, ...props }) => (
</svg>
);
// Share Icon
export const ShareIcon: React.FC<IconProps> = ({ size = 20, ...props }) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
</svg>
);
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() });
},
});
}
......@@ -1291,16 +1291,16 @@
"save": "Save preferences", "saveSuccess": "Notification preferences saved.", "saveFailed": "Could not save notification preferences."
},
"channel": { "IN_APP": "In app", "EMAIL": "Email", "ZALO": "Zalo", "PUSH": "Push" },
"channelHint": { "IN_APP": "Inbox and badge", "EMAIL": "Send to account email", "ZALO": "Personal Zalo Bot message", "PUSH": "Connection coming soon" },
"channelHint": { "IN_APP": "Inbox and badge", "EMAIL": "Send to account email", "ZALO": "Personal Bot Zalo message", "PUSH": "Connection coming soon" },
"zaloBotLink": {
"title": "Link Zalo Bot",
"title": "Link Bot Zalo",
"hint": "Send a message to Bot FinWise on Zalo to automatically connect alerts.",
"placeholder": "e.g. dbcb8889dbdc32826bcd",
"linked": "Zalo Bot linked",
"linked": "Bot Zalo linked",
"unlink": "Unlink",
"saveChat": "Save Chat ID",
"oneClickBtn": "Link via Zalo Bot",
"oneClickTitle": "Link Zalo Bot",
"oneClickBtn": "Link via Bot Zalo",
"oneClickTitle": "Link Bot Zalo",
"oneClickDesc": "Send this code to Bot Finwise on Zalo to automatically connect:",
"openZalo": "Open Zalo Chat",
"copyCode": "Copy",
......@@ -1309,11 +1309,11 @@
"copiedCommand": "Link command copied!",
"autoCopiedToast": "Command copied! Opening Zalo...",
"waiting": "Waiting for message on Zalo...",
"successLinked": "Zalo Bot linked successfully!",
"successLinked": "Bot Zalo linked successfully!",
"codeExpires": "Code expires in",
"manualToggle": "Or enter Chat ID manually",
"hideManual": "Hide manual input",
"guideTitle": "How to link with Zalo Bot:",
"guideTitle": "How to link with Bot Zalo:",
"guideStep1": "Click \"Open Zalo Chat\" or \"Copy Code\" (the link command is copied to your clipboard).",
"guideStep2": "Open Zalo app → search for \"{{botName}}\" (or ID {{botId}}) → paste and send.",
"step1Title": "Copy the link command below:",
......@@ -1362,6 +1362,17 @@
"messageMax": "Note must not exceed 2000 characters", "remindAtRequired": "Please select a reminder time",
"interval": "Repeat interval must be from 1 to 365", "future": "A one-time reminder must be in the future",
"onceNoEnd": "A one-time reminder cannot have an end time", "endAfter": "End time must be after the start time"
},
"daily": {
"title": "Daily Transaction Logging Reminder",
"hint": "The system will send a notification at the configured time if you haven't logged any transactions today.",
"timeLabel": "Daily reminder time",
"toggleLabel": "Toggle daily transaction reminder",
"todayDone": "Logged today ✓",
"todayPending": "Not logged today",
"nextTrigger": "Next reminder:",
"saveSuccess": "Daily transaction reminder enabled.",
"saveDisabled": "Daily transaction reminder disabled."
}
},
"ai": {
......@@ -2054,8 +2065,8 @@
"inAppDesc": "Deliver notifications directly to the in-app notification inbox",
"emailTitle": "Transactional Email (EMAIL)",
"emailDesc": "Send budget limit warning emails and monthly account statements",
"zaloTitle": "Zalo Notification Service (ZNS)",
"zaloDesc": "Deliver automated messages via verified Zalo Official Account",
"zaloTitle": "Bot Zalo",
"zaloDesc": "Deliver automated messages via Bot Zalo",
"pushTitle": "Mobile Push Notifications (PUSH)",
"pushDesc": "Native push notifications via Firebase FCM and Apple APNs",
"error": "Failed to update channel configuration"
......@@ -2339,6 +2350,10 @@
"createdAt": "Created at",
"expiresIn48h": "Expires in 48h",
"downloadFile": "Download",
"shareBtn": "Share",
"selectedJob": "Selected Statement",
"notificationTargetTitle": "Selected Statement from Notification",
"shareOrSave": "Share or Save to Files",
"processingNotice": "Extracting data in background, please wait a moment...",
"failedNotice": "Processing failed. Please try again later."
},
......@@ -2464,7 +2479,11 @@
"cancelTransfer": "Cancel Handover",
"cancelled": "Handover session cancelled",
"targetConnected": "New Account Connected!",
"targetUserDefault": "New Zalo user",
"otpAuthNotice": "Two-way Mutual Authorization:",
"otpPromptDescription": "A 6-digit OTP verification code has been sent to {{email}}. Please enter the code to authorize transferring all data to account {{targetName}}.",
"otpPromptDefault": "Please enter the 6-digit OTP code to authorize the account ownership transfer.",
"targetUserFallback": "new account",
"otpEmailSentTitle": "OTP Sent to Email",
"otpEmailSentDesc": "Check your inbox (or Spam/Junk folder) of:",
"otpInputLabel": "OTP Authorization Code (6 digits)",
......
......@@ -1353,27 +1353,27 @@
"channelHint": {
"IN_APP": "Hộp thư và badge",
"EMAIL": "Gửi tới email tài khoản",
"ZALO": "Tin nhắn qua Zalo Bot cá nhân",
"ZALO": "Tin nhắn qua Bot Zalo",
"PUSH": "Kết nối sắp tới"
},
"zaloBotLink": {
"title": "Liên kết Zalo Bot",
"title": "Liên kết Bot Zalo",
"hint": "Gửi tin nhắn cho Bot FinWise để kết nối nhận thông báo tự động.",
"placeholder": "Ví dụ: dbcb8889dbdc32826bcd",
"linked": "Đã liên kết Zalo Bot",
"linked": "Đã liên kết Bot Zalo",
"unlink": "Hủy liên kết",
"saveChat": "Lưu Chat ID",
"oneClickBtn": "Kết nối với Zalo Bot",
"oneClickTitle": "Kết Nối Zalo Bot",
"oneClickBtn": "Kết nối với Bot Zalo",
"oneClickTitle": "Kết Nối Bot Zalo",
"oneClickDesc": "Nhắn mã bên dưới cho Bot Finwise để hoàn tất kết nối tự động:",
"openZalo": "Mở khung chat Zalo Bot",
"openZalo": "Mở khung chat Zalo",
"copyCode": "Sao chép",
"copyCommand": "Sao chép",
"copied": "Đã sao chép!",
"copiedCommand": "Đã sao chép lệnh liên kết!",
"autoCopiedToast": "Đã sao chép lệnh! Đang chuyển sang Zalo...",
"waiting": "Đang chờ nhận tin nhắn từ Zalo...",
"successLinked": "Đã liên kết Zalo Bot thành công!",
"successLinked": "Đã liên kết Bot Zalo thành công!",
"codeExpires": "Mã hết hạn sau",
"manualToggle": "Hoặc nhập Chat ID thủ công",
"hideManual": "Ẩn nhập thủ công",
......@@ -1463,6 +1463,17 @@
"future": "Lịch nhắc một lần phải nằm trong tương lai",
"onceNoEnd": "Lịch nhắc một lần không có ngày kết thúc",
"endAfter": "Thời gian kết thúc phải sau thời gian bắt đầu"
},
"daily": {
"title": "Nhắc nhở ghi chép giao dịch hôm nay",
"hint": "Hệ thống sẽ gửi thông báo vào giờ đã cài đặt nếu hôm nay bạn chưa ghi bất kỳ giao dịch nào.",
"timeLabel": "Giờ nhắc mỗi ngày",
"toggleLabel": "Bật/tắt nhắc nhở ghi chép giao dịch",
"todayDone": "Đã ghi chép hôm nay ✓",
"todayPending": "Chưa ghi chép hôm nay",
"nextTrigger": "Lần nhắc tiếp theo:",
"saveSuccess": "Đã bật nhắc nhở ghi chép giao dịch.",
"saveDisabled": "Đã tắt nhắc nhở ghi chép giao dịch."
}
},
"ai": {
......@@ -2155,8 +2166,8 @@
"inAppDesc": "Gửi tin nhắn trực tiếp vào hộp thư thông báo trên app",
"emailTitle": "Email Transactional (EMAIL)",
"emailDesc": "Gửi email cảnh báo vượt hạn mức & sao kê tháng",
"zaloTitle": "Zalo Notification Service (ZNS)",
"zaloDesc": "Gửi tin nhắn qua Zalo Official Account",
"zaloTitle": "Bot Zalo",
"zaloDesc": "Gửi tin nhắn tự động qua Bot Zalo",
"pushTitle": "Mobile Push Notifications (PUSH)",
"pushDesc": "Thông báo đẩy native qua FCM / Apple APNs",
"error": "Lưu cấu hình kênh thất bại"
......@@ -2440,6 +2451,10 @@
"createdAt": "Thời gian tạo",
"expiresIn48h": "Hết hạn sau 48h",
"downloadFile": "Tải xuống",
"shareBtn": "Chia sẻ",
"selectedJob": "Sao kê từ thông báo",
"notificationTargetTitle": "Sao kê được chọn từ thông báo",
"shareOrSave": "Chia sẻ hoặc lưu vào Tệp",
"processingNotice": "Hệ thống đang trích xuất dữ liệu, vui lòng đợi trong giây lát...",
"failedNotice": "Xử lý thất bại. Vui lòng thử lại sau."
},
......@@ -2565,7 +2580,11 @@
"cancelTransfer": "Hủy Phiên Chuyển Giao",
"cancelled": "Đã hủy phiên chuyển giao",
"targetConnected": "Tài Khoản Mới Đã Kết Nối!",
"targetUserDefault": "Người dùng Zalo mới",
"otpAuthNotice": "Xác thực bảo mật 2 chiều:",
"otpPromptDescription": "Mã xác thực OTP gồm 6 chữ số đã được gửi đến email {{email}}. Vui lòng nhập mã để xác nhận chuyển toàn bộ dữ liệu sang tài khoản {{targetName}}.",
"otpPromptDefault": "Vui lòng nhập mã OTP gồm 6 chữ số để ký duyệt lệnh chuyển giao quyền sở hữu.",
"targetUserFallback": "mới",
"otpEmailSentTitle": "Đã gửi mã OTP qua Email",
"otpEmailSentDesc": "Kiểm tra hộp thư đến (hoặc hòm thư Rác/Spam) của:",
"otpInputLabel": "Mã xác thực OTP (6 chữ số)",
......
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;
......@@ -24,7 +24,6 @@ import {
TransferIcon,
BudgetIcon,
SavingGoalIcon,
MailIcon,
} from "@/components/ui/icons";
import { useAuthStore } from "@/stores/auth-store";
import { handoverService } from "@/services/handover.service";
......@@ -164,7 +163,12 @@ export const AccountHandoverPage: React.FC = () => {
if (statusRes.targetUserName) setTargetUserName(statusRes.targetUserName);
if (statusRes.otpPrompt) setOtpPrompt(statusRes.otpPrompt);
if (statusRes.otpCodeDev) setOtpDevCode(statusRes.otpCodeDev);
if (statusRes.sourceUserEmailMasked) setMaskedEmail(statusRes.sourceUserEmailMasked);
if (statusRes.sourceUserEmailMasked) {
setMaskedEmail(statusRes.sourceUserEmailMasked);
} else if (statusRes.otpPrompt) {
const emailMatch = statusRes.otpPrompt.match(/email\s+([^\s,]+)/i);
if (emailMatch?.[1]) setMaskedEmail(emailMatch[1]);
}
} else if (statusRes.status === "EXPIRED") {
setSenderStatus("EXPIRED");
}
......@@ -608,7 +612,7 @@ export const AccountHandoverPage: React.FC = () => {
{t("handover.targetConnected") || "Tài Khoản Mới Đã Kết Nối!"}
</span>
<span className="font-baloo font-bold text-sm sm:text-base text-clay-text block">
{targetUserName || "Người dùng Zalo mới"}
{targetUserName?.trim() || t("handover.targetUserDefault") || "Người dùng Zalo mới"}
</span>
</div>
</div>
......@@ -621,28 +625,17 @@ export const AccountHandoverPage: React.FC = () => {
{t("handover.otpAuthNotice") || "Xác thực bảo mật 2 chiều:"}
</span>
<span>
{otpPrompt ||
"Vui lòng nhập mã OTP gồm 6 chữ số để ký duyệt lệnh chuyển giao quyền sở hữu."}
{maskedEmail
? t("handover.otpPromptDescription", {
email: maskedEmail,
targetName: targetUserName?.trim() || t("handover.targetUserFallback") || "mới",
})
: t("handover.otpPromptDefault") ||
"Vui lòng nhập mã OTP gồm 6 chữ số để ký duyệt lệnh chuyển giao quyền sở hữu."}
</span>
</div>
</div>
{/* Email Sent Banner (Production / Real Flow) */}
{maskedEmail && (
<div className="p-3.5 rounded-clay-sm bg-clay-income/10 border border-clay-income/25 flex items-start gap-2.5">
<MailIcon size={18} className="text-clay-income-dark shrink-0 mt-0.5" />
<div className="text-xs text-clay-text leading-relaxed">
<span className="font-bold text-clay-income-dark block">
{t("handover.otpEmailSentTitle") || "Đã gửi mã OTP qua Email"}
</span>
<span>
{t("handover.otpEmailSentDesc") || "Kiểm tra hộp thư đến (hoặc hòm thư Rác/Spam) của:"}{" "}
<strong className="text-clay-primary font-bold">{maskedEmail}</strong>
</span>
</div>
</div>
)}
{/* Sandbox / Dev OTP Code Notice (if present) */}
{otpDevCode && (
<div className="p-2.5 rounded-clay-sm bg-clay-income/15 border border-clay-income/30 text-center">
......
import React, { useEffect, useState } from "react";
import { Card } from "@/components/ui/Card";
import { useI18n } from "@/i18n";
import { DailyTransactionReminder, UpdateDailyTransactionReminderInput } from "@/types/reminder";
interface DailyTransactionReminderCardProps {
reminder: DailyTransactionReminder | undefined;
isLoading: boolean;
isSaving: boolean;
onUpdate: (payload: UpdateDailyTransactionReminderInput) => void;
}
export const DailyTransactionReminderCard: React.FC<DailyTransactionReminderCardProps> = ({
reminder,
isLoading,
isSaving,
onUpdate,
}) => {
const { t } = useI18n();
const [isActive, setIsActive] = useState(reminder?.isActive ?? false);
const [time, setTime] = useState(reminder?.time ?? "20:00");
useEffect(() => {
if (reminder) {
setIsActive(reminder.isActive);
setTime(reminder.time);
}
}, [reminder]);
const hasTransactionToday = reminder?.hasCreatedTransactionToday ?? false;
const handleToggle = () => {
const newActive = !isActive;
setIsActive(newActive);
onUpdate({ isActive: newActive, time });
};
const handleTimeChange = (newTime: string) => {
setTime(newTime);
onUpdate({ isActive, time: newTime });
};
return (
<Card className="p-5">
{/* ── Header row ─────────────────────────────────────── */}
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3 flex-1 min-w-0">
{/* Icon */}
<div
className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-clay shadow-clay-raised transition-all duration-200 ${
isActive ? "bg-clay-warning" : "bg-clay-bg shadow-clay-pressed"
}`}
aria-hidden="true"
>
<span className="text-xl">⏰</span>
</div>
{/* Title + today badge */}
<div className="min-w-0">
<p className="font-nunito text-sm font-bold text-clay-text leading-tight">
{t("reminder.daily.title")}
</p>
<div className="mt-1.5 flex flex-wrap gap-1.5">
{isLoading ? (
<span className="h-5 w-24 animate-pulse rounded-full bg-clay-bg" />
) : hasTransactionToday ? (
<span className="inline-flex items-center gap-1.5 rounded-full bg-green-100 px-2.5 py-0.5 font-nunito text-xs font-bold text-green-700">
<span className="h-1.5 w-1.5 rounded-full bg-green-500 animate-pulse" />
{t("reminder.daily.todayDone")}
</span>
) : (
<span className="inline-flex items-center gap-1.5 rounded-full bg-clay-warning/20 px-2.5 py-0.5 font-nunito text-xs font-bold text-clay-warning">
<span className="h-1.5 w-1.5 rounded-full bg-clay-warning" />
{t("reminder.daily.todayPending")}
</span>
)}
</div>
</div>
</div>
{/* Toggle switch */}
<button
type="button"
role="switch"
aria-checked={isActive}
aria-label={t("reminder.daily.toggleLabel")}
disabled={isSaving || isLoading}
onClick={handleToggle}
className={`relative h-7 w-12 shrink-0 rounded-full border transition-all duration-200 ease-in-out disabled:opacity-50 ${
isActive
? "border-clay-primary-dark/30 bg-clay-primary"
: "border-clay-border bg-clay-surface shadow-clay-pressed"
}`}
>
<span
className={`absolute top-0.5 h-5 w-5 rounded-full bg-clay-on-primary shadow-clay-raised transition-all duration-200 ease-in-out ${
isActive ? "left-6" : "left-1"
}`}
/>
</button>
</div>
{/* ── Description ────────────────────────────────────── */}
<p className="clay-caption mt-3">{t("reminder.daily.hint")}</p>
{/* ── Settings panel — shown when active ─────────────── */}
{isActive && (
<div className="mt-4 flex flex-col gap-3">
{/* Time picker */}
<div className="flex items-center gap-3 rounded-clay-sm bg-clay-bg p-3 shadow-clay-pressed">
<span className="font-nunito text-sm font-semibold text-clay-text-muted flex-1">
{t("reminder.daily.timeLabel")}
</span>
<input
type="time"
value={time}
disabled={isSaving}
onChange={(e) => handleTimeChange(e.target.value)}
className="rounded-clay-sm border border-transparent bg-clay-surface px-3 py-1.5 font-nunito text-sm font-bold text-clay-text shadow-clay-pressed transition-all duration-200 ease-in-out focus:border-clay-primary focus:outline-none focus:ring-2 focus:ring-clay-primary/20 disabled:opacity-50"
aria-label={t("reminder.daily.timeLabel")}
/>
</div>
</div>
)}
{/* ── Next trigger info ───────────────────────────────── */}
{isActive && reminder?.nextTriggerAt && (
<p className="clay-caption mt-2 text-center">
{t("reminder.daily.nextTrigger")}{" "}
<span className="font-bold text-clay-text">
{new Intl.DateTimeFormat("vi-VN", {
timeZone: "Asia/Ho_Chi_Minh",
weekday: "short",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(reminder.nextTriggerAt))}
</span>
</p>
)}
</Card>
);
};
This diff is collapsed.
......@@ -6,6 +6,7 @@ import { Card } from "@/components/ui/Card";
import { ClockIcon, DownloadIcon, LockIcon } from "@/components/ui/icons";
import { useI18n } from "@/i18n";
import { getErrorMessage } from "@/lib/error-message";
import { downloadStatementFile, getStatementFileName } from "@/lib/file-download";
import { statementService } from "@/services/statement.service";
import { StatementJobItem } from "@/types/statement";
......@@ -13,9 +14,14 @@ interface StatementJobCardProps {
job: StatementJobItem;
walletName?: string;
onRefresh?: () => void;
isHighlighted?: boolean;
}
export const StatementJobCard: React.FC<StatementJobCardProps> = ({ job, walletName }) => {
export const StatementJobCard: React.FC<StatementJobCardProps> = ({
job,
walletName,
isHighlighted = false,
}) => {
const { formatDate, formatNumber, t } = useI18n();
const { openSnackbar } = useSnackbar();
const [isDownloading, setIsDownloading] = useState(false);
......@@ -25,29 +31,36 @@ export const StatementJobCard: React.FC<StatementJobCardProps> = ({ job, walletN
setIsDownloading(true);
try {
if (job.downloadUrl && job.downloadUrl.startsWith("http")) {
window.open(job.downloadUrl, "_blank");
const filename = getStatementFileName(job.jobId, job.format);
const directUrl = statementService.getDirectDownloadUrl(job.jobId, job.downloadUrl);
// viewerUrl: proxy URL with filename+extension in path and inline=true for Zalo openDocument (PDF)
const viewerUrl = statementService.getDocumentViewerUrl(job.jobId, filename);
// downloadUrl: proxy URL with filename in path and attachment disposition (no inline) for downloadFile
const downloadUrl = statementService.getProxyDownloadUrl(job.jobId, filename);
// Download binary blob with auth token via backend proxy (no CORS issues)
const blob = await statementService.downloadStatement(job.jobId);
const result = await downloadStatementFile({
blob,
filename,
format: job.format,
directUrl,
viewerUrl,
downloadUrl,
});
if (result.success && result.message) {
openSnackbar({
type: "success",
text: t("statement.downloadSuccess") || "Đang mở tệp sao kê để tải xuống",
text: result.message,
});
} else if (!result.success && !result.aborted) {
openSnackbar({
type: "error",
text: result.message || t("statement.downloadFailed") || "Tải sao kê thất bại",
});
return;
}
const blob = await statementService.downloadStatement(job.jobId, job.downloadUrl);
const url = window.URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `finwise-statement-${job.jobId.slice(0, 8)}.${job.format.toLowerCase()}`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.URL.revokeObjectURL(url);
openSnackbar({
type: "success",
text: t("statement.downloadSuccess") || "Đã tải sao kê thành công",
});
} catch (err) {
openSnackbar({
type: "error",
......@@ -99,11 +112,23 @@ export const StatementJobCard: React.FC<StatementJobCardProps> = ({ job, walletN
};
return (
<Card hoverable className="flex flex-col gap-4 p-5 transition-all duration-200">
<Card
hoverable
className={`flex flex-col gap-4 p-5 transition-all duration-200 ${
isHighlighted
? "!border-clay-primary/70 ring-2 ring-clay-primary/60 !bg-clay-primary/5 shadow-clay-raised"
: ""
}`}
>
{/* Top row: Format & Security info + Status */}
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
{getFormatBadge(job.format)}
{isHighlighted && (
<span className="inline-flex items-center gap-1 rounded-full bg-clay-primary px-2.5 py-0.5 font-nunito text-[11px] font-bold text-clay-on-primary shadow-clay-raised">
{t("statement.selectedJob") || "Sao kê từ thông báo"}
</span>
)}
{job.isPasswordProtected && (
<span
className="inline-flex items-center gap-1.5 rounded-full bg-clay-bg px-2.5 py-1 font-nunito text-xs font-bold text-clay-text-muted shadow-clay-pressed"
......@@ -154,22 +179,26 @@ export const StatementJobCard: React.FC<StatementJobCardProps> = ({ job, walletN
{/* Bottom Footer Action & Timestamps */}
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-clay-text-muted/10 pt-3">
<span className="font-nunito text-xs text-clay-text-muted">
{formatDate(job.createdAt, {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</span>
{job.status === "COMPLETED" && (
<div className="flex items-center gap-3">
<span className="hidden sm:inline-flex items-center gap-1 font-nunito text-[11px] text-clay-text-muted">
<ClockIcon size={14} className="text-clay-warning" />
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2.5 text-clay-text-muted">
<span className="font-nunito text-xs">
{formatDate(job.createdAt, {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</span>
{job.status === "COMPLETED" && (
<span className="inline-flex items-center gap-1 font-nunito text-[11px] text-clay-text-muted">
<ClockIcon size={13} className="text-clay-warning shrink-0" />
{t("statement.expiresIn48h")}
</span>
)}
</div>
{job.status === "COMPLETED" && (
<div className="ml-auto flex items-center justify-end">
<Button
variant="primary"
shape="pill"
......@@ -178,7 +207,7 @@ export const StatementJobCard: React.FC<StatementJobCardProps> = ({ job, walletN
disabled={isDownloading}
>
<DownloadIcon size={15} />
{isDownloading ? t("common.loading") : t("statement.downloadFile")}
{isDownloading ? t("common.loading") : (t("statement.downloadFile") || "Tải xuống")}
</Button>
</div>
)}
......
import React, { useMemo, useState } from "react";
import { Header, Page, useNavigate } from "zmp-ui";
import { Header, Page, useLocation, useNavigate, useParams } from "zmp-ui";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { IconGradients, PlusIcon, ReportIcon } from "@/components/ui/icons";
import { useStatementHistory } from "@/hooks/use-statements";
import { useStatementHistory, useStatementJob } from "@/hooks/use-statements";
import { useWallets } from "@/hooks/use-wallets";
import { useI18n } from "@/i18n";
import { getErrorMessage } from "@/lib/error-message";
......@@ -15,10 +15,17 @@ const PAGE_SIZE = 6;
export const StatementHistoryPage: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const params = useParams<{ id?: string }>();
const { t } = useI18n();
const [isModalOpen, setIsModalOpen] = useState(false);
const [page, setPage] = useState(1);
const targetJobId = params.id || (location.state as any)?.statementJobId;
const targetJobQuery = useStatementJob(targetJobId || "", {
enabled: Boolean(targetJobId),
});
const historyQuery = useStatementHistory(page, PAGE_SIZE);
const walletsQuery = useWallets({
includeArchived: true,
......@@ -40,17 +47,33 @@ export const StatementHistoryPage: React.FC = () => {
const totalItems = historyQuery.data?.meta.total || 0;
const totalPages = historyQuery.data?.meta.totalPages || 0;
const isTargetInList = useMemo(
() => Boolean(targetJobId && items.some((job) => job.jobId === targetJobId)),
[targetJobId, items],
);
const activeJobsCount = useMemo(
() => items.filter((job) => job.status === "PENDING" || job.status === "PROCESSING").length,
[items],
);
const handleBack = () => {
if ((location.state as any)?.fromNotifications) {
navigate("/notifications");
} else if (params.id) {
navigate("/statements");
} else {
navigate("/");
}
};
return (
<Page className="page">
<Header
title={t("statement.pageTitle") || "Xuất sao kê giao dịch"}
showBackIcon
onBackClick={() => navigate("/")}
onBackClick={handleBack}
/>
<IconGradients />
......@@ -109,6 +132,27 @@ export const StatementHistoryPage: React.FC = () => {
</div>
</Card>
{/* Target Job from Notification (if not in current page list) */}
{targetJobId && targetJobQuery.data && !isTargetInList && (
<div className="flex flex-col gap-2 rounded-clay p-3 bg-clay-primary/5 border border-clay-primary/40 shadow-clay-pressed">
<div className="flex items-center gap-2 px-1">
<span className="h-2 w-2 rounded-full bg-clay-primary animate-ping" />
<h3 className="font-baloo text-xs font-bold text-clay-primary uppercase tracking-wide">
{t("statement.notificationTargetTitle") || "Sao kê được chọn từ thông báo"}
</h3>
</div>
<StatementJobCard
job={targetJobQuery.data}
walletName={targetJobQuery.data.walletId ? walletMap.get(targetJobQuery.data.walletId) : undefined}
isHighlighted={true}
onRefresh={() => {
targetJobQuery.refetch();
historyQuery.refetch();
}}
/>
</div>
)}
{/* History List Section */}
<section aria-labelledby="statement-list-heading" className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3 px-1">
......@@ -157,7 +201,7 @@ export const StatementHistoryPage: React.FC = () => {
)}
{/* Empty State */}
{!historyQuery.isLoading && !historyQuery.isError && items.length === 0 && (
{!historyQuery.isLoading && !historyQuery.isError && items.length === 0 && !targetJobQuery.data && (
<Card className="flex flex-col items-center gap-4 py-9 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-clay-lg bg-clay-info/20 shadow-clay-pressed">
<ReportIcon size={34} />
......@@ -186,6 +230,7 @@ export const StatementHistoryPage: React.FC = () => {
key={job.jobId}
job={job}
walletName={job.walletId ? walletMap.get(job.walletId) : undefined}
isHighlighted={job.jobId === targetJobId}
onRefresh={() => historyQuery.refetch()}
/>
))}
......
import { apiClient } from "@/lib/api-client";
import {
CreateReminderInput,
DailyTransactionReminderResponse,
ReminderDeleteResponse,
ReminderListResponse,
ReminderQuery,
ReminderResponse,
UpdateDailyTransactionReminderInput,
UpdateReminderInput,
} from "@/types/reminder";
......@@ -30,5 +32,16 @@ export const reminderService = {
const response = await apiClient.delete<ReminderDeleteResponse>(`/reminders/${id}`);
return ensureSuccess(response.data);
},
// ─── Daily Transaction Reminder ─────────────────────────────────────────
async getDailyTransactionReminder(): Promise<DailyTransactionReminderResponse> {
const response = await apiClient.get<DailyTransactionReminderResponse>("/reminders/daily-transaction");
return ensureSuccess(response.data);
},
async updateDailyTransactionReminder(input: UpdateDailyTransactionReminderInput): Promise<DailyTransactionReminderResponse> {
const response = await apiClient.put<DailyTransactionReminderResponse>("/reminders/daily-transaction", input);
return ensureSuccess(response.data);
},
};
import { apiClient } from "@/lib/api-client";
import { apiClient, API_BASE_URL } from "@/lib/api-client";
import { safeStorage } from "@/lib/storage";
import { useAuthStore } from "@/stores/auth-store";
import {
CreateStatementExportDto,
StatementHistoryResponse,
......@@ -36,15 +38,54 @@ export const statementService = {
return response.data.data;
},
async downloadStatement(jobId: string, downloadUrl?: string | null): Promise<Blob> {
const targetUrl =
downloadUrl && downloadUrl.startsWith("http")
? downloadUrl
: `/statements/jobs/${jobId}/download`;
getDirectDownloadUrl(jobId: string, downloadUrl?: string | null): string {
if (downloadUrl && downloadUrl.startsWith("http")) {
return downloadUrl;
}
const token = useAuthStore.getState().accessToken || safeStorage.getItem("accessToken");
const query = token ? `?proxy=true&token=${encodeURIComponent(token)}` : "?proxy=true";
return `${API_BASE_URL}/statements/jobs/${jobId}/download${query}`;
},
const response = await apiClient.get<Blob>(targetUrl, {
responseType: "blob",
});
getBackendProxyDownloadUrl(jobId: string): string {
const token = useAuthStore.getState().accessToken || safeStorage.getItem("accessToken");
const query = token ? `?proxy=true&token=${encodeURIComponent(token)}` : "?proxy=true";
return `${API_BASE_URL}/statements/jobs/${jobId}/download${query}`;
},
/**
* Returns a proxy URL with the filename (and extension) in the path.
* This is needed for Zalo openDocument to detect the file type from the URL extension.
* Example: /api/v1/statements/jobs/:id/download/finwise-statement-xxxx.pdf?proxy=true&inline=true&token=...
*/
getDocumentViewerUrl(jobId: string, filename: string): string {
const token = useAuthStore.getState().accessToken || safeStorage.getItem("accessToken");
const query = token
? `?proxy=true&inline=true&token=${encodeURIComponent(token)}`
: "?proxy=true&inline=true";
return `${API_BASE_URL}/statements/jobs/${jobId}/download/${encodeURIComponent(filename)}${query}`;
},
/**
* Returns a proxy URL with filename (and extension) in path, WITHOUT inline=true.
* This ensures Content-Disposition: attachment for Zalo downloadFile (CSV, XLSX, PDF).
*/
getProxyDownloadUrl(jobId: string, filename: string): string {
const token = useAuthStore.getState().accessToken || safeStorage.getItem("accessToken");
const query = token
? `?proxy=true&token=${encodeURIComponent(token)}`
: "?proxy=true";
return `${API_BASE_URL}/statements/jobs/${jobId}/download/${encodeURIComponent(filename)}${query}`;
},
async downloadStatement(jobId: string): Promise<Blob> {
const response = await apiClient.get<Blob>(
`/statements/jobs/${jobId}/download?proxy=true`,
{
responseType: "blob",
},
);
return response.data;
},
};
......@@ -65,3 +65,28 @@ export interface ReminderDeleteResponse {
errors?: unknown[] | null;
}
// ─── Daily Transaction Reminder ──────────────────────────────────────────────
export interface DailyTransactionReminder {
id: string | null;
isActive: boolean;
time: string; // HH:mm format, e.g. "20:00"
title: string;
message: string;
nextTriggerAt: string | null;
hasCreatedTransactionToday: boolean;
}
export interface UpdateDailyTransactionReminderInput {
isActive: boolean;
time?: string; // HH:mm format
}
export interface DailyTransactionReminderResponse {
success: boolean;
message?: string;
data: DailyTransactionReminder;
errors?: unknown[] | null;
}
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