Merge branch 'sst_main' into 'main'

Sst main

See merge request spatial/postamates_frontend!3
dev
Dmitry Titov 3 years ago
commit 54bc1604a8

@ -0,0 +1,4 @@
.gitignore
.gitlab-ci.yml
README.md
.git/*

4
.gitignore vendored

@ -8,8 +8,8 @@ pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
# dist
# dist-ssr
*.local
# Editor directories and files

@ -1,54 +1,48 @@
# The Docker image that will be used to build your app
image: node:16.5.0
pages:
stage: deploy
cache:
key:
files:
- yarn.lock
prefix: yarn
paths:
- node_modules/
script:
- yarn
- yarn build
- cp -a dist/. public/
artifacts:
paths:
# The folder that contains the files to be exposed at the Page URL
- public
rules:
# This ensures that only pushes to the default branch will trigger
# a pages deploy
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
variables:
APP_NAME: postamates_front
IMAGE_NAME: $CI_REGISTRY_IMAGE
stages:
- build
- deploy
build-remote:
.build: &build-common
stage: build
only:
- main
before_script:
- sudo docker login -u $CI_DEPLOY_USER -p $CI_DEPLOY_PASSWORD $CI_REGISTRY
script:
- cd /home/toren332/postamates/
- sudo git fetch
- sudo git reset --hard origin/main
- sudo git submodule foreach git pull origin main
- sudo git add postamates_frontend
- sudo git commit -m "new frontend"
- sudo git push
- sudo docker pull $IMAGE_NAME:builder || true
- sudo docker pull $IMAGE_NAME:$CI_COMMIT_SHORT_SHA || true
- sudo docker build --cache-from $IMAGE_NAME:builder --memory=2000m --memory-swap=3000m --target builder --tag $IMAGE_NAME:builder .
- sudo docker push $IMAGE_NAME:builder
- sudo docker build --cache-from $IMAGE_NAME:builder --memory=2000m --memory-swap=3000m --tag $IMAGE_NAME:$IMAGE_TAG --build-arg REACT_APP_DOMAIN_URL=$REACT_APP_DOMAIN_URL --build-arg BUILDKIT_INLINE_CACHE=1 .
- sudo docker push $IMAGE_NAME:$IMAGE_TAG
tags:
- deploy-remote
deploy-remote:
build-test-job:
<<: *build-common
variables:
IMAGE_TAG: $CI_COMMIT_SHORT_SHA
REACT_APP_DOMAIN_URL: https://$DOMAIN/
rules:
- if: $CI_COMMIT_BRANCH == "sst_main"
.deploy: &deploy-common
stage: deploy
only:
- main
before_script:
- sudo docker login -u $CI_DEPLOY_USER -p $CI_DEPLOY_PASSWORD $CI_REGISTRY
- sudo docker pull $IMAGE_NAME:$IMAGE_TAG
script:
- cd /home/toren332/postamates/
- sudo docker-compose down
- sudo git fetch
- sudo git reset --hard origin/main
- sudo git submodule foreach git pull origin main
- sudo docker-compose build --no-cache frontend
- sudo docker-compose up -d
- id=$(sudo docker create $IMAGE_NAME:$IMAGE_TAG)
- sudo docker cp $id:/dist/. /home/toren332/sst_postamates_frontend/dist
- sudo docker rm -v $id
tags:
- deploy-remote
deploy-test-job:
<<: *deploy-common
variables:
IMAGE_TAG: $CI_COMMIT_SHORT_SHA
rules:
- if: $CI_COMMIT_BRANCH == "sst_main"

@ -1,6 +1,12 @@
FROM node:16 as builder
WORKDIR /usr/src/postamates_frontend
ENV NODE_OPTIONS=--max_old_space_size=4096
COPY package*.json ./
COPY yarn.lock ./
RUN yarn install
ADD . .
COPY . .
ARG REACT_APP_DOMAIN_URL=https://${DOMAIN}/
RUN yarn build
FROM nginx:1.23-alpine
COPY --from=builder /usr/src/postamates_frontend/dist /dist

@ -1,95 +1,97 @@
# Postamates Frontend
To build the project all you need is `yarn build` command
## Getting started
To make it easy for you to get started with GitLab, here's a list of recommended next steps.
Already a pro? Just edit this README.md and make it your own. Want to make it
easy? [Use the template at the bottom](#editing-this-readme)!
## Add your files
- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files
- [ ] [Add files using the command line](https://docs.gitlab.com/ee/gitlab-basics/add-file.html#add-a-file-using-the-command-line) or push an existing Git repository with the following command:
```
cd existing_repo
git remote add origin https://gitlab.com/leaders2022/postamates_frontend.git
git branch -M main
git push -uf origin main
```
## Integrate with your tools
- [ ] [Set up project integrations](https://gitlab.com/leaders2022/postamates_frontend/-/settings/integrations)
## Collaborate with your team
- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/)
- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
- [ ] [Automatically merge when pipeline succeeds](https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html)
## Test and Deploy
Use the built-in continuous integration in GitLab.
- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/index.html)
- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing(SAST)](https://docs.gitlab.com/ee/user/application_security/sast/)
- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/)
- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
***
# Editing this README
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thank you to [makeareadme.com](https://www.makeareadme.com/) for this template.
## Suggestions for a good README
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
## Name
Choose a self-explaining name for your project.
## Description
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
## Badges
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
## Visuals
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
## Installation
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
## Usage
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
## Support
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
## Roadmap
If you have ideas for releases in the future, it is a good idea to list them in the README.
## Contributing
State if you are open to contributions and what your requirements are for accepting them.
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
## Authors and acknowledgment
Show your appreciation to those who have contributed to the project.
## License
For open source projects, say how it is licensed.
## Project status
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
test
# Рекомендательный сервис для размещения постаматов
### Инструкция по развёртыванию frontend части проекта:
- Установите [docker](https://docs.docker.com/engine/install/ubuntu/)
- Установите [docker compose](https://docs.docker.com/compose/install/)
- Положите файлы из репозитория в папку ```postamates_frontend```:
- через GIT:
```bash
git clone git@gitlab.com:leaders2022/postamates_frontend.git postamates
git pull
git checkout sst_main
git pull
```
- через zip архив:
```unzip archieve.zip -d postamates_frontend```
- создайте файл ```postamates_frontend/.env``` на основе файла ```postamates_frontend/.env.like```
- Запустите проект:
```
cd postamates
sudo docker-compose up -d
```
после этого статика проекта будет находиться в директории ```postamates_frontend/dist```
### Инструкция по devops части проекта:
- Установите [backend](https://gitlab.com/leaders2022/postamates/-/tree/sst_main/)
- Установите [frontend](https://gitlab.com/leaders2022/postamates_frontend/-/tree/sst_main/)
- Настройте nginx в зависимости от конфигураций .env файлов в backend и frontend репозиториях
- Пример конфигурации:
```server {
server_name postnet-dev.selftech.ru;
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/postnet-dev.selftech.ru/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/postnet-dev.selftech.ru/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
root /home/postamates_frontend/dist;
index index.html;
client_max_body_size 400m;
location / {
try_files $uri /index.html;
}
location /api/ {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_redirect off;
proxy_pass http://0.0.0.0:DJANGO_PORT/api/;
}
location /admin/ {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_redirect off;
proxy_pass http://0.0.0.0:DJANGO_PORT/admin/;
}
location /accounts/ {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_redirect off;
proxy_pass http://0.0.0.0:DJANGO_PORT/accounts/;
}
location /media {
alias /home/postamates/media;
}
location /django_static {
alias /home/postamates/django_static;
}
location /martin {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_redirect off;
proxy_pass http://0.0.0.0:MARTIN_PORT/;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
server {
server_name postnet-dev.selftech.ru;
if ($host = postnet-dev.selftech.ru) {
return 301 https://$host$request_uri;
}
listen 80 ;
listen [::]:80 ;
return 404;
}
```

File diff suppressed because one or more lines are too long

@ -0,0 +1 @@
function s(r,i){for(var o=0;o<i.length;o++){const e=i[o];if(typeof e!="string"&&!Array.isArray(e)){for(const t in e)if(t!=="default"&&!(t in r)){const n=Object.getOwnPropertyDescriptor(e,t);n&&Object.defineProperty(r,t,n.get?n:{enumerable:!0,get:()=>e[t]})}}}return Object.freeze(Object.defineProperty(r,Symbol.toStringTag,{value:"Module"}))}var a={},c=a.printMsg=function(){console.log("This is a message from the demo package")};const f=s({__proto__:null,printMsg:c,default:a},[a]);export{f as i};

File diff suppressed because one or more lines are too long

@ -0,0 +1,17 @@
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
>
<g>
<polygon 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 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 "
/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 641 B

BIN
dist/favicon.ico vendored

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

16
dist/index.html vendored

@ -0,0 +1,16 @@
<!DOCTYPE html>
<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>
<script crossorigin src="/assets/index.eee895f6.js" type="module"></script>
<link href="/assets/index.0353450f.css" rel="stylesheet">
</head>
<body>
<div id="root"></div>
</body>
</html>

18
dist/vite.svg vendored

@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" role="img"
class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257">
<defs>
<linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%">
<stop offset="0%" stop-color="#41D1FF"></stop>
<stop offset="100%" stop-color="#BD34FE"></stop>
</linearGradient>
<linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%">
<stop offset="0%" stop-color="#FFEA83"></stop>
<stop offset="8.333%" stop-color="#FFDD35"></stop>
<stop offset="100%" stop-color="#FFA800"></stop>
</linearGradient>
</defs>
<path fill="url(#IconifyId1813088fe1fbc01fb466)"
d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path>
<path fill="url(#IconifyId1813088fe1fbc01fb467)"
d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@ -0,0 +1,23 @@
version: '3.5'
x-frontend-variables: &frontend-variables
DOMAIN: "${DOMAIN}"
REACT_APP_DOMAIN_URL: "https://${DOMAIN}/"
services:
frontend:
container_name: ${CONTAINERS_NAME}_frontend
build: .
volumes:
- ./dist/:/usr/src/postamates_frontend/dist/
command:
sh -c "yarn build"
environment:
<<: *frontend-variables
deploy:
resources:
limits:
memory: 8096M
reservations:
memory: 8096M

@ -5,7 +5,7 @@
<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>Постаматы by SpatialTeam</title>
<title>PostNet by Spatial</title>
</head>
<body>
<div id="root"></div>

@ -9,28 +9,40 @@
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^5.0.1",
"@nanostores/react": "^0.4.1",
"@tanstack/react-query": "^4.24.9",
"@turf/bbox": "^6.5.0",
"@turf/helpers": "^6.5.0",
"@watergis/maplibre-gl-export": "^1.3.7",
"antd": "^4.23.6",
"axios": "^1.1.3",
"immer": "^9.0.16",
"immer": "^9.0.19",
"immutable": "^4.3.0",
"lodash.debounce": "^4.0.8",
"mapbox-gl": "npm:empty-npm-package@1.0.0",
"maplibre-gl": "^2.4.0",
"nanostores": "^0.7.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^4.6.0",
"react-icons": "^4.8.0",
"react-map-gl": "^7.0.19",
"react-router-dom": "^6.8.1",
"scroll-into-view-if-needed": "^3.0.6",
"tailwind-merge": "^1.7.0",
"typescript": "^4.9.5",
"vite-plugin-svgr": "^2.4.0",
"wellknown": "^0.5.0",
"zustand": "^4.1.3"
},
"devDependencies": {
"@types/react": "^18.0.22",
"@types/react-dom": "^18.0.7",
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
"@vitejs/plugin-react": "^2.2.0",
"autoprefixer": "^10.4.13",
"less": "^4.1.3",
"postcss": "^8.4.18",
"simple-zustand-devtools": "^1.1.0",
"tailwindcss": "^3.2.1",
"vite": "^3.2.0"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@ -1,8 +1,40 @@
import "./App.css";
import { MapComponent } from "./Map/MapComponent";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { LoginPage } from "./pages/Login";
import { VerifyRegistrationPage } from "./pages/VerifyRegistration";
import { MapPage } from "./pages/Map";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { enableMapSet } from "immer";
import { mountStoreDevtool } from "simple-zustand-devtools";
import { usePointSelection } from "./stores/usePointSelection";
import { usePendingPointsFilters } from "./stores/usePendingPointsFilters";
import { useOnApprovalPointsFilters } from "./stores/useOnApprovalPointsFilters";
import { useWorkingPointsFilters } from "./stores/useWorkingPointsFilters";
const queryClient = new QueryClient();
enableMapSet();
if (import.meta.env.MODE === "development") {
mountStoreDevtool("PendingFilters", usePendingPointsFilters);
mountStoreDevtool("OnApprovalFilters", useOnApprovalPointsFilters);
mountStoreDevtool("WorkingFilters", useWorkingPointsFilters);
mountStoreDevtool("PointSelection", usePointSelection);
}
function App() {
return <MapComponent />;
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter basename={import.meta.env.BASE_URL}>
<Routes>
<Route path="/" element={<MapPage />} />
<Route path="/signin" element={<LoginPage />} />
<Route path="/verify-user" element={<VerifyRegistrationPage />} />
{/*<Route path="/register" element={<RegisterPage />} />*/}
</Routes>
</BrowserRouter>
</QueryClientProvider>
);
}
export default App;

@ -0,0 +1,109 @@
import { AutoComplete, Input } from "antd";
import { useQuery } from "@tanstack/react-query";
import { SearchOutlined } from "@ant-design/icons";
import { api } from "../api";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMap } from "react-map-gl";
import parse from "wellknown";
import { usePopup } from "../stores/usePopup.js";
import { useClickedPointConfig } from "../stores/useClickedPointConfig.js";
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}
const renderLabel = (address) => <div>{address}</div>;
export const AddressSearch = ({ autoFocus = false }) => {
const { map } = useMap();
const [value, setValue] = useState("");
const debouncedValue = useDebounce(value);
const { setPopup } = usePopup();
const { setClickedPointConfig } = useClickedPointConfig();
const inputRef = useRef();
const { data } = useQuery(
["address", debouncedValue],
async () => {
const result = await api.get(
`/api/placement_points/search_address?page_size=100&address=${debouncedValue}`
);
return result.data;
},
{ enabled: !!debouncedValue }
);
const options = useMemo(() => {
if (!data) return [];
return data.results.map((item) => ({
label: renderLabel(item.address),
value: `${item.address}$${item.id}`,
item: item,
}));
}, [data]);
const handleChange = (value) => {
if (!value) {
setValue(value);
} else {
setValue(value.split("$")[0]);
}
};
const handleSelect = (_value, option) => {
const geometry = parse(option.item.geometry);
map.flyTo({
center: [geometry.coordinates[0], geometry.coordinates[1]],
zoom: 13,
essential: true,
});
const feature = {
properties: option.item,
};
setPopup({ features: [feature], coordinates: geometry.coordinates });
setClickedPointConfig(feature.properties.id);
};
useEffect(() => {
if (autoFocus && inputRef?.current) {
inputRef.current.focus();
}
}, [autoFocus]);
return (
<div>
<AutoComplete
options={options}
style={{
width: 300,
}}
value={value}
onChange={handleChange}
onSelect={handleSelect}
allowClear={true}
onClear={() => setValue("")}
autoFocus={autoFocus}
popupClassName={"overflow-visible"}
>
<Input
prefix={<SearchOutlined />}
placeholder="Введите адрес точки"
className="text-ellipsis"
ref={inputRef}
/>
</AutoComplete>
</div>
);
};

