- Добавить сотрудника
+ {isEditMode ? 'Редактировать' : 'Добавить сотрудника'}
- Создание пользователя для доступа к смартфонам
+ {isEditMode
+ ? 'Изменение данных пользователя системы MDM'
+ : 'Создание пользователя для доступа к системе MDM'}
@@ -80,44 +172,103 @@ export function AddEmployeeModal({ open, onOpenChange }: AddEmployeeModalProps)
diff --git a/mdm-front/src/pages/EmployeesPage/components/AddOrganisationModal/AddOrganisationModal.scss b/mdm-front/src/pages/EmployeesPage/components/AddOrganisationModal/AddOrganisationModal.scss
new file mode 100644
index 0000000..ec67dd2
--- /dev/null
+++ b/mdm-front/src/pages/EmployeesPage/components/AddOrganisationModal/AddOrganisationModal.scss
@@ -0,0 +1,366 @@
+@use '../../../../shared/styles/variables' as *;
+
+.add-organisation-modal__overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 80;
+
+ background: rgba(15, 23, 42, 0.42);
+ backdrop-filter: blur(2px);
+
+ animation: addOrganisationOverlayShow 0.18s ease;
+}
+
+.add-organisation-modal {
+ position: fixed;
+ z-index: 90;
+ top: 50%;
+ left: 50%;
+
+ width: min(520px, calc(100vw - 32px));
+ padding: 24px;
+
+ border-radius: 24px;
+ background: #ffffff;
+ box-shadow: 0 24px 70px rgba(15, 23, 42, 0.22);
+
+ transform: translate(-50%, -50%);
+ animation: addOrganisationModalShow 0.2s ease;
+}
+
+.add-organisation-modal__header {
+ margin-bottom: 22px;
+
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.add-organisation-modal__title {
+ margin: 0;
+
+ color: $color-text;
+ font-size: 24px;
+ font-weight: 650;
+}
+
+.add-organisation-modal__description {
+ margin: 6px 0 0;
+
+ color: $gray50;
+ font-size: 15px;
+ font-weight: 400;
+ line-height: 1.4;
+}
+
+.add-organisation-modal__close {
+ width: 40px;
+ height: 40px;
+
+ border: none;
+ border-radius: 12px;
+ background: $color-bg;
+
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+
+ color: $gray50;
+ cursor: pointer;
+ transition: 0.2s ease;
+
+ &:hover {
+ color: $blue;
+ background: $gray20;
+ }
+}
+
+.add-organisation-form {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.add-organisation-field {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+
+ span {
+ color: $color-text;
+ font-size: 14px;
+ font-weight: 500;
+ }
+
+ input {
+ height: 44px;
+ border: 1px solid #dfe5ef;
+ border-radius: 14px;
+ padding: 0 14px;
+
+ background: #f8fafc;
+ outline: none;
+
+ color: $color-text;
+ font-size: 15px;
+ font-weight: 400;
+
+ transition: 0.2s ease;
+
+ &:focus {
+ border-color: $blue;
+ background: #ffffff;
+ }
+
+ &::placeholder {
+ color: $gray50;
+ }
+ }
+}
+
+.add-organisation-error {
+ border-radius: 14px;
+ background: rgba(224, 0, 0, 0.08);
+ padding: 12px 14px;
+
+ color: $red;
+ font-size: 14px;
+ font-weight: 500;
+}
+
+.add-organisation-modal__footer {
+ margin-top: 8px;
+
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+}
+
+.add-organisation-cancel,
+.add-organisation-submit {
+ height: 42px;
+ padding: 0 18px;
+
+ border: none;
+ border-radius: 14px;
+
+ font-size: 15px;
+ font-weight: 600;
+ cursor: pointer;
+
+ transition: 0.2s ease;
+
+ &:disabled {
+ opacity: 0.6;
+ cursor: default;
+ }
+}
+
+.add-organisation-cancel {
+ background: $color-bg;
+ color: $gray50;
+
+ &:hover {
+ background: $gray20;
+ color: $color-text;
+ }
+}
+
+.add-organisation-submit {
+ background: $blue;
+ color: #ffffff;
+
+ &:hover {
+ opacity: 0.9;
+ }
+}
+
+.add-organisation-policy {
+ margin-top: 2px;
+ padding: 14px;
+ border: 1px solid #dfe5ef;
+ border-radius: 18px;
+ background: #f8fafc;
+}
+
+.add-organisation-policy__header {
+ margin-bottom: 8px;
+
+ h3 {
+ margin: 0;
+ color: $color-text;
+ font-size: 16px;
+ font-weight: 650;
+ }
+
+ p {
+ margin: 4px 0 0;
+ color: $gray50;
+ font-size: 13px;
+ font-weight: 500;
+ line-height: 1.35;
+ }
+}
+
+.add-organisation-policy__list {
+ display: flex;
+ flex-direction: column;
+}
+
+.add-organisation-policy-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+
+ svg {
+ width: 22px;
+ height: 22px;
+ padding: 4px;
+ border-radius: 8px;
+ color: $blue;
+ background: #eef1f6;
+ }
+
+ &:first-child {
+ .add-organisation-policy-item__content {
+ border-top: none;
+ }
+ }
+
+ &.is-disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+
+ svg,
+ span,
+ button {
+ pointer-events: none;
+ }
+ }
+}
+
+.add-organisation-policy-item__content {
+ min-height: 44px;
+ flex: 1;
+ border-top: 1px solid $gray20;
+
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+
+ > span {
+ color: $color-text;
+ font-size: 15px;
+ font-weight: 500;
+ }
+}
+
+.add-organisation-policy-switch {
+ position: relative;
+
+ width: 48px;
+ height: 24px;
+ flex: 0 0 48px;
+ padding: 0;
+ border: none;
+ outline: none;
+
+ display: inline-flex;
+ align-items: center;
+
+ border-radius: 999px;
+ background: $gray20;
+ box-shadow: inset 0 0 0 1px rgba($gray50, 0.08);
+
+ cursor: pointer;
+ user-select: none;
+
+ transition:
+ background-color 0.28s ease,
+ box-shadow 0.28s ease,
+ transform 0.18s ease;
+
+ > span {
+ position: absolute;
+ top: 3px;
+ left: 3px;
+
+ width: 24px;
+ height: 18px;
+ border-radius: 100px;
+ background: #ffffff;
+
+ box-shadow:
+ 0 2px 5px rgba(15, 23, 42, 0.18),
+ 0 1px 1px rgba(15, 23, 42, 0.08);
+
+ transition:
+ transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1),
+ box-shadow 0.28s ease,
+ width 0.18s ease;
+ }
+
+ &.is-enabled {
+ background: $blue;
+ box-shadow: inset 0 0 0 1px rgba($blue, 0.12);
+
+ > span {
+ transform: translateX(18px);
+ box-shadow:
+ 0 3px 7px rgba(3, 29, 154, 0.24),
+ 0 1px 1px rgba(15, 23, 42, 0.08);
+ }
+ }
+
+ &:hover {
+ box-shadow:
+ inset 0 0 0 1px rgba($gray50, 0.12),
+ 0 4px 10px rgba(15, 23, 42, 0.06);
+ }
+
+ &:active {
+ transform: scale(0.96);
+
+ > span {
+ width: 23px;
+ }
+ }
+
+ &.is-enabled:active > span {
+ transform: translateX(17px);
+ }
+
+ &:focus-visible {
+ box-shadow:
+ 0 0 0 3px rgba($blue, 0.18),
+ inset 0 0 0 1px rgba($blue, 0.18);
+ }
+
+ &:disabled {
+ cursor: not-allowed;
+ }
+}
+
+@keyframes addOrganisationOverlayShow {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes addOrganisationModalShow {
+ from {
+ opacity: 0;
+ transform: translate(-50%, -48%) scale(0.98);
+ }
+
+ to {
+ opacity: 1;
+ transform: translate(-50%, -50%) scale(1);
+ }
+}
\ No newline at end of file
diff --git a/mdm-front/src/pages/EmployeesPage/components/AddOrganisationModal/AddOrganisationModal.tsx b/mdm-front/src/pages/EmployeesPage/components/AddOrganisationModal/AddOrganisationModal.tsx
new file mode 100644
index 0000000..cf7f902
--- /dev/null
+++ b/mdm-front/src/pages/EmployeesPage/components/AddOrganisationModal/AddOrganisationModal.tsx
@@ -0,0 +1,369 @@
+import { useEffect, useState, type FormEvent, type ReactNode } from 'react'
+import * as Dialog from '@radix-ui/react-dialog'
+import { X } from 'lucide-react'
+import { useApolloClient, useMutation } from '@apollo/client/react'
+
+import { WifiIcon } from '../../../../assets/icons/Wifi'
+import { BluetoothIcon } from '../../../../assets/icons/Bluetooth'
+import { GpsIcon } from '../../../../assets/icons/Gps'
+import { CameraIcon } from '../../../../assets/icons/Camera'
+import { SimIcon } from '../../../../assets/icons/Sim'
+import { VolumeIcon } from '../../../../assets/icons/Volume'
+import {
+ CHANGE_ORGANISATION_MUTATION,
+ CREATE_ORGANISATION_MUTATION,
+ GET_ORGANISATION_QUERY,
+ GET_ORGANISATIONS_QUERY,
+} from '../../../../entities/employee/api/employee.graphql'
+import type {
+ ChangeOrganisationData,
+ ChangeOrganisationVariables,
+ CreateOrganisationData,
+ CreateOrganisationVariables,
+ GetOrganisationData,
+ GetOrganisationVariables,
+ Organisation,
+ OrganisationPolicy,
+} from '../../../../entities/employee/model/types'
+
+import './AddOrganisationModal.scss'
+
+type AddOrganisationModalProps = {
+ open: boolean
+ mode?: 'create' | 'edit'
+ organisation?: Organisation | null
+ onOpenChange: (open: boolean) => void
+ onSuccess?: () => void | Promise
+}
+
+type ModalPolicyKey =
+ | keyof OrganisationPolicy
+ | 'wifi'
+ | 'speaker'
+
+type ModalPolicyItem = {
+ key: ModalPolicyKey
+ label: string
+ icon: ReactNode
+ disabled?: boolean
+}
+
+const defaultOrganisationPolicy: OrganisationPolicy = {
+ canUseBluetooth: true,
+ canUseCamera: true,
+ canUseGPS: true,
+ canUseSim: true,
+}
+
+const policyItems: ModalPolicyItem[] = [
+ {
+ key: 'wifi',
+ label: 'Wi-Fi',
+ icon: ,
+ disabled: true,
+ },
+ {
+ key: 'canUseBluetooth',
+ label: 'Bluetooth',
+ icon: ,
+ },
+ {
+ key: 'canUseGPS',
+ label: 'GPS',
+ icon: ,
+ },
+ {
+ key: 'canUseCamera',
+ label: 'Камера',
+ icon: ,
+ },
+ {
+ key: 'canUseSim',
+ label: 'SIM-карта',
+ icon: ,
+ },
+ {
+ key: 'speaker',
+ label: 'Динамик',
+ icon: ,
+ disabled: true,
+ },
+]
+
+const defaultOrganisationsQueryVariables = {
+ page: 0,
+ query: undefined,
+ sortDirection: 'ASC' as const,
+ sortField: 'ID' as const,
+}
+
+function getPolicyVariables(policy?: OrganisationPolicy | null): OrganisationPolicy {
+ return {
+ canUseBluetooth: policy?.canUseBluetooth ?? true,
+ canUseCamera: policy?.canUseCamera ?? true,
+ canUseGPS: policy?.canUseGPS ?? true,
+ canUseSim: policy?.canUseSim ?? true,
+ }
+}
+
+function isBackendPolicyKey(key: ModalPolicyKey): key is keyof OrganisationPolicy {
+ return (
+ key === 'canUseBluetooth' ||
+ key === 'canUseCamera' ||
+ key === 'canUseGPS' ||
+ key === 'canUseSim'
+ )
+}
+
+export function AddOrganisationModal({
+ open,
+ mode = 'create',
+ organisation = null,
+ onOpenChange,
+ onSuccess,
+}: AddOrganisationModalProps) {
+ const client = useApolloClient()
+
+ const [name, setName] = useState('')
+ const [policy, setPolicy] = useState(
+ defaultOrganisationPolicy,
+ )
+
+ const isEditMode = mode === 'edit'
+
+ const [createOrganisation, { loading: isCreating, error: createError }] =
+ useMutation(
+ CREATE_ORGANISATION_MUTATION,
+ )
+
+ const [changeOrganisation, { loading: isChanging, error: changeError }] =
+ useMutation(
+ CHANGE_ORGANISATION_MUTATION,
+ )
+
+ const isLoading = isCreating || isChanging
+ const mutationError = createError || changeError
+
+ useEffect(() => {
+ if (!open) return
+
+ if (isEditMode && organisation) {
+ setName(organisation.name ?? '')
+ return
+ }
+
+ setName('')
+ setPolicy(defaultOrganisationPolicy)
+ }, [open, isEditMode, organisation])
+
+ function handlePolicyToggle(key: ModalPolicyKey, disabled?: boolean) {
+ if (disabled || !isBackendPolicyKey(key)) return
+
+ setPolicy((prev) => ({
+ ...prev,
+ [key]: !prev[key],
+ }))
+ }
+
+ function getPolicyValue(key: ModalPolicyKey) {
+ if (!isBackendPolicyKey(key)) return true
+
+ return policy[key]
+ }
+
+ async function handleSubmit(event: FormEvent) {
+ event.preventDefault()
+
+ const trimmedName = name.trim()
+
+ if (!trimmedName) return
+
+ if (isEditMode) {
+ if (!organisation) return
+
+ let currentPolicy = organisation.policy
+
+ if (!currentPolicy) {
+ const result = await client.query<
+ GetOrganisationData,
+ GetOrganisationVariables
+ >({
+ query: GET_ORGANISATION_QUERY,
+ variables: {
+ id: String(organisation.id),
+ },
+ fetchPolicy: 'network-only',
+ })
+
+ currentPolicy = result.data?.getOrganisation?.policy ?? null
+ }
+
+ const policyVariables = getPolicyVariables(currentPolicy)
+
+ await changeOrganisation({
+ variables: {
+ id: String(organisation.id),
+ name: trimmedName,
+ ...policyVariables,
+ },
+ refetchQueries: [
+ {
+ query: GET_ORGANISATION_QUERY,
+ variables: {
+ id: String(organisation.id),
+ },
+ },
+ {
+ query: GET_ORGANISATIONS_QUERY,
+ variables: defaultOrganisationsQueryVariables,
+ },
+ ],
+ })
+
+ await onSuccess?.()
+
+ onOpenChange(false)
+ return
+ }
+
+ console.log('createOrganisation variables', {
+ name: trimmedName,
+ ...policy,
+ })
+
+ await createOrganisation({
+ variables: {
+ name: trimmedName,
+ ...policy,
+ },
+ })
+
+ await onSuccess?.()
+
+ setName('')
+ setPolicy(defaultOrganisationPolicy)
+ onOpenChange(false)
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ {isEditMode ? 'Редактировать организацию' : 'Добавить организацию'}
+
+
+
+ {isEditMode
+ ? 'Изменение данных организации'
+ : 'Создание организации для привязки пользователей и устройств'}
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/mdm-front/src/pages/EmployeesPage/components/EmployeesToolbar/EmployeesToolbar.scss b/mdm-front/src/pages/EmployeesPage/components/EmployeesToolbar/EmployeesToolbar.scss
index 80fdda8..ed7bf82 100644
--- a/mdm-front/src/pages/EmployeesPage/components/EmployeesToolbar/EmployeesToolbar.scss
+++ b/mdm-front/src/pages/EmployeesPage/components/EmployeesToolbar/EmployeesToolbar.scss
@@ -4,15 +4,19 @@
display: flex;
align-items: center;
gap: 8px;
+ transition: .2s ease;
svg{
height: 18px;
width: auto;
}
+ &:hover{
+ opacity: .85;
+ }
}
.employees-sort {
//height: 32px;
- padding: 12px 14px 12px 20px !important;
+ padding: 11px 14px 11px 20px !important;
border: none;
border-radius: 20px;
background: #ffffff;
@@ -23,7 +27,7 @@
color: $gray50;
font-size: 14px;
- font-weight: 500;
+ font-weight: 550 !important;
cursor: pointer;
transition: 0.2s ease;
@@ -32,21 +36,24 @@
outline: none;
}
- &:hover {
+ &:hover{
+ background-color: $gray20;
+ }
+
+ &[data-state='open'] {
color: $blue;
+ background-color: $blue20;
+ }
+ svg{
+ height: 22px;
+ width: auto;
}
}
-.employees-sort__chevron {
- transition: transform 0.2s ease;
-}
.employees-sort[data-state='open'] {
color: $blue;
- .employees-sort__chevron {
- transform: rotate(180deg);
- }
}
.employees-sort-menu {
diff --git a/mdm-front/src/pages/EmployeesPage/components/EmployeesToolbar/EmployeesToolbar.tsx b/mdm-front/src/pages/EmployeesPage/components/EmployeesToolbar/EmployeesToolbar.tsx
index 158c34f..41e4190 100644
--- a/mdm-front/src/pages/EmployeesPage/components/EmployeesToolbar/EmployeesToolbar.tsx
+++ b/mdm-front/src/pages/EmployeesPage/components/EmployeesToolbar/EmployeesToolbar.tsx
@@ -1,85 +1,181 @@
-import { useState } from 'react'
-import { ChevronDown, Search } from 'lucide-react'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
+import { Search } from 'lucide-react'
+
+import type {
+ OrganisationSortDirection,
+ OrganisationSortField,
+} from '../../../../entities/employee/model/types'
-import '../../../DevicesPage/components/DevicesToolbar/DevicesToolbar.scss'
import './EmployeesToolbar.scss'
-type EmployeesToolbarProps = {
- onAddEmployee: () => void
+export type EmployeesSortOption = {
+ value: string
+ label: string
+ sortField: OrganisationSortField
+ sortDirection: OrganisationSortDirection
}
-const sortOptions = [
- {
- value: 'default',
- label: 'По умолчанию',
- },
- {
- value: 'id-asc',
- label: 'ID по возрастанию',
- },
- {
- value: 'id-desc',
- label: 'ID по убыванию',
- },
- {
- value: 'role-asc',
- label: 'Роль А–Я',
- },
- {
- value: 'role-desc',
- label: 'Роль Я–А',
- },
+export const employeesSortOptions: EmployeesSortOption[] = [
+ {
+ value: 'id-asc',
+ label: 'ID по возрастанию',
+ sortField: 'ID',
+ sortDirection: 'ASC',
+ },
+ {
+ value: 'id-desc',
+ label: 'ID по убыванию',
+ sortField: 'ID',
+ sortDirection: 'DESC',
+ },
+ {
+ value: 'name-asc',
+ label: 'Название А–Я',
+ sortField: 'Name',
+ sortDirection: 'ASC',
+ },
+ {
+ value: 'name-desc',
+ label: 'Название Я–А',
+ sortField: 'Name',
+ sortDirection: 'DESC',
+ },
+ {
+ value: 'date-desc',
+ label: 'Сначала новые',
+ sortField: 'Date',
+ sortDirection: 'DESC',
+ },
+ {
+ value: 'date-asc',
+ label: 'Сначала старые',
+ sortField: 'Date',
+ sortDirection: 'ASC',
+ },
]
-export function EmployeesToolbar({ onAddEmployee }: EmployeesToolbarProps) {
- const [selectedSort, setSelectedSort] = useState(sortOptions[0])
+type EmployeesToolbarProps = {
+ addButtonText: string
+ onAdd: () => void
- return (
-
-
+ searchValue?: string
+ onSearchChange?: (value: string) => void
-
-
-
-
-
-
+ selectedSort?: EmployeesSortOption
+ onSortChange?: (option: EmployeesSortOption) => void
-
-
+
+
+
+
-
- )
+ {addButtonText}
+
+
+ {showSort && (
+
+
+
+
+
+
+
+ {employeesSortOptions.map((option) => (
+ onSortChange?.(option)}
+ >
+ {option.label}
+
+ {selectedSort.value === option.value && (
+
+ )}
+
+ ))}
+
+
+
+ )}
+
+
+ )
}
\ No newline at end of file
diff --git a/mdm-front/src/pages/LoginPage/LoginPage.tsx b/mdm-front/src/pages/LoginPage/LoginPage.tsx
index e56d33b..e2a39b6 100644
--- a/mdm-front/src/pages/LoginPage/LoginPage.tsx
+++ b/mdm-front/src/pages/LoginPage/LoginPage.tsx
@@ -10,7 +10,7 @@ type LoginPageProps = {
}
export function LoginPage({ onSuccess }: LoginPageProps) {
- const [username, setUsername] = useState('User1')
+ const [username, setUsername] = useState('Admin1')
const [password, setPassword] = useState('123456')
const [signIn, { loading, error }] = useMutation(SIGN_IN_MUTATION, {
diff --git a/mdm-front/src/pages/MapPage/MapPage.scss b/mdm-front/src/pages/MapPage/MapPage.scss
index c9569a9..e56a0e4 100644
--- a/mdm-front/src/pages/MapPage/MapPage.scss
+++ b/mdm-front/src/pages/MapPage/MapPage.scss
@@ -2,6 +2,8 @@
.map-page {
display: flex;
+ position: relative;
+ z-index: 99;
flex: 1;
min-height: 0;
height: 100%;
@@ -163,4 +165,63 @@
margin-top: 8px;
color: #738098;
font-size: 12px;
+}
+
+.leaflet-touch .leaflet-bar {
+ border: none !important;
+ background-clip: padding-box;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.leaflet-control-zoom-in,
+.leaflet-control-zoom-out {
+ --sans: 'Montserrat', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ ;
+ font: 28px/100% var(--sans) !important;
+ width: 36px !important;
+ height: 36px !important;
+ display: flex !important;
+ align-items: center;
+ justify-content: center;
+ font-weight: 500 !important;
+ text-indent: 0px !important;
+ border-radius: 8px !important;
+ border: none !important;
+ transition: .2s ease;
+
+ &:hover {
+ background-color: white !important;
+ color: $blue !important;
+ }
+}
+
+.leaflet-top.leaflet-left {
+ top: 50%;
+ left: auto;
+ right: 12px;
+ transform: translateY(-50%);
+}
+
+.leaflet-left .leaflet-control {
+ margin-left: 0px !important;
+}
+
+.leaflet-top .leaflet-control {
+ margin-top: 0px !important;
+}
+
+.map-track-filter {
+ position: absolute;
+ top: 12px;
+ left: 12px;
+ z-index: 600;
+ border-radius: 24px;
+
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+
+ box-shadow: 0 14px 40px rgba(15, 23, 42, 0.16);
}
\ No newline at end of file
diff --git a/mdm-front/src/pages/MapPage/MapPage.tsx b/mdm-front/src/pages/MapPage/MapPage.tsx
index ee182da..649a0e8 100644
--- a/mdm-front/src/pages/MapPage/MapPage.tsx
+++ b/mdm-front/src/pages/MapPage/MapPage.tsx
@@ -1,13 +1,38 @@
+import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useQuery } from '@apollo/client/react'
-import { MapContainer, Marker, Popup, TileLayer } from 'react-leaflet'
+import {
+ CircleMarker,
+ MapContainer,
+ Marker,
+ Polyline,
+ Popup,
+ TileLayer,
+ useMap,
+ useMapEvents,
+} from 'react-leaflet'
import L from 'leaflet'
-import { GET_PHONES_PAGE_QUERY } from '../../entities/device/api/device.graphql'
-import type { GetPhonesPageData, GetPhonesPageVariables } from '../../entities/device/model/types'
+import {
+ GET_PHONE_GPS_TRACK_QUERY,
+ GET_PHONES_PAGE_QUERY,
+} from '../../entities/device/api/device.graphql'
+import type {
+ Device,
+ DeviceGpsTrackPoint,
+ GetPhoneGpsTrackData,
+ GetPhoneGpsTrackVariables,
+ GetPhonesPageData,
+ GetPhonesPageVariables,
+} from '../../entities/device/model/types'
+
+import { FullscreenControl } from '../../widgets/FullscreenControlLeaflet/FullscreenControl'
+import {
+ MapTrackPeriodControl,
+ type MapTrackPeriodValue,
+} from './components/MapTrackPeriodControl/MapTrackPeriodControl'
import './MapPage.scss'
-import { FullscreenControl } from '../../widgets/FullscreenControlLeaflet/FullscreenControl'
const markerIcon = L.divIcon({
className: 'device-map-marker',
@@ -30,20 +55,153 @@ function formatLocationDate(timestamp: number) {
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
- }).format(new Date(timestamp * 1000))
+ }).format(new Date(timestamp))
+}
+
+function sortGpsTrack(track: DeviceGpsTrackPoint[]) {
+ return [...track].sort((a, b) => a.date - b.date)
+}
+
+function isDateInPeriod(date: number, period: MapTrackPeriodValue | null) {
+ if (!period) return false
+
+ return (
+ date >= period.periodStart.getTime() &&
+ date <= period.periodEnd.getTime()
+ )
+}
+
+type MapResetHandlerProps = {
+ onReset: () => void
+}
+
+function MapResetHandler({ onReset }: MapResetHandlerProps) {
+ useMapEvents({
+ click: () => {
+ onReset()
+ },
+ })
+
+ return null
+}
+
+type MapFitBoundsProps = {
+ positions: [number, number][]
+}
+
+function MapFitBounds({ positions }: MapFitBoundsProps) {
+ const map = useMap()
+
+ useEffect(() => {
+ if (!positions.length) return
+
+ if (positions.length === 1) {
+ map.setView(positions[0], 14)
+ return
+ }
+
+ const bounds = L.latLngBounds(positions)
+
+ map.fitBounds(bounds, {
+ padding: [60, 60],
+ maxZoom: 15,
+ animate: true,
+ })
+ }, [map, positions])
+
+ return null
}
export function MapPage() {
- const { data, loading, error } = useQuery