commit
54bc1604a8
@ -0,0 +1,4 @@
|
||||
.gitignore
|
||||
.gitlab-ci.yml
|
||||
README.md
|
||||
.git/*
|
||||
@ -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
|
After Width: | Height: | Size: 641 B |
|
After Width: | Height: | Size: 15 KiB |
@ -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>
|
||||
|
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
|
||||
|
||||
|
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,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,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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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,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()}`
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
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,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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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,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>
|
||||
);
|
||||
};
|
||||
@ -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,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…
Reference in new issue