@ -1,11 +1,16 @@
import { Layer, Source } from "react-map-gl";
const url =
const greyMap =
"https://api.mapbox.com/styles/v1/ghermant/cla2nwk5f00el14nxvtjlsi6z/tiles/256/{z}/{x}/{y}@2x?access_token=pk.eyJ1IjoiZ2hlcm1hbnQiLCJhIjoiY2xhMm5zZ3ZrMDF4MDN2bzc5Yjd0ZjZ1dCJ9.fqnvrEqKKBoguR7R6DR7Yw";
const colorMap =
"https://api.mapbox.com/styles/v1/ghermant/cl9vd1nji002n15s2xxj25f4m/tiles/256/{z}/{x}/{y}@2x?access_token=pk.eyJ1IjoiZ2hlcm1hbnQiLCJhIjoiY2pncDUwcnRmNDQ4ZjJ4czdjZXMzaHZpNyJ9.3rFyYRRtvLUngHm027HZ7A";
const positron =
"https://a.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}@2x.png";
export const Basemap = () => {
return (
<Source type={"raster"} id={"basemap"} tiles={[url]} tileSize={256}>
<Source type={"raster"} id={"basemap"} tiles={[greyMap]} tileSize={256}>
<Layer
type={"raster"}
source={"basemap"}

@ -1,130 +0,0 @@
import { Layer, Source } from "react-map-gl";
import { gridLayer } from "./layers-config";
import { useGridSize } from "../stores/useGridSize";
import { useLayersVisibility } from "../stores/useLayersVisibility";
import { useMemo } from "react";
import { useRateExpression } from "./useRateExpression";
import { useModel } from "../stores/useModel";
const useGridColorScale = () => {
const rate = useRateExpression();
const { model } = useModel();
if (model === "ml") {
return [
"interpolate",
["linear"],
rate,
0,
"rgba(255,0,0,0.78)",
10,
"rgb(255,137,52)",
20,
"rgb(255,197,52)",
30,
"rgb(233,250,0)",
40,
"rgb(92,164,3)",
80,
"rgb(7,112,3)",
100,
"rgb(2,72,1)",
];
}
return [
"interpolate",
["linear"],
rate,
0,
"rgb(204,34,34)",
10,
"rgb(255,221,52)",
20,
"rgb(30,131,42)",
];
};
export const Grid = ({ rate: rateRange }) => {
const { gridSize } = useGridSize();
const {
isVisible: { grid },
} = useLayersVisibility();
const rate = useRateExpression();
const colorScale = useGridColorScale();
const filter = useMemo(() => {
return ["all", [">=", rate, rateRange[0]], ["<=", rate, rateRange[1]]];
}, [rate, rateRange]);
const paintConfig = {
...gridLayer.paint,
"fill-color": colorScale,
};
return (
<>
<Source
id="grid3"
type="vector"
tiles={[
`https://postamates.spatiality.website/martin/public.net_3/{z}/{x}/{y}.pbf`,
]}
>
<Layer
{...gridLayer}
id={"grid3"}
source={"grid3"}
source-layer={"public.net_3"}
layout={{
...gridLayer.layout,
visibility: grid && gridSize === "net_3" ? "visible" : "none",
}}
paint={paintConfig}
filter={filter}
/>
</Source>
<Source
id="grid4"
type="vector"
tiles={[
`https://postamates.spatiality.website/martin/public.net_4/{z}/{x}/{y}.pbf`,
]}
>
<Layer
{...gridLayer}
id={"grid4"}
source={"grid4"}
source-layer={"public.net_4"}
layout={{
...gridLayer.layout,
visibility: grid && gridSize === "net_4" ? "visible" : "none",
}}
filter={filter}
paint={paintConfig}
/>
</Source>
<Source
id="grid5"
type="vector"
tiles={[
`https://postamates.spatiality.website/martin/public.net_5/{z}/{x}/{y}.pbf`,
]}
>
<Layer
{...gridLayer}
id={"grid5"}
source={"grid5"}
source-layer={"public.net_5"}
layout={{
...gridLayer.layout,
visibility: grid && gridSize === "net_5" ? "visible" : "none",
}}
filter={filter}
paint={paintConfig}
/>
</Source>
</>
);
};

@ -0,0 +1,21 @@
import { Logo } from "../icons/Logo";
import { AddressSearch } from "./AddressSearch";
import { twMerge } from "tailwind-merge";
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="flex items-center gap-x-3">
<ModeSelector />
</div>
</div>
<div className="ml-[71px]">
<AddressSearch />
</div>
</div>
);
};

@ -1,59 +0,0 @@
import { Grid } from "./Grid";
import { useRating } from "../stores/useRating";
import { Points } from "./Points";
import { Layer, Source } from "react-map-gl";
import { aoLayer, rayonLayer, selectedRegionLayer } from "./layers-config";
import { useRegionGeometry } from "../stores/useRegionGeometry";
import { useLayersVisibility } from "../stores/useLayersVisibility";
export const Layers = () => {
const { rate } = useRating();
const { geometry } = useRegionGeometry();
const { isVisible } = useLayersVisibility();
return (
<>
<Grid rate={rate} />
<Source
id="ao"
type="vector"
tiles={[
"https://postamates.spatiality.website/martin/public.msk_ao/{z}/{x}/{y}.pbf",
]}
>
<Layer
{...aoLayer}
layout={{
...aoLayer.layout,
visibility: isVisible.atd ? "visible" : "none",
}}
/>
</Source>
<Source
id="rayon"
type="vector"
tiles={[
"https://postamates.spatiality.website/martin/public.msk_rayoni/{z}/{x}/{y}.pbf",
]}
>
<Layer
{...rayonLayer}
layout={{
...rayonLayer.layout,
visibility: isVisible.atd ? "visible" : "none",
}}
/>
</Source>
<Source id="selected-region" type="geojson" data={geometry}>
<Layer
{...selectedRegionLayer}
layout={{
...selectedRegionLayer.layout,
visibility: geometry ? "visible" : "none",
}}
/>
</Source>
<Points rate={rate} />
</>
);
};

@ -0,0 +1,45 @@
import { Layer } from "react-map-gl";
import { cancelledPointLayer } 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";
const statusExpression = ["==", ["get", "status"], STATUSES.cancelled];
export const CancelledPoints = () => {
const { isVisible } = useLayersVisibility();
const {
filters: { region },
} = useOnApprovalPointsFilters();
const regionFilterExpression = useRegionFilterExpression(region);
const { mode } = useMode();
const getFilter = () => {
if (mode === MODES.ON_APPROVAL) {
return regionFilterExpression
? ["all", statusExpression, regionFilterExpression]
: statusExpression;
}
return statusExpression;
};
return (
<>
<Layer
{...cancelledPointLayer}
id={LAYER_IDS.cancelled}
source={"points"}
source-layer={"public.service_placementpoint"}
layout={{
visibility: isVisible[LAYER_IDS.cancelled] ? "visible" : "none",
}}
filter={getFilter()}
/>
</>
);
};

@ -0,0 +1,78 @@
import { Layer } from "react-map-gl";
import {
workingPointBackgroundLayer,
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";
const statusExpression = ["==", ["get", "status"], STATUSES.working];
export const FilteredWorkingPoints = () => {
const { isVisible } = useLayersVisibility();
const {
filters: { deltaTraffic, factTraffic, age, region },
} = useWorkingPointsFilters();
const regionFilterExpression = useRegionFilterExpression(region);
const deltaExpression = [
[">=", ["get", "delta_current"], deltaTraffic[0]],
["<=", ["get", "delta_current"], deltaTraffic[1]],
];
const factExpression = [
[">=", ["get", "fact"], factTraffic[0]],
["<=", ["get", "fact"], factTraffic[1]],
];
const ageExpression = [
[">=", ["get", "age_day"], age[0]],
["<=", ["get", "age_day"], age[1]],
];
const filter = regionFilterExpression
? [
"all",
statusExpression,
...deltaExpression,
...factExpression,
...ageExpression,
regionFilterExpression,
]
: [
"all",
statusExpression,
...deltaExpression,
...factExpression,
...ageExpression,
];
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"}
layout={{
...workingPointSymbolLayer.layout,
visibility: isVisible[LAYER_IDS.filteredWorking] ? "visible" : "none",
}}
filter={filter}
/>
</>
);
};

@ -0,0 +1,52 @@
import { Points } from "./Points";
import { Layer, Source } from "react-map-gl";
import { aoLayer, rayonLayer } from "./layers-config";
import { BASE_URL } from "../../api";
import { PVZ } from "./PVZ";
import { OtherPostamates } from "./OtherPostamates";
import { SelectedRegion } from "./SelectedRegion";
export const Layers = () => {
return (
<>
<Source
id="ao"
type="vector"
tiles={[`${BASE_URL}/martin/public.service_ao/{z}/{x}/{y}.pbf`]}
>
<Layer
{...aoLayer}
layout={{
...aoLayer.layout,
}}
/>
</Source>
<Source
id="rayon"
type="vector"
tiles={[`${BASE_URL}/martin/public.service_rayon/{z}/{x}/{y}.pbf`]}
>
<Layer
{...rayonLayer}
layout={{
...rayonLayer.layout,
}}
/>
</Source>
<SelectedRegion />
<Source
id="rivals"
type="vector"
tiles={[`${BASE_URL}/martin/public.service_rivals/{z}/{x}/{y}.pbf`]}
>
<PVZ />
<OtherPostamates />
</Source>
<Points />
</>
);
};

@ -0,0 +1,36 @@
import { Layer } from "react-map-gl";
import { approvePointLayer } from "./layers-config";
import { useLayersVisibility } from "../../stores/useLayersVisibility";
import { STATUSES } from "../../config";
import { useRegionFilterExpression } from "./useRegionFilterExpression";
import { LAYER_IDS } from "./constants";
import { useOnApprovalPointsFilters } from "../../stores/useOnApprovalPointsFilters";
const statusExpression = ["==", ["get", "status"], STATUSES.onApproval];
export const OnApprovalPoints = () => {
const { isVisible } = useLayersVisibility();
const {
filters: { region },
} = useOnApprovalPointsFilters();
const regionFilterExpression = useRegionFilterExpression(region);
const filter = regionFilterExpression
? ["all", statusExpression, regionFilterExpression]
: statusExpression;
return (
<>
<Layer
{...approvePointLayer}
id={LAYER_IDS.approve}
source={"points"}
source-layer={"public.service_placementpoint"}
layout={{
visibility: isVisible[LAYER_IDS.approve] ? "visible" : "none",
}}
filter={filter}
/>
</>
);
};

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

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

@ -0,0 +1,106 @@
import { Layer } from "react-map-gl";
import {
matchInitialPointLayer,
unmatchInitialPointLayer,
} from "./layers-config";
import { useLayersVisibility } from "../../stores/useLayersVisibility";
import { usePointSelection } from "../../stores/usePointSelection";
import { STATUSES } from "../../config";
import { useRegionFilterExpression } from "./useRegionFilterExpression";
import { LAYER_IDS } from "./constants";
import { usePendingPointsFilters } from "../../stores/usePendingPointsFilters";
const statusExpression = ["==", ["get", "status"], STATUSES.pending];
const useFilterExpression = () => {
const { filters } = usePendingPointsFilters();
const { prediction, categories, region } = filters;
const { selection } = usePointSelection();
const includedArr = [...selection.included];
const excludedArr = [...selection.excluded];
const regionExpression = useRegionFilterExpression(region);
const includedExpression = ["in", ["get", "id"], ["literal", includedArr]];
const excludedExpression = ["in", ["get", "id"], ["literal", excludedArr]];
const predictionExpression = [
[">=", ["get", "prediction_current"], prediction[0]],
["<=", ["get", "prediction_current"], prediction[1]],
];
const categoryExpression =
categories.length > 0
? ["in", ["get", "category"], ["literal", categories]]
: true;
const matchFilterExpression = [
"all",
statusExpression,
["!", excludedExpression],
[
"any",
regionExpression
? ["all", ...predictionExpression, categoryExpression, regionExpression]
: ["all", ...predictionExpression, categoryExpression],
includedExpression,
],
];
const unmatchFilterExpression = [
"all",
statusExpression,
["!", includedExpression],
[
"any",
[
"!",
regionExpression
? [
"all",
...predictionExpression,
categoryExpression,
regionExpression,
]
: ["all", ...predictionExpression, categoryExpression],
],
excludedExpression,
],
];
return { match: matchFilterExpression, unmatch: unmatchFilterExpression };
};
export const PendingPoints = () => {
const { isVisible } = useLayersVisibility();
const { match: matchFilterExpression, unmatch: unmatchFilterExpression } =
useFilterExpression();
return (
<>
<Layer
{...matchInitialPointLayer}
id={LAYER_IDS["initial-unmatch"]}
source={"points"}
source-layer={"public.service_placementpoint"}
layout={{
...matchInitialPointLayer.layout,
visibility: isVisible[LAYER_IDS.initial] ? "visible" : "none",
}}
filter={unmatchFilterExpression}
paint={unmatchInitialPointLayer.paint}
/>
<Layer
{...matchInitialPointLayer}
id={LAYER_IDS["initial-match"]}
source={"points"}
source-layer={"public.service_placementpoint"}
layout={{
...matchInitialPointLayer.layout,
visibility: isVisible[LAYER_IDS.initial] ? "visible" : "none",
}}
filter={matchFilterExpression}
paint={matchInitialPointLayer.paint}
/>
</>
);
};

@ -0,0 +1,31 @@
import { Source } from "react-map-gl";
import { BASE_URL } 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";
export const Points = () => {
const { updateCounter } = useUpdateLayerCounter();
return (
<>
<Source
id="points"
type="vector"
key={`points-${updateCounter}`}
tiles={[
`${BASE_URL}/martin/public.service_placementpoint/{z}/{x}/{y}.pbf`,
]}
>
<PendingPoints />
<OnApprovalPoints />
<WorkingPoints />
<FilteredWorkingPoints />
<CancelledPoints />
</Source>
</>
);
};

@ -0,0 +1,52 @@
import { Layer, Source } from "react-map-gl";
import { selectedRegionLayer } from "./layers-config";
import { usePendingPointsFilters } from "../../stores/usePendingPointsFilters";
import { useOnApprovalPointsFilters } from "../../stores/useOnApprovalPointsFilters";
import { useWorkingPointsFilters } from "../../stores/useWorkingPointsFilters";
import { useMode } from "../../stores/useMode";
import { MODES } from "../../config";
const SelectedRegionLayer = ({ data }) => {
return (
<Source id="selected-region" type="geojson" data={data}>
<Layer {...selectedRegionLayer} />
</Source>
);
};
export const SelectedRegion = () => {
const {
filters: { region: pendingRegion },
} = usePendingPointsFilters();
const {
filters: { region: onApprovalRegion },
} = useOnApprovalPointsFilters();
const {
filters: { region: workingRegion },
} = useWorkingPointsFilters();
const { mode } = useMode();
const shouldRenderPendingRegion =
mode === MODES.PENDING && pendingRegion?.geometry;
const shouldRenderOnApprovalRegion =
mode === MODES.ON_APPROVAL && onApprovalRegion?.geometry;
const shouldRenderWorkingRegion =
mode === MODES.WORKING && workingRegion?.geometry;
return (
<>
{shouldRenderPendingRegion && (
<SelectedRegionLayer data={pendingRegion.geometry} />
)}
{shouldRenderOnApprovalRegion && (
<SelectedRegionLayer data={onApprovalRegion.geometry} />
)}
{shouldRenderWorkingRegion && (
<SelectedRegionLayer data={workingRegion.geometry} />
)}
</>
);
};

@ -0,0 +1,59 @@
import { Layer } from "react-map-gl";
import {
workingPointBackgroundLayer,
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";
const statusExpression = ["==", ["get", "status"], STATUSES.working];
export const WorkingPoints = () => {
const { isVisible } = useLayersVisibility();
const {
filters: { region },
} = useOnApprovalPointsFilters();
const regionFilterExpression = useRegionFilterExpression(region);
const { mode } = useMode();
const getFilter = () => {
if (mode === MODES.ON_APPROVAL) {
return regionFilterExpression
? ["all", statusExpression, regionFilterExpression]
: statusExpression;
}
return statusExpression;
};
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"}
layout={{
...workingPointSymbolLayer.layout,
visibility: isVisible[LAYER_IDS.working] ? "visible" : "none",
}}
filter={getFilter()}
/>
</>
);
};

@ -0,0 +1,14 @@
export const LAYER_IDS = {
initial: "initial",
"initial-match": "initial-match-points",
"initial-unmatch": "initial-unmatch-points",
approve: "approve-points",
working: "working-points",
workingBackground: "working-points-bg",
filteredWorking: "filtered-working-points",
filteredWorkingBackground: "filtered-working-points-bg",
cancelled: "cancelled-points",
atd: "atd",
pvz: "pvz",
other: "other",
};

