Files
frontend/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts
T
ksv741 3c226f8c29
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 12s
Frontend verify / verify (push) Successful in 7m41s
feat: render hourly investment states
2026-10-09 21:13:34 +03:00

202 lines
5.6 KiB
TypeScript

import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useParams } from "react-router";
import type { InvestmentResourceMeta } from "@/shared/api";
import {
type BrokerageOperation,
brokerageOperationsQueryOptions,
operationCategories,
type OperationCategory,
useBrokerageOperationHistory,
} from "@/entities/broker-operation";
import { useScreenSync } from "@/features/tbank-connection";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
import {
defaultPeriod,
isValidPeriod,
moscowDate,
useInitialAvailability,
useUrlPeriod,
} from "@/shared/lib";
const operationCategorySet = new Set<string>(operationCategories);
type AccountOperationsPageViewModel
= | {
canApply: boolean;
draftCategories: OperationCategory[];
draftPeriod: [null | string, null | string];
hasNext: boolean;
hasPrevious: boolean;
historyLoading?: boolean;
items: BrokerageOperation[];
maxDate: string;
onApply: () => void;
onCategoriesChange: (value: string[]) => void;
onNext: () => void;
onPeriodChange: (value: [null | string, null | string]) => void;
onPeriodPopoverOpen: () => void;
onPrevious: () => void;
onReset: () => void;
partial?: boolean;
source?: InvestmentResourceMeta;
state: "loaded";
}
| { onReset: () => void; state: "error" }
| { state: "loading" };
const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
const { accountId } = useParams();
const queryClient = useQueryClient();
const {
from: appliedFrom,
parameters: searchParameters,
setParameters: setSearchParameters,
to: appliedTo,
} = useUrlPeriod();
const appliedCategories = operationCategories.filter(category =>
(searchParameters.get("categories") ?? "").split(",").includes(category),
);
const [draftPeriod, setDraftPeriod] = useState<[
null | string,
null | string,
]>([appliedFrom, appliedTo]);
const [draftFrom, draftTo] = draftPeriod;
const [draftCategories, setDraftCategories]
= useState<OperationCategory[]>(appliedCategories);
const [maxDate, setMaxDate] = useState(() => moscowDate(new Date()));
const firstPageQuery = {
from: dateStart(appliedFrom),
to: dateEnd(appliedTo),
...(appliedCategories.length > 0 && { categories: appliedCategories }),
limit: 100,
};
const history = useBrokerageOperationHistory(accountId, firstPageQuery);
const { operations } = history;
const scope = JSON.stringify([accountId, searchParameters.toString()]);
const [previousScope, setPreviousScope] = useState(scope);
if (previousScope !== scope) {
setPreviousScope(scope);
setDraftPeriod([appliedFrom, appliedTo]);
setDraftCategories(appliedCategories);
history.reset();
}
useScreenSync(
{ accountId, screen: "operations", ...firstPageQuery },
[operations],
);
const availability = useInitialAvailability({
hasContent: !!operations.data,
identity: scope,
isError: operations.isError || operations.meta?.lastAttempt.state === "failed",
isFetching: operations.isFetching,
});
if (availability === "loading" && operations.isLoading)
return { state: "loading" };
if (history.isRecoveringInvalidCursor)
return { state: "loading" };
const resetQueryKey = brokerageOperationsQueryOptions(accountId, {
limit: 100,
}).queryKey;
const onReset = () => {
setDraftPeriod([appliedFrom, appliedTo]);
setDraftCategories([]);
setSearchParameters({});
history.reset();
void queryClient.invalidateQueries({ queryKey: resetQueryKey });
};
const isInvalidCursor = isApiErrorCode(
apiErrorCode(operations.error),
ApiErrorCode.InvalidOperationsCursor,
);
if (
(isInvalidCursor || !operations.data)
&& (operations.isError || operations.meta?.lastAttempt.state === "failed")
)
return { onReset, state: "error" };
if (!operations.data)
return availability === "unavailable"
? { onReset, state: "error" }
: { state: "loading" };
const nextCursor = operations.data.nextCursor;
const isDirty
= draftFrom !== appliedFrom
|| draftTo !== appliedTo
|| !isSameCategories(draftCategories, appliedCategories);
return {
canApply: isDirty && isValidPeriod(draftFrom, draftTo),
draftCategories,
draftPeriod,
hasNext: Boolean(nextCursor && operations.data.hasNext),
hasPrevious: history.hasPrevious,
historyLoading:
operations.meta?.coverage?.historyState === "loading"
|| operations.meta?.coverage?.historyState === "partial",
items: operations.data.items,
maxDate,
onApply: () => {
if (!isValidPeriod(draftFrom, draftTo)) return;
setSearchParameters({
...(draftFrom && { from: draftFrom }),
...(draftTo && { to: draftTo }),
...(draftCategories.length > 0 && {
categories: draftCategories.join(","),
}),
});
history.reset();
},
onCategoriesChange: values => setDraftCategories(
values.filter((value): value is OperationCategory =>
operationCategorySet.has(value),
),
),
onNext: history.onNext,
onPeriodChange: value => setDraftPeriod(
value[0] === null && value[1] === null ? defaultPeriod() : value,
),
onPeriodPopoverOpen: () => setMaxDate(moscowDate(new Date())),
onPrevious: history.onPrevious,
onReset,
partial: operations.meta?.completeness === "incomplete",
source: operations.meta,
state: "loaded",
};
};
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));
}
export { type AccountOperationsPageViewModel, useAccountOperationsPageVm };