hide category image

dev
Timofey Malinin 2 years ago
parent 3d862e62e2
commit 2709d45672

@ -32,6 +32,19 @@ build-docker-prod:
-t ${DOCKER_IMAGE_TAG}-prod .
- docker push ${DOCKER_IMAGE_TAG}-prod
auto-deploy-dev-kuber:
extends: .deploy_base_kuber
variables:
INGRESS_HOST: "postnet.dev.selftech.ru"
DEPLOY_DOCKER_IMAGE: ${DOCKER_IMAGE_TAG}-dev
tags:
- docker
only:
refs:
- dev
environment:
name: dev
deploy-dev-kuber:
extends: .deploy_base_kuber
variables:
@ -39,8 +52,11 @@ deploy-dev-kuber:
DEPLOY_DOCKER_IMAGE: ${DOCKER_IMAGE_TAG}-dev
tags:
- docker
except:
- dev
environment:
name: dev
when: manual
deploy-prod-kuber:
extends: .deploy_base_kuber
@ -51,6 +67,7 @@ deploy-prod-kuber:
- docker-prod
environment:
name: prod
when: manual
.deploy_base_kuber:
image: ${YC_CONTAINER_REGISTRY}/public/helm-kubectl-git:1.0.0
@ -69,7 +86,6 @@ deploy-prod-kuber:
paths:
- ./deploy/front.yaml
expire_in: 1 week
when: manual
# variables:
# APP_NAME: postamates_front

BIN
dist/favicon.ico vendored

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

1
dist/index.html vendored

@ -2,7 +2,6 @@
<html lang="en">
<head>
<meta charset="UTF-8"/>
<link href="/vite.svg" rel="icon" type="image/svg+xml"/>
<link href="/favicon.ico" rel="icon"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>PostNet by Spatial</title>

@ -2,7 +2,6 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link href="/favicon.ico" rel="icon"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PostNet by Spatial</title>

@ -17,6 +17,7 @@
"@watergis/maplibre-gl-export": "^1.3.7",
"antd": "^4.23.6",
"axios": "^1.1.3",
"chart.js": "^4.4.0",
"immer": "^9.0.19",
"immutable": "^4.3.0",
"lodash.debounce": "^4.0.8",
@ -24,6 +25,8 @@
"maplibre-gl": "^2.4.0",
"nanostores": "^0.7.3",
"react": "^18.2.0",
"react-beautiful-dnd": "^13.1.1",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.2.0",
"react-icons": "^4.8.0",
"react-map-gl": "^7.0.19",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

