Update status via map popup

dev
Platon Yasev 3 years ago
parent 9a8dacc38a
commit 2b1494993c

@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link href="/favicon.ico" rel="icon"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Постаматы by SpatialTeam</title>
<title>PostNet by Spatial</title>
</head>
<body>
<div id="root"></div>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@ -3,14 +3,13 @@ import Map, { MapProvider } from "react-map-gl";
import { useEffect, useRef, useState } from "react";
import { Sidebar } from "../modules/Sidebar/Sidebar";
import { Layers } from "./Layers/Layers";
import { MapPopup } from "./Popup";
import { MapPopup } from "./Popup/Popup";
import { Basemap } from "./Basemap";
import { SignOut } from "../SignOut";
import debounce from "lodash.debounce";
import { usePopup } from "../stores/usePopup";
import { useClickedPointConfig } from "../stores/useClickedPointConfig";
import { Legend } from "./Legend";
import { AddressSearch } from "../modules/Sidebar/AddressSearch";
import { TableWrapper } from "../modules/Table/TableWrapper";
import { useFilters } from "../stores/useFilters";
import { useMode } from "../stores/useMode";
@ -24,6 +23,7 @@ import { useTable } from "../stores/useTable";
import { twMerge } from "tailwind-merge";
import { useLayersVisibility } from "../stores/useLayersVisibility";
import { LAYER_IDS } from "./Layers/constants";
import { MapHeader } from "./MapHeader";
export const MapComponent = () => {
const mapRef = useRef(null);
@ -38,6 +38,8 @@ export const MapComponent = () => {
useEffect(() => {
setStatus(MODE_TO_STATUS_MAPPER[mode]);
setLayersVisibility(MODE_TO_LAYER_VISIBILITY_MAPPER[mode]);
setPopup(null);
setClickedPointConfig(null);
}, [mode]);
const handleClick = (event) => {
@ -149,13 +151,13 @@ export const MapComponent = () => {
/>
)}
<MapHeader isSidebarCollapsed={isSidebarCollapsed} />
<FiltersButton toggleCollapse={toggleCollapseSidebar} />
<Basemap />
<Layers />
<AddressSearch />
<Legend />
<SignOut />
<LayersControl />

@ -0,0 +1,22 @@
import { Logo } from "../icons/Logo";
import { AddressSearch } from "../modules/Sidebar/AddressSearch";
import { twMerge } from "tailwind-merge";
import { ModeSelector } from "../components/ModeSelector";
export const MapHeader = ({ isSidebarCollapsed }) => {
return (
<div className="absolute top-[20px] left-[30px] flex items-center gap-x-10">
<div
className={twMerge(
"hidden",
isSidebarCollapsed && "flex items-center gap-x-3 "
)}
>
<Logo />
<ModeSelector />
</div>
<AddressSearch />
</div>
);
};

@ -1,215 +0,0 @@
import { Popup } from "react-map-gl";
import { Button, Col, Row } from "antd";
import { twMerge } from "tailwind-merge";
import { usePointSelection } from "../stores/usePointSelection";
import { useEffect, useState } from "react";
import { CATEGORIES, MODES } from "../config";
import { useClickedPointConfig } from "../stores/useClickedPointConfig";
import { useMode } from "../stores/useMode";
import { useUpdateLayerCounter } from "../stores/useUpdateLayerCounter";
import { LAYER_IDS } from "./Layers/constants";
const popupConfig = [
{
name: "Id",
field: "location_id",
},
{
name: "Адрес",
field: "address",
},
{
name: "Район",
field: "rayon_id",
},
{
name: "Округ",
field: "okrug_id",
},
{
name: "Название",
field: "name",
},
{
name: "Категория",
field: "category",
},
{
name: "Статус",
field: "status",
},
{
name: "Прогнозный трафик",
field: "prediction_current",
},
];
const residentialPointConfig = [
{
name: "Id",
field: "location_id",
},
{
name: "Адрес",
field: "address",
},
{
name: "Район",
field: "rayon_id",
},
{
name: "Округ",
field: "okrug_id",
},
{
name: "Название",
field: "name",
},
{
name: "Категория",
field: "category",
},
{
name: "Статус",
field: "status",
},
{
name: "Прогнозный трафик",
field: "prediction_current",
},
{
name: "Кол-во квартир",
field: "flat_cnt",
},
{
name: "Год постройки",
field: "year_bld",
},
{
name: "Кол-во этажей",
field: "levels",
},
{
name: "Материал стен",
field: "mat_nes",
},
];
const PopupWrapper = ({ lat, lng, onClose, children }) => {
return (
<Popup
longitude={lng}
latitude={lat}
onClose={onClose}
closeOnClick={false}
style={{ minWidth: "300px" }}
>
{children}
</Popup>
);
};
const SingleFeaturePopup = ({ feature }) => {
const { include, selection, exclude } = usePointSelection();
const { setClickedPointConfig } = useClickedPointConfig();
const { mode } = useMode();
const doesMatchFilter = feature.layer.id === LAYER_IDS["initial-match"];
const featureId = feature.properties.location_id;
const { updateCounter } = useUpdateLayerCounter();
useEffect(() => setClickedPointConfig(featureId, doesMatchFilter), [feature]);
const isResidential = feature.properties.category === CATEGORIES.residential;
const config = isResidential ? residentialPointConfig : popupConfig;
const isSelected =
(doesMatchFilter || selection.included.has(featureId)) &&
!selection.excluded.has(featureId);
const handleSelect = () => {
if (isSelected) {
exclude(featureId);
} else {
include(featureId);
}
};
return (
<>
<div key={`popup-${updateCounter}`}>
{config.map(({ field, name }) => {
return (
<Row className={twMerge("p-1")} key={field}>
<Col className={"font-semibold"} span={12}>
{name}
</Col>
<Col span={12}>{feature.properties[field]}</Col>
</Row>
);
})}
</div>
{mode === MODES.INITIAL && (
<Button
type="primary"
className="mt-2 mx-auto"
block
onClick={handleSelect}
>
{isSelected ? "Исключить из выборки" : "Добавить в выборку"}
</Button>
)}
</>
);
};
const MultipleFeaturesPopup = ({ features, onSelect }) => {
return (
<div className="space-y-2 p-1">
{features.map((feature) => {
return (
<Button
type={
feature.layer.id === LAYER_IDS["initial-match"] ? "primary" : ""
}
className="flex items-center gap-x-1"
block
onClick={() => onSelect(feature)}
key={feature.properties.location_id}
>
<span>{feature.properties.location_id}</span>
<span>{feature.properties.category}</span>
</Button>
);
})}
</div>
);
};
export const MapPopup = ({ features, lat, lng, onClose }) => {
const [selectedFeature, setSelectedFeature] = useState(null);
const getContent = () => {
if (features.length === 1) {
return <SingleFeaturePopup feature={features[0]} />;
}
if (selectedFeature) {
return <SingleFeaturePopup feature={selectedFeature} />;
}
return (
<MultipleFeaturesPopup
features={features}
onSelect={setSelectedFeature}
/>
);
};
return (
<PopupWrapper lat={lat} lng={lng} onClose={onClose}>
{getContent()}
</PopupWrapper>
);
};

@ -0,0 +1,73 @@
import { Button } from "antd";
import { useState } from "react";
import { MODES } from "../../config";
import { useMode } from "../../stores/useMode";
import { LAYER_IDS } from "../Layers/constants";
import { PopupWrapper } from "./PopupWrapper";
import { InitialPointPopup } from "./mode-popup/InitialPointPopup";
import { ApproveWorkingPointPopup } from "./mode-popup/ApproveWorkingPointPopup";
import { WorkingPointPopup } from "./mode-popup/WorkingPointPopup";
const SingleFeaturePopup = ({ feature }) => {
const { mode } = useMode();
if (mode === MODES.APPROVE_WORKING) {
return <ApproveWorkingPointPopup feature={feature} />;
}
if (mode === MODES.WORKING) {
return <WorkingPointPopup feature={feature} />;
}
return <InitialPointPopup feature={feature} />;
};
const MultipleFeaturesPopup = ({ features, onSelect }) => {
return (
<div className="space-y-2 p-1">
{features.map((feature) => {
return (
<Button
type={
feature.layer.id === LAYER_IDS["initial-match"] ? "primary" : ""
}
className="flex items-center gap-x-1"
block
onClick={() => onSelect(feature)}
key={feature.properties.location_id}
>
<span>{feature.properties.location_id}</span>
<span>{feature.properties.category}</span>
</Button>
);
})}
</div>
);
};
export const MapPopup = ({ features, lat, lng, onClose }) => {
const [selectedFeature, setSelectedFeature] = useState(null);
const getContent = () => {
if (features.length === 1) {
return <SingleFeaturePopup feature={features[0]} />;
}
if (selectedFeature) {
return <SingleFeaturePopup feature={selectedFeature} />;
}
return (
<MultipleFeaturesPopup
features={features}
onSelect={setSelectedFeature}
/>
);
};
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,46 @@
import { useClickedPointConfig } from "../../../stores/useClickedPointConfig";
import { useEffect, useState } from "react";
import { FeatureProperties } from "./FeatureProperties";
import { Title } from "../../../components/Title";
import { StatusSelect } from "../../../modules/Table/ApproveAndWorkingTable/ApproveAndWorkingTable";
import { useQueryClient } from "@tanstack/react-query";
import { useUpdateStatus } from "../../../hooks/useUpdateStatus";
export const ApproveWorkingPointPopup = ({ feature }) => {
const featureId = feature.properties.location_id;
const { setClickedPointConfig } = useClickedPointConfig();
const [status, setStatus] = useState(feature.properties.status);
useEffect(() => setClickedPointConfig(featureId, false), [feature]);
const queryClient = useQueryClient();
const { mutate: updateStatus } = useUpdateStatus({
onSuccess: () => {
queryClient.invalidateQueries(["approve-working-points"]);
},
});
const handleStatusChange = (value) => {
setStatus(value);
const params = new URLSearchParams({
status: value,
"location_ids[]": [featureId],
});
updateStatus(params);
};
return (
<>
<FeatureProperties feature={feature} dynamicStatus={status} />
<div className="flex justify-center mt-4">
<div className={"flex flex-col items-center"}>
<Title text="Сменить статус" />
<StatusSelect value={status} onChange={handleStatusChange} />
</div>
</div>
</>
);
};

@ -0,0 +1,29 @@
import { CATEGORIES } from "../../../config";
import { popupConfig, residentialPointConfig } from "./config";
import { Col, Row } from "antd";
import { twMerge } from "tailwind-merge";
export const FeatureProperties = ({ feature, dynamicStatus }) => {
const isResidential = feature.properties.category === CATEGORIES.residential;
const config = isResidential ? residentialPointConfig : popupConfig;
return (
<div>
{config.map(({ field, name }) => {
const value =
dynamicStatus && field === "status"
? dynamicStatus
: feature.properties[field];
return (
<Row className={twMerge("p-1")} key={field}>
<Col className={"font-semibold"} span={12}>
{name}
</Col>
<Col span={12}>{value}</Col>
</Row>
);
})}
</div>
);
};

@ -0,0 +1,41 @@
import { usePointSelection } from "../../../stores/usePointSelection";
import { useClickedPointConfig } from "../../../stores/useClickedPointConfig";
import { LAYER_IDS } from "../../Layers/constants";
import { useEffect } from "react";
import { FeatureProperties } from "./FeatureProperties";
import { Button } from "antd";
export const InitialPointPopup = ({ feature }) => {
const { include, selection, exclude } = usePointSelection();
const { setClickedPointConfig } = useClickedPointConfig();
const doesMatchFilter = feature.layer.id === LAYER_IDS["initial-match"];
const featureId = feature.properties.location_id;
useEffect(() => setClickedPointConfig(featureId, doesMatchFilter), [feature]);
const isSelected =
(doesMatchFilter || selection.included.has(featureId)) &&
!selection.excluded.has(featureId);
const handleSelect = () => {
if (isSelected) {
exclude(featureId);
} else {
include(featureId);
}
};
return (
<>
<FeatureProperties feature={feature} />
<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.location_id;
const { setClickedPointConfig } = useClickedPointConfig();
useEffect(() => setClickedPointConfig(featureId, false), [feature]);
return <FeatureProperties feature={feature} dynamicStatus={status} />;
};

@ -0,0 +1,85 @@
export const popupConfig = [
{
name: "Id",
field: "location_id",
},
{
name: "Адрес",
field: "address",
},
{
name: "Район",
field: "rayon_id",
},
{
name: "Округ",
field: "okrug_id",
},
{
name: "Название",
field: "name",
},
{
name: "Категория",
field: "category",
},
{
name: "Статус",
field: "status",
},
{
name: "Прогнозный трафик",
field: "prediction_current",
},
];
export const residentialPointConfig = [
{
name: "Id",
field: "location_id",
},
{
name: "Адрес",
field: "address",
},
{
name: "Район",
field: "rayon_id",
},
{
name: "Округ",
field: "okrug_id",
},
{
name: "Название",
field: "name",
},
{
name: "Категория",
field: "category",
},
{
name: "Статус",
field: "status",
},
{
name: "Прогнозный трафик",
field: "prediction_current",
},
{
name: "Кол-во квартир",
field: "flat_cnt",
},
{
name: "Год постройки",
field: "year_bld",
},
{
name: "Кол-во этажей",
field: "levels",
},
{
name: "Материал стен",
field: "mat_nes",
},
];

@ -1,4 +1,8 @@
import axios from "axios";
import { useQuery } from "@tanstack/react-query";
import { STATUSES } from "./config";
import { useFilters } from "./stores/useFilters";
import { usePointSelection } from "./stores/usePointSelection";
export const BASE_URL = "https://postnet-dev.selftech.ru";
@ -48,3 +52,46 @@ export const exportPoints = async (params, region) => {
return data;
};
export const useGetTotalInitialPointsCount = () => {
return useQuery(
["all-initial-count"],
async () => {
const params = new URLSearchParams({
page: 1,
page_size: 1,
"status[]": [STATUSES.initial],
});
return await getPoints(params);
},
{ select: (data) => data.count }
);
};
export const useGetFilteredInitialPointsCount = () => {
const { filters } = useFilters();
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.initial],
"categories[]": categories,
"included[]": includedIds,
});
return await getPoints(params, region);
},
{ select: (data) => data.count }
);
};

