Compare commits
88 Commits
f04fc5f575
...
fluent
| Author | SHA1 | Date | |
|---|---|---|---|
| f7acfec80e | |||
| 1bb9ca0108 | |||
| 2a3f2b4c99 | |||
| d9cba82369 | |||
| 04ce74d320 | |||
| fa516b3a20 | |||
| e21f75f5e0 | |||
| 677709c1bf | |||
| 4308c84a8b | |||
| 0b39d04114 | |||
| f6b12850c0 | |||
| 9fe0903d29 | |||
| 051411a3ee | |||
| d04b03ac29 | |||
| 38b041cfa5 | |||
| 676bc6d0cb | |||
| 06dede7d3b | |||
| 222146bead | |||
| e7fb7c55ac | |||
| 3167580bda | |||
| b7c772185f | |||
| 41f5ac6fcf | |||
| 67f519233d | |||
| 78998ad4a4 | |||
| 1cb71e73b7 | |||
| 84a82c38eb | |||
| 99bce93c86 | |||
| a53fbc7912 | |||
| 117cb7ac4d | |||
| 5ebb28dbcc | |||
| f5f629fcc2 | |||
| 13bd605a20 | |||
| 6a5b738f93 | |||
| c176d75b82 | |||
| 81562e3692 | |||
| 2180afa529 | |||
| 6d31e1f37a | |||
| 80517cd7cc | |||
| 8b8b242d3e | |||
| 4a6d314472 | |||
| 4bc39eb8eb | |||
| bfb79c96de | |||
| 66172a69ba | |||
| 5a7a70aa6c | |||
| bdab63f1bb | |||
| 2ffd94bd5b | |||
| 5dd75ead39 | |||
| 26132fc1ee | |||
| fc045abe24 | |||
| e2c251f7af | |||
| 06cc2f21a5 | |||
| 2b0b08ae4e | |||
| 9758ab65b6 | |||
| 7dd7878e49 | |||
| eefb514098 | |||
| bf3638f1d5 | |||
| 83b94126fc | |||
| 037c0b7cf1 | |||
| c8caec7351 | |||
| c646cbac15 | |||
| 97fc2f40db | |||
| cb371dcf6f | |||
| c2560b073b | |||
| 34529cea68 | |||
| 32ff36a12c | |||
| 416086059a | |||
| 37bfa912a0 | |||
| 145827ab6d | |||
| 1f9a3a8e03 | |||
| 6015218d59 | |||
| 442255ebaf | |||
| 3bcea3f1ac | |||
| ada3b63b8d | |||
| 0ca6c136e3 | |||
| ec622da773 | |||
| 300921751a | |||
| 4cc3a919ed | |||
| 349d7449f0 | |||
| 8438e05301 | |||
| 85bafea7c8 | |||
| c08f839b70 | |||
| 0788a401ca | |||
| e6b3dc05d3 | |||
| 2bf657e8ed | |||
| 59fded5cab | |||
| 242ed1aee2 | |||
| 75d6420d6b | |||
| 7e8d1f50c8 |
20
.env.example
20
.env.example
@ -1,10 +1,14 @@
|
||||
REDIS_HOST=redis_db
|
||||
REDIS_PORT=6379
|
||||
REDIS_HOST=
|
||||
REDIS_PORT=
|
||||
REDIS_PASSWORD=
|
||||
POSTGRES_HOST=localhost
|
||||
POSTGRES_DB=ems
|
||||
POSTGRES_USER=ems
|
||||
|
||||
POSTGRES_HOST=
|
||||
POSTGRES_DB=
|
||||
POSTGRES_USER=
|
||||
POSTGRES_PASSWORD=
|
||||
POSTGRES_PORT=5432
|
||||
EMS_PORT=5000
|
||||
MONITOR_PORT=1234
|
||||
POSTGRES_PORT=
|
||||
|
||||
CLIENT_PORT=
|
||||
EMS_PORT=
|
||||
|
||||
MONITOR_PORT=
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@ -1,6 +1,11 @@
|
||||
.DS_Store
|
||||
.venv
|
||||
.vscode
|
||||
__pycache__
|
||||
.env
|
||||
redis_data
|
||||
psql_data
|
||||
psql_data
|
||||
postgis_db
|
||||
tools/import_bounds/*/*.geojson
|
||||
tools/import_bounds/*/*.md
|
||||
storage
|
||||
@ -1,14 +1,9 @@
|
||||
# API авторизации
|
||||
VITE_API_AUTH_URL=
|
||||
|
||||
# API info
|
||||
VITE_API_INFO_URL=
|
||||
|
||||
# API fuel
|
||||
VITE_API_FUEL_URL=
|
||||
|
||||
# API servers
|
||||
VITE_API_SERVERS_URL=
|
||||
|
||||
# API EMS
|
||||
VITE_API_EMS_URL=
|
||||
VITE_API_EMS_URL=
|
||||
VITE_API_MONITOR_URL=
|
||||
|
||||
VITE_API_NEST_URL=
|
||||
1
client/.gitignore
vendored
1
client/.gitignore
vendored
@ -11,6 +11,7 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
stats.html
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
4073
client/package-lock.json
generated
4073
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -12,37 +12,27 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"-": "^0.0.1",
|
||||
"@fluentui/react-components": "^9.69.0",
|
||||
"@fluentui/react-datepicker-compat": "^0.6.14",
|
||||
"@fluentui/react-icons": "^2.0.309",
|
||||
"@fontsource/inter": "^5.0.19",
|
||||
"@fontsource/open-sans": "^5.0.28",
|
||||
"@hello-pangea/dnd": "^17.0.0",
|
||||
"@js-preview/docx": "^1.6.2",
|
||||
"@js-preview/excel": "^1.7.8",
|
||||
"@js-preview/pdf": "^2.0.2",
|
||||
"@mantine/carousel": "^7.13.0",
|
||||
"@mantine/charts": "^7.13.0",
|
||||
"@mantine/code-highlight": "^7.13.0",
|
||||
"@mantine/core": "^7.13.0",
|
||||
"@mantine/dates": "^7.13.0",
|
||||
"@mantine/dropzone": "^7.13.0",
|
||||
"@mantine/form": "^7.13.0",
|
||||
"@mantine/hooks": "^7.13.0",
|
||||
"@mantine/modals": "^7.13.0",
|
||||
"@mantine/notifications": "^7.13.0",
|
||||
"@mantine/nprogress": "^7.13.0",
|
||||
"@mantine/spotlight": "^7.13.0",
|
||||
"@mantine/tiptap": "^7.13.0",
|
||||
"@tabler/icons-react": "^3.17.0",
|
||||
"@tanstack/react-table": "^8.20.5",
|
||||
"@tiptap/extension-link": "^2.7.3",
|
||||
"@tiptap/react": "^2.7.3",
|
||||
"@tiptap/starter-kit": "^2.7.3",
|
||||
"@types/ol-ext": "npm:@siedlerchr/types-ol-ext@^3.5.0",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"ag-grid-react": "^33.3.2",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"axios": "^1.7.2",
|
||||
"buffer": "^6.0.3",
|
||||
"dayjs": "^1.11.13",
|
||||
"embla-carousel-react": "^8.3.0",
|
||||
"docx-templates": "^4.13.0",
|
||||
"file-type": "^19.0.0",
|
||||
"jspdf": "^2.5.2",
|
||||
"ol": "^10.0.0",
|
||||
"ol-ext": "^4.0.23",
|
||||
"proj4": "^2.12.0",
|
||||
@ -50,7 +40,6 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.52.0",
|
||||
"react-router-dom": "^6.23.1",
|
||||
"recharts": "^2.12.7",
|
||||
"swr": "^2.2.5",
|
||||
"uuid": "^11.0.3",
|
||||
"zustand": "^4.5.2"
|
||||
@ -68,6 +57,7 @@
|
||||
"postcss": "^8.4.47",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"rollup-plugin-visualizer": "^5.12.0",
|
||||
"sass-embedded": "^1.79.5",
|
||||
"serve": "^14.2.3",
|
||||
"tailwindcss": "^3.4.4",
|
||||
|
||||
129
client/public/map_pin_icon.svg
Normal file
129
client/public/map_pin_icon.svg
Normal file
@ -0,0 +1,129 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
id="svg2816"
|
||||
version="1.1"
|
||||
inkscape:version="0.47 r22583"
|
||||
width="94"
|
||||
height="128"
|
||||
sodipodi:docname="pin.svg">
|
||||
<metadata
|
||||
id="metadata2822">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<defs
|
||||
id="defs2820">
|
||||
<inkscape:perspective
|
||||
sodipodi:type="inkscape:persp3d"
|
||||
inkscape:vp_x="0 : 0.5 : 1"
|
||||
inkscape:vp_y="0 : 1000 : 0"
|
||||
inkscape:vp_z="1 : 0.5 : 1"
|
||||
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
|
||||
id="perspective2824" />
|
||||
</defs>
|
||||
<sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="1126"
|
||||
inkscape:window-height="809"
|
||||
id="namedview2818"
|
||||
showgrid="false"
|
||||
inkscape:zoom="3.0991164"
|
||||
inkscape:cx="44.920355"
|
||||
inkscape:cy="60.493358"
|
||||
inkscape:window-x="287"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="svg2816" />
|
||||
<path
|
||||
style="fill:#c64242;fill-opacity:0.98823529"
|
||||
d="M 46.977003,126.64334 C 46.693972,125.95584 40.813862,120.20567 36.603071,114.98067 11.655836,81.858372 -16.158365,51.082905 16.319943,13.682837 30.700637,-0.21083367 48.43303,-1.0034227 66.662563,5.4726973 117.9922,35.174601 80.828906,83.627914 56.427079,115.48067 l -9.450076,11.16267 z M 62.417383,75.872046 C 96.654166,51.387445 70.185413,4.2391813 32.569429,19.913013 21.585178,25.769872 16.134954,35.960547 15.944071,47.980664 c -0.524495,11.693153 5.685418,21.471037 15.526227,27.460808 7.055481,3.840074 10.157178,4.533661 18.145697,4.057654 5.177622,-0.308516 8.161127,-1.153847 12.801388,-3.62708 z"
|
||||
id="path4127"
|
||||
sodipodi:nodetypes="ccccccccccsc" />
|
||||
<path
|
||||
sodipodi:type="arc"
|
||||
style="fill:#c64242;fill-opacity:0.98823529;fill-rule:nonzero;stroke:none"
|
||||
id="path4129"
|
||||
sodipodi:cx="52.363636"
|
||||
sodipodi:cy="49.05526"
|
||||
sodipodi:rx="51.222816"
|
||||
sodipodi:ry="41.754009"
|
||||
d="m 41.682107,89.891342 a 51.222816,41.754009 0 1 1 1.276617,0.208091"
|
||||
sodipodi:start="1.7808687"
|
||||
sodipodi:end="8.0386371"
|
||||
sodipodi:open="true"
|
||||
transform="matrix(0.87829487,0,0,1.0519028,0.55474126,-6.9952658)" />
|
||||
<path
|
||||
sodipodi:type="arc"
|
||||
style="opacity:0.34016395;fill:#000000;fill-opacity:0;fill-rule:nonzero;stroke:none"
|
||||
id="path4131"
|
||||
sodipodi:cx="49.05526"
|
||||
sodipodi:cy="48.59893"
|
||||
sodipodi:rx="26.010695"
|
||||
sodipodi:ry="20.991087"
|
||||
d="m 43.631232,69.128546 a 26.010695,20.991087 0 1 1 0.64826,0.104614"
|
||||
sodipodi:start="1.7808687"
|
||||
sodipodi:end="8.0386371"
|
||||
sodipodi:open="true"
|
||||
transform="translate(0.64534523,0)" />
|
||||
<path
|
||||
sodipodi:type="arc"
|
||||
style="fill:#000080;fill-opacity:0;fill-rule:nonzero;stroke:none"
|
||||
id="path4135"
|
||||
sodipodi:cx="35.365417"
|
||||
sodipodi:cy="102.78788"
|
||||
sodipodi:rx="16.655972"
|
||||
sodipodi:ry="11.750445"
|
||||
d="m 31.892136,114.28 a 16.655972,11.750445 0 1 1 0.415114,0.0586"
|
||||
sodipodi:start="1.7808687"
|
||||
sodipodi:end="8.0386371"
|
||||
sodipodi:open="true"
|
||||
transform="translate(0.64534523,0)" />
|
||||
<path
|
||||
sodipodi:type="arc"
|
||||
style="fill:#b72c2c;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path4149"
|
||||
sodipodi:cx="52.705883"
|
||||
sodipodi:cy="52.021389"
|
||||
sodipodi:rx="34.452763"
|
||||
sodipodi:ry="33.540108"
|
||||
d="m 45.521425,84.824145 a 34.452763,33.540108 0 1 1 0.85866,0.167155"
|
||||
sodipodi:start="1.7808687"
|
||||
sodipodi:end="8.0386371"
|
||||
sodipodi:open="true"
|
||||
transform="matrix(0.97020484,0,0,1.0272058,-4.0587829,-5.7503824)" />
|
||||
<path
|
||||
sodipodi:type="arc"
|
||||
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none"
|
||||
id="path4184"
|
||||
sodipodi:cx="64.211853"
|
||||
sodipodi:cy="68.789574"
|
||||
sodipodi:rx="34.203297"
|
||||
sodipodi:ry="36.623341"
|
||||
d="m 57.079416,104.60778 a 34.203297,36.623341 0 1 1 0.852443,0.18252"
|
||||
sodipodi:start="1.7808687"
|
||||
sodipodi:end="8.0386371"
|
||||
sodipodi:open="true"
|
||||
transform="matrix(0.64629924,0,0,0.61681122,5.1261236,4.9013803)" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
BIN
client/public/template.docx
Normal file
BIN
client/public/template.docx
Normal file
Binary file not shown.
BIN
client/public/template_1.docx
Normal file
BIN
client/public/template_1.docx
Normal file
Binary file not shown.
BIN
client/public/template_table.docx
Normal file
BIN
client/public/template_table.docx
Normal file
Binary file not shown.
BIN
client/public/template_table_1.docx
Normal file
BIN
client/public/template_table_1.docx
Normal file
Binary file not shown.
BIN
client/public/test.png
Normal file
BIN
client/public/test.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
@ -1,17 +1,30 @@
|
||||
import { BrowserRouter as Router, Route, Routes, Navigate } from "react-router-dom"
|
||||
import { BrowserRouter as Router, Route, Routes } from "react-router-dom"
|
||||
//import { Navigate } from "react-router-dom"
|
||||
import NotFound from "./pages/NotFound"
|
||||
import MainLayout from "./layouts/MainLayout"
|
||||
import { initAuth, useAuthStore } from "./store/auth"
|
||||
import { useEffect, useState } from "react"
|
||||
import DashboardLayout from "./layouts/DashboardLayout"
|
||||
import { Box, Loader } from "@mantine/core"
|
||||
import { pages } from "./constants/app"
|
||||
import { FluentProvider, Spinner, webDarkTheme, webLightTheme } from "@fluentui/react-components"
|
||||
import { setColorScheme, useAppStore } from "./store/app"
|
||||
|
||||
function App() {
|
||||
const auth = useAuthStore()
|
||||
const { colorScheme } = useAppStore()
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const localColorScheme = localStorage.getItem('colorScheme');
|
||||
|
||||
if (localColorScheme === 'light') {
|
||||
setColorScheme('light')
|
||||
} else if (localColorScheme === 'dark') {
|
||||
setColorScheme('dark')
|
||||
} else if (localColorScheme === 'auto') {
|
||||
setColorScheme('auto')
|
||||
}
|
||||
|
||||
initAuth()
|
||||
}, [])
|
||||
|
||||
@ -24,28 +37,34 @@ function App() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Loader />
|
||||
<Spinner />
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<Box w='100%' h='100vh'>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route element={<MainLayout />}>
|
||||
{pages.filter((page) => !page.dashboard).filter((page) => page.enabled).map((page, index) => (
|
||||
<Route key={`ml-${index}`} path={page.path} element={page.component} />
|
||||
))}
|
||||
</Route>
|
||||
<FluentProvider theme={colorScheme === 'light' ? webLightTheme : webDarkTheme}>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '100vh'
|
||||
}}>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route element={<MainLayout />}>
|
||||
{pages.filter((page) => !page.dashboard).filter((page) => page.enabled).map((page, index) => (
|
||||
<Route key={`ml-${index}`} path={page.path} element={page.component} />
|
||||
))}
|
||||
</Route>
|
||||
|
||||
<Route element={auth.isAuthenticated ? <DashboardLayout></DashboardLayout> : <Navigate to={"/auth/signin"} />}>
|
||||
{pages.filter((page) => page.dashboard).filter((page) => page.enabled).map((page, index) => (
|
||||
<Route key={`dl-${index}`} path={page.path} element={page.component} />
|
||||
))}
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Router>
|
||||
</Box>
|
||||
{/* <Route element={auth.isAuthenticated || pages.find(page => page.path === '/auth/signin')?.enabled === false ? <DashboardLayout></DashboardLayout> : <Navigate to={"/auth/signin"} />}> */}
|
||||
<Route element={<DashboardLayout></DashboardLayout>}>
|
||||
{pages.filter((page) => page.dashboard).filter((page) => page.enabled).map((page, index) => (
|
||||
<Route key={`dl-${index}`} path={page.path} element={page.component} />
|
||||
))}
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Router>
|
||||
</div>
|
||||
</FluentProvider>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,23 +0,0 @@
|
||||
import { Divider, Flex, Text } from '@mantine/core';
|
||||
import { PropsWithChildren } from 'react'
|
||||
|
||||
interface CardInfoProps extends PropsWithChildren {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export default function CardInfo({
|
||||
children,
|
||||
label
|
||||
}: CardInfoProps) {
|
||||
return (
|
||||
<Flex direction='column' gap='sm' p='sm'>
|
||||
<Text fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
|
||||
<Divider />
|
||||
|
||||
{children}
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
@ -1,26 +0,0 @@
|
||||
import { Chip } from '@mantine/core';
|
||||
import { ReactElement } from 'react'
|
||||
|
||||
interface CardInfoChipProps {
|
||||
status: boolean;
|
||||
label: string;
|
||||
iconOn: ReactElement
|
||||
iconOff: ReactElement
|
||||
}
|
||||
|
||||
export default function CardInfoChip({
|
||||
status,
|
||||
label,
|
||||
iconOn,
|
||||
iconOff
|
||||
}: CardInfoChipProps) {
|
||||
return (
|
||||
<Chip
|
||||
icon={status ? iconOn : iconOff}
|
||||
color={status ? "success" : "error"}
|
||||
variant='outline'
|
||||
>
|
||||
{label}
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
@ -1,22 +0,0 @@
|
||||
import { Flex, Text } from '@mantine/core';
|
||||
interface CardInfoLabelProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
export default function CardInfoLabel({
|
||||
label,
|
||||
value
|
||||
}: CardInfoLabelProps) {
|
||||
return (
|
||||
<Flex justify='space-between' align='center'>
|
||||
<Text>
|
||||
{label}
|
||||
</Text>
|
||||
|
||||
<Text fw={600}>
|
||||
{value}
|
||||
</Text>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
@ -5,47 +5,48 @@
|
||||
right: 0;
|
||||
height: 100%;
|
||||
width: 5px;
|
||||
background: #27bbff;
|
||||
background: var(--mantine-color-anchor);
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
border-radius: 6px;
|
||||
transition: opacity .2s ease;
|
||||
}
|
||||
|
||||
.resize_handler:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tr {
|
||||
display: flex;
|
||||
//width: 100%;
|
||||
//max-width: 100%;
|
||||
width: fit-content;
|
||||
}
|
||||
// .tr {
|
||||
// display: flex;
|
||||
// //width: 100%;
|
||||
// //max-width: 100%;
|
||||
// width: fit-content;
|
||||
// }
|
||||
|
||||
.th {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.th,
|
||||
.td {
|
||||
display: flex;
|
||||
width: auto;
|
||||
}
|
||||
// .th,
|
||||
// .td {
|
||||
// display: flex;
|
||||
// width: auto;
|
||||
// }
|
||||
|
||||
.thead {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
// .thead {
|
||||
// display: flex;
|
||||
// width: 100%;
|
||||
// }
|
||||
|
||||
.table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
// .table {
|
||||
// display: flex;
|
||||
// flex-direction: column;
|
||||
// width: 100%;
|
||||
// }
|
||||
|
||||
.tbody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
// .tbody {
|
||||
// display: flex;
|
||||
// flex-direction: column;
|
||||
// width: 100%;
|
||||
// }
|
||||
@ -1,110 +1,205 @@
|
||||
import { Input, Table } from '@mantine/core';
|
||||
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||
import { useMemo, useState } from 'react';
|
||||
import styles from './CustomTable.module.scss'
|
||||
import { useState } from 'react';
|
||||
import { IconPlus } from '@tabler/icons-react';
|
||||
import { CreateField } from '../interfaces/create';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import FormFields from './FormFields';
|
||||
import { Badge, Button, createTableColumn, DataGrid, DataGridBody, DataGridCell, DataGridHeader, DataGridHeaderCell, DataGridRow, Dialog, DialogSurface, DialogTitle, DialogTrigger, Input, Select, Spinner, TableCellLayout, TableColumnDefinition } from '@fluentui/react-components';
|
||||
import { IColumn } from '../interfaces/DataGrid/columns';
|
||||
|
||||
// Sample data
|
||||
|
||||
type DataType = {
|
||||
id: number,
|
||||
name: string,
|
||||
age: number
|
||||
type CustomTableProps<T> = {
|
||||
data: (T & { id: number })[];
|
||||
columns: IColumn[];
|
||||
createFields?: CreateField[];
|
||||
submitHandler?: (data: T) => Promise<AxiosResponse>
|
||||
onEditCell?: (rowId: number, columnId: string, value: any) => any
|
||||
searchable?: boolean
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
// Define columns
|
||||
const columns: ColumnDef<DataType>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Name',
|
||||
cell: (info) => info.getValue(),
|
||||
maxSize: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
{
|
||||
accessorKey: 'age',
|
||||
header: 'Age',
|
||||
cell: (info) => info.getValue(),
|
||||
},
|
||||
];
|
||||
const CustomTable = <T extends object>({
|
||||
data: initialData,
|
||||
columns,
|
||||
createFields,
|
||||
submitHandler,
|
||||
searchable = false,
|
||||
isLoading = false
|
||||
}: CustomTableProps<T>) => {
|
||||
const [data, setData] = useState<(T & { id: number })[]>(initialData);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
|
||||
const CustomTable = () => {
|
||||
const [data, setData] = useState<DataType[]>([
|
||||
{ id: 1, name: 'John Doe', age: 25 },
|
||||
{ id: 2, name: 'Jane Smith', age: 30 },
|
||||
{ id: 3, name: 'Sam Green', age: 22 },
|
||||
]);
|
||||
const [editingCell, setEditingCell] = useState<{ rowIndex: string | number | null, columnId: string | number | null }>({ rowIndex: null, columnId: null });
|
||||
const [editingCell, setEditingCell] = useState<{
|
||||
rowId: number | null
|
||||
columnId: string | null
|
||||
}>({ rowId: null, columnId: null })
|
||||
|
||||
const tableColumns = useMemo<ColumnDef<typeof data[0]>[]>(() => columns, []);
|
||||
const handleEditCell = (rowId: number, columnId: string, value: any) => {
|
||||
setData((prev) =>
|
||||
prev.map((row) =>
|
||||
row.id === rowId ? { ...row, [columnId]: value } : row
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns: tableColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
columnResizeMode: "onChange",
|
||||
});
|
||||
const columnDefinitions: TableColumnDefinition<any>[] = columns.filter(column => column.hidden !== true).map(column => (
|
||||
createTableColumn<any>({
|
||||
columnId: column.name,
|
||||
renderHeaderCell: () => column.header,
|
||||
renderCell: (item) => {
|
||||
const isEditing = editingCell.rowId === item.id && editingCell.columnId === column.name;
|
||||
|
||||
// Function to handle cell edit
|
||||
const handleEditCell = (
|
||||
rowIndex: number,
|
||||
columnId: keyof DataType,
|
||||
value: DataType[keyof DataType]
|
||||
) => {
|
||||
const updatedData = [...data];
|
||||
(updatedData[rowIndex][columnId] as DataType[keyof DataType]) = value;
|
||||
setData(updatedData);
|
||||
//setEditingCell({ rowIndex: null, columnId: null });
|
||||
};
|
||||
switch (column.type) {
|
||||
case 'number':
|
||||
return (
|
||||
<TableCellLayout
|
||||
truncate
|
||||
onDoubleClick={() =>
|
||||
setEditingCell({ rowId: item.id, columnId: column.name })
|
||||
}
|
||||
>
|
||||
{isEditing ? (
|
||||
<Input
|
||||
value={item[column.name]}
|
||||
onChange={(_, d) => handleEditCell?.(item.id, column.name, d.value)}
|
||||
onBlur={() => setEditingCell({ rowId: null, columnId: null })}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
item[column.name]
|
||||
)}
|
||||
</TableCellLayout>
|
||||
)
|
||||
case 'string':
|
||||
return (
|
||||
<TableCellLayout
|
||||
truncate
|
||||
onDoubleClick={() =>
|
||||
setEditingCell({ rowId: item.id, columnId: column.name })
|
||||
}
|
||||
>
|
||||
{isEditing ? (
|
||||
<Input
|
||||
value={item[column.name]}
|
||||
onChange={(_, d) => handleEditCell?.(item.id, column.name, d.value)}
|
||||
onBlur={() => setEditingCell({ rowId: null, columnId: null })}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
item[column.name]
|
||||
)}
|
||||
</TableCellLayout>
|
||||
)
|
||||
case 'boolean':
|
||||
return (
|
||||
<TableCellLayout onDoubleClick={() =>
|
||||
setEditingCell({ rowId: item.id, columnId: column.name })
|
||||
}>
|
||||
{isEditing ? (
|
||||
<Select
|
||||
value={item[column.name]}
|
||||
onChange={(_, d) => handleEditCell?.(item.id, column.name, d.value)}
|
||||
onBlur={() => setEditingCell({ rowId: null, columnId: null })}>
|
||||
<option value='true'>Активен</option>
|
||||
<option value='false'>Неактивен</option>
|
||||
</Select>
|
||||
) : (
|
||||
<Badge color={JSON.parse(item[column.name]) === true ? 'success' : 'danger'}>
|
||||
{JSON.parse(item[column.name]) === true ? 'Активен' : 'Неактивен'}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCellLayout>
|
||||
)
|
||||
case 'dictionary':
|
||||
return (
|
||||
<TableCellLayout
|
||||
onDoubleClick={() =>
|
||||
setEditingCell({ rowId: item.id, columnId: column.name })
|
||||
}
|
||||
>
|
||||
{isEditing ? (
|
||||
<Input
|
||||
value={item[column.name]}
|
||||
onChange={(_, d) => handleEditCell?.(item.id, column.name, d.value)}
|
||||
onBlur={() => setEditingCell({ rowId: null, columnId: null })}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
item[column.name]
|
||||
)}
|
||||
</TableCellLayout>
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
))
|
||||
|
||||
return (
|
||||
<Table striped withColumnBorders highlightOnHover className={styles.table}>
|
||||
<Table.Thead className={styles.thead}>
|
||||
{table.getHeaderGroups().map(headerGroup => (
|
||||
<Table.Tr key={headerGroup.id} className={styles.tr}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<Table.Th key={header.id} className={styles.th} w={header.getSize()}>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
<div
|
||||
className={styles.resize_handler}
|
||||
onMouseDown={header.getResizeHandler()} //for desktop
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
>
|
||||
</div>
|
||||
</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Thead>
|
||||
<Table.Tbody className={styles.tbody}>
|
||||
{table.getRowModel().rows.map((row, rowIndex) => (
|
||||
<Table.Tr key={row.id} className={styles.tr}>
|
||||
{row.getVisibleCells().map(cell => {
|
||||
const isEditing = editingCell.rowIndex === rowIndex && editingCell.columnId === cell.column.id;
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '1rem'
|
||||
}}>
|
||||
{searchable &&
|
||||
<Input
|
||||
placeholder="Поиск"
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
/>}
|
||||
|
||||
return (
|
||||
<Table.Td
|
||||
key={cell.id}
|
||||
onDoubleClick={() => setEditingCell({ rowIndex, columnId: cell.column.id })}
|
||||
style={{ width: cell.column.getSize() }}
|
||||
className={styles.td}
|
||||
>
|
||||
{isEditing ? (
|
||||
<Input
|
||||
type='text'
|
||||
value={data[rowIndex][cell.column.id as keyof DataType]}
|
||||
onChange={(e) => handleEditCell(rowIndex, (cell.column.id as keyof DataType), e.target.value)}
|
||||
onBlur={() => setEditingCell({ rowIndex: null, columnId: null })}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
flexRender(cell.column.columnDef.cell, cell.getContext())
|
||||
)}
|
||||
</Table.Td>
|
||||
);
|
||||
})}
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
{createFields && submitHandler &&
|
||||
<Dialog>
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
appearance='primary'
|
||||
icon={<IconPlus />}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
Добавить
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogSurface>
|
||||
<DialogTitle>Добавление объекта</DialogTitle>
|
||||
<FormFields
|
||||
fields={createFields}
|
||||
submitHandler={submitHandler}
|
||||
/>
|
||||
</DialogSurface>
|
||||
</Dialog>
|
||||
}
|
||||
</div>
|
||||
|
||||
{isLoading ?
|
||||
<Spinner />
|
||||
:
|
||||
<DataGrid
|
||||
items={data}
|
||||
columns={columnDefinitions}
|
||||
resizableColumns
|
||||
sortable
|
||||
getRowId={(item) => item.name}
|
||||
focusMode="cell"
|
||||
>
|
||||
<DataGridHeader>
|
||||
<DataGridRow>
|
||||
{({ renderHeaderCell }) => (
|
||||
<DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>
|
||||
)}
|
||||
</DataGridRow>
|
||||
</DataGridHeader>
|
||||
<DataGridBody<CustomTableProps<T>>>
|
||||
{({ item, rowId }) => (
|
||||
<DataGridRow key={rowId}>
|
||||
{({ renderCell }) => <DataGridCell>{renderCell(item)}</DataGridCell>}
|
||||
</DataGridRow>
|
||||
)}
|
||||
</DataGridBody>
|
||||
</DataGrid>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -4,23 +4,14 @@ import React, { useEffect, useState } from 'react'
|
||||
import DocumentService from '../services/DocumentService'
|
||||
import { mutate } from 'swr'
|
||||
import FileViewer from './modals/FileViewer'
|
||||
import { ActionIcon, Anchor, Breadcrumbs, Button, Divider, FileButton, Flex, Loader, MantineStyleProp, RingProgress, ScrollAreaAutosize, Stack, Table, Text } from '@mantine/core'
|
||||
import { IconCancel, IconDownload, IconFile, IconFileFilled, IconFilePlus, IconFileUpload, IconFolderFilled, IconX } from '@tabler/icons-react'
|
||||
import { IconCancel, IconDownload, IconFileUpload, IconX } from '@tabler/icons-react'
|
||||
import { Breadcrumb, BreadcrumbButton, BreadcrumbDivider, BreadcrumbItem, Button, createTableColumn, DataGrid, DataGridBody, DataGridCell, DataGridHeader, DataGridHeaderCell, DataGridRow, Divider, Field, ProgressBar, Spinner, TableCellLayout } from '@fluentui/react-components'
|
||||
import { DocumentAdd20Regular, DocumentColor, DocumentRegular, DocumentTextColor, FolderRegular, ImageColor, TableColor } from '@fluentui/react-icons'
|
||||
|
||||
interface DocumentProps {
|
||||
doc: IDocument;
|
||||
}
|
||||
|
||||
const FileItemStyle: MantineStyleProp = {
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
flexDirection: 'row',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
padding: '8px'
|
||||
}
|
||||
|
||||
const handleSave = async (file: Blob, filename: string) => {
|
||||
const link = document.createElement('a')
|
||||
link.href = window.URL.createObjectURL(file)
|
||||
@ -30,6 +21,27 @@ const handleSave = async (file: Blob, filename: string) => {
|
||||
window.URL.revokeObjectURL(link.href)
|
||||
}
|
||||
|
||||
function getFileExtension(filename: string): string {
|
||||
return filename.split('.').pop()?.toLowerCase() || '';
|
||||
}
|
||||
|
||||
function handleDocFormatIcon(docName: string) {
|
||||
const ext = getFileExtension(docName);
|
||||
|
||||
switch (ext) {
|
||||
case 'docx':
|
||||
return <DocumentTextColor />
|
||||
case 'pdf':
|
||||
return <DocumentTextColor color='red' />
|
||||
case 'xlsx':
|
||||
return <TableColor />
|
||||
case 'jpg':
|
||||
return <ImageColor />
|
||||
default:
|
||||
return <DocumentRegular />
|
||||
}
|
||||
}
|
||||
|
||||
function ItemDocument({ doc }: DocumentProps) {
|
||||
const [shouldFetch, setShouldFetch] = useState(false)
|
||||
|
||||
@ -45,22 +57,16 @@ function ItemDocument({ doc }: DocumentProps) {
|
||||
}, [shouldFetch, file, doc.name])
|
||||
|
||||
return (
|
||||
<Flex>
|
||||
<ActionIcon
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!isLoading) {
|
||||
setShouldFetch(true)
|
||||
}
|
||||
}}
|
||||
variant='subtle'>
|
||||
{isLoading ?
|
||||
<Loader size='sm' />
|
||||
:
|
||||
<IconDownload />
|
||||
}
|
||||
</ActionIcon>
|
||||
</Flex>
|
||||
<Button icon={isLoading ?
|
||||
<Spinner size='tiny' />
|
||||
:
|
||||
<IconDownload />
|
||||
} appearance='subtle' onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!isLoading) {
|
||||
setShouldFetch(true)
|
||||
}
|
||||
}} />
|
||||
)
|
||||
}
|
||||
|
||||
@ -134,14 +140,39 @@ export default function FolderViewer() {
|
||||
}
|
||||
}
|
||||
|
||||
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const handleClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
handleFileInput(Array.from(e.target.files))
|
||||
}
|
||||
};
|
||||
|
||||
if (foldersLoading || documentsLoading) {
|
||||
return (
|
||||
<Loader />
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
padding: '1rem',
|
||||
}}>
|
||||
<Spinner />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollAreaAutosize w={'100%'} h={'100%'} p={'sm'}>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
padding: '1rem',
|
||||
}}>
|
||||
{fileViewerModal &&
|
||||
<FileViewer
|
||||
open={fileViewerModal}
|
||||
@ -152,51 +183,74 @@ export default function FolderViewer() {
|
||||
/>
|
||||
}
|
||||
|
||||
|
||||
<Stack>
|
||||
<Breadcrumbs>
|
||||
<Anchor
|
||||
onClick={() => {
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
gap: '1rem'
|
||||
}}>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbButton onClick={() => {
|
||||
setCurrentFolder(null)
|
||||
setBreadcrumbs([])
|
||||
}}
|
||||
>
|
||||
Главная
|
||||
</Anchor>
|
||||
}}>Главная</BreadcrumbButton>
|
||||
</BreadcrumbItem>
|
||||
|
||||
{breadcrumbs.map((breadcrumb, index) => (
|
||||
<Anchor
|
||||
key={breadcrumb.id}
|
||||
onClick={() => handleBreadcrumbClick(index)}
|
||||
>
|
||||
{breadcrumb.name}
|
||||
</Anchor>
|
||||
<>
|
||||
<BreadcrumbDivider />
|
||||
<BreadcrumbItem key={breadcrumb.id}>
|
||||
<BreadcrumbButton icon={<FolderRegular />} onClick={() => {
|
||||
handleBreadcrumbClick(index)
|
||||
}}>{breadcrumb.name}</BreadcrumbButton>
|
||||
</BreadcrumbItem>
|
||||
</>
|
||||
|
||||
))}
|
||||
</Breadcrumbs>
|
||||
</Breadcrumb>
|
||||
|
||||
{currentFolder &&
|
||||
<Flex direction='column' gap='sm'>
|
||||
<Flex direction='column' gap='sm' p='sm' style={{
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem',
|
||||
padding: '1rem',
|
||||
border: filesToUpload.length > 0 ? '1px dashed gray' : 'none',
|
||||
borderRadius: '8px',
|
||||
}}>
|
||||
<Flex gap='sm'>
|
||||
<FileButton multiple onChange={handleFileInput}>
|
||||
{(props) => <Button variant='filled' leftSection={isUploading ? <Loader /> : <IconFilePlus />} {...props}>Добавить</Button>}
|
||||
</FileButton>
|
||||
<div style={{ display: 'flex', gap: '1rem' }}>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
ref={fileInputRef}
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<Button appearance="primary" icon={<DocumentAdd20Regular />} onClick={handleClick}>
|
||||
Добавить
|
||||
</Button>
|
||||
|
||||
{filesToUpload.length > 0 &&
|
||||
<>
|
||||
<Button
|
||||
variant='filled'
|
||||
leftSection={isUploading ? <RingProgress sections={[{ value: uploadProgress, color: 'blue' }]} /> : <IconFileUpload />}
|
||||
appearance='primary'
|
||||
icon={<IconFileUpload />}
|
||||
onClick={uploadFiles}
|
||||
disabled={isUploading}
|
||||
>
|
||||
Загрузить все
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant='outline'
|
||||
leftSection={<IconCancel />}
|
||||
appearance='outline'
|
||||
icon={<IconCancel />}
|
||||
onClick={() => {
|
||||
setFilesToUpload([])
|
||||
}}
|
||||
@ -205,88 +259,114 @@ export default function FolderViewer() {
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
{isUploading &&
|
||||
<Field validationMessage={"Загрузка файлов..."} validationState='none'>
|
||||
<ProgressBar value={uploadProgress} />
|
||||
</Field>
|
||||
}
|
||||
|
||||
<Divider />
|
||||
|
||||
{filesToUpload.length > 0 &&
|
||||
<Flex direction='column'>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
{filesToUpload.map((file, index) => (
|
||||
<Flex key={index} p='8px'>
|
||||
<Flex gap='sm' direction='row' align='center'>
|
||||
<IconFile />
|
||||
<Text>{file.name}</Text>
|
||||
</Flex>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
}} key={index}>
|
||||
<Button appearance='transparent' icon={<DocumentColor />}>
|
||||
{file.name}
|
||||
</Button>
|
||||
|
||||
<ActionIcon onClick={() => {
|
||||
<Button style={{ marginLeft: 'auto' }} appearance='subtle' icon={<IconX />} onClick={() => {
|
||||
setFilesToUpload(prev => {
|
||||
return prev.filter((_, i) => i != index)
|
||||
})
|
||||
}} ml='auto' variant='subtle'>
|
||||
<IconX />
|
||||
</ActionIcon>
|
||||
</Flex>
|
||||
}} />
|
||||
</div>
|
||||
))}
|
||||
</Flex>
|
||||
</div>
|
||||
}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<Table
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
bg={dragOver ? 'rgba(0, 0, 0, 0.1)' : 'inherit'}
|
||||
highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Название</Table.Th>
|
||||
<Table.Th p={0}>Дата создания</Table.Th>
|
||||
<Table.Th p={0}></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
|
||||
<Table.Tbody>
|
||||
{currentFolder ? (
|
||||
documents?.map((doc: IDocument, index: number) => (
|
||||
<Table.Tr key={doc.id} onClick={() => handleDocumentClick(index)} style={{ cursor: 'pointer' }}>
|
||||
<Table.Td p={0}>
|
||||
<Flex style={FileItemStyle}>
|
||||
<IconFileFilled />
|
||||
{doc.name}
|
||||
</Flex>
|
||||
</Table.Td>
|
||||
<Table.Td p={0}>
|
||||
{new Date(doc.create_date).toLocaleDateString()}
|
||||
</Table.Td>
|
||||
<Table.Td p={0}>
|
||||
<ItemDocument
|
||||
doc={doc}
|
||||
/>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
) : (
|
||||
folders?.map((folder: IDocumentFolder) => (
|
||||
<Table.Tr key={folder.id} onClick={() => handleFolderClick(folder)} style={{ cursor: 'pointer' }}>
|
||||
<Table.Td p={0}>
|
||||
<Flex style={FileItemStyle}>
|
||||
<IconFolderFilled />
|
||||
{folder.name}
|
||||
</Flex>
|
||||
</Table.Td>
|
||||
<Table.Td p={0} align='left'>
|
||||
{new Date(folder.create_date).toLocaleDateString()}
|
||||
</Table.Td>
|
||||
<Table.Td p={0}>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Stack>
|
||||
</ScrollAreaAutosize>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
overflow: 'auto'
|
||||
}}>
|
||||
<DataGrid
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
style={{ backgroundColor: dragOver ? 'rgba(0, 0, 0, 0.1)' : 'inherit' }}
|
||||
items={currentFolder
|
||||
? (documents ?? []).map((doc: IDocument) => ({ kind: "doc", data: doc }))
|
||||
: (folders ?? []).map((folder: IDocumentFolder) => ({ kind: "folder", data: folder }))}
|
||||
columns={[
|
||||
createTableColumn({
|
||||
columnId: "name",
|
||||
renderHeaderCell: () => "Название",
|
||||
renderCell: (item) => (
|
||||
<TableCellLayout truncate media={item.kind === "doc" ? handleDocFormatIcon(item.data.name) : <FolderRegular />}>
|
||||
{item.data.name}
|
||||
</TableCellLayout>
|
||||
),
|
||||
}),
|
||||
createTableColumn({
|
||||
columnId: "date",
|
||||
renderHeaderCell: () => "Дата создания",
|
||||
renderCell: (item) =>
|
||||
new Date(item.data.create_date).toLocaleDateString(),
|
||||
}),
|
||||
createTableColumn({
|
||||
columnId: "actions",
|
||||
renderHeaderCell: () => "",
|
||||
renderCell: (item) => {
|
||||
if (item.kind === "doc") {
|
||||
// replace with your <ItemDocument doc={doc} />
|
||||
return <ItemDocument doc={item.data} />;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
]}
|
||||
focusMode="cell"
|
||||
resizableColumns
|
||||
getRowId={(item) => item.data.id}
|
||||
>
|
||||
<DataGridHeader>
|
||||
<DataGridRow>
|
||||
{({ renderHeaderCell }) => (
|
||||
<DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>
|
||||
)}
|
||||
</DataGridRow>
|
||||
</DataGridHeader>
|
||||
<DataGridBody>
|
||||
{({ item, rowId }: { item: { kind: string, data: any }, rowId: any }) => (
|
||||
<DataGridRow
|
||||
key={rowId}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => {
|
||||
if (item.kind === "doc") {
|
||||
const index = documents?.findIndex((d: any) => d.id === item.data.id) ?? -1;
|
||||
handleDocumentClick(index);
|
||||
} else {
|
||||
handleFolderClick(item.data as IDocumentFolder);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ renderCell }) => <DataGridCell>{renderCell(item)}</DataGridCell>}
|
||||
</DataGridRow>
|
||||
)}
|
||||
</DataGridBody>
|
||||
</DataGrid>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import { SubmitHandler, useForm } from 'react-hook-form'
|
||||
import { CreateField } from '../interfaces/create'
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { Button, Loader, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { Button, Field, Input, Spinner, Text } from '@fluentui/react-components';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
@ -51,42 +51,37 @@ function FormFields({
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap='sm' w='100%'>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', width: '100%' }}>
|
||||
{title.length > 0 &&
|
||||
<Text size="xl" fw={500}>
|
||||
<Text size={500} weight='semibold'>
|
||||
{title}
|
||||
</Text>
|
||||
}
|
||||
|
||||
{fields.map((field: CreateField) => {
|
||||
return (
|
||||
<TextInput
|
||||
key={field.key}
|
||||
label={field.headerName || field.key.charAt(0).toUpperCase() + field.key.slice(1)}
|
||||
//placeholder="Your name"
|
||||
type={field.inputType ? field.inputType : 'text'}
|
||||
{...register(field.key, {
|
||||
required: field.required ? `${field.headerName} обязателен` : false,
|
||||
validate: (val: string | boolean) => {
|
||||
if (field.watch) {
|
||||
if (watch(field.watch) != val) {
|
||||
return field.watchMessage || ''
|
||||
<Field key={field.key} validationState={errors[field.key]?.message ? 'error' : 'none'} label={field.headerName || field.key.charAt(0).toUpperCase() + field.key.slice(1)}>
|
||||
<Input type={field.inputType ? field.inputType : 'text'}
|
||||
{...register(field.key, {
|
||||
required: field.required ? `${field.headerName} обязателен` : false,
|
||||
validate: (val: string | boolean) => {
|
||||
if (field.watch) {
|
||||
if (watch(field.watch) != val) {
|
||||
return field.watchMessage || ''
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
})}
|
||||
radius="md"
|
||||
required={field.required || false}
|
||||
error={errors[field.key]?.message}
|
||||
errorProps={errors[field.key]}
|
||||
/>
|
||||
},
|
||||
})}
|
||||
required={field.required || false}
|
||||
/>
|
||||
</Field>
|
||||
)
|
||||
})}
|
||||
|
||||
<Button disabled={isSubmitting || Object.keys(dirtyFields).length === 0 || !isValid} type='submit'>
|
||||
{isSubmitting ? <Loader size={16} /> : submitButtonText}
|
||||
{isSubmitting ? <Spinner size='extra-small' /> : submitButtonText}
|
||||
</Button>
|
||||
</Stack>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,41 +0,0 @@
|
||||
import { IServer } from '../interfaces/servers'
|
||||
import { useServerIps } from '../hooks/swrHooks'
|
||||
import { Flex, Table } from '@mantine/core'
|
||||
|
||||
function ServerData({ id }: IServer) {
|
||||
const { serverIps } = useServerIps(id, 0, 10)
|
||||
|
||||
const serverIpsColumns = [
|
||||
{ field: 'id', headerName: 'ID', type: 'number' },
|
||||
{ field: 'server_id', headerName: 'Server ID', type: 'number' },
|
||||
{ field: 'name', headerName: 'Название', type: 'string' },
|
||||
{ field: 'is_actual', headerName: 'Действителен', type: 'boolean' },
|
||||
{ field: 'ip', headerName: 'IP', type: 'string' },
|
||||
{ field: 'servername', headerName: 'Название сервера', type: 'string' },
|
||||
]
|
||||
|
||||
return (
|
||||
<Flex direction='column' p='sm'>
|
||||
{serverIps &&
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
{serverIpsColumns.map(column => (
|
||||
<Table.Th key={column.field}>{column.headerName}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
<Table.Tr>
|
||||
{serverIpsColumns.map(column => (
|
||||
<Table.Td key={column.field}>{serverIps ? serverIps[column.field] : ''}</Table.Td>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
}
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
export default ServerData
|
||||
@ -1,7 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import { useHardwares, useServers } from '../hooks/swrHooks'
|
||||
import { Autocomplete, CloseButton, Loader, Table } from '@mantine/core'
|
||||
import { IServer } from '../interfaces/servers'
|
||||
import { Combobox, Option, Spinner } from '@fluentui/react-components'
|
||||
import CustomTable from './CustomTable'
|
||||
|
||||
export default function ServerHardware() {
|
||||
const [selectedOption, setSelectedOption] = useState<number | undefined>(undefined)
|
||||
@ -9,64 +10,40 @@ export default function ServerHardware() {
|
||||
|
||||
const { hardwares, isLoading: serversLoading } = useHardwares(selectedOption, 0, 10)
|
||||
|
||||
const hardwareColumns = [
|
||||
{ field: 'id', headerName: 'ID', type: 'number' },
|
||||
{ field: 'name', headerName: 'Название', type: 'string' },
|
||||
{ field: 'server_id', headerName: 'Server ID', type: 'number' },
|
||||
{ field: 'servername', headerName: 'Название сервера', type: 'string' },
|
||||
{ field: 'os_info', headerName: 'ОС', type: 'string' },
|
||||
{ field: 'ram', headerName: 'ОЗУ', type: 'string' },
|
||||
{ field: 'processor', headerName: 'Проц.', type: 'string' },
|
||||
{ field: 'storages_count', headerName: 'Кол-во хранилищ', type: 'number' },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<form>
|
||||
<Autocomplete
|
||||
<Combobox
|
||||
placeholder="Сервер"
|
||||
flex={'1'}
|
||||
data={servers ? servers.map((item: IServer) => ({ label: item.name, value: item.id.toString() })) : []}
|
||||
onSelect={(e) => console.log(e.currentTarget.value)}
|
||||
//onChange={(value) => setSearch(value)}
|
||||
onOptionSubmit={(value) => setSelectedOption(Number(value))}
|
||||
rightSection={
|
||||
//search !== '' &&
|
||||
(
|
||||
<CloseButton
|
||||
size="sm"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => {
|
||||
//setSearch('')
|
||||
setSelectedOption(undefined)
|
||||
}}
|
||||
aria-label="Clear value"
|
||||
/>
|
||||
)
|
||||
}
|
||||
//value={search}
|
||||
/>
|
||||
style={{ flex: 1 }}
|
||||
selectedOptions={selectedOption ? [selectedOption.toString()] : []}
|
||||
onOptionSelect={(_, data) => {
|
||||
if (data.optionValue) {
|
||||
setSelectedOption(Number(data.optionValue));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{servers?.map((item: IServer) => (
|
||||
<Option key={item.id} value={item.id.toString()}>
|
||||
{item.name}
|
||||
</Option>
|
||||
))}
|
||||
</Combobox>
|
||||
</form>
|
||||
|
||||
{serversLoading ?
|
||||
<Loader />
|
||||
<Spinner />
|
||||
:
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
{hardwareColumns.map(column => (
|
||||
<Table.Th key={column.field}>{column.headerName}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
<Table.Tr>
|
||||
{hardwareColumns.map(column => (
|
||||
<Table.Td key={column.field}>{hardwares ? hardwares[column.field] : ''}</Table.Td>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
<CustomTable data={hardwares} columns={[
|
||||
{ name: 'id', header: 'ID', type: 'number' },
|
||||
{ name: 'name', header: 'Название', type: 'string' },
|
||||
{ name: 'server_id', header: 'Server ID', type: 'number' },
|
||||
{ name: 'servername', header: 'Название сервера', type: 'string' },
|
||||
{ name: 'os_info', header: 'ОС', type: 'string' },
|
||||
{ name: 'ram', header: 'ОЗУ', type: 'string' },
|
||||
{ name: 'processor', header: 'Проц.', type: 'string' },
|
||||
{ name: 'storages_count', header: 'Кол-во хранилищ', type: 'number' },
|
||||
]} />
|
||||
}
|
||||
</>
|
||||
)
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useServerIps, useServers } from '../hooks/swrHooks'
|
||||
import { Autocomplete, CloseButton, Loader, Table } from '@mantine/core'
|
||||
import { IServer } from '../interfaces/servers'
|
||||
import { Combobox, Option, Spinner } from '@fluentui/react-components'
|
||||
import CustomTable from './CustomTable'
|
||||
|
||||
export default function ServerIpsView() {
|
||||
const [selectedOption, setSelectedOption] = useState<number | null>(null)
|
||||
@ -9,68 +10,40 @@ export default function ServerIpsView() {
|
||||
|
||||
const { serverIps, isLoading: serversLoading } = useServerIps(selectedOption, 0, 10)
|
||||
|
||||
const serverIpsColumns = [
|
||||
{ field: 'id', headerName: 'ID', type: 'number' },
|
||||
{ field: 'server_id', headerName: 'Server ID', type: 'number' },
|
||||
{ field: 'name', headerName: 'Название', type: 'string' },
|
||||
{ field: 'is_actual', headerName: 'Действителен', type: 'boolean' },
|
||||
{ field: 'ip', headerName: 'IP', type: 'string' },
|
||||
{ field: 'servername', headerName: 'Название сервера', type: 'string' },
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
console.log(serverIps)
|
||||
}, [serverIps])
|
||||
|
||||
return (
|
||||
<>
|
||||
<form>
|
||||
<Autocomplete
|
||||
placeholder="Сервер"
|
||||
flex={'1'}
|
||||
data={servers ? servers.map((item: IServer) => ({ label: item.name, value: item.id.toString() })) : []}
|
||||
onSelect={(e) => console.log(e.currentTarget.value)}
|
||||
//onChange={(value) => setSearch(value)}
|
||||
onOptionSubmit={(value) => setSelectedOption(Number(value))}
|
||||
rightSection={
|
||||
//search !== '' &&
|
||||
(
|
||||
<CloseButton
|
||||
size="sm"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => {
|
||||
//setSearch('')
|
||||
setSelectedOption(null)
|
||||
}}
|
||||
aria-label="Clear value"
|
||||
/>
|
||||
)
|
||||
<Combobox
|
||||
clearable
|
||||
placeholder="Сервер"
|
||||
style={{ flex: 1 }}
|
||||
//onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
selectedOptions={selectedOption ? [selectedOption.toString()] : []}
|
||||
onOptionSelect={(_, data) => {
|
||||
if (data.optionValue) {
|
||||
setSelectedOption(Number(data.optionValue));
|
||||
} else {
|
||||
setSelectedOption(null)
|
||||
}
|
||||
//value={search}
|
||||
/>
|
||||
</form>
|
||||
}}
|
||||
>
|
||||
{servers?.map((item: IServer) => (
|
||||
<Option key={item.id} value={item.id.toString()}>
|
||||
{item.name}
|
||||
</Option>
|
||||
))}
|
||||
</Combobox>
|
||||
|
||||
{serversLoading ?
|
||||
<Loader />
|
||||
<Spinner />
|
||||
:
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
{serverIpsColumns.map(column => (
|
||||
<Table.Th key={column.field}>{column.headerName}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
<Table.Tr
|
||||
//bg={selectedRows.includes(element.position) ? 'var(--mantine-color-blue-light)' : undefined}
|
||||
>
|
||||
{serverIpsColumns.map(column => (
|
||||
<Table.Td key={column.field}>{servers ? servers[column.field] : ''}</Table.Td>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
<CustomTable data={serverIps} columns={[
|
||||
{ name: 'id', header: 'ID', type: 'number' },
|
||||
{ name: 'server_id', header: 'Server ID', type: 'number' },
|
||||
{ name: 'name', header: 'Название', type: 'string' },
|
||||
{ name: 'is_actual', header: 'Действителен', type: 'boolean' },
|
||||
{ name: 'ip', header: 'IP', type: 'string' },
|
||||
{ name: 'servername', header: 'Название сервера', type: 'string' },
|
||||
]} />
|
||||
}
|
||||
</>
|
||||
)
|
||||
|
||||
@ -1,42 +1,26 @@
|
||||
import { useState } from 'react'
|
||||
import { IRegion } from '../interfaces/fuel'
|
||||
import { useStorages } from '../hooks/swrHooks'
|
||||
import { Loader, Table } from '@mantine/core'
|
||||
import { Spinner } from '@fluentui/react-components'
|
||||
import CustomTable from './CustomTable'
|
||||
|
||||
export default function ServerStorage() {
|
||||
const [selectedOption, setSelectedOption] = useState<IRegion | null>(null)
|
||||
const [selectedOption] = useState<IRegion | null>(null)
|
||||
|
||||
const { storages, isLoading: serversLoading } = useStorages(selectedOption?.id, 0, 10)
|
||||
|
||||
const storageColumns = [
|
||||
{ field: 'id', headerName: 'ID', type: 'number' },
|
||||
{ field: 'hardware_id', headerName: 'Hardware ID', type: 'number' },
|
||||
{ field: 'name', headerName: 'Название', type: 'string' },
|
||||
{ field: 'size', headerName: 'Размер', type: 'string' },
|
||||
{ field: 'storage_type', headerName: 'Тип хранилища', type: 'string' },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
{serversLoading ?
|
||||
<Loader />
|
||||
<Spinner />
|
||||
:
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
{storageColumns.map(column => (
|
||||
<Table.Th key={column.field}>{column.headerName}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
<Table.Tr>
|
||||
{storageColumns.map(column => (
|
||||
<Table.Td key={column.field}>{storages ? storages[column.field] : ''}</Table.Td>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
<CustomTable data={storages} columns={[
|
||||
{ name: 'id', header: 'ID', type: 'number' },
|
||||
{ name: 'hardware_id', header: 'Hardware ID', type: 'number' },
|
||||
{ name: 'name', header: 'Название', type: 'string' },
|
||||
{ name: 'size', header: 'Размер', type: 'string' },
|
||||
{ name: 'storage_type', header: 'Тип хранилища', type: 'string' }
|
||||
]} />
|
||||
}
|
||||
</>
|
||||
)
|
||||
|
||||
@ -2,7 +2,8 @@ import { useState } from 'react'
|
||||
import { IRegion } from '../interfaces/fuel'
|
||||
import { useRegions, useServers } from '../hooks/swrHooks'
|
||||
import { useDebounce } from '@uidotdev/usehooks'
|
||||
import { Autocomplete, CloseButton, Table } from '@mantine/core'
|
||||
import CustomTable from './CustomTable'
|
||||
import { Combobox, Option } from '@fluentui/react-components'
|
||||
|
||||
export default function ServersView() {
|
||||
const [search, setSearch] = useState<string | undefined>("")
|
||||
@ -15,63 +16,46 @@ export default function ServersView() {
|
||||
|
||||
const { servers } = useServers(selectedOption, 0, 10)
|
||||
|
||||
const serversColumns = [
|
||||
//{ field: 'id', headerName: 'ID', type: "number" },
|
||||
{
|
||||
field: 'name', headerName: 'Название', type: "string", editable: true,
|
||||
},
|
||||
{
|
||||
field: 'region_id',
|
||||
editable: true,
|
||||
headerName: 'region_id',
|
||||
flex: 1
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<form>
|
||||
<Autocomplete
|
||||
placeholder="Район"
|
||||
flex={'1'}
|
||||
data={regions ? regions.map((item: IRegion) => ({ label: item.name, value: item.id.toString() })) : []}
|
||||
onSelect={(e) => console.log(e.currentTarget.value)}
|
||||
onChange={(value) => setSearch(value)}
|
||||
onOptionSubmit={(value) => setSelectedOption(Number(value))}
|
||||
rightSection={
|
||||
search !== '' && (
|
||||
<CloseButton
|
||||
size="sm"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => {
|
||||
setSearch('')
|
||||
setSelectedOption(null)
|
||||
}}
|
||||
aria-label="Clear value"
|
||||
/>
|
||||
)
|
||||
<Combobox
|
||||
clearable
|
||||
placeholder="Район"
|
||||
style={{ flex: 1 }}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
selectedOptions={selectedOption ? [selectedOption.toString()] : []}
|
||||
onOptionSelect={(_, data) => {
|
||||
if (data.optionValue) {
|
||||
setSelectedOption(Number(data.optionValue));
|
||||
} else {
|
||||
setSelectedOption(null)
|
||||
}
|
||||
value={search}
|
||||
/>
|
||||
</form>
|
||||
}}
|
||||
>
|
||||
{regions?.map((item: IRegion) => (
|
||||
<Option key={item.id} value={item.id.toString()}>
|
||||
{item.name}
|
||||
</Option>
|
||||
))}
|
||||
</Combobox>
|
||||
|
||||
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
{serversColumns.map(column => (
|
||||
<Table.Th key={column.field}>{column.headerName}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
<Table.Tr>
|
||||
{serversColumns.map(column => (
|
||||
<Table.Td key={column.field}>{servers ? servers[column.field] : ''}</Table.Td>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
{servers &&
|
||||
<CustomTable
|
||||
data={servers}
|
||||
columns={[
|
||||
{
|
||||
name: 'name',
|
||||
header: 'Название',
|
||||
type: "string",
|
||||
//editable: true,
|
||||
},
|
||||
{
|
||||
name: 'region_id',
|
||||
//editable: true,
|
||||
header: 'region_id',
|
||||
type: 'dictionary'
|
||||
}
|
||||
]} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -1,21 +1,23 @@
|
||||
import { useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import useSWR from 'swr'
|
||||
import { fetcher } from '../../http/axiosInstance'
|
||||
import { BASE_URL } from '../../constants'
|
||||
import { Accordion, NavLink, Text } from '@mantine/core';
|
||||
import { setCurrentObjectId, useObjectsStore } from '../../store/objects';
|
||||
import { IconChevronDown } from '@tabler/icons-react';
|
||||
import { setSelectedObjectType } from '../../store/map';
|
||||
import { setCurrentObjectId, useObjectsStore } from '../../store/objects';
|
||||
import { Text, Tree, TreeItem, TreeItemLayout } from '@fluentui/react-components';
|
||||
|
||||
const ObjectTree = () => {
|
||||
const { selectedDistrict, selectedYear } = useObjectsStore()
|
||||
|
||||
const ObjectTree = ({
|
||||
map_id
|
||||
}: {
|
||||
map_id: string,
|
||||
}) => {
|
||||
const { selectedYear, selectedDistrict } = useObjectsStore().id[map_id]
|
||||
const [existingCount, setExistingCount] = useState(0)
|
||||
const [planningCount, setPlanningCount] = useState(0)
|
||||
|
||||
const { data: existingObjectsList } = useSWR(
|
||||
selectedYear && selectedDistrict ? `/general/objects/list?year=${selectedYear}&city_id=${selectedDistrict}&planning=0` : null,
|
||||
(url) => fetcher(url, BASE_URL.ems).then(res => {
|
||||
(url) => fetcher(url, BASE_URL.nest).then(res => {
|
||||
if (Array.isArray(res)) {
|
||||
let count = 0
|
||||
res.forEach(el => {
|
||||
@ -32,7 +34,7 @@ const ObjectTree = () => {
|
||||
|
||||
const { data: planningObjectsList } = useSWR(
|
||||
selectedYear && selectedDistrict ? `/general/objects/list?year=${selectedYear}&city_id=${selectedDistrict}&planning=1` : null,
|
||||
(url) => fetcher(url, BASE_URL.ems).then(res => {
|
||||
(url) => fetcher(url, BASE_URL.nest).then(res => {
|
||||
if (Array.isArray(res)) {
|
||||
let count = 0
|
||||
res.forEach(el => {
|
||||
@ -49,14 +51,14 @@ const ObjectTree = () => {
|
||||
|
||||
if (selectedDistrict) {
|
||||
return (
|
||||
<Accordion multiple chevronPosition='left'>
|
||||
<TypeTree label='Существующие' value={'existing'} count={existingCount} objectList={existingObjectsList} planning={0} />
|
||||
<TypeTree label='Планируемые' value={'planning'} count={planningCount} objectList={planningObjectsList} planning={1} />
|
||||
</Accordion>
|
||||
<div style={{ width: '100%' }}>
|
||||
<TypeTree key={'existing'} map_id={map_id} label='Существующие' value={'existing'} count={existingCount} objectList={existingObjectsList} planning={0} />
|
||||
<TypeTree key={'planning'} map_id={map_id} label='Планируемые' value={'planning'} count={planningCount} objectList={planningObjectsList} planning={1} />
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<Text size='xs'>Выберите регион и населённый пункт, чтобы увидеть список объектов.</Text>
|
||||
<Text size={500}>Выберите регион и населённый пункт, чтобы увидеть список объектов.</Text>
|
||||
)
|
||||
}
|
||||
|
||||
@ -69,21 +71,29 @@ interface TypeTreeProps {
|
||||
count: number;
|
||||
objectList: unknown;
|
||||
planning: number;
|
||||
map_id: string;
|
||||
}
|
||||
|
||||
const TypeTree = ({
|
||||
label,
|
||||
objectList,
|
||||
count,
|
||||
planning
|
||||
planning,
|
||||
map_id
|
||||
}: TypeTreeProps) => {
|
||||
|
||||
return (
|
||||
<NavLink p={0} label={`${label} ${count ? `(${count})` : ''}`}>
|
||||
{Array.isArray(objectList) && objectList.map(list => (
|
||||
<ObjectList key={list.id} label={list.name} id={list.id} planning={planning} count={list.count} />
|
||||
))}
|
||||
</NavLink>
|
||||
<Tree size="small" aria-label="Small Size Tree">
|
||||
<TreeItem key={`branch-${label}`} itemType="branch">
|
||||
<TreeItemLayout>{`${label} ${count ? `(${count})` : ''}`}</TreeItemLayout>
|
||||
|
||||
<Tree>
|
||||
{Array.isArray(objectList) && objectList.map(list => (
|
||||
<ObjectList map_id={map_id} key={`${label}-${list.id}`} label={list.name} id={list.id} planning={planning} count={list.count} />
|
||||
))}
|
||||
</Tree>
|
||||
</TreeItem>
|
||||
</Tree>
|
||||
)
|
||||
}
|
||||
|
||||
@ -92,33 +102,43 @@ interface IObjectList {
|
||||
id: number;
|
||||
planning: number;
|
||||
count: number;
|
||||
map_id: string;
|
||||
}
|
||||
|
||||
const ObjectList = ({
|
||||
label,
|
||||
id,
|
||||
planning,
|
||||
count
|
||||
count,
|
||||
map_id
|
||||
}: IObjectList) => {
|
||||
const { selectedDistrict, selectedYear } = useObjectsStore()
|
||||
const { selectedDistrict, selectedYear } = useObjectsStore().id[map_id]
|
||||
|
||||
const { data } = useSWR(
|
||||
selectedDistrict && selectedYear ? `/general/objects/list?type=${id}&city_id=${selectedDistrict}&year=${selectedYear}&planning=${planning}` : null,
|
||||
(url) => fetcher(url, BASE_URL.ems),
|
||||
{
|
||||
(url) => fetcher(url, BASE_URL.nest),
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false
|
||||
}
|
||||
)
|
||||
|
||||
const navLinks = useMemo(() => (
|
||||
Array.isArray(data) ? data.map((type, index) => (
|
||||
<TreeItem key={`${label}-${id}-${index}`} itemType='leaf' onClick={() => setCurrentObjectId(map_id, type.object_id)}>
|
||||
<TreeItemLayout>{type.caption ? type.caption : 'Без названия'}</TreeItemLayout>
|
||||
</TreeItem>
|
||||
)) : null
|
||||
), [data, map_id]);
|
||||
|
||||
return (
|
||||
<NavLink onClick={() => {
|
||||
setSelectedObjectType(id)
|
||||
}} rightSection={<IconChevronDown size={14} />} p={0} label={`${label} ${count ? `(${count})` : ''}`}>
|
||||
{Array.isArray(data) && data.map((type) => (
|
||||
<NavLink key={type.object_id} label={type.caption ? type.caption : 'Без названия'} p={0} onClick={() => setCurrentObjectId(type.object_id)} />
|
||||
))}
|
||||
</NavLink>
|
||||
<TreeItem itemType='branch' onClick={() => setSelectedObjectType(map_id, id)}>
|
||||
<TreeItemLayout>{`${label} ${count ? `(${count})` : ''}`}</TreeItemLayout>
|
||||
|
||||
<Tree>
|
||||
{navLinks}
|
||||
</Tree>
|
||||
</TreeItem>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
109
client/src/components/map/GTCBParameter.tsx
Normal file
109
client/src/components/map/GTCBParameter.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import TableValue from './TableValue'
|
||||
import { Text } from '@fluentui/react-components';
|
||||
import { IObjectValue } from '../../interfaces/objects';
|
||||
|
||||
interface ITCBParameterProps {
|
||||
values: IObjectValue[];
|
||||
vtable: string;
|
||||
inactive?: boolean;
|
||||
name: string;
|
||||
map_id: string;
|
||||
}
|
||||
|
||||
const GTCBParameter = ({
|
||||
values,
|
||||
vtable,
|
||||
name,
|
||||
map_id
|
||||
}: ITCBParameterProps) => {
|
||||
//Get value
|
||||
// const { data: tcbValue } = useSWR(
|
||||
// value ? `/general/params/tcb?id=${value}&vtable=${vtable}` : null,
|
||||
// (url) => fetcher(url, BASE_URL.ems).then(res => res[0]),
|
||||
// {
|
||||
// revalidateOnFocus: false,
|
||||
// revalidateIfStale: false
|
||||
// }
|
||||
// )
|
||||
|
||||
const tables = [
|
||||
'BoilersTemper',
|
||||
'FuelsParametrs',
|
||||
'PipesTypes',
|
||||
'vAddRepairEvent',
|
||||
'vApartmentTypes',
|
||||
'vBoilers',
|
||||
'vBoilersAppointment',
|
||||
'vBoilersBalance',
|
||||
'vBoilersCondition',
|
||||
'vBoilersFuels',
|
||||
'vBoilersHotWater',
|
||||
'vBoilersPerimeter',
|
||||
'vBoilersPeriods',
|
||||
'vBoilersScheme',
|
||||
'vBoilersState',
|
||||
'vBoilersTypes',
|
||||
'vBuildingManagement',
|
||||
'vBuildingOwner',
|
||||
'vCanalization',
|
||||
'vColdWaterTypes',
|
||||
'vConditionEquipment',
|
||||
'vCovering',
|
||||
'vDensityWater',
|
||||
'vDryer',
|
||||
'vElectroSupplyTypes',
|
||||
'vEquipmentsTypes',
|
||||
'vFoundation',
|
||||
'vFuelsFeed',
|
||||
'vGasSupplyTypes',
|
||||
'vHeatingTypes',
|
||||
'vHeatTransfer',
|
||||
'vHotWaterTypes',
|
||||
'vMaterialsWall',
|
||||
'vNormative',
|
||||
'vPeriodHW',
|
||||
'vPipeDiameters',
|
||||
'vPipeOutDiameters',
|
||||
'vPipesBearingType',
|
||||
'vPipesCovering',
|
||||
'vPipesEvent',
|
||||
'vPipesGround',
|
||||
'vPipesIsolation',
|
||||
'vPipesLayer',
|
||||
'vPipesMaterial',
|
||||
'vPumpType',
|
||||
'vRepairEvent',
|
||||
'vRoof',
|
||||
'vRPSType',
|
||||
'vServe',
|
||||
'vStreets',
|
||||
'vTechStatus',
|
||||
'vTrash',
|
||||
'vTrashStorage',
|
||||
'vVentilation',
|
||||
'vValvingType',
|
||||
'vWallingEquipment',
|
||||
'tTypes',
|
||||
]
|
||||
|
||||
const TCBValue = (vtable: string) => {
|
||||
if (tables.includes(vtable)) {
|
||||
return (
|
||||
<TableValue map_id={map_id} values={values} name={name} type='select' vtable={vtable} />
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<Text>
|
||||
{JSON.stringify(name)}
|
||||
{JSON.stringify(Array.isArray(values) && values.length > 0 && values[0].value)}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
TCBValue(vtable)
|
||||
)
|
||||
}
|
||||
|
||||
export default GTCBParameter
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,21 +1,21 @@
|
||||
import { Checkbox, Flex, NavLink, Slider, Stack } from '@mantine/core'
|
||||
import { Checkbox, Slider, Text } from '@fluentui/react-components'
|
||||
import BaseLayer from 'ol/layer/Base'
|
||||
import Map from 'ol/Map'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface MapLayersProps {
|
||||
map: React.MutableRefObject<Map | null>
|
||||
map: Map | null
|
||||
}
|
||||
|
||||
const MapLayers = ({
|
||||
map
|
||||
}: MapLayersProps) => {
|
||||
return (
|
||||
<Stack gap='0'>
|
||||
{map.current?.getLayers().getArray() && map.current?.getLayers().getArray().map((layer, index) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{map?.getLayers().getArray() && map?.getLayers().getArray().map((layer, index) => (
|
||||
<LayerSetting key={index} index={index} layer={layer} />
|
||||
))}
|
||||
</Stack>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -40,21 +40,23 @@ const LayerSetting = ({
|
||||
}, [opacity, layer])
|
||||
|
||||
return (
|
||||
<Flex key={`layer-${index}`} gap='xs' align='center'>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }} key={`layer-${index}`}>
|
||||
<Checkbox
|
||||
checked={visible}
|
||||
onChange={(e) => setVisible(e.currentTarget.checked)}
|
||||
/>
|
||||
<Slider
|
||||
w='100%'
|
||||
width='100%'
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.001}
|
||||
value={opacity}
|
||||
onChange={(value) => setOpacity(value)}
|
||||
onChange={(_, data) => setOpacity(data.value)}
|
||||
/>
|
||||
<NavLink p={0} label={layer.get('name')} onClick={() => { console.log(layer.getLayerState()) }} />
|
||||
</Flex>
|
||||
<Text truncate size={300} onClick={() => { console.log(layer.getLayerState()) }}>
|
||||
{layer.get('name')}
|
||||
</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
67
client/src/components/map/MapLayers/MapLayersSelect.tsx
Normal file
67
client/src/components/map/MapLayers/MapLayersSelect.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import { Button, Dropdown, Field, Menu, MenuButton, MenuList, MenuPopover, MenuTrigger, Option } from '@fluentui/react-components';
|
||||
import { IconBoxMultiple, IconPlus, IconUpload } from '@tabler/icons-react';
|
||||
import MapLayers from './MapLayers';
|
||||
import { setSatMapsProvider, useMapStore } from '../../../store/map';
|
||||
import { satMapsProviders } from '../../../constants/map';
|
||||
import { IRectCoords, SatelliteMapsProvider } from '../../../interfaces/map';
|
||||
import GisService from '../../../services/GisService';
|
||||
import { Extent } from 'ol/extent';
|
||||
|
||||
const MapLayersSelect = ({
|
||||
map_id
|
||||
}: {
|
||||
map_id: string
|
||||
}) => {
|
||||
const { map, satMapsProvider, file, polygonExtent, rectCoords } = useMapStore().id[map_id]
|
||||
|
||||
// Upload map overlay
|
||||
const submitOverlay = async (file: File | null, polygonExtent: Extent | undefined, rectCoords: IRectCoords | undefined) => {
|
||||
await GisService.uploadOverlay(file, polygonExtent, rectCoords).then(res => {
|
||||
console.log(res)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu persistOnItemClick positioning={{ autoSize: true }}>
|
||||
<MenuTrigger disableButtonEnhancement>
|
||||
<MenuButton appearance='subtle' icon={<IconBoxMultiple />}>Слои</MenuButton>
|
||||
</MenuTrigger>
|
||||
|
||||
<MenuPopover>
|
||||
<MenuList style={{ padding: '1rem' }}>
|
||||
<Field>Настройка видимости слоёв</Field>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<Field label="Спутниковые снимки">
|
||||
<Dropdown
|
||||
value={satMapsProviders.find(provider => provider.value === satMapsProvider)?.label}
|
||||
selectedOptions={[satMapsProvider]}
|
||||
onOptionSelect={(_ev, data) => {
|
||||
if (data.optionValue) {
|
||||
setSatMapsProvider(map_id, data.optionValue as SatelliteMapsProvider);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{satMapsProviders.map((provider) => (
|
||||
<Option key={provider.value} text={provider.label} value={provider.value}>
|
||||
{provider.label}
|
||||
</Option>
|
||||
))}
|
||||
</Dropdown>
|
||||
</Field>
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
}}>
|
||||
<Button icon={<IconUpload />} appearance='transparent' onClick={() => submitOverlay(file, polygonExtent, rectCoords)} />
|
||||
|
||||
<Button icon={<IconPlus />} appearance='transparent' title='Добавить подложку' />
|
||||
</div>
|
||||
<MapLayers map={map} />
|
||||
</MenuList>
|
||||
</MenuPopover>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
export default MapLayersSelect
|
||||
103
client/src/components/map/MapLegend/MapLegend.tsx
Normal file
103
client/src/components/map/MapLegend/MapLegend.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import useSWR from 'swr'
|
||||
import { fetcher } from '../../../http/axiosInstance'
|
||||
import { BASE_URL } from '../../../constants'
|
||||
import { Accordion, AccordionHeader, AccordionItem, AccordionPanel, ColorSwatch, Text } from '@fluentui/react-components'
|
||||
import { useAppStore } from '../../../store/app'
|
||||
|
||||
const MapLegend = ({
|
||||
selectedDistrict,
|
||||
selectedYear,
|
||||
}: {
|
||||
selectedDistrict: number | null,
|
||||
selectedYear: number | null,
|
||||
}) => {
|
||||
const { colorScheme } = useAppStore()
|
||||
|
||||
const { data: existingObjectsList } = useSWR(
|
||||
selectedYear && selectedDistrict ? `/general/objects/list?year=${selectedYear}&city_id=${selectedDistrict}&planning=0` : null,
|
||||
(url) => fetcher(url, BASE_URL.nest),
|
||||
{
|
||||
revalidateOnFocus: false
|
||||
}
|
||||
)
|
||||
|
||||
const { data: planningObjectsList } = useSWR(
|
||||
selectedYear && selectedDistrict ? `/general/objects/list?year=${selectedYear}&city_id=${selectedDistrict}&planning=1` : null,
|
||||
(url) => fetcher(url, BASE_URL.nest),
|
||||
{
|
||||
revalidateOnFocus: false
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ overflow: 'auto', maxWidth: '300px', width: '100%', marginTop: 'auto', zIndex: 1, backdropFilter: 'blur(8px)', backgroundColor: colorScheme === 'light' ? '#FFFFFFAA' : '#000000AA', borderRadius: '4px' }}
|
||||
>
|
||||
<Accordion collapsible>
|
||||
<AccordionItem value='existing'>
|
||||
<AccordionHeader>
|
||||
Легенда
|
||||
</AccordionHeader>
|
||||
|
||||
<AccordionPanel>
|
||||
<Accordion multiple collapsible>
|
||||
<AccordionItem value='existing'>
|
||||
<AccordionHeader>
|
||||
Существующие
|
||||
</AccordionHeader>
|
||||
|
||||
<AccordionPanel>
|
||||
{existingObjectsList && <LegendGroup objectsList={existingObjectsList} border='solid' />}
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value='planning'>
|
||||
<AccordionHeader>
|
||||
Планируемые
|
||||
</AccordionHeader>
|
||||
|
||||
<AccordionPanel>
|
||||
{planningObjectsList && <LegendGroup objectsList={planningObjectsList} border='dotted' />}
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const LegendGroup = ({
|
||||
objectsList,
|
||||
border
|
||||
}: {
|
||||
objectsList: { id: number, name: string, count: number, r: number | null, g: number | null, b: number | null }[],
|
||||
border: 'solid' | 'dotted'
|
||||
}) => {
|
||||
|
||||
const borderStyle = () => {
|
||||
switch (border) {
|
||||
case 'solid':
|
||||
return '2px solid black'
|
||||
case 'dotted':
|
||||
return '2px dotted black'
|
||||
default:
|
||||
return 'none'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
{objectsList.map(object => (
|
||||
<div style={{ display: 'flex', gap: '0.25rem', alignItems: 'center' }} key={object.id}>
|
||||
<ColorSwatch size='extra-small' style={{ border: borderStyle() }} color={`rgb(${object.r},${object.g},${object.b})`} value={`rgb(${object.r},${object.g},${object.b})`} />
|
||||
-
|
||||
<Text size={200}>{object.name}</Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MapLegend
|
||||
127
client/src/components/map/MapLineTest.tsx
Normal file
127
client/src/components/map/MapLineTest.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import 'ol/ol.css'
|
||||
import OlMap from 'ol/Map'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import TileLayer from 'ol/layer/Tile'
|
||||
import { OSM } from 'ol/source'
|
||||
import View from 'ol/View'
|
||||
import { transform } from 'ol/proj'
|
||||
import VectorLayer from 'ol/layer/Vector'
|
||||
import VectorSource from 'ol/source/Vector'
|
||||
import Feature from 'ol/Feature'
|
||||
import { LineString } from 'ol/geom'
|
||||
import { Stroke, Style, Text } from 'ol/style'
|
||||
import { Tab, TabList } from '@fluentui/react-components'
|
||||
|
||||
const center = [14443331.466543002, 8878970.176309839]
|
||||
|
||||
const style = new Style({
|
||||
stroke: new Stroke({ color: 'blue', width: 2 }),
|
||||
text: new Text({
|
||||
font: 'bold 14px',
|
||||
placement: 'line',
|
||||
offsetY: -14
|
||||
}),
|
||||
})
|
||||
|
||||
const MapLineTest = () => {
|
||||
const lines = [
|
||||
{ name: 'A', points: [[100, 100], [200, 200]] },
|
||||
{ name: 'B', points: [[200, 200], [300, 200]] },
|
||||
{ name: 'X', points: [[200, 200], [300, 300]] },
|
||||
{ name: 'L', points: [[300, 300], [350, 300]] },
|
||||
{ name: 'N', points: [[300, 300], [250, 300]] },
|
||||
{ name: 'I', points: [[300, 200], [300, 100]] },
|
||||
{ name: 'J', points: [[300, 100], [250, 50]] },
|
||||
{ name: 'C', points: [[300, 200], [400, 150]] },
|
||||
{ name: 'D', points: [[400, 150], [400, 100]] },
|
||||
]
|
||||
|
||||
const map = useRef<OlMap | null>(null)
|
||||
const vectorLayer = useRef(new VectorLayer({
|
||||
source: new VectorSource(),
|
||||
style: feature => {
|
||||
style.getText()?.setText(feature.get('label'))
|
||||
return style
|
||||
}
|
||||
}))
|
||||
|
||||
const mapElement = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
map.current = new OlMap({
|
||||
controls: [],
|
||||
layers: [
|
||||
new TileLayer({ source: new OSM(), properties: { id: uuidv4(), name: 'OpenStreetMap' } }),
|
||||
vectorLayer.current
|
||||
]
|
||||
})
|
||||
map.current.setTarget(mapElement.current as HTMLDivElement)
|
||||
map.current.setView(new View({
|
||||
center: transform([129.7466541, 62.083504], 'EPSG:4326', 'EPSG:3857'),
|
||||
zoom: 16,
|
||||
maxZoom: 21,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (map.current) {
|
||||
const graph = new Map<string, { node: string, distance: number }[]>()
|
||||
|
||||
// build graph adjacency list
|
||||
lines.forEach(({ points }) => {
|
||||
const [start, end] = points.map(pt => pt.join(','))
|
||||
const distance = Math.hypot(points[1][0] - points[0][0], points[1][1] - points[0][1])
|
||||
|
||||
if (!graph.has(start)) graph.set(start, [])
|
||||
if (!graph.has(end)) graph.set(end, [])
|
||||
|
||||
graph.get(start)?.push({ node: end, distance })
|
||||
graph.get(end)?.push({ node: start, distance })
|
||||
})
|
||||
|
||||
// perform DFS to calculate distances from "A"
|
||||
const startNode = lines[0].points[0].join(',')
|
||||
const distances = new Map<string, number>()
|
||||
const dfs = (node: string, currentDist: number) => {
|
||||
if (distances.has(node) && distances.get(node)! <= currentDist) return
|
||||
distances.set(node, currentDist)
|
||||
for (const { node: neighbor, distance } of graph.get(node) || []) {
|
||||
dfs(neighbor, currentDist + distance)
|
||||
}
|
||||
}
|
||||
dfs(startNode, 0)
|
||||
|
||||
// render features
|
||||
lines.forEach(({ name, points }) => {
|
||||
const lineCoords = points.map(point => [point[0] + center[0], point[1] + center[1]])
|
||||
const feature = new Feature(new LineString(lineCoords))
|
||||
|
||||
const lastNode = points[1].join(',')
|
||||
const label = `${name} ${feature.getGeometry()?.getLength().toFixed(2)} (${distances.get(lastNode)?.toFixed(1) ?? '∞'})`
|
||||
|
||||
feature.set('label', label)
|
||||
vectorLayer.current.getSource()?.addFeature(feature)
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const [selectedTab, setSelectedTab] = useState<string | unknown>('map')
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', width: '100%' }}>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<TabList selectedValue='map' onTabSelect={(_, data) => setSelectedTab(data.value)}>
|
||||
<Tab value='map'>
|
||||
Map
|
||||
</Tab>
|
||||
</TabList>
|
||||
{selectedTab === 'map' && <div style={{ width: '100%', height: '100%' }} ref={mapElement}></div>}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MapLineTest
|
||||
149
client/src/components/map/MapMode.tsx
Normal file
149
client/src/components/map/MapMode.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
import { cleanChanges, getChanges, Mode, setMode, useMapStore } from '../../store/map'
|
||||
import { IconCropLandscape, IconCropPortrait, IconEdit, IconEye, IconPrinter } from '@tabler/icons-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { PrintOrientation, setPrintOrientation, usePrintStore } from '../../store/print'
|
||||
import { Button, Menu, MenuItemRadio, MenuList, MenuPopover, MenuProps, MenuTrigger, SplitButton } from '@fluentui/react-components'
|
||||
import axiosInstance from '../../http/axiosInstanceNest'
|
||||
|
||||
const MapMode = ({
|
||||
map_id
|
||||
}: { map_id: string }) => {
|
||||
const { mode, changes, currentChange } = useMapStore().id[map_id]
|
||||
|
||||
const { printOrientation } = usePrintStore()
|
||||
|
||||
const [checkedValues, setCheckedValues] = useState<Record<string, string[]>>({ orientation: [printOrientation] })
|
||||
|
||||
const onChange: MenuProps["onCheckedValueChange"] = (
|
||||
_,
|
||||
{ name, checkedItems }
|
||||
) => {
|
||||
setCheckedValues((s) => ({ ...s, [name]: checkedItems }))
|
||||
setPrintOrientation(checkedItems[0] as PrintOrientation)
|
||||
setMode(map_id, 'print' as Mode)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (printOrientation) {
|
||||
setCheckedValues((s) => ({ ...s, ['orientation']: [printOrientation] }))
|
||||
}
|
||||
}, [printOrientation])
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '0.25rem',
|
||||
padding: '0.25rem',
|
||||
zIndex: 1,
|
||||
background: 'var(--colorNeutralBackground1)',
|
||||
borderRadius: 'var(--borderRadiusMedium)'
|
||||
}}>
|
||||
<Button
|
||||
appearance={mode === 'view' ? 'primary' : 'subtle'}
|
||||
key={'view'}
|
||||
onClick={() => {
|
||||
setMode(map_id, 'view' as Mode)
|
||||
}}
|
||||
icon={<IconEye size={16} />}
|
||||
//mod={{ active: mode === 'view' as Mode }}
|
||||
>
|
||||
Просмотр
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
appearance={mode === 'edit' ? 'primary' : 'subtle'}
|
||||
key={'edit'}
|
||||
onClick={() => {
|
||||
setMode(map_id, 'edit' as Mode)
|
||||
}}
|
||||
icon={<IconEdit size={16} />}
|
||||
//mod={{ active: mode === 'edit' as Mode }}
|
||||
>
|
||||
Редактирование
|
||||
</Button>
|
||||
|
||||
<Menu checkedValues={checkedValues} onCheckedValueChange={onChange}>
|
||||
<MenuTrigger>
|
||||
<SplitButton
|
||||
appearance={mode === 'print' ? 'primary' : 'subtle'}
|
||||
primaryActionButton={{
|
||||
onClick: () => setMode(map_id, 'print' as Mode)
|
||||
}}
|
||||
icon={<IconPrinter size={16} />}
|
||||
>
|
||||
Печать
|
||||
</SplitButton>
|
||||
</MenuTrigger>
|
||||
|
||||
<MenuPopover>
|
||||
<MenuList>
|
||||
<MenuItemRadio name='orientation' value='horizontal' icon={<IconCropLandscape style={{ display: 'block' }} />}>
|
||||
Горизонтальная
|
||||
</MenuItemRadio>
|
||||
<MenuItemRadio name='orientation' value='vertical' icon={<IconCropPortrait style={{ display: 'block' }} />}>
|
||||
Вертикальная
|
||||
</MenuItemRadio>
|
||||
</MenuList>
|
||||
</MenuPopover>
|
||||
</Menu>
|
||||
|
||||
{changes && currentChange && mode === 'edit' && <Button
|
||||
appearance={mode === 'edit' ? 'primary' : 'subtle'}
|
||||
key={'save'}
|
||||
// onClick={async () => {
|
||||
// setMode(map_id, 'edit' as Mode)
|
||||
|
||||
// const changes = getChanges(map_id).get(currentChange)
|
||||
|
||||
// if (changes) {
|
||||
// await axiosInstance.post(`/gis/features/update`, {
|
||||
// features: changes
|
||||
// },
|
||||
// {
|
||||
// baseURL: import.meta.env.VITE_API_NEST_URL,
|
||||
// }).then(() => {
|
||||
// cleanChanges(map_id)
|
||||
// })
|
||||
// }
|
||||
// }}
|
||||
onClick={async () => {
|
||||
setMode(map_id, 'edit' as Mode);
|
||||
|
||||
const changes = getChanges(map_id);
|
||||
const latestChanges = new Map();
|
||||
|
||||
// Process ALL changes in the map
|
||||
for (const change of changes.values()) {
|
||||
if (Array.isArray(change)) {
|
||||
change.forEach(c => {
|
||||
if (c.object_id) {
|
||||
latestChanges.set(c.object_id, c);
|
||||
}
|
||||
});
|
||||
} else if (change.object_id) {
|
||||
latestChanges.set(change.object_id, change);
|
||||
}
|
||||
}
|
||||
|
||||
const featuresToUpdate = Array.from(latestChanges.values());
|
||||
|
||||
if (featuresToUpdate.length > 0) {
|
||||
await axiosInstance.post(`/gis/features/update`, {
|
||||
features: featuresToUpdate
|
||||
}, {
|
||||
baseURL: import.meta.env.VITE_API_NEST_URL,
|
||||
}).then(() => {
|
||||
cleanChanges(map_id); // This clears the entire map
|
||||
});
|
||||
}
|
||||
}}
|
||||
icon={<IconEdit size={16} />}
|
||||
//mod={{ active: mode === 'edit' as Mode }}
|
||||
>
|
||||
Сохранить
|
||||
</Button>}
|
||||
</div >
|
||||
)
|
||||
}
|
||||
|
||||
export default MapMode
|
||||
@ -0,0 +1,58 @@
|
||||
import { Combobox, Option } from '@fluentui/react-components';
|
||||
import { useState } from 'react'
|
||||
import { setCurrentObjectId, useObjectsStore } from '../../../store/objects';
|
||||
import useSWR, { SWRConfiguration } from 'swr';
|
||||
import { useThrottle } from '@uidotdev/usehooks';
|
||||
import { BASE_URL } from '../../../constants';
|
||||
import { fetcher } from '../../../http/axiosInstanceNest';
|
||||
|
||||
const swrOptions: SWRConfiguration = {
|
||||
revalidateOnFocus: false
|
||||
}
|
||||
|
||||
const MapObjectSearch = ({
|
||||
map_id
|
||||
}: {
|
||||
map_id: string
|
||||
}) => {
|
||||
const [searchObject, setSearchObject] = useState<string | undefined>("")
|
||||
const throttledSearchObject = useThrottle(searchObject, 500)
|
||||
|
||||
const { selectedYear, selectedDistrict } = useObjectsStore().id[map_id]
|
||||
|
||||
const { data: searchData } = useSWR(
|
||||
throttledSearchObject !== "" && selectedDistrict && selectedYear ? `/general/search/objects?q=${throttledSearchObject}&id_city=${selectedDistrict}&year=${selectedYear}` : null,
|
||||
(url) => fetcher(url, BASE_URL.nest),
|
||||
swrOptions
|
||||
)
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
placeholder="Поиск"
|
||||
value={searchObject}
|
||||
onOptionSelect={(_ev, data) => {
|
||||
if (data.optionValue) {
|
||||
setCurrentObjectId(map_id, data.optionValue);
|
||||
setSearchObject(
|
||||
searchData?.find((item: any) => item.id_object.toString() === data.optionValue)?.value ?? ""
|
||||
);
|
||||
}
|
||||
}}
|
||||
onChange={(e) => {
|
||||
setSearchObject(e.currentTarget.value); // free typing like Mantine's onChange
|
||||
}}
|
||||
clearable
|
||||
style={{ minWidth: 'auto' }}
|
||||
>
|
||||
{searchData
|
||||
? searchData.map((item: { value: string; id_object: string }) => (
|
||||
<Option key={item.id_object} value={item.id_object.toString()}>
|
||||
{item.value}
|
||||
</Option>
|
||||
))
|
||||
: null}
|
||||
</Combobox>
|
||||
)
|
||||
}
|
||||
|
||||
export default MapObjectSearch
|
||||
234
client/src/components/map/MapPrint/MapPrint.tsx
Normal file
234
client/src/components/map/MapPrint/MapPrint.tsx
Normal file
@ -0,0 +1,234 @@
|
||||
import { IconHelp, IconWindowMaximize, IconWindowMinimize } from '@tabler/icons-react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { clearPrintArea, PrintScale, setPreviousView, setPrintScale, setPrintScaleLine, useMapStore } from '../../../store/map'
|
||||
import { PrintFormat, PrintOrientation, printResolutions, setPrintOrientation, setPrintResolution, usePrintStore } from '../../../store/print'
|
||||
import { printDimensions, scaleOptions } from '../../../constants/map'
|
||||
import { useObjectsStore } from '../../../store/objects'
|
||||
import jsPDF from 'jspdf'
|
||||
import { getCenter } from 'ol/extent'
|
||||
import ScaleLine from 'ol/control/ScaleLine'
|
||||
import { Button, Checkbox, Dialog, DialogActions, DialogBody, DialogContent, DialogSurface, DialogTitle, Dropdown, Field, Option, Radio, RadioGroup } from '@fluentui/react-components'
|
||||
import { Dismiss24Regular } from '@fluentui/react-icons'
|
||||
|
||||
const MapPrint = ({
|
||||
id,
|
||||
mapElement
|
||||
}: {
|
||||
id: string
|
||||
mapElement: React.MutableRefObject<HTMLDivElement | null>
|
||||
}) => {
|
||||
const [fullscreen, setFullscreen] = useState(false)
|
||||
const { printOrientation, printResolution, printFormat } = usePrintStore()
|
||||
const { selectedYear, selectedRegion, selectedDistrict } = useObjectsStore().id[id]
|
||||
|
||||
const {
|
||||
map,
|
||||
mode,
|
||||
previousView, printArea, printSource, printAreaDraw, printScale, printScaleLine,
|
||||
} = useMapStore().id[id]
|
||||
|
||||
const exportToPDF = (format: PrintFormat, resolution: number, orientation: PrintOrientation) => {
|
||||
const dim = printDimensions[format]
|
||||
|
||||
const width = Math.round((dim[orientation === 'horizontal' ? 0 : 1] * resolution) / 25.4)
|
||||
const height = Math.round((dim[orientation === 'horizontal' ? 1 : 0] * resolution) / 25.4)
|
||||
|
||||
if (!map) return
|
||||
|
||||
// Store original size and scale
|
||||
const originalSize = map.getSize()
|
||||
const originalResolution = map.getView().getResolution()
|
||||
|
||||
if (!originalSize || !originalResolution) return
|
||||
|
||||
// Calculate new resolution to fit high DPI
|
||||
const scaleFactor = width / originalSize[0]
|
||||
const newResolution = originalResolution / scaleFactor
|
||||
|
||||
// Set new high-resolution rendering
|
||||
map.setSize([width, height])
|
||||
map.getView().setResolution(newResolution)
|
||||
map.renderSync()
|
||||
|
||||
map.once("rendercomplete", function () {
|
||||
const mapCanvas = document.createElement("canvas")
|
||||
mapCanvas.width = width
|
||||
mapCanvas.height = height
|
||||
const mapContext = mapCanvas.getContext("2d")
|
||||
|
||||
if (!mapContext) return
|
||||
|
||||
const canvas = document.querySelector('canvas')
|
||||
if (canvas) {
|
||||
if (canvas.width > 0) {
|
||||
const opacity = canvas.parentElement?.style.opacity || "1"
|
||||
mapContext.globalAlpha = parseFloat(opacity)
|
||||
|
||||
const transform = canvas.style.transform
|
||||
const matrixMatch = transform.match(/^matrix\(([^)]+)\)$/)
|
||||
if (matrixMatch) {
|
||||
const matrix = matrixMatch[1].split(",").map(Number)
|
||||
mapContext.setTransform(...matrix as [number, number, number, number, number, number])
|
||||
}
|
||||
|
||||
mapContext.drawImage(canvas, 0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
mapContext.globalAlpha = 1
|
||||
mapContext.setTransform(1, 0, 0, 1, 0, 0)
|
||||
|
||||
// Restore original map settings
|
||||
map.setSize(originalSize)
|
||||
map.getView().setResolution(originalResolution)
|
||||
map.renderSync()
|
||||
|
||||
const dimensions = {
|
||||
w: orientation === 'horizontal' ? dim[0] : dim[1],
|
||||
h: orientation === 'horizontal' ? dim[1] : dim[0]
|
||||
}
|
||||
|
||||
// Generate PDF
|
||||
const pdf = new jsPDF(orientation === 'horizontal' ? "landscape" : 'portrait', undefined, format)
|
||||
pdf.addImage(mapCanvas.toDataURL("image/jpeg"), "JPEG", 0, 0, dimensions.w, dimensions.h)
|
||||
|
||||
const filename = `${selectedYear}-${selectedRegion}-${selectedDistrict}-${new Date().toISOString()}.pdf`
|
||||
pdf.save(filename, {
|
||||
returnPromise: true
|
||||
}).then(() => {
|
||||
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const scaleLine = useRef(new ScaleLine({
|
||||
bar: true,
|
||||
text: true,
|
||||
minWidth: 125
|
||||
}))
|
||||
|
||||
const [opened, setOpened] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (printArea && opened) {
|
||||
// backup view before entering print mode
|
||||
setPreviousView(id, map?.getView())
|
||||
|
||||
map?.setTarget('print-portal')
|
||||
|
||||
printSource.clear()
|
||||
map?.getView().setCenter(getCenter(printArea))
|
||||
map?.getView().fit(printArea, {
|
||||
size: printOrientation === 'horizontal' ? [594, 420] : [420, 594]
|
||||
})
|
||||
map?.removeInteraction(printAreaDraw)
|
||||
}
|
||||
}, [printArea, map, opened])
|
||||
|
||||
useEffect(() => {
|
||||
if (printScaleLine && printArea) {
|
||||
map?.addControl(scaleLine.current)
|
||||
} else {
|
||||
map?.removeControl(scaleLine.current)
|
||||
}
|
||||
}, [printScaleLine, printArea])
|
||||
|
||||
useEffect(() => {
|
||||
if (!!printArea) {
|
||||
setOpened(true)
|
||||
}
|
||||
}, [printArea])
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened && mode === 'print') {
|
||||
clearPrintArea(id)
|
||||
map?.setTarget(mapElement.current as HTMLDivElement)
|
||||
map?.addInteraction(printAreaDraw)
|
||||
}
|
||||
}, [opened])
|
||||
|
||||
return (
|
||||
<Dialog open={opened}>
|
||||
<DialogSurface style={{ maxWidth: fullscreen ? '100%' : 'fit-content', maxHeight: fullscreen ? '100%' : 'fit-content' }}>
|
||||
<DialogBody>
|
||||
<DialogTitle action={
|
||||
<div style={{ display: 'flex', marginLeft: 'auto', gap: '1.5rem' }}>
|
||||
<Button appearance='subtle' title='Помощь' style={{ marginLeft: 'auto' }} icon={<IconHelp color='gray' />} />
|
||||
|
||||
<Button appearance='subtle' title={fullscreen ? 'Свернуть' : 'Развернуть'} style={{ marginLeft: 'auto' }} icon={fullscreen ? <IconWindowMinimize color='gray' /> : <IconWindowMaximize color='gray' />} onClick={() => setFullscreen(!fullscreen)} />
|
||||
|
||||
<Button appearance='subtle' title='Закрыть' icon={<Dismiss24Regular />} onClick={() => setOpened(false)} />
|
||||
</div>
|
||||
}>Предпросмотр области печати</DialogTitle>
|
||||
|
||||
<DialogContent style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<div style={{ display: 'flex', width: 'fit-content', flexDirection: 'row', alignItems: 'flex-start', height: 'fit-content', overflowY: 'auto' }}>
|
||||
<div id='print-portal' style={{
|
||||
width: printOrientation === 'horizontal' ? '594px' : '420px',
|
||||
height: printOrientation === 'horizontal' ? '420px' : '594px',
|
||||
flexShrink: '0'
|
||||
}}>
|
||||
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', width: '100%', flexWrap: 'wrap', gap: '1rem', padding: '1rem', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<Field label={'Ориентация'}>
|
||||
<RadioGroup value={printOrientation} onChange={(_, data) => setPrintOrientation(data.value as PrintOrientation)}>
|
||||
<Radio value='horizontal' label='Горизонтальная' />
|
||||
<Radio value='vertical' label='Вертикальная' />
|
||||
</RadioGroup>
|
||||
</Field>
|
||||
|
||||
<Field label="Разрешение">
|
||||
<Dropdown
|
||||
value={printResolution.toString()}
|
||||
selectedOptions={[printResolution.toString()]}
|
||||
onOptionSelect={(_, data) => setPrintResolution(Number(data.optionValue))}
|
||||
>
|
||||
{printResolutions.map((res) => (
|
||||
<Option key={res} text={res} value={res}>
|
||||
{res}
|
||||
</Option>
|
||||
))}
|
||||
</Dropdown>
|
||||
</Field>
|
||||
|
||||
|
||||
<Field label="Масштаб">
|
||||
<Dropdown
|
||||
value={printScale.toString()}
|
||||
selectedOptions={[printScale]}
|
||||
onOptionSelect={(_, data) => setPrintScale(id, data.optionValue as PrintScale)}
|
||||
>
|
||||
{scaleOptions.map((opt) => (
|
||||
<Option key={opt.value} text={opt.label} value={opt.value}>
|
||||
{opt.label}
|
||||
</Option>
|
||||
))}
|
||||
</Dropdown>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Checkbox
|
||||
checked={printScaleLine}
|
||||
label="Масштабная линия"
|
||||
onChange={(event) => setPrintScaleLine(id, event.currentTarget.checked)}
|
||||
/>
|
||||
<Button style={{ marginLeft: 'auto' }} onClick={() => {
|
||||
if (previousView) {
|
||||
exportToPDF(printFormat, printResolution, printOrientation)
|
||||
}
|
||||
}}>
|
||||
Печать
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogBody>
|
||||
</DialogSurface>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default MapPrint
|
||||
172
client/src/components/map/MapRegionSelect/MapRegionSelect.tsx
Normal file
172
client/src/components/map/MapRegionSelect/MapRegionSelect.tsx
Normal file
@ -0,0 +1,172 @@
|
||||
import { Button, Drawer, DrawerBody, DrawerHeader, Dropdown, Field, Link, Option, Text } from '@fluentui/react-components'
|
||||
import { ArrowLeft24Regular } from '@fluentui/react-icons'
|
||||
import { setSelectedDistrict, setSelectedRegion, setSelectedYear, useObjectsStore } from '../../../store/objects'
|
||||
import { useMapStore } from '../../../store/map'
|
||||
import { fromExtent } from 'ol/geom/Polygon'
|
||||
import { getDistrictData, getRegionData, setDistrictsData, useRegionsStore } from '../../../store/regions'
|
||||
import { IconX } from '@tabler/icons-react'
|
||||
import { IDistrict, IRegion } from '../../../interfaces/gis'
|
||||
import { getFeatureByEntityId } from '../mapUtils'
|
||||
import useSWR, { SWRConfiguration } from 'swr'
|
||||
import { fetcher } from '../../../http/axiosInstanceNest'
|
||||
import { BASE_URL } from '../../../constants'
|
||||
import { schemas } from '../../../constants/map'
|
||||
|
||||
const swrOptions: SWRConfiguration = {
|
||||
revalidateOnFocus: false
|
||||
}
|
||||
|
||||
const MapRegionSelect = ({
|
||||
map_id
|
||||
}: {
|
||||
map_id: string
|
||||
}) => {
|
||||
const { map, districtSelect, regionsLayer, regionSelect, districtsLayer } = useMapStore().id[map_id]
|
||||
const { selectedRegion, selectedYear, selectedDistrict } = useObjectsStore().id[map_id]
|
||||
const { regionsData } = useRegionsStore()
|
||||
|
||||
const { data: districtsData } = useSWR(selectedRegion ? `/general/districts/?region_id=${selectedRegion}` : null, (url) => fetcher(url, BASE_URL.nest).then(res => {
|
||||
setDistrictsData(res)
|
||||
return res
|
||||
}), swrOptions)
|
||||
|
||||
return (
|
||||
<Drawer style={{ position: 'absolute', width: '300px', height: '100%', inset: 0, zIndex: 1 }} open={!selectedRegion || (!!selectedRegion && !selectedYear)} type='inline'>
|
||||
{!!selectedRegion && !selectedYear &&
|
||||
<DrawerHeader style={{ flexDirection: 'row' }}>
|
||||
<Button icon={<ArrowLeft24Regular />} appearance='subtle' onClick={() => {
|
||||
if (selectedDistrict) {
|
||||
setSelectedDistrict(map_id, null)
|
||||
districtSelect.getFeatures().clear()
|
||||
regionsLayer.setOpacity(1)
|
||||
} else {
|
||||
setSelectedRegion(map_id, null)
|
||||
regionSelect.getFeatures().clear()
|
||||
|
||||
if (map) {
|
||||
const extent = regionsLayer.getSource()?.getExtent()
|
||||
|
||||
if (extent) {
|
||||
map.getView().fit(fromExtent(extent), { duration: 100 })
|
||||
regionsLayer.setOpacity(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}} />
|
||||
|
||||
{selectedDistrict ?
|
||||
<Text weight='bold' size={500}>{getDistrictData(selectedDistrict)?.name}</Text>
|
||||
:
|
||||
<Text weight='bold' size={500}>{selectedRegion && getRegionData(selectedRegion)?.name}</Text>}
|
||||
|
||||
<Button appearance='subtle' style={{ marginLeft: 'auto' }} icon={<IconX />} onClick={() => {
|
||||
setSelectedYear(map_id, null)
|
||||
setSelectedDistrict(map_id, null)
|
||||
setSelectedRegion(map_id, null)
|
||||
|
||||
if (map) {
|
||||
const extent = regionsLayer.getSource()?.getExtent()
|
||||
|
||||
if (extent) {
|
||||
map.getView().fit(fromExtent(extent), { duration: 100 })
|
||||
regionsLayer.setOpacity(1)
|
||||
}
|
||||
}
|
||||
}} />
|
||||
</DrawerHeader>
|
||||
}
|
||||
|
||||
{!!selectedRegion && !selectedYear ?
|
||||
<DrawerBody>
|
||||
<div key={selectedRegion} style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
{selectedDistrict ?
|
||||
selectedRegion && Object.entries(getRegionData(selectedRegion) as IRegion).map(([key, value]) => (
|
||||
<div key={key} style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>{key}</span>
|
||||
<span>{value}</span>
|
||||
</div>
|
||||
))
|
||||
:
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<div>
|
||||
{selectedRegion && Object.entries(getRegionData(selectedRegion) as IRegion).map(([key, value]) => (
|
||||
<div key={key} style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>{key}</span>
|
||||
<span>{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{districtsData && districtsData.map((district: IDistrict) => (
|
||||
<Link key={district.id} onClick={() => {
|
||||
setSelectedDistrict(map_id, district.id)
|
||||
map?.removeInteraction(districtSelect)
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
const feature = getFeatureByEntityId(district.id, districtsLayer)
|
||||
|
||||
if (feature) {
|
||||
districtSelect.getFeatures().push(feature)
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
districtSelect.getFeatures().clear()
|
||||
}}
|
||||
>{district.name}</Link>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
|
||||
{selectedDistrict &&
|
||||
<Field label="Схема" >
|
||||
<Dropdown
|
||||
style={{ minWidth: 'auto' }}
|
||||
value={selectedYear ? selectedYear.toString() : ""}
|
||||
selectedOptions={[selectedYear ? selectedYear.toString() : ""]}
|
||||
onOptionSelect={(_ev, data) => {
|
||||
if (data.optionValue) {
|
||||
setSelectedYear(map_id, Number(data.optionValue));
|
||||
} else {
|
||||
setSelectedYear(map_id, null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{schemas.map((el) => (
|
||||
<Option key={el} value={el} text={el}>
|
||||
{el}
|
||||
</Option>
|
||||
))}
|
||||
</Dropdown>
|
||||
</Field>
|
||||
}
|
||||
</div>
|
||||
</DrawerBody>
|
||||
:
|
||||
<DrawerBody>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
{regionsData && regionsData.map((region: IRegion) => (
|
||||
<Link key={region.id} onClick={() => {
|
||||
setSelectedRegion(map_id, region.id)
|
||||
map?.removeInteraction(regionSelect)
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
const feature = getFeatureByEntityId(region.id, regionsLayer)
|
||||
|
||||
if (feature) {
|
||||
regionSelect.getFeatures().push(feature)
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
regionSelect.getFeatures().clear()
|
||||
}}
|
||||
>{region.name}</Link>
|
||||
))}
|
||||
</div>
|
||||
</DrawerBody>
|
||||
}
|
||||
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
export default MapRegionSelect
|
||||
@ -26,7 +26,7 @@ const customMapSource = new XYZ({
|
||||
})
|
||||
|
||||
const regionsLayerSource = new VectorSource({
|
||||
url: 'sakha_republic.geojson',
|
||||
url: 'http://localhost:5000/gis/bounds/region',
|
||||
format: new GeoJSON(),
|
||||
})
|
||||
|
||||
|
||||
@ -1,44 +1,43 @@
|
||||
import { Divider, Flex, rem, Text } from '@mantine/core'
|
||||
import { CSSProperties } from 'react'
|
||||
import { useMapStore } from '../../../store/map';
|
||||
import { Divider, Text } from '@fluentui/react-components';
|
||||
|
||||
interface IMapStatusbarProps {
|
||||
mapControlsStyle: CSSProperties;
|
||||
map_id: string;
|
||||
}
|
||||
|
||||
const MapStatusbar = ({
|
||||
mapControlsStyle,
|
||||
map_id
|
||||
}: IMapStatusbarProps) => {
|
||||
const mapState = useMapStore()
|
||||
const { currentCoordinate, currentX, currentY, currentZ, statusText } = useMapStore().id[map_id]
|
||||
|
||||
return (
|
||||
<Flex gap='sm' p={'4px'} miw={'100%'} fz={'xs'} pos='absolute' bottom='0px' left='0px' style={{ ...mapControlsStyle, borderRadius: 0 }}>
|
||||
<Text fz='xs' w={rem(130)}>
|
||||
x: {mapState.currentCoordinate?.[0]}
|
||||
<div style={{ display: 'flex', gap: '1rem', padding: '0.25rem', width: '100%', borderRadius: 0, zIndex: 1 }}>
|
||||
<Text size={200}>
|
||||
x: {currentCoordinate?.[0]}
|
||||
</Text>
|
||||
|
||||
<Text fz='xs' w={rem(130)}>
|
||||
y: {mapState.currentCoordinate?.[1]}
|
||||
<Text size={200}>
|
||||
y: {currentCoordinate?.[1]}
|
||||
</Text>
|
||||
|
||||
<Divider orientation='vertical' />
|
||||
<Divider vertical />
|
||||
|
||||
<Text fz='xs'>
|
||||
Z={mapState.currentZ}
|
||||
<Text size={200}>
|
||||
Z={currentZ}
|
||||
</Text>
|
||||
|
||||
<Text fz='xs'>
|
||||
X={mapState.currentX}
|
||||
<Text size={200}>
|
||||
X={currentX}
|
||||
</Text>
|
||||
|
||||
<Text fz='xs'>
|
||||
Y={mapState.currentY}
|
||||
<Text size={200}>
|
||||
Y={currentY}
|
||||
</Text>
|
||||
|
||||
<Text fz='xs' ml='auto'>
|
||||
{mapState.statusText}
|
||||
<Text size={200} style={{ marginLeft: 'auto' }}>
|
||||
{statusText}
|
||||
</Text>
|
||||
</Flex>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import Feature, { FeatureLike } from "ol/Feature";
|
||||
import { FeatureLike } from "ol/Feature";
|
||||
import { Text } from "ol/style";
|
||||
import Fill from "ol/style/Fill";
|
||||
import { FlatStyleLike } from "ol/style/flat";
|
||||
@ -10,11 +10,11 @@ import { MultiPoint, Point } from "ol/geom";
|
||||
|
||||
export const highlightStyleYellow = new Style({
|
||||
stroke: new Stroke({
|
||||
color: 'yellow',
|
||||
color: 'blue',
|
||||
width: 3,
|
||||
}),
|
||||
fill: new Fill({
|
||||
color: 'rgba(255, 255, 0, 0.3)',
|
||||
color: 'rgba(0, 0, 255, 0.5)',
|
||||
}),
|
||||
});
|
||||
|
||||
@ -90,101 +90,39 @@ export function overlayStyle(feature: FeatureLike) {
|
||||
return styles
|
||||
}
|
||||
|
||||
export function styleFunction(feature: Feature) {
|
||||
return [
|
||||
new Style({
|
||||
fill: new Fill({
|
||||
color: 'rgba(255,255,255,0.4)'
|
||||
}),
|
||||
stroke: new Stroke({
|
||||
color: '#3399CC',
|
||||
width: 1.25
|
||||
}),
|
||||
text: new Text({
|
||||
font: '12px Calibri,sans-serif',
|
||||
fill: new Fill({ color: '#000' }),
|
||||
stroke: new Stroke({
|
||||
color: '#fff', width: 2
|
||||
}),
|
||||
// get the text from the feature - `this` is ol.Feature
|
||||
// and show only under certain resolution
|
||||
text: feature.get('object_id')
|
||||
})
|
||||
const figureStyle = new Style({
|
||||
fill: new Fill({
|
||||
color: 'rgba(255,255,255,0.4)'
|
||||
}),
|
||||
stroke: new Stroke({
|
||||
color: 'black',
|
||||
width: 1.25
|
||||
}),
|
||||
text: new Text({
|
||||
font: '12px Calibri,sans-serif',
|
||||
fill: new Fill({ color: '#000' }),
|
||||
stroke: new Stroke({
|
||||
color: '#fff', width: 2
|
||||
})
|
||||
];
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
export function firstStyleFunction(feature: Feature) {
|
||||
return [
|
||||
new Style({
|
||||
fill: new Fill({
|
||||
color: 'rgba(255,255,255,0.4)'
|
||||
}),
|
||||
stroke: new Stroke({
|
||||
color: 'red',
|
||||
width: 1.25
|
||||
}),
|
||||
text: new Text({
|
||||
font: '12px Calibri,sans-serif',
|
||||
fill: new Fill({ color: '#000' }),
|
||||
stroke: new Stroke({
|
||||
color: '#fff', width: 2
|
||||
}),
|
||||
// get the text from the feature - `this` is ol.Feature
|
||||
// and show only under certain resolution
|
||||
text: feature.get('object_id')
|
||||
})
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
export function thirdStyleFunction(feature: Feature) {
|
||||
return [
|
||||
new Style({
|
||||
fill: new Fill({
|
||||
color: 'rgba(255,255,255,0.4)'
|
||||
}),
|
||||
stroke: new Stroke({
|
||||
color: '#33ccb3',
|
||||
width: 1.25
|
||||
}),
|
||||
text: new Text({
|
||||
font: '12px Calibri,sans-serif',
|
||||
fill: new Fill({ color: '#000' }),
|
||||
stroke: new Stroke({
|
||||
color: '#fff', width: 2
|
||||
}),
|
||||
// get the text from the feature - `this` is ol.Feature
|
||||
// and show only under certain resolution
|
||||
text: feature.get('object_id')
|
||||
})
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
export function fourthStyleFunction(feature: Feature) {
|
||||
return [
|
||||
new Style({
|
||||
fill: new Fill({
|
||||
color: 'rgba(255,255,255,0.4)'
|
||||
}),
|
||||
stroke: new Stroke({
|
||||
color: '#3399CC',
|
||||
width: 1.25
|
||||
}),
|
||||
text: new Text({
|
||||
font: '12px Calibri,sans-serif',
|
||||
fill: new Fill({ color: '#000' }),
|
||||
stroke: new Stroke({
|
||||
color: '#fff', width: 2
|
||||
}),
|
||||
// get the text from the feature - `this` is ol.Feature
|
||||
// and show only under certain resolution
|
||||
text: `${feature.get('object_id')}\n ${feature.get('angle')}`
|
||||
})
|
||||
})
|
||||
];
|
||||
}
|
||||
const lineStyle = new Style({
|
||||
stroke: new Stroke({
|
||||
color: '#3399CC',
|
||||
width: 1
|
||||
}),
|
||||
text: new Text({
|
||||
font: '12px Calibri,sans-serif',
|
||||
fill: new Fill({ color: '#000' }),
|
||||
stroke: new Stroke({
|
||||
color: '#fff', width: 2
|
||||
}),
|
||||
placement: 'line',
|
||||
overflow: true,
|
||||
//declutterMode: 'obstacle'
|
||||
})
|
||||
})
|
||||
|
||||
const drawingLayerStyle: FlatStyleLike = {
|
||||
'fill-color': 'rgba(255, 255, 255, 0.2)',
|
||||
@ -211,12 +149,14 @@ const regionsLayerStyle = new Style({
|
||||
width: 1,
|
||||
}),
|
||||
fill: new Fill({
|
||||
color: 'rgba(0, 0, 255, 0.1)',
|
||||
color: 'rgba(0, 0, 255, 0.01)',
|
||||
}),
|
||||
})
|
||||
|
||||
export {
|
||||
drawingLayerStyle,
|
||||
selectStyle,
|
||||
regionsLayerStyle
|
||||
regionsLayerStyle,
|
||||
lineStyle,
|
||||
figureStyle
|
||||
}
|
||||
@ -1,93 +1,153 @@
|
||||
import { ActionIcon, useMantineColorScheme } from '@mantine/core'
|
||||
import { IconArrowBackUp, IconArrowsMove, IconCircle, IconExclamationCircle, IconLine, IconPoint, IconPolygon, IconRuler, IconTransformPoint } from '@tabler/icons-react'
|
||||
import { setCurrentTool, useMapStore } from '../../../store/map';
|
||||
|
||||
interface IToolbarProps {
|
||||
onSave: () => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
import { IconArrowBackUp, IconArrowForwardUp, IconArrowsMove, IconCircle, IconExclamationCircle, IconLine, IconPoint, IconPolygon } from '@tabler/icons-react'
|
||||
import { getChanges, getCurrentChange, setCurrentChange, setCurrentTool, useMapStore } from '../../../store/map';
|
||||
import { saveFeatures } from '../mapUtils';
|
||||
import { Button, Tooltip } from '@fluentui/react-components';
|
||||
import { useAppStore } from '../../../store/app';
|
||||
import { useObjectsStore } from '../../../store/objects';
|
||||
import { AddFilled, RulerRegular, SelectObjectRegular, SelectObjectSkewEditRegular, SubtractFilled } from '@fluentui/react-icons';
|
||||
|
||||
const MapToolbar = ({
|
||||
onSave,
|
||||
onRemove,
|
||||
}: IToolbarProps) => {
|
||||
const mapState = useMapStore()
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
map_id
|
||||
}: { map_id: string }) => {
|
||||
const { currentTool, mode, map, selectionDragBox } = useMapStore().id[map_id]
|
||||
const { selectedRegion, selectedDistrict, selectedYear } = useObjectsStore().id[map_id]
|
||||
const { colorScheme } = useAppStore();
|
||||
|
||||
const getEntries = () => {
|
||||
const entries = Array.from(getChanges(map_id).entries())
|
||||
const currentIndex = entries.findIndex(([key]) => key === getCurrentChange(map_id))
|
||||
|
||||
let prevEntry = undefined
|
||||
let currentEntry = undefined
|
||||
let nextEntry = undefined
|
||||
|
||||
if (currentIndex !== -1) {
|
||||
prevEntry = currentIndex > 0 ? entries[currentIndex - 1] : undefined;
|
||||
nextEntry = currentIndex < entries.length - 1 ? entries[currentIndex + 1] : undefined;
|
||||
|
||||
currentEntry = entries[currentIndex]
|
||||
}
|
||||
|
||||
return { prevEntry: prevEntry, currentEntry: currentEntry, nextEntry: nextEntry }
|
||||
}
|
||||
|
||||
return (
|
||||
<ActionIcon.Group orientation='vertical' pos='absolute' top='8px' right='8px' style={{ zIndex: 1, backdropFilter: 'blur(8px)', backgroundColor: colorScheme === 'light' ? '#FFFFFFAA' : '#000000AA', borderRadius: '4px' }}>
|
||||
<ActionIcon size='lg' variant='transparent' onClick={onSave}>
|
||||
<IconExclamationCircle />
|
||||
</ActionIcon>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', zIndex: 1, backdropFilter: 'blur(8px)', backgroundColor: colorScheme === 'light' ? '#FFFFFFAA' : '#000000AA', borderRadius: '4px' }}>
|
||||
{selectedRegion && selectedDistrict && selectedYear && mode === 'edit' &&
|
||||
<>
|
||||
<Button icon={<IconExclamationCircle />} appearance='transparent' onClick={() => saveFeatures(map_id)} />
|
||||
|
||||
<ActionIcon size='lg' variant='transparent' onClick={onRemove}>
|
||||
<IconArrowBackUp />
|
||||
</ActionIcon>
|
||||
<Tooltip content={"Отмена"} relationship='label' hideDelay={0} showDelay={0} withArrow>
|
||||
<Button disabled={getEntries().prevEntry ? false : true} icon={<IconArrowBackUp />} appearance='transparent' onClick={() => {
|
||||
//getDraw(map_id)?.removeLastPoint()
|
||||
const { prevEntry } = getEntries()
|
||||
if (prevEntry) {
|
||||
console.log(prevEntry)
|
||||
setCurrentChange(map_id, prevEntry[0])
|
||||
}
|
||||
}
|
||||
} />
|
||||
</Tooltip>
|
||||
|
||||
<ActionIcon
|
||||
size='lg'
|
||||
variant={mapState.currentTool === 'Edit' ? 'filled' : 'transparent'}
|
||||
onClick={() => {
|
||||
setCurrentTool('Edit')
|
||||
}}>
|
||||
<IconTransformPoint />
|
||||
</ActionIcon>
|
||||
<Tooltip content={"Повтор"} relationship='label' hideDelay={0} showDelay={0} withArrow>
|
||||
<Button disabled={getEntries().nextEntry ? false : true} icon={<IconArrowForwardUp />} appearance='transparent' onClick={() => {
|
||||
//getDraw(map_id)?.removeLastPoint()
|
||||
const { nextEntry } = getEntries()
|
||||
if (nextEntry) {
|
||||
console.log(nextEntry)
|
||||
setCurrentChange(map_id, nextEntry[0])
|
||||
}
|
||||
}
|
||||
} />
|
||||
</Tooltip>
|
||||
|
||||
<ActionIcon
|
||||
size='lg'
|
||||
variant={mapState.currentTool === 'Point' ? 'filled' : 'transparent'}
|
||||
onClick={() => {
|
||||
setCurrentTool('Point')
|
||||
}}>
|
||||
<IconPoint />
|
||||
</ActionIcon>
|
||||
<Tooltip content={"Редактировать"} relationship='label' hideDelay={0} showDelay={0} withArrow>
|
||||
<Button icon={<SelectObjectSkewEditRegular />} appearance={currentTool === 'Edit' ? 'primary' : 'transparent'} onClick={() => {
|
||||
setCurrentTool(map_id, 'Edit')
|
||||
}} />
|
||||
</Tooltip>
|
||||
|
||||
<ActionIcon
|
||||
size='lg'
|
||||
variant={mapState.currentTool === 'LineString' ? 'filled' : 'transparent'}
|
||||
onClick={() => {
|
||||
setCurrentTool('LineString')
|
||||
}}>
|
||||
<IconLine />
|
||||
</ActionIcon>
|
||||
<Tooltip content={"Выделение"} relationship='label' hideDelay={0} showDelay={0} withArrow>
|
||||
<Button icon={<SelectObjectRegular />} appearance={currentTool === 'Selection' ? 'primary' : 'transparent'} onClick={() => {
|
||||
if (currentTool === 'Selection') {
|
||||
map?.removeInteraction(selectionDragBox)
|
||||
setCurrentTool(map_id, null)
|
||||
} else {
|
||||
setCurrentTool(map_id, 'Selection')
|
||||
}
|
||||
}} />
|
||||
</Tooltip>
|
||||
|
||||
<ActionIcon
|
||||
size='lg'
|
||||
variant={mapState.currentTool === 'Polygon' ? 'filled' : 'transparent'}
|
||||
onClick={() => {
|
||||
setCurrentTool('Polygon')
|
||||
}}>
|
||||
<IconPolygon />
|
||||
</ActionIcon>
|
||||
<Tooltip content={"Точка"} relationship='label' hideDelay={0} showDelay={0} withArrow>
|
||||
<Button icon={<IconPoint />} appearance={currentTool === 'Point' ? 'primary' : 'transparent'} onClick={() => {
|
||||
setCurrentTool(map_id, 'Point')
|
||||
}} />
|
||||
</Tooltip>
|
||||
|
||||
<ActionIcon
|
||||
size='lg'
|
||||
variant={mapState.currentTool === 'Circle' ? 'filled' : 'transparent'}
|
||||
onClick={() => {
|
||||
setCurrentTool('Circle')
|
||||
}}>
|
||||
<IconCircle />
|
||||
</ActionIcon>
|
||||
<Tooltip content={"Линия"} relationship='label' hideDelay={0} showDelay={0} withArrow>
|
||||
<Button icon={<IconLine />} appearance={currentTool === 'LineString' ? 'primary' : 'transparent'} onClick={() => {
|
||||
setCurrentTool(map_id, 'LineString')
|
||||
}} />
|
||||
</Tooltip>
|
||||
|
||||
<ActionIcon
|
||||
size='lg'
|
||||
variant={mapState.currentTool === 'Mover' ? 'filled' : 'transparent'}
|
||||
onClick={() => {
|
||||
setCurrentTool('Mover')
|
||||
}}
|
||||
>
|
||||
<IconArrowsMove />
|
||||
</ActionIcon>
|
||||
<Tooltip content={"Многоугольник"} relationship='label' hideDelay={0} showDelay={0} withArrow>
|
||||
<Button icon={<IconPolygon />} appearance={currentTool === 'Polygon' ? 'primary' : 'transparent'} onClick={() => {
|
||||
setCurrentTool(map_id, 'Polygon')
|
||||
}} />
|
||||
</Tooltip>
|
||||
|
||||
<ActionIcon
|
||||
size='lg'
|
||||
variant={mapState.currentTool === 'Measure' ? 'filled' : 'transparent'}
|
||||
onClick={() => {
|
||||
setCurrentTool('Measure')
|
||||
}}>
|
||||
<IconRuler />
|
||||
</ActionIcon>
|
||||
</ActionIcon.Group>
|
||||
<Tooltip content={"Окружность"} relationship='label' hideDelay={0} showDelay={0} withArrow>
|
||||
<Button icon={<IconCircle />} appearance={currentTool === 'Circle' ? 'primary' : 'transparent'} onClick={() => {
|
||||
setCurrentTool(map_id, 'Circle')
|
||||
}} />
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content={"Перемещение"} relationship='label' hideDelay={0} showDelay={0} withArrow>
|
||||
<Button icon={<IconArrowsMove />} appearance={currentTool === 'Mover' ? 'primary' : 'transparent'} onClick={() => {
|
||||
setCurrentTool(map_id, 'Mover')
|
||||
}} />
|
||||
</Tooltip>
|
||||
</>}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', zIndex: 1, backdropFilter: 'blur(8px)', backgroundColor: colorScheme === 'light' ? '#FFFFFFAA' : '#000000AA', borderRadius: '4px' }}>
|
||||
<Tooltip content={"Приблизить"} relationship='label' hideDelay={0} showDelay={0} withArrow>
|
||||
<Button icon={<AddFilled />} appearance={'transparent'} onClick={() => {
|
||||
const currentZoom = map?.getView().getZoom()
|
||||
if (currentZoom) {
|
||||
// map?.getView().setZoom(currentZoom + 1)
|
||||
map?.getView().animate({
|
||||
zoom: currentZoom + 1,
|
||||
duration: 100
|
||||
})
|
||||
}
|
||||
}} />
|
||||
</Tooltip>
|
||||
|
||||
|
||||
<Tooltip content={"Отдалить"} relationship='label' hideDelay={0} showDelay={0} withArrow>
|
||||
<Button icon={<SubtractFilled />} appearance={'transparent'} onClick={() => {
|
||||
const currentZoom = map?.getView().getZoom()
|
||||
if (currentZoom) {
|
||||
// map?.getView().setZoom(currentZoom - 1)
|
||||
map?.getView().animate({
|
||||
zoom: currentZoom - 1,
|
||||
duration: 100
|
||||
})
|
||||
}
|
||||
}} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', zIndex: 1, backdropFilter: 'blur(8px)', backgroundColor: colorScheme === 'light' ? '#FFFFFFAA' : '#000000AA', borderRadius: '4px' }}>
|
||||
<Tooltip content={"Линейка"} relationship='label' hideDelay={0} showDelay={0} withArrow>
|
||||
<Button icon={<RulerRegular />} appearance={currentTool === 'Measure' ? 'primary' : 'transparent'} onClick={() => {
|
||||
setCurrentTool(map_id, 'Measure')
|
||||
}} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,34 +0,0 @@
|
||||
import { Checkbox, Group, RenderTreeNodePayload, Text } from "@mantine/core";
|
||||
import { IconChevronDown } from "@tabler/icons-react";
|
||||
|
||||
export const MapTreeCheckbox = ({
|
||||
node,
|
||||
expanded,
|
||||
hasChildren,
|
||||
elementProps,
|
||||
tree,
|
||||
}: RenderTreeNodePayload) => {
|
||||
const checked = tree.isNodeChecked(node.value);
|
||||
const indeterminate = tree.isNodeIndeterminate(node.value);
|
||||
|
||||
return (
|
||||
<Group gap="xs" {...elementProps}>
|
||||
<Checkbox.Indicator
|
||||
checked={checked}
|
||||
indeterminate={indeterminate}
|
||||
onClick={() => (!checked ? tree.checkNode(node.value) : tree.uncheckNode(node.value))}
|
||||
/>
|
||||
|
||||
<Group gap={5} onClick={() => tree.toggleExpanded(node.value)}>
|
||||
<Text size="xs">{node.label}</Text>
|
||||
|
||||
{hasChildren && (
|
||||
<IconChevronDown
|
||||
size={14}
|
||||
style={{ transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@ -138,6 +138,7 @@ const formatArea = function (polygon: Geometry) {
|
||||
};
|
||||
|
||||
export function measureStyleFunction(
|
||||
map_id: string,
|
||||
feature: FeatureLike,
|
||||
drawType?: Type,
|
||||
tip?: string,
|
||||
@ -149,7 +150,7 @@ export function measureStyleFunction(
|
||||
const type = geometry?.getType();
|
||||
const segmentStyles = [segmentStyle];
|
||||
|
||||
const segments = getMeasureShowSegments()
|
||||
const segments = getMeasureShowSegments(map_id)
|
||||
|
||||
if (!geometry) return
|
||||
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { Flex } from '@mantine/core'
|
||||
import { IObjectData, IObjectType } from '../../interfaces/objects'
|
||||
import useSWR from 'swr'
|
||||
import { fetcher } from '../../http/axiosInstance'
|
||||
@ -6,19 +5,17 @@ import { BASE_URL } from '../../constants'
|
||||
|
||||
const ObjectData = (object_data: IObjectData) => {
|
||||
const { data: typeData } = useSWR(
|
||||
object_data.type ? `/general/types/all` : null,
|
||||
(url) => fetcher(url, BASE_URL.ems),
|
||||
object_data.type ? `/general/types` : null,
|
||||
(url) => fetcher(url, BASE_URL.nest),
|
||||
{
|
||||
revalidateOnFocus: false
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<Flex gap='sm' direction='column'>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
{Array.isArray(typeData) && (typeData.find(type => Number(type.id) === Number(object_data.type)) as IObjectType).name}
|
||||
|
||||
|
||||
</Flex>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,85 +1,138 @@
|
||||
import useSWR from 'swr'
|
||||
import { fetcher } from '../../http/axiosInstance'
|
||||
import { BASE_URL } from '../../constants'
|
||||
import { IObjectParam, IParam } from '../../interfaces/objects'
|
||||
import { IObjectParam, IObjectValue } from '../../interfaces/objects'
|
||||
import TCBParameter from './TCBParameter'
|
||||
import TableValue from './TableValue'
|
||||
import { Accordion, AccordionHeader, AccordionItem, AccordionPanel, Table, TableBody, TableCell, TableCellLayout, TableRow, Text } from '@fluentui/react-components'
|
||||
|
||||
interface ObjectParameterProps {
|
||||
showLabel?: boolean,
|
||||
param: IObjectParam,
|
||||
showLabel?: boolean;
|
||||
param: IObjectParam;
|
||||
map_id: string;
|
||||
}
|
||||
|
||||
const ObjectParameter = ({
|
||||
param
|
||||
param,
|
||||
map_id
|
||||
}: ObjectParameterProps) => {
|
||||
const { data: paramData } = useSWR(
|
||||
`/general/params/all?param_id=${param.id_param}`,
|
||||
(url) => fetcher(url, BASE_URL.ems).then(res => res[0] as IParam),
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false
|
||||
}
|
||||
)
|
||||
|
||||
const Parameter = (type: string, name: string, value: unknown, vtable: string, unit: string | null) => {
|
||||
const Parameter = (type: string, name: string, values: IObjectValue[], vtable: string, unit: string | null) => {
|
||||
switch (type) {
|
||||
case 'bit':
|
||||
return (
|
||||
<TableValue value={value} name={name} type='boolean' />
|
||||
<TableValue map_id={map_id} values={values} name={name} type='boolean' />
|
||||
)
|
||||
case 'int':
|
||||
return (
|
||||
<TableValue map_id={map_id} values={values} name={name} type='number' />
|
||||
)
|
||||
case 'smallint':
|
||||
return (
|
||||
<TableValue map_id={map_id} values={values} name={name} type='number' />
|
||||
)
|
||||
case 'bigint':
|
||||
return (
|
||||
<TableValue value={value} name={name} type='number' />
|
||||
<TableValue map_id={map_id} values={values} name={name} type='number' />
|
||||
)
|
||||
case 'tinyint':
|
||||
return (
|
||||
<TableValue value={value} name={name} type='number' />
|
||||
<TableValue map_id={map_id} values={values} name={name} type='number' />
|
||||
)
|
||||
case 'smalldatetime':
|
||||
return (
|
||||
<TableValue map_id={map_id} values={values} name={name} type='number' />
|
||||
)
|
||||
// TODO: Calculate from calc procedures
|
||||
case 'calculate':
|
||||
return (
|
||||
<TableValue value={value} name={name} type='value' />
|
||||
<TableValue map_id={map_id} values={values} name={name} type='value' />
|
||||
)
|
||||
case 'GTCB':
|
||||
return (
|
||||
<TCBParameter value={value as string} vtable={vtable} name={name} />
|
||||
<TCBParameter map_id={map_id} values={values} vtable={vtable} name={name} />
|
||||
)
|
||||
case 'TCB':
|
||||
return (
|
||||
<TCBParameter value={value as string} vtable={vtable} name={name} />
|
||||
<TCBParameter map_id={map_id} values={values} vtable={vtable} name={name} />
|
||||
)
|
||||
case type.match(/varchar\((\d+)\)/)?.input:
|
||||
return (
|
||||
<TableValue value={value} name={name} type='string' />
|
||||
<TableValue map_id={map_id} values={values} name={name} type='string' />
|
||||
)
|
||||
case type.match(/numeric\((\d+),(\d+)\)/)?.input:
|
||||
return (
|
||||
<TableValue value={value} name={name} type='number' unit={unit} />
|
||||
<TableValue map_id={map_id} values={values} name={name} type='number' unit={unit} />
|
||||
)
|
||||
case 'year':
|
||||
return (
|
||||
<TableValue value={value} name={name} type='number' />
|
||||
<TableValue map_id={map_id} values={values} name={name} type='number' />
|
||||
)
|
||||
case 'uniqueidentifier':
|
||||
return (
|
||||
<TableValue value={value} name={name} type='value'/>
|
||||
<TableValue map_id={map_id} values={values} name={name} type='value' />
|
||||
)
|
||||
case 'group':
|
||||
return (
|
||||
<TableValue map_id={map_id} values={values} name={name} type='value' />
|
||||
)
|
||||
case 'groupcalculate':
|
||||
return (
|
||||
<TableValue map_id={map_id} values={values} name={name} type='value' />
|
||||
)
|
||||
case 'array':
|
||||
return (
|
||||
<TableValue map_id={map_id} values={values} name={name} type='value' />
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<div>
|
||||
{name}
|
||||
{value as string}
|
||||
</div>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<TableCellLayout>
|
||||
{name}
|
||||
</TableCellLayout>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<TableCellLayout>
|
||||
{values}
|
||||
</TableCellLayout>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{paramData &&
|
||||
Parameter(paramData.format, paramData.name, param.value, paramData.vtable, paramData.unit)
|
||||
{param && param.parameters && Array.isArray(param.parameters) && param.parameters.length > 0 ?
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} style={{ padding: 0 }}>
|
||||
<TableCellLayout>
|
||||
<Accordion collapsible defaultOpenItems={param.id_param} style={{ width: '100%' }}>
|
||||
<AccordionItem style={{ width: '100%' }} value={param.id_param}>
|
||||
<AccordionHeader size='small'>
|
||||
<Text weight='bold' size={200} style={{ textWrap: 'wrap' }}>{param.name}</Text>
|
||||
</AccordionHeader>
|
||||
|
||||
<AccordionPanel style={{ width: '100%', margin: 0 }}>
|
||||
<Table size='extra-small'>
|
||||
<TableBody>
|
||||
{param.parameters.length > 0 &&
|
||||
param.parameters.map((p, index) => (
|
||||
<ObjectParameter key={`child-param-${param.id_param}-${index}`} param={p} map_id={map_id} />
|
||||
))
|
||||
}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</TableCellLayout>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
:
|
||||
param && Parameter(param.format, param.name, param.values, param.vtable, param.unit)
|
||||
}
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,58 +1,167 @@
|
||||
import { Flex, LoadingOverlay } from '@mantine/core';
|
||||
import { IObjectParam } from '../../../interfaces/objects';
|
||||
import ObjectParameter from '../ObjectParameter';
|
||||
import useSWR from 'swr';
|
||||
import { BASE_URL } from '../../../constants';
|
||||
import { fetcher } from '../../../http/axiosInstance';
|
||||
import { useObjectsStore } from '../../../store/objects';
|
||||
import { Spinner, Tab, Table, TableBody, TableHeader, TableHeaderCell, TableRow, TabList, Text } from '@fluentui/react-components';
|
||||
import { useState } from 'react';
|
||||
|
||||
const ObjectParameters = () => {
|
||||
const { currentObjectId } = useObjectsStore()
|
||||
const ObjectParameters = ({
|
||||
map_id
|
||||
}: {
|
||||
map_id: string
|
||||
}) => {
|
||||
|
||||
const { currentObjectId } = useObjectsStore().id[map_id]
|
||||
|
||||
const { data: valuesData, isValidating: valuesValidating } = useSWR(
|
||||
currentObjectId ? `/general/values/all?object_id=${currentObjectId}` : null,
|
||||
(url) => fetcher(url, BASE_URL.ems),
|
||||
currentObjectId ? `/general/values/?object_id=${currentObjectId}` : null,
|
||||
(url) => fetcher(url, BASE_URL.nest),
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<Flex gap={'sm'} direction={'column'} pos='relative'>
|
||||
<LoadingOverlay visible={valuesValidating} />
|
||||
{Array.isArray(valuesData) &&
|
||||
Object.entries(
|
||||
valuesData.reduce((acc, param) => {
|
||||
if (!acc[param.id_param]) {
|
||||
acc[param.id_param] = [];
|
||||
}
|
||||
acc[param.id_param].push(param);
|
||||
return acc;
|
||||
}, {} as Record<string, IObjectParam[]>)
|
||||
).map(([id_param, params]) => {
|
||||
// Step 1: Sort the parameters by date_s (start date) and date_po (end date)
|
||||
const sortedParams = (params as IObjectParam[]).sort((b, a) => {
|
||||
const dateA = new Date(a.date_s || 0);
|
||||
const dateB = new Date(b.date_s || 0);
|
||||
return dateA.getTime() - dateB.getTime();
|
||||
});
|
||||
const [selectedTab, setSelectedTab] = useState<string | unknown>('parameters')
|
||||
|
||||
return sortedParams.length > 1 ? (
|
||||
sortedParams.map((param: IObjectParam) => {
|
||||
if (param.date_po == null) {
|
||||
return (
|
||||
<ObjectParameter key={id_param} param={param} showLabel={false} />
|
||||
)
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
maxHeight: '50%',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
maxWidth: '100%',
|
||||
overflowX: 'auto',
|
||||
minHeight: 'min-content',
|
||||
borderBottom: '1px solid var(--colorNeutralShadowKey)'
|
||||
}}>
|
||||
<TabList size='small' selectedValue={selectedTab} onTabSelect={(_, data) => setSelectedTab(data.value)}>
|
||||
<Tab key={'history'} value={'history'}>
|
||||
<Text size={200}>{'История изменений'}</Text>
|
||||
</Tab>
|
||||
|
||||
<Tab key={'parameters'} value={'parameters'}>
|
||||
<Text size={200}>{'Параметры'}</Text>
|
||||
</Tab>
|
||||
|
||||
<Tab key={'calculated'} value={'calculated'}>
|
||||
<Text size={200}>{'Вычисляемые'}</Text>
|
||||
</Tab>
|
||||
</TabList>
|
||||
</div>
|
||||
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
overflow: 'auto',
|
||||
position: 'relative'
|
||||
}}>
|
||||
{(valuesValidating) && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.6)",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
zIndex: 9999,
|
||||
}}
|
||||
>
|
||||
<Spinner size="large" label="Загрузка..." />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: selectedTab !== 'history' ? 'none' : 'initial' }}>
|
||||
|
||||
</div>
|
||||
|
||||
<div style={{ display: selectedTab !== 'parameters' ? 'none' : 'initial' }}>
|
||||
<Table size='extra-small'>
|
||||
<TableHeader>
|
||||
<TableRow appearance='neutral'>
|
||||
<TableHeaderCell>Параметр</TableHeaderCell>
|
||||
|
||||
<TableHeaderCell>Значение</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{Array.isArray(valuesData) &&
|
||||
valuesData.filter((param: IObjectParam) => param.format !== 'calculate').map((param: IObjectParam) => (
|
||||
<ObjectParameter map_id={map_id} key={param.id_param} param={param} />
|
||||
))
|
||||
}
|
||||
}
|
||||
)
|
||||
) : (
|
||||
<ObjectParameter key={id_param} param={sortedParams[0]} />
|
||||
);
|
||||
})
|
||||
}
|
||||
</Flex>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div style={{ display: selectedTab !== 'calculated' ? 'none' : 'initial' }}>
|
||||
<Table size='extra-small'>
|
||||
<TableHeader>
|
||||
<TableRow appearance='neutral'>
|
||||
<TableHeaderCell>Параметр</TableHeaderCell>
|
||||
|
||||
<TableHeaderCell>Значение</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{Array.isArray(valuesData) &&
|
||||
valuesData.filter((param: IObjectParam) => param.format === 'calculate').map((param: IObjectParam) => (
|
||||
<ObjectParameter map_id={map_id} key={param.id_param} param={param} />
|
||||
))
|
||||
}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '1rem', flexDirection: 'column', position: 'relative' }}>
|
||||
{(valuesValidating) && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.6)",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
zIndex: 9999,
|
||||
}}
|
||||
>
|
||||
<Spinner size="large" label="Загрузка..." />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Table size='extra-small'>
|
||||
<TableHeader>
|
||||
<TableRow appearance='neutral'>
|
||||
<TableHeaderCell>Параметр</TableHeaderCell>
|
||||
|
||||
<TableHeaderCell>Значение</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{Array.isArray(valuesData) &&
|
||||
valuesData.map((param: IObjectParam) => (
|
||||
<ObjectParameter map_id={map_id} key={param.id_param} param={param} />
|
||||
))
|
||||
}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,16 +1,15 @@
|
||||
import useSWR from 'swr'
|
||||
import { BASE_URL } from '../../constants'
|
||||
import { fetcher } from '../../http/axiosInstance'
|
||||
import { Flex } from '@mantine/core'
|
||||
|
||||
const RegionSelect = () => {
|
||||
const { data } = useSWR(`/gis/regions/borders`, (url) => fetcher(url, BASE_URL.ems), {
|
||||
const { data } = useSWR(`/gis/regions/borders`, (url) => fetcher(url, BASE_URL.nest), {
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false
|
||||
})
|
||||
|
||||
return (
|
||||
<Flex align='center' justify='center'>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{Array.isArray(data) &&
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" width='100%' height='100vh' transform='scale(1, -1)'>
|
||||
{data.map((el, index) => (
|
||||
@ -18,7 +17,7 @@ const RegionSelect = () => {
|
||||
))}
|
||||
</svg>
|
||||
}
|
||||
</Flex>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,35 +1,38 @@
|
||||
import useSWR from 'swr'
|
||||
import { fetcher } from '../../http/axiosInstance'
|
||||
import { BASE_URL } from '../../constants'
|
||||
import { Text } from '@mantine/core'
|
||||
import TableValue from './TableValue'
|
||||
import { Text } from '@fluentui/react-components';
|
||||
import { IObjectValue } from '../../interfaces/objects';
|
||||
|
||||
interface ITCBParameterProps {
|
||||
value: string;
|
||||
values: IObjectValue[];
|
||||
vtable: string;
|
||||
inactive?: boolean;
|
||||
name: string;
|
||||
map_id: string;
|
||||
}
|
||||
|
||||
const TCBParameter = ({
|
||||
value,
|
||||
values,
|
||||
vtable,
|
||||
name
|
||||
name,
|
||||
map_id
|
||||
}: ITCBParameterProps) => {
|
||||
|
||||
//Get value
|
||||
const { data: tcbValue } = useSWR(
|
||||
`/general/params/tcb?id=${value}&vtable=${vtable}`,
|
||||
(url) => fetcher(url, BASE_URL.ems).then(res => res[0]),
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false
|
||||
}
|
||||
)
|
||||
// const { data: tcbValue } = useSWR(
|
||||
// value ? `/general/params/tcb?id=${value}&vtable=${vtable}` : null,
|
||||
// (url) => fetcher(url, BASE_URL.ems).then(res => res[0]),
|
||||
// {
|
||||
// revalidateOnFocus: false,
|
||||
// revalidateIfStale: false
|
||||
// }
|
||||
// )
|
||||
|
||||
const tables = [
|
||||
'boiler_meter_caption',
|
||||
'BoilersTemper',
|
||||
'FuelsParametrs',
|
||||
'PipesTypes',
|
||||
'vAddRepairEvent',
|
||||
'vApartmentTypes',
|
||||
'vBoilers',
|
||||
'vBoilersAppointment',
|
||||
'vBoilersBalance',
|
||||
@ -47,6 +50,7 @@ const TCBParameter = ({
|
||||
'vColdWaterTypes',
|
||||
'vConditionEquipment',
|
||||
'vCovering',
|
||||
'vDensityWater',
|
||||
'vDryer',
|
||||
'vElectroSupplyTypes',
|
||||
'vEquipmentsTypes',
|
||||
@ -58,21 +62,27 @@ const TCBParameter = ({
|
||||
'vHotWaterTypes',
|
||||
'vMaterialsWall',
|
||||
'vNormative',
|
||||
'vPeriodHW',
|
||||
'vPipeDiameters',
|
||||
'vPipeOutDiameters',
|
||||
'vPipesBearingType',
|
||||
'vPipesCovering',
|
||||
'vPipesEvent',
|
||||
'vPipesGround',
|
||||
'vPipesIsolation',
|
||||
'vPipesLayer',
|
||||
'vPipesMaterial',
|
||||
'vPumpType',
|
||||
'vRepairEvent',
|
||||
'vRoof',
|
||||
'vRPSType',
|
||||
'vServe',
|
||||
'vStreets',
|
||||
'vTechStatus',
|
||||
'vTrash',
|
||||
'vTrashStorage',
|
||||
'vVentilation',
|
||||
'vValvingType',
|
||||
'vWallingEquipment',
|
||||
'tTypes',
|
||||
]
|
||||
@ -80,13 +90,13 @@ const TCBParameter = ({
|
||||
const TCBValue = (vtable: string) => {
|
||||
if (tables.includes(vtable)) {
|
||||
return (
|
||||
<TableValue value={tcbValue?.id} name={name} type='select' vtable={vtable} />
|
||||
<TableValue map_id={map_id} values={values} name={name} type='select' vtable={vtable} />
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<Text>
|
||||
{JSON.stringify(name)}
|
||||
{JSON.stringify(tcbValue)}
|
||||
{JSON.stringify(Array.isArray(values) && values.length > 0 && values[0].value)}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,35 +1,41 @@
|
||||
import { Checkbox, ComboboxData, Grid, NumberInput, Select, Text, Textarea } from '@mantine/core';
|
||||
import useSWR from 'swr';
|
||||
import { fetcher } from '../../http/axiosInstance';
|
||||
import { BASE_URL } from '../../constants';
|
||||
import { useObjectsStore } from '../../store/objects';
|
||||
import { Input, Select, TableCell, TableCellLayout, TableRow, Text } from '@fluentui/react-components';
|
||||
import { useState } from 'react';
|
||||
import { IObjectValue } from '../../interfaces/objects';
|
||||
|
||||
interface TableValueProps {
|
||||
name: string;
|
||||
value: unknown;
|
||||
values: IObjectValue[];
|
||||
type: 'value' | 'boolean' | 'number' | 'select' | 'string';
|
||||
unit?: string | null | undefined;
|
||||
vtable?: string;
|
||||
map_id: string;
|
||||
}
|
||||
|
||||
const TableValue = ({
|
||||
name,
|
||||
value,
|
||||
values,
|
||||
type,
|
||||
unit,
|
||||
vtable
|
||||
vtable,
|
||||
map_id
|
||||
}: TableValueProps) => {
|
||||
const { selectedDistrict } = useObjectsStore()
|
||||
const { selectedDistrict } = useObjectsStore().id[map_id]
|
||||
|
||||
//Get available values
|
||||
const [value] = useState(Array.isArray(values) && values.length > 0 ? values[0].value : '')
|
||||
|
||||
const { data: tcbAll, isValidating } = useSWR(
|
||||
type === 'select' && selectedDistrict ? `/general/params/tcb?vtable=${vtable}&id_city=${selectedDistrict}` : null,
|
||||
(url) => fetcher(url, BASE_URL.ems).then(res => {
|
||||
(url) => fetcher(url, BASE_URL.nest).then(res => {
|
||||
if (Array.isArray(res)) {
|
||||
return res.map((el) => ({
|
||||
label: el.name || "",
|
||||
value: JSON.stringify(el.id)
|
||||
})) as ComboboxData
|
||||
}))
|
||||
}
|
||||
}),
|
||||
{
|
||||
@ -39,32 +45,55 @@ const TableValue = ({
|
||||
)
|
||||
|
||||
return (
|
||||
<Grid>
|
||||
<Grid.Col span={4} style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Text size='xs' style={{ textWrap: 'wrap' }}>{name as string}</Text>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={8}>
|
||||
{type === 'boolean' ?
|
||||
<Checkbox defaultChecked={value as boolean} />
|
||||
:
|
||||
type === 'number' ?
|
||||
<NumberInput
|
||||
size='xs'
|
||||
value={value as number}
|
||||
onChange={() => { }}
|
||||
suffix={unit ? ` ${unit}` : ''}
|
||||
/>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<TableCellLayout truncate title={name as string}>
|
||||
{name as string}
|
||||
</TableCellLayout>
|
||||
</TableCell>
|
||||
|
||||
<TableCell style={{ padding: '0.25rem' }}>
|
||||
<div style={{ display: 'flex' }}>
|
||||
{type === 'boolean' ?
|
||||
<Select style={{ display: 'flex', width: '100%' }} size='small' defaultChecked={value as boolean}>
|
||||
{[true, false].map(tcb => (
|
||||
<option key={JSON.stringify(tcb)} value={JSON.stringify(tcb)}>
|
||||
{tcb === true ? 'Да' : 'Нет'}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
// <Checkbox defaultChecked={value as boolean} />
|
||||
:
|
||||
type === 'select' && !isValidating && tcbAll ?
|
||||
<Select size='xs' data={tcbAll} defaultValue={JSON.stringify(value)} />
|
||||
type === 'number' ?
|
||||
<Input
|
||||
size='small'
|
||||
style={{ display: 'flex', width: '100%' }}
|
||||
defaultValue={value as string}
|
||||
onChange={() => { }}
|
||||
contentAfter={unit ? <Text size={200} wrap={false}>{unit}</Text> : undefined}
|
||||
//displayValue={unit ? ` ${unit}` : ''}
|
||||
/>
|
||||
:
|
||||
type === 'string' ?
|
||||
<Textarea size='xs' defaultValue={value as string} autosize minRows={1} />
|
||||
type === 'select' && !isValidating && tcbAll ?
|
||||
<Select style={{ display: 'flex', width: '100%' }} size='small' defaultValue={JSON.stringify(value)}>
|
||||
{tcbAll.map(tcb => (
|
||||
<option key={tcb.value} value={tcb.value}>
|
||||
{tcb.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
:
|
||||
<Text size='xs'>{value as string}</Text>
|
||||
}
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
type === 'string' ?
|
||||
<Input style={{ display: 'flex', width: '100%' }} size='small' value={value as string} />
|
||||
:
|
||||
type === 'value' ?
|
||||
<Input style={{ display: 'flex', width: '100%' }} size='small' value={value as string} />
|
||||
:
|
||||
<Text size={200}>{value as string}</Text>
|
||||
}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { ScrollAreaAutosize, Tabs } from '@mantine/core';
|
||||
import { Tab, TabList, Text } from '@fluentui/react-components';
|
||||
import { useState } from 'react';
|
||||
|
||||
export interface ITabsPane {
|
||||
title: string;
|
||||
@ -15,30 +16,41 @@ const TabsPane = ({
|
||||
defaultTab,
|
||||
tabs
|
||||
}: TabsPaneProps) => {
|
||||
const [selectedTab, setSelectedTab] = useState<string | unknown>(defaultTab)
|
||||
|
||||
return (
|
||||
<Tabs defaultValue={defaultTab} mah='50%' h={'100%'} style={{
|
||||
display: 'grid',
|
||||
gridTemplateRows: 'min-content auto'
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
maxHeight: '50%',
|
||||
}}>
|
||||
<ScrollAreaAutosize>
|
||||
<Tabs.List>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
maxWidth: '100%',
|
||||
overflowX: 'auto',
|
||||
minHeight: 'min-content',
|
||||
borderBottom: '1px solid var(--colorNeutralShadowKey)'
|
||||
}}>
|
||||
<TabList size='small' selectedValue={selectedTab} onTabSelect={(_, data) => setSelectedTab(data.value)}>
|
||||
{tabs.map((tab) => (
|
||||
<Tabs.Tab key={tab.value} value={tab.value}>
|
||||
{tab.title}
|
||||
</Tabs.Tab>
|
||||
<Tab key={tab.value} value={tab.value}>
|
||||
<Text size={200}>{tab.title}</Text>
|
||||
</Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
</ScrollAreaAutosize>
|
||||
</TabList>
|
||||
</div>
|
||||
|
||||
|
||||
<ScrollAreaAutosize h='100%' offsetScrollbars p='xs'>
|
||||
{tabs.map(tab => (
|
||||
<Tabs.Panel key={tab.value} value={tab.value}>
|
||||
{tab.view}
|
||||
</Tabs.Panel>
|
||||
))}
|
||||
</ScrollAreaAutosize>
|
||||
</Tabs>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
overflow: 'auto'
|
||||
}}>
|
||||
{tabs.find(tab => tab.value === selectedTab)?.view}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -9,8 +9,8 @@ import '@js-preview/docx/lib/index.css'
|
||||
|
||||
import jsPreviewPdf from '@js-preview/pdf'
|
||||
import { IDocument } from '../../interfaces/documents';
|
||||
import { IconAlertTriangle, IconChevronLeft, IconChevronRight } from '@tabler/icons-react';
|
||||
import { Button, Flex, Grid, Loader, Modal, ScrollAreaAutosize, Text } from '@mantine/core';
|
||||
import { IconAlertTriangle, IconChevronLeft, IconChevronRight, IconX } from '@tabler/icons-react';
|
||||
import { Button, Spinner, Text } from '@fluentui/react-components';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@ -21,7 +21,7 @@ interface Props {
|
||||
}
|
||||
|
||||
interface ViewerProps {
|
||||
url: string
|
||||
url: string | ArrayBuffer | Blob
|
||||
}
|
||||
|
||||
function PdfViewer({
|
||||
@ -82,7 +82,7 @@ function DocxViewer({
|
||||
)
|
||||
}
|
||||
|
||||
function ExcelViewer({
|
||||
export function ExcelViewer({
|
||||
url
|
||||
}: ViewerProps) {
|
||||
const previewContainerRef = useRef(null)
|
||||
@ -112,7 +112,7 @@ function ImageViewer({
|
||||
url
|
||||
}: ViewerProps) {
|
||||
return (
|
||||
<Flex style={{
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
@ -120,12 +120,12 @@ function ImageViewer({
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
}}>
|
||||
<img alt='image-preview' src={url} style={{
|
||||
<img alt='image-preview' src={url as string} style={{
|
||||
display: 'flex',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%'
|
||||
}} />
|
||||
</Flex>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -151,71 +151,71 @@ export default function FileViewer({
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal.Root fullScreen opened={open} onClose={() => setOpen(false)} scrollAreaComponent={ScrollAreaAutosize.Autosize}>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content style={{
|
||||
<div style={{display: open ? 'flex' : 'none'}}>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: '9999',
|
||||
background: 'var(--colorNeutralBackground1)',
|
||||
gridTemplateRows: 'min-content auto',
|
||||
width: '100vw',
|
||||
height: '100vh'
|
||||
}}>
|
||||
<Modal.Header>
|
||||
<Modal.Title component='div' w='100%'>
|
||||
<Flex align='center'>
|
||||
<Text mr='auto'>{currentFileNo != -1 && docs[currentFileNo].name}</Text>
|
||||
<div style={{ display: 'flex', padding: '1rem' }}>
|
||||
<div style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Text style={{ marginRight: 'auto' }}>
|
||||
{currentFileNo != -1 && docs[currentFileNo].name}
|
||||
</Text>
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span='auto'>
|
||||
<Button
|
||||
variant='transparent'
|
||||
onClick={() => {
|
||||
if (currentFileNo >= 0 && currentFileNo > 0) {
|
||||
setCurrentFileNo(currentFileNo - 1)
|
||||
}
|
||||
}}
|
||||
disabled={currentFileNo >= 0 && currentFileNo === 0}
|
||||
>
|
||||
<IconChevronLeft />
|
||||
</Button>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span='auto'>
|
||||
<Button
|
||||
variant='transparent'
|
||||
onClick={() => {
|
||||
if (currentFileNo >= 0 && currentFileNo < docs.length) {
|
||||
setCurrentFileNo(currentFileNo + 1)
|
||||
}
|
||||
}}
|
||||
disabled={currentFileNo >= 0 && currentFileNo >= docs.length - 1}
|
||||
>
|
||||
<IconChevronRight />
|
||||
</Button>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<div>
|
||||
<Button
|
||||
icon={<IconChevronLeft />}
|
||||
appearance='transparent'
|
||||
onClick={() => {
|
||||
if (currentFileNo >= 0 && currentFileNo > 0) {
|
||||
setCurrentFileNo(currentFileNo - 1)
|
||||
}
|
||||
}}
|
||||
disabled={currentFileNo >= 0 && currentFileNo === 0}
|
||||
/>
|
||||
<Button
|
||||
icon={<IconChevronRight />}
|
||||
appearance='transparent'
|
||||
onClick={() => {
|
||||
if (currentFileNo >= 0 && currentFileNo < docs.length) {
|
||||
setCurrentFileNo(currentFileNo + 1)
|
||||
}
|
||||
}}
|
||||
disabled={currentFileNo >= 0 && currentFileNo >= docs.length - 1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
autoFocus
|
||||
variant='subtle'
|
||||
appearance='subtle'
|
||||
onClick={handleSave}
|
||||
>
|
||||
Сохранить
|
||||
</Button>
|
||||
</Flex>
|
||||
</Modal.Title>
|
||||
<Modal.CloseButton ml='xl' />
|
||||
</Modal.Header>
|
||||
<Modal.Body style={{ display: 'flex', flexGrow: 1, height: '100%', width: '100vw' }}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button icon={<IconX />} appearance='subtle' onClick={() => setOpen(false)} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexGrow: 1, height: '100%', overflow: 'auto', width: '100vw' }}>
|
||||
{fileIsLoading || fileTypeIsLoading ?
|
||||
<Flex style={{
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<Loader />
|
||||
</Flex>
|
||||
<Spinner />
|
||||
</div>
|
||||
:
|
||||
fileType === 'application/pdf' ?
|
||||
<PdfViewer url={window.URL.createObjectURL(file)} />
|
||||
@ -230,27 +230,27 @@ export default function FileViewer({
|
||||
<ImageViewer url={window.URL.createObjectURL(file)} />
|
||||
:
|
||||
fileType && file ?
|
||||
<Flex style={{ display: 'flex', gap: '16px', flexDirection: 'column', p: '16px' }}>
|
||||
<Flex style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', gap: '16px', flexDirection: 'column', padding: '1rem' }}>
|
||||
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
|
||||
<IconAlertTriangle />
|
||||
<Text>
|
||||
Предпросмотр данного файла невозможен.
|
||||
</Text>
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
<Flex>
|
||||
<Button variant='contained' onClick={() => {
|
||||
<div style={{ display: 'flex' }}>
|
||||
<Button appearance='secondary' onClick={() => {
|
||||
handleSave()
|
||||
}}>
|
||||
Сохранить
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</div>
|
||||
</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,19 +1,24 @@
|
||||
import { IconBuildingFactory2, IconComponents, IconDeviceDesktopAnalytics, IconFiles, IconHome, IconLogin, IconLogin2, IconMap, IconPassword, IconReport, IconServer, IconSettings, IconShield, IconTable, IconUsers } from "@tabler/icons-react";
|
||||
import { IconComponents, IconDeviceDesktopAnalytics, IconFlame, IconLogin, IconLogin2, IconPassword, IconSettings } from "@tabler/icons-react";
|
||||
import SignIn from "../pages/auth/SignIn";
|
||||
import SignUp from "../pages/auth/SignUp";
|
||||
import PasswordReset from "../pages/auth/PasswordReset";
|
||||
import TableTest from "../pages/TableTest";
|
||||
import ComponentTest from "../pages/ComponentTest";
|
||||
import MonitorPage from "../pages/MonitorPage";
|
||||
import Settings from "../pages/Settings";
|
||||
import Main from "../pages/Main";
|
||||
import Users from "../pages/Users";
|
||||
import Roles from "../pages/Roles";
|
||||
import Documents from "../pages/Documents";
|
||||
import Reports from "../pages/Reports";
|
||||
import Servers from "../pages/Servers";
|
||||
import Boilers from "../pages/Boilers";
|
||||
import Boilers from "../pages/fuel/Fuel/Boilers";
|
||||
import MapTest from "../pages/MapTest";
|
||||
import PrintReport from "../pages/PrintReport";
|
||||
import DBManager from "../pages/DBManager";
|
||||
import MapLineTest from "../components/map/MapLineTest";
|
||||
import FuelPage from "../pages/Fuel";
|
||||
import { Building24Color, Cloud24Color, Database24Color, Diversity24Color, Document24Color, Form24Color, Gauge24Color, Map24Filled, Map24Regular, PeopleList24Color, Shield24Color } from "@fluentui/react-icons"
|
||||
import LimitsPage from "../pages/fuel/Limits";
|
||||
import ReportsPage from "../pages/fuel/Reports";
|
||||
import FuelsPage from "../pages/fuel/Fuel/Fuels";
|
||||
|
||||
// Определение страниц с путями и компонентом для рендера
|
||||
|
||||
@ -57,8 +62,10 @@ const pages = [
|
||||
{
|
||||
label: "Главная",
|
||||
path: "/",
|
||||
icon: <IconHome />,
|
||||
component: <Main />,
|
||||
icon: <Map24Filled />,
|
||||
component: <MapTest />,
|
||||
// icon: <Home24Color />,
|
||||
// component: <Main />,
|
||||
drawer: true,
|
||||
dashboard: true,
|
||||
enabled: true,
|
||||
@ -66,7 +73,7 @@ const pages = [
|
||||
{
|
||||
label: "Пользователи",
|
||||
path: "/user",
|
||||
icon: <IconUsers />,
|
||||
icon: <PeopleList24Color />,
|
||||
component: <Users />,
|
||||
drawer: true,
|
||||
dashboard: true,
|
||||
@ -75,7 +82,7 @@ const pages = [
|
||||
{
|
||||
label: "Роли",
|
||||
path: "/role",
|
||||
icon: <IconShield />,
|
||||
icon: <Shield24Color />,
|
||||
component: <Roles />,
|
||||
drawer: true,
|
||||
dashboard: true,
|
||||
@ -84,7 +91,7 @@ const pages = [
|
||||
{
|
||||
label: "Документы",
|
||||
path: "/documents",
|
||||
icon: <IconFiles />,
|
||||
icon: <Document24Color />,
|
||||
component: <Documents />,
|
||||
drawer: true,
|
||||
dashboard: true,
|
||||
@ -93,7 +100,7 @@ const pages = [
|
||||
{
|
||||
label: "Отчеты",
|
||||
path: "/reports",
|
||||
icon: <IconReport />,
|
||||
icon: <Form24Color />,
|
||||
component: <Reports />,
|
||||
drawer: true,
|
||||
dashboard: true,
|
||||
@ -102,7 +109,7 @@ const pages = [
|
||||
{
|
||||
label: "Серверы",
|
||||
path: "/servers",
|
||||
icon: <IconServer />,
|
||||
icon: <Cloud24Color />,
|
||||
component: <Servers />,
|
||||
drawer: true,
|
||||
dashboard: true,
|
||||
@ -110,22 +117,58 @@ const pages = [
|
||||
},
|
||||
{
|
||||
label: "Котельные",
|
||||
path: "/boilers",
|
||||
icon: <IconBuildingFactory2 />,
|
||||
path: "/fuel/boilers",
|
||||
icon: <Building24Color />,
|
||||
component: <Boilers />,
|
||||
drawer: true,
|
||||
dashboard: true,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
label: "ИКС",
|
||||
path: "/map-test",
|
||||
icon: <IconMap />,
|
||||
component: <MapTest />,
|
||||
label: "Виды топлива",
|
||||
path: "/fuel/fuels",
|
||||
icon: <Diversity24Color />,
|
||||
component: <FuelsPage />,
|
||||
drawer: true,
|
||||
dashboard: true,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
label: "Лимиты",
|
||||
path: "/fuel/limits",
|
||||
icon: <Gauge24Color />,
|
||||
component: <LimitsPage />,
|
||||
drawer: true,
|
||||
dashboard: true,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
label: "Отчеты",
|
||||
path: "/fuel/reports",
|
||||
icon: <Form24Color />,
|
||||
component: <ReportsPage />,
|
||||
drawer: false,
|
||||
dashboard: false,
|
||||
enabled: false,
|
||||
},
|
||||
// {
|
||||
// label: "ИКС",
|
||||
// path: "/map-test",
|
||||
// icon: <Map24Filled />,
|
||||
// component: <MapTest />,
|
||||
// drawer: true,
|
||||
// dashboard: true,
|
||||
// enabled: true,
|
||||
// },
|
||||
{
|
||||
label: "Map line test",
|
||||
path: "/map-line-test",
|
||||
icon: <Map24Regular />,
|
||||
component: <MapLineTest />,
|
||||
drawer: true,
|
||||
dashboard: true,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
label: "Монитор",
|
||||
path: "/monitor",
|
||||
@ -136,23 +179,32 @@ const pages = [
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
label: "Table test",
|
||||
path: "/table-test",
|
||||
icon: <IconTable />,
|
||||
component: <TableTest />,
|
||||
label: "Print report test",
|
||||
path: "/print-report-test",
|
||||
icon: <IconComponents />,
|
||||
component: <PrintReport />,
|
||||
drawer: true,
|
||||
dashboard: true,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
label: "Component test",
|
||||
path: "/component-test",
|
||||
icon: <IconComponents />,
|
||||
component: <ComponentTest />,
|
||||
label: "Тест БД",
|
||||
path: "/db-manager",
|
||||
icon: <Database24Color />,
|
||||
component: <DBManager />,
|
||||
drawer: true,
|
||||
dashboard: true,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
label: 'Fuel',
|
||||
path: '/fuel',
|
||||
icon: <IconFlame />,
|
||||
component: <FuelPage />,
|
||||
drawer: false,
|
||||
dashboard: false,
|
||||
enabled: false
|
||||
}
|
||||
]
|
||||
|
||||
export {
|
||||
|
||||
@ -9,4 +9,5 @@ export const BASE_URL = {
|
||||
fuel: import.meta.env.VITE_API_FUEL_URL,
|
||||
servers: import.meta.env.VITE_API_SERVERS_URL,
|
||||
ems: import.meta.env.VITE_API_EMS_URL,
|
||||
nest: import.meta.env.VITE_API_NEST_URL
|
||||
}
|
||||
48
client/src/constants/map.ts
Normal file
48
client/src/constants/map.ts
Normal file
@ -0,0 +1,48 @@
|
||||
export const schemas = [
|
||||
'2018',
|
||||
'2019',
|
||||
'2020',
|
||||
'2021',
|
||||
'2022',
|
||||
'2023',
|
||||
'2024',
|
||||
]
|
||||
|
||||
export const scaleOptions = [
|
||||
{
|
||||
label: '1:500000',
|
||||
value: '500'
|
||||
},
|
||||
{
|
||||
label: '1:100000',
|
||||
value: '250'
|
||||
},
|
||||
{
|
||||
label: '1:50000',
|
||||
value: '50'
|
||||
},
|
||||
{
|
||||
label: '1:25000',
|
||||
value: '25'
|
||||
},
|
||||
{
|
||||
label: '1:10000',
|
||||
value: '10'
|
||||
},
|
||||
]
|
||||
|
||||
export const satMapsProviders = [
|
||||
{ label: 'Google', value: 'google' },
|
||||
{ label: 'Яндекс', value: 'yandex' },
|
||||
{ label: 'Подложка', value: 'custom' },
|
||||
{ label: 'Static', value: 'static' }
|
||||
]
|
||||
|
||||
export const printDimensions = {
|
||||
a0: [1189, 841],
|
||||
a1: [841, 594],
|
||||
a2: [594, 420],
|
||||
a3: [420, 297],
|
||||
a4: [297, 210],
|
||||
a5: [210, 148],
|
||||
}
|
||||
29
client/src/dto/fuel/fuel.dto.ts
Normal file
29
client/src/dto/fuel/fuel.dto.ts
Normal file
@ -0,0 +1,29 @@
|
||||
export class FuelExpenseDto {
|
||||
id_boiler?: string
|
||||
id_fuel?: number
|
||||
date?: Date
|
||||
value?: number
|
||||
}
|
||||
|
||||
export const FuelExpenseDtoHeaders = {
|
||||
id_boiler: 'Boiler ID',
|
||||
id_fuel: 'Fuel ID',
|
||||
date: 'Date',
|
||||
value: 'Value',
|
||||
}
|
||||
|
||||
export class FuelLimitDto {
|
||||
id_boiler?: string
|
||||
id_fuel?: number
|
||||
month?: Date
|
||||
year?: number
|
||||
value?: number
|
||||
}
|
||||
|
||||
export const FuelLimitDtoHeaders = {
|
||||
id_boiler: 'Boiler ID',
|
||||
id_fuel: 'Fuel ID',
|
||||
month: 'Month',
|
||||
year: 'Year',
|
||||
value: 'Value'
|
||||
}
|
||||
20
client/src/hooks/map/useCapitals.ts
Normal file
20
client/src/hooks/map/useCapitals.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { useEffect } from "react";
|
||||
import type VectorLayer from "ol/layer/Vector";
|
||||
import type VectorSource from "ol/source/Vector";
|
||||
|
||||
/**
|
||||
* Hook that controls the opacity of the capitals layer depending on the selected district.
|
||||
*
|
||||
* @param capitalsLayer - The OpenLayers VectorLayer instance for capitals.
|
||||
* @param value - Value to observe, boolean.
|
||||
*/
|
||||
export function useCapitals(
|
||||
capitalsLayer: VectorLayer<VectorSource<any>> | null,
|
||||
value: boolean
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!capitalsLayer) return;
|
||||
|
||||
capitalsLayer.setOpacity(value ? 0 : 1);
|
||||
}, [capitalsLayer, value]);
|
||||
}
|
||||
30
client/src/hooks/useFluentColorScheme.ts
Normal file
30
client/src/hooks/useFluentColorScheme.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import * as React from "react";
|
||||
import { webLightTheme, webDarkTheme, Theme } from "@fluentui/react-components";
|
||||
|
||||
type ColorScheme = "light" | "dark";
|
||||
const STORAGE_KEY = "color-scheme";
|
||||
|
||||
export function useFluentColorScheme(): {
|
||||
colorScheme: ColorScheme;
|
||||
setColorScheme: (value: ColorScheme) => void;
|
||||
theme: Theme;
|
||||
} {
|
||||
const [colorScheme, setColorSchemeState] = React.useState<ColorScheme>(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const saved = localStorage.getItem(STORAGE_KEY) as ColorScheme | null;
|
||||
if (saved === "light" || saved === "dark") return saved;
|
||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
return prefersDark ? "dark" : "light";
|
||||
}
|
||||
return "light";
|
||||
});
|
||||
|
||||
const setColorScheme = React.useCallback((value: ColorScheme) => {
|
||||
setColorSchemeState(value);
|
||||
localStorage.setItem(STORAGE_KEY, value);
|
||||
}, []);
|
||||
|
||||
const theme = colorScheme === "dark" ? webDarkTheme : webLightTheme;
|
||||
|
||||
return { colorScheme, setColorScheme, theme };
|
||||
}
|
||||
27
client/src/http/axiosInstanceNest.ts
Normal file
27
client/src/http/axiosInstanceNest.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import axios, { ResponseType } from 'axios';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
|
||||
const axiosInstance = axios.create();
|
||||
|
||||
axiosInstance.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = useAuthStore.getState().token;
|
||||
if (token) {
|
||||
config.headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export const fetcher = (url: string, baseURL?: string, responseType?: ResponseType) => axiosInstance.get(url, {
|
||||
baseURL: baseURL || import.meta.env.VITE_API_NEST_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
responseType: responseType ? responseType : "json"
|
||||
}).then(res => res.data)
|
||||
|
||||
export default axiosInstance;
|
||||
@ -1,3 +1,10 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
--ag-wrapper-border-radius: 4px;
|
||||
--ag-spacing: 4px;
|
||||
--ag-font-size: 12px;
|
||||
--ag-header-font-weight: bold;
|
||||
}
|
||||
12
client/src/interfaces/DataGrid/columns.ts
Normal file
12
client/src/interfaces/DataGrid/columns.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export type IColumnType = "string" | "number" | "boolean" | "dictionary"
|
||||
|
||||
export interface IColumn {
|
||||
name: string
|
||||
header: string
|
||||
type: IColumnType
|
||||
hidden?: boolean
|
||||
}
|
||||
|
||||
export interface IColumnsDefinition {
|
||||
|
||||
}
|
||||
4
client/src/interfaces/dictionary.ts
Normal file
4
client/src/interfaces/dictionary.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface IDictionary {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
@ -3,6 +3,11 @@ export interface IRegion {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ISeason {
|
||||
name: string
|
||||
year: number
|
||||
}
|
||||
|
||||
export interface ICity {
|
||||
id: number;
|
||||
name: string;
|
||||
@ -14,4 +19,9 @@ export interface IBoiler {
|
||||
boiler_code: string;
|
||||
id_city: number;
|
||||
activity: boolean;
|
||||
}
|
||||
|
||||
export type FuelType = {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
@ -1,3 +1,19 @@
|
||||
import { Point } from "ol/geom";
|
||||
import { ToolType } from "../types/tools";
|
||||
import { SatelliteMapsProvider } from "./map";
|
||||
import Map from "ol/Map";
|
||||
import { Coordinate } from "ol/coordinate";
|
||||
import { Mode } from "../store/map";
|
||||
export interface IRegion {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface IDistrict {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface IFigure {
|
||||
object_id: string,
|
||||
figure_type_id: number,
|
||||
@ -13,7 +29,8 @@ export interface IFigure {
|
||||
label_size: number | null,
|
||||
year: number,
|
||||
type: number,
|
||||
planning: boolean
|
||||
planning: boolean,
|
||||
modified?: string
|
||||
}
|
||||
|
||||
export interface ILine {
|
||||
@ -32,5 +49,60 @@ export interface ILine {
|
||||
label_positions: string | null,
|
||||
year: number,
|
||||
type: number,
|
||||
planning: boolean
|
||||
planning: boolean,
|
||||
modified?: string
|
||||
}
|
||||
|
||||
export interface ICitySettings {
|
||||
city_id: number;
|
||||
image_width: number;
|
||||
image_height: number;
|
||||
scale: number;
|
||||
offset_x: number;
|
||||
offset_y: number;
|
||||
rotation: number;
|
||||
image_scale: number;
|
||||
}
|
||||
|
||||
export interface TypeRole {
|
||||
id: number;
|
||||
sname: string;
|
||||
r: number | null;
|
||||
g: number | null;
|
||||
b: number | null;
|
||||
object_type_id: number;
|
||||
object_role_id: number;
|
||||
parent_type_id: string | null;
|
||||
owner_role_ids: string | null;
|
||||
necessary_params_ids: string | null;
|
||||
vis_params_ids: string | null;
|
||||
read_only_params_ids: string | null;
|
||||
inactive_r: number | null;
|
||||
inactive_g: number | null;
|
||||
inactive_b: number | null;
|
||||
color_group: number | null;
|
||||
}
|
||||
|
||||
export interface CreateMapState {
|
||||
currentTool: ToolType;
|
||||
measureType: "LineString" | "Polygon";
|
||||
measureShowSegments: boolean;
|
||||
measureClearPrevious: boolean;
|
||||
tipPoint: Point | null;
|
||||
map: Map | null;
|
||||
currentZ: number | undefined;
|
||||
currentX: number | undefined;
|
||||
currentY: number | undefined;
|
||||
currentCoordinate: Coordinate | null;
|
||||
statusText: string;
|
||||
satMapsProvider: SatelliteMapsProvider;
|
||||
selectedObjectType: number | null;
|
||||
alignMode: boolean;
|
||||
mode: Mode;
|
||||
typeRoles: TypeRole[] | null;
|
||||
setCurrentCoordinate: (currentCoordinate: Coordinate | null) => void;
|
||||
setCurrentZ: (currentZ: number | undefined) => void;
|
||||
setCurrentX: (currentX: number | undefined) => void;
|
||||
setCurrentY: (currentY: number | undefined) => void;
|
||||
setStatusText: (statusText: string) => void;
|
||||
}
|
||||
@ -1,44 +1,52 @@
|
||||
export interface IObjectList {
|
||||
id: number,
|
||||
name: string,
|
||||
id: number
|
||||
name: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface IObjectData {
|
||||
object_id: string,
|
||||
id_city: number,
|
||||
year: number,
|
||||
id_parent: number | null,
|
||||
type: number,
|
||||
planning: boolean,
|
||||
activity: boolean,
|
||||
kvr: string | null,
|
||||
jur: string | null,
|
||||
fuel: string | null,
|
||||
object_id: string
|
||||
id_city: number
|
||||
year: number
|
||||
id_parent: number | null
|
||||
type: number
|
||||
planning: boolean
|
||||
activity: boolean
|
||||
kvr: string | null
|
||||
jur: string | null
|
||||
fuel: string | null
|
||||
boiler_id: string | null
|
||||
}
|
||||
|
||||
export interface IObjectParam {
|
||||
id_object: string,
|
||||
id_param: number,
|
||||
value: string,
|
||||
date_s: string | null,
|
||||
date_po: string | null,
|
||||
id_user: number
|
||||
export interface IObjectValue {
|
||||
value: any
|
||||
date_s: string | null
|
||||
date_po: string | null
|
||||
}
|
||||
|
||||
export interface IParam {
|
||||
id: number,
|
||||
id_group: number | null,
|
||||
name: string,
|
||||
format: string,
|
||||
vtable: string,
|
||||
unit: string | null,
|
||||
exact_format: string | null,
|
||||
inHistory: string | null
|
||||
export type IObjectParamFormat =
|
||||
'bit' |
|
||||
'int' | 'smallint' | 'bigint' | 'tinyint' |
|
||||
'smalldatetime' | 'calculate' |
|
||||
'TCB' | 'GTCB' |
|
||||
'year' | 'uniqueidentifier' | 'group' | 'groupcalculate' | 'array' | string
|
||||
|
||||
export interface IObjectParam {
|
||||
id_object: string
|
||||
id_param: number
|
||||
name: string
|
||||
date_s: string | null
|
||||
date_po: string | null
|
||||
id_user: number
|
||||
format: IObjectParamFormat
|
||||
vtable: string
|
||||
exact_format: string | null
|
||||
unit: string | null
|
||||
values: IObjectValue[]
|
||||
parameters?: IObjectParam[]
|
||||
}
|
||||
|
||||
export interface IObjectType {
|
||||
id: number,
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
@ -1,16 +1,56 @@
|
||||
import { AppShell, Avatar, Burger, Button, Flex, Group, Image, Menu, NavLink, rem, Text, useMantineColorScheme } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { Outlet, useNavigate } from 'react-router-dom';
|
||||
import { IconChevronDown, IconLogout, IconSettings, IconMoon, IconSun } from '@tabler/icons-react';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { IconLogout, IconSettings, IconMoon, IconSun, IconMenu2, IconUser, IconLogin } from '@tabler/icons-react';
|
||||
import { getUserData, logout, useAuthStore } from '../store/auth';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { UserData } from '../interfaces/auth';
|
||||
import { pages } from '../constants/app';
|
||||
import { Button, Image, makeStyles, Menu, MenuButton, MenuItem, MenuList, MenuPopover, MenuTrigger, Text } from '@fluentui/react-components';
|
||||
import { setColorScheme, useAppStore } from '../store/app';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
display: 'grid',
|
||||
gridTemplateRows: 'min-content auto',
|
||||
height: '100vh',
|
||||
maxHeight: '100vh',
|
||||
overflow: 'hidden'
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
maxHeight: '3rem',
|
||||
borderBottom: '1px solid var(--colorNeutralShadowKey)'
|
||||
},
|
||||
|
||||
main: {
|
||||
display: 'flex',
|
||||
overflow: 'hidden',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
navbar: {
|
||||
overflow: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
maxWidth: '200px',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
transition: 'max-width .2s ease-in-out',
|
||||
borderRight: '1px solid var(--colorNeutralShadowKey)'
|
||||
},
|
||||
content: {
|
||||
overflow: 'auto',
|
||||
display: 'flex',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
}
|
||||
})
|
||||
|
||||
function DashboardLayout() {
|
||||
const [mobileOpened, { toggle: toggleMobile }] = useDisclosure()
|
||||
const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true)
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { colorScheme } = useAppStore()
|
||||
|
||||
const getPageTitle = () => {
|
||||
const currentPath = location.pathname
|
||||
@ -31,110 +71,95 @@ function DashboardLayout() {
|
||||
}
|
||||
}, [authStore])
|
||||
|
||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||
const classes = useStyles()
|
||||
|
||||
const [navbarOpen, setNavbarOpen] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (colorScheme === 'dark') {
|
||||
document.body.dataset.agThemeMode = 'dark'
|
||||
} else {
|
||||
document.body.dataset.agThemeMode = 'light'
|
||||
}
|
||||
}, [colorScheme])
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
header={{ height: 60 }}
|
||||
navbar={{
|
||||
width: desktopOpened ? 200 : 50,
|
||||
breakpoint: 'sm',
|
||||
collapsed: { mobile: !mobileOpened },
|
||||
}}
|
||||
>
|
||||
<AppShell.Header>
|
||||
<Flex h="100%" px="md" w='100%' align='center' gap='sm'>
|
||||
<Group>
|
||||
<Burger opened={mobileOpened} onClick={toggleMobile} hiddenFrom="sm" size="sm" />
|
||||
<Burger opened={desktopOpened} onClick={toggleDesktop} visibleFrom="sm" size="sm" />
|
||||
</Group>
|
||||
<div className={classes.root}>
|
||||
<div className={classes.header}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
padding: '0.5rem 0.5rem 0.5rem 0.25rem',
|
||||
}}>
|
||||
<Button appearance='subtle' onClick={() => setNavbarOpen(!navbarOpen)} icon={<IconMenu2 />} />
|
||||
|
||||
<Group w='auto'>
|
||||
<Text weight='bold' size={400}>
|
||||
{getPageTitle()}
|
||||
</Group>
|
||||
</Text>
|
||||
|
||||
<Group id='header-portal' w='auto' ml='auto'>
|
||||
<div id='header-portal' style={{ marginLeft: 'auto' }}>
|
||||
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<Group style={{ flexShrink: 0 }}>
|
||||
<Menu
|
||||
width={260}
|
||||
position="bottom-end"
|
||||
transitionProps={{ transition: 'pop-top-right' }}
|
||||
withinPortal
|
||||
>
|
||||
<Menu.Target>
|
||||
<Button variant='transparent'>
|
||||
<Group gap={7}>
|
||||
<Avatar name={`${userData?.name} ${userData?.surname}`} radius="xl" size={30} />
|
||||
<Text fw={500} size="sm" lh={1} mr={3}>
|
||||
{`${userData?.name} ${userData?.surname}`}
|
||||
</Text>
|
||||
<IconChevronDown style={{ width: rem(12), height: rem(12) }} stroke={1.5} />
|
||||
</Group>
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>{userData?.login}</Menu.Label>
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
colorScheme === 'dark' ? <IconMoon style={{ width: rem(16), height: rem(16) }} stroke={1.5} /> : <IconSun style={{ width: rem(16), height: rem(16) }} stroke={1.5} />
|
||||
}
|
||||
onClick={() => colorScheme === 'dark' ? setColorScheme('light') : setColorScheme('dark')}
|
||||
>
|
||||
Тема: {colorScheme === 'dark' ? 'тёмная' : 'светлая'}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
<IconSettings style={{ width: rem(16), height: rem(16) }} stroke={1.5} />
|
||||
}
|
||||
onClick={() => navigate('/settings')}
|
||||
>
|
||||
Настройки профиля
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => {
|
||||
logout()
|
||||
<Menu positioning={{ autoSize: true }}>
|
||||
<MenuTrigger>
|
||||
<MenuButton appearance='transparent' icon={authStore.isAuthenticated ? <IconUser /> : <IconSettings />}>{authStore.isAuthenticated && `${userData?.name} ${userData?.surname}`}</MenuButton>
|
||||
</MenuTrigger>
|
||||
|
||||
<MenuPopover>
|
||||
<MenuList>
|
||||
{!authStore.isAuthenticated && <MenuItem icon={<IconLogin />} onClick={() => navigate('/auth/signin')}>Войти</MenuItem>}
|
||||
|
||||
<MenuItem icon={colorScheme === 'dark' ? <IconMoon /> : <IconSun />} onClick={() => colorScheme === 'dark' ? setColorScheme('light') : setColorScheme('dark')}>Тема: {colorScheme === 'dark' ? 'тёмная' : 'светлая'}</MenuItem>
|
||||
{authStore.isAuthenticated && <MenuItem icon={<IconSettings />} onClick={() => navigate('/settings')}>Настройки профиля</MenuItem>}
|
||||
{authStore.isAuthenticated && <MenuItem icon={<IconLogout />} onClick={() => {
|
||||
logout()
|
||||
|
||||
if (pages.find(page => page.path === '/auth/signin')?.enabled) {
|
||||
navigate("/auth/signin")
|
||||
}}
|
||||
leftSection={<IconLogout style={{ width: rem(16), height: rem(16) }} stroke={1.5} />}
|
||||
>
|
||||
Выход
|
||||
</Menu.Item>
|
||||
}
|
||||
}}>Выход</MenuItem>}
|
||||
<MenuItem icon={<Image src={'/logo2.svg'} width={24} />}>
|
||||
0.1.0
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</MenuPopover>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Menu.Item>
|
||||
<Flex gap='sm' align='center'>
|
||||
<Image src={'/logo2.svg'} w={32} />
|
||||
<Text>0.1.0</Text>
|
||||
</Flex>
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
</Flex>
|
||||
</AppShell.Header>
|
||||
<AppShell.Navbar style={{ transition: "width 0.2s ease" }}>
|
||||
{pages.filter((page) => page.drawer).filter((page) => page.enabled).map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
onClick={() => navigate(item.path)}
|
||||
label={item.label}
|
||||
leftSection={item.icon}
|
||||
active={location.pathname === item.path}
|
||||
style={{ textWrap: 'nowrap' }}
|
||||
/>
|
||||
))}
|
||||
</AppShell.Navbar>
|
||||
<AppShell.Main>
|
||||
<Flex w={{
|
||||
sm: desktopOpened ? 'calc(100% - 200px)' : 'calc(100% - 50px)',
|
||||
base: '100%'
|
||||
}} h={'calc(100% - 60px)'} style={{ transition: "width 0.2s ease" }} pos={'fixed'}>
|
||||
<div className={classes.main}>
|
||||
<div className={classes.navbar} style={{
|
||||
maxWidth: navbarOpen ? '200px' : '2.70rem',
|
||||
}}>
|
||||
{pages.filter((page) => page.drawer).filter((page) => page.enabled).map((item) => (
|
||||
<Button key={item.path} style={{ paddingLeft: '0.5rem', flexShrink: 0, flexWrap: 'nowrap', textWrap: 'nowrap', borderRadius: 0 }} appearance={location.pathname === item.path ? 'primary' : 'subtle'} onClick={() => navigate(item.path)}>
|
||||
<div style={{ display: 'flex', }}>
|
||||
{item.icon}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-start',
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
marginLeft: '1rem',
|
||||
}}>
|
||||
{item.label}
|
||||
</div>
|
||||
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={classes.content}>
|
||||
<Outlet />
|
||||
</Flex>
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,12 +1,119 @@
|
||||
// Layout for fullscreen pages
|
||||
|
||||
import { Flex } from "@mantine/core";
|
||||
import { makeStyles } from "@fluentui/react-components";
|
||||
import { Outlet } from "react-router-dom";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
width: '100%'
|
||||
}
|
||||
})
|
||||
|
||||
export default function MainLayout() {
|
||||
const classes = useStyles()
|
||||
|
||||
return (
|
||||
<Flex align='center' justify='center' h='100%' w='100%'>
|
||||
<Outlet />
|
||||
</Flex>
|
||||
<div className={classes.root}>
|
||||
<Background />
|
||||
<div style={{ position: 'absolute', inset: 0, display: 'flex', width: '100vw', height: '100vh' }}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Background = () => {
|
||||
return (
|
||||
<svg id="corp-bg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1440 900" preserveAspectRatio="xMidYMid slice" style={{ width: '100%', height: '100vh', display: 'block' }}>
|
||||
<defs>
|
||||
<style>
|
||||
{`:root {
|
||||
--c1: #0f4c81; /* deep blue */
|
||||
--c2: #1fb6ff; /* bright cyan */
|
||||
--c3: #7bd389; /* muted green */
|
||||
--muted: rgba(255,255,255,0.06);
|
||||
}`}
|
||||
</style>
|
||||
|
||||
<linearGradient id="grad-main" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0" stop-color="var(--c2)" stop-opacity="0.95" />
|
||||
<stop offset="0.45" stop-color="var(--c1)" stop-opacity="0.95" />
|
||||
<stop offset="1" stop-color="var(--c3)" stop-opacity="0.9" />
|
||||
</linearGradient>
|
||||
|
||||
<filter id="f-blur">
|
||||
<feGaussianBlur stdDeviation="40" result="b" />
|
||||
<feColorMatrix type="matrix"
|
||||
values="1 0 0 0 0
|
||||
0 1 0 0 0
|
||||
0 0 1 0 0
|
||||
0 0 0 0.6 0" in="b" />
|
||||
</filter>
|
||||
|
||||
<pattern id="dots" width="20" height="20" patternUnits="userSpaceOnUse">
|
||||
<rect width="100%" height="100%" fill="none" />
|
||||
<circle cx="2" cy="2" r="1.1" fill="white" fill-opacity="0.06" />
|
||||
</pattern>
|
||||
|
||||
<pattern id="diagonal" width="12" height="12" patternUnits="userSpaceOnUse" patternTransform="rotate(20)">
|
||||
<rect width="12" height="12" fill="transparent" />
|
||||
<path d="M0 0 L0 1" stroke="white" stroke-opacity="0.02" stroke-width="1" />
|
||||
</pattern>
|
||||
|
||||
<radialGradient id="spot" cx="70%" cy="20%" r="50%">
|
||||
<stop offset="0" stop-color="#ffffff" stop-opacity="0.06" />
|
||||
<stop offset="1" stop-color="#ffffff" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
|
||||
<clipPath id="blob-clip">
|
||||
<path id="blobPath"
|
||||
d="M420 80C520 10 760 10 890 100C1040 210 1080 360 980 470C880 580 640 620 480 540C300 440 320 180 420 80Z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect width="100%" height="100%" fill="url(#grad-main)" />
|
||||
|
||||
<g filter="url(#f-blur)" opacity="0.95">
|
||||
<path d="M-60 150 C180 40 420 10 700 120 C1040 260 1180 520 960 760 C760 980 420 920 140 800 C-80 700 -40 300 -60 150 Z"
|
||||
fill="white" fill-opacity="0.02" />
|
||||
<path d="M1200 40 C1060 -20 900 10 760 120 C620 240 620 420 760 560 C900 700 1160 760 1320 640 C1460 540 1440 200 1200 40 Z"
|
||||
fill="white" fill-opacity="0.03" />
|
||||
</g>
|
||||
|
||||
<g transform="translate(60,30) scale(1.2)" opacity="0.9">
|
||||
<g clip-path="url(#blob-clip)">
|
||||
<rect width="100%" height="100%" fill="url(#spot)" />
|
||||
</g>
|
||||
<path id="morph" fill="white" fill-opacity="0.06">
|
||||
<animate attributeName="d"
|
||||
dur="18s"
|
||||
repeatCount="indefinite"
|
||||
values="
|
||||
M420 80C520 10 760 10 890 100C1040 210 1080 360 980 470C880 580 640 620 480 540C300 440 320 180 420 80Z;
|
||||
M380 60C520 0 760 40 930 120C1080 220 1060 360 940 460C800 560 600 620 420 560C240 500 250 200 380 60Z;
|
||||
M440 100C580 30 820 10 980 140C1130 280 1100 430 980 540C840 660 600 680 420 600C220 500 300 160 440 100Z;
|
||||
M420 80C520 10 760 10 890 100C1040 210 1080 360 980 470C880 580 640 620 480 540C300 440 320 180 420 80Z"/>
|
||||
</path>
|
||||
</g>
|
||||
|
||||
<g opacity="0.12">
|
||||
<rect x="40" y="60" width="360" height="620" rx="20" fill="url(#diagonal)" />
|
||||
<rect x="980" y="80" width="380" height="540" rx="26" fill="url(#dots)" />
|
||||
</g>
|
||||
|
||||
<g>
|
||||
<rect x="80" y="420" width="1280" height="120" rx="60" fill="white" fill-opacity="0.02" transform="skewX(-18)" />
|
||||
<rect x="-60" y="260" width="820" height="60" rx="30" fill="white" fill-opacity="0.015" transform="skewX(10)" />
|
||||
</g>
|
||||
|
||||
<g stroke="white" stroke-opacity="0.06" stroke-width="1" fill="none">
|
||||
<path d="M120 720 C320 600 520 540 720 580 C920 620 1120 760 1360 700" />
|
||||
<path d="M40 620 C240 540 460 460 680 490 C920 520 1080 680 1360 640" />
|
||||
</g>
|
||||
|
||||
<rect width="100%" height="100%" fill="black" opacity="0.02" />
|
||||
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@ -1,25 +1,12 @@
|
||||
import "@fontsource/inter";
|
||||
import '@mantine/core/styles.css';
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
import { createTheme, DEFAULT_THEME, MantineProvider, mergeMantineTheme } from '@mantine/core';
|
||||
|
||||
const overrides = createTheme({
|
||||
// Set this color to `--mantine-color-body` CSS variable
|
||||
white: '#F0F0F0',
|
||||
colors: {
|
||||
// ...
|
||||
},
|
||||
})
|
||||
|
||||
const theme = mergeMantineTheme(DEFAULT_THEME, overrides);
|
||||
import 'dayjs/locale/ru';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<MantineProvider theme={theme}>
|
||||
<App />
|
||||
</MantineProvider>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@ -1,85 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useBoilers } from '../hooks/swrHooks'
|
||||
import { Badge, ScrollAreaAutosize, Table, Text } from '@mantine/core'
|
||||
import { IBoiler } from '../interfaces/fuel'
|
||||
|
||||
function Boilers() {
|
||||
const [boilersPage, setBoilersPage] = useState(1)
|
||||
const [boilerSearch, setBoilerSearch] = useState("")
|
||||
const [debouncedBoilerSearch, setDebouncedBoilerSearch] = useState("")
|
||||
const { boilers } = useBoilers(10, boilersPage, debouncedBoilerSearch)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedBoilerSearch(boilerSearch)
|
||||
}, 500)
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler)
|
||||
}
|
||||
}, [boilerSearch])
|
||||
|
||||
useEffect(() => {
|
||||
setBoilersPage(1)
|
||||
setBoilerSearch("")
|
||||
}, [])
|
||||
|
||||
const boilersColumns = [
|
||||
{ field: 'id_object', headerName: 'ID', type: "number" },
|
||||
{ field: 'boiler_name', headerName: 'Название', type: "string", flex: 1 },
|
||||
{ field: 'boiler_code', headerName: 'Код', type: "string", flex: 1 },
|
||||
{ field: 'id_city', headerName: 'Город', type: "string", flex: 1 },
|
||||
{ field: 'activity', headerName: 'Активен', type: "boolean", flex: 1 },
|
||||
]
|
||||
|
||||
return (
|
||||
<ScrollAreaAutosize w={'100%'} h={'100%'} p='sm'>
|
||||
<Text size="xl" fw={600}>
|
||||
Котельные
|
||||
</Text>
|
||||
|
||||
{boilers &&
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
{boilersColumns.map(column => (
|
||||
<Table.Th key={column.field}>{column.headerName}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{boilers.map((boiler: IBoiler) => (
|
||||
<Table.Tr key={boiler.id_object}>
|
||||
{boilersColumns.map(column => {
|
||||
if (column.field === 'activity') {
|
||||
return (
|
||||
boiler.activity ? (
|
||||
<Table.Td key={`${boiler.id_object}-${boiler[column.field]}`}>
|
||||
<Badge fullWidth variant="light">
|
||||
Активен
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
|
||||
) : (
|
||||
<Table.Td key={`${boiler.id_object}-${boiler[column.field]}`}>
|
||||
<Badge color="gray" fullWidth variant="light">
|
||||
Отключен
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
)
|
||||
)
|
||||
}
|
||||
else return (
|
||||
<Table.Td key={`${boiler.id_object}-${column.field}`}>{boiler[column.field as keyof IBoiler]}</Table.Td>
|
||||
)
|
||||
})}
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
}
|
||||
</ScrollAreaAutosize>
|
||||
)
|
||||
}
|
||||
|
||||
export default Boilers
|
||||
@ -1,12 +0,0 @@
|
||||
import { Flex } from '@mantine/core'
|
||||
import ServerHardware from '../components/ServerHardware'
|
||||
|
||||
const ComponentTest = () => {
|
||||
return (
|
||||
<Flex direction='column' align='flex-start' gap='sm' p='sm'>
|
||||
<ServerHardware />
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
export default ComponentTest
|
||||
83
client/src/pages/DBManager.tsx
Normal file
83
client/src/pages/DBManager.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import useSWR from 'swr'
|
||||
import { BASE_URL } from '../constants'
|
||||
import { fetcher } from '../http/axiosInstance'
|
||||
import { useState } from 'react'
|
||||
import CustomTable from '../components/CustomTable'
|
||||
import { Tab, TabList } from '@fluentui/react-components'
|
||||
|
||||
const DBManager = () => {
|
||||
const { data: tablesData } = useSWR(`/db/tables`, (key) => fetcher(key, BASE_URL.ems), {
|
||||
revalidateOnFocus: false
|
||||
})
|
||||
|
||||
const [selectedTab, setSelectedTab] = useState<string | unknown>(undefined)
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', width: '100%', height: '100%', padding: '0.5rem' }}>
|
||||
{tablesData && Array.isArray(tablesData) && tablesData.length > 0 &&
|
||||
<div style={{ width: '100%', height: '100%' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<TabList selectedValue={selectedTab} onTabSelect={(_, data) => setSelectedTab(data.value)}>
|
||||
{tablesData.map(table => (
|
||||
<Tab key={table.tablename} value={table.tablename}>
|
||||
{table.tablename}
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
|
||||
<div style={{ width: '100%', height: '100%' }}>
|
||||
{tablesData.map((table) => {
|
||||
if (table.tablename === selectedTab)
|
||||
return (
|
||||
<TableData tablename={table.tablename} />
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
}
|
||||
{/* <Card withBorder radius='sm'>
|
||||
<Stack>
|
||||
<Group justify='flex-start'>
|
||||
<Text fw='bold'>Figures</Text>
|
||||
</Group>
|
||||
<Grid>
|
||||
<Button>Import from New_Gis (erases data)</Button>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Card> */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TableData = ({
|
||||
tablename
|
||||
}: { tablename: string }) => {
|
||||
const [offset] = useState(0)
|
||||
const [limit] = useState(20)
|
||||
|
||||
const { data: columnsData } = useSWR(`/db/columns/${tablename}`, (key) => fetcher(key, BASE_URL.ems), {
|
||||
revalidateOnFocus: false
|
||||
})
|
||||
|
||||
const { data: rowsData } = useSWR(columnsData ? `/db/rows/${tablename}?offset=${offset}&limit=${limit}` : null, (key) => fetcher(key, BASE_URL.ems), {
|
||||
revalidateOnFocus: false
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
{columnsData && rowsData && Array.isArray(columnsData) && Array.isArray(rowsData) && columnsData.length > 0 &&
|
||||
<CustomTable data={rowsData} columns={columnsData.map(column => (
|
||||
{
|
||||
name: column.column_name,
|
||||
header: column.column_name,
|
||||
type: 'string',
|
||||
}
|
||||
))} />
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DBManager
|
||||
248
client/src/pages/Fuel.tsx
Normal file
248
client/src/pages/Fuel.tsx
Normal file
@ -0,0 +1,248 @@
|
||||
import { IconMathMax, IconPlus, IconTableMinus } from "@tabler/icons-react";
|
||||
import { FuelExpenseDtoHeaders, FuelLimitDtoHeaders } from "../dto/fuel/fuel.dto";
|
||||
import useSWR from "swr";
|
||||
import { fetcher } from "../http/axiosInstanceNest";
|
||||
import { useState } from "react";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
|
||||
import { AgGridReact } from "ag-grid-react";
|
||||
import { AllCommunityModule, ColDef, ModuleRegistry } from 'ag-grid-community'
|
||||
import { CalendarStrings, DatePicker, defaultDatePickerStrings } from "@fluentui/react-datepicker-compat"
|
||||
import { Button, Dialog, DialogSurface, DialogTitle, DialogTrigger, Field, Input, Spinner, Tab, TabList } from "@fluentui/react-components";
|
||||
|
||||
ModuleRegistry.registerModules([AllCommunityModule])
|
||||
|
||||
type FieldType = 'text' | 'number' | 'select' | 'date'
|
||||
|
||||
interface IPostInclude {
|
||||
field: string
|
||||
field_type: FieldType
|
||||
}
|
||||
|
||||
interface ITableSchema {
|
||||
label: string
|
||||
value: string
|
||||
get: string
|
||||
post: string
|
||||
headers: { [key: string]: string }
|
||||
post_include: IPostInclude[]
|
||||
icon: JSX.Element
|
||||
}
|
||||
|
||||
export default function FuelPage() {
|
||||
const tables: ITableSchema[] = [
|
||||
{
|
||||
label: 'Расходы',
|
||||
value: 'expenses',
|
||||
get: '/fuel/expenses',
|
||||
post: '/fuel/expenses',
|
||||
headers: FuelExpenseDtoHeaders,
|
||||
post_include: [
|
||||
{
|
||||
field: 'id_boiler',
|
||||
field_type: 'text'
|
||||
},
|
||||
{
|
||||
field: 'id_fuel',
|
||||
field_type: 'text'
|
||||
},
|
||||
{
|
||||
field: 'date',
|
||||
field_type: 'date'
|
||||
},
|
||||
{
|
||||
field: 'value',
|
||||
field_type: 'text'
|
||||
}
|
||||
],
|
||||
icon: <IconTableMinus size={12} />
|
||||
},
|
||||
{
|
||||
label: 'Лимиты',
|
||||
value: 'limits',
|
||||
get: '/fuel/limits',
|
||||
post: '/fuel/limits',
|
||||
headers: FuelLimitDtoHeaders,
|
||||
post_include: [
|
||||
{
|
||||
field: 'id_boiler',
|
||||
field_type: 'text'
|
||||
},
|
||||
{
|
||||
field: 'id_fuel',
|
||||
field_type: 'text'
|
||||
},
|
||||
{
|
||||
field: 'month',
|
||||
field_type: 'number'
|
||||
},
|
||||
{
|
||||
field: 'year',
|
||||
field_type: 'number'
|
||||
},
|
||||
{
|
||||
field: 'value',
|
||||
field_type: 'text'
|
||||
}
|
||||
],
|
||||
icon: <IconMathMax size={12} />,
|
||||
}
|
||||
]
|
||||
|
||||
const [currentTab, setCurrentTab] = useState(tables[0])
|
||||
|
||||
const { data, isLoading } = useSWR(currentTab.get, () => fetcher(currentTab.get), { revalidateOnFocus: false })
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', width: '100%' }}>
|
||||
<TabList defaultValue={tables[0].value} selectedValue={currentTab.value}>
|
||||
{tables.map((table, index) => (
|
||||
<Tab key={index} value={table.value} icon={table.icon} onClick={() => setCurrentTab(table)}>
|
||||
{table.label}
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
|
||||
<div style={{ display: 'flex', padding: '1rem' }}>
|
||||
<Dialog>
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
appearance='primary'
|
||||
icon={<IconPlus />}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
Добавить
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogSurface>
|
||||
<DialogTitle>Добавление объекта</DialogTitle>
|
||||
<ModalCreate currentTab={currentTab} />
|
||||
</DialogSurface>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}>
|
||||
{tables.map((table, index) => {
|
||||
if (table.value === currentTab.value) {
|
||||
return (
|
||||
isLoading ?
|
||||
<div style={{ display: 'flex', width: '100%', justifyContent: 'center', padding: '1rem' }}>
|
||||
<Spinner />
|
||||
</div>
|
||||
:
|
||||
<div style={{ width: 'auto', height: '100%', padding: '1rem' }}>
|
||||
<AgGridReact
|
||||
key={index}
|
||||
// rowData={[
|
||||
// Object.keys(table.headers).reduce((obj, key) => ({ ...obj, [key]: 'test1' }), {}),
|
||||
// Object.keys(table.headers).reduce((obj, key) => ({ ...obj, [key]: 'test' }), {})
|
||||
// ]}
|
||||
rowData={data}
|
||||
columnDefs={Object.keys(table.headers).map((header) => ({
|
||||
field: header
|
||||
})) as ColDef[]}
|
||||
defaultColDef={{
|
||||
flex: 1,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ModalCreate = ({
|
||||
currentTab
|
||||
}: {
|
||||
currentTab: ITableSchema
|
||||
}) => {
|
||||
const { register, handleSubmit,
|
||||
//formState: { errors, isSubmitting, dirtyFields, isValid }
|
||||
} = useForm({
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<any> = async (values: any) => {
|
||||
console.log('Values to submit:', values)
|
||||
}
|
||||
|
||||
const localizedStrings: CalendarStrings = {
|
||||
...defaultDatePickerStrings,
|
||||
days: [
|
||||
'Воскресенье',
|
||||
'Понедельник',
|
||||
'Вторник',
|
||||
'Среда',
|
||||
'Четверг',
|
||||
'Пятница',
|
||||
'Суббота'
|
||||
],
|
||||
shortDays: ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'],
|
||||
months: [
|
||||
"Январь", "Февраль", "Март", "Апрель", "Май", "Июнь", "Июль", "Август", "Сентябрь", "Ноябрь", "Декабрь"
|
||||
],
|
||||
shortMonths: [
|
||||
"Янв",
|
||||
"Фев",
|
||||
"Мар",
|
||||
"Апр",
|
||||
"Май",
|
||||
"Июн",
|
||||
"Июл",
|
||||
"Авг",
|
||||
"Сен",
|
||||
"Ноя",
|
||||
"Дек",
|
||||
]
|
||||
}
|
||||
|
||||
return (
|
||||
<form style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }} onSubmit={handleSubmit(onSubmit)}>
|
||||
{currentTab.post_include.map((header, index) => {
|
||||
switch (header.field_type) {
|
||||
case 'date':
|
||||
return (
|
||||
<Field key={index} label={header.field}>
|
||||
<DatePicker
|
||||
strings={localizedStrings}
|
||||
{...register(header.field, {
|
||||
required: true
|
||||
})} />
|
||||
</Field>
|
||||
)
|
||||
case 'text':
|
||||
return (
|
||||
<Field key={index} label={header.field}>
|
||||
<Input {...register(header.field, {
|
||||
required: true
|
||||
})} />
|
||||
</Field>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<Field key={index} label={header.field}>
|
||||
<Input {...register(header.field, {
|
||||
required: true
|
||||
})} />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
})}
|
||||
|
||||
<Button style={{ marginTop: '2rem' }} appearance="primary" type='submit'>
|
||||
Добавить
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import { Card, Flex, SimpleGrid, Text } from "@mantine/core";
|
||||
import { IconBuildingFactory2, IconFiles, IconMap, IconReport, IconServer, IconShield, IconUsers } from "@tabler/icons-react";
|
||||
import { ReactNode } from "react";
|
||||
import { CompoundButton, Text } from "@fluentui/react-components";
|
||||
import { BuildingColor, CloudColor, DocumentColor, FormColor, MapFilled, PeopleListColor, ShieldColor } from "@fluentui/react-icons";
|
||||
//import { IconBuildingFactory2, IconFiles, IconMap, IconReport, IconServer, IconShield, IconUsers } from "@tabler/icons-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export default function Main() {
|
||||
@ -8,47 +8,56 @@ export default function Main() {
|
||||
|
||||
interface CustomCardProps {
|
||||
link: string;
|
||||
icon: ReactNode;
|
||||
icon: any;
|
||||
label: string;
|
||||
secondaryLabel?: string;
|
||||
}
|
||||
const CustomCard = ({
|
||||
link,
|
||||
icon,
|
||||
label
|
||||
label,
|
||||
secondaryLabel
|
||||
}: CustomCardProps) => {
|
||||
return (
|
||||
<Card
|
||||
<CompoundButton
|
||||
onClick={() => navigate(link)}
|
||||
withBorder
|
||||
style={{ cursor: 'pointer', userSelect: 'none' }}
|
||||
icon={icon}
|
||||
secondaryContent={secondaryLabel}
|
||||
>
|
||||
<Flex mih='50'>
|
||||
{icon}
|
||||
</Flex>
|
||||
|
||||
<Text fw={500} size="lg" mt="md">
|
||||
<Text weight={'bold'} size={400}>
|
||||
{label}
|
||||
</Text>
|
||||
</Card>
|
||||
</CompoundButton>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex w={'100%'} h={'100%'} direction='column' gap='sm' p='sm'>
|
||||
<Text size="xl" fw={700}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
gap: '1rem',
|
||||
padding: '1rem'
|
||||
}}>
|
||||
<Text size={600} weight='bold'>
|
||||
Главная
|
||||
</Text>
|
||||
|
||||
<SimpleGrid cols={{ xs: 1, md: 3 }}>
|
||||
<CustomCard link="/user" icon={<IconUsers size='50' color="#6495ED" />} label="Пользователи" />
|
||||
<CustomCard link="/role" icon={<IconShield size='50' color="#6495ED" />} label="Роли" />
|
||||
<CustomCard link="/documents" icon={<IconFiles size='50' color="#6495ED" />} label="Документы" />
|
||||
<CustomCard link="/reports" icon={<IconReport size='50' color="#6495ED" />} label="Отчеты" />
|
||||
<CustomCard link="/servers" icon={<IconServer size='50' color="#6495ED" />} label="Серверы" />
|
||||
<CustomCard link="/boilers" icon={<IconBuildingFactory2 size='50' color="#6495ED" />} label="Котельные" />
|
||||
<CustomCard link="/map-test" icon={<IconMap size='50' color="#6495ED" />} label="ИКС" />
|
||||
</SimpleGrid>
|
||||
|
||||
</Flex>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '1rem',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<CustomCard link="/user" icon={<PeopleListColor color="#6495ED" />} label="Пользователи" secondaryLabel="Управление пользователями"/>
|
||||
<CustomCard link="/role" icon={<ShieldColor color="#6495ED" />} label="Роли" />
|
||||
<CustomCard link="/documents" icon={<DocumentColor color="#6495ED" />} label="Документы" secondaryLabel="Обзор файлов/документов"/>
|
||||
<CustomCard link="/reports" icon={<FormColor color="#6495ED" />} label="Отчеты" secondaryLabel="Просмотр и создание отчетных документов"/>
|
||||
<CustomCard link="/servers" icon={<CloudColor color="#6495ED" />} label="Серверы" secondaryLabel="Мониторинг серверов"/>
|
||||
<CustomCard link="/boilers" icon={<BuildingColor color="#6495ED" />} label="Котельные" />
|
||||
<CustomCard link="/map-test" icon={<MapFilled color="#6495ED" />} label="ИКС" secondaryLabel="Инженерно-картографическая система"/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,9 +1,100 @@
|
||||
import MapComponent from '../components/map/MapComponent'
|
||||
import { Tab, TabList } from "@fluentui/react-tabs";
|
||||
import MapComponent from "../components/map/MapComponent";
|
||||
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
|
||||
|
||||
import {
|
||||
useAppStore,
|
||||
setCurrentTab,
|
||||
deleteMapTab,
|
||||
addMapTab,
|
||||
reorderTabs,
|
||||
} from "../store/app";
|
||||
import { initializeMapState, useMapStore } from "../store/map";
|
||||
import { initializeObjectsState } from "../store/objects";
|
||||
import { Button } from "@fluentui/react-components";
|
||||
import { Add12Filled, Dismiss12Filled, Map16Regular } from "@fluentui/react-icons";
|
||||
|
||||
function MapTest() {
|
||||
const { currentTab, tabOrder } = useAppStore()
|
||||
const { id } = useMapStore()
|
||||
|
||||
const handleDragEnd = (result: any) => {
|
||||
if (!result.destination) return
|
||||
reorderTabs(result.source.index, result.destination.index)
|
||||
}
|
||||
|
||||
return (
|
||||
<MapComponent />
|
||||
<div style={{ height: "100%", width: "100%", position: "relative" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<DragDropContext onDragEnd={handleDragEnd}>
|
||||
<Droppable droppableId="tabs" direction="horizontal">
|
||||
{(provided) => (
|
||||
<TabList
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
size="small"
|
||||
selectedValue={currentTab}
|
||||
onTabSelect={(_, data) => setCurrentTab(data.value as string)}
|
||||
style={{ borderBottom: '1px solid var(--colorNeutralShadowKey)' }}
|
||||
onDragStart={(e) => {
|
||||
e.stopPropagation(); // stop TabList from also handling it
|
||||
}}
|
||||
onDrag={(e) => e.stopPropagation()}
|
||||
>
|
||||
{tabOrder.map((key, index) => (
|
||||
<Draggable disableInteractiveElementBlocking draggableId={key} index={index} key={key}>
|
||||
{(dragProvided) => (
|
||||
<div
|
||||
ref={dragProvided.innerRef}
|
||||
{...dragProvided.draggableProps}
|
||||
{...dragProvided.dragHandleProps}
|
||||
>
|
||||
<Tab value={key} icon={<Map16Regular />}>
|
||||
{id[key]?.mapLabel ?? `Tab ${key}`}
|
||||
<Button
|
||||
as='a'
|
||||
style={{ marginLeft: '0.5rem' }}
|
||||
size="small"
|
||||
icon={<Dismiss12Filled />}
|
||||
appearance="subtle"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
deleteMapTab(key)
|
||||
}}
|
||||
/>
|
||||
</Tab>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
|
||||
{provided.placeholder}
|
||||
|
||||
<Button
|
||||
icon={<Add12Filled />}
|
||||
title="Открыть новую вкладку"
|
||||
appearance="subtle"
|
||||
onClick={() => {
|
||||
const newId = addMapTab();
|
||||
initializeObjectsState(newId, null, null, null, null);
|
||||
initializeMapState(newId);
|
||||
}}
|
||||
/>
|
||||
</TabList>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
|
||||
<div style={{ flexGrow: 1, position: "relative" }}>
|
||||
{tabOrder.map((key) => (
|
||||
<div key={key} style={{ height: "100%", position: "relative", display: currentTab === key ? 'unset' : 'none' }}>
|
||||
<MapComponent id={key} active={currentTab === key} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MapTest
|
||||
export default MapTest;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Card } from '@fluentui/react-components';
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, Flex } from '@mantine/core';
|
||||
|
||||
function CardComponent({
|
||||
url,
|
||||
@ -7,10 +7,10 @@ function CardComponent({
|
||||
}: { url: string, is_alive: boolean }) {
|
||||
return (
|
||||
<Card>
|
||||
<Flex p='sm' direction='column'>
|
||||
<div>
|
||||
<p>{url}</p>
|
||||
<p>{JSON.stringify(is_alive)}</p>
|
||||
</Flex>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@ -38,11 +38,15 @@ export default function MonitorPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Flex direction='column' gap='sm'>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem'
|
||||
}}>
|
||||
{servers.length > 0 && servers.map((server: { name: string, status: boolean }) => (
|
||||
<CardComponent url={server.name} is_alive={server.status} />
|
||||
))}
|
||||
</Flex>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,15 +1,29 @@
|
||||
import { Flex, Text } from "@mantine/core";
|
||||
import { Text } from "@fluentui/react-components";
|
||||
import { makeStyles } from "@fluentui/react-components";
|
||||
import { IconError404 } from "@tabler/icons-react";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center'
|
||||
}
|
||||
})
|
||||
|
||||
export default function NotFound() {
|
||||
const classes = useStyles()
|
||||
|
||||
return (
|
||||
<Flex w={'100%'} h={'100%'} p='sm' gap='sm' align='center' justify='center'>
|
||||
<Flex direction='column' gap='sm' align='center'>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}>
|
||||
<div className={classes.root}>
|
||||
<IconError404 size={100} />
|
||||
<Text size="xl" fw={500} ta='center'>
|
||||
<Text size={500} weight='medium' align='center'>
|
||||
Запрашиваемая страница не найдена.
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1296
client/src/pages/PrintReport.tsx
Normal file
1296
client/src/pages/PrintReport.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -3,8 +3,8 @@ import { useCities, useReport, useReportExport } from "../hooks/swrHooks"
|
||||
import { useDebounce } from "@uidotdev/usehooks"
|
||||
import { ICity } from "../interfaces/fuel"
|
||||
import { mutate } from "swr"
|
||||
import { ActionIcon, Autocomplete, Badge, Button, CloseButton, Flex, ScrollAreaAutosize, Table } from "@mantine/core"
|
||||
import { IconRefresh } from "@tabler/icons-react"
|
||||
import { Badge, Button, Combobox, createTableColumn, DataGrid, DataGridBody, DataGridCell, DataGridHeader, DataGridHeaderCell, DataGridRow, Option, TableCellLayout, TableColumnDefinition } from "@fluentui/react-components"
|
||||
|
||||
export default function Reports() {
|
||||
const [download, setDownload] = useState(false)
|
||||
@ -40,99 +40,125 @@ export default function Reports() {
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollAreaAutosize w={'100%'} h={'100%'} p='sm'>
|
||||
<Flex component="form" gap={'sm'}>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
padding: '1rem'
|
||||
}}>
|
||||
<form style={{
|
||||
display: 'flex',
|
||||
gap: '0.5rem'
|
||||
}}>
|
||||
{/* <SearchableSelect /> */}
|
||||
<Autocomplete
|
||||
placeholder="Населенный пункт"
|
||||
flex={'1'}
|
||||
data={cities ? cities.map((item: ICity) => ({ label: item.name, value: item.id.toString() })) : []}
|
||||
onSelect={(e) => console.log(e.currentTarget.value)}
|
||||
onChange={(value) => setSearch(value)}
|
||||
onOptionSubmit={(value) => setSelectedOption(Number(value))}
|
||||
rightSection={
|
||||
search !== '' && (
|
||||
<CloseButton
|
||||
size="sm"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => {
|
||||
setSearch('')
|
||||
setSelectedOption(null)
|
||||
}}
|
||||
aria-label="Clear value"
|
||||
/>
|
||||
)
|
||||
}
|
||||
value={search}
|
||||
/>
|
||||
<Combobox clearable placeholder="Населенный пункт" onOptionSelect={(_, data) => {
|
||||
setSelectedOption(Number(data.optionValue))
|
||||
setSearch(data.optionText ?? "")
|
||||
}} value={search} onChange={(e) => setSearch(e.currentTarget.value)}>
|
||||
{cities && Array.isArray(cities) && cities.map((item: ICity) => (
|
||||
<Option key={item.id} value={item.id.toString()}>
|
||||
{item.name}
|
||||
</Option>
|
||||
))}
|
||||
</Combobox>
|
||||
|
||||
<ActionIcon size='auto' variant='transparent' onClick={() => refreshReport()}>
|
||||
<IconRefresh />
|
||||
</ActionIcon>
|
||||
<Button icon={<IconRefresh />} appearance="subtle" onClick={() => refreshReport()}>
|
||||
|
||||
</Button>
|
||||
|
||||
<Button disabled={!selectedOption} onClick={() => exportReport()}>
|
||||
Экспорт
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
|
||||
{report &&
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
{[
|
||||
{ field: 'id', headerName: '№', width: 70 },
|
||||
...Object.keys(report).map(key => ({
|
||||
field: key,
|
||||
headerName: key.charAt(0).toUpperCase() + key.slice(1),
|
||||
width: 150
|
||||
}))
|
||||
].map(column => (
|
||||
<Table.Th key={column.headerName}>{column.headerName}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{[...new Set(Object.keys(report).flatMap(key => Object.keys(report[key])))].map(id => {
|
||||
const row: any = { id: Number(id) };
|
||||
Object.keys(report).forEach(key => {
|
||||
row[key] = report[key][id];
|
||||
});
|
||||
return (<Table.Tr key={row.id}>
|
||||
{[
|
||||
{ field: 'id', headerName: '№', width: 70 },
|
||||
...Object.keys(report).map(key => ({
|
||||
field: key,
|
||||
headerName: key.charAt(0).toUpperCase() + key.slice(1),
|
||||
width: 150
|
||||
}))
|
||||
].map(column => {
|
||||
if (column.field === 'Активность') {
|
||||
return (
|
||||
row['Активность'] ? (
|
||||
<Table.Td key={`${row.id}-${column.headerName}`}>
|
||||
<Badge fullWidth variant="light">
|
||||
Активен
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
overflow: 'auto'
|
||||
}}>
|
||||
{report &&
|
||||
<ReportTable report={report} />
|
||||
}
|
||||
</div>
|
||||
|
||||
) : (
|
||||
<Table.Td key={`${row.id}-${column.headerName}`}>
|
||||
<Badge color="gray" fullWidth variant="light">
|
||||
Отключен
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
)
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Table.Td key={`${row.id}-${column.headerName}`}>{row[column.field]}</Table.Td>
|
||||
)
|
||||
})}
|
||||
</Table.Tr>)
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
}
|
||||
</ScrollAreaAutosize>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
interface ReportType {
|
||||
[key: string]: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function ReportTable({ report }: { report: ReportType }) {
|
||||
// Build column definitions
|
||||
const columns: TableColumnDefinition<any>[] = [
|
||||
createTableColumn({
|
||||
columnId: "id",
|
||||
renderHeaderCell: () => {
|
||||
return "№"
|
||||
},
|
||||
renderCell: (item) => <TableCellLayout>{item.id}</TableCellLayout>,
|
||||
}),
|
||||
...Object.keys(report).map((key) =>
|
||||
createTableColumn({
|
||||
columnId: key,
|
||||
renderHeaderCell: () => {
|
||||
return key.charAt(0).toUpperCase() + key.slice(1)
|
||||
},
|
||||
renderCell: (item: any) => {
|
||||
if (key === "Активность") {
|
||||
return (
|
||||
<TableCellLayout>
|
||||
{item["Активность"] ? (
|
||||
<Badge color="success">Активен</Badge>
|
||||
) : (
|
||||
<Badge color="danger">Отключен</Badge>
|
||||
)}
|
||||
</TableCellLayout>
|
||||
);
|
||||
}
|
||||
return <TableCellLayout>{item[key] as string}</TableCellLayout>;
|
||||
},
|
||||
})
|
||||
),
|
||||
];
|
||||
|
||||
// Build rows from report (same logic you used)
|
||||
const items = [...new Set(Object.keys(report).flatMap((key) => Object.keys(report[key])))]
|
||||
.map((id) => {
|
||||
const row: Record<string, unknown> = { id: Number(id) };
|
||||
Object.keys(report).forEach((key) => {
|
||||
row[key] = report[key][id];
|
||||
});
|
||||
return row;
|
||||
});
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
items={items}
|
||||
columns={columns}
|
||||
sortable
|
||||
focusMode='row_unstable'
|
||||
resizableColumns
|
||||
resizableColumnsOptions={{ autoFitColumns: false }}
|
||||
size='extra-small'
|
||||
|
||||
>
|
||||
<DataGridHeader>
|
||||
<DataGridRow>
|
||||
{({ renderHeaderCell }) => (
|
||||
<DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>
|
||||
)}
|
||||
</DataGridRow>
|
||||
</DataGridHeader>
|
||||
|
||||
<DataGridBody>
|
||||
{({ item, rowId }) => (
|
||||
<DataGridRow key={rowId}>
|
||||
{({ renderCell }) => <DataGridCell>{renderCell(item)}</DataGridCell>}
|
||||
</DataGridRow>
|
||||
)}
|
||||
</DataGridBody>
|
||||
</DataGrid>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,63 +1,75 @@
|
||||
import { useRoles } from '../hooks/swrHooks'
|
||||
import { CreateField } from '../interfaces/create'
|
||||
import RoleService from '../services/RoleService'
|
||||
import FormFields from '../components/FormFields'
|
||||
import { Button, Loader, Modal, ScrollAreaAutosize, Table } from '@mantine/core'
|
||||
import { useDisclosure } from '@mantine/hooks'
|
||||
import { IRole } from '../interfaces/role'
|
||||
import CustomTable from '../components/CustomTable'
|
||||
import { Link, Spinner } from '@fluentui/react-components'
|
||||
|
||||
export default function Roles() {
|
||||
const { roles, isError, isLoading } = useRoles()
|
||||
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
|
||||
const createFields: CreateField[] = [
|
||||
{ key: 'name', headerName: 'Название', type: 'string', required: true, defaultValue: '' },
|
||||
{ key: 'description', headerName: 'Описание', type: 'string', required: false, defaultValue: '' },
|
||||
]
|
||||
|
||||
const columns = [
|
||||
{ field: 'id', headerName: 'ID', type: "number" },
|
||||
{ field: 'name', headerName: 'Название', flex: 1, editable: true },
|
||||
{ field: 'description', headerName: 'Описание', flex: 1, editable: true },
|
||||
];
|
||||
const handleError = (error: any) => {
|
||||
if (error?.response?.status === 401) {
|
||||
return (
|
||||
<Link href="/auth/signin">
|
||||
Войдите, чтобы продолжить
|
||||
</Link>
|
||||
)
|
||||
} else {
|
||||
return "Произошла ошибка при получении данных."
|
||||
}
|
||||
}
|
||||
|
||||
if (isError) return <div>Произошла ошибка при получении данных.</div>
|
||||
if (isLoading) return <Loader />
|
||||
if (isError) return (
|
||||
<div style={{ padding: '1rem' }}>
|
||||
{handleError(isError)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
padding: '1rem',
|
||||
}}>
|
||||
<Spinner />
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<ScrollAreaAutosize w={'100%'} h={'100%'} p='sm'>
|
||||
<Button onClick={open}>
|
||||
Добавить роль
|
||||
</Button>
|
||||
|
||||
<Modal opened={opened} onClose={close} title="Создание роли" centered>
|
||||
<FormFields
|
||||
fields={createFields}
|
||||
submitHandler={RoleService.createRole}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
{columns.map(column => (
|
||||
<Table.Th key={column.field}>{column.headerName}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{roles.map((role: IRole) => (
|
||||
<Table.Tr
|
||||
key={role.id}
|
||||
>
|
||||
{columns.map(column => (
|
||||
<Table.Td key={column.field}>{role[column.field as keyof IRole]}</Table.Td>
|
||||
))}
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollAreaAutosize>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
padding: '1rem'
|
||||
}} >
|
||||
<CustomTable
|
||||
createFields={createFields}
|
||||
submitHandler={RoleService.createRole}
|
||||
data={roles} columns={[
|
||||
{
|
||||
name: 'id',
|
||||
header: 'id',
|
||||
type: 'number'
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
header: 'Название',
|
||||
type: 'string'
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
header: 'Описание',
|
||||
type: 'string'
|
||||
},
|
||||
]} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -3,46 +3,52 @@ import ServersView from "../components/ServersView"
|
||||
import ServerIpsView from "../components/ServerIpsView"
|
||||
import ServerHardware from "../components/ServerHardware"
|
||||
import ServerStorage from "../components/ServerStorages"
|
||||
import { Flex, ScrollAreaAutosize, Tabs } from "@mantine/core"
|
||||
import { Tab, TabList } from "@fluentui/react-components"
|
||||
|
||||
export default function Servers() {
|
||||
const [currentTab, setCurrentTab] = useState<string | null>('0')
|
||||
const tabs = [{
|
||||
id: 'servers',
|
||||
name: 'Серверы',
|
||||
content: <ServersView />
|
||||
},
|
||||
{
|
||||
id: 'ips',
|
||||
name: 'IP-адреса',
|
||||
content: <ServerIpsView />
|
||||
},
|
||||
{
|
||||
id: 'hardware',
|
||||
name: 'Hardware',
|
||||
content: <ServerHardware />
|
||||
},
|
||||
{
|
||||
id: 'storage',
|
||||
name: 'Хранилище',
|
||||
content: <ServerStorage />
|
||||
}
|
||||
]
|
||||
|
||||
const [selectedTab, setSelectedTab] = useState<string | unknown>(tabs[0].id)
|
||||
|
||||
return (
|
||||
<ScrollAreaAutosize w={'100%'} h={'100%'} p='sm'>
|
||||
<Flex gap='sm' direction='column'>
|
||||
<Tabs value={currentTab} onChange={setCurrentTab}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="0">Серверы</Tabs.Tab>
|
||||
<Tabs.Tab value="1">IP-адреса</Tabs.Tab>
|
||||
<Tabs.Tab value="3">Hardware</Tabs.Tab>
|
||||
<Tabs.Tab value="4">Storages</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
gap: '1rem',
|
||||
padding: '1rem'
|
||||
}}>
|
||||
<TabList selectedValue={selectedTab} onTabSelect={(_, data) => setSelectedTab(data.value)}>
|
||||
{tabs.map(tab => (
|
||||
<Tab key={tab.id} value={tab.id}>{tab.name}</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
|
||||
<Tabs.Panel value="0" pt='sm'>
|
||||
<ServersView />
|
||||
</Tabs.Panel>
|
||||
<div>
|
||||
{tabs.find(tab => tab.id === selectedTab)?.content}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs.Panel value="1" pt='sm'>
|
||||
<ServerIpsView />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="2" pt='sm'>
|
||||
<ServerHardware />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="3" pt='sm'>
|
||||
<ServerStorage />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Flex>
|
||||
|
||||
{/* <BarChart
|
||||
xAxis={[{ scaleType: 'band', data: ['group A', 'group B', 'group C'] }]}
|
||||
series={[{ data: [4, 3, 5] }, { data: [1, 6, 3] }, { data: [2, 5, 6] }]}
|
||||
width={500}
|
||||
height={300}
|
||||
/> */}
|
||||
</ScrollAreaAutosize>
|
||||
)
|
||||
}
|
||||
@ -5,7 +5,6 @@ import { CreateField } from "../interfaces/create"
|
||||
import { IUser } from "../interfaces/user"
|
||||
import FormFields from "../components/FormFields"
|
||||
import AuthService from "../services/AuthService"
|
||||
import { Flex, ScrollAreaAutosize } from "@mantine/core"
|
||||
|
||||
export default function Settings() {
|
||||
const { token } = useAuthStore()
|
||||
@ -39,13 +38,20 @@ export default function Settings() {
|
||||
]
|
||||
|
||||
return (
|
||||
<ScrollAreaAutosize
|
||||
w={'100%'}
|
||||
h={'100%'}
|
||||
p='sm'
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
padding: '1rem'
|
||||
}}
|
||||
>
|
||||
{currentUser &&
|
||||
<Flex direction='column' gap='sm' w='100%'>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
gap: '1rem'
|
||||
}}>
|
||||
<FormFields
|
||||
fields={profileFields}
|
||||
defaultValues={currentUser}
|
||||
@ -61,8 +67,8 @@ export default function Settings() {
|
||||
submitHandler={(data) => AuthService.updatePassword({ id: currentUser.id, ...data })}
|
||||
title="Смена пароля"
|
||||
/>
|
||||
</Flex>
|
||||
</div>
|
||||
}
|
||||
</ScrollAreaAutosize>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,13 +0,0 @@
|
||||
import { Flex } from '@mantine/core';
|
||||
import CustomTable from '../components/CustomTable';
|
||||
|
||||
function TableTest() {
|
||||
|
||||
return (
|
||||
<Flex direction='column' align='flex-start' gap='sm' p='sm'>
|
||||
<CustomTable />
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
export default TableTest
|
||||
@ -3,9 +3,8 @@ import { IRole } from "../interfaces/role"
|
||||
import { useEffect, useState } from "react"
|
||||
import { CreateField } from "../interfaces/create"
|
||||
import UserService from "../services/UserService"
|
||||
import FormFields from "../components/FormFields"
|
||||
import { Badge, Button, Flex, Loader, Modal, Pagination, ScrollAreaAutosize, Select, Table } from "@mantine/core"
|
||||
import { useDisclosure } from "@mantine/hooks"
|
||||
import CustomTable from "../components/CustomTable"
|
||||
import { Link, Spinner } from "@fluentui/react-components"
|
||||
import { IUser } from "../interfaces/user"
|
||||
|
||||
export default function Users() {
|
||||
@ -15,13 +14,19 @@ export default function Users() {
|
||||
|
||||
const [roleOptions, setRoleOptions] = useState<{ label: string, value: string }[]>()
|
||||
|
||||
const [data, setData] = useState<IUser[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (Array.isArray(roles)) {
|
||||
setRoleOptions(roles.map((role: IRole) => ({ label: role.name, value: role.id.toString() })))
|
||||
}
|
||||
}, [roles])
|
||||
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
useEffect(() => {
|
||||
if (users) {
|
||||
setData(users)
|
||||
}
|
||||
}, [users])
|
||||
|
||||
const createFields: CreateField[] = [
|
||||
{ key: 'email', headerName: 'E-mail', type: 'string', required: true, defaultValue: '' },
|
||||
@ -32,130 +37,98 @@ export default function Users() {
|
||||
{ key: 'password', headerName: 'Пароль', type: 'string', required: true, defaultValue: '' },
|
||||
]
|
||||
|
||||
const columns = [
|
||||
{ field: 'id', headerName: 'ID', type: "number", flex: 1 },
|
||||
{ field: 'email', headerName: 'Email', flex: 1, editable: true },
|
||||
{ field: 'login', headerName: 'Логин', flex: 1, editable: true },
|
||||
{ field: 'phone', headerName: 'Телефон', flex: 1, editable: true },
|
||||
{ field: 'name', headerName: 'Имя', flex: 1, editable: true },
|
||||
{ field: 'surname', headerName: 'Фамилия', flex: 1, editable: true },
|
||||
{ field: 'is_active', headerName: 'Статус', type: "boolean", flex: 1, editable: true },
|
||||
{
|
||||
field: 'role_id',
|
||||
headerName: 'Роль',
|
||||
valueOptions: roles ? roles.map((role: IRole) => ({ label: role.name, value: role.id })) : [],
|
||||
type: 'singleSelect',
|
||||
flex: 1,
|
||||
editable: true
|
||||
},
|
||||
];
|
||||
const handleError = (error: any) => {
|
||||
if (error?.response?.status === 401) {
|
||||
return (
|
||||
<Link href="/auth/signin">
|
||||
Войдите, чтобы продолжить
|
||||
</Link>
|
||||
)
|
||||
} else {
|
||||
return "Произошла ошибка при получении данных."
|
||||
}
|
||||
}
|
||||
|
||||
if (isError) return (
|
||||
<div>
|
||||
Произошла ошибка при получении данных.
|
||||
<div style={{ padding: '1rem' }}>
|
||||
{handleError(isError)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Flex direction='column' align='flex-start' gap='sm' p='sm'>
|
||||
<Loader />
|
||||
</Flex>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
padding: '1rem',
|
||||
}}>
|
||||
<Spinner />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollAreaAutosize w={'100%'} h={'100%'} p='sm'>
|
||||
<Button onClick={open}>
|
||||
Добавить пользователя
|
||||
</Button>
|
||||
|
||||
<Modal opened={opened} onClose={close} title="Регистрация пользователя" centered>
|
||||
<FormFields
|
||||
fields={createFields}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
padding: '1rem'
|
||||
}}>
|
||||
{Array.isArray(roleOptions) && Array.isArray(data) &&
|
||||
<CustomTable
|
||||
createFields={createFields}
|
||||
submitHandler={UserService.createUser}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{Array.isArray(roleOptions) &&
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
{columns.map(column => (
|
||||
<Table.Th key={column.field}>{column.headerName}</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{users.map((user: IUser) => (
|
||||
<Table.Tr
|
||||
key={user.id}
|
||||
//bg={selectedRows.includes(element.position) ? 'var(--mantine-color-blue-light)' : undefined}
|
||||
>
|
||||
{columns.map(column => {
|
||||
if (column.field === 'is_active') {
|
||||
return (
|
||||
user.is_active ? (
|
||||
<Table.Td key={column.field}>
|
||||
<Badge fullWidth variant="light">
|
||||
Активен
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
) : (
|
||||
<Table.Td key={column.field}>
|
||||
<Badge color="gray" fullWidth variant="light">
|
||||
Отключен
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
)
|
||||
)
|
||||
}
|
||||
else if (column.field === 'role_id') {
|
||||
return (
|
||||
<Table.Td key={column.field}>
|
||||
<Select
|
||||
data={roleOptions}
|
||||
defaultValue={user.role_id.toString()}
|
||||
variant="unstyled"
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Table.Td>
|
||||
)
|
||||
}
|
||||
else return (
|
||||
<Table.Td key={column.field}>{user[column.field as keyof IUser]}</Table.Td>
|
||||
)
|
||||
})}
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
data={data}
|
||||
onEditCell={(rowId, columnId, value) => {
|
||||
console.log(rowId, columnId, value)
|
||||
setData((prev) =>
|
||||
prev.map((row) =>
|
||||
row.id === rowId ? { ...row, [columnId]: value } : row
|
||||
)
|
||||
)
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
name: 'email',
|
||||
header: 'E-mail',
|
||||
type: "string"
|
||||
},
|
||||
{
|
||||
name: 'login',
|
||||
header: 'Логин',
|
||||
type: "string"
|
||||
},
|
||||
{
|
||||
name: 'phone',
|
||||
header: 'Телефон',
|
||||
type: "string"
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
header: 'Имя',
|
||||
type: "string"
|
||||
},
|
||||
{
|
||||
name: 'surname',
|
||||
header: 'Фамилия',
|
||||
type: "string"
|
||||
},
|
||||
{
|
||||
name: 'is_active',
|
||||
header: 'Активен',
|
||||
type: "boolean"
|
||||
},
|
||||
{
|
||||
name: 'role_id',
|
||||
header: 'Роль',
|
||||
type: "dictionary" //TODO: dictionary getter by id
|
||||
}
|
||||
]} />
|
||||
}
|
||||
|
||||
<Pagination total={10} />
|
||||
|
||||
{/* <DataGrid
|
||||
density="compact"
|
||||
autoHeight
|
||||
style={{ width: "100%" }}
|
||||
rows={users}
|
||||
columns={columns}
|
||||
initialState={{
|
||||
pagination: {
|
||||
paginationModel: { page: 0, pageSize: 10 },
|
||||
},
|
||||
}}
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
checkboxSelection
|
||||
disableRowSelectionOnClick
|
||||
|
||||
processRowUpdate={(updatedRow) => {
|
||||
return updatedRow
|
||||
}}
|
||||
|
||||
onProcessRowUpdateError={() => {
|
||||
}}
|
||||
/> */}
|
||||
</ScrollAreaAutosize>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,8 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import { SubmitHandler, useForm } from 'react-hook-form';
|
||||
import AuthService from '../../services/AuthService';
|
||||
import { Button, Flex, Loader, Paper, Text, TextInput, Transition } from '@mantine/core';
|
||||
import { IconCheck } from '@tabler/icons-react';
|
||||
import { Button, Input, Spinner, Text } from '@fluentui/react-components';
|
||||
|
||||
interface PasswordResetProps {
|
||||
email: string;
|
||||
@ -11,7 +11,7 @@ interface PasswordResetProps {
|
||||
function PasswordReset() {
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
const { register, handleSubmit, watch, setError, formState: { errors, isSubmitting } } = useForm<PasswordResetProps>({
|
||||
const { register, handleSubmit, watch, setError, formState: { isSubmitting } } = useForm<PasswordResetProps>({
|
||||
defaultValues: {
|
||||
email: ''
|
||||
}
|
||||
@ -31,65 +31,82 @@ function PasswordReset() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper flex={1} maw='500' withBorder radius='md' p='xl'>
|
||||
<Flex direction='column' gap='sm'>
|
||||
<Text size="xl" fw={500}>
|
||||
Восстановление пароля
|
||||
</Text>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
margin: 'auto',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem',
|
||||
maxWidth: '400px',
|
||||
width: '100%',
|
||||
height: 'min-content',
|
||||
borderRadius: '1rem',
|
||||
border: '1px solid #00000030',
|
||||
padding: '2rem',
|
||||
background: 'var(--colorNeutralBackground1)'
|
||||
}}>
|
||||
<Text size={600} weight='medium'>
|
||||
Восстановление пароля
|
||||
</Text>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{!success &&
|
||||
<Transition mounted={!success} transition='fade'>
|
||||
{(styles) =>
|
||||
<Flex style={styles} direction='column' gap={'md'}>
|
||||
<Text>
|
||||
Введите адрес электронной почты, на который будут отправлены новые данные для авторизации:
|
||||
</Text>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{!success &&
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem'
|
||||
}}>
|
||||
<Text>
|
||||
Введите адрес электронной почты, на который будут отправлены новые данные для авторизации:
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
label='E-mail'
|
||||
required
|
||||
{...register('email', { required: 'Введите E-mail' })}
|
||||
error={errors.email?.message}
|
||||
/>
|
||||
<Input
|
||||
placeholder='E-mail'
|
||||
required
|
||||
{...register('email', { required: 'Введите E-mail' })}
|
||||
//error={errors.email?.message}
|
||||
/>
|
||||
|
||||
<Flex gap='sm'>
|
||||
<Button flex={1} type="submit" disabled={isSubmitting || watch('email').length == 0} variant='filled'>
|
||||
{isSubmitting ? <Loader size={16} /> : 'Восстановить пароль'}
|
||||
</Button>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<Button type="submit" disabled={isSubmitting || watch('email').length == 0} appearance='primary'>
|
||||
{isSubmitting ? <Spinner /> : 'Восстановить пароль'}
|
||||
</Button>
|
||||
|
||||
<Button flex={1} component='a' href="/auth/signin" type="button" variant='light'>
|
||||
Назад
|
||||
</Button>
|
||||
</Flex>
|
||||
<Button as='a' href="/auth/signin" type="button" appearance='subtle'>
|
||||
Назад
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</Flex>
|
||||
}
|
||||
|
||||
</Transition>
|
||||
}
|
||||
{success &&
|
||||
<Transition mounted={!success} transition='scale'>
|
||||
{(styles) =>
|
||||
<Flex style={styles} direction='column' gap='sm'>
|
||||
<Flex align='center' gap='sm'>
|
||||
<IconCheck />
|
||||
<Text>
|
||||
На указанный адрес было отправлено письмо с новыми данными для авторизации.
|
||||
</Text>
|
||||
</Flex>
|
||||
<Flex gap='sm'>
|
||||
<Button component='a' href="/auth/signin" type="button">
|
||||
Войти
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
}
|
||||
</Transition>
|
||||
}
|
||||
</form>
|
||||
</Flex>
|
||||
</Paper>
|
||||
</div>
|
||||
}
|
||||
{success &&
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1rem'
|
||||
}}>
|
||||
<IconCheck />
|
||||
<Text>
|
||||
На указанный адрес было отправлено письмо с новыми данными для авторизации.
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex' }}>
|
||||
<Button as='a' href="/auth/signin" type="button">
|
||||
Войти
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -5,7 +5,8 @@ import { login, setUserData } from '../../store/auth';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import AuthService from '../../services/AuthService';
|
||||
import UserService from '../../services/UserService';
|
||||
import { Button, Flex, Loader, Paper, Text, TextInput } from '@mantine/core';
|
||||
import { Button, Field, Input, Link, Spinner, Text } from '@fluentui/react-components';
|
||||
import { pages } from '../../constants/app';
|
||||
|
||||
const SignIn = () => {
|
||||
const { register, handleSubmit, setError, formState: { errors, isSubmitting, isValid } } = useForm<LoginFormData>({
|
||||
@ -46,53 +47,65 @@ const SignIn = () => {
|
||||
message: (err as { detail: string })?.detail
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper flex={1} maw='500' withBorder radius='md' p='xl'>
|
||||
<Flex direction='column' gap='sm'>
|
||||
<Text size="xl" fw={500}>
|
||||
Вход
|
||||
</Text>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
margin: 'auto',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem',
|
||||
maxWidth: '400px',
|
||||
width: '100%',
|
||||
height: 'min-content',
|
||||
borderRadius: '1rem',
|
||||
border: '1px solid #00000030',
|
||||
padding: '2rem',
|
||||
background: 'var(--colorNeutralBackground1)'
|
||||
}}>
|
||||
<Text align='center' size={500} weight='bold'>
|
||||
Вход
|
||||
</Text>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Flex direction='column' gap='sm'>
|
||||
<TextInput
|
||||
label='Логин'
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem'
|
||||
}}>
|
||||
|
||||
<Field label={'Логин'} validationState={errors.username?.message ? 'error' : 'none'}>
|
||||
<Input
|
||||
required
|
||||
{...register('username', { required: 'Введите логин' })}
|
||||
error={errors.username?.message}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<TextInput
|
||||
label='Пароль'
|
||||
type='password'
|
||||
<Field label={'Пароль'} validationState={errors.password?.message ? 'error' : 'none'}>
|
||||
<Input
|
||||
required
|
||||
type='password'
|
||||
{...register('password', { required: 'Введите пароль' })}
|
||||
error={errors.password?.message}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Flex justify='flex-end' gap='sm'>
|
||||
<Button component='a' href='/auth/password-reset' variant='transparent'>
|
||||
Восстановить пароль
|
||||
</Button>
|
||||
</Flex>
|
||||
<Link href='/auth/password-reset'>
|
||||
Восстановить пароль
|
||||
</Link>
|
||||
|
||||
<Flex gap='sm'>
|
||||
<Button disabled={!isValid} type="submit" flex={1} variant='filled'>
|
||||
{isSubmitting ? <Loader size={16} /> : 'Вход'}
|
||||
</Button>
|
||||
<Button disabled={!isValid} type="submit" appearance='primary' icon={isSubmitting ? <Spinner size='extra-tiny' /> : undefined}>
|
||||
Вход
|
||||
</Button>
|
||||
|
||||
{/* <Button component='a' flex={1} href='/auth/signup' type="button" variant='light'>
|
||||
Регистрация
|
||||
</Button> */}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</form>
|
||||
</Flex>
|
||||
</Paper>
|
||||
{pages.find(page => page.path === '/auth/signup')?.enabled &&
|
||||
<Button as='a' href='/auth/signup' type="button" appearance='subtle'>
|
||||
Регистрация
|
||||
</Button>
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useForm, SubmitHandler } from 'react-hook-form';
|
||||
import UserService from '../../services/UserService';
|
||||
import { IUser } from '../../interfaces/user';
|
||||
import { Button, Flex, Loader, Paper, Text, TextInput } from '@mantine/core';
|
||||
import { Button, Field, Input, Spinner, Text } from '@fluentui/react-components';
|
||||
|
||||
const SignUp = () => {
|
||||
const { register, handleSubmit, formState: { errors, isValid, isSubmitting } } = useForm<IUser>({
|
||||
@ -26,66 +26,77 @@ const SignUp = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper flex={1} maw='500' withBorder radius='md' p='xl'>
|
||||
<Flex direction='column' gap='sm'>
|
||||
<Text size="xl" fw={500}>
|
||||
Регистрация
|
||||
</Text>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
margin: 'auto',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem',
|
||||
maxWidth: '400px',
|
||||
width: '100%',
|
||||
height: 'min-content',
|
||||
borderRadius: '1rem',
|
||||
border: '1px solid #00000030',
|
||||
padding: '2rem',
|
||||
background: 'var(--colorNeutralBackground1)'
|
||||
}}>
|
||||
<Text align='center' size={500} weight='bold'>
|
||||
Регистрация
|
||||
</Text>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Flex direction='column' gap='sm'>
|
||||
<TextInput
|
||||
label='Email'
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem'
|
||||
}}>
|
||||
<Field label={'Email'} validationState={errors.email?.message ? 'error' : 'none'}>
|
||||
<Input
|
||||
required
|
||||
{...register('email', { required: 'Email обязателен' })}
|
||||
error={errors.email?.message}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<TextInput
|
||||
label='Логин'
|
||||
<Field label={'Логин'} validationState={errors.login?.message ? 'error' : 'none'}>
|
||||
<Input
|
||||
required
|
||||
{...register('login', { required: 'Логин обязателен' })}
|
||||
error={errors.login?.message}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<TextInput
|
||||
label='Телефон'
|
||||
<Field label={'Телефон'} validationState={errors.phone?.message ? 'error' : 'none'}>
|
||||
<Input
|
||||
required
|
||||
{...register('phone')}
|
||||
error={errors.phone?.message}
|
||||
{...register('phone', { required: 'Телефон обязателен' })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<TextInput
|
||||
label='Имя'
|
||||
<Field label={'Имя'} validationState={errors.name?.message ? 'error' : 'none'}>
|
||||
<Input
|
||||
required
|
||||
{...register('name')}
|
||||
error={errors.name?.message}
|
||||
{...register('name', { required: 'Имя обязательно' })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<TextInput
|
||||
label='Фамилия'
|
||||
<Field label={'Фамилия'} validationState={errors.surname?.message ? 'error' : 'none'}>
|
||||
<Input
|
||||
required
|
||||
{...register('surname')}
|
||||
error={errors.surname?.message}
|
||||
{...register('surname', { required: 'Фамилия обязательна' })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<TextInput
|
||||
label='Пароль'
|
||||
type="password"
|
||||
<Field label={'Пароль'} validationState={errors.password?.message ? 'error' : 'none'}>
|
||||
<Input
|
||||
required
|
||||
{...register('password', { required: 'Пароль обязателен' })}
|
||||
error={errors.password?.message}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Flex gap='sm'>
|
||||
<Button disabled={!isValid} type="submit" flex={1} variant='filled'>
|
||||
{isSubmitting ? <Loader size={16} /> : 'Зарегистрироваться'}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</form>
|
||||
</Flex>
|
||||
</Paper>
|
||||
<Button disabled={!isValid} type="submit" appearance='primary'>
|
||||
{isSubmitting ? <Spinner /> : 'Зарегистрироваться'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -1,10 +0,0 @@
|
||||
$ka-background-color: #2c2c2c;
|
||||
$ka-border-color: #4d4d4d;
|
||||
$ka-cell-hover-background-color: adjust(#fff, 0.8);
|
||||
$ka-color-base: #fefefe;
|
||||
$ka-input-background-color: $ka-background-color;
|
||||
$ka-input-border-color: $ka-border-color;
|
||||
$ka-input-color: $ka-color-base;
|
||||
$ka-row-hover-background-color: adjust(#fff, 0.9);
|
||||
$ka-thead-background-color: #1b1b1b;
|
||||
$ka-thead-color: #c5c5c5;
|
||||
239
client/src/pages/fuel/Fuel/Boilers.tsx
Normal file
239
client/src/pages/fuel/Fuel/Boilers.tsx
Normal file
@ -0,0 +1,239 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Dropdown, Option, Spinner } from '@fluentui/react-components'
|
||||
import useSWR from 'swr'
|
||||
import axios from 'axios'
|
||||
import { AgGridReact, CustomCellRendererProps } from 'ag-grid-react'
|
||||
import { AllCommunityModule, ModuleRegistry } from 'ag-grid-community'
|
||||
import FuelRenderer from './FuelRenderer'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { ICity, IRegion } from '../../../interfaces/fuel'
|
||||
import BoilersCard from './BoilersCard'
|
||||
|
||||
ModuleRegistry.registerModules([AllCommunityModule])
|
||||
|
||||
function Boilers() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [regionId, setRegionId] = useState<number | undefined>(
|
||||
searchParams.get("region_id") ? Number(searchParams.get("region_id")) : undefined
|
||||
);
|
||||
|
||||
const [cityId, setCityId] = useState<number | undefined>(
|
||||
searchParams.get("city_id") ? Number(searchParams.get("city_id")) : undefined
|
||||
)
|
||||
|
||||
// Load regions
|
||||
const { data: regions, isLoading: regionsLoading } = useSWR("/general/regions", () =>
|
||||
axios
|
||||
.get("/general/regions", { baseURL: import.meta.env.VITE_API_NEST_URL })
|
||||
.then((res) => res.data)
|
||||
);
|
||||
|
||||
// Load cities when regionId exists
|
||||
const { data: cities, isLoading: citiesLoading } = useSWR(
|
||||
regionId ? `/general/cities?region_id=${regionId}` : null,
|
||||
() =>
|
||||
axios
|
||||
.get(`/general/cities?region_id=${regionId}`, {
|
||||
baseURL: import.meta.env.VITE_API_NEST_URL,
|
||||
})
|
||||
.then((res) => res.data)
|
||||
);
|
||||
|
||||
// Load boilers when cityId exists
|
||||
const { data: boilers, isLoading: boilersLoading } = useSWR(
|
||||
cityId ? `/fuel/boilers?city_id=${cityId}&offset=0&limit=100` : null,
|
||||
() =>
|
||||
axios
|
||||
.get(`/fuel/boilers?city_id=${cityId}&offset=0&limit=100`, {
|
||||
baseURL: import.meta.env.VITE_API_NEST_URL,
|
||||
})
|
||||
.then((res) => res.data)
|
||||
);
|
||||
|
||||
//
|
||||
// Sync regionId → URL
|
||||
//
|
||||
useEffect(() => {
|
||||
if (!regionId) return;
|
||||
|
||||
setSearchParams((prev) => {
|
||||
const params = new URLSearchParams(prev);
|
||||
params.set("region_id", regionId.toString());
|
||||
params.delete("city_id"); // reset city when region changes
|
||||
return params;
|
||||
});
|
||||
|
||||
}, [regionId]);
|
||||
|
||||
//
|
||||
// Sync cityId → URL
|
||||
//
|
||||
useEffect(() => {
|
||||
if (!cityId) return;
|
||||
|
||||
setSearchParams((prev) => {
|
||||
const params = new URLSearchParams(prev);
|
||||
params.set("city_id", cityId.toString());
|
||||
return params;
|
||||
});
|
||||
}, [cityId]);
|
||||
|
||||
//
|
||||
// Utility: get display values
|
||||
//
|
||||
const selectedRegion = regions?.find((r: IRegion) => r.id === regionId);
|
||||
const selectedCity = cities?.find((c: ICity) => c.id === cityId);
|
||||
|
||||
useEffect(() => {
|
||||
const paramCityId = searchParams.get("city_id");
|
||||
|
||||
if (!paramCityId) return;
|
||||
|
||||
const id = Number(paramCityId);
|
||||
|
||||
// If cityId not yet set OR mismatched with URL
|
||||
if (cities && !cityId && cities.some((c: ICity) => c.id === id)) {
|
||||
setCityId(id);
|
||||
}
|
||||
}, [cities])
|
||||
|
||||
useEffect(() => {
|
||||
const paramRegionId = searchParams.get("region_id");
|
||||
if (!paramRegionId) return;
|
||||
|
||||
const id = Number(paramRegionId);
|
||||
|
||||
if (regions && !regionId && regions.some((r: IRegion) => r.id === id)) {
|
||||
setRegionId(id);
|
||||
}
|
||||
}, [regions]);
|
||||
|
||||
useEffect(() => {
|
||||
setCityId(undefined); // clear stale value before SWR runs
|
||||
}, [regionId]);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: '1rem',
|
||||
width: '100%',
|
||||
gap: '1rem'
|
||||
}}>
|
||||
{/* <Portal mountNode={document.querySelector('#header-portal')}>
|
||||
|
||||
</Portal> */}
|
||||
|
||||
<div style={{ display: 'flex', gap: '1rem' }}>
|
||||
{regionsLoading ?
|
||||
<Spinner />
|
||||
:
|
||||
<Dropdown
|
||||
placeholder='Выберите район'
|
||||
value={selectedRegion?.name ?? ""}
|
||||
selectedOptions={regionId ? [regionId.toString()] : []}
|
||||
onOptionSelect={(_, data) => {
|
||||
if (!data.optionValue) {
|
||||
setRegionId(undefined);
|
||||
return;
|
||||
}
|
||||
setRegionId(Number(data.optionValue));
|
||||
}}
|
||||
>
|
||||
{regions && Array.isArray(regions) && regions.map((option) => (
|
||||
<Option key={`region-${option.id}`} text={option.name} value={option.id}>
|
||||
{option.name}
|
||||
</Option>
|
||||
))}
|
||||
</Dropdown>
|
||||
}
|
||||
|
||||
{citiesLoading ?
|
||||
<Spinner />
|
||||
:
|
||||
<Dropdown
|
||||
placeholder='Выберите населенный пункт'
|
||||
value={selectedCity?.name ?? ""}
|
||||
selectedOptions={cityId ? [cityId.toString()] : []}
|
||||
onOptionSelect={(_, data) => {
|
||||
if (!data.optionValue) {
|
||||
setCityId(undefined);
|
||||
return;
|
||||
}
|
||||
setCityId(Number(data.optionValue));
|
||||
}}
|
||||
>
|
||||
{cities && Array.isArray(cities) && cities.map((option) => (
|
||||
<Option key={`region-${option.id}`} text={option.name} value={option.id}>
|
||||
{option.name}
|
||||
</Option>
|
||||
))}
|
||||
</Dropdown>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem'
|
||||
}}>
|
||||
{cityId &&
|
||||
<div style={{ display: 'flex', width: '100%', gap: '1rem', justifyContent: 'space-between' }}>
|
||||
<BoilersCard title='Всего объектов' value={boilers && Array.isArray(boilers) ? boilers.length.toString() : ''} subtitle='' />
|
||||
|
||||
<BoilersCard title='Общий остаток' value={''} subtitle='' />
|
||||
|
||||
<BoilersCard title='Лимит на сезон' value={''} subtitle='' />
|
||||
|
||||
<BoilersCard title='Требуют внимания' value={''} subtitle='' />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
{boilersLoading ?
|
||||
<Spinner />
|
||||
:
|
||||
<AgGridReact
|
||||
key={`boilers-${cityId}`}
|
||||
loading={boilersLoading}
|
||||
overlayLoadingTemplate='Загрузка...'
|
||||
overlayNoRowsTemplate='Нет данных'
|
||||
rowData={boilers}
|
||||
columnDefs={[
|
||||
{
|
||||
field: 'boiler_name',
|
||||
headerName: 'Наименование'
|
||||
},
|
||||
{
|
||||
field: 'boiler_code',
|
||||
headerName: 'Идент. код'
|
||||
},
|
||||
{
|
||||
field: 'id_fuels',
|
||||
headerName: 'Вид топлива',
|
||||
//editable: true,
|
||||
//cellEditor: FuelTypeEditor,
|
||||
autoHeight: true,
|
||||
cellRenderer: FuelRenderer,
|
||||
cellStyle: (_) => {
|
||||
return { padding: '1px' }
|
||||
}
|
||||
//enableCellChangeFlash: true
|
||||
},
|
||||
{
|
||||
field: 'activity',
|
||||
headerName: 'Активный',
|
||||
cellRenderer: (params: CustomCellRendererProps) => (<span>{params.value === true ? 'Да' : 'Нет'}</span>)
|
||||
}
|
||||
]}
|
||||
defaultColDef={{
|
||||
flex: 1,
|
||||
}}
|
||||
/>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Boilers
|
||||
34
client/src/pages/fuel/Fuel/BoilersCard.tsx
Normal file
34
client/src/pages/fuel/Fuel/BoilersCard.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { CompoundButton, Text } from "@fluentui/react-components"
|
||||
|
||||
const BoilersCard = ({
|
||||
title,
|
||||
value,
|
||||
subtitle,
|
||||
}: {
|
||||
title: string
|
||||
value: string
|
||||
subtitle: string
|
||||
}) => {
|
||||
return (
|
||||
<CompoundButton
|
||||
onClick={() => { }}
|
||||
style={{ display: 'flex', width: '100%', justifyContent: 'flex-start', flexDirection: 'column', gap: '2rem', alignItems: 'flex-start', cursor: 'pointer', userSelect: 'none' }}
|
||||
//icon={icon}
|
||||
>
|
||||
|
||||
<Text weight='bold' size={300}>
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
<Text weight='bold' size={500}>
|
||||
{value}
|
||||
</Text>
|
||||
|
||||
<Text weight='regular' size={200} style={{ color: 'gray' }}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
</CompoundButton>
|
||||
)
|
||||
}
|
||||
|
||||
export default BoilersCard
|
||||
47
client/src/pages/fuel/Fuel/Flow.tsx
Normal file
47
client/src/pages/fuel/Fuel/Flow.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import { Text } from '@fluentui/react-components'
|
||||
|
||||
const FlowPage = () => {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: '1rem',
|
||||
width: '100%',
|
||||
gap: '1rem'
|
||||
}}>
|
||||
{/* <Portal mountNode={document.querySelector('#header-portal')}>
|
||||
|
||||
</Portal> */}
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem'
|
||||
}}>
|
||||
<Text size={600} weight='bold'>
|
||||
Приход и расход топлива
|
||||
</Text>
|
||||
|
||||
<Text size={400} weight='medium'>
|
||||
|
||||
</Text>
|
||||
|
||||
|
||||
{/* {cityId &&
|
||||
<div style={{ display: 'flex', width: '100%', gap: '1rem', justifyContent: 'space-between' }}>
|
||||
<BoilersCard title='Всего объектов' value={boilers && Array.isArray(boilers) ? boilers.length.toString() : ''} subtitle='' />
|
||||
|
||||
<BoilersCard title='Общий остаток' value={''} subtitle='' />
|
||||
|
||||
<BoilersCard title='Лимит на сезон' value={''} subtitle='' />
|
||||
|
||||
<BoilersCard title='Требуют внимания' value={''} subtitle='' />
|
||||
</div>
|
||||
} */}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FlowPage
|
||||
53
client/src/pages/fuel/Fuel/FuelRenderer.tsx
Normal file
53
client/src/pages/fuel/Fuel/FuelRenderer.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { Button, Spinner, Table, TableBody, TableCell, TableCellActions, TableCellLayout, TableRow } from '@fluentui/react-components';
|
||||
import { Add12Regular, DeleteRegular, EditRegular } from '@fluentui/react-icons';
|
||||
import type { CustomCellRendererProps } from 'ag-grid-react';
|
||||
import axios from 'axios';
|
||||
import useSWR from 'swr';
|
||||
|
||||
export default (params: CustomCellRendererProps) => {
|
||||
const { data: fuelTypes } = useSWR('/fuel/fuel-types', () => axios.get(`/fuel/fuel-types`, {
|
||||
baseURL: import.meta.env.VITE_API_NEST_URL
|
||||
}).then(res => res.data))
|
||||
|
||||
if (fuelTypes) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', padding: '0.25rem', gap: '0.5rem' }}>
|
||||
{Array.isArray(params.value) && params.value.length > 0 &&
|
||||
<Table style={{ width: '100%' }} size='small'>
|
||||
<TableBody>
|
||||
{Array.isArray(params.value) && params.value.map(fuel => (
|
||||
<TableRow key={`${fuel.id}`}>
|
||||
<TableCell>
|
||||
<TableCellLayout truncate>
|
||||
{fuel.name}
|
||||
</TableCellLayout>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCellLayout truncate>
|
||||
{fuelTypes && Array.isArray(fuelTypes) &&
|
||||
fuelTypes.find(ft => ft.id === fuel.id_fuels).name
|
||||
}
|
||||
</TableCellLayout>
|
||||
|
||||
<TableCellActions>
|
||||
<Button icon={<EditRegular />} appearance='subtle' />
|
||||
<Button icon={<DeleteRegular color='red' />} appearance='subtle' />
|
||||
</TableCellActions>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
}
|
||||
|
||||
<Button style={{ width: 'min-content' }} appearance='primary' icon={<Add12Regular />} size='small'>
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Spinner />
|
||||
)
|
||||
}
|
||||
}
|
||||
43
client/src/pages/fuel/Fuel/FuelTypeEditor.tsx
Normal file
43
client/src/pages/fuel/Fuel/FuelTypeEditor.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { Combobox, Option, Spinner } from "@fluentui/react-components";
|
||||
import { CustomCellEditorProps } from "ag-grid-react";
|
||||
import axios from "axios";
|
||||
import useSWR from "swr";
|
||||
|
||||
export default ({ value, onValueChange }: CustomCellEditorProps) => {
|
||||
const { data: fuelTypes } = useSWR('/fuel/fuel-types', () => axios.get(`/fuel/fuel-types`, {
|
||||
baseURL: import.meta.env.VITE_API_NEST_URL
|
||||
}).then(res => res.data))
|
||||
|
||||
if (fuelTypes) {
|
||||
return (
|
||||
<Combobox
|
||||
autoFocus
|
||||
placeholder="Тип топлива"
|
||||
clearable
|
||||
value={fuelTypes.find((f: {
|
||||
id: number
|
||||
name: string
|
||||
}) => f.id === value).name}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
marginTop: '-4px'
|
||||
}}
|
||||
size='small'
|
||||
onOptionSelect={(_, data) => {
|
||||
onValueChange(data.optionValue === '' || data.optionValue === undefined ? null : data.optionValue)
|
||||
}}
|
||||
>
|
||||
{fuelTypes && Array.isArray(fuelTypes) && fuelTypes.map((option) => (
|
||||
<Option key={option.id} text={option.name} value={option.id}>
|
||||
{option.name}
|
||||
</Option>
|
||||
))}
|
||||
</Combobox>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Spinner />
|
||||
)
|
||||
}
|
||||
}
|
||||
25
client/src/pages/fuel/Fuel/FuelTypeRenderer.tsx
Normal file
25
client/src/pages/fuel/Fuel/FuelTypeRenderer.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { Spinner } from '@fluentui/react-components';
|
||||
import type { CustomCellRendererProps } from 'ag-grid-react';
|
||||
import axios from 'axios';
|
||||
import useSWR from 'swr';
|
||||
|
||||
export default (params: CustomCellRendererProps) => {
|
||||
const { data: fuelTypes } = useSWR('/fuel/fuel-types', () => axios.get(`/fuel/fuel-types`, {
|
||||
baseURL: import.meta.env.VITE_API_NEST_URL
|
||||
}).then(res => res.data))
|
||||
|
||||
if (fuelTypes) {
|
||||
return (
|
||||
<span>
|
||||
{fuelTypes.find((t: {
|
||||
id: number
|
||||
name: string
|
||||
}) => t.id === params.value).name}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Spinner />
|
||||
)
|
||||
}
|
||||
}
|
||||
34
client/src/pages/fuel/Fuel/FuelTypes.tsx
Normal file
34
client/src/pages/fuel/Fuel/FuelTypes.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { Text } from '@fluentui/react-components'
|
||||
|
||||
const FuelTypesPage = () => {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: '1rem',
|
||||
width: '100%',
|
||||
gap: '1rem'
|
||||
}}>
|
||||
{/* <Portal mountNode={document.querySelector('#header-portal')}>
|
||||
|
||||
</Portal> */}
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem'
|
||||
}}>
|
||||
<Text size={600} weight='bold'>
|
||||
Виды топлива
|
||||
</Text>
|
||||
|
||||
<Text size={400} weight='medium'>
|
||||
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FuelTypesPage
|
||||
185
client/src/pages/fuel/Fuel/Fuels.tsx
Normal file
185
client/src/pages/fuel/Fuel/Fuels.tsx
Normal file
@ -0,0 +1,185 @@
|
||||
import { Button, Combobox, Dialog, DialogActions, DialogBody, DialogContent, DialogSurface, DialogTitle, DialogTrigger, Field, Input, Option, Tab, TabList, Text } from '@fluentui/react-components'
|
||||
import useSWR from 'swr'
|
||||
import axios from 'axios'
|
||||
import { AgGridReact } from 'ag-grid-react'
|
||||
import { Controller, SubmitHandler, useForm } from 'react-hook-form'
|
||||
import { useState } from 'react'
|
||||
import { FuelType } from '../../../interfaces/fuel'
|
||||
|
||||
type Inputs = {
|
||||
id_fuel_type: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const FuelsPage = () => {
|
||||
const [selectedIdFuels, setSelectedIdFuels] = useState<number | null>(null)
|
||||
|
||||
const { data: fuelTypes } = useSWR('/fuel/fuel-types', () => axios.get(`/fuel/fuel-types`, {
|
||||
baseURL: import.meta.env.VITE_API_NEST_URL
|
||||
}).then(res => {
|
||||
setSelectedIdFuels(res.data[0].id)
|
||||
return res.data
|
||||
}))
|
||||
|
||||
const { data: fuels, mutate: mutateFuels, isLoading: fuelsLoading } = useSWR(selectedIdFuels ? `/fuel/fuels?id_fuels=${selectedIdFuels}` : null, () => axios.get(`/fuel/fuels?id_fuels=${selectedIdFuels}`, {
|
||||
baseURL: import.meta.env.VITE_API_NEST_URL
|
||||
}).then(res => res.data))
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
//formState: { errors, isSubmitting },
|
||||
} = useForm<Inputs>()
|
||||
|
||||
const onSubmit: SubmitHandler<Inputs> = (data) => {
|
||||
console.log(data)
|
||||
mutateFuels([...fuels, data])
|
||||
|
||||
setTimeout(() => {
|
||||
console.log("done")
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: '1rem',
|
||||
width: '100%',
|
||||
gap: '1rem'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem'
|
||||
}}>
|
||||
<Text size={600} weight='bold'>
|
||||
Виды топлива
|
||||
</Text>
|
||||
|
||||
<Dialog>
|
||||
<DialogTrigger>
|
||||
<Button appearance='primary' style={{ width: 'min-content' }}>
|
||||
Добавить
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogSurface>
|
||||
{fuelTypes &&
|
||||
<form onSubmit={handleSubmit(onSubmit)} >
|
||||
<DialogBody>
|
||||
<DialogTitle>Добавление вида топлива</DialogTitle>
|
||||
|
||||
<DialogContent style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<Controller
|
||||
name='id_fuel_type'
|
||||
control={control}
|
||||
rules={{
|
||||
required: true
|
||||
}}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return (
|
||||
<Field label={'Тип топлива'}>
|
||||
<Combobox
|
||||
autoFocus
|
||||
placeholder="Тип топлива"
|
||||
clearable
|
||||
value={fuelTypes.find((f: {
|
||||
id: number
|
||||
name: string
|
||||
}) => f.id.toString() == value)?.name || ''}
|
||||
size='medium'
|
||||
onOptionSelect={(_, data) => {
|
||||
onChange(data.optionValue)
|
||||
//onValueChange(data.optionValue === '' || data.optionValue === undefined ? null : data.optionValue)
|
||||
}}
|
||||
>
|
||||
{fuelTypes && Array.isArray(fuelTypes) && fuelTypes.map((option) => (
|
||||
<Option key={`fuel-type-${option.id}`} text={option.name} value={option.id}>
|
||||
{option.name}
|
||||
</Option>
|
||||
))}
|
||||
</Combobox>
|
||||
</Field>
|
||||
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Field label={'Наименование'}>
|
||||
<Input {...register('name', { required: true })} />
|
||||
</Field>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button type='submit' appearance='primary'>
|
||||
Добавить
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</DialogBody>
|
||||
</form>
|
||||
}
|
||||
|
||||
</DialogSurface>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{fuelTypes && Array.isArray(fuelTypes) &&
|
||||
<TabList selectedValue={selectedIdFuels} onTabSelect={(_, data) => {
|
||||
setSelectedIdFuels(Number(data.value))
|
||||
}}>
|
||||
{fuelTypes && Array.isArray(fuelTypes) && fuelTypes.map((ft: FuelType) => (
|
||||
<Tab id={ft.id.toString()} value={ft.id} key={ft.id}>
|
||||
{ft.name}
|
||||
</Tab>
|
||||
))
|
||||
}
|
||||
</TabList>}
|
||||
|
||||
<AgGridReact
|
||||
key={`fuels`}
|
||||
loading={fuelsLoading}
|
||||
overlayLoadingTemplate='Загрузка...'
|
||||
overlayNoRowsTemplate='Нет данных'
|
||||
rowData={fuels}
|
||||
columnDefs={[
|
||||
{
|
||||
field: 'name',
|
||||
headerName: 'Наименование'
|
||||
},
|
||||
{
|
||||
field: 'tnt',
|
||||
headerName: 'Перевод в натуральный показатель'
|
||||
},
|
||||
{
|
||||
field: 'tut',
|
||||
headerName: 'Перевод в условный показатель'
|
||||
},
|
||||
{
|
||||
field: 'koef',
|
||||
headerName: 'Коэффициент'
|
||||
},
|
||||
{
|
||||
field: 'Gk',
|
||||
headerName: 'Гк'
|
||||
},
|
||||
// {
|
||||
// field: 'id_fuels',
|
||||
// headerName: 'Вид топлива',
|
||||
// editable: true,
|
||||
// cellEditor: FuelTypeEditor,
|
||||
// cellRenderer: FuelTypeRenderer,
|
||||
// enableCellChangeFlash: true
|
||||
// },
|
||||
]}
|
||||
defaultColDef={{
|
||||
flex: 1,
|
||||
}}
|
||||
/>
|
||||
</div >
|
||||
)
|
||||
}
|
||||
|
||||
export default FuelsPage
|
||||
391
client/src/pages/fuel/Limits.tsx
Normal file
391
client/src/pages/fuel/Limits.tsx
Normal file
@ -0,0 +1,391 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, CompoundButton, Dropdown, Option, Spinner, Text } from '@fluentui/react-components'
|
||||
import useSWR from 'swr'
|
||||
import axios from 'axios'
|
||||
import { AgGridReact } from 'ag-grid-react'
|
||||
import { AllCommunityModule, ModuleRegistry } from 'ag-grid-community'
|
||||
import { ICity, IRegion, ISeason } from '../../interfaces/fuel'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import LimitAddForm from './Limits/LimitEditForm'
|
||||
|
||||
ModuleRegistry.registerModules([AllCommunityModule])
|
||||
|
||||
function LimitsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [year, setYear] = useState<number | undefined>(
|
||||
searchParams.get("year") ? Number(searchParams.get("year")) : undefined
|
||||
)
|
||||
|
||||
const [regionId, setRegionId] = useState<number | undefined>(
|
||||
searchParams.get("region_id") ? Number(searchParams.get("region_id")) : undefined
|
||||
);
|
||||
|
||||
const [cityId, setCityId] = useState<number | undefined>(
|
||||
searchParams.get("city_id") ? Number(searchParams.get("city_id")) : undefined
|
||||
)
|
||||
|
||||
// Load regions
|
||||
const { data: regions, isLoading: regionsLoading } = useSWR("/general/regions", () =>
|
||||
axios
|
||||
.get("/general/regions", { baseURL: import.meta.env.VITE_API_NEST_URL })
|
||||
.then((res) => res.data)
|
||||
);
|
||||
|
||||
// Load cities when regionId exists
|
||||
const { data: cities, isLoading: citiesLoading } = useSWR(
|
||||
regionId ? `/general/cities?region_id=${regionId}` : null,
|
||||
() =>
|
||||
axios
|
||||
.get(`/general/cities?region_id=${regionId}`, {
|
||||
baseURL: import.meta.env.VITE_API_NEST_URL,
|
||||
})
|
||||
.then((res) => res.data)
|
||||
);
|
||||
|
||||
// Load seasons
|
||||
const { data: seasons } = useSWR(
|
||||
`/fuel/seasons`,
|
||||
() => [
|
||||
{
|
||||
name: 'Отопительный сезон 2025-2026',
|
||||
year: 2025
|
||||
}
|
||||
] as ISeason[]
|
||||
);
|
||||
|
||||
// Load boilers when cityId exists
|
||||
const { data: limits, isLoading: limitsLoading } = useSWR(
|
||||
cityId && year ? `/fuel/limits?city_id=${cityId}&year=${year}&offset=0&limit=100` : null,
|
||||
() =>
|
||||
axios
|
||||
.get(`/fuel/limits?city_id=${cityId}&year=${year}&offset=0&limit=100`, {
|
||||
baseURL: import.meta.env.VITE_API_NEST_URL,
|
||||
})
|
||||
.then((res) => res.data)
|
||||
);
|
||||
|
||||
//
|
||||
// Sync regionId → URL
|
||||
//
|
||||
useEffect(() => {
|
||||
if (!regionId) return;
|
||||
|
||||
setSearchParams((prev) => {
|
||||
const params = new URLSearchParams(prev);
|
||||
params.set("region_id", regionId.toString());
|
||||
params.delete("city_id"); // reset city when region changes
|
||||
return params;
|
||||
});
|
||||
|
||||
}, [regionId]);
|
||||
|
||||
//
|
||||
// Sync cityId → URL
|
||||
//
|
||||
useEffect(() => {
|
||||
if (!cityId) return;
|
||||
|
||||
setSearchParams((prev) => {
|
||||
const params = new URLSearchParams(prev);
|
||||
params.set("city_id", cityId.toString());
|
||||
return params;
|
||||
});
|
||||
}, [cityId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!year) return;
|
||||
|
||||
setSearchParams((prev) => {
|
||||
const params = new URLSearchParams(prev);
|
||||
params.set("year", year.toString());
|
||||
return params;
|
||||
});
|
||||
}, [year]);
|
||||
|
||||
//
|
||||
// Utility: get display values
|
||||
//
|
||||
const selectedRegion = regions?.find((r: IRegion) => r.id === regionId);
|
||||
const selectedCity = cities?.find((c: ICity) => c.id === cityId);
|
||||
const selectedSeason = seasons?.find((c: ISeason) => c.year === year);
|
||||
|
||||
useEffect(() => {
|
||||
const paramCityId = searchParams.get("city_id");
|
||||
|
||||
if (!paramCityId) return;
|
||||
|
||||
const id = Number(paramCityId);
|
||||
|
||||
// If cityId not yet set OR mismatched with URL
|
||||
if (cities && !cityId && cities.some((c: ICity) => c.id === id)) {
|
||||
setCityId(id);
|
||||
}
|
||||
}, [cities])
|
||||
|
||||
useEffect(() => {
|
||||
const paramRegionId = searchParams.get("region_id");
|
||||
if (!paramRegionId) return;
|
||||
|
||||
const id = Number(paramRegionId);
|
||||
|
||||
if (regions && !regionId && regions.some((r: IRegion) => r.id === id)) {
|
||||
setRegionId(id);
|
||||
}
|
||||
}, [regions]);
|
||||
|
||||
useEffect(() => {
|
||||
const paramYear = searchParams.get("year");
|
||||
if (!paramYear) return;
|
||||
|
||||
const year = Number(paramYear);
|
||||
|
||||
if (year && seasons) {
|
||||
setYear(year);
|
||||
}
|
||||
}, [seasons]);
|
||||
|
||||
useEffect(() => {
|
||||
setCityId(undefined); // clear stale value before SWR runs
|
||||
setYear(undefined)
|
||||
}, [regionId])
|
||||
|
||||
const [limitAddFormOpen, setLimitAddFormOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: '1rem',
|
||||
width: '100%',
|
||||
gap: '1rem'
|
||||
}}>
|
||||
{/* <Portal mountNode={document.querySelector('#header-portal')}>
|
||||
|
||||
</Portal> */}
|
||||
|
||||
<div style={{ display: 'flex', gap: '1rem' }}>
|
||||
{regionsLoading ?
|
||||
<Spinner />
|
||||
:
|
||||
<Dropdown
|
||||
placeholder='Выберите район'
|
||||
value={selectedRegion?.name ?? ""}
|
||||
selectedOptions={regionId ? [regionId.toString()] : []}
|
||||
onOptionSelect={(_, data) => {
|
||||
if (!data.optionValue) {
|
||||
setRegionId(undefined);
|
||||
return;
|
||||
}
|
||||
setRegionId(Number(data.optionValue));
|
||||
}}
|
||||
>
|
||||
{regions && Array.isArray(regions) && regions.map((option) => (
|
||||
<Option key={`region-${option.id}`} text={option.name} value={option.id}>
|
||||
{option.name}
|
||||
</Option>
|
||||
))}
|
||||
</Dropdown>
|
||||
}
|
||||
|
||||
{citiesLoading ?
|
||||
<Spinner />
|
||||
:
|
||||
<Dropdown
|
||||
placeholder='Выберите населенный пункт'
|
||||
value={selectedCity?.name ?? ""}
|
||||
selectedOptions={cityId ? [cityId.toString()] : []}
|
||||
onOptionSelect={(_, data) => {
|
||||
if (!data.optionValue) {
|
||||
setCityId(undefined);
|
||||
return;
|
||||
}
|
||||
setCityId(Number(data.optionValue));
|
||||
}}
|
||||
>
|
||||
{cities && Array.isArray(cities) && cities.map((option) => (
|
||||
<Option key={`region-${option.id}`} text={option.name} value={option.id}>
|
||||
{option.name}
|
||||
</Option>
|
||||
))}
|
||||
</Dropdown>
|
||||
}
|
||||
|
||||
<Dropdown
|
||||
placeholder='Выберите отопительный сезон'
|
||||
value={selectedSeason?.name ?? ""}
|
||||
selectedOptions={year ? [year.toString()] : []}
|
||||
onOptionSelect={(_, data) => {
|
||||
if (!data.optionValue) {
|
||||
setYear(undefined);
|
||||
return;
|
||||
}
|
||||
setYear(Number(data.optionValue));
|
||||
}}
|
||||
>
|
||||
{seasons && Array.isArray(seasons) && seasons.map((option) => (
|
||||
<Option key={`region-${option.year}`} text={option.name} value={option.year.toString()}>
|
||||
{option.name}
|
||||
</Option>
|
||||
))}
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem'
|
||||
}}>
|
||||
<Text size={400} weight='medium'>
|
||||
{cityId && cities && Array.isArray(cities) && cities.find(city => city.id === cityId).name}
|
||||
</Text>
|
||||
|
||||
{cityId &&
|
||||
<div style={{ display: 'flex', width: '100%', gap: '1rem', justifyContent: 'space-between' }}>
|
||||
<BoilersCard title='Всего объектов' value={limits && Array.isArray(limits) ? limits.length.toString() : ''} subtitle='' />
|
||||
|
||||
<BoilersCard title='Общий остаток' value={''} subtitle='' />
|
||||
|
||||
<BoilersCard title='Лимит на сезон' value={''} subtitle='' />
|
||||
|
||||
<BoilersCard title='Требуют внимания' value={''} subtitle='' />
|
||||
</div>
|
||||
}
|
||||
|
||||
<Button appearance='primary' style={{ width: 'min-content' }} onClick={() => setLimitAddFormOpen(true)}>
|
||||
Добавить
|
||||
</Button>
|
||||
|
||||
<LimitAddForm cityId={cityId} open={limitAddFormOpen} setOpen={setLimitAddFormOpen} />
|
||||
</div>
|
||||
|
||||
<AgGridReact
|
||||
key={`boilers-${cityId}`}
|
||||
loading={limitsLoading}
|
||||
overlayLoadingTemplate='Загрузка...'
|
||||
overlayNoRowsTemplate='Нет данных'
|
||||
rowData={limits}
|
||||
onRowClicked={(e) => {
|
||||
console.log(e.data)
|
||||
}
|
||||
}
|
||||
columnDefs={[
|
||||
{
|
||||
field: 'boiler_name',
|
||||
headerName: 'Наименование'
|
||||
},
|
||||
{
|
||||
field: 'fuel_name',
|
||||
headerName: 'Вид топлива'
|
||||
},
|
||||
// {
|
||||
// field: 'boiler_code',
|
||||
// headerName: 'Идент. код'
|
||||
// },
|
||||
// {
|
||||
// field: 'id_fuels',
|
||||
// headerName: 'Вид топлива',
|
||||
// //editable: true,
|
||||
// //cellEditor: FuelTypeEditor,
|
||||
// autoHeight: true,
|
||||
// cellRenderer: FuelRenderer,
|
||||
// cellStyle: (_) => {
|
||||
// return { padding: '1px' }
|
||||
// }
|
||||
// //enableCellChangeFlash: true
|
||||
// },
|
||||
// {
|
||||
// field: 'activity',
|
||||
// headerName: 'Активный',
|
||||
// cellRenderer: (params: CustomCellRendererProps) => (<span>{params.value === true ? 'Да' : 'Нет'}</span>)
|
||||
// }
|
||||
{
|
||||
field: 'jul',
|
||||
headerName: 'Июль'
|
||||
},
|
||||
{
|
||||
field: 'aug',
|
||||
headerName: 'Август'
|
||||
},
|
||||
{
|
||||
field: 'sep',
|
||||
headerName: 'Сентябрь'
|
||||
},
|
||||
{
|
||||
field: 'oct',
|
||||
headerName: 'Октябрь'
|
||||
},
|
||||
{
|
||||
field: 'nov',
|
||||
headerName: 'Ноябрь'
|
||||
},
|
||||
{
|
||||
field: 'dec',
|
||||
headerName: 'Декабрь'
|
||||
},
|
||||
{
|
||||
field: 'jan',
|
||||
headerName: 'Январь'
|
||||
},
|
||||
{
|
||||
field: 'feb',
|
||||
headerName: 'Февраль'
|
||||
},
|
||||
{
|
||||
field: 'mar',
|
||||
headerName: 'Март'
|
||||
},
|
||||
{
|
||||
field: 'apr',
|
||||
headerName: 'Апрель'
|
||||
},
|
||||
{
|
||||
field: 'may',
|
||||
headerName: 'Май'
|
||||
},
|
||||
{
|
||||
field: 'jun',
|
||||
headerName: 'Июнь'
|
||||
}
|
||||
]}
|
||||
defaultColDef={{
|
||||
flex: 1,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const BoilersCard = ({
|
||||
title,
|
||||
value,
|
||||
subtitle,
|
||||
}: {
|
||||
title: string
|
||||
value: string
|
||||
subtitle: string
|
||||
}) => {
|
||||
return (
|
||||
<CompoundButton
|
||||
onClick={() => { }}
|
||||
style={{ display: 'flex', width: '100%', justifyContent: 'flex-start', flexDirection: 'column', gap: '2rem', alignItems: 'flex-start', cursor: 'pointer', userSelect: 'none' }}
|
||||
//icon={icon}
|
||||
>
|
||||
|
||||
<Text weight='bold' size={300}>
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
<Text weight='bold' size={500}>
|
||||
{value}
|
||||
</Text>
|
||||
|
||||
<Text weight='regular' size={200} style={{ color: 'gray' }}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
</CompoundButton>
|
||||
)
|
||||
}
|
||||
|
||||
export default LimitsPage
|
||||
287
client/src/pages/fuel/Limits/LimitEditForm.tsx
Normal file
287
client/src/pages/fuel/Limits/LimitEditForm.tsx
Normal file
@ -0,0 +1,287 @@
|
||||
import { Button, Dialog, DialogBody, DialogContent, DialogSurface, DialogTitle, Field, Input, ProgressBar, Spinner } from '@fluentui/react-components'
|
||||
import { DataPieColor } from '@fluentui/react-icons'
|
||||
import axios from 'axios'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Controller, SubmitHandler, useFieldArray, useForm, useWatch } from 'react-hook-form'
|
||||
import useSWR from 'swr'
|
||||
|
||||
type Month = {
|
||||
month: number
|
||||
value: number
|
||||
}
|
||||
|
||||
type Inputs = {
|
||||
id_boiler: string
|
||||
id_fuel: string
|
||||
year: number
|
||||
months: Month[]
|
||||
}
|
||||
|
||||
const months = [
|
||||
{
|
||||
id: 7,
|
||||
month: 'jul',
|
||||
label: 'Июль'
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
month: 'aug',
|
||||
label: 'Август'
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
month: 'sep',
|
||||
label: 'Сентябрь'
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
month: 'nov',
|
||||
label: 'Октябрь'
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
month: 'oct',
|
||||
label: 'Ноябрь'
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
month: 'dec',
|
||||
label: 'Декабрь'
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
month: 'jan',
|
||||
label: 'Январь'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
month: 'feb',
|
||||
label: 'Февраль'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
month: 'mar',
|
||||
label: 'Март'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
month: 'apr',
|
||||
label: 'Апрель'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
month: 'may',
|
||||
label: 'Май'
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
month: 'jun',
|
||||
label: 'Июнь'
|
||||
},
|
||||
]
|
||||
|
||||
const LimitAddForm = ({
|
||||
cityId,
|
||||
open,
|
||||
setOpen,
|
||||
percentage
|
||||
}: {
|
||||
cityId: number | undefined
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
percentage?: boolean
|
||||
}) => {
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
setValue,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<Inputs>({
|
||||
defaultValues: {
|
||||
months: Array.from({ length: 12 }, (_, i) => ({
|
||||
month: i + 1,
|
||||
value: 0,
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
const { fields } = useFieldArray({
|
||||
control,
|
||||
name: 'months'
|
||||
})
|
||||
|
||||
const onSubmit: SubmitHandler<Inputs> = async (data) => {
|
||||
await axios.post(`/fuel/limits`, {
|
||||
id_boiler: '06407B1C-C23F-44C8-BADF-4653060EB784',
|
||||
id_fuel: 3,
|
||||
year: 2025,
|
||||
months: data.months
|
||||
}, {
|
||||
baseURL: import.meta.env.VITE_API_NEST_URL,
|
||||
})
|
||||
//mutateFuels([...fuels, data])
|
||||
|
||||
// setTimeout(() => {
|
||||
// console.log("done")
|
||||
// }, 1000)
|
||||
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(() => resolve(console.log("done")), 1000);
|
||||
})
|
||||
}
|
||||
|
||||
const [overallLimit, setOverallLimit] = useState(0)
|
||||
|
||||
const watchedMonths = useWatch({
|
||||
control,
|
||||
name: "months",
|
||||
})
|
||||
|
||||
const { data: citySettings } = useSWR(
|
||||
cityId ? `/fuel/city-settings?city_id=${cityId}` : null,
|
||||
() =>
|
||||
axios
|
||||
.get(`/fuel/city-settings?city_id=${cityId}`, {
|
||||
baseURL: import.meta.env.VITE_API_NEST_URL,
|
||||
})
|
||||
.then((res) => res.data)
|
||||
)
|
||||
|
||||
const handlePartition = () => {
|
||||
if (citySettings && Array.isArray(citySettings) && citySettings.length > 0) {
|
||||
citySettings.map(s => {
|
||||
setValue(`months.${Number(s.month - 1)}.value`, Number((Number((overallLimit / 100).toFixed(3)) * s.procent).toFixed(3)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (watchedMonths) {
|
||||
let sum = 0
|
||||
watchedMonths.map(wm => sum = sum + wm.value)
|
||||
setOverallLimit(Number(sum.toFixed(3)))
|
||||
}
|
||||
}, [watchedMonths])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(_, data) => setOpen(data.open)}>
|
||||
<DialogSurface>
|
||||
<DialogBody>
|
||||
{isSubmitting &&
|
||||
<div style={{ position: 'absolute', inset: 0, zIndex: '1', background: '#00000030', display: 'flex', justifyContent: 'center', alignItems: 'center', width: '100%', height: '100%' }}>
|
||||
<Spinner />
|
||||
</div>
|
||||
}
|
||||
<DialogTitle>Распределение лимитов</DialogTitle>
|
||||
|
||||
<DialogContent
|
||||
style={{
|
||||
display: 'flex', flexDirection: 'column', gap: '1rem',
|
||||
minWidth: 'fit-content', minHeight: 'fit-content'
|
||||
}}
|
||||
>
|
||||
<form onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handlePartition()
|
||||
}} style={{ display: 'flex', width: '100%', gap: '0.25rem', justifyContent: 'flex-end' }}>
|
||||
<Field style={{ display: 'flex' }} label='Лимит расхода котельного топлива за сезон' orientation='horizontal'>
|
||||
<Input type='number' value={overallLimit.toString()} onChange={(_, data) => setOverallLimit(Number(data.value))} />
|
||||
</Field>
|
||||
|
||||
<Button title='Распределить' type='submit' icon={<DataPieColor />}></Button>
|
||||
</form>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div style={{ display: 'flex', flexDirection: 'row', gap: '1rem', width: '100%' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', width: '50%', gap: '0.25rem' }}>
|
||||
{fields.map((f, index) => {
|
||||
if (f.month >= 7 && f.month <= 12) {
|
||||
return (
|
||||
<Controller
|
||||
key={f.month}
|
||||
name={`months.${index}.value`}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Field style={{ gridTemplateColumns: '1fr auto' }} key={f.month} validationMessage={percentage ? `${(field.value / (overallLimit / 100)).toFixed(1)}%` : undefined} validationState='none' label={months.find(m => m.id === f.month)?.label} orientation='horizontal'>
|
||||
<Input value={field.value.toString()} onChange={(_, data) => field.onChange(Number(data.value))} />
|
||||
{percentage && <ProgressBar value={field.value / (overallLimit / 100) * 0.01} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})}
|
||||
|
||||
|
||||
<Field style={{ gridTemplateColumns: '1fr auto' }} validationMessage={percentage ? `${(watchedMonths.filter(month => month.month >= 7 && month.month <= 12)
|
||||
.reduce((sum, month) => {
|
||||
const value = Number(month.value) || 0;
|
||||
return sum + value;
|
||||
}, 0) / (overallLimit / 100)).toFixed(1)}%` : undefined} validationState='none' label={'2 полугодие'} orientation='horizontal'>
|
||||
<Input disabled value={watchedMonths.filter(month => month.month >= 7 && month.month <= 12)
|
||||
.reduce((sum, month) => {
|
||||
const value = Number(month.value) || 0;
|
||||
return sum + value;
|
||||
}, 0).toFixed(3)} />
|
||||
|
||||
{percentage && <ProgressBar value={watchedMonths.filter(month => month.month >= 7 && month.month <= 12)
|
||||
.reduce((sum, month) => {
|
||||
const value = Number(month.value) || 0;
|
||||
return sum + value;
|
||||
}, 0) / (overallLimit / 100) * 0.01} />}
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', width: '50%', gap: '0.25rem' }}>
|
||||
{fields.map((f, index) => {
|
||||
if (f.month >= 1 && f.month <= 6) {
|
||||
return (
|
||||
<Controller
|
||||
key={f.month}
|
||||
name={`months.${index}.value`}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Field style={{ gridTemplateColumns: '1fr auto' }} key={f.month} validationMessage={percentage ? `${(field.value / (overallLimit / 100)).toFixed(1)}%` : undefined} validationState='none' label={months.find(m => m.id === f.month)?.label} orientation='horizontal'>
|
||||
<Input value={field.value.toString()} onChange={(_, data) => field.onChange(Number(data.value))} />
|
||||
{percentage && <ProgressBar value={field.value / (overallLimit / 100) * 0.01} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})}
|
||||
|
||||
<Field style={{ gridTemplateColumns: '1fr auto' }} validationMessage={percentage ? `${(watchedMonths.filter(month => month.month >= 1 && month.month <= 6)
|
||||
.reduce((sum, month) => {
|
||||
const value = Number(month.value) || 0;
|
||||
return sum + value;
|
||||
}, 0) / (overallLimit / 100)).toFixed(1)}%` : undefined} validationState='none' label={'1 полугодие'} orientation='horizontal'>
|
||||
<Input disabled value={watchedMonths.filter(month => month.month >= 1 && month.month <= 6)
|
||||
.reduce((sum, month) => {
|
||||
const value = Number(month.value) || 0;
|
||||
return sum + value;
|
||||
}, 0).toFixed(3)} />
|
||||
{percentage && <ProgressBar value={watchedMonths.filter(month => month.month >= 1 && month.month <= 6)
|
||||
.reduce((sum, month) => {
|
||||
const value = Number(month.value) || 0;
|
||||
return sum + value;
|
||||
}, 0) / (overallLimit / 100) * 0.01} />}
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', marginTop: '1rem', justifyContent: 'flex-end' }}>
|
||||
<Button type='submit' appearance='primary'>
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</DialogBody>
|
||||
</DialogSurface>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default LimitAddForm
|
||||
69
client/src/pages/fuel/Reports.tsx
Normal file
69
client/src/pages/fuel/Reports.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import { Button, CompoundButton, Text } from '@fluentui/react-components'
|
||||
|
||||
const ReportsPage = () => {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: '1rem',
|
||||
width: '100%',
|
||||
gap: '1rem'
|
||||
}}>
|
||||
{/* <Portal mountNode={document.querySelector('#header-portal')}>
|
||||
|
||||
</Portal> */}
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem'
|
||||
}}>
|
||||
<Text size={600} weight='bold'>
|
||||
Отчеты
|
||||
</Text>
|
||||
|
||||
<Text size={400} weight='medium'>
|
||||
|
||||
</Text>
|
||||
|
||||
<CompoundButton
|
||||
onClick={() => { }}
|
||||
style={{ display: 'flex', width: '100%', justifyContent: 'flex-start', flexDirection: 'column', gap: '2rem', alignItems: 'flex-start', cursor: 'pointer', userSelect: 'none' }}
|
||||
//icon={icon}
|
||||
>
|
||||
|
||||
<Text weight='bold' size={300}>
|
||||
Параметры отчета
|
||||
</Text>
|
||||
|
||||
<Text weight='bold' size={500}>
|
||||
|
||||
</Text>
|
||||
|
||||
<Text weight='regular' size={200} style={{ color: 'gray' }}>
|
||||
|
||||
</Text>
|
||||
|
||||
<Button>
|
||||
|
||||
</Button>
|
||||
</CompoundButton>
|
||||
|
||||
{/* {cityId &&
|
||||
<div style={{ display: 'flex', width: '100%', gap: '1rem', justifyContent: 'space-between' }}>
|
||||
<BoilersCard title='Всего объектов' value={boilers && Array.isArray(boilers) ? boilers.length.toString() : ''} subtitle='' />
|
||||
|
||||
<BoilersCard title='Общий остаток' value={''} subtitle='' />
|
||||
|
||||
<BoilersCard title='Лимит на сезон' value={''} subtitle='' />
|
||||
|
||||
<BoilersCard title='Требуют внимания' value={''} subtitle='' />
|
||||
</div>
|
||||
} */}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReportsPage
|
||||
9
client/src/pages/fuel/Transport/Drivers.tsx
Normal file
9
client/src/pages/fuel/Transport/Drivers.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
const DriversPage = () => {
|
||||
return (
|
||||
<div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DriversPage
|
||||
7
client/src/pages/fuel/Transport/Routes.tsx
Normal file
7
client/src/pages/fuel/Transport/Routes.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
const RoutesPage = () => {
|
||||
return (
|
||||
<div>RoutesPage</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RoutesPage
|
||||
7
client/src/pages/fuel/Transport/Transport.tsx
Normal file
7
client/src/pages/fuel/Transport/Transport.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
const TransportPage = () => {
|
||||
return (
|
||||
<div>TransportPage</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TransportPage
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user