fuel; nest api

This commit is contained in:
2025-12-23 09:53:04 +09:00
parent fa516b3a20
commit 04ce74d320
23 changed files with 1125 additions and 183 deletions

View File

@ -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

View File

@ -78,20 +78,20 @@ const MapComponent = ({
const mapElement = useRef<HTMLDivElement | null>(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',

View File

@ -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
}

View File

@ -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
)

View File

@ -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)

View File

@ -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
}

View File

@ -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

View File

@ -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
})

View File

@ -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 || "",

View File

@ -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
}

View File

@ -3,6 +3,11 @@ export interface IRegion {
name: string;
}
export interface ISeason {
name: string
year: number
}
export interface ICity {
id: number;
name: string;

View File

@ -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<number | undefined>(
searchParams.get("region_id") ? Number(searchParams.get("region_id")) : undefined
);
const [regionId, setRegionId] = useState<number | undefined>(undefined)
const [cityId, setCityId] = useState<number | undefined>(
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<number | undefined>(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 (
<div style={{
@ -58,42 +126,51 @@ function Boilers() {
</Portal> */}
<div style={{ display: 'flex', gap: '1rem' }}>
<Combobox
placeholder="Выберите регион"
clearable
onOptionSelect={(_, data) => {
if (data.optionValue) {
setRegionId(Number(data.optionValue))
} else {
setCityId(undefined)
setRegionId(undefined)
}
}}
>
{regions && Array.isArray(regions) && regions.map((option) => (
<Option key={option.id} text={option.name} value={option.id}>
{option.name}
</Option>
))}
</Combobox>
{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>
}
{cities && <Combobox
clearable
placeholder="Выберите населенный пункт"
onOptionSelect={(_, data) => {
if (data.optionValue) {
setCityId(Number(data.optionValue))
} else {
setCityId(undefined)
}
}}
>
{cities && Array.isArray(cities) && cities.map((option) => (
<Option key={option.id} text={option.name} value={option.id}>
{option.name}
</Option>
))}
</Combobox>}
{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={{
@ -102,10 +179,6 @@ function Boilers() {
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={boilers && Array.isArray(boilers) ? boilers.length.toString() : ''} subtitle='' />
@ -119,80 +192,48 @@ function Boilers() {
}
</div>
<AgGridReact
// rowData={[
// Object.keys(table.headers).reduce((obj, key) => ({ ...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 ?
<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>)
}
//enableCellChangeFlash: true
},
{
field: 'activity',
headerName: 'Активный',
cellRenderer: (params: CustomCellRendererProps) => (<span>{params.value === true ? 'Да' : 'Нет'}</span>)
}
]}
defaultColDef={{
flex: 1,
}}
/>
]}
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 Boilers

View 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

View File

@ -16,7 +16,7 @@ export default (params: CustomCellRendererProps) => {
<Table style={{ width: '100%' }} size='small'>
<TableBody>
{Array.isArray(params.value) && params.value.map(fuel => (
<TableRow>
<TableRow key={`${fuel.id}`}>
<TableCell>
<TableCellLayout truncate>
{fuel.name}

View File

@ -97,7 +97,7 @@ const FuelsPage = () => {
}}
>
{fuelTypes && Array.isArray(fuelTypes) && fuelTypes.map((option) => (
<Option key={option.id} text={option.name} value={option.id}>
<Option key={`fuel-type-${option.id}`} text={option.name} value={option.id}>
{option.name}
</Option>
))}
@ -131,7 +131,7 @@ const FuelsPage = () => {
setSelectedIdFuels(Number(data.value))
}}>
{fuelTypes && Array.isArray(fuelTypes) && fuelTypes.map((ft: FuelType) => (
<Tab id={ft.id.toString()} value={ft.id}>
<Tab id={ft.id.toString()} value={ft.id} key={ft.id}>
{ft.name}
</Tab>
))

View File

@ -1,7 +1,391 @@
const LimitsPage = () => {
return (
<div>LimitsPage</div>
)
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

View 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

19
client/src/store/fuel.ts Normal file
View File

@ -0,0 +1,19 @@
import { create } from 'zustand'
export interface FuelState {
regionId: number | undefined
cityId: number | undefined
}
export const useFuelStore = create<FuelState>(() => {
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 }))