@ -0,0 +1,43 @@
import { useMode } from "../stores/useMode";
import { Button } 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 (
<>
<Button
icon={<AIIcon />}
type={getType(MODES.INITIAL)}
onClick={() => handleClick(MODES.INITIAL)}
title="Локации к рассмотрению"
/>
<Button
icon={<ApproveIcon />}
type={getType(MODES.APPROVE_WORKING)}
onClick={() => handleClick(MODES.APPROVE_WORKING)}
title="Локации на согласовании"
/>
<Button
icon={<WorkingIcon />}
type={getType(MODES.WORKING)}
onClick={() => handleClick(MODES.WORKING)}
title="Локации в работе"
/>
</>
);
};

@ -0,0 +1,50 @@
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="11.4,3.1 11.4,4.6 11.4,17.8 11.1,18.6 10.9,19.2 10.4,19.8 9.9,20.1 9.2,20.3 8.6,20.3 8.2,20.3
7.6,20.3 7,19.8 6.6,19.3 6.2,18.7 5.4,18.7 4.9,18.4 4.4,18 4.1,17.6 3.9,17.1 3.9,16 3.4,15.4 3,14.9 2,13.4 1.8,12.6 1.4,11.8
1.4,9.9 1.4,9 1.9,8.3 2.5,7.7 3,7.3 2.9,6.5 2.8,5.7 3,5.1 3.4,4.7 4.1,4.3 5,4.1 5.6,4 5.6,3.3 5.8,2.7 6.4,1.9 7,1.3 7.6,1.1
8.4,0.9 8.9,0.9 9.4,1.1 10.1,1.5 10.8,2.1 "
/>
<polyline className="ai-st0" points="17.4,3 16.1,4.7 11.5,4.6 " />
<polyline className="ai-st0" points="11.2,8.7 16,8.6 19.8,12.3 " />
<line className="ai-st0" x1="11.2" y1="12.2" x2="13.4" y2="12.2" />
<polyline className="ai-st0" points="11.4,16.6 14.8,16.6 16.4,18.2 " />
<g>
<circle className="ai-st1" cx="18.1" cy="2" r="1.9" />
<circle className="ai-st2" cx="18.1" cy="1.9" r="1.1" />
</g>
<g>
<circle className="ai-st1" cx="18.5" cy="6.5" r="1.9" />
<circle className="ai-st2" cx="18.5" cy="6.4" r="1.1" />
</g>
<g>
<circle className="ai-st1" cx="20.8" cy="13.3" r="1.9" />
<circle className="ai-st2" cx="20.9" cy="13.3" r="1.1" />
</g>
<g>
<circle className="ai-st1" cx="15" cy="12.2" r="1.9" />
<circle className="ai-st2" cx="15" cy="12.1" r="1.1" />
</g>
<g>
<circle className="ai-st1" cx="17.4" cy="19.2" r="1.9" />
<circle className="ai-st2" cx="17.4" cy="19.2" r="1.1" />
</g>
<g>
<circle className="ai-st1" cx="13" cy="22" r="1.9" />
<circle className="ai-st2" cx="13.1" cy="22" r="1.1" />
</g>
<polyline
className="ai-st0"
points="5.9,8.1 4.5,9.5 4.4,11.2 4.8,12.4 6.5,14.8 "
/>
</svg>
);
};

