-
+
${formatTooltipDate(hoverTime)}
${rows}
${temperatureRow}
${batteryRow}
+ ${appsBlock}
`
},
@@ -466,15 +1438,15 @@ export function DeviceHistoryChart({
axisPointer: {
link: [
{
- xAxisIndex: [0, 1, 2],
+ xAxisIndex: [0, 1, 2, 3],
},
],
},
- dataZoom: [
+ /* dataZoom: [
{
type: 'slider',
- xAxisIndex: [0, 1, 2],
+ xAxisIndex: [0, 1, 2, 3],
top: 0,
height: 28,
@@ -501,7 +1473,7 @@ export function DeviceHistoryChart({
moveHandleSize: 0,
textStyle: {
- color: '#738098',
+ color: chartColors.axisLabel,
fontSize: 12,
fontFamily: FONT_FAMILY,
},
@@ -509,14 +1481,8 @@ export function DeviceHistoryChart({
labelFormatter: (value: number) => {
return formatTooltipDate(value)
},
- },
- {
- type: 'inside',
- xAxisIndex: [0, 1, 2],
- start: 0,
- end: 100,
- },
- ],
+ }
+ ], */
grid: [
{
@@ -540,23 +1506,30 @@ export function DeviceHistoryChart({
top: 750,
height: 150,
},
+ {
+ id: 'apps',
+ left: 270,
+ right: 90,
+ top: APP_GRID_TOP,
+ height: appGridHeight,
+ },
],
xAxis: [
{
type: 'time',
gridIndex: 0,
- min: periodStart,
- max: periodEnd,
+ min: visibleStart,
+ max: visibleEnd,
axisLabel: {
- color: '#738098',
+ color: chartColors.axisLabel,
fontSize: 12,
fontFamily: FONT_FAMILY,
hideOverlap: true,
},
axisLine: {
lineStyle: {
- color: '#dbe2ee',
+ color: chartColors.axisLine,
},
},
axisTick: {
@@ -565,24 +1538,24 @@ export function DeviceHistoryChart({
splitLine: {
show: true,
lineStyle: {
- color: '#edf0f5',
+ color: chartColors.splitLine,
},
},
},
{
type: 'time',
gridIndex: 1,
- min: periodStart,
- max: periodEnd,
+ min: visibleStart,
+ max: visibleEnd,
axisLabel: {
- color: '#738098',
+ color: chartColors.axisLabel,
fontSize: 12,
fontFamily: FONT_FAMILY,
hideOverlap: true,
},
axisLine: {
lineStyle: {
- color: '#dbe2ee',
+ color: chartColors.axisLine,
},
},
axisTick: {
@@ -591,24 +1564,24 @@ export function DeviceHistoryChart({
splitLine: {
show: true,
lineStyle: {
- color: '#edf0f5',
+ color: chartColors.splitLine,
},
},
},
{
type: 'time',
gridIndex: 2,
- min: periodStart,
- max: periodEnd,
+ min: visibleStart,
+ max: visibleEnd,
axisLabel: {
- color: '#738098',
+ color: chartColors.axisLabel,
fontSize: 12,
fontFamily: FONT_FAMILY,
hideOverlap: true,
},
axisLine: {
lineStyle: {
- color: '#dbe2ee',
+ color: chartColors.axisLine,
},
},
axisTick: {
@@ -617,7 +1590,33 @@ export function DeviceHistoryChart({
splitLine: {
show: true,
lineStyle: {
- color: '#edf0f5',
+ color: chartColors.splitLine,
+ },
+ },
+ },
+ {
+ type: 'time',
+ gridIndex: 3,
+ min: visibleStart,
+ max: visibleEnd,
+ axisLabel: {
+ color: chartColors.axisLabel,
+ fontSize: 12,
+ fontFamily: FONT_FAMILY,
+ hideOverlap: true,
+ },
+ axisLine: {
+ lineStyle: {
+ color: chartColors.axisLine,
+ },
+ },
+ axisTick: {
+ show: false,
+ },
+ splitLine: {
+ show: true,
+ lineStyle: {
+ color: chartColors.splitLine,
},
},
},
@@ -641,7 +1640,7 @@ export function DeviceHistoryChart({
splitLine: {
show: true,
lineStyle: {
- color: '#edf0f5',
+ color: chartColors.splitLine,
},
},
},
@@ -654,7 +1653,7 @@ export function DeviceHistoryChart({
position: 'right',
axisLabel: {
formatter: '{value}°C',
- color: '#738098',
+ color: chartColors.axisLabel,
fontSize: 12,
fontFamily: FONT_FAMILY,
},
@@ -667,7 +1666,7 @@ export function DeviceHistoryChart({
splitLine: {
show: true,
lineStyle: {
- color: '#edf0f5',
+ color: chartColors.splitLine,
},
},
},
@@ -680,7 +1679,7 @@ export function DeviceHistoryChart({
position: 'right',
axisLabel: {
formatter: '{value}%',
- color: '#738098',
+ color: chartColors.axisLabel,
fontSize: 12,
fontFamily: FONT_FAMILY,
},
@@ -693,7 +1692,28 @@ export function DeviceHistoryChart({
splitLine: {
show: true,
lineStyle: {
- color: '#edf0f5',
+ color: chartColors.splitLine,
+ },
+ },
+ },
+ {
+ type: 'category',
+ gridIndex: 3,
+ data: enabledAppRows.map((row) => row.packageName),
+ inverse: true,
+ axisLabel: {
+ show: false,
+ },
+ axisLine: {
+ show: false,
+ },
+ axisTick: {
+ show: false,
+ },
+ splitLine: {
+ show: true,
+ lineStyle: {
+ color: chartColors.splitLine,
},
},
},
@@ -711,7 +1731,19 @@ export function DeviceHistoryChart({
y: 0,
},
data: eventSeriesData,
- universalTransition: true,
+ //universalTransition: true,
+ },
+ {
+ type: 'scatter',
+ name: 'Точки событий',
+ xAxisIndex: 0,
+ yAxisIndex: 0,
+ encode: {
+ x: 0,
+ y: 1,
+ },
+ data: eventPointSeriesData,
+ z: 10,
},
{
type: 'line',
@@ -719,15 +1751,16 @@ export function DeviceHistoryChart({
xAxisIndex: 1,
yAxisIndex: 1,
data: temperature,
- symbol: 'circle',
- symbolSize: 7,
+ symbol: 'none',
+ showSymbol: false,
+ sampling: 'lttb',
smooth: true,
lineStyle: {
- width: 2,
- color: '#ff8a34',
+ width: isDarkTheme ? 1.6 : 2,
+ color: chartColors.temperature,
},
itemStyle: {
- color: '#ff8a34',
+ color: chartColors.temperature,
},
areaStyle: {
color: {
@@ -739,11 +1772,11 @@ export function DeviceHistoryChart({
colorStops: [
{
offset: 0,
- color: 'rgba(255, 138, 52, 0.24)',
+ color: chartColors.temperatureAreaStart,
},
{
offset: 1,
- color: 'rgba(255, 138, 52, 0.04)',
+ color: chartColors.temperatureAreaEnd,
},
],
},
@@ -751,7 +1784,7 @@ export function DeviceHistoryChart({
markArea: {
silent: true,
itemStyle: {
- color: 'rgba(224, 0, 0, 0.045)',
+ color: chartColors.dangerMark,
},
data: [[{ yAxis: 45 }, { yAxis: 60 }]],
},
@@ -762,15 +1795,16 @@ export function DeviceHistoryChart({
xAxisIndex: 2,
yAxisIndex: 2,
data: battery,
- symbol: 'circle',
- symbolSize: 7,
+ symbol: 'none',
+ showSymbol: false,
+ sampling: 'lttb',
smooth: true,
lineStyle: {
- width: 2,
- color: '#31b24a',
+ width: isDarkTheme ? 1.6 : 2,
+ color: chartColors.battery,
},
itemStyle: {
- color: '#31b24a',
+ color: chartColors.battery,
},
areaStyle: {
color: {
@@ -782,11 +1816,11 @@ export function DeviceHistoryChart({
colorStops: [
{
offset: 0,
- color: 'rgba(49, 178, 74, 0.28)',
+ color: chartColors.batteryAreaStart,
},
{
offset: 1,
- color: 'rgba(49, 178, 74, 0.04)',
+ color: chartColors.batteryAreaEnd,
},
],
},
@@ -803,11 +1837,11 @@ export function DeviceHistoryChart({
colorStops: [
{
offset: 0,
- color: 'rgba(224, 0, 0, 0.1)',
+ color: chartColors.dangerArea,
},
{
offset: 1,
- color: 'rgba(224, 0, 0, 0.1)',
+ color: chartColors.dangerArea,
},
],
},
@@ -815,11 +1849,44 @@ export function DeviceHistoryChart({
data: [[{ yAxis: 0 }, { yAxis: 20 }]],
},
},
+ {
+ type: 'custom',
+ name: 'Активность приложений',
+ xAxisIndex: 3,
+ yAxisIndex: 3,
+ renderItem: renderAppIntervalItem,
+ encode: {
+ x: [1, 2],
+ y: 0,
+ },
+ data: appIntervalSeriesData,
+ //universalTransition: true,
+ z: 4,
+ },
],
- }
+ }), [
+ appGridHeight,
+ appIntervalSeriesData,
+ battery,
+ chartColors,
+ enabledAppRows,
+ eventPointSeriesData,
+ eventSeriesData,
+ isDarkTheme,
+ periodStart,
+ temperature,
+ visibleCategories,
+ visibleEnd,
+ visibleEventPoints,
+ visibleEvents,
+ visibleStart,
+ ])
return (
-
+
{isLoading && (
Загрузка телеметрии...
@@ -831,7 +1898,11 @@ export function DeviceHistoryChart({
Не удалось загрузить телеметрию
)}
-
+
События
@@ -857,7 +1928,7 @@ export function DeviceHistoryChart({
className="device-history-events-legend__checkbox"
style={{
backgroundColor: checked ? category.color : 'transparent',
- borderColor: checked ? category.color : '#c9d2e3',
+ borderColor: checked ? category.color : chartColors.axisLine,
}}
>
{checked && (
@@ -880,6 +1951,133 @@ export function DeviceHistoryChart({
Аккумулятор
Динамика уровня заряда за выбранный период
+
+
Приложения
+
События жизненного цикла приложений за выбранный период
+
+
+
+
+
+ На экране
+
+
+
+
+ Фон
+
+
+
+ {enabledAppRows.map((row, index) => {
+ const checked = enabledPackagesSet.has(row.packageName)
+ const { Icon, modifier } = getAppIcon(row)
+ const top =
+ APP_GRID_TOP +
+ appRowBand * index +
+ appRowBand / 2 -
+ APP_ROW_HEIGHT / 2
+
+ return (
+
togglePackage(row.packageName)}
+ >
+
+ {row.app?.iconUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {row.name}
+
+ {formatDuration(row.activeDuration)} · {formatUsagePercent(row.activePercent)}
+
+
+
+ )
+ })}
+
+ {!enabledAppRows.length && !isLoading && (
+
+
+ {appRows.length
+ ? 'Приложения не выбраны'
+ : 'Нет событий по приложениям'}
+
+
+
+ {appRows.length
+ ? 'Выберите приложения, которые нужно показать на графике.'
+ : 'За выбранный период не найдено событий жизненного цикла приложений.'}
+
+
+ )}
+
+ {appRows.length > 0 && (
+
+
+ Выбрано {enabledAppRows.length} из {appRows.length}
+
+
+
+
+ {appRows.map((row) => {
+ const checked = enabledPackagesSet.has(row.packageName)
+
+ return (
+ togglePackage(row.packageName)}
+ >
+ {row.name}
+
+ {formatDuration(row.activeDuration)}
+
+
+ )
+ })}
+
+
+
+ )}
)
-}
\ No newline at end of file
+})
diff --git a/mdm-front/src/pages/DevicePage/components/DeviceHistoryModal/DeviceHistoryModal.scss b/mdm-front/src/pages/DevicePage/components/DeviceHistoryModal/DeviceHistoryModal.scss
index c46fb18..9b7fe59 100644
--- a/mdm-front/src/pages/DevicePage/components/DeviceHistoryModal/DeviceHistoryModal.scss
+++ b/mdm-front/src/pages/DevicePage/components/DeviceHistoryModal/DeviceHistoryModal.scss
@@ -4,61 +4,121 @@
position: fixed;
inset: 0;
z-index: 500;
- background: rgba(15, 23, 42, 0.35);
- animation: historyOverlayShow 0.2s ease;
+ background: $color-backdrop-soft;
}
.device-history-modal {
position: fixed;
z-index: 600;
inset: 24px;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ max-height: calc(100vh - 48px);
border-radius: 24px;
- background: #ffffff;
- padding: 26px 28px;
+ background: $color-surface;
+ padding: 20px;
- overflow: auto;
+ //overflow: hidden;
- box-shadow: 0 24px 80px rgba(15, 23, 42, 0.22);
- animation: historyModalShow 0.2s ease;
+ box-shadow: 0 24px 80px rgba($color-shadow-rgb, 0.22);
+}
- scrollbar-width: thin;
- scrollbar-color: $gray50 transparent;
+.device-history-modal__scroll {
+ flex: 1 1 auto;
+ height: 100%;
+ min-height: 0;
- &::-webkit-scrollbar {
- width: 6px;
+ .simplebar-content {
+ min-height: 100%;
}
- &::-webkit-scrollbar-track {
- background: transparent;
+ .simplebar-track.simplebar-vertical {
+ top: 14px;
+ right: -18px;
+ bottom: 14px;
+ width: 8px;
}
- &::-webkit-scrollbar-thumb {
+ .simplebar-scrollbar::before {
background: $gray50;
- border-radius: 999px;
+ border-radius: 4px;
+ opacity: 1;
}
}
+.device-history-modal__chart-placeholder {
+ min-height: 720px;
+ padding-top: 220px;
+
+ color: $color-text-muted;
+ font-size: $font-size-14;
+ font-weight: 600;
+ text-align: center;
+}
+
.device-history-modal__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
margin-bottom: 14px;
+ .device-history-modal__title{
+ padding-left: 18px;
+ padding-top: 18px;
+ }
+ .device-history-modal__meta{
+ padding-left: 18px;
+ }
+}
+
+.device-history-modal {
+ .history-period-control__top {
+ //padding-left: 18px;
+ padding-bottom: 18px;
+ }
+}
+
+.device-history-chart__zoom {
+ position: absolute;
+ top: 20px;
+ left: 20px;
+ right: 20px;
+ z-index: 50;
+
+ padding: 8px;
+ //margin: 0 64px;
+ //border-bottom-right-radius: 20px;
+ //border-bottom-left-radius: 20px;
+ border-radius: 20px;
+
+ background: rgba($white, 0.3);
+ backdrop-filter: blur(12px);
+ box-shadow: 0 0 40px -6px rgba($gray50-rgb, .3);
+ .sticky-line{
+ display: flex;
+ flex: 1;
+ height: 1px;
+ background-color: $gray20;
+ margin-top: 26px;
+ margin-right: 90px;
+
+ }
}
.device-history-modal__title {
margin: 0;
- color: #111827;
- font-size: 20px;
+ color: $color-text-strong;
+ font-size: $font-size-20;
font-weight: 650;
text-transform: uppercase;
span {
margin-left: 6px;
color: $gray50;
- font-size: 14px;
+ font-size: $font-size-14;
font-weight: 500;
text-transform: none;
}
@@ -73,17 +133,21 @@
gap: 14px;
color: $gray50;
- font-size: 14px;
+ font-size: $font-size-14;
.is-green {
position: relative;
- padding-left: 12px;
- color: #30394b;
-
+ padding-left: 20px;
+ padding-right: 8px;
+ border-radius: 8px;
+ color: $green;
+ font-weight: 500;
+ font-size: $font-size-16;
+ background-color: rgba($color-success-rgb, .1);
&::before {
content: '';
position: absolute;
- left: 0;
+ left: 8px;
top: 50%;
width: 6px;
@@ -94,6 +158,53 @@
transform: translateY(-50%);
}
}
+ .is-red {
+ position: relative;
+ padding-left: 20px;
+ padding-right: 8px;
+ border-radius: 8px;
+ color: $red;
+ font-weight: 500;
+ font-size: $font-size-16;
+ background-color: rgba($color-danger-rgb, .1);
+ &::before {
+ content: '';
+ position: absolute;
+ left: 8px;
+ top: 50%;
+
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+
+ background: $red;
+ transform: translateY(-50%);
+ }
+ }
+ .is-gray {
+ position: relative;
+ padding-left: 20px;
+ padding-right: 8px;
+ border-radius: 8px;
+ color: $gray50;
+ font-weight: 500;
+ font-size: $font-size-16;
+ background-color: rgba($gray50-rgb, .1);
+
+ &::before {
+ content: '';
+ position: absolute;
+ left: 8px;
+ top: 50%;
+
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+
+ background: $gray50;
+ transform: translateY(-50%);
+ }
+ }
}
.device-history-modal__close {
@@ -101,13 +212,13 @@
height: 36px;
border: none;
border-radius: 12px;
- background: transparent;
+ background: $transparent;
display: inline-flex;
align-items: center;
justify-content: center;
- color: #111827;
+ color: $color-text-strong;
cursor: pointer;
&:hover {
@@ -139,17 +250,17 @@
border: none;
border-radius: 999px;
- background: transparent;
+ background: $transparent;
color: $gray50;
- font-size: 14px;
+ font-size: $font-size-14;
font-weight: 500;
cursor: pointer;
&.is-active {
- background: #ffffff;
+ background: $color-surface;
color: $blue;
- box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08);
+ box-shadow: 0 2px 8px rgba($color-shadow-rgb, 0.08);
}
}
@@ -165,8 +276,8 @@
align-items: center;
gap: 8px;
- color: #30394b;
- font-size: 14px;
+ color: $color-text-heading;
+ font-size: $font-size-14;
cursor: pointer;
svg {
@@ -185,7 +296,7 @@
margin-bottom: 8px;
color: $blue;
- font-size: 14px;
+ font-size: $font-size-14;
}
.device-history-range__track {
@@ -210,7 +321,7 @@
margin: 0;
appearance: none;
- background: transparent;
+ background: $transparent;
pointer-events: none;
}
@@ -244,8 +355,8 @@
h3 {
margin: 0 0 8px 100px;
- color: #30394b;
- font-size: 16px;
+ color: $color-text-heading;
+ font-size: $font-size-16;
font-weight: 600;
}
}
@@ -262,7 +373,7 @@
grid-template-columns: repeat(5, 1fr);
color: $gray50;
- font-size: 13px;
+ font-size: $font-size-13;
span {
transform: translateX(-50%);
@@ -289,12 +400,12 @@
text-align: right;
color: $gray50;
- font-size: 14px;
+ font-size: $font-size-14;
}
.history-row__line {
position: relative;
- border-top: 1px solid #dbe2ee;
+ border-top: 1px solid $color-border-muted;
&::before,
&::after {
@@ -303,7 +414,7 @@
top: -1px;
bottom: 0;
width: 1px;
- background: #dbe2ee;
+ background: $color-border-muted;
}
&::before {
@@ -327,15 +438,15 @@
}
.history-segment--purple {
- background: #7a3fe8;
+ background: $accent-purple;
}
.history-segment--green {
- background: #18b89b;
+ background: $accent-teal;
}
.history-segment--light-blue {
- background: #2f80ed;
+ background: $accent-sky;
}
.history-point {
@@ -354,20 +465,20 @@
}
.history-point--orange {
- background: #ff7a1a;
+ background: $color-warning-strong;
}
.battery-chart {
position: relative;
height: 110px;
margin-left: 100px;
- border-bottom: 1px solid #dbe2ee;
+ border-bottom: 1px solid $color-border-muted;
background:
- linear-gradient(to bottom, transparent 19%, #dbe2ee 20%, transparent 21%),
- linear-gradient(to bottom, transparent 39%, #dbe2ee 40%, transparent 41%),
- linear-gradient(to bottom, transparent 59%, #dbe2ee 60%, transparent 61%),
- linear-gradient(to bottom, transparent 79%, #dbe2ee 80%, transparent 81%);
+ linear-gradient(to bottom, $transparent 19%, $color-border-muted 20%, $transparent 21%),
+ linear-gradient(to bottom, $transparent 39%, $color-border-muted 40%, $transparent 41%),
+ linear-gradient(to bottom, $transparent 59%, $color-border-muted 60%, $transparent 61%),
+ linear-gradient(to bottom, $transparent 79%, $color-border-muted 80%, $transparent 81%);
}
.battery-chart__area {
@@ -376,10 +487,10 @@
background: linear-gradient(
168deg,
- rgba(49, 178, 74, 0.38) 0%,
- rgba(49, 178, 74, 0.28) 45%,
- rgba(49, 178, 74, 0.05) 46%,
- rgba(49, 178, 74, 0.05) 100%
+ rgba($color-success-strong-rgb, 0.38) 0%,
+ rgba($color-success-strong-rgb, 0.28) 45%,
+ rgba($color-success-strong-rgb, 0.05) 46%,
+ rgba($color-success-strong-rgb, 0.05) 100%
);
clip-path: polygon(0 10%, 100% 90%, 100% 100%, 0 100%);
@@ -396,7 +507,7 @@
justify-content: space-between;
color: $gray50;
- font-size: 13px;
+ font-size: $font-size-13;
}
.history-summary {
@@ -412,10 +523,10 @@
background: $color-bg;
color: $gray50;
- font-size: 13px;
+ font-size: $font-size-13;
b {
- color: #111827;
+ color: $color-text-strong;
font-weight: 600;
}
}
@@ -429,17 +540,17 @@
h3 {
margin: 0 0 10px;
padding-bottom: 10px;
- border-bottom: 1px solid #dbe2ee;
+ border-bottom: 1px solid $color-border-muted;
- color: #30394b;
- font-size: 16px;
+ color: $color-text-heading;
+ font-size: $font-size-16;
font-weight: 600;
}
}
.device-apps-list {
border-radius: 14px;
- background: #ffffff;
+ background: $color-surface;
overflow: hidden;
}
@@ -449,8 +560,8 @@
padding: 0 16px;
border: none;
- border-bottom: 1px solid #e4e8f0;
- background: #ffffff;
+ border-bottom: 1px solid $color-border-muted;
+ background: $color-surface;
display: grid;
grid-template-columns: 28px 220px 1fr auto 20px;
@@ -465,7 +576,7 @@
}
&:hover {
- background: #f8fafc;
+ background: $color-surface-muted;
}
}
@@ -477,56 +588,58 @@
}
.device-app-row__name {
- color: #30394b;
- font-size: 15px;
+ color: $color-text-heading;
+ font-size: $font-size-15;
font-weight: 500;
}
.device-app-row__time {
color: $gray50;
- font-size: 13px;
+ font-size: $font-size-13;
}
.device-app-row__percent {
color: $gray50;
- font-size: 13px;
+ font-size: $font-size-13;
}
.device-app-row__arrow {
color: $gray50;
- font-size: 22px;
+ font-size: $font-size-22;
}
.device-apps-card__more {
margin-top: 12px;
border: none;
- background: transparent;
+ background: $transparent;
color: $blue;
- font-size: 14px;
+ font-size: $font-size-14;
font-weight: 500;
cursor: pointer;
}
-@keyframes historyOverlayShow {
- from {
- opacity: 0;
+[data-theme='dark'] {
+ .device-history-modal {
+ background: $color-surface;
+ box-shadow: 0 26px 80px rgba($color-shadow-rgb, 0.62);
}
- to {
- opacity: 1;
+ .device-history-chart__zoom {
+ background: rgba($color-surface-rgb, 0.76);
+ backdrop-filter: blur(14px);
+ box-shadow:
+ 0 18px 44px rgba($color-shadow-rgb, 0.38),
+ inset 0 0 0 1px rgba($gray50-rgb, 0.08);
+
+ .sticky-line {
+ background-color: rgba($gray50-rgb, 0.16);
+ }
+ }
+
+ .device-history-modal__close:hover {
+ background: $color-surface-hover;
+ color: $color-primary-hover;
}
}
-
-@keyframes historyModalShow {
- from {
- opacity: 0;
- transform: translateY(10px) scale(0.99);
- }
-
- to {
- opacity: 1;
- transform: translateY(0) scale(1);
- }
-}
\ No newline at end of file
diff --git a/mdm-front/src/pages/DevicePage/components/DeviceHistoryModal/DeviceHistoryModal.tsx b/mdm-front/src/pages/DevicePage/components/DeviceHistoryModal/DeviceHistoryModal.tsx
index 29698c9..b7e9d8a 100644
--- a/mdm-front/src/pages/DevicePage/components/DeviceHistoryModal/DeviceHistoryModal.tsx
+++ b/mdm-front/src/pages/DevicePage/components/DeviceHistoryModal/DeviceHistoryModal.tsx
@@ -1,22 +1,37 @@
-import { useState } from 'react'
+import {
+ lazy,
+ Suspense,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react'
import * as Dialog from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { useQuery } from '@apollo/client/react'
+import SimpleBar from 'simplebar-react'
+import 'simplebar-react/dist/simplebar.min.css'
import type { Device } from '../../types'
+import { conditionText, connectionText } from '../../types'
import {
GET_TELEMETRY_QUERY,
} from '../../../../entities/device/api/device.graphql'
import type {
GetTelemetryData,
GetTelemetryVariables,
+ DeviceHistoryChartItem,
+ DevicePackageUseEventsGroup,
+ DevicePhoneStateEventType
} from '../../../../entities/device/model/types'
import {
DeviceHistoryPeriodControl,
type DeviceHistoryPeriodValue,
} from '../DeviceHistoryPeriodControl/DeviceHistoryPeriodControl'
-import { DeviceHistoryChart } from '../DeviceHistoryChart/DeviceHistoryChart'
+import { TimelineZoom } from '../TimelineZoom/TimelineZoom'
+import { MotionDialogContent } from '../../../../shared/ui/MotionDialog/MotionDialog'
import './DeviceHistoryModal.scss'
@@ -26,6 +41,50 @@ type DeviceHistoryModalProps = {
device: Device
}
+const PHONE_STATE_EVENT_LABELS: Record
= {
+ ScreenOn: 'Экран включен',
+ ScreenOff: 'Экран выключен',
+ EnterNetwork: 'В сети',
+ LeaveNetwork: 'Не в сети',
+ PowerOn: 'Включение',
+ PowerOff: 'Выключение',
+ Hit: 'Удар',
+ Overheat: 'Перегрев',
+ PowerAC: 'Зарядка AC',
+ PowerUSB: 'Зарядка USB',
+ PowerWireless: 'Беспроводная зарядка',
+ PowerDisconnected: 'Зарядка отключена',
+}
+
+const CHART_MOUNT_DELAY_MS = 160
+const EMPTY_HISTORY_ITEMS: DeviceHistoryChartItem[] = []
+const EMPTY_PACKAGE_USE_EVENTS: DevicePackageUseEventsGroup[] = []
+
+const DeviceHistoryChart = lazy(() =>
+ import('../DeviceHistoryChart/DeviceHistoryChart').then((module) => ({
+ default: module.DeviceHistoryChart,
+ })),
+)
+
+function DeviceHistoryChartPlaceholder() {
+ return (
+
+ Подготавливаем графики...
+
+ )
+}
+
+function getHistoryConditionClass(device: Device) {
+ return device.condition === 'inspection' ? 'is-red' : 'is-green'
+}
+
+function getHistoryConnectionClass(device: Device) {
+ if (device.connection === 'online') return 'is-green'
+ if (device.connection === 'offlineDanger') return 'is-red'
+
+ return 'is-gray'
+}
+
export function DeviceHistoryModal({
open,
onOpenChange,
@@ -34,59 +93,183 @@ export function DeviceHistoryModal({
const [selectedPeriod, setSelectedPeriod] =
useState(null)
+ const [zoomRange, setZoomRange] = useState<{
+ start: number
+ end: number
+ } | null>(null)
+ const [isChartReady, setIsChartReady] = useState(false)
+ const closeFrameRef = useRef(null)
+
+ useEffect(() => {
+ if (!selectedPeriod) return
+
+ setZoomRange({
+ start: selectedPeriod.periodStart.getTime(),
+ end: selectedPeriod.periodEnd.getTime(),
+ })
+ }, [selectedPeriod])
+
+ useEffect(() => {
+ if (!open) {
+ setIsChartReady(false)
+ return
+ }
+
+ setIsChartReady(false)
+
+ let firstFrameId = 0
+ let secondFrameId = 0
+ let timeoutId = 0
+
+ firstFrameId = window.requestAnimationFrame(() => {
+ secondFrameId = window.requestAnimationFrame(() => {
+ timeoutId = window.setTimeout(() => {
+ setIsChartReady(true)
+ }, CHART_MOUNT_DELAY_MS)
+ })
+ })
+
+ return () => {
+ window.cancelAnimationFrame(firstFrameId)
+ window.cancelAnimationFrame(secondFrameId)
+ window.clearTimeout(timeoutId)
+ }
+ }, [device.id, open])
+
+ useEffect(() => {
+ return () => {
+ if (closeFrameRef.current !== null) {
+ window.cancelAnimationFrame(closeFrameRef.current)
+ }
+ }
+ }, [])
+
+ const handleOpenChange = useCallback(
+ (nextOpen: boolean) => {
+ if (nextOpen) {
+ onOpenChange(true)
+ return
+ }
+
+ setIsChartReady(false)
+
+ if (closeFrameRef.current !== null) {
+ window.cancelAnimationFrame(closeFrameRef.current)
+ }
+
+ closeFrameRef.current = window.requestAnimationFrame(() => {
+ closeFrameRef.current = null
+ onOpenChange(false)
+ })
+ },
+ [onOpenChange],
+ )
+
+ const shouldRenderChart = Boolean(open && isChartReady && selectedPeriod && zoomRange)
+
+ const telemetryVariables = useMemo(
+ () => ({
+ phoneId: Number(device.id),
+ packagesPhoneId: String(device.id),
+ startDate: selectedPeriod?.periodStart.getTime() ?? 0,
+ endDate: selectedPeriod?.periodEnd.getTime(),
+ }),
+ [device.id, selectedPeriod],
+ )
+
const {
data: telemetryData,
loading: telemetryLoading,
error: telemetryError,
} = useQuery(GET_TELEMETRY_QUERY, {
- variables: {
- phoneId: Number(device.id),
- startDate: selectedPeriod?.periodStart.getTime() ?? 0,
- endDate: selectedPeriod?.periodEnd.getTime(),
- },
- skip: !open || !selectedPeriod,
+ variables: telemetryVariables,
+ skip: !shouldRenderChart,
fetchPolicy: 'network-only',
- pollInterval: 15000,
+ //pollInterval: 15000,
})
+ const telemetryWithEvents = useMemo(() => {
+ if (!telemetryData) return EMPTY_HISTORY_ITEMS
+
+ return [
+ ...telemetryData.getTelemetry,
+
+ ...telemetryData.getPhoneStateEvents.map(
+ (event): DeviceHistoryChartItem => ({
+ date: event.date,
+ type: 'event',
+ eventType: event.type,
+ label: PHONE_STATE_EVENT_LABELS[event.type] ?? event.type,
+ }),
+ ),
+ ].sort((a, b) => a.date - b.date)
+ }, [telemetryData])
+
+ const packageUseEvents =
+ telemetryData?.getPhonePackagesUseEvents ?? EMPTY_PACKAGE_USE_EVENTS
+
return (
-
-
-
+
+
+
+
+
+
+ {device.factoryNumber}
+ ID: {device.id}
+
-
-
-
-
- {device.factoryNumber}
- ID: {device.id}
-
+
+ {device.imei} · {device.imei2}
+
+ {conditionText[device.condition]}
+
+
+ {device.connectionText || connectionText[device.connection]}
+
+
+
-
- {device.imei}
- Версия приложения: 244
- Исправно
-
- {device.connectionText || 'В сети'}
-
+
+
+
-
-
-
-
+
+ {selectedPeriod && zoomRange && (
+
+ )}
+
-
-
-
-
-
+
+ {shouldRenderChart && selectedPeriod && zoomRange ? (
+ }>
+
+
+ ) : (
+
+ )}
+
+
)
-}
\ No newline at end of file
+}
diff --git a/mdm-front/src/pages/DevicePage/components/DeviceHistoryPeriodControl/DeviceHistoryPeriodControl.scss b/mdm-front/src/pages/DevicePage/components/DeviceHistoryPeriodControl/DeviceHistoryPeriodControl.scss
index faf0b13..b7d2029 100644
--- a/mdm-front/src/pages/DevicePage/components/DeviceHistoryPeriodControl/DeviceHistoryPeriodControl.scss
+++ b/mdm-front/src/pages/DevicePage/components/DeviceHistoryPeriodControl/DeviceHistoryPeriodControl.scss
@@ -15,7 +15,7 @@
.history-period-tabs {
padding: 4px;
border-radius: 999px;
- background: $color-bg;
+ //background: $color-bg;
display: flex;
align-items: center;
@@ -39,11 +39,17 @@
&:hover {
color: $blue;
+ background: rgba($color-surface-rgb, .6) !important;
}
&.is-active {
background: #ffffff;
color: $blue;
+ box-shadow: 0 0 10px -6px $gray50;
+ }
+ [data-theme='dark'] &.is-active{
+ background: $color-surface;
+ box-shadow: 0 0 10px -6px rgba($gray50-rgb, .4);
}
}
@@ -57,13 +63,13 @@
border: none;
border-radius: 999px;
- background: $color-bg;
+ //background: $color-bg;
display: inline-flex;
align-items: center;
gap: 8px;
- color: #30394b;
+ //color: #30394b;
font-size: 14px;
font-weight: 500;
@@ -77,7 +83,7 @@
&:hover,
&.is-active {
color: $blue;
- box-shadow: inset 0 0 0 1px rgba(3, 29, 154, 0.16);
+ box-shadow: inset 0 0 0 1px rgba($color-primary-rgb, 0.16);
svg {
color: $blue;
@@ -91,94 +97,102 @@
top: calc(100% + 8px);
z-index: 120;
+ min-width: 100%;
+ width: max-content;
border-radius: 18px;
- background: #ffffff;
- padding: 14px;
+ background: $color-surface;
+ padding: 12px;
border: 1.5px solid $gray20;
- box-shadow: 0 18px 45px rgba(15, 23, 42, 0.16);
+ box-shadow: none;
button{
transition: .18s ease;
}
.rdp-root {
- --rdp-accent-color: $blue;
- --rdp-accent-background-color: rgba(3, 29, 154, 0.1);
- --rdp-day_button-border-radius: 10px;
+ --rdp-accent-color: #{$blue};
+ --rdp-accent-background-color: #{$color-primary-soft};
+ --rdp-day_button-border-radius: 12px;
+ --rdp-day_button-height: 34px;
+ --rdp-day_button-width: 34px;
+ --rdp-day-height: 36px;
+ --rdp-day-width: 36px;
--rdp-selected-border: 0;
margin: 0;
+ font-family: inherit;
}
.rdp-months {
+ max-width: 100%;
gap: 18px;
}
+ .rdp-month {
+ width: 100%;
+ }
+
.rdp-month_caption {
margin-bottom: 8px;
}
.rdp-caption_label {
- color: #30394b;
- font-size: 15px;
- font-weight: 600;
+ color: $color-text-strong;
+ font-size: $font-size-14;
+ font-weight: 700;
}
.rdp-nav {
- button {
- width: 30px;
- height: 30px;
- border-radius: 10px;
+ gap: 4px;
+ }
- color: $gray50;
+ .rdp-button_previous,
+ .rdp-button_next,
+ .rdp-nav button {
+ width: 28px;
+ height: 28px;
+ border-radius: 8px;
- &:hover {
- background: $color-bg;
- color: $blue;
- }
+ color: $blue;
+
+ &:hover {
+ background: $color-surface-hover;
}
}
.rdp-weekday {
- color: $gray50;
- font-size: 12px;
- font-weight: 500;
- }
-
- .rdp-day {
- width: 38px;
- height: 38px;
- padding: 0;
+ color: $color-text-muted;
+ font-size: $font-size-12;
+ font-weight: 600;
}
.rdp-day_button {
- width: 36px;
- height: 36px;
-
border: none;
- border-radius: 10px;
- color: #30394b;
- font-size: 14px;
- font-weight: 500;
+ color: $color-text-heading;
+ font-size: $font-size-12;
+ font-weight: 600;
+ width: 100%;
+ height: 100%;
&:hover {
- background: $color-bg;
+ background: $color-surface-hover;
}
}
.rdp-selected {
.rdp-day_button {
background: $blue;
- color: #ffffff;
+ color: $color-text-inverse;
}
}
.rdp-range_middle {
background-color: transparent;
+ padding: 0;
.rdp-day_button {
- background: #e8edff;
+ background: $color-primary-soft;
color: $blue;
border-radius: 0;
}
@@ -186,19 +200,23 @@
.rdp-range_start {
.rdp-day_button {
- border-radius: 10px;
+ background: $blue;
+ color: $color-text-inverse;
+ border-radius: 12px;
}
}
.rdp-range_end {
.rdp-day_button {
- border-radius: 10px;
+ background: $blue;
+ color: $color-text-inverse;
+ border-radius: 12px;
}
}
.rdp-range_start.rdp-range_end {
.rdp-day_button {
- border-radius: 10px;
+ border-radius: 12px;
}
.rdp-today {
@@ -241,7 +259,7 @@
height: 4px;
border-radius: 999px;
- background: rgba(3, 29, 154, 0.16);
+ background: rgba($color-primary-rgb, 0.16);
}
.history-range__active {
@@ -293,51 +311,3 @@
cursor: pointer;
}
-/* react-day-picker */
-
-.history-date-picker__dropdown .rdp-root {
- --rdp-accent-color: $blue;
- --rdp-accent-background-color: rgba(3, 29, 154, 0.1);
- --rdp-day_button-border-radius: 10px;
- --rdp-selected-border: 0;
-
- margin: 0;
-}
-
-.history-date-picker__dropdown .rdp-months {
- gap: 18px;
-}
-
-.history-date-picker__dropdown .rdp-caption_label {
- color: #30394b;
- font-size: 15px;
- font-weight: 600;
-}
-
-.history-date-picker__dropdown .rdp-weekday {
- color: $gray50;
- font-size: 12px;
- font-weight: 500;
-}
-
-.history-date-picker__dropdown .rdp-day_button {
- width: 36px;
- height: 36px;
-
- color: #30394b;
- font-size: 14px;
-}
-
-.history-date-picker__dropdown .rdp-selected .rdp-day_button {
- background: $blue;
- color: #ffffff;
-}
-
-.history-date-picker__dropdown .rdp-range_middle .rdp-day_button {
- background: #e8edff;
- color: $blue;
-}
-
-.history-date-picker__dropdown .rdp-today .rdp-day_button {
- font-weight: 700;
-}
\ No newline at end of file
diff --git a/mdm-front/src/pages/DevicePage/components/DeviceMainCard/DeviceMainCard.tsx b/mdm-front/src/pages/DevicePage/components/DeviceMainCard/DeviceMainCard.tsx
index 219f337..387f23c 100644
--- a/mdm-front/src/pages/DevicePage/components/DeviceMainCard/DeviceMainCard.tsx
+++ b/mdm-front/src/pages/DevicePage/components/DeviceMainCard/DeviceMainCard.tsx
@@ -1,36 +1,109 @@
-import { useState } from 'react'
-import { ShieldCheck, Signal, Smartphone, Trash2 } from 'lucide-react'
+import { memo, useState } from 'react'
+import { AnimatePresence, m, useReducedMotion } from 'framer-motion'
+import { Link } from 'react-router-dom'
+import {
+ Building2,
+ Lock,
+ ShieldCheck,
+ Signal,
+ Smartphone,
+ Trash2,
+} from 'lucide-react'
+
import type { Device } from '../../types'
import { conditionText, connectionText, getStatusClass } from '../../types'
import { ConfirmDangerDialog } from '../../../../widgets/ConfirmDangerDialog/ConfirmDangerDialog'
+import { useDeferredModalPayload } from '../../../../shared/lib/lazyMount'
type DeviceMainCardProps = {
device: Device
onOpenHistory: () => void
}
-function getDeviceFullName(employee: Device) {
- return [employee.serialNumber]
- .filter(Boolean)
- .join(' ')
+function getDeviceFullName(device: Device) {
+ return [device.serialNumber].filter(Boolean).join(' ')
}
+function areDeviceMainCardPropsEqual(
+ prev: DeviceMainCardProps,
+ next: DeviceMainCardProps,
+) {
+ return (
+ prev.onOpenHistory === next.onOpenHistory &&
+ prev.device.id === next.device.id &&
+ prev.device.image === next.device.image &&
+ prev.device.model === next.device.model &&
+ prev.device.factoryNumber === next.device.factoryNumber &&
+ prev.device.serialNumber === next.device.serialNumber &&
+ prev.device.imei === next.device.imei &&
+ prev.device.imei2 === next.device.imei2 &&
+ prev.device.condition === next.device.condition &&
+ prev.device.connection === next.device.connection &&
+ prev.device.connectionText === next.device.connectionText &&
+ prev.device.permissions?.locked === next.device.permissions?.locked &&
+ prev.device.workTime === next.device.workTime &&
+ prev.device.organisationId === next.device.organisationId &&
+ prev.device.organisation === next.device.organisation &&
+ prev.device.registeredAt === next.device.registeredAt
+ )
+}
-export function DeviceMainCard({ device, onOpenHistory, }: DeviceMainCardProps) {
-
+export const DeviceMainCard = memo(function DeviceMainCard({
+ device,
+ onOpenHistory,
+}: DeviceMainCardProps) {
const [deletingDevice, setDeletingDevice] = useState
(null)
+ const renderDeletingDevice = useDeferredModalPayload(deletingDevice)
+ const shouldReduceMotion = useReducedMotion()
+ const statusTransition = shouldReduceMotion
+ ? { duration: 0 }
+ : {
+ duration: 0.24,
+ ease: [0.34, 1.42, 0.64, 1] as [number, number, number, number],
+ }
+ const statusItems = [
+ {
+ key: `condition-${device.condition}`,
+ className: getStatusClass(device.condition),
+ icon: ,
+ content: conditionText[device.condition],
+ },
+ {
+ key: `connection-${device.connection}-${device.connectionText ?? ''}`,
+ className: getStatusClass(device.connection),
+ icon: ,
+ content: device.connectionText ?? connectionText[device.connection],
+ },
+ ]
+
+ if (device.permissions?.locked) {
+ statusItems.push({
+ key: 'locked',
+ className: 'is-danger',
+ icon: ,
+ content: 'Заблокирован',
+ })
+ }
+
+ if (device.workTime) {
+ statusItems.push({
+ key: 'work-time',
+ className: 'is-muted',
+ icon: ,
+ content: `В работе: ${device.workTime}`,
+ })
+ }
function handleConfirmDeleteDevice() {
if (!deletingDevice) return
console.log('Удаление устройства пока без мутации', deletingDevice)
-
setDeletingDevice(null)
}
function handleDeleteDevice(device: Device) {
- setDeletingDevice(device)
- }
+ setDeletingDevice(device)
+ }
return (
@@ -39,7 +112,7 @@ export function DeviceMainCard({ device, onOpenHistory, }: DeviceMainCardProps)
{device.image ? (
) : (
-
+
)}
@@ -49,33 +122,60 @@ export function DeviceMainCard({ device, onOpenHistory, }: DeviceMainCardProps)
{device.factoryNumber}
{device.imei}
- {device.serialNumber ? ` · ${device.serialNumber}` : ''}
+ {device.imei2 ? ` · ${device.imei2}` : ''}
-
-
- {conditionText[device.condition]}
-
-
-
-
- {device.connectionText ?? connectionText[device.connection]}
-
-
- {device.workTime && (
-
-
- В работе: {device.workTime}
-
- )}
+
+ {statusItems.map((status, index) => (
+
+ {status.icon}
+ {status.content}
+
+ ))}
+
+
+
+
+
Организация
+ {device.organisationId ? (
+
+ {device.organisation ?? `Организация #${device.organisationId}`}
+
+
+
+
+ ) : (
+
{device.organisation ?? 'Не указана'}
+ )}
+
+
+
@@ -85,21 +185,31 @@ export function DeviceMainCard({ device, onOpenHistory, }: DeviceMainCardProps)
-
+
Подробная история эксплуатации
- handleDeleteDevice(device)}>
+ handleDeleteDevice(device)}
+ >
+
)
-}
\ No newline at end of file
+}, areDeviceMainCardPropsEqual)
diff --git a/mdm-front/src/pages/DevicePage/components/DeviceMapCard/DeviceMapCard.scss b/mdm-front/src/pages/DevicePage/components/DeviceMapCard/DeviceMapCard.scss
index 669dade..74d5785 100644
--- a/mdm-front/src/pages/DevicePage/components/DeviceMapCard/DeviceMapCard.scss
+++ b/mdm-front/src/pages/DevicePage/components/DeviceMapCard/DeviceMapCard.scss
@@ -7,7 +7,7 @@
position: relative;
border-radius: 14px;
overflow: visible;
- background: #eef1f6;
+ background: $color-media-bg;
}
.device-map__leaflet {
@@ -27,17 +27,17 @@
padding: 0 10px;
border-radius: 999px;
- background: rgba(255, 255, 255, 0.92);
+ background: rgba($color-surface-rgb, 0.92);
display: inline-flex;
align-items: center;
gap: 6px;
- color: #30394b;
+ color: $color-text-heading;
font-size: 12px;
font-weight: 600;
- box-shadow: 0 8px 22px rgba(15, 23, 42, 0.12);
+ box-shadow: 0 8px 22px rgba($color-shadow-rgb, 0.12);
svg {
color: $blue;
@@ -53,7 +53,7 @@
width: 34px;
height: 34px;
border-radius: 50%;
- background: rgba(3, 29, 154, 0.18);
+ background: rgba($color-primary-rgb, 0.18);
display: flex;
align-items: center;
@@ -65,8 +65,41 @@
height: 16px;
border-radius: 50%;
background: $blue;
- border: 3px solid #ffffff;
- box-shadow: 0 8px 18px rgba(3, 29, 154, 0.35);
+ border: 3px solid $color-surface;
+ box-shadow: 0 8px 18px rgba($color-primary-rgb, 0.35);
+}
+
+.device-map-marker.is-online {
+ .device-map-marker__pin {
+ background: rgba($color-success-rgb, 0.18);
+ }
+
+ .device-map-marker__inner {
+ background: $green;
+ box-shadow: 0 8px 18px rgba($color-success-rgb, 0.35);
+ }
+}
+
+.device-map-marker.is-offline {
+ .device-map-marker__pin {
+ background: rgba($gray50-rgb, 0.18);
+ }
+
+ .device-map-marker__inner {
+ background: $gray50;
+ box-shadow: 0 8px 18px rgba($gray50-rgb, 0.28);
+ }
+}
+
+.device-map-marker.is-lost {
+ .device-map-marker__pin {
+ background: rgba($color-danger-rgb, 0.16);
+ }
+
+ .device-map-marker__inner {
+ background: $red;
+ box-shadow: 0 8px 18px rgba($color-danger-rgb, 0.28);
+ }
}
.device-map-popup {
@@ -75,13 +108,13 @@
gap: 3px;
b {
- color: #151a24;
+ color: $color-text-heading;
font-size: 13px;
font-weight: 700;
}
span {
- color: #738098;
+ color: $color-text-muted;
font-size: 12px;
font-weight: 500;
}
@@ -95,6 +128,179 @@
border-radius: 24px;
}
+.device-map-info-card {
+ position: absolute;
+ left: 12px;
+ top: 70px;
+ z-index: 430;
+ //transform: translateY(-50%);
+
+ width: min(180px, calc(100% - 24px));
+ padding: 14px;
+ border: 1px solid rgba($color-border-rgb, 0.72);
+ border-radius: 16px;
+ background: rgba($color-surface-rgb, 0.94);
+ backdrop-filter: blur(14px);
+ box-shadow: $shadow-popover;
+}
+
+.device-map-info-card__close {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+
+ width: 26px;
+ height: 26px;
+ border: none;
+ border-radius: 8px;
+ background: $color-surface-muted;
+
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+
+ color: $color-text-muted;
+ cursor: pointer;
+ transition:
+ background 0.18s ease,
+ color 0.18s ease;
+
+ &:hover {
+ background: $color-surface-hover;
+ color: $color-primary;
+ }
+}
+
+.device-map-info-card__header {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding-right: 28px;
+ margin-bottom: 12px;
+ line-height: 1;
+ text-align: left;
+
+ b,
+ span {
+ display: block;
+ }
+
+ b {
+ overflow: hidden;
+ color: $color-text-heading;
+ font-size: $font-size-15;
+ font-weight: 600;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ span {
+ margin-top: 3px;
+ overflow: hidden;
+ color: $color-text-muted;
+ font-size: $font-size-12;
+ font-weight: 600;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+}
+
+.device-map-info-card__icon {
+ width: 34px;
+ height: 34px;
+ border-radius: 10px;
+ background: $color-primary-tint;
+
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+
+ color: $color-primary;
+}
+
+.device-map-info-card__rows {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.device-map-info-card__row {
+ display: grid;
+ grid-template-columns: 18px minmax(0, 1fr);
+ align-items: center;
+ justify-content: flex-start;
+ gap: 8px;
+
+ color: $color-text;
+ font-size: $font-size-12;
+ font-weight: 500;
+
+ svg {
+ color: $color-primary;
+ }
+
+ span {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ text-align: left ;
+ }
+}
+
+.device-map-info-card__status {
+ grid-column: 1 / -1;
+ min-height: 22px;
+ width: fit-content;
+ padding: 0 8px;
+ border-radius: 999px;
+
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+
+ color: $color-text-heading;
+ font-size: $font-size-11;
+ font-weight: 700;
+ text-transform: uppercase;
+
+ &::before {
+ content: '';
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: currentColor;
+ }
+
+ &.is-online {
+ background: rgba($color-success-rgb, 0.14);
+ color: $green;
+ }
+
+ &.is-offline {
+ background: $color-bg;
+ color: $gray50;
+ }
+
+ &.is-lost {
+ background: rgba($color-danger-rgb, 0.14);
+ color: $red;
+ }
+}
+
+.device-map-info-card__footer {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ margin-top: 12px;
+ padding-top: 10px;
+ border-top: 1px solid rgba($color-border-rgb, 0.72);
+
+ color: $color-text-muted;
+ font-size: $font-size-11;
+ font-weight: 600;
+}
+
.device-map--modal {
padding: 0 !important;
box-shadow: none;
diff --git a/mdm-front/src/pages/DevicePage/components/DeviceMapCard/DeviceMapCard.tsx b/mdm-front/src/pages/DevicePage/components/DeviceMapCard/DeviceMapCard.tsx
index 4afb4d1..f9d274c 100644
--- a/mdm-front/src/pages/DevicePage/components/DeviceMapCard/DeviceMapCard.tsx
+++ b/mdm-front/src/pages/DevicePage/components/DeviceMapCard/DeviceMapCard.tsx
@@ -1,23 +1,21 @@
-import { useEffect, useMemo, useState } from 'react'
+import { useEffect, useMemo, useRef, useState } from 'react'
import { useQuery } from '@apollo/client/react'
import {
CircleMarker,
MapContainer,
Marker,
Polyline,
- Popup,
TileLayer,
useMap,
} from 'react-leaflet'
import L from 'leaflet'
-import { Map } from 'lucide-react'
+import { Battery, Clock3, Map as MapIcon, MapPin, Thermometer, X } from 'lucide-react'
import type { Device } from '../../types'
-import {
- GET_PHONE_GPS_TRACK_QUERY,
-} from '../../../../entities/device/api/device.graphql'
+import { GET_PHONE_GPS_TRACK_QUERY } from '../../../../entities/device/api/device.graphql'
import type {
DeviceGpsTrackPoint,
+ DeviceTelemetryItem,
GetPhoneGpsTrackData,
GetPhoneGpsTrackVariables,
} from '../../../../entities/device/model/types'
@@ -34,24 +32,63 @@ type DeviceMapCardProps = {
variant?: 'card' | 'modal'
}
+type TelemetryLine = Array<[number, number]>
+
+type DeviceMapPoint = {
+ key: string
+ kind: 'current' | 'route'
+ title: string
+ subtitle?: string
+ date?: number
+ lat: number
+ lng: number
+}
+
+type MapFitBoundsProps = {
+ positions: [number, number][]
+ fitKey: string
+}
+
+const routeColor = 'var(--color-primary)'
+
const defaultLocation = {
lat: 54.7558,
lng: 87.1099,
}
-const deviceMarkerIcon = L.divIcon({
- className: 'device-map-marker',
- html: `
-
- `,
- iconSize: [34, 34],
- iconAnchor: [17, 17],
- popupAnchor: [0, -18],
-})
+const markerIconCache = new Map()
-function formatLocationDate(timestamp: number) {
+function getConnectionStatusClass(connection: Device['connection']) {
+ if (connection === 'online') return 'is-online'
+ if (connection === 'offlineDanger') return 'is-lost'
+
+ return 'is-offline'
+}
+
+function createDeviceMarkerIcon(connection: Device['connection']) {
+ const className = getConnectionStatusClass(connection)
+ const cachedIcon = markerIconCache.get(className)
+
+ if (cachedIcon) return cachedIcon
+
+ const icon = L.divIcon({
+ className: `device-map-marker ${className}`,
+ html: `
+
+ `,
+ iconSize: [34, 34],
+ iconAnchor: [17, 17],
+ popupAnchor: [0, -18],
+ })
+
+ markerIconCache.set(className, icon)
+
+ return icon
+}
+
+function formatLocationDate(timestamp?: number) {
if (!timestamp) return 'Нет данных'
return new Intl.DateTimeFormat('ru-RU', {
@@ -63,11 +100,79 @@ function formatLocationDate(timestamp: number) {
}).format(new Date(timestamp))
}
+function formatCoordinate(value: number) {
+ return value.toFixed(6)
+}
+
+function formatMetric(value: number | null, suffix: string) {
+ if (value === null) return '-'
+
+ return `${value}${suffix}`
+}
+
function sortGpsTrack(track: DeviceGpsTrackPoint[]) {
return [...track].sort((a, b) => a.date - b.date)
}
-function isDateInPeriod(date: number | undefined, period: MapTrackPeriodValue | null) {
+function sortTelemetry(telemetry: DeviceTelemetryItem[]) {
+ return [...telemetry].sort((a, b) => a.date - b.date)
+}
+
+function createTelemetryLine(
+ telemetry: DeviceTelemetryItem[],
+ getValue: (item: DeviceTelemetryItem) => number | null,
+): TelemetryLine {
+ return telemetry
+ .map((item) => [item.date, getValue(item)] as const)
+ .filter((item): item is [number, number] => typeof item[1] === 'number')
+}
+
+function getLineValueAtTime(data: TelemetryLine, timestamp?: number) {
+ if (!timestamp || !data.length) return null
+
+ const firstPoint = data[0]
+ const lastPoint = data[data.length - 1]
+
+ if (timestamp <= firstPoint[0]) return firstPoint[1]
+ if (timestamp >= lastPoint[0]) return lastPoint[1]
+
+ let left = 0
+ let right = data.length - 1
+
+ while (right - left > 1) {
+ const middle = Math.floor((left + right) / 2)
+
+ if (data[middle][0] <= timestamp) {
+ left = middle
+ } else {
+ right = middle
+ }
+ }
+
+ const currentPoint = data[left]
+ const nextPoint = data[right]
+ const currentTime = currentPoint[0]
+ const currentValue = currentPoint[1]
+ const nextTime = nextPoint[0]
+ const nextValue = nextPoint[1]
+
+ if (nextTime === currentTime) return Math.round(currentValue)
+
+ const progress = (timestamp - currentTime) / (nextTime - currentTime)
+ const interpolatedValue =
+ currentValue + (nextValue - currentValue) * progress
+
+ return Math.round(interpolatedValue)
+}
+
+function createPointKey(kind: DeviceMapPoint['kind'], lat: number, lng: number, date?: number) {
+ return `${kind}-${date ?? 'no-date'}-${lat}-${lng}`
+}
+
+function isDateInPeriod(
+ date: number | undefined,
+ period: MapTrackPeriodValue | null,
+) {
if (!date || !period) return false
return (
@@ -90,15 +195,14 @@ function MapResizeWatcher() {
return null
}
-type MapFitBoundsProps = {
- positions: [number, number][]
-}
-
-function MapFitBounds({ positions }: MapFitBoundsProps) {
+function MapFitBounds({ positions, fitKey }: MapFitBoundsProps) {
const map = useMap()
+ const lastFitKeyRef = useRef(null)
useEffect(() => {
- if (!positions.length) return
+ if (!positions.length || lastFitKeyRef.current === fitKey) return
+
+ lastFitKeyRef.current = fitKey
if (positions.length === 1) {
map.setView(positions[0], 14)
@@ -112,14 +216,23 @@ function MapFitBounds({ positions }: MapFitBoundsProps) {
maxZoom: 15,
animate: true,
})
- }, [map, positions])
+ }, [fitKey, map, positions])
return null
}
-export function DeviceMapCard({ device, variant = 'card', }: DeviceMapCardProps) {
+export function DeviceMapCard({
+ device,
+ variant = 'card',
+}: DeviceMapCardProps) {
const [selectedPeriod, setSelectedPeriod] =
useState(null)
+ const [selectedPointKey, setSelectedPointKey] = useState(null)
+ const networkStatusClass = getConnectionStatusClass(device.connection)
+ const deviceMarkerIcon = useMemo(
+ () => createDeviceMarkerIcon(device.connection),
+ [device.connection],
+ )
const { data: gpsTrackData } = useQuery<
GetPhoneGpsTrackData,
@@ -139,6 +252,19 @@ export function DeviceMapCard({ device, variant = 'card', }: DeviceMapCardProps)
return sortGpsTrack(gpsTrackData?.getPhoneGpsTrack ?? [])
}, [gpsTrackData])
+ const telemetry = useMemo(() => {
+ return sortTelemetry(device.telemetry ?? [])
+ }, [device.telemetry])
+
+ const telemetryLines = useMemo(() => {
+ return {
+ battery: createTelemetryLine(telemetry, (item) => item.batteryLevel),
+ temperature: createTelemetryLine(telemetry, (item) => item.temperature),
+ capacity: createTelemetryLine(telemetry, (item) => item.batteryCapacity),
+ cycles: createTelemetryLine(telemetry, (item) => item.batteryCycles),
+ }
+ }, [telemetry])
+
const shouldShowCurrentLocation = isDateInPeriod(
device.lastLocationDate,
selectedPeriod,
@@ -147,10 +273,10 @@ export function DeviceMapCard({ device, variant = 'card', }: DeviceMapCardProps)
const selectedCurrentLocation =
shouldShowCurrentLocation && device.location
? {
- lat: device.location.lat,
- lng: device.location.lng,
- time: device.lastLocationAt,
- }
+ lat: device.location.lat,
+ lng: device.location.lng,
+ date: device.lastLocationDate,
+ }
: null
const selectedRoutePoints = useMemo(() => {
@@ -187,7 +313,81 @@ export function DeviceMapCard({ device, variant = 'card', }: DeviceMapCardProps)
return [location.lat, location.lng]
}, [location.lat, location.lng])
- const fitPositions = routePositions.length > 0 ? routePositions : [currentPosition]
+ const fitPositions =
+ routePositions.length > 0 ? routePositions : [currentPosition]
+
+ const fitKey = useMemo(() => {
+ if (!selectedPeriod) {
+ return `${device.id}-${device.location ? 'located' : 'fallback'}`
+ }
+
+ return [
+ device.id,
+ selectedPeriod.periodStart.getTime(),
+ selectedPeriod.periodEnd.getTime(),
+ routePositions.length > 0 ? 'route' : 'point',
+ ].join('-')
+ }, [device.id, device.location, routePositions.length, selectedPeriod])
+
+ const routeMapPoints = useMemo(() => {
+ return selectedRoutePoints.map((point) => ({
+ key: createPointKey('route', point.lat, point.lng, point.date),
+ kind: 'route',
+ title: 'Точка маршрута',
+ date: point.date,
+ lat: point.lat,
+ lng: point.lng,
+ }))
+ }, [selectedRoutePoints])
+
+ const currentMapPoint = useMemo(() => {
+ if (!selectedCurrentLocation) return null
+
+ return {
+ key: createPointKey(
+ 'current',
+ selectedCurrentLocation.lat,
+ selectedCurrentLocation.lng,
+ selectedCurrentLocation.date,
+ ),
+ kind: 'current',
+ title: device.model ?? 'Устройство',
+ subtitle: device.factoryNumber,
+ date: selectedCurrentLocation.date,
+ lat: selectedCurrentLocation.lat,
+ lng: selectedCurrentLocation.lng,
+ }
+ }, [device.factoryNumber, device.model, selectedCurrentLocation])
+
+ const selectedMapPoint = useMemo(() => {
+ if (!selectedPointKey) return null
+
+ if (currentMapPoint?.key === selectedPointKey) {
+ return currentMapPoint
+ }
+
+ return (
+ routeMapPoints.find((point) => point.key === selectedPointKey) ?? null
+ )
+ }, [currentMapPoint, routeMapPoints, selectedPointKey])
+
+ useEffect(() => {
+ if (selectedPointKey && !selectedMapPoint) {
+ setSelectedPointKey(null)
+ }
+ }, [selectedMapPoint, selectedPointKey])
+
+ const selectedTelemetry = useMemo(() => {
+ return {
+ battery: getLineValueAtTime(telemetryLines.battery, selectedMapPoint?.date),
+ temperature: getLineValueAtTime(
+ telemetryLines.temperature,
+ selectedMapPoint?.date,
+ ),
+ capacity: getLineValueAtTime(telemetryLines.capacity, selectedMapPoint?.date),
+ cycles: getLineValueAtTime(telemetryLines.cycles, selectedMapPoint?.date),
+ }
+ }, [selectedMapPoint, telemetryLines])
return (
-
+
Устройство на карте
@@ -214,6 +414,63 @@ export function DeviceMapCard({ device, variant = 'card', }: DeviceMapCardProps)
+ {selectedMapPoint && (
+
+
setSelectedPointKey(null)}
+ >
+
+
+
+
+ {selectedMapPoint.title}
+
+ {selectedMapPoint.subtitle ?? device.factoryNumber}
+
+
+
+
+
+
+ {device.connectionText}
+
+
+
+
+
+ {formatLocationDate(selectedMapPoint.date)}
+
+
+
+
+
+ Заряд АКБ: {formatMetric(selectedTelemetry.battery, '%')}
+
+
+
+
+
+
+ Температура: {formatMetric(selectedTelemetry.temperature, '°C')}
+
+
+
+
+
+
+ {formatCoordinate(selectedMapPoint.lat)},{' '}
+ {formatCoordinate(selectedMapPoint.lng)}
+
+
+
+
+ )}
+
-
+
@@ -231,45 +488,44 @@ export function DeviceMapCard({ device, variant = 'card', }: DeviceMapCardProps)
)}
- {selectedRoutePoints.map((point, index) => (
+ {routeMapPoints.map((point) => (
-
-
- Точка маршрута
- {formatLocationDate(point.date)}
-
-
-
+ eventHandlers={{
+ click: (event) => {
+ event.originalEvent.stopPropagation()
+ setSelectedPointKey(point.key)
+ },
+ }}
+ />
))}
- {selectedCurrentLocation && (
-
-
-
- {device.model ?? 'Устройство'}
- {device.factoryNumber}
- {device.imei}
- {device.lastLocationAt ?? 'Время не указано'}
-
-
-
+ {currentMapPoint && (
+ {
+ event.originalEvent.stopPropagation()
+ setSelectedPointKey(currentMapPoint.key)
+ },
+ }}
+ />
)}
@@ -277,4 +533,4 @@ export function DeviceMapCard({ device, variant = 'card', }: DeviceMapCardProps)
)
-}
\ No newline at end of file
+}
diff --git a/mdm-front/src/pages/DevicePage/components/DevicePageSkeleton/DevicePageSkeleton.scss b/mdm-front/src/pages/DevicePage/components/DevicePageSkeleton/DevicePageSkeleton.scss
new file mode 100644
index 0000000..00b6a75
--- /dev/null
+++ b/mdm-front/src/pages/DevicePage/components/DevicePageSkeleton/DevicePageSkeleton.scss
@@ -0,0 +1,243 @@
+@use '../../../../shared/styles/variables' as *;
+
+.device-page-skeleton {
+ position: relative;
+ display: block;
+ overflow: hidden;
+ flex: 0 0 auto;
+ border-radius: 12px;
+ background: $color-skeleton;
+
+ &::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ transform: translateX(-100%);
+ background: linear-gradient(90deg,
+ $transparent,
+ $color-skeleton-highlight,
+ $transparent);
+ animation: device-page-skeleton-shimmer 1.35s ease-in-out infinite;
+ }
+}
+
+.device-page-skeleton--text {
+ height: 1em;
+ min-width: 32px;
+ border-radius: 999px;
+}
+
+.device-page-skeleton--icon {
+ width: 32px;
+ height: 32px;
+ border-radius: 9px;
+}
+
+.device-page-skeleton--button {
+ width: 100%;
+ height: 44px;
+ border-radius: 12px;
+}
+
+.device-page-skeleton--square-button {
+ width: 42px;
+ height: 42px;
+ border-radius: 12px;
+}
+
+.device-page-skeleton--mini-button {
+ width: 34px;
+ height: 34px;
+ border-radius: 10px;
+}
+
+.device-page-skeleton--switch {
+ width: 48px;
+ height: 26px;
+ border-radius: 999px;
+}
+
+.device-page-skeleton--battery-circle {
+ width: 120px;
+ height: 120px;
+ border-radius: 50%;
+}
+
+.device-page-loading__grid {
+ align-items: stretch;
+}
+
+.device-page-loading__main-card,
+.device-page-loading__map-card,
+.device-page-loading__actions-card,
+.device-page-loading__permissions-card,
+.device-page-loading__battery-card,
+.device-page-loading__impacts-card {
+ pointer-events: none;
+}
+
+.device-page-loading__device-image {
+ min-height: 220px;
+}
+
+.device-page-loading__phone {
+ width: 42%;
+ min-width: 92px;
+ max-width: 138px;
+ height: 100%;
+ min-height: 180px;
+ border-radius: 28px;
+}
+
+.device-page-loading__main-info {
+ padding-top: 2px;
+
+ .device-page-skeleton--text {
+ height: 17px;
+ }
+}
+
+.device-page-loading__title {
+ height: 30px !important;
+ margin-bottom: 2px;
+}
+
+.device-page-loading__status-row {
+ .device-page-skeleton--text {
+ height: 18px;
+ }
+}
+
+.device-page-loading__registered {
+ .device-registered-info {
+ min-height: 44px;
+ }
+}
+
+.device-page-loading__history-button {
+ max-width: 286px;
+}
+
+.device-page-loading__map-card {
+ min-height: 420px;
+}
+
+.device-page-loading__map-header {
+ align-items: flex-start;
+
+ .device-card__title {
+ flex: 0 0 auto;
+ }
+
+ >.device-page-skeleton--text {
+ height: 15px;
+ margin-top: 7px;
+ }
+}
+
+.device-page-loading__map-container {
+ position: relative;
+ display: flex;
+ flex: 1;
+ min-height: 332px;
+ border-radius: 16px;
+}
+
+.device-page-loading__period-control {
+ position: absolute;
+ z-index: 1;
+ top: 12px;
+ left: 12px;
+ right: 12px;
+
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 8px;
+
+ .device-page-skeleton--button {
+ height: 34px;
+ border-radius: 10px;
+ background: rgba($white, 0.86);
+ }
+}
+
+.device-page-loading__map {
+ width: 100%;
+ min-height: 332px;
+ border-radius: 16px;
+ background:
+ linear-gradient(135deg, rgba($color-primary-rgb, 0.05), $transparent 48%),
+ $color-skeleton;
+}
+
+.device-page-loading__actions-card {
+ min-height: 174px;
+
+ .device-page-loading__card-title {
+ height: 18px;
+ margin-bottom: 12px;
+ padding-bottom: 12px;
+ }
+
+ .actions-button-container {
+ gap: 8px;
+ }
+}
+
+.device-page-loading__permissions-card {
+ min-height: 236px;
+}
+
+.device-page-loading__permissions-header {
+ margin-bottom: 12px;
+
+ .device-permissions__header-actions {
+ display: flex;
+ gap: 8px;
+ }
+}
+
+.device-page-loading__card-title {
+ height: 18px;
+}
+
+.device-page-loading__permission {
+ .device-permission__label {
+ min-height: 43px;
+ }
+}
+
+.device-page-loading__stats-column {
+ align-self: stretch;
+}
+
+.device-page-loading__battery-card {
+ min-height: 194px;
+
+ >.device-page-loading__card-title {
+ margin-bottom: 12px;
+ padding-bottom: 12px;
+ }
+}
+
+.device-page-loading__stat {
+ .device-stats-text {
+ min-height: 30px;
+ }
+}
+
+.device-page-loading__impacts-card {
+ min-height: 34px;
+}
+
+@keyframes device-page-skeleton-shimmer {
+ to {
+ transform: translateX(100%);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .device-page-skeleton::after {
+ animation: none;
+ }
+}
diff --git a/mdm-front/src/pages/DevicePage/components/DevicePageSkeleton/DevicePageSkeleton.tsx b/mdm-front/src/pages/DevicePage/components/DevicePageSkeleton/DevicePageSkeleton.tsx
new file mode 100644
index 0000000..97f3f46
--- /dev/null
+++ b/mdm-front/src/pages/DevicePage/components/DevicePageSkeleton/DevicePageSkeleton.tsx
@@ -0,0 +1,184 @@
+import type { CSSProperties } from 'react'
+
+import './DevicePageSkeleton.scss'
+
+type SkeletonProps = {
+ className?: string
+ width?: CSSProperties['width']
+ height?: CSSProperties['height']
+}
+
+function Skeleton({ className = '', width, height }: SkeletonProps) {
+ return (
+
+ )
+}
+
+function SkeletonText({
+ className = '',
+ width = '100%',
+}: Omit
) {
+ return (
+
+ )
+}
+
+function SkeletonIcon({ className = '' }: Pick) {
+ return
+}
+
+function SkeletonButton({ className = '' }: Pick) {
+ return
+}
+
+export function DevicePageSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: 4 }).map((_, index) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: 3 }).map((_, index) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/mdm-front/src/pages/DevicePage/components/DevicePermissionsCard/DevicePermissionsCard.scss b/mdm-front/src/pages/DevicePage/components/DevicePermissionsCard/DevicePermissionsCard.scss
index 1066ad5..1090b45 100644
--- a/mdm-front/src/pages/DevicePage/components/DevicePermissionsCard/DevicePermissionsCard.scss
+++ b/mdm-front/src/pages/DevicePage/components/DevicePermissionsCard/DevicePermissionsCard.scss
@@ -1,21 +1,358 @@
@use '../../../../shared/styles/variables' as *;
+.device-permissions {
+ justify-content: space-between;
+}
+
+.device-permissions__header {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 12px;
+ padding-bottom: 4px;
+ border-bottom: 1px solid $gray20;
+
+ h3 {
+ line-height: 1;
+ margin: 0;
+ text-align: left;
+ color: $color-text-heading;
+ font-size: $font-size-18;
+ font-weight: 600;
+ }
+}
+
+.device-permissions__title {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+}
+
+.device-permissions__help {
+ position: relative;
+ width: 18px;
+ height: 18px;
+
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+
+ color: $color-text-muted;
+ cursor: help;
+ outline: none;
+ transition: color 0.18s ease;
+
+ svg {
+ width: 16px;
+ height: 16px;
+ stroke-width: 2.2;
+ transition: .2s ease;
+ }
+
+ &:hover,
+ &:focus-visible {
+ color: $blue;
+ }
+
+ &:hover .device-permissions__help-tooltip,
+ &:focus-visible .device-permissions__help-tooltip {
+ opacity: 1;
+ visibility: visible;
+ transform: translateY(8px);
+ }
+}
+
+.device-permissions__help-tooltip {
+ position: absolute;
+ z-index: 30;
+ top: 100%;
+ left: -34px;
+
+ width: 400px;
+ padding: 11px;
+ border-radius: 12px;
+ //background: rgba($color-surface-rgb, 0.94);
+ backdrop-filter: blur(18px);
+ box-shadow:
+ 0 14px 30px rgba($color-shadow-rgb, 0.16),
+ inset 0 0 0 1px rgba($color-primary-rgb, 0.08);
+
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+
+ opacity: 0;
+ visibility: hidden;
+ pointer-events: none;
+ transform: translateY(2px);
+ transition:
+ opacity 0.18s ease,
+ visibility 0.18s ease,
+ transform 0.18s ease;
+
+ &::before {
+ content: '';
+ position: absolute;
+ left: 38px;
+ bottom: 100%;
+ width: 9px;
+ height: 9px;
+ background: rgba($color-surface-rgb, 0.94);
+ box-shadow: -1px -1px 0 rgba($color-primary-rgb, 0.08);
+ transform: translateY(5px) rotate(45deg);
+ }
+}
+
+.device-permissions__help-row {
+ display: grid;
+ grid-template-columns: 22px 1fr;
+ gap: 9px;
+ align-items: center;
+
+ >svg {
+ margin-top: 1px;
+ width: 20px;
+ height: 20px;
+ color: $blue;
+ }
+
+ span {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ text-align: left;
+ gap: 2px;
+ }
+
+ b {
+ color: $color-text-heading;
+ font-size: $font-size-14;
+ font-weight: 700;
+ line-height: 1.2;
+ }
+
+ small {
+ color: $color-text-muted;
+ font-size: $font-size-13;
+ font-weight: 500;
+ line-height: 1.28;
+ }
+}
+
+.device-permissions__header-actions {
+ position: relative;
+ width: 82px;
+ height: 36px;
+ flex: 0 0 82px;
+}
+
+.device-permissions__icon-btn {
+ position: absolute;
+ top: 0;
+ right: 0;
+
+ width: 34px;
+ height: 34px;
+ padding: 0;
+ border: none;
+ border-radius: 12px;
+ background: $color-bg;
+
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+
+ color: $gray50;
+ cursor: pointer;
+ transition:
+ background-color 0.18s ease,
+ color 0.18s ease,
+ opacity 0.18s ease,
+ transform 0.18s ease,
+ box-shadow 0.18s ease;
+
+ &:hover:not(:disabled) {
+ background: $gray20;
+ color: $color-text;
+ //transform: scale(1.05);
+ }
+
+ &:disabled {
+ opacity: 0.45;
+ cursor: default;
+ }
+}
+
+.device-permissions__icon-btn--edit {
+ z-index: 2;
+ background: $blue20;
+ color: $blue;
+
+ &:hover:not(:disabled) {
+ background: $blue;
+ color: $color-text-inverse;
+ box-shadow: 0 8px 20px rgba($color-primary-rgb, 0.18);
+ }
+}
+
+.device-permissions__icon-btn--reset {
+ z-index: 1;
+ opacity: 0;
+ pointer-events: none;
+ transform: translateX(0) scale(0.92);
+ transition:
+ background-color 0.18s ease,
+ color 0.18s ease,
+ opacity 0.18s ease,
+ transform 0.28s cubic-bezier(0.34, 1.42, 0.64, 1);
+}
+
+.device-permissions.is-editing {
+ .device-permissions__icon-btn--reset {
+ opacity: 1;
+ pointer-events: auto;
+ transform: translateX(-46px) scale(1);
+
+ &:disabled {
+ opacity: 0.45;
+ pointer-events: none;
+ }
+ }
+}
+
+.device-permissions:not(.is-policy-editing) {
+ .device-permission {
+ opacity: 0.72;
+ }
+
+ .device-permission__switch {
+ cursor: not-allowed;
+ }
+}
+
+.device-permissions__list {
+ display: flex;
+ flex-direction: column;
+}
+
+.device-permission__controls {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ flex: 0 0 auto;
+}
+
+.device-permission__policy-lock {
+ position: relative;
+ z-index: 3;
+ width: 34px;
+ height: 34px;
+ flex: 0 0 34px;
+ border-radius: 9px;
+ background: $color-surface-hover;
+
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+
+ color: $color-text-muted;
+ cursor: help;
+ outline: none;
+ transition:
+ background-color 0.18s ease,
+ color 0.18s ease,
+ transform 0.18s ease;
+
+ svg {
+ width: 18px !important;
+ height: 18px !important;
+ padding: 0 !important;
+ border-radius: 0 !important;
+ background: $transparent !important;
+ color: currentColor;
+ stroke-width: 2.35;
+ }
+
+ &:hover,
+ &:focus-visible {
+ z-index: 120;
+ color: $blue;
+ background: $blue20;
+ transform: translateY(-1px);
+ }
+
+ &:hover .device-permission__policy-lock-tooltip,
+ &:focus-visible .device-permission__policy-lock-tooltip {
+ opacity: 1;
+ visibility: visible;
+ transform: translate(-50%, -8px);
+ }
+}
+
+.device-permission__policy-lock-tooltip {
+ position: absolute;
+ z-index: 110;
+ left: 50%;
+ bottom: calc(100% + 4px);
+
+ width: 260px;
+ padding: 9px 11px;
+ border-radius: 10px;
+ //background: rgba($color-surface-rgb, 0.94);
+ backdrop-filter: blur(18px);
+ box-shadow:
+ 0 12px 28px rgba($color-shadow-rgb, 0.14),
+ inset 0 0 0 1px rgba($color-primary-rgb, 0.08);
+
+ color: $color-text-heading;
+ font-size: $font-size-13;
+ font-weight: 500;
+ line-height: 1.32;
+ text-align: left;
+
+ opacity: 0;
+ visibility: hidden;
+ pointer-events: none;
+ transform: translate(-50%, -2px);
+ transition:
+ opacity 0.18s ease,
+ visibility 0.18s ease,
+ transform 0.18s ease;
+
+ &::after {
+ content: '';
+ position: absolute;
+ top: 100%;
+ left: 50%;
+ width: 9px;
+ height: 9px;
+ background: rgba($color-surface-rgb, 0.94);
+ box-shadow: 1px 1px 0 rgba($color-primary-rgb, 0.08);
+ transform: translate(-50%, -5px) rotate(45deg);
+ }
+}
+
.device-permission__switch {
position: relative;
- width: 48px;
- height: 24px;
- flex: 0 0 48px;
- padding: 0;
+ width: 118px;
+ height: 28px;
+ flex: 0 0 118px;
+ padding: 3px;
border: none;
outline: none;
- display: inline-flex;
- align-items: center;
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ align-items: stretch;
border-radius: 999px;
- background: $gray20;
- box-shadow: inset 0 0 0 1px rgba($gray50, 0.08);
+ background: $color-bg;
+ box-shadow: inset 0 0 0 1px rgba($gray50-rgb, 0.08);
cursor: pointer;
user-select: none;
@@ -25,19 +362,29 @@
box-shadow 0.28s ease,
transform 0.18s ease;
- > span {
+ &--component {
+ width: 78px;
+ flex-basis: 78px;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+
+ .device-permission__switch-thumb {
+ width: calc((100% - 6px) / 2) !important;
+ }
+ }
+
+ .device-permission__switch-thumb {
position: absolute;
top: 3px;
left: 3px;
- width: 26px;
- height: 18px;
+ width: calc((100% - 6px) / 3);
+ height: calc(100% - 6px);
border-radius: 100px;
- background: #ffffff;
+ background: $color-surface;
box-shadow:
- 0 2px 5px rgba(15, 23, 42, 0.18),
- 0 1px 1px rgba(15, 23, 42, 0.08);
+ 0 2px 5px rgba($color-shadow-rgb, 0.18),
+ 0 1px 1px rgba($color-shadow-rgb, 0.08);
transition:
transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1),
@@ -45,40 +392,124 @@
width 0.18s ease;
}
- &.is-enabled {
- background: $blue;
- box-shadow: inset 0 0 0 1px rgba($blue, 0.12);
+ .device-permission__switch-option {
+ position: relative;
+ z-index: 1;
- > span {
- transform: translateX(16px);
+ min-width: 0;
+ padding: 0;
+ border: none;
+ background: $transparent;
+
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+
+ color: $gray50;
+ cursor: pointer;
+ outline: none;
+ -webkit-tap-highlight-color: $transparent;
+
+ &:focus-visible {
+ border-radius: 999px;
+ //box-shadow: 0 0 0 2px rgba($color-primary-rgb, 0.22);
+ }
+ }
+
+ .device-permission__switch-option[aria-disabled='true'] {
+ cursor: not-allowed;
+ }
+
+ .device-permission__switch-label {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+
+ transition:
+ color 0.2s ease,
+ opacity 0.2s ease;
+
+ svg {
+ width: 14px;
+ height: 14px;
+ stroke-width: 2.35;
+ background-color: transparent;
+ }
+ }
+
+ &.is-unspecified {
+ box-shadow: inset 0 0 0 1px rgba($color-primary-rgb, 0.1);
+
+ .device-permission__switch-thumb {
+ transform: translateX(100%);
+ background: $blue20;
box-shadow:
- 0 3px 7px rgba(3, 29, 154, 0.24),
- 0 1px 1px rgba(15, 23, 42, 0.08);
+ 0 3px 7px -4px rgba($color-primary-rgb),
+ 0 1px 1px rgba($color-shadow-rgb, 0.08);
+ }
+
+ .device-permission__switch-option.is-active .device-permission__switch-label {
+ color: $blue;
}
}
- &:hover {
- box-shadow:
- inset 0 0 0 1px rgba($gray50, 0.12),
- 0 4px 10px rgba(15, 23, 42, 0.06);
+ &.is-enabled,
+ &.is-allowed {
+ background: rgba($color-primary-rgb, 0.1);
+ box-shadow: inset 0 0 0 1px rgba($color-primary-rgb, 0.12);
+
+ .device-permission__switch-thumb {
+ background: $blue;
+ box-shadow:
+ 0 3px 7px rgba($color-primary-rgb, 0.24),
+ 0 1px 1px rgba($color-shadow-rgb, 0.08);
+ }
+
+ .device-permission__switch-option.is-active .device-permission__switch-label {
+ color: $color-text-inverse;
+ svg{
+ color: $white;
+ }
+ }
}
- &:active {
+ &.is-enabled {
+ .device-permission__switch-thumb {
+ transform: translateX(200%);
+ }
+ }
+
+ &.is-allowed {
+ .device-permission__switch-thumb {
+ transform: translateX(100%);
+ }
+ }
+
+ &.is-disabled,
+ &.is-disallowed {
+ .device-permission__switch-option.is-active .device-permission__switch-label {
+ color: $color-text;
+ }
+ }
+
+ &:hover:not(.is-disabled) {
+ //box-shadow:
+ //inset 0 0 0 1px rgba($gray50-rgb, 0.12),
+ //0 4px 10px rgba($color-shadow-rgb, 0.06);
+ }
+
+ &:active:not(.is-disabled) {
transform: scale(0.96);
-
- > span {
- width: 23px;
- }
}
- &.is-enabled:active > span {
- transform: translateX(17px);
- }
-
- &:focus-visible {
+ &:focus-within {
box-shadow:
- 0 0 0 3px rgba($blue, 0.18),
- inset 0 0 0 1px rgba($blue, 0.18);
+ //0 0 0 3px rgba($color-primary-rgb, 0.18),
+ inset 0 0 0 1px rgba($color-primary-rgb, 0.18);
+ }
+
+ &.is-disabled {
+ cursor: not-allowed;
}
}
@@ -87,19 +518,91 @@
opacity 0.2s ease,
filter 0.2s ease;
- &.is-disabled {
- opacity: 0.45;
+ &.is-policy-locked {
cursor: not-allowed;
- svg,
- span,
- button {
- pointer-events: none;
+ >svg,
+ .device-permission__label>span:first-child,
+ .device-permission__switch {
+ opacity: 0.52;
}
.device-permission__switch {
cursor: not-allowed;
- box-shadow: inset 0 0 0 1px rgba($gray50, 0.08);
+ box-shadow: inset 0 0 0 1px rgba($gray50-rgb, 0.08);
}
}
-}
\ No newline at end of file
+}
+
+.device-permissions:not(.is-policy-editing) {
+ .device-permission.is-policy-locked {
+ opacity: 1;
+ }
+}
+
+.device-permissions__error {
+ margin-top: 10px;
+ padding: 6px 8px;
+ border-radius: 9px;
+ background: rgba($color-danger-rgb, 0.1);
+
+ color: $red;
+ font-size: $font-size-13;
+ font-weight: 600;
+ line-height: 1.25;
+ text-align: left;
+}
+
+[data-theme='dark'] {
+ .device-permission__switch:not(.is-enabled):not(.is-allowed):not(.is-unspecified) {
+ svg {
+ color: $blue !important;
+ stroke-width: 3px;
+
+ path {
+ box-shadow: 0 0 1px 0px $blue;
+ }
+ }
+
+ .device-permission__switch-thumb {
+ background: $white;
+ }
+ }
+
+ .device-permission__switch {
+
+ &.is-disabled,
+ &.is-disallowed {
+ .device-permission__switch-option.is-active .device-permission__switch-label {
+ color: $neutral-900;
+ }
+ }
+
+ &:hover:not(.is-disabled) {
+ //box-shadow:
+ //inset 0 0 0 1px rgba($gray50-rgb, 0.16),
+ //0 4px 10px rgba($color-shadow-rgb, 0.08);
+ }
+ }
+
+ .device-permissions__icon-btn--reset {
+ background: $color-surface-hover;
+ color: $color-text-muted;
+ }
+
+ .device-permissions__icon-btn:not(.device-permissions__icon-btn--edit):hover:not(:disabled) {
+ background: $color-surface-hover;
+ color: $color-text-heading;
+ }
+
+ .device-permissions__icon-btn--reset:hover:not(:disabled) {
+ background: $color-surface-active;
+ color: $color-text-heading;
+ }
+
+ .device-permissions__icon-btn--edit:hover:not(:disabled) {
+ background: $blue;
+ color: $color-text-inverse;
+ box-shadow: 0 8px 20px rgba($color-primary-rgb, 0.18);
+ }
+}
diff --git a/mdm-front/src/pages/DevicePage/components/DevicePermissionsCard/DevicePermissionsCard.tsx b/mdm-front/src/pages/DevicePage/components/DevicePermissionsCard/DevicePermissionsCard.tsx
index f48dc15..a7ffd64 100644
--- a/mdm-front/src/pages/DevicePage/components/DevicePermissionsCard/DevicePermissionsCard.tsx
+++ b/mdm-front/src/pages/DevicePage/components/DevicePermissionsCard/DevicePermissionsCard.tsx
@@ -1,5 +1,15 @@
-import { useEffect, useMemo, useState, type ReactNode } from 'react'
+import { memo, useEffect, useMemo, useState, type ReactNode } from 'react'
import { useMutation } from '@apollo/client/react'
+import {
+ Check,
+ CircleHelp,
+ Lock,
+ Pencil,
+ Power,
+ PowerOff,
+ Smartphone,
+ X,
+} from 'lucide-react'
import type { Device } from '../../types'
import {
@@ -8,186 +18,573 @@ import {
import type {
ChangePhonePolicyData,
ChangePhonePolicyVariables,
+ ComponentUseOption,
+ DevicePolicy,
+ GroupUsePolicy,
+ ServiceUseOption,
} from '../../../../entities/device/model/types'
+import {
+ normalizeComponentUseOption,
+ normalizeServiceUseOption,
+} from '../../../../entities/device/lib/phonePolicy'
-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 { GpsIcon } from '../../../../assets/icons/Gps'
import { SimIcon } from '../../../../assets/icons/Sim'
-import { VolumeIcon } from '../../../../assets/icons/Volume'
+import {
+ Notification,
+ type NotificationVariant,
+} from '../../../../widgets/Notification/Notification'
import './DevicePermissionsCard.scss'
type DevicePermissionsCardProps = {
device: Device
+ onEditingChange?: (isEditing: boolean) => void
+ onRefetchPolicy?: () => Promise
}
-type DevicePolicyKey = 'wifi' | 'bluetooth' | 'gps' | 'camera' | 'sim' | 'speaker'
+type DevicePolicyKey = 'bluetooth' | 'gps' | 'camera' | 'sim'
+type ServicePolicyKey = Exclude
+type PolicyKind = 'service' | 'component'
+type PolicyValue = ServiceUseOption | ComponentUseOption
+type OrganisationPolicyEditableKey =
+ | 'bluetoothEditable'
+ | 'gpsEditable'
+ | 'cameraEditable'
+ | 'simEditable'
-type DevicePolicyState = Record
+type DevicePolicyState = {
+ bluetooth: ServiceUseOption
+ gps: ServiceUseOption
+ camera: ComponentUseOption
+ sim: ServiceUseOption
+ locked: boolean
+}
type PermissionItemConfig = {
key: DevicePolicyKey
+ editableKey: OrganisationPolicyEditableKey
+ kind: PolicyKind
icon: ReactNode
label: string
- disabled?: boolean
+}
+
+type PolicyNotification = {
+ id: number
+ variant: NotificationVariant
+ title: string
+ description?: string
+} | null
+
+const SERVICE_POLICY_OPTION_ORDER: ServiceUseOption[] = [
+ 'Disabled',
+ 'Unspecified',
+ 'Enabled',
+]
+const COMPONENT_POLICY_OPTION_ORDER: ComponentUseOption[] = [
+ 'Disallowed',
+ 'Allowed',
+]
+
+const policyOptionFullLabels: Record = {
+ Disabled: 'Выключено',
+ Unspecified: 'Свободно',
+ Enabled: 'Включено',
+ Allowed: 'Включено',
+ Disallowed: 'Выключено',
+}
+
+const policyOptionDescriptions: Record = {
+ Disabled: 'Опция отключена политикой устройства.',
+ Unspecified: 'Политика устройства не фиксирует значение. Можно менять на смартфоне.',
+ Enabled: 'Опция включена политикой устройства.',
+ Allowed: 'Компонент разрешен политикой устройства.',
+ Disallowed: 'Компонент запрещен политикой устройства.',
}
const permissionItems: PermissionItemConfig[] = [
- {
- key: 'wifi',
- icon: ,
- label: 'Wi-Fi',
- disabled: true,
- },
{
key: 'bluetooth',
+ editableKey: 'bluetoothEditable',
+ kind: 'service',
icon: ,
label: 'Bluetooth',
},
{
key: 'gps',
+ editableKey: 'gpsEditable',
+ kind: 'service',
icon: ,
label: 'GPS',
},
{
key: 'camera',
+ editableKey: 'cameraEditable',
+ kind: 'component',
icon: ,
label: 'Камера',
},
{
key: 'sim',
+ editableKey: 'simEditable',
+ kind: 'service',
icon: ,
label: 'SIM-карта',
},
- {
- key: 'speaker',
- icon: ,
- label: 'Динамик',
- disabled: true,
- },
]
+function getServicePolicyKey(key: DevicePolicyKey): ServicePolicyKey | null {
+ return key === 'camera' ? null : key
+}
+
+function getPolicyOptions(kind: PolicyKind): PolicyValue[] {
+ return kind === 'service'
+ ? SERVICE_POLICY_OPTION_ORDER
+ : COMPONENT_POLICY_OPTION_ORDER
+}
+
+function getPolicyOptionIcon(option: PolicyValue, size = 14) {
+ if (option === 'Unspecified') {
+ return
+ }
+
+ if (option === 'Enabled' || option === 'Allowed') {
+ return
+ }
+
+ return
+}
+
+function isOrganisationPolicyLocked(
+ policy: GroupUsePolicy | null | undefined,
+ key: OrganisationPolicyEditableKey,
+) {
+ return policy?.[key] === false
+}
+
function mapDevicePermissionsToState(device: Device): DevicePolicyState {
+ const policy = device.policy
+ const permissions = device.permissions
+
return {
- wifi: device.permissions?.wifi ?? false,
- bluetooth: device.permissions?.bluetooth ?? false,
- gps: device.permissions?.gps ?? false,
- camera: device.permissions?.camera ?? false,
- sim: device.permissions?.sim ?? false,
- speaker: device.permissions?.speaker ?? false,
+ bluetooth: normalizeServiceUseOption(
+ policy?.bluetooth ?? policy?.canUseBluetooth,
+ permissions?.bluetooth,
+ ),
+ gps: normalizeServiceUseOption(
+ policy?.GPS ?? policy?.canUseGPS,
+ permissions?.gps,
+ ),
+ camera: normalizeComponentUseOption(
+ policy?.camera ?? policy?.canUseCamera,
+ permissions?.camera,
+ ),
+ sim: normalizeServiceUseOption(
+ policy?.sim ?? policy?.canUseSim,
+ permissions?.sim,
+ ),
+ locked: policy?.locked ?? permissions?.locked ?? false,
}
}
-function mapStateToMutationVariables(policy: DevicePolicyState) {
+function mapApiPolicyToState(
+ policy: DevicePolicy | null | undefined,
+ fallback: DevicePolicyState,
+): DevicePolicyState {
return {
- canUseBluetooth: policy.bluetooth,
- canUseCamera: policy.camera,
- canUseGPS: policy.gps,
- canUseSim: policy.sim,
+ bluetooth: normalizeServiceUseOption(
+ policy?.bluetooth ?? policy?.canUseBluetooth ?? fallback.bluetooth,
+ ),
+ gps: normalizeServiceUseOption(
+ policy?.GPS ?? policy?.canUseGPS ?? fallback.gps,
+ ),
+ camera: normalizeComponentUseOption(
+ policy?.camera ?? policy?.canUseCamera ?? fallback.camera,
+ ),
+ sim: normalizeServiceUseOption(
+ policy?.sim ?? policy?.canUseSim ?? fallback.sim,
+ ),
+ locked: policy?.locked ?? fallback.locked,
}
}
-export function DevicePermissionsCard({ device }: DevicePermissionsCardProps) {
+function mapStateToMutationVariables(
+ policy: DevicePolicyState,
+): Omit {
+ return {
+ bluetooth: policy.bluetooth,
+ camera: policy.camera,
+ GPS: policy.gps,
+ sim: policy.sim,
+ locked: policy.locked,
+ }
+}
+
+function isPolicyChanged(
+ current: DevicePolicyState,
+ saved: DevicePolicyState,
+) {
+ return (
+ current.bluetooth !== saved.bluetooth ||
+ current.gps !== saved.gps ||
+ current.camera !== saved.camera ||
+ current.sim !== saved.sim
+ )
+}
+
+function areDevicePermissionsCardPropsEqual(
+ prev: DevicePermissionsCardProps,
+ next: DevicePermissionsCardProps,
+) {
+ const prevPolicy = prev.device.policy
+ const nextPolicy = next.device.policy
+ const prevPermissions = prev.device.permissions
+ const nextPermissions = next.device.permissions
+ const prevOrganisationPolicy = prev.device.organisationPolicy
+ const nextOrganisationPolicy = next.device.organisationPolicy
+
+ return (
+ prev.onEditingChange === next.onEditingChange &&
+ prev.onRefetchPolicy === next.onRefetchPolicy &&
+ prev.device.id === next.device.id &&
+ prevPolicy?.bluetooth === nextPolicy?.bluetooth &&
+ prevPolicy?.camera === nextPolicy?.camera &&
+ prevPolicy?.GPS === nextPolicy?.GPS &&
+ prevPolicy?.sim === nextPolicy?.sim &&
+ prevPolicy?.locked === nextPolicy?.locked &&
+ prevPermissions?.bluetooth === nextPermissions?.bluetooth &&
+ prevPermissions?.camera === nextPermissions?.camera &&
+ prevPermissions?.gps === nextPermissions?.gps &&
+ prevPermissions?.sim === nextPermissions?.sim &&
+ prevPermissions?.locked === nextPermissions?.locked &&
+ prevOrganisationPolicy?.bluetoothEditable ===
+ nextOrganisationPolicy?.bluetoothEditable &&
+ prevOrganisationPolicy?.cameraEditable ===
+ nextOrganisationPolicy?.cameraEditable &&
+ prevOrganisationPolicy?.gpsEditable ===
+ nextOrganisationPolicy?.gpsEditable &&
+ prevOrganisationPolicy?.simEditable === nextOrganisationPolicy?.simEditable
+ )
+}
+
+export const DevicePermissionsCard = memo(function DevicePermissionsCard({
+ device,
+ onEditingChange,
+ onRefetchPolicy,
+}: DevicePermissionsCardProps) {
const initialPolicy = useMemo(() => {
return mapDevicePermissionsToState(device)
}, [device])
const [policyState, setPolicyState] = useState(initialPolicy)
- const [savingKey, setSavingKey] = useState(null)
+ const [savedPolicyState, setSavedPolicyState] =
+ useState(initialPolicy)
+ const [isSaving, setIsSaving] = useState(false)
+ const [isEditing, setIsEditing] = useState(false)
const [saveError, setSaveError] = useState(false)
+ const [notification, setNotification] =
+ useState(null)
const [changePhonePolicy] = useMutation<
ChangePhonePolicyData,
ChangePhonePolicyVariables
>(CHANGE_PHONE_POLICY_MUTATION)
+ const hasChanges = isPolicyChanged(policyState, savedPolicyState)
+ const hasHeaderActionsOpen = isEditing || hasChanges
+
useEffect(() => {
+ if (isEditing || isSaving) return
+
setPolicyState(initialPolicy)
- }, [initialPolicy])
+ setSavedPolicyState(initialPolicy)
+ setSaveError(false)
+ setIsEditing(false)
+ }, [initialPolicy, isEditing, isSaving])
- async function handleToggle(key: DevicePolicyKey, disabled?: boolean) {
- if (disabled || savingKey) return
+ useEffect(() => {
+ onEditingChange?.(isEditing)
- const previousPolicy = policyState
- const nextPolicy = {
- ...policyState,
- [key]: !policyState[key],
+ return () => {
+ if (isEditing) {
+ onEditingChange?.(false)
+ }
+ }
+ }, [isEditing, onEditingChange])
+
+ function showNotification(
+ variant: NotificationVariant,
+ title: string,
+ description?: string,
+ ) {
+ setNotification({
+ id: Date.now(),
+ variant,
+ title,
+ description,
+ })
+ }
+
+ function handlePolicyOptionChange(
+ item: PermissionItemConfig,
+ option: PolicyValue,
+ disabled?: boolean,
+ ) {
+ if (disabled || !isEditing || isSaving) return
+
+ setPolicyState((currentPolicy) => {
+ if (item.kind === 'component') {
+ if (!COMPONENT_POLICY_OPTION_ORDER.includes(option as ComponentUseOption)) {
+ return currentPolicy
+ }
+
+ return {
+ ...currentPolicy,
+ camera: option as ComponentUseOption,
+ }
+ }
+
+ const key = getServicePolicyKey(item.key)
+
+ if (
+ !key ||
+ !SERVICE_POLICY_OPTION_ORDER.includes(option as ServiceUseOption)
+ ) {
+ return currentPolicy
+ }
+
+ return {
+ ...currentPolicy,
+ [key]: option as ServiceUseOption,
+ }
+ })
+ setSaveError(false)
+ }
+
+ function handleReset() {
+ if ((!isEditing && !hasChanges) || isSaving) return
+
+ setPolicyState(savedPolicyState)
+ setSaveError(false)
+ setIsEditing(false)
+ }
+
+ function handleEditClick() {
+ setIsEditing(true)
+ }
+
+ async function handleConfirmClick() {
+ if (isSaving) return
+
+ if (!hasChanges) {
+ setIsEditing(false)
+ setSaveError(false)
+ return
}
- setPolicyState(nextPolicy)
- setSavingKey(key)
+ await handleSave()
+ }
+
+ async function handleSave() {
+ if (!hasChanges || isSaving) return
+
+ const requestedPolicy = policyState
+
+ setIsSaving(true)
setSaveError(false)
try {
- await changePhonePolicy({
+ const result = await changePhonePolicy({
variables: {
id: String(device.id),
- ...mapStateToMutationVariables(nextPolicy),
+ ...mapStateToMutationVariables(requestedPolicy),
},
})
+
+ const changedApiPolicy = result.data?.changePhonePolicy
+
+ if (!changedApiPolicy) {
+ throw new Error('Phone policy was not saved')
+ }
+
+ const refreshedApiPolicy = await onRefetchPolicy?.()
+ const refreshedPolicy = mapApiPolicyToState(
+ refreshedApiPolicy ?? changedApiPolicy,
+ requestedPolicy,
+ )
+
+ setPolicyState(refreshedPolicy)
+ setSavedPolicyState(refreshedPolicy)
+
+ if (isPolicyChanged(requestedPolicy, refreshedPolicy)) {
+ setSaveError(true)
+ showNotification(
+ 'error',
+ 'Политика не изменилась',
+ 'Сервер ответил без ошибки, но после повторной загрузки вернулась прежняя политика.',
+ )
+ return
+ }
+
+ setIsEditing(false)
+ showNotification('success', 'Политика устройства сохранена')
} catch {
- setPolicyState(previousPolicy)
setSaveError(true)
+ showNotification(
+ 'error',
+ 'Не удалось сохранить политику устройства',
+ 'Изменения остались в черновике. Попробуйте сохранить еще раз.',
+ )
} finally {
- setSavingKey(null)
+ setIsSaving(false)
}
}
return (
-
- {permissionItems.map((item) => {
- const enabled = policyState[item.key]
- const isSaving = savingKey === item.key
- const isDisabled = item.disabled || Boolean(savingKey)
+
+
+
+
Опции
- return (
-
handleToggle(item.key, item.disabled)}
- />
- )
- })}
+
+
+
+
+
+ {getPolicyOptionIcon('Disabled', 15)}
+
+ Выключено
+ Опция отключена политикой устройства.
+
+
+
+ {getPolicyOptionIcon('Unspecified', 15)}
+
+ Свободно
+ Можно включить или выключить на смартфоне.
+
+
+
+ {getPolicyOptionIcon('Enabled', 15)}
+
+ Включено
+ Опция включена политикой устройства.
+
+
+
+
+
+
+
+
+
+
+
+
+ {hasHeaderActionsOpen ? : }
+
+
+
+
+
+ {permissionItems.map((item) => {
+ const value = policyState[item.key]
+ const isPolicyLocked = isOrganisationPolicyLocked(
+ device.organisationPolicy,
+ item.editableKey,
+ )
+ const isDisabled = isPolicyLocked || !isEditing || isSaving
+
+ return (
+
{
+ handlePolicyOptionChange(item, option, isPolicyLocked)
+ }}
+ />
+ )
+ })}
+
{saveError && (
Не удалось сохранить политику устройства
)}
+
+ {notification && (
+
setNotification(null)}
+ />
+ )}
)
-}
+}, areDevicePermissionsCardPropsEqual)
type PermissionItemProps = {
icon: ReactNode
label: string
- enabled: boolean
+ kind: PolicyKind
+ value: PolicyValue
+ options: PolicyValue[]
disabled?: boolean
- isUnavailable?: boolean
- isSaving?: boolean
- onToggle: () => void
+ isPolicyLocked?: boolean
+ onChange: (value: PolicyValue) => void
}
function PermissionItem({
icon,
label,
- enabled,
+ kind,
+ value,
+ options,
disabled = false,
- isUnavailable = false,
- isSaving = false,
- onToggle,
+ isPolicyLocked = false,
+ onChange,
}: PermissionItemProps) {
return (
@@ -196,17 +593,67 @@ function PermissionItem({
{label}
-
-
-
+
+ {isPolicyLocked && (
+
+
+
+
+ Опция зафиксирована политикой организации. Изменить ее на странице устройства нельзя.
+
+
+ )}
+
+
+
+
+ {options.map((option) => {
+ const isActive = value === option
+
+ return (
+ {
+ if (!disabled && !isActive) {
+ onChange(option)
+ }
+ }}
+ >
+
+ {getPolicyOptionIcon(option)}
+
+
+ )
+ })}
+
+
)
-}
\ No newline at end of file
+}
diff --git a/mdm-front/src/pages/DevicePage/components/DeviceStatsCards/DeviceStatsCards.tsx b/mdm-front/src/pages/DevicePage/components/DeviceStatsCards/DeviceStatsCards.tsx
index b0b9e1c..02532bb 100644
--- a/mdm-front/src/pages/DevicePage/components/DeviceStatsCards/DeviceStatsCards.tsx
+++ b/mdm-front/src/pages/DevicePage/components/DeviceStatsCards/DeviceStatsCards.tsx
@@ -1,4 +1,5 @@
-import { Battery, RotateCcw, ShieldCheck, Smartphone, ThermometerSun } from 'lucide-react'
+import { memo } from 'react'
+import { Battery, RotateCcw, ShieldCheck, Smartphone } from 'lucide-react'
import type { Device } from '../../types'
type DeviceStatsCardsProps = {
@@ -23,7 +24,22 @@ function formatWorkTimeHours(worktime?: number | null) {
return `${Math.round(hours)} ч`
}
-export function DeviceStatsCards({ device }: DeviceStatsCardsProps) {
+function areStatsCardsPropsEqual(
+ prev: DeviceStatsCardsProps,
+ next: DeviceStatsCardsProps,
+) {
+ return (
+ prev.device.battery === next.device.battery &&
+ prev.device.batteryMaxCapacity === next.device.batteryMaxCapacity &&
+ prev.device.chargeCycles === next.device.chargeCycles &&
+ prev.device.totalWorkTime === next.device.totalWorkTime &&
+ prev.device.mediumImpacts === next.device.mediumImpacts
+ )
+}
+
+export const DeviceStatsCards = memo(function DeviceStatsCards({
+ device,
+}: DeviceStatsCardsProps) {
const batteryValue =
typeof device.battery === 'number'
@@ -31,18 +47,18 @@ export function DeviceStatsCards({ device }: DeviceStatsCardsProps) {
: null
const batterySize = 110
- const batteryStroke = 10
+ const batteryStroke = 12
const batteryRadius = (batterySize - batteryStroke) / 2
const batteryLength = 2 * Math.PI * batteryRadius
const batteryProgress = batteryValue !== null ? batteryValue / 100 : 0
const batteryDashOffset = batteryLength * (1 - batteryProgress)
function getBatteryColor(value: number | null) {
- if (value === null) return '#cfd5df'
- if (value <= 20) return '#e00000'
- if (value <= 50) return '#ff8a34'
+ if (value === null) return 'var(--device-battery-progress-empty)'
+ if (value <= 20) return 'var(--device-battery-progress-danger)'
+ if (value <= 50) return 'var(--device-battery-progress-warning)'
- return '#031d9a'
+ return 'var(--device-battery-progress-strong)'
}
return (
@@ -124,4 +140,4 @@ export function DeviceStatsCards({ device }: DeviceStatsCardsProps) {
)
-}
\ No newline at end of file
+}, areStatsCardsPropsEqual)
diff --git a/mdm-front/src/pages/DevicePage/components/TimelineZoom/TimelineZoom.scss b/mdm-front/src/pages/DevicePage/components/TimelineZoom/TimelineZoom.scss
new file mode 100644
index 0000000..bbff9b0
--- /dev/null
+++ b/mdm-front/src/pages/DevicePage/components/TimelineZoom/TimelineZoom.scss
@@ -0,0 +1,107 @@
+@use '../../../../shared/styles/variables' as *;
+
+.timeline-zoom {
+ position: sticky;
+ top: 0;
+ z-index: 50;
+
+ padding: 10px 20px 20px 20px;
+
+}
+
+.timeline-zoom__track {
+ position: relative;
+
+ height: 28px;
+
+ border-radius: 8px;
+ background: $color-surface-active;
+
+ overflow: visible;
+}
+
+.timeline-zoom__selection {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+
+ min-width: 24px;
+
+ border-radius: 8px;
+ background: $color-primary-focus;
+}
+
+.timeline-zoom__window {
+ position: absolute;
+ inset: 0;
+
+ border: none;
+ background: $transparent;
+
+ cursor: grab;
+}
+
+.timeline-zoom__window:active {
+ cursor: grabbing;
+}
+
+.timeline-zoom__label--left {
+ transform: translate(50%, -50%);
+ &.out-place{
+ transform: translate(-90%, -50%);
+ }
+}
+
+.timeline-zoom__label--right {
+ transform: translate(-150%, -50%);
+
+ &.out-place{
+ transform: translate(-5%, -50%);
+ }
+}
+
+.timeline-zoom__handle {
+ position: absolute;
+ top: 2px;
+ bottom: 2px;
+
+ width: 6px;
+
+ border: none;
+ border-radius: 999px;
+
+ background: $blue;
+
+ cursor: ew-resize;
+ z-index: 10;
+}
+
+.timeline-zoom__handle--left {
+ left: 2px;
+}
+
+.timeline-zoom__handle--right {
+ right: 2px;
+}
+
+.timeline-zoom__label {
+ position: absolute;
+ top: 50%;
+
+ color: $gray50;
+ font-size: $font-size-12;
+ font-weight: 550;
+ line-height: 1;
+
+ pointer-events: none;
+ white-space: nowrap;
+ transition: transform .3s ease;
+}
+
+.timeline-zoom__label--left {
+ margin-left: -12px;
+}
+
+.timeline-zoom__label--right {
+ margin-left: 12px;
+}
diff --git a/mdm-front/src/pages/DevicePage/components/TimelineZoom/TimelineZoom.tsx b/mdm-front/src/pages/DevicePage/components/TimelineZoom/TimelineZoom.tsx
new file mode 100644
index 0000000..59cbf87
--- /dev/null
+++ b/mdm-front/src/pages/DevicePage/components/TimelineZoom/TimelineZoom.tsx
@@ -0,0 +1,406 @@
+import { useEffect, useMemo, useRef, useState } from 'react'
+
+import './TimelineZoom.scss'
+
+type TimelineZoomProps = {
+ min: number
+ max: number
+
+ start: number
+ end: number
+
+ onChange: (range: {
+ start: number
+ end: number
+ }) => void
+
+ formatLabel?: (value: number) => string
+
+ minWindow?: number
+}
+
+type DragType =
+ | 'left'
+ | 'right'
+ | 'move'
+ | null
+
+const CHANGE_THROTTLE_MS = 90
+
+export function TimelineZoom({
+ min,
+ max,
+ start,
+ end,
+ onChange,
+ formatLabel = (value) =>
+ new Date(value).toLocaleString('ru-RU', {
+ day: '2-digit',
+ month: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ }),
+ minWindow = 5 * 60 * 1000,
+}: TimelineZoomProps) {
+
+ const STEP = 5 * 60 * 1000
+
+ function snap(value: number) {
+ return Math.round(value / STEP) * STEP
+ }
+
+ const trackRef = useRef