@ -10,6 +10,7 @@ import { usePointSelection } from "./stores/usePointSelection";
import { usePendingPointsFilters } from "./stores/usePendingPointsFilters";
import { useOnApprovalPointsFilters } from "./stores/useOnApprovalPointsFilters";
import { useWorkingPointsFilters } from "./stores/useWorkingPointsFilters";
import useLocalStorage from "./hooks/useLocalStorage.js";
const queryClient = new QueryClient();
@ -22,7 +23,17 @@ if (import.meta.env.MODE === "development") {
mountStoreDevtool("PointSelection", usePointSelection);
}
const version = '0.0.9';
function App() {
const [versionControl, setVersionControl] = useLocalStorage('version_control', version);
if (versionControl !== version) {
localStorage.clear();
setVersionControl(version);
}
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter basename={import.meta.env.BASE_URL}>

@ -1,4 +1,4 @@
import { Logo } from "../icons/Logo";
import { HeaderLogo } from "../icons/Logo";
import { AddressSearch } from "./AddressSearch";
import { twMerge } from "tailwind-merge";
import { ModeSelector } from "../components/ModeSelector";
@ -6,8 +6,8 @@ import { ModeSelector } from "../components/ModeSelector";
export const Header = () => {
return (
<div className="absolute top-[20px] left-[19px] flex items-center z-10">
<div className={twMerge("flex items-center gap-x-20")}>
<Logo />
<div className={twMerge("flex items-center gap-x-[42px]")}>
<HeaderLogo />
<div className="flex items-center gap-x-3">
<ModeSelector />
</div>

@ -0,0 +1,57 @@
import { startML, useLastMLRun } from "../api.js";
import { Button, Popover, Spin, Tooltip } from "antd";
import { InfoCircleOutlined, LoadingOutlined } from "@ant-design/icons";
import { useMemo } from "react";
const TASK_STATUSES = {
finished: "Перерасчет ML завершен"
}
export function LastMLRun() {
const { data } = useLastMLRun();
const hasFinishedUpdate = useMemo(() => {
return data?.task_status === TASK_STATUSES.finished
}, [data]);
const lastMLRunRender = () => {
if (hasFinishedUpdate) return (
<>
<div className="text-xs text-grey z-10 bg-white-background rounded-xl px-2 py-0.5 space-y-3">
Последнее обновление системы
</div>
<div className="text-xs text-grey z-10 bg-white-background rounded-xl px-2 py-0.5 space-y-3">
{new Date(data?.last_time).toLocaleString('ru-RU')}
</div>
<Button type="text" className="flex items-center p-2 text-[#C50000] hover:text-[#C50000] text-xs" onClick={() => startML()}>
Обновить систему
</Button>
</>
);
return (
<div className="flex items-center gap-2">
<div>Идет обновление системы...</div>
<Spin />
</div>
)
};
return (
<Popover
content={lastMLRunRender}
trigger="click"
placement={"leftBottom"}
color="#ffffff"
>
<Tooltip title="Инфо">
<Button className="absolute bottom-[64px] right-[20px] flex items-center justify-center p-3">
{hasFinishedUpdate
? <InfoCircleOutlined className="w-4 h-4" />
: <Spin indicator={<LoadingOutlined style={{ fontSize: 16, color: "#000000" }} spin />} />
}
</Button>
</Tooltip>
</Popover>
);
}

@ -6,6 +6,7 @@ import { useRegionFilterExpression } from "./useRegionFilterExpression";
import { LAYER_IDS } from "./constants";
import { useMode } from "../../stores/useMode";
import { useOnApprovalPointsFilters } from "../../stores/useOnApprovalPointsFilters";
import { useSourceLayerName } from "../../api.js";
const statusExpression = ["==", ["get", "status"], STATUSES.cancelled];
@ -17,6 +18,7 @@ export const CancelledPoints = () => {
const regionFilterExpression = useRegionFilterExpression(region);
const { mode } = useMode();
const layerName = useSourceLayerName();
const getFilter = () => {
if (mode === MODES.ON_APPROVAL) {
@ -34,7 +36,7 @@ export const CancelledPoints = () => {
{...cancelledPointLayer}
id={LAYER_IDS.cancelled}
source={"points"}
source-layer={"public.service_placementpoint"}
source-layer={layerName}
layout={{
visibility: isVisible[LAYER_IDS.cancelled] ? "visible" : "none",
}}

@ -1,37 +1,38 @@
import { Layer } from "react-map-gl";
import {
workingPointBackgroundLayer,
workingPointSymbolLayer,
} from "./layers-config";
import { workingPointSymbolLayer } from "./layers-config";
import { useLayersVisibility } from "../../stores/useLayersVisibility";
import { STATUSES } from "../../config";
import { useRegionFilterExpression } from "./useRegionFilterExpression";
import { LAYER_IDS } from "./constants";
import { useWorkingPointsFilters } from "../../stores/useWorkingPointsFilters";
import { useSourceLayerName } from "../../api.js";
import { workingFilterHasChanged } from "../../utils.js";
const statusExpression = ["==", ["get", "status"], STATUSES.working];
export const FilteredWorkingPoints = () => {
const { isVisible } = useLayersVisibility();
const layerName = useSourceLayerName();
const {
filters: { deltaTraffic, factTraffic, age, region },
ranges
} = useWorkingPointsFilters();
const regionFilterExpression = useRegionFilterExpression(region);
const deltaExpression = [
const deltaExpression = workingFilterHasChanged(deltaTraffic, ranges, "deltaTraffic") ? [
[">=", ["get", "delta_current"], deltaTraffic[0]],
["<=", ["get", "delta_current"], deltaTraffic[1]],
];
] : [true];
const factExpression = [
const factExpression = workingFilterHasChanged(factTraffic, ranges, "factTraffic") ? [
[">=", ["get", "fact"], factTraffic[0]],
["<=", ["get", "fact"], factTraffic[1]],
];
] : [true];
const ageExpression = [
const ageExpression = workingFilterHasChanged(age, ranges, "age") ? [
[">=", ["get", "age_day"], age[0]],
["<=", ["get", "age_day"], age[1]],
];
] : [true];
const filter = regionFilterExpression
? [
@ -52,21 +53,11 @@ export const FilteredWorkingPoints = () => {
return (
<>
<Layer
{...workingPointBackgroundLayer}
id={LAYER_IDS.filteredWorkingBackground}
source={"points"}
source-layer={"public.service_placementpoint"}
layout={{
visibility: isVisible[LAYER_IDS.filteredWorking] ? "visible" : "none",
}}
filter={filter}
/>
<Layer
{...workingPointSymbolLayer}
id={LAYER_IDS.filteredWorking}
source={"points"}
source-layer={"public.service_placementpoint"}
source-layer={layerName}
layout={{
...workingPointSymbolLayer.layout,
visibility: isVisible[LAYER_IDS.filteredWorking] ? "visible" : "none",

@ -5,8 +5,9 @@ import { BASE_URL } from "../../api";
import { PVZ } from "./PVZ";
import { OtherPostamates } from "./OtherPostamates";
import { SelectedRegion } from "./SelectedRegion";
import { transliterate } from "../../utils.js";
export const Layers = () => {
export const Layers = ({ postGroups, otherGroups }) => {
return (
<>
<Source
@ -37,16 +38,39 @@ export const Layers = () => {
<SelectedRegion />
<Points />
<Source
id="rivals"
id="pvz"
type="vector"
tiles={[`${BASE_URL}/martin/public.service_rivals/{z}/{x}/{y}.pbf`]}
tiles={[`${BASE_URL}/martin/public.service_post_and_pvz/{z}/{x}/{y}.pbf`]}
>
<PVZ />
<OtherPostamates />
{postGroups?.map((item) => {
return item.groups.map((itemGroup) =>
<PVZ
id={itemGroup.id}
categoryId={itemGroup.category}
name={transliterate(itemGroup.name)}
/>
);
})}
</Source>
<Points />
<Source
id="other"
type="vector"
tiles={[`${BASE_URL}/martin/public.service_otherobjects/{z}/{x}/{y}.pbf`]}
>
{otherGroups && otherGroups.map((item) => {
return item.groups.map((itemGroup) =>
<OtherPostamates
id={itemGroup.id}
categoryId={itemGroup.category}
name={transliterate(itemGroup.name)}
/>
);
})}
</Source>
</>
);
};

@ -5,11 +5,13 @@ import { STATUSES } from "../../config";
import { useRegionFilterExpression } from "./useRegionFilterExpression";
import { LAYER_IDS } from "./constants";
import { useOnApprovalPointsFilters } from "../../stores/useOnApprovalPointsFilters";
import { useSourceLayerName } from "../../api.js";
const statusExpression = ["==", ["get", "status"], STATUSES.onApproval];
export const OnApprovalPoints = () => {
const { isVisible } = useLayersVisibility();
const layerName = useSourceLayerName();
const {
filters: { region },
} = useOnApprovalPointsFilters();
@ -25,7 +27,7 @@ export const OnApprovalPoints = () => {
{...approvePointLayer}
id={LAYER_IDS.approve}
source={"points"}
source-layer={"public.service_placementpoint"}
source-layer={layerName}
layout={{
visibility: isVisible[LAYER_IDS.approve] ? "visible" : "none",
}}

@ -1,24 +1,24 @@
import { Layer } from "react-map-gl";
import { otherPostamatesLayer } from "./layers-config";
import {getPointSymbolLayer} from "./layers-config";
import { useLayersVisibility } from "../../stores/useLayersVisibility";
import { LAYER_IDS } from "./constants";
const typeFilter = ["==", ["get", "type"], "post"];
export const OtherPostamates = () => {
export const OtherPostamates = ({ id, categoryId, name }) => {
const { isVisible } = useLayersVisibility();
const filter = ["==", ["get", "group_id"], id]
return (
<>
<Layer
{...otherPostamatesLayer}
id={LAYER_IDS.other}
source={"rivals"}
source-layer={"public.service_rivals"}
{...getPointSymbolLayer(name + id)}
id={LAYER_IDS.other + id}
source={"other"}
source-layer={"public.service_otherobjects"}
layout={{
visibility: isVisible[LAYER_IDS.other] ? "visible" : "none",
...getPointSymbolLayer(name + id).layout,
visibility: isVisible[LAYER_IDS.other_category + categoryId] ? "visible" : "none",
}}
filter={typeFilter}
filter={filter}
/>
</>
);

@ -1,24 +1,24 @@
import { Layer } from "react-map-gl";
import { pvzPointLayer } from "./layers-config";
import { getPointSymbolLayer } from "./layers-config";
import { useLayersVisibility } from "../../stores/useLayersVisibility";
import { LAYER_IDS } from "./constants";
const typeFilter = ["==", ["get", "type"], "pvz"];
export const PVZ = () => {
export const PVZ = ({ id, categoryId, name }) => {
const { isVisible } = useLayersVisibility();
const filter = ["==", ["get", "group_id"], id]
return (
<>
<Layer
{...pvzPointLayer}
id={LAYER_IDS.pvz}
source={"rivals"}
source-layer={"public.service_rivals"}
{...getPointSymbolLayer(name + id)}
id={LAYER_IDS.pvz + id}
source={"pvz"}
source-layer={"public.service_post_and_pvz"}
layout={{
visibility: isVisible[LAYER_IDS.pvz] ? "visible" : "none",
...getPointSymbolLayer(name + id).layout,
visibility: isVisible[LAYER_IDS.pvz_category + categoryId] ? "visible" : "none",
}}
filter={typeFilter}
filter={filter}
/>
</>
);

@ -8,12 +8,14 @@ import { usePointSelection } from "../../stores/usePointSelection";
import { STATUSES } from "../../config";
import { useRegionFilterExpression } from "./useRegionFilterExpression";
import { LAYER_IDS } from "./constants";
import { usePendingPointsFilters } from "../../stores/usePendingPointsFilters";
import { RANGE_FILTERS_KEYS, usePendingPointsFilters } from "../../stores/usePendingPointsFilters";
import { fieldHasChanged, predictionHasChanged } from "../../utils.js";
import { useSourceLayerName } from "../../api.js";
const statusExpression = ["==", ["get", "status"], STATUSES.pending];
const rawStatusExpression = ["==", ["get", "status"], STATUSES.pending];
const useFilterExpression = () => {
const { filters } = usePendingPointsFilters();
const { filters, ranges } = usePendingPointsFilters();
const { prediction, categories, region } = filters;
const { selection } = usePointSelection();
const includedArr = [...selection.included];
@ -23,16 +25,32 @@ const useFilterExpression = () => {
const includedExpression = ["in", ["get", "id"], ["literal", includedArr]];
const excludedExpression = ["in", ["get", "id"], ["literal", excludedArr]];
const predictionExpression = [
const rawPredictionExpression = [
[">=", ["get", "prediction_current"], prediction[0]],
["<=", ["get", "prediction_current"], prediction[1]],
];
const staticKeyFiltersExpressions = RANGE_FILTERS_KEYS.map((key) => {
return [
[">=", ["get", key], filters[`${key}__gt`]],
["<=", ["get", key], filters[`${key}__lt`]],
];
});
const staticKeyExpressions = staticKeyFiltersExpressions.filter((expression) => {
const filterKey = expression[0][1][1];
return fieldHasChanged(filters, ranges, filterKey).result;
}).flat();
const categoryExpression =
categories.length > 0
? ["in", ["get", "category"], ["literal", categories]]
: true;
const statusExpression = rawStatusExpression;
const predictionExpression = predictionHasChanged(filters, ranges) ? rawPredictionExpression : [true];
const matchFilterExpression = [
"all",
statusExpression,
@ -40,8 +58,8 @@ const useFilterExpression = () => {
[
"any",
regionExpression
? ["all", ...predictionExpression, categoryExpression, regionExpression]
: ["all", ...predictionExpression, categoryExpression],
? ["all", ...predictionExpression, ...staticKeyExpressions, categoryExpression, regionExpression]
: ["all", ...predictionExpression, ...staticKeyExpressions, categoryExpression],
includedExpression,
],
];
@ -60,8 +78,10 @@ const useFilterExpression = () => {
...predictionExpression,
categoryExpression,
regionExpression,
...staticKeyExpressions,
categoryExpression,
]
: ["all", ...predictionExpression, categoryExpression],
: ["all", ...predictionExpression, categoryExpression, ...staticKeyExpressions],
],
excludedExpression,
],
@ -72,6 +92,7 @@ const useFilterExpression = () => {
export const PendingPoints = () => {
const { isVisible } = useLayersVisibility();
const layerName = useSourceLayerName();
const { match: matchFilterExpression, unmatch: unmatchFilterExpression } =
useFilterExpression();
@ -81,7 +102,7 @@ export const PendingPoints = () => {
{...matchInitialPointLayer}
id={LAYER_IDS["initial-unmatch"]}
source={"points"}
source-layer={"public.service_placementpoint"}
source-layer={useSourceLayerName()}
layout={{
...matchInitialPointLayer.layout,
visibility: isVisible[LAYER_IDS.initial] ? "visible" : "none",
@ -93,7 +114,7 @@ export const PendingPoints = () => {
{...matchInitialPointLayer}
id={LAYER_IDS["initial-match"]}
source={"points"}
source-layer={"public.service_placementpoint"}
source-layer={layerName}
layout={{
...matchInitialPointLayer.layout,
visibility: isVisible[LAYER_IDS.initial] ? "visible" : "none",

@ -1,14 +1,19 @@
import { Source } from "react-map-gl";
import { BASE_URL } from "../../api";
import { BASE_URL, useSourceLayerName } from "../../api";
import { useUpdateLayerCounter } from "../../stores/useUpdateLayerCounter";
import { PendingPoints } from "./PendingPoints";
import { OnApprovalPoints } from "./OnApprovalPoints";
import { WorkingPoints } from "./WorkingPoints";
import { FilteredWorkingPoints } from "./FilteredWorkingPoints";
import { CancelledPoints } from "./CancelledPoints";
import { useEffect } from "react";
export const Points = () => {
const { updateCounter } = useUpdateLayerCounter();
const { updateCounter, toggleUpdateCounter } = useUpdateLayerCounter();
const layer = useSourceLayerName();
useEffect(() => {
toggleUpdateCounter();
}, [layer]);
return (
<>
@ -17,14 +22,14 @@ export const Points = () => {
type="vector"
key={`points-${updateCounter}`}
tiles={[
`${BASE_URL}/martin/public.service_placementpoint/{z}/{x}/{y}.pbf`,
`${BASE_URL}/martin/${layer}/{z}/{x}/{y}.pbf`,
]}
>
<PendingPoints />
<OnApprovalPoints />
<CancelledPoints />
<WorkingPoints />
<FilteredWorkingPoints />
<CancelledPoints />
</Source>
</>
);

@ -1,19 +1,18 @@
import { Layer } from "react-map-gl";
import {
workingPointBackgroundLayer,
workingPointSymbolLayer,
} from "./layers-config";
import { workingPointSymbolLayer } from "./layers-config";
import { useLayersVisibility } from "../../stores/useLayersVisibility";
import { MODES, STATUSES } from "../../config";
import { useRegionFilterExpression } from "./useRegionFilterExpression";
import { LAYER_IDS } from "./constants";
import { useMode } from "../../stores/useMode";
import { useOnApprovalPointsFilters } from "../../stores/useOnApprovalPointsFilters";
import {useSourceLayerName} from "../../api.js";
const statusExpression = ["==", ["get", "status"], STATUSES.working];
export const WorkingPoints = () => {
const { isVisible } = useLayersVisibility();
const layerName = useSourceLayerName();
const {
filters: { region },
} = useOnApprovalPointsFilters();
@ -33,21 +32,11 @@ export const WorkingPoints = () => {
return (
<>
<Layer
{...workingPointBackgroundLayer}
id={LAYER_IDS.workingBackground}
source={"points"}
source-layer={"public.service_placementpoint"}
layout={{
visibility: isVisible[LAYER_IDS.working] ? "visible" : "none",
}}
filter={getFilter()}
/>
<Layer
{...workingPointSymbolLayer}
id={LAYER_IDS.working}
source={"points"}
source-layer={"public.service_placementpoint"}
source-layer={layerName}
layout={{
...workingPointSymbolLayer.layout,
visibility: isVisible[LAYER_IDS.working] ? "visible" : "none",

@ -10,5 +10,7 @@ export const LAYER_IDS = {
cancelled: "cancelled-points",
atd: "atd",
pvz: "pvz",
pvz_category: "pvz_category",
other_category: "other_category",
other: "other",
};

@ -1,6 +1,29 @@
const POINT_SIZE = 5;
const UNMATCH_POINT_SIZE = 3;
// const getPendingColor = () => {
// const { prediction } = usePendingPointsFilters();
//
// const getValue = (multiply) => {
// const difference = prediction[1] - prediction[0];
// const value = prediction[0] + (difference * multiply);
// return Math.floor(value);
// }
//
// return {
// property: "prediction_current",
// stops: [
// [getValue(0), "#FDEBF0"],
// [getValue(0.1), "#F8C7D8"],
// [getValue(0.25), "#F398BC"],
// [getValue(0.4), "#EE67A1"],
// [getValue(0.55), "#B64490"],
// [getValue(0.7), "#7E237E"],
// [getValue(0.85), "#46016C"],
// ],
// };
// }
export const PENDING_COLOR = {
property: "prediction_current",
stops: [
@ -13,7 +36,8 @@ export const PENDING_COLOR = {
[251, "#46016C"],
],
};
export const CANCELLED_COLOR = "#CC2222";
export const CANCELLED_COLOR = "#A6A6A6";
export const APPROVE_COLOR = "#ff7d00";
export const WORKING_COLOR = "#006e01";
export const UNMATCHED_COLOR = "rgba(196,195,195,0.6)";
@ -55,19 +79,36 @@ export const unmatchInitialPointLayer = getPointConfig(
UNMATCHED_COLOR,
UNMATCH_POINT_SIZE
);
export const approvePointLayer = getPointConfig(APPROVE_COLOR);
export const approvePointLayer = {
...getPointConfig(APPROVE_COLOR),
paint: {
...getPointConfig(APPROVE_COLOR).paint,
"circle-stroke-width": 1,
"circle-stroke-color": "#252525",
},
};
export const workingPointSymbolLayer = {
type: "symbol",
layout: {
"icon-image": "logo",
"icon-size": ["interpolate", ["linear"], ["zoom"], 3, 0, 9, 0.1, 13, 0.7],
"icon-size": ["interpolate", ["linear"], ["zoom"], 3, 0, 9, 0.1, 13, 0.5],
},
paint: {
"icon-color": "#E63941",
},
};
export const getPointSymbolLayer = (image) => {
return {
type: "symbol",
layout: {
"icon-image": image,
"icon-size": ["interpolate", ["linear"], ["zoom"], 3, 0, 9, 0.1, 13, 0.5],
},
};
}
const workingBgColor = "#ffffff";
const workingBgSize = 16;
export const workingPointBackgroundLayer = {
@ -78,7 +119,14 @@ export const workingPointBackgroundLayer = {
"circle-stroke-color": "#252525",
},
};
export const cancelledPointLayer = getPointConfig(CANCELLED_COLOR);
export const cancelledPointLayer = {
...getPointConfig(CANCELLED_COLOR),
paint: {
...getPointConfig(CANCELLED_COLOR).paint,
"circle-stroke-width": 1,
"circle-stroke-color": "#252525",
},
};
export const pvzPointLayer = getPointConfig(PVZ_COLOR);
export const otherPostamatesLayer = getPointConfig(OTHER_POSTAMATES_COLOR);

@ -2,10 +2,10 @@ import { Button, Popover, Tooltip } from "antd";
import { BiLayer } from "react-icons/all";
import { LayersVisibility } from "./LayersVisibility";
export const LayersControl = () => {
export const LayersControl = ({ postGroups, otherGroups }) => {
return (
<Popover
content={<LayersVisibility />}
content={<LayersVisibility postGroups={postGroups} otherGroups={otherGroups} />}
trigger="click"
placement={"leftBottom"}
>

@ -4,7 +4,7 @@ import Checkbox from "antd/es/checkbox/Checkbox";
import { MODES } from "../../config";
import { LAYER_IDS } from "../Layers/constants";
export const LayersVisibility = () => {
export const LayersVisibility = ({ postGroups, otherGroups }) => {
const { toggleVisibility, isVisible } = useLayersVisibility();
const { mode } = useMode();
@ -28,20 +28,32 @@ export const LayersVisibility = () => {
</Checkbox>
</>
)}
<Checkbox
className={"!ml-0"}
onChange={() => toggleVisibility(LAYER_IDS.pvz)}
checked={isVisible[LAYER_IDS.pvz]}
>
ПВЗ
</Checkbox>
<Checkbox
className={"!ml-0"}
onChange={() => toggleVisibility(LAYER_IDS.other)}
checked={isVisible[LAYER_IDS.other]}
>
Постаматы прочих сетей
</Checkbox>
{postGroups?.map((item) => {
return (
<Checkbox
key={item.id}
className={"!ml-0"}
onChange={() => toggleVisibility(LAYER_IDS.pvz_category + item.id)}
checked={isVisible[LAYER_IDS.pvz_category + item.id]}
>
{item.name}
</Checkbox>
);
})}
{otherGroups && otherGroups.map((item) => {
return (
<Checkbox
key={item.id}
className={"!ml-0"}
onChange={() => toggleVisibility(LAYER_IDS.other_category + item.id)}
checked={isVisible[LAYER_IDS.other_category + item.id]}
>
{item.name}
</Checkbox>
);
})}
{/*{mode === MODES.WORKING && (*/}
{/* <>*/}
{/* <Checkbox*/}

@ -1,34 +1,39 @@
import { useMode } from "../stores/useMode";
import { MODES } from "../config";
import {useMode} from "../stores/useMode";
import {MODES} from "../config";
import {
APPROVE_COLOR,
CANCELLED_COLOR,
OTHER_POSTAMATES_COLOR,
PENDING_COLOR,
PVZ_COLOR,
PENDING_COLOR, PVZ_COLOR,
} from "./Layers/layers-config";
import { Logo } from "../icons/Logo.jsx";
import {Logo} from "../icons/Logo.jsx";
import {Collapse, Image} from "antd";
import React from "react";
const LegendPointItem = ({ color, name }) => {
const LegendPointItem = ({color, imageSrc, name, hideImage, border}) => {
return (
<div className="flex gap-2 items-center">
{color ? (
<span
className="rounded-xl w-3 h-3 inline-block"
style={{ backgroundColor: color }}
/>
) : (
<Logo width={12} height={12} fill="#E63941" />
{imageSrc && <Image src={imageSrc} width={18} height={18} className='flex items-center' preview={false}/>}
{color && !imageSrc && (
<span className="w-4 h-[100%] flex items-center justify-center">
<span
className={`rounded-xl w-3 h-3 inline-block ${border && "border-black border-[1px] border-solid"}`}
style={{backgroundColor: color}}
/>
</span>
)}
{!imageSrc && !color && !hideImage && (
<Logo width={18} height={18} />
)}
<span>{name}</span>
<span className='text-xs text-grey'>{name}</span>
</div>
);
};
const pendingColors = PENDING_COLOR.stops.map(([_value, color]) => color);
const LegendColorRampItem = ({ colors, name }) => {
const LegendColorRampItem = ({colors, name}) => {
return (
<div className="mb-3">
<span className={"mb-1 mt-3 text-center"}>{name}</span>
@ -46,11 +51,45 @@ const LegendColorRampItem = ({ colors, name }) => {
);
};
export function Legend() {
const { mode } = useMode();
const LegendGroupItem = ({item, color}) => {
return (
<Collapse
bordered={false}
expandIcon={null}
style={{
background: 'none',
}}
className="legend_group"
>
<Collapse.Panel
key={"opened"}
header={<LegendPointItem name={item.name} hideImage />}
>
<div className="ml-3 my-1">
{item.groups && item.groups?.map((groupItem) => {
return (
<div key={groupItem.id} className="my-1">
<LegendPointItem
color={color}
imageSrc={groupItem.image}
name={groupItem.name}
/>
</div>
)
})}
</div>
</Collapse.Panel>
</Collapse>
)
}
export function Legend({ postGroups, otherGroups }) {
const {mode} = useMode();
return (
<div className="absolute bottom-[20px] left-[20px] text-xs text-grey z-10 bg-white-background rounded-xl p-3 space-y-3">
<div
className="absolute bottom-[20px] left-[20px] text-xs text-grey z-10 bg-white-background rounded-xl p-3 space-y-3">
<div>
<div className="space-y-1">
{mode === MODES.PENDING && (
@ -59,10 +98,11 @@ export function Legend() {
colors={pendingColors}
name="Локации к рассмотрению"
/>
<LegendPointItem name="Работающие постаматы" />
<LegendPointItem name="Работающие постаматы"/>
<LegendPointItem
name="Отмененные локации"
color={CANCELLED_COLOR}
border
/>
</>
)}
@ -71,28 +111,33 @@ export function Legend() {
<LegendPointItem
name="Согласование-установка"
color={APPROVE_COLOR}
border
/>
<LegendPointItem name="Работающие постаматы" />
<LegendPointItem name="Работающие постаматы"/>
<LegendPointItem
name="Отмененные локации"
color={CANCELLED_COLOR}
border
/>
</>
)}
{mode === MODES.WORKING && (
<>
<LegendPointItem name="Работающие постаматы" />
<LegendPointItem name="Работающие постаматы"/>
</>
)}
</div>
</div>
<div className="space-y-1">
<LegendPointItem name="ПВЗ" color={PVZ_COLOR} />
<LegendPointItem
name="Постаматы прочих сетей"
color={OTHER_POSTAMATES_COLOR}
/>
{postGroups?.map((item) => {
return <LegendGroupItem key={item.id} item={item} color={PVZ_COLOR}/>
})}
</div>
<div className="space-y-1">
{otherGroups?.map((item) => {
return <LegendGroupItem key={item.id} item={item} color={OTHER_POSTAMATES_COLOR}/>
})}
</div>
</div>
);

@ -1,6 +1,6 @@
import maplibregl from "maplibre-gl";
import Map, { MapProvider } from "react-map-gl";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Sidebar } from "../modules/Sidebar/Sidebar";
import { Layers } from "./Layers/Layers";
import { MapPopup } from "./Popup/Popup";
@ -21,6 +21,14 @@ import { useLayersVisibility } from "../stores/useLayersVisibility";
import { LAYER_IDS } from "./Layers/constants";
import { Header } from "./Header";
import { icons } from "../icons/icons-config";
import { LastMLRun } from "./LastMLRun";
import { useOtherGroups, usePostamatesAndPvzGroups } from "../api.js";
import { getFilteredGroups, transliterate } from "../utils.js";
import {
CATEGORIES_MAP,
RANGE_FILTERS_KEYS,
RANGE_FILTERS_MAP
} from "../stores/usePendingPointsFilters.js";
export const MapComponent = () => {
const mapRef = useRef(null);
@ -32,6 +40,51 @@ export const MapComponent = () => {
const { mode } = useMode();
const { tableState, openTable } = useTable();
const { data: postamatesAndPvzGroups } = usePostamatesAndPvzGroups();
const { data: otherGroups } = useOtherGroups();
const filteredPostamatesGroups = useMemo(() => {
return getFilteredGroups(postamatesAndPvzGroups);
}, [postamatesAndPvzGroups]);
const filteredOtherGroups = useMemo(() => {
return getFilteredGroups(otherGroups);
}, [otherGroups]);
const mapIcons = useMemo(() => {
const res = [];
[...filteredOtherGroups, ...filteredPostamatesGroups].map((category) => {
category.groups.map((group) => {
res.push({name: transliterate(group.name + group.id), url: group.image});
})
});
return [...res, ...icons];
}, [icons, filteredPostamatesGroups, filteredOtherGroups]);
const mapLayersIds = useMemo(() => {
const res = []
filteredPostamatesGroups.map((item) => {
RANGE_FILTERS_MAP[`category${item.id}`] = {
name: CATEGORIES_MAP[item.name],
};
item.groups.map((groupItem) => {
if (!RANGE_FILTERS_KEYS.includes(`d${groupItem.id}`)) RANGE_FILTERS_KEYS.push(`d${groupItem.id}`);
RANGE_FILTERS_MAP[`category${item.id}`][`d${groupItem.id}`] = groupItem.name;
res.push(LAYER_IDS.pvz + groupItem.id);
});
});
filteredOtherGroups.map((item) => {
item.groups.map((groupItem) => {
res.push(LAYER_IDS.other + groupItem.id);
})
});
return res;
}, [filteredPostamatesGroups, filteredOtherGroups]);
useEffect(() => {
setLayersVisibility(MODE_TO_LAYER_VISIBILITY_MAPPER[mode]);
setPopup(null);
@ -131,21 +184,21 @@ export const MapComponent = () => {
LAYER_IDS.working,
LAYER_IDS.filteredWorking,
LAYER_IDS.cancelled,
LAYER_IDS.pvz,
LAYER_IDS.other,
...mapLayersIds,
]}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onLoad={() => {
icons.map((icon) => {
mapIcons.map((icon) => {
const img = new Image(
icon.size?.width || 24,
icon.size?.height || 24
icon.size?.width || 64,
icon.size?.height || 64
);
img.onload = () =>
mapRef.current.addImage(icon.name, img, { sdf: true });
mapRef.current.addImage(icon.name, img);
img.src = icon.url;
img.crossOrigin = "Anonymous";
});
}}
id="map"
@ -165,11 +218,12 @@ export const MapComponent = () => {
<SidebarControl toggleCollapse={toggleCollapseSidebar} />
<Basemap />
<Layers />
<Layers postGroups={filteredPostamatesGroups} otherGroups={filteredOtherGroups} />
<Legend />
<Legend postGroups={filteredPostamatesGroups} otherGroups={filteredOtherGroups} />
<LastMLRun />
<SignOut />
<LayersControl />
<LayersControl postGroups={filteredPostamatesGroups} otherGroups={filteredOtherGroups} />
</Map>
</div>
<div className="w-full border-solid border-border border-0 border-t-[1px] z-20">

@ -0,0 +1,168 @@
import { Line } from "react-chartjs-2";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip as ChartTooltip,
Legend, PointElement, LineElement, BarController,
} from 'chart.js';
import { useQuery } from "@tanstack/react-query";
import { api } from "../api.js";
ChartJS.register(
CategoryScale,
BarController,
PointElement,
LineElement,
LinearScale,
BarElement,
Title,
ChartTooltip,
Legend
);
const GRAPH_LABELS_MAP= {
target_dist_shap: "Расстояние до ближайшего постамата Мой постамат",
target_post_cnt_shap: "Кол-во точек Мой постамат*",
target_cnt_ao_mean_shap: "Средний трафик в точках Мой постамат в АО",
rival_pvz_cnt_shap: "Кол-во ПВЗ*",
rival_post_cnt_shap: "Кол-во постаматов конкурентов *",
metro_dist_shap: "Расстояние до метро",
property_price_bargains_shap: "Цена сделок жилой недвижимости*",
property_price_offers_shap: "Цена предложений жилой недвижимости*",
property_mean_floor_shap: "Средняя этажность застройки*",
property_era_shap: "Эпоха жилой недвижимости*",
flats_cnt_shap: "Кол-во квартир*",
popul_home_shap: "Численность проживающего населения*",
popul_job_shap: "Численность работающего населения*",
yndxfood_sum_shap: "Сумма заказов Яндекс.Еда*",
yndxfood_cnt_shap: "Кол-во заказов Яндекс.Еда*",
school_cnt_shap: "Кол-во школ*",
kindergar_cnt_shap: "Кол-во детсадов*",
public_stop_cnt_shap: "Кол-во остановок общ. транспорта*",
sport_center_cnt_shap: "Кол-во спортивных центров*",
pharmacy_cnt_shap: "Кол-во аптек*",
supermarket_cnt_shap: "Кол-во супермаркетов*",
supermarket_premium_cnt_shap: "Кол-во премиальных супермаркетов*",
clinic_cnt_shap: "Кол-во поликлиник*",
bank_cnt_shap: "Кол-во банков*",
reca_cnt_shap: "Кол-во точек общепита*",
lab_cnt_shap: "Кол-во лабораторий*",
culture_cnt_shap: "Кол-во объектов культуры*",
attraction_cnt_shap: "Кол-во достопримечательностей*",
mfc_cnt_shap: "Кол-во МФЦ*",
bc_cnt_shap: "Кол-во бизнес-центров*",
tc_cnt_shap: "Кол-во торговых центров*",
business_activity_shap: "Бизнес активность",
}
export const PointChart = ({ point }) => {
const { data: meanData } = useQuery(
["mean-data"],
async () => {
const { data } = await api.get(
`/api/avg_bi_values/`
);
return data;
},
{
refetchOnWindowFocus: false,
refetchOnMount: false
}
);
const options = {
indexAxis: 'y',
elements: {
bar: {
borderWidth: 0,
borderRadius: 5,
pointStyle: 'circle'
},
},
plugins: {
legend: {
display: false
},
tooltip: {
displayColors: false,
yAlign: "top",
callbacks: {
label: function(context) {
const label = []
const shap_key = Object.keys(GRAPH_LABELS_MAP).find(key => GRAPH_LABELS_MAP[key] === context.label);
const key = shap_key.substring(0, shap_key.length - 5)
if (context.datasetIndex === 0) label.push("Значение: " + point[key]);
if (context.parsed.x !== null) {
let labelText = "";
if (context.datasetIndex === 0) labelText = "Вклад в прогноз, %: ";
if (context.datasetIndex === 1) labelText = "Минимальный вклад в прогноз, %: ";
if (context.datasetIndex === 2) labelText = "Максимальный вклад в прогноз, %: ";
label.push(labelText + context.parsed.x);
}
return label;
},
body: () => {
return "Вклад в прогноз, %:"
}
}
},
},
scales: {
y: {
stacked: true,
},
x: {
title: {
display: true,
text: 'Вклад в прогноз, %',
},
grid: {
color: function(context) {
if (context.tick.value === 0) {
return "#000000";
}
return "#E5E5E5";
},
},
}
}
};
const labels = Object.keys(GRAPH_LABELS_MAP).sort((a, b) => {
if (Math.abs(point[a]) < Math.abs(point[b])) return 1;
else return -1;
}).slice(0, 15);
const data = {
labels: labels.map((l) => GRAPH_LABELS_MAP[l]),
datasets: [
{
data: labels.map((l) => point[l]),
backgroundColor: labels.map((l) => point[l]).map(v => v <= 0 ? '#CC2500' : '#278211'),
hoverBackgroundColor: labels.map((l) => point[l]).map(v => v <= 0 ? '#F22C00' : '#2DB20C'),
type: 'line',
showLine: false,
},
{
data: labels.map((l) => meanData ? meanData[`min_${l}`] : 0),
backgroundColor: "#cccccc",
hoverBackgroundColor: "#aaaaaa",
type: 'bar',
showLine: false,
},
{
data: labels.map((l) => meanData ? meanData[`max_${l}`] : 0),
backgroundColor: "#cccccc",
hoverBackgroundColor: "#aaaaaa",
type: 'bar',
showLine: false,
},
],
};
return <Line options={options} data={data} />
}

@ -1,4 +1,4 @@
import { Button } from "antd";
import { Button, Spin, Tooltip } from "antd";
import { CATEGORIES, MODES, STATUSES } from "../../config";
import { useMode } from "../../stores/useMode";
import { LAYER_IDS } from "../Layers/constants";
@ -8,84 +8,129 @@ import { OnApprovalPointPopup } from "./mode-popup/OnApprovalPointPopup";
import { WorkingPointPopup } from "./mode-popup/WorkingPointPopup";
import { FeatureProperties } from "./mode-popup/FeatureProperties";
import { usePopup } from "../../stores/usePopup.js";
import { PanoramaIcon } from "../../icons/PanoramaIcon";
import { useGetPopupPoints } from "../../api.js";
import { doesMatchFilter } from "../../utils.js";
import Checkbox from "antd/es/checkbox/Checkbox";
import { usePointSelection } from "../../stores/usePointSelection.js";
import { usePendingPointsFilters } from "../../stores/usePendingPointsFilters.js";
const SingleFeaturePopup = ({ feature }) => {
const SingleFeaturePopup = ({ feature, point }) => {
const { mode } = useMode();
const isRivals =
feature.layer?.id === LAYER_IDS.pvz ||
feature.layer?.id === LAYER_IDS.other;
feature.layer?.id.includes(LAYER_IDS.pvz) ||
feature.layer?.id.includes(LAYER_IDS.other);
const isPendingPoint = feature.properties.status === STATUSES.pending;
const isWorkingPoint = feature.properties.status === STATUSES.working;
if (isRivals) {
return <FeatureProperties feature={feature} />;
return <FeatureProperties feature={feature} point={point} />;
}
if (mode === MODES.ON_APPROVAL && !isPendingPoint) {
return <OnApprovalPointPopup feature={feature} />;
return <OnApprovalPointPopup feature={feature} point={point} />;
}
if (mode === MODES.WORKING && isWorkingPoint) {
return <WorkingPointPopup feature={feature} />;
return <WorkingPointPopup feature={feature} point={point} />;
}
if (mode === MODES.PENDING && isPendingPoint)
return <PendingPointPopup feature={feature} />;
return <PendingPointPopup feature={feature} point={point} />;
return <FeatureProperties feature={feature} />;
return <FeatureProperties feature={feature} point={point} />;
};
const MultipleFeaturesPopup = ({ features }) => {
const MultipleFeaturesPopup = ({ features, points }) => {
const { setPopup } = usePopup();
const { selection, include, exclude } = usePointSelection();
const { filters, ranges } = usePendingPointsFilters();
return (
<div className="space-y-2 p-1">
{features.map((feature) => {
const featureId = feature.properties.id;
const point = points.find(p => p.id === featureId);
const isSelected = (doesMatchFilter(filters, ranges, feature) && !selection.excluded.has(featureId)) ||
selection.included.has(featureId);
const handleSelect = () => {
if (isSelected) {
exclude(featureId);
} else {
include(featureId);
}
};
return (
<Button
className="text-start w-full"
block
onClick={() => {
setPopup({
features: [feature],
coordinates: feature.geometry.coordinates,
});
}}
key={feature.properties.id}
>
{feature.properties.category === CATEGORIES.residential ? (
<div className="space-x-2 flex items-center w-full">
<span className="flex-1 truncate inline-block">
{feature.properties.address}
</span>
<span>{feature.properties.name}</span>
</div>
) : (
<div className="flex w-full">
<span className="truncate">
{feature.properties.name ?? feature.properties.category}
</span>
</div>
<div className="flex flex-row items-center gap-2 w-full">
{feature.properties.status === STATUSES.pending && (
<Checkbox
checked={isSelected}
onClick={handleSelect}
/>
)}
</Button>
<Button
className="text-start flex-1 !w-0"
block
onClick={() => {
setPopup({
features: [feature],
coordinates: feature.geometry.coordinates,
});
}}
key={feature.properties.id}
>
{feature.properties.category === CATEGORIES.residential || feature.layer.id === LAYER_IDS.working ? (
<div className="space-x-2 flex items-center w-full">
<span className="flex-1 truncate inline-block">
{point.address}
</span>
<span>{point.name}</span>
</div>
) : (
<div className="flex w-full">
<span className="truncate">
{point.name ?? point.category}
{point.category_id && getRivalsName(feature).name}
</span>
</div>
)}
</Button>
</div>
);
})}
</div>
);
};
const YandexPanoramaLink = ({ lat, lng }) => {
const link = `https://yandex.ru/maps/?panorama[point]=${lng},${lat}`
return (
<div className="pl-1 flex">
<Tooltip title="Перейти на Яндекс.Панорамы">
<a target="_blank" href={link}>
<PanoramaIcon />
</a>
</Tooltip>
</div>
);
}
export const MapPopup = ({ features, lat, lng, onClose }) => {
const {data: points, isLoading} = useGetPopupPoints(features);
const getContent = () => {
if (features.length === 1) {
return <SingleFeaturePopup feature={features[0]} />;
return <SingleFeaturePopup feature={features[0]} point={points[0]}/>;
}
return <MultipleFeaturesPopup features={features} />;
return <MultipleFeaturesPopup features={features} points={points} />;
};
return (
<PopupWrapper lat={lat} lng={lng} onClose={onClose}>
{getContent()}
<YandexPanoramaLink lat={lat} lng={lng}/>
{isLoading ? <Spin /> : getContent()}
</PopupWrapper>
);
};

@ -7,7 +7,7 @@ export const PopupWrapper = ({ lat, lng, onClose, children }) => {
latitude={lat}
onClose={onClose}
closeOnClick={false}
style={{ minWidth: "300px" }}
style={{ minWidth: "330px" }}
>
{children}
</Popup>

@ -1,35 +1,95 @@
import { CATEGORIES, STATUSES } from "../../../config";
import {
commonPopupConfig,
residentialPopupConfig,
residentialPopupFields,
rivalsConfig,
workingPointFields,
} from "./config";
import { Col, Row } from "antd";
import { twMerge } from "tailwind-merge";
import { LAYER_IDS } from "../../Layers/constants";
import { isNil } from "../../../utils.js";
import {getFilteredGroups, isNil} from "../../../utils.js";
import { useGetRegions } from "../../../components/RegionSelect.jsx";
import {useOtherGroups, usePostamatesAndPvzGroups} from "../../../api.js";
import {useMemo} from "react";
import { TrafficModal } from "../../TrafficModal.jsx";
export const FeatureProperties = ({ feature, dynamicStatus, postamatId }) => {
const getRivalsName = (feature) => {
const { data: postamatesAndPvzGroups } = usePostamatesAndPvzGroups();
const { data: otherGroups } = useOtherGroups();
const filteredPostamatesCategories = useMemo(() => {
return getFilteredGroups(postamatesAndPvzGroups);
}, [postamatesAndPvzGroups]);
const filteredOtherCategories = useMemo(() => {
return getFilteredGroups(otherGroups);
}, [otherGroups]);
const filteredPostamatesGroups = useMemo(() => {
if (!filteredPostamatesCategories) return [];
return filteredPostamatesCategories
.map((category) => {
return [...category.groups]
}).flat();
}, [filteredPostamatesCategories]);
const filteredOtherGroups = useMemo(() => {
if (!filteredOtherCategories) return [];
return filteredOtherCategories
.map((category) => {
return [...category.groups]
}).flat();
}, [filteredOtherCategories]);
const isOther = feature.layer?.id.includes(LAYER_IDS.other);
const name = isOther ?
filteredOtherCategories.find(c => c.id === feature.properties.category_id)?.name :
filteredPostamatesCategories.find(c => c.id === feature.properties.category_id)?.name;
const groupName = isOther ?
filteredOtherGroups.find(c => c.id === feature.properties.group_id)?.name :
filteredPostamatesGroups.find(c => c.id === feature.properties.group_id)?.name;
return {
name,
groupName
}
}
export const FeatureProperties = ({ feature, dynamicStatus, postamatId, point }) => {
const { data } = useGetRegions();
const isResidential = feature.properties.category === CATEGORIES.residential;
const isWorking = feature.properties.status === STATUSES.working;
const { name, groupName } = getRivalsName(feature);
const isRivals =
feature.layer?.id === LAYER_IDS.pvz ||
feature.layer?.id === LAYER_IDS.other;
feature.layer?.id.includes(LAYER_IDS.pvz) ||
feature.layer?.id.includes(LAYER_IDS.other);
const getConfig = () => {
if (isRivals) {
return rivalsConfig;
}
const config = isResidential ? residentialPopupConfig : commonPopupConfig;
return isWorking ? [...config, ...workingPointFields] : config;
const config = isWorking ? [...commonPopupConfig, ...workingPointFields] : commonPopupConfig;
return isResidential ? [...config, ...residentialPopupFields] : config;
};
const getValue = ({ field, render, empty, type, fallbackField }) => {
let value = feature.properties[field];
let value = point ? point[field] : feature.properties[field];
if (field === "prediction_current") {
value = <TrafficModal point={point} />;
}
if (field === "category_id") {
value = name;
}
if (field === "group_id") {
value = groupName;
}
if (field === "status" && dynamicStatus) {
value = dynamicStatus;
@ -40,7 +100,8 @@ export const FeatureProperties = ({ feature, dynamicStatus, postamatId }) => {
}
if (type === "region") {
value = value ? value : feature.properties[fallbackField];
const valueProvider = point ? point : feature
value = value ? value : valueProvider[fallbackField];
value = render(value, data?.normalized);
} else {
value = render ? render(value) : value;

@ -10,12 +10,12 @@ import { Button, InputNumber } from "antd";
import { STATUSES } from "../../../config";
import { isNil } from "../../../utils.js";
export const OnApprovalPointPopup = ({ feature }) => {
export const OnApprovalPointPopup = ({ feature, point }) => {
const featureId = feature.properties.id;
const { setClickedPointConfig } = useClickedPointConfig();
const { status: initialStatus, postamat_id: initialPostamatId } =
feature.properties;
point;
const [status, setStatus] = useState(initialStatus);
const [postamatId, setPostamatId] = useState(initialPostamatId);
@ -92,6 +92,7 @@ export const OnApprovalPointPopup = ({ feature }) => {
<>
<FeatureProperties
feature={feature}
point={point}
dynamicStatus={status}
postamatId={postamatId}
/>

@ -5,50 +5,17 @@ import { FeatureProperties } from "./FeatureProperties";
import { Button } from "antd";
import { useCanEdit } from "../../../api";
import { usePendingPointsFilters } from "../../../stores/usePendingPointsFilters";
import { doesMatchFilter } from "../../../utils.js";
export const PendingPointPopup = ({ feature }) => {
export const PendingPointPopup = ({ feature, point }) => {
const { include, selection, exclude } = usePointSelection();
const { setClickedPointConfig } = useClickedPointConfig();
const { filters } = usePendingPointsFilters();
const doesMatchFilter = () => {
const { prediction, categories, region } = filters;
const {
prediction_current,
category,
area,
district,
area_id,
district_id,
} = feature.properties;
const doesMatchPredictionFilter =
prediction_current >= prediction[0] &&
prediction_current <= prediction[1];
const doesMatchCategoriesFilter =
categories.length > 0 ? categories.includes(category) : true;
const doesMatchRegionFilter = () => {
if (!region) return true;
if (region.type === "ao") {
return (district ?? district_id) === region.id;
} else {
return (area ?? area_id) === region.id;
}
};
return (
doesMatchPredictionFilter &&
doesMatchCategoriesFilter &&
doesMatchRegionFilter()
);
};
const { filters, ranges } = usePendingPointsFilters();
const featureId = feature.properties.id;
const isSelected =
(doesMatchFilter() && !selection.excluded.has(featureId)) ||
(doesMatchFilter(filters, ranges, feature) && !selection.excluded.has(featureId)) ||
selection.included.has(featureId);
useEffect(
@ -68,7 +35,7 @@ export const PendingPointPopup = ({ feature }) => {
return (
<>
<FeatureProperties feature={feature} />
<FeatureProperties feature={feature} point={point} />
{canEdit && (
<Button
type="primary"

@ -2,11 +2,11 @@ import { useClickedPointConfig } from "../../../stores/useClickedPointConfig";
import { useEffect } from "react";
import { FeatureProperties } from "./FeatureProperties";
export const WorkingPointPopup = ({ feature }) => {
export const WorkingPointPopup = ({ feature, point }) => {
const featureId = feature.properties.id;
const { setClickedPointConfig } = useClickedPointConfig();
useEffect(() => setClickedPointConfig(featureId), [feature]);
return <FeatureProperties feature={feature} />;
return <FeatureProperties feature={feature} point={point} />;
};

@ -41,8 +41,7 @@ export const commonPopupConfig = [
},
];
export const residentialPopupConfig = [
...commonPopupConfig,
export const residentialPopupFields = [
{
name: "Кол-во квартир",
field: "flat_cnt",
@ -59,22 +58,24 @@ export const residentialPopupConfig = [
name: "Материал стен",
field: "mat_nes",
},
]
export const residentialPopupConfig = [
...commonPopupConfig,
...residentialPopupFields
];
export const workingPointFields = [
{ name: "План", field: "plan_current" },
{ name: "Факт", field: "fact" },
{ name: "Расхождение с прогнозом", field: "delta_current" },
{ name: "Зрелость", field: "age_day" },
{ name: "id постамата", field: "postamat_id", empty: "Не указан" },
{ name: "id локации", field: "postamat_id", empty: "Не указан" },
];
const RIVALS_MAPPER = {
pvz: "ПВЗ",
post: "Постамат",
};
export const rivalsConfig = [
{ name: "Инфо", field: "info" },
{ name: "Категория", field: "type", render: (value) => RIVALS_MAPPER[value] },
{ name: "Категория", field: "category_id" },
{ name: "Группа", field: "group_id" },
];

@ -0,0 +1,79 @@
import { Button, Col, Divider, Modal, Popover, Row, Tooltip } from "antd";
import { useState } from "react";
import { BiStats } from "react-icons/all.js";
import { twMerge } from "tailwind-merge";
import { PointChart } from "./PointChart.jsx";
const ChartHelp = () => {
return (
<div className="w-[200px]">
График показывает топ-15 факторов, которые оказывают наибольшее влияние на прогнозный трафик в определённой точке.<br/><br/>
Факторы могут оказывать положительное или отрицательное влияние.<br/><br/>
Чем больше влияния оказывает фактор на прогнозный трафик, тем ближе его значение к 100% (-100%).
</div>
)
}
export const TrafficModal = ({point}) => {
const [isOpened, setIsOpened] = useState(false);
const getFooter = () => {
return [
<Button
key="close-button"
type="primary"
onClick={() => setIsOpened(false)}
>
Закрыть
</Button>,
]
}
return (
<div className="flex items-center">
{point.prediction_current}
<Tooltip title="Влияние факторов на прогноз">
<Button className="flex justify-center items-center h-6 ml-1 p-1" type="primary" onClick={() => setIsOpened(true)}>
<BiStats />
</Button>
</Tooltip>
<Modal
open={isOpened}
title="Вклад факторов в прогноз трафика"
onCancel={() => setIsOpened(false)}
width={800}
footer={getFooter()}
style={{ top: "15px" }}
>
<div>
<div className="flex flex-col gap-2">
<Row className={twMerge("p-1")}>
<Col className={"font-semibold"} span={12}>
Адрес точки:
</Col>
<Col span={12}>{point.address}</Col>
</Row>
<Row className={twMerge("p-1")}>
<Col className={"font-semibold"} span={12}>
Прогнозный траффик:
</Col>
<Col span={12}>{point.prediction_current}</Col>
</Row>
</div>
<Divider />
<PointChart point={point} />
<p>* - в окрестности</p>
<Popover
content={<ChartHelp autoFocus={true}/>}
trigger="click"
placement="leftBottom"
color="#ffffff"
>
<Button type="text" className="text-[#1890FF] p-0">Как читать график?</Button>
</Popover>
</div>
</Modal>
</div>
);
}

@ -13,7 +13,7 @@ export function SignOut() {
};
const { data } = useQuery(["profile"], async () => {
const { data } = await api.get("/accounts/profile");
const { data } = await api.get("/accounts/profile/");
return data;
});

@ -2,7 +2,11 @@ import axios from "axios";
import { useMutation, useQuery } from "@tanstack/react-query";
import { STATUSES } from "./config";
import { usePointSelection } from "./stores/usePointSelection";
import { usePendingPointsFilters } from "./stores/usePendingPointsFilters";
import { RANGE_FILTERS_KEYS, usePendingPointsFilters } from "./stores/usePendingPointsFilters";
import { appendFiltersInUse } from "./utils.js";
import { useMode } from "./stores/useMode.js";
import { useMemo } from "react";
import { useUpdateLayerCounter } from "./stores/useUpdateLayerCounter.js";
export const BASE_URL = import.meta.env.VITE_API_URL;
@ -16,6 +20,18 @@ export const api = axios.create({
xsrfCookieName: "csrftoken",
});
export const useDbTableName = () => {
const {isImportMode} = useMode();
if (isImportMode) return "pre_placement_points";
return "placement_points";
}
export const useSourceLayerName = () => {
const {isImportMode} = useMode();
if (isImportMode) return "public.prepoints_with_dist";
return "public.points_with_dist";
}
const enrichParamsWithRegionFilter = (params, region) => {
const resultParams = params ? params : new URLSearchParams();
@ -32,73 +48,164 @@ const enrichParamsWithRegionFilter = (params, region) => {
return resultParams;
};
export const getPoints = async (params, region) => {
export const getPoints = async (params, region, dbTable = "placement_points", signal) => {
const resultParams = enrichParamsWithRegionFilter(params, region);
const { data } = await api.get(
`/api/placement_points?${resultParams.toString()}`
`/api/${dbTable}/?${resultParams.toString()}`, {signal}
);
return data;
};
export const exportPoints = async (params, region) => {
export const exportPoints = async (params, region, dbTable = "placement_points") => {
const resultParams = enrichParamsWithRegionFilter(params, region);
const { data } = await api.get(
`/api/placement_points/to_excel?${resultParams.toString()}`,
`/api/${dbTable}/to_excel/?${resultParams.toString()}`,
{ responseType: "arraybuffer" }
);
return data;
};
export const downloadImportTemplate = async () => {
const { data } = await api.get(
'/api/pre_placement_points/download_template/',
{ responseType: "arraybuffer" }
);
return data;
};
export const uploadPointsFile = async (file, config) => {
const formData = new FormData();
formData.append("file", file);
const { data } = await api.post(
`/api/pre_placement_points/load_matching_file/`,
formData,
config
);
return data;
};
export const importPoints = async (id) => {
const formData = new FormData();
formData.append("id", id);
const { data } = await api.post(
`/api/pre_placement_points/start_matching/`,
formData
);
return data;
};
export const useGetTotalInitialPointsCount = () => {
const dbTable = useDbTableName();
const { updateCounter } = useUpdateLayerCounter();
return useQuery(
["all-initial-count"],
async () => {
["all-initial-count", dbTable, updateCounter],
async ({signal}) => {
const params = new URLSearchParams({
page: 1,
page_size: 1,
"status[]": [STATUSES.pending],
});
params.append("status[]", STATUSES.pending);
return await getPoints(params);
return await getPoints(params, null, dbTable, signal);
},
{ select: (data) => data.count }
{
select: (data) => data.count,
refetchOnWindowFocus: false,
refetchOnMount: false
}
);
};
export const useGetFilteredPendingPointsCount = () => {
const { filters } = usePendingPointsFilters();
const { prediction, categories, region } = filters;
export const useGetFilteredPendingPointsCount = (isMerge) => {
const { filters, ranges } = usePendingPointsFilters();
const { updateCounter } = useUpdateLayerCounter();
const {
categories,
region,
} = filters;
const {
selection: { included },
} = usePointSelection();
const includedIds = [...included];
const getParams = () => {
const params = new URLSearchParams({
page: 1,
page_size: 1,
"categories[]": categories,
"included[]": includedIds,
});
params.append("status[]", STATUSES.pending);
appendFiltersInUse(params, filters, ranges);
return params;
}
const getMergeParams = () => {
return new URLSearchParams({
"matching_status": "New",
});
}
const dbTable = useDbTableName();
return useQuery(
["filtered-points", filters, includedIds],
["filtered-points", filters, dbTable, includedIds, updateCounter],
async ({signal}) => {
const params = isMerge ? getMergeParams() : getParams();
return await getPoints(params, region, dbTable, signal);
},
{
select: (data) => data.count,
keepPreviousData: true,
refetchOnWindowFocus: false,
refetchOnMount: false
}
);
};
export const useGetPointsToMergeCount = () => {
const getMergeParams = () => {
return new URLSearchParams({
"matching_status": "New",
});
}
const dbTable = useDbTableName();
return useQuery(
["filtered-points", dbTable],
async () => {
const params = new URLSearchParams({
page: 1,
page_size: 1,
"prediction_current[]": prediction,
"status[]": [STATUSES.pending],
"categories[]": categories,
"included[]": includedIds,
});
const params = getMergeParams();
return await getPoints(params, region);
return await getPoints(params, null, dbTable);
},
{ select: (data) => data.count, keepPreviousData: true }
);
};
export const useMergePointsToDb = () => {
return useMutation({
mutationFn: () => {
return api.post(
`/api/pre_placement_points/move_points/`
);
},
});
};
export const useGetPermissions = () => {
return useQuery(["permissions"], async () => {
const { data } = await api.get("/api/me");
const { data } = await api.get("/api/me/");
if (data?.groups?.includes("Редактор")) {
return "editor";
@ -108,18 +215,163 @@ export const useGetPermissions = () => {
});
};
const TASK_STATUSES = {
finished: "Перерасчет ML завершен"
}
export const useCanEdit = () => {
const { data } = useGetPermissions();
const { data: statusData } = useLastMLRun();
const hasFinishedUpdate = useMemo(() => {
return statusData?.task_status === TASK_STATUSES.finished
}, [statusData]);
return data === "editor";
return data === "editor" && hasFinishedUpdate;
};
export const useUpdatePostamatId = () => {
return useMutation({
mutationFn: (params) => {
return api.put(
`/api/placement_points/update_postamat_id?${params.toString()}`
`/api/placement_points/update_postamat_id/?${params.toString()}`
);
},
});
};
export const getLastMLRun = async () => {
const { data } = await api.get(
`/api/placement_points/last_time_ml_run/`
);
return data;
};
export const startML = async () => {
const { data } = await api.get(
`/api/placement_points/start/`
);
return data;
};
export const getPostamatesAndPvzGroups = async () => {
const { data } = await api.get(
`/api/postamate_and_pvz_groups/`
);
return data;
};
export const usePostamatesAndPvzGroups = () => {
return useQuery(
['groups'],
async () => {
return await getPostamatesAndPvzGroups();
},
);
};
export const getOtherGroups = async () => {
const { data } = await api.get(
`/api/other_object_groups/`
);
return data;
};
export const useOtherGroups = () => {
return useQuery(
['other_groups'],
async () => {
return await getOtherGroups();
},
);
};
export const useLastMLRun = () => {
return useQuery(
['last_time'],
async () => {
return await getLastMLRun();
},
{
refetchInterval: 5000,
}
);
}
export const useGetPendingPointsRange = (dbTable) => {
const { isImportMode } = useMode();
const statusFilter = isImportMode ? '' : `?status[]=${STATUSES.pending}`;
return useQuery(
["prediction-max-min", dbTable],
async () => {
const { data, isInitialLoading, isFetching } = await api.get(
`/api/${dbTable}/filters/${statusFilter}`
);
return {data, isLoading: isInitialLoading || isFetching};
},
{
select: ({data, isLoading}) => {
const distToGroupsArr = data.dist_to_groups.map((groupRange) => {
return {
[`d${groupRange.group_id}`]: [Math.floor(groupRange.dist[0]), Math.min(Math.ceil(groupRange.dist[1]), 4000)],
}
});
const distToGroups = Object.assign({}, ...distToGroupsArr);
const rangesArr = RANGE_FILTERS_KEYS.map((key) => {
if ((/d[0-9]/.test(key))) return;
return {
[key]: [Math.floor(data[key][0]), Math.min(Math.ceil(data[key][1]), 4000)]
}
}).filter(item => !!item);
const ranges = Object.assign({}, ...rangesArr);
return {
fullRange: {
prediction: data.prediction_current,
...ranges,
...distToGroups
},
isLoading: isLoading
};
},
}
);
};
export const useGetPopupPoints = (features) => {
const pointsIds = features.map(f => f.properties.id);
const dbTable = useDbTableName();
const { data, isInitialLoading, isFetching } = useQuery(
["popup_data", features],
async () => {
const params = new URLSearchParams({
"location_ids[]": pointsIds,
});
const { data } = await api.get(
`/api/${dbTable}/?${params.toString()}`
);
return data.results;
},
{
refetchOnWindowFocus: false,
refetchOnMount: false
}
);
return { data, isLoading: isInitialLoading || isFetching };
};
export const deletePoint = async (id) => {
const formData = new FormData();
formData.append("ids", id);
await api.delete(
`/api/pre_placement_points/delete_points/`,
{ data: formData }
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

@ -6,7 +6,7 @@ import { ApproveIcon } from "../icons/ApproveIcon";
import { WorkingIcon } from "../icons/WorkingIcon";
export const ModeSelector = () => {
const { mode, setMode } = useMode();
const { mode, setMode, isImportMode } = useMode();
const handleClick = (selectedMode) => {
setMode(selectedMode);
@ -47,6 +47,7 @@ export const ModeSelector = () => {
onClick={() => handleClick(MODES.WORKING)}
className="flex items-center justify-center"
size="large"
disabled={isImportMode}
/>
</Tooltip>
</>

@ -27,7 +27,7 @@ export const useGetRegions = () => {
return useQuery(
["regions"],
async () => {
const { data } = await api.get("/api/ao_rayons");
const { data } = await api.get("/api/ao_rayons/");
return data;
},
{
@ -37,6 +37,8 @@ export const useGetRegions = () => {
normalized: normalizeRegions(rawRegions),
};
},
refetchOnWindowFocus: false,
refetchOnMount: false
}
);
};

@ -3,11 +3,11 @@ import { twMerge } from "tailwind-merge";
const { Text } = Typography;
export const Title = ({ text, className, classNameText }) => {
export const Title = ({ text, className, classNameText, type = "secondary" }) => {
return (
<div className={twMerge("mb-1", className)}>
<Text
type="secondary"
type={type}
className={twMerge("uppercase text-xs", classNameText)}
>
{text}

@ -0,0 +1,25 @@
import { useState, useEffect } from "react";
const useLocalStorage = (key, defaultValue) => {
const [value, setValue] = useState(() => {
let currentValue;
try {
currentValue = JSON.parse(
localStorage.getItem(key) || String(defaultValue)
);
} catch (error) {
currentValue = defaultValue;
}
return currentValue;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [value, key]);
return [value, setValue];
};
export default useLocalStorage;

@ -1,14 +1,15 @@
import { useMutation } from "@tanstack/react-query";
import { api } from "../api";
import { api, useDbTableName } from "../api";
import { useUpdateLayerCounter } from "../stores/useUpdateLayerCounter";
export const useUpdateStatus = ({ onSuccess }) => {
const { toggleUpdateCounter } = useUpdateLayerCounter();
const dbTable = useDbTableName();
return useMutation({
mutationFn: (params) => {
return api.put(
`/api/placement_points/update_status?${params.toString()}`
`/api/${dbTable}/update_status/?${params.toString()}`
);
},
onSuccess: () => {

@ -1,23 +1,42 @@
export const Logo = ({ width = 40, height = 40, fill }) => {
import logo from "../assets/logopng.png";
export const Logo = ({ width = 40, height = 40 }) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width={width}
height={height}
>
<g>
<polygon
style={{ fill: fill ?? "#3B555E" }}
points="19.6,24 21.4,24 22.5,23.9 23,23.8 23.5,23.4 23.8,22.9 24,22.3 24,20.4 24,1.9 23.9,1.4 23.6,0.8
23.3,0.4 22.9,0.1 22.3,0 14.2,0 8.9,0 11.2,4.6 19.5,4.5 19.6,4.5 "
/>
<polygon
style={{ fill: fill ?? "#E63941" }}
points="13.6,6.4 17.7,6.4 16.2,9.4 14.1,14.2 12.3,18.4 11.7,18.4 9.8,14.5 6.8,8.8 4.5,4.5 4.7,23.9 1.7,24
1,23.8 0.5,23.2 0.2,22.7 0,22.1 0,1.8 0.1,1.3 0.4,0.8 0.8,0.4 1.1,0.2 1.4,0.1 2,0.1 7,0.1 11.9,10.6 "
/>
<img width={width} height={height} src={logo} alt={"logo"}/>
);
};
export const HeaderLogo = () => {
return (
<svg width="102" height="16" viewBox="0 0 102 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0_857_5233)">
<g clipPath="url(#clip1_857_5233)">
<path d="M27.8387 15.9746L41.7002 15.9746V2.11306L27.8387 2.11306V15.9746Z" fill="#E40050"/>
<path d="M13.922 2.11328V15.9748H0.0604095L13.922 2.11328Z" fill="#E40050"/>
<path d="M41.6451 2.11328V15.9748H27.7835L41.6451 2.11328Z" fill="#CE1344"/>
<path d="M0.0604095 2.11328V15.9748H13.922L0.0604095 2.11328Z" fill="#CE1344"/>
<path d="M20.8526 15.9745C24.6802 15.9745 27.7832 12.8716 27.7832 9.04389C27.7832 5.21622 24.6802 2.11328 20.8526 2.11328C17.0249 2.11328 13.922 5.21622 13.922 9.04389C13.922 12.8716 17.0249 15.9745 20.8526 15.9745Z" fill="#CE1344"/>
<path d="M51.9638 8.56478C51.7816 8.56478 51.6242 8.50208 51.4916 8.37604C51.3656 8.2438 51.3029 8.08641 51.3029 7.90388V4.52363L49.6506 6.61049C49.5246 6.77408 49.3515 6.85604 49.1311 6.85604C48.9107 6.85604 48.7376 6.77441 48.6119 6.61049L46.9597 4.52363V7.90388C46.9597 8.08641 46.8967 8.2438 46.7709 8.37604C46.6387 8.50208 46.4813 8.56478 46.2988 8.56478C46.1163 8.56478 45.9589 8.50208 45.8269 8.37604C45.7009 8.2438 45.6379 8.08641 45.6379 7.90388V2.61637C45.6379 2.30159 45.7858 2.09392 46.0816 1.99302C46.3775 1.88624 46.623 1.95873 46.818 2.21016L49.1311 5.13718L51.4442 2.21016C51.6392 1.96461 51.8847 1.89245 52.1812 1.99302C52.4771 2.09392 52.625 2.30159 52.625 2.61637V7.90388C52.625 8.08641 52.5616 8.2438 52.4356 8.37604C52.3037 8.50208 52.1463 8.56478 51.9634 8.56478H51.9638Z" fill="#192732"/>
<path d="M57.3456 8.56406C56.433 8.56406 55.6555 8.24014 55.0132 7.59165C54.3651 6.94969 54.0408 6.17222 54.0408 5.25957C54.0408 4.34692 54.3651 3.56945 55.0132 2.92749C55.6555 2.27932 56.433 1.95508 57.3456 1.95508C58.2583 1.95508 59.0358 2.27932 59.6777 2.92749C60.3262 3.56977 60.6505 4.34692 60.6505 5.25957C60.6505 6.17222 60.3262 6.94969 59.6777 7.59165C59.0358 8.24014 58.2583 8.56406 57.3456 8.56406ZM57.3456 3.27655C56.798 3.27655 56.3321 3.47181 55.9484 3.86202C55.5579 4.24602 55.3629 4.71198 55.3629 5.25924C55.3629 5.80651 55.5579 6.27279 55.9484 6.65647C56.3324 7.047 56.798 7.24194 57.3456 7.24194C57.8932 7.24194 58.3592 7.047 58.7429 6.65647C59.1331 6.27279 59.3283 5.80683 59.3283 5.25924C59.3283 4.71165 59.1331 4.24569 58.7429 3.86202C58.3589 3.47181 57.8932 3.27655 57.3456 3.27655Z" fill="#192732"/>
<path d="M67.9207 7.90314C67.9207 8.08567 67.8576 8.24012 67.7319 8.36584C67.5997 8.49808 67.4423 8.56404 67.2598 8.56404C67.0772 8.56404 66.9198 8.49808 66.7876 8.36584C66.6616 8.24012 66.5985 8.086 66.5985 7.90314V4.40012L63.2281 8.32796C63.0958 8.48535 62.929 8.56404 62.7275 8.56404C62.6459 8.56404 62.5701 8.5513 62.5009 8.52616C62.2116 8.41939 62.0669 8.21139 62.0669 7.90314V2.61563C62.0669 2.4331 62.1296 2.27898 62.2557 2.15294C62.3879 2.02069 62.5453 1.95473 62.7278 1.95473C62.9104 1.95473 63.0678 2.02069 63.2 2.15294C63.326 2.27898 63.3891 2.4331 63.3891 2.61563V6.11832L66.7595 2.18102C66.9545 1.95441 67.1971 1.89138 67.4864 1.99196C67.776 2.09906 67.9207 2.30673 67.9207 2.6153V7.90314ZM65.7492 0.0664062C65.9318 0.0664062 66.0888 0.129426 66.2214 0.255141C66.3471 0.387386 66.4101 0.544773 66.4101 0.727304C66.4101 0.909834 66.3471 1.06722 66.2214 1.19947C66.0892 1.32551 65.9318 1.3882 65.7492 1.3882H64.2384C64.0555 1.3882 63.8985 1.32551 63.7662 1.19947C63.6402 1.06722 63.5771 0.909834 63.5771 0.727304C63.5771 0.544773 63.6402 0.387386 63.7662 0.255141C63.8985 0.129426 64.0555 0.0664062 64.2384 0.0664062H65.7492Z" fill="#192732"/>
<path d="M45.6379 10.0261C45.6379 9.8436 45.7006 9.68948 45.8269 9.56344C45.9592 9.43119 46.1162 9.36523 46.2988 9.36523H50.8307C51.0136 9.36523 51.1703 9.43119 51.3029 9.56344C51.4289 9.68948 51.4916 9.8436 51.4916 10.0261V15.3136C51.4916 15.4962 51.4289 15.6506 51.3029 15.7763C51.1706 15.9086 51.0136 15.9745 50.8307 15.9745C50.6478 15.9745 50.4911 15.9086 50.3585 15.7763C50.2325 15.6506 50.1698 15.4965 50.1698 15.3136V10.687H46.9593V15.3136C46.9593 15.4962 46.8963 15.6506 46.7706 15.7763C46.6384 15.9086 46.481 15.9745 46.2985 15.9745C46.1159 15.9745 45.9585 15.9086 45.8266 15.7763C45.7006 15.6506 45.6376 15.4965 45.6376 15.3136L45.6379 10.0261Z" fill="#192732"/>
<path d="M56.2129 15.9742C55.3003 15.9742 54.5228 15.6503 53.8805 15.0018C53.2323 14.3598 52.9081 13.5824 52.9081 12.6697C52.9081 11.7571 53.2323 10.9796 53.8805 10.3376C54.5228 9.68948 55.3003 9.36523 56.2129 9.36523C57.1256 9.36523 57.903 9.68948 58.545 10.3376C59.1935 10.9799 59.5177 11.7571 59.5177 12.6697C59.5177 13.5824 59.1935 14.3598 58.545 15.0018C57.903 15.6503 57.1256 15.9742 56.2129 15.9742ZM56.2129 10.6867C55.6653 10.6867 55.1993 10.882 54.8157 11.2722C54.4251 11.6562 54.2302 12.1221 54.2302 12.6694C54.2302 13.2167 54.4251 13.6829 54.8157 14.0666C55.1997 14.4572 55.6653 14.6521 56.2129 14.6521C56.7605 14.6521 57.2265 14.4572 57.6101 14.0666C58.0003 13.6829 58.1956 13.217 58.1956 12.6694C58.1956 12.1218 58.0003 11.6558 57.6101 11.2722C57.2261 10.882 56.7605 10.6867 56.2129 10.6867Z" fill="#192732"/>
<path d="M60.5561 12.6697C60.5561 11.7571 60.8803 10.9796 61.5285 10.3376C62.1708 9.68948 62.9482 9.36523 63.8609 9.36523C64.9309 9.36523 65.7962 9.78711 66.4575 10.6302C66.5708 10.7752 66.6181 10.9355 66.5992 11.1118C66.5737 11.2944 66.4888 11.4423 66.3442 11.5556C66.1995 11.6689 66.0389 11.713 65.8625 11.6878C65.68 11.6689 65.5321 11.5873 65.4188 11.4423C65.022 10.9388 64.5029 10.687 63.8609 10.687C63.3133 10.687 62.8474 10.8823 62.4637 11.2725C62.0731 11.6565 61.8782 12.1225 61.8782 12.6697C61.8782 13.217 62.0731 13.6833 62.4637 14.0669C62.8477 14.4575 63.3133 14.6524 63.8609 14.6524C64.5029 14.6524 65.0224 14.4007 65.4188 13.8972C65.5321 13.7525 65.68 13.6705 65.8625 13.6516C66.0389 13.6265 66.1995 13.6702 66.3442 13.7838C66.4888 13.8975 66.5737 14.0451 66.5992 14.2276C66.6178 14.4039 66.5708 14.5646 66.4575 14.7092C65.7962 15.5527 64.9309 15.9742 63.8609 15.9742C62.9482 15.9742 62.1708 15.6503 61.5285 15.0018C60.8803 14.3598 60.5561 13.5824 60.5561 12.6697Z" fill="#192732"/>
<path d="M68.1378 10.687C67.9549 10.687 67.7979 10.6243 67.6656 10.498C67.5396 10.3657 67.4766 10.2087 67.4766 10.0261C67.4766 9.8436 67.5396 9.68621 67.6656 9.55397C67.7979 9.42826 67.9549 9.36523 68.1378 9.36523H72.67C72.8526 9.36523 73.0096 9.42826 73.1419 9.55397C73.2679 9.68621 73.3309 9.8436 73.3309 10.0261C73.3309 10.2087 73.2679 10.3661 73.1419 10.498C73.0096 10.624 72.8526 10.687 72.67 10.687H71.0648V15.3136C71.0648 15.4962 71.0018 15.6506 70.8761 15.7763C70.7438 15.9086 70.5865 15.9745 70.4039 15.9745C70.2214 15.9745 70.0669 15.9086 69.9412 15.7763C69.809 15.6506 69.743 15.4965 69.743 15.3136V10.687H68.1381H68.1378Z" fill="#192732"/>
<path d="M79.6382 15.0105C79.7198 15.1741 79.7326 15.3443 79.6761 15.5203C79.6193 15.6904 79.5092 15.8161 79.3453 15.898C79.2509 15.9487 79.1533 15.9735 79.0527 15.9735C78.7755 15.9735 78.5773 15.8507 78.4578 15.6055L77.9762 14.6517H74.832L74.3504 15.6055C74.2684 15.7691 74.1427 15.8791 73.9729 15.9359C73.7966 15.9927 73.6265 15.98 73.4629 15.898C73.2993 15.8164 73.1889 15.6904 73.1321 15.5203C73.0756 15.3439 73.088 15.1741 73.17 15.0105L75.8139 9.72303C75.9272 9.50262 76.1254 9.39258 76.4088 9.39258C76.6857 9.39258 76.8807 9.50262 76.994 9.72303L79.6379 15.0105H79.6382ZM77.3153 13.3299L76.4091 11.4984L75.4932 13.3299H77.3153Z" fill="#192732"/>
<path d="M87.314 15.973C87.1314 15.973 86.9744 15.9103 86.8418 15.7842C86.7158 15.652 86.6531 15.4946 86.6531 15.3121V11.9318L85.0005 14.0187C84.8744 14.1823 84.7014 14.2642 84.4813 14.2642C84.2612 14.2642 84.0878 14.1826 83.9621 14.0187L82.3099 11.9318V15.3121C82.3099 15.4946 82.2469 15.652 82.1208 15.7842C81.9886 15.9103 81.8312 15.973 81.6487 15.973C81.4661 15.973 81.3091 15.9103 81.1765 15.7842C81.0504 15.652 80.9878 15.4946 80.9878 15.3121V10.0246C80.9878 9.7098 81.1353 9.50212 81.4315 9.40122C81.7273 9.29445 81.9729 9.36694 82.1678 9.61837L84.481 12.5454L86.7941 9.61837C86.9891 9.37282 87.2346 9.30065 87.5308 9.40122C87.8266 9.50212 87.9745 9.7098 87.9745 10.0246V15.3121C87.9745 15.4946 87.9115 15.652 87.7855 15.7842C87.6532 15.9103 87.4958 15.973 87.3133 15.973H87.314Z" fill="#192732"/>
<path d="M95.793 15.0105C95.8746 15.1741 95.8874 15.3443 95.8309 15.5203C95.774 15.6904 95.664 15.8161 95.5001 15.898C95.4057 15.9487 95.3081 15.9735 95.2075 15.9735C94.9303 15.9735 94.7321 15.8507 94.6126 15.6055L94.1309 14.6517H90.9868L90.5051 15.6055C90.4232 15.7691 90.2975 15.8791 90.1277 15.9359C89.9514 15.9927 89.7812 15.98 89.6176 15.898C89.4541 15.8164 89.3437 15.6904 89.2869 15.5203C89.2304 15.3439 89.2428 15.1741 89.3247 15.0105L91.9687 9.72303C92.082 9.50262 92.2802 9.39258 92.5636 9.39258C92.8405 9.39258 93.0354 9.50262 93.1487 9.72303L95.7927 15.0105H95.793ZM93.4701 13.3299L92.5639 11.4984L91.648 13.3299H93.4701Z" fill="#192732"/>
<path d="M96.2932 10.687C96.1104 10.687 95.9533 10.6243 95.8211 10.498C95.695 10.3657 95.632 10.2087 95.632 10.0261C95.632 9.8436 95.695 9.68621 95.8211 9.55397C95.9533 9.42826 96.1104 9.36523 96.2932 9.36523H100.825C101.008 9.36523 101.165 9.42826 101.297 9.55397C101.423 9.68621 101.486 9.8436 101.486 10.0261C101.486 10.2087 101.423 10.3661 101.297 10.498C101.165 10.624 101.008 10.687 100.825 10.687H99.2202V15.3136C99.2202 15.4962 99.1572 15.6506 99.0315 15.7763C98.8993 15.9086 98.7419 15.9745 98.5594 15.9745C98.3768 15.9745 98.2224 15.9086 98.0967 15.7763C97.9644 15.6506 97.8984 15.4965 97.8984 15.3136V10.687H96.2932Z" fill="#192732"/>
</g>
</g>
<defs>
<clipPath id="clip0_857_5233">
<rect width="102" height="16" fill="white"/>
</clipPath>
<clipPath id="clip1_857_5233">
<rect width="101.6" height="16" fill="white"/>
</clipPath>
</defs>
</svg>
);
};

@ -0,0 +1,12 @@
export const PanoramaIcon = ({ width = 24, height = 24 }) => {
return (
<svg xmlns="http://www.w3.org/2000/svg" className="rounded-md bg-[#cc2222] hover:bg-[#d94c48] p-1" width={width} height={height} viewBox="0 0 24 24">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 6a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm4.21 14.16c3.53-.61 6.79-1.174 6.79-4.146V7a3 3 0 0 0-2.999-3H15.5l.008.143-.01-.001a3.487 3.487 0 0 1-.914 2.22l.549.182A2 2 0 0 1 16.5 8.441v5.492l.423.073c2.13.37 4.077.708 4.077 2.008 0 1.327-1.993 1.655-4.328 2.04l-.853.143a1 1 0 1 0 .341 1.971l.05-.009zM1 16V7a3 3 0 0 1 3-3h4.5l-.008.143.01-.001c.012.286.058.564.134.828-.66.318-1.136.99-1.136 1.805v7.157l-.488.084C4.907 14.376 3 14.702 3 16c0 1.355 2.146 1.701 4.533 2.087l.775.126.13-1.524a.217.217 0 0 1 .027-.084.196.196 0 0 1 .275-.077l4.66 3.019a.214.214 0 0 1-.022.375L8.28 21.983a.194.194 0 0 1-.099.016.208.208 0 0 1-.18-.23l.134-1.554C4.439 19.635 1 19 1 16zm9.316-9.561A1 1 0 0 0 9 7.387v2.406a.5.5 0 0 0 .146.353l.708.708a.5.5 0 0 1 .146.353v3.984a.5.5 0 0 0 .276.447l.924.462.665-2.992c.024-.11.186-.101.197.012l.36 3.59 1.144.573a.3.3 0 0 0 .434-.268v-4.206a.5.5 0 0 1 .276-.447l.448-.224a.5.5 0 0 0 .276-.447v-2.61a1.5 1.5 0 0 0-1.026-1.423l-3.658-1.22z"
fill="#ffffff"
/>
</svg>
);
};

@ -1,3 +1,3 @@
import logo from "../assets/logo.svg";
import logo from "../assets/logopng.png";
export const icons = [{ name: "logo", url: logo }];

@ -26,9 +26,9 @@
@apply bg-white-background rounded-xl max-h-[calc(100vh-100px)] overflow-y-auto;
}
.ant-modal-header {
border-bottom: none;
}
/*.ant-modal-header {*/
/* border-bottom: none;*/
/*}*/
.ant-select-multiple .ant-select-selection-item {
@apply !bg-rose;
@ -95,6 +95,17 @@
width: 100% !important;
}
.legend_group .ant-collapse-header {
padding: 0 !important;
}
.filter_group .ant-collapse-header {
padding: 0 !important;
}
.filter_group .ant-collapse-arrow {
right: 0 !important;
}
::-webkit-scrollbar {
width: 12px;
height: 12px;
@ -112,3 +123,19 @@
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
}
.import_status_new {
background: rgba(82, 196, 26, 0.25);
color: rgb(82, 196, 26);
border-color: rgb(82, 196, 26);
}
.import_status_error {
background: rgba(245, 34, 45, 0.25);
color: rgb(245, 34, 45);
border-color: rgb(245, 34, 45);
}
.import_status_matched {
background: rgba(47, 84, 235, 0.25);
color: rgb(47, 84, 235);
border-color: rgb(47, 84, 235);
}

@ -0,0 +1,52 @@
import { useLayoutEffect, useRef, useState } from "react";
import { downloadImportTemplate, uploadPointsFile } from "../../api.js";
import { Button, Upload } from "antd";
import { UploadOutlined } from "@ant-design/icons";
import { download } from "../../utils.js";
export const LoadingStage = ({setFileId}) => {
const ref = useRef(null);
const [isClicked, setIsClicked] = useState(true);
const onFileChange = async (options) => {
const { onSuccess, onError, file, onProgress } = options;
const config = {
onUploadProgress: event => {
const percent = Math.floor((event.loaded / event.total) * 100);
onProgress({ percent });
}
};
try {
const {id} = await uploadPointsFile(file, config);
onSuccess("Ok");
setFileId(id);
} catch (e) {
//
}
};
const onTemplateDownload = async () => {
const data = await downloadImportTemplate();
await download('template.xlsx', data);
}
useLayoutEffect(() => {
if (ref && ref.current && !isClicked) {
ref.current.click();
setIsClicked(true);
}
}, [isClicked]);
return (
<>
<Upload
name='file'
accept='application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml'
customRequest={onFileChange}
>
<Button icon={<UploadOutlined />}>Выбрать файл .xlsx</Button>
</Upload>
<Button className="p-0 text-xs text-grey underline" type="text" onClick={onTemplateDownload}>Скачать шаблон</Button>
</>
);
}

@ -0,0 +1,97 @@
import { Button, Modal, Spin } from "antd";
import { useState } from "react";
import { useGetPointsToMergeCount, useMergePointsToDb } from "../../api.js";
import { CheckCircleOutlined, InfoCircleOutlined, LoadingOutlined } from "@ant-design/icons";
import { useMode } from "../../stores/useMode.js";
export const MergePointsModal = ({isOpened, onClose}) => {
const {setImportMode} = useMode();
const [isLoading, setIsLoading] = useState(false);
const { data: filteredCount, isInitialLoading: isFilteredLoading } =
useGetPointsToMergeCount();
const [isSuccess, setIsSuccess] = useState(false);
const {mutateAsync: mergePoints} = useMergePointsToDb();
const onConfirm = async () => {
setIsLoading(true);
try {
await mergePoints();
setIsSuccess(true);
} catch (e) {
//
} finally {
setIsLoading(false);
}
}
const getFooter = () => {
if (isSuccess) return [
<Button
key="ok-button"
type="primary"
onClick={() => {
onClose();
setImportMode(false);
}}
disabled={isLoading}
>
Хорошо
</Button>,
];
return [
<Button
key="close-button"
type="default"
onClick={onClose}
>
Назад
</Button>,
<Button
key="ok-button"
type="primary"
onClick={() => onConfirm()}
disabled={isLoading}
>
Подтвердить
</Button>,
]
}
const getContent = () => {
if (isFilteredLoading) return <Spin />;
if (isLoading) return (
<div className="flex flex-col justify-center gap-2 items-center">
<Spin indicator={<LoadingOutlined style={{ fontSize: 32}} spin />} />
Добавляем точки...
</div>
);
if (isSuccess) return (
<div className="flex items-center justify-center font-bold gap-2">
<CheckCircleOutlined style={{ fontSize: 24, color: "#52C41A" }} />
Добавлено {filteredCount} новых точек
</div>
);
return (
<div className="flex flex-row gap-4">
<InfoCircleOutlined style={{ fontSize: 24, color: "#FFC53D" }} />
<div className="flex flex-col gap-2">
<p className="font-bold mb-0">Подтвердите добавление</p>
<p>В базу данных будет добавлено {filteredCount} новых точек.</p>
</div>
</div>
);
}
return (
<Modal
open={isOpened}
title="Добавить в базу"
onCancel={onClose}
width={400}
footer={getFooter()}
>
{getContent()}
</Modal>
);
}

@ -0,0 +1,114 @@
import { Button, Modal, Spin } from "antd";
import { useState } from "react";
import { importPoints } from "../../api.js";
import { LoadingStage } from "./LoadingStage.jsx";
import { ReportStage } from "./ReportStage.jsx";
import {
CheckCircleOutlined,
CloseCircleOutlined,
LoadingOutlined
} from "@ant-design/icons";
import { useUpdateLayerCounter } from "../../stores/useUpdateLayerCounter.js";
export const PointsFileUploadModal = ({onClose, isOpened}) => {
const [fileId, setFileId] = useState();
const [report, setReport] = useState();
const [isImporting, setIsImporting] = useState(false);
const [isReportStage, setIsReportStage] = useState(false);
const [isError, setIsError] = useState(false);
const { toggleUpdateCounter } = useUpdateLayerCounter();
const onImportPoints = async () => {
setIsImporting(true);
try {
const { message } = await importPoints(fileId);
setReport(message);
toggleUpdateCounter();
} catch (e) {
setIsError(true);
} finally {
setIsImporting(false);
}
}
const getFooter = () => {
if (isError) return [
<Button
key="error-button"
type="primary"
onClick={onClose}
>
Закрыть
</Button>
]
if (isReportStage) return [
<Button
key="finish-button"
type="primary"
onClick={onClose}
>
Перейти к выбору
</Button>
]
if (report) return [
<Button
key="report-button"
type="primary"
onClick={() => setIsReportStage(true)}
>
Просмотреть отчет
</Button>
]
return [
<Button
key="close-button"
type="default"
onClick={onClose}
>
Отмена
</Button>,
<Button
key="ok-button"
type="primary"
onClick={() => onImportPoints()}
disabled={!fileId || isImporting}
>
Импортировать
</Button>,
]
}
const getContent = () => {
if (isError) return (
<div className="flex items-center justify-center font-bold gap-2">
<CloseCircleOutlined style={{ fontSize: 24, color: "#FF4D4F" }} />
При импорте точек произошла ошибка
</div>
)
if (isImporting) return (
<div className="flex flex-col justify-center gap-2 items-center">
<Spin indicator={<LoadingOutlined style={{ fontSize: 64 }} spin />} />
Импортируем точки...
</div>
);
if (isReportStage) return <ReportStage report={report} />;
if (report) return (
<div className="flex items-center justify-center font-bold gap-2">
<CheckCircleOutlined style={{ fontSize: 24, color: "#52C41A" }} />
Точки успешно импортированы
</div>
);
return <LoadingStage setFileId={setFileId} />;
}
return (
<Modal
open={isOpened}
title="Импорт точек"
onCancel={onClose}
width={400}
footer={getFooter()}
>
{getContent()}
</Modal>
);
}

@ -0,0 +1,34 @@
import { Col, Row } from "antd";
import { twMerge } from "tailwind-merge";
export const ReportStage = ({ report }) => {
return (
<>
<Row className={twMerge("p-1")}>
<Col className="text-gray-600" span={12}>
Всего точек:
</Col>
<Col className="font-semibold" span={12}>{report.total}</Col>
</Row>
<Row className={twMerge("p-1")}>
<Col className={"text-gray-600"} span={12}>
Совпадений:
</Col>
<Col className="font-semibold text-[#2f54eb]" span={12}>{report.matched}</Col>
</Row>
<Row className={twMerge("p-1")}>
<Col className={"text-gray-600"} span={12}>
Проблемные:
</Col>
<Col className="font-semibold text-[#f5222d]" span={12}>{report.error}</Col>
</Row>
<Row className={twMerge("p-1")}>
<Col className={"text-gray-600"} span={12}>
Новые:
</Col>
<Col className="font-semibold text-[#52c41a]" span={12}>{report.unmatched}</Col>
</Row>
</>
);
}

@ -0,0 +1,57 @@
import { useMode } from "../../stores/useMode.js";
import { Button } from "antd";
import { ImportOutlined } from "@ant-design/icons";
import { PointsFileUploadModal } from "./PointsFileUploadModal.jsx";
import { useState } from "react";
import { MergePointsModal } from "./MergePointsModal.jsx";
import { MODES } from "../../config.js";
export const ImportModeSidebarButtons = () => {
const { mode, isImportMode, setImportMode } = useMode();
const [uploadModalOpen, setUploadModalOpen] = useState(false);
const [addPointsModalOpen, setAddPointsModalOpen] = useState(false);
const onCancel = () => {
setImportMode(false);
};
const onImport = () => {
setImportMode(true);
setUploadModalOpen(true);
};
if (isImportMode) {
return (
<div className="flex flex-row flex-0 gap-2 border-t-[1px] border-b-[1px]">
<Button type="default" onClick={onCancel}>
Отмена
</Button>
<Button type="primary" className="flex-1" onClick={() => setAddPointsModalOpen(true)}>
Добавить в базу
</Button>
{uploadModalOpen &&
<PointsFileUploadModal
isOpened={uploadModalOpen}
onClose={() => setUploadModalOpen(false)}
/>
}
{addPointsModalOpen &&
<MergePointsModal
isOpened={addPointsModalOpen}
onClose={() => setAddPointsModalOpen(false)}
/>
}
</div>
);
}
return mode === MODES.PENDING && (
<div className="flex flex-col flex-0 border-t-[1px] border-b-[1px]">
<Button type="default" onClick={onImport}>
<ImportOutlined />
Импортировать
</Button>
</div>
);
}

@ -0,0 +1,227 @@
import { CATEGORIES_MAP, RANGE_FILTERS_KEYS, usePendingPointsFilters } from "../../../../stores/usePendingPointsFilters";
import { FilterSlider } from "./Slider.jsx";
import { Button, Collapse } from "antd";
import React, { useMemo } from "react";
import { Title } from "../../../../components/Title.jsx";
import { usePostamatesAndPvzGroups } from "../../../../api.js";
import { fieldHasChanged, getFilteredGroups } from "../../../../utils.js";
import { CloseOutlined } from "@ant-design/icons";
export const AdvancedFilters = ({onClose}) => {
const { filters, ranges, setFilterWithKey } = usePendingPointsFilters();
const { data: postamatesAndPvzGroups } = usePostamatesAndPvzGroups();
const filteredPostamatesGroups = useMemo(() => {
return getFilteredGroups(postamatesAndPvzGroups);
}, [postamatesAndPvzGroups]);
const selectedCnt = useMemo(() => {
let counter = 0;
RANGE_FILTERS_KEYS.map((key) => {
if (fieldHasChanged(filters, ranges, key).result) counter += 1;
});
return counter;
}, [filters, ranges]);
const clearFilters = () => {
RANGE_FILTERS_KEYS.map((key) => {
setFilterWithKey(ranges[key], key);
});
};
return (
<div className="ml-4 bg-white rounded-xl z-20 mt-[5vh] shadow-2xl" style={{maxHeight: '90vh', width: '350px', maxWidth: '450px'}}>
<div className="flex items-center justify-between font-semibold p-4 border-0 border-b border-solid border-gray-300">
Расширенные фильтры
<CloseOutlined onClick={onClose}/>
</div>
<div style={{maxHeight: 'calc(90vh - 150px)'}} className="overflow-y-scroll py-3 px-6">
<Collapse
bordered={false}
expandIconPosition={"end"}
style={{
background: 'none',
}}
className="filter_group my-4"
>
<Collapse.Panel
key={"filter_common"}
header={<Title type={"primary"} text={"Общие"} classNameText="text-black" />}
forceRender
>
<div className="mt-4 mb-12">
<div>
<FilterSlider
filterRange={[filters.doors__gt, filters.doors__lt]}
title={"Кол-во подъездов в жилом доме"}
fullRange={ranges.doors || [0, 0]}
filterKey={"doors"}
/>
</div>
<div>
<FilterSlider
filterRange={[filters.flat_cnt__gt, filters.flat_cnt__lt]}
title={"Кол-во квартир в подъезде жилого дома"}
fullRange={ranges.flat_cnt || [0, 0]}
filterKey={"flat_cnt"}
/>
</div>
</div>
</Collapse.Panel>
</Collapse>
<Collapse
bordered={false}
expandIconPosition={"end"}
style={{
background: 'none',
}}
className="filter_group my-4"
>
<Collapse.Panel
key={"filter_dist"}
header={<Title type={"primary"} text={"Кол-во объектов в окрестности 500м"} classNameText="text-black" />}
forceRender
>
<div className="mt-4 mb-12">
<div>
<FilterSlider
filterRange={[filters.rival_post_cnt__gt, filters.rival_post_cnt__lt]}
title={"Кол-во постаматов других сетей"}
fullRange={ranges.rival_post_cnt || [0, 0]}
filterKey={"rival_post_cnt"}
/>
</div>
<div>
<FilterSlider
filterRange={[filters.rival_pvz_cnt__gt, filters.rival_pvz_cnt__lt]}
title={"Кол-во ПВЗ"}
fullRange={ranges.rival_pvz_cnt || [0, 0]}
filterKey={"rival_pvz_cnt"}
/>
</div>
<div>
<FilterSlider
filterRange={[filters.target_post_cnt__gt, filters.target_post_cnt__lt]}
title={"Кол-во постаматов Мой постамат"}
fullRange={ranges.target_post_cnt || [0, 0]}
filterKey={"target_post_cnt"}
/>
</div>
<div>
<FilterSlider
filterRange={[filters.flats_cnt__gt, filters.flats_cnt__lt]}
title={"Кол-во квартир в окрестности"}
fullRange={ranges.flats_cnt || [0, 0]}
filterKey={"flats_cnt"}
/>
</div>
<div>
<FilterSlider
filterRange={[filters.tc_cnt__gt, filters.tc_cnt__lt]}
title={"Кол-во торговых центров"}
fullRange={ranges.tc_cnt || [0, 0]}
filterKey={"tc_cnt"}
/>
</div>
<div>
<FilterSlider
filterRange={[filters.culture_cnt__gt, filters.culture_cnt__lt]}
title={"Кол-во объектов культуры (театры, музей и тд)"}
fullRange={ranges.culture_cnt || [0, 0]}
filterKey={"culture_cnt"}
/>
</div>
<div>
<FilterSlider
filterRange={[filters.mfc_cnt__gt, filters.mfc_cnt__lt]}
title={"Кол-во МФЦ"}
fullRange={ranges.mfc_cnt || [0, 0]}
filterKey={"mfc_cnt"}
/>
</div>
<div>
<FilterSlider
filterRange={[filters.public_stop_cnt__gt, filters.public_stop_cnt__lt]}
title={"Кол-во остановок ОТ"}
fullRange={ranges.public_stop_cnt || [0, 0]}
filterKey={"public_stop_cnt"}
/>
</div>
<div>
<FilterSlider
filterRange={[filters.supermarket_cnt__gt, filters.supermarket_cnt__lt]}
title={"Кол-во супермаркетов"}
fullRange={ranges.supermarket_cnt || [0, 0]}
filterKey={"supermarket_cnt"}
/>
</div>
<div>
<FilterSlider
filterRange={[filters.target_dist__gt, filters.target_dist__lt]}
title={"Расстояние до постамата Мой постамат"}
fullRange={ranges.target_dist || [0, 0]}
filterKey={"target_dist"}
/>
</div>
<div>
<FilterSlider
filterRange={[filters.metro_dist__gt, filters.metro_dist__lt]}
title={"Расстояние до метро"}
fullRange={ranges.metro_dist || [0, 0]}
filterKey={"metro_dist"}
/>
</div>
</div>
</Collapse.Panel>
</Collapse>
{filteredPostamatesGroups.map((category) => {
return (
<Collapse
key={`filter_${category.id}`}
bordered={false}
expandIconPosition={"end"}
style={{
background: 'none',
}}
className="filter_group my-4"
>
<Collapse.Panel
key={`filter_${category.id}`}
header={<Title type={"primary"} text={CATEGORIES_MAP[category.name]} classNameText="text-black" />}
forceRender
>
<div className="mt-4 mb-12">
{category.groups.map((group) => {
return (
<div key={group.id}>
<FilterSlider
filterRange={[filters[`d${group.id}__gt`], filters[`d${group.id}__lt`]]}
title={group.name}
fullRange={ranges[`d${group.id}`] || [0, 0]}
filterKey={`d${group.id}`}
dynamicKey
/>
</div>
)
})}
</div>
</Collapse.Panel>
</Collapse>
)
})}
</div>
<div className="flex items-center justify-between p-4 border-0 border-t border-solid border-gray-300">
<span>Выбрано: {selectedCnt}</span>
<div className="flex gap-2">
<Button disabled={selectedCnt === 0} onClick={() => clearFilters()} type="secondary">
Сбросить фильтры
</Button>
</div>
</div>
</div>
);
};

@ -0,0 +1,99 @@
import { Button, Dropdown, Popover } from "antd";
import { AdvancedFilters } from "./AdvancedFilters.jsx";
import { RightOutlined } from "@ant-design/icons";
import {useMemo, useState} from "react";
import {
RANGE_FILTERS_KEYS,
RANGE_FILTERS_MAP,
usePendingPointsFilters
} from "../../../../stores/usePendingPointsFilters.js";
import {fieldHasChanged} from "../../../../utils.js";
export const AdvancedFiltersWrapper = () => {
const { filters, ranges } = usePendingPointsFilters();
const selectedCnt = useMemo(() => {
let counter = 0;
RANGE_FILTERS_KEYS.map((key) => {
if (fieldHasChanged(filters, ranges, key).result) counter += 1;
})
return counter;
}, [filters, ranges]);
const getPopoverContent = () => {
const keys = RANGE_FILTERS_KEYS.map((key) => {
if (fieldHasChanged(filters, ranges, key).result) return key;
}).filter(k => !!k);
if (keys.length === 0) {
return <p className="my-0.5 text-white">Не выбрано ни одного фильтра</p>
}
return (
<ul className="mb-0 max-w-[300px] pl-5">
{Object.keys(RANGE_FILTERS_MAP).map((category_object) => {
const obj = RANGE_FILTERS_MAP[category_object];
const selectedArr = [];
keys.map((key) => {
if (obj[key]) selectedArr.push(obj[key]);
});
if (selectedArr.length === 0) return;
return (
<li className="text-white">
<span></span>
{obj.name + ' '}
<span className="text-gray-400">
({selectedArr.join(", ")})
</span>
</li>
);
})}
</ul>
);
}
const [open, setOpen] = useState(false);
const handleOpenChange = (flag) => {
setOpen(flag);
};
const filtersRender = () => {
return (
<AdvancedFilters onClose={() => setOpen(false)}/>
)
};
return (
<Dropdown
trigger="click"
dropdownRender={() => filtersRender()}
onOpenChange={handleOpenChange}
open={open}
forceRender
placement='right'
>
<Button
onClick={(e) => e.stopPropagation()}
className="w-full text-left flex justify-between items-center border-0 p-0 mt-16"
>
<div className="flex gap-2 items-center">
Расширенные фильтры
<Popover
content={getPopoverContent}
trigger="hover"
placement={"rightBottom"}
className="rounded-xl mt-0.5 bg-gray-200 p-1 flex justify-center items-center w-[22px] h-[22px] z-10 !text-black"
color="#000000cc"
zIndex={4000}
>
{selectedCnt}
</Popover>
</div>
<RightOutlined rotate={open ? 180 : 0} className="mt-0.5 mr-1"/>
</Button>
</Dropdown>
);
};

@ -0,0 +1,44 @@
import { SliderComponent as Slider } from "../../../../components/SliderComponent";
import { useEffect } from "react";
import {
INITIAL, usePendingPointsFilters,
} from "../../../../stores/usePendingPointsFilters";
export const FilterSlider = ({ filterRange, disabled, fullRange, title, filterKey, dynamicKey }) => {
const { setFilterWithKey } = usePendingPointsFilters();
const handleAfterChange = (range) => setFilterWithKey(range, filterKey);
useEffect(() => {
if (!fullRange) return;
const min = fullRange[0];
const max = fullRange[1];
const shouldSetFullRange =
filterRange[0] === INITIAL[`${filterKey}__gt`] &&
filterRange[1] === INITIAL[`${filterKey}__lt`];
const shouldSetDynamicKeyRange =
(filterRange[0] === undefined &&
filterRange[1] === undefined) ||
(filterRange[0] === 0 &&
filterRange[1] === 0);
if (shouldSetFullRange || (shouldSetDynamicKeyRange && dynamicKey)) {
setFilterWithKey([min, max], filterKey);
}
}, [fullRange]);
return (
<Slider
title={title}
value={filterRange}
onAfterChange={handleAfterChange}
min={fullRange[0]}
max={fullRange[1]}
range
disabled={disabled}
/>
);
};

@ -1,5 +1,5 @@
import { DISABLED_FILTER_TEXT, STATUSES } from "../../../config";
import { Button, Tooltip } from "antd";
import { DISABLED_FILTER_TEXT } from "../../../config";
import {Button, Spin, Tooltip} from "antd";
import { SelectedLocations } from "./SelectedLocations";
import { TakeToWorkButton } from "./TakeToWorkButton";
import { RegionSelect } from "../../../components/RegionSelect";
@ -10,37 +10,36 @@ import {
useHasManualEdits,
usePointSelection,
} from "../../../stores/usePointSelection";
import { usePendingPointsFilters } from "../../../stores/usePendingPointsFilters";
import {RANGE_FILTERS_KEYS, usePendingPointsFilters} from "../../../stores/usePendingPointsFilters";
import { ClearFiltersButton } from "../../../components/ClearFiltersButton";
import { getDynamicActiveFilters } from "../utils";
import { api, useCanEdit } from "../../../api";
import { useQuery } from "@tanstack/react-query";
const useGetPendingPointsRange = () => {
return useQuery(
["prediction-max-min"],
async () => {
const { data } = await api.get(
`/api/placement_points/filters?status[]=${STATUSES.pending}`
);
return data;
},
{
select: (data) => {
return {
prediction: [data.prediction_current[0], data.prediction_current[1]],
};
},
}
);
};
import { useDbTableName, useCanEdit, useGetPendingPointsRange } from "../../../api";
import { AdvancedFiltersWrapper } from "./AdvancedFilters/AdvancedFiltersWrapper.jsx";
import { predictionHasChanged } from "../../../utils.js";
export const PendingPointsFilters = () => {
const hasManualEdits = useHasManualEdits();
const { reset: resetPointSelection } = usePointSelection();
const { filters, setRegion, clear } = usePendingPointsFilters();
const { data: fullRange, isInitialLoading } = useGetPendingPointsRange();
const { ranges, filters, setRegion, setFilterWithKey, setPrediction, setCategories, setRanges } = usePendingPointsFilters();
const dbTable = useDbTableName();
const { data } = useGetPendingPointsRange(dbTable);
useEffect(() => {
const newRanges = data?.fullRange;
if (!newRanges) return;
RANGE_FILTERS_KEYS.map((key) => {
if (!ranges[key]) {
setFilterWithKey(newRanges[key], key);
return;
}
const gtChanged = ranges[key][0] !== newRanges[key][0];
const ltChanged = ranges[key][1] !== newRanges[key][1];
if (gtChanged || ltChanged) setFilterWithKey(newRanges[key], key);
});
if (predictionHasChanged(newRanges, ranges)) setPrediction(newRanges.prediction);
setRanges({...ranges, ...newRanges});
}, [data]);
const [isSelectionEmpty, setIsSelectionEmpty] = useState(false);
@ -68,11 +67,18 @@ export const PendingPointsFilters = () => {
setHover(false);
};
const activeDynamicFilters = getDynamicActiveFilters(filters, fullRange, [
const activeDynamicFilters = getDynamicActiveFilters(filters, ranges, [
"prediction",
]);
const clearFilters = () => clear(fullRange);
const clearFilters = () => {
RANGE_FILTERS_KEYS.map((key) => {
setFilterWithKey(ranges[key], key);
});
setPrediction(ranges.prediction);
setCategories([]);
setRegion(null);
};
const hasActiveFilters =
filters.region ||
@ -98,11 +104,16 @@ export const PendingPointsFilters = () => {
onChange={setRegion}
/>
<CategoriesSelect disabled={hasManualEdits} />
<PredictionSlider
disabled={hasManualEdits}
fullRange={fullRange}
isLoading={isInitialLoading}
/>
{data?.isLoading ? <Spin /> :
<>
<PredictionSlider
disabled={hasManualEdits}
fullRange={ranges}
isLoading={false}
/>
<AdvancedFiltersWrapper />
</>
}
</div>
{hasActiveFilters && (

@ -9,7 +9,7 @@ import { useEffect } from "react";
export const SelectedLocations = ({ onSelectedChange }) => {
const { data: totalCount, isInitialLoading: isTotalLoading } =
useGetTotalInitialPointsCount();
const { data: filteredCount, isInitialLoading: isFilteredLoading } =
const { data: filteredCount, isInitialLoading: isFilteredLoading, isFetching: isFilteredFetching } =
useGetFilteredPendingPointsCount();
const {
@ -21,7 +21,7 @@ export const SelectedLocations = ({ onSelectedChange }) => {
[filteredCount, excluded]
);
const showSpinner = isTotalLoading || isFilteredLoading;
const showSpinner = isTotalLoading || isFilteredLoading || isFilteredFetching;
return (
<div className={"flex items-center justify-between"}>

@ -7,9 +7,10 @@ import { useUpdateStatus } from "../../../hooks/useUpdateStatus";
import { ArrowRightOutlined } from "@ant-design/icons";
import { Title } from "../../../components/Title";
import { usePendingPointsFilters } from "../../../stores/usePendingPointsFilters";
import { appendFiltersInUse } from "../../../utils.js";
export const TakeToWorkButton = ({ disabled }) => {
const { filters } = usePendingPointsFilters();
const { filters, ranges } = usePendingPointsFilters();
const { prediction, categories, region } = filters;
const { selection } = usePointSelection();
const queryClient = useQueryClient();
@ -34,6 +35,8 @@ export const TakeToWorkButton = ({ disabled }) => {
"excluded[]": [...selection.excluded],
});
appendFiltersInUse(params, filters, ranges);
if (region) {
if (region.type === "ao") {
params.append("district[]", region.id);

@ -5,6 +5,7 @@ import { MODES } from "../../config";
import { PendingPointsFilters } from "./PendingPointsFilters/PendingPointsFilters";
import { OnApprovalPointsFilters } from "./OnApprovalPointsFilters/OnApprovalPointsFilters";
import { WorkingPointsFilters } from "./WorkingPointsFilters/WorkingPointsFilters";
import { ImportModeSidebarButtons } from "../ImportMode/SidebarButtons.jsx";
export const Sidebar = forwardRef(({ isCollapsed }, ref) => {
const { mode } = useMode();
@ -29,6 +30,7 @@ export const Sidebar = forwardRef(({ isCollapsed }, ref) => {
)}
ref={ref}
>
<ImportModeSidebarButtons />
<div className="flex flex-col flex-1">{getFilters()}</div>
</div>
);

@ -7,15 +7,18 @@ import { ClearFiltersButton } from "../../../components/ClearFiltersButton";
import { getDynamicActiveFilters } from "../utils";
import { Spin } from "antd";
import { useQuery } from "@tanstack/react-query";
import { api } from "../../../api.js";
import { api, useDbTableName } from "../../../api.js";
import { STATUSES } from "../../../config.js";
import { useEffect } from "react";
import { workingFilterHasChanged } from "../../../utils.js";
const useGetDataRange = () => {
const dbTable = useDbTableName();
return useQuery(
["working-max-min"],
async () => {
const { data } = await api.get(
`/api/placement_points/filters?status[]=${STATUSES.working}`
`/api/${dbTable}/filters?status[]=${STATUSES.working}`
);
return data;
@ -33,11 +36,20 @@ const useGetDataRange = () => {
};
export const WorkingPointsFilters = () => {
const { filters, setRegion, clear } = useWorkingPointsFilters();
const { filters, ranges, setRegion, setAge, setDeltaTraffic, setRanges, setFactTraffic, clear } = useWorkingPointsFilters();
const { data: fullRange, isInitialLoading: isFullRangeLoading } =
useGetDataRange();
useEffect(() => {
if (!fullRange) return;
const newRanges = fullRange;
if (workingFilterHasChanged(newRanges.deltaTraffic, ranges, "deltaTraffic")) setDeltaTraffic(fullRange.deltaTraffic);
if (workingFilterHasChanged(newRanges.factTraffic, ranges, "factTraffic")) setFactTraffic(fullRange.deltaTraffic);
if (workingFilterHasChanged(newRanges.age, ranges, "age")) setAge(fullRange.deltaTraffic);
setRanges({...newRanges});
}, [fullRange]);
const activeDynamicFilters = getDynamicActiveFilters(filters, fullRange, [
"deltaTraffic",
"factTraffic",

@ -3,6 +3,7 @@ import { Button, Tooltip } from "antd";
import { useTable } from "../../stores/useTable";
import { FullscreenExitOutlined, FullscreenOutlined } from "@ant-design/icons";
import { useEffect, useState } from "react";
import { TableSettings } from "./TableSettings";
const ToggleFullScreenButton = () => {
const {
@ -51,6 +52,7 @@ export const HeaderWrapper = ({
rightColumn,
exportProvider,
classes,
orderColumns
}) => {
return (
<div className={"flex items-center w-full justify-between"}>
@ -61,6 +63,7 @@ export const HeaderWrapper = ({
<div className={classes?.rightColumn}>
{rightColumn}
<div className="flex items-center gap-x-1">
<TableSettings orderColumns={orderColumns} />
{exportProvider && <ExportButton provider={exportProvider} />}
<ToggleFullScreenButton />
</div>

@ -10,6 +10,7 @@ export const Header = ({
selectedIds,
onClearSelected,
onOpenMakeWorkingModal,
orderColumns
}) => {
const [status, setStatus] = useState(STATUSES.pending);
@ -42,6 +43,7 @@ export const Header = ({
leftColumn: "flex items-center gap-x-4",
rightColumn: "flex item-center gap-x-4",
}}
orderColumns={orderColumns}
exportProvider={useExportOnApprovalData}
/>
);

@ -1,6 +1,6 @@
import { Table } from "../Table";
import { useQuery } from "@tanstack/react-query";
import { getPoints, useCanEdit } from "../../../api";
import { getPoints, useCanEdit, useDbTableName } from "../../../api";
import { useCallback, useState } from "react";
import { PAGE_SIZE } from "../constants";
import { STATUSES } from "../../../config";
@ -19,6 +19,8 @@ const extraCols = [
key: "postamat_id",
width: "70px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
];
@ -31,13 +33,20 @@ export const OnApprovalTable = ({ fullWidth }) => {
} = useOnApprovalPointsFilters();
const [isMakeWorkingModalOpened, setIsMakeWorkingModalOpened] =
useState(false);
const columns = useColumns(extraCols);
const { columns, orderColumns, sort, setSort } = useColumns(extraCols, 'onApprovalTableOrder');
const { isVisible } = useLayersVisibility();
const dbTable = useDbTableName();
const onSort = (sortDirection, key) => {
if (sortDirection === `ascend`) setSort(key);
if (sortDirection === `descend`) setSort(`-${key}`);
if (!sortDirection) setSort(null);
}
const clearSelected = () => setSelectedIds([]);
const { data, isInitialLoading } = useQuery(
["on-approval-points", page, region, isVisible],
const { data, isInitialLoading, isFetching } = useQuery(
["on-approval-points", page, region, isVisible, sort],
async () => {
const statuses = [];
@ -60,13 +69,14 @@ export const OnApprovalTable = ({ fullWidth }) => {
statuses.length > 0
? statuses
: [STATUSES.onApproval, STATUSES.working, STATUSES.cancelled],
ordering: sort,
});
if (statuses.length === 0) {
return { count: 0, results: [] };
}
return await getPoints(params, region);
return await getPoints(params, region, dbTable);
},
{ keepPreviousData: true }
);
@ -94,6 +104,7 @@ export const OnApprovalTable = ({ fullWidth }) => {
selectedIds={selectedIds}
onClearSelected={clearSelected}
onOpenMakeWorkingModal={() => setIsMakeWorkingModalOpened(true)}
orderColumns={orderColumns}
/>
}
rowSelection={canEdit ? rowSelection : undefined}
@ -104,7 +115,10 @@ export const OnApprovalTable = ({ fullWidth }) => {
isClickedPointLoading={isClickedPointLoading}
columns={columns}
fullWidth={fullWidth}
loading={isInitialLoading}
onChange={(val, filter, sorter) => {
onSort(sorter.order, sorter.columnKey);
}}
loading={isInitialLoading || isFetching}
/>
{isMakeWorkingModalOpened && (
<MakeWorkingModal

@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { STATUSES } from "../../../config";
import { exportPoints } from "../../../api";
import { exportPoints, useDbTableName } from "../../../api";
import { handleExportSuccess } from "../ExportButton";
import { useOnApprovalPointsFilters } from "../../../stores/useOnApprovalPointsFilters";
@ -8,6 +8,7 @@ export const useExportOnApprovalData = (enabled, onSettled) => {
const {
filters: { region },
} = useOnApprovalPointsFilters();
const dbTable = useDbTableName();
return useQuery(
["export-on-approval", region],
@ -16,7 +17,7 @@ export const useExportOnApprovalData = (enabled, onSettled) => {
"status[]": [STATUSES.onApproval, STATUSES.working],
});
return await exportPoints(params, region);
return await exportPoints(params, region, dbTable);
},
{
enabled,

@ -1,4 +1,4 @@
import React, { useCallback, useState } from "react";
import React, {useCallback, useState} from "react";
import { Table } from "../Table";
import { usePointSelection } from "../../../stores/usePointSelection";
import { useClickedPointConfig } from "../../../stores/useClickedPointConfig";
@ -9,20 +9,31 @@ import { useCanEdit } from "../../../api";
import { useColumns } from "../useColumns.jsx";
import { PAGE_SIZE } from "../constants.js";
import { usePopup } from "../../../stores/usePopup.js";
import { usePendingTableFields } from "./usePendingTableFields.jsx";
const tableKey = 'pendingTable';
export const PendingTable = ({ fullWidth }) => {
const { selection, include, exclude } = usePointSelection();
const { clickedPointConfig, setClickedPointConfig } = useClickedPointConfig();
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(PAGE_SIZE);
const columns = useColumns();
const fields = usePendingTableFields();
const {columns, orderColumns, sort, setSort} = useColumns(fields, tableKey);
const { setPopup } = usePopup();
const onSort = (sortDirection, key) => {
if (sortDirection === `ascend`) setSort(key);
if (sortDirection === `descend`) setSort(`-${key}`);
if (!sortDirection) setSort(null);
}
const { data, isClickedPointLoading, isDataLoading } = usePendingTableData(
page,
() => setPage(1),
pageSize,
setPageSize
setPageSize,
sort
);
const resetPageSize = () => setPageSize(PAGE_SIZE);
@ -79,7 +90,10 @@ export const PendingTable = ({ fullWidth }) => {
isClickedPointLoading={isClickedPointLoading}
columns={columns}
fullWidth={fullWidth}
header={<HeaderWrapper exportProvider={useExportPendingData} />}
onChange={(val, filter, sorter) => {
onSort(sorter.order, sorter.columnKey);
}}
header={<HeaderWrapper exportProvider={useExportPendingData} orderColumns={orderColumns} />}
loading={isDataLoading}
/>
);

@ -1,38 +1,47 @@
import { usePointSelection } from "../../../stores/usePointSelection";
import { useQuery } from "@tanstack/react-query";
import { exportPoints } from "../../../api";
import { exportPoints, useDbTableName } from "../../../api";
import { handleExportSuccess } from "../ExportButton";
import { usePendingPointsFilters } from "../../../stores/usePendingPointsFilters";
import { STATUSES } from "../../../config.js";
import { appendFiltersInUse } from "../../../utils.js";
export const useExportPendingData = (enabled, onSettled) => {
const { filters } = usePendingPointsFilters();
const { prediction, categories, region } = filters;
const { filters, ranges } = usePendingPointsFilters();
const { categories, region } = filters;
const { selection } = usePointSelection();
const includedArr = [...selection.included];
const excludedArr = [...selection.excluded];
const dbTable = useDbTableName();
return useQuery(
["export-initial", filters, selection],
async () => {
const params = new URLSearchParams({
"prediction_current[]": prediction,
"status[]": [STATUSES.pending],
});
if (categories.length) {
params.append("categories[]", categories);
}
const getParams = () => {
const params = new URLSearchParams({
"status[]": [STATUSES.pending],
});
if (includedArr.length) {
params.append("included[]", includedArr);
}
appendFiltersInUse(params, filters, ranges);
params.append("status[]", [STATUSES.pending, STATUSES.cancelled].join(","));
if (categories.length) {
params.append("categories[]", categories);
}
if (includedArr.length) {
params.append("included[]", includedArr);
}
if (excludedArr.length) {
params.append("excluded[]", excludedArr);
}
if (excludedArr.length) {
params.append("excluded[]", excludedArr);
return params;
}
return await exportPoints(params, region);
return await exportPoints(getParams(), region, dbTable);
},
{
enabled,

@ -1,25 +1,41 @@
import { useQuery } from "@tanstack/react-query";
import { getPoints } from "../../../api";
import { useDbTableName, getPoints } from "../../../api";
import { useMergeTableData } from "../useMergeTableData";
import { STATUSES } from "../../../config";
import { usePendingPointsFilters } from "../../../stores/usePendingPointsFilters";
import { appendFiltersInUse } from "../../../utils.js";
import { useUpdateLayerCounter } from "../../../stores/useUpdateLayerCounter.js";
export const usePendingTableData = (page, resetPage, pageSize, setPageSize) => {
const { filters } = usePendingPointsFilters();
const { prediction, categories, region } = filters;
const { data, isInitialLoading } = useQuery(
["table", page, filters],
async () => {
const params = new URLSearchParams({
page,
page_size: pageSize,
"prediction_current[]": prediction,
"status[]": [STATUSES.pending],
"categories[]": categories,
});
return await getPoints(params, region);
export const usePendingTableData = (page, resetPage, pageSize, setPageSize, sort) => {
const { filters, ranges } = usePendingPointsFilters();
const { updateCounter } = useUpdateLayerCounter();
const {
categories,
region,
} = filters;
const dbTable = useDbTableName();
const getParams = () => {
const params = new URLSearchParams({
page,
page_size: pageSize,
"categories[]": categories,
ordering: sort,
});
appendFiltersInUse(params, filters, ranges);
params.append("status[]", [STATUSES.pending, STATUSES.cancelled].join(","))
return params;
}
const {data, isInitialLoading, isFetching} = useQuery(
["table", page, filters, sort, dbTable, updateCounter],
async ({signal}) => {
const params = getParams();
return await getPoints(params, region, dbTable, signal);
},
{
keepPreviousData: true,
@ -28,10 +44,12 @@ export const usePendingTableData = (page, resetPage, pageSize, setPageSize) => {
resetPage();
}
},
refetchOnWindowFocus: false,
refetchOnMount: false
}
);
const { data: mergedData, isClickedPointLoading } = useMergeTableData(
const {data: mergedData, isClickedPointLoading} = useMergeTableData(
data,
setPageSize
);
@ -40,6 +58,6 @@ export const usePendingTableData = (page, resetPage, pageSize, setPageSize) => {
data: mergedData,
pageSize,
isClickedPointLoading,
isDataLoading: isInitialLoading,
isDataLoading: isInitialLoading || isFetching,
};
};

@ -0,0 +1,75 @@
import { useMode } from "../../../stores/useMode.js";
import React, { useMemo } from "react";
import { Button } from "antd";
import { BiTrash } from "react-icons/all.js";
import { deletePoint } from "../../../api.js";
import { useUpdateLayerCounter } from "../../../stores/useUpdateLayerCounter.js";
const MATCHING_STATUS = {
New: {
name: 'Новая',
color: 'import_status_new'
},
Error: {
name: 'Ошибка геокодирования',
color: 'import_status_error'
},
Matched: {
name: 'Совпадение',
color: 'import_status_matched'
},
}
export const usePendingTableFields = () => {
const { isImportMode } = useMode();
const { toggleUpdateCounter } = useUpdateLayerCounter();
const deleteRow = async (e, id) => {
e.stopPropagation();
try {
await deletePoint(id);
toggleUpdateCounter();
} catch (e) {
//
}
};
const fields = useMemo(() => {
return isImportMode ? [
{
title: "Статус импорта",
dataIndex: "matching_status",
key: "matching_status",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
render: (_, record) => {
if (!record.matching_status) return;
const name = MATCHING_STATUS[record.matching_status].name;
const color = MATCHING_STATUS[record.matching_status].color;
return (
<div className={`bg-opacity-25 rounded-md px-2 py-1 text-center border-solid border-[2px] ${color}`}>
{name}
</div>
);
},
},
{
title: "Удалить",
key: "del",
width: "60px",
ellipsis: true,
render: (_, record) => {
if (!record.id) return;
return (
<Button type="text" onClick={(event) => deleteRow(event, record.id)}>
<BiTrash />
</Button>
);
},
}
] : [];
}, [isImportMode]);
return fields;
}

@ -14,6 +14,10 @@
cursor: pointer;
}
.table__wrapper__fullScreen .ant-table-container {
height: calc(100vh - 98px);
}
.table__title {
padding: 0 1rem;
display: flex;

@ -22,6 +22,7 @@ export const Table = React.memo(
header,
fullWidth,
loading,
onChange
}) => {
const { clickedPointConfig, setClickedPointConfig } =
useClickedPointConfig();
@ -56,7 +57,10 @@ export const Table = React.memo(
>
<AntdTable
size="small"
className="table__wrapper"
className={twMerge(
"table__wrapper",
tableState.fullScreen && "table__wrapper__fullScreen"
)}
locale={{ emptyText: <Empty description="Нет данных" /> }}
pagination={{
pageSize,
@ -66,8 +70,10 @@ export const Table = React.memo(
showSizeChanger: false,
position: "bottomCenter",
}}
showHeader={data?.results && data.results.length > 0}
dataSource={data?.results}
columns={columns}
onChange={onChange}
rowKey="id"
scroll={SCROLL}
sticky={true}

@ -0,0 +1,79 @@
import { useEffect, useState } from "react";
import {Button, Checkbox, Dropdown} from "antd";
import {SettingOutlined} from "@ant-design/icons";
import {DragDropContext, Draggable, Droppable} from "react-beautiful-dnd";
export const TableSettings = ({orderColumns}) => {
const [columnsList, setColumnsList] = useState(orderColumns.order);
useEffect(() => {
setColumnsList(orderColumns.order);
}, [orderColumns]);
const handleDrop = (droppedItem) => {
// Ignore drop outside droppable container
if (!droppedItem.destination) return;
var updatedList = [...columnsList];
// Remove dragged item
const [reorderedItem] = updatedList.splice(droppedItem.source.index, 1);
// Add dropped item
updatedList.splice(droppedItem.destination.index, 0, reorderedItem);
// Update State
setColumnsList(updatedList);
orderColumns.setOrder(updatedList);
};
const hideColumn = (columnIndex) => {
const updatedList = columnsList.map((item, index) => {
if (columnIndex === index) return {...item, show: !item.show};
return item;
});
setColumnsList(updatedList);
orderColumns.setOrder(updatedList);
}
const columnsListRender = () => {
return (
<div onClick={(e) => e.stopPropagation()} className='z-10 bg-white-background rounded-xl p-3 space-y-3'
style={{ maxHeight: "80vh", overflowY: "scroll", margin: "24px 0 24px" }}>
<DragDropContext onDragEnd={handleDrop}>
<Droppable droppableId="tableOrder">
{(provided) => (
<div className="flex flex-col" {...provided.droppableProps} ref={provided.innerRef}>
{columnsList.map((item, index) => {
const num = item.position;
if (!orderColumns.defaultColumns[num]) return;
return (
<Draggable key={`list-${num}`} draggableId={`list-${num}`} index={index}>
{(provided) => (
<div className="flex flex-row gap-2 p-1.5 hover:bg-gray-300 rounded-md" ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps}>
<Checkbox onChange={() => hideColumn(index)} checked={item.show} />
<p className="m-0">
{ orderColumns.defaultColumns[num].name || orderColumns.defaultColumns[num].title }
</p>
</div>
)}
</Draggable>
);
})}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
</div>
)
}
return (
<Dropdown
trigger="click"
dropdownRender={() => columnsListRender()}
>
<Button
onClick={(e) => e.stopPropagation()}
>
<SettingOutlined/>
</Button>
</Dropdown>
);
};

@ -10,16 +10,23 @@ import { useExportWorkingData } from "./useExportWorkingData";
import { useWorkingPointsFilters } from "../../../stores/useWorkingPointsFilters";
import { useColumns } from "./useColumns.jsx";
const tableKey = 'workingTable'
export const WorkingTable = ({ fullWidth }) => {
const [pageSize, setPageSize] = useState(PAGE_SIZE);
const [page, setPage] = useState(1);
const {
filters: { region, deltaTraffic, factTraffic, age },
} = useWorkingPointsFilters();
const columns = useColumns();
const {columns, orderColumns, sort, setSort} = useColumns(tableKey);
const { data, isInitialLoading } = useQuery(
["working-points", page, region, deltaTraffic, factTraffic, age],
const onSort = (sortDirection, key) => {
if (sortDirection === `ascend`) setSort(key);
if (sortDirection === `descend`) setSort(`-${key}`);
if (!sortDirection) setSort(null);
}
const { data, isInitialLoading, isFetching } = useQuery(
["working-points", page, region, deltaTraffic, factTraffic, age, sort],
async () => {
const params = new URLSearchParams({
page,
@ -28,6 +35,7 @@ export const WorkingTable = ({ fullWidth }) => {
"delta_current[]": deltaTraffic,
"fact[]": factTraffic,
"age_day[]": age,
ordering: sort
});
return await getPoints(params, region);
@ -51,8 +59,11 @@ export const WorkingTable = ({ fullWidth }) => {
isClickedPointLoading={isClickedPointLoading}
columns={columns}
fullWidth={fullWidth}
header={<HeaderWrapper exportProvider={useExportWorkingData} />}
loading={isInitialLoading}
onChange={(val, filter, sorter) => {
onSort(sorter.order, sorter.columnKey);
}}
header={<HeaderWrapper exportProvider={useExportWorkingData} orderColumns={orderColumns} />}
loading={isInitialLoading || isFetching}
/>
);
};

@ -1,38 +1,52 @@
import { useGetRegions } from "../../../components/RegionSelect.jsx";
import { useMemo } from "react";
import { getRegionNameById } from "../../../Map/Popup/mode-popup/config.js";
import { SearchOutlined } from "@ant-design/icons";
import { Button, Popover } from "antd";
import { AddressSearch } from "../../../Map/AddressSearch.jsx";
import { useTable } from "../../../stores/useTable.js";
import {useGetRegions} from "../../../components/RegionSelect.jsx";
import {useMemo} from "react";
import {getRegionNameById} from "../../../Map/Popup/mode-popup/config.js";
import {SearchOutlined} from "@ant-design/icons";
import {Button, Popover} from "antd";
import {AddressSearch} from "../../../Map/AddressSearch.jsx";
import {useTable} from "../../../stores/useTable.js";
import useLocalStorage from "../../../hooks/useLocalStorage.js";
export const useColumns = () => {
const { data: regions } = useGetRegions();
const DEFAULT_LENGTH = 11;
export const useColumns = (key) => {
const {data: regions} = useGetRegions();
const {
tableState: { fullScreen },
tableState: {fullScreen},
} = useTable();
return useMemo(() => {
const [order, setOrder] = useLocalStorage(`${key}Order`, [...Array(DEFAULT_LENGTH).keys()].map((position) => {
return {
position,
show: true,
}
}));
const [sort, setSort] = useLocalStorage(`${key}Sort`, null);
const defaultColumns = useMemo(() => {
return [
{
title: fullScreen ? (
<div className="flex items-center justify-between">
<span>Адрес</span>
<Popover
content={<AddressSearch autoFocus={true} />}
content={<AddressSearch autoFocus={true}/>}
trigger="click"
placement={"right"}
>
<Button>
<SearchOutlined />
<Button onClick={(e) => e.stopPropagation()}>
<SearchOutlined/>
</Button>
</Popover>
</div>
) : (
"Адрес"
),
name: "Адрес",
dataIndex: "address",
key: "address",
sorter: true,
showSorterTooltip: false,
width: 200,
},
{
@ -41,6 +55,8 @@ export const useColumns = () => {
key: "area",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
render: (_, record) => {
return getRegionNameById(record.area, regions?.normalized);
},
@ -54,6 +70,8 @@ export const useColumns = () => {
render: (_, record) => {
return getRegionNameById(record.district, regions?.normalized);
},
sorter: true,
showSorterTooltip: false,
},
{
title: "Название",
@ -61,6 +79,8 @@ export const useColumns = () => {
key: "name",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
{
title: "Категория",
@ -68,6 +88,8 @@ export const useColumns = () => {
key: "category",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
{
title: "План",
@ -75,6 +97,8 @@ export const useColumns = () => {
key: "plan_current",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
{
title: "Факт",
@ -82,6 +106,8 @@ export const useColumns = () => {
key: "fact",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
{
title: "Расхождение с прогнозом",
@ -89,6 +115,8 @@ export const useColumns = () => {
key: "delta_current",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
{
title: "Зрелость",
@ -96,12 +124,16 @@ export const useColumns = () => {
key: "age_day",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
{
title: "Дата начала работы",
dataIndex: "start_date",
key: "start_date",
width: "120px",
sorter: true,
showSorterTooltip: false,
render: (value) => {
if (!value) return "Нет данных";
@ -115,7 +147,31 @@ export const useColumns = () => {
key: "postamat_id",
width: "70px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
];
}, [regions?.normalized, fullScreen]);
const columns = useMemo(() => {
return order.flatMap((item) => !item.show ? [] : defaultColumns[item.position])
.map((column) => {
if (sort && sort.includes(column.key)) return {
...column,
defaultSortOrder: sort.includes('-') ? 'descend' : 'ascend',
};
return column;
});
}, [defaultColumns, order, fullScreen]);
return {
columns,
orderColumns: {
defaultColumns,
order,
setOrder,
},
sort,
setSort
};
};

@ -1,19 +1,22 @@
import { STATUS_LABEL_MAPPER } from "../../config";
import { useMemo } from "react";
import { useEffect, useMemo } from "react";
import { useGetRegions } from "../../components/RegionSelect.jsx";
import { getRegionNameById } from "../../Map/Popup/mode-popup/config.js";
import { Button, Popover } from "antd";
import { AddressSearch } from "../../Map/AddressSearch.jsx";
import { SearchOutlined } from "@ant-design/icons";
import { useTable } from "../../stores/useTable.js";
export const useColumns = (fields = []) => {
import useLocalStorage from "../../hooks/useLocalStorage.js";
import { TrafficModal } from "../../Map/TrafficModal.jsx";
export const useColumns = (fields = [], key) => {
const { data: regions } = useGetRegions();
const {
tableState: { fullScreen },
} = useTable();
return useMemo(() => {
const [sort, setSort] = useLocalStorage(`${key}Sort`, null);
const defaultColumns = useMemo(() => {
return [
{
title: fullScreen ? (
@ -24,7 +27,7 @@ export const useColumns = (fields = []) => {
trigger="click"
placement={"right"}
>
<Button>
<Button onClick={(e) => e.stopPropagation()} >
<SearchOutlined />
</Button>
</Popover>
@ -32,9 +35,12 @@ export const useColumns = (fields = []) => {
) : (
"Адрес"
),
name: "Адрес",
dataIndex: "address",
key: "address",
width: 200,
sorter: true,
showSorterTooltip: false,
},
{
title: "Район",
@ -42,6 +48,8 @@ export const useColumns = (fields = []) => {
key: "area",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
render: (_, record) => {
return getRegionNameById(record.area, regions?.normalized);
},
@ -52,6 +60,8 @@ export const useColumns = (fields = []) => {
key: "district",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
render: (_, record) => {
return getRegionNameById(record.district, regions?.normalized);
},
@ -62,6 +72,8 @@ export const useColumns = (fields = []) => {
key: "name",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
{
title: "Категория",
@ -69,6 +81,8 @@ export const useColumns = (fields = []) => {
key: "category",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
{
title: "Статус",
@ -76,6 +90,8 @@ export const useColumns = (fields = []) => {
key: "status",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
render: (_, record) => {
return STATUS_LABEL_MAPPER[record.status];
},
@ -86,8 +102,346 @@ export const useColumns = (fields = []) => {
key: "prediction_current",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
render: (_, record) => <TrafficModal point={record} />
},
{
title: "Кол-во подъездов в жилом доме",
dataIndex: "doors",
key: "doors",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
{
title: "Класс энероэффективности жилого дома",
dataIndex: "enrg_cls",
key: "enrg_cls",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
{
title: "Кол-во квартир в подъезде жилого дома",
dataIndex: "flat_cnt",
key: "flat_cnt",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
{
title: "Год постройки жилого дома",
dataIndex: "year_bld",
key: "year_bld",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
{
title: "Кол-во этажей жилого дома",
dataIndex: "levels",
key: "levels",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
{
title: "Материал стен жилого дома",
dataIndex: "mat_nes",
key: "mat_nes",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
{
title: "Кол-во постаматов других сетей в окрестности 500м (далее аналогично)",
dataIndex: "rival_post_cnt",
key: "rival_post_cnt",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
{
title: "Кол-во ПВЗ",
dataIndex: "rival_pvz_cnt",
key: "rival_pvz_cnt",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
{
title: "Кол-во постаматов Мой постамат",
dataIndex: "target_post_cnt",
key: "target_post_cnt",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
{
title: "Кол-во квартир в окрестности",
dataIndex: "flats_cnt",
key: "flats_cnt",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
{
title: "Кол-во достопримечательностей",
dataIndex: "attraction_cnt",
key: "attraction_cnt",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
{
title: "Кол-во банков",
dataIndex: "bank_cnt",
key: "bank_cnt",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
{
title: "Кол-во торговых центров",
dataIndex: "tc_cnt",
key: "tc_cnt",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
{
title: "Кол-во бизнес-центров",
dataIndex: "bc_cnt",
key: "bc_cnt",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
{
title: "Кол-во клиник",
dataIndex: "clinic_cnt",
key: "clinic_cnt",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
{
title: "Кол-во объектов культуры (театры, музей и тд)",
dataIndex: "culture_cnt",
key: "culture_cnt",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
{
title: "Кол-во спортивных центров",
dataIndex: "sport_center_cnt",
key: "sport_center_cnt",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
{
title: "Кол-во лабораторий",
dataIndex: "lab_cnt",
key: "lab_cnt",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
{
title: "Кол-во школ",
dataIndex: "school_cnt",
key: "school_cnt",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
{
title: "Кол-во детских садов",
dataIndex: "kindergar_cnt",
key: "kindergar_cnt",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
{
title: "Кол-во МФЦ",
dataIndex: "mfc_cnt",
key: "mfc_cnt",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
{
title: "Кол-во аптек",
dataIndex: "pharmacy_cnt",
key: "pharmacy_cnt",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
{
title: "Кол-во остановок ОТ",
dataIndex: "public_stop_cnt",
key: "public_stop_cnt",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
{
title: "Кол-во объектов из HORECA",
dataIndex: "reca_cnt",
key: "reca_cnt",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
{
title: "Кол-во супермаркетов",
dataIndex: "supermarket_cnt",
key: "supermarket_cnt",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
{
title: "Кол-во премиальных супермаркетов",
dataIndex: "supermarket_premium_cnt",
key: "supermarket_premium_cnt",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
{
title: "Расстояние до постамата Мой постамата",
dataIndex: "target_dist",
key: "target_dist",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
{
title: "Расстояние до метро",
dataIndex: "metro_dist",
key: "metro_dist",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
{
title: "Стоимость жилой недвижимости ",
dataIndex: "property_price_bargains",
key: "property_price_bargains",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
{
title: "Бизнес-активность",
dataIndex: "business_activity",
key: "business_activity",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
{
title: "Эра постройки жилой недвижимости",
dataIndex: "property_era",
key: "property_era",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
{
title: "Средняя этажность застройки",
dataIndex: "property_mean_floor",
key: "property_mean_floor",
width: "120px",
ellipsis: true,
sorter: true,
showSorterTooltip: false,
},
...fields,
];
].filter(Boolean);
}, [regions?.normalized, fields, fullScreen]);
const [order, setOrder] = useLocalStorage(`${key}Order`, defaultColumns.map((column, index) => {
return {
key: column.key,
position: index,
show: true,
}
}));
useEffect(() => {
const newColumns = defaultColumns.filter((column) => {
return !order.find(c => c.key === column.key);
});
const newOrderColumns = newColumns.map((column, index) => {
return {
key: column.key,
position: defaultColumns.length - index - 1,
show: true,
}
});
setOrder([
...order,
...newOrderColumns
]);
}, [defaultColumns]);
const columns = useMemo(() => {
return order.flatMap((item) => !item.show ? [] : defaultColumns[item.position])
.map((column) => {
if (sort && sort.includes(column.key)) return {
...column,
defaultSortOrder: sort.includes('-') ? 'descend' : 'ascend',
};
return column;
}).filter(Boolean);
}, [defaultColumns, order, fullScreen]);
return {
columns,
orderColumns: {
defaultColumns,
order,
setOrder,
},
sort,
setSort
};
};

@ -47,7 +47,7 @@ export const useMergeTableData = (fullData, onPageSizeChange) => {
onPageSizeChange(PAGE_SIZE + 1);
setMergedData({
count: fullData.count + 1,
count: fullData?.count + 1,
results: [clickedPointData.results[0], ...fullData.results],
});
}, [clickedPointData, fullData]);

@ -5,10 +5,10 @@ import { persist } from "zustand/middleware";
const INITIAL_STATE = {
[LAYER_IDS.initial]: true,
[LAYER_IDS.approve]: false,
[LAYER_IDS.working]: false,
[LAYER_IDS.approve]: true,
[LAYER_IDS.working]: true,
[LAYER_IDS.filteredWorking]: false,
[LAYER_IDS.cancelled]: false,
[LAYER_IDS.cancelled]: true,
[LAYER_IDS.pvz]: true,
[LAYER_IDS.other]: true,
};

@ -5,12 +5,18 @@ import { persist } from "zustand/middleware";
const store = (set) => ({
mode: MODES.PENDING,
isImportMode: false,
setMode: (mode) => {
set((state) => {
state.mode = mode;
});
},
setImportMode: (value) => {
set((state) => {
state.isImportMode = value
});
}
});
export const useMode = create(persist(immer(store), { name: "postnet/mode" }));

@ -2,14 +2,102 @@ import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
import { persist } from "zustand/middleware";
export const RANGE_FILTERS_KEYS = [
'doors',
'flat_cnt',
'rival_post_cnt',
'rival_pvz_cnt',
'target_post_cnt',
'flats_cnt',
'tc_cnt',
'culture_cnt',
'mfc_cnt',
'public_stop_cnt',
'supermarket_cnt',
'target_dist',
'metro_dist',
]
export const RANGE_FILTERS_MAP = {
common: {
name: "Общие",
doors: "Кол-во подъездов в жилом доме",
flat_cnt: "Кол-во квартир в подъезде жилого дома",
},
objects_dist: {
name: "Кол-во объектов в окрестности 500м",
rival_post_cnt: "Кол-во постаматов других сетей",
rival_pvz_cnt: "Кол-во ПВЗ",
target_post_cnt: "Кол-во постаматов Мой постамат",
flats_cnt: "Кол-во квартир в окрестности",
tc_cnt: "Кол-во торговых центров",
culture_cnt: "Кол-во объектов культуры (театры, музей и тд)",
mfc_cnt: "Кол-во МФЦ",
public_stop_cnt: "Кол-во остановок ОТ",
supermarket_cnt: "Кол-во супермаркетов",
target_dist: "Расстояние до постамата Мой постамат",
metro_dist: "Расстояние до метро",
},
}
export const CATEGORIES_MAP = {
"ПВЗ": "Расстояние до ПВЗ сети",
"Постаматы прочих сетей": "Расстояние до постамата сети",
}
export const INITIAL = {
prediction: [0, 0],
categories: [],
region: null,
doors__gt: 0,
doors__lt: 0,
flat_cnt__gt: 0,
flat_cnt__lt: 5000,
rival_post_cnt__gt: 0,
rival_post_cnt__lt: 5000,
rival_pvz_cnt__gt: 0,
rival_pvz_cnt__lt: 5000,
target_post_cnt__gt: 0,
target_post_cnt__lt: 5000,
flats_cnt__gt: 0,
flats_cnt__lt: 5000,
tc_cnt__gt: 0,
tc_cnt__lt: 5000,
culture_cnt__gt: 0,
culture_cnt__lt: 5000,
mfc_cnt__gt: 0,
mfc_cnt__lt: 5000,
public_stop_cnt__gt: 0,
public_stop_cnt__lt: 5000,
supermarket_cnt__gt: 0,
supermarket_cnt__lt: 5000,
target_dist__gt: 0,
target_dist__lt: 5000,
metro_dist__gt: 0,
metro_dist__lt: 5000,
};
const INITIAL_RANGES = {
prediction: [0, 0],
doors: [0, 0],
flat_cnt: [0, 5000],
rival_post_cnt: [0, 5000],
rival_pvz_cnt: [0, 5000],
target_post_cnt: [0, 5000],
flats_cnt: [0, 5000],
tc_cnt: [0, 5000],
culture_cnt: [0, 5000],
mfc_cnt: [0, 5000],
public_stop_cnt: [0, 5000],
supermarket_cnt: [0, 5000],
target_dist: [0, 5000],
metro_dist: [0, 5000],
}
const store = (set) => ({
filters: INITIAL,
ranges: INITIAL_RANGES,
setPrediction: (value) => {
set((state) => {
state.filters.prediction = value;
@ -26,6 +114,17 @@ const store = (set) => ({
state.filters.region = value;
}),
setFilterWithKey: (value, key) =>
set((state) => {
state.filters[`${key}__gt`] = value[0];
state.filters[`${key}__lt`] = value[1];
}),
setRanges: (value) =>
set((state) => {
state.ranges = value;
}),
clear: (fullRange) =>
set((state) => {
if (!fullRange) {

@ -9,8 +9,16 @@ export const INITIAL = {
age: [-1, 0],
};
export const INITIAL_RANGES = {
region: null,
deltaTraffic: [-10000, 10000],
factTraffic: [-100, 0],
age: [-1, 0],
};
const store = (set) => ({
filters: INITIAL,
ranges: INITIAL_RANGES,
setDeltaTraffic: (value) => {
set((state) => {
@ -35,6 +43,11 @@ const store = (set) => ({
state.filters.region = value;
}),
setRanges: (value) =>
set((state) => {
state.ranges = value;
}),
clear: (fullRange) =>
set((state) => {
if (!fullRange) {

@ -1,3 +1,5 @@
import {RANGE_FILTERS_KEYS} from "./stores/usePendingPointsFilters.js";
export function download(filename, data) {
const downloadLink = window.document.createElement("a");
downloadLink.href = window.URL.createObjectURL(
@ -11,5 +13,127 @@ export function download(filename, data) {
document.body.removeChild(downloadLink);
}
var chars = {"Ё":"YO","Й":"I","Ц":"TS","У":"U","К":"K","Е":"E","Н":"N","Г":"G","Ш":"SH","Щ":"SCH","З":"Z","Х":"H","Ъ":"'","ё":"yo","й":"i","ц":"ts","у":"u","к":"k","е":"e","н":"n","г":"g","ш":"sh","щ":"sch","з":"z","х":"h","ъ":"'","Ф":"F","Ы":"I","В":"V","А":"A","П":"P","Р":"R","О":"O","Л":"L","Д":"D","Ж":"ZH","Э":"E","ф":"f","ы":"i","в":"v","а":"a","п":"p","р":"r","о":"o","л":"l","д":"d","ж":"zh","э":"e","Я":"Ya","Ч":"CH","С":"S","М":"M","И":"I","Т":"T","Ь":"'","Б":"B","Ю":"YU","я":"ya","ч":"ch","с":"s","м":"m","и":"i","т":"t","ь":"'","б":"b","ю":"yu"," ":""};
export function transliterate(word){
return word.split('').map(function (char) {
return chars[char] || char;
}).join("");
}
export function getFilteredGroups(categories) {
if (!categories) return [];
return categories
.filter((category) => category.visible)
.map((category) => {
return {
...category,
groups: [...category.groups.filter((group) => group.visible)],
}
})
}
export function fieldHasChanged(filters, ranges, filterKey) {
const r = ranges[filterKey]
const gtValue = filters[`${filterKey}__gt`];
const gtInitial = r ? r[0] : 0 ;
const ltValue = filters[`${filterKey}__lt`];
const ltInitial =r ? r[1] : 0;
const result = !(gtValue === gtInitial && ltValue === ltInitial)
return {
result,
gtValue,
ltValue,
}
}
export const doesMatchFilter = (filters, ranges, feature) => {
const { prediction, categories, region } = filters;
const {
prediction_current,
category,
area,
district,
area_id,
district_id,
} = feature.properties;
const doesMatchPredictionFilter =
prediction_current >= prediction[0] &&
prediction_current <= prediction[1];
const doesMatchCategoriesFilter =
categories.length > 0 ? categories.includes(category) : true;
const doesMatchOtherFilters = () => {
let res = true;
RANGE_FILTERS_KEYS.map((filterKey) => {
if (fieldHasChanged(filters, ranges, filterKey).result && res) {
res =
feature.properties[filterKey] >= filters[`${filterKey}__gt`] &&
feature.properties[filterKey] <= filters[`${filterKey}__lt`];
}
});
return res;
}
const doesMatchRegionFilter = () => {
if (!region) return true;
if (region.type === "ao") {
return (district ?? district_id) === region.id;
} else {
return (area ?? area_id) === region.id;
}
};
return (
doesMatchPredictionFilter &&
doesMatchCategoriesFilter &&
doesMatchRegionFilter() &&
doesMatchOtherFilters()
);
};
export const appendFiltersInUse = (params, filters, ranges) => {
RANGE_FILTERS_KEYS.map((filterKey) => {
if (!fieldHasChanged(filters, ranges, filterKey).result) return;
if (/d[0-9]/.test(filterKey)) {
params.append('dist_to_group__gt', [
filterKey.split('d')[1],
filters[`${filterKey}__gt`] - 1
].join(','));
params.append('dist_to_group__lt', [
filterKey.split('d')[1],
filters[`${filterKey}__lt`] + 1
].join(','));
} else {
params.append(`${filterKey}__gt`, filters[`${filterKey}__gt`] - 1);
params.append(`${filterKey}__lt`, filters[`${filterKey}__lt`] + 1);
}
});
if (predictionHasChanged(filters, ranges)) {
params.append("prediction_current[]", filters.prediction);
}
}
export const predictionHasChanged = (filters, ranges) => {
const gtChanged = ranges.prediction[0] !== filters.prediction[0];
const ltChanged = ranges.prediction[1] !== filters.prediction[1];
return gtChanged || ltChanged;
}
export const workingFilterHasChanged = (filter, ranges, fieldKey) => {
if (!ranges[fieldKey]) return false;
const gtChanged = ranges[fieldKey][0] !== filter[0];
const ltChanged = ranges[fieldKey][1] !== filter[1];
return gtChanged || ltChanged;
}
export const isNil = (value) =>
value === undefined || value === null || value === "";

Loading…
Cancel
Save