@ -0,0 +1,65 @@
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}
className="ml-[2px] mt-[2px]"
>
<polyline
className="approve-st0"
points="-273.7,390.2 -273.7,388.3 -274,387.5 -274.7,387 -275.8,387 -290.4,387 -291.1,387.2 -291.8,387.6
-292.2,388.3 -292.3,403.6 -292.2,404.4 -291.9,404.9 -291.3,405.2 -290.8,405.3 -275.9,405.3 -275.1,405.2 -274.3,404.9
-273.8,404.3 -273.8,402.3 "
/>
<path
className="approve-st1"
d="M-287.2,397h-1.2c-0.4,0-0.7-0.3-0.7-0.7v-1.2c0-0.4,0.3-0.7,0.7-0.7h1.2c0.4,0,0.7,0.3,0.7,0.7v1.2
C-286.6,396.7-286.9,397-287.2,397z"
/>
<path
className="approve-st1"
d="M-287.2,400.5h-1.2c-0.4,0-0.7-0.3-0.7-0.7v-1.2c0-0.4,0.3-0.7,0.7-0.7h1.2c0.4,0,0.7,0.3,0.7,0.7v1.2
C-286.5,400.2-286.8,400.5-287.2,400.5z"
/>
<polygon
className="approve-st2"
points="-288.7,390.7 -288.3,391.2 -288.2,391.3 -288,391.3 -287.6,391.2 -287.2,390.7 -286.7,390.3
-286.5,390.3 -286.2,390.3 -286,390.5 -285.9,390.9 -286,391.1 -286.4,391.6 -287.9,392.9 -288.1,392.9 -288.3,392.9 -288.6,392.6
-289.6,391.6 -289.8,391.4 -289.8,391 -289.6,390.7 -289.4,390.6 -289.1,390.5 -288.9,390.5 "
/>
<polygon
className="approve-st3"
points="-283.9,390.9 -279.1,390.9 -278.8,391.1 -278.7,391.3 -278.6,391.8 -278.7,392 -279,392.4
-279.2,392.5 -279.6,392.5 -284,392.5 -284.2,392.5 -284.5,392.3 -284.7,391.9 -284.7,391.7 -284.6,391.5 -284.3,391.1
-284.1,390.9 "
/>
<polygon
className="approve-st3"
points="-284,394.4 -280,394.4 -279.8,394.6 -279.7,394.8 -279.6,395.2 -279.7,395.5 -279.9,395.9 -280.1,396
-280.4,396 -284.1,396 -284.3,395.9 -284.5,395.7 -284.7,395.4 -284.7,395.2 -284.6,394.9 -284.4,394.6 -284.2,394.4 "
/>
<polygon
className="approve-st3"
points="-284.1,397.9 -280.9,397.9 -280.7,398.1 -280.6,398.3 -280.6,398.7 -280.6,399 -280.8,399.4
-280.9,399.5 -281.2,399.5 -284.2,399.5 -284.3,399.4 -284.5,399.2 -284.7,398.9 -284.7,398.7 -284.6,398.4 -284.4,398.1
-284.3,397.9 "
/>
<polygon
className="approve-st2"
points="-274.2,391.7 -273.4,391.6 -273.1,391.7 -273,392.7 -272.6,392.8 -272.1,393 -271.6,393.2
-271.1,392.6 -270.7,392.8 -270.4,393.1 -270.1,393.5 -270,393.7 -270.6,394.2 -270.5,394.6 -270.4,394.9 -270.3,395.4
-269.5,395.4 -269.4,395.7 -269.4,396.2 -269.4,396.4 -269.6,396.7 -269.6,396.9 -270.4,396.8 -270.5,397.3 -270.6,397.7
-270.8,397.9 -270.1,398.6 -270.3,398.9 -270.6,399.2 -271.1,399.5 -271.2,399.6 -271.7,399 -271.7,398.9 -272.1,399 -272.4,399.1
-272.8,399.2 -273,399.3 -273,400 -273.2,400.2 -274.2,400.2 -274.4,400 -274.4,399.3 -274.4,399.1 -275,398.9 -275.5,398.7
-275.9,399.4 -276.2,399.5 -276.5,399.3 -277.1,398.8 -277,398.5 -276.4,397.9 -276.8,397.5 -276.9,397.2 -277.1,396.8
-277.9,396.8 -278,396.3 -278,395.6 -277.8,395.3 -277,395.3 -276.7,394.6 -276.6,394.2 -276.9,393.9 -277.1,393.6 -277.2,393.5
-277.1,393.2 -276.7,392.7 -276.5,392.6 -276.2,392.7 -275.4,393.2 -275.1,392.9 -274.7,392.8 -274.5,392.7 -274.5,391.8 "
/>
<circle className="approve-st4" cx="-273.8" cy="396" r="1.8" />
</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 = 24, height = 24 }) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width={width}
height={height}
>
<g>
<polygon
style={{ 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: "#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,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: #fa0000;
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;
}

@ -34,6 +34,10 @@
border-bottom: none;
}
.ant-select-multiple .ant-select-selection-item {
@apply !bg-blue;
}
.mapboxgl-ctrl-group,
.maplibregl-ctrl-group {
@apply bg-white-background;

@ -59,7 +59,7 @@ export const AddressSearch = () => {
};
return (
<div className="absolute top-[20px] left-[30px]">
<div>
<AutoComplete
options={options}
style={{

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

@ -1,50 +1,12 @@
import { Button } from "antd";
import { MODES } from "../../config";
import { useMode } from "../../stores/useMode";
import {
GiGlowingArtifact,
IoAnalyticsSharp,
TbTransform,
} from "react-icons/all";
import { Logo } from "../../icons/Logo";
import { ModeSelector } from "../../components/ModeSelector";
export const Header = () => {
const { mode, setMode } = useMode();
const handleClick = (selectedMode) => {
setMode(selectedMode);
};
const getType = (currentMode) => {
if (currentMode === mode) return "primary";
return "default";
};
return (
<div className="mb-4 flex items-center justify-between">
LOGO
<Logo />
<div className={"flex items-center gap-x-3"}>
<Button
type={getType(MODES.INITIAL)}
className="flex items-center justify-center p-3"
onClick={() => handleClick(MODES.INITIAL)}
>
<GiGlowingArtifact />
</Button>
<Button
type={getType(MODES.APPROVE_WORKING)}
className="flex items-center justify-center p-3"
onClick={() => handleClick(MODES.APPROVE_WORKING)}
>
<TbTransform />
</Button>
<Button
type={getType(MODES.WORKING)}
className="flex items-center justify-center p-3"
onClick={() => handleClick(MODES.WORKING)}
>
<IoAnalyticsSharp />
</Button>
<ModeSelector />
</div>
</div>
);

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

@ -9,6 +9,7 @@ import { Header } from "./Header";
import { useMode } from "../../stores/useMode";
import { MODES } from "../../config";
import { twMerge } from "tailwind-merge";
import { SelectedLocations } from "./InitialSidebar/SelectedLocations";
export const Sidebar = ({ isCollapsed }) => {
const hasManualEdits = useHasManualEdits();
@ -34,7 +35,14 @@ export const Sidebar = ({ isCollapsed }) => {
) : null}
</div>
<div>{mode === MODES.INITIAL && <TakeToWorkButton />}</div>
<div>
{mode === MODES.INITIAL && (
<>
<SelectedLocations />
<TakeToWorkButton />
</>
)}
</div>
</div>
</div>
);

@ -11,6 +11,7 @@ import { useUpdateStatus } from "../../../hooks/useUpdateStatus";
import { HeaderWrapper } from "../HeaderWrapper";
import { useExportApproveAndWorkingData } from "./useExportApproveAndWorkingData";
import { useFilters } from "../../../stores/useFilters";
import { usePopup } from "../../../stores/usePopup";
const statusOptions = [
{ label: STATUSES.approve, value: STATUSES.approve },
@ -18,7 +19,7 @@ const statusOptions = [
{ label: STATUSES.initial, value: STATUSES.initial },
];
const StatusSelect = ({ value, onChange, disabled }) => {
export const StatusSelect = ({ value, onChange, disabled }) => {
const handleClick = (e) => e.stopPropagation();
const handleChange = (value) => {
@ -42,10 +43,12 @@ const StatusSelect = ({ value, onChange, disabled }) => {
const ChangeStatusButton = ({ selectedIds, selectedStatus }) => {
const queryClient = useQueryClient();
const { setPopup } = usePopup();
const { mutate: updateStatus } = useUpdateStatus({
onSuccess: () => {
queryClient.invalidateQueries(["approve-working-points"]);
setPopup(null);
},
});

@ -13,7 +13,7 @@ export const InitialTable = ({ fullWidth }) => {
const [page, setPage] = useState(1);
const { data, pageSize, isClickedPointLoading, isDataLoading } =
useInitialTableData(page);
useInitialTableData(page, () => setPage(1));
const getSelectedRowKeys = useCallback(() => {
const ids = data?.results.map((item) => item.location_id) ?? [];

@ -7,14 +7,18 @@ import { getPoints } from "../../../api";
import { useMergeTableData } from "../useMergeTableData";
import { STATUSES } from "../../../config";
export const useInitialTableData = (page) => {
export const useInitialTableData = (page, resetPage) => {
const [pageSize, setPageSize] = useState(PAGE_SIZE);
const { filters } = useFilters();
const { prediction, categories, region } = filters;
const { selection } = usePointSelection();
const {
selection: { included },
} = usePointSelection();
const includedIds = [...included];
const { data, isInitialLoading } = useQuery(
["table", page, filters, selection],
["table", page, filters, includedIds],
async () => {
const params = new URLSearchParams({
page,
@ -22,12 +26,19 @@ export const useInitialTableData = (page) => {
"prediction_current[]": prediction,
"status[]": [STATUSES.initial],
"categories[]": categories,
"included[]": [...selection.included],
"included[]": includedIds,
});
return await getPoints(params, region);
},
{ keepPreviousData: true }
{
keepPreviousData: true,
onError: (err) => {
if (err.response.data.detail === "Неправильная страница") {
resetPage();
}
},
}
);
const { data: mergedData, isClickedPointLoading } = useMergeTableData(

@ -40,11 +40,15 @@ export const useMergeTableData = (fullData, onPageSizeChange) => {
if (!clickedPointData) return;
onPageSizeChange((prevState) => prevState + 1);
setMergedData((prevState) => ({
...prevState,
count: prevState.count + 1,
results: [clickedPointData.results[0], ...prevState.results],
}));
setMergedData((prevState) => {
if (prevState) {
return {
...prevState,
count: prevState.count + 1,
results: [clickedPointData.results[0], ...prevState.results],
};
}
});
}, [clickedPointData]);
// reset data after popup disappeared

Loading…
Cancel
Save