@ -0,0 +1,131 @@
const POINT_SIZE = 5;
const UNMATCH_POINT_SIZE = 3;
export const PENDING_COLOR = {
property: "prediction_current",
stops: [
[160, "#FDEBF0"],
[161, "#F8C7D8"],
[186, "#F398BC"],
[201, "#EE67A1"],
[211, "#B64490"],
[226, "#7E237E"],
[251, "#46016C"],
],
};
export const CANCELLED_COLOR = "#CC2222";
export const APPROVE_COLOR = "#ff7d00";
export const WORKING_COLOR = "#006e01";
export const UNMATCHED_COLOR = "rgba(196,195,195,0.6)";
export const PVZ_COLOR = "#3f5be8";
export const OTHER_POSTAMATES_COLOR = "#26a2a2";
const DEFAULT_POINT_CONFIG = {
type: "circle",
paint: {
"circle-stroke-width": 0.4,
"circle-stroke-color": "#fff",
"circle-opacity": 0.8,
},
};
const getPointConfig = (color = PENDING_COLOR, size = POINT_SIZE) => {
return {
...DEFAULT_POINT_CONFIG,
paint: {
...DEFAULT_POINT_CONFIG.paint,
"circle-color": color,
"circle-radius": [
"interpolate",
["linear"],
["zoom"],
3,
0,
9,
2,
13,
size,
],
},
};
};
export const matchInitialPointLayer = getPointConfig();
export const unmatchInitialPointLayer = getPointConfig(
UNMATCHED_COLOR,
UNMATCH_POINT_SIZE
);
export const approvePointLayer = getPointConfig(APPROVE_COLOR);
export const workingPointSymbolLayer = {
type: "symbol",
layout: {
"icon-image": "logo",
"icon-size": ["interpolate", ["linear"], ["zoom"], 3, 0, 9, 0.1, 13, 0.7],
},
paint: {
"icon-color": "#E63941",
},
};
const workingBgColor = "#ffffff";
const workingBgSize = 16;
export const workingPointBackgroundLayer = {
...getPointConfig(workingBgColor, workingBgSize),
paint: {
...getPointConfig(workingBgColor, workingBgSize).paint,
"circle-stroke-width": 0.4,
"circle-stroke-color": "#252525",
},
};
export const cancelledPointLayer = getPointConfig(CANCELLED_COLOR);
export const pvzPointLayer = getPointConfig(PVZ_COLOR);
export const otherPostamatesLayer = getPointConfig(OTHER_POSTAMATES_COLOR);
const regionColor = "#676767";
export const aoLayer = {
id: "ao",
type: "line",
source: "ao",
"source-layer": "public.service_ao",
layout: {
"line-join": "round",
"line-cap": "round",
},
paint: {
"line-color": regionColor,
"line-width": 1.5,
"line-opacity": 0.8,
},
};
export const rayonLayer = {
id: "rayon",
type: "line",
source: "rayon",
"source-layer": "public.service_rayon",
layout: {
"line-join": "round",
"line-cap": "round",
},
paint: {
"line-color": regionColor,
"line-width": 0.5,
"line-opacity": 0.8,
},
};
export const selectedRegionLayer = {
id: "selected-region",
type: "line",
source: "selected-region",
layout: {
"line-join": "round",
"line-cap": "round",
},
paint: {
"line-color": "#CC2222",
"line-width": 4,
},
};

@ -0,0 +1,16 @@
import { useMemo } from "react";
const REGION_FIELD_MAPPER = {
ao: "district_id",
rayon: "area_id",
};
export const useRegionFilterExpression = (region) => {
return useMemo(
() =>
region
? ["==", ["get", REGION_FIELD_MAPPER[region.type]], region.id]
: null,
[region]
);
};

@ -0,0 +1,19 @@
import { Button, Popover, Tooltip } from "antd";
import { BiLayer } from "react-icons/all";
import { LayersVisibility } from "./LayersVisibility";
export const LayersControl = () => {
return (
<Popover
content={<LayersVisibility />}
trigger="click"
placement={"leftBottom"}
>
<Tooltip title="Слои">
<Button className="absolute bottom-[20px] right-[20px] flex items-center justify-center p-3">
<BiLayer className="w-4 h-4" />
</Button>
</Tooltip>
</Popover>
);
};

@ -0,0 +1,58 @@
import { useLayersVisibility } from "../../stores/useLayersVisibility";
import { useMode } from "../../stores/useMode";
import Checkbox from "antd/es/checkbox/Checkbox";
import { MODES } from "../../config";
import { LAYER_IDS } from "../Layers/constants";
export const LayersVisibility = () => {
const { toggleVisibility, isVisible } = useLayersVisibility();
const { mode } = useMode();
return (
<div className={"space-y-1 flex flex-col"}>
{mode === MODES.PENDING && (
<>
<Checkbox
className={"!ml-0"}
onChange={() => toggleVisibility(LAYER_IDS.working)}
checked={isVisible[LAYER_IDS.working]}
>
Работающие постаматы
</Checkbox>
<Checkbox
className={"!ml-0"}
onChange={() => toggleVisibility(LAYER_IDS.cancelled)}
checked={isVisible[LAYER_IDS.cancelled]}
>
Отмененные локации
</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>
{/*{mode === MODES.WORKING && (*/}
{/* <>*/}
{/* <Checkbox*/}
{/* className={"!ml-0"}*/}
{/* onChange={() => toggleVisibility(LAYER_IDS.working)}*/}
{/* checked={isVisible[LAYER_IDS.working]}*/}
{/* >*/}
{/* Работает*/}
{/* </Checkbox>*/}
{/* </>*/}
{/*)}*/}
</div>
);
};

@ -0,0 +1,99 @@
import { useMode } from "../stores/useMode";
import { MODES } from "../config";
import {
APPROVE_COLOR,
CANCELLED_COLOR,
OTHER_POSTAMATES_COLOR,
PENDING_COLOR,
PVZ_COLOR,
} from "./Layers/layers-config";
import { Logo } from "../icons/Logo.jsx";
const LegendPointItem = ({ color, name }) => {
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" />
)}
<span>{name}</span>
</div>
);
};
const pendingColors = PENDING_COLOR.stops.map(([_value, color]) => color);
const LegendColorRampItem = ({ colors, name }) => {
return (
<div className="mb-3">
<span className={"mb-1 mt-3 text-center"}>{name}</span>
<div className="w-[200px]">
<div
className={"w-full h-[10px] rounded-xl"}
style={{
background: `linear-gradient(to right, ${colors.join(",")})`,
}}
/>
</div>
<span className="italic">прогноз трафика </span>
</div>
);
};
export function Legend() {
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>
<div className="space-y-1">
{mode === MODES.PENDING && (
<>
<LegendColorRampItem
colors={pendingColors}
name="Локации к рассмотрению"
/>
<LegendPointItem name="Работающие постаматы" />
<LegendPointItem
name="Отмененные локации"
color={CANCELLED_COLOR}
/>
</>
)}
{mode === MODES.ON_APPROVAL && (
<>
<LegendPointItem
name="Согласование-установка"
color={APPROVE_COLOR}
/>
<LegendPointItem name="Работающие постаматы" />
<LegendPointItem
name="Отмененные локации"
color={CANCELLED_COLOR}
/>
</>
)}
{mode === MODES.WORKING && (
<>
<LegendPointItem name="Работающие постаматы" />
</>
)}
</div>
</div>
<div className="space-y-1">
<LegendPointItem name="ПВЗ" color={PVZ_COLOR} />
<LegendPointItem
name="Постаматы прочих сетей"
color={OTHER_POSTAMATES_COLOR}
/>
</div>
</div>
);
}

@ -1,77 +1,58 @@
import maplibregl from "maplibre-gl";
import Map, { useControl } from "react-map-gl";
import { useRef, useState } from "react";
import Map, { MapProvider } from "react-map-gl";
import { useEffect, useRef, useState } from "react";
import { Sidebar } from "../modules/Sidebar/Sidebar";
import { Layers } from "./Layers";
import { MapPopup } from "./Popup";
import { MaplibreExportControl } from "@watergis/maplibre-gl-export";
import { Layers } from "./Layers/Layers";
import { MapPopup } from "./Popup/Popup";
import { Basemap } from "./Basemap";
import { Legend } from "../modules/Sidebar/Legend";
const ruTranslation = {
PageSize: "Размер",
PageOrientation: "Ориентация",
Format: "Формат",
DPI: "DPI",
Generate: "Экспорт карты",
};
class CustomMaplibreExportControl extends MaplibreExportControl {
constructor(options) {
super(options);
}
getTranslation() {
if (this.options.Local === "ru") {
return ruTranslation;
}
return super.getTranslation();
}
}
// const MAP_TILER_KEY = "hE7PBueqYiS7hKSYUXP9";
const ExportControl = (props) => {
const control = useRef(null);
useControl(
() => {
const controlInstance = new CustomMaplibreExportControl(props);
control.current = controlInstance;
return controlInstance;
},
{
position: props.position,
}
);
// useEffect(() => {
// console.log(control.current);
// }, []);
return null;
};
import { SignOut } from "../SignOut";
import debounce from "lodash.debounce";
import { usePopup } from "../stores/usePopup";
import { useClickedPointConfig } from "../stores/useClickedPointConfig";
import { Legend } from "./Legend";
import { TableWrapper } from "../modules/Table/TableWrapper";
import { useMode } from "../stores/useMode";
import { MODE_TO_LAYER_VISIBILITY_MAPPER } from "../config";
import { SidebarControl } from "./SidebarControl";
import { LayersControl } from "./LayersControl/LayersControl";
import { useTable } from "../stores/useTable";
import { twMerge } from "tailwind-merge";
import { useLayersVisibility } from "../stores/useLayersVisibility";
import { LAYER_IDS } from "./Layers/constants";
import { Header } from "./Header";
import { icons } from "../icons/icons-config";
export const MapComponent = () => {
const mapRef = useRef(null);
const [clickedFeature, setClickedFeature] = useState(null);
const [popupCoordinates, setPopupCoordinates] = useState(null);
const mapContainerRef = useRef(null);
const sidebarRef = useRef(null);
const { popup, setPopup } = usePopup();
const { setClickedPointConfig } = useClickedPointConfig();
const { setLayersVisibility } = useLayersVisibility();
const { mode } = useMode();
const { tableState, openTable } = useTable();
useEffect(() => {
setLayersVisibility(MODE_TO_LAYER_VISIBILITY_MAPPER[mode]);
setPopup(null);
setClickedPointConfig(null);
}, [mode]);
const handleClick = (event) => {
if (!event.features) {
setPopupCoordinates(null);
setClickedFeature(null);
setPopup(null);
setClickedPointConfig(null);
return;
}
const feature = event.features[0];
if (!feature) {
setPopupCoordinates(null);
setClickedFeature(null);
setPopup(null);
setClickedPointConfig(null);
return;
}
const { lng: pointLng, lat: pointLat } = event.lngLat;
const { lng: pointLng } = event.lngLat;
if (feature.geometry.type === "Point") {
const coordinates = feature.geometry.coordinates.slice();
@ -79,11 +60,8 @@ export const MapComponent = () => {
coordinates[0] += pointLng > coordinates[0] ? 360 : -360;
}
setPopupCoordinates(coordinates);
} else {
setPopupCoordinates([pointLng, pointLat]);
setPopup({ features: event.features, coordinates });
}
setClickedFeature(feature);
};
const handleMouseEnter = (event) => {
@ -101,58 +79,104 @@ export const MapComponent = () => {
mapRef.current.getCanvas().style.cursor = "";
};
useEffect(() => {
const resizeObserver = new ResizeObserver(
debounce(() => {
mapRef?.current?.resize();
}, 16)
);
if (mapContainerRef.current) {
resizeObserver.observe(mapContainerRef.current);
}
return () => {
resizeObserver.disconnect();
};
}, [mapContainerRef.current]);
useEffect(() => {
if (tableState.fullScreen && !tableState.isOpened) {
openTable();
}
}, [tableState.fullScreen]);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const toggleCollapseSidebar = () =>
setIsSidebarCollapsed((prevState) => !prevState);
return (
<Map
mapLib={maplibregl}
// mapStyle={`https://api.maptiler.com/maps/voyager/style.json?key=${MAP_TILER_KEY}`}
style={{ width: "100vw", height: "100vh" }}
initialViewState={{
latitude: 55.7558,
longitude: 37.6173,
zoom: 9,
}}
dragRotate={false}
ref={mapRef}
interactiveLayerIds={[
"point3",
"point4",
"point5",
"grid3",
"grid4",
"grid5",
]}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<ExportControl
position="top-left"
// PageSize={Size.A4}
// PageOrientation={PageOrientation.Landscape}
// Format={Format.PNG}
// DPI={DPI[200]}
Crosshair={true}
PrintableArea={true}
Local={"ru"}
/>
{clickedFeature && popupCoordinates && (
<MapPopup
lat={popupCoordinates[1]}
lng={popupCoordinates[0]}
feature={clickedFeature}
onClose={() => {
setPopupCoordinates(null);
setClickedFeature(null);
}}
/>
)}
<Basemap />
<Layers />
<Sidebar />
<Legend />
</Map>
<MapProvider>
<div className={"w-screen h-screen relative flex overflow-hidden"}>
<Header />
<Sidebar isCollapsed={isSidebarCollapsed} ref={sidebarRef} />
<div className="flex-1 h-screen flex flex-col ">
<div
ref={mapContainerRef}
className={twMerge(tableState.fullScreen ? "" : "flex-1")}
>
<Map
mapLib={maplibregl}
initialViewState={{
latitude: 55.7558,
longitude: 37.6173,
zoom: 12,
}}
dragRotate={false}
ref={mapRef}
interactiveLayerIds={[
LAYER_IDS["initial-match"],
LAYER_IDS["initial-unmatch"],
LAYER_IDS.approve,
LAYER_IDS.working,
LAYER_IDS.filteredWorking,
LAYER_IDS.cancelled,
LAYER_IDS.pvz,
LAYER_IDS.other,
]}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onLoad={() => {
icons.map((icon) => {
const img = new Image(
icon.size?.width || 24,
icon.size?.height || 24
);
img.onload = () =>
mapRef.current.addImage(icon.name, img, { sdf: true });
img.src = icon.url;
});
}}
id="map"
>
{popup && (
<MapPopup
lat={popup.coordinates[1]}
lng={popup.coordinates[0]}
features={popup.features}
onClose={() => {
setPopup(null);
setClickedPointConfig(null);
}}
/>
)}
<SidebarControl toggleCollapse={toggleCollapseSidebar} />
<Basemap />
<Layers />
<Legend />
<SignOut />
<LayersControl />
</Map>
</div>
<div className="w-full border-solid border-border border-0 border-t-[1px] z-20">
<TableWrapper fullWidth={isSidebarCollapsed} />
</div>
</div>
</div>
</MapProvider>
);
};

@ -1,138 +0,0 @@
import { Layer, Source } from "react-map-gl";
import { pointLayer } from "./layers-config";
import { useLayersVisibility } from "../stores/useLayersVisibility";
import { useActiveTypes } from "../stores/useActiveTypes";
import { useGridSize } from "../stores/useGridSize";
import { useMemo } from "react";
import { useRateExpression } from "./useRateExpression";
import { useModel } from "../stores/useModel";
const usePointSizeScale = () => {
const rate = useRateExpression();
const { model } = useModel();
if (model === "ml") {
return [
"interpolate",
["linear"],
rate,
0,
0,
10,
3,
20,
4,
30,
5,
40,
6,
80,
9,
100,
12,
];
}
return ["interpolate", ["linear"], rate, 0, 0, 10, 5, 50, 20];
};
export const Points = ({ rate: rateRange }) => {
const { gridSize } = useGridSize();
const { isVisible } = useLayersVisibility();
const { activeTypes } = useActiveTypes();
const rate = useRateExpression();
const sizeScale = usePointSizeScale();
const filter = useMemo(() => {
let result = [
"all",
[">=", rate, rateRange[0]],
["<=", rate, rateRange[1]],
];
if (activeTypes.length) {
result = [
"all",
["in", ["get", "category"], ["literal", activeTypes]],
[">=", rate, rateRange[0]],
["<=", rate, rateRange[1]],
];
}
return result;
}, [rate, rateRange, activeTypes]);
const paintConfig = {
...pointLayer.paint,
"circle-radius": sizeScale,
};
return (
<>
<Source
id="point3"
type="vector"
tiles={[
"https://postamates.spatiality.website/martin/public.point3/{z}/{x}/{y}.pbf",
]}
>
<Layer
{...pointLayer}
id={"point3"}
source={"point3"}
source-layer={"public.point3"}
layout={{
...pointLayer.layout,
visibility:
isVisible.points && gridSize === "net_3" ? "visible" : "none",
}}
filter={filter}
paint={paintConfig}
/>
</Source>
<Source
id="point4"
type="vector"
tiles={[
"https://postamates.spatiality.website/martin/public.point4/{z}/{x}/{y}.pbf",
]}
>
<Layer
{...pointLayer}
id={"point4"}
source={"point4"}
source-layer={"public.point4"}
layout={{
...pointLayer.layout,
visibility:
isVisible.points && gridSize === "net_4" ? "visible" : "none",
}}
filter={filter}
paint={paintConfig}
/>
</Source>
<Source
id="point5"
type="vector"
tiles={[
"https://postamates.spatiality.website/martin/public.point5/{z}/{x}/{y}.pbf",
]}
>
<Layer
{...pointLayer}
id={"point5"}
source={"point5"}
source-layer={"public.point5"}
layout={{
...pointLayer.layout,
visibility:
isVisible.points && gridSize === "net_5" ? "visible" : "none",
}}
filter={filter}
paint={paintConfig}
/>
</Source>
</>
);
};

@ -1,103 +0,0 @@
import { Popup } from "react-map-gl";
import { Col, Row } from "antd";
import { twMerge } from "tailwind-merge";
import { TYPE_MAPPER } from "../config";
import { useFactors } from "../stores/useFactors";
import { useMemo } from "react";
import { useModel } from "../stores/useModel";
const useRateValue = (feature) => {
const { factors: weights } = useFactors();
const { model } = useModel();
const result = useMemo(() => {
if (model === "ml") {
return feature.properties.model;
}
const weightedSum = Object.entries(weights).reduce(
(acc, [factor, weight]) => {
const value = Number(feature.properties[factor]);
const weightedValue = value * weight;
acc += weightedValue;
return acc;
},
0
);
const weightSum = Object.values(weights).reduce((acc, weight) => {
acc += weight;
return acc;
}, 0);
return weightedSum / weightSum;
}, [weights, feature, model]);
return result;
};
const pointConfig = [
{
field: "name",
},
{
field: "category",
name: "Тип",
formatter: (value) => TYPE_MAPPER[value],
},
{
name: "Востребованность, у.е.",
formatter: (value) => Math.round(value),
},
];
const gridConfig = [
{
name: "Востребованность, у.е.",
formatter: (value) => Math.round(value),
},
];
export const MapPopup = ({ feature, lat, lng, onClose }) => {
const isPoint = feature.geometry.type === "Point";
const config = isPoint ? pointConfig : gridConfig;
const layout = isPoint
? { keyCol: 15, valueCol: 9 }
: { keyCol: 20, valueCol: 4 };
const rate = useRateValue(feature);
return (
<Popup
longitude={lng}
latitude={lat}
anchor="bottom"
onClose={onClose}
closeOnClick={false}
>
<div>
{config.map((item) => {
const value = item.field ? feature.properties[item.field] : rate;
return (
<Row className={twMerge("p-1")} key={item.field ?? "rate"}>
{item.field === "name" ? (
<Col span={24} className={"font-semibold text-center"}>
{value}
</Col>
) : (
<>
<Col span={layout.keyCol} className={"font-semibold"}>
{item.name}
</Col>
<Col span={layout.valueCol}>
{item.formatter ? item.formatter(value) : value}
</Col>
</>
)}
</Row>
);
})}
</div>
</Popup>
);
};

@ -0,0 +1,91 @@
import { Button } from "antd";
import { CATEGORIES, MODES, STATUSES } from "../../config";
import { useMode } from "../../stores/useMode";
import { LAYER_IDS } from "../Layers/constants";
import { PopupWrapper } from "./PopupWrapper";
import { PendingPointPopup } from "./mode-popup/PendingPointPopup";
import { OnApprovalPointPopup } from "./mode-popup/OnApprovalPointPopup";
import { WorkingPointPopup } from "./mode-popup/WorkingPointPopup";
import { FeatureProperties } from "./mode-popup/FeatureProperties";
import { usePopup } from "../../stores/usePopup.js";
const SingleFeaturePopup = ({ feature }) => {
const { mode } = useMode();
const isRivals =
feature.layer?.id === LAYER_IDS.pvz ||
feature.layer?.id === LAYER_IDS.other;
const isPendingPoint = feature.properties.status === STATUSES.pending;
const isWorkingPoint = feature.properties.status === STATUSES.working;
if (isRivals) {
return <FeatureProperties feature={feature} />;
}
if (mode === MODES.ON_APPROVAL && !isPendingPoint) {
return <OnApprovalPointPopup feature={feature} />;
}
if (mode === MODES.WORKING && isWorkingPoint) {
return <WorkingPointPopup feature={feature} />;
}
if (mode === MODES.PENDING && isPendingPoint)
return <PendingPointPopup feature={feature} />;
return <FeatureProperties feature={feature} />;
};
const MultipleFeaturesPopup = ({ features }) => {
const { setPopup } = usePopup();
return (
<div className="space-y-2 p-1">
{features.map((feature) => {
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>
)}
</Button>
);
})}
</div>
);
};
export const MapPopup = ({ features, lat, lng, onClose }) => {
const getContent = () => {
if (features.length === 1) {
return <SingleFeaturePopup feature={features[0]} />;
}
return <MultipleFeaturesPopup features={features} />;
};
return (
<PopupWrapper lat={lat} lng={lng} onClose={onClose}>
{getContent()}
</PopupWrapper>
);
};

