From 2709d4567295a760473da59f4b8056329c7e9c0d Mon Sep 17 00:00:00 2001 From: Timofey Malinin Date: Tue, 17 Oct 2023 05:03:37 +0000 Subject: [PATCH 1/4] hide category image --- .gitlab-ci.yml | 18 +- dist/favicon.ico | Bin 15406 -> 2166 bytes dist/index.html | 1 - index.html | 1 - package.json | 3 + public/favicon.ico | Bin 15406 -> 2166 bytes src/App.jsx | 11 + src/Map/Header.jsx | 6 +- src/Map/LastMLRun.jsx | 57 +++ src/Map/Layers/CancelledPoints.jsx | 4 +- src/Map/Layers/FilteredWorkingPoints.jsx | 33 +- src/Map/Layers/Layers.jsx | 36 +- src/Map/Layers/OnApprovalPoints.jsx | 4 +- src/Map/Layers/OtherPostamates.jsx | 20 +- src/Map/Layers/PVZ.jsx | 20 +- src/Map/Layers/PendingPoints.jsx | 39 +- src/Map/Layers/Points.jsx | 13 +- src/Map/Layers/WorkingPoints.jsx | 19 +- src/Map/Layers/constants.js | 2 + src/Map/Layers/layers-config.js | 56 ++- src/Map/LayersControl/LayersControl.jsx | 4 +- src/Map/LayersControl/LayersVisibility.jsx | 42 +- src/Map/Legend.jsx | 97 +++-- src/Map/MapComponent.jsx | 74 +++- src/Map/PointChart.jsx | 168 ++++++++ src/Map/Popup/Popup.jsx | 121 ++++-- src/Map/Popup/PopupWrapper.jsx | 2 +- .../Popup/mode-popup/FeatureProperties.jsx | 79 +++- .../Popup/mode-popup/OnApprovalPointPopup.jsx | 5 +- .../Popup/mode-popup/PendingPointPopup.jsx | 43 +- .../Popup/mode-popup/WorkingPointPopup.jsx | 4 +- src/Map/Popup/mode-popup/config.js | 21 +- src/Map/TrafficModal.jsx | 79 ++++ src/SignOut.jsx | 2 +- src/api.js | 304 +++++++++++++-- src/assets/logopng.png | Bin 0 -> 7676 bytes src/components/ModeSelector.jsx | 3 +- src/components/RegionSelect.jsx | 4 +- src/components/Title.jsx | 4 +- src/hooks/useLocalStorage.js | 25 ++ src/hooks/useUpdateStatus.js | 5 +- src/icons/Logo.jsx | 55 ++- src/icons/PanoramaIcon.jsx | 12 + src/icons/icons-config.js | 2 +- src/index.css | 33 +- src/modules/ImportMode/LoadingStage.jsx | 52 +++ src/modules/ImportMode/MergePointsModal.jsx | 97 +++++ .../ImportMode/PointsFileUploadModal.jsx | 114 ++++++ src/modules/ImportMode/ReportStage.jsx | 34 ++ src/modules/ImportMode/SidebarButtons.jsx | 57 +++ .../AdvancedFilters/AdvancedFilters.jsx | 227 +++++++++++ .../AdvancedFiltersWrapper.jsx | 99 +++++ .../AdvancedFilters/Slider.jsx | 44 +++ .../PendingPointsFilters.jsx | 79 ++-- .../SelectedLocations.jsx | 4 +- .../PendingPointsFilters/TakeToWorkButton.jsx | 5 +- src/modules/Sidebar/Sidebar.jsx | 2 + .../WorkingPointsFilters.jsx | 18 +- src/modules/Table/HeaderWrapper.jsx | 3 + src/modules/Table/OnApprovalTable/Header.jsx | 2 + .../Table/OnApprovalTable/OnApprovalTable.jsx | 26 +- .../useExportOnApprovalData.js | 5 +- .../Table/PendingTable/PendingTable.jsx | 22 +- .../PendingTable/useExportPendingData.js | 41 +- .../Table/PendingTable/usePendingTableData.js | 56 ++- .../PendingTable/usePendingTableFields.jsx | 75 ++++ src/modules/Table/Table.css | 4 + src/modules/Table/Table.jsx | 8 +- src/modules/Table/TableSettings.jsx | 79 ++++ .../Table/WorkingTable/WorkingTable.jsx | 21 +- src/modules/Table/WorkingTable/useColumns.jsx | 84 +++- src/modules/Table/useColumns.jsx | 366 +++++++++++++++++- src/modules/Table/useMergeTableData.js | 2 +- src/stores/useLayersVisibility.js | 6 +- src/stores/useMode.js | 6 + src/stores/usePendingPointsFilters.js | 99 +++++ src/stores/useWorkingPointsFilters.js | 13 + src/utils.js | 124 ++++++ 78 files changed, 2993 insertions(+), 412 deletions(-) create mode 100644 src/Map/LastMLRun.jsx create mode 100644 src/Map/PointChart.jsx create mode 100644 src/Map/TrafficModal.jsx create mode 100644 src/assets/logopng.png create mode 100644 src/hooks/useLocalStorage.js create mode 100644 src/icons/PanoramaIcon.jsx create mode 100644 src/modules/ImportMode/LoadingStage.jsx create mode 100644 src/modules/ImportMode/MergePointsModal.jsx create mode 100644 src/modules/ImportMode/PointsFileUploadModal.jsx create mode 100644 src/modules/ImportMode/ReportStage.jsx create mode 100644 src/modules/ImportMode/SidebarButtons.jsx create mode 100644 src/modules/Sidebar/PendingPointsFilters/AdvancedFilters/AdvancedFilters.jsx create mode 100644 src/modules/Sidebar/PendingPointsFilters/AdvancedFilters/AdvancedFiltersWrapper.jsx create mode 100644 src/modules/Sidebar/PendingPointsFilters/AdvancedFilters/Slider.jsx create mode 100644 src/modules/Table/PendingTable/usePendingTableFields.jsx create mode 100644 src/modules/Table/TableSettings.jsx diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 94ee432..1c18545 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -32,6 +32,19 @@ build-docker-prod: -t ${DOCKER_IMAGE_TAG}-prod . - docker push ${DOCKER_IMAGE_TAG}-prod +auto-deploy-dev-kuber: + extends: .deploy_base_kuber + variables: + INGRESS_HOST: "postnet.dev.selftech.ru" + DEPLOY_DOCKER_IMAGE: ${DOCKER_IMAGE_TAG}-dev + tags: + - docker + only: + refs: + - dev + environment: + name: dev + deploy-dev-kuber: extends: .deploy_base_kuber variables: @@ -39,8 +52,11 @@ deploy-dev-kuber: DEPLOY_DOCKER_IMAGE: ${DOCKER_IMAGE_TAG}-dev tags: - docker + except: + - dev environment: name: dev + when: manual deploy-prod-kuber: extends: .deploy_base_kuber @@ -51,6 +67,7 @@ deploy-prod-kuber: - docker-prod environment: name: prod + when: manual .deploy_base_kuber: image: ${YC_CONTAINER_REGISTRY}/public/helm-kubectl-git:1.0.0 @@ -69,7 +86,6 @@ deploy-prod-kuber: paths: - ./deploy/front.yaml expire_in: 1 week - when: manual # variables: # APP_NAME: postamates_front diff --git a/dist/favicon.ico b/dist/favicon.ico index ea447bb2f53975d27abb0cda9557df1767e3a5bf..989ad19ea3e9779f792ed4675753ad72d89636ff 100644 GIT binary patch literal 2166 zcmeH}NpsUM6vzLT+r(^5(=1Sy1j@ee`;ubCF5u9laNx=}X+K&{T+#%X;ReivmeO)7 zw;mY8d$Jv?jdSC|Oyzlgl74@^CutPLfJING0(v`m>i}f{=g6oFkI1;6OWW2id|Bkk z>V+Ppwl3sU7cQB=xYh_{(871fd=E`)mFjDx>A}l?KvS&1Ep)IzeRb+roI_i&i@^Cj$jNKuv8>@W%HlE_*)KYuEG@$=cSzgS z^N^(u0%DS4D5Z?e-L3BK!2huWPh7W_US@-Zr+_=M1kKiC;PeRoGH5)Xr_1MTI%oXC zqf08CYwXKWJi?=RjOF>%_ya5-rtt*J2PxdZ^8PS4asA#9H?j2Y4sqTVUq;2hvy(py z3@rWIL)^sDpWDfA-tyw$BHAeeq;Oi85}(1oJ<7%)opwRD3u*U;9W5}VXIJd z3_<;}!5NGE6!9d&rG#rNa+Aylf{OzlV*9{^=CR;H%p;8bA&w)&=Oe~K?As=w^T+2q z!F%?`T&Fk#oG|g2J-|A}DgOObd11na5MyFIYY2`@N&J|JGZ|A*A$|l_GCD)W6de5r ucMN|MWS4+k8>sey+9t5L1*qS!)F0UNzm`>MDW_Dss#L$G)aIg6TYmv9`Yr_k literal 15406 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 F{2!lqJL3QV diff --git a/dist/index.html b/dist/index.html index c1a816c..6193ba1 100644 --- a/dist/index.html +++ b/dist/index.html @@ -2,7 +2,6 @@ - PostNet by Spatial diff --git a/index.html b/index.html index 1f3988b..f4f4a73 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,6 @@ - PostNet by Spatial diff --git a/package.json b/package.json index 0e7c2d0..97481eb 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@watergis/maplibre-gl-export": "^1.3.7", "antd": "^4.23.6", "axios": "^1.1.3", + "chart.js": "^4.4.0", "immer": "^9.0.19", "immutable": "^4.3.0", "lodash.debounce": "^4.0.8", @@ -24,6 +25,8 @@ "maplibre-gl": "^2.4.0", "nanostores": "^0.7.3", "react": "^18.2.0", + "react-beautiful-dnd": "^13.1.1", + "react-chartjs-2": "^5.2.0", "react-dom": "^18.2.0", "react-icons": "^4.8.0", "react-map-gl": "^7.0.19", diff --git a/public/favicon.ico b/public/favicon.ico index ea447bb2f53975d27abb0cda9557df1767e3a5bf..989ad19ea3e9779f792ed4675753ad72d89636ff 100644 GIT binary patch literal 2166 zcmeH}NpsUM6vzLT+r(^5(=1Sy1j@ee`;ubCF5u9laNx=}X+K&{T+#%X;ReivmeO)7 zw;mY8d$Jv?jdSC|Oyzlgl74@^CutPLfJING0(v`m>i}f{=g6oFkI1;6OWW2id|Bkk z>V+Ppwl3sU7cQB=xYh_{(871fd=E`)mFjDx>A}l?KvS&1Ep)IzeRb+roI_i&i@^Cj$jNKuv8>@W%HlE_*)KYuEG@$=cSzgS z^N^(u0%DS4D5Z?e-L3BK!2huWPh7W_US@-Zr+_=M1kKiC;PeRoGH5)Xr_1MTI%oXC zqf08CYwXKWJi?=RjOF>%_ya5-rtt*J2PxdZ^8PS4asA#9H?j2Y4sqTVUq;2hvy(py z3@rWIL)^sDpWDfA-tyw$BHAeeq;Oi85}(1oJ<7%)opwRD3u*U;9W5}VXIJd z3_<;}!5NGE6!9d&rG#rNa+Aylf{OzlV*9{^=CR;H%p;8bA&w)&=Oe~K?As=w^T+2q z!F%?`T&Fk#oG|g2J-|A}DgOObd11na5MyFIYY2`@N&J|JGZ|A*A$|l_GCD)W6de5r ucMN|MWS4+k8>sey+9t5L1*qS!)F0UNzm`>MDW_Dss#L$G)aIg6TYmv9`Yr_k literal 15406 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 F{2!lqJL3QV diff --git a/src/App.jsx b/src/App.jsx index 1e17ddf..bee1555 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -10,6 +10,7 @@ import { usePointSelection } from "./stores/usePointSelection"; import { usePendingPointsFilters } from "./stores/usePendingPointsFilters"; import { useOnApprovalPointsFilters } from "./stores/useOnApprovalPointsFilters"; import { useWorkingPointsFilters } from "./stores/useWorkingPointsFilters"; +import useLocalStorage from "./hooks/useLocalStorage.js"; const queryClient = new QueryClient(); @@ -22,7 +23,17 @@ if (import.meta.env.MODE === "development") { mountStoreDevtool("PointSelection", usePointSelection); } +const version = '0.0.9'; + function App() { + + const [versionControl, setVersionControl] = useLocalStorage('version_control', version); + + if (versionControl !== version) { + localStorage.clear(); + setVersionControl(version); + } + return ( diff --git a/src/Map/Header.jsx b/src/Map/Header.jsx index d821ded..c81ec01 100644 --- a/src/Map/Header.jsx +++ b/src/Map/Header.jsx @@ -1,4 +1,4 @@ -import { Logo } from "../icons/Logo"; +import { HeaderLogo } from "../icons/Logo"; import { AddressSearch } from "./AddressSearch"; import { twMerge } from "tailwind-merge"; import { ModeSelector } from "../components/ModeSelector"; @@ -6,8 +6,8 @@ import { ModeSelector } from "../components/ModeSelector"; export const Header = () => { return (
-
- +
+
diff --git a/src/Map/LastMLRun.jsx b/src/Map/LastMLRun.jsx new file mode 100644 index 0000000..c2c41ef --- /dev/null +++ b/src/Map/LastMLRun.jsx @@ -0,0 +1,57 @@ +import { startML, useLastMLRun } from "../api.js"; +import { Button, Popover, Spin, Tooltip } from "antd"; +import { InfoCircleOutlined, LoadingOutlined } from "@ant-design/icons"; +import { useMemo } from "react"; + +const TASK_STATUSES = { + finished: "Перерасчет ML завершен" +} +export function LastMLRun() { + const { data } = useLastMLRun(); + const hasFinishedUpdate = useMemo(() => { + return data?.task_status === TASK_STATUSES.finished + }, [data]); + + const lastMLRunRender = () => { + if (hasFinishedUpdate) return ( + <> +
+ Последнее обновление системы +
+
+ {new Date(data?.last_time).toLocaleString('ru-RU')} +
+ + + ); + + return ( +
+
Идет обновление системы...
+ +
+ ) + + }; + + return ( + + + + + + + ); +} \ No newline at end of file diff --git a/src/Map/Layers/CancelledPoints.jsx b/src/Map/Layers/CancelledPoints.jsx index 6cc6be6..a26c423 100644 --- a/src/Map/Layers/CancelledPoints.jsx +++ b/src/Map/Layers/CancelledPoints.jsx @@ -6,6 +6,7 @@ import { useRegionFilterExpression } from "./useRegionFilterExpression"; import { LAYER_IDS } from "./constants"; import { useMode } from "../../stores/useMode"; import { useOnApprovalPointsFilters } from "../../stores/useOnApprovalPointsFilters"; +import { useSourceLayerName } from "../../api.js"; const statusExpression = ["==", ["get", "status"], STATUSES.cancelled]; @@ -17,6 +18,7 @@ export const CancelledPoints = () => { const regionFilterExpression = useRegionFilterExpression(region); const { mode } = useMode(); + const layerName = useSourceLayerName(); const getFilter = () => { if (mode === MODES.ON_APPROVAL) { @@ -34,7 +36,7 @@ export const CancelledPoints = () => { {...cancelledPointLayer} id={LAYER_IDS.cancelled} source={"points"} - source-layer={"public.service_placementpoint"} + source-layer={layerName} layout={{ visibility: isVisible[LAYER_IDS.cancelled] ? "visible" : "none", }} diff --git a/src/Map/Layers/FilteredWorkingPoints.jsx b/src/Map/Layers/FilteredWorkingPoints.jsx index f757271..156a512 100644 --- a/src/Map/Layers/FilteredWorkingPoints.jsx +++ b/src/Map/Layers/FilteredWorkingPoints.jsx @@ -1,37 +1,38 @@ import { Layer } from "react-map-gl"; -import { - workingPointBackgroundLayer, - workingPointSymbolLayer, -} from "./layers-config"; +import { workingPointSymbolLayer } from "./layers-config"; import { useLayersVisibility } from "../../stores/useLayersVisibility"; import { STATUSES } from "../../config"; import { useRegionFilterExpression } from "./useRegionFilterExpression"; import { LAYER_IDS } from "./constants"; import { useWorkingPointsFilters } from "../../stores/useWorkingPointsFilters"; +import { useSourceLayerName } from "../../api.js"; +import { workingFilterHasChanged } from "../../utils.js"; const statusExpression = ["==", ["get", "status"], STATUSES.working]; export const FilteredWorkingPoints = () => { const { isVisible } = useLayersVisibility(); + const layerName = useSourceLayerName(); const { filters: { deltaTraffic, factTraffic, age, region }, + ranges } = useWorkingPointsFilters(); const regionFilterExpression = useRegionFilterExpression(region); - const deltaExpression = [ + const deltaExpression = workingFilterHasChanged(deltaTraffic, ranges, "deltaTraffic") ? [ [">=", ["get", "delta_current"], deltaTraffic[0]], ["<=", ["get", "delta_current"], deltaTraffic[1]], - ]; + ] : [true]; - const factExpression = [ + const factExpression = workingFilterHasChanged(factTraffic, ranges, "factTraffic") ? [ [">=", ["get", "fact"], factTraffic[0]], ["<=", ["get", "fact"], factTraffic[1]], - ]; + ] : [true]; - const ageExpression = [ + const ageExpression = workingFilterHasChanged(age, ranges, "age") ? [ [">=", ["get", "age_day"], age[0]], ["<=", ["get", "age_day"], age[1]], - ]; + ] : [true]; const filter = regionFilterExpression ? [ @@ -52,21 +53,11 @@ export const FilteredWorkingPoints = () => { return ( <> - { +export const Layers = ({ postGroups, otherGroups }) => { return ( <> { + + - - + {postGroups?.map((item) => { + return item.groups.map((itemGroup) => + + ); + })} - + + {otherGroups && otherGroups.map((item) => { + return item.groups.map((itemGroup) => + + ); + })} + ); }; diff --git a/src/Map/Layers/OnApprovalPoints.jsx b/src/Map/Layers/OnApprovalPoints.jsx index a665834..0aab95e 100644 --- a/src/Map/Layers/OnApprovalPoints.jsx +++ b/src/Map/Layers/OnApprovalPoints.jsx @@ -5,11 +5,13 @@ import { STATUSES } from "../../config"; import { useRegionFilterExpression } from "./useRegionFilterExpression"; import { LAYER_IDS } from "./constants"; import { useOnApprovalPointsFilters } from "../../stores/useOnApprovalPointsFilters"; +import { useSourceLayerName } from "../../api.js"; const statusExpression = ["==", ["get", "status"], STATUSES.onApproval]; export const OnApprovalPoints = () => { const { isVisible } = useLayersVisibility(); + const layerName = useSourceLayerName(); const { filters: { region }, } = useOnApprovalPointsFilters(); @@ -25,7 +27,7 @@ export const OnApprovalPoints = () => { {...approvePointLayer} id={LAYER_IDS.approve} source={"points"} - source-layer={"public.service_placementpoint"} + source-layer={layerName} layout={{ visibility: isVisible[LAYER_IDS.approve] ? "visible" : "none", }} diff --git a/src/Map/Layers/OtherPostamates.jsx b/src/Map/Layers/OtherPostamates.jsx index 3670e99..84d2c59 100644 --- a/src/Map/Layers/OtherPostamates.jsx +++ b/src/Map/Layers/OtherPostamates.jsx @@ -1,24 +1,24 @@ import { Layer } from "react-map-gl"; -import { otherPostamatesLayer } from "./layers-config"; +import {getPointSymbolLayer} from "./layers-config"; import { useLayersVisibility } from "../../stores/useLayersVisibility"; import { LAYER_IDS } from "./constants"; -const typeFilter = ["==", ["get", "type"], "post"]; - -export const OtherPostamates = () => { +export const OtherPostamates = ({ id, categoryId, name }) => { const { isVisible } = useLayersVisibility(); + const filter = ["==", ["get", "group_id"], id] return ( <> ); diff --git a/src/Map/Layers/PVZ.jsx b/src/Map/Layers/PVZ.jsx index 5da7194..08eab55 100644 --- a/src/Map/Layers/PVZ.jsx +++ b/src/Map/Layers/PVZ.jsx @@ -1,24 +1,24 @@ import { Layer } from "react-map-gl"; -import { pvzPointLayer } from "./layers-config"; +import { getPointSymbolLayer } from "./layers-config"; import { useLayersVisibility } from "../../stores/useLayersVisibility"; import { LAYER_IDS } from "./constants"; -const typeFilter = ["==", ["get", "type"], "pvz"]; - -export const PVZ = () => { +export const PVZ = ({ id, categoryId, name }) => { const { isVisible } = useLayersVisibility(); + const filter = ["==", ["get", "group_id"], id] return ( <> ); diff --git a/src/Map/Layers/PendingPoints.jsx b/src/Map/Layers/PendingPoints.jsx index c5bd605..f87f20f 100644 --- a/src/Map/Layers/PendingPoints.jsx +++ b/src/Map/Layers/PendingPoints.jsx @@ -8,12 +8,14 @@ import { usePointSelection } from "../../stores/usePointSelection"; import { STATUSES } from "../../config"; import { useRegionFilterExpression } from "./useRegionFilterExpression"; import { LAYER_IDS } from "./constants"; -import { usePendingPointsFilters } from "../../stores/usePendingPointsFilters"; +import { RANGE_FILTERS_KEYS, usePendingPointsFilters } from "../../stores/usePendingPointsFilters"; +import { fieldHasChanged, predictionHasChanged } from "../../utils.js"; +import { useSourceLayerName } from "../../api.js"; -const statusExpression = ["==", ["get", "status"], STATUSES.pending]; +const rawStatusExpression = ["==", ["get", "status"], STATUSES.pending]; const useFilterExpression = () => { - const { filters } = usePendingPointsFilters(); + const { filters, ranges } = usePendingPointsFilters(); const { prediction, categories, region } = filters; const { selection } = usePointSelection(); const includedArr = [...selection.included]; @@ -23,16 +25,32 @@ const useFilterExpression = () => { const includedExpression = ["in", ["get", "id"], ["literal", includedArr]]; const excludedExpression = ["in", ["get", "id"], ["literal", excludedArr]]; - const predictionExpression = [ + + const rawPredictionExpression = [ [">=", ["get", "prediction_current"], prediction[0]], ["<=", ["get", "prediction_current"], prediction[1]], ]; + const staticKeyFiltersExpressions = RANGE_FILTERS_KEYS.map((key) => { + return [ + [">=", ["get", key], filters[`${key}__gt`]], + ["<=", ["get", key], filters[`${key}__lt`]], + ]; + }); + + const staticKeyExpressions = staticKeyFiltersExpressions.filter((expression) => { + const filterKey = expression[0][1][1]; + return fieldHasChanged(filters, ranges, filterKey).result; + }).flat(); + const categoryExpression = categories.length > 0 ? ["in", ["get", "category"], ["literal", categories]] : true; + const statusExpression = rawStatusExpression; + const predictionExpression = predictionHasChanged(filters, ranges) ? rawPredictionExpression : [true]; + const matchFilterExpression = [ "all", statusExpression, @@ -40,8 +58,8 @@ const useFilterExpression = () => { [ "any", regionExpression - ? ["all", ...predictionExpression, categoryExpression, regionExpression] - : ["all", ...predictionExpression, categoryExpression], + ? ["all", ...predictionExpression, ...staticKeyExpressions, categoryExpression, regionExpression] + : ["all", ...predictionExpression, ...staticKeyExpressions, categoryExpression], includedExpression, ], ]; @@ -60,8 +78,10 @@ const useFilterExpression = () => { ...predictionExpression, categoryExpression, regionExpression, + ...staticKeyExpressions, + categoryExpression, ] - : ["all", ...predictionExpression, categoryExpression], + : ["all", ...predictionExpression, categoryExpression, ...staticKeyExpressions], ], excludedExpression, ], @@ -72,6 +92,7 @@ const useFilterExpression = () => { export const PendingPoints = () => { const { isVisible } = useLayersVisibility(); + const layerName = useSourceLayerName(); const { match: matchFilterExpression, unmatch: unmatchFilterExpression } = useFilterExpression(); @@ -81,7 +102,7 @@ export const PendingPoints = () => { {...matchInitialPointLayer} id={LAYER_IDS["initial-unmatch"]} source={"points"} - source-layer={"public.service_placementpoint"} + source-layer={useSourceLayerName()} layout={{ ...matchInitialPointLayer.layout, visibility: isVisible[LAYER_IDS.initial] ? "visible" : "none", @@ -93,7 +114,7 @@ export const PendingPoints = () => { {...matchInitialPointLayer} id={LAYER_IDS["initial-match"]} source={"points"} - source-layer={"public.service_placementpoint"} + source-layer={layerName} layout={{ ...matchInitialPointLayer.layout, visibility: isVisible[LAYER_IDS.initial] ? "visible" : "none", diff --git a/src/Map/Layers/Points.jsx b/src/Map/Layers/Points.jsx index 0c39b97..5d8847b 100644 --- a/src/Map/Layers/Points.jsx +++ b/src/Map/Layers/Points.jsx @@ -1,14 +1,19 @@ import { Source } from "react-map-gl"; -import { BASE_URL } from "../../api"; +import { BASE_URL, useSourceLayerName } from "../../api"; import { useUpdateLayerCounter } from "../../stores/useUpdateLayerCounter"; import { PendingPoints } from "./PendingPoints"; import { OnApprovalPoints } from "./OnApprovalPoints"; import { WorkingPoints } from "./WorkingPoints"; import { FilteredWorkingPoints } from "./FilteredWorkingPoints"; import { CancelledPoints } from "./CancelledPoints"; +import { useEffect } from "react"; export const Points = () => { - const { updateCounter } = useUpdateLayerCounter(); + const { updateCounter, toggleUpdateCounter } = useUpdateLayerCounter(); + const layer = useSourceLayerName(); + useEffect(() => { + toggleUpdateCounter(); + }, [layer]); return ( <> @@ -17,14 +22,14 @@ export const Points = () => { type="vector" key={`points-${updateCounter}`} tiles={[ - `${BASE_URL}/martin/public.service_placementpoint/{z}/{x}/{y}.pbf`, + `${BASE_URL}/martin/${layer}/{z}/{x}/{y}.pbf`, ]} > + - ); diff --git a/src/Map/Layers/WorkingPoints.jsx b/src/Map/Layers/WorkingPoints.jsx index b9f96ab..50028e3 100644 --- a/src/Map/Layers/WorkingPoints.jsx +++ b/src/Map/Layers/WorkingPoints.jsx @@ -1,19 +1,18 @@ import { Layer } from "react-map-gl"; -import { - workingPointBackgroundLayer, - workingPointSymbolLayer, -} from "./layers-config"; +import { workingPointSymbolLayer } from "./layers-config"; import { useLayersVisibility } from "../../stores/useLayersVisibility"; import { MODES, STATUSES } from "../../config"; import { useRegionFilterExpression } from "./useRegionFilterExpression"; import { LAYER_IDS } from "./constants"; import { useMode } from "../../stores/useMode"; import { useOnApprovalPointsFilters } from "../../stores/useOnApprovalPointsFilters"; +import {useSourceLayerName} from "../../api.js"; const statusExpression = ["==", ["get", "status"], STATUSES.working]; export const WorkingPoints = () => { const { isVisible } = useLayersVisibility(); + const layerName = useSourceLayerName(); const { filters: { region }, } = useOnApprovalPointsFilters(); @@ -33,21 +32,11 @@ export const WorkingPoints = () => { return ( <> - { +// const { prediction } = usePendingPointsFilters(); +// +// const getValue = (multiply) => { +// const difference = prediction[1] - prediction[0]; +// const value = prediction[0] + (difference * multiply); +// return Math.floor(value); +// } +// +// return { +// property: "prediction_current", +// stops: [ +// [getValue(0), "#FDEBF0"], +// [getValue(0.1), "#F8C7D8"], +// [getValue(0.25), "#F398BC"], +// [getValue(0.4), "#EE67A1"], +// [getValue(0.55), "#B64490"], +// [getValue(0.7), "#7E237E"], +// [getValue(0.85), "#46016C"], +// ], +// }; +// } + export const PENDING_COLOR = { property: "prediction_current", stops: [ @@ -13,7 +36,8 @@ export const PENDING_COLOR = { [251, "#46016C"], ], }; -export const CANCELLED_COLOR = "#CC2222"; + +export const CANCELLED_COLOR = "#A6A6A6"; export const APPROVE_COLOR = "#ff7d00"; export const WORKING_COLOR = "#006e01"; export const UNMATCHED_COLOR = "rgba(196,195,195,0.6)"; @@ -55,19 +79,36 @@ export const unmatchInitialPointLayer = getPointConfig( UNMATCHED_COLOR, UNMATCH_POINT_SIZE ); -export const approvePointLayer = getPointConfig(APPROVE_COLOR); +export const approvePointLayer = { + ...getPointConfig(APPROVE_COLOR), + paint: { + ...getPointConfig(APPROVE_COLOR).paint, + "circle-stroke-width": 1, + "circle-stroke-color": "#252525", + }, +}; export const workingPointSymbolLayer = { type: "symbol", layout: { "icon-image": "logo", - "icon-size": ["interpolate", ["linear"], ["zoom"], 3, 0, 9, 0.1, 13, 0.7], + "icon-size": ["interpolate", ["linear"], ["zoom"], 3, 0, 9, 0.1, 13, 0.5], }, paint: { "icon-color": "#E63941", }, }; +export const getPointSymbolLayer = (image) => { + return { + type: "symbol", + layout: { + "icon-image": image, + "icon-size": ["interpolate", ["linear"], ["zoom"], 3, 0, 9, 0.1, 13, 0.5], + }, + }; +} + const workingBgColor = "#ffffff"; const workingBgSize = 16; export const workingPointBackgroundLayer = { @@ -78,7 +119,14 @@ export const workingPointBackgroundLayer = { "circle-stroke-color": "#252525", }, }; -export const cancelledPointLayer = getPointConfig(CANCELLED_COLOR); +export const cancelledPointLayer = { + ...getPointConfig(CANCELLED_COLOR), + paint: { + ...getPointConfig(CANCELLED_COLOR).paint, + "circle-stroke-width": 1, + "circle-stroke-color": "#252525", + }, +}; export const pvzPointLayer = getPointConfig(PVZ_COLOR); export const otherPostamatesLayer = getPointConfig(OTHER_POSTAMATES_COLOR); diff --git a/src/Map/LayersControl/LayersControl.jsx b/src/Map/LayersControl/LayersControl.jsx index 2e05a5b..dce3aa6 100644 --- a/src/Map/LayersControl/LayersControl.jsx +++ b/src/Map/LayersControl/LayersControl.jsx @@ -2,10 +2,10 @@ import { Button, Popover, Tooltip } from "antd"; import { BiLayer } from "react-icons/all"; import { LayersVisibility } from "./LayersVisibility"; -export const LayersControl = () => { +export const LayersControl = ({ postGroups, otherGroups }) => { return ( } + content={} trigger="click" placement={"leftBottom"} > diff --git a/src/Map/LayersControl/LayersVisibility.jsx b/src/Map/LayersControl/LayersVisibility.jsx index 85a8335..dbf8ff9 100644 --- a/src/Map/LayersControl/LayersVisibility.jsx +++ b/src/Map/LayersControl/LayersVisibility.jsx @@ -4,7 +4,7 @@ import Checkbox from "antd/es/checkbox/Checkbox"; import { MODES } from "../../config"; import { LAYER_IDS } from "../Layers/constants"; -export const LayersVisibility = () => { +export const LayersVisibility = ({ postGroups, otherGroups }) => { const { toggleVisibility, isVisible } = useLayersVisibility(); const { mode } = useMode(); @@ -28,20 +28,32 @@ export const LayersVisibility = () => { )} - toggleVisibility(LAYER_IDS.pvz)} - checked={isVisible[LAYER_IDS.pvz]} - > - ПВЗ - - toggleVisibility(LAYER_IDS.other)} - checked={isVisible[LAYER_IDS.other]} - > - Постаматы прочих сетей - + {postGroups?.map((item) => { + return ( + toggleVisibility(LAYER_IDS.pvz_category + item.id)} + checked={isVisible[LAYER_IDS.pvz_category + item.id]} + > + {item.name} + + ); + })} + + {otherGroups && otherGroups.map((item) => { + return ( + toggleVisibility(LAYER_IDS.other_category + item.id)} + checked={isVisible[LAYER_IDS.other_category + item.id]} + > + {item.name} + + ); + })} + {/*{mode === MODES.WORKING && (*/} {/* <>*/} {/* { +const LegendPointItem = ({color, imageSrc, name, hideImage, border}) => { return (
- {color ? ( - - ) : ( - + {imageSrc && } + {color && !imageSrc && ( + + + + )} + {!imageSrc && !color && !hideImage && ( + )} - {name} + {name}
); }; const pendingColors = PENDING_COLOR.stops.map(([_value, color]) => color); -const LegendColorRampItem = ({ colors, name }) => { +const LegendColorRampItem = ({colors, name}) => { return (
{name} @@ -46,11 +51,45 @@ const LegendColorRampItem = ({ colors, name }) => { ); }; -export function Legend() { - const { mode } = useMode(); +const LegendGroupItem = ({item, color}) => { + return ( + + } + > +
+ {item.groups && item.groups?.map((groupItem) => { + return ( +
+ +
+ ) + })} +
+ +
+
+ ) +} + +export function Legend({ postGroups, otherGroups }) { + const {mode} = useMode(); return ( -
+
{mode === MODES.PENDING && ( @@ -59,10 +98,11 @@ export function Legend() { colors={pendingColors} name="Локации к рассмотрению" /> - + )} @@ -71,28 +111,33 @@ export function Legend() { - + )} {mode === MODES.WORKING && ( <> - + )}
- - + {postGroups?.map((item) => { + return + })} +
+
+ {otherGroups?.map((item) => { + return + })}
); diff --git a/src/Map/MapComponent.jsx b/src/Map/MapComponent.jsx index 7e16d4d..b1a1158 100644 --- a/src/Map/MapComponent.jsx +++ b/src/Map/MapComponent.jsx @@ -1,6 +1,6 @@ import maplibregl from "maplibre-gl"; import Map, { MapProvider } from "react-map-gl"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Sidebar } from "../modules/Sidebar/Sidebar"; import { Layers } from "./Layers/Layers"; import { MapPopup } from "./Popup/Popup"; @@ -21,6 +21,14 @@ import { useLayersVisibility } from "../stores/useLayersVisibility"; import { LAYER_IDS } from "./Layers/constants"; import { Header } from "./Header"; import { icons } from "../icons/icons-config"; +import { LastMLRun } from "./LastMLRun"; +import { useOtherGroups, usePostamatesAndPvzGroups } from "../api.js"; +import { getFilteredGroups, transliterate } from "../utils.js"; +import { + CATEGORIES_MAP, + RANGE_FILTERS_KEYS, + RANGE_FILTERS_MAP +} from "../stores/usePendingPointsFilters.js"; export const MapComponent = () => { const mapRef = useRef(null); @@ -32,6 +40,51 @@ export const MapComponent = () => { const { mode } = useMode(); const { tableState, openTable } = useTable(); + const { data: postamatesAndPvzGroups } = usePostamatesAndPvzGroups(); + const { data: otherGroups } = useOtherGroups(); + + const filteredPostamatesGroups = useMemo(() => { + return getFilteredGroups(postamatesAndPvzGroups); + }, [postamatesAndPvzGroups]); + + const filteredOtherGroups = useMemo(() => { + return getFilteredGroups(otherGroups); + }, [otherGroups]); + + const mapIcons = useMemo(() => { + const res = []; + + [...filteredOtherGroups, ...filteredPostamatesGroups].map((category) => { + category.groups.map((group) => { + res.push({name: transliterate(group.name + group.id), url: group.image}); + }) + }); + return [...res, ...icons]; + }, [icons, filteredPostamatesGroups, filteredOtherGroups]); + + const mapLayersIds = useMemo(() => { + const res = [] + + filteredPostamatesGroups.map((item) => { + RANGE_FILTERS_MAP[`category${item.id}`] = { + name: CATEGORIES_MAP[item.name], + }; + item.groups.map((groupItem) => { + if (!RANGE_FILTERS_KEYS.includes(`d${groupItem.id}`)) RANGE_FILTERS_KEYS.push(`d${groupItem.id}`); + RANGE_FILTERS_MAP[`category${item.id}`][`d${groupItem.id}`] = groupItem.name; + res.push(LAYER_IDS.pvz + groupItem.id); + }); + }); + + filteredOtherGroups.map((item) => { + item.groups.map((groupItem) => { + res.push(LAYER_IDS.other + groupItem.id); + }) + }); + + return res; + }, [filteredPostamatesGroups, filteredOtherGroups]); + useEffect(() => { setLayersVisibility(MODE_TO_LAYER_VISIBILITY_MAPPER[mode]); setPopup(null); @@ -131,21 +184,21 @@ export const MapComponent = () => { LAYER_IDS.working, LAYER_IDS.filteredWorking, LAYER_IDS.cancelled, - LAYER_IDS.pvz, - LAYER_IDS.other, + ...mapLayersIds, ]} onClick={handleClick} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} onLoad={() => { - icons.map((icon) => { + mapIcons.map((icon) => { const img = new Image( - icon.size?.width || 24, - icon.size?.height || 24 + icon.size?.width || 64, + icon.size?.height || 64 ); img.onload = () => - mapRef.current.addImage(icon.name, img, { sdf: true }); + mapRef.current.addImage(icon.name, img); img.src = icon.url; + img.crossOrigin = "Anonymous"; }); }} id="map" @@ -165,11 +218,12 @@ export const MapComponent = () => { - + - + + - +
diff --git a/src/Map/PointChart.jsx b/src/Map/PointChart.jsx new file mode 100644 index 0000000..a3c306d --- /dev/null +++ b/src/Map/PointChart.jsx @@ -0,0 +1,168 @@ +import { Line } from "react-chartjs-2"; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + BarElement, + Title, + Tooltip as ChartTooltip, + Legend, PointElement, LineElement, BarController, +} from 'chart.js'; +import { useQuery } from "@tanstack/react-query"; +import { api } from "../api.js"; + +ChartJS.register( + CategoryScale, + BarController, + PointElement, + LineElement, + LinearScale, + BarElement, + Title, + ChartTooltip, + Legend +); + +const GRAPH_LABELS_MAP= { + target_dist_shap: "Расстояние до ближайшего постамата Мой постамат", + target_post_cnt_shap: "Кол-во точек Мой постамат*", + target_cnt_ao_mean_shap: "Средний трафик в точках Мой постамат в АО", + rival_pvz_cnt_shap: "Кол-во ПВЗ*", + rival_post_cnt_shap: "Кол-во постаматов конкурентов *", + metro_dist_shap: "Расстояние до метро", + property_price_bargains_shap: "Цена сделок жилой недвижимости*", + property_price_offers_shap: "Цена предложений жилой недвижимости*", + property_mean_floor_shap: "Средняя этажность застройки*", + property_era_shap: "Эпоха жилой недвижимости*", + flats_cnt_shap: "Кол-во квартир*", + popul_home_shap: "Численность проживающего населения*", + popul_job_shap: "Численность работающего населения*", + yndxfood_sum_shap: "Сумма заказов Яндекс.Еда*", + yndxfood_cnt_shap: "Кол-во заказов Яндекс.Еда*", + school_cnt_shap: "Кол-во школ*", + kindergar_cnt_shap: "Кол-во детсадов*", + public_stop_cnt_shap: "Кол-во остановок общ. транспорта*", + sport_center_cnt_shap: "Кол-во спортивных центров*", + pharmacy_cnt_shap: "Кол-во аптек*", + supermarket_cnt_shap: "Кол-во супермаркетов*", + supermarket_premium_cnt_shap: "Кол-во премиальных супермаркетов*", + clinic_cnt_shap: "Кол-во поликлиник*", + bank_cnt_shap: "Кол-во банков*", + reca_cnt_shap: "Кол-во точек общепита*", + lab_cnt_shap: "Кол-во лабораторий*", + culture_cnt_shap: "Кол-во объектов культуры*", + attraction_cnt_shap: "Кол-во достопримечательностей*", + mfc_cnt_shap: "Кол-во МФЦ*", + bc_cnt_shap: "Кол-во бизнес-центров*", + tc_cnt_shap: "Кол-во торговых центров*", + business_activity_shap: "Бизнес активность", +} +export const PointChart = ({ point }) => { + const { data: meanData } = useQuery( + ["mean-data"], + async () => { + const { data } = await api.get( + `/api/avg_bi_values/` + ); + + return data; + }, + { + refetchOnWindowFocus: false, + refetchOnMount: false + } + ); + + const options = { + indexAxis: 'y', + elements: { + bar: { + borderWidth: 0, + borderRadius: 5, + pointStyle: 'circle' + }, + }, + plugins: { + legend: { + display: false + }, + tooltip: { + displayColors: false, + yAlign: "top", + callbacks: { + label: function(context) { + const label = [] + const shap_key = Object.keys(GRAPH_LABELS_MAP).find(key => GRAPH_LABELS_MAP[key] === context.label); + const key = shap_key.substring(0, shap_key.length - 5) + if (context.datasetIndex === 0) label.push("Значение: " + point[key]); + if (context.parsed.x !== null) { + let labelText = ""; + if (context.datasetIndex === 0) labelText = "Вклад в прогноз, %: "; + if (context.datasetIndex === 1) labelText = "Минимальный вклад в прогноз, %: "; + if (context.datasetIndex === 2) labelText = "Максимальный вклад в прогноз, %: "; + label.push(labelText + context.parsed.x); + } + return label; + }, + body: () => { + return "Вклад в прогноз, %:" + } + } + }, + }, + scales: { + y: { + stacked: true, + }, + x: { + title: { + display: true, + text: 'Вклад в прогноз, %', + }, + grid: { + color: function(context) { + if (context.tick.value === 0) { + return "#000000"; + } + + return "#E5E5E5"; + }, + }, + } + } + }; + + const labels = Object.keys(GRAPH_LABELS_MAP).sort((a, b) => { + if (Math.abs(point[a]) < Math.abs(point[b])) return 1; + else return -1; + }).slice(0, 15); + + const data = { + labels: labels.map((l) => GRAPH_LABELS_MAP[l]), + datasets: [ + { + data: labels.map((l) => point[l]), + backgroundColor: labels.map((l) => point[l]).map(v => v <= 0 ? '#CC2500' : '#278211'), + hoverBackgroundColor: labels.map((l) => point[l]).map(v => v <= 0 ? '#F22C00' : '#2DB20C'), + type: 'line', + showLine: false, + }, + { + data: labels.map((l) => meanData ? meanData[`min_${l}`] : 0), + backgroundColor: "#cccccc", + hoverBackgroundColor: "#aaaaaa", + type: 'bar', + showLine: false, + }, + { + data: labels.map((l) => meanData ? meanData[`max_${l}`] : 0), + backgroundColor: "#cccccc", + + hoverBackgroundColor: "#aaaaaa", + type: 'bar', + showLine: false, + }, + ], + }; + return +} \ No newline at end of file diff --git a/src/Map/Popup/Popup.jsx b/src/Map/Popup/Popup.jsx index f024c3d..aece2c2 100644 --- a/src/Map/Popup/Popup.jsx +++ b/src/Map/Popup/Popup.jsx @@ -1,4 +1,4 @@ -import { Button } from "antd"; +import { Button, Spin, Tooltip } from "antd"; import { CATEGORIES, MODES, STATUSES } from "../../config"; import { useMode } from "../../stores/useMode"; import { LAYER_IDS } from "../Layers/constants"; @@ -8,84 +8,129 @@ import { OnApprovalPointPopup } from "./mode-popup/OnApprovalPointPopup"; import { WorkingPointPopup } from "./mode-popup/WorkingPointPopup"; import { FeatureProperties } from "./mode-popup/FeatureProperties"; import { usePopup } from "../../stores/usePopup.js"; +import { PanoramaIcon } from "../../icons/PanoramaIcon"; +import { useGetPopupPoints } from "../../api.js"; +import { doesMatchFilter } from "../../utils.js"; +import Checkbox from "antd/es/checkbox/Checkbox"; +import { usePointSelection } from "../../stores/usePointSelection.js"; +import { usePendingPointsFilters } from "../../stores/usePendingPointsFilters.js"; -const SingleFeaturePopup = ({ feature }) => { +const SingleFeaturePopup = ({ feature, point }) => { const { mode } = useMode(); const isRivals = - feature.layer?.id === LAYER_IDS.pvz || - feature.layer?.id === LAYER_IDS.other; + feature.layer?.id.includes(LAYER_IDS.pvz) || + feature.layer?.id.includes(LAYER_IDS.other); const isPendingPoint = feature.properties.status === STATUSES.pending; const isWorkingPoint = feature.properties.status === STATUSES.working; if (isRivals) { - return ; + return ; } if (mode === MODES.ON_APPROVAL && !isPendingPoint) { - return ; + return ; } if (mode === MODES.WORKING && isWorkingPoint) { - return ; + return ; } if (mode === MODES.PENDING && isPendingPoint) - return ; + return ; - return ; + return ; }; -const MultipleFeaturesPopup = ({ features }) => { +const MultipleFeaturesPopup = ({ features, points }) => { const { setPopup } = usePopup(); + const { selection, include, exclude } = usePointSelection(); + const { filters, ranges } = usePendingPointsFilters(); return (
{features.map((feature) => { + const featureId = feature.properties.id; + const point = points.find(p => p.id === featureId); + const isSelected = (doesMatchFilter(filters, ranges, feature) && !selection.excluded.has(featureId)) || + selection.included.has(featureId); + const handleSelect = () => { + if (isSelected) { + exclude(featureId); + } else { + include(featureId); + } + }; return ( - + + +
); })}
); }; +const YandexPanoramaLink = ({ lat, lng }) => { + const link = `https://yandex.ru/maps/?panorama[point]=${lng},${lat}` + return ( +
+ + + + + +
+ ); +} + export const MapPopup = ({ features, lat, lng, onClose }) => { + const {data: points, isLoading} = useGetPopupPoints(features); + const getContent = () => { if (features.length === 1) { - return ; + return ; } - return ; + return ; }; return ( - {getContent()} + + {isLoading ? : getContent()} ); }; diff --git a/src/Map/Popup/PopupWrapper.jsx b/src/Map/Popup/PopupWrapper.jsx index 73e9869..03202b1 100644 --- a/src/Map/Popup/PopupWrapper.jsx +++ b/src/Map/Popup/PopupWrapper.jsx @@ -7,7 +7,7 @@ export const PopupWrapper = ({ lat, lng, onClose, children }) => { latitude={lat} onClose={onClose} closeOnClick={false} - style={{ minWidth: "300px" }} + style={{ minWidth: "330px" }} > {children} diff --git a/src/Map/Popup/mode-popup/FeatureProperties.jsx b/src/Map/Popup/mode-popup/FeatureProperties.jsx index 3f2830c..f674e92 100644 --- a/src/Map/Popup/mode-popup/FeatureProperties.jsx +++ b/src/Map/Popup/mode-popup/FeatureProperties.jsx @@ -1,35 +1,95 @@ import { CATEGORIES, STATUSES } from "../../../config"; import { commonPopupConfig, - residentialPopupConfig, + residentialPopupFields, rivalsConfig, workingPointFields, } from "./config"; import { Col, Row } from "antd"; import { twMerge } from "tailwind-merge"; import { LAYER_IDS } from "../../Layers/constants"; -import { isNil } from "../../../utils.js"; +import {getFilteredGroups, isNil} from "../../../utils.js"; import { useGetRegions } from "../../../components/RegionSelect.jsx"; +import {useOtherGroups, usePostamatesAndPvzGroups} from "../../../api.js"; +import {useMemo} from "react"; +import { TrafficModal } from "../../TrafficModal.jsx"; -export const FeatureProperties = ({ feature, dynamicStatus, postamatId }) => { +const getRivalsName = (feature) => { + const { data: postamatesAndPvzGroups } = usePostamatesAndPvzGroups(); + const { data: otherGroups } = useOtherGroups(); + + const filteredPostamatesCategories = useMemo(() => { + return getFilteredGroups(postamatesAndPvzGroups); + }, [postamatesAndPvzGroups]); + + const filteredOtherCategories = useMemo(() => { + return getFilteredGroups(otherGroups); + }, [otherGroups]); + + const filteredPostamatesGroups = useMemo(() => { + if (!filteredPostamatesCategories) return []; + return filteredPostamatesCategories + .map((category) => { + return [...category.groups] + }).flat(); + }, [filteredPostamatesCategories]); + + const filteredOtherGroups = useMemo(() => { + if (!filteredOtherCategories) return []; + return filteredOtherCategories + .map((category) => { + return [...category.groups] + }).flat(); + }, [filteredOtherCategories]); + + const isOther = feature.layer?.id.includes(LAYER_IDS.other); + const name = isOther ? + filteredOtherCategories.find(c => c.id === feature.properties.category_id)?.name : + filteredPostamatesCategories.find(c => c.id === feature.properties.category_id)?.name; + + const groupName = isOther ? + filteredOtherGroups.find(c => c.id === feature.properties.group_id)?.name : + filteredPostamatesGroups.find(c => c.id === feature.properties.group_id)?.name; + + return { + name, + groupName + } +} + +export const FeatureProperties = ({ feature, dynamicStatus, postamatId, point }) => { const { data } = useGetRegions(); const isResidential = feature.properties.category === CATEGORIES.residential; const isWorking = feature.properties.status === STATUSES.working; + const { name, groupName } = getRivalsName(feature); + const isRivals = - feature.layer?.id === LAYER_IDS.pvz || - feature.layer?.id === LAYER_IDS.other; + feature.layer?.id.includes(LAYER_IDS.pvz) || + feature.layer?.id.includes(LAYER_IDS.other); const getConfig = () => { if (isRivals) { return rivalsConfig; } - const config = isResidential ? residentialPopupConfig : commonPopupConfig; - return isWorking ? [...config, ...workingPointFields] : config; + const config = isWorking ? [...commonPopupConfig, ...workingPointFields] : commonPopupConfig; + return isResidential ? [...config, ...residentialPopupFields] : config; }; const getValue = ({ field, render, empty, type, fallbackField }) => { - let value = feature.properties[field]; + let value = point ? point[field] : feature.properties[field]; + + if (field === "prediction_current") { + value = ; + } + + if (field === "category_id") { + value = name; + } + + if (field === "group_id") { + value = groupName; + } if (field === "status" && dynamicStatus) { value = dynamicStatus; @@ -40,7 +100,8 @@ export const FeatureProperties = ({ feature, dynamicStatus, postamatId }) => { } if (type === "region") { - value = value ? value : feature.properties[fallbackField]; + const valueProvider = point ? point : feature + value = value ? value : valueProvider[fallbackField]; value = render(value, data?.normalized); } else { value = render ? render(value) : value; diff --git a/src/Map/Popup/mode-popup/OnApprovalPointPopup.jsx b/src/Map/Popup/mode-popup/OnApprovalPointPopup.jsx index e9b07ae..47c78fa 100644 --- a/src/Map/Popup/mode-popup/OnApprovalPointPopup.jsx +++ b/src/Map/Popup/mode-popup/OnApprovalPointPopup.jsx @@ -10,12 +10,12 @@ import { Button, InputNumber } from "antd"; import { STATUSES } from "../../../config"; import { isNil } from "../../../utils.js"; -export const OnApprovalPointPopup = ({ feature }) => { +export const OnApprovalPointPopup = ({ feature, point }) => { const featureId = feature.properties.id; const { setClickedPointConfig } = useClickedPointConfig(); const { status: initialStatus, postamat_id: initialPostamatId } = - feature.properties; + point; const [status, setStatus] = useState(initialStatus); const [postamatId, setPostamatId] = useState(initialPostamatId); @@ -92,6 +92,7 @@ export const OnApprovalPointPopup = ({ feature }) => { <> diff --git a/src/Map/Popup/mode-popup/PendingPointPopup.jsx b/src/Map/Popup/mode-popup/PendingPointPopup.jsx index 68cee6e..261bddc 100644 --- a/src/Map/Popup/mode-popup/PendingPointPopup.jsx +++ b/src/Map/Popup/mode-popup/PendingPointPopup.jsx @@ -5,50 +5,17 @@ import { FeatureProperties } from "./FeatureProperties"; import { Button } from "antd"; import { useCanEdit } from "../../../api"; import { usePendingPointsFilters } from "../../../stores/usePendingPointsFilters"; +import { doesMatchFilter } from "../../../utils.js"; -export const PendingPointPopup = ({ feature }) => { +export const PendingPointPopup = ({ feature, point }) => { const { include, selection, exclude } = usePointSelection(); const { setClickedPointConfig } = useClickedPointConfig(); - const { filters } = usePendingPointsFilters(); - const doesMatchFilter = () => { - const { prediction, categories, region } = filters; - const { - prediction_current, - category, - area, - district, - area_id, - district_id, - } = feature.properties; - - const doesMatchPredictionFilter = - prediction_current >= prediction[0] && - prediction_current <= prediction[1]; - - const doesMatchCategoriesFilter = - categories.length > 0 ? categories.includes(category) : true; - - const doesMatchRegionFilter = () => { - if (!region) return true; - - if (region.type === "ao") { - return (district ?? district_id) === region.id; - } else { - return (area ?? area_id) === region.id; - } - }; - - return ( - doesMatchPredictionFilter && - doesMatchCategoriesFilter && - doesMatchRegionFilter() - ); - }; + const { filters, ranges } = usePendingPointsFilters(); const featureId = feature.properties.id; const isSelected = - (doesMatchFilter() && !selection.excluded.has(featureId)) || + (doesMatchFilter(filters, ranges, feature) && !selection.excluded.has(featureId)) || selection.included.has(featureId); useEffect( @@ -68,7 +35,7 @@ export const PendingPointPopup = ({ feature }) => { return ( <> - + {canEdit && ( , + ] + } + + return ( +
+ {point.prediction_current} + + + + setIsOpened(false)} + width={800} + footer={getFooter()} + style={{ top: "15px" }} + > +
+
+ + + Адрес точки: + + {point.address} + + + + Прогнозный траффик: + + {point.prediction_current} + +
+ + +

* - в окрестности

+ } + trigger="click" + placement="leftBottom" + color="#ffffff" + > + + + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/SignOut.jsx b/src/SignOut.jsx index 280e378..f342779 100644 --- a/src/SignOut.jsx +++ b/src/SignOut.jsx @@ -13,7 +13,7 @@ export function SignOut() { }; const { data } = useQuery(["profile"], async () => { - const { data } = await api.get("/accounts/profile"); + const { data } = await api.get("/accounts/profile/"); return data; }); diff --git a/src/api.js b/src/api.js index 6c6378a..4ffbe88 100644 --- a/src/api.js +++ b/src/api.js @@ -2,7 +2,11 @@ import axios from "axios"; import { useMutation, useQuery } from "@tanstack/react-query"; import { STATUSES } from "./config"; import { usePointSelection } from "./stores/usePointSelection"; -import { usePendingPointsFilters } from "./stores/usePendingPointsFilters"; +import { RANGE_FILTERS_KEYS, usePendingPointsFilters } from "./stores/usePendingPointsFilters"; +import { appendFiltersInUse } from "./utils.js"; +import { useMode } from "./stores/useMode.js"; +import { useMemo } from "react"; +import { useUpdateLayerCounter } from "./stores/useUpdateLayerCounter.js"; export const BASE_URL = import.meta.env.VITE_API_URL; @@ -16,6 +20,18 @@ export const api = axios.create({ xsrfCookieName: "csrftoken", }); +export const useDbTableName = () => { + const {isImportMode} = useMode(); + if (isImportMode) return "pre_placement_points"; + return "placement_points"; +} + +export const useSourceLayerName = () => { + const {isImportMode} = useMode(); + if (isImportMode) return "public.prepoints_with_dist"; + return "public.points_with_dist"; +} + const enrichParamsWithRegionFilter = (params, region) => { const resultParams = params ? params : new URLSearchParams(); @@ -32,73 +48,164 @@ const enrichParamsWithRegionFilter = (params, region) => { return resultParams; }; -export const getPoints = async (params, region) => { +export const getPoints = async (params, region, dbTable = "placement_points", signal) => { const resultParams = enrichParamsWithRegionFilter(params, region); const { data } = await api.get( - `/api/placement_points?${resultParams.toString()}` + `/api/${dbTable}/?${resultParams.toString()}`, {signal} ); return data; }; -export const exportPoints = async (params, region) => { +export const exportPoints = async (params, region, dbTable = "placement_points") => { const resultParams = enrichParamsWithRegionFilter(params, region); const { data } = await api.get( - `/api/placement_points/to_excel?${resultParams.toString()}`, + `/api/${dbTable}/to_excel/?${resultParams.toString()}`, { responseType: "arraybuffer" } ); return data; }; +export const downloadImportTemplate = async () => { + const { data } = await api.get( + '/api/pre_placement_points/download_template/', + { responseType: "arraybuffer" } + ); + + return data; +}; + +export const uploadPointsFile = async (file, config) => { + const formData = new FormData(); + formData.append("file", file); + const { data } = await api.post( + `/api/pre_placement_points/load_matching_file/`, + formData, + config + ); + + return data; +}; + +export const importPoints = async (id) => { + const formData = new FormData(); + formData.append("id", id); + const { data } = await api.post( + `/api/pre_placement_points/start_matching/`, + formData + ); + + return data; +}; + export const useGetTotalInitialPointsCount = () => { + const dbTable = useDbTableName(); + const { updateCounter } = useUpdateLayerCounter(); return useQuery( - ["all-initial-count"], - async () => { + ["all-initial-count", dbTable, updateCounter], + async ({signal}) => { const params = new URLSearchParams({ page: 1, page_size: 1, - "status[]": [STATUSES.pending], }); + params.append("status[]", STATUSES.pending); - return await getPoints(params); + return await getPoints(params, null, dbTable, signal); }, - { select: (data) => data.count } + { + select: (data) => data.count, + refetchOnWindowFocus: false, + refetchOnMount: false + } ); }; -export const useGetFilteredPendingPointsCount = () => { - const { filters } = usePendingPointsFilters(); - const { prediction, categories, region } = filters; +export const useGetFilteredPendingPointsCount = (isMerge) => { + const { filters, ranges } = usePendingPointsFilters(); + const { updateCounter } = useUpdateLayerCounter(); + const { + categories, + region, + } = filters; const { selection: { included }, } = usePointSelection(); const includedIds = [...included]; + const getParams = () => { + const params = new URLSearchParams({ + page: 1, + page_size: 1, + "categories[]": categories, + "included[]": includedIds, + }); + params.append("status[]", STATUSES.pending); + + appendFiltersInUse(params, filters, ranges); + + return params; + } + + const getMergeParams = () => { + return new URLSearchParams({ + "matching_status": "New", + }); + } + + const dbTable = useDbTableName(); return useQuery( - ["filtered-points", filters, includedIds], + ["filtered-points", filters, dbTable, includedIds, updateCounter], + async ({signal}) => { + const params = isMerge ? getMergeParams() : getParams(); + + return await getPoints(params, region, dbTable, signal); + }, + { + select: (data) => data.count, + keepPreviousData: true, + refetchOnWindowFocus: false, + refetchOnMount: false + } + ); +}; + +export const useGetPointsToMergeCount = () => { + const getMergeParams = () => { + return new URLSearchParams({ + "matching_status": "New", + }); + } + + const dbTable = useDbTableName(); + + return useQuery( + ["filtered-points", dbTable], async () => { - const params = new URLSearchParams({ - page: 1, - page_size: 1, - "prediction_current[]": prediction, - "status[]": [STATUSES.pending], - "categories[]": categories, - "included[]": includedIds, - }); + const params = getMergeParams(); - return await getPoints(params, region); + return await getPoints(params, null, dbTable); }, { select: (data) => data.count, keepPreviousData: true } ); }; +export const useMergePointsToDb = () => { + return useMutation({ + mutationFn: () => { + return api.post( + `/api/pre_placement_points/move_points/` + ); + }, + }); +}; + export const useGetPermissions = () => { return useQuery(["permissions"], async () => { - const { data } = await api.get("/api/me"); + const { data } = await api.get("/api/me/"); if (data?.groups?.includes("Редактор")) { return "editor"; @@ -108,18 +215,163 @@ export const useGetPermissions = () => { }); }; +const TASK_STATUSES = { + finished: "Перерасчет ML завершен" +} export const useCanEdit = () => { const { data } = useGetPermissions(); + const { data: statusData } = useLastMLRun(); + + const hasFinishedUpdate = useMemo(() => { + return statusData?.task_status === TASK_STATUSES.finished + }, [statusData]); - return data === "editor"; + return data === "editor" && hasFinishedUpdate; }; export const useUpdatePostamatId = () => { return useMutation({ mutationFn: (params) => { return api.put( - `/api/placement_points/update_postamat_id?${params.toString()}` + `/api/placement_points/update_postamat_id/?${params.toString()}` ); }, }); }; + +export const getLastMLRun = async () => { + const { data } = await api.get( + `/api/placement_points/last_time_ml_run/` + ); + + return data; +}; + +export const startML = async () => { + const { data } = await api.get( + `/api/placement_points/start/` + ); + + return data; +}; + +export const getPostamatesAndPvzGroups = async () => { + const { data } = await api.get( + `/api/postamate_and_pvz_groups/` + ); + + return data; +}; + +export const usePostamatesAndPvzGroups = () => { + return useQuery( + ['groups'], + async () => { + return await getPostamatesAndPvzGroups(); + }, + ); +}; + +export const getOtherGroups = async () => { + const { data } = await api.get( + `/api/other_object_groups/` + ); + + return data; +}; + +export const useOtherGroups = () => { + return useQuery( + ['other_groups'], + async () => { + return await getOtherGroups(); + }, + ); +}; + +export const useLastMLRun = () => { + return useQuery( + ['last_time'], + async () => { + return await getLastMLRun(); + }, + { + refetchInterval: 5000, + } + ); +} + +export const useGetPendingPointsRange = (dbTable) => { + const { isImportMode } = useMode(); + const statusFilter = isImportMode ? '' : `?status[]=${STATUSES.pending}`; + + return useQuery( + ["prediction-max-min", dbTable], + async () => { + const { data, isInitialLoading, isFetching } = await api.get( + `/api/${dbTable}/filters/${statusFilter}` + ); + return {data, isLoading: isInitialLoading || isFetching}; + }, + { + select: ({data, isLoading}) => { + const distToGroupsArr = data.dist_to_groups.map((groupRange) => { + return { + [`d${groupRange.group_id}`]: [Math.floor(groupRange.dist[0]), Math.min(Math.ceil(groupRange.dist[1]), 4000)], + } + }); + const distToGroups = Object.assign({}, ...distToGroupsArr); + const rangesArr = RANGE_FILTERS_KEYS.map((key) => { + if ((/d[0-9]/.test(key))) return; + return { + [key]: [Math.floor(data[key][0]), Math.min(Math.ceil(data[key][1]), 4000)] + } + }).filter(item => !!item); + const ranges = Object.assign({}, ...rangesArr); + return { + fullRange: { + prediction: data.prediction_current, + ...ranges, + ...distToGroups + }, + isLoading: isLoading + }; + }, + } + ); +}; + +export const useGetPopupPoints = (features) => { + const pointsIds = features.map(f => f.properties.id); + const dbTable = useDbTableName(); + + const { data, isInitialLoading, isFetching } = useQuery( + ["popup_data", features], + async () => { + const params = new URLSearchParams({ + "location_ids[]": pointsIds, + }); + + const { data } = await api.get( + `/api/${dbTable}/?${params.toString()}` + ); + + return data.results; + }, + { + refetchOnWindowFocus: false, + refetchOnMount: false + } + ); + + return { data, isLoading: isInitialLoading || isFetching }; +}; + +export const deletePoint = async (id) => { + const formData = new FormData(); + formData.append("ids", id); + await api.delete( + `/api/pre_placement_points/delete_points/`, + { data: formData } + ) +} diff --git a/src/assets/logopng.png b/src/assets/logopng.png new file mode 100644 index 0000000000000000000000000000000000000000..4a838dce8f479b67f431114aaed595def1d67630 GIT binary patch literal 7676 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 literal 0 HcmV?d00001 diff --git a/src/components/ModeSelector.jsx b/src/components/ModeSelector.jsx index 6b67075..74bcbb4 100644 --- a/src/components/ModeSelector.jsx +++ b/src/components/ModeSelector.jsx @@ -6,7 +6,7 @@ import { ApproveIcon } from "../icons/ApproveIcon"; import { WorkingIcon } from "../icons/WorkingIcon"; export const ModeSelector = () => { - const { mode, setMode } = useMode(); + const { mode, setMode, isImportMode } = useMode(); const handleClick = (selectedMode) => { setMode(selectedMode); @@ -47,6 +47,7 @@ export const ModeSelector = () => { onClick={() => handleClick(MODES.WORKING)} className="flex items-center justify-center" size="large" + disabled={isImportMode} /> diff --git a/src/components/RegionSelect.jsx b/src/components/RegionSelect.jsx index bdb46a0..e02f7ef 100644 --- a/src/components/RegionSelect.jsx +++ b/src/components/RegionSelect.jsx @@ -27,7 +27,7 @@ export const useGetRegions = () => { return useQuery( ["regions"], async () => { - const { data } = await api.get("/api/ao_rayons"); + const { data } = await api.get("/api/ao_rayons/"); return data; }, { @@ -37,6 +37,8 @@ export const useGetRegions = () => { normalized: normalizeRegions(rawRegions), }; }, + refetchOnWindowFocus: false, + refetchOnMount: false } ); }; diff --git a/src/components/Title.jsx b/src/components/Title.jsx index c9c2f7c..4ac0255 100644 --- a/src/components/Title.jsx +++ b/src/components/Title.jsx @@ -3,11 +3,11 @@ import { twMerge } from "tailwind-merge"; const { Text } = Typography; -export const Title = ({ text, className, classNameText }) => { +export const Title = ({ text, className, classNameText, type = "secondary" }) => { return (
{text} diff --git a/src/hooks/useLocalStorage.js b/src/hooks/useLocalStorage.js new file mode 100644 index 0000000..01f6d35 --- /dev/null +++ b/src/hooks/useLocalStorage.js @@ -0,0 +1,25 @@ +import { useState, useEffect } from "react"; + +const useLocalStorage = (key, defaultValue) => { + const [value, setValue] = useState(() => { + let currentValue; + + try { + currentValue = JSON.parse( + localStorage.getItem(key) || String(defaultValue) + ); + } catch (error) { + currentValue = defaultValue; + } + + return currentValue; + }); + + useEffect(() => { + localStorage.setItem(key, JSON.stringify(value)); + }, [value, key]); + + return [value, setValue]; +}; + +export default useLocalStorage; \ No newline at end of file diff --git a/src/hooks/useUpdateStatus.js b/src/hooks/useUpdateStatus.js index 2254506..84f84fb 100644 --- a/src/hooks/useUpdateStatus.js +++ b/src/hooks/useUpdateStatus.js @@ -1,14 +1,15 @@ import { useMutation } from "@tanstack/react-query"; -import { api } from "../api"; +import { api, useDbTableName } from "../api"; import { useUpdateLayerCounter } from "../stores/useUpdateLayerCounter"; export const useUpdateStatus = ({ onSuccess }) => { const { toggleUpdateCounter } = useUpdateLayerCounter(); + const dbTable = useDbTableName(); return useMutation({ mutationFn: (params) => { return api.put( - `/api/placement_points/update_status?${params.toString()}` + `/api/${dbTable}/update_status/?${params.toString()}` ); }, onSuccess: () => { diff --git a/src/icons/Logo.jsx b/src/icons/Logo.jsx index 44a6ff6..78beaec 100644 --- a/src/icons/Logo.jsx +++ b/src/icons/Logo.jsx @@ -1,23 +1,42 @@ -export const Logo = ({ width = 40, height = 40, fill }) => { +import logo from "../assets/logopng.png"; + +export const Logo = ({ width = 40, height = 40 }) => { return ( - - - - + {"logo"}/ + ); +}; + +export const HeaderLogo = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); }; diff --git a/src/icons/PanoramaIcon.jsx b/src/icons/PanoramaIcon.jsx new file mode 100644 index 0000000..b2ba483 --- /dev/null +++ b/src/icons/PanoramaIcon.jsx @@ -0,0 +1,12 @@ +export const PanoramaIcon = ({ width = 24, height = 24 }) => { + return ( + + + + ); +}; diff --git a/src/icons/icons-config.js b/src/icons/icons-config.js index 8b3e604..b95da27 100644 --- a/src/icons/icons-config.js +++ b/src/icons/icons-config.js @@ -1,3 +1,3 @@ -import logo from "../assets/logo.svg"; +import logo from "../assets/logopng.png"; export const icons = [{ name: "logo", url: logo }]; diff --git a/src/index.css b/src/index.css index d9bce9f..ce72427 100644 --- a/src/index.css +++ b/src/index.css @@ -26,9 +26,9 @@ @apply bg-white-background rounded-xl max-h-[calc(100vh-100px)] overflow-y-auto; } -.ant-modal-header { - border-bottom: none; -} +/*.ant-modal-header {*/ +/* border-bottom: none;*/ +/*}*/ .ant-select-multiple .ant-select-selection-item { @apply !bg-rose; @@ -95,6 +95,17 @@ width: 100% !important; } +.legend_group .ant-collapse-header { + padding: 0 !important; +} + +.filter_group .ant-collapse-header { + padding: 0 !important; +} +.filter_group .ant-collapse-arrow { + right: 0 !important; +} + ::-webkit-scrollbar { width: 12px; height: 12px; @@ -112,3 +123,19 @@ scrollbar-width: thin; scrollbar-color: rgba(0, 0, 0, 0.2) transparent; } + +.import_status_new { + background: rgba(82, 196, 26, 0.25); + color: rgb(82, 196, 26); + border-color: rgb(82, 196, 26); +} +.import_status_error { + background: rgba(245, 34, 45, 0.25); + color: rgb(245, 34, 45); + border-color: rgb(245, 34, 45); +} +.import_status_matched { + background: rgba(47, 84, 235, 0.25); + color: rgb(47, 84, 235); + border-color: rgb(47, 84, 235); +} diff --git a/src/modules/ImportMode/LoadingStage.jsx b/src/modules/ImportMode/LoadingStage.jsx new file mode 100644 index 0000000..64d4aa1 --- /dev/null +++ b/src/modules/ImportMode/LoadingStage.jsx @@ -0,0 +1,52 @@ +import { useLayoutEffect, useRef, useState } from "react"; +import { downloadImportTemplate, uploadPointsFile } from "../../api.js"; +import { Button, Upload } from "antd"; +import { UploadOutlined } from "@ant-design/icons"; +import { download } from "../../utils.js"; + +export const LoadingStage = ({setFileId}) => { + const ref = useRef(null); + const [isClicked, setIsClicked] = useState(true); + + const onFileChange = async (options) => { + const { onSuccess, onError, file, onProgress } = options; + const config = { + onUploadProgress: event => { + const percent = Math.floor((event.loaded / event.total) * 100); + onProgress({ percent }); + } + }; + try { + const {id} = await uploadPointsFile(file, config); + onSuccess("Ok"); + setFileId(id); + } catch (e) { + // + } + }; + + const onTemplateDownload = async () => { + const data = await downloadImportTemplate(); + await download('template.xlsx', data); + } + + useLayoutEffect(() => { + if (ref && ref.current && !isClicked) { + ref.current.click(); + setIsClicked(true); + } + }, [isClicked]); + + return ( + <> + + + + + + ); +} \ No newline at end of file diff --git a/src/modules/ImportMode/MergePointsModal.jsx b/src/modules/ImportMode/MergePointsModal.jsx new file mode 100644 index 0000000..d3df527 --- /dev/null +++ b/src/modules/ImportMode/MergePointsModal.jsx @@ -0,0 +1,97 @@ +import { Button, Modal, Spin } from "antd"; +import { useState } from "react"; +import { useGetPointsToMergeCount, useMergePointsToDb } from "../../api.js"; +import { CheckCircleOutlined, InfoCircleOutlined, LoadingOutlined } from "@ant-design/icons"; +import { useMode } from "../../stores/useMode.js"; + +export const MergePointsModal = ({isOpened, onClose}) => { + const {setImportMode} = useMode(); + const [isLoading, setIsLoading] = useState(false); + const { data: filteredCount, isInitialLoading: isFilteredLoading } = + useGetPointsToMergeCount(); + const [isSuccess, setIsSuccess] = useState(false); + const {mutateAsync: mergePoints} = useMergePointsToDb(); + + const onConfirm = async () => { + setIsLoading(true); + + try { + await mergePoints(); + setIsSuccess(true); + } catch (e) { + // + } finally { + setIsLoading(false); + } + } + + const getFooter = () => { + if (isSuccess) return [ + , + ]; + return [ + , + , + ] + } + + const getContent = () => { + if (isFilteredLoading) return ; + if (isLoading) return ( +
+ } /> + Добавляем точки... +
+ ); + if (isSuccess) return ( +
+ + Добавлено {filteredCount} новых точек +
+ ); + return ( +
+ +
+

Подтвердите добавление

+

В базу данных будет добавлено {filteredCount} новых точек.

+
+
+ ); + } + + return ( + + {getContent()} + + ); +} \ No newline at end of file diff --git a/src/modules/ImportMode/PointsFileUploadModal.jsx b/src/modules/ImportMode/PointsFileUploadModal.jsx new file mode 100644 index 0000000..6637d44 --- /dev/null +++ b/src/modules/ImportMode/PointsFileUploadModal.jsx @@ -0,0 +1,114 @@ +import { Button, Modal, Spin } from "antd"; +import { useState } from "react"; +import { importPoints } from "../../api.js"; +import { LoadingStage } from "./LoadingStage.jsx"; +import { ReportStage } from "./ReportStage.jsx"; +import { + CheckCircleOutlined, + CloseCircleOutlined, + LoadingOutlined +} from "@ant-design/icons"; +import { useUpdateLayerCounter } from "../../stores/useUpdateLayerCounter.js"; + +export const PointsFileUploadModal = ({onClose, isOpened}) => { + const [fileId, setFileId] = useState(); + const [report, setReport] = useState(); + const [isImporting, setIsImporting] = useState(false); + const [isReportStage, setIsReportStage] = useState(false); + const [isError, setIsError] = useState(false); + const { toggleUpdateCounter } = useUpdateLayerCounter(); + + const onImportPoints = async () => { + setIsImporting(true); + try { + const { message } = await importPoints(fileId); + setReport(message); + toggleUpdateCounter(); + } catch (e) { + setIsError(true); + } finally { + setIsImporting(false); + } + } + + const getFooter = () => { + if (isError) return [ + + ] + if (isReportStage) return [ + + ] + if (report) return [ + + ] + return [ + , + , + ] + } + const getContent = () => { + if (isError) return ( +
+ + При импорте точек произошла ошибка +
+ ) + if (isImporting) return ( +
+ } /> + Импортируем точки... +
+ ); + if (isReportStage) return ; + if (report) return ( +
+ + Точки успешно импортированы +
+ ); + return ; + } + + return ( + + {getContent()} + + ); +} \ No newline at end of file diff --git a/src/modules/ImportMode/ReportStage.jsx b/src/modules/ImportMode/ReportStage.jsx new file mode 100644 index 0000000..16f19c1 --- /dev/null +++ b/src/modules/ImportMode/ReportStage.jsx @@ -0,0 +1,34 @@ +import { Col, Row } from "antd"; +import { twMerge } from "tailwind-merge"; + +export const ReportStage = ({ report }) => { + return ( + <> + + + Всего точек: + + {report.total} + + + + Совпадений: + + {report.matched} + + + + Проблемные: + + {report.error} + + + + Новые: + + {report.unmatched} + + + + ); +} \ No newline at end of file diff --git a/src/modules/ImportMode/SidebarButtons.jsx b/src/modules/ImportMode/SidebarButtons.jsx new file mode 100644 index 0000000..64cde60 --- /dev/null +++ b/src/modules/ImportMode/SidebarButtons.jsx @@ -0,0 +1,57 @@ +import { useMode } from "../../stores/useMode.js"; +import { Button } from "antd"; +import { ImportOutlined } from "@ant-design/icons"; +import { PointsFileUploadModal } from "./PointsFileUploadModal.jsx"; +import { useState } from "react"; +import { MergePointsModal } from "./MergePointsModal.jsx"; +import { MODES } from "../../config.js"; + +export const ImportModeSidebarButtons = () => { + const { mode, isImportMode, setImportMode } = useMode(); + const [uploadModalOpen, setUploadModalOpen] = useState(false); + const [addPointsModalOpen, setAddPointsModalOpen] = useState(false); + + const onCancel = () => { + setImportMode(false); + }; + + const onImport = () => { + setImportMode(true); + setUploadModalOpen(true); + }; + + if (isImportMode) { + return ( +
+ + + {uploadModalOpen && + setUploadModalOpen(false)} + /> + } + {addPointsModalOpen && + setAddPointsModalOpen(false)} + /> + } + +
+ ); + } + + return mode === MODES.PENDING && ( +
+ +
+ ); +} \ No newline at end of file diff --git a/src/modules/Sidebar/PendingPointsFilters/AdvancedFilters/AdvancedFilters.jsx b/src/modules/Sidebar/PendingPointsFilters/AdvancedFilters/AdvancedFilters.jsx new file mode 100644 index 0000000..94bd47e --- /dev/null +++ b/src/modules/Sidebar/PendingPointsFilters/AdvancedFilters/AdvancedFilters.jsx @@ -0,0 +1,227 @@ +import { CATEGORIES_MAP, RANGE_FILTERS_KEYS, usePendingPointsFilters } from "../../../../stores/usePendingPointsFilters"; +import { FilterSlider } from "./Slider.jsx"; +import { Button, Collapse } from "antd"; +import React, { useMemo } from "react"; +import { Title } from "../../../../components/Title.jsx"; +import { usePostamatesAndPvzGroups } from "../../../../api.js"; +import { fieldHasChanged, getFilteredGroups } from "../../../../utils.js"; +import { CloseOutlined } from "@ant-design/icons"; + +export const AdvancedFilters = ({onClose}) => { + const { filters, ranges, setFilterWithKey } = usePendingPointsFilters(); + + const { data: postamatesAndPvzGroups } = usePostamatesAndPvzGroups(); + + const filteredPostamatesGroups = useMemo(() => { + return getFilteredGroups(postamatesAndPvzGroups); + }, [postamatesAndPvzGroups]); + + const selectedCnt = useMemo(() => { + let counter = 0; + RANGE_FILTERS_KEYS.map((key) => { + if (fieldHasChanged(filters, ranges, key).result) counter += 1; + }); + return counter; + }, [filters, ranges]); + + const clearFilters = () => { + RANGE_FILTERS_KEYS.map((key) => { + setFilterWithKey(ranges[key], key); + }); + }; + + return ( +
+
+ Расширенные фильтры + +
+
+ + } + forceRender + > +
+
+ +
+
+ +
+
+ +
+
+ + + } + forceRender + > +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ + {filteredPostamatesGroups.map((category) => { + return ( + + } + forceRender + > +
+ {category.groups.map((group) => { + return ( +
+ +
+ ) + })} +
+
+
+ ) + })} +
+
+ Выбрано: {selectedCnt} +
+ +
+
+
+ ); +}; diff --git a/src/modules/Sidebar/PendingPointsFilters/AdvancedFilters/AdvancedFiltersWrapper.jsx b/src/modules/Sidebar/PendingPointsFilters/AdvancedFilters/AdvancedFiltersWrapper.jsx new file mode 100644 index 0000000..a5ead79 --- /dev/null +++ b/src/modules/Sidebar/PendingPointsFilters/AdvancedFilters/AdvancedFiltersWrapper.jsx @@ -0,0 +1,99 @@ +import { Button, Dropdown, Popover } from "antd"; +import { AdvancedFilters } from "./AdvancedFilters.jsx"; +import { RightOutlined } from "@ant-design/icons"; +import {useMemo, useState} from "react"; + +import { + RANGE_FILTERS_KEYS, + RANGE_FILTERS_MAP, + usePendingPointsFilters +} from "../../../../stores/usePendingPointsFilters.js"; +import {fieldHasChanged} from "../../../../utils.js"; + +export const AdvancedFiltersWrapper = () => { + const { filters, ranges } = usePendingPointsFilters(); + + const selectedCnt = useMemo(() => { + let counter = 0; + RANGE_FILTERS_KEYS.map((key) => { + if (fieldHasChanged(filters, ranges, key).result) counter += 1; + }) + return counter; + }, [filters, ranges]); + + const getPopoverContent = () => { + const keys = RANGE_FILTERS_KEYS.map((key) => { + if (fieldHasChanged(filters, ranges, key).result) return key; + }).filter(k => !!k); + + if (keys.length === 0) { + return

Не выбрано ни одного фильтра

+ } + + return ( +
    + {Object.keys(RANGE_FILTERS_MAP).map((category_object) => { + const obj = RANGE_FILTERS_MAP[category_object]; + const selectedArr = []; + + keys.map((key) => { + if (obj[key]) selectedArr.push(obj[key]); + }); + + if (selectedArr.length === 0) return; + + return ( +
  • + + {obj.name + ' '} + + ({selectedArr.join(", ")}) + +
  • + ); + })} +
+ ); + } + + const [open, setOpen] = useState(false); + const handleOpenChange = (flag) => { + setOpen(flag); + }; + const filtersRender = () => { + return ( + setOpen(false)}/> + ) + }; + + return ( + filtersRender()} + onOpenChange={handleOpenChange} + open={open} + forceRender + placement='right' + > + + + ); +}; \ No newline at end of file diff --git a/src/modules/Sidebar/PendingPointsFilters/AdvancedFilters/Slider.jsx b/src/modules/Sidebar/PendingPointsFilters/AdvancedFilters/Slider.jsx new file mode 100644 index 0000000..0b4e947 --- /dev/null +++ b/src/modules/Sidebar/PendingPointsFilters/AdvancedFilters/Slider.jsx @@ -0,0 +1,44 @@ +import { SliderComponent as Slider } from "../../../../components/SliderComponent"; +import { useEffect } from "react"; +import { + INITIAL, usePendingPointsFilters, +} from "../../../../stores/usePendingPointsFilters"; + +export const FilterSlider = ({ filterRange, disabled, fullRange, title, filterKey, dynamicKey }) => { + + const { setFilterWithKey } = usePendingPointsFilters(); + const handleAfterChange = (range) => setFilterWithKey(range, filterKey); + + useEffect(() => { + if (!fullRange) return; + + const min = fullRange[0]; + const max = fullRange[1]; + + const shouldSetFullRange = + filterRange[0] === INITIAL[`${filterKey}__gt`] && + filterRange[1] === INITIAL[`${filterKey}__lt`]; + + const shouldSetDynamicKeyRange = + (filterRange[0] === undefined && + filterRange[1] === undefined) || + (filterRange[0] === 0 && + filterRange[1] === 0); + + if (shouldSetFullRange || (shouldSetDynamicKeyRange && dynamicKey)) { + setFilterWithKey([min, max], filterKey); + } + }, [fullRange]); + + return ( + + ); +}; diff --git a/src/modules/Sidebar/PendingPointsFilters/PendingPointsFilters.jsx b/src/modules/Sidebar/PendingPointsFilters/PendingPointsFilters.jsx index f2d770a..d800891 100644 --- a/src/modules/Sidebar/PendingPointsFilters/PendingPointsFilters.jsx +++ b/src/modules/Sidebar/PendingPointsFilters/PendingPointsFilters.jsx @@ -1,5 +1,5 @@ -import { DISABLED_FILTER_TEXT, STATUSES } from "../../../config"; -import { Button, Tooltip } from "antd"; +import { DISABLED_FILTER_TEXT } from "../../../config"; +import {Button, Spin, Tooltip} from "antd"; import { SelectedLocations } from "./SelectedLocations"; import { TakeToWorkButton } from "./TakeToWorkButton"; import { RegionSelect } from "../../../components/RegionSelect"; @@ -10,37 +10,36 @@ import { useHasManualEdits, usePointSelection, } from "../../../stores/usePointSelection"; -import { usePendingPointsFilters } from "../../../stores/usePendingPointsFilters"; +import {RANGE_FILTERS_KEYS, usePendingPointsFilters} from "../../../stores/usePendingPointsFilters"; import { ClearFiltersButton } from "../../../components/ClearFiltersButton"; import { getDynamicActiveFilters } from "../utils"; -import { api, useCanEdit } from "../../../api"; -import { useQuery } from "@tanstack/react-query"; - -const useGetPendingPointsRange = () => { - return useQuery( - ["prediction-max-min"], - async () => { - const { data } = await api.get( - `/api/placement_points/filters?status[]=${STATUSES.pending}` - ); - - return data; - }, - { - select: (data) => { - return { - prediction: [data.prediction_current[0], data.prediction_current[1]], - }; - }, - } - ); -}; +import { useDbTableName, useCanEdit, useGetPendingPointsRange } from "../../../api"; +import { AdvancedFiltersWrapper } from "./AdvancedFilters/AdvancedFiltersWrapper.jsx"; +import { predictionHasChanged } from "../../../utils.js"; export const PendingPointsFilters = () => { const hasManualEdits = useHasManualEdits(); const { reset: resetPointSelection } = usePointSelection(); - const { filters, setRegion, clear } = usePendingPointsFilters(); - const { data: fullRange, isInitialLoading } = useGetPendingPointsRange(); + const { ranges, filters, setRegion, setFilterWithKey, setPrediction, setCategories, setRanges } = usePendingPointsFilters(); + const dbTable = useDbTableName(); + const { data } = useGetPendingPointsRange(dbTable); + + useEffect(() => { + const newRanges = data?.fullRange; + if (!newRanges) return; + RANGE_FILTERS_KEYS.map((key) => { + if (!ranges[key]) { + setFilterWithKey(newRanges[key], key); + return; + } + const gtChanged = ranges[key][0] !== newRanges[key][0]; + const ltChanged = ranges[key][1] !== newRanges[key][1]; + + if (gtChanged || ltChanged) setFilterWithKey(newRanges[key], key); + }); + if (predictionHasChanged(newRanges, ranges)) setPrediction(newRanges.prediction); + setRanges({...ranges, ...newRanges}); + }, [data]); const [isSelectionEmpty, setIsSelectionEmpty] = useState(false); @@ -68,11 +67,18 @@ export const PendingPointsFilters = () => { setHover(false); }; - const activeDynamicFilters = getDynamicActiveFilters(filters, fullRange, [ + const activeDynamicFilters = getDynamicActiveFilters(filters, ranges, [ "prediction", ]); - const clearFilters = () => clear(fullRange); + const clearFilters = () => { + RANGE_FILTERS_KEYS.map((key) => { + setFilterWithKey(ranges[key], key); + }); + setPrediction(ranges.prediction); + setCategories([]); + setRegion(null); + }; const hasActiveFilters = filters.region || @@ -98,11 +104,16 @@ export const PendingPointsFilters = () => { onChange={setRegion} /> - + {data?.isLoading ? : + <> + + + + }
{hasActiveFilters && ( diff --git a/src/modules/Sidebar/PendingPointsFilters/SelectedLocations.jsx b/src/modules/Sidebar/PendingPointsFilters/SelectedLocations.jsx index 0e67cfb..c1e6921 100644 --- a/src/modules/Sidebar/PendingPointsFilters/SelectedLocations.jsx +++ b/src/modules/Sidebar/PendingPointsFilters/SelectedLocations.jsx @@ -9,7 +9,7 @@ import { useEffect } from "react"; export const SelectedLocations = ({ onSelectedChange }) => { const { data: totalCount, isInitialLoading: isTotalLoading } = useGetTotalInitialPointsCount(); - const { data: filteredCount, isInitialLoading: isFilteredLoading } = + const { data: filteredCount, isInitialLoading: isFilteredLoading, isFetching: isFilteredFetching } = useGetFilteredPendingPointsCount(); const { @@ -21,7 +21,7 @@ export const SelectedLocations = ({ onSelectedChange }) => { [filteredCount, excluded] ); - const showSpinner = isTotalLoading || isFilteredLoading; + const showSpinner = isTotalLoading || isFilteredLoading || isFilteredFetching; return (
diff --git a/src/modules/Sidebar/PendingPointsFilters/TakeToWorkButton.jsx b/src/modules/Sidebar/PendingPointsFilters/TakeToWorkButton.jsx index 64bf172..5f78c5d 100644 --- a/src/modules/Sidebar/PendingPointsFilters/TakeToWorkButton.jsx +++ b/src/modules/Sidebar/PendingPointsFilters/TakeToWorkButton.jsx @@ -7,9 +7,10 @@ import { useUpdateStatus } from "../../../hooks/useUpdateStatus"; import { ArrowRightOutlined } from "@ant-design/icons"; import { Title } from "../../../components/Title"; import { usePendingPointsFilters } from "../../../stores/usePendingPointsFilters"; +import { appendFiltersInUse } from "../../../utils.js"; export const TakeToWorkButton = ({ disabled }) => { - const { filters } = usePendingPointsFilters(); + const { filters, ranges } = usePendingPointsFilters(); const { prediction, categories, region } = filters; const { selection } = usePointSelection(); const queryClient = useQueryClient(); @@ -34,6 +35,8 @@ export const TakeToWorkButton = ({ disabled }) => { "excluded[]": [...selection.excluded], }); + appendFiltersInUse(params, filters, ranges); + if (region) { if (region.type === "ao") { params.append("district[]", region.id); diff --git a/src/modules/Sidebar/Sidebar.jsx b/src/modules/Sidebar/Sidebar.jsx index 24469f0..e9ebb46 100644 --- a/src/modules/Sidebar/Sidebar.jsx +++ b/src/modules/Sidebar/Sidebar.jsx @@ -5,6 +5,7 @@ import { MODES } from "../../config"; import { PendingPointsFilters } from "./PendingPointsFilters/PendingPointsFilters"; import { OnApprovalPointsFilters } from "./OnApprovalPointsFilters/OnApprovalPointsFilters"; import { WorkingPointsFilters } from "./WorkingPointsFilters/WorkingPointsFilters"; +import { ImportModeSidebarButtons } from "../ImportMode/SidebarButtons.jsx"; export const Sidebar = forwardRef(({ isCollapsed }, ref) => { const { mode } = useMode(); @@ -29,6 +30,7 @@ export const Sidebar = forwardRef(({ isCollapsed }, ref) => { )} ref={ref} > +
{getFilters()}
); diff --git a/src/modules/Sidebar/WorkingPointsFilters/WorkingPointsFilters.jsx b/src/modules/Sidebar/WorkingPointsFilters/WorkingPointsFilters.jsx index 4101979..95de144 100644 --- a/src/modules/Sidebar/WorkingPointsFilters/WorkingPointsFilters.jsx +++ b/src/modules/Sidebar/WorkingPointsFilters/WorkingPointsFilters.jsx @@ -7,15 +7,18 @@ import { ClearFiltersButton } from "../../../components/ClearFiltersButton"; import { getDynamicActiveFilters } from "../utils"; import { Spin } from "antd"; import { useQuery } from "@tanstack/react-query"; -import { api } from "../../../api.js"; +import { api, useDbTableName } from "../../../api.js"; import { STATUSES } from "../../../config.js"; +import { useEffect } from "react"; +import { workingFilterHasChanged } from "../../../utils.js"; const useGetDataRange = () => { + const dbTable = useDbTableName(); return useQuery( ["working-max-min"], async () => { const { data } = await api.get( - `/api/placement_points/filters?status[]=${STATUSES.working}` + `/api/${dbTable}/filters?status[]=${STATUSES.working}` ); return data; @@ -33,11 +36,20 @@ const useGetDataRange = () => { }; export const WorkingPointsFilters = () => { - const { filters, setRegion, clear } = useWorkingPointsFilters(); + const { filters, ranges, setRegion, setAge, setDeltaTraffic, setRanges, setFactTraffic, clear } = useWorkingPointsFilters(); const { data: fullRange, isInitialLoading: isFullRangeLoading } = useGetDataRange(); + useEffect(() => { + if (!fullRange) return; + const newRanges = fullRange; + if (workingFilterHasChanged(newRanges.deltaTraffic, ranges, "deltaTraffic")) setDeltaTraffic(fullRange.deltaTraffic); + if (workingFilterHasChanged(newRanges.factTraffic, ranges, "factTraffic")) setFactTraffic(fullRange.deltaTraffic); + if (workingFilterHasChanged(newRanges.age, ranges, "age")) setAge(fullRange.deltaTraffic); + setRanges({...newRanges}); + }, [fullRange]); + const activeDynamicFilters = getDynamicActiveFilters(filters, fullRange, [ "deltaTraffic", "factTraffic", diff --git a/src/modules/Table/HeaderWrapper.jsx b/src/modules/Table/HeaderWrapper.jsx index de1a562..f068fc7 100644 --- a/src/modules/Table/HeaderWrapper.jsx +++ b/src/modules/Table/HeaderWrapper.jsx @@ -3,6 +3,7 @@ import { Button, Tooltip } from "antd"; import { useTable } from "../../stores/useTable"; import { FullscreenExitOutlined, FullscreenOutlined } from "@ant-design/icons"; import { useEffect, useState } from "react"; +import { TableSettings } from "./TableSettings"; const ToggleFullScreenButton = () => { const { @@ -51,6 +52,7 @@ export const HeaderWrapper = ({ rightColumn, exportProvider, classes, + orderColumns }) => { return (
@@ -61,6 +63,7 @@ export const HeaderWrapper = ({
{rightColumn}
+ {exportProvider && }
diff --git a/src/modules/Table/OnApprovalTable/Header.jsx b/src/modules/Table/OnApprovalTable/Header.jsx index 7513e82..206bf11 100644 --- a/src/modules/Table/OnApprovalTable/Header.jsx +++ b/src/modules/Table/OnApprovalTable/Header.jsx @@ -10,6 +10,7 @@ export const Header = ({ selectedIds, onClearSelected, onOpenMakeWorkingModal, + orderColumns }) => { const [status, setStatus] = useState(STATUSES.pending); @@ -42,6 +43,7 @@ export const Header = ({ leftColumn: "flex items-center gap-x-4", rightColumn: "flex item-center gap-x-4", }} + orderColumns={orderColumns} exportProvider={useExportOnApprovalData} /> ); diff --git a/src/modules/Table/OnApprovalTable/OnApprovalTable.jsx b/src/modules/Table/OnApprovalTable/OnApprovalTable.jsx index c7bd27b..54115d5 100644 --- a/src/modules/Table/OnApprovalTable/OnApprovalTable.jsx +++ b/src/modules/Table/OnApprovalTable/OnApprovalTable.jsx @@ -1,6 +1,6 @@ import { Table } from "../Table"; import { useQuery } from "@tanstack/react-query"; -import { getPoints, useCanEdit } from "../../../api"; +import { getPoints, useCanEdit, useDbTableName } from "../../../api"; import { useCallback, useState } from "react"; import { PAGE_SIZE } from "../constants"; import { STATUSES } from "../../../config"; @@ -19,6 +19,8 @@ const extraCols = [ key: "postamat_id", width: "70px", ellipsis: true, + sorter: true, + showSorterTooltip: false, }, ]; @@ -31,13 +33,20 @@ export const OnApprovalTable = ({ fullWidth }) => { } = useOnApprovalPointsFilters(); const [isMakeWorkingModalOpened, setIsMakeWorkingModalOpened] = useState(false); - const columns = useColumns(extraCols); + const { columns, orderColumns, sort, setSort } = useColumns(extraCols, 'onApprovalTableOrder'); const { isVisible } = useLayersVisibility(); + const dbTable = useDbTableName(); + + const onSort = (sortDirection, key) => { + if (sortDirection === `ascend`) setSort(key); + if (sortDirection === `descend`) setSort(`-${key}`); + if (!sortDirection) setSort(null); + } const clearSelected = () => setSelectedIds([]); - const { data, isInitialLoading } = useQuery( - ["on-approval-points", page, region, isVisible], + const { data, isInitialLoading, isFetching } = useQuery( + ["on-approval-points", page, region, isVisible, sort], async () => { const statuses = []; @@ -60,13 +69,14 @@ export const OnApprovalTable = ({ fullWidth }) => { statuses.length > 0 ? statuses : [STATUSES.onApproval, STATUSES.working, STATUSES.cancelled], + ordering: sort, }); if (statuses.length === 0) { return { count: 0, results: [] }; } - return await getPoints(params, region); + return await getPoints(params, region, dbTable); }, { keepPreviousData: true } ); @@ -94,6 +104,7 @@ export const OnApprovalTable = ({ fullWidth }) => { selectedIds={selectedIds} onClearSelected={clearSelected} onOpenMakeWorkingModal={() => setIsMakeWorkingModalOpened(true)} + orderColumns={orderColumns} /> } rowSelection={canEdit ? rowSelection : undefined} @@ -104,7 +115,10 @@ export const OnApprovalTable = ({ fullWidth }) => { isClickedPointLoading={isClickedPointLoading} columns={columns} fullWidth={fullWidth} - loading={isInitialLoading} + onChange={(val, filter, sorter) => { + onSort(sorter.order, sorter.columnKey); + }} + loading={isInitialLoading || isFetching} /> {isMakeWorkingModalOpened && ( { const { filters: { region }, } = useOnApprovalPointsFilters(); + const dbTable = useDbTableName(); return useQuery( ["export-on-approval", region], @@ -16,7 +17,7 @@ export const useExportOnApprovalData = (enabled, onSettled) => { "status[]": [STATUSES.onApproval, STATUSES.working], }); - return await exportPoints(params, region); + return await exportPoints(params, region, dbTable); }, { enabled, diff --git a/src/modules/Table/PendingTable/PendingTable.jsx b/src/modules/Table/PendingTable/PendingTable.jsx index db74dd6..2f3af31 100644 --- a/src/modules/Table/PendingTable/PendingTable.jsx +++ b/src/modules/Table/PendingTable/PendingTable.jsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from "react"; +import React, {useCallback, useState} from "react"; import { Table } from "../Table"; import { usePointSelection } from "../../../stores/usePointSelection"; import { useClickedPointConfig } from "../../../stores/useClickedPointConfig"; @@ -9,20 +9,31 @@ import { useCanEdit } from "../../../api"; import { useColumns } from "../useColumns.jsx"; import { PAGE_SIZE } from "../constants.js"; import { usePopup } from "../../../stores/usePopup.js"; +import { usePendingTableFields } from "./usePendingTableFields.jsx"; +const tableKey = 'pendingTable'; export const PendingTable = ({ fullWidth }) => { const { selection, include, exclude } = usePointSelection(); const { clickedPointConfig, setClickedPointConfig } = useClickedPointConfig(); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(PAGE_SIZE); - const columns = useColumns(); + + const fields = usePendingTableFields(); + const {columns, orderColumns, sort, setSort} = useColumns(fields, tableKey); const { setPopup } = usePopup(); + const onSort = (sortDirection, key) => { + if (sortDirection === `ascend`) setSort(key); + if (sortDirection === `descend`) setSort(`-${key}`); + if (!sortDirection) setSort(null); + } + const { data, isClickedPointLoading, isDataLoading } = usePendingTableData( page, () => setPage(1), pageSize, - setPageSize + setPageSize, + sort ); const resetPageSize = () => setPageSize(PAGE_SIZE); @@ -79,7 +90,10 @@ export const PendingTable = ({ fullWidth }) => { isClickedPointLoading={isClickedPointLoading} columns={columns} fullWidth={fullWidth} - header={} + onChange={(val, filter, sorter) => { + onSort(sorter.order, sorter.columnKey); + }} + header={} loading={isDataLoading} /> ); diff --git a/src/modules/Table/PendingTable/useExportPendingData.js b/src/modules/Table/PendingTable/useExportPendingData.js index 014f7aa..7ab1b1e 100644 --- a/src/modules/Table/PendingTable/useExportPendingData.js +++ b/src/modules/Table/PendingTable/useExportPendingData.js @@ -1,38 +1,47 @@ import { usePointSelection } from "../../../stores/usePointSelection"; import { useQuery } from "@tanstack/react-query"; -import { exportPoints } from "../../../api"; +import { exportPoints, useDbTableName } from "../../../api"; import { handleExportSuccess } from "../ExportButton"; import { usePendingPointsFilters } from "../../../stores/usePendingPointsFilters"; import { STATUSES } from "../../../config.js"; +import { appendFiltersInUse } from "../../../utils.js"; export const useExportPendingData = (enabled, onSettled) => { - const { filters } = usePendingPointsFilters(); - const { prediction, categories, region } = filters; + const { filters, ranges } = usePendingPointsFilters(); + const { categories, region } = filters; const { selection } = usePointSelection(); const includedArr = [...selection.included]; const excludedArr = [...selection.excluded]; + const dbTable = useDbTableName(); return useQuery( ["export-initial", filters, selection], async () => { - const params = new URLSearchParams({ - "prediction_current[]": prediction, - "status[]": [STATUSES.pending], - }); - if (categories.length) { - params.append("categories[]", categories); - } + const getParams = () => { + const params = new URLSearchParams({ + "status[]": [STATUSES.pending], + }); - if (includedArr.length) { - params.append("included[]", includedArr); - } + appendFiltersInUse(params, filters, ranges); + params.append("status[]", [STATUSES.pending, STATUSES.cancelled].join(",")); + + if (categories.length) { + params.append("categories[]", categories); + } + + if (includedArr.length) { + params.append("included[]", includedArr); + } + + if (excludedArr.length) { + params.append("excluded[]", excludedArr); + } - if (excludedArr.length) { - params.append("excluded[]", excludedArr); + return params; } - return await exportPoints(params, region); + return await exportPoints(getParams(), region, dbTable); }, { enabled, diff --git a/src/modules/Table/PendingTable/usePendingTableData.js b/src/modules/Table/PendingTable/usePendingTableData.js index 1d7619c..d0a52e6 100644 --- a/src/modules/Table/PendingTable/usePendingTableData.js +++ b/src/modules/Table/PendingTable/usePendingTableData.js @@ -1,25 +1,41 @@ import { useQuery } from "@tanstack/react-query"; -import { getPoints } from "../../../api"; +import { useDbTableName, getPoints } from "../../../api"; import { useMergeTableData } from "../useMergeTableData"; import { STATUSES } from "../../../config"; import { usePendingPointsFilters } from "../../../stores/usePendingPointsFilters"; +import { appendFiltersInUse } from "../../../utils.js"; +import { useUpdateLayerCounter } from "../../../stores/useUpdateLayerCounter.js"; -export const usePendingTableData = (page, resetPage, pageSize, setPageSize) => { - const { filters } = usePendingPointsFilters(); - const { prediction, categories, region } = filters; - - const { data, isInitialLoading } = useQuery( - ["table", page, filters], - async () => { - const params = new URLSearchParams({ - page, - page_size: pageSize, - "prediction_current[]": prediction, - "status[]": [STATUSES.pending], - "categories[]": categories, - }); - - return await getPoints(params, region); +export const usePendingTableData = (page, resetPage, pageSize, setPageSize, sort) => { + const { filters, ranges } = usePendingPointsFilters(); + const { updateCounter } = useUpdateLayerCounter(); + const { + categories, + region, + } = filters; + + const dbTable = useDbTableName(); + + const getParams = () => { + const params = new URLSearchParams({ + page, + page_size: pageSize, + "categories[]": categories, + ordering: sort, + }); + + appendFiltersInUse(params, filters, ranges); + params.append("status[]", [STATUSES.pending, STATUSES.cancelled].join(",")) + + return params; + } + + const {data, isInitialLoading, isFetching} = useQuery( + ["table", page, filters, sort, dbTable, updateCounter], + async ({signal}) => { + const params = getParams(); + + return await getPoints(params, region, dbTable, signal); }, { keepPreviousData: true, @@ -28,10 +44,12 @@ export const usePendingTableData = (page, resetPage, pageSize, setPageSize) => { resetPage(); } }, + refetchOnWindowFocus: false, + refetchOnMount: false } ); - const { data: mergedData, isClickedPointLoading } = useMergeTableData( + const {data: mergedData, isClickedPointLoading} = useMergeTableData( data, setPageSize ); @@ -40,6 +58,6 @@ export const usePendingTableData = (page, resetPage, pageSize, setPageSize) => { data: mergedData, pageSize, isClickedPointLoading, - isDataLoading: isInitialLoading, + isDataLoading: isInitialLoading || isFetching, }; }; diff --git a/src/modules/Table/PendingTable/usePendingTableFields.jsx b/src/modules/Table/PendingTable/usePendingTableFields.jsx new file mode 100644 index 0000000..3e31073 --- /dev/null +++ b/src/modules/Table/PendingTable/usePendingTableFields.jsx @@ -0,0 +1,75 @@ +import { useMode } from "../../../stores/useMode.js"; +import React, { useMemo } from "react"; +import { Button } from "antd"; +import { BiTrash } from "react-icons/all.js"; +import { deletePoint } from "../../../api.js"; +import { useUpdateLayerCounter } from "../../../stores/useUpdateLayerCounter.js"; + +const MATCHING_STATUS = { + New: { + name: 'Новая', + color: 'import_status_new' + }, + Error: { + name: 'Ошибка геокодирования', + color: 'import_status_error' + }, + Matched: { + name: 'Совпадение', + color: 'import_status_matched' + }, + +} + +export const usePendingTableFields = () => { + const { isImportMode } = useMode(); + const { toggleUpdateCounter } = useUpdateLayerCounter(); + const deleteRow = async (e, id) => { + e.stopPropagation(); + try { + await deletePoint(id); + toggleUpdateCounter(); + } catch (e) { + // + } + }; + + const fields = useMemo(() => { + return isImportMode ? [ + { + title: "Статус импорта", + dataIndex: "matching_status", + key: "matching_status", + width: "120px", + ellipsis: true, + sorter: true, + showSorterTooltip: false, + render: (_, record) => { + if (!record.matching_status) return; + const name = MATCHING_STATUS[record.matching_status].name; + const color = MATCHING_STATUS[record.matching_status].color; + return ( +
+ {name} +
+ ); + }, + }, + { + title: "Удалить", + key: "del", + width: "60px", + ellipsis: true, + render: (_, record) => { + if (!record.id) return; + return ( + + ); + }, + } + ] : []; + }, [isImportMode]); + return fields; +} \ No newline at end of file diff --git a/src/modules/Table/Table.css b/src/modules/Table/Table.css index d3c24b4..750ca2f 100644 --- a/src/modules/Table/Table.css +++ b/src/modules/Table/Table.css @@ -14,6 +14,10 @@ cursor: pointer; } +.table__wrapper__fullScreen .ant-table-container { + height: calc(100vh - 98px); +} + .table__title { padding: 0 1rem; display: flex; diff --git a/src/modules/Table/Table.jsx b/src/modules/Table/Table.jsx index 07aa1f6..a6539a9 100644 --- a/src/modules/Table/Table.jsx +++ b/src/modules/Table/Table.jsx @@ -22,6 +22,7 @@ export const Table = React.memo( header, fullWidth, loading, + onChange }) => { const { clickedPointConfig, setClickedPointConfig } = useClickedPointConfig(); @@ -56,7 +57,10 @@ export const Table = React.memo( > }} pagination={{ pageSize, @@ -66,8 +70,10 @@ export const Table = React.memo( showSizeChanger: false, position: "bottomCenter", }} + showHeader={data?.results && data.results.length > 0} dataSource={data?.results} columns={columns} + onChange={onChange} rowKey="id" scroll={SCROLL} sticky={true} diff --git a/src/modules/Table/TableSettings.jsx b/src/modules/Table/TableSettings.jsx new file mode 100644 index 0000000..81fa9fb --- /dev/null +++ b/src/modules/Table/TableSettings.jsx @@ -0,0 +1,79 @@ +import { useEffect, useState } from "react"; +import {Button, Checkbox, Dropdown} from "antd"; +import {SettingOutlined} from "@ant-design/icons"; +import {DragDropContext, Draggable, Droppable} from "react-beautiful-dnd"; + +export const TableSettings = ({orderColumns}) => { + const [columnsList, setColumnsList] = useState(orderColumns.order); + useEffect(() => { + setColumnsList(orderColumns.order); + }, [orderColumns]); + + const handleDrop = (droppedItem) => { + // Ignore drop outside droppable container + if (!droppedItem.destination) return; + var updatedList = [...columnsList]; + // Remove dragged item + const [reorderedItem] = updatedList.splice(droppedItem.source.index, 1); + // Add dropped item + updatedList.splice(droppedItem.destination.index, 0, reorderedItem); + // Update State + setColumnsList(updatedList); + orderColumns.setOrder(updatedList); + }; + + const hideColumn = (columnIndex) => { + const updatedList = columnsList.map((item, index) => { + if (columnIndex === index) return {...item, show: !item.show}; + return item; + }); + setColumnsList(updatedList); + orderColumns.setOrder(updatedList); + } + + const columnsListRender = () => { + return ( +
e.stopPropagation()} className='z-10 bg-white-background rounded-xl p-3 space-y-3' + style={{ maxHeight: "80vh", overflowY: "scroll", margin: "24px 0 24px" }}> + + + {(provided) => ( +
+ {columnsList.map((item, index) => { + const num = item.position; + if (!orderColumns.defaultColumns[num]) return; + return ( + + {(provided) => ( +
+ hideColumn(index)} checked={item.show} /> +

+ { orderColumns.defaultColumns[num].name || orderColumns.defaultColumns[num].title } +

+
+ )} +
+ ); + })} + {provided.placeholder} +
+ )} +
+
+
+ ) + } + + return ( + columnsListRender()} + > + + + ); +}; diff --git a/src/modules/Table/WorkingTable/WorkingTable.jsx b/src/modules/Table/WorkingTable/WorkingTable.jsx index 89b5f8b..7ad286a 100644 --- a/src/modules/Table/WorkingTable/WorkingTable.jsx +++ b/src/modules/Table/WorkingTable/WorkingTable.jsx @@ -10,16 +10,23 @@ import { useExportWorkingData } from "./useExportWorkingData"; import { useWorkingPointsFilters } from "../../../stores/useWorkingPointsFilters"; import { useColumns } from "./useColumns.jsx"; +const tableKey = 'workingTable' export const WorkingTable = ({ fullWidth }) => { const [pageSize, setPageSize] = useState(PAGE_SIZE); const [page, setPage] = useState(1); const { filters: { region, deltaTraffic, factTraffic, age }, } = useWorkingPointsFilters(); - const columns = useColumns(); + const {columns, orderColumns, sort, setSort} = useColumns(tableKey); - const { data, isInitialLoading } = useQuery( - ["working-points", page, region, deltaTraffic, factTraffic, age], + const onSort = (sortDirection, key) => { + if (sortDirection === `ascend`) setSort(key); + if (sortDirection === `descend`) setSort(`-${key}`); + if (!sortDirection) setSort(null); + } + + const { data, isInitialLoading, isFetching } = useQuery( + ["working-points", page, region, deltaTraffic, factTraffic, age, sort], async () => { const params = new URLSearchParams({ page, @@ -28,6 +35,7 @@ export const WorkingTable = ({ fullWidth }) => { "delta_current[]": deltaTraffic, "fact[]": factTraffic, "age_day[]": age, + ordering: sort }); return await getPoints(params, region); @@ -51,8 +59,11 @@ export const WorkingTable = ({ fullWidth }) => { isClickedPointLoading={isClickedPointLoading} columns={columns} fullWidth={fullWidth} - header={} - loading={isInitialLoading} + onChange={(val, filter, sorter) => { + onSort(sorter.order, sorter.columnKey); + }} + header={} + loading={isInitialLoading || isFetching} /> ); }; diff --git a/src/modules/Table/WorkingTable/useColumns.jsx b/src/modules/Table/WorkingTable/useColumns.jsx index d988161..7fea0d1 100644 --- a/src/modules/Table/WorkingTable/useColumns.jsx +++ b/src/modules/Table/WorkingTable/useColumns.jsx @@ -1,38 +1,52 @@ -import { useGetRegions } from "../../../components/RegionSelect.jsx"; -import { useMemo } from "react"; -import { getRegionNameById } from "../../../Map/Popup/mode-popup/config.js"; -import { SearchOutlined } from "@ant-design/icons"; -import { Button, Popover } from "antd"; -import { AddressSearch } from "../../../Map/AddressSearch.jsx"; -import { useTable } from "../../../stores/useTable.js"; +import {useGetRegions} from "../../../components/RegionSelect.jsx"; +import {useMemo} from "react"; +import {getRegionNameById} from "../../../Map/Popup/mode-popup/config.js"; +import {SearchOutlined} from "@ant-design/icons"; +import {Button, Popover} from "antd"; +import {AddressSearch} from "../../../Map/AddressSearch.jsx"; +import {useTable} from "../../../stores/useTable.js"; +import useLocalStorage from "../../../hooks/useLocalStorage.js"; -export const useColumns = () => { - const { data: regions } = useGetRegions(); +const DEFAULT_LENGTH = 11; +export const useColumns = (key) => { + const {data: regions} = useGetRegions(); const { - tableState: { fullScreen }, + tableState: {fullScreen}, } = useTable(); - return useMemo(() => { + const [order, setOrder] = useLocalStorage(`${key}Order`, [...Array(DEFAULT_LENGTH).keys()].map((position) => { + return { + position, + show: true, + } + })); + + const [sort, setSort] = useLocalStorage(`${key}Sort`, null); + + const defaultColumns = useMemo(() => { return [ { title: fullScreen ? (
Адрес } + content={} trigger="click" placement={"right"} > -
) : ( "Адрес" ), + name: "Адрес", dataIndex: "address", key: "address", + sorter: true, + showSorterTooltip: false, width: 200, }, { @@ -41,6 +55,8 @@ export const useColumns = () => { key: "area", width: "120px", ellipsis: true, + sorter: true, + showSorterTooltip: false, render: (_, record) => { return getRegionNameById(record.area, regions?.normalized); }, @@ -54,6 +70,8 @@ export const useColumns = () => { render: (_, record) => { return getRegionNameById(record.district, regions?.normalized); }, + sorter: true, + showSorterTooltip: false, }, { title: "Название", @@ -61,6 +79,8 @@ export const useColumns = () => { key: "name", width: "120px", ellipsis: true, + sorter: true, + showSorterTooltip: false, }, { title: "Категория", @@ -68,6 +88,8 @@ export const useColumns = () => { key: "category", width: "120px", ellipsis: true, + sorter: true, + showSorterTooltip: false, }, { title: "План", @@ -75,6 +97,8 @@ export const useColumns = () => { key: "plan_current", width: "120px", ellipsis: true, + sorter: true, + showSorterTooltip: false, }, { title: "Факт", @@ -82,6 +106,8 @@ export const useColumns = () => { key: "fact", width: "120px", ellipsis: true, + sorter: true, + showSorterTooltip: false, }, { title: "Расхождение с прогнозом", @@ -89,6 +115,8 @@ export const useColumns = () => { key: "delta_current", width: "120px", ellipsis: true, + sorter: true, + showSorterTooltip: false, }, { title: "Зрелость", @@ -96,12 +124,16 @@ export const useColumns = () => { key: "age_day", width: "120px", ellipsis: true, + sorter: true, + showSorterTooltip: false, }, { title: "Дата начала работы", dataIndex: "start_date", key: "start_date", width: "120px", + sorter: true, + showSorterTooltip: false, render: (value) => { if (!value) return "Нет данных"; @@ -115,7 +147,31 @@ export const useColumns = () => { key: "postamat_id", width: "70px", ellipsis: true, + sorter: true, + showSorterTooltip: false, }, ]; }, [regions?.normalized, fullScreen]); + + const columns = useMemo(() => { + return order.flatMap((item) => !item.show ? [] : defaultColumns[item.position]) + .map((column) => { + if (sort && sort.includes(column.key)) return { + ...column, + defaultSortOrder: sort.includes('-') ? 'descend' : 'ascend', + }; + return column; + }); + }, [defaultColumns, order, fullScreen]); + + return { + columns, + orderColumns: { + defaultColumns, + order, + setOrder, + }, + sort, + setSort + }; }; diff --git a/src/modules/Table/useColumns.jsx b/src/modules/Table/useColumns.jsx index e5a3614..f30b15f 100644 --- a/src/modules/Table/useColumns.jsx +++ b/src/modules/Table/useColumns.jsx @@ -1,19 +1,22 @@ import { STATUS_LABEL_MAPPER } from "../../config"; -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; import { useGetRegions } from "../../components/RegionSelect.jsx"; import { getRegionNameById } from "../../Map/Popup/mode-popup/config.js"; import { Button, Popover } from "antd"; import { AddressSearch } from "../../Map/AddressSearch.jsx"; import { SearchOutlined } from "@ant-design/icons"; import { useTable } from "../../stores/useTable.js"; - -export const useColumns = (fields = []) => { +import useLocalStorage from "../../hooks/useLocalStorage.js"; +import { TrafficModal } from "../../Map/TrafficModal.jsx"; +export const useColumns = (fields = [], key) => { const { data: regions } = useGetRegions(); const { tableState: { fullScreen }, } = useTable(); - return useMemo(() => { + const [sort, setSort] = useLocalStorage(`${key}Sort`, null); + + const defaultColumns = useMemo(() => { return [ { title: fullScreen ? ( @@ -24,7 +27,7 @@ export const useColumns = (fields = []) => { trigger="click" placement={"right"} > - @@ -32,9 +35,12 @@ export const useColumns = (fields = []) => { ) : ( "Адрес" ), + name: "Адрес", dataIndex: "address", key: "address", width: 200, + sorter: true, + showSorterTooltip: false, }, { title: "Район", @@ -42,6 +48,8 @@ export const useColumns = (fields = []) => { key: "area", width: "120px", ellipsis: true, + sorter: true, + showSorterTooltip: false, render: (_, record) => { return getRegionNameById(record.area, regions?.normalized); }, @@ -52,6 +60,8 @@ export const useColumns = (fields = []) => { key: "district", width: "120px", ellipsis: true, + sorter: true, + showSorterTooltip: false, render: (_, record) => { return getRegionNameById(record.district, regions?.normalized); }, @@ -62,6 +72,8 @@ export const useColumns = (fields = []) => { key: "name", width: "120px", ellipsis: true, + sorter: true, + showSorterTooltip: false, }, { title: "Категория", @@ -69,6 +81,8 @@ export const useColumns = (fields = []) => { key: "category", width: "120px", ellipsis: true, + sorter: true, + showSorterTooltip: false, }, { title: "Статус", @@ -76,6 +90,8 @@ export const useColumns = (fields = []) => { key: "status", width: "120px", ellipsis: true, + sorter: true, + showSorterTooltip: false, render: (_, record) => { return STATUS_LABEL_MAPPER[record.status]; }, @@ -86,8 +102,346 @@ export const useColumns = (fields = []) => { key: "prediction_current", width: "120px", ellipsis: true, + sorter: true, + showSorterTooltip: false, + render: (_, record) => + }, + { + title: "Кол-во подъездов в жилом доме", + dataIndex: "doors", + key: "doors", + width: "120px", + ellipsis: true, + sorter: true, + showSorterTooltip: false, + }, + { + title: "Класс энероэффективности жилого дома", + dataIndex: "enrg_cls", + key: "enrg_cls", + width: "120px", + ellipsis: true, + sorter: true, + showSorterTooltip: false, + }, + { + title: "Кол-во квартир в подъезде жилого дома", + dataIndex: "flat_cnt", + key: "flat_cnt", + width: "120px", + ellipsis: true, + sorter: true, + showSorterTooltip: false, + }, + { + title: "Год постройки жилого дома", + dataIndex: "year_bld", + key: "year_bld", + width: "120px", + ellipsis: true, + sorter: true, + showSorterTooltip: false, + }, + { + title: "Кол-во этажей жилого дома", + dataIndex: "levels", + key: "levels", + width: "120px", + ellipsis: true, + sorter: true, + showSorterTooltip: false, + }, + { + title: "Материал стен жилого дома", + dataIndex: "mat_nes", + key: "mat_nes", + width: "120px", + ellipsis: true, + sorter: true, + showSorterTooltip: false, + }, + { + title: "Кол-во постаматов других сетей в окрестности 500м (далее аналогично)", + dataIndex: "rival_post_cnt", + key: "rival_post_cnt", + width: "120px", + ellipsis: true, + sorter: true, + showSorterTooltip: false, + }, + { + title: "Кол-во ПВЗ", + dataIndex: "rival_pvz_cnt", + key: "rival_pvz_cnt", + width: "120px", + ellipsis: true, + sorter: true, + showSorterTooltip: false, + }, + { + title: "Кол-во постаматов Мой постамат", + dataIndex: "target_post_cnt", + key: "target_post_cnt", + width: "120px", + ellipsis: true, + sorter: true, + showSorterTooltip: false, + }, + { + title: "Кол-во квартир в окрестности", + dataIndex: "flats_cnt", + key: "flats_cnt", + width: "120px", + ellipsis: true, + sorter: true, + showSorterTooltip: false, + }, + { + title: "Кол-во достопримечательностей", + dataIndex: "attraction_cnt", + key: "attraction_cnt", + width: "120px", + ellipsis: true, + sorter: true, + showSorterTooltip: false, + }, + { + title: "Кол-во банков", + dataIndex: "bank_cnt", + key: "bank_cnt", + width: "120px", + ellipsis: true, + sorter: true, + showSorterTooltip: false, + }, + { + title: "Кол-во торговых центров", + dataIndex: "tc_cnt", + key: "tc_cnt", + width: "120px", + ellipsis: true, + sorter: true, + showSorterTooltip: false, + }, + { + title: "Кол-во бизнес-центров", + dataIndex: "bc_cnt", + key: "bc_cnt", + width: "120px", + ellipsis: true, + sorter: true, + showSorterTooltip: false, + }, + { + title: "Кол-во клиник", + dataIndex: "clinic_cnt", + key: "clinic_cnt", + width: "120px", + ellipsis: true, + sorter: true, + showSorterTooltip: false, + }, + { + title: "Кол-во объектов культуры (театры, музей и тд)", + dataIndex: "culture_cnt", + key: "culture_cnt", + width: "120px", + ellipsis: true, + sorter: true, + showSorterTooltip: false, + }, + { + title: "Кол-во спортивных центров", + dataIndex: "sport_center_cnt", + key: "sport_center_cnt", + width: "120px", + ellipsis: true, + sorter: true, + showSorterTooltip: false, + }, + { + title: "Кол-во лабораторий", + dataIndex: "lab_cnt", + key: "lab_cnt", + width: "120px", + ellipsis: true, + sorter: true, + showSorterTooltip: false, + }, + { + title: "Кол-во школ", + dataIndex: "school_cnt", + key: "school_cnt", + width: "120px", + ellipsis: true, + sorter: true, + showSorterTooltip: false, + }, + { + title: "Кол-во детских садов", + dataIndex: "kindergar_cnt", + key: "kindergar_cnt", + width: "120px", + ellipsis: true, + sorter: true, + showSorterTooltip: false, + }, + { + title: "Кол-во МФЦ", + dataIndex: "mfc_cnt", + key: "mfc_cnt", + width: "120px", + ellipsis: true, + sorter: true, + showSorterTooltip: false, + }, + { + title: "Кол-во аптек", + dataIndex: "pharmacy_cnt", + key: "pharmacy_cnt", + width: "120px", + ellipsis: true, + sorter: true, + showSorterTooltip: false, + }, + { + title: "Кол-во остановок ОТ", + dataIndex: "public_stop_cnt", + key: "public_stop_cnt", + width: "120px", + ellipsis: true, + sorter: true, + showSorterTooltip: false, + }, + { + title: "Кол-во объектов из HORECA", + dataIndex: "reca_cnt", + key: "reca_cnt", + width: "120px", + ellipsis: true, + sorter: true, + showSorterTooltip: false, + }, + { + title: "Кол-во супермаркетов", + dataIndex: "supermarket_cnt", + key: "supermarket_cnt", + width: "120px", + ellipsis: true, + sorter: true, + showSorterTooltip: false, + }, + { + title: "Кол-во премиальных супермаркетов", + dataIndex: "supermarket_premium_cnt", + key: "supermarket_premium_cnt", + width: "120px", + ellipsis: true, + sorter: true, + showSorterTooltip: false, + }, + { + title: "Расстояние до постамата Мой постамата", + dataIndex: "target_dist", + key: "target_dist", + width: "120px", + ellipsis: true, + sorter: true, + showSorterTooltip: false, + }, + { + title: "Расстояние до метро", + dataIndex: "metro_dist", + key: "metro_dist", + width: "120px", + ellipsis: true, + sorter: true, + showSorterTooltip: false, + }, + { + title: "Стоимость жилой недвижимости ", + dataIndex: "property_price_bargains", + key: "property_price_bargains", + width: "120px", + ellipsis: true, + sorter: true, + showSorterTooltip: false, + }, + { + title: "Бизнес-активность", + dataIndex: "business_activity", + key: "business_activity", + width: "120px", + ellipsis: true, + sorter: true, + showSorterTooltip: false, + }, + { + title: "Эра постройки жилой недвижимости", + dataIndex: "property_era", + key: "property_era", + width: "120px", + ellipsis: true, + sorter: true, + showSorterTooltip: false, + }, + { + title: "Средняя этажность застройки", + dataIndex: "property_mean_floor", + key: "property_mean_floor", + width: "120px", + ellipsis: true, + sorter: true, + showSorterTooltip: false, }, ...fields, - ]; + ].filter(Boolean); }, [regions?.normalized, fields, fullScreen]); + + const [order, setOrder] = useLocalStorage(`${key}Order`, defaultColumns.map((column, index) => { + return { + key: column.key, + position: index, + show: true, + } + })); + + useEffect(() => { + const newColumns = defaultColumns.filter((column) => { + return !order.find(c => c.key === column.key); + }); + const newOrderColumns = newColumns.map((column, index) => { + return { + key: column.key, + position: defaultColumns.length - index - 1, + show: true, + } + }); + setOrder([ + ...order, + ...newOrderColumns + ]); + }, [defaultColumns]); + + const columns = useMemo(() => { + return order.flatMap((item) => !item.show ? [] : defaultColumns[item.position]) + .map((column) => { + if (sort && sort.includes(column.key)) return { + ...column, + defaultSortOrder: sort.includes('-') ? 'descend' : 'ascend', + }; + return column; + }).filter(Boolean); + }, [defaultColumns, order, fullScreen]); + + return { + columns, + orderColumns: { + defaultColumns, + order, + setOrder, + }, + sort, + setSort + }; }; diff --git a/src/modules/Table/useMergeTableData.js b/src/modules/Table/useMergeTableData.js index 4580513..25691f9 100644 --- a/src/modules/Table/useMergeTableData.js +++ b/src/modules/Table/useMergeTableData.js @@ -47,7 +47,7 @@ export const useMergeTableData = (fullData, onPageSizeChange) => { onPageSizeChange(PAGE_SIZE + 1); setMergedData({ - count: fullData.count + 1, + count: fullData?.count + 1, results: [clickedPointData.results[0], ...fullData.results], }); }, [clickedPointData, fullData]); diff --git a/src/stores/useLayersVisibility.js b/src/stores/useLayersVisibility.js index 3c9819d..60e52f0 100644 --- a/src/stores/useLayersVisibility.js +++ b/src/stores/useLayersVisibility.js @@ -5,10 +5,10 @@ import { persist } from "zustand/middleware"; const INITIAL_STATE = { [LAYER_IDS.initial]: true, - [LAYER_IDS.approve]: false, - [LAYER_IDS.working]: false, + [LAYER_IDS.approve]: true, + [LAYER_IDS.working]: true, [LAYER_IDS.filteredWorking]: false, - [LAYER_IDS.cancelled]: false, + [LAYER_IDS.cancelled]: true, [LAYER_IDS.pvz]: true, [LAYER_IDS.other]: true, }; diff --git a/src/stores/useMode.js b/src/stores/useMode.js index c339f62..9ab7a5a 100644 --- a/src/stores/useMode.js +++ b/src/stores/useMode.js @@ -5,12 +5,18 @@ import { persist } from "zustand/middleware"; const store = (set) => ({ mode: MODES.PENDING, + isImportMode: false, setMode: (mode) => { set((state) => { state.mode = mode; }); }, + setImportMode: (value) => { + set((state) => { + state.isImportMode = value + }); + } }); export const useMode = create(persist(immer(store), { name: "postnet/mode" })); diff --git a/src/stores/usePendingPointsFilters.js b/src/stores/usePendingPointsFilters.js index ff8ab77..35f4d00 100644 --- a/src/stores/usePendingPointsFilters.js +++ b/src/stores/usePendingPointsFilters.js @@ -2,14 +2,102 @@ import { create } from "zustand"; import { immer } from "zustand/middleware/immer"; import { persist } from "zustand/middleware"; +export const RANGE_FILTERS_KEYS = [ + 'doors', + 'flat_cnt', + 'rival_post_cnt', + 'rival_pvz_cnt', + 'target_post_cnt', + 'flats_cnt', + 'tc_cnt', + 'culture_cnt', + 'mfc_cnt', + 'public_stop_cnt', + 'supermarket_cnt', + 'target_dist', + 'metro_dist', +] + +export const RANGE_FILTERS_MAP = { + common: { + name: "Общие", + doors: "Кол-во подъездов в жилом доме", + flat_cnt: "Кол-во квартир в подъезде жилого дома", + }, + objects_dist: { + name: "Кол-во объектов в окрестности 500м", + rival_post_cnt: "Кол-во постаматов других сетей", + rival_pvz_cnt: "Кол-во ПВЗ", + target_post_cnt: "Кол-во постаматов Мой постамат", + flats_cnt: "Кол-во квартир в окрестности", + tc_cnt: "Кол-во торговых центров", + culture_cnt: "Кол-во объектов культуры (театры, музей и тд)", + mfc_cnt: "Кол-во МФЦ", + public_stop_cnt: "Кол-во остановок ОТ", + supermarket_cnt: "Кол-во супермаркетов", + target_dist: "Расстояние до постамата Мой постамат", + metro_dist: "Расстояние до метро", + }, +} + +export const CATEGORIES_MAP = { + "ПВЗ": "Расстояние до ПВЗ сети", + "Постаматы прочих сетей": "Расстояние до постамата сети", +} + export const INITIAL = { prediction: [0, 0], categories: [], region: null, + doors__gt: 0, + doors__lt: 0, + flat_cnt__gt: 0, + flat_cnt__lt: 5000, + rival_post_cnt__gt: 0, + rival_post_cnt__lt: 5000, + rival_pvz_cnt__gt: 0, + rival_pvz_cnt__lt: 5000, + target_post_cnt__gt: 0, + target_post_cnt__lt: 5000, + flats_cnt__gt: 0, + flats_cnt__lt: 5000, + tc_cnt__gt: 0, + tc_cnt__lt: 5000, + culture_cnt__gt: 0, + culture_cnt__lt: 5000, + mfc_cnt__gt: 0, + mfc_cnt__lt: 5000, + public_stop_cnt__gt: 0, + public_stop_cnt__lt: 5000, + supermarket_cnt__gt: 0, + supermarket_cnt__lt: 5000, + target_dist__gt: 0, + target_dist__lt: 5000, + metro_dist__gt: 0, + metro_dist__lt: 5000, }; +const INITIAL_RANGES = { + prediction: [0, 0], + doors: [0, 0], + flat_cnt: [0, 5000], + rival_post_cnt: [0, 5000], + rival_pvz_cnt: [0, 5000], + target_post_cnt: [0, 5000], + flats_cnt: [0, 5000], + tc_cnt: [0, 5000], + culture_cnt: [0, 5000], + mfc_cnt: [0, 5000], + public_stop_cnt: [0, 5000], + supermarket_cnt: [0, 5000], + target_dist: [0, 5000], + metro_dist: [0, 5000], +} + const store = (set) => ({ filters: INITIAL, + ranges: INITIAL_RANGES, + setPrediction: (value) => { set((state) => { state.filters.prediction = value; @@ -26,6 +114,17 @@ const store = (set) => ({ state.filters.region = value; }), + setFilterWithKey: (value, key) => + set((state) => { + state.filters[`${key}__gt`] = value[0]; + state.filters[`${key}__lt`] = value[1]; + }), + + setRanges: (value) => + set((state) => { + state.ranges = value; + }), + clear: (fullRange) => set((state) => { if (!fullRange) { diff --git a/src/stores/useWorkingPointsFilters.js b/src/stores/useWorkingPointsFilters.js index 63955a3..9e108f7 100644 --- a/src/stores/useWorkingPointsFilters.js +++ b/src/stores/useWorkingPointsFilters.js @@ -9,8 +9,16 @@ export const INITIAL = { age: [-1, 0], }; +export const INITIAL_RANGES = { + region: null, + deltaTraffic: [-10000, 10000], + factTraffic: [-100, 0], + age: [-1, 0], +}; + const store = (set) => ({ filters: INITIAL, + ranges: INITIAL_RANGES, setDeltaTraffic: (value) => { set((state) => { @@ -35,6 +43,11 @@ const store = (set) => ({ state.filters.region = value; }), + setRanges: (value) => + set((state) => { + state.ranges = value; + }), + clear: (fullRange) => set((state) => { if (!fullRange) { diff --git a/src/utils.js b/src/utils.js index cc59667..d5fd698 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,3 +1,5 @@ +import {RANGE_FILTERS_KEYS} from "./stores/usePendingPointsFilters.js"; + export function download(filename, data) { const downloadLink = window.document.createElement("a"); downloadLink.href = window.URL.createObjectURL( @@ -11,5 +13,127 @@ export function download(filename, data) { document.body.removeChild(downloadLink); } +var chars = {"Ё":"YO","Й":"I","Ц":"TS","У":"U","К":"K","Е":"E","Н":"N","Г":"G","Ш":"SH","Щ":"SCH","З":"Z","Х":"H","Ъ":"'","ё":"yo","й":"i","ц":"ts","у":"u","к":"k","е":"e","н":"n","г":"g","ш":"sh","щ":"sch","з":"z","х":"h","ъ":"'","Ф":"F","Ы":"I","В":"V","А":"A","П":"P","Р":"R","О":"O","Л":"L","Д":"D","Ж":"ZH","Э":"E","ф":"f","ы":"i","в":"v","а":"a","п":"p","р":"r","о":"o","л":"l","д":"d","ж":"zh","э":"e","Я":"Ya","Ч":"CH","С":"S","М":"M","И":"I","Т":"T","Ь":"'","Б":"B","Ю":"YU","я":"ya","ч":"ch","с":"s","м":"m","и":"i","т":"t","ь":"'","б":"b","ю":"yu"," ":""}; + +export function transliterate(word){ + return word.split('').map(function (char) { + return chars[char] || char; + }).join(""); +} + +export function getFilteredGroups(categories) { + if (!categories) return []; + return categories + .filter((category) => category.visible) + .map((category) => { + return { + ...category, + groups: [...category.groups.filter((group) => group.visible)], + } + }) +} + +export function fieldHasChanged(filters, ranges, filterKey) { + const r = ranges[filterKey] + const gtValue = filters[`${filterKey}__gt`]; + const gtInitial = r ? r[0] : 0 ; + const ltValue = filters[`${filterKey}__lt`]; + const ltInitial =r ? r[1] : 0; + + const result = !(gtValue === gtInitial && ltValue === ltInitial) + + return { + result, + gtValue, + ltValue, + } +} + +export const doesMatchFilter = (filters, ranges, feature) => { + const { prediction, categories, region } = filters; + const { + prediction_current, + category, + area, + district, + area_id, + district_id, + } = feature.properties; + + const doesMatchPredictionFilter = + prediction_current >= prediction[0] && + prediction_current <= prediction[1]; + + const doesMatchCategoriesFilter = + categories.length > 0 ? categories.includes(category) : true; + + const doesMatchOtherFilters = () => { + let res = true; + RANGE_FILTERS_KEYS.map((filterKey) => { + if (fieldHasChanged(filters, ranges, filterKey).result && res) { + res = + feature.properties[filterKey] >= filters[`${filterKey}__gt`] && + feature.properties[filterKey] <= filters[`${filterKey}__lt`]; + } + + }); + + return res; + } + + const doesMatchRegionFilter = () => { + if (!region) return true; + + if (region.type === "ao") { + return (district ?? district_id) === region.id; + } else { + return (area ?? area_id) === region.id; + } + }; + + return ( + doesMatchPredictionFilter && + doesMatchCategoriesFilter && + doesMatchRegionFilter() && + doesMatchOtherFilters() + ); +}; + +export const appendFiltersInUse = (params, filters, ranges) => { + RANGE_FILTERS_KEYS.map((filterKey) => { + if (!fieldHasChanged(filters, ranges, filterKey).result) return; + if (/d[0-9]/.test(filterKey)) { + params.append('dist_to_group__gt', [ + filterKey.split('d')[1], + filters[`${filterKey}__gt`] - 1 + ].join(',')); + params.append('dist_to_group__lt', [ + filterKey.split('d')[1], + filters[`${filterKey}__lt`] + 1 + ].join(',')); + } else { + params.append(`${filterKey}__gt`, filters[`${filterKey}__gt`] - 1); + params.append(`${filterKey}__lt`, filters[`${filterKey}__lt`] + 1); + } + }); + if (predictionHasChanged(filters, ranges)) { + params.append("prediction_current[]", filters.prediction); + } +} + +export const predictionHasChanged = (filters, ranges) => { + const gtChanged = ranges.prediction[0] !== filters.prediction[0]; + const ltChanged = ranges.prediction[1] !== filters.prediction[1]; + return gtChanged || ltChanged; +} + +export const workingFilterHasChanged = (filter, ranges, fieldKey) => { + if (!ranges[fieldKey]) return false; + const gtChanged = ranges[fieldKey][0] !== filter[0]; + const ltChanged = ranges[fieldKey][1] !== filter[1]; + return gtChanged || ltChanged; +} + + export const isNil = (value) => value === undefined || value === null || value === ""; From 044578e53a40ae8afbd45ba99a5fd9af5f1f2227 Mon Sep 17 00:00:00 2001 From: Timofey Malinin Date: Tue, 14 Nov 2023 08:02:20 +0000 Subject: [PATCH 2/4] Dev to main --- .env | 3 ++ .gitlab-ci.yml | 10 ++++ Dockerfile | 3 ++ src/Map/Layers/Layers.jsx | 21 +++++--- src/SignOut.jsx | 17 +++--- src/api.js | 16 ++++-- .../ImportMode/PointsFileUploadModal.jsx | 12 ++--- .../PendingPointsFilters.jsx | 4 +- src/pages/Login.jsx | 16 ++++-- src/stores/auth.js | 53 ++++++++++++++++++- src/stores/signin.js | 26 +++++++-- vite.config.ts | 2 +- 12 files changed, 143 insertions(+), 40 deletions(-) diff --git a/.env b/.env index cf067ee..91539c8 100644 --- a/.env +++ b/.env @@ -1 +1,4 @@ VITE_API_URL=https://postnet.dev.selftech.ru +VITE_KEYCLOAK_CLIENT_ID=postnet +VITE_KEYCLOAK_CLIENT_SECRET=K2yHweEUispkVeWn03VMk843sW2Moic5 +VITE_KEYCLOAK_URL=https://kk.dev.selftech.ru/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 1c18545..36e2a1f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -17,8 +17,13 @@ build-docker-dev: docker build --build-arg YC_CONTAINER_REGISTRY=${YC_CONTAINER_REGISTRY} --build-arg VITE_API_URL="https://postnet.dev.selftech.ru" + --build-arg VITE_KEYCLOAK_CLIENT_ID="postnet" + --build-arg VITE_KEYCLOAK_CLIENT_SECRET=${VITE_KEYCLOAK_CLIENT_SECRET} + --build-arg VITE_KEYCLOAK_URL="https://kk.dev.selftech.ru/" -t ${DOCKER_IMAGE_TAG}-dev . - docker push ${DOCKER_IMAGE_TAG}-dev + environment: + name: dev build-docker-prod: stage: build @@ -29,8 +34,13 @@ build-docker-prod: docker build --build-arg YC_CONTAINER_REGISTRY=${YC_CONTAINER_REGISTRY} --build-arg VITE_API_URL="https://postnet.selftech.ru" + --build-arg VITE_KEYCLOAK_CLIENT_ID="" + --build-arg VITE_KEYCLOAK_CLIENT_SECRET=${VITE_KEYCLOAK_CLIENT_SECRET} + --build-arg VITE_KEYCLOAK_URL="" -t ${DOCKER_IMAGE_TAG}-prod . - docker push ${DOCKER_IMAGE_TAG}-prod + environment: + name: prod auto-deploy-dev-kuber: extends: .deploy_base_kuber diff --git a/Dockerfile b/Dockerfile index a18a09c..439f839 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,9 @@ ARG YC_CONTAINER_REGISTRY FROM ${YC_CONTAINER_REGISTRY}/public/node:16 as builder ARG VITE_API_URL +ARG VITE_KEYCLOAK_CLIENT_ID +ARG VITE_KEYCLOAK_CLIENT_SECRET +ARG VITE_KEYCLOAK_URL WORKDIR /usr/src/postamates_frontend ENV NODE_OPTIONS=--max_old_space_size=4096 COPY package*.json ./ diff --git a/src/Map/Layers/Layers.jsx b/src/Map/Layers/Layers.jsx index 8f3677f..4063be8 100644 --- a/src/Map/Layers/Layers.jsx +++ b/src/Map/Layers/Layers.jsx @@ -1,13 +1,16 @@ -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"; -import { transliterate } from "../../utils.js"; +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"; +import {transliterate} from "../../utils.js"; +import {useUpdateLayerCounter} from "../../stores/useUpdateLayerCounter.js"; export const Layers = ({ postGroups, otherGroups }) => { + const { updateCounter } = useUpdateLayerCounter(); + return ( <> { @@ -58,6 +62,7 @@ export const Layers = ({ postGroups, otherGroups }) => { diff --git a/src/SignOut.jsx b/src/SignOut.jsx index f342779..2b8351c 100644 --- a/src/SignOut.jsx +++ b/src/SignOut.jsx @@ -5,15 +5,16 @@ 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); - }; +export const logOut = () => { + localStorage.removeItem('access_token'); + localStorage.removeItem('refresh_token'); + localStorage.removeItem('expires_in'); + setAuth(false); +}; +export function SignOut() { const { data } = useQuery(["profile"], async () => { - const { data } = await api.get("/accounts/profile/"); + const { data } = await api.get("/api/me/"); return data; }); @@ -22,7 +23,7 @@ export function SignOut() { - + <Title text={data?.username} classNameText={"lowercase"} /> <Button type="primary" block onClick={logOut}> <span className="mr-1">Выйти</span> <ArrowRightOutlined /> diff --git a/src/api.js b/src/api.js index 41e1e3b..dc84799 100644 --- a/src/api.js +++ b/src/api.js @@ -15,9 +15,15 @@ export const api = axios.create({ import.meta.env.MODE === "development" ? "http://localhost:5173/" : BASE_URL, - withCredentials: true, - xsrfHeaderName: "X-CSRFToken", - xsrfCookieName: "csrftoken", +}); + +api.interceptors.request.use(function (config) { + const token = localStorage.getItem("access_token"); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + + return config; }); export const useDbTableName = () => { @@ -215,7 +221,7 @@ export const useGetPermissions = () => { return useQuery(["permissions"], async () => { const { data } = await api.get("/api/me/"); - if (data?.groups?.includes("Редактор")) { + if (data?.groups?.includes("postnet_editor")) { return "editor"; } @@ -332,7 +338,7 @@ export const useGetPendingPointsRange = (dbTable) => { const rangesArr = RANGE_FILTERS_KEYS.map((key) => { if ((/d[0-9]/.test(key))) return; return { - [key]: [Math.floor(data[key][0]), Math.min(Math.ceil(data[key][1]), 4000)] + [key]: [Math.floor(data[key][0]), Math.ceil(data[key][1])] } }).filter(item => !!item); const ranges = Object.assign({}, ...rangesArr); diff --git a/src/modules/ImportMode/PointsFileUploadModal.jsx b/src/modules/ImportMode/PointsFileUploadModal.jsx index e212ed4..7049533 100644 --- a/src/modules/ImportMode/PointsFileUploadModal.jsx +++ b/src/modules/ImportMode/PointsFileUploadModal.jsx @@ -26,9 +26,10 @@ export const PointsFileUploadModal = ({onClose, isOpened}) => { const myInterval = setInterval(async () => { const response = await getImportStatus(); setImportStatus(response.task_status); - if (response.task_status === "Перерасчет ML завершен") { + if (response.task_status === "Перерасчет ML завершен" || !isOpened) { setReport(response.data); setIsImporting(false); + toggleUpdateCounter(); clearInterval(myInterval); } }, 2000); @@ -68,13 +69,6 @@ export const PointsFileUploadModal = ({onClose, isOpened}) => { </Button> ] return [ - <Button - key="close-button" - type="default" - onClick={onClose} - > - Отмена - </Button>, <Button key="ok-button" type="primary" @@ -112,7 +106,7 @@ export const PointsFileUploadModal = ({onClose, isOpened}) => { <Modal open={isOpened} title="Импорт точек" - onCancel={onClose} + onCancel={() => {if (!isImporting) onClose()}} width={400} footer={getFooter()} > diff --git a/src/modules/Sidebar/PendingPointsFilters/PendingPointsFilters.jsx b/src/modules/Sidebar/PendingPointsFilters/PendingPointsFilters.jsx index d800891..4150c80 100644 --- a/src/modules/Sidebar/PendingPointsFilters/PendingPointsFilters.jsx +++ b/src/modules/Sidebar/PendingPointsFilters/PendingPointsFilters.jsx @@ -32,8 +32,8 @@ export const PendingPointsFilters = () => { setFilterWithKey(newRanges[key], key); return; } - const gtChanged = ranges[key][0] !== newRanges[key][0]; - const ltChanged = ranges[key][1] !== newRanges[key][1]; + const gtChanged = ranges[key] && newRanges[key] && ranges[key][0] !== newRanges[key][0]; + const ltChanged = ranges[key] && newRanges[key] && ranges[key][1] !== newRanges[key][1]; if (gtChanged || ltChanged) setFilterWithKey(newRanges[key], key); }); diff --git a/src/pages/Login.jsx b/src/pages/Login.jsx index dcb3e3e..633142c 100644 --- a/src/pages/Login.jsx +++ b/src/pages/Login.jsx @@ -3,15 +3,25 @@ import { Alert, Button, Form, Input, Space, Typography } from "antd"; import { LockOutlined, UserOutlined } from "@ant-design/icons"; import React from "react"; import { Navigate } from "react-router-dom"; -import { isAuthorized$ } from "../stores/auth"; +import { isAuthorized$, refreshTokenIntervalFunction, setAuthLocalStorage } from "../stores/auth"; import { signin, signinError$, signinLoading$ } from "../stores/signin"; function LoginForm() { const signinError = useStore(signinError$); const signinLoading = useStore(signinLoading$); - const onFinish = (values) => { - signin(values); + const onFinish = async (values) => { + const data = await signin(values); + setAuthLocalStorage(data); + + let interval; + + setTimeout(async () => { + await refreshTokenIntervalFunction(); + interval = setInterval(async () => { + await refreshTokenIntervalFunction(); + }, (data.expires_in - 5) * 1000) + }, 0); }; return ( diff --git a/src/stores/auth.js b/src/stores/auth.js index 2b85ce8..ac52517 100644 --- a/src/stores/auth.js +++ b/src/stores/auth.js @@ -1,5 +1,6 @@ import { action, atom } from "nanostores"; import { api } from "../api"; +import { logOut } from "../SignOut.jsx"; export const userInfoLoading$ = atom(true); @@ -9,13 +10,63 @@ export const setAuth = action(isAuthorized$, "setAuth", (store, newValue) => { store.set(newValue); }); +export const refreshToken = () => { + const url = import.meta.env.VITE_KEYCLOAK_URL || 'https://kk.dev.selftech.ru/'; + const clientId = import.meta.env.VITE_KEYCLOAK_CLIENT_ID || 'postnet'; + const clientSecret = import.meta.env.VITE_KEYCLOAK_CLIENT_SECRET || 'K2yHweEUispkVeWn03VMk843sW2Moic5'; + + return api.request({ + url: "/realms/SST/protocol/openid-connect/token", + baseURL: url, + method: "POST", + data: { + grant_type: "refresh_token", + client_id: clientId, + client_secret: clientSecret, + token_type: "bearer", + refresh_token: localStorage.getItem("refresh_token") + }, + headers: { + 'Content-type': 'application/x-www-form-urlencoded', + }, + }); +} + +export const setAuthLocalStorage = (data) => { + localStorage.setItem("access_token", data.access_token); + localStorage.setItem("refresh_token", data.refresh_token); + localStorage.setItem("expires_in", data.expires_in); +} + +export const refreshTokenIntervalFunction = async () => { + try { + const { data: refreshData } = await refreshToken(); + setAuthLocalStorage(refreshData); + } catch (error) { + clearInterval(interval); + logOut(); + throw error; + } +} + async function checkAuth() { try { - await api.get("/accounts/profile/"); + const { data } = await refreshToken(); + setAuthLocalStorage(data); + + let interval; + + setTimeout(async () => { + await refreshTokenIntervalFunction(); + interval = setInterval(async () => { + await refreshTokenIntervalFunction(); + }, (data.expires_in - 5) * 1000) + }, 0); setAuth(true); } catch (e) { console.log("Not authorized"); + clearInterval(interval); } finally { userInfoLoading$.set(false); } diff --git a/src/stores/signin.js b/src/stores/signin.js index b290f97..51f0d82 100644 --- a/src/stores/signin.js +++ b/src/stores/signin.js @@ -31,8 +31,30 @@ export async function signin(values) { signinLoading$.set(true); signinError$.set(""); + const url = import.meta.env.VITE_KEYCLOAK_URL || 'https://kk.dev.selftech.ru/'; + const clientId = import.meta.env.VITE_KEYCLOAK_CLIENT_ID || 'postnet'; + const clientSecret = import.meta.env.VITE_KEYCLOAK_CLIENT_SECRET || 'K2yHweEUispkVeWn03VMk843sW2Moic5'; + + const auth = () => { + return api.request({ + url: "/realms/SST/protocol/openid-connect/token", + baseURL: url, + method: "POST", + data: { + "grant_type": "password", + client_id: clientId, + client_secret: clientSecret, + username: values.login, + password: values.password, + }, + headers: { + 'Content-type': 'application/x-www-form-urlencoded', + }, + }); + } + try { - const { data } = await api.post("accounts/login/", values); + const { data } = await auth(); setAuth(true); return data; @@ -50,8 +72,6 @@ export async function signin(values) { } } -export const resetSignin = function () {}; - export const signupLoading$ = atom(false); export const signupError$ = atom(""); diff --git a/vite.config.ts b/vite.config.ts index 7a5e279..c6b1934 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,8 +11,8 @@ export default defineConfig(({ mode }) => { plugins: [svgr(), react()], server: { proxy: { - "/account": env.VITE_API_URL, "/api": env.VITE_API_URL, + "/realms": "https://kk.dev.selftech.ru/", }, }, css: { From 3026e087ddaf7a164db265bdff58491a96b7bcd1 Mon Sep 17 00:00:00 2001 From: Timofey Malinin <tmalinin@endwork.today> Date: Tue, 14 Nov 2023 09:34:36 +0000 Subject: [PATCH 3/4] Update .gitlab-ci.yml --- .gitlab-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 36e2a1f..5326330 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -34,9 +34,9 @@ build-docker-prod: docker build --build-arg YC_CONTAINER_REGISTRY=${YC_CONTAINER_REGISTRY} --build-arg VITE_API_URL="https://postnet.selftech.ru" - --build-arg VITE_KEYCLOAK_CLIENT_ID="" + --build-arg VITE_KEYCLOAK_CLIENT_ID="postnet" --build-arg VITE_KEYCLOAK_CLIENT_SECRET=${VITE_KEYCLOAK_CLIENT_SECRET} - --build-arg VITE_KEYCLOAK_URL="" + --build-arg VITE_KEYCLOAK_URL="https://auth.selftech.ru/" -t ${DOCKER_IMAGE_TAG}-prod . - docker push ${DOCKER_IMAGE_TAG}-prod environment: From 75cf1bca392fe7d10340ff29fd04476c5720e6c6 Mon Sep 17 00:00:00 2001 From: Timofey Malinin <tmalinin@endwork.today> Date: Tue, 14 Nov 2023 09:51:43 +0000 Subject: [PATCH 4/4] Update README.md --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 600fed8..370e28c 100644 --- a/README.md +++ b/README.md @@ -94,4 +94,5 @@ server { listen [::]:80 ; return 404; } -``` \ No newline at end of file + +```