Frontend security / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 4m57s
Conventional commits / commits (pull_request) Successful in 28s
Frontend security / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 5m10s
Repair investment recovery, draft preservation, date validation, currency presentation, accessibility, and route error recovery. Correct Docker and dependency checks and consume explicit income roles.
214 lines
6.6 KiB
TypeScript
214 lines
6.6 KiB
TypeScript
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,
|
|
type OperationCategory,
|
|
operationCategories,
|
|
useBrokerageOperations,
|
|
} from "@/entities/broker-operation";
|
|
|
|
import { useScreenSync } from "@/features/tbank-connection";
|
|
import { apiErrorCode } from "@/shared/api";
|
|
|
|
type AccountOperationsPageViewModel =
|
|
| { state: "loading" }
|
|
| { state: "error"; retry: () => void; onReset: () => void }
|
|
| {
|
|
state: "loaded";
|
|
sourceError?: boolean;
|
|
lastSuccessfulAt?: string | null;
|
|
partial?: boolean;
|
|
historyLoading?: boolean;
|
|
refreshing?: boolean;
|
|
refreshError?: boolean;
|
|
retry?: () => void;
|
|
draftFrom: string | null;
|
|
draftTo: string | null;
|
|
draftCategories: OperationCategory[];
|
|
items: BrokerageOperation[];
|
|
hasNext: boolean;
|
|
hasPrevious: boolean;
|
|
canApply: boolean;
|
|
onDraftFromChange: (value: string | null) => void;
|
|
onDraftToChange: (value: string | null) => void;
|
|
onDraftCategoriesChange: (value: OperationCategory[]) => void;
|
|
onApply: () => void;
|
|
onReset: () => void;
|
|
onNext: () => void;
|
|
onPrevious: () => void;
|
|
};
|
|
|
|
const useAccountOperationsPageVm = (
|
|
scopeId?: string,
|
|
): AccountOperationsPageViewModel => {
|
|
const { accountId: routeAccountId } = useParams();
|
|
const accountId = scopeId ?? routeAccountId;
|
|
const queryClient = useQueryClient();
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const urlFrom = searchParams.get("from");
|
|
const urlTo = searchParams.get("to");
|
|
const validUrlPeriod = isValidPeriod(urlFrom, urlTo);
|
|
const appliedFrom = validUrlPeriod ? urlFrom : null;
|
|
const appliedTo = validUrlPeriod ? urlTo : null;
|
|
const appliedCategories = operationCategories.filter((category) =>
|
|
(searchParams.get("categories") ?? "").split(",").includes(category),
|
|
);
|
|
const [draftFrom, setDraftFrom] = useState<string | null>(appliedFrom);
|
|
const [draftTo, setDraftTo] = useState<string | null>(appliedTo);
|
|
const [draftCategories, setDraftCategories] =
|
|
useState<OperationCategory[]>(appliedCategories);
|
|
const scopeKey = JSON.stringify([
|
|
accountId,
|
|
urlFrom,
|
|
urlTo,
|
|
appliedCategories,
|
|
]);
|
|
const [pagination, setPagination] = useState<{
|
|
scopeKey: string;
|
|
cursors: string[];
|
|
}>({ scopeKey, cursors: [] });
|
|
const cursors = pagination.scopeKey === scopeKey ? pagination.cursors : [];
|
|
const cursor = cursors.at(-1);
|
|
useEffect(() => {
|
|
setPagination((current) =>
|
|
current.scopeKey === scopeKey ? current : { scopeKey, cursors: [] },
|
|
);
|
|
}, [scopeKey]);
|
|
useEffect(() => {
|
|
if (validUrlPeriod) return;
|
|
const normalized = new URLSearchParams(searchParams);
|
|
normalized.delete("from");
|
|
normalized.delete("to");
|
|
setSearchParams(normalized, { replace: true });
|
|
}, [validUrlPeriod, searchParams, setSearchParams]);
|
|
useEffect(() => {
|
|
setDraftFrom(appliedFrom);
|
|
setDraftTo(appliedTo);
|
|
setDraftCategories(
|
|
operationCategories.filter((category) =>
|
|
(searchParams.get("categories") ?? "").split(",").includes(category),
|
|
),
|
|
);
|
|
}, [appliedFrom, appliedTo, searchParams]);
|
|
const firstPageQuery = {
|
|
from: dateStart(appliedFrom),
|
|
to: dateEnd(appliedTo),
|
|
...(appliedCategories.length ? { categories: appliedCategories } : {}),
|
|
limit: 100,
|
|
};
|
|
const operations = useBrokerageOperations(accountId, {
|
|
...firstPageQuery,
|
|
...(cursor ? { cursor } : {}),
|
|
});
|
|
const sync = useScreenSync(
|
|
{ screen: "operations", accountId, ...firstPageQuery },
|
|
[operations],
|
|
);
|
|
const resetQueryKey = brokerageOperationsQueryOptions(accountId, {
|
|
limit: 100,
|
|
}).queryKey;
|
|
|
|
const resetCursors = () => setPagination({ scopeKey, cursors: [] });
|
|
const retry = () => {
|
|
if (apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR") {
|
|
void queryClient.invalidateQueries({
|
|
queryKey: brokerageOperationsQueryOptions(accountId, firstPageQuery)
|
|
.queryKey,
|
|
exact: true,
|
|
});
|
|
resetCursors();
|
|
} else void sync.retry();
|
|
};
|
|
const onReset = () => {
|
|
setDraftFrom(null);
|
|
setDraftTo(null);
|
|
setDraftCategories([]);
|
|
setSearchParams({});
|
|
resetCursors();
|
|
void queryClient.invalidateQueries({ queryKey: resetQueryKey });
|
|
};
|
|
|
|
if (operations.isLoading) return { state: "loading" };
|
|
if (
|
|
(operations.isError || operations.meta?.sync.state === "failed") &&
|
|
(!operations.data ||
|
|
apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR")
|
|
)
|
|
return { state: "error", retry, onReset };
|
|
if (!operations.data) return { state: "loading" };
|
|
const nextCursor = operations.data.nextCursor;
|
|
|
|
const isDirty =
|
|
draftFrom !== appliedFrom ||
|
|
draftTo !== appliedTo ||
|
|
!sameCategories(draftCategories, appliedCategories);
|
|
|
|
return {
|
|
state: "loaded",
|
|
sourceError: sync.sourceError,
|
|
lastSuccessfulAt: operations.meta?.lastSuccessfulAt,
|
|
partial: operations.meta?.availability !== "complete",
|
|
historyLoading:
|
|
operations.meta?.coverage?.historyState === "loading" ||
|
|
operations.meta?.coverage?.historyState === "partial",
|
|
refreshing: operations.isFetching || sync.refreshing,
|
|
refreshError: operations.isError || sync.sourceError || sync.transportError,
|
|
retry,
|
|
draftFrom,
|
|
draftTo,
|
|
draftCategories,
|
|
items: operations.data.items,
|
|
hasNext: Boolean(operations.data.hasNext && nextCursor),
|
|
hasPrevious: Boolean(cursors.length),
|
|
canApply: isDirty && isValidPeriod(draftFrom, draftTo),
|
|
onDraftFromChange: setDraftFrom,
|
|
onDraftToChange: setDraftTo,
|
|
onDraftCategoriesChange: setDraftCategories,
|
|
onApply: () => {
|
|
if (!isValidPeriod(draftFrom, draftTo)) return;
|
|
setSearchParams({
|
|
...(draftFrom ? { from: draftFrom } : {}),
|
|
...(draftTo ? { to: draftTo } : {}),
|
|
...(draftCategories.length
|
|
? { categories: draftCategories.join(",") }
|
|
: {}),
|
|
});
|
|
resetCursors();
|
|
},
|
|
onReset,
|
|
onNext: () => {
|
|
if (nextCursor)
|
|
setPagination({ scopeKey, cursors: [...cursors, nextCursor] });
|
|
},
|
|
onPrevious: () =>
|
|
setPagination({ scopeKey, cursors: cursors.slice(0, -1) }),
|
|
};
|
|
};
|
|
|
|
const calendarDate = z.string().date();
|
|
function isValidPeriod(from: string | null, to: string | null) {
|
|
return (
|
|
[from, to].every(
|
|
(date) => date === null || calendarDate.safeParse(date).success,
|
|
) &&
|
|
(!from || !to || from <= to)
|
|
);
|
|
}
|
|
|
|
function dateStart(value: string | null) {
|
|
return value ? `${value}T00:00:00.000Z` : undefined;
|
|
}
|
|
|
|
function dateEnd(value: string | null) {
|
|
return value ? `${value}T23:59:59.999Z` : undefined;
|
|
}
|
|
|
|
function sameCategories(a: OperationCategory[], b: OperationCategory[]) {
|
|
return a.length === b.length && a.every((value) => b.includes(value));
|
|
}
|
|
|
|
export { type AccountOperationsPageViewModel, useAccountOperationsPageVm };
|