@ -0,0 +1,15 @@
import { Popup } from "react-map-gl";
export const PopupWrapper = ({ lat, lng, onClose, children }) => {
return (
<Popup
longitude={lng}
latitude={lat}
onClose={onClose}
closeOnClick={false}
style={{ minWidth: "300px" }}
>
{children}
</Popup>
);
};

@ -0,0 +1,67 @@
import { CATEGORIES, STATUSES } from "../../../config";
import {
commonPopupConfig,
residentialPopupConfig,
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 { useGetRegions } from "../../../components/RegionSelect.jsx";
export const FeatureProperties = ({ feature, dynamicStatus, postamatId }) => {
const { data } = useGetRegions();
const isResidential = feature.properties.category === CATEGORIES.residential;
const isWorking = feature.properties.status === STATUSES.working;
const isRivals =
feature.layer?.id === LAYER_IDS.pvz ||
feature.layer?.id === LAYER_IDS.other;
const getConfig = () => {
if (isRivals) {
return rivalsConfig;
}
const config = isResidential ? residentialPopupConfig : commonPopupConfig;
return isWorking ? [...config, ...workingPointFields] : config;
};
const getValue = ({ field, render, empty, type, fallbackField }) => {
let value = feature.properties[field];
if (field === "status" && dynamicStatus) {
value = dynamicStatus;
}
if (field === "postamat_id" && postamatId) {
value = postamatId;
}
if (type === "region") {
value = value ? value : feature.properties[fallbackField];
value = render(value, data?.normalized);
} else {
value = render ? render(value) : value;
value = isNil(value) && empty ? empty : value;
}
return value;
};
return (
<div>
{getConfig().map((row) => {
return (
<Row className={twMerge("p-1")} key={row.field}>
<Col className={"font-semibold"} span={12}>
{row.name}
</Col>
<Col span={12}>{getValue(row)}</Col>
</Row>
);
})}
</div>
);
};

@ -0,0 +1,131 @@
import { useClickedPointConfig } from "../../../stores/useClickedPointConfig";
import { useEffect, useState } from "react";
import { FeatureProperties } from "./FeatureProperties";
import { Title } from "../../../components/Title";
import { useQueryClient } from "@tanstack/react-query";
import { useUpdateStatus } from "../../../hooks/useUpdateStatus";
import { StatusSelect } from "../../../components/StatusSelect";
import { useCanEdit, useUpdatePostamatId } from "../../../api";
import { Button, InputNumber } from "antd";
import { STATUSES } from "../../../config";
import { isNil } from "../../../utils.js";
export const OnApprovalPointPopup = ({ feature }) => {
const featureId = feature.properties.id;
const { setClickedPointConfig } = useClickedPointConfig();
const { status: initialStatus, postamat_id: initialPostamatId } =
feature.properties;
const [status, setStatus] = useState(initialStatus);
const [postamatId, setPostamatId] = useState(initialPostamatId);
useEffect(() => {
setStatus(initialStatus);
setPostamatId(initialPostamatId);
}, [initialStatus, initialPostamatId]);
const [needToFillPostamatId, setNeedToFillPostamatId] = useState(
status === STATUSES.working && isNil(postamatId)
);
const [error, setError] = useState("");
useEffect(() => setClickedPointConfig(featureId), [featureId]);
const queryClient = useQueryClient();
const onSuccess = () => {
queryClient.invalidateQueries(["on-approval-points"]);
queryClient.invalidateQueries(["clicked-point", featureId]);
};
const { mutateAsync: updateStatus, isLoading: isStatusUpdating } =
useUpdateStatus({});
const { mutateAsync: updatePostamatId, isLoading: isPostamatIdUpdating } =
useUpdatePostamatId();
const isUpdating = isStatusUpdating || isPostamatIdUpdating;
const makeWorking = async () => {
const updatePostamatIdParams = new URLSearchParams({
id: featureId,
postamat_id: postamatId,
});
const updateStatusParams = new URLSearchParams({
status: STATUSES.working,
"location_ids[]": [featureId],
});
try {
await updatePostamatId(updatePostamatIdParams);
} catch (err) {
setError("Указанный id уже существует, попробуйте другой");
return;
}
await updateStatus(updateStatusParams);
onSuccess();
setNeedToFillPostamatId(false);
};
const handleStatusChange = (value) => {
setStatus(value);
if (value === STATUSES.working) {
setNeedToFillPostamatId(true);
} else {
setNeedToFillPostamatId(false);
const params = new URLSearchParams({
status: value,
"location_ids[]": [featureId],
});
updateStatus(params).then(onSuccess);
}
};
const canEdit = useCanEdit();
return (
<>
<FeatureProperties
feature={feature}
dynamicStatus={status}
postamatId={postamatId}
/>
{canEdit && (
<div className="flex justify-center mt-4">
<div className={"flex flex-col items-center space-y-2"}>
<Title text="Изменить статус" />
<StatusSelect value={status} onChange={handleStatusChange} />
{needToFillPostamatId && (
<>
<Title text="Укажите id постамата" />
<InputNumber
className="w-full"
min={0}
precision={0}
value={postamatId}
onChange={(value) => setPostamatId(value)}
/>
{error && (
<div className="text-primary text-center">{error}</div>
)}
<Button
type="primary"
disabled={isNil(postamatId)}
onClick={makeWorking}
loading={isUpdating}
>
Обновить статус
</Button>
</>
)}
</div>
</div>
)}
</>
);
};

@ -0,0 +1,84 @@
import { usePointSelection } from "../../../stores/usePointSelection";
import { useClickedPointConfig } from "../../../stores/useClickedPointConfig";
import { useEffect } from "react";
import { FeatureProperties } from "./FeatureProperties";
import { Button } from "antd";
import { useCanEdit } from "../../../api";
import { usePendingPointsFilters } from "../../../stores/usePendingPointsFilters";
export const PendingPointPopup = ({ feature }) => {
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 featureId = feature.properties.id;
const isSelected =
(doesMatchFilter() && !selection.excluded.has(featureId)) ||
selection.included.has(featureId);
useEffect(
() => setClickedPointConfig(featureId, isSelected),
[featureId, isSelected]
);
const handleSelect = () => {
if (isSelected) {
exclude(featureId);
} else {
include(featureId);
}
};
const canEdit = useCanEdit();
return (
<>
<FeatureProperties feature={feature} />
{canEdit && (
<Button
type="primary"
className="mt-2 mx-auto"
block
onClick={handleSelect}
>
{isSelected ? "Исключить из выборки" : "Добавить в выборку"}
</Button>
)}
</>
);
};

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

@ -0,0 +1,80 @@
import { STATUS_LABEL_MAPPER } from "../../../config";
export const getRegionNameById = (id, normalizedRegions) =>
normalizedRegions?.[id]?.name ?? id;
export const commonPopupConfig = [
{
name: "Адрес",
field: "address",
},
{
name: "Район",
field: "area_id",
fallbackField: "area",
render: getRegionNameById,
type: "region",
},
{
name: "Округ",
field: "district_id",
fallbackField: "district",
render: getRegionNameById,
type: "region",
},
{
name: "Название",
field: "name",
},
{
name: "Категория",
field: "category",
},
{
name: "Статус",
field: "status",
render: (value) => STATUS_LABEL_MAPPER[value],
},
{
name: "Прогнозный трафик",
field: "prediction_current",
},
];
export const residentialPopupConfig = [
...commonPopupConfig,
{
name: "Кол-во квартир",
field: "flat_cnt",
},
{
name: "Год постройки",
field: "year_bld",
},
{
name: "Кол-во этажей",
field: "levels",
},
{
name: "Материал стен",
field: "mat_nes",
},
];
export const workingPointFields = [
{ name: "План", field: "plan_current" },
{ name: "Факт", field: "fact" },
{ name: "Расхождение с прогнозом", field: "delta_current" },
{ name: "Зрелость", field: "age_day" },
{ 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] },
];

@ -0,0 +1,12 @@
import { Button } from "antd";
import { FiltersIcon } from "../icons/FiltersIcon";
export const SidebarControl = ({ toggleCollapse }) => {
return (
<Button
icon={<FiltersIcon width={16} height={16} />}
onClick={toggleCollapse}
className="border-l-0 rounded-bl-none rounded-tl-none absolute top-[100px] flex items-center justify-center"
/>
);
};

@ -1,97 +0,0 @@
//kiosk - городские киоски
// mfc - многофункциональные центры предоставления государственных и муниципальных услуг
// library - библиотеки
// dk - дома культуры и клубы
// sport - спортивные объекты
export const pointColors = {
kiosk: "#4561ff",
mfc: "#932301",
library: "#a51eda",
dk: "#ff5204",
sport: "#138c44",
};
export const pointLayer = {
type: "circle",
layout: {},
paint: {
"circle-color": [
"match",
["get", "category"],
"kiosk",
pointColors.kiosk,
"mfc",
pointColors.mfc,
"library",
pointColors.library,
"dk",
pointColors.dk,
"sport",
pointColors.sport,
"black",
],
"circle-radius": 4,
"circle-stroke-width": 0.4,
"circle-stroke-color": "#fff",
"circle-opacity": 0.8,
},
};
export const gridLayer = {
type: "fill",
layout: {},
paint: {
"fill-opacity": 0.5,
"fill-outline-color": "transparent",
},
};
const regionColor = "#676767";
export const aoLayer = {
id: "ao",
type: "line",
source: "ao",
"source-layer": "public.msk_ao",
layout: {
"line-join": "round",
"line-cap": "round",
},
paint: {
"line-color": regionColor,
"line-width": 1.5,
"line-opacity": 0.8,
},
};
export const rayonLayer = {
id: "rayon",
type: "line",
source: "rayon",
"source-layer": "public.msk_rayoni",
layout: {
"line-join": "round",
"line-cap": "round",
},
paint: {
"line-color": regionColor,
"line-width": 0.5,
"line-opacity": 0.8,
},
};
export const selectedRegionLayer = {
id: "selected-region",
type: "line",
source: "selected-region",
layout: {
"line-join": "round",
"line-cap": "round",
},
paint: {
"line-color": "#47006e",
"line-width": 2,
"line-opacity": 1,
},
};

@ -1,38 +0,0 @@
import { useMemo } from "react";
import { useFactors } from "../stores/useFactors";
import { useModel } from "../stores/useModel";
const getWeightedValueExpression = (factor, weight) => {
return ["*", ["to-number", ["get", factor]], weight];
};
export const useRateExpression = () => {
const { factors } = useFactors();
const { model } = useModel();
const result = useMemo(() => {
if (model === "ml") {
return ["get", "model"];
}
const weightSum = Object.entries(factors).reduce(
(acc, [_factor, weight]) => {
acc += weight;
return acc;
},
0
);
const weightedValuesSum = Object.entries(factors).reduce(
(acc, [factor, weight]) => {
acc.push(getWeightedValueExpression(factor, weight));
return acc;
},
["+"]
);
return ["/", weightedValuesSum, weightSum];
}, [factors, model]);
return result;
};

File diff suppressed because it is too large Load Diff

@ -0,0 +1,41 @@
import { Button, Popover, Tooltip } from "antd";
import { ArrowRightOutlined, LogoutOutlined } from "@ant-design/icons";
import { api } from "./api";
import { setAuth } from "./stores/auth";
import { useQuery } from "@tanstack/react-query";
import { Title } from "./components/Title";
export function SignOut() {
const logOut = async () => {
await api.post("accounts/logout/");
setAuth(false);
};
const { data } = useQuery(["profile"], async () => {
const { data } = await api.get("/accounts/profile");
return data;
});
return (
<div className="absolute top-[20px] right-[20px]">
<Popover
content={
<>
<Title text={data?.email} classNameText={"lowercase"} />
<Button type="primary" block onClick={logOut}>
<span className="mr-1">Выйти</span>
<ArrowRightOutlined />
</Button>
</>
}
trigger="click"
placement={"bottomRight"}
>
<Tooltip title="Выйти" placement={"left"}>
<Button icon={<LogoutOutlined />} type="primary" size="large" />
</Tooltip>
</Popover>
</div>
);
}

