init
5
.claude/settings.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"enabledPlugins": {
|
||||||
|
"expo@claude-plugins-official": true
|
||||||
|
}
|
||||||
|
}
|
||||||
2
.env
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
EXPO_PUBLIC_API_BASE_URL=https://irida.ae4.dev
|
||||||
|
EXPO_PUBLIC_USE_MOCK=0
|
||||||
6
.env.example
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# Тот же сервер, что VITE_API_URL в irida-admin/.env.
|
||||||
|
EXPO_PUBLIC_API_BASE_URL=https://irida.ae4.dev
|
||||||
|
EXPO_PUBLIC_USE_MOCK=0
|
||||||
|
|
||||||
|
# Необязательный ID зарегистрированного устройства. Секрет вводится при входе.
|
||||||
|
EXPO_PUBLIC_DEVICE_ID=
|
||||||
43
.gitignore
vendored
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Expo
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
web-build/
|
||||||
|
expo-env.d.ts
|
||||||
|
|
||||||
|
# Native
|
||||||
|
.kotlin/
|
||||||
|
*.orig.*
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
|
||||||
|
# Metro
|
||||||
|
.metro-health-check*
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.*
|
||||||
|
yarn-debug.*
|
||||||
|
yarn-error.*
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
example
|
||||||
|
|
||||||
|
# generated native folders
|
||||||
|
/ios
|
||||||
|
/android
|
||||||
1
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{ "recommendations": ["expo.vscode-expo-tools"] }
|
||||||
7
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll": "explicit",
|
||||||
|
"source.organizeImports": "explicit",
|
||||||
|
"source.sortMembers": "explicit"
|
||||||
|
}
|
||||||
|
}
|
||||||
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
27
README.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# ИРИДА — мобильное приложение водителя
|
||||||
|
|
||||||
|
Мобильное приложение на Expo и React Native для работы водителя сессиями, расписанием и диспетчерской связью. Поддерживает Android, iOS и web.
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
|
- `app/` — маршруты Expo Router и точки входа приложения.
|
||||||
|
- `src/app/` — сборка приложения, провайдеры, обработка ошибок и основной экран.
|
||||||
|
- `src/pages/` — страницы входа, водителя, списка водителей, чата и служебных экранов.
|
||||||
|
- `src/widgets/` — крупные элементы интерфейса: меню, заголовок киоска, чат, уведомления, таблицы и маршрут.
|
||||||
|
- `src/features/` — пользовательские сценарии: активация устройства, SOS, завершение смены, перерыв и контроль длительности смены.
|
||||||
|
- `src/entities/` — доменные сущности, их API, модели состояния, типы и тестовые данные.
|
||||||
|
- `src/shared/` — общие API-клиенты, конфигурация, тема, библиотеки и UI-компоненты.
|
||||||
|
- `assets/` — изображения, иконки и прочие ресурсы приложения.
|
||||||
|
- `scripts/` — вспомогательные скрипты и проверки.
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
Установите зависимости командой `npm install`, затем запустите приложение командой `npm start`. Для конкретной платформы доступны `npm run android`, `npm run ios` и `npm run web`.
|
||||||
|
|
||||||
|
Настройки окружения задаются через переменные `EXPO_PUBLIC_*`; пример находится в `.env.example`.
|
||||||
|
|
||||||
|
## Проверки
|
||||||
|
|
||||||
|
- `npm run lint` — проверка ESLint.
|
||||||
|
- `npm run test:live-chat` — проверка сценариев чата.
|
||||||
|
- `npm run test:client-id` — проверка генерации идентификатора клиента.
|
||||||
67
app.json
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "irida-mobile",
|
||||||
|
"slug": "fisenko",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"orientation": "landscape",
|
||||||
|
"icon": "./assets/images/icon.png",
|
||||||
|
"scheme": "iridamobile",
|
||||||
|
"userInterfaceStyle": "automatic",
|
||||||
|
"ios": {
|
||||||
|
"icon": "./assets/expo.icon",
|
||||||
|
"supportsTablet": true,
|
||||||
|
"requireFullScreen": true
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"package": "ru.irida.mobile",
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"backgroundColor": "#E6F4FE",
|
||||||
|
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||||
|
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||||
|
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||||
|
},
|
||||||
|
"predictiveBackGestureEnabled": false
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"output": "static",
|
||||||
|
"favicon": "./assets/images/favicon.png"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"expo-router",
|
||||||
|
[
|
||||||
|
"expo-splash-screen",
|
||||||
|
{
|
||||||
|
"backgroundColor": "#0EA5E9",
|
||||||
|
"image": "./assets/images/splash-icon.png",
|
||||||
|
"imageWidth": 76
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expo-image",
|
||||||
|
"expo-status-bar",
|
||||||
|
[
|
||||||
|
"expo-navigation-bar",
|
||||||
|
{
|
||||||
|
"hidden": true,
|
||||||
|
"style": "light",
|
||||||
|
"enforceContrast": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expo-web-browser",
|
||||||
|
"expo-audio",
|
||||||
|
"expo-secure-store"
|
||||||
|
],
|
||||||
|
"experiments": {
|
||||||
|
"typedRoutes": true,
|
||||||
|
"reactCompiler": true
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"router": {
|
||||||
|
"root": "app"
|
||||||
|
},
|
||||||
|
"eas": {
|
||||||
|
"projectId": "bd78a0a9-d596-48f1-befd-3ae58ee2721a"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"owner": "fisenkos-team"
|
||||||
|
}
|
||||||
|
}
|
||||||
18
app/_layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Slot } from 'expo-router';
|
||||||
|
import * as SplashScreen from 'expo-splash-screen';
|
||||||
|
import { AppProviders, RouterErrorBoundary } from '@/app';
|
||||||
|
|
||||||
|
export { RouterErrorBoundary as ErrorBoundary };
|
||||||
|
|
||||||
|
const SPLASH_FALLBACK_TIMEOUT = 5000;
|
||||||
|
|
||||||
|
SplashScreen.preventAutoHideAsync().catch(() => {});
|
||||||
|
setTimeout(() => void SplashScreen.hideAsync().catch(() => {}), SPLASH_FALLBACK_TIMEOUT);
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
return (
|
||||||
|
<AppProviders>
|
||||||
|
<Slot />
|
||||||
|
</AppProviders>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
app/driver.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { KioskApp } from '@/app';
|
||||||
|
|
||||||
|
export default function DriverScreen() {
|
||||||
|
return <KioskApp />;
|
||||||
|
}
|
||||||
1
app/drivers.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { DriversPage as default } from '@/pages/drivers';
|
||||||
5
app/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Redirect } from 'expo-router';
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
return <Redirect href="/driver" />;
|
||||||
|
}
|
||||||
3
assets/expo.icon/Assets/expo-symbol 2.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="652" height="606" viewBox="0 0 652 606" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M353.554 0H298.446C273.006 0 249.684 14.6347 237.962 37.9539L4.37994 502.646C-1.04325 513.435 -1.45067 526.178 3.2716 537.313L22.6123 582.918C34.6475 611.297 72.5404 614.156 88.4414 587.885L309.863 222.063C313.34 216.317 319.439 212.826 326 212.826C332.561 212.826 338.659 216.317 342.137 222.063L563.559 587.885C579.46 614.156 617.352 611.297 629.388 582.918L648.728 537.313C653.451 526.178 653.043 513.435 647.62 502.646L414.038 37.9539C402.316 14.6347 378.994 0 353.554 0Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 608 B |
BIN
assets/expo.icon/Assets/grid.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
40
assets/expo.icon/icon.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"fill" : {
|
||||||
|
"automatic-gradient" : "extended-srgb:0.00000,0.47843,1.00000,1.00000"
|
||||||
|
},
|
||||||
|
"groups" : [
|
||||||
|
{
|
||||||
|
"layers" : [
|
||||||
|
{
|
||||||
|
"image-name" : "expo-symbol 2.svg",
|
||||||
|
"name" : "expo-symbol 2",
|
||||||
|
"position" : {
|
||||||
|
"scale" : 1,
|
||||||
|
"translation-in-points" : [
|
||||||
|
1.1008400065293245e-05,
|
||||||
|
-16.046875
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"image-name" : "grid.png",
|
||||||
|
"name" : "grid"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shadow" : {
|
||||||
|
"kind" : "neutral",
|
||||||
|
"opacity" : 0.5
|
||||||
|
},
|
||||||
|
"translucency" : {
|
||||||
|
"enabled" : true,
|
||||||
|
"value" : 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"supported-platforms" : {
|
||||||
|
"circles" : [
|
||||||
|
"watchOS"
|
||||||
|
],
|
||||||
|
"squares" : "shared"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
assets/images/android-icon-background.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/images/android-icon-foreground.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
assets/images/android-icon-monochrome.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
assets/images/expo-badge-white.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
assets/images/expo-badge.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
assets/images/expo-logo.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
assets/images/favicon.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
assets/images/icon.png
Normal file
|
After Width: | Height: | Size: 780 KiB |
BIN
assets/images/logo-glow.png
Normal file
|
After Width: | Height: | Size: 324 KiB |
BIN
assets/images/react-logo.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
assets/images/react-logo@2x.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/images/react-logo@3x.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
assets/images/splash-icon.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
assets/images/tabIcons/explore.png
Normal file
|
After Width: | Height: | Size: 215 B |
BIN
assets/images/tabIcons/explore@2x.png
Normal file
|
After Width: | Height: | Size: 347 B |
BIN
assets/images/tabIcons/explore@3x.png
Normal file
|
After Width: | Height: | Size: 468 B |
BIN
assets/images/tabIcons/home.png
Normal file
|
After Width: | Height: | Size: 253 B |
BIN
assets/images/tabIcons/home@2x.png
Normal file
|
After Width: | Height: | Size: 343 B |
BIN
assets/images/tabIcons/home@3x.png
Normal file
|
After Width: | Height: | Size: 479 B |
BIN
assets/images/tutorial-web.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
34
eas.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"version": ">= 5.0.0",
|
||||||
|
"appVersionSource": "remote"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"development": {
|
||||||
|
"developmentClient": true,
|
||||||
|
"distribution": "internal",
|
||||||
|
"android": {
|
||||||
|
"buildType": "apk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"distribution": "internal",
|
||||||
|
"env": {
|
||||||
|
"EXPO_PUBLIC_USE_MOCK": "1"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"buildType": "apk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"apk": {
|
||||||
|
"distribution": "internal",
|
||||||
|
"env": {
|
||||||
|
"EXPO_PUBLIC_API_BASE_URL": "https://irida.ae4.dev",
|
||||||
|
"EXPO_PUBLIC_USE_MOCK": "0"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"buildType": "apk"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
74
eslint.config.js
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
const { defineConfig } = require('eslint/config');
|
||||||
|
const expoConfig = require('eslint-config-expo/flat');
|
||||||
|
const boundaries = require('eslint-plugin-boundaries');
|
||||||
|
|
||||||
|
module.exports = defineConfig([
|
||||||
|
expoConfig,
|
||||||
|
{
|
||||||
|
ignores: ['dist/*', '.expo/*'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['src/**/*.{ts,tsx}', 'app/**/*.{ts,tsx}'],
|
||||||
|
plugins: { boundaries },
|
||||||
|
settings: {
|
||||||
|
'import/resolver': { typescript: { project: './tsconfig.json' } },
|
||||||
|
'boundaries/include': ['src/**/*', 'app/**/*'],
|
||||||
|
'boundaries/elements': [
|
||||||
|
{ type: 'routes', pattern: 'app' },
|
||||||
|
{ type: 'pages', pattern: 'src/pages/*', capture: ['slice'] },
|
||||||
|
{ type: 'widgets', pattern: 'src/widgets/*', capture: ['slice'] },
|
||||||
|
{ type: 'features', pattern: 'src/features/*', capture: ['slice'] },
|
||||||
|
{ type: 'entities', pattern: 'src/entities/*', capture: ['slice'] },
|
||||||
|
{ type: 'shared', pattern: 'src/shared/*', capture: ['segment'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'boundaries/dependencies': ['error', {
|
||||||
|
default: 'disallow',
|
||||||
|
message: 'Слой {{from.type}} не может импортировать {{to.type}}',
|
||||||
|
policies: [
|
||||||
|
{
|
||||||
|
from: { element: { type: 'routes' } },
|
||||||
|
allow: { to: { element: { types: { anyOf: ['pages', 'widgets', 'features', 'entities', 'shared'] } } } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: { element: { type: 'pages' } },
|
||||||
|
allow: { to: { element: { types: { anyOf: ['widgets', 'features', 'entities', 'shared'] } } } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: { element: { type: 'widgets' } },
|
||||||
|
allow: { to: { element: { types: { anyOf: ['features', 'entities', 'shared'] } } } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: { element: { type: 'features' } },
|
||||||
|
allow: { to: { element: { types: { anyOf: ['entities', 'shared'] } } } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: { element: { type: 'entities' } },
|
||||||
|
allow: { to: { element: { type: 'shared' } } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: { element: { type: 'entities' } },
|
||||||
|
allow: { to: { element: { type: 'entities', fileInternalPath: '@x/*.ts' } } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: { element: { type: 'shared' } },
|
||||||
|
allow: { to: { element: { type: 'shared' } } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
disallow: {
|
||||||
|
to: {
|
||||||
|
element: {
|
||||||
|
types: { anyOf: ['pages', 'widgets', 'features', 'entities'] },
|
||||||
|
fileInternalPath: '!(index.ts|@x/*.ts)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
message: 'Импортируйте слайс через public API, а не вглубь: {{dependency.source}}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
'boundaries/no-unknown-dependencies': ['warn', { require: 'element' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
12323
package-lock.json
generated
Normal file
64
package.json
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"name": "irida-mobile",
|
||||||
|
"main": "expo-router/entry",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/ui": "~57.0.9",
|
||||||
|
"@expo/vector-icons": "^15.1.1",
|
||||||
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
|
"@react-native-community/netinfo": "^12.0.1",
|
||||||
|
"expo": "~57.0.10",
|
||||||
|
"expo-audio": "~57.0.4",
|
||||||
|
"expo-brightness": "~57.0.1",
|
||||||
|
"expo-constants": "~57.0.2",
|
||||||
|
"expo-device": "~57.0.1",
|
||||||
|
"expo-file-system": "~57.0.0",
|
||||||
|
"expo-font": "~57.0.0",
|
||||||
|
"expo-glass-effect": "~57.0.1",
|
||||||
|
"expo-image": "~57.0.2",
|
||||||
|
"expo-keep-awake": "~57.0.0",
|
||||||
|
"expo-linking": "~57.0.5",
|
||||||
|
"expo-location": "~57.0.7",
|
||||||
|
"expo-navigation-bar": "~57.0.2",
|
||||||
|
"expo-router": "~57.0.10",
|
||||||
|
"expo-secure-store": "~57.0.1",
|
||||||
|
"expo-splash-screen": "~57.0.5",
|
||||||
|
"expo-status-bar": "~57.0.1",
|
||||||
|
"expo-symbols": "~57.0.1",
|
||||||
|
"expo-system-ui": "~57.0.2",
|
||||||
|
"expo-web-browser": "~57.0.2",
|
||||||
|
"lucide-react-native": "^1.23.0",
|
||||||
|
"mobx": "^6.16.1",
|
||||||
|
"mobx-react-lite": "^4.1.1",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3",
|
||||||
|
"react-native": "0.86.2",
|
||||||
|
"react-native-gesture-handler": "~2.32.0",
|
||||||
|
"react-native-reanimated": "4.5.1",
|
||||||
|
"react-native-safe-area-context": "~5.7.0",
|
||||||
|
"react-native-screens": "~4.26.0",
|
||||||
|
"react-native-svg": "15.15.4",
|
||||||
|
"react-native-web": "~0.21.0",
|
||||||
|
"react-native-webview": "13.16.1",
|
||||||
|
"react-native-worklets": "0.10.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "~19.2.2",
|
||||||
|
"eslint": "^9.39.5",
|
||||||
|
"eslint-config-expo": "^57.0.1",
|
||||||
|
"eslint-import-resolver-typescript": "^4.4.5",
|
||||||
|
"eslint-plugin-boundaries": "^7.1.0",
|
||||||
|
"typescript": "~6.0.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"reset-project": "node ./scripts/reset-project.js",
|
||||||
|
"android": "expo start --android",
|
||||||
|
"ios": "expo start --ios",
|
||||||
|
"web": "expo start --web",
|
||||||
|
"lint": "expo lint",
|
||||||
|
"test:live-chat": "node --test scripts/test-live-chat.mjs",
|
||||||
|
"test:client-id": "node --experimental-strip-types --test src/shared/lib/offlineQueue/clientId.test.mjs"
|
||||||
|
},
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
105
scripts/reset-project.js
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const readline = require("readline");
|
||||||
|
|
||||||
|
const root = process.cwd();
|
||||||
|
const oldDirs = ["src", "scripts"];
|
||||||
|
const exampleDir = "example";
|
||||||
|
const newAppDir = "src/app";
|
||||||
|
const exampleDirPath = path.join(root, exampleDir);
|
||||||
|
|
||||||
|
const indexContent = `import { Text, View, StyleSheet } from "react-native";
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text>Edit src/app/index.tsx to edit this screen.</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
`;
|
||||||
|
|
||||||
|
const layoutContent = `import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
return <Stack />;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
});
|
||||||
|
|
||||||
|
const moveDirectories = async (userInput) => {
|
||||||
|
try {
|
||||||
|
if (userInput === "y") {
|
||||||
|
await fs.promises.mkdir(exampleDirPath, { recursive: true });
|
||||||
|
console.log(`📁 /${exampleDir} directory created.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dir of oldDirs) {
|
||||||
|
const oldDirPath = path.join(root, dir);
|
||||||
|
if (fs.existsSync(oldDirPath)) {
|
||||||
|
if (userInput === "y") {
|
||||||
|
const newDirPath = path.join(root, exampleDir, dir);
|
||||||
|
await fs.promises.rename(oldDirPath, newDirPath);
|
||||||
|
console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`);
|
||||||
|
} else {
|
||||||
|
await fs.promises.rm(oldDirPath, { recursive: true, force: true });
|
||||||
|
console.log(`❌ /${dir} deleted.`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`➡️ /${dir} does not exist, skipping.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newAppDirPath = path.join(root, newAppDir);
|
||||||
|
await fs.promises.mkdir(newAppDirPath, { recursive: true });
|
||||||
|
console.log("\n📁 New /src/app directory created.");
|
||||||
|
|
||||||
|
const indexPath = path.join(newAppDirPath, "index.tsx");
|
||||||
|
await fs.promises.writeFile(indexPath, indexContent);
|
||||||
|
console.log("📄 src/app/index.tsx created.");
|
||||||
|
|
||||||
|
const layoutPath = path.join(newAppDirPath, "_layout.tsx");
|
||||||
|
await fs.promises.writeFile(layoutPath, layoutContent);
|
||||||
|
console.log("📄 src/app/_layout.tsx created.");
|
||||||
|
|
||||||
|
console.log("\n✅ Project reset complete. Next steps:");
|
||||||
|
console.log(
|
||||||
|
`1. Run \`npx expo start\` to start a development server.\n2. Edit src/app/index.tsx to edit the main screen.\n3. Put all your application code in /src, only screens and layout files should be in /src/app.${
|
||||||
|
userInput === "y"
|
||||||
|
? `\n4. Delete the /${exampleDir} directory when you're done referencing it.`
|
||||||
|
: ""
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Error during script execution: ${error.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rl.question(
|
||||||
|
"Do you want to move existing files to /example instead of deleting them? (Y/n): ",
|
||||||
|
(answer) => {
|
||||||
|
const userInput = answer.trim().toLowerCase() || "y";
|
||||||
|
if (userInput === "y" || userInput === "n") {
|
||||||
|
moveDirectories(userInput).finally(() => rl.close());
|
||||||
|
} else {
|
||||||
|
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
|
||||||
|
rl.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
313
scripts/test-live-chat.mjs
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import test from 'node:test';
|
||||||
|
import ts from 'typescript';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
function load(path, dependencies = {}) {
|
||||||
|
const source = readFileSync(new URL(`../src/${path}`, import.meta.url), 'utf8');
|
||||||
|
const { outputText } = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } });
|
||||||
|
const module = { exports: {} };
|
||||||
|
new Function('require', 'module', 'exports', outputText)(name => dependencies[name] ?? require(name), module, module.exports);
|
||||||
|
return module.exports;
|
||||||
|
}
|
||||||
|
const mapping = load('entities/message/model/liveMessage.ts');
|
||||||
|
const { LiveMessageStore } = load('entities/message/model/liveMessageStore.ts', { './liveMessage': mapping });
|
||||||
|
const incoming = (id, important = false, status = 'pending') => ({ id, fromId: '22', important, message: 'Сообщение', createdAt: '2026-10-28T10:00:00Z', receivers: [{ toId: '11', status }] });
|
||||||
|
const tick = () => new Promise(resolve => setImmediate(resolve));
|
||||||
|
|
||||||
|
test('preserves 64-bit message IDs, ordering, own/read/important states', () => {
|
||||||
|
const large = '9007199254740993';
|
||||||
|
const small = '9007199254740992';
|
||||||
|
assert.deepEqual(mapping.chronological([incoming(large), incoming(small), incoming(large)]).map(message => message.id), [small, large]);
|
||||||
|
assert.equal(mapping.mapLiveMessage(incoming(large, true), '11').type, 'important');
|
||||||
|
assert.equal(mapping.mapLiveMessage(incoming(large, true, 'read'), '11').confirmed, true);
|
||||||
|
const own = mapping.mapLiveMessage({ ...incoming(large), fromId: '11', receivers: [] }, '11');
|
||||||
|
assert.equal(own.type, 'driver_self');
|
||||||
|
assert.equal(own.deliveryStatus, 'sent');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports delivery, reads visible ordinary messages, keeps every important message pending', async () => {
|
||||||
|
const writes = [];
|
||||||
|
const messages = [incoming('101'), incoming('102', true), incoming('103', true)];
|
||||||
|
const store = new LiveMessageStore('11', async (path, options) => {
|
||||||
|
if (options?.method === 'POST') { writes.push([path, options.body]); return {}; }
|
||||||
|
return { messages: path === '/messages/list' && options.query?.fromId ? [] : messages };
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
store.start();
|
||||||
|
await tick();
|
||||||
|
assert.equal(store.chats.length, 3);
|
||||||
|
assert.deepEqual(writes[0], ['/messages/report/delivered', { messageIds: ['101', '102', '103'] }]);
|
||||||
|
assert.equal(writes.some(([path]) => path === '/messages/report/read'), false);
|
||||||
|
await store.markVisibleRead(['101', '102']);
|
||||||
|
assert.deepEqual(writes[1], ['/messages/report/read', { messageIds: ['101'] }]);
|
||||||
|
assert.equal(store.important.id, '102');
|
||||||
|
messages[1].receivers[0].status = 'read';
|
||||||
|
assert.equal(await store.acknowledge('102'), true);
|
||||||
|
assert.equal(store.important.id, '103');
|
||||||
|
} finally { store.stop(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('routes answers to the selected message rather than the latest sender; failed writes retain an error', async () => {
|
||||||
|
const writes = [];
|
||||||
|
let fail = false;
|
||||||
|
const store = new LiveMessageStore('11', async (path, options) => {
|
||||||
|
if (path === '/messages/reply') {
|
||||||
|
writes.push(options.body);
|
||||||
|
if (fail) throw new Error('Нет связи');
|
||||||
|
return { ...incoming('999'), fromId: '11', replyId: options.body.replyId };
|
||||||
|
}
|
||||||
|
return { messages: [] };
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
store.start();
|
||||||
|
await tick();
|
||||||
|
store.messages = [incoming('9007199254740993'), { ...incoming('9007199254740994'), fromId: '33' }];
|
||||||
|
store.selectReply('9007199254740993');
|
||||||
|
assert.equal(await store.send('Ответ'), true);
|
||||||
|
assert.deepEqual(writes[0], { replyId: '9007199254740993', message: 'Ответ' });
|
||||||
|
fail = true;
|
||||||
|
assert.equal(await store.send('Повторный ответ'), false);
|
||||||
|
assert.equal(store.sendError, 'Нет связи');
|
||||||
|
assert.equal(store.sending, false);
|
||||||
|
} finally { store.stop(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed read acknowledgement does not dismiss an important message', async () => {
|
||||||
|
const store = new LiveMessageStore('11', async (path) => {
|
||||||
|
if (path === '/messages/report/read') throw new Error('Нет связи');
|
||||||
|
return { messages: [] };
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
store.start();
|
||||||
|
await tick();
|
||||||
|
store.messages = [incoming('102', true)];
|
||||||
|
assert.equal(await store.acknowledge('102'), false);
|
||||||
|
assert.equal(store.important.id, '102');
|
||||||
|
} finally { store.stop(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
class HttpError extends Error { constructor(status, message) { super(message); this.status = status; } }
|
||||||
|
function sessionFixture(handler, activationState = { value: { deviceId: '77', deviceSecret: 'secret' } }, suppliedStorage) {
|
||||||
|
const storage = suppliedStorage ?? { values: new Map(), getJson(key) { return this.values.get(key); }, setJson(key, value) { this.values.set(key, value); }, remove(key) { this.values.delete(key); } };
|
||||||
|
const deviceActivationStorage = { load: async () => activationState.value, save: async value => { if (activationState.failSave) throw new Error('Storage unavailable'); activationState.value = value; }, clear: async () => { activationState.value = null; } };
|
||||||
|
const { LiveSessionStore } = load('entities/session/model/liveSessionStore.ts', {
|
||||||
|
'../api/deviceActivation': { deviceActivationStorage },
|
||||||
|
'@/shared/api': { HttpError }, '@/shared/api/irida': { iridaRequest: handler },
|
||||||
|
'@/shared/lib': { storage, hydrateStorage: async () => {} }, '@/shared/config': { apiBaseUrl: 'https://irida.example' },
|
||||||
|
});
|
||||||
|
const store = new LiveSessionStore();
|
||||||
|
store.activation = activationState.value;
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('creates a device-bound driver session and revokes the preliminary login', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const store = sessionFixture(async (path, options) => {
|
||||||
|
calls.push([path, options]);
|
||||||
|
if (path === '/auth/email/login') return { accessToken: 'access', refreshToken: options.body.shiftId ? 'bound' : 'temporary', user: { id: '11' }, ...(options.body.shiftId ? { shift: { id: '55', deviceId: '77' } } : {}) };
|
||||||
|
if (path === '/shifts/start') return { driverShift: { id: '55', deviceId: '77' } };
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
await store.login(' driver@example.com ', 'password');
|
||||||
|
assert.equal(store.session.shift.id, '55');
|
||||||
|
assert.deepEqual(calls.find(([path]) => path === '/shifts/start')[1].body, { userId: '11', deviceId: '77', deviceSecret: 'secret' });
|
||||||
|
assert.equal(calls.filter(([path]) => path === '/auth/email/login')[1][1].body.shiftId, '55');
|
||||||
|
assert.equal(calls.at(-1)[1].body.refreshToken, 'temporary');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('coalesces concurrent refreshes and retries requests with Bearer authorization', async () => {
|
||||||
|
let refreshes = 0;
|
||||||
|
const store = sessionFixture(async (path, options) => {
|
||||||
|
if (path === '/auth/refresh') { refreshes++; await tick(); return { accessToken: 'fresh' }; }
|
||||||
|
if (options.headers.Authorization === 'Bearer old') throw new HttpError(401, 'Expired');
|
||||||
|
assert.equal(options.headers.Authorization, 'Bearer fresh');
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
store.session = { accessToken: 'old', refreshToken: 'refresh', user: { id: '11' }, shift: { id: '55' } };
|
||||||
|
await Promise.all([store.authorized('/messages/list'), store.authorized('/messages/pending')]);
|
||||||
|
assert.equal(refreshes, 1);
|
||||||
|
assert.equal(store.session.accessToken, 'fresh');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reuses the created shift after a network failure in the device-bound login', async () => {
|
||||||
|
let starts = 0;
|
||||||
|
let fail = true;
|
||||||
|
const store = sessionFixture(async (path, options) => {
|
||||||
|
if (path === '/shifts/start') { starts++; return { driverShift: { id: '55', deviceId: '77' } }; }
|
||||||
|
if (path === '/auth/email/login') {
|
||||||
|
if (options.body.shiftId && fail) throw new Error('Нет связи');
|
||||||
|
return { accessToken: 'access', refreshToken: 'refresh', user: { id: '11' }, ...(options.body.shiftId ? { shift: { id: '55', deviceId: '77' } } : {}) };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
await store.login('driver@example.com', 'password');
|
||||||
|
assert.equal(store.session, null);
|
||||||
|
fail = false;
|
||||||
|
await store.login('driver@example.com', 'password');
|
||||||
|
assert.equal(starts, 1);
|
||||||
|
assert.equal(store.session.shift.id, '55');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an invalid refresh returns to login; a network error retains the session', async () => {
|
||||||
|
for (const status of [401, 0]) {
|
||||||
|
const store = sessionFixture(async path => { throw new HttpError(path === '/auth/refresh' ? status : 401, 'Ошибка'); });
|
||||||
|
store.session = { accessToken: 'old', refreshToken: 'refresh', user: { id: '11' }, shift: { id: '55' } };
|
||||||
|
await assert.rejects(store.authorized('/messages/pending'));
|
||||||
|
assert.equal(store.session === null, status === 401);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('completes local logout once the server ends the shift, even if token revocation fails', async () => {
|
||||||
|
const store = sessionFixture(async path => {
|
||||||
|
if (path === '/auth/logout') throw new Error('Нет связи');
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
store.session = { accessToken: 'token', refreshToken: 'refresh', user: { id: '11' }, shift: { id: '55' } };
|
||||||
|
await store.logout();
|
||||||
|
assert.equal(store.session, null);
|
||||||
|
assert.equal(store.error, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only visible incoming messages are read, and stale polls preserve their read status', async () => {
|
||||||
|
const writes = [];
|
||||||
|
const store = new LiveMessageStore('11', async (path, options) => {
|
||||||
|
if (options?.method === 'POST') { writes.push([path, options.body]); return { updated: 1 }; }
|
||||||
|
return { messages: options.query?.fromId ? [] : [incoming('101'), incoming('102')] };
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
store.start();
|
||||||
|
await tick();
|
||||||
|
await Promise.all([store.markVisibleRead(['101']), store.markVisibleRead(['101'])]);
|
||||||
|
assert.equal(writes.filter(([path]) => path === '/messages/report/read').length, 1);
|
||||||
|
assert.equal(store.messages.find(message => message.id === '102').receivers[0].status, 'delivered');
|
||||||
|
await store.refresh();
|
||||||
|
assert.equal(store.messages.find(message => message.id === '101').receivers[0].status, 'read');
|
||||||
|
} finally { store.stop(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('polling replaces an outgoing delivery indicator with the server read status', async () => {
|
||||||
|
let status = 'delivered';
|
||||||
|
const store = new LiveMessageStore('11', async (path, options) => ({ messages: options.query?.fromId ? [{ ...incoming('100'), fromId: '11', receivers: [{ toId: '22', status }] }] : [] }));
|
||||||
|
try {
|
||||||
|
store.start();
|
||||||
|
await tick();
|
||||||
|
assert.equal(store.chats[0].deliveryStatus, 'delivered');
|
||||||
|
status = 'read';
|
||||||
|
await store.refresh();
|
||||||
|
assert.equal(store.chats[0].deliveryStatus, 'read');
|
||||||
|
} finally { store.stop(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test('viewport receipts exclude offscreen bubbles and support messages taller than the viewport', () => {
|
||||||
|
const { visibleMessageIds } = load('widgets/messenger/model/visibleMessages.ts');
|
||||||
|
const layouts = [{ id: 'above', y: 0, height: 80 }, { id: 'visible', y: 100, height: 60 }, { id: 'below', y: 300, height: 80 }];
|
||||||
|
assert.deepEqual(visibleMessageIds(layouts, 100, 120), ['visible']);
|
||||||
|
assert.deepEqual(visibleMessageIds([{ id: 'long', y: 0, height: 1000 }], 400, 120), ['long']);
|
||||||
|
assert.deepEqual(visibleMessageIds(layouts, 100, 0), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('activation is saved once and restored before email/password login', async () => {
|
||||||
|
const state = { value: null };
|
||||||
|
const store = sessionFixture(async () => ({}), state);
|
||||||
|
await store.init();
|
||||||
|
assert.equal(store.activation, null);
|
||||||
|
await store.activateDevice('77', 'device-secret');
|
||||||
|
assert.deepEqual(state.value, { deviceId: '77', deviceSecret: 'device-secret' });
|
||||||
|
const restored = sessionFixture(async () => ({}), state);
|
||||||
|
await restored.init();
|
||||||
|
assert.deepEqual({ ...restored.activation }, state.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('login requires saved activation and uses it without additional device fields', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const state = { value: null };
|
||||||
|
const store = sessionFixture(async (path, options) => {
|
||||||
|
calls.push([path, options]);
|
||||||
|
if (path === '/auth/email/login') return { accessToken: 'access', refreshToken: 'refresh', user: { id: '11' }, ...(options.body.shiftId ? { shift: { id: '55', deviceId: '77' } } : {}) };
|
||||||
|
if (path === '/shifts/start') return { driverShift: { id: '55' } };
|
||||||
|
return {};
|
||||||
|
}, state);
|
||||||
|
await store.login('driver@example.com', 'password');
|
||||||
|
assert.equal(calls.length, 0);
|
||||||
|
await store.activateDevice('77', 'saved-secret');
|
||||||
|
await store.login('driver@example.com', 'password');
|
||||||
|
assert.deepEqual(calls.find(([path]) => path === '/shifts/start')[1].body, { userId: '11', deviceId: '77', deviceSecret: 'saved-secret' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reset clears activation and the session, and the next launch requires activation', async () => {
|
||||||
|
const state = { value: { deviceId: '77', deviceSecret: 'saved-secret' } };
|
||||||
|
const store = sessionFixture(async () => ({}), state);
|
||||||
|
store.session = { accessToken: 'access', refreshToken: 'refresh', user: { id: '11' }, shift: { id: '55', deviceId: '77' } };
|
||||||
|
await store.resetDevice();
|
||||||
|
assert.equal(state.value, null);
|
||||||
|
assert.equal(store.session, null);
|
||||||
|
const restored = sessionFixture(async () => ({}), state);
|
||||||
|
await restored.init();
|
||||||
|
assert.equal(restored.activation, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed secure-store write does not activate the device', async () => {
|
||||||
|
const state = { value: null, failSave: true };
|
||||||
|
const store = sessionFixture(async () => ({}), state);
|
||||||
|
await store.activateDevice('77', 'secret');
|
||||||
|
assert.equal(store.activation, null);
|
||||||
|
assert.match(store.error, /сохранить/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('native device credentials use SecureStore rather than AsyncStorage', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const memory = new Map();
|
||||||
|
const { deviceActivationStorage } = load('entities/session/api/deviceActivation.ts', {
|
||||||
|
'react-native': { Platform: { OS: 'android' } },
|
||||||
|
'expo-secure-store': {
|
||||||
|
WHEN_UNLOCKED_THIS_DEVICE_ONLY: 1,
|
||||||
|
setItemAsync: async (key, value) => { calls.push('save'); memory.set(key, value); },
|
||||||
|
getItemAsync: async key => memory.get(key) ?? null,
|
||||||
|
deleteItemAsync: async key => { calls.push('delete'); memory.delete(key); },
|
||||||
|
},
|
||||||
|
'@/shared/lib': { storage: new Proxy({}, { get() { throw new Error('Native secrets must not use AsyncStorage'); } }) },
|
||||||
|
'@/shared/config': { apiBaseUrl: 'https://irida.example' },
|
||||||
|
});
|
||||||
|
await deviceActivationStorage.save({ deviceId: '77', deviceSecret: 'secret' });
|
||||||
|
assert.deepEqual(await deviceActivationStorage.load(), { deviceId: '77', deviceSecret: 'secret' });
|
||||||
|
await deviceActivationStorage.clear();
|
||||||
|
assert.equal(await deviceActivationStorage.load(), null);
|
||||||
|
assert.deepEqual(calls, ['save', 'delete']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reply mapping provides the quoted author and text instead of displaying an ID', () => {
|
||||||
|
const original = { ...incoming('101'), message: 'Исходный текст' };
|
||||||
|
const reply = { ...incoming('102'), fromId: '11', replyId: '101' };
|
||||||
|
const mapped = mapping.mapLiveMessage(reply, '11', [original, reply]);
|
||||||
|
assert.equal(mapped.replyToId, '101');
|
||||||
|
assert.equal(mapped.replyTo, 'Исходный текст');
|
||||||
|
assert.equal(mapped.replyToAuthor, 'Диспетчер #22');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('native activation refuses a write that resolves without persisting the credentials', async () => {
|
||||||
|
const { deviceActivationStorage } = load('entities/session/api/deviceActivation.ts', {
|
||||||
|
'react-native': { Platform: { OS: 'android' } },
|
||||||
|
'expo-secure-store': {
|
||||||
|
WHEN_UNLOCKED_THIS_DEVICE_ONLY: 1,
|
||||||
|
setItemAsync: async () => {},
|
||||||
|
getItemAsync: async () => null,
|
||||||
|
},
|
||||||
|
'@/shared/lib': { storage: {} },
|
||||||
|
'@/shared/config': { apiBaseUrl: 'https://irida.example' },
|
||||||
|
});
|
||||||
|
await assert.rejects(() => deviceActivationStorage.save({ deviceId: '77', deviceSecret: 'secret' }), /сохранение/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('distinguishes device shift rejection from email login rejection', async () => {
|
||||||
|
const store = sessionFixture(async (path) => {
|
||||||
|
if (path === '/auth/email/login') return { accessToken: 'access', refreshToken: 'refresh', user: { id: '11' } };
|
||||||
|
if (path === '/shifts/start') throw new HttpError(401, 'invalid credentials');
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
await store.login('driver@example.com', 'password');
|
||||||
|
assert.equal(store.error, 'Открытие смены на устройстве 77: invalid credentials');
|
||||||
|
assert.equal(store.activation.deviceSecret, 'secret');
|
||||||
|
});
|
||||||
3
src/app/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { default as AppProviders } from './ui/AppProviders';
|
||||||
|
export { default as KioskApp } from './ui/KioskApp';
|
||||||
|
export { CrashOverlay, RouterErrorBoundary } from './ui/CrashScreen';
|
||||||
25
src/app/ui/AppProviders.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import React, { type ReactNode } from 'react';
|
||||||
|
import { View } from 'react-native';
|
||||||
|
import { DarkTheme, ThemeProvider } from 'expo-router';
|
||||||
|
import { NavigationBar } from 'expo-navigation-bar';
|
||||||
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||||
|
import { AnimatedSplashOverlay } from '@/shared/ui';
|
||||||
|
import { useTheme } from '@/shared/config';
|
||||||
|
import { CrashOverlay } from './CrashScreen';
|
||||||
|
|
||||||
|
export default function AppProviders({ children }: { children: ReactNode }) {
|
||||||
|
const { colors: c } = useTheme();
|
||||||
|
return (
|
||||||
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
|
<SafeAreaProvider>
|
||||||
|
<NavigationBar hidden style="light" />
|
||||||
|
<ThemeProvider value={DarkTheme}>
|
||||||
|
<AnimatedSplashOverlay />
|
||||||
|
<View style={{ flex: 1, backgroundColor: c.kioskBg }}>{children}</View>
|
||||||
|
<CrashOverlay />
|
||||||
|
</ThemeProvider>
|
||||||
|
</SafeAreaProvider>
|
||||||
|
</GestureHandlerRootView>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
src/app/ui/CrashScreen.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import type { ErrorBoundaryProps } from 'expo-router';
|
||||||
|
|
||||||
|
interface CrashInfo {
|
||||||
|
message: string;
|
||||||
|
stack: string;
|
||||||
|
fatal: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCrashInfo(error: unknown, fatal: boolean): CrashInfo {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return { message: `${error.name}: ${error.message}`, stack: error.stack ?? '', fatal };
|
||||||
|
}
|
||||||
|
return { message: String(error), stack: '', fatal };
|
||||||
|
}
|
||||||
|
|
||||||
|
function CrashView({ info, onRetry }: { info: CrashInfo; onRetry?: () => void }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.root}>
|
||||||
|
<Text style={styles.title}>{info.fatal ? 'Фатальная ошибка' : 'Ошибка приложения'}</Text>
|
||||||
|
<Text style={styles.message}>{info.message}</Text>
|
||||||
|
<ScrollView style={styles.stackBox} contentContainerStyle={styles.stackContent}>
|
||||||
|
<Text style={styles.stack}>{info.stack || 'stack недоступен'}</Text>
|
||||||
|
</ScrollView>
|
||||||
|
{onRetry ? (
|
||||||
|
<TouchableOpacity style={styles.button} onPress={onRetry}>
|
||||||
|
<Text style={styles.buttonText}>Повторить</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RouterErrorBoundary({ error, retry }: ErrorBoundaryProps) {
|
||||||
|
return <CrashView info={toCrashInfo(error, false)} onRetry={() => void retry()} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CrashOverlay() {
|
||||||
|
const [info, setInfo] = useState<CrashInfo | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const globalHandlers = globalThis as typeof globalThis & {
|
||||||
|
ErrorUtils?: {
|
||||||
|
getGlobalHandler(): (error: unknown, isFatal?: boolean) => void;
|
||||||
|
setGlobalHandler(handler: (error: unknown, isFatal?: boolean) => void): void;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const utils = globalHandlers.ErrorUtils;
|
||||||
|
if (!utils) return;
|
||||||
|
|
||||||
|
const previous = utils.getGlobalHandler();
|
||||||
|
utils.setGlobalHandler((error, isFatal) => {
|
||||||
|
setInfo(toCrashInfo(error, Boolean(isFatal)));
|
||||||
|
previous(error, false);
|
||||||
|
});
|
||||||
|
return () => utils.setGlobalHandler(previous);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!info) return null;
|
||||||
|
return (
|
||||||
|
<View style={styles.overlay}>
|
||||||
|
<CrashView info={info} onRetry={() => setInfo(null)} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: { ...StyleSheet.absoluteFill, zIndex: 9999 },
|
||||||
|
root: { flex: 1, backgroundColor: '#1B0F0F', padding: 24, paddingTop: 48 },
|
||||||
|
title: { color: '#FF6B6B', fontSize: 22, fontWeight: 'bold', marginBottom: 12 },
|
||||||
|
message: { color: '#FFFFFF', fontSize: 15, marginBottom: 16 },
|
||||||
|
stackBox: { flex: 1, backgroundColor: '#000000', borderRadius: 8 },
|
||||||
|
stackContent: { padding: 12 },
|
||||||
|
stack: { color: '#B9C0C7', fontSize: 11, fontFamily: 'monospace' },
|
||||||
|
button: { marginTop: 16, backgroundColor: '#FF6B6B', borderRadius: 8, paddingVertical: 14, alignItems: 'center' },
|
||||||
|
buttonText: { color: '#1B0F0F', fontWeight: 'bold', fontSize: 16 },
|
||||||
|
});
|
||||||
197
src/app/ui/KioskApp.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { ActivityIndicator, Modal, StatusBar, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
|
||||||
|
import { authSettingsStore, kioskFlagsStore, settingsStore, tabletSettingsStore } from '@/entities/app-settings';
|
||||||
|
import { onDutyStore } from '@/entities/dispatcher';
|
||||||
|
import { sessionStore } from '@/entities/session';
|
||||||
|
import { ChatPage, type ChatDashboardProps } from '@/pages/chat';
|
||||||
|
import { LiveDriverDashboard } from './LiveDriverDashboard';
|
||||||
|
import { DriverPage } from '@/pages/driver';
|
||||||
|
import { GoodbyePage } from '@/pages/goodbye';
|
||||||
|
import { LoginPage } from '@/pages/login';
|
||||||
|
import { ShiftLimitWarningModal, useShiftLimitTimer } from '@/features/session-guard';
|
||||||
|
import { useKioskNotifQueue } from '@/widgets/notification-stack';
|
||||||
|
import { useMockData, useTheme, withAlpha } from '@/shared/config';
|
||||||
|
|
||||||
|
function KioskApp() {
|
||||||
|
'use no memo'; // MobX observable changes must invalidate the rendered screen.
|
||||||
|
|
||||||
|
const { colors: c, radii } = useTheme();
|
||||||
|
const [goodbyeScreen, setGoodbyeScreen] = useState(false);
|
||||||
|
const [, setMessengerFullscreen] = useState(false);
|
||||||
|
const [activeVoiceMsgId, setActiveVoiceMsgId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void sessionStore.init().then(() => {
|
||||||
|
tabletSettingsStore.restore();
|
||||||
|
settingsStore.start();
|
||||||
|
authSettingsStore.start();
|
||||||
|
kioskFlagsStore.start();
|
||||||
|
onDutyStore.start();
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
sessionStore.stop();
|
||||||
|
settingsStore.stop();
|
||||||
|
authSettingsStore.stop();
|
||||||
|
kioskFlagsStore.stop();
|
||||||
|
onDutyStore.stop();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { screen, driver, messageStore } = sessionStore;
|
||||||
|
|
||||||
|
const {
|
||||||
|
warningOpen: shiftLimitWarningOpen,
|
||||||
|
secondsLeft: shiftLimitSecondsLeft,
|
||||||
|
dismissWarning: dismissShiftLimitWarning,
|
||||||
|
} = useShiftLimitTimer({
|
||||||
|
active: screen === 'main',
|
||||||
|
shiftStart: driver?.shiftStart,
|
||||||
|
onAutoEnd: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const notifQueue = useKioskNotifQueue({
|
||||||
|
messages: sessionStore.messages,
|
||||||
|
pendingImportant: sessionStore.pendingImportant,
|
||||||
|
sendMessage: messageStore.send,
|
||||||
|
confirmImportant: messageStore.confirmImportant,
|
||||||
|
markRead: messageStore.markRead,
|
||||||
|
addDispatcherMessage: messageStore.addDispatcherMessage,
|
||||||
|
setMessengerFullscreen,
|
||||||
|
setActiveVoiceMsgId,
|
||||||
|
popupNotifEnabled: kioskFlagsStore.popupNotifEnabled,
|
||||||
|
popupMsgEnabled: kioskFlagsStore.popupMsgEnabled,
|
||||||
|
popupImportantEnabled: kioskFlagsStore.popupImportantEnabled,
|
||||||
|
popupSoundEnabled: kioskFlagsStore.popupSoundEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleEndShiftConfirm = () => setGoodbyeScreen(true);
|
||||||
|
|
||||||
|
if (!sessionStore.ready) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.splash, { backgroundColor: c.kioskBg }]}>
|
||||||
|
<StatusBar hidden />
|
||||||
|
<View style={[styles.splashLogo, { backgroundColor: c.primary }]}>
|
||||||
|
<Ionicons name="bus" size={72} color={c.primaryForeground} />
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.splashTitle, { color: c.kioskOnSurface }]}>ИРИДА</Text>
|
||||||
|
<Text style={[styles.splashSubtitle, { color: c.mutedForeground }]}>Мобильные рабочие места</Text>
|
||||||
|
<ActivityIndicator size="large" color={c.primary} style={{ marginTop: 40 }} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.root, { backgroundColor: c.kioskBg }]}>
|
||||||
|
<StatusBar hidden />
|
||||||
|
|
||||||
|
{screen === 'login' && (
|
||||||
|
<LoginPage
|
||||||
|
onLogin={(employeeId, pin) => void sessionStore.login(employeeId, pin)}
|
||||||
|
onLoginMrm={(login, password) => void sessionStore.loginMrm(login, password)}
|
||||||
|
onSmsLoginSuccess={sessionStore.loginSmsVerified}
|
||||||
|
error={sessionStore.loginError}
|
||||||
|
loading={sessionStore.loginLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{}
|
||||||
|
|
||||||
|
{screen === 'main' && driver && !goodbyeScreen && (
|
||||||
|
<DriverPage
|
||||||
|
activeVoiceMsgId={activeVoiceMsgId}
|
||||||
|
top={notifQueue.top}
|
||||||
|
stackCount={notifQueue.stackCount}
|
||||||
|
onEndShiftConfirm={handleEndShiftConfirm}
|
||||||
|
dismissTopAndReply={notifQueue.dismissTopAndReply}
|
||||||
|
dismissTopWithAnswer={notifQueue.dismissTopWithAnswer}
|
||||||
|
handlePlayVoiceInNotif={notifQueue.handlePlayVoiceInNotif}
|
||||||
|
handleForwardNotif={notifQueue.handleForwardNotif}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{shiftLimitWarningOpen && !goodbyeScreen && (
|
||||||
|
<ShiftLimitWarningModal
|
||||||
|
open={shiftLimitWarningOpen}
|
||||||
|
secondsLeft={shiftLimitSecondsLeft}
|
||||||
|
onEndNow={() => {
|
||||||
|
dismissShiftLimitWarning();
|
||||||
|
setGoodbyeScreen(true);
|
||||||
|
}}
|
||||||
|
onDismiss={dismissShiftLimitWarning}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{goodbyeScreen && (
|
||||||
|
<GoodbyePage onComplete={() => setGoodbyeScreen(false)} onLogout={() => void sessionStore.logout()} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sessionStore.sessionKicked && (
|
||||||
|
<Modal statusBarTranslucent navigationBarTranslucent visible transparent animationType="fade">
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<View style={[styles.kickCard, { backgroundColor: c.card, borderColor: c.border, borderRadius: radii.widget }]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.kickHeader,
|
||||||
|
{ backgroundColor: withAlpha(c.destructive, 0.08), borderBottomColor: c.border },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={[styles.kickIconBox, { backgroundColor: withAlpha(c.destructive, 0.15) }]}>
|
||||||
|
<Text style={styles.kickIconEmoji}>🚪</Text>
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={[styles.kickTitle, { color: c.cardForeground }]}>Вы вошли с другого планшета</Text>
|
||||||
|
<Text style={[styles.kickSubtitle, { color: c.mutedForeground }]}>Сессия закрыта автоматически</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.kickBody, { color: c.cardForeground }]}>
|
||||||
|
Под вашим табельным номером был выполнен вход на другом устройстве.
|
||||||
|
В целях безопасности на одном водителе разрешена только одна активная сессия.
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.kickHint, { color: c.mutedForeground }]}>
|
||||||
|
Войдите снова, если хотите продолжить работу на этом планшете.
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.kickBtn, { backgroundColor: c.primary, borderRadius: radii.button }]}
|
||||||
|
onPress={sessionStore.dismissSessionKicked}
|
||||||
|
>
|
||||||
|
<Text style={[styles.kickBtnText, { color: c.primaryForeground }]}>Понятно</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1 },
|
||||||
|
splash: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
||||||
|
splashLogo: { width: 140, height: 140, borderRadius: 36, justifyContent: 'center', alignItems: 'center', marginBottom: 24 },
|
||||||
|
splashTitle: { fontSize: 52, fontWeight: '900', letterSpacing: 3 },
|
||||||
|
splashSubtitle: { fontSize: 18, marginTop: 8, fontWeight: '500' },
|
||||||
|
modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.7)', justifyContent: 'center', alignItems: 'center', padding: 24 },
|
||||||
|
kickCard: { width: '100%', maxWidth: 440, overflow: 'hidden', borderWidth: 1 },
|
||||||
|
kickHeader: { flexDirection: 'row', alignItems: 'center', gap: 16, padding: 24, paddingBottom: 20, borderBottomWidth: 1 },
|
||||||
|
kickIconBox: { width: 48, height: 48, borderRadius: 16, justifyContent: 'center', alignItems: 'center' },
|
||||||
|
kickIconEmoji: { fontSize: 24 },
|
||||||
|
kickTitle: { fontSize: 18, fontWeight: 'bold' },
|
||||||
|
kickSubtitle: { fontSize: 13, marginTop: 2 },
|
||||||
|
kickBody: { fontSize: 15, lineHeight: 22, padding: 24, paddingBottom: 12 },
|
||||||
|
kickHint: { fontSize: 13, paddingHorizontal: 24, paddingBottom: 20 },
|
||||||
|
kickBtn: { margin: 24, marginTop: 4, paddingVertical: 14, alignItems: 'center' },
|
||||||
|
kickBtnText: { fontWeight: 'bold', fontSize: 17 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const DemoKioskApp = observer(KioskApp);
|
||||||
|
|
||||||
|
function renderLiveDashboard(props: ChatDashboardProps) {
|
||||||
|
return <LiveDriverDashboard {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DriverApp() {
|
||||||
|
return useMockData ? <DemoKioskApp /> : <ChatPage renderDashboard={renderLiveDashboard} />;
|
||||||
|
}
|
||||||
53
src/app/ui/LiveDriverDashboard.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { View } from 'react-native';
|
||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
|
import { MainDashboard } from '@/pages/driver';
|
||||||
|
import type { ChatDashboardProps } from '@/pages/chat';
|
||||||
|
import type { Driver } from '@/entities/driver';
|
||||||
|
import { SidebarMenu } from '@/widgets/sidebar-menu';
|
||||||
|
import { BreakModal } from '@/features/take-break';
|
||||||
|
import { SosModal } from '@/features/sos-call';
|
||||||
|
import { EndShiftModal } from '@/features/end-shift';
|
||||||
|
import { themeStore } from '@/shared/config';
|
||||||
|
|
||||||
|
export const LiveDriverDashboard = observer(function LiveDriverDashboard({
|
||||||
|
children, session, messages, connection, busy, onSend, onLogout, onResetDevice,
|
||||||
|
}: ChatDashboardProps) {
|
||||||
|
'use no memo';
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const [breakOpen, setBreakOpen] = useState(false);
|
||||||
|
const [sosOpen, setSosOpen] = useState(false);
|
||||||
|
const [endShiftOpen, setEndShiftOpen] = useState(false);
|
||||||
|
const theme = themeStore.isDark ? 'dark' : 'light';
|
||||||
|
const driver: Driver = {
|
||||||
|
id: session.user.id,
|
||||||
|
name: session.user.fullName || session.user.email || '',
|
||||||
|
vehicleNumber: '—',
|
||||||
|
routeNumber: '—',
|
||||||
|
vehicleType: 'technical',
|
||||||
|
shiftStart: session.shift?.startedAt ? new Date(session.shift.startedAt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }) : '—',
|
||||||
|
};
|
||||||
|
const send = (text: string) => { void onSend(text); };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<MainDashboard
|
||||||
|
driver={driver} messages={messages} speed={0} isMoving={false} currentStopIndex={0}
|
||||||
|
connection={connection} unreadCount={messages.filter(message => !message.read).length}
|
||||||
|
theme={theme} pendingCount={0} activeVoiceMsgId={null}
|
||||||
|
onOpenMenu={() => setMenuOpen(true)} onSendMessage={send}
|
||||||
|
onToggleTheme={() => themeStore.setDark(!themeStore.isDark)}
|
||||||
|
onBreak={() => setBreakOpen(true)} onEndShift={() => { if (!busy) setEndShiftOpen(true); }}
|
||||||
|
onSos={() => setSosOpen(true)} messengerContent={children}
|
||||||
|
/>
|
||||||
|
<SidebarMenu isOpen={menuOpen} onClose={() => setMenuOpen(false)} driver={driver}
|
||||||
|
messages={messages} theme={theme} onResetDevice={onResetDevice} onSetTheme={value => themeStore.setDark(value === 'dark')}
|
||||||
|
onLogout={() => { setMenuOpen(false); if (!busy) setEndShiftOpen(true); }}
|
||||||
|
onSupportContact={contact => { send(`Связь: ${contact.name}`); setMenuOpen(false); }} />
|
||||||
|
<BreakModal isOpen={breakOpen} onClose={() => setBreakOpen(false)} />
|
||||||
|
<SosModal isOpen={sosOpen} onClose={() => setSosOpen(false)} onSend={send} driver={driver} />
|
||||||
|
<EndShiftModal open={endShiftOpen} driver={driver} onClose={() => setEndShiftOpen(false)}
|
||||||
|
onConfirm={() => { setEndShiftOpen(false); if (!busy) void onLogout(); }} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
24
src/entities/app-settings/api/authSettingsApi.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { request } from '@/shared/api';
|
||||||
|
import { endpoints } from '@/shared/config';
|
||||||
|
|
||||||
|
export type AuthType = 'standard' | 'sms';
|
||||||
|
|
||||||
|
interface AuthSettings {
|
||||||
|
tablet: AuthType;
|
||||||
|
dashboard: AuthType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAuthSettings(): Promise<AuthSettings> {
|
||||||
|
try {
|
||||||
|
const data = await request<{ tablet?: { auth_type?: AuthType }; dashboard?: { auth_type?: AuthType } }>(
|
||||||
|
`${endpoints.authSettings}/`,
|
||||||
|
{ query: { action: 'get' } },
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
tablet: data.tablet?.auth_type ?? 'standard',
|
||||||
|
dashboard: data.dashboard?.auth_type ?? 'standard',
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { tablet: 'standard', dashboard: 'standard' };
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/entities/app-settings/api/kioskSettingsApi.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { request } from '@/shared/api';
|
||||||
|
import { endpoints } from '@/shared/config';
|
||||||
|
|
||||||
|
export async function loadRemoteSetting<T>(key: string, defaults: T): Promise<T> {
|
||||||
|
try {
|
||||||
|
const data = await request<Partial<T>>(`${endpoints.kioskSettings}/`, { query: { key } });
|
||||||
|
if (!data || Object.keys(data).length === 0) return defaults;
|
||||||
|
return { ...defaults, ...data };
|
||||||
|
} catch {
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveRemoteSetting(key: string, value: unknown): Promise<void> {
|
||||||
|
await request(`${endpoints.kioskSettings}/`, { method: 'POST', body: { key, value } }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadKioskFlag(key: string): Promise<boolean | null> {
|
||||||
|
try {
|
||||||
|
const data = await request<{ enabled?: boolean }>(endpoints.kioskSettings, { query: { key } });
|
||||||
|
return typeof data?.enabled === 'boolean' ? data.enabled : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/entities/app-settings/index.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export type { AppSettings, FeatureFlags, TabletHeaderConfig, TransportType } from './model/types';
|
||||||
|
export { settingsStore } from './model/settingsStore';
|
||||||
|
export { authSettingsStore } from './model/authSettingsStore';
|
||||||
|
export { kioskFlagsStore } from './model/kioskFlagsStore';
|
||||||
|
export { tabletSettingsStore, type TabletSettings } from './model/tabletSettingsStore';
|
||||||
|
export {
|
||||||
|
loadShiftLimit,
|
||||||
|
loadShiftLimitRemote,
|
||||||
|
saveShiftLimit,
|
||||||
|
defaultShiftLimit,
|
||||||
|
type ShiftLimitSettings,
|
||||||
|
} from './model/shiftLimit';
|
||||||
|
export type { AuthType } from './api/authSettingsApi';
|
||||||
|
export { loadRemoteSetting, saveRemoteSetting } from './api/kioskSettingsApi';
|
||||||
40
src/entities/app-settings/model/authSettingsStore.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { makeAutoObservable, runInAction } from 'mobx';
|
||||||
|
import { appEvents, type AppEvent } from '@/shared/lib';
|
||||||
|
import { fetchAuthSettings, type AuthType } from '../api/authSettingsApi';
|
||||||
|
|
||||||
|
class AuthSettingsStore {
|
||||||
|
tablet: AuthType = 'standard';
|
||||||
|
dashboard: AuthType = 'standard';
|
||||||
|
loading = true;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
makeAutoObservable(this, {}, { autoBind: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
void this.load();
|
||||||
|
appEvents.on('auth-settings-changed', this.applyExternal);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
appEvents.off('auth-settings-changed', this.applyExternal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async load(): Promise<void> {
|
||||||
|
const settings = await fetchAuthSettings();
|
||||||
|
runInAction(() => {
|
||||||
|
this.tablet = settings.tablet;
|
||||||
|
this.dashboard = settings.dashboard;
|
||||||
|
this.loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyExternal(event: AppEvent): void {
|
||||||
|
const detail = event.detail as { tablet?: AuthType; dashboard?: AuthType } | undefined;
|
||||||
|
if (!detail) return;
|
||||||
|
if (detail.tablet) this.tablet = detail.tablet;
|
||||||
|
if (detail.dashboard) this.dashboard = detail.dashboard;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authSettingsStore = new AuthSettingsStore();
|
||||||
58
src/entities/app-settings/model/kioskFlagsStore.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { makeAutoObservable, runInAction } from 'mobx';
|
||||||
|
import { appEvents } from '@/shared/lib';
|
||||||
|
import { loadKioskFlag } from '../api/kioskSettingsApi';
|
||||||
|
|
||||||
|
const FLAGS_INTERVAL = 10_000;
|
||||||
|
|
||||||
|
class KioskFlagsStore {
|
||||||
|
directReplyEnabled = false;
|
||||||
|
chatLabelEnabled = true;
|
||||||
|
popupNotifEnabled = true;
|
||||||
|
popupMsgEnabled = true;
|
||||||
|
popupImportantEnabled = true;
|
||||||
|
popupSoundEnabled = true;
|
||||||
|
|
||||||
|
private timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
makeAutoObservable(this, {}, { autoBind: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (this.timer) return;
|
||||||
|
void this.load();
|
||||||
|
this.timer = setInterval(() => void this.load(), FLAGS_INTERVAL);
|
||||||
|
appEvents.on('kiosk-settings-updated', this.reload);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.timer) clearInterval(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
appEvents.off('kiosk-settings-updated', this.reload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private reload(): void {
|
||||||
|
void this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async load(): Promise<void> {
|
||||||
|
const [directReply, chatLabel, notif, message, important, sound] = await Promise.all([
|
||||||
|
loadKioskFlag('direct_reply_enabled'),
|
||||||
|
loadKioskFlag('chat_label_enabled'),
|
||||||
|
loadKioskFlag('tablet_popup_notification_enabled'),
|
||||||
|
loadKioskFlag('tablet_popup_message_enabled'),
|
||||||
|
loadKioskFlag('tablet_popup_important_enabled'),
|
||||||
|
loadKioskFlag('tablet_popup_sound_enabled'),
|
||||||
|
]);
|
||||||
|
runInAction(() => {
|
||||||
|
if (directReply != null) this.directReplyEnabled = directReply;
|
||||||
|
if (chatLabel != null) this.chatLabelEnabled = chatLabel;
|
||||||
|
if (notif != null) this.popupNotifEnabled = notif;
|
||||||
|
if (message != null) this.popupMsgEnabled = message;
|
||||||
|
if (important != null) this.popupImportantEnabled = important;
|
||||||
|
if (sound != null) this.popupSoundEnabled = sound;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const kioskFlagsStore = new KioskFlagsStore();
|
||||||
104
src/entities/app-settings/model/settingsStore.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { makeAutoObservable, reaction, runInAction } from 'mobx';
|
||||||
|
import { getSetting, listSettings } from '@/shared/api';
|
||||||
|
import { themeStore, type BrandColors } from '@/shared/config';
|
||||||
|
import { appEvents, storage } from '@/shared/lib';
|
||||||
|
import { loadRemoteSetting } from '../api/kioskSettingsApi';
|
||||||
|
import type { AppSettings } from './types';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'app_settings';
|
||||||
|
const SETTINGS_INTERVAL = 60_000;
|
||||||
|
const HEADER_INTERVAL = 4_000;
|
||||||
|
const CITY_INTERVAL = 120_000;
|
||||||
|
|
||||||
|
const defaultSettings: AppSettings = {
|
||||||
|
scheduleDeviationSource: 'real',
|
||||||
|
notifVolume: 70,
|
||||||
|
brightness: 80,
|
||||||
|
wifi: true,
|
||||||
|
bluetooth: false,
|
||||||
|
language: 'ru',
|
||||||
|
};
|
||||||
|
|
||||||
|
function storedSettings(): AppSettings {
|
||||||
|
const stored = storage.getJson<AppSettings>(STORAGE_KEY);
|
||||||
|
return stored ? { ...defaultSettings, ...stored } : { ...defaultSettings };
|
||||||
|
}
|
||||||
|
|
||||||
|
class SettingsStore {
|
||||||
|
settings: AppSettings = storedSettings();
|
||||||
|
|
||||||
|
private timers: ReturnType<typeof setInterval>[] = [];
|
||||||
|
private lastHeader = '';
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
makeAutoObservable(this, {}, { autoBind: true });
|
||||||
|
reaction(
|
||||||
|
() => this.settings.brandColorsTablet as BrandColors | undefined,
|
||||||
|
(brand) => themeStore.setBrand(brand ?? null),
|
||||||
|
{ fireImmediately: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (this.timers.length > 0) return;
|
||||||
|
void this.pullSettings();
|
||||||
|
void this.pullHeader();
|
||||||
|
void this.pullCity();
|
||||||
|
this.timers = [
|
||||||
|
setInterval(() => void this.pullSettings(), SETTINGS_INTERVAL),
|
||||||
|
setInterval(() => void this.pullHeader(), HEADER_INTERVAL),
|
||||||
|
setInterval(() => void this.pullCity(), CITY_INTERVAL),
|
||||||
|
];
|
||||||
|
appEvents.on('app-settings:force-pull', this.forcePull);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
for (const timer of this.timers) clearInterval(timer);
|
||||||
|
this.timers = [];
|
||||||
|
appEvents.off('app-settings:force-pull', this.forcePull);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(patch: Partial<AppSettings>): void {
|
||||||
|
this.settings = { ...this.settings, ...patch };
|
||||||
|
storage.setJson(STORAGE_KEY, this.settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.settings = { ...defaultSettings };
|
||||||
|
storage.remove(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private forcePull(): void {
|
||||||
|
this.lastHeader = '';
|
||||||
|
void this.pullSettings();
|
||||||
|
void this.pullHeader();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pullSettings(): Promise<void> {
|
||||||
|
const all = await listSettings();
|
||||||
|
const patch: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(all)) {
|
||||||
|
if (key.startsWith('ui.') && value != null) patch[key.slice(3)] = value;
|
||||||
|
}
|
||||||
|
if (Object.keys(patch).length === 0) return;
|
||||||
|
runInAction(() => this.update(patch as Partial<AppSettings>));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pullHeader(): Promise<void> {
|
||||||
|
const value = await getSetting<AppSettings['tabletHeader']>('ui.tabletHeader');
|
||||||
|
if (value == null) return;
|
||||||
|
const serialized = JSON.stringify(value);
|
||||||
|
if (serialized === this.lastHeader) return;
|
||||||
|
this.lastHeader = serialized;
|
||||||
|
runInAction(() => this.update({ tabletHeader: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pullCity(): Promise<void> {
|
||||||
|
const value = await loadRemoteSetting<{ city?: string; customCityName?: string }>('global_city', {});
|
||||||
|
if (!value.city) return;
|
||||||
|
if (this.settings.city === value.city && this.settings.customCityName === value.customCityName) return;
|
||||||
|
runInAction(() => this.update({ city: value.city, customCityName: value.customCityName }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const settingsStore = new SettingsStore();
|
||||||
39
src/entities/app-settings/model/shiftLimit.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { getSetting } from '@/shared/api';
|
||||||
|
import { appEvents, storage } from '@/shared/lib';
|
||||||
|
|
||||||
|
export interface ShiftLimitSettings {
|
||||||
|
limitEnabled: boolean;
|
||||||
|
maxHours: number;
|
||||||
|
popupEnabled: boolean;
|
||||||
|
popupMinutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'shift_limit_settings_v1';
|
||||||
|
const REMOTE_KEY = 'ui.shiftLimitSettings';
|
||||||
|
|
||||||
|
export const defaultShiftLimit: ShiftLimitSettings = {
|
||||||
|
limitEnabled: true,
|
||||||
|
maxHours: 10,
|
||||||
|
popupEnabled: true,
|
||||||
|
popupMinutes: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function loadShiftLimit(): ShiftLimitSettings {
|
||||||
|
const stored = storage.getJson<Partial<ShiftLimitSettings>>(STORAGE_KEY);
|
||||||
|
return stored ? { ...defaultShiftLimit, ...stored } : { ...defaultShiftLimit };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadShiftLimitRemote(): Promise<ShiftLimitSettings> {
|
||||||
|
const remote = await getSetting<Partial<ShiftLimitSettings>>(REMOTE_KEY);
|
||||||
|
if (remote && typeof remote === 'object') {
|
||||||
|
const merged = { ...defaultShiftLimit, ...remote };
|
||||||
|
storage.setJson(STORAGE_KEY, merged);
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
return loadShiftLimit();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveShiftLimit(settings: ShiftLimitSettings): void {
|
||||||
|
storage.setJson(STORAGE_KEY, settings);
|
||||||
|
appEvents.emit('shift-limit-settings-changed');
|
||||||
|
}
|
||||||
36
src/entities/app-settings/model/tabletSettingsStore.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { makeAutoObservable, runInAction } from 'mobx';
|
||||||
|
import { applyScreenBrightness, storage } from '@/shared/lib';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'kiosk_tablet_settings';
|
||||||
|
|
||||||
|
export interface TabletSettings {
|
||||||
|
brightness: number;
|
||||||
|
notifVolume: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaults: TabletSettings = {
|
||||||
|
brightness: 80,
|
||||||
|
notifVolume: 70,
|
||||||
|
};
|
||||||
|
|
||||||
|
class TabletSettingsStore {
|
||||||
|
settings: TabletSettings = { ...defaults };
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
makeAutoObservable(this, {}, { autoBind: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
restore(): void {
|
||||||
|
const stored = storage.getJson<Partial<TabletSettings>>(STORAGE_KEY);
|
||||||
|
if (stored) runInAction(() => (this.settings = { ...defaults, ...stored }));
|
||||||
|
void applyScreenBrightness(this.settings.brightness);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(patch: Partial<TabletSettings>): void {
|
||||||
|
this.settings = { ...this.settings, ...patch };
|
||||||
|
storage.setJson(STORAGE_KEY, this.settings);
|
||||||
|
if (patch.brightness != null) void applyScreenBrightness(patch.brightness);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tabletSettingsStore = new TabletSettingsStore();
|
||||||
79
src/entities/app-settings/model/types.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
|
||||||
|
export type TransportType = 'tram' | 'trolleybus' | 'bus' | 'electrobus' | 'technical' | 'custom';
|
||||||
|
|
||||||
|
export interface FeatureFlags {
|
||||||
|
showMap: boolean;
|
||||||
|
showSpeed: boolean;
|
||||||
|
showRoute: boolean;
|
||||||
|
showMessenger: boolean;
|
||||||
|
showBreak: boolean;
|
||||||
|
showTelemetry: boolean;
|
||||||
|
showServiceRequests: boolean;
|
||||||
|
showSchedule: boolean;
|
||||||
|
showDocuments: boolean;
|
||||||
|
showDiagnostics: boolean;
|
||||||
|
showVehicles: boolean;
|
||||||
|
showDrivers: boolean;
|
||||||
|
showRatings: boolean;
|
||||||
|
showNotifications: boolean;
|
||||||
|
showVoting: boolean;
|
||||||
|
showTasks: boolean;
|
||||||
|
showDepot: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TabletHeaderConfig {
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppSettings {
|
||||||
|
city?: string;
|
||||||
|
customCityName?: string;
|
||||||
|
cityOverride?: string;
|
||||||
|
cityLat?: number;
|
||||||
|
cityLon?: number;
|
||||||
|
carrierName?: string;
|
||||||
|
carrierDescription?: string;
|
||||||
|
carrierLogo?: string;
|
||||||
|
carrierLogoColor?: string;
|
||||||
|
carrierSidebarLogoColor?: string;
|
||||||
|
appCarrierLogo?: string;
|
||||||
|
appCarrierLogoColor?: string;
|
||||||
|
tabletLoginHtmlBg?: string;
|
||||||
|
tabletLoginBgMode?: string;
|
||||||
|
tabletLoginBgImage?: string;
|
||||||
|
tabletLoginBgScale?: number;
|
||||||
|
tabletLoginBgOpacity?: number;
|
||||||
|
tabletLoginBgFixed?: boolean;
|
||||||
|
tabletLoginBgColor?: string;
|
||||||
|
tabletLoginLogoSize?: number;
|
||||||
|
tabletLoginLogoX?: number;
|
||||||
|
tabletLoginLogoY?: number;
|
||||||
|
tabletLoginLogoBg?: string;
|
||||||
|
tabletLoginFormX?: number;
|
||||||
|
tabletLoginFormY?: number;
|
||||||
|
tabletLoginTitleText?: string;
|
||||||
|
tabletLoginSubtitleText?: string;
|
||||||
|
tabletLoginFont?: string;
|
||||||
|
tabletLoginFontSize?: number;
|
||||||
|
tabletLoginTitleColor?: string;
|
||||||
|
tabletLoginSubtitleColor?: string;
|
||||||
|
tabletLoginFormBg?: string;
|
||||||
|
tabletLoginFormRadius?: number;
|
||||||
|
tabletLoginAccentIcon?: string;
|
||||||
|
tabletHeader?: TabletHeaderConfig;
|
||||||
|
notifVolume?: number;
|
||||||
|
brightness?: number;
|
||||||
|
wifi?: boolean;
|
||||||
|
bluetooth?: boolean;
|
||||||
|
language?: string;
|
||||||
|
scheduleDeviationSource?: 'demo' | 'real';
|
||||||
|
featuresTablet?: Partial<FeatureFlags>;
|
||||||
|
blocksTablet?: Record<string, unknown>;
|
||||||
|
brandColorsTablet?: Record<string, string>;
|
||||||
|
sosTurnEnabled?: boolean;
|
||||||
|
sosTurnUrl?: string;
|
||||||
|
sosTurnUsername?: string;
|
||||||
|
sosTurnCredential?: string;
|
||||||
|
sosTurnForceRelay?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
34
src/entities/dispatcher/api/dispatcherApi.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { authToken, request } from '@/shared/api';
|
||||||
|
import { endpoints, useMockData } from '@/shared/config';
|
||||||
|
import { sendOrQueue } from '@/shared/lib';
|
||||||
|
import type { OnDutyDispatcher, OnDutyResult } from '../model/types';
|
||||||
|
import { mockOnDutyResult } from '../mocks/onDuty';
|
||||||
|
|
||||||
|
export async function fetchOnDutyDispatchers(): Promise<OnDutyResult> {
|
||||||
|
if (useMockData) return mockOnDutyResult;
|
||||||
|
const token = authToken();
|
||||||
|
if (!token) return { dispatchers: [], phrase: '' };
|
||||||
|
try {
|
||||||
|
const data = await request<{ dispatchers?: Array<{ id: number; name: string; role: OnDutyDispatcher['role'] }>; phrase?: string }>(
|
||||||
|
endpoints.driverAuth,
|
||||||
|
{ query: { action: 'on_duty_dispatchers' }, auth: true },
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
dispatchers: (data.dispatchers ?? []).map((item) => ({ id: String(item.id), name: item.name, role: item.role })),
|
||||||
|
phrase: data.phrase ?? '',
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { dispatchers: [], phrase: '' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rateDispatcher(rating: number, ratings?: Record<string, number>): Promise<void> {
|
||||||
|
if (useMockData) return;
|
||||||
|
const token = authToken();
|
||||||
|
if (!token) return;
|
||||||
|
await sendOrQueue('rate_dispatcher', `${endpoints.driverAuth}/?action=rate_dispatcher`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Auth-Token': token },
|
||||||
|
body: JSON.stringify({ rating, ratings }),
|
||||||
|
});
|
||||||
|
}
|
||||||
4
src/entities/dispatcher/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export type { OnDutyDispatcher, OnDutyResult } from './model/types';
|
||||||
|
export { onDutyStore } from './model/onDutyStore';
|
||||||
|
export { cachedOnDuty, cacheOnDuty, cacheStaffList } from './model/onDutyCache';
|
||||||
|
export { fetchOnDutyDispatchers, rateDispatcher } from './api/dispatcherApi';
|
||||||
9
src/entities/dispatcher/mocks/onDuty.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { OnDutyResult } from '../model/types';
|
||||||
|
|
||||||
|
export const mockOnDutyResult: OnDutyResult = {
|
||||||
|
dispatchers: [
|
||||||
|
{ id: '1', name: 'Иванова Анна Петровна', role: 'dispatcher' },
|
||||||
|
{ id: '2', name: 'Петров Максим Сергеевич', role: 'senior_dispatcher' },
|
||||||
|
],
|
||||||
|
phrase: 'Спасибо за отличную смену! Хорошего отдыха.',
|
||||||
|
};
|
||||||
38
src/entities/dispatcher/model/onDutyCache.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { appEvents, storage } from '@/shared/lib';
|
||||||
|
import type { OnDutyDispatcher } from './types';
|
||||||
|
|
||||||
|
const ON_DUTY_KEY = 'dispatchers_on_duty_v1';
|
||||||
|
const STAFF_KEY = 'staff_list_v1';
|
||||||
|
|
||||||
|
interface StaffMember {
|
||||||
|
id?: string | number;
|
||||||
|
name?: string;
|
||||||
|
fullName?: string;
|
||||||
|
role?: string;
|
||||||
|
onDuty?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cachedOnDuty(): OnDutyDispatcher[] {
|
||||||
|
const direct = storage.getJson<OnDutyDispatcher[]>(ON_DUTY_KEY);
|
||||||
|
if (Array.isArray(direct) && direct.length > 0) return direct;
|
||||||
|
|
||||||
|
const staff = storage.getJson<StaffMember[]>(STAFF_KEY);
|
||||||
|
if (!Array.isArray(staff)) return [];
|
||||||
|
return staff
|
||||||
|
.filter((person) => (person.role === 'dispatcher' || person.role === 'senior_dispatcher') && person.onDuty !== false)
|
||||||
|
.map((person, index) => ({
|
||||||
|
id: String(person.id ?? `staff-${index}`),
|
||||||
|
name: person.fullName || person.name || 'Без имени',
|
||||||
|
role: (person.role as OnDutyDispatcher['role']) ?? 'dispatcher',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cacheOnDuty(list: OnDutyDispatcher[]): void {
|
||||||
|
storage.setJson(ON_DUTY_KEY, list);
|
||||||
|
appEvents.emit('dispatchers-on-duty-changed');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cacheStaffList(list: unknown[]): void {
|
||||||
|
storage.setJson(STAFF_KEY, list);
|
||||||
|
appEvents.emit('staff-list-changed');
|
||||||
|
}
|
||||||
45
src/entities/dispatcher/model/onDutyStore.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { makeAutoObservable, runInAction } from 'mobx';
|
||||||
|
import { appEvents } from '@/shared/lib';
|
||||||
|
import { fetchOnDutyDispatchers } from '../api/dispatcherApi';
|
||||||
|
import { cachedOnDuty } from './onDutyCache';
|
||||||
|
import type { OnDutyDispatcher } from './types';
|
||||||
|
|
||||||
|
const REFRESH_INTERVAL = 60_000;
|
||||||
|
|
||||||
|
class OnDutyStore {
|
||||||
|
dispatchers: OnDutyDispatcher[] = cachedOnDuty();
|
||||||
|
phrase = '';
|
||||||
|
|
||||||
|
private timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
makeAutoObservable(this, {}, { autoBind: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (this.timer) return;
|
||||||
|
void this.refresh();
|
||||||
|
this.timer = setInterval(() => void this.refresh(), REFRESH_INTERVAL);
|
||||||
|
appEvents.on('dispatchers-on-duty-changed', this.applyCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.timer) clearInterval(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
appEvents.off('dispatchers-on-duty-changed', this.applyCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh(): Promise<void> {
|
||||||
|
const { dispatchers, phrase } = await fetchOnDutyDispatchers();
|
||||||
|
runInAction(() => {
|
||||||
|
if (dispatchers.length > 0) this.dispatchers = dispatchers;
|
||||||
|
if (phrase) this.phrase = phrase;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyCache(): void {
|
||||||
|
this.dispatchers = cachedOnDuty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const onDutyStore = new OnDutyStore();
|
||||||
10
src/entities/dispatcher/model/types.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export interface OnDutyDispatcher {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
role?: 'dispatcher' | 'senior_dispatcher';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnDutyResult {
|
||||||
|
dispatchers: OnDutyDispatcher[];
|
||||||
|
phrase: string;
|
||||||
|
}
|
||||||
35
src/entities/document/api/docsApi.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { authToken, request } from '@/shared/api';
|
||||||
|
import { endpoints, useMockData } from '@/shared/config';
|
||||||
|
import { sendOrQueue } from '@/shared/lib';
|
||||||
|
import type { DriverDocument } from '../model/types';
|
||||||
|
import { mockDocuments } from '../mocks/documents';
|
||||||
|
|
||||||
|
export async function fetchNewDocuments(): Promise<DriverDocument[]> {
|
||||||
|
if (useMockData) return mockDocuments;
|
||||||
|
const token = authToken();
|
||||||
|
if (!token) return [];
|
||||||
|
const data = await request<{ docs?: DriverDocument[] }>(endpoints.driverDocs, { auth: true });
|
||||||
|
return data.docs ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markDocumentRead(docId: number): Promise<void> {
|
||||||
|
if (useMockData) return;
|
||||||
|
const token = authToken();
|
||||||
|
if (!token) return;
|
||||||
|
await sendOrQueue('doc_mark_read', `${endpoints.driverDocs}/?action=mark_read`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Auth-Token': token },
|
||||||
|
body: JSON.stringify({ doc_id: docId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function confirmDocuments(docIds: number[]): Promise<void> {
|
||||||
|
if (useMockData || docIds.length === 0) return;
|
||||||
|
const token = authToken();
|
||||||
|
if (!token) return;
|
||||||
|
await sendOrQueue('doc_confirm', `${endpoints.driverDocs}/?action=confirm`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Auth-Token': token },
|
||||||
|
body: JSON.stringify({ doc_ids: docIds }),
|
||||||
|
});
|
||||||
|
}
|
||||||
2
src/entities/document/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export type { DriverDocument } from './model/types';
|
||||||
|
export { fetchNewDocuments, markDocumentRead, confirmDocuments } from './api/docsApi';
|
||||||
40
src/entities/document/mocks/documents.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { DriverDocument } from '../model/types';
|
||||||
|
|
||||||
|
export const mockDocuments: DriverDocument[] = [
|
||||||
|
{
|
||||||
|
id: 101,
|
||||||
|
title: 'Инструкция по безопасности на линии',
|
||||||
|
category: 'instruction',
|
||||||
|
content:
|
||||||
|
'1. Водитель обязан соблюдать скоростной режим.\n2. Перед выездом проверить исправность тормозной системы.\n3. При возникновении ЧС нажать кнопку SOS.\n4. Соблюдать интервалы движения на маршруте.',
|
||||||
|
file_size: '15 КБ',
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
is_read: false,
|
||||||
|
is_confirmed: false,
|
||||||
|
storage_section: 'Документация',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 102,
|
||||||
|
title: 'График движения маршрута №27 · Июль 2026',
|
||||||
|
category: 'schedule',
|
||||||
|
content:
|
||||||
|
'Будни: интервал 5–7 минут.\nВыходные: интервал 10–12 минут.\nПервый рейс: 05:30, последний рейс: 23:45.',
|
||||||
|
file_size: '8 КБ',
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
is_read: false,
|
||||||
|
is_confirmed: false,
|
||||||
|
storage_section: 'Документация',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 103,
|
||||||
|
title: 'Общий регламент работы водителей',
|
||||||
|
category: 'document',
|
||||||
|
content:
|
||||||
|
'Настоящий регламент определяет права и обязанности водителей на линии ИРИДА, порядок взаимодействия с диспетчерской службой и правила эксплуатации МРМ.',
|
||||||
|
file_size: '22 КБ',
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
is_read: false,
|
||||||
|
is_confirmed: false,
|
||||||
|
storage_section: 'Документация',
|
||||||
|
},
|
||||||
|
];
|
||||||
11
src/entities/document/model/types.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export interface DriverDocument {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
category: string;
|
||||||
|
content: string;
|
||||||
|
file_size: string;
|
||||||
|
created_at: string;
|
||||||
|
is_read: boolean;
|
||||||
|
is_confirmed: boolean;
|
||||||
|
storage_section?: string;
|
||||||
|
}
|
||||||
2
src/entities/driver/@x/session.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export type { Driver, MrmAdmin } from '../model/types';
|
||||||
|
export { loginByPin, loginMrm, logout, sendHeartbeat, type HeartbeatResult } from '../api/authApi';
|
||||||
73
src/entities/driver/api/authApi.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { request } from '@/shared/api';
|
||||||
|
import { endpoints, useMockData } from '@/shared/config';
|
||||||
|
import type { Driver, MrmAdmin } from '../model/types';
|
||||||
|
import { mockDriver, mockMrmAdmin } from '../mocks/driver';
|
||||||
|
|
||||||
|
interface LoginResponse {
|
||||||
|
token: string;
|
||||||
|
driver: Driver;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginByPin(employeeId: string, pin: string): Promise<LoginResponse> {
|
||||||
|
if (useMockData) {
|
||||||
|
return { token: `mock_token_${employeeId || 'demo'}`, driver: { ...mockDriver, id: employeeId ? `d-${employeeId}` : mockDriver.id } };
|
||||||
|
}
|
||||||
|
const data = await request<LoginResponse>(`${endpoints.driverAuth}/`, {
|
||||||
|
method: 'POST',
|
||||||
|
query: { action: 'login' },
|
||||||
|
body: { employee_id: employeeId, pin },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginMrm(login: string, password: string): Promise<MrmAdmin> {
|
||||||
|
if (useMockData) {
|
||||||
|
return { ...mockMrmAdmin, login: login || mockMrmAdmin.login };
|
||||||
|
}
|
||||||
|
const data = await request<{ mrmAdmin: MrmAdmin }>(`${endpoints.mrm}/`, {
|
||||||
|
method: 'POST',
|
||||||
|
query: { action: 'login' },
|
||||||
|
body: { login, password },
|
||||||
|
});
|
||||||
|
return data.mrmAdmin;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(token: string | null): Promise<void> {
|
||||||
|
if (useMockData || !token) return;
|
||||||
|
await request(`${endpoints.driverAuth}/`, { method: 'POST', query: { action: 'logout' }, headers: { 'X-Auth-Token': token } }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeartbeatResult {
|
||||||
|
sessionExpired: boolean;
|
||||||
|
deviationSettings: Driver['deviationSettings'] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendHeartbeat(
|
||||||
|
token: string | null,
|
||||||
|
position: { lat?: number; lng?: number; speed?: number },
|
||||||
|
deviationSettingsUpdatedAt: string | null,
|
||||||
|
): Promise<HeartbeatResult> {
|
||||||
|
if (useMockData || !token) return { sessionExpired: false, deviationSettings: null };
|
||||||
|
try {
|
||||||
|
const data = await request<{ deviationSettings?: Driver['deviationSettings'] }>(
|
||||||
|
`${endpoints.driverAuth}/`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
query: { action: 'heartbeat' },
|
||||||
|
headers: { 'X-Auth-Token': token },
|
||||||
|
body: {
|
||||||
|
latitude: position.lat,
|
||||||
|
longitude: position.lng,
|
||||||
|
speed: position.speed,
|
||||||
|
deviationSettingsUpdatedAt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return { sessionExpired: false, deviationSettings: data.deviationSettings ?? null };
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as { status?: number })?.status === 401) {
|
||||||
|
return { sessionExpired: true, deviationSettings: null };
|
||||||
|
}
|
||||||
|
return { sessionExpired: false, deviationSettings: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/entities/driver/api/driverDirectoryApi.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { useMockData } from '@/shared/config';
|
||||||
|
import type { DriverInfo } from '../model/types';
|
||||||
|
import { mockDriverDirectory } from '../mocks/driverDirectory';
|
||||||
|
|
||||||
|
export function getDriverDirectory(): DriverInfo[] {
|
||||||
|
return useMockData ? mockDriverDirectory : [];
|
||||||
|
}
|
||||||
17
src/entities/driver/api/scheduleApi.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { request } from '@/shared/api';
|
||||||
|
import { endpoints, useMockData } from '@/shared/config';
|
||||||
|
import type { ScheduleRecord } from '../model/schedule';
|
||||||
|
import { mockScheduleRecord } from '../mocks/schedule';
|
||||||
|
|
||||||
|
export async function fetchMonthlySchedule(
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
driverToken: string,
|
||||||
|
): Promise<ScheduleRecord | null> {
|
||||||
|
if (useMockData) return mockScheduleRecord;
|
||||||
|
const data = await request<{ schedule: ScheduleRecord | null }>(`${endpoints.monthlySchedule}/`, {
|
||||||
|
query: { action: 'driver', year, month },
|
||||||
|
headers: { 'X-Driver-Token': driverToken },
|
||||||
|
});
|
||||||
|
return data.schedule;
|
||||||
|
}
|
||||||
7
src/entities/driver/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export type { Driver, DriverInfo, MrmAdmin, VehicleType, DeviationSettings } from './model/types';
|
||||||
|
export { defaultDeviationSettings } from './model/types';
|
||||||
|
export * from './api/authApi';
|
||||||
|
export { getDriverDirectory } from './api/driverDirectoryApi';
|
||||||
|
export { fetchMonthlySchedule } from './api/scheduleApi';
|
||||||
|
export { resolveShift, type ScheduleRecord, type DriverShiftInfo } from './model/schedule';
|
||||||
|
export { useDriverShiftInfo } from './model/useDriverShiftInfo';
|
||||||
20
src/entities/driver/mocks/driver.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { Driver, MrmAdmin } from '../model/types';
|
||||||
|
|
||||||
|
export const mockDriver: Driver = {
|
||||||
|
id: 'd-1042',
|
||||||
|
name: 'Соколов Дмитрий Андреевич',
|
||||||
|
vehicleType: 'tram',
|
||||||
|
vehicleNumber: 'Б-3407',
|
||||||
|
routeNumber: '27',
|
||||||
|
routeId: 'route-27',
|
||||||
|
shiftStart: '08:00',
|
||||||
|
dispatcherName: 'Иванова Анна Петровна',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockMrmAdmin: MrmAdmin = {
|
||||||
|
id: 999,
|
||||||
|
fullName: 'Администратор МРМ',
|
||||||
|
login: 'admin',
|
||||||
|
adminPin: '1111',
|
||||||
|
kioskExitPassword: 'exit',
|
||||||
|
};
|
||||||
9
src/entities/driver/mocks/driverDirectory.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { DriverInfo } from '../model/types';
|
||||||
|
|
||||||
|
export const mockDriverDirectory: DriverInfo[] = [
|
||||||
|
{ id: '1', tabNumber: 'T001', name: 'Иванов Иван Иванович', vehicleType: 'bus', depotId: 'depot-1', driverStatus: 'active', pin: '123456', phone: '+7 (999) 111-22-33', rating: 4.8 },
|
||||||
|
{ id: '2', tabNumber: 'T002', name: 'Петров Петр Петрович', vehicleType: 'tram', depotId: 'depot-2', driverStatus: 'vacation', pin: '654321', phone: '+7 (999) 222-33-44', rating: 4.5 },
|
||||||
|
{ id: '3', tabNumber: 'T003', name: 'Сидоров Сидор Сидорович', vehicleType: 'trolleybus', depotId: 'depot-1', driverStatus: 'sick', pin: '111111', phone: '+7 (999) 333-44-55', rating: 4.2 },
|
||||||
|
{ id: '4', tabNumber: 'T004', name: 'Алексеев Алексей Алексеевич', vehicleType: 'bus', depotId: 'depot-3', driverStatus: 'active', pin: '222222', phone: '+7 (999) 444-55-66', rating: 4.9 },
|
||||||
|
{ id: '5', tabNumber: 'T005', name: 'Смирнов Сергей Сергеевич', vehicleType: 'bus', depotId: 'depot-1', driverStatus: 'active', pin: '333333', phone: '+7 (999) 555-66-77', rating: 4.1 },
|
||||||
|
];
|
||||||
27
src/entities/driver/mocks/schedule.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { ScheduleRecord } from '../model/schedule';
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
export const mockScheduleRecord: ScheduleRecord = {
|
||||||
|
id: 1,
|
||||||
|
year: today.getFullYear(),
|
||||||
|
month: today.getMonth() + 1,
|
||||||
|
driver_id: 1042,
|
||||||
|
full_name: 'Соколов Дмитрий Андреевич',
|
||||||
|
employee_id: '1042',
|
||||||
|
days: { [String(today.getDate())]: '1' },
|
||||||
|
shift1_start: '08:00',
|
||||||
|
shift1_end: '16:00',
|
||||||
|
shift2_start: '16:00',
|
||||||
|
shift2_end: '00:00',
|
||||||
|
razryvnaya_start1: '06:00',
|
||||||
|
razryvnaya_end1: '10:00',
|
||||||
|
razryvnaya_start2: '16:00',
|
||||||
|
razryvnaya_end2: '20:00',
|
||||||
|
status: 'approved',
|
||||||
|
approved_at: null,
|
||||||
|
submitted_at: null,
|
||||||
|
approver_name: null,
|
||||||
|
created_at: '',
|
||||||
|
updated_at: '',
|
||||||
|
};
|
||||||
61
src/entities/driver/model/schedule.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
export interface ScheduleRecord {
|
||||||
|
id: number;
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
driver_id: number;
|
||||||
|
full_name: string;
|
||||||
|
employee_id: string;
|
||||||
|
days: Record<string, string>;
|
||||||
|
shift1_start: string;
|
||||||
|
shift1_end: string;
|
||||||
|
shift2_start: string;
|
||||||
|
shift2_end: string;
|
||||||
|
razryvnaya_start1: string;
|
||||||
|
razryvnaya_end1: string;
|
||||||
|
razryvnaya_start2: string;
|
||||||
|
razryvnaya_end2: string;
|
||||||
|
status: 'draft' | 'pending' | 'approved';
|
||||||
|
approved_at: string | null;
|
||||||
|
submitted_at: string | null;
|
||||||
|
approver_name: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DriverShiftInfo {
|
||||||
|
shiftStart?: string;
|
||||||
|
shiftEnd?: string;
|
||||||
|
shiftCode?: string;
|
||||||
|
shiftLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shiftLabels: Record<string, string> = {
|
||||||
|
'1': '1-я смена',
|
||||||
|
'2': '2-я смена',
|
||||||
|
'Р1': 'Резерв — блок 1',
|
||||||
|
'Р2': 'Резерв — блок 2',
|
||||||
|
'Р': 'Резерв',
|
||||||
|
'В': 'Выходной',
|
||||||
|
'О': 'Отпуск',
|
||||||
|
'Б': 'Больничный',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveShift(record: ScheduleRecord, day: number): DriverShiftInfo {
|
||||||
|
const code = record.days?.[String(day)];
|
||||||
|
if (!code) return {};
|
||||||
|
const info: DriverShiftInfo = { shiftCode: code, shiftLabel: shiftLabels[code] || code };
|
||||||
|
if (code === '1') {
|
||||||
|
info.shiftStart = record.shift1_start;
|
||||||
|
info.shiftEnd = record.shift1_end;
|
||||||
|
} else if (code === '2') {
|
||||||
|
info.shiftStart = record.shift2_start;
|
||||||
|
info.shiftEnd = record.shift2_end;
|
||||||
|
} else if (code === 'Р1' || code === 'Р') {
|
||||||
|
info.shiftStart = record.razryvnaya_start1;
|
||||||
|
info.shiftEnd = record.razryvnaya_end1;
|
||||||
|
} else if (code === 'Р2') {
|
||||||
|
info.shiftStart = record.razryvnaya_start2;
|
||||||
|
info.shiftEnd = record.razryvnaya_end2;
|
||||||
|
}
|
||||||
|
return info;
|
||||||
|
}
|
||||||
64
src/entities/driver/model/types.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
export type VehicleType = 'tram' | 'trolleybus' | 'bus' | 'electrobus' | 'technical';
|
||||||
|
|
||||||
|
export interface DeviationSettings {
|
||||||
|
intervalOkMin: number;
|
||||||
|
intervalOkMax: number;
|
||||||
|
intervalWarnMin: number;
|
||||||
|
intervalWarnMax: number;
|
||||||
|
intervalCritMin: number;
|
||||||
|
intervalCritMax: number;
|
||||||
|
scheduleOkMin: number;
|
||||||
|
scheduleOkMax: number;
|
||||||
|
scheduleWarnMin: number;
|
||||||
|
scheduleWarnMax: number;
|
||||||
|
scheduleCritMin: number;
|
||||||
|
scheduleCritMax: number;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Driver {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
routeNumber: string;
|
||||||
|
routeId?: string;
|
||||||
|
vehicleType: VehicleType;
|
||||||
|
vehicleNumber: string;
|
||||||
|
shiftStart: string;
|
||||||
|
dispatcherName?: string;
|
||||||
|
deviationSettings?: DeviationSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DriverInfo {
|
||||||
|
id: string;
|
||||||
|
tabNumber?: string;
|
||||||
|
name: string;
|
||||||
|
vehicleType?: string;
|
||||||
|
depotId?: string;
|
||||||
|
driverStatus?: string;
|
||||||
|
pin?: string;
|
||||||
|
phone?: string;
|
||||||
|
rating?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MrmAdmin {
|
||||||
|
id: number;
|
||||||
|
fullName: string;
|
||||||
|
login: string;
|
||||||
|
adminPin: string;
|
||||||
|
kioskExitPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultDeviationSettings: DeviationSettings = {
|
||||||
|
intervalOkMin: -60,
|
||||||
|
intervalOkMax: 60,
|
||||||
|
intervalWarnMin: -180,
|
||||||
|
intervalWarnMax: 180,
|
||||||
|
intervalCritMin: -999999,
|
||||||
|
intervalCritMax: 999999,
|
||||||
|
scheduleOkMin: -60,
|
||||||
|
scheduleOkMax: 60,
|
||||||
|
scheduleWarnMin: -180,
|
||||||
|
scheduleWarnMax: 180,
|
||||||
|
scheduleCritMin: -999999,
|
||||||
|
scheduleCritMax: 999999,
|
||||||
|
};
|
||||||
27
src/entities/driver/model/useDriverShiftInfo.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { fetchMonthlySchedule } from '../api/scheduleApi';
|
||||||
|
import { resolveShift, type DriverShiftInfo } from './schedule';
|
||||||
|
|
||||||
|
export function useDriverShiftInfo(driverToken: string | null | undefined): DriverShiftInfo {
|
||||||
|
const [info, setInfo] = useState<DriverShiftInfo>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!driverToken) {
|
||||||
|
setInfo({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
const now = new Date();
|
||||||
|
fetchMonthlySchedule(now.getFullYear(), now.getMonth() + 1, driverToken)
|
||||||
|
.then((schedule) => {
|
||||||
|
if (cancelled || !schedule) return;
|
||||||
|
setInfo(resolveShift(schedule, now.getDate()));
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [driverToken]);
|
||||||
|
|
||||||
|
return info;
|
||||||
|
}
|
||||||
1
src/entities/message/@x/session.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { MessageStore } from '../model/messageStore';
|
||||||
102
src/entities/message/api/messageApi.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { request } from '@/shared/api';
|
||||||
|
import { endpoints, useMockData } from '@/shared/config';
|
||||||
|
import type { Message } from '../model/types';
|
||||||
|
import { mockIncomingMessages } from '../mocks/messages';
|
||||||
|
|
||||||
|
export interface RawMessage {
|
||||||
|
id: number;
|
||||||
|
sender?: string;
|
||||||
|
senderUserId?: number | null;
|
||||||
|
senderName?: string;
|
||||||
|
senderRole?: string;
|
||||||
|
senderLabel?: string;
|
||||||
|
senderIcon?: string;
|
||||||
|
text: string;
|
||||||
|
type?: string;
|
||||||
|
isRead?: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
deliveredAt?: string;
|
||||||
|
clientId?: string;
|
||||||
|
isVoice?: boolean;
|
||||||
|
voiceDuration?: number;
|
||||||
|
audioUrl?: string | null;
|
||||||
|
design_type?: string;
|
||||||
|
chat_id?: number;
|
||||||
|
deliveryStatus?: 'sent' | 'delivered' | 'read';
|
||||||
|
meta?: { call?: Message['callMeta'] } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mockServedInitial = false;
|
||||||
|
|
||||||
|
interface SentMessage {
|
||||||
|
id: number;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BatchOutgoing {
|
||||||
|
text: string;
|
||||||
|
type: string;
|
||||||
|
clientId: string;
|
||||||
|
audioUrl?: string;
|
||||||
|
audioDuration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMessagesSince(sinceId: number, signal?: AbortSignal): Promise<RawMessage[]> {
|
||||||
|
if (useMockData) {
|
||||||
|
if (mockServedInitial) return [];
|
||||||
|
mockServedInitial = true;
|
||||||
|
return mockIncomingMessages();
|
||||||
|
}
|
||||||
|
const data = await request<{ messages?: RawMessage[] }>(endpoints.driverMessages, {
|
||||||
|
query: { since_id: sinceId },
|
||||||
|
auth: true,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
return data.messages ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendDriverMessage(
|
||||||
|
text: string,
|
||||||
|
clientId: string,
|
||||||
|
audioUrl?: string,
|
||||||
|
audioDuration?: number,
|
||||||
|
replyToChatId?: number,
|
||||||
|
designType?: string,
|
||||||
|
): Promise<SentMessage> {
|
||||||
|
if (useMockData) return { id: Date.now(), status: 'sent' };
|
||||||
|
return request<SentMessage>(endpoints.driverMessages, {
|
||||||
|
method: 'POST',
|
||||||
|
auth: true,
|
||||||
|
body: {
|
||||||
|
text,
|
||||||
|
type: 'normal',
|
||||||
|
clientId,
|
||||||
|
...(audioUrl ? { audio_url: audioUrl } : {}),
|
||||||
|
...(audioDuration ? { audio_duration: audioDuration } : {}),
|
||||||
|
...(replyToChatId ? { reply_to_chat_id: replyToChatId } : {}),
|
||||||
|
...(designType ? { design_type: designType } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendBatch(messages: BatchOutgoing[]): Promise<{ results: Array<{ clientId: string; status: string; serverId: number }> }> {
|
||||||
|
if (useMockData) {
|
||||||
|
return {
|
||||||
|
results: messages.map((message) => ({ clientId: message.clientId, status: 'sent', serverId: Date.now() + Math.floor(Math.random() * 1000) })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return request(endpoints.driverMessages, {
|
||||||
|
method: 'POST',
|
||||||
|
query: { action: 'batch' },
|
||||||
|
auth: true,
|
||||||
|
body: {
|
||||||
|
messages: messages.map((message) => ({
|
||||||
|
text: message.text,
|
||||||
|
type: message.type,
|
||||||
|
clientId: message.clientId,
|
||||||
|
...(message.audioUrl ? { audio_url: message.audioUrl } : {}),
|
||||||
|
...(message.audioDuration ? { audio_duration: message.audioDuration } : {}),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
5
src/entities/message/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export type { Message, MessageType, DeliveryStatus, CallMeta } from './model/types';
|
||||||
|
export { isImportantMessage, isNotificationMessage } from './model/types';
|
||||||
|
export { MessageStore } from './model/messageStore';
|
||||||
|
export { LiveMessageStore } from './model/liveMessageStore';
|
||||||
|
export { mapLiveMessage } from './model/liveMessage';
|
||||||
40
src/entities/message/mocks/messages.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { RawMessage } from '../api/messageApi';
|
||||||
|
|
||||||
|
export function mockIncomingMessages(): RawMessage[] {
|
||||||
|
const now = Date.now();
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
sender: 'dispatcher',
|
||||||
|
senderName: 'Иванова А.П.',
|
||||||
|
senderRole: 'dispatcher',
|
||||||
|
text: 'Доброе утро! Хорошей смены. Соблюдайте расписание на маршруте №27.',
|
||||||
|
type: 'dispatcher',
|
||||||
|
isRead: true,
|
||||||
|
createdAt: new Date(now - 3600_000).toISOString(),
|
||||||
|
deliveredAt: new Date(now - 3600_000).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
sender: 'dispatcher',
|
||||||
|
senderName: 'Иванова А.П.',
|
||||||
|
senderRole: 'dispatcher',
|
||||||
|
text: 'На участке ул. Ленина ведутся дорожные работы, возможны задержки.',
|
||||||
|
type: 'dispatcher',
|
||||||
|
isRead: true,
|
||||||
|
createdAt: new Date(now - 1800_000).toISOString(),
|
||||||
|
deliveredAt: new Date(now - 1800_000).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
sender: 'dispatcher',
|
||||||
|
senderName: 'Петров М.С.',
|
||||||
|
senderRole: 'senior_dispatcher',
|
||||||
|
text: 'Внимание! Объезд по ул. Садовой в связи с ДТП. Следуйте по ул. Невской.',
|
||||||
|
type: 'important',
|
||||||
|
isRead: false,
|
||||||
|
createdAt: new Date(now - 300_000).toISOString(),
|
||||||
|
deliveredAt: new Date(now - 300_000).toISOString(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
39
src/entities/message/model/liveMessage.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { ServerMessage } from '@/shared/api/irida';
|
||||||
|
import type { Message } from './types';
|
||||||
|
|
||||||
|
export function mapLiveMessage(raw: ServerMessage, userId: string, history: ServerMessage[] = []): Message {
|
||||||
|
const own = raw.fromId === userId;
|
||||||
|
const original = raw.replyId ? history.find(message => message.id === raw.replyId) : undefined;
|
||||||
|
const receivers = own ? raw.receivers ?? [] : (raw.receivers ?? []).filter(receiver => receiver.toId === userId);
|
||||||
|
const read = receivers.length > 0 && receivers.every(receiver => receiver.status === 'read');
|
||||||
|
const delivered = receivers.length > 0 && receivers.every(receiver => receiver.status === 'read' || receiver.status === 'delivered');
|
||||||
|
return {
|
||||||
|
id: raw.id,
|
||||||
|
type: own ? 'driver_self' : raw.important ? 'important' : 'dispatcher',
|
||||||
|
text: raw.message ?? '',
|
||||||
|
timestamp: new Date(raw.createdAt),
|
||||||
|
read: own || read,
|
||||||
|
confirmed: !own && !!raw.important && read,
|
||||||
|
senderName: own ? undefined : `Диспетчер #${raw.fromId}`,
|
||||||
|
deliveryStatus: read ? 'read' : delivered ? 'delivered' : 'sent',
|
||||||
|
replyToId: raw.replyId ?? undefined,
|
||||||
|
replyTo: raw.replyId ? original?.message || 'Исходное сообщение недоступно' : undefined,
|
||||||
|
replyToAuthor: raw.replyId ? original?.fromId === userId ? 'Вы' : original?.fromId ? `Диспетчер #${original.fromId}` : 'Сообщение' : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function chronological(messages: ServerMessage[]): ServerMessage[] {
|
||||||
|
return [...new Map(messages.map(message => [message.id, message])).values()].sort((a, b) => {
|
||||||
|
const time = Date.parse(a.createdAt) - Date.parse(b.createdAt);
|
||||||
|
if (time) return time;
|
||||||
|
return BigInt(a.id) < BigInt(b.id) ? -1 : BigInt(a.id) > BigInt(b.id) ? 1 : 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function preserveLiveReceipts(previous: ServerMessage | undefined, message: ServerMessage): ServerMessage {
|
||||||
|
const rank = { pending: 0, delivered: 1, read: 2 };
|
||||||
|
return { ...message, receivers: message.receivers?.map(receiver => {
|
||||||
|
const old = previous?.receivers?.find(candidate => candidate.toId === receiver.toId && candidate.deviceId === receiver.deviceId);
|
||||||
|
return old?.status && rank[old.status] > (receiver.status ? rank[receiver.status] : -1) ? { ...receiver, ...old } : receiver;
|
||||||
|
}) };
|
||||||
|
}
|
||||||
142
src/entities/message/model/liveMessageStore.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { makeAutoObservable, runInAction, type AnnotationsMap } from 'mobx';
|
||||||
|
import type { AuthorizedRequest, ServerMessage } from '@/shared/api/irida';
|
||||||
|
import { chronological, mapLiveMessage, preserveLiveReceipts } from './liveMessage';
|
||||||
|
|
||||||
|
export class LiveMessageStore {
|
||||||
|
messages: ServerMessage[] = [];
|
||||||
|
loading = true;
|
||||||
|
sending = false;
|
||||||
|
reporting = false;
|
||||||
|
error: string | null = null;
|
||||||
|
sendError: string | null = null;
|
||||||
|
receiptError: string | null = null;
|
||||||
|
private readonly readsInFlight = new Set<string>();
|
||||||
|
replyId: string | null = null;
|
||||||
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private controller: AbortController | null = null;
|
||||||
|
private writeController = new AbortController();
|
||||||
|
private stopped = true;
|
||||||
|
|
||||||
|
constructor(readonly userId: string, private readonly request: AuthorizedRequest) {
|
||||||
|
makeAutoObservable(this, {
|
||||||
|
request: false, readsInFlight: false, timer: false, controller: false, writeController: false,
|
||||||
|
} as AnnotationsMap<LiveMessageStore, never>, { autoBind: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
get chats() { return this.messages.map(message => mapLiveMessage(message, this.userId, this.messages)); }
|
||||||
|
get incoming() { return this.messages.filter(message => message.fromId !== this.userId); }
|
||||||
|
get replyTarget() { return this.incoming.find(message => message.id === this.replyId) ?? this.incoming.at(-1) ?? null; }
|
||||||
|
get important() {
|
||||||
|
return this.incoming.find(message => message.important && (message.receivers ?? []).some(receiver => receiver.toId === this.userId && receiver.status !== 'read')) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectedReply() { return this.incoming.find(message => message.id === this.replyId) ?? null; }
|
||||||
|
clearReply() { this.replyId = null; }
|
||||||
|
|
||||||
|
selectReply(id: string) { this.replyId = id; }
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (!this.stopped) return;
|
||||||
|
this.stopped = false;
|
||||||
|
this.writeController = new AbortController();
|
||||||
|
void this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.stopped = true;
|
||||||
|
if (this.timer) clearTimeout(this.timer);
|
||||||
|
this.controller?.abort();
|
||||||
|
this.writeController.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh() {
|
||||||
|
if (this.stopped) return;
|
||||||
|
if (this.timer) clearTimeout(this.timer);
|
||||||
|
this.controller?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
this.controller = controller;
|
||||||
|
try {
|
||||||
|
const options = { signal: controller.signal };
|
||||||
|
const [sent, received, pending] = await Promise.all([
|
||||||
|
this.request<{ messages?: ServerMessage[] }>('/messages/list', { ...options, query: { fromId: this.userId, limit: 100, offset: 0 } }),
|
||||||
|
this.request<{ messages?: ServerMessage[] }>('/messages/list', { ...options, query: { toId: this.userId, limit: 100, offset: 0 } }),
|
||||||
|
this.request<{ messages?: ServerMessage[] }>('/messages/pending', options),
|
||||||
|
]);
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
const messages = chronological([...(sent.messages ?? []), ...(received.messages ?? []), ...(pending.messages ?? [])]).map(message => preserveLiveReceipts(this.messages.find(previous => previous.id === message.id), message));
|
||||||
|
runInAction(() => { this.messages = messages; this.error = null; });
|
||||||
|
const delivered = messages.filter(message => message.fromId !== this.userId && message.receivers?.some(receiver => receiver.toId === this.userId && receiver.status === 'pending')).map(message => message.id);
|
||||||
|
if (delivered.length) {
|
||||||
|
await this.request('/messages/report/delivered', { ...options, method: 'POST', body: { messageIds: delivered } });
|
||||||
|
if (!controller.signal.aborted) runInAction(() => this.applyReceipt(delivered, 'delivered'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!controller.signal.aborted) runInAction(() => { this.error = error instanceof Error ? error.message : 'Не удалось обновить чат'; });
|
||||||
|
} finally {
|
||||||
|
if (!controller.signal.aborted && !this.stopped) {
|
||||||
|
runInAction(() => { this.loading = false; });
|
||||||
|
this.timer = setTimeout(() => void this.refresh(), 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyReceipt(ids: string[], status: 'delivered' | 'read') {
|
||||||
|
const selected = new Set(ids);
|
||||||
|
this.messages = this.messages.map(message => selected.has(message.id) ? {
|
||||||
|
...message, receivers: message.receivers?.map(receiver => receiver.toId === this.userId && receiver.status !== 'read' ? { ...receiver, status } : receiver),
|
||||||
|
} : message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async markVisibleRead(ids: string[]): Promise<void> {
|
||||||
|
if (this.stopped) return;
|
||||||
|
const selected = new Set(ids), signal = this.writeController.signal;
|
||||||
|
const messageIds = this.incoming.filter(message => selected.has(message.id) && !message.important && !this.readsInFlight.has(message.id) && message.receivers?.some(receiver => receiver.toId === this.userId && receiver.status !== 'read')).map(message => message.id);
|
||||||
|
if (!messageIds.length) return;
|
||||||
|
messageIds.forEach(id => this.readsInFlight.add(id));
|
||||||
|
try {
|
||||||
|
await this.request('/messages/report/read', { method: 'POST', body: { messageIds }, signal });
|
||||||
|
if (!signal.aborted) runInAction(() => { this.applyReceipt(messageIds, 'read'); this.receiptError = null; });
|
||||||
|
} catch (error) {
|
||||||
|
if (!signal.aborted) runInAction(() => { this.receiptError = error instanceof Error ? error.message : 'Не удалось обновить статус прочтения'; });
|
||||||
|
} finally { messageIds.forEach(id => this.readsInFlight.delete(id)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async acknowledge(id: string): Promise<boolean> {
|
||||||
|
if (this.reporting || this.sending || this.stopped) return false;
|
||||||
|
const signal = this.writeController.signal;
|
||||||
|
this.reporting = true;
|
||||||
|
this.sendError = null;
|
||||||
|
try {
|
||||||
|
await this.request('/messages/report/read', { method: 'POST', body: { messageIds: [id] }, signal });
|
||||||
|
if (signal.aborted) return false;
|
||||||
|
runInAction(() => {
|
||||||
|
this.messages = this.messages.map(message => message.id === id ? {
|
||||||
|
...message, receivers: message.receivers?.map(receiver => receiver.toId === this.userId ? { ...receiver, status: 'read' } : receiver),
|
||||||
|
} : message);
|
||||||
|
});
|
||||||
|
void this.refresh();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (!signal.aborted) runInAction(() => { this.sendError = error instanceof Error ? error.message : 'Не удалось подтвердить сообщение'; });
|
||||||
|
return false;
|
||||||
|
} finally { runInAction(() => { this.reporting = false; }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(text: string, replyId = this.replyTarget?.id): Promise<boolean> {
|
||||||
|
if (!replyId || !text.trim() || this.sending || this.reporting || this.stopped) return false;
|
||||||
|
const signal = this.writeController.signal;
|
||||||
|
this.sending = true;
|
||||||
|
this.sendError = null;
|
||||||
|
try {
|
||||||
|
const result = await this.request<ServerMessage>('/messages/reply', {
|
||||||
|
method: 'POST', body: { replyId, message: text.trim() }, signal,
|
||||||
|
});
|
||||||
|
if (signal.aborted) return false;
|
||||||
|
runInAction(() => { this.messages = chronological([...this.messages, result]); });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (!signal.aborted) runInAction(() => { this.sendError = error instanceof Error ? error.message : 'Не удалось отправить ответ'; });
|
||||||
|
return false;
|
||||||
|
} finally { runInAction(() => { this.sending = false; }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/entities/message/model/mapMessage.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import type { Message } from './types';
|
||||||
|
import type { RawMessage } from '../api/messageApi';
|
||||||
|
|
||||||
|
function parseServerDate(value: unknown): Date {
|
||||||
|
if (value instanceof Date) return value;
|
||||||
|
if (typeof value !== 'string') return new Date(NaN);
|
||||||
|
let normalized = value.trim();
|
||||||
|
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(normalized)) normalized = normalized.replace(' ', 'T');
|
||||||
|
const hasTimezone = /[Zz]$|[+-]\d{2}:?\d{2}$/.test(normalized);
|
||||||
|
if (!hasTimezone) normalized += 'Z';
|
||||||
|
return new Date(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapRawMessage(raw: RawMessage): Message {
|
||||||
|
const voiceMatch = raw.text.match(/🎤\s*Голосовое сообщение\s*\((\d+)с\)/);
|
||||||
|
const isVoice = Boolean(raw.isVoice || voiceMatch || raw.audioUrl);
|
||||||
|
const voiceDuration = raw.voiceDuration || (voiceMatch ? parseInt(voiceMatch[1], 10) : undefined);
|
||||||
|
const isOwn = raw.sender === 'driver';
|
||||||
|
const rawType = (raw.type || '').toLowerCase();
|
||||||
|
const isCall = rawType === 'call';
|
||||||
|
|
||||||
|
const type: Message['type'] = isCall
|
||||||
|
? 'call'
|
||||||
|
: isOwn
|
||||||
|
? 'driver_self'
|
||||||
|
: rawType === 'important'
|
||||||
|
? 'important'
|
||||||
|
: 'dispatcher';
|
||||||
|
|
||||||
|
const callMeta = isCall && raw.meta && typeof raw.meta === 'object' ? raw.meta.call : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(raw.id),
|
||||||
|
type,
|
||||||
|
text: raw.text,
|
||||||
|
timestamp: parseServerDate(raw.createdAt),
|
||||||
|
read: Boolean(raw.isRead),
|
||||||
|
clientId: raw.clientId || undefined,
|
||||||
|
deliveryStatus: isOwn
|
||||||
|
? raw.deliveryStatus || 'sent'
|
||||||
|
: raw.deliveredAt
|
||||||
|
? 'delivered'
|
||||||
|
: 'sent',
|
||||||
|
isVoice,
|
||||||
|
voiceDuration,
|
||||||
|
audioUrl: raw.audioUrl || undefined,
|
||||||
|
senderName: raw.senderName,
|
||||||
|
senderRole: raw.senderRole,
|
||||||
|
senderLabel: raw.senderLabel,
|
||||||
|
senderIcon: raw.senderIcon,
|
||||||
|
senderUserId: raw.senderUserId ?? undefined,
|
||||||
|
replyToChatId: raw.chat_id,
|
||||||
|
designType: raw.design_type,
|
||||||
|
callMeta,
|
||||||
|
};
|
||||||
|
}
|
||||||
257
src/entities/message/model/messageStore.ts
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import { makeAutoObservable, runInAction, type AnnotationsMap } from 'mobx';
|
||||||
|
import NetInfo from '@react-native-community/netinfo';
|
||||||
|
import type { ConnectionStatus } from '@/shared/api';
|
||||||
|
import {
|
||||||
|
cacheMessages,
|
||||||
|
cachedMessages,
|
||||||
|
clearMessageCache,
|
||||||
|
clearSentMessages,
|
||||||
|
enqueueMessage,
|
||||||
|
flushActionsQueue,
|
||||||
|
generateClientId,
|
||||||
|
markMessageSent,
|
||||||
|
mobxFetch,
|
||||||
|
pendingMessages,
|
||||||
|
} from '@/shared/lib';
|
||||||
|
import { fetchMessagesSince, sendBatch, sendDriverMessage, type RawMessage } from '../api/messageApi';
|
||||||
|
import { mapRawMessage } from './mapMessage';
|
||||||
|
import { isImportantMessage, isNotificationMessage, type Message } from './types';
|
||||||
|
|
||||||
|
const MAX_MESSAGES = 50;
|
||||||
|
|
||||||
|
export class MessageStore {
|
||||||
|
messages: Message[] = [];
|
||||||
|
connection: ConnectionStatus = 'online';
|
||||||
|
pendingImportant: Message | null = null;
|
||||||
|
pendingCount = 0;
|
||||||
|
refreshing = false;
|
||||||
|
|
||||||
|
private lastServerId = 0;
|
||||||
|
private syncing = false;
|
||||||
|
private online = true;
|
||||||
|
private netUnsubscribe: (() => void) | null = null;
|
||||||
|
|
||||||
|
private readonly poll = mobxFetch<RawMessage[], MessageStore>({
|
||||||
|
store: this,
|
||||||
|
refreshing: 'refreshing',
|
||||||
|
pollingInterval: 1000,
|
||||||
|
keepPreviousData: true,
|
||||||
|
fn: (signal) => fetchMessagesSince(this.lastServerId, signal),
|
||||||
|
onSuccess: (raw) => this.ingest(raw),
|
||||||
|
onError: () => runInAction(() => (this.connection = 'offline')),
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
makeAutoObservable(this, { poll: false } as AnnotationsMap<MessageStore, never>, { autoBind: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
get unreadCount(): number {
|
||||||
|
return this.messages.filter((message) => !message.read).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
get notifications(): Message[] {
|
||||||
|
return this.messages.filter(isNotificationMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
get importantMessages(): Message[] {
|
||||||
|
return this.messages.filter(isImportantMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
hydrateFromCache(): void {
|
||||||
|
const cached = cachedMessages() as Message[];
|
||||||
|
if (cached.length === 0) return;
|
||||||
|
this.messages = cached.map((message) => ({ ...message, timestamp: new Date(message.timestamp) }));
|
||||||
|
this.lastServerId = Math.max(0, ...cached.map((message) => Number.parseInt(message.id, 10) || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
this.netUnsubscribe = NetInfo.addEventListener((state) => {
|
||||||
|
this.online = Boolean(state.isConnected) && state.isInternetReachable !== false;
|
||||||
|
runInAction(() => (this.connection = this.online ? 'online' : 'offline'));
|
||||||
|
if (this.online) void this.syncOfflineQueue();
|
||||||
|
});
|
||||||
|
void this.poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.poll.stopPolling();
|
||||||
|
this.poll.cancel();
|
||||||
|
this.netUnsubscribe?.();
|
||||||
|
this.netUnsubscribe = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.messages = [];
|
||||||
|
this.pendingImportant = null;
|
||||||
|
this.pendingCount = 0;
|
||||||
|
this.lastServerId = 0;
|
||||||
|
clearMessageCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmImportant(id: string): void {
|
||||||
|
this.messages = this.messages.map((message) =>
|
||||||
|
message.id === id ? { ...message, confirmed: true, confirmedAt: new Date(), read: true } : message,
|
||||||
|
);
|
||||||
|
this.pendingImportant = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
markRead(id: string): void {
|
||||||
|
this.messages = this.messages.map((message) => (message.id === id ? { ...message, read: true } : message));
|
||||||
|
}
|
||||||
|
|
||||||
|
clearNotifications(): void {
|
||||||
|
this.messages = this.messages.filter((message) => !isNotificationMessage(message));
|
||||||
|
cacheMessages(this.messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearImportant(): void {
|
||||||
|
this.messages = this.messages.filter((message) => !isImportantMessage(message));
|
||||||
|
cacheMessages(this.messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
addDispatcherMessage(text: string, sender?: string): void {
|
||||||
|
this.messages.push({
|
||||||
|
id: `alert_${Date.now()}`,
|
||||||
|
type: 'dispatcher',
|
||||||
|
text: sender ? `[${sender}]: ${text}` : text,
|
||||||
|
timestamp: new Date(),
|
||||||
|
read: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(
|
||||||
|
text: string,
|
||||||
|
isVoice?: boolean,
|
||||||
|
voiceDuration?: number,
|
||||||
|
audioUrl?: string,
|
||||||
|
replyTo?: string,
|
||||||
|
replyToChatId?: number,
|
||||||
|
designType?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const clientId = generateClientId();
|
||||||
|
const cleanReply = replyTo
|
||||||
|
? replyTo.replace(/\[В ответ:.*?»\]\s*/g, '').replace(/^\[Водитель\]:\s*/, '').slice(0, 60)
|
||||||
|
: undefined;
|
||||||
|
const fullText = cleanReply ? `[В ответ: «${cleanReply}»] ${text}` : text;
|
||||||
|
|
||||||
|
const optimistic: Message = {
|
||||||
|
id: `tmp_${clientId}`,
|
||||||
|
type: 'driver_self',
|
||||||
|
text: fullText,
|
||||||
|
timestamp: new Date(),
|
||||||
|
read: true,
|
||||||
|
clientId,
|
||||||
|
deliveryStatus: this.online ? 'sending' : 'pending',
|
||||||
|
isVoice,
|
||||||
|
voiceDuration,
|
||||||
|
audioUrl,
|
||||||
|
replyTo,
|
||||||
|
};
|
||||||
|
this.messages.push(optimistic);
|
||||||
|
cacheMessages(this.messages);
|
||||||
|
|
||||||
|
const queueExtra = { audioUrl, audioDuration: voiceDuration, isVoice };
|
||||||
|
|
||||||
|
if (!this.online) {
|
||||||
|
enqueueMessage(fullText, 'normal', clientId, queueExtra);
|
||||||
|
this.pendingCount += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await sendDriverMessage(fullText, clientId, audioUrl, voiceDuration, replyToChatId, designType);
|
||||||
|
runInAction(() => {
|
||||||
|
this.messages = this.messages.map((message) =>
|
||||||
|
message.clientId === clientId ? { ...message, id: String(result.id), deliveryStatus: 'sent' } : message,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
enqueueMessage(fullText, 'normal', clientId, queueExtra);
|
||||||
|
runInAction(() => {
|
||||||
|
this.messages = this.messages.map((message) =>
|
||||||
|
message.clientId === clientId ? { ...message, deliveryStatus: 'pending' } : message,
|
||||||
|
);
|
||||||
|
this.pendingCount += 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ingest(raw: RawMessage[]): void {
|
||||||
|
runInAction(() => (this.connection = 'online'));
|
||||||
|
if (raw.length === 0) {
|
||||||
|
void this.syncOfflineQueue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapped = raw.map(mapRawMessage);
|
||||||
|
this.lastServerId = raw[raw.length - 1].id;
|
||||||
|
|
||||||
|
const existingClientIds = new Set(this.messages.filter((message) => message.clientId).map((message) => message.clientId));
|
||||||
|
const existingIds = new Set(this.messages.map((message) => message.id));
|
||||||
|
const fresh = mapped.filter((message) => {
|
||||||
|
if (existingIds.has(message.id)) return false;
|
||||||
|
if (message.clientId && existingClientIds.has(message.clientId)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
let merged = this.messages.map((message) => {
|
||||||
|
if (message.clientId) {
|
||||||
|
const server = mapped.find((candidate) => candidate.clientId === message.clientId);
|
||||||
|
if (server) return { ...message, id: server.id, deliveryStatus: server.deliveryStatus ?? 'delivered' };
|
||||||
|
}
|
||||||
|
const serverById = mapped.find((candidate) => candidate.id === message.id);
|
||||||
|
if (serverById?.deliveryStatus && serverById.deliveryStatus !== message.deliveryStatus) {
|
||||||
|
return { ...message, deliveryStatus: serverById.deliveryStatus };
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
});
|
||||||
|
|
||||||
|
const important = fresh.find((message) => message.type === 'important');
|
||||||
|
if (important) this.pendingImportant = important;
|
||||||
|
|
||||||
|
merged = [...merged, ...fresh].slice(-MAX_MESSAGES);
|
||||||
|
this.messages = merged;
|
||||||
|
cacheMessages(merged);
|
||||||
|
void this.syncOfflineQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async syncOfflineQueue(): Promise<void> {
|
||||||
|
if (this.syncing) return;
|
||||||
|
this.syncing = true;
|
||||||
|
try {
|
||||||
|
const pending = pendingMessages();
|
||||||
|
if (pending.length === 0) {
|
||||||
|
runInAction(() => (this.pendingCount = 0));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = await sendBatch(
|
||||||
|
pending.map((message) => ({
|
||||||
|
text: message.text,
|
||||||
|
type: message.type,
|
||||||
|
clientId: message.clientId,
|
||||||
|
audioUrl: message.audioUrl,
|
||||||
|
audioDuration: message.audioDuration,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
runInAction(() => {
|
||||||
|
for (const result of response.results) {
|
||||||
|
if (result.status === 'sent' || result.status === 'duplicate') {
|
||||||
|
markMessageSent(result.clientId, result.serverId);
|
||||||
|
this.messages = this.messages.map((message) =>
|
||||||
|
message.clientId === result.clientId
|
||||||
|
? { ...message, id: String(result.serverId), deliveryStatus: 'sent' }
|
||||||
|
: message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clearSentMessages();
|
||||||
|
this.pendingCount = pendingMessages().length;
|
||||||
|
});
|
||||||
|
void flushActionsQueue();
|
||||||
|
} catch {
|
||||||
|
runInAction(() => (this.pendingCount = pendingMessages().length));
|
||||||
|
} finally {
|
||||||
|
this.syncing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/entities/message/model/types.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
export type MessageType = 'normal' | 'important' | 'can_error' | 'dispatcher' | 'driver_self' | 'call';
|
||||||
|
|
||||||
|
export type DeliveryStatus = 'pending' | 'sending' | 'sent' | 'delivered' | 'failed' | 'read';
|
||||||
|
|
||||||
|
export interface CallMeta {
|
||||||
|
callId: string;
|
||||||
|
status: 'answered' | 'declined' | 'missed';
|
||||||
|
durationSec: number;
|
||||||
|
dispatcherName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
type: MessageType;
|
||||||
|
text: string;
|
||||||
|
timestamp: Date;
|
||||||
|
read: boolean;
|
||||||
|
confirmed?: boolean;
|
||||||
|
confirmedAt?: Date;
|
||||||
|
clientId?: string;
|
||||||
|
deliveryStatus?: DeliveryStatus;
|
||||||
|
isVoice?: boolean;
|
||||||
|
voiceDuration?: number;
|
||||||
|
transcription?: string;
|
||||||
|
audioUrl?: string;
|
||||||
|
replyTo?: string;
|
||||||
|
replyToId?: string;
|
||||||
|
replyToAuthor?: string;
|
||||||
|
replyToChatId?: number;
|
||||||
|
senderName?: string;
|
||||||
|
senderRole?: string;
|
||||||
|
senderLabel?: string;
|
||||||
|
senderIcon?: string;
|
||||||
|
senderUserId?: number;
|
||||||
|
designType?: string;
|
||||||
|
notificationType?: string;
|
||||||
|
messageType?: string;
|
||||||
|
callMeta?: CallMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOTIFICATION_DESIGN_KEYS = new Set([
|
||||||
|
'info',
|
||||||
|
'warning',
|
||||||
|
'error',
|
||||||
|
'success',
|
||||||
|
'transport',
|
||||||
|
'weather',
|
||||||
|
'emergency',
|
||||||
|
'schedule',
|
||||||
|
'road',
|
||||||
|
'notification',
|
||||||
|
'alert',
|
||||||
|
'call',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function isImportantMessage(message: Message): boolean {
|
||||||
|
if (message.type === 'important') return true;
|
||||||
|
return (message.designType || '').toLowerCase().trim() === 'important';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNotificationMessage(message: Message): boolean {
|
||||||
|
if (message.type === 'important') return false;
|
||||||
|
const designKey = (message.designType || '').toLowerCase().trim();
|
||||||
|
if (designKey === 'important') return false;
|
||||||
|
if (designKey && NOTIFICATION_DESIGN_KEYS.has(designKey)) return true;
|
||||||
|
const fallbackKey = (message.notificationType || message.messageType || '').toLowerCase().trim();
|
||||||
|
return NOTIFICATION_DESIGN_KEYS.has(fallbackKey);
|
||||||
|
}
|
||||||
14
src/entities/route/api/routeApi.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { useMockData } from '@/shared/config';
|
||||||
|
import { mockRouteStopNames, mockMinutesBetweenStops } from '../mocks/routeStops';
|
||||||
|
|
||||||
|
export interface RouteSchedule {
|
||||||
|
stopNames: string[];
|
||||||
|
minutesBetweenStops: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptySchedule: RouteSchedule = { stopNames: [], minutesBetweenStops: [] };
|
||||||
|
|
||||||
|
export function getRouteSchedule(): RouteSchedule {
|
||||||
|
if (!useMockData) return emptySchedule;
|
||||||
|
return { stopNames: mockRouteStopNames, minutesBetweenStops: mockMinutesBetweenStops };
|
||||||
|
}
|
||||||
1
src/entities/route/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { getRouteSchedule, type RouteSchedule } from './api/routeApi';
|
||||||
24
src/entities/route/mocks/routeStops.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
export const mockRouteStopNames = [
|
||||||
|
'Депо Северное (нач.)',
|
||||||
|
'ул. Заводская',
|
||||||
|
'пл. Ленина',
|
||||||
|
'ул. Садовая',
|
||||||
|
'Центральный рынок',
|
||||||
|
'пр. Мира',
|
||||||
|
'ул. Комсомольская (тек.)',
|
||||||
|
'пл. Советская',
|
||||||
|
'ул. Кирова',
|
||||||
|
'Парк культуры',
|
||||||
|
'ул. Победы',
|
||||||
|
'ст. м. Площадь',
|
||||||
|
'Торговый центр',
|
||||||
|
'ул. Гагарина',
|
||||||
|
'пр. Строителей',
|
||||||
|
'ул. Молодёжная',
|
||||||
|
'Стадион',
|
||||||
|
'Больница №2',
|
||||||
|
'ул. Весенняя',
|
||||||
|
'Депо Южное (кон.)',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockMinutesBetweenStops = [0, 3, 2, 2, 4, 3, 2, 3, 2, 4, 3, 2, 3, 2, 3, 2, 4, 3, 2, 3];
|
||||||
32
src/entities/session/api/deviceActivation.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Platform } from 'react-native';
|
||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
import { storage } from '@/shared/lib';
|
||||||
|
import { apiBaseUrl } from '@/shared/config';
|
||||||
|
|
||||||
|
export interface DeviceActivation { deviceId: string; deviceSecret: string }
|
||||||
|
const key = `irida_device_${Array.from(apiBaseUrl, char => char.codePointAt(0)!.toString(16)).join('_')}`;
|
||||||
|
|
||||||
|
export const deviceActivationStorage = {
|
||||||
|
async load(): Promise<DeviceActivation | null> {
|
||||||
|
const raw = Platform.OS === 'web' ? storage.get(key) : await SecureStore.getItemAsync(key);
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(raw) as Partial<DeviceActivation>;
|
||||||
|
return typeof value.deviceId === 'string' && /^[1-9]\d*$/.test(value.deviceId) && typeof value.deviceSecret === 'string' && !!value.deviceSecret.trim()
|
||||||
|
? { deviceId: value.deviceId, deviceSecret: value.deviceSecret } : null;
|
||||||
|
} catch { return null; }
|
||||||
|
},
|
||||||
|
async save(value: DeviceActivation): Promise<void> {
|
||||||
|
const raw = JSON.stringify(value);
|
||||||
|
if (Platform.OS === 'web') storage.set(key, raw);
|
||||||
|
else await SecureStore.setItemAsync(key, raw, { keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY });
|
||||||
|
const persisted = await deviceActivationStorage.load();
|
||||||
|
if (persisted?.deviceId !== value.deviceId || persisted.deviceSecret !== value.deviceSecret) {
|
||||||
|
throw new Error('Не удалось подтвердить сохранение активации устройства.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async clear(): Promise<void> {
|
||||||
|
if (Platform.OS === 'web') storage.remove(key);
|
||||||
|
else await SecureStore.deleteItemAsync(key);
|
||||||
|
},
|
||||||
|
};
|
||||||
5
src/entities/session/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export type { AppScreen, ThemeMode } from './model/types';
|
||||||
|
export type { ConnectionStatus } from '@/shared/api';
|
||||||
|
export { sessionStore, SessionStore } from './model/sessionStore';
|
||||||
|
export { storedToken, storedDriver, sessionStartedAt, persistSession } from './model/sessionStorage';
|
||||||
|
export { LiveSessionStore } from './model/liveSessionStore';
|
||||||
167
src/entities/session/model/liveSessionStore.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { makeAutoObservable, runInAction, type AnnotationsMap } from 'mobx';
|
||||||
|
import { HttpError, type RequestOptions } from '@/shared/api';
|
||||||
|
import { iridaRequest, type ServerSession, type ServerShift } from '@/shared/api/irida';
|
||||||
|
import { hydrateStorage, storage } from '@/shared/lib';
|
||||||
|
import { apiBaseUrl } from '@/shared/config';
|
||||||
|
import { deviceActivationStorage, type DeviceActivation } from '../api/deviceActivation';
|
||||||
|
|
||||||
|
const KEY = `irida:chat-session:${apiBaseUrl}`;
|
||||||
|
const PENDING_SHIFT_KEY = `${KEY}:pending-shift`;
|
||||||
|
interface PendingShift { userId: string; deviceId: string; id: string }
|
||||||
|
|
||||||
|
export class LiveSessionStore {
|
||||||
|
session: ServerSession | null = null;
|
||||||
|
activation: DeviceActivation | null = null;
|
||||||
|
ready = false;
|
||||||
|
busy = false;
|
||||||
|
error: string | null = null;
|
||||||
|
private refreshPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
makeAutoObservable(this, { refreshPromise: false } as AnnotationsMap<LiveSessionStore, never>, { autoBind: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
await hydrateStorage();
|
||||||
|
try {
|
||||||
|
const activation = await deviceActivationStorage.load();
|
||||||
|
runInAction(() => {
|
||||||
|
this.activation = activation;
|
||||||
|
const saved = storage.getJson<ServerSession>(KEY);
|
||||||
|
this.session = saved?.accessToken && saved.refreshToken && saved.user?.id && saved.shift?.id ? saved : null;
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
runInAction(() => { this.error = 'Не удалось открыть сохранённую активацию устройства. Повторите запуск.'; });
|
||||||
|
} finally { runInAction(() => { this.ready = true; }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async activateDevice(deviceId: string, deviceSecret: string) {
|
||||||
|
if (this.busy || this.session) return;
|
||||||
|
const activation = { deviceId: deviceId.trim(), deviceSecret: deviceSecret.trim() };
|
||||||
|
if (!/^[1-9]\d*$/.test(activation.deviceId) || !activation.deviceSecret) {
|
||||||
|
this.error = 'Введите ID и секрет устройства из админки.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.busy = true;
|
||||||
|
this.error = null;
|
||||||
|
try {
|
||||||
|
await deviceActivationStorage.save(activation);
|
||||||
|
runInAction(() => { this.activation = activation; });
|
||||||
|
} catch {
|
||||||
|
runInAction(() => { this.error = 'Не удалось сохранить активацию устройства.'; });
|
||||||
|
} finally { runInAction(() => { this.busy = false; }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetDevice() {
|
||||||
|
if (this.busy) return;
|
||||||
|
const previous = this.session;
|
||||||
|
if (previous) await this.logout();
|
||||||
|
this.busy = true;
|
||||||
|
try {
|
||||||
|
await deviceActivationStorage.clear();
|
||||||
|
if (this.session && previous?.shift?.id) {
|
||||||
|
storage.setJson(PENDING_SHIFT_KEY, { userId: previous.user.id, deviceId: previous.shift.deviceId, id: previous.shift.id });
|
||||||
|
}
|
||||||
|
runInAction(() => { this.save(null); this.activation = null; this.error = null; });
|
||||||
|
} catch {
|
||||||
|
runInAction(() => { this.error = 'Не удалось сбросить активацию устройства.'; });
|
||||||
|
throw new Error('Не удалось сбросить активацию устройства.');
|
||||||
|
} finally { runInAction(() => { this.busy = false; }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private save(session: ServerSession | null) {
|
||||||
|
this.session = session;
|
||||||
|
if (session) storage.setJson(KEY, session);
|
||||||
|
else storage.remove(KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(email: string, password: string) {
|
||||||
|
if (this.busy) return;
|
||||||
|
const activation = this.activation;
|
||||||
|
if (!activation) { this.error = 'Сначала активируйте устройство.'; return; }
|
||||||
|
const { deviceId, deviceSecret } = activation;
|
||||||
|
this.busy = true;
|
||||||
|
this.error = null;
|
||||||
|
let preliminary: ServerSession | undefined;
|
||||||
|
let stage = 'Вход пользователя';
|
||||||
|
try {
|
||||||
|
preliminary = await iridaRequest<ServerSession>('/auth/email/login', {
|
||||||
|
method: 'POST', body: { email: email.trim(), password },
|
||||||
|
});
|
||||||
|
stage = `Открытие смены на устройстве ${deviceId}`;
|
||||||
|
const pending = storage.getJson<PendingShift>(PENDING_SHIFT_KEY);
|
||||||
|
let shiftId = pending?.userId === preliminary.user.id && pending.deviceId === deviceId.trim() ? pending.id : null;
|
||||||
|
if (!shiftId) {
|
||||||
|
const { driverShift } = await iridaRequest<{ driverShift: ServerShift }>('/shifts/start', {
|
||||||
|
method: 'POST', body: { userId: preliminary.user.id, deviceId: deviceId.trim(), deviceSecret },
|
||||||
|
});
|
||||||
|
shiftId = driverShift.id;
|
||||||
|
storage.setJson(PENDING_SHIFT_KEY, { userId: preliminary.user.id, deviceId: deviceId.trim(), id: shiftId });
|
||||||
|
}
|
||||||
|
stage = 'Привязка сессии к смене';
|
||||||
|
const session = await iridaRequest<ServerSession>('/auth/email/login', {
|
||||||
|
method: 'POST', body: { email: email.trim(), password, shiftId },
|
||||||
|
});
|
||||||
|
if (!session.shift?.id) throw new Error('Сервер не привязал устройство к сессии.');
|
||||||
|
runInAction(() => this.save(session));
|
||||||
|
storage.remove(PENDING_SHIFT_KEY);
|
||||||
|
} catch (error) {
|
||||||
|
runInAction(() => { this.error = `${stage}: ${error instanceof Error ? error.message : 'Не удалось войти'}`; });
|
||||||
|
} finally {
|
||||||
|
if (preliminary?.refreshToken) {
|
||||||
|
void iridaRequest('/auth/logout', { method: 'POST', body: { refreshToken: preliminary.refreshToken } }).catch(() => {});
|
||||||
|
}
|
||||||
|
runInAction(() => { this.busy = false; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refresh() {
|
||||||
|
if (this.refreshPromise) return this.refreshPromise;
|
||||||
|
const previous = this.session;
|
||||||
|
if (!previous) throw new HttpError(401, 'Войдите в приложение');
|
||||||
|
this.refreshPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const result = await iridaRequest<{ accessToken: string }>('/auth/refresh', {
|
||||||
|
method: 'POST', body: { refreshToken: previous.refreshToken },
|
||||||
|
});
|
||||||
|
if (this.session !== previous) throw new Error('Сессия изменилась');
|
||||||
|
runInAction(() => this.save({ ...previous, accessToken: result.accessToken }));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpError && error.status === 401 && this.session === previous) {
|
||||||
|
runInAction(() => { this.save(null); this.error = 'Сессия истекла. Войдите снова.'; });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally { this.refreshPromise = null; }
|
||||||
|
})();
|
||||||
|
return this.refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async authorized<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||||
|
const session = this.session;
|
||||||
|
if (!session) throw new HttpError(401, 'Войдите в приложение');
|
||||||
|
const perform = (token: string) => iridaRequest<T>(path, {
|
||||||
|
...options, headers: { ...options.headers, Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
try { return await perform(session.accessToken); }
|
||||||
|
catch (error) {
|
||||||
|
if (!(error instanceof HttpError) || error.status !== 401 || options.signal?.aborted) throw error;
|
||||||
|
if (this.session === session) await this.refresh();
|
||||||
|
if (!this.session || this.session.user.id !== session.user.id) throw error;
|
||||||
|
return perform(this.session.accessToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout() {
|
||||||
|
if (!this.session || this.busy) return;
|
||||||
|
this.busy = true;
|
||||||
|
this.error = null;
|
||||||
|
const session = this.session;
|
||||||
|
try {
|
||||||
|
await this.authorized('/shifts/stop', { method: 'POST', body: { shiftId: session.shift!.id } });
|
||||||
|
runInAction(() => this.save(null));
|
||||||
|
void iridaRequest('/auth/logout', { method: 'POST', body: { refreshToken: session.refreshToken } }).catch(() => {});
|
||||||
|
} catch (error) {
|
||||||
|
runInAction(() => { this.error = error instanceof Error ? error.message : 'Не удалось завершить смену'; });
|
||||||
|
} finally { runInAction(() => { this.busy = false; }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/entities/session/model/sessionStorage.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { storage } from '@/shared/lib';
|
||||||
|
import { setAuthTokenProvider } from '@/shared/api';
|
||||||
|
import type { Driver, MrmAdmin } from '@/entities/driver/@x/session';
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'driver_session_token';
|
||||||
|
const DRIVER_KEY = 'driver_info';
|
||||||
|
const SESSION_START_KEY = 'driver_session_start';
|
||||||
|
const MRM_KEY = 'mrm_admin_info';
|
||||||
|
|
||||||
|
setAuthTokenProvider(() => storage.get(TOKEN_KEY));
|
||||||
|
|
||||||
|
export function storedToken(): string | null {
|
||||||
|
return storage.get(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storedDriver(): Driver | null {
|
||||||
|
return storage.getJson<Driver>(DRIVER_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storedMrmAdmin(): MrmAdmin | null {
|
||||||
|
return storage.getJson<MrmAdmin>(MRM_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sessionStartedAt(): number | null {
|
||||||
|
const value = storage.get(SESSION_START_KEY);
|
||||||
|
return value ? Number(value) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persistSession(token: string, driver: Driver): void {
|
||||||
|
storage.set(TOKEN_KEY, token);
|
||||||
|
storage.setJson(DRIVER_KEY, driver);
|
||||||
|
storage.set(SESSION_START_KEY, String(Date.now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persistDriver(driver: Driver): void {
|
||||||
|
storage.setJson(DRIVER_KEY, driver);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persistMrmAdmin(admin: MrmAdmin): void {
|
||||||
|
storage.setJson(MRM_KEY, admin);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearMrmAdmin(): void {
|
||||||
|
storage.remove(MRM_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSession(): void {
|
||||||
|
storage.remove(TOKEN_KEY);
|
||||||
|
storage.remove(DRIVER_KEY);
|
||||||
|
storage.remove(SESSION_START_KEY);
|
||||||
|
}
|
||||||
286
src/entities/session/model/sessionStore.ts
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
import { makeAutoObservable, reaction, runInAction } from 'mobx';
|
||||||
|
import { AppState, type AppStateStatus } from 'react-native';
|
||||||
|
import { themeStore } from '@/shared/config';
|
||||||
|
import { appEvents, currentPosition, hydrateStorage, startGeoTracking, stopGeoTracking, storage } from '@/shared/lib';
|
||||||
|
import type { Driver, MrmAdmin } from '@/entities/driver/@x/session';
|
||||||
|
import { MessageStore } from '@/entities/message/@x/session';
|
||||||
|
import { loginByPin, loginMrm as loginMrmRequest, logout as logoutRequest, sendHeartbeat } from '@/entities/driver/@x/session';
|
||||||
|
import type { AppScreen, ThemeMode } from './types';
|
||||||
|
import {
|
||||||
|
clearMrmAdmin,
|
||||||
|
clearSession,
|
||||||
|
persistDriver,
|
||||||
|
persistMrmAdmin,
|
||||||
|
persistSession,
|
||||||
|
sessionStartedAt,
|
||||||
|
storedDriver,
|
||||||
|
storedMrmAdmin,
|
||||||
|
storedToken,
|
||||||
|
} from './sessionStorage';
|
||||||
|
|
||||||
|
const HEARTBEAT_INTERVAL = 10_000;
|
||||||
|
const SPEED_INTERVAL = 3_000;
|
||||||
|
|
||||||
|
function themeStorageKey(driverId: string): string {
|
||||||
|
return `kiosk:theme:${driverId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SessionStore {
|
||||||
|
readonly messageStore = new MessageStore();
|
||||||
|
|
||||||
|
screen: AppScreen = 'login';
|
||||||
|
driver: Driver | null = null;
|
||||||
|
mrmAdmin: MrmAdmin | null = null;
|
||||||
|
sessionKicked = false;
|
||||||
|
ready = false;
|
||||||
|
|
||||||
|
theme: ThemeMode = 'dark';
|
||||||
|
isDark = true;
|
||||||
|
|
||||||
|
isMoving = true;
|
||||||
|
speed = 32;
|
||||||
|
currentStopIndex = 3;
|
||||||
|
|
||||||
|
loginError: string | null = null;
|
||||||
|
loginLoading = false;
|
||||||
|
|
||||||
|
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private speedTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private appStateSub: { remove: () => void } | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
makeAutoObservable(this, {}, { autoBind: true });
|
||||||
|
reaction(
|
||||||
|
() => this.isDark,
|
||||||
|
(isDark) => themeStore.setDark(isDark),
|
||||||
|
{ fireImmediately: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async init(): Promise<void> {
|
||||||
|
if (this.ready) return;
|
||||||
|
await hydrateStorage();
|
||||||
|
runInAction(() => {
|
||||||
|
this.driver = storedDriver();
|
||||||
|
this.mrmAdmin = storedMrmAdmin();
|
||||||
|
this.restore();
|
||||||
|
this.ready = true;
|
||||||
|
});
|
||||||
|
this.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
get messages() {
|
||||||
|
return this.messageStore.messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
get connection() {
|
||||||
|
return this.messageStore.connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
get pendingImportant() {
|
||||||
|
return this.messageStore.pendingImportant;
|
||||||
|
}
|
||||||
|
|
||||||
|
get pendingCount() {
|
||||||
|
return this.messageStore.pendingCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
get unreadCount() {
|
||||||
|
return this.messageStore.unreadCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
private restore(): void {
|
||||||
|
if (this.mrmAdmin) {
|
||||||
|
this.screen = 'main';
|
||||||
|
} else if (storedToken() && this.driver) {
|
||||||
|
this.screen = 'main';
|
||||||
|
this.messageStore.hydrateFromCache();
|
||||||
|
}
|
||||||
|
if (this.driver) this.applyStoredTheme(this.driver.id);
|
||||||
|
this.isDark = this.theme === 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
this.speedTimer = setInterval(() => {
|
||||||
|
if (this.screen !== 'main') return;
|
||||||
|
runInAction(() => {
|
||||||
|
this.speed = Math.max(0, Math.min(60, this.speed + (Math.random() - 0.5) * 8));
|
||||||
|
});
|
||||||
|
}, SPEED_INTERVAL);
|
||||||
|
this.startSessionLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.speedTimer) clearInterval(this.speedTimer);
|
||||||
|
this.stopSessionLoop();
|
||||||
|
this.messageStore.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private startSessionLoop(): void {
|
||||||
|
if (this.screen === 'login' || this.screen === 'welcome') return;
|
||||||
|
startGeoTracking();
|
||||||
|
this.messageStore.start();
|
||||||
|
void this.heartbeat();
|
||||||
|
this.heartbeatTimer = setInterval(() => void this.heartbeat(), HEARTBEAT_INTERVAL);
|
||||||
|
this.appStateSub = AppState.addEventListener('change', (state: AppStateStatus) => {
|
||||||
|
if (state === 'active') void this.heartbeat();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopSessionLoop(): void {
|
||||||
|
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
||||||
|
this.heartbeatTimer = null;
|
||||||
|
this.appStateSub?.remove();
|
||||||
|
this.appStateSub = null;
|
||||||
|
stopGeoTracking();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async heartbeat(): Promise<void> {
|
||||||
|
const result = await sendHeartbeat(
|
||||||
|
storedToken(),
|
||||||
|
currentPosition(),
|
||||||
|
this.driver?.deviationSettings?.updatedAt ?? null,
|
||||||
|
);
|
||||||
|
if (result.sessionExpired) {
|
||||||
|
runInAction(() => {
|
||||||
|
this.sessionKicked = true;
|
||||||
|
this.driver = null;
|
||||||
|
this.screen = 'login';
|
||||||
|
});
|
||||||
|
clearSession();
|
||||||
|
this.stopSessionLoop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.deviationSettings && this.driver) {
|
||||||
|
runInAction(() => {
|
||||||
|
this.driver = { ...this.driver!, deviationSettings: result.deviationSettings! };
|
||||||
|
});
|
||||||
|
persistDriver(this.driver);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyStoredTheme(driverId: string): void {
|
||||||
|
const raw = storage.get(themeStorageKey(driverId));
|
||||||
|
if (raw === 'light' || raw === 'dark') this.theme = raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapDriver(raw: Record<string, unknown>): Driver {
|
||||||
|
return {
|
||||||
|
id: String(raw.id),
|
||||||
|
name: raw.name as string,
|
||||||
|
vehicleType: raw.vehicleType as Driver['vehicleType'],
|
||||||
|
vehicleNumber: raw.vehicleNumber as string,
|
||||||
|
routeNumber: raw.routeNumber as string,
|
||||||
|
routeId: (raw.routeId as string) ?? undefined,
|
||||||
|
shiftStart: (raw.shiftStart as string) || '08:00',
|
||||||
|
dispatcherName: (raw.dispatcherName as string) ?? undefined,
|
||||||
|
deviationSettings: (raw.deviationSettings as Driver['deviationSettings']) ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(employeeId: string, pin: string): Promise<boolean> {
|
||||||
|
this.loginError = null;
|
||||||
|
this.loginLoading = true;
|
||||||
|
this.sessionKicked = false;
|
||||||
|
try {
|
||||||
|
const { driver, token } = await loginByPin(employeeId, pin);
|
||||||
|
persistSession(token, driver);
|
||||||
|
runInAction(() => {
|
||||||
|
this.driver = driver;
|
||||||
|
this.messageStore.reset();
|
||||||
|
});
|
||||||
|
this.startShift();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
runInAction(() => {
|
||||||
|
this.loginError = error instanceof Error ? error.message : 'Ошибка входа';
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
runInAction(() => (this.loginLoading = false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loginSmsVerified(data: { token: string; driver: Record<string, unknown> }): void {
|
||||||
|
const driver = this.mapDriver(data.driver);
|
||||||
|
persistSession(data.token, driver);
|
||||||
|
this.driver = driver;
|
||||||
|
this.messageStore.reset();
|
||||||
|
this.loginError = null;
|
||||||
|
this.startShift();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loginMrm(login: string, password: string): Promise<void> {
|
||||||
|
this.loginError = null;
|
||||||
|
this.loginLoading = true;
|
||||||
|
try {
|
||||||
|
const admin = await loginMrmRequest(login, password);
|
||||||
|
persistMrmAdmin(admin);
|
||||||
|
runInAction(() => {
|
||||||
|
this.mrmAdmin = admin;
|
||||||
|
this.screen = 'main';
|
||||||
|
});
|
||||||
|
this.startSessionLoop();
|
||||||
|
} catch (error) {
|
||||||
|
runInAction(() => {
|
||||||
|
this.loginError = error instanceof Error ? error.message : 'Ошибка входа';
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
runInAction(() => (this.loginLoading = false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startShift(): void {
|
||||||
|
this.screen = 'main';
|
||||||
|
this.isMoving = true;
|
||||||
|
this.startSessionLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
setScreen(screen: AppScreen): void {
|
||||||
|
this.screen = screen;
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(): Promise<void> {
|
||||||
|
const token = storedToken();
|
||||||
|
clearSession();
|
||||||
|
clearMrmAdmin();
|
||||||
|
this.stopSessionLoop();
|
||||||
|
this.messageStore.stop();
|
||||||
|
runInAction(() => {
|
||||||
|
this.driver = null;
|
||||||
|
this.mrmAdmin = null;
|
||||||
|
this.messageStore.reset();
|
||||||
|
this.screen = 'login';
|
||||||
|
this.isMoving = false;
|
||||||
|
});
|
||||||
|
await logoutRequest(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
dismissSessionKicked(): void {
|
||||||
|
this.sessionKicked = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleTheme(): void {
|
||||||
|
this.theme = this.theme === 'light' ? 'dark' : 'light';
|
||||||
|
this.isDark = this.theme === 'dark';
|
||||||
|
this.persistTheme();
|
||||||
|
appEvents.emit('theme:changed', this.theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTheme(theme: ThemeMode): void {
|
||||||
|
this.theme = theme;
|
||||||
|
this.isDark = this.theme === 'dark';
|
||||||
|
this.persistTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
private persistTheme(): void {
|
||||||
|
if (!this.driver) return;
|
||||||
|
storage.set(themeStorageKey(this.driver.id), this.theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
get sessionStartedAt(): number | null {
|
||||||
|
return sessionStartedAt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sessionStore = new SessionStore();
|
||||||
3
src/entities/session/model/types.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export type AppScreen = 'login' | 'welcome' | 'new_docs' | 'main';
|
||||||
|
|
||||||
|
export type ThemeMode = 'light' | 'dark';
|
||||||
11
src/entities/vehicle/api/vehicleApi.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { useMockData } from '@/shared/config';
|
||||||
|
import type { MapVehicle, RouteLine } from '../model/route';
|
||||||
|
import { mockMapVehicles, mockRoutePolyline } from '../mocks/vehicles';
|
||||||
|
|
||||||
|
export function getRoutePolyline(): RouteLine {
|
||||||
|
return useMockData ? mockRoutePolyline : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMapVehicles(): MapVehicle[] {
|
||||||
|
return useMockData ? mockMapVehicles : [];
|
||||||
|
}
|
||||||
2
src/entities/vehicle/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { positionOnRoute, type MapVehicle, type RouteLine } from './model/route';
|
||||||
|
export { getRoutePolyline, getMapVehicles } from './api/vehicleApi';
|
||||||
20
src/entities/vehicle/mocks/vehicles.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { MapVehicle, RouteLine } from '../model/route';
|
||||||
|
|
||||||
|
export const mockRoutePolyline: RouteLine = [
|
||||||
|
[56.831, 60.595],
|
||||||
|
[56.833, 60.601],
|
||||||
|
[56.835, 60.607],
|
||||||
|
[56.837, 60.613],
|
||||||
|
[56.839, 60.619],
|
||||||
|
[56.841, 60.625],
|
||||||
|
[56.843, 60.631],
|
||||||
|
[56.845, 60.637],
|
||||||
|
[56.847, 60.643],
|
||||||
|
[56.849, 60.649],
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockMapVehicles: MapVehicle[] = [
|
||||||
|
{ label: 'Б-3405', progress: 0.15, color: '#f59e0b', role: 'впереди' },
|
||||||
|
{ label: 'Б-3407', progress: 0.42, color: '#22c55e', role: 'вы' },
|
||||||
|
{ label: 'Б-3410', progress: 0.72, color: '#3b82f6', role: 'сзади' },
|
||||||
|
];
|
||||||
18
src/entities/vehicle/model/route.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export type RouteLine = [number, number][];
|
||||||
|
|
||||||
|
export interface MapVehicle {
|
||||||
|
label: string;
|
||||||
|
progress: number;
|
||||||
|
color: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function positionOnRoute(line: RouteLine, progress: number): [number, number] {
|
||||||
|
const total = line.length - 1;
|
||||||
|
if (total < 1) return [0, 0];
|
||||||
|
const segmentIndex = Math.min(Math.floor(progress * total), total - 1);
|
||||||
|
const segmentProgress = progress * total - segmentIndex;
|
||||||
|
const [fromLat, fromLng] = line[segmentIndex];
|
||||||
|
const [toLat, toLng] = line[Math.min(segmentIndex + 1, total)];
|
||||||
|
return [fromLat + (toLat - fromLat) * segmentProgress, fromLng + (toLng - fromLng) * segmentProgress];
|
||||||
|
}
|
||||||
1
src/features/device-activation/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { ResetDeviceButton } from './ui/ResetDeviceButton';
|
||||||