Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 | 539x 539x 539x 356x 356x 356x 3x 3x 3x 3x 539x 356x 356x 356x 3x 3x 356x 73x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 356x 539x 356x 356x 356x 64x 64x 64x 356x 356x 356x 356x 70x 70x 356x 70x 3x 67x 356x 356x 356x 70x 3x 3x 356x 3x 3x 3x 3x 356x 356x | import { useMutation, useQuery, useQueryClient } from "react-query";
import {
initiateTransactionExport,
getBackgroundTaskStatus,
} from "@services/api/transactions";
import type {
TransactionExportTask,
BackgroundTaskStatus,
} from "@customTypes/backgroundTasks";
import { useGetCurrentMerchantId } from "@hooks/common";
import { showMessage } from "@common/Toast";
import { useEffect, useRef, useState } from "react";
import {
activeExports,
cleanupAllAsyncExports,
handleExportDownload,
startGlobalPolling,
} from "@hooks/exports/asyncExportCore";
import { getTaskErrorMessage } from "@hooks/exports/asyncExport.helpers";
const QUERY_KEY_PREFIX = "transaction-export-task";
/**
* Export cleanup function for use in logout or account switching.
* Re-exported here for backward compatibility with existing import sites.
*/
export const cleanupAsyncExports = cleanupAllAsyncExports;
/**
* Hook to initiate an async transaction export
*/
export const useInitiateTransactionExport = () => {
const { merchantId } = useGetCurrentMerchantId();
const queryClient = useQueryClient();
return useMutation(
async ({ filters, sorting }: { filters?: string; sorting?: string }) => {
Iif (!merchantId) throw new Error("Merchant ID is required");
return await initiateTransactionExport(merchantId, filters, sorting);
},
{
onSuccess: () => {
// Invalidate any existing export tasks queries
queryClient.invalidateQueries(["transaction-export-task"]);
showMessage(
"Info",
"Your export is being prepared. This may take a few minutes.",
true,
"Export Started",
3000,
);
},
onError: (error: Error) => {
showMessage(
"Error",
error?.message || "Failed to initiate export. Please try again.",
true,
"Export Failed",
);
},
},
);
};
/**
* Hook to poll the status of a transaction export task
* Uses global polling that continues regardless of component lifecycle
*/
export const useTransactionExportTaskStatus = (
taskId: number | null,
exportType = "default",
) => {
const { merchantId } = useGetCurrentMerchantId();
const queryClient = useQueryClient();
const query = useQuery<TransactionExportTask>(
[QUERY_KEY_PREFIX, merchantId, taskId],
async (): Promise<TransactionExportTask> => {
Iif (!merchantId || !taskId) {
throw new Error("Merchant ID and Task ID are required");
}
return (await getBackgroundTaskStatus(
merchantId,
taskId,
)) as TransactionExportTask;
},
{
enabled: Boolean(merchantId && taskId),
refetchOnWindowFocus: true,
staleTime: 0,
cacheTime: Infinity,
},
);
// Start global polling when task is active, or handle immediate completion
useEffect(() => {
if (!merchantId || !taskId || !query.data) return;
const storageKey = `transaction-export-task-${merchantId}-${exportType}`;
const taskData = query.data;
// If task is running, start global polling
Iif (taskData.status === "running") {
startGlobalPolling(
merchantId,
taskId,
queryClient,
storageKey,
QUERY_KEY_PREFIX,
);
}
// If task completed before polling started (fast export), handle it immediately.
//
// activeExports normally gets populated by startGlobalPolling when status is
// "running". When the BE worker is faster than our polling interval and the
// first /background-tasks/:id poll already returns "completed",
// startGlobalPolling never runs for this task, so activeExports has no entry
// and the previous `if (existingExport && !existingExport.downloadAttempted)`
// guard silently rejected the case the comment said it was handling — the
// user saw the "Export started" toast and nothing else.
//
// Register an entry on first observation so the downloadAttempted flag still
// dedupes a re-entrant render of the same completed task, then trigger the
// download and clean up.
Eif (taskData.status === "completed") {
const key = `${merchantId}-${taskId}`;
let entry = activeExports.get(key);
Eif (!entry) {
entry = {
merchantId,
taskId,
downloadAttempted: false,
storageKey,
queryKeyPrefix: QUERY_KEY_PREFIX,
};
activeExports.set(key, entry);
}
// Flip the flag before downloading so a re-entrant render can't trigger a
// second download while handleExportDownload is awaiting.
Eif (!entry.downloadAttempted) {
entry.downloadAttempted = true;
handleExportDownload(taskData, merchantId);
// Clean up — the task is done as far as the FE is concerned.
activeExports.delete(key);
sessionStorage.removeItem(storageKey);
}
}
// Same shape as the completed branch: register-then-act so a fast-failing
// task (status "failed" on the first poll) still surfaces the error toast.
Iif (taskData.status === "failed") {
const key = `${merchantId}-${taskId}`;
let entry = activeExports.get(key);
if (!entry) {
entry = {
merchantId,
taskId,
downloadAttempted: false,
storageKey,
queryKeyPrefix: QUERY_KEY_PREFIX,
};
activeExports.set(key, entry);
}
if (!entry.downloadAttempted) {
entry.downloadAttempted = true;
showMessage(
"Error",
getTaskErrorMessage(taskData.error),
true,
"Export Failed",
);
// Clean up
activeExports.delete(key);
sessionStorage.removeItem(storageKey);
}
}
// Cleanup on unmount - but don't stop polling, just let it continue globally
return () => {
// Note: We intentionally don't stop polling here
// The global polling will continue and stop automatically when task completes
};
}, [merchantId, taskId, query.data, queryClient]);
return query;
};
/**
* Hook to manage the full async export workflow
* Returns state and functions to handle export, polling, and download
*/
export const useAsyncTransactionExport = (
exportType = "default",
): {
startExport: (
filters?: string,
sorting?: string,
) => Promise<TransactionExportTask>;
cancelExport: () => void;
isExporting: boolean;
isInitiating: boolean;
isPolling: boolean;
taskData: TransactionExportTask | undefined;
activeTaskId: number | null;
progress: BackgroundTaskStatus | "processing" | undefined;
} => {
const { merchantId } = useGetCurrentMerchantId();
const storageKey = `transaction-export-task-${merchantId}-${exportType}`;
// SessionStorage persists the active task ID across page refreshes.
// The global polling uses an in-memory Map that gets cleared on refresh,
// so we need sessionStorage to restore the task ID and resume polling.
const getStoredTaskId = () => {
try {
const stored = sessionStorage.getItem(storageKey);
return stored ? Number(stored) : null;
} catch {
return null;
}
};
const [activeTaskId, setActiveTaskId] = useState<number | null>(
getStoredTaskId,
);
const downloadAttemptedRef = useRef(false);
const previousMerchantIdRef = useRef<number | undefined>(merchantId);
// Clear exports when merchant changes (account selector) or on unmount (logout)
useEffect(() => {
// Check if merchant changed
Iif (
previousMerchantIdRef.current !== undefined &&
previousMerchantIdRef.current !== merchantId
) {
// Merchant changed - clear all exports
cleanupAllAsyncExports();
setActiveTaskId(null);
downloadAttemptedRef.current = false;
}
// Update the ref
previousMerchantIdRef.current = merchantId;
}, [merchantId, activeTaskId]);
// Persist task ID to storage whenever it changes
useEffect(() => {
if (activeTaskId) {
sessionStorage.setItem(storageKey, String(activeTaskId));
} else {
sessionStorage.removeItem(storageKey);
}
}, [activeTaskId, storageKey]);
const initiateMutation = useInitiateTransactionExport();
const { data: taskData, isLoading: isPolling } =
useTransactionExportTaskStatus(activeTaskId, exportType);
// Sync local state with global polling results
useEffect(() => {
if (
taskData?.status === "completed" ||
taskData?.status === "failed" ||
taskData?.status === "cancelled"
) {
// Global polling already handled download/notifications
// Just clean up local state
setActiveTaskId(null);
downloadAttemptedRef.current = false;
}
}, [taskData]);
const startExport = async (filters?: string, sorting?: string) => {
downloadAttemptedRef.current = false;
const result = await initiateMutation.mutateAsync({ filters, sorting });
setActiveTaskId(result.id);
return result;
};
const cancelExport = () => {
setActiveTaskId(null);
downloadAttemptedRef.current = false;
};
return {
startExport,
cancelExport,
isExporting:
initiateMutation.isLoading ||
(Boolean(activeTaskId) &&
taskData?.status !== "completed" &&
taskData?.status !== "failed" &&
taskData?.status !== "cancelled"),
isInitiating: initiateMutation.isLoading,
isPolling,
taskData,
activeTaskId,
progress: taskData?.status === "running" ? "processing" : taskData?.status,
};
};
|