@ -0,0 +1,21 @@
import { useStore } from "@nanostores/react";
import { Spin } from "antd";
import { Navigate } from "react-router-dom";
import { isAuthorized$, userInfoLoading$ } from "./stores/auth";
export function WithAuth(props) {
const isAuthorized = useStore(isAuthorized$);
const userInfoLoading = useStore(userInfoLoading$);
if (userInfoLoading) {
return <Spin className="user-info-loader" />;
}
if (isAuthorized) {
return <>{props.children}</>;
}
return <Navigate to="/signin" replace={true} />;
}

@ -0,0 +1,125 @@
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";
export const BASE_URL = "https://postnet-dev.selftech.ru";
export const api = axios.create({
baseURL:
import.meta.env.MODE === "development"
? "http://localhost:5173/"
: `${BASE_URL}/`,
withCredentials: true,
xsrfHeaderName: "X-CSRFToken",
xsrfCookieName: "csrftoken",
});
const enrichParamsWithRegionFilter = (params, region) => {
const resultParams = params ? params : new URLSearchParams();
if (region) {
if (region.type === "ao") {
resultParams.append("district[]", region.id);
}
if (region.type === "rayon") {
resultParams.append("area[]", region.id);
}
}
return resultParams;
};
export const getPoints = async (params, region) => {
const resultParams = enrichParamsWithRegionFilter(params, region);
const { data } = await api.get(
`/api/placement_points?${resultParams.toString()}`
);
return data;
};
export const exportPoints = async (params, region) => {
const resultParams = enrichParamsWithRegionFilter(params, region);
const { data } = await api.get(
`/api/placement_points/to_excel?${resultParams.toString()}`,
{ responseType: "arraybuffer" }
);
return data;
};
export const useGetTotalInitialPointsCount = () => {
return useQuery(
["all-initial-count"],
async () => {
const params = new URLSearchParams({
page: 1,
page_size: 1,
"status[]": [STATUSES.pending],
});
return await getPoints(params);
},
{ select: (data) => data.count }
);
};
export const useGetFilteredPendingPointsCount = () => {
const { filters } = usePendingPointsFilters();
const { prediction, categories, region } = filters;
const {
selection: { included },
} = usePointSelection();
const includedIds = [...included];
return useQuery(
["filtered-points", filters, includedIds],
async () => {
const params = new URLSearchParams({
page: 1,
page_size: 1,
"prediction_current[]": prediction,
"status[]": [STATUSES.pending],
"categories[]": categories,
"included[]": includedIds,
});
return await getPoints(params, region);
},
{ select: (data) => data.count, keepPreviousData: true }
);
};
export const useGetPermissions = () => {
return useQuery(["permissions"], async () => {
const { data } = await api.get("/api/me");
if (data?.groups?.includes("Редактор")) {
return "editor";
}
return "viewer";
});
};
export const useCanEdit = () => {
const { data } = useGetPermissions();
return data === "editor";
};
export const useUpdatePostamatId = () => {
return useMutation({
mutationFn: (params) => {
return api.put(
`/api/placement_points/update_postamat_id?${params.toString()}`
);
},
});
};

@ -0,0 +1,17 @@
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
>
<g>
<polygon 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 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 "
/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 697 B

@ -0,0 +1,9 @@
import { Button } from "antd";
export const ClearFiltersButton = ({ onClick, disabled }) => {
return (
<Button block className={"mt-2"} onClick={onClick} disabled={disabled}>
Сбросить фильтры
</Button>
);
};

@ -0,0 +1,54 @@
import { useMode } from "../stores/useMode";
import { Button, Tooltip } from "antd";
import { AIIcon } from "../icons/AIIcon";
import { MODES } from "../config";
import { ApproveIcon } from "../icons/ApproveIcon";
import { WorkingIcon } from "../icons/WorkingIcon";
export const ModeSelector = () => {
const { mode, setMode } = useMode();
const handleClick = (selectedMode) => {
setMode(selectedMode);
};
const getType = (currentMode) => {
if (currentMode === mode) return "primary";
return "default";
};
return (
<>
<Tooltip title="Отбор локаций для работы">
<Button
icon={<AIIcon />}
type={getType(MODES.PENDING)}
onClick={() => handleClick(MODES.PENDING)}
className="flex items-center justify-center"
size="large"
/>
</Tooltip>
<Tooltip title="Управление статусами локаций">
<Button
icon={<ApproveIcon />}
type={getType(MODES.ON_APPROVAL)}
onClick={() => handleClick(MODES.ON_APPROVAL)}
className="flex items-center justify-center"
size="large"
/>
</Tooltip>
<Tooltip title="Мониторинг работающих постаматов" className="text-center">
<Button
icon={<WorkingIcon />}
type={getType(MODES.WORKING)}
onClick={() => handleClick(MODES.WORKING)}
className="flex items-center justify-center"
size="large"
/>
</Tooltip>
</>
);
};

@ -0,0 +1,112 @@
import { Empty, TreeSelect } from "antd";
import { Title } from "./Title";
import { useMap } from "react-map-gl";
import getBbox from "@turf/bbox";
import { polygon as getPolygon } from "@turf/helpers";
import { api } from "../api";
import parse from "wellknown";
import { useQuery } from "@tanstack/react-query";
const { TreeNode } = TreeSelect;
const normalizeRegions = (rawRegions) => {
return rawRegions.reduce((acc, ao) => {
acc[ao.id] = ao;
acc[ao.id].type = "ao";
if (ao.rayons) {
ao.rayons.forEach((rayon) => {
acc[rayon.id] = rayon;
acc[rayon.id].type = "rayon";
});
}
return acc;
}, {});
};
export const useGetRegions = () => {
return useQuery(
["regions"],
async () => {
const { data } = await api.get("/api/ao_rayons");
return data;
},
{
select: (rawRegions) => {
return {
raw: rawRegions,
normalized: normalizeRegions(rawRegions),
};
},
}
);
};
export const RegionSelect = ({
disabled,
value: selectedRegionId,
onChange,
}) => {
const { map } = useMap();
const { data, isInitialLoading } = useGetRegions();
const handleChange = (value) => {
if (!value) return;
const selectedRegion = data.normalized[value];
const polygonWrapperGeom = parse(selectedRegion.polygon);
const polygon = getPolygon(polygonWrapperGeom.coordinates[0]);
const bbox = getBbox(polygon);
onChange({ id: value, geometry: polygon, type: selectedRegion.type });
map.fitBounds(
[
[bbox[0], bbox[1]], // southwestern corner of the bounds
[bbox[2], bbox[3]], // northeastern corner of the bounds
],
{
padding: 20,
}
);
};
const handleClear = () => onChange(null);
return (
<div>
<Title text={"АО / район"} />
<TreeSelect
showSearch
style={{ width: "100%" }}
value={selectedRegionId}
dropdownStyle={{ maxHeight: 400, overflow: "auto" }}
placeholder="Выберите АО или район"
allowClear
treeDefaultExpandAll={false}
onChange={handleChange}
loading={isInitialLoading}
treeNodeFilterProp="title"
onClear={handleClear}
notFoundContent={
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={"Не найдено"}
/>
}
disabled={disabled}
>
{data?.raw.map((ao) => {
return (
<TreeNode key={ao.id} value={ao.id} title={ao.name}>
{ao.rayons?.map((rayon) => (
<TreeNode key={rayon.id} value={rayon.id} title={rayon.name} />
))}
</TreeNode>
);
})}
</TreeSelect>
</div>
);
};

