merge branch feature/cache-manager-admin

This commit is contained in:
vchikalkin 2024-04-11 00:53:31 +03:00
parent bd7cf3284b
commit 93d16cf35c
51 changed files with 799 additions and 131 deletions

View File

@ -45,6 +45,7 @@
"fastify": "^4.26.1", "fastify": "^4.26.1",
"jest": "^29.5.0", "jest": "^29.5.0",
"prettier": "^3.0.0", "prettier": "^3.0.0",
"shared": "workspace:*",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"supertest": "^6.3.3", "supertest": "^6.3.3",
"ts-jest": "29.1.1", "ts-jest": "29.1.1",

View File

@ -4,14 +4,18 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { import {
All, All,
Controller, Controller,
Delete,
Get,
HttpException, HttpException,
HttpStatus, HttpStatus,
Inject, Inject,
Query,
Req, Req,
Res, Res,
} from '@nestjs/common'; } from '@nestjs/common';
import type { Cache } from 'cache-manager'; import type { Cache } from 'cache-manager';
import { FastifyReply, FastifyRequest } from 'fastify'; import { FastifyReply, FastifyRequest } from 'fastify';
import type { QueryItem } from 'shared/types/cache';
import { env } from 'src/config/env'; import { env } from 'src/config/env';
type RedisStore = Omit<Cache, 'set'> & { type RedisStore = Omit<Cache, 'set'> & {
@ -23,6 +27,7 @@ export class ProxyController {
constructor( constructor(
@Inject(CACHE_MANAGER) private readonly cacheManager: RedisStore, @Inject(CACHE_MANAGER) private readonly cacheManager: RedisStore,
) {} ) {}
@All('/graphql') @All('/graphql')
public async graphql(@Req() req: FastifyRequest, @Res() reply: FastifyReply) { public async graphql(@Req() req: FastifyRequest, @Res() reply: FastifyReply) {
const { operationName, query, variables } = req.body as GQLRequest; const { operationName, query, variables } = req.body as GQLRequest;
@ -56,4 +61,67 @@ export class ProxyController {
return reply.send(data); return reply.send(data);
} }
@Get('/get-queries')
public async getQueriesList(@Res() reply: FastifyReply) {
const res = await this.getAllQueries();
return reply.send(res);
}
private async getAllQueries() {
const list = await this.cacheManager.store.keys('*');
return (Object.keys(queryTTL) as Array<keyof typeof queryTTL>).reduce(
(acc, queryName) => {
const queries = list.filter((x) => x.split(' ').at(0) === queryName);
if (queries.length) {
const ttl = queryTTL[queryName];
acc[queryName] = { queries, ttl };
}
return acc;
},
{} as Record<string, QueryItem>,
);
}
@Delete('/delete-query')
public async deleteQuery(
@Query('queryKey') queryKey: string,
@Res() reply: FastifyReply,
) {
try {
await this.cacheManager.del(queryKey);
return reply.send('ok');
} catch (error) {
throw new HttpException(error, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
@Delete('/reset')
public async reset(@Res() reply: FastifyReply) {
try {
await this.cacheManager.reset();
return reply.send('ok');
} catch (error) {
throw new HttpException(error, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
@Get('/get-query')
public async getQueryValue(
@Query('queryKey') queryKey: string,
@Res() reply: FastifyReply,
) {
try {
const value = await this.cacheManager.get(queryKey);
return reply.send(value);
} catch (error) {
throw new HttpException(error, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
} }

View File

@ -0,0 +1,77 @@
import * as cacheApi from '@/api/cache/query';
import { min } from '@/styles/mq';
import { useQuery } from '@tanstack/react-query';
import { memo, useState } from 'react';
import styled from 'styled-components';
import { Button, Collapse } from 'ui/elements';
import { Flex } from 'ui/grid';
type QueryProps = {
readonly onDeleteQuery: () => Promise<void>;
readonly queryKey: string;
};
const StyledPre = styled.pre`
max-height: 300px;
overflow-y: auto;
${min('desktop')} {
max-height: 800px;
}
`;
export const Query = memo(({ onDeleteQuery, queryKey }: QueryProps) => {
const { data, refetch } = useQuery({
enabled: false,
queryFn: ({ signal }) => signal && cacheApi.getQueryValue(queryKey, { signal }),
queryKey: ['admin', 'cache', 'query', queryKey],
refetchOnWindowFocus: false,
});
const [activeKey, setActiveKey] = useState<string | undefined>(undefined);
const [deletePending, setDeletePending] = useState(false);
const content = (
<>
<StyledPre>{JSON.stringify(data, null, 2)}</StyledPre>
<Flex justifyContent="flex-end">
<Button
type="primary"
danger
disabled={deletePending}
onClick={() => {
setDeletePending(true);
onDeleteQuery().finally(() => {
setDeletePending(false);
});
}}
>
Удалить
</Button>
</Flex>
</>
);
return (
<Collapse
bordered={false}
activeKey={activeKey}
items={[
{
children: data ? content : 'Загрузка...',
key: queryKey,
label: queryKey,
},
]}
onChange={() => {
if (activeKey) {
setActiveKey(undefined);
} else {
setActiveKey(queryKey);
refetch();
}
}}
/>
);
});

View File

@ -0,0 +1,25 @@
import { Query } from './Query';
import * as cacheApi from '@/api/cache/query';
import { memo, useMemo, useState } from 'react';
import type { QueryItem } from 'shared/types/cache';
type QueryListProps = QueryItem;
export const QueryList = memo(({ queries }: QueryListProps) => {
const [deletedQueries, setDeletedQueries] = useState<QueryItem['queries']>([]);
const activeQueries = useMemo(
() => queries.filter((queryKey) => !deletedQueries.includes(queryKey)),
[deletedQueries, queries]
);
function handleDeleteQuery(queryKey: string) {
return cacheApi
.deleteQuery(queryKey)
.then(() => setDeletedQueries([...deletedQueries, queryKey]));
}
return activeQueries.map((queryKey) => (
<Query key={queryKey} queryKey={queryKey} onDeleteQuery={() => handleDeleteQuery(queryKey)} />
));
});

View File

@ -0,0 +1,24 @@
import { useState } from 'react';
import { Button } from 'ui/elements';
import { ReloadOutlined } from 'ui/elements/icons';
export function ReloadButton({ onClick }: { readonly onClick: () => Promise<unknown> }) {
const [pending, setPending] = useState(false);
return (
<Button
loading={pending}
onClick={() => {
setPending(true);
onClick().finally(() => {
setTimeout(() => {
setPending(false);
}, 1000);
});
}}
icon={<ReloadOutlined rev="" />}
>
Обновить
</Button>
);
}

View File

@ -0,0 +1,58 @@
import Background from '../../Layout/Background';
import { useFilteredQueries } from './lib/hooks';
import { QueryList } from './QueryList';
import { ReloadButton } from './ReloadButton';
import { min } from '@/styles/mq';
import styled from 'styled-components';
import { Collapse, Divider, Input } from 'ui/elements';
const Wrapper = styled(Background)`
padding: 4px 6px;
width: 100vw;
${min('tablet')} {
min-height: 790px;
}
${min('laptop')} {
padding: 4px 18px 10px;
width: 1280px;
}
`;
const Flex = styled.div`
display: flex;
margin-bottom: 16px;
justify-content: space-between;
gap: 10px;
`;
export function Cache() {
const { filteredQueries, refetch, setFilterString } = useFilteredQueries();
if (!filteredQueries) {
return <div>Загрузка...</div>;
}
return (
<Wrapper>
<Divider>Управление кэшем</Divider>
<Flex>
<Input
placeholder="Поиск по запросу"
allowClear
onChange={(e) => setFilterString(e.target.value)}
/>
<ReloadButton onClick={refetch} />
</Flex>
<Collapse
accordion
items={Object.keys(filteredQueries).map((queryGroupName) => ({
children: <QueryList {...filteredQueries[queryGroupName]} />,
key: queryGroupName,
label: queryGroupName,
}))}
/>
</Wrapper>
);
}

View File

@ -0,0 +1,30 @@
import { filterQueries } from './utils';
import * as cacheApi from '@/api/cache/query';
import type { ResponseQueries } from '@/api/cache/types';
import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useDebounce } from 'use-debounce';
export function useFilteredQueries() {
const { data: queries, refetch } = useQuery({
enabled: false,
queryFn: ({ signal }) => signal && cacheApi.getQueries({ signal }),
queryKey: ['admin', 'cache', 'queries'],
refetchOnWindowFocus: false,
});
const [filteredQueries, setFilteredQueries] = useState<ResponseQueries | undefined>(queries);
const [filterString, setFilterString] = useState('');
const [debouncedFilterString] = useDebounce(filterString, 350);
useEffect(() => {
if (!debouncedFilterString) {
setFilteredQueries(queries);
}
if (queries && debouncedFilterString) {
setFilteredQueries(filterQueries(queries, debouncedFilterString));
}
}, [debouncedFilterString, queries]);
return { filteredQueries, queries, refetch, setFilterString };
}

View File

@ -0,0 +1,23 @@
import type { ResponseQueries } from '@/api/cache/types';
export function filterQueries(queriesObj: ResponseQueries, searchStr: string): ResponseQueries {
const filteredObj: ResponseQueries = {};
for (const key in queriesObj) {
if (key.includes(searchStr)) {
filteredObj[key] = queriesObj[key];
} else {
const queries: string[] = [];
queriesObj[key].queries.forEach((queryKey) => {
if (queryKey.toLowerCase().includes(searchStr.toLowerCase())) {
queries.push(queryKey);
}
});
if (queries.length) {
filteredObj[key] = { ...queriesObj[key], queries };
}
}
}
return filteredObj;
}

View File

@ -0,0 +1,17 @@
import { min } from '@/styles/mq';
import type { PropsWithChildren } from 'react';
import styled from 'styled-components';
const Flex = styled.div`
display: flex;
flex-direction: column;
${min('laptop')} {
flex-direction: row;
justify-content: center;
}
`;
export function Layout({ children }: PropsWithChildren) {
return <Flex>{children}</Flex>;
}

View File

@ -0,0 +1,2 @@
export * from './Cache';
export * from './Layout';

View File

@ -44,13 +44,13 @@ const ComponentWrapper = styled.div`
} }
`; `;
function Form({ prune }) { export function Form({ prune }) {
return ( return (
<Wrapper> <Wrapper>
<Tabs type="card" tabBarGutter="5px"> <Tabs type="card" tabBarGutter="5px">
{formTabs {formTabs
.filter((tab) => !prune?.includes(tab.id)) .filter((tab) => !prune?.includes(tab.id))
.map(({ id, title, Component }) => ( .map(({ Component, id, title }) => (
<Tabs.TabPane tab={title} key={id}> <Tabs.TabPane tab={title} key={id}>
<ComponentWrapper> <ComponentWrapper>
<Component /> <Component />
@ -61,5 +61,3 @@ function Form({ prune }) {
</Wrapper> </Wrapper>
); );
} }
export default Form;

View File

@ -2,7 +2,7 @@ import { min } from '@/styles/mq';
import styled from 'styled-components'; import styled from 'styled-components';
import { Box } from 'ui/grid'; import { Box } from 'ui/grid';
export const Grid = styled(Box)` export const Layout = styled(Box)`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
@ -17,8 +17,4 @@ export const Grid = styled(Box)`
grid-template-columns: 2fr 1fr 1.5fr; grid-template-columns: 2fr 1fr 1.5fr;
/* margin: 8px 5%; */ /* margin: 8px 5%; */
} }
${min('desktop-xl')} {
margin: 8px 10% !important;
}
`; `;

View File

@ -22,7 +22,7 @@ const Wrapper = styled.div`
`; `;
const Results = observer(() => { const Results = observer(() => {
const { $results, $process } = useStore(); const { $process, $results } = useStore();
const resultsValues = toJS($results.values); const resultsValues = toJS($results.values);
// eslint-disable-next-line no-negated-condition // eslint-disable-next-line no-negated-condition

View File

@ -41,7 +41,7 @@ function getElementsErrors({ $calculation, $process }) {
}); });
} }
function getPaymentsTableErrors({ $tables, $process }) { function getPaymentsTableErrors({ $process, $tables }) {
const { payments } = $tables; const { payments } = $tables;
const errors = payments.validation.getErrors(); const errors = payments.validation.getErrors();
const title = payments.validation.params.err_title; const title = payments.validation.params.err_title;
@ -58,7 +58,7 @@ function getPaymentsTableErrors({ $tables, $process }) {
)); ));
} }
function getInsuranceTableErrors({ $tables, $process }) { function getInsuranceTableErrors({ $process, $tables }) {
const { insurance } = $tables; const { insurance } = $tables;
const errors = insurance.validation.getErrors(); const errors = insurance.validation.getErrors();
const title = insurance.validation.params.err_title; const title = insurance.validation.params.err_title;
@ -75,7 +75,7 @@ function getInsuranceTableErrors({ $tables, $process }) {
)); ));
} }
function getFingapTableErrors({ $tables, $process }) { function getFingapTableErrors({ $process, $tables }) {
const { fingap } = $tables; const { fingap } = $tables;
const errors = fingap.validation.getErrors(); const errors = fingap.validation.getErrors();
const title = fingap.validation.params.err_title; const title = fingap.validation.params.err_title;

View File

@ -42,7 +42,7 @@ const Wrapper = styled(Background)`
} }
`; `;
const Output = observer(() => { export const Output = observer(() => {
const { $results } = useStore(); const { $results } = useStore();
const [activeKey, setActiveKey] = useState(undefined); const [activeKey, setActiveKey] = useState(undefined);
const { hasErrors } = useErrors(); const { hasErrors } = useErrors();
@ -69,5 +69,3 @@ const Output = observer(() => {
</Wrapper> </Wrapper>
); );
}); });
export default Output;

View File

@ -17,7 +17,7 @@ const Wrapper = styled(Background)`
} }
`; `;
export default function Settings() { export function Settings() {
const { $process } = useStore(); const { $process } = useStore();
const mainRows = $process.has('Unlimited') const mainRows = $process.has('Unlimited')

View File

@ -1,2 +0,0 @@
export { default as Form } from './Form';
export { default as Settings } from './Settings';

View File

@ -0,0 +1,4 @@
export * from './Form';
export * from './Layout';
export * from './Output';
export * from './Settings';

View File

@ -45,13 +45,7 @@ const Logout = styled.a`
function Auth() { function Auth() {
return ( return (
<Flex <Flex flexDirection="column" alignItems="flex-end" justifyContent="flex-start" height="100%">
flexDirection="column"
alignItems="flex-end"
alignSelf={['flex-start']}
justifyContent="space-between"
height="100%"
>
<User /> <User />
<Logout href="/logout">Выход</Logout> <Logout href="/logout">Выход</Logout>
</Flex> </Flex>

View File

@ -7,6 +7,7 @@ import { Flex } from 'ui/grid';
const HeaderContent = styled(Flex)` const HeaderContent = styled(Flex)`
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
padding: 14px 12px;
background: linear-gradient( background: linear-gradient(
90deg, 90deg,
@ -15,9 +16,7 @@ const HeaderContent = styled(Flex)`
var(--color-tertiarty) 100% var(--color-tertiarty) 100%
); );
padding: 14px 12px;
${min('laptop')} { ${min('laptop')} {
padding: 14px 12px;
padding-left: 20px; padding-left: 20px;
} }
`; `;

View File

@ -5,6 +5,7 @@ import getColors from '@/styles/colors';
import { min } from '@/styles/mq'; import { min } from '@/styles/mq';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link';
import logo from 'public/assets/images/logo-primary.svg'; import logo from 'public/assets/images/logo-primary.svg';
import styled from 'styled-components'; import styled from 'styled-components';
import { Tag } from 'ui/elements'; import { Tag } from 'ui/elements';
@ -33,8 +34,9 @@ const LogoText = styled.h3`
const TagWrapper = styled.div` const TagWrapper = styled.div`
font-family: 'Montserrat'; font-family: 'Montserrat';
font-weight: 500; font-weight: 500;
* {
font-size: 0.7rem; ${min('tablet')} {
margin: 0 5px;
} }
`; `;
@ -42,12 +44,11 @@ const { COLOR_PRIMARY } = getColors();
const UnlimitedTag = observer(() => { const UnlimitedTag = observer(() => {
const { $process } = useStore(); const { $process } = useStore();
if ($process.has('Unlimited')) { if ($process.has('Unlimited')) {
return ( return (
<TagWrapper> <TagWrapper>
<Tag color={COLOR_PRIMARY} style={{ margin: '0 5px' }}> <Tag color={COLOR_PRIMARY}>без ограничений</Tag>
без ограничений
</Tag>
</TagWrapper> </TagWrapper>
); );
} }
@ -59,9 +60,11 @@ function Logo() {
return ( return (
<Flex flexDirection="column" alignItems="flex-start" justifyContent="space-between"> <Flex flexDirection="column" alignItems="flex-start" justifyContent="space-between">
<ImageWrapper> <ImageWrapper>
<Image priority className={styles.logo} alt="logo" src={logo} layout="responsive" /> <Link href="/">
<Image priority className={styles.logo} alt="logo" src={logo} layout="responsive" />
</Link>
</ImageWrapper> </ImageWrapper>
<Flex justifyContent="space-between" alignItems="center"> <Flex flexDirection={['column', 'row']} alignItems={[undefined, 'center']}>
<LogoText>Лизинговый Калькулятор</LogoText> <LogoText>Лизинговый Калькулятор</LogoText>
<UnlimitedTag /> <UnlimitedTag />
</Flex> </Flex>

View File

@ -0,0 +1,48 @@
import Background from './Background';
import type { MenuProps } from 'antd';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { Menu } from 'ui/elements';
import { AppstoreOutlined, SettingOutlined } from 'ui/elements/icons';
const items: MenuProps['items'] = [
{
children: [
{
// icon: <HomeOutlined />,
key: '/',
label: <Link href="/">Главная</Link>,
},
{
// icon: <PlusSquareOutlined />,
key: '/unlimited',
label: <Link href="/unlimited">Без ограничений</Link>,
},
],
icon: <AppstoreOutlined />,
key: 'home',
label: 'Приложение',
},
{
children: [
{
// icon: <DatabaseOutlined />,
key: '/admin/cache',
label: <Link href="/admin/cache">Управление кэшем</Link>,
},
],
icon: <SettingOutlined />,
key: 'admin',
label: 'Панель управления',
},
];
export function AppNavigation() {
const { pathname } = useRouter();
return (
<Background>
<Menu selectedKeys={[pathname]} mode="horizontal" items={items} />
</Background>
);
}

View File

@ -1,11 +1,22 @@
import Header from './Header'; import Header from './Header';
import { Flex } from 'ui/grid'; import { AppNavigation } from './Navigation';
import { min } from '@/styles/mq';
import styled from 'styled-components';
export default function Layout({ children }) { const Main = styled.main`
margin: 8px 0;
${min('desktop-xl')} {
margin: 8px 10%;
}
`;
export default function Layout({ children, user }) {
return ( return (
<Flex flexDirection="column"> <>
<Header /> <Header />
<main>{children}</main> {user?.admin ? <AppNavigation /> : false}
</Flex> <Main>{children}</Main>
</>
); );
} }

42
apps/web/api/cache/query.ts vendored Normal file
View File

@ -0,0 +1,42 @@
import type { ResponseQueries } from './types';
import getUrls from '@/config/urls';
import { withHandleError } from '@/utils/axios';
import axios from 'axios';
const {
URL_CACHE_GET_QUERIES,
URL_CACHE_DELETE_QUERY,
URL_CACHE_RESET_QUERIES,
URL_CACHE_GET_QUERY,
} = getUrls();
export function getQueries({ signal }: { signal: AbortSignal }) {
return withHandleError(axios.get<ResponseQueries>(URL_CACHE_GET_QUERIES, { signal })).then(
({ data }) => data
);
}
export function deleteQuery(queryKey: string) {
return withHandleError(
axios.delete(URL_CACHE_DELETE_QUERY, {
params: {
queryKey,
},
})
).then(({ data }) => data);
}
export function reset() {
return withHandleError(axios.delete(URL_CACHE_RESET_QUERIES)).then(({ data }) => data);
}
export function getQueryValue(queryKey: string, { signal }: { signal: AbortSignal }) {
return withHandleError(
axios.get<object>(URL_CACHE_GET_QUERY, {
params: {
queryKey,
},
signal,
})
).then(({ data }) => data);
}

3
apps/web/api/cache/types.ts vendored Normal file
View File

@ -0,0 +1,3 @@
import type { QueryItem } from 'shared/types/cache';
export type ResponseQueries = Record<string, QueryItem>;

View File

@ -7,12 +7,16 @@ const envSchema = z.object({
SENTRY_DSN: z.string(), SENTRY_DSN: z.string(),
SENTRY_ENVIRONMENT: z.string(), SENTRY_ENVIRONMENT: z.string(),
URL_1C_TRANSTAX_DIRECT: z.string(), URL_1C_TRANSTAX_DIRECT: z.string(),
URL_CACHE_DELETE_QUERY_DIRECT: z.string().default('http://api:3001/proxy/delete-query'),
URL_CACHE_GET_QUERIES_DIRECT: z.string().default('http://api:3001/proxy/get-queries'),
URL_CACHE_GET_QUERY_DIRECT: z.string().default('http://api:3001/proxy/get-query'),
URL_CACHE_RESET_QUERIES_DIRECT: z.string().default('http://api:3001/proxy/reset'),
URL_CORE_CALCULATE_DIRECT: z.string(), URL_CORE_CALCULATE_DIRECT: z.string(),
URL_CORE_FINGAP_DIRECT: z.string(), URL_CORE_FINGAP_DIRECT: z.string(),
URL_CRM_CREATEKP_DIRECT: z.string(), URL_CRM_CREATEKP_DIRECT: z.string(),
URL_CRM_DOWNLOADKP_BASE: z.string(), URL_CRM_DOWNLOADKP_BASE: z.string(),
URL_CRM_GRAPHQL_DIRECT: z.string(), URL_CRM_GRAPHQL_DIRECT: z.string(),
URL_CRM_GRAPHQL_PROXY: z.string(), URL_CRM_GRAPHQL_PROXY: z.string().default('http://api:3001/proxy/graphql'),
URL_ELT_KASKO_DIRECT: z.string(), URL_ELT_KASKO_DIRECT: z.string(),
URL_ELT_OSAGO_DIRECT: z.string(), URL_ELT_OSAGO_DIRECT: z.string(),
URL_GET_USER_DIRECT: z.string(), URL_GET_USER_DIRECT: z.string(),

View File

@ -13,6 +13,10 @@ const serverRuntimeConfigSchema = envSchema.pick({
SENTRY_DSN: true, SENTRY_DSN: true,
SENTRY_ENVIRONMENT: true, SENTRY_ENVIRONMENT: true,
URL_1C_TRANSTAX_DIRECT: true, URL_1C_TRANSTAX_DIRECT: true,
URL_CACHE_DELETE_QUERY_DIRECT: true,
URL_CACHE_GET_QUERIES_DIRECT: true,
URL_CACHE_GET_QUERY_DIRECT: true,
URL_CACHE_RESET_QUERIES_DIRECT: true,
URL_CORE_CALCULATE_DIRECT: true, URL_CORE_CALCULATE_DIRECT: true,
URL_CORE_FINGAP_DIRECT: true, URL_CORE_FINGAP_DIRECT: true,
URL_CRM_CREATEKP_DIRECT: true, URL_CRM_CREATEKP_DIRECT: true,

View File

@ -22,6 +22,10 @@ function getUrls() {
URL_ELT_KASKO_DIRECT, URL_ELT_KASKO_DIRECT,
URL_ELT_OSAGO_DIRECT, URL_ELT_OSAGO_DIRECT,
URL_CRM_GRAPHQL_PROXY, URL_CRM_GRAPHQL_PROXY,
URL_CACHE_GET_QUERIES_DIRECT,
URL_CACHE_DELETE_QUERY_DIRECT,
URL_CACHE_RESET_QUERIES_DIRECT,
URL_CACHE_GET_QUERY_DIRECT,
} = serverRuntimeConfigSchema.parse(serverRuntimeConfig); } = serverRuntimeConfigSchema.parse(serverRuntimeConfig);
return { return {
@ -29,6 +33,10 @@ function getUrls() {
PORT, PORT,
SENTRY_DSN, SENTRY_DSN,
URL_1C_TRANSTAX: URL_1C_TRANSTAX_DIRECT, URL_1C_TRANSTAX: URL_1C_TRANSTAX_DIRECT,
URL_CACHE_DELETE_QUERY: URL_CACHE_DELETE_QUERY_DIRECT,
URL_CACHE_GET_QUERIES: URL_CACHE_GET_QUERIES_DIRECT,
URL_CACHE_GET_QUERY: URL_CACHE_GET_QUERY_DIRECT,
URL_CACHE_RESET_QUERIES: URL_CACHE_RESET_QUERIES_DIRECT,
URL_CORE_CALCULATE: URL_CORE_CALCULATE_DIRECT, URL_CORE_CALCULATE: URL_CORE_CALCULATE_DIRECT,
URL_CORE_FINGAP: URL_CORE_FINGAP_DIRECT, URL_CORE_FINGAP: URL_CORE_FINGAP_DIRECT,
URL_CRM_CREATEKP: URL_CRM_CREATEKP_DIRECT, URL_CRM_CREATEKP: URL_CRM_CREATEKP_DIRECT,
@ -44,6 +52,10 @@ function getUrls() {
BASE_PATH, BASE_PATH,
SENTRY_DSN, SENTRY_DSN,
URL_1C_TRANSTAX: withBasePath(urls.URL_1C_TRANSTAX_PROXY), URL_1C_TRANSTAX: withBasePath(urls.URL_1C_TRANSTAX_PROXY),
URL_CACHE_DELETE_QUERY: withBasePath(urls.URL_CACHE_DELETE_QUERY_PROXY),
URL_CACHE_GET_QUERIES: withBasePath(urls.URL_CACHE_GET_QUERIES_PROXY),
URL_CACHE_GET_QUERY: withBasePath(urls.URL_CACHE_GET_QUERY_PROXY),
URL_CACHE_RESET_QUERIES: withBasePath(urls.URL_CACHE_RESET_QUERIES_PROXY),
URL_CORE_CALCULATE: withBasePath(urls.URL_CORE_CALCULATE_PROXY), URL_CORE_CALCULATE: withBasePath(urls.URL_CORE_CALCULATE_PROXY),
URL_CORE_FINGAP: withBasePath(urls.URL_CORE_FINGAP_PROXY), URL_CORE_FINGAP: withBasePath(urls.URL_CORE_FINGAP_PROXY),
URL_CRM_CREATEKP: withBasePath(urls.URL_CRM_CREATEKP_PROXY), URL_CRM_CREATEKP: withBasePath(urls.URL_CRM_CREATEKP_PROXY),

View File

@ -1,3 +1,4 @@
export const unlimitedRoles = ['Калькулятор без ограничений']; export const unlimitedRoles = ['Калькулятор без ограничений'];
export const defaultRoles = ['Лизинговый калькулятор', 'МПЛ', 'Управляющий подразделением']; export const defaultRoles = ['Лизинговый калькулятор', 'МПЛ', 'Управляющий подразделением'];
export const adminRoles = ['Калькулятор без ограничений', 'Системный администратор'];
export const usersSuper = ['akalinina', 'vchikalkin']; export const usersSuper = ['akalinina', 'vchikalkin'];

View File

@ -0,0 +1,3 @@
export const PAGE_TITLE = 'Лизинговый калькулятор - Эволюция';
export const PAGE_DESCRIPTION =
'Лизинговый калькулятор - Эволюция - Расчет лизинговых платежей - Создание КП';

View File

@ -1,5 +1,9 @@
module.exports = { module.exports = {
URL_1C_TRANSTAX_PROXY: '/api/1c/transtax', URL_1C_TRANSTAX_PROXY: '/api/1c/transtax',
URL_CACHE_DELETE_QUERY_PROXY: '/api/admin/cache/delete',
URL_CACHE_GET_QUERIES_PROXY: '/api/admin/cache/get-queries',
URL_CACHE_GET_QUERY_PROXY: '/api/admin/cache/get-query',
URL_CACHE_RESET_QUERIES_PROXY: '/api/admin/cache/reset',
URL_CORE_CALCULATE_PROXY: '/api/core/calculate', URL_CORE_CALCULATE_PROXY: '/api/core/calculate',
URL_CORE_FINGAP_PROXY: '/api/core/fingap', URL_CORE_FINGAP_PROXY: '/api/core/fingap',
URL_CRM_CREATEKP_PROXY: '/api/crm/create-kp', URL_CRM_CREATEKP_PROXY: '/api/crm/create-kp',

View File

@ -36,6 +36,17 @@ module.exports = withSentryConfig(
output: 'standalone', output: 'standalone',
publicRuntimeConfig: publicRuntimeConfigSchema.parse(env), publicRuntimeConfig: publicRuntimeConfigSchema.parse(env),
reactStrictMode: true, reactStrictMode: true,
async redirects() {
return [
{
source: '/admin',
destination: '/admin/cache',
permanent: true,
},
];
},
async rewrites() { async rewrites() {
return [ return [
{ {
@ -66,6 +77,22 @@ module.exports = withSentryConfig(
destination: env.URL_ELT_OSAGO_DIRECT, destination: env.URL_ELT_OSAGO_DIRECT,
source: urls.URL_ELT_OSAGO_PROXY, source: urls.URL_ELT_OSAGO_PROXY,
}, },
{
destination: env.URL_CACHE_GET_QUERIES_DIRECT,
source: urls.URL_CACHE_GET_QUERIES_PROXY,
},
{
destination: env.URL_CACHE_DELETE_QUERY_DIRECT + '/:path*',
source: urls.URL_CACHE_DELETE_QUERY_PROXY + '/:path*',
},
{
destination: env.URL_CACHE_RESET_QUERIES_DIRECT,
source: urls.URL_CACHE_RESET_QUERIES_PROXY,
},
{
destination: env.URL_CACHE_GET_QUERY_DIRECT,
source: urls.URL_CACHE_GET_QUERY_PROXY,
},
...favicons.map((fileName) => buildFaviconRewrite(`/${fileName}`)), ...favicons.map((fileName) => buildFaviconRewrite(`/${fileName}`)),
]; ];
}, },

View File

@ -58,6 +58,7 @@
"jest": "^29.4.3", "jest": "^29.4.3",
"jest-environment-jsdom": "^29.3.1", "jest-environment-jsdom": "^29.3.1",
"msw": "^1.1.0", "msw": "^1.1.0",
"shared": "workspace:*",
"ts-jest": "^29.0.5", "ts-jest": "^29.0.5",
"typescript": "^5.3.3" "typescript": "^5.3.3"
}, },

View File

@ -52,7 +52,7 @@ function App({ Component, pageProps }) {
}} }}
> >
<Notification> <Notification>
<Layout> <Layout {...pageProps}>
<Component {...pageProps} /> <Component {...pageProps} />
</Layout> </Layout>
</Notification> </Notification>

View File

@ -1,6 +1,8 @@
/* eslint-disable react/no-danger */
/* eslint-disable @typescript-eslint/explicit-member-accessibility */ /* eslint-disable @typescript-eslint/explicit-member-accessibility */
import { metaFavicon } from '@/config/meta'; import { metaFavicon } from '@/config/meta';
import { withBasePath } from '@/config/urls'; import { withBasePath } from '@/config/urls';
import { PAGE_DESCRIPTION } from '@/constants/page';
import Document, { Head, Html, Main, NextScript } from 'next/document'; import Document, { Head, Html, Main, NextScript } from 'next/document';
import { ServerStyleSheet } from 'styled-components'; import { ServerStyleSheet } from 'styled-components';
import { createCache, doExtraStyle, StyleProvider } from 'ui/tools'; import { createCache, doExtraStyle, StyleProvider } from 'ui/tools';
@ -43,7 +45,7 @@ export default class MyDocument extends Document {
<Head> <Head>
<meta charSet="utf-8" /> <meta charSet="utf-8" />
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#000000" />
<meta name="description" content="Лизинговый калькулятор - Эволюция" /> <meta name="description" content={PAGE_DESCRIPTION} />
{metaFavicon} {metaFavicon}
</Head> </Head>
<body> <body>

View File

@ -0,0 +1,69 @@
import { getQueries } from '@/api/cache/query';
import initializeApollo from '@/apollo/client';
import * as Admin from '@/Components/Admin';
import { Error } from '@/Components/Common/Error';
import { getPageTitle } from '@/utils/page';
import { makeGetUserType } from '@/utils/user';
import { dehydrate, QueryClient } from '@tanstack/react-query';
import Head from 'next/head';
function Content() {
return (
<Admin.Layout>
<Head>
<title>{getPageTitle('Управление кэшем')}</title>
</Head>
<Admin.Cache />
</Admin.Layout>
);
}
export default function Page(props) {
if (props.statusCode !== 200) return <Error {...props} />;
return <Content />;
}
/** @type {import('next').GetServerSideProps} */
export async function getServerSideProps({ req }) {
const { cookie = '' } = req.headers;
const queryClient = new QueryClient();
const apolloClient = initializeApollo();
const getUserType = makeGetUserType({ apolloClient, queryClient });
try {
const user = await getUserType({ cookie });
if (!user.admin) {
return {
props: {
initialQueryState: dehydrate(queryClient),
statusCode: 403,
},
};
}
await queryClient.prefetchQuery(['admin', 'cache', 'queries'], ({ signal }) =>
getQueries({ signal })
);
return {
props: {
calculation: {},
initialApolloState: apolloClient.cache.extract(),
initialQueryState: dehydrate(queryClient),
statusCode: 200,
user,
},
};
} catch (error) {
return {
props: {
error: JSON.stringify(error),
initialQueryState: dehydrate(queryClient),
statusCode: 500,
},
};
}
}

View File

@ -1,12 +1,9 @@
import { getUser } from '@/api/user/query';
import initializeApollo from '@/apollo/client'; import initializeApollo from '@/apollo/client';
import * as Calculation from '@/Components/Calculation'; import * as Calculation from '@/Components/Calculation';
import { Error } from '@/Components/Common/Error'; import { Error } from '@/Components/Common/Error';
import { Grid } from '@/Components/Layout/Page';
import Output from '@/Components/Output';
import { defaultRoles } from '@/config/users';
import * as CRMTypes from '@/graphql/crm.types';
import * as hooks from '@/process/hooks'; import * as hooks from '@/process/hooks';
import { getPageTitle } from '@/utils/page';
import { makeGetUserType } from '@/utils/user';
import { dehydrate, QueryClient } from '@tanstack/react-query'; import { dehydrate, QueryClient } from '@tanstack/react-query';
import Head from 'next/head'; import Head from 'next/head';
@ -17,80 +14,59 @@ function Content() {
hooks.useReactions(); hooks.useReactions();
return ( return (
<Grid> <Calculation.Layout>
<Head> <Head>
<title>Лизинговый калькулятор - Эволюция</title> <title>{getPageTitle()}</title>
</Head> </Head>
<Calculation.Form prune={['unlimited']} /> <Calculation.Form prune={['unlimited']} />
<Calculation.Settings /> <Calculation.Settings />
<Output /> <Calculation.Output />
</Grid> </Calculation.Layout>
); );
} }
export default function Home(props) { export default function Page(props) {
if (props.statusCode !== 200) return <Error {...props} />; if (props.statusCode !== 200) return <Error {...props} />;
return <Content />; return <Content />;
} }
export const makeGetServerSideProps = ({ roles }) => /** @type {import('next').GetServerSideProps} */
/** @type {import('next').GetServerSideProps} */ export async function getServerSideProps({ req }) {
( const { cookie = '' } = req.headers;
async function ({ req }) {
const { cookie = '' } = req.headers;
const queryClient = new QueryClient(); const queryClient = new QueryClient();
const apolloClient = initializeApollo();
const getUserType = makeGetUserType({ apolloClient, queryClient });
const user = await queryClient.fetchQuery(['user'], ({ signal }) => try {
getUser({ const user = await getUserType({ cookie });
headers: {
cookie,
},
signal,
})
);
const apolloClient = initializeApollo(); if (!user.default) {
return {
try { props: {
const { initialQueryState: dehydrate(queryClient),
data: { systemuser }, statusCode: 403,
} = await apolloClient.query({ },
fetchPolicy: 'network-only', };
query: CRMTypes.GetSystemUserDocument,
variables: {
domainname: user.domainName,
},
});
if (!systemuser?.roles?.some((x) => x?.name && roles.includes(x.name))) {
return {
props: {
initialQueryState: dehydrate(queryClient),
statusCode: 403,
},
};
}
return {
props: {
calculation: {},
initialApolloState: apolloClient.cache.extract(),
initialQueryState: dehydrate(queryClient),
statusCode: 200,
},
};
} catch (error) {
return {
props: {
error: JSON.stringify(error),
initialQueryState: dehydrate(queryClient),
statusCode: 500,
},
};
}
} }
);
export const getServerSideProps = makeGetServerSideProps({ roles: defaultRoles }); return {
props: {
calculation: {},
initialApolloState: apolloClient.cache.extract(),
initialQueryState: dehydrate(queryClient),
statusCode: 200,
user,
},
};
} catch (error) {
return {
props: {
error: JSON.stringify(error),
initialQueryState: dehydrate(queryClient),
statusCode: 500,
},
};
}
}

View File

@ -1,17 +1,13 @@
import { makeGetServerSideProps } from '.'; import initializeApollo from '@/apollo/client';
import * as Calculation from '@/Components/Calculation'; import * as Calculation from '@/Components/Calculation';
import { Error } from '@/Components/Common/Error'; import { Error } from '@/Components/Common/Error';
import { Grid } from '@/Components/Layout/Page';
import Output from '@/Components/Output';
import { unlimitedRoles } from '@/config/users';
import * as hooks from '@/process/hooks'; import * as hooks from '@/process/hooks';
import { useStore } from '@/stores/hooks'; import { getPageTitle } from '@/utils/page';
import { makeGetUserType } from '@/utils/user';
import { dehydrate, QueryClient } from '@tanstack/react-query';
import Head from 'next/head'; import Head from 'next/head';
function Content() { function Content() {
const store = useStore();
store.$process.add('Unlimited');
hooks.useSentryScope(); hooks.useSentryScope();
hooks.useMainData(); hooks.useMainData();
hooks.useGetUsers(); hooks.useGetUsers();
@ -19,24 +15,60 @@ function Content() {
hooks.useReactions(); hooks.useReactions();
return ( return (
<Grid> <Calculation.Layout>
<Head> <Head>
<title>Лизинговый калькулятор без ограничений - Эволюция</title> <title>{getPageTitle('Без ограничений')}</title>
<meta name="description" content="Лизинговый калькулятор без ограничений - Эволюция" />
</Head> </Head>
<Calculation.Form /> <Calculation.Form />
<Calculation.Settings /> <Calculation.Settings />
<Output /> <Calculation.Output />
</Grid> </Calculation.Layout>
); );
} }
export default function Unlimited(props) { export default function Page(props) {
if (props.statusCode !== 200) return <Error {...props} />; if (props.statusCode !== 200) return <Error {...props} />;
return <Content />; return <Content />;
} }
export const getServerSideProps = makeGetServerSideProps({ /** @type {import('next').GetServerSideProps} */
roles: unlimitedRoles, export async function getServerSideProps({ req }) {
}); const { cookie = '' } = req.headers;
const queryClient = new QueryClient();
const apolloClient = initializeApollo();
const getUserType = makeGetUserType({ apolloClient, queryClient });
try {
const user = await getUserType({ cookie });
if (!user.unlimited) {
return {
props: {
initialQueryState: dehydrate(queryClient),
statusCode: 403,
},
};
}
return {
props: {
calculation: {},
initialApolloState: apolloClient.cache.extract(),
initialQueryState: dehydrate(queryClient),
mode: 'unlimited',
statusCode: 200,
user,
},
};
} catch (error) {
return {
props: {
error: JSON.stringify(error),
initialQueryState: dehydrate(queryClient),
statusCode: 500,
},
};
}
}

View File

@ -1,6 +1,6 @@
{ {
"name": "Лизинговый калькулятор | Эволюция", "name": "Лизинговый калькулятор - Эволюция",
"short_name": "Лизинговый калькулятор | Эволюция", "short_name": "Калькулятор",
"start_url": "/", "start_url": "/",
"icons": [ "icons": [
{ {

View File

@ -10,7 +10,7 @@ export function initializeStore(initialData) {
const _store = store ?? new RootStore(); const _store = store ?? new RootStore();
if (initialData) { if (initialData) {
const { calculation, tables } = initialData; const { calculation, tables, mode } = initialData;
if (calculation?.values) _store.$calculation.$values.hydrate(calculation.values); if (calculation?.values) _store.$calculation.$values.hydrate(calculation.values);
if (calculation?.statuses) _store.$calculation.$status.hydrate(calculation.statuses); if (calculation?.statuses) _store.$calculation.$status.hydrate(calculation.statuses);
@ -23,6 +23,9 @@ export function initializeStore(initialData) {
values: tables.insurance.values, values: tables.insurance.values,
}); });
} }
_store.$process.clear();
if (mode === 'unlimited') _store.$process.add('Unlimited');
} }
if (isServer()) return _store; if (isServer()) return _store;

7
apps/web/utils/page.tsx Normal file
View File

@ -0,0 +1,7 @@
import { PAGE_TITLE } from '@/constants/page';
export function getPageTitle(title?: string) {
if (!title) return PAGE_TITLE;
return `${title} - ${PAGE_TITLE}`;
}

51
apps/web/utils/user.ts Normal file
View File

@ -0,0 +1,51 @@
import { getUser } from '@/api/user/query';
import { adminRoles, defaultRoles, unlimitedRoles } from '@/config/users';
import * as CRMTypes from '@/graphql/crm.types';
import type { ApolloClient, NormalizedCache } from '@apollo/client';
import type { QueryClient } from '@tanstack/react-query';
import { sift } from 'radash';
type GetUserTypeProps = {
cookie: string;
};
type UserType = {
admin: boolean;
default: boolean;
unlimited: boolean;
};
type MakeGetUserTypeProps = {
apolloClient: ApolloClient<NormalizedCache>;
queryClient: QueryClient;
};
export function makeGetUserType({ apolloClient, queryClient }: MakeGetUserTypeProps) {
return async function ({ cookie }: GetUserTypeProps): Promise<UserType> {
const user = await queryClient.fetchQuery(['user'], ({ signal }) =>
getUser({
headers: {
cookie,
},
signal,
})
);
const {
data: { systemuser },
} = await apolloClient.query({
fetchPolicy: 'network-only',
query: CRMTypes.GetSystemUserDocument,
variables: {
domainname: user.domainName,
},
});
const roles = systemuser?.roles ? sift(systemuser?.roles)?.map((x) => x?.name) : [];
return {
admin: adminRoles.some((x) => roles?.includes(x)),
default: defaultRoles.some((x) => roles?.includes(x)),
unlimited: unlimitedRoles.some((x) => roles?.includes(x)),
};
};
}

View File

@ -0,0 +1,10 @@
const { createConfig } = require('@vchikalkin/eslint-config-awesome');
module.exports = createConfig('typescript', {
parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: __dirname,
},
ignorePatterns: ['*.config.js', '.eslintrc.js'],
root: true,
});

View File

@ -0,0 +1,13 @@
{
"name": "shared",
"version": "0.0.0",
"license": "MIT",
"scripts": {
"lint": "TIMING=1 eslint \"**/*.ts*\""
},
"devDependencies": {
"@vchikalkin/eslint-config-awesome": "^1.1.6",
"eslint": "^8.52.0",
"tsconfig": "workspace:*"
}
}

View File

@ -0,0 +1,6 @@
{
"extends": "tsconfig/common.json",
"include": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"],
"exclude": ["dist", "build", "node_modules"],
"compilerOptions": { "outDir": "./build", "lib": ["DOM", "ES2020"] }
}

View File

@ -0,0 +1,4 @@
export type QueryItem = {
queries: string[];
ttl: number | false;
};

View File

@ -11,9 +11,11 @@ export * from './Text';
export { export {
Alert, Alert,
Badge, Badge,
Collapse,
Divider, Divider,
Form, Form,
InputNumber, InputNumber,
Menu,
message, message,
notification, notification,
Result, Result,

24
pnpm-lock.yaml generated
View File

@ -96,6 +96,9 @@ importers:
prettier: prettier:
specifier: ^3.0.0 specifier: ^3.0.0
version: 3.2.5 version: 3.2.5
shared:
specifier: workspace:*
version: link:../../packages/shared
source-map-support: source-map-support:
specifier: ^0.5.21 specifier: ^0.5.21
version: 0.5.21 version: 0.5.21
@ -250,6 +253,9 @@ importers:
msw: msw:
specifier: ^1.1.0 specifier: ^1.1.0
version: 1.3.2(typescript@5.3.3) version: 1.3.2(typescript@5.3.3)
shared:
specifier: workspace:*
version: link:../../packages/shared
ts-jest: ts-jest:
specifier: ^29.0.5 specifier: ^29.0.5
version: 29.1.1(@babel/core@7.23.9)(jest@29.7.0)(typescript@5.3.3) version: 29.1.1(@babel/core@7.23.9)(jest@29.7.0)(typescript@5.3.3)
@ -257,6 +263,18 @@ importers:
specifier: ^5.3.3 specifier: ^5.3.3
version: 5.3.3 version: 5.3.3
packages/shared:
devDependencies:
'@vchikalkin/eslint-config-awesome':
specifier: ^1.1.6
version: 1.1.6(@babel/eslint-plugin@7.23.5)(@babel/plugin-syntax-flow@7.23.3)(@babel/plugin-transform-react-jsx@7.23.4)(eslint-plugin-import@2.29.1)(eslint@8.57.0)(graphql@16.8.1)(prettier@3.2.5)(typescript@5.3.3)
eslint:
specifier: ^8.52.0
version: 8.57.0
tsconfig:
specifier: workspace:*
version: link:../tsconfig
packages/tools: packages/tools:
devDependencies: devDependencies:
'@vchikalkin/eslint-config-awesome': '@vchikalkin/eslint-config-awesome':
@ -6907,7 +6925,7 @@ packages:
'@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.57.0)(typescript@5.3.3) '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.57.0)(typescript@5.3.3)
'@typescript-eslint/utils': 5.62.0(eslint@8.57.0)(typescript@5.3.3) '@typescript-eslint/utils': 5.62.0(eslint@8.57.0)(typescript@5.3.3)
eslint: 8.57.0 eslint: 8.57.0
jest: 29.7.0(@types/node@20.11.20)(ts-node@10.9.2) jest: 29.7.0(@types/node@18.19.18)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
- typescript - typescript
@ -8151,7 +8169,7 @@ packages:
'@graphql-tools/json-file-loader': 7.4.18(graphql@16.8.1) '@graphql-tools/json-file-loader': 7.4.18(graphql@16.8.1)
'@graphql-tools/load': 7.8.14(graphql@16.8.1) '@graphql-tools/load': 7.8.14(graphql@16.8.1)
'@graphql-tools/merge': 8.4.2(graphql@16.8.1) '@graphql-tools/merge': 8.4.2(graphql@16.8.1)
'@graphql-tools/url-loader': 7.17.18(@types/node@18.19.18)(graphql@16.8.1) '@graphql-tools/url-loader': 7.17.18(@types/node@20.11.20)(graphql@16.8.1)
'@graphql-tools/utils': 9.2.1(graphql@16.8.1) '@graphql-tools/utils': 9.2.1(graphql@16.8.1)
cosmiconfig: 8.0.0 cosmiconfig: 8.0.0
graphql: 16.8.1 graphql: 16.8.1
@ -13175,7 +13193,7 @@ packages:
'@babel/core': 7.23.9 '@babel/core': 7.23.9
bs-logger: 0.2.6 bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0 fast-json-stable-stringify: 2.1.0
jest: 29.7.0(@types/node@20.11.20)(ts-node@10.9.2) jest: 29.7.0(@types/node@18.19.18)
jest-util: 29.7.0 jest-util: 29.7.0
json5: 2.2.3 json5: 2.2.3
lodash.memoize: 4.1.2 lodash.memoize: 4.1.2