import { useQueryClient } from "@tanstack/react-query"; import { useEffect, useState } from "react"; import { useParams, useSearchParams } from "react-router"; import { z } from "zod"; import { type BrokerageOperation, brokerageOperationsQueryOptions, operationCategories, type OperationCategory, useBrokerageOperations, } from "@/entities/broker-operation"; import { useScreenSync } from "@/features/tbank-connection"; import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; type AccountOperationsPageViewModel = | { canApply: boolean; draftCategories: OperationCategory[]; draftFrom: null | string; draftTo: null | string; hasNext: boolean; hasPrevious: boolean; historyLoading?: boolean; items: BrokerageOperation[]; lastSuccessfulAt?: null | string; onApply: () => void; onDraftCategoriesChange: (value: OperationCategory[]) => void; onDraftFromChange: (value: null | string) => void; onDraftToChange: (value: null | string) => void; onNext: () => void; onPrevious: () => void; onReset: () => void; partial?: boolean; refreshError?: boolean; refreshing?: boolean; retry?: () => void; sourceError?: boolean; state: "loaded"; } | { onReset: () => void; retry: () => void; state: "error" } | { state: "loading" }; const useAccountOperationsPageVm = ( scopeId?: string, ): AccountOperationsPageViewModel => { const { accountId: routeAccountId } = useParams(); const accountId = scopeId ?? routeAccountId; const queryClient = useQueryClient(); const [searchParameters, setSearchParameters] = useSearchParams(); const urlFrom = searchParameters.get("from"); const urlTo = searchParameters.get("to"); const isValidUrlPeriod = isValidPeriod(urlFrom, urlTo); const appliedFrom = isValidUrlPeriod ? urlFrom : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null; const appliedTo = isValidUrlPeriod ? urlTo : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null; const appliedCategories = operationCategories.filter(category => (searchParameters.get("categories") ?? "").split(",").includes(category), ); const [draftFrom, setDraftFrom] = useState(appliedFrom); const [draftTo, setDraftTo] = useState(appliedTo); const [draftCategories, setDraftCategories] = useState(appliedCategories); const scopeKey = JSON.stringify([ accountId, urlFrom, urlTo, appliedCategories, ]); const [pagination, setPagination] = useState<{ cursors: string[]; scopeKey: string; }>({ cursors: [], scopeKey }); const cursors = pagination.scopeKey === scopeKey ? pagination.cursors : []; const cursor = cursors.at(-1); if (pagination.scopeKey !== scopeKey) { setPagination({ cursors: [], scopeKey }); } useEffect(() => { if (isValidUrlPeriod) return; const normalized = new URLSearchParams(searchParameters); normalized.delete("from"); normalized.delete("to"); setSearchParameters(normalized, { replace: true }); }, [isValidUrlPeriod, searchParameters, setSearchParameters]); const [previousSearch, setPreviousSearch] = useState(searchParameters); if (previousSearch !== searchParameters) { setPreviousSearch(searchParameters); setDraftFrom(appliedFrom); setDraftTo(appliedTo); setDraftCategories( operationCategories.filter(category => (searchParameters.get("categories") ?? "").split(",").includes(category), ), ); } const firstPageQuery = { from: dateStart(appliedFrom), to: dateEnd(appliedTo), ...((appliedCategories.length > 0) && { categories: appliedCategories }), limit: 100, }; const operations = useBrokerageOperations(accountId, { ...firstPageQuery, ...(cursor && { cursor }), }); const sync = useScreenSync( { accountId, screen: "operations", ...firstPageQuery }, [operations], ); if (operations.isLoading) return { state: "loading" }; const resetQueryKey = brokerageOperationsQueryOptions(accountId, { limit: 100, }).queryKey; const resetCursors = () => setPagination({ cursors: [], scopeKey }); const retry = () => { if (isApiErrorCode(apiErrorCode(operations.error), ApiErrorCode.InvalidOperationsCursor)) { void queryClient.invalidateQueries({ exact: true, queryKey: brokerageOperationsQueryOptions(accountId, firstPageQuery) .queryKey, }); resetCursors(); } else void sync.retry(); }; const onReset = () => { setDraftFrom(null); setDraftTo(null); setDraftCategories([]); setSearchParameters({}); resetCursors(); void queryClient.invalidateQueries({ queryKey: resetQueryKey }); }; if ( (operations.isError || operations.meta?.sync.state === "failed") && (!operations.data || isApiErrorCode(apiErrorCode(operations.error), ApiErrorCode.InvalidOperationsCursor)) ) return { onReset, retry, state: "error" }; if (!operations.data) return { state: "loading" }; const nextCursor = operations.data.nextCursor; const isDirty = draftFrom !== appliedFrom || draftTo !== appliedTo || !isSameCategories(draftCategories, appliedCategories); return { canApply: isDirty && isValidPeriod(draftFrom, draftTo), draftCategories, draftFrom, draftTo, hasNext: Boolean(nextCursor && operations.data.hasNext), hasPrevious: cursors.length > 0, historyLoading: operations.meta?.coverage?.historyState === "loading" || operations.meta?.coverage?.historyState === "partial", items: operations.data.items, lastSuccessfulAt: operations.meta?.lastSuccessfulAt, onApply: () => { if (!isValidPeriod(draftFrom, draftTo)) return; setSearchParameters({ ...(draftFrom && { from: draftFrom }), ...(draftTo && { to: draftTo }), ...((draftCategories.length > 0) && { categories: draftCategories.join(",") }), }); resetCursors(); }, onDraftCategoriesChange: setDraftCategories, onDraftFromChange: setDraftFrom, onDraftToChange: setDraftTo, onNext: () => { if (nextCursor) setPagination({ cursors: [...cursors, nextCursor], scopeKey }); }, onPrevious: () => setPagination({ cursors: cursors.slice(0, -1), scopeKey }), onReset, partial: operations.meta?.availability !== "complete", refreshError: operations.isError || sync.sourceError || sync.transportError, refreshing: operations.isFetching || sync.refreshing, retry, sourceError: sync.sourceError, state: "loaded", }; }; const calendarDate = z.iso.date(); function dateEnd(value: null | string) { return value ? `${value}T23:59:59.999Z` : undefined; } function dateStart(value: null | string) { return value ? `${value}T00:00:00.000Z` : undefined; } function isSameCategories(a: OperationCategory[], b: OperationCategory[]) { return a.length === b.length && a.every(value => b.includes(value)); } function isValidPeriod(from: null | string, to: null | string) { return ( [from, to].every( date => date === null || calendarDate.safeParse(date).success, ) && (!from || !to || from <= to) ); } export { type AccountOperationsPageViewModel, useAccountOperationsPageVm };