@ -1,9 +1,13 @@
import { Title } from "./Title";
import { Slider } from "antd";
import { useState } from "react";
import { useEffect, useState } from "react";
const Mark = ({ value }) => {
return <span className={"text-grey text-xs"}>{value}</span>;
return (
<span className={"text-grey text-xs bg-white-background-light"}>
{value}
</span>
);
};
const getInitialMarks = (fullRange, value) => {
@ -32,6 +36,9 @@ export const SliderComponent = ({
range = false,
step = 1,
disabled = false,
onMouseEnter,
onMouseLeave,
showZeroMark = false,
}) => {
const fullRangeMarks = {
[min]: <Mark value={min} />,
@ -42,6 +49,11 @@ export const SliderComponent = ({
getInitialMarks(fullRangeMarks, initialValue)
);
useEffect(() => {
setValue(initialValue);
setMarks(getInitialMarks(fullRangeMarks, initialValue));
}, [initialValue]);
const handleAfterChange = (value) => {
if (Array.isArray(value)) {
const [min, max] = value;
@ -65,13 +77,15 @@ export const SliderComponent = ({
onChange?.(value);
};
const finalMarks = showZeroMark ? { ...marks, 0: <Mark value={0} /> } : marks;
return (
<div>
<div onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
<Title text={title} />
<Slider
range={range}
value={value}
marks={marks}
marks={finalMarks}
onChange={handleChange}
onAfterChange={handleAfterChange}
min={min}

@ -0,0 +1,34 @@
import { Select } from "antd";
import { STATUS_LABEL_MAPPER, STATUSES } from "../config";
const statusOptions = [
{ label: STATUS_LABEL_MAPPER[STATUSES.pending], value: STATUSES.pending },
{
label: STATUS_LABEL_MAPPER[STATUSES.onApproval],
value: STATUSES.onApproval,
},
{ label: STATUS_LABEL_MAPPER[STATUSES.working], value: STATUSES.working },
{ label: STATUS_LABEL_MAPPER[STATUSES.cancelled], value: STATUSES.cancelled },
];
export const StatusSelect = ({ value, onChange, disabled }) => {
const handleClick = (e) => e.stopPropagation();
const handleChange = (value) => {
onChange(value);
};
return (
<Select
style={{
width: 250,
}}
value={value}
onChange={handleChange}
options={statusOptions}
disabled={disabled}
placeholder="Выберите статус"
onClick={handleClick}
/>
);
};

@ -1,34 +1,50 @@
import axios from "axios";
import { LAYER_IDS } from "./Map/Layers/constants";
export const TYPE_MAPPER = {
export const STATUSES = {
pending: "Pending",
onApproval: "Installation",
working: "Working",
cancelled: "Cancelled",
};
export const STATUS_LABEL_MAPPER = {
[STATUSES.pending]: "К рассмотрению",
[STATUSES.onApproval]: "Согласование-установка",
[STATUSES.working]: "Работает",
[STATUSES.cancelled]: "Отменен",
};
export const CATEGORIES = {
kiosk: "Городской киоск",
mfc: "МФЦ",
library: "Библиотека",
dk: "Дом культуры и отдыха",
sport: "Спортивный объект",
retail: "Ритейл",
residential: "Подъезд жилого дома",
dk: "Дом культуры/Клуб",
};
export const factorsNameMapper = {
people: "Численность населения в 2021 г.",
people2025: "Численность населения в 2025 г. (прогноз)",
stops_ot: "Остановки общественного транспорта",
routes_ot: "Маршруты общетвенного транспорта",
in_metro: "Входы в ближайшее метро в месяц",
out_metro: "Выходы из ближайшего метро в месяц",
tc: "Тогровые центры",
empls: "Рабочие места",
walkers: "Трафик населения",
schools: "Школы и детские сады",
parking: "Парковочные места",
pvz: "Пункты выдачи заказов",
gov_place: "Рекомендованные пункты размещения постаматов",
bike_park: "Городская аренда велосипедов, шт.",
products: "Продовольственные магазины",
nonprod: "Непродовольственные магазины",
service: "Пункты оказания бытовых услуг",
vuz: "ВУЗы и техникумы",
export const DISABLED_FILTER_TEXT =
"Фильтр заблокирован - было ручное редактирование";
export const MODES = {
PENDING: "INITIAL",
ON_APPROVAL: "ON_APPROVAL",
WORKING: "WORKING",
};
export const MODE_TO_STATUS_MAPPER = {
[MODES.PENDING]: [STATUSES.pending],
[MODES.ON_APPROVAL]: [STATUSES.onApproval, STATUSES.working],
[MODES.WORKING]: [STATUSES.working],
};
export const api = axios.create({
baseURL: "https://postamates.spatiality.website",
});
export const MODE_TO_LAYER_VISIBILITY_MAPPER = {
[MODES.PENDING]: [LAYER_IDS.initial, LAYER_IDS.working, LAYER_IDS.cancelled],
[MODES.ON_APPROVAL]: [
LAYER_IDS.approve,
LAYER_IDS.working,
LAYER_IDS.cancelled,
],
[MODES.WORKING]: [LAYER_IDS.filteredWorking],
};

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

@ -0,0 +1,42 @@
export const AIIcon = ({ width = 24, height = 24 }) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width={width}
height={height}
>
<polygon
className="ai-st0"
points="10.5,3.6 10.5,5.2 10.5,19.9 10.3,20.8 10,21.5 9.5,22.1 9.1,22.5 8.4,22.7 7.8,22.7 7.3,22.7
6.7,22.7 6.1,22.1 5.7,21.6 5.3,20.9 4.5,20.9 4,20.6 3.5,20.2 3.2,19.6 3,19.1 3,17.9 2.3,17.2 2,16.7 1,15 0.7,14.1 0.4,13.2
0.3,11.1 0.4,10.1 0.9,9.3 1.5,8.7 2,8.2 1.8,7.3 1.7,6.4 2,5.8 2.3,5.3 3.2,4.9 4.1,4.7 4.7,4.6 4.7,3.8 4.8,3 5.4,2.2 6.1,1.5
6.7,1.3 7.4,1.1 8,1.1 8.5,1.3 9.3,1.7 9.9,2.4 "
/>
<polyline className="ai-st0" points="18.6,4.1 17.3,5.8 12.6,5.7 " />
<polyline className="ai-st0" points="12.3,9.9 17.2,9.8 21,13.6 " />
<line className="ai-st0" x1="12.3" y1="14.5" x2="14.5" y2="14.5" />
<polyline className="ai-st0" points="12.4,19 15.9,19 17.5,20.7 " />
<g>
<ellipse className="ai-st1" cx="19.4" cy="3" rx="1.9" ry="1.9" />
<ellipse className="ai-st2" cx="19.4" cy="2.9" rx="1.1" ry="1.1" />
</g>
<g>
<ellipse className="ai-st1" cx="22" cy="14.6" rx="1.9" ry="1.9" />
<ellipse className="ai-st2" cx="22.1" cy="14.6" rx="1.1" ry="1.1" />
</g>
<g>
<ellipse className="ai-st1" cx="16.2" cy="14.5" rx="1.9" ry="1.9" />
<ellipse className="ai-st2" cx="16.2" cy="14.4" rx="1.1" ry="1.1" />
</g>
<g>
<ellipse className="ai-st1" cx="18.7" cy="21.7" rx="1.9" ry="1.9" />
<ellipse className="ai-st2" cx="18.7" cy="21.7" rx="1.1" ry="1.1" />
</g>
<polyline
className="ai-st0"
points="5,9.5 3.6,10.9 3.4,12.7 3.8,13.9 5.6,16.4 "
/>
</svg>
);
};

@ -0,0 +1,47 @@
import "./styles.css";
export const ApproveIcon = ({ width = 24, height = 24 }) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="-293 385 24 24"
width={width}
height={height}
>
<polyline
className="approve-st0"
points="-273.8,391.3 -273.8,389.8 -274.1,389 -274.8,388.5 -275.9,388.5 -290.5,388.5 -291.2,388.7
-291.9,389.1 -292.3,389.8 -292.4,405.1 -292.3,405.9 -292,406.4 -291.4,406.7 -290.9,406.8 -276,406.8 -275.2,406.7 -274.4,406.4
-273.9,405.8 -273.8,404.3 "
/>
<polygon
className="approve-st1"
points="-287.3,392.4 -286.4,393.5 -286.2,393.7 -285.8,393.7 -285,393.5 -284.1,392.4 -283.1,391.6
-282.6,391.6 -282,391.6 -281.6,392 -281.4,392.8 -281.6,393.2 -282.4,394.3 -285.6,397 -286,397 -286.4,397 -287.1,396.4
-289.2,394.3 -289.6,393.9 -289.6,393 -289.2,392.4 -288.7,392.2 -288.1,392 -287.7,392 "
/>
<polygon
className="approve-st1"
points="-276.8,392.9 -275.9,392.4 -275.5,392.4 -275,393.5 -274.5,393.5 -273.9,393.5 -273.2,393.6
-272.9,392.7 -272.3,392.7 -271.8,392.9 -271.3,393.3 -271.1,393.5 -271.6,394.3 -271.3,394.7 -271.1,395 -270.8,395.6
-269.9,395.2 -269.6,395.6 -269.4,396.1 -269.3,396.4 -269.4,396.8 -269.4,397 -270.3,397.2 -270.2,397.9 -270.2,398.4
-270.3,398.7 -269.2,399.2 -269.4,399.6 -269.6,400.1 -270,400.6 -270.1,400.8 -270.9,400.3 -271,400.2 -271.4,400.5 -271.7,400.7
-272.1,401 -272.3,401.2 -272,402 -272.2,402.3 -273.3,402.7 -273.7,402.6 -273.9,401.8 -274,401.5 -274.8,401.6 -275.5,401.5
-275.6,402.5 -275.9,402.7 -276.4,402.6 -277.3,402.3 -277.3,401.9 -276.8,401 -277.5,400.7 -277.7,400.4 -278.1,400 -279,400.3
-279.3,399.8 -279.6,399 -279.5,398.5 -278.6,398.2 -278.5,397.3 -278.6,396.8 -279,396.5 -279.4,396.3 -279.6,396.2 -279.6,395.8
-279.3,395.1 -279.1,394.9 -278.7,394.9 -277.6,395.1 -277.4,394.6 -276.9,394.4 -276.8,394.2 -277.1,393.1 "
/>
<circle className="approve-st2" cx="-274.4" cy="397.5" r="2.2" />
<path
className="approve-st1"
d="M-282,400.2h-7.4c-0.3,0-0.5-0.2-0.5-0.5l0,0c0-0.3,0.2-0.5,0.5-0.5h7.4c0.3,0,0.5,0.2,0.5,0.5l0,0
C-281.5,400-281.7,400.2-282,400.2z"
/>
<path
className="approve-st1"
d="M-282.1,402.6h-7.4c-0.3,0-0.5-0.2-0.5-0.5l0,0c0-0.3,0.2-0.5,0.5-0.5h7.4c0.3,0,0.5,0.2,0.5,0.5l0,0
C-281.6,402.4-281.8,402.6-282.1,402.6z"
/>
</svg>
);
};

@ -0,0 +1,27 @@
export const FiltersIcon = ({ width = 24, height = 24 }) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
width={width}
height={height}
>
<polygon points="23.7,4.1 23.7,6.1 0.1,6.2 0.1,4.1 " />
<g>
<circle cx="6.3" cy="5.2" r="3.1" />
<circle fill="#fff" cx="6.3" cy="5.2" r="1.4" />
</g>
<polygon points="23.6,11.1 23.7,13.1 0,13.1 0,11 " />
<g>
<circle cx="18.6" cy="12.1" r="3.1" />
<circle fill="#fff" cx="18.6" cy="12.1" r="1.4" />
</g>
<polygon points="23.8,18.2 23.9,20.1 0.2,20.2 0.2,18.2 " />
<g>
<circle cx="10.2" cy="19.1" r="3.1" />
<circle fill="#fff" cx="10.2" cy="19.1" r="1.4" />
</g>
</svg>
);
};

@ -0,0 +1,23 @@
export const Logo = ({ width = 40, height = 40, fill }) => {
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 "
/>
</g>
</svg>
);
};

@ -0,0 +1,28 @@
export const WorkingIcon = ({ width = 24, height = 24 }) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="-293 385 24 24"
width={width}
height={height}
>
<path className="working-st0" d="M-279.1,402.7" />
<polyline
className="working-st1"
points="-275,391.8 -275,390.5 -275.3,389.9 -276,389.6 -277,389.5 -290.8,389.6 -291.4,389.7 -292.1,390
-292.5,390.4 -292.5,401.4 -292.5,401.9 -292.1,402.3 -291.6,402.5 -291.1,402.6 -280,402.5 "
/>
<polyline
className="working-st2"
points="-290.8,396 -289.3,396 -288.7,398.4 -287.6,394.3 -286.1,399.7 -285.1,392 -284.2,397 -283.2,395.9
-281.8,396 "
/>
<circle className="working-st3" cx="-277" cy="397.6" r="4.6" />
<circle className="working-st4" cx="-277.1" cy="397.5" r="2.9" />
<polygon
className="working-st3"
points="-270.9,405.7 -269,403.8 -273.5,399.6 -275.4,401 "
/>
</svg>
);
};

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

@ -0,0 +1,75 @@
.ai-st0 {
fill: none;
stroke: currentColor;
stroke-miterlimit: 10;
}
.ai-st1 {
fill: currentColor;
}
.ai-st2 {
fill: white;
}
.approve-st0 {
fill: none;
stroke: currentColor;
stroke-miterlimit: 10;
}
.approve-st1 {
fill: none;
stroke: currentColor;
stroke-width: 0.5;
stroke-miterlimit: 10;
}
.approve-st2 {
fill: #ffffff;
stroke: currentColor;
stroke-width: 0.5;
stroke-miterlimit: 10;
}
.approve-st3 {
fill: none;
stroke: currentColor;
stroke-width: 0.5;
stroke-miterlimit: 10;
}
.approve-st4 {
fill: #ffffff;
stroke: currentColor;
stroke-width: 0.5;
stroke-miterlimit: 10;
}
.working-st0 {
fill: none;
stroke: currentColor;
stroke-width: 7;
stroke-miterlimit: 10;
}
.working-st1 {
fill: none;
stroke: currentColor;
stroke-miterlimit: 10;
}
.working-st2 {
fill: none;
stroke: currentColor;
stroke-width: 0.5;
stroke-miterlimit: 10;
}
.working-st3 {
fill: currentColor;
}
.working-st4 {
fill: white;
}

@ -9,7 +9,7 @@
.mapboxgl-popup-content,
.maplibregl-popup-content {
@apply bg-grey-light shadow-lg rounded-md;
@apply bg-grey-light shadow-lg rounded-md max-h-[500px] overflow-y-auto;
}
.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip,
@ -17,10 +17,23 @@
@apply border-t-grey-light;
}
.mapboxgl-popup-anchor-top .mapboxgl-popup-tip,
.maplibregl-popup-anchor-top .maplibregl-popup-tip {
@apply border-b-grey-light;
}
.ant-popover-inner {
@apply bg-white-background rounded-xl max-h-[calc(100vh-100px)] overflow-y-auto;
}
.ant-modal-header {
border-bottom: none;
}
.ant-select-multiple .ant-select-selection-item {
@apply !bg-rose;
}
.mapboxgl-ctrl-group,
.maplibregl-ctrl-group {
@apply bg-white-background;
@ -48,6 +61,12 @@
padding: 8px;
}
.ant-select-item-option-content {
overflow: initial;
white-space: initial;
text-overflow: initial;
}
.generate-button {
color: #fff;
border-color: #cc2222ff !important;
@ -76,25 +95,20 @@
width: 100% !important;
}
/* Works on Firefox*/
* {
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
}
/* Works on Chrome and Safari */
*::-webkit-scrollbar {
@apply h-[2px] w-[4px];
}
*::-webkit-scrollbar-thumb {
@apply rounded-xl border border-solid border-transparent bg-grey-light;
::-webkit-scrollbar {
width: 12px;
height: 12px;
}
*::-webkit-scrollbar-track {
@apply rounded-xl bg-transparent;
::-webkit-scrollbar-thumb {
border: 4px solid rgba(0, 0, 0, 0);
background-clip: padding-box;
border-radius: 9999px;
background-color: #e2e2e3;
}
*::-webkit-scrollbar-corner {
@apply hidden;
/* Works on Firefox*/
* {
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
}

@ -1,29 +0,0 @@
import { Radio } from "antd";
import { Title } from "../../components/Title";
import { useGridSize } from "../../stores/useGridSize";
const options = [
{ label: "3 мин", value: "net_3" },
{ label: "4 мин", value: "net_4" },
{ label: "5 мин", value: "net_5" },
];
export const GridSizeSelect = () => {
const { gridSize, setGridSize } = useGridSize();
const onChange = ({ target: { value } }) => {
setGridSize(value);
};
return (
<div className={"flex flex-col items-center"}>
<Title text={"Зона пешей доступности"} />
<Radio.Group
options={options}
onChange={onChange}
value={gridSize}
optionType="button"
buttonStyle={"solid"}
/>
</div>
);
};

@ -1,36 +0,0 @@
import { Title } from "../../components/Title";
import { Checkbox } from "antd";
import { useLayersVisibility } from "../../stores/useLayersVisibility";
export const LayersVisibility = () => {
const { toggleVisibility, isVisible } = useLayersVisibility();
return (
<div>
<Title text={"Слои"} className={"mb-1"} />
<div className={"space-y-1 flex flex-col"}>
<Checkbox
onChange={() => toggleVisibility("points")}
checked={isVisible.points}
>
Точки размещения постаматов
</Checkbox>
<Checkbox
className={"!ml-0"}
onChange={() => toggleVisibility("grid")}
checked={isVisible.grid}
>
Тепловая карта
</Checkbox>
<Checkbox
className={"!ml-0"}
onChange={() => toggleVisibility("atd")}
checked={isVisible.atd}
>
Единицы АТД
</Checkbox>
</div>
</div>
);
};

@ -1,50 +0,0 @@
import { Title } from "../../components/Title";
import { pointColors } from "../../Map/layers-config";
const types = [
{ color: pointColors.kiosk, id: "kiosk", name: "Городские киоски" },
{ color: pointColors.mfc, id: "mfc", name: "МФЦ" },
{ color: pointColors.library, id: "library", name: "Библиотеки" },
{ color: pointColors.dk, id: "dk", name: "Дома культуры и клубы" },
{ color: pointColors.sport, id: "sport", name: "Спортивные объекты" },
];
export function Legend() {
return (
<div className="absolute bottom-[20px] left-[20px] bg-white-background w-[250px] rounded-xl p-4 text-xs text-grey z-10">
<Title text={"Тип объекта размещения"} className={"text-center"} />
<Title
text={"Размер кружка пропорционален востребованности точки"}
className={"text-center mb-1"}
classNameText={"lowercase"}
/>
<div className="space-y-2">
{types.map((type) => (
<div className="flex gap-2 items-center" key={type.id}>
<span
className="rounded-xl w-3 h-3 inline-block"
style={{ backgroundColor: type.color }}
/>
<span>{type.name}</span>
</div>
))}
</div>
<Title
text={"Востребованность постамата"}
className={"mb-1 mt-3 text-center"}
/>
<div
className={"w-full h-[10px] rounded-xl"}
style={{
background:
"linear-gradient(to right, rgba(204,34,34,0.5), rgba(255,221,52,0.5), rgba(30,131,42,0.5))",
}}
/>
<div className={"w-full flex justify-between"}>
<span>мин</span>
<span>макс</span>
</div>
</div>
);
}

@ -1,25 +0,0 @@
import { Select } from "antd";
import { Title } from "../../components/Title";
import { useModel } from "../../stores/useModel";
const options = [
{ value: "statistic", label: "Обычная" },
{ value: "ml", label: "Основанная на ML" },
];
export const ModelSelect = () => {
const { model, setModel } = useModel();
const handleChange = (newValue) => setModel(newValue);
return (
<div className={"flex flex-col items-center"}>
<Title text={"Модель расчета"} />
<Select
className={"w-full"}
value={model}
onChange={handleChange}
options={options}
/>
</div>
);
};

@ -1,70 +0,0 @@
import { Button } from "antd";
import { twMerge } from "tailwind-merge";
import { Title } from "../../components/Title";
import { useActiveTypes } from "../../stores/useActiveTypes";
//kiosk - городские киоски
// mfc - многофункциональные центры предоставления государственных и муниципальных услуг
// library - библиотеки
// dk - дома культуры и клубы
// sport - спортивные объекты
const types = [
{ id: "kiosk", name: "Городские киоски" },
{ id: "mfc", name: "МФЦ" },
{ id: "library", name: "Библиотеки" },
{ id: "dk", name: "Дома культуры и клубы" },
{ id: "sport", name: "Спортивные объекты" },
];
const SelectItem = ({ name, isActive, onClick }) => {
return (
<Button
block
type="text"
className={twMerge(
"text-left",
isActive && "bg-blue hover:bg-blue active:bg-blue focus:bg-blue"
)}
onClick={onClick}
>
{name}
</Button>
);
};
export const ObjectTypesSelect = () => {
const { activeTypes, setActiveTypes } = useActiveTypes();
const handleClick = (type) => setActiveTypes(type);
const clear = () => setActiveTypes([]);
return (
<div>
<div className="flex justify-between items-center mb-1">
<Title text={"Тип объекта размещения"} />
{activeTypes.length !== 0 && (
<Button
type="text"
className="text-grey text-xs p-0 hover:bg-transparent active:bg-transparent focus:bg-transparent h-fit"
onClick={clear}
>
сбросить
</Button>
)}
</div>
<div className="space-y-2">
{types.map((type) => (
<SelectItem
key={type.id}
name={type.name}
isActive={activeTypes.includes(type.id)}
onClick={() => handleClick(type.id)}
/>
))}
</div>
</div>
);
};

@ -0,0 +1,59 @@
import { Title } from "../../../components/Title";
import { Checkbox } from "antd";
import { LAYER_IDS } from "../../../Map/Layers/constants";
import { RegionSelect } from "../../../components/RegionSelect";
import { useOnApprovalPointsFilters } from "../../../stores/useOnApprovalPointsFilters";
import { useLayersVisibility } from "../../../stores/useLayersVisibility";
import { ClearFiltersButton } from "../../../components/ClearFiltersButton";
export const OnApprovalPointsFilters = () => {
const {
filters: { region },
setRegion,
clear,
} = useOnApprovalPointsFilters();
const { isVisible, toggleVisibility, showLayers } = useLayersVisibility();
const hasActiveFilters =
region ||
!isVisible[LAYER_IDS.approve] ||
!isVisible[LAYER_IDS.working] ||
!isVisible[LAYER_IDS.cancelled];
const clearFilters = () => {
clear();
showLayers([LAYER_IDS.approve, LAYER_IDS.working, LAYER_IDS.cancelled]);
};
return (
<div className="space-y-4">
<RegionSelect value={region?.id} onChange={setRegion} />
<div>
<Title text="Статусы" />
<div className="flex flex-col space-y-2">
<Checkbox
onChange={() => toggleVisibility(LAYER_IDS.approve)}
checked={isVisible[LAYER_IDS.approve]}
>
Согласование-установка
</Checkbox>
<Checkbox
className={"!ml-0"}
onChange={() => toggleVisibility(LAYER_IDS.working)}
checked={isVisible[LAYER_IDS.working]}
>
Работает
</Checkbox>
<Checkbox
className={"!ml-0"}
onChange={() => toggleVisibility(LAYER_IDS.cancelled)}
checked={isVisible[LAYER_IDS.cancelled]}
>
Отменен
</Checkbox>
</div>
</div>
{hasActiveFilters && <ClearFiltersButton onClick={clearFilters} />}
</div>
);
};

@ -0,0 +1,37 @@
import { Select } from "antd";
import { Title } from "../../../components/Title";
import { CATEGORIES } from "../../../config";
import { usePendingPointsFilters } from "../../../stores/usePendingPointsFilters";
const options = Object.entries(CATEGORIES).map(([_key, value]) => {
return {
value,
label: value,
};
});
export const CategoriesSelect = ({ disabled }) => {
const {
filters: { categories },
setCategories,
} = usePendingPointsFilters();
return (
<div>
<Title text={"Категории"} />
<Select
mode="tags"
style={{
width: "100%",
}}
placeholder="Выберите категории локаций"
onChange={setCategories}
options={options}
allowClear={true}
value={categories}
disabled={disabled}
/>
</div>
);
};

@ -0,0 +1,129 @@
import { DISABLED_FILTER_TEXT, STATUSES } from "../../../config";
import { Button, Tooltip } from "antd";
import { SelectedLocations } from "./SelectedLocations";
import { TakeToWorkButton } from "./TakeToWorkButton";
import { RegionSelect } from "../../../components/RegionSelect";
import { CategoriesSelect } from "./CategoriesSelect";
import { PredictionSlider } from "./PredictionSlider";
import { useEffect, useState } from "react";
import {
useHasManualEdits,
usePointSelection,
} from "../../../stores/usePointSelection";
import { 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]],
};
},
}
);
};
export const PendingPointsFilters = () => {
const hasManualEdits = useHasManualEdits();
const { reset: resetPointSelection } = usePointSelection();
const { filters, setRegion, clear } = usePendingPointsFilters();
const { data: fullRange, isInitialLoading } = useGetPendingPointsRange();
const [isSelectionEmpty, setIsSelectionEmpty] = useState(false);
const handleSelectedChange = (count) => {
if (count === 0) {
setIsSelectionEmpty(true);
} else {
setIsSelectionEmpty(false);
}
};
const [hover, setHover] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setHover(false), 1500);
return () => clearTimeout(timer);
}, [hover]);
const handleMouseEnter = () => {
setHover(true);
};
const handleMouseLeave = () => {
setHover(false);
};
const activeDynamicFilters = getDynamicActiveFilters(filters, fullRange, [
"prediction",
]);
const clearFilters = () => clear(fullRange);
const hasActiveFilters =
filters.region ||
activeDynamicFilters.prediction ||
filters.categories.length !== 0;
const canEdit = useCanEdit();
return (
<div className="flex flex-col flex-1 justify-between">
<div>
<Tooltip
title={DISABLED_FILTER_TEXT}
placement="right"
open={hasManualEdits && hover}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className="space-y-5">
<RegionSelect
disabled={hasManualEdits}
value={filters.region?.id}
onChange={setRegion}
/>
<CategoriesSelect disabled={hasManualEdits} />
<PredictionSlider
disabled={hasManualEdits}
fullRange={fullRange}
isLoading={isInitialLoading}
/>
</div>
{hasActiveFilters && (
<ClearFiltersButton
onClick={clearFilters}
disabled={hasManualEdits}
/>
)}
</Tooltip>
{hasManualEdits ? (
<Button block className={"mt-2"} onClick={resetPointSelection}>
Отменить ручное редактирование
</Button>
) : null}
</div>
<div>
<SelectedLocations onSelectedChange={handleSelectedChange} />
{canEdit && <TakeToWorkButton disabled={isSelectionEmpty} />}
</div>
</div>
);
};

