Commit 187a6849 authored by ThinhNC's avatar ThinhNC

fix(statement): resolve Zalo file download for XLSX and CSV, streamline UI and notification linking

- Fix CSV file download not persisting to device storage by separating attachment download URL from inline preview URL
- Support Zalo native openDocument preview for PDF and direct downloadFile saving for XLSX and CSV
- Remove redundant share button and align expiration badge with download button
- Enable deep linking and statement job highlighting from notification clicks
parent 0b02012c
......@@ -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>
);
......@@ -2339,6 +2339,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."
},
......
......@@ -2440,6 +2440,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."
},
......
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;
......@@ -94,6 +94,7 @@ const NotificationsPage: React.FC = () => {
if (/^https:\/\//i.test(item.actionUrl)) window.open(item.actionUrl, "_blank", "noopener,noreferrer");
else {
const transactionPathMatch = item.actionUrl.match(/^\/transactions\/([^/?#]+)/);
const statementJobMatch = item.actionUrl.match(/^\/statements\/jobs\/([^/?#]+)/);
const recurringIdMatch = item.actionUrl.match(/[?&]id=([^/?#&]+)/);
const dataTransactionId = item.data?.transactionId;
......@@ -108,6 +109,11 @@ const NotificationsPage: React.FC = () => {
? dataScheduleId
: recurringIdMatch?.[1];
const dataJobId = (item.data as any)?.statementJobId || (item.data as any)?.jobId;
const statementJobId = typeof dataJobId === "string"
? dataJobId
: statementJobMatch?.[1] || (item.sourceType === "SYSTEM" ? item.sourceId : undefined);
navigate(
transactionPathMatch ? "/transactions" : item.actionUrl,
{
......@@ -115,6 +121,7 @@ const NotificationsPage: React.FC = () => {
fromNotifications: true,
...(transactionId ? { transactionId } : {}),
...(scheduleId ? { scheduleId } : {}),
...(statementJobId ? { statementJobId } : {}),
},
},
);
......
......@@ -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 { 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;
},
};
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