@@ -182,15 +186,15 @@ export function DevicesPage() {
-
-
-
- {device.lastLocation? 'Исправно' : 'Требует ТО'}
+
+
+
+ {device.lastLocation ? 'Исправно' : 'Требует ТО'}
|
-
+
+
)
}
\ No newline at end of file
diff --git a/mdm-front/src/pages/DevicesPage/components/AddDeviceModal/AddDeviceModal.scss b/mdm-front/src/pages/DevicesPage/components/AddDeviceModal/AddDeviceModal.scss
new file mode 100644
index 0000000..c936bbc
--- /dev/null
+++ b/mdm-front/src/pages/DevicesPage/components/AddDeviceModal/AddDeviceModal.scss
@@ -0,0 +1,163 @@
+@use '../../../../shared/styles/variables' as *;
+
+.add-device-modal__overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 80;
+
+ background: rgba(15, 23, 42, 0.42);
+ backdrop-filter: blur(2px);
+
+ animation: addDeviceOverlayShow 0.18s ease;
+}
+
+.add-device-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: addDeviceModalShow 0.2s ease;
+}
+
+.add-device-modal__header {
+ margin-bottom: 22px;
+
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.add-device-modal__title {
+ margin: 0;
+
+ color: #111827;
+ font-size: 24px;
+ font-weight: 650;
+}
+
+.add-device-modal__description {
+ margin: 6px 0 0;
+
+ color: $gray50;
+ font-size: 15px;
+ font-weight: 400;
+ line-height: 1.4;
+}
+
+.add-device-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-device-qr {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+
+ p {
+ margin: 0;
+
+ color: $gray50;
+ font-size: 14px;
+ line-height: 1.45;
+ text-align: center;
+ }
+}
+
+.add-device-qr__box {
+ min-height: 260px;
+ border-radius: 22px;
+ border: 1px dashed rgba(3, 29, 154, 0.32);
+ background: $color-bg;
+
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 14px;
+
+ color: $blue;
+
+ span {
+ color: #111827;
+ font-size: 18px;
+ font-weight: 600;
+ }
+}
+
+.add-device-modal__footer {
+ margin-top: 22px;
+
+ display: flex;
+ justify-content: flex-end;
+}
+
+.add-device-cancel {
+ height: 42px;
+ padding: 0 18px;
+
+ border: none;
+ border-radius: 14px;
+ background: $color-bg;
+
+ color: $gray50;
+ font-size: 15px;
+ font-weight: 600;
+ cursor: pointer;
+
+ transition: 0.2s ease;
+
+ &:hover {
+ background: $gray20;
+ color: #111827;
+ }
+}
+
+@keyframes addDeviceOverlayShow {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes addDeviceModalShow {
+ 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/DevicesPage/components/AddDeviceModal/AddDeviceModal.tsx b/mdm-front/src/pages/DevicesPage/components/AddDeviceModal/AddDeviceModal.tsx
new file mode 100644
index 0000000..752a130
--- /dev/null
+++ b/mdm-front/src/pages/DevicesPage/components/AddDeviceModal/AddDeviceModal.tsx
@@ -0,0 +1,55 @@
+import * as Dialog from '@radix-ui/react-dialog'
+import { QrCode, X } from 'lucide-react'
+
+import './AddDeviceModal.scss'
+
+type AddDeviceModalProps = {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+export function AddDeviceModal({ open, onOpenChange }: AddDeviceModalProps) {
+ return (
+
+
+
+
+
+
+
+
+ Добавить устройство
+
+
+
+ Отсканируйте QR-код на устройстве для привязки к системе
+
+
+
+
+
+
+
+
+
+
+
+ Здесь будет QR-код
+
+
+
+ После сканирования устройство появится в списке и будет доступно
+ для назначения сотруднику.
+
+
+
+
+
+ Закрыть
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/mdm-front/src/pages/DevicesPage/components/DevicesTabs/DevicesTabs.scss b/mdm-front/src/pages/DevicesPage/components/DevicesTabs/DevicesTabs.scss
index 36f9a90..2552105 100644
--- a/mdm-front/src/pages/DevicesPage/components/DevicesTabs/DevicesTabs.scss
+++ b/mdm-front/src/pages/DevicesPage/components/DevicesTabs/DevicesTabs.scss
@@ -3,18 +3,18 @@
.devices-tabs {
display: flex;
align-items: center;
- gap: 10px;
+ gap: 8px;
}
.devices-tabs__item {
position: relative;
min-height: 36px;
- padding: 8px 18px;
+ padding: 12px 20px;
border: none;
- border-radius: 10px;
+ border-radius: 12px;
background: #ffffff;
color: #4f5b73;
- font-size: 16px;
+ font-size: 18px;
font-weight: 400;
cursor: pointer;
diff --git a/mdm-front/src/pages/DevicesPage/components/DevicesToolbar/DevicesToolbar.scss b/mdm-front/src/pages/DevicesPage/components/DevicesToolbar/DevicesToolbar.scss
index 833c984..ee00a4d 100644
--- a/mdm-front/src/pages/DevicesPage/components/DevicesToolbar/DevicesToolbar.scss
+++ b/mdm-front/src/pages/DevicesPage/components/DevicesToolbar/DevicesToolbar.scss
@@ -45,8 +45,9 @@
gap: 6px;
button {
- padding: 12px;
+ padding: 12px 20px;
font-size: 16px;
+ font-weight: 450;
background-color: $color-bg;
border-radius: 20px;
border: none;
@@ -55,35 +56,124 @@
.add-device {
background-color: $blue;
color: white;
+ cursor: pointer;
}
}
.devices-sort {
- display: flex;
- flex-direction: row;
- gap: 8px;
- align-items: center;
- background: #ffffff;
- color: $gray50;
- font-size: 16px;
- cursor: pointer;
+ //height: 32px;
+ padding: 12px 14px 12px 20px !important;
+
+ border: none;
+ border-radius: 20px;
+ background: #ffffff;
+
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+
+ color: $gray50;
+ font-size: 14px;
+ font-weight: 500;
+
+ cursor: pointer;
+ transition: 0.2s ease;
+
+ &:hover {
+ color: $blue;
+ }
+}
+
+.devices-sort__chevron {
+ transition: transform 0.2s ease;
+}
+
+.devices-sort[data-state='open'] {
+ color: $blue;
+
+ .devices-sort__chevron {
+ transform: rotate(180deg);
+ }
+}
+
+.devices-sort-menu {
+ z-index: 100;
+
+ min-width: 230px;
+ padding: 6px;
+
+ border-radius: 16px;
+ background: #ffffff;
+
+ box-shadow: 0 18px 45px rgba(15, 23, 42, 0.16);
+
+ animation: devicesSortMenuShow 0.16s ease;
+}
+
+.devices-sort-menu__item {
+ min-height: 38px;
+ padding: 0 10px;
+ border-radius: 10px;
+
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+
+ color: #30394b;
+ font-size: 14px;
+ font-weight: 500;
+
+ outline: none;
+ cursor: pointer;
+
+ &[data-highlighted] {
+ background: #f1f4f8;
+ color: $blue;
+ }
+}
+
+.devices-sort-menu__check {
+ width: 7px;
+ height: 7px;
+
+ border-radius: 50%;
+ background: $blue;
+ flex: 0 0 7px;
+}
+
+@keyframes devicesSortMenuShow {
+ from {
+ opacity: 0;
+ transform: translateY(-4px) scale(0.98);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
}
.devices-filter {
position: relative;
border: none;
border-radius: 20px;
- color: $blue;
-
+ color: $gray50;
+ padding: 12px !important;
display: inline-flex;
align-items: center;
justify-content: center;
+ transition: .2s ease;
cursor: pointer;
+ &--active{
+ color: $blue;
+ }
+
svg {
- height: 16px;
+ height: 19px;
width: auto;
}
}
@@ -92,8 +182,8 @@
position: absolute;
top: -2px;
right: -1px;
- width: 8px;
- height: 8px;
+ width: 12px;
+ height: 12px;
border-radius: 50%;
background: $blue;
}
\ No newline at end of file
diff --git a/mdm-front/src/pages/DevicesPage/components/DevicesToolbar/DevicesToolbar.tsx b/mdm-front/src/pages/DevicesPage/components/DevicesToolbar/DevicesToolbar.tsx
index 0596ed6..6ab2f59 100644
--- a/mdm-front/src/pages/DevicesPage/components/DevicesToolbar/DevicesToolbar.tsx
+++ b/mdm-front/src/pages/DevicesPage/components/DevicesToolbar/DevicesToolbar.tsx
@@ -1,38 +1,98 @@
-import { Search, SlidersHorizontal } from 'lucide-react'
+import { useState } from 'react'
+import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
+import { ChevronDown, Search, SlidersHorizontal } from 'lucide-react'
+
import './DevicesToolbar.scss'
type DevicesToolbarProps = {
- isFiltersOpen: boolean
- onToggleFilters: () => void
+ isFiltersOpen: boolean
+ onToggleFilters: () => void
+ onAddDevice: () => void
}
-export function DevicesToolbar({ isFiltersOpen, onToggleFilters }: DevicesToolbarProps) {
- return (
-
-
+const sortOptions = [
+ {
+ value: 'default',
+ label: 'По умолчанию',
+ },
+ {
+ value: 'id-asc',
+ label: 'ID по возрастанию',
+ },
+ {
+ value: 'id-desc',
+ label: 'ID по убыванию',
+ },
+ {
+ value: 'serial-asc',
+ label: 'Заводской номер А–Я',
+ },
+ {
+ value: 'serial-desc',
+ label: 'Заводской номер Я–А',
+ }
+]
-
-
-
+export function DevicesToolbar({
+ isFiltersOpen,
+ onToggleFilters,
+ onAddDevice,
+}: DevicesToolbarProps) {
+ const [selectedSort, setSelectedSort] = useState(sortOptions[0])
-
- )
+ {option.label}
+
+ {selectedSort.value === option.value && (
+
+ )}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ )
}
\ No newline at end of file
diff --git a/mdm-front/src/pages/EmployeesPage/EmployeesPage.scss b/mdm-front/src/pages/EmployeesPage/EmployeesPage.scss
new file mode 100644
index 0000000..afd300f
--- /dev/null
+++ b/mdm-front/src/pages/EmployeesPage/EmployeesPage.scss
@@ -0,0 +1,181 @@
+@use '../../shared/styles/variables' as *;
+
+.employees-page {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+
+ flex: 1;
+ min-height: 0;
+ height: 100%;
+ overflow: hidden;
+}
+
+.employees-table-container {
+ display: flex;
+ flex: 1;
+ min-width: 0;
+ min-height: 0;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.employees-table-card {
+ flex: 1;
+ min-height: 0;
+ overflow: auto;
+
+ border-radius: 20px;
+ background: #ffffff;
+
+ scrollbar-width: thin;
+ scrollbar-color: $gray50 transparent;
+
+ &::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+ }
+
+ &::-webkit-scrollbar-track {
+ background: transparent;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ background: $gray50;
+ border-radius: 999px;
+ }
+
+ &::-webkit-scrollbar-thumb:hover {
+ background: $gray50;
+ }
+}
+
+.employees-table {
+ width: 100%;
+ min-width: 700px;
+ border-collapse: collapse;
+ table-layout: fixed;
+
+ thead {
+ position: sticky;
+ top: 0;
+ z-index: 2;
+ background: #ffffff;
+ }
+
+ th {
+ padding: 14px 20px;
+ line-height: 1;
+ border-bottom: 1px solid #e3e8f0;
+
+ color: $gray50;
+ font-size: 16px;
+ font-weight: 450;
+ text-align: left;
+ background: #ffffff;
+ }
+
+ td {
+ height: 68px;
+ padding: 14px 20px;
+ border-bottom: 1px solid $gray20;
+ vertical-align: middle;
+ text-align: left;
+
+ color: #151a24;
+ font-size: 18px;
+ font-weight: 400;
+ }
+
+ th:nth-child(1),
+ td:nth-child(1) {
+ width: 140px;
+ }
+
+ th:nth-child(2),
+ td:nth-child(2) {
+ width: auto;
+ }
+}
+
+.employees-table__row {
+ transition: 0.2s ease;
+
+ &:hover {
+ background-color: $gray20;
+ }
+}
+
+.employee-role {
+ display: inline-flex;
+ align-items: center;
+
+ min-height: 32px;
+ padding: 0 12px;
+ border-radius: 999px;
+
+ background: rgba(3, 29, 154, 0.08);
+ color: $blue;
+
+ font-size: 16px;
+ font-weight: 500;
+}
+
+.employees-pagination {
+ flex: 0 0 auto;
+ padding: 12px 14px 0;
+
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+
+ color: #738098;
+ font-size: 13px;
+}
+
+.employees-pagination__controls {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+
+ button {
+ min-width: 30px;
+ height: 30px;
+ padding: 0 12px;
+
+ border: none;
+ border-radius: 10px;
+ background: #e9edf5;
+
+ color: #738098;
+ font-size: 14px;
+ cursor: pointer;
+
+ &.is-active {
+ background: #031d9a;
+ color: #ffffff;
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: default;
+ }
+ }
+}
+
+.employees-state {
+ min-height: 220px;
+ height: 100%;
+
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ color: #738098;
+ font-size: 15px;
+ font-weight: 500;
+}
+
+.employees-state--error {
+ color: $red;
+}
\ No newline at end of file
diff --git a/mdm-front/src/pages/EmployeesPage/EmployeesPage.tsx b/mdm-front/src/pages/EmployeesPage/EmployeesPage.tsx
index 1bc9891..9f0531f 100644
--- a/mdm-front/src/pages/EmployeesPage/EmployeesPage.tsx
+++ b/mdm-front/src/pages/EmployeesPage/EmployeesPage.tsx
@@ -1,3 +1,162 @@
+import { useState } from 'react'
+import { useApolloClient, useQuery } from '@apollo/client/react'
+
+import { GET_USERS_PAGE_QUERY } from '../../entities/employee/api/employee.graphql'
+import type {
+ Employee,
+ GetUsersPageData,
+ GetUsersPageVariables,
+} from '../../entities/employee/model/types'
+
+import { EmployeesToolbar } from './components/EmployeesToolbar/EmployeesToolbar'
+import { AddEmployeeModal } from './components/AddEmployeeModal/AddEmployeeModal'
+
+import './EmployeesPage.scss'
+
export function EmployeesPage() {
- return Сотрудники
+ const client = useApolloClient()
+
+ const [currentPage, setCurrentPage] = useState(1)
+ const [loadedPages, setLoadedPages] = useState([])
+ const [loadedNextKeys, setLoadedNextKeys] = useState>([])
+ const [isPageLoading, setIsPageLoading] = useState(false)
+
+ const [isAddEmployeeOpen, setIsAddEmployeeOpen] = useState(false)
+
+ const {
+ data: firstPageData,
+ loading: isFirstPageLoading,
+ error,
+ } = useQuery(GET_USERS_PAGE_QUERY, {
+ variables: {},
+ fetchPolicy: 'network-only',
+ })
+
+ const firstPageEmployees = firstPageData?.getUsersPage.page ?? []
+ const firstPageNextKey = firstPageData?.getUsersPage.nextKey ?? null
+
+ const pages = [firstPageEmployees, ...loadedPages]
+ const employees = pages[currentPage - 1] ?? []
+
+ const nextKey =
+ currentPage === 1
+ ? firstPageNextKey
+ : loadedNextKeys[currentPage - 2] ?? null
+
+ const loading = isFirstPageLoading || isPageLoading
+
+ async function handleNextPage() {
+ const alreadyLoadedNextPage = pages[currentPage]
+
+ if (alreadyLoadedNextPage) {
+ setCurrentPage((prev) => prev + 1)
+ return
+ }
+
+ if (!nextKey) return
+
+ setIsPageLoading(true)
+
+ try {
+ const result = await client.query({
+ query: GET_USERS_PAGE_QUERY,
+ variables: {
+ key: nextKey,
+ },
+ fetchPolicy: 'network-only',
+ })
+
+ const nextPageData = result.data?.getUsersPage
+
+ if (!nextPageData) {
+ return
+ }
+
+ setLoadedPages((prev) => [...prev, nextPageData.page])
+ setLoadedNextKeys((prev) => [...prev, nextPageData.nextKey])
+ setCurrentPage((prev) => prev + 1)
+ } finally {
+ setIsPageLoading(false)
+ }
+ }
+
+ function handlePrevPage() {
+ setCurrentPage((prev) => Math.max(1, prev - 1))
+ }
+
+ return (
+
+ setIsAddEmployeeOpen(true)} />
+
+
+
+ {loading && employees.length === 0 && (
+ Загрузка сотрудников...
+ )}
+
+ {error && (
+
+ Не удалось загрузить сотрудников
+
+ )}
+
+ {!loading && !error && employees.length === 0 && (
+ Сотрудники не найдены
+ )}
+
+ {!error && employees.length > 0 && (
+
+
+
+ | ID |
+ Роль |
+
+
+
+
+ {employees.map((employee) => (
+
+ | {employee.id} |
+
+ {employee.role}
+ |
+
+ ))}
+
+
+ )}
+
+
+
+ Страница {currentPage}
+
+
+
+ Назад
+
+
+
+ {currentPage}
+
+
+
+ Вперед
+
+
+
+
+
+
+ )
}
\ No newline at end of file
diff --git a/mdm-front/src/pages/EmployeesPage/components/AddEmployeeModal/AddEmployeeModal.scss b/mdm-front/src/pages/EmployeesPage/components/AddEmployeeModal/AddEmployeeModal.scss
new file mode 100644
index 0000000..8ba19ad
--- /dev/null
+++ b/mdm-front/src/pages/EmployeesPage/components/AddEmployeeModal/AddEmployeeModal.scss
@@ -0,0 +1,201 @@
+@use '../../../../shared/styles/variables' as *;
+
+.add-employee-modal__overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 80;
+
+ background: rgba(15, 23, 42, 0.42);
+ backdrop-filter: blur(2px);
+
+ animation: addEmployeeOverlayShow 0.18s ease;
+}
+
+.add-employee-modal {
+ position: fixed;
+ z-index: 90;
+ top: 50%;
+ left: 50%;
+
+ width: min(520px, calc(100vw - 32px));
+
+ transform: translate(-50%, -50%);
+
+ border-radius: 24px;
+ background: #ffffff;
+ padding: 24px;
+
+ box-shadow: 0 24px 70px rgba(15, 23, 42, 0.22);
+
+ animation: addEmployeeModalShow 0.2s ease;
+}
+
+.add-employee-modal__header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 16px;
+
+ margin-bottom: 22px;
+}
+
+.add-employee-modal__title {
+ margin: 0;
+
+ color: #111827;
+ font-size: 24px;
+ font-weight: 650;
+}
+
+.add-employee-modal__description {
+ margin: 6px 0 0;
+
+ color: $gray50;
+ font-size: 15px;
+ font-weight: 400;
+}
+
+.add-employee-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-employee-form {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.add-employee-field {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+
+ span {
+ color: #30394b;
+ font-size: 14px;
+ font-weight: 500;
+ }
+
+ input {
+ height: 44px;
+ border: 1px solid #dfe5ef;
+ border-radius: 14px;
+ padding: 0 14px;
+
+ background: #f8fafc;
+ outline: none;
+
+ color: #111827;
+ font-size: 15px;
+ font-weight: 400;
+
+ transition: 0.2s ease;
+
+ &:focus {
+ border-color: $blue;
+ background: #ffffff;
+ }
+
+ &::placeholder {
+ color: $gray50;
+ }
+ }
+}
+
+.add-employee-error {
+ border-radius: 14px;
+ background: rgba(224, 0, 0, 0.08);
+ padding: 12px 14px;
+
+ color: $red;
+ font-size: 14px;
+ font-weight: 500;
+}
+
+.add-employee-modal__footer {
+ margin-top: 8px;
+
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+}
+
+.add-employee-cancel,
+.add-employee-submit {
+ height: 42px;
+ padding: 0 18px;
+
+ border: none;
+ border-radius: 14px;
+
+ font-size: 15px;
+ font-weight: 600;
+ cursor: pointer;
+
+ transition: 0.2s ease;
+}
+
+.add-employee-cancel {
+ background: $color-bg;
+ color: $gray50;
+
+ &:hover {
+ background: $gray20;
+ color: #111827;
+ }
+}
+
+.add-employee-submit {
+ background: $blue;
+ color: #ffffff;
+
+ &:hover {
+ opacity: 0.9;
+ }
+
+ &:disabled {
+ opacity: 0.65;
+ cursor: default;
+ }
+}
+
+@keyframes addEmployeeOverlayShow {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes addEmployeeModalShow {
+ 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/AddEmployeeModal/AddEmployeeModal.tsx b/mdm-front/src/pages/EmployeesPage/components/AddEmployeeModal/AddEmployeeModal.tsx
new file mode 100644
index 0000000..233a1e6
--- /dev/null
+++ b/mdm-front/src/pages/EmployeesPage/components/AddEmployeeModal/AddEmployeeModal.tsx
@@ -0,0 +1,146 @@
+import { useState } from 'react'
+import * as Dialog from '@radix-ui/react-dialog'
+import { X } from 'lucide-react'
+import { useMutation } from '@apollo/client/react'
+
+import {
+ GET_USERS_PAGE_QUERY,
+ SIGN_UP_MUTATION,
+} from '../../../../entities/employee/api/employee.graphql'
+import type {
+ SignUpData,
+ SignUpVariables,
+} from '../../../../entities/employee/model/types'
+
+import './AddEmployeeModal.scss'
+
+type AddEmployeeModalProps = {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+export function AddEmployeeModal({ open, onOpenChange }: AddEmployeeModalProps) {
+ const [organisationId, setOrganisationId] = useState('')
+ const [username, setUsername] = useState('')
+ const [password, setPassword] = useState('')
+ const [groupId, setGroupId] = useState('')
+
+ const [signUp, { loading, error }] = useMutation(
+ SIGN_UP_MUTATION,
+ {
+ refetchQueries: [
+ {
+ query: GET_USERS_PAGE_QUERY,
+ variables: {},
+ },
+ ],
+ onCompleted: () => {
+ setUsername('')
+ setPassword('')
+ setGroupId('')
+ onOpenChange(false)
+ },
+ },
+ )
+
+ function handleSubmit(event: React.SubmitEvent) {
+ event.preventDefault()
+
+ signUp({
+ variables: {
+ organisationId,
+ username,
+ password,
+ groupId: groupId.trim() ? groupId : null,
+ },
+ })
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ Добавить сотрудника
+
+
+
+ Создание пользователя для доступа к смартфонам
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
\ 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
new file mode 100644
index 0000000..80fdda8
--- /dev/null
+++ b/mdm-front/src/pages/EmployeesPage/components/EmployeesToolbar/EmployeesToolbar.scss
@@ -0,0 +1,104 @@
+@use '../../../../shared/styles/variables' as *;
+
+.add-device.add-employees{
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ svg{
+ height: 18px;
+ width: auto;
+ }
+}
+
+.employees-sort {
+ //height: 32px;
+ padding: 12px 14px 12px 20px !important;
+ border: none;
+ border-radius: 20px;
+ background: #ffffff;
+
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+
+ color: $gray50;
+ font-size: 14px;
+ font-weight: 500;
+
+ cursor: pointer;
+ transition: 0.2s ease;
+
+ &:focus-visible{
+ outline: none;
+ }
+
+ &:hover {
+ color: $blue;
+ }
+}
+
+.employees-sort__chevron {
+ transition: transform 0.2s ease;
+}
+
+.employees-sort[data-state='open'] {
+ color: $blue;
+
+ .employees-sort__chevron {
+ transform: rotate(180deg);
+ }
+}
+
+.employees-sort-menu {
+ z-index: 100;
+ min-width: 210px;
+ padding: 6px;
+ border-radius: 16px;
+ background: #ffffff;
+ box-shadow: 0 18px 45px rgba(15, 23, 42, 0.16);
+ animation: employeesSortMenuShow 0.16s ease;
+}
+
+.employees-sort-menu__item {
+ min-height: 38px;
+ padding: 0 10px;
+ border-radius: 10px;
+
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+
+ color: $gray50;
+ font-size: 14px;
+ font-weight: 500;
+
+ outline: none;
+ cursor: pointer;
+
+
+ &[data-highlighted] {
+ background: $color-bg;
+ color: $blue;
+ }
+}
+
+.employees-sort-menu__check {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: $blue;
+ flex: 0 0 7px;
+}
+
+@keyframes employeesSortMenuShow {
+ from {
+ opacity: 0;
+ transform: translateY(-4px) scale(0.98);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
\ No newline at end of file
diff --git a/mdm-front/src/pages/EmployeesPage/components/EmployeesToolbar/EmployeesToolbar.tsx b/mdm-front/src/pages/EmployeesPage/components/EmployeesToolbar/EmployeesToolbar.tsx
new file mode 100644
index 0000000..158c34f
--- /dev/null
+++ b/mdm-front/src/pages/EmployeesPage/components/EmployeesToolbar/EmployeesToolbar.tsx
@@ -0,0 +1,85 @@
+import { useState } from 'react'
+import { ChevronDown, Search } from 'lucide-react'
+import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
+
+import '../../../DevicesPage/components/DevicesToolbar/DevicesToolbar.scss'
+import './EmployeesToolbar.scss'
+
+type EmployeesToolbarProps = {
+ onAddEmployee: () => void
+}
+
+const sortOptions = [
+ {
+ value: 'default',
+ label: 'По умолчанию',
+ },
+ {
+ value: 'id-asc',
+ label: 'ID по возрастанию',
+ },
+ {
+ value: 'id-desc',
+ label: 'ID по убыванию',
+ },
+ {
+ value: 'role-asc',
+ label: 'Роль А–Я',
+ },
+ {
+ value: 'role-desc',
+ label: 'Роль Я–А',
+ },
+]
+
+export function EmployeesToolbar({ onAddEmployee }: EmployeesToolbarProps) {
+ const [selectedSort, setSelectedSort] = useState(sortOptions[0])
+
+ return (
+
+
+
+
+
+
+ Добавить
+
+
+
+
+ {selectedSort.label}
+
+
+
+
+
+
+ {sortOptions.map((option) => (
+ setSelectedSort(option)}
+ >
+ {option.label}
+
+ {selectedSort.value === option.value && (
+
+ )}
+
+ ))}
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/mdm-front/src/pages/MapPage/MapPage.scss b/mdm-front/src/pages/MapPage/MapPage.scss
new file mode 100644
index 0000000..c9569a9
--- /dev/null
+++ b/mdm-front/src/pages/MapPage/MapPage.scss
@@ -0,0 +1,166 @@
+@use '../../shared/styles/variables' as *;
+
+.map-page {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+ height: 100%;
+ gap: 12px;
+ overflow: hidden;
+}
+
+.map-page__sidebar {
+ width: 320px;
+ flex: 0 0 320px;
+ min-height: 0;
+}
+
+.map-page__panel {
+ height: 100%;
+ border-radius: 20px;
+ background: #ffffff;
+ padding: 18px;
+
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+
+ h2 {
+ margin: 0 0 14px;
+ color: #111827;
+ font-size: 20px;
+ font-weight: 600;
+ }
+}
+
+.map-page__state {
+ margin: 0;
+ color: $gray50;
+ font-size: 15px;
+}
+
+.map-page__state--error {
+ color: $red;
+}
+
+.map-page__count {
+ margin: 0 0 14px;
+ color: $gray50;
+ font-size: 15px;
+
+ b {
+ color: #111827;
+ }
+}
+
+.map-page__list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+
+ overflow: auto;
+ min-height: 0;
+
+ scrollbar-width: thin;
+ scrollbar-color: rgba(3, 29, 154, 0.45) transparent;
+
+ &::-webkit-scrollbar {
+ width: 6px;
+ }
+
+ &::-webkit-scrollbar-track {
+ background: transparent;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ background: rgba(3, 29, 154, 0.35);
+ border-radius: 999px;
+ }
+}
+
+.map-device-link {
+ padding: 12px;
+ border-radius: 14px;
+ background: $color-bg;
+
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+
+ text-decoration: none;
+ transition: 0.2s ease;
+
+ span {
+ color: #111827;
+ font-size: 15px;
+ font-weight: 600;
+ }
+
+ small {
+ color: $gray50;
+ font-size: 13px;
+ }
+
+ &:hover {
+ background: $gray20;
+
+ span {
+ color: $blue;
+ }
+ }
+}
+
+.map-page__map {
+ flex: 1;
+ min-width: 0;
+ min-height: 0;
+ border-radius: 20px;
+ overflow: hidden;
+ background: #ffffff;
+}
+
+.map-page__leaflet {
+ width: 100%;
+ height: 100%;
+}
+
+.map-popup {
+ min-width: 210px;
+}
+
+.map-popup__title {
+ display: inline-block;
+ margin-bottom: 8px;
+
+ color: $blue !important;
+ font-size: 15px;
+ font-weight: 700;
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+}
+
+.map-popup__row {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+
+ span {
+ color: #738098;
+ font-size: 12px;
+ }
+
+ b {
+ color: #111827;
+ font-size: 13px;
+ font-weight: 600;
+ }
+}
+
+.map-popup__coords {
+ margin-top: 8px;
+ color: #738098;
+ font-size: 12px;
+}
\ 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 bc2c9ef..ee182da 100644
--- a/mdm-front/src/pages/MapPage/MapPage.tsx
+++ b/mdm-front/src/pages/MapPage/MapPage.tsx
@@ -1,3 +1,136 @@
+import { Link } from 'react-router-dom'
+import { useQuery } from '@apollo/client/react'
+import { MapContainer, Marker, Popup, TileLayer } 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 './MapPage.scss'
+import { FullscreenControl } from '../../widgets/FullscreenControlLeaflet/FullscreenControl'
+
+const markerIcon = L.divIcon({
+ className: 'device-map-marker',
+ html: `
+
+ `,
+ iconSize: [34, 34],
+ iconAnchor: [17, 17],
+ popupAnchor: [0, -18],
+})
+
+function formatLocationDate(timestamp: number) {
+ if (!timestamp) return 'Нет данных'
+
+ return new Intl.DateTimeFormat('ru-RU', {
+ day: '2-digit',
+ month: '2-digit',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ }).format(new Date(timestamp * 1000))
+}
+
export function MapPage() {
- return Карта
+ const { data, loading, error } = useQuery(
+ GET_PHONES_PAGE_QUERY,
+ {
+ variables: {},
+ fetchPolicy: 'network-only',
+ },
+ )
+
+ const devices = data?.getPhonesPage.page ?? []
+ const devicesWithLocation = devices.filter((device) => device.lastLocation)
+
+ const firstLocation = devicesWithLocation[0]?.lastLocation
+
+ const mapCenter: [number, number] = firstLocation
+ ? [firstLocation.lat, firstLocation.lng]
+ : [55.397243, 86.117034]
+
+ return (
+
+ {/*
+
+
+ {loading && Загрузка устройств... }
+
+ {error && (
+
+ Не удалось загрузить устройства
+
+ )}
+
+ {!loading && !error && (
+ <>
+
+
+ {devicesWithLocation.map((device) => (
+
+ {device.serial || `Устройство #${device.id}`}
+
+ {device.lastLocation
+ ? formatLocationDate(device.lastLocation.date)
+ : 'Нет данных'}
+
+
+ ))}
+
+ >
+ )}
+
+ */}
+
+
+
+
+
+ {devicesWithLocation.map((device) => {
+ if (!device.lastLocation) return null
+
+ return (
+
+
+
+
+ {device.serial || `Устройство #${device.id}`}
+
+
+
+ {formatLocationDate(device.lastLocation.date)}
+
+
+
+ {device.lastLocation.lat}, {device.lastLocation.lng}
+
+
+
+
+ )
+ })}
+
+
+
+
+ )
}
\ No newline at end of file
diff --git a/mdm-front/src/shared/styles/_variables.scss b/mdm-front/src/shared/styles/_variables.scss
index e45e8a0..e09b235 100644
--- a/mdm-front/src/shared/styles/_variables.scss
+++ b/mdm-front/src/shared/styles/_variables.scss
@@ -9,3 +9,4 @@ $red20: hsla(0, 100%, 43%, 0.2);
$green: #33B343;
$blue: #031D9A;
$blue20: hsla(230, 96%, 31%, 0.2);
+$orange: #FF6B16;
diff --git a/mdm-front/src/widgets/FullscreenControlLeaflet/FullscreenControl.scss b/mdm-front/src/widgets/FullscreenControlLeaflet/FullscreenControl.scss
new file mode 100644
index 0000000..e4890be
--- /dev/null
+++ b/mdm-front/src/widgets/FullscreenControlLeaflet/FullscreenControl.scss
@@ -0,0 +1,28 @@
+.map-fullscreen-button {
+ position: absolute;
+ top: 12px;
+ right: 12px;
+ z-index: 500;
+
+ width: 36px;
+ height: 36px;
+
+ border: none;
+ border-radius: 8px;
+ background: #ffffff;
+
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ color: #30394b;
+ cursor: pointer;
+ box-shadow: 0 2px 8px rgba(15, 23, 42, 0.14);
+
+ transition: 0.2s ease;
+
+ &:hover {
+ color: #031d9a;
+ background: #f1f4f8;
+ }
+}
\ No newline at end of file
diff --git a/mdm-front/src/widgets/FullscreenControlLeaflet/FullscreenControl.tsx b/mdm-front/src/widgets/FullscreenControlLeaflet/FullscreenControl.tsx
new file mode 100644
index 0000000..d77566b
--- /dev/null
+++ b/mdm-front/src/widgets/FullscreenControlLeaflet/FullscreenControl.tsx
@@ -0,0 +1,33 @@
+import { useState } from 'react'
+import { Maximize2, Minimize2 } from 'lucide-react'
+
+import './FullscreenControl.scss'
+
+type FullscreenControlProps = {
+ targetId: string
+}
+
+export function FullscreenControl({ targetId }: FullscreenControlProps) {
+ const [isFullscreen, setIsFullscreen] = useState(false)
+
+ async function handleToggleFullscreen() {
+ const element = document.getElementById(targetId)
+
+ if (!element) return
+
+ if (document.fullscreenElement) {
+ await document.exitFullscreen()
+ setIsFullscreen(false)
+ return
+ }
+
+ await element.requestFullscreen()
+ setIsFullscreen(true)
+ }
+
+ return (
+
+ {isFullscreen ? : }
+
+ )
+}
\ No newline at end of file
diff --git a/mdm-front/src/widgets/Navbar/Navbar.scss b/mdm-front/src/widgets/Navbar/Navbar.scss
index b18adc8..56f37d1 100644
--- a/mdm-front/src/widgets/Navbar/Navbar.scss
+++ b/mdm-front/src/widgets/Navbar/Navbar.scss
@@ -1,9 +1,158 @@
@use '../../shared/styles/variables' as *;
-.navbar{
+.navbar {
display: flex;
flex-direction: row;
+ align-items: center;
gap: 100px;
justify-content: space-between;
- padding-bottom: 26px;
+ padding-bottom: 20px;
+}
+
+.navbar__actions {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.navbar__icon-btn {
+ width: 34px;
+ height: 34px;
+
+ border: none;
+ border-radius: 12px;
+ background: transparent;
+
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+
+ color: #738098;
+ cursor: pointer;
+
+ transition: 0.2s ease;
+
+ &:hover {
+ background: #ffffff;
+ color: $blue;
+ }
+}
+
+.navbar-user {
+ border: none;
+ background: transparent;
+
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+
+ color: #151a24;
+ font-size: 16px;
+ font-weight: 500;
+ cursor: pointer;
+
+ &:focus-visible{
+ outline: none;
+ }
+}
+
+.navbar-user__avatar {
+ width: 28px;
+ height: 28px;
+
+ border-radius: 50%;
+ background: $blue;
+
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+
+ color: #ffffff;
+ svg{
+ height: 16px;
+ width: auto;
+ }
+}
+
+.navbar-user__chevron {
+ color: #738098;
+}
+
+.navbar-dropdown {
+ z-index: 100;
+
+ width: 220px;
+ border-radius: 16px;
+ background: #ffffff;
+ padding: 8px;
+
+ box-shadow: 0 18px 45px rgba(15, 23, 42, 0.16);
+
+ animation: navbarDropdownShow 0.16s ease;
+}
+
+.navbar-dropdown__user {
+ padding: 10px 12px;
+
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+
+ span {
+ color: #738098;
+ font-size: 13px;
+ }
+
+ b {
+ color: #111827;
+ font-size: 15px;
+ font-weight: 600;
+ }
+}
+
+.navbar-dropdown__separator {
+ height: 1px;
+ margin: 6px 0;
+ background: #e3e8f0;
+}
+
+.navbar-dropdown__item {
+ min-height: 38px;
+ padding: 0 10px;
+ border-radius: 10px;
+
+ display: flex;
+ align-items: center;
+ gap: 8px;
+
+ color: #30394b;
+ font-size: 14px;
+ font-weight: 500;
+
+ outline: none;
+ cursor: pointer;
+
+ &[data-highlighted] {
+ background: #f1f4f8;
+ }
+}
+
+.navbar-dropdown__item--danger {
+ color: $red;
+
+ &[data-highlighted] {
+ background: rgba(224, 0, 0, 0.08);
+ }
+}
+
+@keyframes navbarDropdownShow {
+ from {
+ opacity: 0;
+ transform: translateY(-4px) scale(0.98);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
}
\ No newline at end of file
diff --git a/mdm-front/src/widgets/Navbar/Navbar.tsx b/mdm-front/src/widgets/Navbar/Navbar.tsx
index 9a4783c..66671da 100644
--- a/mdm-front/src/widgets/Navbar/Navbar.tsx
+++ b/mdm-front/src/widgets/Navbar/Navbar.tsx
@@ -1,5 +1,28 @@
import { useLocation } from 'react-router-dom'
import './Navbar.scss'
+import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
+import { ChevronDown, LogOut, Settings, Bell, UserRound } from 'lucide-react'
+import { useQuery } from '@apollo/client/react'
+import { CURRENT_USER_QUERY } from '../../features/auth/api/auth.graphql'
+import { apolloClient } from '../../shared/api/apolloClient'
+
+function clearAuthCookies() {
+ document.cookie = 'Access-token=; Max-Age=0; path=/'
+ document.cookie = 'Refresh-token=; Max-Age=0; path=/'
+}
+
+async function handleLogout() {
+ clearAuthCookies()
+ await apolloClient.clearStore()
+ window.dispatchEvent(new Event('auth:logout'))
+}
+
+type CurrentUserData = {
+ currentUser: {
+ id: string
+ role: string
+ } | null
+}
function getPageTitle(pathname: string) {
if (pathname === '/devices') return 'Устройства'
@@ -12,6 +35,13 @@ function getPageTitle(pathname: string) {
}
export function Navbar() {
+
+ const { data } = useQuery(CURRENT_USER_QUERY, {
+ fetchPolicy: 'cache-first',
+ })
+
+ const userRole = data?.currentUser?.role ?? 'Администратор'
+
const location = useLocation()
const title = getPageTitle(location.pathname)
@@ -21,8 +51,51 @@ export function Navbar() {
{title}
-
- Администратор
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {userRole}
+
+
+
+
+
+
+
+
+ Текущая роль
+ {userRole}
+
+
+
+
+
+
+ Выйти
+
+
+
+
)
|