From 04ce74d3202966bdfbdb2a5d4a605c3d68b7999f Mon Sep 17 00:00:00 2001 From: popovspiridon99 Date: Tue, 23 Dec 2025 09:53:04 +0900 Subject: [PATCH] fuel; nest api --- client/src/components/Tree/ObjectTree.tsx | 6 +- client/src/components/map/MapComponent.tsx | 18 +- .../components/map/MapLegend/MapLegend.tsx | 4 +- .../map/MapObjectSearch/MapObjectSearch.tsx | 2 +- .../map/MapRegionSelect/MapRegionSelect.tsx | 2 +- client/src/components/map/ObjectData.tsx | 2 +- .../map/ObjectParameters/ObjectParameters.tsx | 2 +- client/src/components/map/RegionSelect.tsx | 2 +- client/src/components/map/TableValue.tsx | 2 +- client/src/constants/index.ts | 1 + client/src/interfaces/fuel.ts | 5 + client/src/pages/fuel/Fuel/Boilers.tsx | 311 ++++++++------ client/src/pages/fuel/Fuel/BoilersCard.tsx | 34 ++ client/src/pages/fuel/Fuel/FuelRenderer.tsx | 2 +- client/src/pages/fuel/Fuel/Fuels.tsx | 4 +- client/src/pages/fuel/Limits.tsx | 392 +++++++++++++++++- .../src/pages/fuel/Limits/LimitEditForm.tsx | 287 +++++++++++++ client/src/store/fuel.ts | 19 + server/src/fuel/dto/create-limit.ts | 21 +- server/src/fuel/dto/get-city-settings.ts | 18 + server/src/fuel/dto/get-limits.ts | 22 + server/src/fuel/fuel.controller.ts | 16 +- server/src/fuel/fuel.service.ts | 136 +++++- 23 files changed, 1125 insertions(+), 183 deletions(-) create mode 100644 client/src/pages/fuel/Fuel/BoilersCard.tsx create mode 100644 client/src/pages/fuel/Limits/LimitEditForm.tsx create mode 100644 client/src/store/fuel.ts create mode 100644 server/src/fuel/dto/get-city-settings.ts create mode 100644 server/src/fuel/dto/get-limits.ts diff --git a/client/src/components/Tree/ObjectTree.tsx b/client/src/components/Tree/ObjectTree.tsx index cfa6346..595df2a 100644 --- a/client/src/components/Tree/ObjectTree.tsx +++ b/client/src/components/Tree/ObjectTree.tsx @@ -17,7 +17,7 @@ const ObjectTree = ({ 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 => { @@ -34,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 => { @@ -116,7 +116,7 @@ const ObjectList = ({ 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 diff --git a/client/src/components/map/MapComponent.tsx b/client/src/components/map/MapComponent.tsx index 948592d..e3e47c9 100644 --- a/client/src/components/map/MapComponent.tsx +++ b/client/src/components/map/MapComponent.tsx @@ -78,20 +78,20 @@ const MapComponent = ({ const mapElement = useRef(null) // Get type roles - useSWR(`/gis/type-roles`, (url) => fetcher(url, BASE_URL.ems).then(res => { + useSWR(`/gis/type-roles`, (url) => fetcher(url, BASE_URL.nest).then(res => { if (Array.isArray(res)) { setTypeRoles(id, res) } return res }), swrOptions) - const { data: regionsData } = useSWR(`/general/regions`, (url) => fetcher(url, BASE_URL.ems).then(res => { + const { data: regionsData } = useSWR(`/general/regions`, (url) => fetcher(url, BASE_URL.nest).then(res => { setRegionsData(res) return res }), swrOptions) // Bounds: region - const { data: regionBoundsData } = useSWR(`/gis/bounds/region`, (url) => fetcher(url, BASE_URL.ems), swrOptions) + const { data: regionBoundsData } = useSWR(`/gis/bounds/region`, (url) => fetcher(url, BASE_URL.nest), swrOptions) // Map init useEffect(() => { @@ -169,7 +169,7 @@ const MapComponent = ({ useEffect(() => { if (selectedDistrict && selectedYear && districtBoundLayer) { const bounds = new VectorSource({ - url: `${BASE_URL.ems}/gis/bounds/city/${selectedDistrict}`, + url: `${BASE_URL.nest}/gis/bounds/city/${selectedDistrict}`, format: new GeoJSON(), }) @@ -279,7 +279,7 @@ const MapComponent = ({ } }, [currentObjectId, figuresLayer, linesLayer]) - const { data: districtsData } = useSWR(selectedRegion ? `/general/districts/?region_id=${selectedRegion}` : null, (url) => fetcher(url, BASE_URL.ems).then(res => { + const { data: districtsData } = useSWR(selectedRegion ? `/general/districts/?region_id=${selectedRegion}` : null, (url) => fetcher(url, BASE_URL.nest).then(res => { setDistrictsData(res) return res }), swrOptions) @@ -287,7 +287,7 @@ const MapComponent = ({ const { data: figuresData, isValidating: figuresValidating } = useSWR( selectedDistrict && selectedYear ? `/gis/figures/all?city_id=${selectedDistrict}&year=${selectedYear}&offset=0&limit=${10000}` : null, (url) => axios.get(url, { - baseURL: BASE_URL.ems + baseURL: BASE_URL.nest }).then((res) => res.data), swrOptions ) @@ -295,7 +295,7 @@ const MapComponent = ({ const { data: linesData, isValidating: linesValidating } = useSWR( !figuresValidating && selectedDistrict && selectedYear ? `/gis/lines/all?city_id=${selectedDistrict}&year=${selectedYear}&offset=0&limit=${10000}` : null, (url) => axios.get(url, { - baseURL: BASE_URL.ems + baseURL: BASE_URL.nest }).then((res) => res.data), swrOptions ) @@ -303,7 +303,7 @@ const MapComponent = ({ const { data: districtData } = useSWR( selectedDistrict ? `/gis/images/all?city_id=${selectedDistrict}` : null, (url) => axios.get(url, { - baseURL: BASE_URL.ems + baseURL: BASE_URL.nest }).then((res) => Array.isArray(res.data) ? res.data[0] : null), swrOptions ) @@ -398,7 +398,7 @@ const MapComponent = ({ list.push(district.id as Number) }) - fetch(`${BASE_URL.ems}/gis/bounds/city`, { + fetch(`${BASE_URL.nest}/gis/bounds/city`, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/client/src/components/map/MapLegend/MapLegend.tsx b/client/src/components/map/MapLegend/MapLegend.tsx index c1ab94b..7a8fe02 100644 --- a/client/src/components/map/MapLegend/MapLegend.tsx +++ b/client/src/components/map/MapLegend/MapLegend.tsx @@ -15,7 +15,7 @@ const MapLegend = ({ const { data: existingObjectsList } = useSWR( selectedYear && selectedDistrict ? `/general/objects/list?year=${selectedYear}&city_id=${selectedDistrict}&planning=0` : null, - (url) => fetcher(url, BASE_URL.ems), + (url) => fetcher(url, BASE_URL.nest), { revalidateOnFocus: false } @@ -23,7 +23,7 @@ const MapLegend = ({ const { data: planningObjectsList } = useSWR( selectedYear && selectedDistrict ? `/general/objects/list?year=${selectedYear}&city_id=${selectedDistrict}&planning=1` : null, - (url) => fetcher(url, BASE_URL.ems), + (url) => fetcher(url, BASE_URL.nest), { revalidateOnFocus: false } diff --git a/client/src/components/map/MapObjectSearch/MapObjectSearch.tsx b/client/src/components/map/MapObjectSearch/MapObjectSearch.tsx index 78e92c3..ac4e69d 100644 --- a/client/src/components/map/MapObjectSearch/MapObjectSearch.tsx +++ b/client/src/components/map/MapObjectSearch/MapObjectSearch.tsx @@ -22,7 +22,7 @@ const MapObjectSearch = ({ const { data: searchData } = useSWR( throttledSearchObject !== "" && selectedDistrict && selectedYear ? `/general/search/objects?q=${throttledSearchObject}&id_city=${selectedDistrict}&year=${selectedYear}` : null, - (url) => fetcher(url, BASE_URL.ems), + (url) => fetcher(url, BASE_URL.nest), swrOptions ) diff --git a/client/src/components/map/MapRegionSelect/MapRegionSelect.tsx b/client/src/components/map/MapRegionSelect/MapRegionSelect.tsx index f245886..00c76f2 100644 --- a/client/src/components/map/MapRegionSelect/MapRegionSelect.tsx +++ b/client/src/components/map/MapRegionSelect/MapRegionSelect.tsx @@ -25,7 +25,7 @@ const MapRegionSelect = ({ 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.ems).then(res => { + const { data: districtsData } = useSWR(selectedRegion ? `/general/districts/?region_id=${selectedRegion}` : null, (url) => fetcher(url, BASE_URL.nest).then(res => { setDistrictsData(res) return res }), swrOptions) diff --git a/client/src/components/map/ObjectData.tsx b/client/src/components/map/ObjectData.tsx index 2c29bdf..00fc7ac 100644 --- a/client/src/components/map/ObjectData.tsx +++ b/client/src/components/map/ObjectData.tsx @@ -6,7 +6,7 @@ import { BASE_URL } from '../../constants' const ObjectData = (object_data: IObjectData) => { const { data: typeData } = useSWR( object_data.type ? `/general/types` : null, - (url) => fetcher(url, BASE_URL.ems), + (url) => fetcher(url, BASE_URL.nest), { revalidateOnFocus: false } diff --git a/client/src/components/map/ObjectParameters/ObjectParameters.tsx b/client/src/components/map/ObjectParameters/ObjectParameters.tsx index 7323f06..5f88d58 100644 --- a/client/src/components/map/ObjectParameters/ObjectParameters.tsx +++ b/client/src/components/map/ObjectParameters/ObjectParameters.tsx @@ -17,7 +17,7 @@ const ObjectParameters = ({ const { data: valuesData, isValidating: valuesValidating } = useSWR( currentObjectId ? `/general/values/?object_id=${currentObjectId}` : null, - (url) => fetcher(url, BASE_URL.ems), + (url) => fetcher(url, BASE_URL.nest), { revalidateOnFocus: false, revalidateIfStale: false diff --git a/client/src/components/map/RegionSelect.tsx b/client/src/components/map/RegionSelect.tsx index ed67595..e8d810a 100644 --- a/client/src/components/map/RegionSelect.tsx +++ b/client/src/components/map/RegionSelect.tsx @@ -3,7 +3,7 @@ import { BASE_URL } from '../../constants' import { fetcher } from '../../http/axiosInstance' 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 }) diff --git a/client/src/components/map/TableValue.tsx b/client/src/components/map/TableValue.tsx index f1a1dc0..40a7fe6 100644 --- a/client/src/components/map/TableValue.tsx +++ b/client/src/components/map/TableValue.tsx @@ -30,7 +30,7 @@ const TableValue = ({ 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 || "", diff --git a/client/src/constants/index.ts b/client/src/constants/index.ts index f3fee9b..019c378 100644 --- a/client/src/constants/index.ts +++ b/client/src/constants/index.ts @@ -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 } \ No newline at end of file diff --git a/client/src/interfaces/fuel.ts b/client/src/interfaces/fuel.ts index 039fa22..1e92272 100644 --- a/client/src/interfaces/fuel.ts +++ b/client/src/interfaces/fuel.ts @@ -3,6 +3,11 @@ export interface IRegion { name: string; } +export interface ISeason { + name: string + year: number +} + export interface ICity { id: number; name: string; diff --git a/client/src/pages/fuel/Fuel/Boilers.tsx b/client/src/pages/fuel/Fuel/Boilers.tsx index 3a50e4e..576f421 100644 --- a/client/src/pages/fuel/Fuel/Boilers.tsx +++ b/client/src/pages/fuel/Fuel/Boilers.tsx @@ -1,49 +1,117 @@ import { useEffect, useState } from 'react' -import { Combobox, CompoundButton, Option, Text } from '@fluentui/react-components' +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 [, setBoilersPage] = useState(1) - const [boilerSearch, setBoilerSearch] = useState("") - const [, setDebouncedBoilerSearch] = useState("") - // const { boilers } = useBoilers(10, boilersPage, debouncedBoilerSearch) + const [searchParams, setSearchParams] = useSearchParams(); - const { data: regions } = useSWR('/general/regions', () => axios.get('/general/regions', { - baseURL: import.meta.env.VITE_API_NEST_URL - }).then(res => res.data)) + const [regionId, setRegionId] = useState( + searchParams.get("region_id") ? Number(searchParams.get("region_id")) : undefined + ); - const [regionId, setRegionId] = useState(undefined) + const [cityId, setCityId] = useState( + searchParams.get("city_id") ? Number(searchParams.get("city_id")) : undefined + ) - const { data: cities } = 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 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) + ); - const [cityId, setCityId] = useState(undefined) + // 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) + ); - const { data: boilers, isLoading: boilersLoading } = useSWR(cityId !== undefined ? `/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)) + // 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 handler = setTimeout(() => { - setDebouncedBoilerSearch(boilerSearch) - }, 500) + const paramCityId = searchParams.get("city_id"); - return () => { - clearTimeout(handler) + 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); } - }, [boilerSearch]) + }, [cities]) useEffect(() => { - setBoilersPage(1) - setBoilerSearch("") - }, []) + 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 (
*/}
- { - if (data.optionValue) { - setRegionId(Number(data.optionValue)) - } else { - setCityId(undefined) - setRegionId(undefined) - } - }} - > - {regions && Array.isArray(regions) && regions.map((option) => ( - - ))} - + {regionsLoading ? + + : + { + if (!data.optionValue) { + setRegionId(undefined); + return; + } + setRegionId(Number(data.optionValue)); + }} + > + {regions && Array.isArray(regions) && regions.map((option) => ( + + ))} + + } - {cities && { - if (data.optionValue) { - setCityId(Number(data.optionValue)) - } else { - setCityId(undefined) - } - }} - > - {cities && Array.isArray(cities) && cities.map((option) => ( - - ))} - } + {citiesLoading ? + + : + { + if (!data.optionValue) { + setCityId(undefined); + return; + } + setCityId(Number(data.optionValue)); + }} + > + {cities && Array.isArray(cities) && cities.map((option) => ( + + ))} + + }
- - {cityId && cities && Array.isArray(cities) && cities.find(city => city.id === cityId).name} - - {cityId &&
@@ -119,80 +192,48 @@ function Boilers() { }
- ({ ...obj, [key]: 'test1' }), {}), - // Object.keys(table.headers).reduce((obj, key) => ({ ...obj, [key]: 'test' }), {}) - // ]} - 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' } + {boilersLoading ? + + : + { + return { padding: '1px' } + } + //enableCellChangeFlash: true + }, + { + field: 'activity', + headerName: 'Активный', + cellRenderer: (params: CustomCellRendererProps) => ({params.value === true ? 'Да' : 'Нет'}) } - //enableCellChangeFlash: true - }, - { - field: 'activity', - headerName: 'Активный', - cellRenderer: (params: CustomCellRendererProps) => ({params.value === true ? 'Да' : 'Нет'}) - } - ]} - defaultColDef={{ - flex: 1, - }} - /> + ]} + defaultColDef={{ + flex: 1, + }} + />}
) } -const BoilersCard = ({ - title, - value, - subtitle, -}: { - title: string - value: string - subtitle: string -}) => { - return ( - { }} - style={{ display: 'flex', width: '100%', justifyContent: 'flex-start', flexDirection: 'column', gap: '2rem', alignItems: 'flex-start', cursor: 'pointer', userSelect: 'none' }} - //icon={icon} - > - - - {title} - - - - {value} - - - - {subtitle} - - - ) -} - export default Boilers \ No newline at end of file diff --git a/client/src/pages/fuel/Fuel/BoilersCard.tsx b/client/src/pages/fuel/Fuel/BoilersCard.tsx new file mode 100644 index 0000000..404a17e --- /dev/null +++ b/client/src/pages/fuel/Fuel/BoilersCard.tsx @@ -0,0 +1,34 @@ +import { CompoundButton, Text } from "@fluentui/react-components" + +const BoilersCard = ({ + title, + value, + subtitle, +}: { + title: string + value: string + subtitle: string +}) => { + return ( + { }} + style={{ display: 'flex', width: '100%', justifyContent: 'flex-start', flexDirection: 'column', gap: '2rem', alignItems: 'flex-start', cursor: 'pointer', userSelect: 'none' }} + //icon={icon} + > + + + {title} + + + + {value} + + + + {subtitle} + + + ) +} + +export default BoilersCard \ No newline at end of file diff --git a/client/src/pages/fuel/Fuel/FuelRenderer.tsx b/client/src/pages/fuel/Fuel/FuelRenderer.tsx index f414dfb..eca68d3 100644 --- a/client/src/pages/fuel/Fuel/FuelRenderer.tsx +++ b/client/src/pages/fuel/Fuel/FuelRenderer.tsx @@ -16,7 +16,7 @@ export default (params: CustomCellRendererProps) => { {Array.isArray(params.value) && params.value.map(fuel => ( - + {fuel.name} diff --git a/client/src/pages/fuel/Fuel/Fuels.tsx b/client/src/pages/fuel/Fuel/Fuels.tsx index 636df42..81d9adf 100644 --- a/client/src/pages/fuel/Fuel/Fuels.tsx +++ b/client/src/pages/fuel/Fuel/Fuels.tsx @@ -97,7 +97,7 @@ const FuelsPage = () => { }} > {fuelTypes && Array.isArray(fuelTypes) && fuelTypes.map((option) => ( - ))} @@ -131,7 +131,7 @@ const FuelsPage = () => { setSelectedIdFuels(Number(data.value)) }}> {fuelTypes && Array.isArray(fuelTypes) && fuelTypes.map((ft: FuelType) => ( - + {ft.name} )) diff --git a/client/src/pages/fuel/Limits.tsx b/client/src/pages/fuel/Limits.tsx index c38f040..de96ad9 100644 --- a/client/src/pages/fuel/Limits.tsx +++ b/client/src/pages/fuel/Limits.tsx @@ -1,7 +1,391 @@ -const LimitsPage = () => { - return ( -
LimitsPage
- ) +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( + searchParams.get("year") ? Number(searchParams.get("year")) : undefined + ) + + const [regionId, setRegionId] = useState( + searchParams.get("region_id") ? Number(searchParams.get("region_id")) : undefined + ); + + const [cityId, setCityId] = useState( + 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 ( +
+ {/* + + */} + +
+ {regionsLoading ? + + : + { + if (!data.optionValue) { + setRegionId(undefined); + return; + } + setRegionId(Number(data.optionValue)); + }} + > + {regions && Array.isArray(regions) && regions.map((option) => ( + + ))} + + } + + {citiesLoading ? + + : + { + if (!data.optionValue) { + setCityId(undefined); + return; + } + setCityId(Number(data.optionValue)); + }} + > + {cities && Array.isArray(cities) && cities.map((option) => ( + + ))} + + } + + { + if (!data.optionValue) { + setYear(undefined); + return; + } + setYear(Number(data.optionValue)); + }} + > + {seasons && Array.isArray(seasons) && seasons.map((option) => ( + + ))} + +
+ +
+ + {cityId && cities && Array.isArray(cities) && cities.find(city => city.id === cityId).name} + + + {cityId && +
+ + + + + + + +
+ } + + + + +
+ + { + 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) => ({params.value === true ? 'Да' : 'Нет'}) + // } + { + 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, + }} + /> +
+ ) +} + +const BoilersCard = ({ + title, + value, + subtitle, +}: { + title: string + value: string + subtitle: string +}) => { + return ( + { }} + style={{ display: 'flex', width: '100%', justifyContent: 'flex-start', flexDirection: 'column', gap: '2rem', alignItems: 'flex-start', cursor: 'pointer', userSelect: 'none' }} + //icon={icon} + > + + + {title} + + + + {value} + + + + {subtitle} + + + ) } export default LimitsPage \ No newline at end of file diff --git a/client/src/pages/fuel/Limits/LimitEditForm.tsx b/client/src/pages/fuel/Limits/LimitEditForm.tsx new file mode 100644 index 0000000..3900fdc --- /dev/null +++ b/client/src/pages/fuel/Limits/LimitEditForm.tsx @@ -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({ + defaultValues: { + months: Array.from({ length: 12 }, (_, i) => ({ + month: i + 1, + value: 0, + })) + } + }) + + const { fields } = useFieldArray({ + control, + name: 'months' + }) + + const onSubmit: SubmitHandler = 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 ( + setOpen(data.open)}> + + + {isSubmitting && +
+ +
+ } + Распределение лимитов + + +
{ + e.preventDefault() + e.stopPropagation() + handlePartition() + }} style={{ display: 'flex', width: '100%', gap: '0.25rem', justifyContent: 'flex-end' }}> + + setOverallLimit(Number(data.value))} /> + + + + + +
+
+
+ {fields.map((f, index) => { + if (f.month >= 7 && f.month <= 12) { + return ( + ( + m.id === f.month)?.label} orientation='horizontal'> + field.onChange(Number(data.value))} /> + {percentage && } + + )} + /> + ) + } + })} + + + 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'> + month.month >= 7 && month.month <= 12) + .reduce((sum, month) => { + const value = Number(month.value) || 0; + return sum + value; + }, 0).toFixed(3)} /> + + {percentage && month.month >= 7 && month.month <= 12) + .reduce((sum, month) => { + const value = Number(month.value) || 0; + return sum + value; + }, 0) / (overallLimit / 100) * 0.01} />} + +
+ +
+ {fields.map((f, index) => { + if (f.month >= 1 && f.month <= 6) { + return ( + ( + m.id === f.month)?.label} orientation='horizontal'> + field.onChange(Number(data.value))} /> + {percentage && } + + )} + /> + ) + } + })} + + 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'> + month.month >= 1 && month.month <= 6) + .reduce((sum, month) => { + const value = Number(month.value) || 0; + return sum + value; + }, 0).toFixed(3)} /> + {percentage && month.month >= 1 && month.month <= 6) + .reduce((sum, month) => { + const value = Number(month.value) || 0; + return sum + value; + }, 0) / (overallLimit / 100) * 0.01} />} + +
+
+ +
+ +
+ +
+
+
+
+ ) +} + +export default LimitAddForm \ No newline at end of file diff --git a/client/src/store/fuel.ts b/client/src/store/fuel.ts new file mode 100644 index 0000000..017deff --- /dev/null +++ b/client/src/store/fuel.ts @@ -0,0 +1,19 @@ +import { create } from 'zustand' + +export interface FuelState { + regionId: number | undefined + cityId: number | undefined +} + +export const useFuelStore = create(() => { + return { + regionId: undefined, + cityId: undefined + } +}) + +export const getRegionId = () => useFuelStore.getState().regionId +export const setRegionId = (id: number | undefined) => useFuelStore.setState(() => ({ regionId: id })) + +export const getCityId = () => useFuelStore.getState().cityId +export const setCityId = (id: number | undefined) => useFuelStore.setState(() => ({ cityId: id })) \ No newline at end of file diff --git a/server/src/fuel/dto/create-limit.ts b/server/src/fuel/dto/create-limit.ts index 80a52ca..dad3940 100644 --- a/server/src/fuel/dto/create-limit.ts +++ b/server/src/fuel/dto/create-limit.ts @@ -1,5 +1,14 @@ import { ApiProperty } from "@nestjs/swagger"; -import { IsNumber, IsUUID } from "class-validator"; +import { Type } from "class-transformer"; +import { IsArray, IsNumber, IsObject, IsUUID, ValidateNested } from "class-validator"; + +class MonthDto { + @IsNumber() + month: number + + @IsNumber() + value: number +} export class CreateLimitDto { @ApiProperty({ format: 'uuid' }) @@ -10,15 +19,13 @@ export class CreateLimitDto { @IsNumber() id_fuel: number - @ApiProperty() - @IsNumber() - month: Date - @ApiProperty() @IsNumber() year: number @ApiProperty() - @IsNumber() - value: number + @IsArray() + @ValidateNested({ each: true }) + @Type(() => MonthDto) + months: MonthDto[] } \ No newline at end of file diff --git a/server/src/fuel/dto/get-city-settings.ts b/server/src/fuel/dto/get-city-settings.ts new file mode 100644 index 0000000..1acbf78 --- /dev/null +++ b/server/src/fuel/dto/get-city-settings.ts @@ -0,0 +1,18 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger" +import { IsNumberString, IsOptional } from "class-validator" + +export class GetCitySettingsDTO { + @ApiProperty() + @IsNumberString() + city_id: number + + @ApiPropertyOptional() + @IsNumberString() + @IsOptional() + offset?: number + + @ApiPropertyOptional() + @IsNumberString() + @IsOptional() + limit?: number +} \ No newline at end of file diff --git a/server/src/fuel/dto/get-limits.ts b/server/src/fuel/dto/get-limits.ts new file mode 100644 index 0000000..5d532d1 --- /dev/null +++ b/server/src/fuel/dto/get-limits.ts @@ -0,0 +1,22 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger" +import { IsNumberString, IsOptional } from "class-validator" + +export class GetLimitsDTO { + @ApiProperty() + @IsNumberString() + city_id: number + + @ApiProperty() + @IsNumberString() + year: number + + @ApiPropertyOptional() + @IsNumberString() + @IsOptional() + offset?: number + + @ApiPropertyOptional() + @IsNumberString() + @IsOptional() + limit?: number +} \ No newline at end of file diff --git a/server/src/fuel/fuel.controller.ts b/server/src/fuel/fuel.controller.ts index 4a7d4bd..ddebfaf 100644 --- a/server/src/fuel/fuel.controller.ts +++ b/server/src/fuel/fuel.controller.ts @@ -9,6 +9,8 @@ import { FuelExpenseDto } from './dto/expense' import { FuelTransferDto } from './dto/transfer' import { GetBoilersDTO } from './dto/get-boilers' import { GetFuelsDTO } from './dto/get-fuels' +import { GetLimitsDTO } from './dto/get-limits' +import { GetCitySettingsDTO } from './dto/get-city-settings' @Controller('fuel') export class FuelController { @@ -43,8 +45,8 @@ export class FuelController { type: FuelLimitDto, isArray: true, }) - async getBoilersFuelsLimits(): Promise { - return this.fuelService.getBoilersFuelsLimits() + async getBoilersFuelsLimits(@Query() getLimitsDTO: GetLimitsDTO): Promise { + return this.fuelService.getBoilersFuelsLimits(getLimitsDTO) } @Post('/limits') @@ -52,6 +54,16 @@ export class FuelController { return this.fuelService.addBoilersFuelsLimit(createLimitDto) } + // Fuel limits + @Get('/city-settings') + @ApiOkResponse({ + description: 'City settings', + isArray: true, + }) + async getCitySettings(@Query() getCitySettingsDTO: GetCitySettingsDTO): Promise { + return this.fuelService.getCitySettings(getCitySettingsDTO) + } + // Fuel expenses @Get('/expenses') @ApiOkResponse({ diff --git a/server/src/fuel/fuel.service.ts b/server/src/fuel/fuel.service.ts index 6b109d0..2c422db 100644 --- a/server/src/fuel/fuel.service.ts +++ b/server/src/fuel/fuel.service.ts @@ -1,10 +1,12 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { CreateExpenseDto } from './dto/create-expense'; import { CreateLimitDto } from './dto/create-limit'; import { CreateTransferDto } from './dto/create-transfer'; import { GetFuelsDTO } from './dto/get-fuels'; +import { GetLimitsDTO } from './dto/get-limits'; +import { GetCitySettingsDTO } from './dto/get-city-settings'; @Injectable() export class FuelService { @@ -56,13 +58,12 @@ export class FuelService { const result = await this.wsDataSource.query(` SELECT b.*, - COALESCE( - (SELECT fp.* + ( + SELECT fp.* FROM isFuels.dbo.BoilersFuels bf INNER JOIN isWorldstone.dbo.dFuelsParameters fp ON bf.id_fuels = fp.id WHERE bf.id_boilers = b.id_object - FOR JSON PATH), - '[]' + FOR JSON PATH ) AS id_fuels FROM isWorldstone.dbo.vBoilers b WHERE id_city = @0 @@ -90,11 +91,118 @@ export class FuelService { return result } - async getBoilersFuelsLimits(): Promise { + async getCitySettings(getCitySettings: GetCitySettingsDTO): Promise { + const { city_id } = getCitySettings + const result = await this.wsDataSource.query(` + SELECT * FROM isWorldstone..vCitySettings + WHERE id_city = @0 + `, [Number(city_id)]) + + return result + } + + async getBoilersFuelsLimits(getLimitsDTO: GetLimitsDTO): Promise { + const { city_id, year, offset, limit } = getLimitsDTO + + // const result = await this.wsDataSource.query(` + // SELECT + // b.*, + // ( + // SELECT + // bf.id AS id_fuels_entry, + // df.id AS fuel_id, + // df.name, + + // -- вложенный JSON: лимиты по месяцам + // ( + // SELECT + // l.month, + // l.year, + // l.value + // FROM isFuels..BoilersFuelsLimits l + // WHERE l.id_fuel = bf.id + // ORDER BY l.year, l.month + // FOR JSON PATH + // ) AS limits + + // FROM isFuels..BoilersFuels bf + // JOIN isWorldstone..dFuelsParameters df + // ON df.id = bf.id_fuels + // WHERE bf.id_boilers = b.id_object + // FOR JSON PATH + // ) AS fuels + // FROM isWorldstone..vBoilers b + // WHERE id_city = @0 + // ORDER BY id_object + // OFFSET @1 ROWS + // FETCH NEXT @2 ROWS ONLY + // ; + // `, [city_id, Number(offset || 0), Number(limit || 100)]) + + const result = await this.wsDataSource.query(` + WITH Src AS ( + SELECT + b.id_object AS id_boiler, + b.name AS boiler_name, + bf.id AS id_boiler_fuel, + bf.id_fuels AS id_fuel_type, + fp.name AS fuel_name, + l.month, + l.value, + l.year + FROM isWorldstone..vBoilers b + LEFT JOIN isFuels..BoilersFuels bf + ON bf.id_boilers = b.id_object + LEFT JOIN isWorldstone..dFuelsParameters fp + ON fp.id = bf.id_fuels + LEFT JOIN isFuels..BoilersFuelsLimits l + ON l.id_boiler = bf.id_boilers + AND l.id_fuel = bf.id + AND ( + (l.month BETWEEN 7 AND 12 AND l.year = @1) -- second half + OR + (l.month BETWEEN 1 AND 6 AND l.year = @2) -- first half next year + ) + WHERE b.id_city = @0 + ), + + Renamed AS ( + SELECT + id_boiler, + boiler_name, + id_boiler_fuel, + id_fuel_type, + fuel_name, + CASE month + WHEN 7 THEN 'jul' + WHEN 8 THEN 'aug' + WHEN 9 THEN 'sep' + WHEN 10 THEN 'oct' + WHEN 11 THEN 'nov' + WHEN 12 THEN 'dec' + WHEN 1 THEN 'jan' + WHEN 2 THEN 'feb' + WHEN 3 THEN 'mar' + WHEN 4 THEN 'apr' + WHEN 5 THEN 'may' + WHEN 6 THEN 'jun' + END AS MonthName, + value + FROM Src + ) + SELECT * - FROM "vBoilerLimits"; - `) + FROM Renamed + PIVOT ( + MAX(value) + FOR MonthName IN ( + [jul], [aug], [sep], [oct], [nov], [dec], + [jan], [feb], [mar], [apr], [may], [jun] + ) + ) AS P + ORDER BY id_boiler, id_boiler_fuel; + `, [city_id, year, year + 1]) return result } @@ -114,10 +222,14 @@ export class FuelService { } async addBoilersFuelsLimit(createLimitDto: CreateLimitDto): Promise { - const result = await this.dataSource.query(` - INSERT INTO dbo.BoilersFuelsLimits (id_boiler, id_fuel, value, month, year) VALUES ($1, $2, $3, $4, $5) - `, [createLimitDto.id_boiler, createLimitDto.id_fuel, createLimitDto.value, createLimitDto.month, createLimitDto.year]) - return result + const logger = new Logger('INFO'); // 'MyContext' is an optional string for log context + logger.log(createLimitDto) + + return [createLimitDto] + // const result = await this.dataSource.query(` + // INSERT INTO isFuels..BoilersFuelsLimits (id_boiler, id_fuel, value, month, year) VALUES ($1, $2, $3, $4, $5) + // `, [createLimitDto.id_boiler, createLimitDto.id_fuel, createLimitDto.value, createLimitDto.month, createLimitDto.year]) + // return result } async addFuelsTransfer(createTransferDto: CreateTransferDto): Promise {