@ -0,0 +1,50 @@
import { SliderComponent as Slider } from "../../../components/SliderComponent";
import { useEffect } from "react";
import {
INITIAL,
usePendingPointsFilters,
} from "../../../stores/usePendingPointsFilters";
import { Spin } from "antd";
export const PredictionSlider = ({ disabled, fullRange, isLoading }) => {
const {
filters: { prediction },
setPrediction,
} = usePendingPointsFilters();
const handleAfterChange = (range) => setPrediction(range);
useEffect(() => {
if (!fullRange) return;
const min = fullRange.prediction[0];
const max = fullRange.prediction[1];
const shouldSetFullRange =
prediction[0] === INITIAL.prediction[0] &&
prediction[1] === INITIAL.prediction[1];
if (shouldSetFullRange) {
setPrediction([min, max]);
}
}, [fullRange]);
if (isLoading) {
return (
<div className={"flex justify-center items-center"}>
<Spin />
</div>
);
}
return (
<Slider
title={"Прогнозный трафик"}
value={prediction}
onAfterChange={handleAfterChange}
min={fullRange.prediction[0]}
max={fullRange.prediction[1]}
range
disabled={disabled}
/>
);
};

@ -0,0 +1,37 @@
import {
useGetFilteredPendingPointsCount,
useGetTotalInitialPointsCount,
} from "../../../api";
import { usePointSelection } from "../../../stores/usePointSelection";
import { Spin } from "antd";
import { useEffect } from "react";
export const SelectedLocations = ({ onSelectedChange }) => {
const { data: totalCount, isInitialLoading: isTotalLoading } =
useGetTotalInitialPointsCount();
const { data: filteredCount, isInitialLoading: isFilteredLoading } =
useGetFilteredPendingPointsCount();
const {
selection: { excluded },
} = usePointSelection();
useEffect(
() => onSelectedChange(filteredCount - excluded.size),
[filteredCount, excluded]
);
const showSpinner = isTotalLoading || isFilteredLoading;
return (
<div className={"flex items-center justify-between"}>
<span>Отобрано локаций</span>
{showSpinner ? (
<Spin />
) : (
<span>{`${filteredCount - excluded.size} / ${totalCount}`}</span>
)}
</div>
);
};

@ -0,0 +1,119 @@
import { Alert, Button, Modal, Spin } from "antd";
import { useQueryClient } from "@tanstack/react-query";
import { usePointSelection } from "../../../stores/usePointSelection";
import { STATUSES } from "../../../config";
import { useState } from "react";
import { useUpdateStatus } from "../../../hooks/useUpdateStatus";
import { ArrowRightOutlined } from "@ant-design/icons";
import { Title } from "../../../components/Title";
import { usePendingPointsFilters } from "../../../stores/usePendingPointsFilters";
export const TakeToWorkButton = ({ disabled }) => {
const { filters } = usePendingPointsFilters();
const { prediction, categories, region } = filters;
const { selection } = usePointSelection();
const queryClient = useQueryClient();
const [isModalOpened, setIsModalOpened] = useState(false);
const {
mutate: updateStatus,
isLoading,
isSuccess,
} = useUpdateStatus({
onSuccess: () => {
queryClient.invalidateQueries(["table", 1, filters]);
},
});
const takeToWork = () => {
const params = new URLSearchParams({
status: STATUSES.onApproval,
"prediction_current[]": prediction,
"categories[]": categories,
"included[]": [...selection.included],
"excluded[]": [...selection.excluded],
});
if (region) {
if (region.type === "ao") {
params.append("district[]", region.id);
}
if (region.type === "rayon") {
params.append("area[]", region.id);
}
}
updateStatus(params);
};
const getModalFooter = () => {
if (isSuccess) {
return [
<Button
key="ok-button"
type="primary"
onClick={() => setIsModalOpened(false)}
>
Хорошо
</Button>,
];
}
if (isLoading) return null;
return (
<div className="flex items-center justify-center gap-x-4">
<Button onClick={() => setIsModalOpened(false)}>Отмена</Button>
<Button type="primary" onClick={takeToWork}>
Да
</Button>
</div>
);
};
return (
<>
<Button
type="primary"
block
className={"mt-2"}
onClick={() => setIsModalOpened(true)}
disabled={disabled}
>
<span className="mr-1">Взять в работу</span>
<ArrowRightOutlined />
</Button>
<Modal
title={" "}
centered
open={isModalOpened}
onCancel={() => setIsModalOpened(false)}
closable={true}
footer={getModalFooter()}
>
{isSuccess ? (
<Alert
message="Успешно"
description="Выбранные точки отправлены на согласование. Посмотреть на них можно во второй
вкладке"
type="success"
showIcon
/>
) : (
<Title
text="Уверены, что хотите взять отобранные локации в работу?"
className="text-center"
/>
)}
{isLoading && (
<div className="flex justify-center py-2">
<Spin tip="Отправляем на согласование..." size="large" />
</div>
)}
</Modal>
</>
);
};

@ -1,19 +0,0 @@
import { useRating } from "../../stores/useRating";
import { SliderComponent as Slider } from "../../components/SliderComponent";
export const RatingSlider = () => {
const { rate, setRate } = useRating();
const handleAfterChange = (range) => setRate(range);
return (
<Slider
title={"Востребованность постамата, у.e."}
value={rate}
onAfterChange={handleAfterChange}
min={1}
max={100}
range
/>
);
};

@ -1,120 +0,0 @@
import { Empty, TreeSelect } from "antd";
import { Title } from "../../components/Title";
import { useRegion } from "../../stores/useRegion";
import { useEffect, useMemo, useState } from "react";
import { api } from "../../config";
import { useMap } from "react-map-gl";
import getBbox from "@turf/bbox";
import { polygon as getPolygon } from "@turf/helpers";
import { useRegionGeometry } from "../../stores/useRegionGeometry";
const { TreeNode } = TreeSelect;
const normalizeRegions = (rawRegions) => {
if (!rawRegions) return {};
return rawRegions.reduce((acc, raw) => {
acc[`ao-${raw.id}`] = raw;
if (raw.children) {
raw.children.forEach((child) => {
acc[`rayon-${child.id}`] = child;
});
}
return acc;
}, {});
};
export const RegionSelect = () => {
const { current: map } = useMap();
const { region, setRegion } = useRegion();
const { setRegionGeometry } = useRegionGeometry();
const [data, setData] = useState([]);
const normalizedData = useMemo(() => normalizeRegions(data), [data]);
const [loading, setLoading] = useState(false);
useEffect(() => {
const getRegions = async () => {
setLoading(true);
try {
const response = await api.get("/api/ao_and_rayons");
setData(response.data);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
getRegions();
}, []);
const onChange = (value) => {
if (!value) return;
const selectedRegion = normalizedData[value];
const polygon = getPolygon(selectedRegion.geometry[0]);
const bbox = getBbox(polygon);
setRegionGeometry(polygon);
setRegion(value);
map.fitBounds(
[
[bbox[0], bbox[1]], // southwestern corner of the bounds
[bbox[2], bbox[3]], // northeastern corner of the bounds
],
{
padding: 20,
}
);
};
const handleClear = () => {
setRegion(null);
setRegionGeometry(null);
};
return (
<div>
<Title text={"АО / район"} />
<TreeSelect
showSearch
style={{ width: "100%" }}
value={region}
dropdownStyle={{ maxHeight: 400, overflow: "auto" }}
placeholder="Выберите АО или район"
allowClear
treeDefaultExpandAll={false}
onChange={onChange}
loading={loading}
treeNodeFilterProp="title"
onClear={handleClear}
notFoundContent={
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={"Не найдено"}
/>
}
>
{data?.map((parent) => {
return (
<TreeNode
key={`ao-${parent.id}`}
value={`ao-${parent.id}`}
title={parent.name}
>
{parent.children?.map((child) => (
<TreeNode
key={`rayon-${child.id}`}
value={`rayon-${child.id}`}
title={child.name}
/>
))}
</TreeNode>
);
})}
</TreeSelect>
</div>
);
};

@ -1,31 +0,0 @@
import { SliderComponent as Slider } from "../../components/SliderComponent";
import { useFactors } from "../../stores/useFactors";
import { factorsNameMapper } from "../../config";
import { useModel } from "../../stores/useModel";
export const Settings = () => {
const { factors, setWeight } = useFactors();
const { model } = useModel();
const handleAfterChange = (factor, value) => {
setWeight(factor, value);
};
return (
<div className={"space-y-2 min-w-[300px]"}>
{Object.entries(factors).map(([field, value]) => {
return (
<Slider
title={factorsNameMapper[field]}
value={value}
key={field}
max={1}
step={0.01}
onAfterChange={(value) => handleAfterChange(field, value)}
disabled={model === "ml"}
/>
);
})}
</div>
);
};

@ -1,138 +1,35 @@
import { RegionSelect } from "./RegionSelect";
import { Button, Popover } from "antd";
import { BsChevronLeft } from "react-icons/bs";
import { ObjectTypesSelect } from "./ObjectTypesSelect";
import { RatingSlider } from "./RatingSlider";
import { LayersVisibility } from "./LayersVisibility";
import { GridSizeSelect } from "./GridSizeSelect";
import { ModelSelect } from "./ModelSelect";
import { Settings } from "./Settings";
import { useFactors } from "../../stores/useFactors";
import { useActiveTypes } from "../../stores/useActiveTypes";
import { useRegion } from "../../stores/useRegion";
import { useGridSize } from "../../stores/useGridSize";
import { api } from "../../config";
import { useRating } from "../../stores/useRating";
import { useState } from "react";
import { useModel } from "../../stores/useModel";
const activeTablesMapper = {
net_3: ["point3", "net_3"],
net_4: ["point4", "net_4"],
net_5: ["point5", "net_5"],
};
function download(filename, data) {
const downloadLink = window.document.createElement("a");
downloadLink.href = window.URL.createObjectURL(
new Blob([data], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
})
);
downloadLink.download = filename;
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
}
const getRegionParam = (regionId) => {
if (!regionId) return null;
const [type, id] = regionId.split("-");
if (type === "ao") {
return { msk_ao: Number(id) };
}
return { msk_rayon: Number(id) - 1 };
};
export const Sidebar = () => {
const { factors } = useFactors();
const { activeTypes } = useActiveTypes();
const { region } = useRegion();
const { gridSize } = useGridSize();
const { rate: rateRange } = useRating();
const { model } = useModel();
const [isExporting, setIsExporting] = useState(false);
const handleExport = async () => {
setIsExporting(true);
try {
const params = {
koefs: factors,
tables: activeTablesMapper[gridSize],
filters: {
rate_from: rateRange[0],
rate_to: rateRange[1],
},
method: model === "ml" ? "model" : "rate",
};
if (region) {
params.filters = {
...params.filters,
...getRegionParam(region),
};
}
if (activeTypes.length) {
params.filters = {
...params.filters,
category: activeTypes,
};
}
const resp = await api.post("/api/raschet/", params, {
responseType: "arraybuffer",
});
const blob = resp.data;
import { useMode } from "../../stores/useMode";
import { twMerge } from "tailwind-merge";
import { forwardRef } from "react";
import { MODES } from "../../config";
import { PendingPointsFilters } from "./PendingPointsFilters/PendingPointsFilters";
import { OnApprovalPointsFilters } from "./OnApprovalPointsFilters/OnApprovalPointsFilters";
import { WorkingPointsFilters } from "./WorkingPointsFilters/WorkingPointsFilters";
export const Sidebar = forwardRef(({ isCollapsed }, ref) => {
const { mode } = useMode();
const getFilters = () => {
if (mode === MODES.PENDING) {
return <PendingPointsFilters />;
}
download("postamates.xlsx", blob);
} catch (err) {
console.log("Произошла ошибка");
} finally {
setIsExporting(false);
if (mode === MODES.ON_APPROVAL) {
return <OnApprovalPointsFilters />;
}
return <WorkingPointsFilters />;
};
return (
<div className="absolute top-[20px] right-[20px] bg-white-background w-[300px] rounded-xl p-3 max-h-[calc(100vh-40px)] overflow-y-auto z-10">
<Popover
placement="leftTop"
title={"Веса факторов"}
content={Settings}
trigger="click"
>
<Button
type="text"
icon={<BsChevronLeft className="mr-3" />}
className="flex items-center p-0 pr-1 text-grey mb-2 hover:bg-transparent focus:bg-transparent"
>
Настройки
</Button>
</Popover>
<div className="space-y-5">
<ModelSelect />
<GridSizeSelect />
<LayersVisibility />
<RegionSelect />
<ObjectTypesSelect />
<RatingSlider />
<div>
<Button
type="primary"
block
className={"mt-2"}
onClick={handleExport}
loading={isExporting}
>
Экспорт данных
</Button>
</div>
</div>
<div
className={twMerge(
"h-screen p-3 overflow-y-auto shrink-0 border-solid border-border border-0 border-r-[1px] flex flex-col transition-all pt-20",
isCollapsed ? "basis-0 px-0 -translate-x-[320px]" : "basis-[320px]"
)}
ref={ref}
>
<div className="flex flex-col flex-1">{getFilters()}</div>
</div>
);
};
});

@ -0,0 +1,35 @@
import { useEffect } from "react";
import { SliderComponent as Slider } from "../../../components/SliderComponent";
import {
INITIAL,
useWorkingPointsFilters,
} from "../../../stores/useWorkingPointsFilters";
export const AgeSlider = ({ fullRange }) => {
const {
filters: { age },
setAge,
} = useWorkingPointsFilters();
const handleAfterChange = (range) => setAge(range);
useEffect(() => {
const min = fullRange.age[0];
const max = fullRange.age[1];
if (age[0] === INITIAL.age[0] && age[1] === INITIAL.age[1]) {
setAge([min, max]);
}
}, [fullRange, age]);
return (
<Slider
title={"Зрелость постамата, дней"}
value={age}
onAfterChange={handleAfterChange}
min={fullRange.age[0]}
max={fullRange.age[1]}
range
/>
);
};

@ -0,0 +1,39 @@
import { useEffect } from "react";
import { SliderComponent as Slider } from "../../../components/SliderComponent";
import {
INITIAL,
useWorkingPointsFilters,
} from "../../../stores/useWorkingPointsFilters";
export const DeltaTrafficSlider = ({ fullRange }) => {
const {
filters: { deltaTraffic },
setDeltaTraffic,
} = useWorkingPointsFilters();
const handleAfterChange = (range) => setDeltaTraffic(range);
useEffect(() => {
const min = fullRange.deltaTraffic[0];
const max = fullRange.deltaTraffic[1];
if (
deltaTraffic[0] === INITIAL.deltaTraffic[0] &&
deltaTraffic[1] === INITIAL.deltaTraffic[1]
) {
setDeltaTraffic([min, max]);
}
}, [fullRange, deltaTraffic]);
return (
<Slider
title={"Расхождение факта с прогнозом, %"}
value={deltaTraffic}
onAfterChange={handleAfterChange}
min={fullRange.deltaTraffic[0]}
max={fullRange.deltaTraffic[1]}
range
showZeroMark={true}
/>
);
};

@ -0,0 +1,38 @@
import { useEffect } from "react";
import { SliderComponent as Slider } from "../../../components/SliderComponent";
import {
INITIAL,
useWorkingPointsFilters,
} from "../../../stores/useWorkingPointsFilters";
export const FactTrafficSlider = ({ fullRange }) => {
const {
filters: { factTraffic },
setFactTraffic,
} = useWorkingPointsFilters();
const handleAfterChange = (range) => setFactTraffic(range);
useEffect(() => {
const min = fullRange.factTraffic[0];
const max = fullRange.factTraffic[1];
if (
factTraffic[0] === INITIAL.factTraffic[0] &&
factTraffic[1] === INITIAL.factTraffic[1]
) {
setFactTraffic([min, max]);
}
}, [fullRange, factTraffic]);
return (
<Slider
title={"Фактический трафик"}
value={factTraffic}
onAfterChange={handleAfterChange}
min={fullRange.factTraffic[0]}
max={fullRange.factTraffic[1]}
range
/>
);
};

@ -0,0 +1,75 @@
import { RegionSelect } from "../../../components/RegionSelect";
import { useWorkingPointsFilters } from "../../../stores/useWorkingPointsFilters";
import { DeltaTrafficSlider } from "./DeltaSlider";
import { FactTrafficSlider } from "./FactTrafficSlider";
import { AgeSlider } from "./AgeSlider";
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 { STATUSES } from "../../../config.js";
const useGetDataRange = () => {
return useQuery(
["working-max-min"],
async () => {
const { data } = await api.get(
`/api/placement_points/filters?status[]=${STATUSES.working}`
);
return data;
},
{
select: (data) => {
return {
deltaTraffic: [data.delta_current[0], data.delta_current[1]],
factTraffic: [data.fact[0], data.fact[1]],
age: [data.age_day[0], data.age_day[1]],
};
},
}
);
};
export const WorkingPointsFilters = () => {
const { filters, setRegion, clear } = useWorkingPointsFilters();
const { data: fullRange, isInitialLoading: isFullRangeLoading } =
useGetDataRange();
const activeDynamicFilters = getDynamicActiveFilters(filters, fullRange, [
"deltaTraffic",
"factTraffic",
"age",
]);
const hasActiveFilters =
filters.region ||
activeDynamicFilters.deltaTraffic ||
activeDynamicFilters.factTraffic ||
activeDynamicFilters.age;
const handleClear = () => clear(fullRange);
return (
<div>
<RegionSelect value={filters.region?.id} onChange={setRegion} />
<div className={"space-y-12 mt-4"}>
{isFullRangeLoading ? (
<div className={"flex justify-center items-center"}>
<Spin />
</div>
) : (
<>
<DeltaTrafficSlider fullRange={fullRange} />
<FactTrafficSlider fullRange={fullRange} />
<AgeSlider fullRange={fullRange} />
</>
)}
</div>
{hasActiveFilters && <ClearFiltersButton onClick={handleClear} />}
</div>
);
};

@ -0,0 +1,13 @@
export const getDynamicActiveFilters = (filters, fullRange, props) => {
if (!fullRange || !props) return false;
const result = {};
props.forEach((prop) => {
result[prop] =
filters[prop][0] !== fullRange[prop][0] ||
filters[prop][1] !== fullRange[prop][1];
});
return result;
};

@ -0,0 +1,31 @@
import { useState } from "react";
import { Button, Tooltip } from "antd";
import { DownloadOutlined } from "@ant-design/icons";
import { download } from "../../utils";
export const handleExportSuccess = (data) => {
download("postamates.xlsx", data);
};
export const ExportButton = ({ provider }) => {
const [startExport, setStartExport] = useState(false);
provider(startExport, () => setStartExport(false));
const handleExport = (e) => {
e.stopPropagation();
setStartExport(true);
};
return (
<Tooltip title="Скачать данные">
<Button
onClick={handleExport}
loading={startExport}
disabled={startExport}
>
<DownloadOutlined />
</Button>
</Tooltip>
);
};

@ -0,0 +1,70 @@
import { ExportButton } from "./ExportButton";
import { Button, Tooltip } from "antd";
import { useTable } from "../../stores/useTable";
import { FullscreenExitOutlined, FullscreenOutlined } from "@ant-design/icons";
import { useEffect, useState } from "react";
const ToggleFullScreenButton = () => {
const {
tableState: { fullScreen },
toggleFullScreen,
} = useTable();
const [hover, setHover] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setHover(false), 1500);
return () => clearTimeout(timer);
}, [hover]);
const handleMouseEnter = () => {
setHover(true);
};
const handleMouseLeave = () => {
setHover(false);
};
const handleClick = (e) => {
e.stopPropagation();
toggleFullScreen();
};
return (
<Tooltip
title={fullScreen ? "Свернуть" : "Раскрыть на полный экран"}
placement={"topRight"}
open={hover}
>
<Button
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{fullScreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
</Button>
</Tooltip>
);
};
export const HeaderWrapper = ({
leftColumn,
rightColumn,
exportProvider,
classes,
}) => {
return (
<div className={"flex items-center w-full justify-between"}>
<div className={classes?.leftColumn}>
<span className="py-[5px]">Таблица атрибутов</span>
{leftColumn}
</div>
<div className={classes?.rightColumn}>
{rightColumn}
<div className="flex items-center gap-x-1">
{exportProvider && <ExportButton provider={exportProvider} />}
<ToggleFullScreenButton />
</div>
</div>
</div>
);
};

@ -0,0 +1,45 @@
import { useQueryClient } from "@tanstack/react-query";
import { usePopup } from "../../../stores/usePopup";
import { useUpdateStatus } from "../../../hooks/useUpdateStatus";
import { Button } from "antd";
import { STATUSES } from "../../../config";
export const ChangeStatusButton = ({
selectedIds,
selectedStatus,
onOpenMakeWorkingModal,
onSuccess,
}) => {
const queryClient = useQueryClient();
const { setPopup } = usePopup();
const { mutate: updateStatus } = useUpdateStatus({
onSuccess: () => {
queryClient.invalidateQueries(["on-approval-points"]);
setPopup(null);
onSuccess();
},
});
const handleClick = (e) => {
e.stopPropagation();
const params = new URLSearchParams({
status: selectedStatus,
"location_ids[]": selectedIds,
});
if (selectedStatus === STATUSES.working) {
onOpenMakeWorkingModal();
} else {
updateStatus(params);
}
};
return (
<>
<Button type="primary" onClick={handleClick}>
Обновить статус
</Button>
</>
);
};

@ -0,0 +1,48 @@
import { useState } from "react";
import { STATUSES } from "../../../config";
import { HeaderWrapper } from "../HeaderWrapper";
import { StatusSelect } from "../../../components/StatusSelect";
import { Button } from "antd";
import { useExportOnApprovalData } from "./useExportOnApprovalData";
import { ChangeStatusButton } from "./ChangeStatusButton";
export const Header = ({
selectedIds,
onClearSelected,
onOpenMakeWorkingModal,
}) => {
const [status, setStatus] = useState(STATUSES.pending);
const handleClear = (e) => {
e.stopPropagation();
onClearSelected();
};
return (
<HeaderWrapper
leftColumn={
selectedIds.length > 0 && (
<>
<StatusSelect value={status} onChange={setStatus} />
<ChangeStatusButton
selectedIds={selectedIds}
selectedStatus={status}
onOpenMakeWorkingModal={onOpenMakeWorkingModal}
onSuccess={onClearSelected}
/>
</>
)
}
rightColumn={
selectedIds.length > 0 && (
<Button onClick={handleClear}>Очистить все</Button>
)
}
classes={{
leftColumn: "flex items-center gap-x-4",
rightColumn: "flex item-center gap-x-4",
}}
exportProvider={useExportOnApprovalData}
/>
);
};

@ -0,0 +1,101 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { getPoints, useUpdatePostamatId } from "../../../../api";
import { Button, Modal } from "antd";
import { MakeWorkingTable } from "./MakeWorkingTable";
import { useEffect, useState } from "react";
import { usePopup } from "../../../../stores/usePopup";
import { useUpdateStatus } from "../../../../hooks/useUpdateStatus";
import { STATUSES } from "../../../../config";
export const MakeWorkingModal = ({ selectedIds, onClose, onSuccess }) => {
const { data } = useQuery(["make-working-table", selectedIds], async () => {
const params = new URLSearchParams({
page: 1,
page_size: 100,
"location_ids[]": selectedIds,
});
return await getPoints(params);
});
const [hasError, setHasError] = useState(false);
const [dataSource, setDataSource] = useState([]);
const [updateError, setUpdateError] = useState(null);
useEffect(() => {
setDataSource(data?.results);
}, [data]);
const queryClient = useQueryClient();
const { setPopup } = usePopup();
const { mutateAsync: updatePostamatId } = useUpdatePostamatId();
const { mutateAsync: updateStatus } = useUpdateStatus({});
const handleUpdate = () => {
const updatePostamatPromises = dataSource.map((it) => {
const params = new URLSearchParams({
id: it.id,
postamat_id: it.postamat_id,
});
return updatePostamatId(params);
});
const updateStatusParams = new URLSearchParams({
status: STATUSES.working,
"location_ids[]": selectedIds,
});
const updateStatusPromise = updateStatus(updateStatusParams);
Promise.all([...updatePostamatPromises, updateStatusPromise])
.then(() => {
queryClient.invalidateQueries(["on-approval-points"]);
setUpdateError(null);
setPopup(null);
onSuccess();
onClose();
})
.catch(() =>
setUpdateError(
"Введенные идентификаторы уже существуют, попробуйте другие"
)
);
};
return (
<Modal
open={true}
title="Укажите идентификаторы постаматов"
onCancel={onClose}
width={800}
footer={[
updateError && (
<span key="error" className="mr-2 text-primary">
{updateError}
</span>
),
<Button
key="ok-button"
type="primary"
onClick={handleUpdate}
disabled={hasError}
>
Обновить статус
</Button>,
]}
>
{dataSource && (
<MakeWorkingTable
data={dataSource}
onChange={setDataSource}
setHasError={setHasError}
/>
)}
</Modal>
);
};

@ -0,0 +1,161 @@
import { Form, InputNumber, Table as AntdTable } from "antd";
import React, { useContext, useEffect, useRef, useState } from "react";
import { columns as defaultColumns } from "./columns";
import "./style.css";
const EditableContext = React.createContext(null);
const EditableRow = ({ index, ...props }) => {
const [form] = Form.useForm();
return (
<Form form={form} component={false}>
<EditableContext.Provider value={form}>
<tr {...props} />
</EditableContext.Provider>
</Form>
);
};
const EditableCell = ({
title,
editable,
children,
dataIndex,
record,
handleSave,
setHasError,
...restProps
}) => {
const [editing, setEditing] = useState(false);
const inputRef = useRef(null);
const form = useContext(EditableContext);
useEffect(() => {
if (editing) {
inputRef.current.focus();
}
}, [editing]);
const toggleEdit = () => {
setEditing(!editing);
form.setFieldsValue({
[dataIndex]: record[dataIndex],
});
};
const save = async () => {
try {
const values = await form.validateFields();
setHasError(false);
toggleEdit();
handleSave({
...record,
...values,
});
} catch (errInfo) {
console.log("Save failed:", errInfo);
setHasError(true);
}
};
let childNode = children;
if (editable) {
childNode = editing ? (
<Form.Item
style={{
margin: 0,
}}
name={dataIndex}
rules={[
{
required: true,
message: `Укажите ${title}`,
},
]}
>
<InputNumber
ref={inputRef}
onPressEnter={save}
onBlur={save}
className="w-full"
min={0}
precision={0}
/>
</Form.Item>
) : (
<div
className="editable-cell-value-wrap"
style={{
paddingRight: 24,
}}
onClick={toggleEdit}
>
{children}
</div>
);
}
return <td {...restProps}>{childNode}</td>;
};
export const MakeWorkingTable = ({ data, onChange, setHasError }) => {
const handleSave = (row) => {
const newData = [...data];
const index = newData.findIndex((item) => row.id === item.id);
const item = newData[index];
newData.splice(index, 1, {
...item,
...row,
});
onChange(newData);
};
const components = {
body: {
row: EditableRow,
cell: EditableCell,
},
};
const columns = defaultColumns.map((col) => {
if (!col.editable) {
return col;
}
return {
...col,
onCell: (record) => ({
record,
editable: col.editable,
dataIndex: col.dataIndex,
title: col.title,
handleSave,
setHasError,
}),
};
});
const SCROLL = {
y: "max-content",
x: "max-content",
};
return (
<AntdTable
components={components}
size="small"
pagination={false}
dataSource={data}
columns={columns}
rowKey="id"
sticky={true}
rowClassName={() => "editable-row"}
className="!max-w-full"
scroll={SCROLL}
/>
);
};

@ -0,0 +1,40 @@
import { STATUS_LABEL_MAPPER } from "../../../../config";
export const columns = [
{
title: "Id",
dataIndex: "id",
key: "id",
width: 50,
},
{
title: "Адрес",
dataIndex: "address",
key: "address",
width: 200,
},
{
title: "Категория",
dataIndex: "category",
key: "category",
width: "120px",
ellipsis: true,
},
{
title: "Статус",
dataIndex: "status",
key: "status",
width: "120px",
ellipsis: true,
render: (_, record) => {
return STATUS_LABEL_MAPPER[record.status];
},
},
{
title: "Id постамата",
dataIndex: "postamat_id",
key: "postamat_id",
width: "120px",
editable: true,
},
];

@ -0,0 +1,17 @@
.editable-cell {
position: relative;
}
.editable-cell-value-wrap {
padding: 5px 12px;
cursor: pointer;
border: 1px solid #d9d9d9;
border-radius: 4px;
min-height: 30px;
}
.editable-row:hover .editable-cell-value-wrap {
padding: 5px 12px;
border: 1px solid #d9d9d9;
border-radius: 2px;
}

@ -0,0 +1,118 @@
import { Table } from "../Table";
import { useQuery } from "@tanstack/react-query";
import { getPoints, useCanEdit } from "../../../api";
import { useCallback, useState } from "react";
import { PAGE_SIZE } from "../constants";
import { STATUSES } from "../../../config";
import { useMergeTableData } from "../useMergeTableData";
import { Header } from "./Header";
import { useOnApprovalPointsFilters } from "../../../stores/useOnApprovalPointsFilters";
import { MakeWorkingModal } from "./MakeWorkingTable/MakeWorkingModal";
import { useColumns } from "../useColumns.jsx";
import { useLayersVisibility } from "../../../stores/useLayersVisibility.js";
import { LAYER_IDS } from "../../../Map/Layers/constants.js";
const extraCols = [
{
title: "Id постамата",
dataIndex: "postamat_id",
key: "postamat_id",
width: "70px",
ellipsis: true,
},
];
export const OnApprovalTable = ({ fullWidth }) => {
const [pageSize, setPageSize] = useState(PAGE_SIZE);
const [page, setPage] = useState(1);
const [selectedIds, setSelectedIds] = useState([]);
const {
filters: { region },
} = useOnApprovalPointsFilters();
const [isMakeWorkingModalOpened, setIsMakeWorkingModalOpened] =
useState(false);
const columns = useColumns(extraCols);
const { isVisible } = useLayersVisibility();
const clearSelected = () => setSelectedIds([]);
const { data, isInitialLoading } = useQuery(
["on-approval-points", page, region, isVisible],
async () => {
const statuses = [];
if (isVisible[LAYER_IDS.approve]) {
statuses.push(STATUSES.onApproval);
}
if (isVisible[LAYER_IDS.working]) {
statuses.push(STATUSES.working);
}
if (isVisible[LAYER_IDS.cancelled]) {
statuses.push(STATUSES.cancelled);
}
const params = new URLSearchParams({
page,
page_size: pageSize,
"status[]":
statuses.length > 0
? statuses
: [STATUSES.onApproval, STATUSES.working, STATUSES.cancelled],
});
if (statuses.length === 0) {
return { count: 0, results: [] };
}
return await getPoints(params, region);
},
{ keepPreviousData: true }
);
const { data: mergedData, isClickedPointLoading } = useMergeTableData(
data,
setPageSize
);
const handlePageChange = useCallback((page) => setPage(page), []);
const rowSelection = {
selectedRowKeys: selectedIds,
onChange: (selectedRowKeys) => setSelectedIds(selectedRowKeys),
hideSelectAll: true,
};
const canEdit = useCanEdit();
return (
<>
<Table
header={
<Header
selectedIds={selectedIds}
onClearSelected={clearSelected}
onOpenMakeWorkingModal={() => setIsMakeWorkingModalOpened(true)}
/>
}
rowSelection={canEdit ? rowSelection : undefined}
data={mergedData}
onPageChange={handlePageChange}
page={page}
pageSize={pageSize}
isClickedPointLoading={isClickedPointLoading}
columns={columns}
fullWidth={fullWidth}
loading={isInitialLoading}
/>
{isMakeWorkingModalOpened && (
<MakeWorkingModal
selectedIds={selectedIds}
onClose={() => setIsMakeWorkingModalOpened(false)}
onSuccess={clearSelected}
/>
)}
</>
);
};

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save