feat: board list view, excalidraw basePath fix, analytics, feedback pages
- Board: replace blank iframe with board list (my_boards + shared_with_me), mentor gets per-student buttons to open/create boards, iframe fills full available height via CSS vars (dvh) - Excalidraw: build with NEXT_PUBLIC_BASE_PATH=/devboard so _next/ assets served under /devboard/_next/ and nginx path proxy works correctly - Nginx: preserve /devboard/ path in proxy_pass (no trailing slash), add /yjs WebSocket proxy to devapi.uchill.online for dev YJS on port 1235 - Analytics: real API charts (income/lessons/students) with DateRangePicker - Feedback: mentor kanban view of completed lessons, grade + notes drawer - Nav: dynamic role-based menu (getNavData(role)), mentor-only analytics/feedback - New API utils: analytics-api.js, board-api.js (full), dashboard-api getLessons() - Routes: paths.dashboard.analytics, feedback, board added Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d4ec417ebf
commit
f679f0c0f4
|
|
@ -284,11 +284,13 @@ services:
|
||||||
build:
|
build:
|
||||||
context: ./excalidraw-server
|
context: ./excalidraw-server
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
- NEXT_PUBLIC_BASE_PATH=/devboard
|
||||||
container_name: ${COMPOSE_PROJECT_NAME}_excalidraw
|
container_name: ${COMPOSE_PROJECT_NAME}_excalidraw
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=${NODE_ENV:-production}
|
- NODE_ENV=${NODE_ENV:-production}
|
||||||
- NEXT_PUBLIC_BASE_PATH=
|
- NEXT_PUBLIC_BASE_PATH=/devboard
|
||||||
ports:
|
ports:
|
||||||
- "${EXCALIDRAW_PORT:-3001}:3001"
|
- "${EXCALIDRAW_PORT:-3001}:3001"
|
||||||
networks:
|
networks:
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,9 @@ RUN npx patch-package
|
||||||
# Копируем исходный код
|
# Копируем исходный код
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Собираем приложение
|
# Собираем приложение (NEXT_PUBLIC_* переменные нужны на этапе сборки)
|
||||||
|
ARG NEXT_PUBLIC_BASE_PATH=
|
||||||
|
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,9 @@
|
||||||
"@fullcalendar/timeline": "^6.1.14",
|
"@fullcalendar/timeline": "^6.1.14",
|
||||||
"@hookform/resolvers": "^3.6.0",
|
"@hookform/resolvers": "^3.6.0",
|
||||||
"@iconify/react": "^5.0.1",
|
"@iconify/react": "^5.0.1",
|
||||||
|
"@livekit/components-core": "^0.12.13",
|
||||||
|
"@livekit/components-react": "^2.9.20",
|
||||||
|
"@livekit/components-styles": "^1.2.0",
|
||||||
"@mui/lab": "^5.0.0-alpha.170",
|
"@mui/lab": "^5.0.0-alpha.170",
|
||||||
"@mui/material": "^5.15.20",
|
"@mui/material": "^5.15.20",
|
||||||
"@mui/material-nextjs": "^5.15.11",
|
"@mui/material-nextjs": "^5.15.11",
|
||||||
|
|
@ -51,6 +54,7 @@
|
||||||
"autosuggest-highlight": "^3.3.4",
|
"autosuggest-highlight": "^3.3.4",
|
||||||
"aws-amplify": "^6.3.6",
|
"aws-amplify": "^6.3.6",
|
||||||
"axios": "^1.7.2",
|
"axios": "^1.7.2",
|
||||||
|
"date-fns": "^3.6.0",
|
||||||
"dayjs": "^1.11.11",
|
"dayjs": "^1.11.11",
|
||||||
"embla-carousel": "^8.1.5",
|
"embla-carousel": "^8.1.5",
|
||||||
"embla-carousel-auto-height": "^8.1.5",
|
"embla-carousel-auto-height": "^8.1.5",
|
||||||
|
|
@ -62,6 +66,7 @@
|
||||||
"i18next": "^23.11.5",
|
"i18next": "^23.11.5",
|
||||||
"i18next-browser-languagedetector": "^8.0.0",
|
"i18next-browser-languagedetector": "^8.0.0",
|
||||||
"i18next-resources-to-backend": "^1.2.1",
|
"i18next-resources-to-backend": "^1.2.1",
|
||||||
|
"livekit-client": "^2.17.2",
|
||||||
"lowlight": "^3.1.0",
|
"lowlight": "^3.1.0",
|
||||||
"mapbox-gl": "^3.4.0",
|
"mapbox-gl": "^3.4.0",
|
||||||
"mui-one-time-password-input": "^2.0.2",
|
"mui-one-time-password-input": "^2.0.2",
|
||||||
|
|
@ -3239,6 +3244,12 @@
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@bufbuild/protobuf": {
|
||||||
|
"version": "1.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz",
|
||||||
|
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
|
||||||
|
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
||||||
|
},
|
||||||
"node_modules/@dnd-kit/accessibility": {
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.0.tgz",
|
||||||
|
|
@ -4076,20 +4087,22 @@
|
||||||
"integrity": "sha512-zuWxyfXNbsKbm96HhXzainONPFqRcoZblQ++e9cAIGUuHfl2cFSBzW01jtesqWG/lqaUyX3H8O1y9oWboGNQBA=="
|
"integrity": "sha512-zuWxyfXNbsKbm96HhXzainONPFqRcoZblQ++e9cAIGUuHfl2cFSBzW01jtesqWG/lqaUyX3H8O1y9oWboGNQBA=="
|
||||||
},
|
},
|
||||||
"node_modules/@floating-ui/core": {
|
"node_modules/@floating-ui/core": {
|
||||||
"version": "1.6.0",
|
"version": "1.7.5",
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
||||||
"integrity": "sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==",
|
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/utils": "^0.2.1"
|
"@floating-ui/utils": "^0.2.11"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@floating-ui/dom": {
|
"node_modules/@floating-ui/dom": {
|
||||||
"version": "1.6.1",
|
"version": "1.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
|
||||||
"integrity": "sha512-iA8qE43/H5iGozC3W0YSnVSW42Vh522yyM1gj+BqRwVsTNOyr231PsXDaV04yT39PsO0QL2QpbI/M0ZaLUQgRQ==",
|
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/core": "^1.6.0",
|
"@floating-ui/core": "^1.7.3",
|
||||||
"@floating-ui/utils": "^0.2.1"
|
"@floating-ui/utils": "^0.2.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@floating-ui/react-dom": {
|
"node_modules/@floating-ui/react-dom": {
|
||||||
|
|
@ -4105,9 +4118,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@floating-ui/utils": {
|
"node_modules/@floating-ui/utils": {
|
||||||
"version": "0.2.1",
|
"version": "0.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
|
||||||
"integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q=="
|
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@fontsource/barlow": {
|
"node_modules/@fontsource/barlow": {
|
||||||
"version": "5.0.13",
|
"version": "5.0.13",
|
||||||
|
|
@ -4382,6 +4396,76 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@livekit/components-core": {
|
||||||
|
"version": "0.12.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.13.tgz",
|
||||||
|
"integrity": "sha512-DQmi84afHoHjZ62wm8y+XPNIDHTwFHAltjd3lmyXj8UZHOY7wcza4vFt1xnghJOD5wLRY58L1dkAgAw59MgWvw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/dom": "1.7.4",
|
||||||
|
"loglevel": "1.9.1",
|
||||||
|
"rxjs": "7.8.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"livekit-client": "^2.17.2",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@livekit/components-react": {
|
||||||
|
"version": "2.9.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.20.tgz",
|
||||||
|
"integrity": "sha512-hjkYOsJj9Jbghb7wM5cI8HoVisKeL6Zcy1VnRWTLm0sqVbto8GJp/17T4Udx85mCPY6Jgh8I1Cv0yVzgz7CQtg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@livekit/components-core": "0.12.13",
|
||||||
|
"clsx": "2.1.1",
|
||||||
|
"events": "^3.3.0",
|
||||||
|
"jose": "^6.0.12",
|
||||||
|
"usehooks-ts": "3.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@livekit/krisp-noise-filter": "^0.2.12 || ^0.3.0",
|
||||||
|
"livekit-client": "^2.17.2",
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18",
|
||||||
|
"tslib": "^2.6.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@livekit/krisp-noise-filter": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@livekit/components-styles": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@livekit/components-styles/-/components-styles-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-74/rt0lDh6aHmOPmWAeDE9C4OrNW9RIdmhX/YRbovQBVNGNVWojRjl3FgQZ5LPFXO6l1maKB4JhXcBFENVxVvw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@livekit/mutex": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
|
"node_modules/@livekit/protocol": {
|
||||||
|
"version": "1.44.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.44.0.tgz",
|
||||||
|
"integrity": "sha512-/vfhDUGcUKO8Q43r6i+5FrDhl5oZjm/X3U4x2Iciqvgn5C8qbj+57YPcWSJ1kyIZm5Cm6AV2nAPjMm3ETD/iyg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@bufbuild/protobuf": "^1.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@mapbox/jsonlint-lines-primitives": {
|
"node_modules/@mapbox/jsonlint-lines-primitives": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz",
|
||||||
|
|
@ -6740,6 +6824,13 @@
|
||||||
"@types/ms": "*"
|
"@types/ms": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/dom-mediacapture-record": {
|
||||||
|
"version": "1.0.22",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz",
|
||||||
|
"integrity": "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/@types/geojson": {
|
"node_modules/@types/geojson": {
|
||||||
"version": "7946.0.13",
|
"version": "7946.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.13.tgz",
|
||||||
|
|
@ -7994,6 +8085,16 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/date-fns": {
|
||||||
|
"version": "3.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
|
||||||
|
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dayjs": {
|
"node_modules/dayjs": {
|
||||||
"version": "1.11.11",
|
"version": "1.11.11",
|
||||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.11.tgz",
|
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.11.tgz",
|
||||||
|
|
@ -10610,6 +10711,15 @@
|
||||||
"restructure": "^3.0.0"
|
"restructure": "^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jose": {
|
||||||
|
"version": "6.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.0.tgz",
|
||||||
|
"integrity": "sha512-xsfE1TcSCbUdo6U07tR0mvhg0flGxU8tPLbF03mirl2ukGQENhUg4ubGYQnhVH0b5stLlPM+WOqDkEl1R1y5sQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-cookie": {
|
"node_modules/js-cookie": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
|
||||||
|
|
@ -10779,6 +10889,40 @@
|
||||||
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.1.3.tgz",
|
||||||
"integrity": "sha512-auMesunaJ8yfkHvK4gfg1K0SaKX/6Wn9g2Aac/NwX+l5VdmFZzo/hdPGxEOETj+ryRa4/fiOPjeeKURSAJx1sg=="
|
"integrity": "sha512-auMesunaJ8yfkHvK4gfg1K0SaKX/6Wn9g2Aac/NwX+l5VdmFZzo/hdPGxEOETj+ryRa4/fiOPjeeKURSAJx1sg=="
|
||||||
},
|
},
|
||||||
|
"node_modules/livekit-client": {
|
||||||
|
"version": "2.17.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.17.2.tgz",
|
||||||
|
"integrity": "sha512-+67y2EtAWZabARlY7kANl/VT1Uu1EJYR5a8qwpT2ub/uBCltsEgEDOxCIMwE9HFR5w+z41HR6GL9hyEvW/y6CQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@livekit/mutex": "1.1.1",
|
||||||
|
"@livekit/protocol": "1.44.0",
|
||||||
|
"events": "^3.3.0",
|
||||||
|
"jose": "^6.1.0",
|
||||||
|
"loglevel": "^1.9.2",
|
||||||
|
"sdp-transform": "^2.15.0",
|
||||||
|
"ts-debounce": "^4.0.0",
|
||||||
|
"tslib": "2.8.1",
|
||||||
|
"typed-emitter": "^2.1.0",
|
||||||
|
"webrtc-adapter": "^9.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/dom-mediacapture-record": "^1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/livekit-client/node_modules/loglevel": {
|
||||||
|
"version": "1.9.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
|
||||||
|
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/loglevel"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/locate-path": {
|
"node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||||
|
|
@ -10830,6 +10974,19 @@
|
||||||
"resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
|
||||||
"integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ=="
|
"integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ=="
|
||||||
},
|
},
|
||||||
|
"node_modules/loglevel": {
|
||||||
|
"version": "1.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.1.tgz",
|
||||||
|
"integrity": "sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/loglevel"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/long": {
|
"node_modules/long": {
|
||||||
"version": "5.2.3",
|
"version": "5.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz",
|
||||||
|
|
@ -13237,9 +13394,10 @@
|
||||||
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="
|
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="
|
||||||
},
|
},
|
||||||
"node_modules/rxjs": {
|
"node_modules/rxjs": {
|
||||||
"version": "7.8.1",
|
"version": "7.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||||
"integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==",
|
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.1.0"
|
"tslib": "^2.1.0"
|
||||||
}
|
}
|
||||||
|
|
@ -13323,6 +13481,21 @@
|
||||||
"resolved": "https://registry.npmjs.org/scrollparent/-/scrollparent-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/scrollparent/-/scrollparent-2.1.0.tgz",
|
||||||
"integrity": "sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA=="
|
"integrity": "sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/sdp": {
|
||||||
|
"version": "3.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.1.tgz",
|
||||||
|
"integrity": "sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/sdp-transform": {
|
||||||
|
"version": "2.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz",
|
||||||
|
"integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"sdp-verify": "checker.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||||
|
|
@ -14044,6 +14217,12 @@
|
||||||
"typescript": ">=4.2.0"
|
"typescript": ">=4.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ts-debounce": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ts-debounce/-/ts-debounce-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-+1iDGY6NmOGidq7i7xZGA4cm8DAa6fqdYcvO5Z6yBevH++Bdo9Qt/mN0TzHUgcCcKv1gmh9+W5dHqz8pMWbCbg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tsconfig-paths": {
|
"node_modules/tsconfig-paths": {
|
||||||
"version": "3.15.0",
|
"version": "3.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
|
||||||
|
|
@ -14069,9 +14248,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tslib": {
|
"node_modules/tslib": {
|
||||||
"version": "2.6.2",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/turndown": {
|
"node_modules/turndown": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
|
|
@ -14185,6 +14365,15 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/typed-emitter": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"rxjs": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.4.5",
|
"version": "5.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
|
||||||
|
|
@ -14473,6 +14662,21 @@
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/usehooks-ts": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lodash.debounce": "^4.0.8"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.15.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
|
@ -14580,6 +14784,19 @@
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
|
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
|
||||||
},
|
},
|
||||||
|
"node_modules/webrtc-adapter": {
|
||||||
|
"version": "9.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.4.tgz",
|
||||||
|
"integrity": "sha512-5ZZY1+lGq8LEKuDlg9M2RPJHlH3R7OVwyHqMcUsLKCgd9Wvf+QrFTCItkXXYPmrJn8H6gRLXbSgxLLdexiqHxw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"sdp": "^3.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0",
|
||||||
|
"npm": ">=3.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/websocket-driver": {
|
"node_modules/websocket-driver": {
|
||||||
"version": "0.7.4",
|
"version": "0.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz",
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,9 @@
|
||||||
"@fullcalendar/timeline": "^6.1.14",
|
"@fullcalendar/timeline": "^6.1.14",
|
||||||
"@hookform/resolvers": "^3.6.0",
|
"@hookform/resolvers": "^3.6.0",
|
||||||
"@iconify/react": "^5.0.1",
|
"@iconify/react": "^5.0.1",
|
||||||
|
"@livekit/components-core": "^0.12.13",
|
||||||
|
"@livekit/components-react": "^2.9.20",
|
||||||
|
"@livekit/components-styles": "^1.2.0",
|
||||||
"@mui/lab": "^5.0.0-alpha.170",
|
"@mui/lab": "^5.0.0-alpha.170",
|
||||||
"@mui/material": "^5.15.20",
|
"@mui/material": "^5.15.20",
|
||||||
"@mui/material-nextjs": "^5.15.11",
|
"@mui/material-nextjs": "^5.15.11",
|
||||||
|
|
@ -62,6 +65,7 @@
|
||||||
"autosuggest-highlight": "^3.3.4",
|
"autosuggest-highlight": "^3.3.4",
|
||||||
"aws-amplify": "^6.3.6",
|
"aws-amplify": "^6.3.6",
|
||||||
"axios": "^1.7.2",
|
"axios": "^1.7.2",
|
||||||
|
"date-fns": "^3.6.0",
|
||||||
"dayjs": "^1.11.11",
|
"dayjs": "^1.11.11",
|
||||||
"embla-carousel": "^8.1.5",
|
"embla-carousel": "^8.1.5",
|
||||||
"embla-carousel-auto-height": "^8.1.5",
|
"embla-carousel-auto-height": "^8.1.5",
|
||||||
|
|
@ -73,6 +77,7 @@
|
||||||
"i18next": "^23.11.5",
|
"i18next": "^23.11.5",
|
||||||
"i18next-browser-languagedetector": "^8.0.0",
|
"i18next-browser-languagedetector": "^8.0.0",
|
||||||
"i18next-resources-to-backend": "^1.2.1",
|
"i18next-resources-to-backend": "^1.2.1",
|
||||||
|
"livekit-client": "^2.17.2",
|
||||||
"lowlight": "^3.1.0",
|
"lowlight": "^3.1.0",
|
||||||
"mapbox-gl": "^3.4.0",
|
"mapbox-gl": "^3.4.0",
|
||||||
"mui-one-time-password-input": "^2.0.2",
|
"mui-one-time-password-input": "^2.0.2",
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,61 @@
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
import { format, startOfMonth, endOfMonth, addMonths, subMonths } from 'date-fns';
|
||||||
import useSWR, { mutate } from 'swr';
|
import useSWR, { mutate } from 'swr';
|
||||||
import { getCalendarLessons, getMentorStudents, getMentorSubjects, createCalendarLesson } from 'src/utils/dashboard-api';
|
|
||||||
|
import {
|
||||||
|
getCalendarLessons,
|
||||||
|
getMentorStudents,
|
||||||
|
getMentorSubjects,
|
||||||
|
createCalendarLesson,
|
||||||
|
updateCalendarLesson,
|
||||||
|
deleteCalendarLesson,
|
||||||
|
} from 'src/utils/dashboard-api';
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
const CALENDAR_ENDPOINT = '/schedule/lessons/calendar/';
|
|
||||||
const STUDENTS_ENDPOINT = '/manage/clients/?page=1&page_size=200';
|
const STUDENTS_ENDPOINT = '/manage/clients/?page=1&page_size=200';
|
||||||
const SUBJECTS_ENDPOINT = '/schedule/subjects/';
|
const SUBJECTS_ENDPOINT = '/schedule/subjects/';
|
||||||
|
|
||||||
const swrOptions = {
|
const swrOptions = {
|
||||||
revalidateIfStale: true,
|
revalidateIfStale: true,
|
||||||
revalidateOnFocus: true,
|
revalidateOnFocus: false,
|
||||||
revalidateOnReconnect: true,
|
revalidateOnReconnect: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Ключ кэша для календаря (по месяцу)
|
||||||
|
function calendarKey(date = new Date()) {
|
||||||
|
const start = format(startOfMonth(subMonths(date, 1)), 'yyyy-MM-dd');
|
||||||
|
const end = format(endOfMonth(addMonths(date, 1)), 'yyyy-MM-dd');
|
||||||
|
return ['calendar', start, end];
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
export function useGetEvents() {
|
export function useGetEvents(currentDate) {
|
||||||
const startDate = '2026-02-01';
|
const date = currentDate || new Date();
|
||||||
const endDate = '2026-04-30';
|
const start = format(startOfMonth(subMonths(date, 1)), 'yyyy-MM-dd');
|
||||||
|
const end = format(endOfMonth(addMonths(date, 1)), 'yyyy-MM-dd');
|
||||||
|
|
||||||
const { data: response, isLoading, error, isValidating } = useSWR(
|
const { data: response, isLoading, error, isValidating } = useSWR(
|
||||||
[CALENDAR_ENDPOINT, startDate, endDate],
|
['calendar', start, end],
|
||||||
([url, start, end]) => getCalendarLessons(start, end),
|
([, s, e]) => getCalendarLessons(s, e),
|
||||||
swrOptions
|
swrOptions
|
||||||
);
|
);
|
||||||
|
|
||||||
const memoizedValue = useMemo(() => {
|
const memoizedValue = useMemo(() => {
|
||||||
const lessonsArray = response?.data?.lessons || [];
|
const lessonsArray = response?.data?.lessons || response?.lessons || [];
|
||||||
|
|
||||||
const events = lessonsArray.map((lesson) => {
|
const events = lessonsArray.map((lesson) => {
|
||||||
const start = lesson.start_time || lesson.start;
|
const start = lesson.start_time || lesson.start;
|
||||||
const end = lesson.end_time || lesson.end || start;
|
const end = lesson.end_time || lesson.end || start;
|
||||||
|
|
||||||
const startTimeStr = start ? new Date(start).toLocaleTimeString('ru-RU', {
|
const startTimeStr = start
|
||||||
hour: '2-digit',
|
? new Date(start).toLocaleTimeString('ru-RU', {
|
||||||
minute: '2-digit',
|
hour: '2-digit',
|
||||||
hourCycle: 'h23'
|
minute: '2-digit',
|
||||||
}) : '';
|
hourCycle: 'h23',
|
||||||
|
})
|
||||||
|
: '';
|
||||||
|
|
||||||
const subject = lesson.subject_name || lesson.subject || 'Урок';
|
const subject = lesson.subject_name || lesson.subject || 'Урок';
|
||||||
const student = lesson.client_name || '';
|
const student = lesson.client_name || '';
|
||||||
|
|
@ -83,14 +101,16 @@ export function useGetEvents() {
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
export function useGetStudents() {
|
export function useGetStudents() {
|
||||||
const { data: response, isLoading, error } = useSWR(STUDENTS_ENDPOINT, getMentorStudents, swrOptions);
|
const { data: response, isLoading, error } = useSWR(
|
||||||
|
STUDENTS_ENDPOINT,
|
||||||
|
getMentorStudents,
|
||||||
|
swrOptions
|
||||||
|
);
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const rawData = response?.data?.results || response?.results || response || [];
|
const rawData = response?.data?.results || response?.results || response || [];
|
||||||
const studentsArray = Array.isArray(rawData) ? rawData : [];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
students: studentsArray,
|
students: Array.isArray(rawData) ? rawData : [],
|
||||||
studentsLoading: isLoading,
|
studentsLoading: isLoading,
|
||||||
studentsError: error,
|
studentsError: error,
|
||||||
};
|
};
|
||||||
|
|
@ -98,14 +118,16 @@ export function useGetStudents() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGetSubjects() {
|
export function useGetSubjects() {
|
||||||
const { data: response, isLoading, error } = useSWR(SUBJECTS_ENDPOINT, getMentorSubjects, swrOptions);
|
const { data: response, isLoading, error } = useSWR(
|
||||||
|
SUBJECTS_ENDPOINT,
|
||||||
|
getMentorSubjects,
|
||||||
|
swrOptions
|
||||||
|
);
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const rawData = response?.data || response?.results || response || [];
|
const rawData = response?.data || response?.results || response || [];
|
||||||
const subjectsArray = Array.isArray(rawData) ? rawData : [];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subjects: subjectsArray,
|
subjects: Array.isArray(rawData) ? rawData : [],
|
||||||
subjectsLoading: isLoading,
|
subjectsLoading: isLoading,
|
||||||
subjectsError: error,
|
subjectsError: error,
|
||||||
};
|
};
|
||||||
|
|
@ -114,25 +136,53 @@ export function useGetSubjects() {
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
export async function createEvent(eventData) {
|
function revalidateCalendar(date) {
|
||||||
const payload = {
|
const d = date || new Date();
|
||||||
client: String(eventData.client),
|
const start = format(startOfMonth(subMonths(d, 1)), 'yyyy-MM-dd');
|
||||||
title: eventData.title.replace(' - ', ' — '),
|
const end = format(endOfMonth(addMonths(d, 1)), 'yyyy-MM-dd');
|
||||||
description: eventData.description,
|
mutate(['calendar', start, end]);
|
||||||
start_time: eventData.start_time,
|
|
||||||
duration: eventData.duration,
|
|
||||||
price: eventData.price,
|
|
||||||
is_recurring: eventData.is_recurring,
|
|
||||||
subject_id: Number(eventData.subject),
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await createCalendarLesson(payload);
|
|
||||||
|
|
||||||
// Обновляем кэш, чтобы занятия появлялись сразу
|
|
||||||
mutate([CALENDAR_ENDPOINT, '2026-02-01', '2026-04-30']);
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateEvent(eventData) { console.log('Update Event:', eventData); }
|
export async function createEvent(eventData, currentDate) {
|
||||||
export async function deleteEvent(eventId) { console.log('Delete Event:', eventId); }
|
const startTime = new Date(eventData.start_time);
|
||||||
|
const endTime = new Date(startTime.getTime() + (eventData.duration || 60) * 60000);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
client: String(eventData.client),
|
||||||
|
title: eventData.title || 'Занятие',
|
||||||
|
description: eventData.description || '',
|
||||||
|
start_time: startTime.toISOString(),
|
||||||
|
end_time: endTime.toISOString(),
|
||||||
|
price: eventData.price,
|
||||||
|
is_recurring: eventData.is_recurring || false,
|
||||||
|
...(eventData.subject && { subject_id: Number(eventData.subject) }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await createCalendarLesson(payload);
|
||||||
|
revalidateCalendar(currentDate);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateEvent(eventData, currentDate) {
|
||||||
|
const { id, ...data } = eventData;
|
||||||
|
|
||||||
|
const updatePayload = {};
|
||||||
|
if (data.start_time) {
|
||||||
|
const startTime = new Date(data.start_time);
|
||||||
|
const endTime = new Date(startTime.getTime() + (data.duration || 60) * 60000);
|
||||||
|
updatePayload.start_time = startTime.toISOString();
|
||||||
|
updatePayload.end_time = endTime.toISOString();
|
||||||
|
}
|
||||||
|
if (data.price != null) updatePayload.price = data.price;
|
||||||
|
if (data.description != null) updatePayload.description = data.description;
|
||||||
|
if (data.status) updatePayload.status = data.status;
|
||||||
|
|
||||||
|
const res = await updateCalendarLesson(String(id), updatePayload);
|
||||||
|
revalidateCalendar(currentDate);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteEvent(eventId, deleteAllFuture = false, currentDate) {
|
||||||
|
await deleteCalendarLesson(String(eventId), deleteAllFuture);
|
||||||
|
revalidateCalendar(currentDate);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { LocalizationProvider } from 'src/locales';
|
||||||
|
import { I18nProvider } from 'src/locales/i18n-provider';
|
||||||
|
import { ThemeProvider } from 'src/theme/theme-provider';
|
||||||
|
|
||||||
|
import { Snackbar } from 'src/components/snackbar';
|
||||||
|
import { ProgressBar } from 'src/components/progress-bar';
|
||||||
|
import { MotionLazy } from 'src/components/animate/motion-lazy';
|
||||||
|
import { SettingsDrawer, defaultSettings, SettingsProvider } from 'src/components/settings';
|
||||||
|
|
||||||
|
import { CheckoutProvider } from 'src/sections/checkout/context';
|
||||||
|
|
||||||
|
import { AuthProvider } from 'src/auth/context/jwt';
|
||||||
|
|
||||||
|
import { Router } from 'src/routes/sections';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<I18nProvider>
|
||||||
|
<LocalizationProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
<SettingsProvider settings={defaultSettings} caches="localStorage">
|
||||||
|
<ThemeProvider>
|
||||||
|
<MotionLazy>
|
||||||
|
<CheckoutProvider>
|
||||||
|
<Snackbar />
|
||||||
|
<ProgressBar />
|
||||||
|
<SettingsDrawer />
|
||||||
|
<Router />
|
||||||
|
</CheckoutProvider>
|
||||||
|
</MotionLazy>
|
||||||
|
</ThemeProvider>
|
||||||
|
</SettingsProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</LocalizationProvider>
|
||||||
|
</I18nProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { AuthSplitLayout } from 'src/layouts/auth-split';
|
||||||
|
|
||||||
|
import { GuestGuard } from 'src/auth/guard';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function Layout({ children }) {
|
||||||
|
return (
|
||||||
|
<GuestGuard>
|
||||||
|
<AuthSplitLayout>{children}</AuthSplitLayout>
|
||||||
|
</GuestGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { CONFIG } from 'src/config-global';
|
||||||
|
|
||||||
|
import { JwtForgotPasswordView } from 'src/sections/auth/jwt';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const metadata = { title: `Forgot password | Jwt - ${CONFIG.site.name}` };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <JwtForgotPasswordView />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { AuthSplitLayout } from 'src/layouts/auth-split';
|
||||||
|
|
||||||
|
import { GuestGuard } from 'src/auth/guard';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function Layout({ children }) {
|
||||||
|
return (
|
||||||
|
<GuestGuard>
|
||||||
|
<AuthSplitLayout>{children}</AuthSplitLayout>
|
||||||
|
</GuestGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { CONFIG } from 'src/config-global';
|
||||||
|
|
||||||
|
import { JwtResetPasswordView } from 'src/sections/auth/jwt';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const metadata = { title: `Reset password | Jwt - ${CONFIG.site.name}` };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <JwtResetPasswordView />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { AuthSplitLayout } from 'src/layouts/auth-split';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function Layout({ children }) {
|
||||||
|
return <AuthSplitLayout>{children}</AuthSplitLayout>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { CONFIG } from 'src/config-global';
|
||||||
|
|
||||||
|
import { JwtVerifyEmailView } from 'src/sections/auth/jwt';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const metadata = { title: `Verify email | Jwt - ${CONFIG.site.name}` };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <JwtVerifyEmailView />;
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { CONFIG } from 'src/config-global';
|
import { CONFIG } from 'src/config-global';
|
||||||
|
|
||||||
import { OverviewAnalyticsView } from 'src/sections/overview/analytics/view';
|
import { AnalyticsView } from 'src/sections/analytics/view';
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
export const metadata = { title: `Analytics | Dashboard - ${CONFIG.site.name}` };
|
export const metadata = { title: `Аналитика | ${CONFIG.site.name}` };
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return <OverviewAnalyticsView />;
|
return <AnalyticsView />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { CONFIG } from 'src/config-global';
|
||||||
|
|
||||||
|
import { BoardView } from 'src/sections/board/view';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const metadata = { title: `Доска | ${CONFIG.site.name}` };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <BoardView />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { CONFIG } from 'src/config-global';
|
||||||
|
|
||||||
|
import { ChatPlatformView } from 'src/sections/chat/view';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const metadata = { title: `Чат | ${CONFIG.site.name}` };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <ChatPlatformView />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { CONFIG } from 'src/config-global';
|
||||||
|
|
||||||
|
import { ChildrenProgressView } from 'src/sections/children/view';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const metadata = { title: `Прогресс ребёнка | ${CONFIG.site.name}` };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <ChildrenProgressView />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { CONFIG } from 'src/config-global';
|
||||||
|
|
||||||
|
import { ChildrenView } from 'src/sections/children/view';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const metadata = { title: `Мои дети | ${CONFIG.site.name}` };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <ChildrenView />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { CONFIG } from 'src/config-global';
|
||||||
|
|
||||||
|
import { FeedbackView } from 'src/sections/feedback/view';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const metadata = { title: `Обратная связь | ${CONFIG.site.name}` };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <FeedbackView />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { CONFIG } from 'src/config-global';
|
||||||
|
|
||||||
|
import { MyProgressView } from 'src/sections/my-progress/view';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const metadata = { title: `Мой прогресс | ${CONFIG.site.name}` };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <MyProgressView />;
|
||||||
|
}
|
||||||
|
|
@ -1,38 +1,65 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { CONFIG } from 'src/config-global';
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
|
||||||
import { useAuthContext } from 'src/auth/hooks';
|
import { useAuthContext } from 'src/auth/hooks';
|
||||||
|
|
||||||
// Временно импортируем только ментора (позже добавим клиента и родителя)
|
|
||||||
import { OverviewCourseView } from 'src/sections/overview/course/view';
|
import { OverviewCourseView } from 'src/sections/overview/course/view';
|
||||||
|
import { OverviewClientView } from 'src/sections/overview/client/view';
|
||||||
|
|
||||||
export default function Page() {
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
const { user, loading } = useAuthContext();
|
const { user, loading } = useAuthContext();
|
||||||
|
|
||||||
|
// Для родителя: выбранный ребёнок из localStorage
|
||||||
|
const [selectedChild, setSelectedChild] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user?.role === 'parent') {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('selected_child');
|
||||||
|
if (saved) setSelectedChild(JSON.parse(saved));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div>Загрузка...</div>;
|
return (
|
||||||
|
<Box sx={{ display: 'flex', height: '80vh', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Роутинг по ролям
|
|
||||||
if (user.role === 'mentor') {
|
if (user.role === 'mentor') {
|
||||||
return <OverviewCourseView />;
|
return <OverviewCourseView />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.role === 'client') {
|
if (user.role === 'client') {
|
||||||
return <div>Дашборд Клиента (в разработке)</div>;
|
return <OverviewClientView />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.role === 'parent') {
|
if (user.role === 'parent') {
|
||||||
return <div>Дашборд Родителя (в разработке)</div>;
|
return (
|
||||||
|
<OverviewClientView
|
||||||
|
childId={selectedChild?.id || null}
|
||||||
|
childName={selectedChild?.name || null}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '24px', textAlign: 'center' }}>
|
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||||
<p>Неизвестная роль пользователя: {user.role}</p>
|
<Typography color="text.secondary">Неизвестная роль: {user.role}</Typography>
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { CONFIG } from 'src/config-global';
|
||||||
|
|
||||||
|
import { PaymentPlatformView } from 'src/sections/payment/view';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const metadata = { title: `Оплата | ${CONFIG.site.name}` };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <PaymentPlatformView />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { CONFIG } from 'src/config-global';
|
||||||
|
|
||||||
|
import { AccountPlatformView } from 'src/sections/account-platform/view';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const metadata = { title: `Профиль | ${CONFIG.site.name}` };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <AccountPlatformView />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { CONFIG } from 'src/config-global';
|
||||||
|
|
||||||
|
import { ReferralsView } from 'src/sections/referrals/view';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const metadata = { title: `Рефералы | ${CONFIG.site.name}` };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <ReferralsView />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
// Fullscreen layout — no sidebar or header
|
||||||
|
|
||||||
|
export default function VideoCallLayout({ children }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { CONFIG } from 'src/config-global';
|
||||||
|
|
||||||
|
import { VideoCallView } from 'src/sections/video-call/view';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const metadata = { title: `Видеозвонок | ${CONFIG.site.name}` };
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <VideoCallView />;
|
||||||
|
}
|
||||||
|
|
@ -3,25 +3,24 @@
|
||||||
import axios, { endpoints } from 'src/utils/axios';
|
import axios, { endpoints } from 'src/utils/axios';
|
||||||
|
|
||||||
import { setSession } from './utils';
|
import { setSession } from './utils';
|
||||||
import { STORAGE_KEY } from './constant';
|
import { STORAGE_KEY, REFRESH_STORAGE_KEY } from './constant';
|
||||||
|
|
||||||
/** **************************************
|
/** **************************************
|
||||||
* Sign in
|
* Sign in
|
||||||
*************************************** */
|
*************************************** */
|
||||||
export const signInWithPassword = async ({ email, password }) => {
|
export const signInWithPassword = async ({ email, password }) => {
|
||||||
try {
|
try {
|
||||||
const params = { email, password };
|
const res = await axios.post(endpoints.auth.signIn, { email, password });
|
||||||
|
|
||||||
const res = await axios.post(endpoints.auth.signIn, params);
|
const data = res.data?.data;
|
||||||
|
const accessToken = data?.tokens?.access;
|
||||||
// Адаптация под твой API: { data: { tokens: { access } } }
|
const refreshToken = data?.tokens?.refresh;
|
||||||
const accessToken = res.data?.data?.tokens?.access;
|
|
||||||
|
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
throw new Error('Access token not found in response');
|
throw new Error('Access token not found in response');
|
||||||
}
|
}
|
||||||
|
|
||||||
setSession(accessToken);
|
await setSession(accessToken, refreshToken);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error during sign in:', error);
|
console.error('Error during sign in:', error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -31,24 +30,32 @@ export const signInWithPassword = async ({ email, password }) => {
|
||||||
/** **************************************
|
/** **************************************
|
||||||
* Sign up
|
* Sign up
|
||||||
*************************************** */
|
*************************************** */
|
||||||
export const signUp = async ({ email, password, firstName, lastName }) => {
|
export const signUp = async ({ email, password, passwordConfirm, firstName, lastName, role, city, timezone }) => {
|
||||||
const params = {
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
firstName,
|
|
||||||
lastName,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const params = {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
password_confirm: passwordConfirm,
|
||||||
|
first_name: firstName,
|
||||||
|
last_name: lastName,
|
||||||
|
role: role || 'client',
|
||||||
|
city: city || '',
|
||||||
|
timezone: timezone || (typeof Intl !== 'undefined' ? Intl.DateTimeFormat().resolvedOptions().timeZone : 'Europe/Moscow'),
|
||||||
|
};
|
||||||
|
|
||||||
const res = await axios.post(endpoints.auth.signUp, params);
|
const res = await axios.post(endpoints.auth.signUp, params);
|
||||||
|
|
||||||
const { accessToken } = res.data;
|
const data = res.data?.data;
|
||||||
|
const accessToken = data?.tokens?.access;
|
||||||
|
const refreshToken = data?.tokens?.refresh;
|
||||||
|
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
throw new Error('Access token not found in response');
|
// Регистрация прошла, но токен не выдан (требуется верификация email)
|
||||||
|
return { requiresVerification: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionStorage.setItem(STORAGE_KEY, accessToken);
|
await setSession(accessToken, refreshToken);
|
||||||
|
return { requiresVerification: false };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error during sign up:', error);
|
console.error('Error during sign up:', error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -66,3 +73,67 @@ export const signOut = async () => {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** **************************************
|
||||||
|
* Refresh token
|
||||||
|
*************************************** */
|
||||||
|
export const refreshAccessToken = async () => {
|
||||||
|
try {
|
||||||
|
const refreshToken = localStorage.getItem(REFRESH_STORAGE_KEY);
|
||||||
|
if (!refreshToken) throw new Error('No refresh token');
|
||||||
|
|
||||||
|
const res = await axios.post(endpoints.auth.refresh, { refresh: refreshToken }, {
|
||||||
|
headers: { Authorization: undefined },
|
||||||
|
});
|
||||||
|
|
||||||
|
const accessToken = res.data?.access;
|
||||||
|
if (!accessToken) throw new Error('No access token in refresh response');
|
||||||
|
|
||||||
|
await setSession(accessToken, refreshToken);
|
||||||
|
return accessToken;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during token refresh:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** **************************************
|
||||||
|
* Request password reset
|
||||||
|
*************************************** */
|
||||||
|
export const requestPasswordReset = async ({ email }) => {
|
||||||
|
try {
|
||||||
|
await axios.post(endpoints.auth.passwordReset, { email });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during password reset request:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** **************************************
|
||||||
|
* Confirm password reset
|
||||||
|
*************************************** */
|
||||||
|
export const confirmPasswordReset = async ({ token, newPassword, newPasswordConfirm }) => {
|
||||||
|
try {
|
||||||
|
await axios.post(endpoints.auth.passwordResetConfirm, {
|
||||||
|
token,
|
||||||
|
new_password: newPassword,
|
||||||
|
new_password_confirm: newPasswordConfirm,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during password reset confirm:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** **************************************
|
||||||
|
* Verify email
|
||||||
|
*************************************** */
|
||||||
|
export const verifyEmail = async ({ token }) => {
|
||||||
|
try {
|
||||||
|
const res = await axios.post(endpoints.auth.verifyEmail, { token });
|
||||||
|
return res.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during email verification:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,10 @@
|
||||||
import { useMemo, useEffect, useCallback } from 'react';
|
import { useMemo, useEffect, useCallback } from 'react';
|
||||||
import { useSetState } from 'src/hooks/use-set-state';
|
import { useSetState } from 'src/hooks/use-set-state';
|
||||||
import axios, { endpoints } from 'src/utils/axios';
|
import axios, { endpoints } from 'src/utils/axios';
|
||||||
import { STORAGE_KEY } from './constant';
|
import { STORAGE_KEY, REFRESH_STORAGE_KEY } from './constant';
|
||||||
import { AuthContext } from '../auth-context';
|
import { AuthContext } from '../auth-context';
|
||||||
import { setSession, isValidToken } from './utils';
|
import { setSession, isValidToken } from './utils';
|
||||||
|
import { refreshAccessToken } from './action';
|
||||||
|
|
||||||
export function AuthProvider({ children }) {
|
export function AuthProvider({ children }) {
|
||||||
const { state, setState } = useSetState({
|
const { state, setState } = useSetState({
|
||||||
|
|
@ -15,25 +16,28 @@ export function AuthProvider({ children }) {
|
||||||
|
|
||||||
const checkUserSession = useCallback(async () => {
|
const checkUserSession = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const accessToken = sessionStorage.getItem(STORAGE_KEY);
|
let accessToken = localStorage.getItem(STORAGE_KEY);
|
||||||
|
|
||||||
if (accessToken && isValidToken(accessToken)) {
|
if (accessToken && isValidToken(accessToken)) {
|
||||||
setSession(accessToken);
|
setSession(accessToken);
|
||||||
|
|
||||||
const res = await axios.get(endpoints.auth.me);
|
|
||||||
|
|
||||||
// Гарантируем получение объекта пользователя из data
|
|
||||||
const userData = res.data?.data || res.data;
|
|
||||||
|
|
||||||
// Если прилетел массив или невалидный объект - сбрасываем
|
|
||||||
if (!userData || typeof userData !== 'object' || Array.isArray(userData)) {
|
|
||||||
throw new Error('Invalid user data format');
|
|
||||||
}
|
|
||||||
|
|
||||||
setState({ user: { ...userData, accessToken }, loading: false });
|
|
||||||
} else {
|
} else {
|
||||||
setState({ user: null, loading: false });
|
// Пробуем обновить через refresh token
|
||||||
|
try {
|
||||||
|
accessToken = await refreshAccessToken();
|
||||||
|
} catch {
|
||||||
|
setState({ user: null, loading: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const res = await axios.get(endpoints.auth.me);
|
||||||
|
const userData = res.data?.data || res.data;
|
||||||
|
|
||||||
|
if (!userData || typeof userData !== 'object' || Array.isArray(userData)) {
|
||||||
|
throw new Error('Invalid user data format');
|
||||||
|
}
|
||||||
|
|
||||||
|
setState({ user: { ...userData, accessToken }, loading: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Auth Debug]:', error);
|
console.error('[Auth Debug]:', error);
|
||||||
setState({ user: null, loading: false });
|
setState({ user: null, loading: false });
|
||||||
|
|
@ -52,7 +56,7 @@ export function AuthProvider({ children }) {
|
||||||
user: state.user
|
user: state.user
|
||||||
? {
|
? {
|
||||||
...state.user,
|
...state.user,
|
||||||
role: state.user?.role ?? 'admin',
|
role: state.user?.role ?? 'client',
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
checkUserSession,
|
checkUserSession,
|
||||||
|
|
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
export const STORAGE_KEY = 'jwt_access_token';
|
export const STORAGE_KEY = 'jwt_access_token';
|
||||||
|
export const REFRESH_STORAGE_KEY = 'jwt_refresh_token';
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { paths } from 'src/routes/paths';
|
||||||
|
|
||||||
import axios from 'src/utils/axios';
|
import axios from 'src/utils/axios';
|
||||||
|
|
||||||
import { STORAGE_KEY } from './constant';
|
import { STORAGE_KEY, REFRESH_STORAGE_KEY } from './constant';
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -57,26 +57,29 @@ export function tokenExpired(exp) {
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
try {
|
try {
|
||||||
alert('Token expired!');
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
sessionStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(REFRESH_STORAGE_KEY);
|
||||||
window.location.href = paths.auth.jwt.signIn;
|
window.location.href = paths.auth.jwt.signIn;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error during token expiration:', error);
|
console.error('Error during token expiration:', error);
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
}, timeLeft);
|
}, timeLeft);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
export async function setSession(accessToken) {
|
export async function setSession(accessToken, refreshToken) {
|
||||||
try {
|
try {
|
||||||
if (accessToken) {
|
if (accessToken) {
|
||||||
sessionStorage.setItem(STORAGE_KEY, accessToken);
|
localStorage.setItem(STORAGE_KEY, accessToken);
|
||||||
|
|
||||||
|
if (refreshToken) {
|
||||||
|
localStorage.setItem(REFRESH_STORAGE_KEY, refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
axios.defaults.headers.common.Authorization = `Bearer ${accessToken}`;
|
axios.defaults.headers.common.Authorization = `Bearer ${accessToken}`;
|
||||||
|
|
||||||
const decodedToken = jwtDecode(accessToken); // ~3 days by minimals server
|
const decodedToken = jwtDecode(accessToken);
|
||||||
|
|
||||||
if (decodedToken && 'exp' in decodedToken) {
|
if (decodedToken && 'exp' in decodedToken) {
|
||||||
tokenExpired(decodedToken.exp);
|
tokenExpired(decodedToken.exp);
|
||||||
|
|
@ -84,7 +87,8 @@ export async function setSession(accessToken) {
|
||||||
throw new Error('Invalid access token!');
|
throw new Error('Invalid access token!');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
sessionStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
localStorage.removeItem(REFRESH_STORAGE_KEY);
|
||||||
delete axios.defaults.headers.common.Authorization;
|
delete axios.defaults.headers.common.Authorization;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,10 @@
|
||||||
import dynamic from 'next/dynamic';
|
import { lazy, Suspense } from 'react';
|
||||||
|
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
|
|
||||||
import { withLoadingProps } from 'src/utils/with-loading-props';
|
|
||||||
|
|
||||||
import { ChartLoading } from './chart-loading';
|
import { ChartLoading } from './chart-loading';
|
||||||
|
|
||||||
const ApexChart = withLoadingProps((props) =>
|
const ApexChart = lazy(() => import('react-apexcharts').then((mod) => ({ default: mod.default })));
|
||||||
dynamic(() => import('react-apexcharts').then((mod) => mod.default), {
|
|
||||||
ssr: false,
|
|
||||||
loading: () => {
|
|
||||||
const { loading, type } = props();
|
|
||||||
|
|
||||||
return loading?.disabled ? null : <ChartLoading type={type} sx={loading?.sx} />;
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -42,14 +31,21 @@ export function Chart({
|
||||||
}}
|
}}
|
||||||
{...other}
|
{...other}
|
||||||
>
|
>
|
||||||
<ApexChart
|
<Suspense
|
||||||
type={type}
|
fallback={
|
||||||
series={series}
|
loadingProps?.disabled ? null : (
|
||||||
options={options}
|
<ChartLoading type={type} sx={loadingProps?.sx} />
|
||||||
width="100%"
|
)
|
||||||
height="100%"
|
}
|
||||||
loading={loadingProps}
|
>
|
||||||
/>
|
<ApexChart
|
||||||
|
type={type}
|
||||||
|
series={series}
|
||||||
|
options={options}
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import dynamic from 'next/dynamic';
|
import { lazy, Suspense, cloneElement } from 'react';
|
||||||
import { cloneElement } from 'react';
|
|
||||||
|
|
||||||
import { useTheme } from '@mui/material/styles';
|
import { useTheme } from '@mui/material/styles';
|
||||||
|
|
||||||
|
|
@ -7,13 +6,13 @@ import { flattenArray } from 'src/utils/helper';
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
const Tree = dynamic(() => import('react-organizational-chart').then((mod) => mod.Tree), {
|
const Tree = lazy(() =>
|
||||||
ssr: false,
|
import('react-organizational-chart').then((mod) => ({ default: mod.Tree }))
|
||||||
});
|
);
|
||||||
|
|
||||||
const TreeNode = dynamic(() => import('react-organizational-chart').then((mod) => mod.TreeNode), {
|
const TreeNode = lazy(() =>
|
||||||
ssr: false,
|
import('react-organizational-chart').then((mod) => ({ default: mod.TreeNode }))
|
||||||
});
|
);
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -27,18 +26,20 @@ export function OrganizationalChart({ data, nodeItem, ...other }) {
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tree
|
<Suspense fallback={null}>
|
||||||
lineWidth="1.5px"
|
<Tree
|
||||||
nodePadding="4px"
|
lineWidth="1.5px"
|
||||||
lineBorderRadius="24px"
|
nodePadding="4px"
|
||||||
lineColor={theme.vars.palette.divider}
|
lineBorderRadius="24px"
|
||||||
label={label}
|
lineColor={theme.vars.palette.divider}
|
||||||
{...other}
|
label={label}
|
||||||
>
|
{...other}
|
||||||
{data.children.map((list, index) => (
|
>
|
||||||
<TreeList key={index} depth={1} data={list} nodeItem={nodeItem} />
|
{data.children.map((list, index) => (
|
||||||
))}
|
<TreeList key={index} depth={1} data={list} nodeItem={nodeItem} />
|
||||||
</Tree>
|
))}
|
||||||
|
</Tree>
|
||||||
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,6 @@
|
||||||
import { cookies } from 'next/headers';
|
// Stub — server-side functions not used in Vite SPA
|
||||||
|
// Settings are always read from localStorage
|
||||||
import { STORAGE_KEY, defaultSettings } from './config-settings';
|
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
|
||||||
|
|
||||||
export async function detectSettings() {
|
export async function detectSettings() {
|
||||||
const cookieStore = cookies();
|
return undefined;
|
||||||
|
|
||||||
const settingsStore = cookieStore.get(STORAGE_KEY);
|
|
||||||
|
|
||||||
return settingsStore ? JSON.parse(settingsStore?.value) : defaultSettings;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import dynamic from 'next/dynamic';
|
import { lazy, Suspense } from 'react';
|
||||||
|
|
||||||
import { useTheme } from '@mui/material/styles';
|
import { useTheme } from '@mui/material/styles';
|
||||||
|
|
||||||
|
|
@ -8,9 +8,7 @@ import { WalktourTooltip } from './walktour-tooltip';
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
const Joyride = dynamic(() => import('react-joyride').then((mod) => mod.default), {
|
const Joyride = lazy(() => import('react-joyride').then((mod) => ({ default: mod.default })));
|
||||||
ssr: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -32,6 +30,7 @@ export function Walktour({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
<Joyride
|
<Joyride
|
||||||
scrollOffset={100}
|
scrollOffset={100}
|
||||||
locale={{ last: 'Done', ...locale }}
|
locale={{ last: 'Done', ...locale }}
|
||||||
|
|
@ -71,5 +70,6 @@ export function Walktour({
|
||||||
}}
|
}}
|
||||||
{...other}
|
{...other}
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,16 +6,17 @@ import packageJson from '../package.json';
|
||||||
|
|
||||||
export const CONFIG = {
|
export const CONFIG = {
|
||||||
site: {
|
site: {
|
||||||
name: 'Minimals',
|
name: 'Platform',
|
||||||
serverUrl: process.env.NEXT_PUBLIC_SERVER_URL ?? '',
|
serverUrl: process.env.NEXT_PUBLIC_SERVER_URL ?? '',
|
||||||
assetURL: process.env.NEXT_PUBLIC_ASSET_URL ?? '',
|
assetURL: process.env.NEXT_PUBLIC_ASSET_URL ?? '',
|
||||||
basePath: process.env.NEXT_PUBLIC_BASE_PATH ?? '',
|
basePath: process.env.NEXT_PUBLIC_BASE_PATH ?? '',
|
||||||
version: packageJson.version,
|
version: packageJson.version,
|
||||||
},
|
},
|
||||||
isStaticExport: JSON.parse(`${process.env.BUILD_STATIC_EXPORT}`),
|
// В Next.js всегда работаем как SPA (без SSR для наших страниц)
|
||||||
|
isStaticExport: true,
|
||||||
/**
|
/**
|
||||||
* Auth
|
* Auth
|
||||||
* @method jwt | amplify | firebase | supabase | auth0
|
* @method jwt
|
||||||
*/
|
*/
|
||||||
auth: {
|
auth: {
|
||||||
method: 'jwt',
|
method: 'jwt',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { useRef, useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
import { CONFIG } from 'src/config-global';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function useChatWebSocket({ chatUuid, enabled = true, onMessage }) {
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const wsRef = useRef(null);
|
||||||
|
const onMessageRef = useRef(onMessage);
|
||||||
|
onMessageRef.current = onMessage;
|
||||||
|
|
||||||
|
const disconnect = useCallback(() => {
|
||||||
|
if (wsRef.current) {
|
||||||
|
wsRef.current.close();
|
||||||
|
wsRef.current = null;
|
||||||
|
}
|
||||||
|
setIsConnected(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
if (!enabled || !chatUuid) return;
|
||||||
|
const token =
|
||||||
|
typeof window !== 'undefined'
|
||||||
|
? localStorage.getItem('jwt_access_token') || localStorage.getItem('access_token') || ''
|
||||||
|
: '';
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const serverUrl = CONFIG.site.serverUrl || '';
|
||||||
|
let apiBase = serverUrl.replace(/\/api\/?$/, '').replace(/\/$/, '');
|
||||||
|
if (!apiBase) apiBase = typeof window !== 'undefined' ? window.location.origin : '';
|
||||||
|
|
||||||
|
const wsProtocol = apiBase.startsWith('https') ? 'wss:' : 'ws:';
|
||||||
|
const wsHost = apiBase.replace(/^https?:\/\//, '');
|
||||||
|
const wsUrl = `${wsProtocol}//${wsHost}/ws/chat/${chatUuid}/?token=${token}`;
|
||||||
|
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => setIsConnected(true);
|
||||||
|
ws.onclose = () => setIsConnected(false);
|
||||||
|
ws.onerror = () => setIsConnected(false);
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(ev.data);
|
||||||
|
if (data.type === 'chat_message' && data.message) {
|
||||||
|
onMessageRef.current?.(data.message);
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
// ignore parse errors
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [chatUuid, enabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
disconnect();
|
||||||
|
connect();
|
||||||
|
return () => disconnect();
|
||||||
|
}, [connect, disconnect]);
|
||||||
|
|
||||||
|
return { isConnected };
|
||||||
|
}
|
||||||
|
|
@ -14,32 +14,65 @@ const ICONS = {
|
||||||
dashboard: icon('ic-dashboard'),
|
dashboard: icon('ic-dashboard'),
|
||||||
kanban: icon('ic-kanban'),
|
kanban: icon('ic-kanban'),
|
||||||
folder: icon('ic-folder'),
|
folder: icon('ic-folder'),
|
||||||
|
analytics: icon('ic-analytics'),
|
||||||
|
label: icon('ic-label'),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
export const navData = [
|
export function getNavData(role) {
|
||||||
/**
|
const isMentor = role === 'mentor';
|
||||||
* Основное
|
const isClient = role === 'client';
|
||||||
*/
|
const isParent = role === 'parent';
|
||||||
{
|
|
||||||
subheader: 'Главная',
|
return [
|
||||||
items: [
|
{
|
||||||
{ title: 'Панель управления', path: paths.dashboard.root, icon: ICONS.dashboard },
|
subheader: 'Главная',
|
||||||
],
|
items: [
|
||||||
},
|
{ title: 'Панель управления', path: paths.dashboard.root, icon: ICONS.dashboard },
|
||||||
/**
|
],
|
||||||
* Управление
|
},
|
||||||
*/
|
{
|
||||||
{
|
subheader: 'Инструменты',
|
||||||
subheader: 'Инструменты',
|
items: [
|
||||||
items: [
|
// Ученики/Менторы — для всех ролей (разный контент внутри)
|
||||||
{ title: 'Ученики', path: paths.dashboard.students, icon: ICONS.user },
|
{ title: isMentor ? 'Ученики' : 'Менторы', path: paths.dashboard.students, icon: ICONS.user },
|
||||||
{ title: 'Расписание', path: paths.dashboard.calendar, icon: ICONS.calendar },
|
{ title: 'Расписание', path: paths.dashboard.calendar, icon: ICONS.calendar },
|
||||||
{ title: 'Домашние задания', path: paths.dashboard.homework, icon: ICONS.kanban },
|
{ title: 'Домашние задания', path: paths.dashboard.homework, icon: ICONS.kanban },
|
||||||
{ title: 'Материалы', path: paths.dashboard.materials, icon: ICONS.folder },
|
{ title: 'Материалы', path: paths.dashboard.materials, icon: ICONS.folder },
|
||||||
{ title: 'Чат', path: paths.dashboard.chat, icon: ICONS.chat },
|
{ title: 'Доска', path: paths.dashboard.board, icon: ICONS.kanban },
|
||||||
{ title: 'Уведомления', path: paths.dashboard.notifications, icon: icon('ic-label') },
|
{ title: 'Чат', path: paths.dashboard.chatPlatform, icon: ICONS.chat },
|
||||||
],
|
{ title: 'Уведомления', path: paths.dashboard.notifications, icon: ICONS.label },
|
||||||
},
|
|
||||||
];
|
// Ментор-специфичные
|
||||||
|
...(isMentor ? [
|
||||||
|
{ title: 'Аналитика', path: paths.dashboard.analytics, icon: ICONS.analytics },
|
||||||
|
{ title: 'Обратная связь', path: paths.dashboard.feedback, icon: icon('ic-label') },
|
||||||
|
] : []),
|
||||||
|
|
||||||
|
// Клиент/Родитель
|
||||||
|
...((isClient || isParent) ? [
|
||||||
|
{ title: 'Мой прогресс', path: paths.dashboard.myProgress, icon: ICONS.course },
|
||||||
|
] : []),
|
||||||
|
|
||||||
|
// Родитель-специфичные
|
||||||
|
...(isParent ? [
|
||||||
|
{ title: 'Дети', path: paths.dashboard.children, icon: ICONS.user },
|
||||||
|
{ title: 'Прогресс детей', path: paths.dashboard.childrenProgress, icon: ICONS.course },
|
||||||
|
] : []),
|
||||||
|
|
||||||
|
{ title: 'Оплата', path: paths.dashboard.payment, icon: ICONS.folder },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
subheader: 'Аккаунт',
|
||||||
|
items: [
|
||||||
|
{ title: 'Профиль', path: paths.dashboard.profile, icon: ICONS.user },
|
||||||
|
{ title: 'Рефералы', path: paths.dashboard.referrals, icon: ICONS.course },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обратная совместимость — статический nav для случаев без роли
|
||||||
|
export const navData = getNavData('mentor');
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,8 @@ import { _account } from '../config-nav-account';
|
||||||
import { HeaderBase } from '../core/header-base';
|
import { HeaderBase } from '../core/header-base';
|
||||||
import { _workspaces } from '../config-nav-workspace';
|
import { _workspaces } from '../config-nav-workspace';
|
||||||
import { LayoutSection } from '../core/layout-section';
|
import { LayoutSection } from '../core/layout-section';
|
||||||
import { navData as dashboardNavData } from '../config-nav-dashboard';
|
import { getNavData } from '../config-nav-dashboard';
|
||||||
|
import { useAuthContext } from 'src/auth/hooks';
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -35,11 +36,13 @@ export function DashboardLayout({ sx, children, data }) {
|
||||||
|
|
||||||
const settings = useSettingsContext();
|
const settings = useSettingsContext();
|
||||||
|
|
||||||
|
const { user } = useAuthContext();
|
||||||
|
|
||||||
const navColorVars = useNavColorVars(theme, settings);
|
const navColorVars = useNavColorVars(theme, settings);
|
||||||
|
|
||||||
const layoutQuery = 'lg';
|
const layoutQuery = 'lg';
|
||||||
|
|
||||||
const navData = data?.nav ?? dashboardNavData;
|
const navData = data?.nav ?? getNavData(user?.role);
|
||||||
|
|
||||||
const isNavMini = settings.navLayout === 'mini';
|
const isNavMini = settings.navLayout === 'mini';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,51 +1,6 @@
|
||||||
import { cache } from 'react';
|
// Stub — server-side functions not used in Vite SPA
|
||||||
import { createInstance } from 'i18next';
|
// Language detection is handled by i18n-provider via localStorage
|
||||||
import { cookies as getCookies } from 'next/headers';
|
|
||||||
import resourcesToBackend from 'i18next-resources-to-backend';
|
|
||||||
import { initReactI18next } from 'react-i18next/initReactI18next';
|
|
||||||
|
|
||||||
import { defaultNS, cookieName, i18nOptions, fallbackLng } from './config-locales';
|
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [1] with url:
|
|
||||||
* https://nextjs.org/docs/pages/building-your-application/routing/internationalization
|
|
||||||
*
|
|
||||||
* Use i18next with app folder and without locale in url:
|
|
||||||
* https://github.com/i18next/next-app-dir-i18next-example/issues/12#issuecomment-1500917570
|
|
||||||
*/
|
|
||||||
|
|
||||||
export async function detectLanguage() {
|
export async function detectLanguage() {
|
||||||
const cookies = getCookies();
|
return undefined;
|
||||||
|
|
||||||
const language = cookies.get(cookieName)?.value ?? fallbackLng;
|
|
||||||
|
|
||||||
return language;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const getServerTranslations = cache(async (ns = defaultNS, options = {}) => {
|
|
||||||
const language = await detectLanguage();
|
|
||||||
|
|
||||||
const i18nextInstance = await initServerI18next(language, ns);
|
|
||||||
|
|
||||||
return {
|
|
||||||
t: i18nextInstance.getFixedT(language, Array.isArray(ns) ? ns[0] : ns, options.keyPrefix),
|
|
||||||
i18n: i18nextInstance,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
|
||||||
|
|
||||||
const initServerI18next = async (language, namespace) => {
|
|
||||||
const i18nInstance = createInstance();
|
|
||||||
|
|
||||||
await i18nInstance
|
|
||||||
.use(initReactI18next)
|
|
||||||
.use(resourcesToBackend((lang, ns) => import(`./langs/${lang}/${ns}.json`)))
|
|
||||||
.init(i18nOptions(language, namespace));
|
|
||||||
|
|
||||||
return i18nInstance;
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import './global.css';
|
||||||
|
|
||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
|
import App from './app';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const root = createRoot(document.getElementById('root'));
|
||||||
|
|
||||||
|
root.render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
|
|
@ -17,6 +17,7 @@ const ROOTS = {
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
export const paths = {
|
export const paths = {
|
||||||
|
videoCall: '/video-call',
|
||||||
comingSoon: '/coming-soon',
|
comingSoon: '/coming-soon',
|
||||||
maintenance: '/maintenance',
|
maintenance: '/maintenance',
|
||||||
pricing: '/pricing',
|
pricing: '/pricing',
|
||||||
|
|
@ -106,6 +107,16 @@ export const paths = {
|
||||||
materials: `${ROOTS.DASHBOARD}/materials`,
|
materials: `${ROOTS.DASHBOARD}/materials`,
|
||||||
students: `${ROOTS.DASHBOARD}/students`,
|
students: `${ROOTS.DASHBOARD}/students`,
|
||||||
notifications: `${ROOTS.DASHBOARD}/notifications`,
|
notifications: `${ROOTS.DASHBOARD}/notifications`,
|
||||||
|
board: `${ROOTS.DASHBOARD}/board`,
|
||||||
|
referrals: `${ROOTS.DASHBOARD}/referrals`,
|
||||||
|
profile: `${ROOTS.DASHBOARD}/profile`,
|
||||||
|
children: `${ROOTS.DASHBOARD}/children`,
|
||||||
|
childrenProgress: `${ROOTS.DASHBOARD}/children-progress`,
|
||||||
|
myProgress: `${ROOTS.DASHBOARD}/my-progress`,
|
||||||
|
payment: `${ROOTS.DASHBOARD}/payment-platform`,
|
||||||
|
chatPlatform: `${ROOTS.DASHBOARD}/chat-platform`,
|
||||||
|
analytics: `${ROOTS.DASHBOARD}/analytics`,
|
||||||
|
feedback: `${ROOTS.DASHBOARD}/feedback`,
|
||||||
fileManager: `${ROOTS.DASHBOARD}/file-manager`,
|
fileManager: `${ROOTS.DASHBOARD}/file-manager`,
|
||||||
permission: `${ROOTS.DASHBOARD}/permission`,
|
permission: `${ROOTS.DASHBOARD}/permission`,
|
||||||
general: {
|
general: {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,691 @@
|
||||||
|
import { lazy, Suspense } from 'react';
|
||||||
|
import { Navigate, useRoutes, Outlet } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { AuthGuard } from 'src/auth/guard/auth-guard';
|
||||||
|
import { GuestGuard } from 'src/auth/guard/guest-guard';
|
||||||
|
|
||||||
|
import { AuthSplitLayout } from 'src/layouts/auth-split';
|
||||||
|
import { DashboardLayout } from 'src/layouts/dashboard';
|
||||||
|
|
||||||
|
import { SplashScreen } from 'src/components/loading-screen';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// Auth - JWT
|
||||||
|
|
||||||
|
const JwtSignInView = lazy(() =>
|
||||||
|
import('src/sections/auth/jwt/jwt-sign-in-view').then((m) => ({ default: m.JwtSignInView }))
|
||||||
|
);
|
||||||
|
const JwtSignUpView = lazy(() =>
|
||||||
|
import('src/sections/auth/jwt/jwt-sign-up-view').then((m) => ({ default: m.JwtSignUpView }))
|
||||||
|
);
|
||||||
|
const JwtForgotPasswordView = lazy(() =>
|
||||||
|
import('src/sections/auth/jwt/jwt-forgot-password-view').then((m) => ({
|
||||||
|
default: m.JwtForgotPasswordView,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const JwtResetPasswordView = lazy(() =>
|
||||||
|
import('src/sections/auth/jwt/jwt-reset-password-view').then((m) => ({
|
||||||
|
default: m.JwtResetPasswordView,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const JwtVerifyEmailView = lazy(() =>
|
||||||
|
import('src/sections/auth/jwt/jwt-verify-email-view').then((m) => ({
|
||||||
|
default: m.JwtVerifyEmailView,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// Dashboard - Overview
|
||||||
|
|
||||||
|
const OverviewAnalyticsView = lazy(() =>
|
||||||
|
import('src/sections/overview/analytics/view').then((m) => ({
|
||||||
|
default: m.OverviewAnalyticsView,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const OverviewEcommerceView = lazy(() =>
|
||||||
|
import('src/sections/overview/e-commerce/view').then((m) => ({
|
||||||
|
default: m.OverviewEcommerceView,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const OverviewBankingView = lazy(() =>
|
||||||
|
import('src/sections/overview/banking/view').then((m) => ({ default: m.OverviewBankingView }))
|
||||||
|
);
|
||||||
|
const OverviewBookingView = lazy(() =>
|
||||||
|
import('src/sections/overview/booking/view').then((m) => ({ default: m.OverviewBookingView }))
|
||||||
|
);
|
||||||
|
const OverviewFileView = lazy(() =>
|
||||||
|
import('src/sections/overview/file/view').then((m) => ({ default: m.OverviewFileView }))
|
||||||
|
);
|
||||||
|
const OverviewCourseView = lazy(() =>
|
||||||
|
import('src/sections/overview/course/view').then((m) => ({ default: m.OverviewCourseView }))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dashboard - Features
|
||||||
|
|
||||||
|
const CalendarView = lazy(() =>
|
||||||
|
import('src/sections/calendar/view').then((m) => ({ default: m.CalendarView }))
|
||||||
|
);
|
||||||
|
const ChatView = lazy(() =>
|
||||||
|
import('src/sections/chat/view').then((m) => ({ default: m.ChatView }))
|
||||||
|
);
|
||||||
|
const MailView = lazy(() =>
|
||||||
|
import('src/sections/mail/view').then((m) => ({ default: m.MailView }))
|
||||||
|
);
|
||||||
|
const KanbanView = lazy(() =>
|
||||||
|
import('src/sections/kanban/view').then((m) => ({ default: m.KanbanView }))
|
||||||
|
);
|
||||||
|
const FileManagerView = lazy(() =>
|
||||||
|
import('src/sections/file-manager/view').then((m) => ({ default: m.FileManagerView }))
|
||||||
|
);
|
||||||
|
const PermissionDeniedView = lazy(() =>
|
||||||
|
import('src/sections/permission/view').then((m) => ({ default: m.PermissionDeniedView }))
|
||||||
|
);
|
||||||
|
const BlankView = lazy(() =>
|
||||||
|
import('src/sections/blank/view').then((m) => ({ default: m.BlankView }))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dashboard - User
|
||||||
|
|
||||||
|
const UserProfileView = lazy(() =>
|
||||||
|
import('src/sections/user/view').then((m) => ({ default: m.UserProfileView }))
|
||||||
|
);
|
||||||
|
const UserListView = lazy(() =>
|
||||||
|
import('src/sections/user/view').then((m) => ({ default: m.UserListView }))
|
||||||
|
);
|
||||||
|
const UserCardsView = lazy(() =>
|
||||||
|
import('src/sections/user/view').then((m) => ({ default: m.UserCardsView }))
|
||||||
|
);
|
||||||
|
const UserCreateView = lazy(() =>
|
||||||
|
import('src/sections/user/view').then((m) => ({ default: m.UserCreateView }))
|
||||||
|
);
|
||||||
|
const UserEditView = lazy(() =>
|
||||||
|
import('src/sections/user/view').then((m) => ({ default: m.UserEditView }))
|
||||||
|
);
|
||||||
|
const AccountView = lazy(() =>
|
||||||
|
import('src/sections/account/view').then((m) => ({ default: m.AccountView }))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dashboard - Product
|
||||||
|
|
||||||
|
const ProductListView = lazy(() =>
|
||||||
|
import('src/sections/product/view').then((m) => ({ default: m.ProductListView }))
|
||||||
|
);
|
||||||
|
const ProductDetailsView = lazy(() =>
|
||||||
|
import('src/sections/product/view').then((m) => ({ default: m.ProductDetailsView }))
|
||||||
|
);
|
||||||
|
const ProductCreateView = lazy(() =>
|
||||||
|
import('src/sections/product/view').then((m) => ({ default: m.ProductCreateView }))
|
||||||
|
);
|
||||||
|
const ProductEditView = lazy(() =>
|
||||||
|
import('src/sections/product/view').then((m) => ({ default: m.ProductEditView }))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dashboard - Order
|
||||||
|
|
||||||
|
const OrderListView = lazy(() =>
|
||||||
|
import('src/sections/order/view').then((m) => ({ default: m.OrderListView }))
|
||||||
|
);
|
||||||
|
const OrderDetailsView = lazy(() =>
|
||||||
|
import('src/sections/order/view').then((m) => ({ default: m.OrderDetailsView }))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dashboard - Invoice
|
||||||
|
|
||||||
|
const InvoiceListView = lazy(() =>
|
||||||
|
import('src/sections/invoice/view').then((m) => ({ default: m.InvoiceListView }))
|
||||||
|
);
|
||||||
|
const InvoiceDetailsView = lazy(() =>
|
||||||
|
import('src/sections/invoice/view').then((m) => ({ default: m.InvoiceDetailsView }))
|
||||||
|
);
|
||||||
|
const InvoiceCreateView = lazy(() =>
|
||||||
|
import('src/sections/invoice/view').then((m) => ({ default: m.InvoiceCreateView }))
|
||||||
|
);
|
||||||
|
const InvoiceEditView = lazy(() =>
|
||||||
|
import('src/sections/invoice/view').then((m) => ({ default: m.InvoiceEditView }))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dashboard - Blog / Post
|
||||||
|
|
||||||
|
const PostListView = lazy(() =>
|
||||||
|
import('src/sections/blog/view').then((m) => ({ default: m.PostListView }))
|
||||||
|
);
|
||||||
|
const PostCreateView = lazy(() =>
|
||||||
|
import('src/sections/blog/view').then((m) => ({ default: m.PostCreateView }))
|
||||||
|
);
|
||||||
|
const PostDetailsView = lazy(() =>
|
||||||
|
import('src/sections/blog/view').then((m) => ({ default: m.PostDetailsView }))
|
||||||
|
);
|
||||||
|
const PostEditView = lazy(() =>
|
||||||
|
import('src/sections/blog/view').then((m) => ({ default: m.PostEditView }))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dashboard - Job
|
||||||
|
|
||||||
|
const JobListView = lazy(() =>
|
||||||
|
import('src/sections/job/view').then((m) => ({ default: m.JobListView }))
|
||||||
|
);
|
||||||
|
const JobDetailsView = lazy(() =>
|
||||||
|
import('src/sections/job/view').then((m) => ({ default: m.JobDetailsView }))
|
||||||
|
);
|
||||||
|
const JobCreateView = lazy(() =>
|
||||||
|
import('src/sections/job/view').then((m) => ({ default: m.JobCreateView }))
|
||||||
|
);
|
||||||
|
const JobEditView = lazy(() =>
|
||||||
|
import('src/sections/job/view').then((m) => ({ default: m.JobEditView }))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dashboard - Tour
|
||||||
|
|
||||||
|
const TourListView = lazy(() =>
|
||||||
|
import('src/sections/tour/view').then((m) => ({ default: m.TourListView }))
|
||||||
|
);
|
||||||
|
const TourDetailsView = lazy(() =>
|
||||||
|
import('src/sections/tour/view').then((m) => ({ default: m.TourDetailsView }))
|
||||||
|
);
|
||||||
|
const TourCreateView = lazy(() =>
|
||||||
|
import('src/sections/tour/view').then((m) => ({ default: m.TourCreateView }))
|
||||||
|
);
|
||||||
|
const TourEditView = lazy(() =>
|
||||||
|
import('src/sections/tour/view').then((m) => ({ default: m.TourEditView }))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Error pages
|
||||||
|
|
||||||
|
const Page403 = lazy(() =>
|
||||||
|
import('src/sections/error/403-view').then((m) => ({ default: m.View403 }))
|
||||||
|
);
|
||||||
|
const Page404 = lazy(() =>
|
||||||
|
import('src/sections/error/not-found-view').then((m) => ({ default: m.NotFoundView }))
|
||||||
|
);
|
||||||
|
const Page500 = lazy(() =>
|
||||||
|
import('src/sections/error/500-view').then((m) => ({ default: m.View500 }))
|
||||||
|
);
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function Loading() {
|
||||||
|
return <SplashScreen />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardLayoutWrapper() {
|
||||||
|
return (
|
||||||
|
<AuthGuard>
|
||||||
|
<DashboardLayout>
|
||||||
|
<Outlet />
|
||||||
|
</DashboardLayout>
|
||||||
|
</AuthGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthLayoutWrapper() {
|
||||||
|
return (
|
||||||
|
<GuestGuard>
|
||||||
|
<AuthSplitLayout>
|
||||||
|
<Outlet />
|
||||||
|
</AuthSplitLayout>
|
||||||
|
</GuestGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function Router() {
|
||||||
|
return useRoutes([
|
||||||
|
// Root redirect
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
element: <Navigate to="/dashboard/analytics" replace />,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Auth - JWT
|
||||||
|
{
|
||||||
|
path: 'auth/jwt',
|
||||||
|
element: <AuthLayoutWrapper />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'sign-in',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<JwtSignInView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'sign-up',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<JwtSignUpView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'forgot-password',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<JwtForgotPasswordView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'reset-password',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<JwtResetPasswordView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Verify email — без GuestGuard
|
||||||
|
{
|
||||||
|
path: 'auth/jwt/verify-email',
|
||||||
|
element: (
|
||||||
|
<AuthSplitLayout>
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<JwtVerifyEmailView />
|
||||||
|
</Suspense>
|
||||||
|
</AuthSplitLayout>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
{
|
||||||
|
path: 'dashboard',
|
||||||
|
element: <DashboardLayoutWrapper />,
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <Navigate to="analytics" replace /> },
|
||||||
|
|
||||||
|
// Overview
|
||||||
|
{
|
||||||
|
path: 'analytics',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<OverviewAnalyticsView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'ecommerce',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<OverviewEcommerceView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'banking',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<OverviewBankingView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'booking',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<OverviewBookingView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'file',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<OverviewFileView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'course',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<OverviewCourseView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Features
|
||||||
|
{
|
||||||
|
path: 'schedule',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<CalendarView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'chat',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<ChatView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'mail',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<MailView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'kanban',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<KanbanView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'file-manager',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<FileManagerView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'permission',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<PermissionDeniedView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'blank',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<BlankView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
// User
|
||||||
|
{
|
||||||
|
path: 'user',
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <Navigate to="profile" replace /> },
|
||||||
|
{
|
||||||
|
path: 'profile',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<UserProfileView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'list',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<UserListView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'cards',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<UserCardsView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'new',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<UserCreateView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':id/edit',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<UserEditView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'account',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<AccountView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Product
|
||||||
|
{
|
||||||
|
path: 'product',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
index: true,
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<ProductListView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'new',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<ProductCreateView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':id',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<ProductDetailsView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':id/edit',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<ProductEditView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Order
|
||||||
|
{
|
||||||
|
path: 'order',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
index: true,
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<OrderListView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':id',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<OrderDetailsView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Invoice
|
||||||
|
{
|
||||||
|
path: 'invoice',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
index: true,
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<InvoiceListView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'new',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<InvoiceCreateView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':id',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<InvoiceDetailsView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':id/edit',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<InvoiceEditView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Blog / Post
|
||||||
|
{
|
||||||
|
path: 'post',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
index: true,
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<PostListView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'new',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<PostCreateView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':title',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<PostDetailsView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':title/edit',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<PostEditView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Job
|
||||||
|
{
|
||||||
|
path: 'job',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
index: true,
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<JobListView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'new',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<JobCreateView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':id',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<JobDetailsView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':id/edit',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<JobEditView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Tour
|
||||||
|
{
|
||||||
|
path: 'tour',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
index: true,
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<TourListView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'new',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<TourCreateView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':id',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<TourDetailsView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':id/edit',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<TourEditView />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Error pages
|
||||||
|
{ path: '403', element: <Suspense fallback={<Loading />}><Page403 /></Suspense> },
|
||||||
|
{ path: '404', element: <Suspense fallback={<Loading />}><Page404 /></Suspense> },
|
||||||
|
{ path: '500', element: <Suspense fallback={<Loading />}><Page500 /></Suspense> },
|
||||||
|
|
||||||
|
// Catch-all
|
||||||
|
{ path: '*', element: <Navigate to="/404" replace /> },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,983 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRef, useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Card from '@mui/material/Card';
|
||||||
|
import Chip from '@mui/material/Chip';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
|
import Alert from '@mui/material/Alert';
|
||||||
|
import Paper from '@mui/material/Paper';
|
||||||
|
import Table from '@mui/material/Table';
|
||||||
|
import Avatar from '@mui/material/Avatar';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import Dialog from '@mui/material/Dialog';
|
||||||
|
import Switch from '@mui/material/Switch';
|
||||||
|
import Divider from '@mui/material/Divider';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import TableRow from '@mui/material/TableRow';
|
||||||
|
import Snackbar from '@mui/material/Snackbar';
|
||||||
|
import TableBody from '@mui/material/TableBody';
|
||||||
|
import TableCell from '@mui/material/TableCell';
|
||||||
|
import TableHead from '@mui/material/TableHead';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import LoadingButton from '@mui/lab/LoadingButton';
|
||||||
|
import CardContent from '@mui/material/CardContent';
|
||||||
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
|
import Autocomplete from '@mui/material/Autocomplete';
|
||||||
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
|
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
|
||||||
|
import { paths } from 'src/routes/paths';
|
||||||
|
|
||||||
|
import { unlinkTelegram, getTelegramStatus, getTelegramBotInfo, generateTelegramCode } from 'src/utils/telegram-api';
|
||||||
|
import {
|
||||||
|
searchCities,
|
||||||
|
deleteAvatar,
|
||||||
|
updateProfile,
|
||||||
|
loadTelegramAvatar,
|
||||||
|
getProfileSettings,
|
||||||
|
updateProfileSettings,
|
||||||
|
getNotificationPreferences,
|
||||||
|
updateNotificationPreferences,
|
||||||
|
} from 'src/utils/profile-api';
|
||||||
|
|
||||||
|
import { CONFIG } from 'src/config-global';
|
||||||
|
import { DashboardContent } from 'src/layouts/dashboard';
|
||||||
|
|
||||||
|
import { Iconify } from 'src/components/iconify';
|
||||||
|
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
|
||||||
|
|
||||||
|
import { useAuthContext } from 'src/auth/hooks';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ROLE_LABELS = {
|
||||||
|
mentor: 'Ментор',
|
||||||
|
client: 'Студент',
|
||||||
|
parent: 'Родитель',
|
||||||
|
};
|
||||||
|
|
||||||
|
const NOTIFICATION_TYPES = [
|
||||||
|
{ key: 'lesson_created', label: 'Занятие создано' },
|
||||||
|
{ key: 'lesson_cancelled', label: 'Занятие отменено' },
|
||||||
|
{ key: 'lesson_reminder', label: 'Напоминание о занятии' },
|
||||||
|
{ key: 'homework_assigned', label: 'ДЗ назначено' },
|
||||||
|
{ key: 'homework_submitted', label: 'ДЗ сдано' },
|
||||||
|
{ key: 'homework_reviewed', label: 'ДЗ проверено' },
|
||||||
|
{ key: 'message_received', label: 'Новое сообщение' },
|
||||||
|
{ key: 'subscription_expiring', label: 'Подписка истекает' },
|
||||||
|
{ key: 'subscription_expired', label: 'Подписка истекла' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PARENT_EXCLUDED_TYPES = [
|
||||||
|
'lesson_created', 'lesson_cancelled', 'lesson_reminder',
|
||||||
|
'homework_assigned', 'homework_submitted', 'homework_reviewed',
|
||||||
|
];
|
||||||
|
|
||||||
|
const CHANNELS = [
|
||||||
|
{ key: 'email', label: 'Email' },
|
||||||
|
{ key: 'telegram', label: 'Telegram' },
|
||||||
|
{ key: 'in_app', label: 'В приложении' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function avatarSrc(src) {
|
||||||
|
if (!src) return '';
|
||||||
|
if (src.startsWith('http://') || src.startsWith('https://')) return src;
|
||||||
|
const base = CONFIG.site.serverUrl?.replace('/api', '') || '';
|
||||||
|
return base + (src.startsWith('/') ? src : `/${src}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function TelegramSection({ onAvatarLoaded }) {
|
||||||
|
const [status, setStatus] = useState(null);
|
||||||
|
const [botInfo, setBotInfo] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [codeModal, setCodeModal] = useState(false);
|
||||||
|
const [code, setCode] = useState('');
|
||||||
|
const [codeInstructions, setCodeInstructions] = useState('');
|
||||||
|
const [generating, setGenerating] = useState(false);
|
||||||
|
const [unlinking, setUnlinking] = useState(false);
|
||||||
|
const [loadingTgAvatar, setLoadingTgAvatar] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const pollRef = useRef(null);
|
||||||
|
|
||||||
|
const loadStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [s, b] = await Promise.all([
|
||||||
|
getTelegramStatus().catch(() => null),
|
||||||
|
getTelegramBotInfo().catch(() => null),
|
||||||
|
]);
|
||||||
|
setStatus(s);
|
||||||
|
setBotInfo(b);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadStatus();
|
||||||
|
return () => {
|
||||||
|
if (pollRef.current) clearInterval(pollRef.current);
|
||||||
|
};
|
||||||
|
}, [loadStatus]);
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
setGenerating(true);
|
||||||
|
try {
|
||||||
|
const res = await generateTelegramCode();
|
||||||
|
setCode(res.code || '');
|
||||||
|
setCodeInstructions(res.instructions || '');
|
||||||
|
setCodeModal(true);
|
||||||
|
// Poll status every 5s after opening modal
|
||||||
|
pollRef.current = setInterval(async () => {
|
||||||
|
const s = await getTelegramStatus().catch(() => null);
|
||||||
|
if (s?.linked) {
|
||||||
|
setStatus(s);
|
||||||
|
setCodeModal(false);
|
||||||
|
clearInterval(pollRef.current);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setCodeModal(false);
|
||||||
|
if (pollRef.current) clearInterval(pollRef.current);
|
||||||
|
loadStatus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnlink = async () => {
|
||||||
|
setUnlinking(true);
|
||||||
|
try {
|
||||||
|
await unlinkTelegram();
|
||||||
|
await loadStatus();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setUnlinking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadTgAvatar = async () => {
|
||||||
|
setLoadingTgAvatar(true);
|
||||||
|
try {
|
||||||
|
await loadTelegramAvatar();
|
||||||
|
if (onAvatarLoaded) onAvatarLoaded();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setLoadingTgAvatar(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
navigator.clipboard.writeText(`/link ${code}`).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
|
||||||
|
<CircularProgress size={24} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const botLink = botInfo?.link || (botInfo?.username ? `https://t.me/${botInfo.username}` : null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box>
|
||||||
|
{status?.linked ? (
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1}>
|
||||||
|
<Iconify icon="logos:telegram" width={20} />
|
||||||
|
<Typography variant="body2" color="success.main" fontWeight={600}>
|
||||||
|
Telegram подключён
|
||||||
|
</Typography>
|
||||||
|
{status.telegram_username && (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
@{status.telegram_username}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<LoadingButton
|
||||||
|
size="small"
|
||||||
|
variant="soft"
|
||||||
|
color="info"
|
||||||
|
loading={loadingTgAvatar}
|
||||||
|
onClick={handleLoadTgAvatar}
|
||||||
|
startIcon={<Iconify icon="solar:user-id-bold" />}
|
||||||
|
>
|
||||||
|
Загрузить фото из Telegram
|
||||||
|
</LoadingButton>
|
||||||
|
<LoadingButton
|
||||||
|
size="small"
|
||||||
|
variant="soft"
|
||||||
|
color="error"
|
||||||
|
loading={unlinking}
|
||||||
|
onClick={handleUnlink}
|
||||||
|
startIcon={<Iconify icon="solar:link-broken-bold" />}
|
||||||
|
>
|
||||||
|
Отвязать
|
||||||
|
</LoadingButton>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Подключите Telegram для уведомлений
|
||||||
|
</Typography>
|
||||||
|
<LoadingButton
|
||||||
|
size="small"
|
||||||
|
variant="soft"
|
||||||
|
color="primary"
|
||||||
|
loading={generating}
|
||||||
|
onClick={handleGenerate}
|
||||||
|
startIcon={<Iconify icon="logos:telegram" />}
|
||||||
|
>
|
||||||
|
Привязать Telegram
|
||||||
|
</LoadingButton>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Code Modal */}
|
||||||
|
<Dialog open={codeModal} onClose={handleCloseModal} maxWidth="xs" fullWidth>
|
||||||
|
<DialogTitle>Привязать Telegram</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={2} sx={{ pt: 1 }}>
|
||||||
|
<Typography variant="body2">
|
||||||
|
1. Откройте бота{' '}
|
||||||
|
{botLink ? (
|
||||||
|
<Box component="a" href={botLink} target="_blank" rel="noopener noreferrer" sx={{ color: 'primary.main' }}>
|
||||||
|
{botInfo?.username ? `@${botInfo.username}` : 'Telegram бот'}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
'Telegram бот'
|
||||||
|
)}{' '}
|
||||||
|
в Telegram
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">2. Отправьте боту команду:</Typography>
|
||||||
|
<Paper
|
||||||
|
variant="outlined"
|
||||||
|
sx={{
|
||||||
|
p: 1.5,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
bgcolor: 'background.neutral',
|
||||||
|
borderRadius: 1,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" fontFamily="monospace" fontWeight={700}>
|
||||||
|
/link {code}
|
||||||
|
</Typography>
|
||||||
|
<Tooltip title={copied ? 'Скопировано!' : 'Скопировать'}>
|
||||||
|
<IconButton size="small" onClick={handleCopy}>
|
||||||
|
<Iconify icon={copied ? 'solar:check-circle-bold' : 'solar:copy-bold'} width={18} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Paper>
|
||||||
|
{codeInstructions && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{codeInstructions}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1}>
|
||||||
|
<CircularProgress size={14} />
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Ожидаем подтверждения...
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleCloseModal} color="inherit">
|
||||||
|
Закрыть
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function NotificationMatrix({ prefs, onChange, role }) {
|
||||||
|
const visibleTypes = role === 'parent'
|
||||||
|
? NOTIFICATION_TYPES.filter((t) => !PARENT_EXCLUDED_TYPES.includes(t.key))
|
||||||
|
: NOTIFICATION_TYPES;
|
||||||
|
|
||||||
|
const getTypeValue = (typeKey, channelKey) => {
|
||||||
|
const tp = prefs?.type_preferences;
|
||||||
|
if (tp && tp[typeKey] && typeof tp[typeKey][channelKey] === 'boolean') {
|
||||||
|
return tp[typeKey][channelKey];
|
||||||
|
}
|
||||||
|
// Fallback to channel-level setting
|
||||||
|
const channelMap = { email: 'email_enabled', telegram: 'telegram_enabled', in_app: 'in_app_enabled' };
|
||||||
|
return !!prefs?.[channelMap[channelKey]];
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = (typeKey, channelKey) => {
|
||||||
|
const current = getTypeValue(typeKey, channelKey);
|
||||||
|
onChange({
|
||||||
|
type_preferences: {
|
||||||
|
...prefs?.type_preferences,
|
||||||
|
[typeKey]: {
|
||||||
|
...(prefs?.type_preferences?.[typeKey] || {}),
|
||||||
|
[channelKey]: !current,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ overflowX: 'auto' }}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell sx={{ minWidth: 180 }}>Тип уведомления</TableCell>
|
||||||
|
{CHANNELS.map((ch) => (
|
||||||
|
<TableCell key={ch.key} align="center" sx={{ minWidth: 100 }}>
|
||||||
|
{ch.label}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{visibleTypes.map((type) => (
|
||||||
|
<TableRow key={type.key} hover>
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2">{type.label}</Typography>
|
||||||
|
</TableCell>
|
||||||
|
{CHANNELS.map((ch) => (
|
||||||
|
<TableCell key={ch.key} align="center">
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={getTypeValue(type.key, ch.key)}
|
||||||
|
onChange={() => handleToggle(type.key, ch.key)}
|
||||||
|
disabled={!prefs?.enabled}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function AccountPlatformView() {
|
||||||
|
const { user, checkUserSession } = useAuthContext();
|
||||||
|
|
||||||
|
// Profile fields
|
||||||
|
const [firstName, setFirstName] = useState('');
|
||||||
|
const [lastName, setLastName] = useState('');
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [avatarPreview, setAvatarPreview] = useState(null);
|
||||||
|
const [avatarHovered, setAvatarHovered] = useState(false);
|
||||||
|
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||||
|
const [deletingAvatar, setDeletingAvatar] = useState(false);
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
const [settings, setSettings] = useState(null);
|
||||||
|
const [settingsLoading, setSettingsLoading] = useState(true);
|
||||||
|
const [settingsSaving, setSettingsSaving] = useState(false);
|
||||||
|
|
||||||
|
// Notification prefs
|
||||||
|
const [notifPrefs, setNotifPrefs] = useState(null);
|
||||||
|
const [notifSaving, setNotifSaving] = useState(false);
|
||||||
|
|
||||||
|
// City autocomplete
|
||||||
|
const [cityQuery, setCityQuery] = useState('');
|
||||||
|
const [cityOptions, setCityOptions] = useState([]);
|
||||||
|
const [citySearching, setCitySearching] = useState(false);
|
||||||
|
const cityDebounceRef = useRef(null);
|
||||||
|
|
||||||
|
// Auto-save debounce for settings
|
||||||
|
const settingsSaveRef = useRef(null);
|
||||||
|
|
||||||
|
// Snackbar
|
||||||
|
const [snack, setSnack] = useState({ open: false, message: '', severity: 'success' });
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
setFirstName(user.first_name || '');
|
||||||
|
setLastName(user.last_name || '');
|
||||||
|
setPhone(user.phone || '');
|
||||||
|
setEmail(user.email || '');
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
setSettingsLoading(true);
|
||||||
|
try {
|
||||||
|
const [s, n] = await Promise.all([
|
||||||
|
getProfileSettings().catch(() => null),
|
||||||
|
getNotificationPreferences().catch(() => null),
|
||||||
|
]);
|
||||||
|
setSettings(s);
|
||||||
|
setNotifPrefs(n);
|
||||||
|
if (s?.preferences?.city) setCityQuery(s.preferences.city);
|
||||||
|
} finally {
|
||||||
|
setSettingsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// City search
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (cityDebounceRef.current) clearTimeout(cityDebounceRef.current);
|
||||||
|
if (!cityQuery || cityQuery.length < 2) {
|
||||||
|
setCityOptions([]);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
cityDebounceRef.current = setTimeout(async () => {
|
||||||
|
setCitySearching(true);
|
||||||
|
const results = await searchCities(cityQuery, 20);
|
||||||
|
setCityOptions(results);
|
||||||
|
setCitySearching(false);
|
||||||
|
}, 400);
|
||||||
|
return () => {
|
||||||
|
if (cityDebounceRef.current) clearTimeout(cityDebounceRef.current);
|
||||||
|
};
|
||||||
|
}, [cityQuery]);
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// Avatar
|
||||||
|
|
||||||
|
const handleAvatarChange = async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setAvatarPreview(URL.createObjectURL(file));
|
||||||
|
setUploadingAvatar(true);
|
||||||
|
try {
|
||||||
|
await updateProfile({ avatar: file });
|
||||||
|
if (checkUserSession) await checkUserSession();
|
||||||
|
showSnack('Аватар обновлён');
|
||||||
|
} catch {
|
||||||
|
showSnack('Ошибка загрузки аватара', 'error');
|
||||||
|
} finally {
|
||||||
|
setUploadingAvatar(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteAvatar = async () => {
|
||||||
|
setDeletingAvatar(true);
|
||||||
|
try {
|
||||||
|
await deleteAvatar();
|
||||||
|
setAvatarPreview(null);
|
||||||
|
if (checkUserSession) await checkUserSession();
|
||||||
|
showSnack('Аватар удалён');
|
||||||
|
} catch {
|
||||||
|
showSnack('Ошибка удаления аватара', 'error');
|
||||||
|
} finally {
|
||||||
|
setDeletingAvatar(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTgAvatarLoaded = async () => {
|
||||||
|
if (checkUserSession) await checkUserSession();
|
||||||
|
showSnack('Фото из Telegram загружено');
|
||||||
|
};
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// Profile field auto-save on blur
|
||||||
|
|
||||||
|
const handleProfileBlur = useCallback(async (field, value) => {
|
||||||
|
try {
|
||||||
|
await updateProfile({ [field]: value });
|
||||||
|
if (checkUserSession) await checkUserSession();
|
||||||
|
} catch {
|
||||||
|
showSnack('Ошибка сохранения', 'error');
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [checkUserSession]);
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// Settings save (debounced)
|
||||||
|
|
||||||
|
const scheduleSettingsSave = useCallback((newSettings) => {
|
||||||
|
setSettings(newSettings);
|
||||||
|
if (settingsSaveRef.current) clearTimeout(settingsSaveRef.current);
|
||||||
|
settingsSaveRef.current = setTimeout(async () => {
|
||||||
|
setSettingsSaving(true);
|
||||||
|
try {
|
||||||
|
await updateProfileSettings({
|
||||||
|
notifications: newSettings?.notifications,
|
||||||
|
preferences: newSettings?.preferences,
|
||||||
|
mentor_homework_ai: newSettings?.mentor_homework_ai,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
showSnack('Ошибка сохранения настроек', 'error');
|
||||||
|
} finally {
|
||||||
|
setSettingsSaving(false);
|
||||||
|
}
|
||||||
|
}, 800);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// Notification prefs save (debounced)
|
||||||
|
|
||||||
|
const notifSaveRef = useRef(null);
|
||||||
|
|
||||||
|
const scheduleNotifSave = useCallback((updated) => {
|
||||||
|
setNotifPrefs(updated);
|
||||||
|
if (notifSaveRef.current) clearTimeout(notifSaveRef.current);
|
||||||
|
notifSaveRef.current = setTimeout(async () => {
|
||||||
|
setNotifSaving(true);
|
||||||
|
try {
|
||||||
|
await updateNotificationPreferences(updated);
|
||||||
|
} catch {
|
||||||
|
showSnack('Ошибка сохранения уведомлений', 'error');
|
||||||
|
} finally {
|
||||||
|
setNotifSaving(false);
|
||||||
|
}
|
||||||
|
}, 800);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleNotifChange = (partial) => {
|
||||||
|
scheduleNotifSave({ ...notifPrefs, ...partial });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const showSnack = (message, severity = 'success') => {
|
||||||
|
setSnack({ open: true, message, severity });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const currentAvatar = avatarPreview || avatarSrc(user?.avatar);
|
||||||
|
const displayName = `${firstName} ${lastName}`.trim() || user?.email || '';
|
||||||
|
const roleLabel = ROLE_LABELS[user?.role] || user?.role || '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardContent>
|
||||||
|
<CustomBreadcrumbs
|
||||||
|
heading="Профиль"
|
||||||
|
links={[{ name: 'Главная', href: paths.dashboard.root }, { name: 'Профиль' }]}
|
||||||
|
sx={{ mb: 3 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: { xs: '1fr', md: '440px 1fr' },
|
||||||
|
gap: 3,
|
||||||
|
alignItems: 'start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* ─── LEFT COLUMN ─── */}
|
||||||
|
<Stack spacing={3}>
|
||||||
|
{/* Avatar card */}
|
||||||
|
<Card>
|
||||||
|
<CardContent sx={{ pb: '16px !important' }}>
|
||||||
|
<Stack alignItems="center" spacing={2}>
|
||||||
|
{/* Square avatar with hover overlay */}
|
||||||
|
<Box
|
||||||
|
sx={{ position: 'relative', width: 160, height: 160, cursor: 'pointer' }}
|
||||||
|
onMouseEnter={() => setAvatarHovered(true)}
|
||||||
|
onMouseLeave={() => setAvatarHovered(false)}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
src={currentAvatar}
|
||||||
|
alt={displayName}
|
||||||
|
sx={{ width: 160, height: 160, borderRadius: 2, fontSize: 48 }}
|
||||||
|
>
|
||||||
|
{!currentAvatar && displayName[0]?.toUpperCase()}
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
{/* Hover overlay */}
|
||||||
|
{avatarHovered && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: 'rgba(0,0,0,0.55)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="avatar-upload"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleAvatarChange}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
<Tooltip title="Загрузить фото">
|
||||||
|
<IconButton
|
||||||
|
component="label"
|
||||||
|
htmlFor="avatar-upload"
|
||||||
|
size="small"
|
||||||
|
sx={{ color: 'white' }}
|
||||||
|
disabled={uploadingAvatar}
|
||||||
|
>
|
||||||
|
{uploadingAvatar ? (
|
||||||
|
<CircularProgress size={20} color="inherit" />
|
||||||
|
) : (
|
||||||
|
<Iconify icon="solar:camera-bold" />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{currentAvatar && (
|
||||||
|
<Tooltip title="Удалить фото">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
sx={{ color: 'white' }}
|
||||||
|
onClick={handleDeleteAvatar}
|
||||||
|
disabled={deletingAvatar}
|
||||||
|
>
|
||||||
|
{deletingAvatar ? (
|
||||||
|
<CircularProgress size={20} color="inherit" />
|
||||||
|
) : (
|
||||||
|
<Iconify icon="solar:trash-bin-trash-bold" />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ textAlign: 'center' }}>
|
||||||
|
<Typography variant="subtitle1">{displayName}</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">{user?.email}</Typography>
|
||||||
|
{roleLabel && (
|
||||||
|
<Chip label={roleLabel} size="small" sx={{ mt: 0.5 }} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Profile fields */}
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="subtitle1" sx={{ mb: 2 }}>
|
||||||
|
Личные данные
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
||||||
|
<TextField
|
||||||
|
label="Имя"
|
||||||
|
value={firstName}
|
||||||
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
|
onBlur={(e) => handleProfileBlur('first_name', e.target.value.trim())}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Фамилия"
|
||||||
|
value={lastName}
|
||||||
|
onChange={(e) => setLastName(e.target.value)}
|
||||||
|
onBlur={(e) => handleProfileBlur('last_name', e.target.value.trim())}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<TextField
|
||||||
|
label="Телефон"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
onBlur={(e) => handleProfileBlur('phone', e.target.value.trim())}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Email"
|
||||||
|
value={email}
|
||||||
|
fullWidth
|
||||||
|
disabled
|
||||||
|
helperText="Изменить email нельзя"
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* City + Timezone */}
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="subtitle1" sx={{ mb: 2 }}>
|
||||||
|
Местоположение
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{settingsLoading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
|
||||||
|
<CircularProgress size={24} />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Autocomplete
|
||||||
|
freeSolo
|
||||||
|
options={cityOptions}
|
||||||
|
getOptionLabel={(opt) =>
|
||||||
|
typeof opt === 'string' ? opt : `${opt.name}${opt.region ? `, ${opt.region}` : ''}`
|
||||||
|
}
|
||||||
|
inputValue={cityQuery}
|
||||||
|
onInputChange={(_, val) => setCityQuery(val)}
|
||||||
|
onChange={(_, opt) => {
|
||||||
|
if (opt && typeof opt === 'object') {
|
||||||
|
setCityQuery(opt.name || '');
|
||||||
|
const newSettings = {
|
||||||
|
...settings,
|
||||||
|
preferences: {
|
||||||
|
...settings?.preferences,
|
||||||
|
city: opt.name || '',
|
||||||
|
timezone: opt.timezone || settings?.preferences?.timezone || '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
scheduleSettingsSave(newSettings);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
loading={citySearching}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Город"
|
||||||
|
fullWidth
|
||||||
|
onBlur={() => {
|
||||||
|
const newSettings = {
|
||||||
|
...settings,
|
||||||
|
preferences: {
|
||||||
|
...settings?.preferences,
|
||||||
|
city: cityQuery,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
scheduleSettingsSave(newSettings);
|
||||||
|
}}
|
||||||
|
InputProps={{
|
||||||
|
...params.InputProps,
|
||||||
|
endAdornment: (
|
||||||
|
<>
|
||||||
|
{citySearching ? <CircularProgress size={16} /> : null}
|
||||||
|
{params.InputProps.endAdornment}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
renderOption={(props, opt) => (
|
||||||
|
<Box component="li" {...props} key={`${opt.name}-${opt.timezone}`}>
|
||||||
|
<Stack>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{opt.name}{opt.region ? `, ${opt.region}` : ''}
|
||||||
|
</Typography>
|
||||||
|
{opt.timezone && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{opt.timezone}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Часовой пояс"
|
||||||
|
value={settings?.preferences?.timezone || ''}
|
||||||
|
fullWidth
|
||||||
|
disabled
|
||||||
|
helperText="Заполняется автоматически при выборе города"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Telegram */}
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="subtitle1" sx={{ mb: 2 }}>
|
||||||
|
Telegram
|
||||||
|
</Typography>
|
||||||
|
<TelegramSection onAvatarLoaded={handleTgAvatarLoaded} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* ─── RIGHT COLUMN ─── */}
|
||||||
|
<Stack spacing={3}>
|
||||||
|
{/* Notification preferences */}
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
|
||||||
|
<Typography variant="subtitle1">Уведомления</Typography>
|
||||||
|
{notifSaving && <CircularProgress size={16} />}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{settingsLoading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 3 }}>
|
||||||
|
<CircularProgress size={24} />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{/* Global toggle */}
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={!!notifPrefs?.enabled}
|
||||||
|
onChange={() => handleNotifChange({ enabled: !notifPrefs?.enabled })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Включить уведомления"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{notifPrefs?.enabled && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
{/* Channel toggles */}
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={!!notifPrefs?.email_enabled}
|
||||||
|
onChange={() => handleNotifChange({ email_enabled: !notifPrefs?.email_enabled })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Email"
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={!!notifPrefs?.telegram_enabled}
|
||||||
|
onChange={() => handleNotifChange({ telegram_enabled: !notifPrefs?.telegram_enabled })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Telegram"
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={!!notifPrefs?.in_app_enabled}
|
||||||
|
onChange={() => handleNotifChange({ in_app_enabled: !notifPrefs?.in_app_enabled })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="В приложении"
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
<Typography variant="subtitle2" color="text.secondary">
|
||||||
|
Настройки по типу уведомлений
|
||||||
|
</Typography>
|
||||||
|
<NotificationMatrix
|
||||||
|
prefs={notifPrefs}
|
||||||
|
onChange={handleNotifChange}
|
||||||
|
role={user?.role}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* AI homework settings (mentor only) */}
|
||||||
|
{user?.role === 'mentor' && settings && (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
|
||||||
|
<Typography variant="subtitle1">AI проверка домашних заданий</Typography>
|
||||||
|
{settingsSaving && <CircularProgress size={16} />}
|
||||||
|
</Stack>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={!!settings?.mentor_homework_ai?.ai_trust_draft}
|
||||||
|
onChange={() => {
|
||||||
|
const newVal = !settings?.mentor_homework_ai?.ai_trust_draft;
|
||||||
|
scheduleSettingsSave({
|
||||||
|
...settings,
|
||||||
|
mentor_homework_ai: {
|
||||||
|
...settings?.mentor_homework_ai,
|
||||||
|
ai_trust_draft: newVal,
|
||||||
|
// Mutually exclusive
|
||||||
|
ai_trust_publish: newVal ? false : settings?.mentor_homework_ai?.ai_trust_publish,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="AI сохраняет как черновик (нужна ваша проверка)"
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={!!settings?.mentor_homework_ai?.ai_trust_publish}
|
||||||
|
onChange={() => {
|
||||||
|
const newVal = !settings?.mentor_homework_ai?.ai_trust_publish;
|
||||||
|
scheduleSettingsSave({
|
||||||
|
...settings,
|
||||||
|
mentor_homework_ai: {
|
||||||
|
...settings?.mentor_homework_ai,
|
||||||
|
ai_trust_publish: newVal,
|
||||||
|
// Mutually exclusive
|
||||||
|
ai_trust_draft: newVal ? false : settings?.mentor_homework_ai?.ai_trust_draft,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="AI публикует результат автоматически"
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Эти опции взаимно исключают друг друга
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Snackbar
|
||||||
|
open={snack.open}
|
||||||
|
autoHideDuration={3000}
|
||||||
|
onClose={() => setSnack((prev) => ({ ...prev, open: false }))}
|
||||||
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||||
|
>
|
||||||
|
<Alert severity={snack.severity} onClose={() => setSnack((prev) => ({ ...prev, open: false }))}>
|
||||||
|
{snack.message}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
</DashboardContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export { AccountPlatformView } from './account-platform-view';
|
||||||
|
|
@ -0,0 +1,387 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
|
import Tab from '@mui/material/Tab';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Tabs from '@mui/material/Tabs';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
|
import Paper from '@mui/material/Paper';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
|
||||||
|
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
|
||||||
|
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import 'dayjs/locale/ru';
|
||||||
|
|
||||||
|
import { DashboardContent } from 'src/layouts/dashboard';
|
||||||
|
import { useAuthContext } from 'src/auth/hooks';
|
||||||
|
import {
|
||||||
|
getLast30DaysRange,
|
||||||
|
getAnalyticsOverview,
|
||||||
|
getAnalyticsStudents,
|
||||||
|
getAnalyticsRevenue,
|
||||||
|
getAnalyticsGradesByDay,
|
||||||
|
} from 'src/utils/analytics-api';
|
||||||
|
import { getMentorIncome } from 'src/utils/dashboard-api';
|
||||||
|
|
||||||
|
const ApexChart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const formatCurrency = (v) =>
|
||||||
|
new Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'RUB', maximumFractionDigits: 0 }).format(v ?? 0);
|
||||||
|
|
||||||
|
function StatCard({ label, value }) {
|
||||||
|
return (
|
||||||
|
<Paper sx={{ p: 2, borderRadius: 2, flex: 1, minWidth: 120 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h6" fontWeight={700} mt={0.5}>
|
||||||
|
{value ?? '—'}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DateRangeFilter({ value, onChange, disabled }) {
|
||||||
|
return (
|
||||||
|
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="ru">
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
|
<DatePicker
|
||||||
|
label="От"
|
||||||
|
value={dayjs(value.start_date)}
|
||||||
|
onChange={(d) => d && onChange({ ...value, start_date: d.format('YYYY-MM-DD') })}
|
||||||
|
disabled={disabled}
|
||||||
|
slotProps={{ textField: { size: 'small', sx: { width: 140 } } }}
|
||||||
|
/>
|
||||||
|
<DatePicker
|
||||||
|
label="До"
|
||||||
|
value={dayjs(value.end_date)}
|
||||||
|
onChange={(d) => d && onChange({ ...value, end_date: d.format('YYYY-MM-DD') })}
|
||||||
|
disabled={disabled}
|
||||||
|
slotProps={{ textField: { size: 'small', sx: { width: 140 } } }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</LocalizationProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function IncomeTab() {
|
||||||
|
const defaultRange = useMemo(() => getLast30DaysRange(), []);
|
||||||
|
const [range, setRange] = useState(defaultRange);
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const d = await getMentorIncome('range', range.start_date, range.end_date);
|
||||||
|
setData(d);
|
||||||
|
} catch {
|
||||||
|
setData(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [range]);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const chartOptions = useMemo(() => ({
|
||||||
|
chart: { id: 'income-chart', toolbar: { show: false } },
|
||||||
|
stroke: { curve: 'smooth', width: 2 },
|
||||||
|
colors: ['#7C3AED'],
|
||||||
|
dataLabels: { enabled: false },
|
||||||
|
xaxis: {
|
||||||
|
categories: (data?.chart_data ?? []).map((d) => d.date),
|
||||||
|
labels: { style: { fontSize: '11px' } },
|
||||||
|
},
|
||||||
|
fill: { type: 'gradient', gradient: { opacityFrom: 0.5, opacityTo: 0.05 } },
|
||||||
|
tooltip: { y: { formatter: (val) => formatCurrency(val) } },
|
||||||
|
}), [data]);
|
||||||
|
|
||||||
|
const series = useMemo(() => [{ name: 'Доход', data: (data?.chart_data ?? []).map((d) => d.income) }], [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Stack direction="row" alignItems="center" justifyContent="space-between" mb={2} flexWrap="wrap" gap={1}>
|
||||||
|
<Typography variant="h6">Доход</Typography>
|
||||||
|
<DateRangeFilter value={range} onChange={setRange} disabled={loading} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Stack direction="row" spacing={2} mb={3} flexWrap="wrap">
|
||||||
|
<StatCard label="Общий доход" value={formatCurrency(data?.summary?.total_income)} />
|
||||||
|
<StatCard label="Средняя цена" value={formatCurrency(data?.summary?.average_lesson_price)} />
|
||||||
|
<StatCard label="Занятий" value={data?.summary?.total_lessons} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{(data?.chart_data ?? []).length > 0 ? (
|
||||||
|
<ApexChart options={chartOptions} series={series} type="area" height={300} />
|
||||||
|
) : (
|
||||||
|
<Typography color="text.secondary" align="center" py={4}>
|
||||||
|
Нет данных за период
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(data?.top_lessons ?? []).length > 0 && (
|
||||||
|
<Box mt={3}>
|
||||||
|
<Typography variant="subtitle1" fontWeight={600} mb={1}>
|
||||||
|
Топ занятий по доходам
|
||||||
|
</Typography>
|
||||||
|
{data.top_lessons.slice(0, 10).map((item, i) => (
|
||||||
|
<Stack
|
||||||
|
key={i}
|
||||||
|
direction="row"
|
||||||
|
justifyContent="space-between"
|
||||||
|
py={1}
|
||||||
|
sx={{ borderBottom: '1px solid', borderColor: 'divider' }}
|
||||||
|
>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{i + 1}. {item.lesson_title || item.target_name || 'Занятие'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" fontWeight={600} color="primary.main">
|
||||||
|
{formatCurrency(item.total_income)}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function LessonsTab() {
|
||||||
|
const defaultRange = useMemo(() => getLast30DaysRange(), []);
|
||||||
|
const [range, setRange] = useState(defaultRange);
|
||||||
|
const [revenue, setRevenue] = useState(null);
|
||||||
|
const [overview, setOverview] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [rev, ov] = await Promise.all([
|
||||||
|
getAnalyticsRevenue({ period: 'custom', ...range }).catch(() => null),
|
||||||
|
getAnalyticsOverview({ period: 'custom', ...range }).catch(() => null),
|
||||||
|
]);
|
||||||
|
setRevenue(rev);
|
||||||
|
setOverview(ov);
|
||||||
|
} catch {
|
||||||
|
setRevenue(null);
|
||||||
|
setOverview(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [range]);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const byDay = revenue?.by_day ?? [];
|
||||||
|
|
||||||
|
const chartOptions = useMemo(() => ({
|
||||||
|
chart: { id: 'lessons-chart', toolbar: { show: false } },
|
||||||
|
stroke: { curve: 'smooth', width: 2 },
|
||||||
|
colors: ['#7C3AED'],
|
||||||
|
dataLabels: { enabled: false },
|
||||||
|
xaxis: {
|
||||||
|
categories: byDay.map((d) => d.date),
|
||||||
|
labels: { style: { fontSize: '11px' } },
|
||||||
|
},
|
||||||
|
fill: { type: 'gradient', gradient: { opacityFrom: 0.5, opacityTo: 0.05 } },
|
||||||
|
tooltip: { y: { formatter: (val) => `${val} занятий` } },
|
||||||
|
}), [byDay]);
|
||||||
|
|
||||||
|
const series = useMemo(() => [{ name: 'Занятий', data: byDay.map((d) => d.lessons_count) }], [byDay]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Stack direction="row" alignItems="center" justifyContent="space-between" mb={2} flexWrap="wrap" gap={1}>
|
||||||
|
<Typography variant="h6">Занятия</Typography>
|
||||||
|
<DateRangeFilter value={range} onChange={setRange} disabled={loading} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Stack direction="row" spacing={2} mb={3} flexWrap="wrap">
|
||||||
|
<StatCard label="Всего" value={overview?.lessons?.total} />
|
||||||
|
<StatCard label="Проведено" value={overview?.lessons?.completed} />
|
||||||
|
<StatCard label="Отменено" value={overview?.lessons?.cancelled} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{byDay.length > 0 ? (
|
||||||
|
<ApexChart options={chartOptions} series={series} type="area" height={300} />
|
||||||
|
) : (
|
||||||
|
<Typography color="text.secondary" align="center" py={4}>
|
||||||
|
Нет данных за период
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function StudentsTab() {
|
||||||
|
const defaultRange = useMemo(() => getLast30DaysRange(), []);
|
||||||
|
const [range, setRange] = useState(defaultRange);
|
||||||
|
const [grades, setGrades] = useState(null);
|
||||||
|
const [students, setStudents] = useState(null);
|
||||||
|
const [overview, setOverview] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const r = { period: 'custom', ...range };
|
||||||
|
const [gr, stu, ov] = await Promise.all([
|
||||||
|
getAnalyticsGradesByDay(r).catch(() => null),
|
||||||
|
getAnalyticsStudents(r).catch(() => null),
|
||||||
|
getAnalyticsOverview(r).catch(() => null),
|
||||||
|
]);
|
||||||
|
setGrades(gr);
|
||||||
|
setStudents(stu);
|
||||||
|
setOverview(ov);
|
||||||
|
} catch {
|
||||||
|
setGrades(null);
|
||||||
|
setStudents(null);
|
||||||
|
setOverview(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [range]);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const byDay = grades?.by_day ?? [];
|
||||||
|
|
||||||
|
const chartOptions = useMemo(() => ({
|
||||||
|
chart: { id: 'grades-chart', toolbar: { show: false } },
|
||||||
|
stroke: { curve: 'smooth', width: 2 },
|
||||||
|
colors: ['#7C3AED'],
|
||||||
|
dataLabels: { enabled: false },
|
||||||
|
xaxis: {
|
||||||
|
categories: byDay.map((d) => d.date),
|
||||||
|
labels: { style: { fontSize: '11px' } },
|
||||||
|
},
|
||||||
|
yaxis: { min: 0, max: 5 },
|
||||||
|
fill: { type: 'gradient', gradient: { opacityFrom: 0.5, opacityTo: 0.05 } },
|
||||||
|
tooltip: { y: { formatter: (val) => (val ? `Ср. оценка: ${val}` : '—') } },
|
||||||
|
}), [byDay]);
|
||||||
|
|
||||||
|
const series = useMemo(() => [
|
||||||
|
{ name: 'Средняя оценка', data: byDay.map((d) => d.average_grade ?? 0) },
|
||||||
|
], [byDay]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Stack direction="row" alignItems="center" justifyContent="space-between" mb={2} flexWrap="wrap" gap={1}>
|
||||||
|
<Typography variant="h6">Успех учеников</Typography>
|
||||||
|
<DateRangeFilter value={range} onChange={setRange} disabled={loading} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Stack direction="row" spacing={2} mb={3} flexWrap="wrap">
|
||||||
|
<StatCard label="Средняя оценка" value={grades?.summary?.average_grade ?? overview?.grades?.average} />
|
||||||
|
<StatCard label="Занятий с оценкой" value={grades?.summary?.graded_lessons} />
|
||||||
|
<StatCard label="Активных учеников" value={overview?.students?.active} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{byDay.length > 0 ? (
|
||||||
|
<ApexChart options={chartOptions} series={series} type="area" height={300} />
|
||||||
|
) : (
|
||||||
|
<Typography color="text.secondary" align="center" py={4}>
|
||||||
|
Нет данных за период
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(students?.students ?? []).length > 0 && (
|
||||||
|
<Box mt={3}>
|
||||||
|
<Typography variant="subtitle1" fontWeight={600} mb={1}>
|
||||||
|
Топ ученики
|
||||||
|
</Typography>
|
||||||
|
{students.students.slice(0, 10).map((s, i) => (
|
||||||
|
<Stack
|
||||||
|
key={s.id}
|
||||||
|
direction="row"
|
||||||
|
justifyContent="space-between"
|
||||||
|
py={1}
|
||||||
|
sx={{ borderBottom: '1px solid', borderColor: 'divider' }}
|
||||||
|
>
|
||||||
|
<Typography variant="body2">{i + 1}. {s.name}</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{s.lessons_completed} занятий · ср. {s.average_grade}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function AnalyticsView() {
|
||||||
|
const { user } = useAuthContext();
|
||||||
|
const [tab, setTab] = useState(0);
|
||||||
|
|
||||||
|
if (user?.role !== 'mentor') {
|
||||||
|
return (
|
||||||
|
<DashboardContent>
|
||||||
|
<Typography color="text.secondary" align="center" mt={8}>
|
||||||
|
Аналитика доступна только менторам.
|
||||||
|
</Typography>
|
||||||
|
</DashboardContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardContent maxWidth="lg">
|
||||||
|
<Typography variant="h4" mb={3}>
|
||||||
|
Аналитика
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Paper sx={{ borderRadius: 2 }}>
|
||||||
|
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ px: 2, borderBottom: 1, borderColor: 'divider' }}>
|
||||||
|
<Tab label="Доход" />
|
||||||
|
<Tab label="Занятия" />
|
||||||
|
<Tab label="Ученики" />
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<Box p={3}>
|
||||||
|
{tab === 0 && <IncomeTab />}
|
||||||
|
{tab === 1 && <LessonsTab />}
|
||||||
|
{tab === 2 && <StudentsTab />}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</DashboardContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './analytics-view';
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
export * from './jwt-sign-in-view';
|
export * from './jwt-sign-in-view';
|
||||||
|
|
||||||
export * from './jwt-sign-up-view';
|
export * from './jwt-sign-up-view';
|
||||||
|
export * from './jwt-forgot-password-view';
|
||||||
|
export * from './jwt-reset-password-view';
|
||||||
|
export * from './jwt-verify-email-view';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { z as zod } from 'zod';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
|
||||||
|
import Link from '@mui/material/Link';
|
||||||
|
import Alert from '@mui/material/Alert';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import LoadingButton from '@mui/lab/LoadingButton';
|
||||||
|
|
||||||
|
import { paths } from 'src/routes/paths';
|
||||||
|
import { RouterLink } from 'src/routes/components';
|
||||||
|
|
||||||
|
import { Form, Field } from 'src/components/hook-form';
|
||||||
|
|
||||||
|
import { requestPasswordReset } from 'src/auth/context/jwt';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ForgotPasswordSchema = zod.object({
|
||||||
|
email: zod
|
||||||
|
.string()
|
||||||
|
.min(1, { message: 'Email is required!' })
|
||||||
|
.email({ message: 'Email must be a valid email address!' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function JwtForgotPasswordView() {
|
||||||
|
const [errorMsg, setErrorMsg] = useState('');
|
||||||
|
const [successMsg, setSuccessMsg] = useState('');
|
||||||
|
|
||||||
|
const methods = useForm({
|
||||||
|
resolver: zodResolver(ForgotPasswordSchema),
|
||||||
|
defaultValues: { email: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
formState: { isSubmitting },
|
||||||
|
} = methods;
|
||||||
|
|
||||||
|
const onSubmit = handleSubmit(async (data) => {
|
||||||
|
try {
|
||||||
|
await requestPasswordReset({ email: data.email });
|
||||||
|
setSuccessMsg('Password reset instructions have been sent to your email.');
|
||||||
|
setErrorMsg('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
const msg = error?.response?.data?.message || error?.response?.data?.detail || 'Error sending request. Please check your email.';
|
||||||
|
setErrorMsg(msg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack spacing={1.5} sx={{ mb: 5 }}>
|
||||||
|
<Typography variant="h5">Forgot your password?</Typography>
|
||||||
|
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
|
Enter your email address and we will send you a link to reset your password.
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{!!successMsg && (
|
||||||
|
<Alert severity="success" sx={{ mb: 3 }}>
|
||||||
|
{successMsg}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!!errorMsg && (
|
||||||
|
<Alert severity="error" sx={{ mb: 3 }}>
|
||||||
|
{errorMsg}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!successMsg && (
|
||||||
|
<Form methods={methods} onSubmit={onSubmit}>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Field.Text name="email" label="Email address" InputLabelProps={{ shrink: true }} />
|
||||||
|
|
||||||
|
<LoadingButton
|
||||||
|
fullWidth
|
||||||
|
color="inherit"
|
||||||
|
size="large"
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
loading={isSubmitting}
|
||||||
|
loadingIndicator="Sending..."
|
||||||
|
>
|
||||||
|
Send reset link
|
||||||
|
</LoadingButton>
|
||||||
|
</Stack>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Link
|
||||||
|
component={RouterLink}
|
||||||
|
href={paths.auth.jwt.signIn}
|
||||||
|
variant="body2"
|
||||||
|
color="inherit"
|
||||||
|
sx={{ display: 'block', mt: 3, textAlign: 'center' }}
|
||||||
|
>
|
||||||
|
Back to sign in
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,204 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { z as zod } from 'zod';
|
||||||
|
import { useState, Suspense } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import Link from '@mui/material/Link';
|
||||||
|
import Alert from '@mui/material/Alert';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import LoadingButton from '@mui/lab/LoadingButton';
|
||||||
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
|
|
||||||
|
import { paths } from 'src/routes/paths';
|
||||||
|
import { useRouter } from 'src/routes/hooks';
|
||||||
|
import { RouterLink } from 'src/routes/components';
|
||||||
|
|
||||||
|
import { useBoolean } from 'src/hooks/use-boolean';
|
||||||
|
|
||||||
|
import { Iconify } from 'src/components/iconify';
|
||||||
|
import { Form, Field } from 'src/components/hook-form';
|
||||||
|
|
||||||
|
import { confirmPasswordReset } from 'src/auth/context/jwt';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ResetPasswordSchema = zod
|
||||||
|
.object({
|
||||||
|
newPassword: zod
|
||||||
|
.string()
|
||||||
|
.min(1, { message: 'Password is required!' })
|
||||||
|
.min(6, { message: 'Password must be at least 6 characters!' }),
|
||||||
|
newPasswordConfirm: zod.string().min(1, { message: 'Please confirm your password!' }),
|
||||||
|
})
|
||||||
|
.refine((data) => data.newPassword === data.newPasswordConfirm, {
|
||||||
|
message: 'Passwords do not match!',
|
||||||
|
path: ['newPasswordConfirm'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function ResetPasswordContent() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const token = searchParams.get('token');
|
||||||
|
|
||||||
|
const [errorMsg, setErrorMsg] = useState('');
|
||||||
|
const [successMsg, setSuccessMsg] = useState('');
|
||||||
|
|
||||||
|
const newPassword = useBoolean();
|
||||||
|
const newPasswordConfirm = useBoolean();
|
||||||
|
|
||||||
|
const methods = useForm({
|
||||||
|
resolver: zodResolver(ResetPasswordSchema),
|
||||||
|
defaultValues: { newPassword: '', newPasswordConfirm: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
formState: { isSubmitting },
|
||||||
|
} = methods;
|
||||||
|
|
||||||
|
const onSubmit = handleSubmit(async (data) => {
|
||||||
|
if (!token) {
|
||||||
|
setErrorMsg('Reset link is missing. Please request a new password reset.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await confirmPasswordReset({
|
||||||
|
token,
|
||||||
|
newPassword: data.newPassword,
|
||||||
|
newPasswordConfirm: data.newPasswordConfirm,
|
||||||
|
});
|
||||||
|
setSuccessMsg('Password changed successfully. You can now sign in with your new password.');
|
||||||
|
setErrorMsg('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
const msg = error?.response?.data?.message || error?.response?.data?.detail || 'Failed to reset password. The link may have expired — please request a new one.';
|
||||||
|
setErrorMsg(msg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack spacing={1.5} sx={{ mb: 5 }}>
|
||||||
|
<Typography variant="h5">Reset password</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Alert severity="error" sx={{ mb: 3 }}>
|
||||||
|
Reset link is missing. Please follow the link from your email or request a new one.
|
||||||
|
</Alert>
|
||||||
|
<Link component={RouterLink} href={paths.auth.jwt.forgotPassword} variant="subtitle2">
|
||||||
|
Request new reset link
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successMsg) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack spacing={1.5} sx={{ mb: 5 }}>
|
||||||
|
<Typography variant="h5">Reset password</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Alert severity="success" sx={{ mb: 3 }}>
|
||||||
|
{successMsg}
|
||||||
|
</Alert>
|
||||||
|
<Link component={RouterLink} href={paths.auth.jwt.signIn} variant="subtitle2">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack spacing={1.5} sx={{ mb: 5 }}>
|
||||||
|
<Typography variant="h5">Set new password</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
|
Enter your new password below.
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{!!errorMsg && (
|
||||||
|
<Alert severity="error" sx={{ mb: 3 }}>
|
||||||
|
{errorMsg}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form methods={methods} onSubmit={onSubmit}>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Field.Text
|
||||||
|
name="newPassword"
|
||||||
|
label="New password"
|
||||||
|
placeholder="6+ characters"
|
||||||
|
type={newPassword.value ? 'text' : 'password'}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton onClick={newPassword.onToggle} edge="end">
|
||||||
|
<Iconify icon={newPassword.value ? 'solar:eye-bold' : 'solar:eye-closed-bold'} />
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Field.Text
|
||||||
|
name="newPasswordConfirm"
|
||||||
|
label="Confirm new password"
|
||||||
|
placeholder="6+ characters"
|
||||||
|
type={newPasswordConfirm.value ? 'text' : 'password'}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton onClick={newPasswordConfirm.onToggle} edge="end">
|
||||||
|
<Iconify icon={newPasswordConfirm.value ? 'solar:eye-bold' : 'solar:eye-closed-bold'} />
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LoadingButton
|
||||||
|
fullWidth
|
||||||
|
color="inherit"
|
||||||
|
size="large"
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
loading={isSubmitting}
|
||||||
|
loadingIndicator="Saving..."
|
||||||
|
>
|
||||||
|
Save new password
|
||||||
|
</LoadingButton>
|
||||||
|
</Stack>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
component={RouterLink}
|
||||||
|
href={paths.auth.jwt.signIn}
|
||||||
|
variant="body2"
|
||||||
|
color="inherit"
|
||||||
|
sx={{ display: 'block', mt: 3, textAlign: 'center' }}
|
||||||
|
>
|
||||||
|
Back to sign in
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function JwtResetPasswordView() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<Typography variant="body2">Loading...</Typography>}>
|
||||||
|
<ResetPasswordContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -50,8 +50,8 @@ export function JwtSignInView() {
|
||||||
const password = useBoolean();
|
const password = useBoolean();
|
||||||
|
|
||||||
const defaultValues = {
|
const defaultValues = {
|
||||||
email: 'demo@minimals.cc',
|
email: 'mentor@demo.uchill.online',
|
||||||
password: '@demo1',
|
password: 'demo123456',
|
||||||
};
|
};
|
||||||
|
|
||||||
const methods = useForm({
|
const methods = useForm({
|
||||||
|
|
@ -99,7 +99,7 @@ export function JwtSignInView() {
|
||||||
<Stack spacing={1.5}>
|
<Stack spacing={1.5}>
|
||||||
<Link
|
<Link
|
||||||
component={RouterLink}
|
component={RouterLink}
|
||||||
href="#"
|
href={paths.auth.jwt.forgotPassword}
|
||||||
variant="body2"
|
variant="body2"
|
||||||
color="inherit"
|
color="inherit"
|
||||||
sx={{ alignSelf: 'flex-end' }}
|
sx={{ alignSelf: 'flex-end' }}
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,11 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import Link from '@mui/material/Link';
|
import Link from '@mui/material/Link';
|
||||||
import Alert from '@mui/material/Alert';
|
import Alert from '@mui/material/Alert';
|
||||||
import Stack from '@mui/material/Stack';
|
import Stack from '@mui/material/Stack';
|
||||||
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
|
import Checkbox from '@mui/material/Checkbox';
|
||||||
import IconButton from '@mui/material/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
|
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||||
import LoadingButton from '@mui/lab/LoadingButton';
|
import LoadingButton from '@mui/lab/LoadingButton';
|
||||||
import InputAdornment from '@mui/material/InputAdornment';
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
|
|
||||||
|
|
@ -27,35 +30,48 @@ import { useAuthContext } from 'src/auth/hooks';
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
export const SignUpSchema = zod.object({
|
export const SignUpSchema = zod
|
||||||
firstName: zod.string().min(1, { message: 'First name is required!' }),
|
.object({
|
||||||
lastName: zod.string().min(1, { message: 'Last name is required!' }),
|
firstName: zod.string().min(1, { message: 'First name is required!' }),
|
||||||
email: zod
|
lastName: zod.string().min(1, { message: 'Last name is required!' }),
|
||||||
.string()
|
email: zod
|
||||||
.min(1, { message: 'Email is required!' })
|
.string()
|
||||||
.email({ message: 'Email must be a valid email address!' }),
|
.min(1, { message: 'Email is required!' })
|
||||||
password: zod
|
.email({ message: 'Email must be a valid email address!' }),
|
||||||
.string()
|
role: zod.enum(['mentor', 'client', 'parent'], { message: 'Role is required!' }),
|
||||||
.min(1, { message: 'Password is required!' })
|
city: zod.string().min(1, { message: 'City is required!' }),
|
||||||
.min(6, { message: 'Password must be at least 6 characters!' }),
|
password: zod
|
||||||
});
|
.string()
|
||||||
|
.min(1, { message: 'Password is required!' })
|
||||||
|
.min(6, { message: 'Password must be at least 6 characters!' }),
|
||||||
|
passwordConfirm: zod.string().min(1, { message: 'Please confirm your password!' }),
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.passwordConfirm, {
|
||||||
|
message: 'Passwords do not match!',
|
||||||
|
path: ['passwordConfirm'],
|
||||||
|
});
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
export function JwtSignUpView() {
|
export function JwtSignUpView() {
|
||||||
const { checkUserSession } = useAuthContext();
|
const { checkUserSession } = useAuthContext();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const password = useBoolean();
|
const password = useBoolean();
|
||||||
|
const passwordConfirm = useBoolean();
|
||||||
|
|
||||||
const [errorMsg, setErrorMsg] = useState('');
|
const [errorMsg, setErrorMsg] = useState('');
|
||||||
|
const [successMsg, setSuccessMsg] = useState('');
|
||||||
|
const [consent, setConsent] = useState(false);
|
||||||
|
|
||||||
const defaultValues = {
|
const defaultValues = {
|
||||||
firstName: 'Hello',
|
firstName: '',
|
||||||
lastName: 'Friend',
|
lastName: '',
|
||||||
email: 'hello@gmail.com',
|
email: '',
|
||||||
password: '@demo1',
|
role: 'client',
|
||||||
|
city: '',
|
||||||
|
password: '',
|
||||||
|
passwordConfirm: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
const methods = useForm({
|
const methods = useForm({
|
||||||
|
|
@ -69,19 +85,35 @@ export function JwtSignUpView() {
|
||||||
} = methods;
|
} = methods;
|
||||||
|
|
||||||
const onSubmit = handleSubmit(async (data) => {
|
const onSubmit = handleSubmit(async (data) => {
|
||||||
|
if (!consent) {
|
||||||
|
setErrorMsg('Please agree to the Terms of Service and Privacy Policy.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await signUp({
|
const result = await signUp({
|
||||||
email: data.email,
|
email: data.email,
|
||||||
password: data.password,
|
password: data.password,
|
||||||
|
passwordConfirm: data.passwordConfirm,
|
||||||
firstName: data.firstName,
|
firstName: data.firstName,
|
||||||
lastName: data.lastName,
|
lastName: data.lastName,
|
||||||
|
role: data.role,
|
||||||
|
city: data.city,
|
||||||
});
|
});
|
||||||
await checkUserSession?.();
|
|
||||||
|
|
||||||
|
if (result?.requiresVerification) {
|
||||||
|
setSuccessMsg(
|
||||||
|
`A confirmation email has been sent to ${data.email}. Please follow the link to verify your account.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await checkUserSession?.();
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
setErrorMsg(error instanceof Error ? error.message : error);
|
const msg = error?.response?.data?.message || error?.response?.data?.detail || (error instanceof Error ? error.message : 'Registration error. Please check your data.');
|
||||||
|
setErrorMsg(msg);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -101,6 +133,20 @@ export function JwtSignUpView() {
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (successMsg) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{renderHead}
|
||||||
|
<Alert severity="success">{successMsg}</Alert>
|
||||||
|
<Stack sx={{ mt: 3 }}>
|
||||||
|
<Link component={RouterLink} href={paths.auth.jwt.signIn} variant="subtitle2">
|
||||||
|
Back to sign in
|
||||||
|
</Link>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const renderForm = (
|
const renderForm = (
|
||||||
<Stack spacing={3}>
|
<Stack spacing={3}>
|
||||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
||||||
|
|
@ -110,6 +156,14 @@ export function JwtSignUpView() {
|
||||||
|
|
||||||
<Field.Text name="email" label="Email address" InputLabelProps={{ shrink: true }} />
|
<Field.Text name="email" label="Email address" InputLabelProps={{ shrink: true }} />
|
||||||
|
|
||||||
|
<Field.Select name="role" label="Role" InputLabelProps={{ shrink: true }}>
|
||||||
|
<MenuItem value="client">Student</MenuItem>
|
||||||
|
<MenuItem value="mentor">Mentor</MenuItem>
|
||||||
|
<MenuItem value="parent">Parent</MenuItem>
|
||||||
|
</Field.Select>
|
||||||
|
|
||||||
|
<Field.Text name="city" label="City (for timezone)" InputLabelProps={{ shrink: true }} />
|
||||||
|
|
||||||
<Field.Text
|
<Field.Text
|
||||||
name="password"
|
name="password"
|
||||||
label="Password"
|
label="Password"
|
||||||
|
|
@ -127,6 +181,39 @@ export function JwtSignUpView() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Field.Text
|
||||||
|
name="passwordConfirm"
|
||||||
|
label="Confirm password"
|
||||||
|
placeholder="6+ characters"
|
||||||
|
type={passwordConfirm.value ? 'text' : 'password'}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton onClick={passwordConfirm.onToggle} edge="end">
|
||||||
|
<Iconify icon={passwordConfirm.value ? 'solar:eye-bold' : 'solar:eye-closed-bold'} />
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Checkbox checked={consent} onChange={(e) => setConsent(e.target.checked)} />}
|
||||||
|
label={
|
||||||
|
<Typography variant="body2">
|
||||||
|
I agree to the{' '}
|
||||||
|
<Link underline="always" color="text.primary">
|
||||||
|
Terms of service
|
||||||
|
</Link>{' '}
|
||||||
|
and{' '}
|
||||||
|
<Link underline="always" color="text.primary">
|
||||||
|
Privacy policy
|
||||||
|
</Link>
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<LoadingButton
|
<LoadingButton
|
||||||
fullWidth
|
fullWidth
|
||||||
color="inherit"
|
color="inherit"
|
||||||
|
|
@ -134,35 +221,13 @@ export function JwtSignUpView() {
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="contained"
|
variant="contained"
|
||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
loadingIndicator="Create account..."
|
loadingIndicator="Creating account..."
|
||||||
>
|
>
|
||||||
Create account
|
Create account
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderTerms = (
|
|
||||||
<Typography
|
|
||||||
component="div"
|
|
||||||
sx={{
|
|
||||||
mt: 3,
|
|
||||||
textAlign: 'center',
|
|
||||||
typography: 'caption',
|
|
||||||
color: 'text.secondary',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{'By signing up, I agree to '}
|
|
||||||
<Link underline="always" color="text.primary">
|
|
||||||
Terms of service
|
|
||||||
</Link>
|
|
||||||
{' and '}
|
|
||||||
<Link underline="always" color="text.primary">
|
|
||||||
Privacy policy
|
|
||||||
</Link>
|
|
||||||
.
|
|
||||||
</Typography>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{renderHead}
|
{renderHead}
|
||||||
|
|
@ -176,8 +241,6 @@ export function JwtSignUpView() {
|
||||||
<Form methods={methods} onSubmit={onSubmit}>
|
<Form methods={methods} onSubmit={onSubmit}>
|
||||||
{renderForm}
|
{renderForm}
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
{renderTerms}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, Suspense } from 'react';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import Link from '@mui/material/Link';
|
||||||
|
import Alert from '@mui/material/Alert';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
|
||||||
|
import { paths } from 'src/routes/paths';
|
||||||
|
import { RouterLink } from 'src/routes/components';
|
||||||
|
|
||||||
|
import { verifyEmail } from 'src/auth/context/jwt';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function VerifyEmailContent() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const token = searchParams.get('token');
|
||||||
|
|
||||||
|
const [status, setStatus] = useState('loading'); // 'loading' | 'success' | 'error'
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
setStatus('error');
|
||||||
|
setMessage('Verification link is missing. Please check your email or request a new one.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
verifyEmail({ token })
|
||||||
|
.then((res) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (res?.success) {
|
||||||
|
setStatus('success');
|
||||||
|
setMessage('Email successfully verified. You can now sign in.');
|
||||||
|
} else {
|
||||||
|
setStatus('error');
|
||||||
|
setMessage(res?.message || 'Failed to verify email.');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setStatus('error');
|
||||||
|
const msg = err?.response?.data?.message || err?.response?.data?.detail || 'Invalid or expired link. Please request a new verification email.';
|
||||||
|
setMessage(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack spacing={1.5} sx={{ mb: 5 }}>
|
||||||
|
<Typography variant="h5">Email verification</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{status === 'loading' && (
|
||||||
|
<Stack alignItems="center" sx={{ py: 4 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
<Typography variant="body2" sx={{ mt: 2, color: 'text.secondary' }}>
|
||||||
|
Verifying your email...
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'success' && (
|
||||||
|
<>
|
||||||
|
<Alert severity="success" sx={{ mb: 3 }}>
|
||||||
|
{message}
|
||||||
|
</Alert>
|
||||||
|
<Link component={RouterLink} href={paths.auth.jwt.signIn} variant="subtitle2">
|
||||||
|
Sign in to your account
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'error' && (
|
||||||
|
<>
|
||||||
|
<Alert severity="error" sx={{ mb: 3 }}>
|
||||||
|
{message}
|
||||||
|
</Alert>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<Link component={RouterLink} href={paths.auth.jwt.signIn} variant="subtitle2">
|
||||||
|
Back to sign in
|
||||||
|
</Link>
|
||||||
|
<Link component={RouterLink} href={paths.auth.jwt.signUp} variant="body2" color="text.secondary">
|
||||||
|
Create a new account
|
||||||
|
</Link>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function JwtVerifyEmailView() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<Typography variant="body2">Loading...</Typography>}>
|
||||||
|
<VerifyEmailContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,392 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRef, useEffect, useState, useCallback } from 'react';
|
||||||
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Card from '@mui/material/Card';
|
||||||
|
import Grid from '@mui/material/Unstable_Grid2';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import Avatar from '@mui/material/Avatar';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import CardContent from '@mui/material/CardContent';
|
||||||
|
import CardActionArea from '@mui/material/CardActionArea';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
|
||||||
|
import { CONFIG } from 'src/config-global';
|
||||||
|
import { Iconify } from 'src/components/iconify';
|
||||||
|
import { DashboardContent } from 'src/layouts/dashboard';
|
||||||
|
import { useAuthContext } from 'src/auth/hooks';
|
||||||
|
import { getMyBoards, getSharedBoards, getOrCreateMentorStudentBoard } from 'src/utils/board-api';
|
||||||
|
import { getMentorStudents } from 'src/utils/dashboard-api';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function buildExcalidrawSrc(boardId, user) {
|
||||||
|
const token =
|
||||||
|
typeof window !== 'undefined'
|
||||||
|
? localStorage.getItem('jwt_access_token') || localStorage.getItem('access_token') || ''
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const serverUrl = CONFIG.site.serverUrl || '';
|
||||||
|
const apiUrl = serverUrl.replace(/\/api\/?$/, '') || '';
|
||||||
|
const isMentor = user?.role === 'mentor';
|
||||||
|
|
||||||
|
const excalidrawUrl = (process.env.NEXT_PUBLIC_EXCALIDRAW_URL || '').trim().replace(/\/?$/, '');
|
||||||
|
const excalidrawPath = process.env.NEXT_PUBLIC_EXCALIDRAW_PATH || '';
|
||||||
|
const excalidrawPort = process.env.NEXT_PUBLIC_EXCALIDRAW_PORT || '3001';
|
||||||
|
const yjsPort = process.env.NEXT_PUBLIC_YJS_PORT || '1236';
|
||||||
|
|
||||||
|
if (excalidrawUrl && excalidrawUrl.startsWith('http')) {
|
||||||
|
const url = new URL(`${excalidrawUrl}/`);
|
||||||
|
url.searchParams.set('boardId', boardId);
|
||||||
|
url.searchParams.set('apiUrl', apiUrl);
|
||||||
|
url.searchParams.set('yjsPort', yjsPort);
|
||||||
|
if (token) url.searchParams.set('token', token);
|
||||||
|
if (isMentor) url.searchParams.set('isMentor', '1');
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const origin = excalidrawPath
|
||||||
|
? (typeof window !== 'undefined' ? window.location.origin : '')
|
||||||
|
: `${typeof window !== 'undefined' ? window.location.protocol : 'https:'}//${typeof window !== 'undefined' ? window.location.hostname : ''}:${excalidrawPort}`;
|
||||||
|
const pathname = excalidrawPath ? `/${excalidrawPath.replace(/^\//, '')}/` : '/';
|
||||||
|
const params = new URLSearchParams({ boardId, apiUrl });
|
||||||
|
params.set('yjsPort', yjsPort);
|
||||||
|
if (token) params.set('token', token);
|
||||||
|
if (isMentor) params.set('isMentor', '1');
|
||||||
|
return `${origin}${pathname}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function WhiteboardIframe({ boardId, user }) {
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
const iframeRef = useRef(null);
|
||||||
|
const createdRef = useRef(false);
|
||||||
|
const username = user ? `${user.first_name || ''} ${user.last_name || ''}`.trim() || user.email : 'Пользователь';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined' || !containerRef.current || !boardId) return undefined;
|
||||||
|
if (createdRef.current) return undefined;
|
||||||
|
|
||||||
|
const excalidrawUrl = (process.env.NEXT_PUBLIC_EXCALIDRAW_URL || '').trim();
|
||||||
|
const excalidrawPath = process.env.NEXT_PUBLIC_EXCALIDRAW_PATH || '';
|
||||||
|
const iframeSrc = buildExcalidrawSrc(boardId, user);
|
||||||
|
|
||||||
|
const sendUsername = (iframe) => {
|
||||||
|
if (!iframe.contentWindow) return;
|
||||||
|
try {
|
||||||
|
const targetOrigin = excalidrawUrl.startsWith('http')
|
||||||
|
? new URL(excalidrawUrl).origin
|
||||||
|
: window.location.origin;
|
||||||
|
iframe.contentWindow.postMessage({ type: 'excalidraw-username', username }, targetOrigin);
|
||||||
|
} catch (_e) {
|
||||||
|
// ignore cross-origin errors
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const iframe = document.createElement('iframe');
|
||||||
|
iframe.src = iframeSrc;
|
||||||
|
iframe.style.cssText = 'width:100%;height:100%;border:none;display:block';
|
||||||
|
iframe.title = 'Интерактивная доска';
|
||||||
|
iframe.setAttribute('allow', 'camera; microphone; fullscreen');
|
||||||
|
iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts allow-forms allow-popups allow-modals');
|
||||||
|
iframe.onload = () => { sendUsername(iframe); setTimeout(() => sendUsername(iframe), 500); };
|
||||||
|
|
||||||
|
containerRef.current.appendChild(iframe);
|
||||||
|
iframeRef.current = iframe;
|
||||||
|
createdRef.current = true;
|
||||||
|
setTimeout(() => sendUsername(iframe), 300);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
createdRef.current = false;
|
||||||
|
iframeRef.current = null;
|
||||||
|
iframe.remove();
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [boardId]);
|
||||||
|
|
||||||
|
return <div ref={containerRef} style={{ width: '100%', height: '100%' }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function getUserName(u) {
|
||||||
|
if (!u) return '—';
|
||||||
|
if (typeof u === 'object') return [u.first_name, u.last_name].filter(Boolean).join(' ') || u.email || '—';
|
||||||
|
return String(u);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserInitials(u) {
|
||||||
|
if (!u || typeof u !== 'object') return '?';
|
||||||
|
return [u.first_name?.[0], u.last_name?.[0]].filter(Boolean).join('').toUpperCase() || (u.email?.[0] || '?').toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function BoardCard({ board, currentUser, onClick }) {
|
||||||
|
const isMentor = currentUser?.role === 'mentor';
|
||||||
|
const otherPerson = isMentor ? board.student : board.mentor;
|
||||||
|
const otherName = getUserName(otherPerson);
|
||||||
|
const otherInitials = getUserInitials(otherPerson);
|
||||||
|
const lastEdited = board.last_edited_at
|
||||||
|
? new Date(board.last_edited_at).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card sx={{ borderRadius: 2, height: '100%' }}>
|
||||||
|
<CardActionArea onClick={onClick} sx={{ height: '100%' }}>
|
||||||
|
{/* Preview area */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
height: 140,
|
||||||
|
bgcolor: 'primary.lighter',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Iconify icon="solar:pen-new-square-bold-duotone" width={48} sx={{ color: 'primary.main', opacity: 0.4 }} />
|
||||||
|
{board.elements_count > 0 && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 8,
|
||||||
|
right: 8,
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
borderRadius: 1,
|
||||||
|
px: 1,
|
||||||
|
py: 0.25,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{board.elements_count} элем.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="subtitle2" noWrap mb={0.5}>
|
||||||
|
{board.title || 'Без названия'}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1}>
|
||||||
|
<Avatar sx={{ width: 24, height: 24, fontSize: 11, bgcolor: 'primary.main' }}>
|
||||||
|
{otherInitials}
|
||||||
|
</Avatar>
|
||||||
|
<Typography variant="caption" color="text.secondary" noWrap>
|
||||||
|
{isMentor ? 'Ученик: ' : 'Ментор: '}{otherName}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{lastEdited && (
|
||||||
|
<Typography variant="caption" color="text.disabled" display="block" mt={0.5}>
|
||||||
|
Изменено {lastEdited}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</CardActionArea>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function BoardListView({ onOpen }) {
|
||||||
|
const { user } = useAuthContext();
|
||||||
|
const [myBoards, setMyBoards] = useState([]);
|
||||||
|
const [sharedBoards, setSharedBoards] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [students, setStudents] = useState([]);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [mine, shared] = await Promise.all([
|
||||||
|
getMyBoards().catch(() => []),
|
||||||
|
getSharedBoards().catch(() => []),
|
||||||
|
]);
|
||||||
|
setMyBoards(Array.isArray(mine) ? mine : []);
|
||||||
|
setSharedBoards(Array.isArray(shared) ? shared : []);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
// Ментор может создать доску с учеником
|
||||||
|
useEffect(() => {
|
||||||
|
if (user?.role !== 'mentor') return;
|
||||||
|
getMentorStudents()
|
||||||
|
.then((res) => {
|
||||||
|
const list = Array.isArray(res) ? res : res?.results || [];
|
||||||
|
setStudents(list);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, [user?.role]);
|
||||||
|
|
||||||
|
const handleCreateWithStudent = async (student) => {
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
const board = await getOrCreateMentorStudentBoard(user.id, student.id ?? student.user?.id ?? student);
|
||||||
|
await load();
|
||||||
|
onOpen(board.board_id);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const excalidrawAvailable = !!(
|
||||||
|
process.env.NEXT_PUBLIC_EXCALIDRAW_URL ||
|
||||||
|
process.env.NEXT_PUBLIC_EXCALIDRAW_PATH ||
|
||||||
|
process.env.NEXT_PUBLIC_EXCALIDRAW_PORT
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allBoards = [...myBoards, ...sharedBoards];
|
||||||
|
const uniqueBoards = allBoards.filter((b, i, arr) => arr.findIndex((x) => x.board_id === b.board_id) === i);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
{!excalidrawAvailable && (
|
||||||
|
<Box sx={{ mb: 3, p: 2, bgcolor: 'warning.lighter', borderRadius: 2 }}>
|
||||||
|
<Typography variant="body2" color="warning.dark">
|
||||||
|
Excalidraw не настроен. Укажите NEXT_PUBLIC_EXCALIDRAW_URL или NEXT_PUBLIC_EXCALIDRAW_PATH в .env
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Stack direction="row" alignItems="center" justifyContent="space-between" mb={3} flexWrap="wrap" gap={2}>
|
||||||
|
<Typography variant="h4">Доски</Typography>
|
||||||
|
|
||||||
|
{user?.role === 'mentor' && students.length > 0 && (
|
||||||
|
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||||
|
{students.slice(0, 6).map((s) => {
|
||||||
|
const name = getUserName(s.user || s);
|
||||||
|
const initials = getUserInitials(s.user || s);
|
||||||
|
return (
|
||||||
|
<Tooltip key={s.id} title={`Открыть доску с ${name}`}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
startIcon={<Avatar sx={{ width: 20, height: 20, fontSize: 10 }}>{initials}</Avatar>}
|
||||||
|
onClick={() => handleCreateWithStudent(s.user || s)}
|
||||||
|
disabled={creating}
|
||||||
|
>
|
||||||
|
{name.split(' ')[0]}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{uniqueBoards.length === 0 ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
py: 10,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 2,
|
||||||
|
color: 'text.secondary',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Iconify icon="solar:pen-new-square-linear" width={64} sx={{ opacity: 0.3 }} />
|
||||||
|
<Typography variant="body1">Досок пока нет</Typography>
|
||||||
|
{user?.role === 'mentor' && (
|
||||||
|
<Typography variant="body2">
|
||||||
|
Нажмите на имя ученика выше, чтобы открыть совместную доску
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Grid container spacing={2.5}>
|
||||||
|
{uniqueBoards.map((board) => (
|
||||||
|
<Grid key={board.board_id} xs={12} sm={6} md={4} lg={3}>
|
||||||
|
<BoardCard board={board} currentUser={user} onClick={() => onOpen(board.board_id)} />
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function BoardView() {
|
||||||
|
const { user } = useAuthContext();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const boardId = searchParams.get('id') || searchParams.get('board_id');
|
||||||
|
|
||||||
|
const handleOpen = (id) => {
|
||||||
|
router.push(`/dashboard/board?id=${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
router.push('/dashboard/board');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (boardId) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: {
|
||||||
|
xs: 'calc(100dvh - var(--layout-header-mobile-height))',
|
||||||
|
lg: 'calc(100dvh - var(--layout-header-desktop-height))',
|
||||||
|
},
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 2,
|
||||||
|
py: 1.5,
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconButton size="small" onClick={handleBack}>
|
||||||
|
<Iconify icon="solar:arrow-left-linear" />
|
||||||
|
</IconButton>
|
||||||
|
<Typography variant="h6">Интерактивная доска</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">#{boardId}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: 1, minHeight: 0, overflow: 'hidden' }}>
|
||||||
|
<WhiteboardIframe boardId={boardId} user={user} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardContent maxWidth="xl">
|
||||||
|
<BoardListView onOpen={handleOpen} />
|
||||||
|
</DashboardContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export { BoardView } from './board-view';
|
||||||
|
|
@ -1,39 +1,45 @@
|
||||||
|
import 'dayjs/locale/ru';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
import { z as zod } from 'zod';
|
import { z as zod } from 'zod';
|
||||||
import { useForm, Controller } from 'react-hook-form';
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useMemo, useCallback } from 'react';
|
import { useMemo, useState, useCallback } from 'react';
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import 'dayjs/locale/ru';
|
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
dayjs.locale('ru');
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
|
||||||
|
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Stack from '@mui/material/Stack';
|
import Stack from '@mui/material/Stack';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import Dialog from '@mui/material/Dialog';
|
import Dialog from '@mui/material/Dialog';
|
||||||
import Avatar from '@mui/material/Avatar';
|
import Avatar from '@mui/material/Avatar';
|
||||||
|
import Switch from '@mui/material/Switch';
|
||||||
import Tooltip from '@mui/material/Tooltip';
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
import MenuItem from '@mui/material/MenuItem';
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
import Switch from '@mui/material/Switch';
|
|
||||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
|
||||||
import IconButton from '@mui/material/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
import LoadingButton from '@mui/lab/LoadingButton';
|
import LoadingButton from '@mui/lab/LoadingButton';
|
||||||
import DialogTitle from '@mui/material/DialogTitle';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
import DialogActions from '@mui/material/DialogActions';
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
import DialogContent from '@mui/material/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import InputAdornment from '@mui/material/InputAdornment';
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
|
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||||
|
|
||||||
|
import { paths } from 'src/routes/paths';
|
||||||
|
|
||||||
|
import { createLiveKitRoom } from 'src/utils/livekit-api';
|
||||||
|
|
||||||
import { useGetStudents, useGetSubjects } from 'src/actions/calendar';
|
import { useGetStudents, useGetSubjects } from 'src/actions/calendar';
|
||||||
import { fIsAfter } from 'src/utils/format-time';
|
|
||||||
import { Iconify } from 'src/components/iconify';
|
import { Iconify } from 'src/components/iconify';
|
||||||
import { Form, Field } from 'src/components/hook-form';
|
import { Form, Field } from 'src/components/hook-form';
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
dayjs.locale('ru');
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
const DURATION_OPTIONS = [
|
const DURATION_OPTIONS = [
|
||||||
{ value: 30, label: '30 минут' },
|
{ value: 30, label: '30 минут' },
|
||||||
{ value: 45, label: '45 минут' },
|
{ value: 45, label: '45 минут' },
|
||||||
|
|
@ -51,8 +57,10 @@ export function CalendarForm({
|
||||||
onUpdateEvent,
|
onUpdateEvent,
|
||||||
onDeleteEvent,
|
onDeleteEvent,
|
||||||
}) {
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
const { students } = useGetStudents();
|
const { students } = useGetStudents();
|
||||||
const { subjects } = useGetSubjects();
|
const { subjects } = useGetSubjects();
|
||||||
|
const [joiningVideo, setJoiningVideo] = useState(false);
|
||||||
|
|
||||||
const EventSchema = zod.object({
|
const EventSchema = zod.object({
|
||||||
client: zod.union([zod.string(), zod.number()]).refine((val) => !!val, 'Выберите ученика'),
|
client: zod.union([zod.string(), zod.number()]).refine((val) => !!val, 'Выберите ученика'),
|
||||||
|
|
@ -112,12 +120,12 @@ export function CalendarForm({
|
||||||
const displayTitle = `${subjectName} ${mentorFullName} - ${studentFullName}${lessonNumber}`.trim();
|
const displayTitle = `${subjectName} ${mentorFullName} - ${studentFullName}${lessonNumber}`.trim();
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
title: displayTitle, // Добавляем обязательное поле title
|
title: displayTitle,
|
||||||
client: data.client,
|
client: data.client,
|
||||||
subject: data.subject,
|
subject: data.subject,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
start_time: startTime.toISOString(),
|
start_time: startTime.toISOString(),
|
||||||
end_time: endTime.toISOString(),
|
duration: data.duration,
|
||||||
price: data.price,
|
price: data.price,
|
||||||
is_recurring: data.is_recurring,
|
is_recurring: data.is_recurring,
|
||||||
};
|
};
|
||||||
|
|
@ -134,6 +142,21 @@ export function CalendarForm({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const onJoinVideo = useCallback(async () => {
|
||||||
|
if (!currentEvent?.id) return;
|
||||||
|
setJoiningVideo(true);
|
||||||
|
try {
|
||||||
|
const room = await createLiveKitRoom(currentEvent.id);
|
||||||
|
const token = room.access_token || room.token;
|
||||||
|
router.push(`${paths.videoCall}?token=${encodeURIComponent(token)}&lesson_id=${currentEvent.id}`);
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('LiveKit join error', e);
|
||||||
|
} finally {
|
||||||
|
setJoiningVideo(false);
|
||||||
|
}
|
||||||
|
}, [currentEvent?.id, router, onClose]);
|
||||||
|
|
||||||
const onDelete = useCallback(async () => {
|
const onDelete = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await onDeleteEvent(`${currentEvent?.id}`);
|
await onDeleteEvent(`${currentEvent?.id}`);
|
||||||
|
|
@ -270,6 +293,18 @@ export function CalendarForm({
|
||||||
|
|
||||||
<Box sx={{ flexGrow: 1 }} />
|
<Box sx={{ flexGrow: 1 }} />
|
||||||
|
|
||||||
|
{currentEvent?.id && currentEvent?.extendedProps?.status !== 'completed' && (
|
||||||
|
<LoadingButton
|
||||||
|
variant="soft"
|
||||||
|
color="success"
|
||||||
|
loading={joiningVideo}
|
||||||
|
onClick={onJoinVideo}
|
||||||
|
startIcon={<Iconify icon="solar:videocamera-record-bold" />}
|
||||||
|
>
|
||||||
|
Войти
|
||||||
|
</LoadingButton>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button variant="outlined" color="inherit" onClick={onClose}>
|
<Button variant="outlined" color="inherit" onClick={onClose}>
|
||||||
Отмена
|
Отмена
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -74,12 +74,6 @@ export function CalendarToolbar({
|
||||||
<Button size="small" color="error" variant="contained" onClick={onToday}>
|
<Button size="small" color="error" variant="contained" onClick={onToday}>
|
||||||
Сегодня
|
Сегодня
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<IconButton onClick={onOpenFilters}>
|
|
||||||
<Badge color="error" variant="dot" invisible={!canReset}>
|
|
||||||
<Iconify icon="ic:round-filter-list" />
|
|
||||||
</Badge>
|
|
||||||
</IconButton>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import Container from '@mui/material/Container';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
|
|
||||||
import { paths } from 'src/routes/paths';
|
import { paths } from 'src/routes/paths';
|
||||||
|
import { useAuthContext } from 'src/auth/hooks';
|
||||||
import { useBoolean } from 'src/hooks/use-boolean';
|
import { useBoolean } from 'src/hooks/use-boolean';
|
||||||
import { useGetEvents, updateEvent, createEvent, deleteEvent } from 'src/actions/calendar';
|
import { useGetEvents, updateEvent, createEvent, deleteEvent } from 'src/actions/calendar';
|
||||||
|
|
||||||
|
|
@ -27,86 +28,49 @@ import { useSettingsContext } from 'src/components/settings';
|
||||||
import { CalendarForm } from '../calendar-form';
|
import { CalendarForm } from '../calendar-form';
|
||||||
import { StyledCalendar } from '../styles';
|
import { StyledCalendar } from '../styles';
|
||||||
import { CalendarToolbar } from '../calendar-toolbar';
|
import { CalendarToolbar } from '../calendar-toolbar';
|
||||||
import { CalendarFilters } from '../calendar-filters';
|
|
||||||
import { useCalendar } from '../hooks/use-calendar';
|
import { useCalendar } from '../hooks/use-calendar';
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
export function CalendarView() {
|
export function CalendarView() {
|
||||||
const settings = useSettingsContext();
|
const settings = useSettingsContext();
|
||||||
|
const { user } = useAuthContext();
|
||||||
|
const isMentor = user?.role === 'mentor';
|
||||||
|
|
||||||
const { events, eventsLoading } = useGetEvents();
|
const { calendarRef, view, date, onDatePrev, onDateNext, onDateToday, onChangeView,
|
||||||
|
onSelectRange, onClickEvent, onResizeEvent, onDropEvent, onInitialView,
|
||||||
|
openForm, onOpenForm, onCloseForm, selectEventId, selectedRange } = useCalendar();
|
||||||
|
|
||||||
const {
|
const { events, eventsLoading } = useGetEvents(date);
|
||||||
calendarRef,
|
|
||||||
view,
|
|
||||||
date,
|
|
||||||
onDatePrev,
|
|
||||||
onDateNext,
|
|
||||||
onDateToday,
|
|
||||||
onChangeView,
|
|
||||||
onSelectRange,
|
|
||||||
onClickEvent,
|
|
||||||
onResizeEvent,
|
|
||||||
onDropEvent,
|
|
||||||
onInitialView,
|
|
||||||
openForm,
|
|
||||||
onOpenForm,
|
|
||||||
onCloseForm,
|
|
||||||
selectEventId,
|
|
||||||
selectedRange,
|
|
||||||
onClickEventInFilters,
|
|
||||||
} = useCalendar();
|
|
||||||
|
|
||||||
const openFilters = useBoolean();
|
|
||||||
|
|
||||||
const [filters, setFilters] = useState({
|
|
||||||
colors: [],
|
|
||||||
startDate: null,
|
|
||||||
endDate: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const dateError = filters.startDate && filters.endDate ? filters.startDate > filters.endDate : false;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onInitialView();
|
onInitialView();
|
||||||
}, [onInitialView]);
|
}, [onInitialView]);
|
||||||
|
|
||||||
const handleFilters = useCallback((name, value) => {
|
const handleCreateEvent = useCallback(
|
||||||
setFilters((prevState) => ({
|
(eventData) => createEvent(eventData, date),
|
||||||
...prevState,
|
[date]
|
||||||
[name]: value,
|
);
|
||||||
}));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleResetFilters = useCallback(() => {
|
const handleUpdateEvent = useCallback(
|
||||||
setFilters({
|
(eventData) => updateEvent(eventData, date),
|
||||||
colors: [],
|
[date]
|
||||||
startDate: null,
|
);
|
||||||
endDate: null,
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const dataPrepared = events.filter((event) => {
|
const handleDeleteEvent = useCallback(
|
||||||
const { colors, startDate, endDate } = filters;
|
(eventId, deleteAllFuture) => deleteEvent(eventId, deleteAllFuture, date),
|
||||||
|
[date]
|
||||||
if (colors.length && !colors.includes(event.color)) {
|
);
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startDate && endDate) {
|
|
||||||
const eventStart = new Date(event.start);
|
|
||||||
const eventEnd = new Date(event.end);
|
|
||||||
return eventStart >= startDate && eventEnd <= endDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Container maxWidth={settings.themeStretch ? false : 'xl'}>
|
<Container maxWidth={settings.themeStretch ? false : 'xl'}>
|
||||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: { xs: 3, md: 5 } }}>
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="space-between"
|
||||||
|
sx={{ mb: { xs: 3, md: 5 } }}
|
||||||
|
>
|
||||||
<Stack spacing={1}>
|
<Stack spacing={1}>
|
||||||
<Typography variant="h4">Расписание</Typography>
|
<Typography variant="h4">Расписание</Typography>
|
||||||
<CustomBreadcrumbs
|
<CustomBreadcrumbs
|
||||||
|
|
@ -117,13 +81,15 @@ export function CalendarView() {
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Button
|
{isMentor && (
|
||||||
variant="contained"
|
<Button
|
||||||
startIcon={<Iconify icon="mingcute:add-line" />}
|
variant="contained"
|
||||||
onClick={() => onOpenForm()}
|
startIcon={<Iconify icon="mingcute:add-line" />}
|
||||||
>
|
onClick={() => onOpenForm()}
|
||||||
Новое занятие
|
>
|
||||||
</Button>
|
Новое занятие
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Card sx={{ position: 'relative' }}>
|
<Card sx={{ position: 'relative' }}>
|
||||||
|
|
@ -136,16 +102,15 @@ export function CalendarView() {
|
||||||
onPrevDate={onDatePrev}
|
onPrevDate={onDatePrev}
|
||||||
onToday={onDateToday}
|
onToday={onDateToday}
|
||||||
onChangeView={onChangeView}
|
onChangeView={onChangeView}
|
||||||
onOpenFilters={openFilters.onTrue}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FullCalendar
|
<FullCalendar
|
||||||
weekends
|
weekends
|
||||||
editable
|
editable={isMentor}
|
||||||
droppable
|
droppable={isMentor}
|
||||||
selectable
|
selectable={isMentor}
|
||||||
rerenderEvents
|
rerenderEvents
|
||||||
events={dataPrepared}
|
events={events}
|
||||||
ref={calendarRef}
|
ref={calendarRef}
|
||||||
initialDate={date}
|
initialDate={date}
|
||||||
initialView={view}
|
initialView={view}
|
||||||
|
|
@ -154,18 +119,12 @@ export function CalendarView() {
|
||||||
headerToolbar={false}
|
headerToolbar={false}
|
||||||
allDayMaintainDuration
|
allDayMaintainDuration
|
||||||
eventResizableFromStart
|
eventResizableFromStart
|
||||||
displayEventTime={false} // Скрываем дефолтное время, так как мы добавили его в title
|
displayEventTime={false}
|
||||||
select={onSelectRange}
|
select={isMentor ? onSelectRange : undefined}
|
||||||
eventClick={onClickEvent}
|
eventClick={onClickEvent}
|
||||||
eventDrop={onDropEvent}
|
eventDrop={isMentor ? onDropEvent : undefined}
|
||||||
eventResize={onResizeEvent}
|
eventResize={isMentor ? onResizeEvent : undefined}
|
||||||
plugins={[
|
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin, listPlugin, timelinePlugin]}
|
||||||
dayGridPlugin,
|
|
||||||
timeGridPlugin,
|
|
||||||
interactionPlugin,
|
|
||||||
listPlugin,
|
|
||||||
timelinePlugin,
|
|
||||||
]}
|
|
||||||
locale={ruLocale}
|
locale={ruLocale}
|
||||||
slotLabelFormat={{ hour: '2-digit', minute: '2-digit', hour12: false }}
|
slotLabelFormat={{ hour: '2-digit', minute: '2-digit', hour12: false }}
|
||||||
eventTimeFormat={{ hour: '2-digit', minute: '2-digit', hour12: false }}
|
eventTimeFormat={{ hour: '2-digit', minute: '2-digit', hour12: false }}
|
||||||
|
|
@ -174,27 +133,17 @@ export function CalendarView() {
|
||||||
</Card>
|
</Card>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
<CalendarForm
|
{isMentor && (
|
||||||
currentEvent={events.find((event) => event.id === selectEventId)}
|
<CalendarForm
|
||||||
range={selectedRange}
|
currentEvent={events.find((event) => event.id === selectEventId)}
|
||||||
open={openForm}
|
range={selectedRange}
|
||||||
onClose={onCloseForm}
|
open={openForm}
|
||||||
onCreateEvent={createEvent}
|
onClose={onCloseForm}
|
||||||
onUpdateEvent={updateEvent}
|
onCreateEvent={handleCreateEvent}
|
||||||
onDeleteEvent={deleteEvent}
|
onUpdateEvent={handleUpdateEvent}
|
||||||
/>
|
onDeleteEvent={handleDeleteEvent}
|
||||||
|
/>
|
||||||
<CalendarFilters
|
)}
|
||||||
open={openFilters.value}
|
|
||||||
onClose={openFilters.onFalse}
|
|
||||||
filters={filters}
|
|
||||||
onFilters={handleFilters}
|
|
||||||
canReset={!!filters.colors.length || (!!filters.startDate && !!filters.endDate)}
|
|
||||||
onResetFilters={handleResetFilters}
|
|
||||||
dateError={dateError}
|
|
||||||
events={events}
|
|
||||||
onClickEvent={onClickEventInFilters}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,737 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRef, useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import List from '@mui/material/List';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
|
import Alert from '@mui/material/Alert';
|
||||||
|
import Badge from '@mui/material/Badge';
|
||||||
|
import Avatar from '@mui/material/Avatar';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import Dialog from '@mui/material/Dialog';
|
||||||
|
import Divider from '@mui/material/Divider';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
|
import ListItemButton from '@mui/material/ListItemButton';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
|
||||||
|
import { paths } from 'src/routes/paths';
|
||||||
|
|
||||||
|
import { useChatWebSocket } from 'src/hooks/use-chat-websocket';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createChat,
|
||||||
|
getMessages,
|
||||||
|
searchUsers,
|
||||||
|
sendMessage,
|
||||||
|
getConversations,
|
||||||
|
markMessagesAsRead,
|
||||||
|
getChatMessagesByUuid,
|
||||||
|
} from 'src/utils/chat-api';
|
||||||
|
|
||||||
|
import { DashboardContent } from 'src/layouts/dashboard';
|
||||||
|
|
||||||
|
import { Iconify } from 'src/components/iconify';
|
||||||
|
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
|
||||||
|
|
||||||
|
import { useAuthContext } from 'src/auth/hooks';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function formatTime(ts) {
|
||||||
|
try {
|
||||||
|
if (!ts) return '';
|
||||||
|
const d = new Date(ts);
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateKey(ts) {
|
||||||
|
if (!ts) return '';
|
||||||
|
const d = new Date(ts);
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDayHeader(ts) {
|
||||||
|
if (!ts) return '';
|
||||||
|
const d = new Date(ts);
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
const now = new Date();
|
||||||
|
const todayKey = dateKey(now.toISOString());
|
||||||
|
const yKey = dateKey(new Date(now.getTime() - 86400000).toISOString());
|
||||||
|
const k = dateKey(ts);
|
||||||
|
if (k === todayKey) return 'Сегодня';
|
||||||
|
if (k === yKey) return 'Вчера';
|
||||||
|
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitials(name) {
|
||||||
|
return (name || 'Ч')
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((p) => p[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripHtml(s) {
|
||||||
|
if (typeof s !== 'string') return '';
|
||||||
|
return s.replace(/<[^>]*>/g, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function NewChatDialog({ open, onClose, onCreated }) {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [results, setResults] = useState([]);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const handleSearch = useCallback(async (q) => {
|
||||||
|
if (!q.trim()) {
|
||||||
|
setResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setSearching(true);
|
||||||
|
const users = await searchUsers(q.trim());
|
||||||
|
setResults(users);
|
||||||
|
} catch {
|
||||||
|
setResults([]);
|
||||||
|
} finally {
|
||||||
|
setSearching(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => handleSearch(query), 400);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [query, handleSearch]);
|
||||||
|
|
||||||
|
const handleCreate = async (userId) => {
|
||||||
|
try {
|
||||||
|
setCreating(true);
|
||||||
|
setError(null);
|
||||||
|
const chat = await createChat(userId);
|
||||||
|
onCreated(chat);
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e?.response?.data?.detail || e?.message || 'Ошибка создания чата');
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setQuery('');
|
||||||
|
setResults([]);
|
||||||
|
setError(null);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>Новый чат</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
label="Поиск пользователя"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
autoFocus
|
||||||
|
sx={{ mt: 1 }}
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: searching ? (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<CircularProgress size={18} />
|
||||||
|
</InputAdornment>
|
||||||
|
) : null,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mt: 1 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<List sx={{ mt: 1 }}>
|
||||||
|
{results.map((u) => {
|
||||||
|
const name = `${u.first_name || ''} ${u.last_name || ''}`.trim() || u.email || '';
|
||||||
|
return (
|
||||||
|
<ListItemButton
|
||||||
|
key={u.id}
|
||||||
|
onClick={() => handleCreate(u.id)}
|
||||||
|
disabled={creating}
|
||||||
|
sx={{ borderRadius: 1 }}
|
||||||
|
>
|
||||||
|
<Avatar sx={{ mr: 1.5, width: 36, height: 36 }}>{getInitials(name)}</Avatar>
|
||||||
|
<ListItemText primary={name} secondary={u.email} />
|
||||||
|
</ListItemButton>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!searching && query.trim() && results.length === 0 && (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ px: 2, py: 1 }}>
|
||||||
|
Пользователи не найдены
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleClose}>Отмена</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function ChatList({ chats, selectedUuid, onSelect, onNew, loading }) {
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
|
||||||
|
const filtered = chats.filter((c) => {
|
||||||
|
if (!q.trim()) return true;
|
||||||
|
const qq = q.toLowerCase();
|
||||||
|
return (
|
||||||
|
(c.participant_name || '').toLowerCase().includes(qq) ||
|
||||||
|
(c.last_message || '').toLowerCase().includes(qq)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: { xs: '100%', md: 300 },
|
||||||
|
flexShrink: 0,
|
||||||
|
borderRight: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ p: 1.5, borderBottom: '1px solid', borderColor: 'divider' }}>
|
||||||
|
<TextField
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder="Поиск"
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<Iconify icon="eva:search-outline" width={18} />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton onClick={onNew} color="primary" size="small">
|
||||||
|
<Iconify icon="eva:edit-outline" width={22} />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Box sx={{ flex: 1, overflowY: 'auto' }}>
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||||
|
<CircularProgress size={28} />
|
||||||
|
</Box>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ p: 2 }}>
|
||||||
|
{q ? 'Не найдено' : 'Нет чатов'}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<List disablePadding>
|
||||||
|
{filtered.map((chat) => {
|
||||||
|
const selected = !!selectedUuid && chat.uuid === selectedUuid;
|
||||||
|
return (
|
||||||
|
<ListItemButton
|
||||||
|
key={chat.uuid || chat.id}
|
||||||
|
selected={selected}
|
||||||
|
onClick={() => onSelect(chat)}
|
||||||
|
sx={{ py: 1.25, px: 1.5 }}
|
||||||
|
>
|
||||||
|
<Avatar sx={{ mr: 1.5, width: 38, height: 38 }}>
|
||||||
|
{getInitials(chat.participant_name)}
|
||||||
|
</Avatar>
|
||||||
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||||
|
<Typography variant="subtitle2" noWrap sx={{ maxWidth: 140 }}>
|
||||||
|
{chat.participant_name || 'Чат'}
|
||||||
|
</Typography>
|
||||||
|
{!!chat.unread_count && (
|
||||||
|
<Badge
|
||||||
|
badgeContent={chat.unread_count}
|
||||||
|
color="primary"
|
||||||
|
sx={{ '& .MuiBadge-badge': { fontSize: 10, minWidth: 16, height: 16 } }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
secondary={
|
||||||
|
<Typography variant="caption" noWrap color="text.secondary">
|
||||||
|
{stripHtml(chat.last_message || '')}
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
primaryTypographyProps={{ component: 'div' }}
|
||||||
|
secondaryTypographyProps={{ component: 'div' }}
|
||||||
|
/>
|
||||||
|
</ListItemButton>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function ChatWindow({ chat, currentUserId, onBack }) {
|
||||||
|
const [messages, setMessages] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const listRef = useRef(null);
|
||||||
|
const markedRef = useRef(new Set());
|
||||||
|
const lastSentRef = useRef(null);
|
||||||
|
const lastWheelUpRef = useRef(0);
|
||||||
|
|
||||||
|
const chatUuid = chat?.uuid || null;
|
||||||
|
|
||||||
|
useChatWebSocket({
|
||||||
|
chatUuid,
|
||||||
|
enabled: !!chatUuid,
|
||||||
|
onMessage: (m) => {
|
||||||
|
const chatId = chat?.id != null ? Number(chat.id) : null;
|
||||||
|
const msgChatId = m.chat != null ? Number(m.chat) : null;
|
||||||
|
if (chatId == null || msgChatId !== chatId) return;
|
||||||
|
const mid = m.id;
|
||||||
|
const muuid = m.uuid;
|
||||||
|
const sent = lastSentRef.current;
|
||||||
|
if (
|
||||||
|
sent &&
|
||||||
|
(String(mid) === String(sent.id) ||
|
||||||
|
(muuid != null && sent.uuid != null && String(muuid) === String(sent.uuid)))
|
||||||
|
) {
|
||||||
|
lastSentRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMessages((prev) => {
|
||||||
|
const isDuplicate = prev.some((x) => {
|
||||||
|
const sameId = mid != null && x.id != null && String(x.id) === String(mid);
|
||||||
|
const sameUuid = muuid != null && x.uuid != null && String(x.uuid) === String(muuid);
|
||||||
|
return sameId || sameUuid;
|
||||||
|
});
|
||||||
|
if (isDuplicate) return prev;
|
||||||
|
return [...prev, m];
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!chat) return undefined;
|
||||||
|
setLoading(true);
|
||||||
|
setPage(1);
|
||||||
|
setHasMore(false);
|
||||||
|
markedRef.current = new Set();
|
||||||
|
lastSentRef.current = null;
|
||||||
|
|
||||||
|
const fetchMessages = async () => {
|
||||||
|
try {
|
||||||
|
const PAGE_SIZE = 30;
|
||||||
|
const resp = chatUuid
|
||||||
|
? await getChatMessagesByUuid(chatUuid, { page: 1, page_size: PAGE_SIZE })
|
||||||
|
: await getMessages(chat.id, { page: 1, page_size: PAGE_SIZE });
|
||||||
|
const sorted = (resp.results || []).slice().sort((a, b) => {
|
||||||
|
const ta = a.created_at ? new Date(a.created_at).getTime() : 0;
|
||||||
|
const tb = b.created_at ? new Date(b.created_at).getTime() : 0;
|
||||||
|
return ta - tb;
|
||||||
|
});
|
||||||
|
setMessages(sorted);
|
||||||
|
setHasMore(!!resp.next || (resp.count ?? 0) > sorted.length);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = listRef.current;
|
||||||
|
if (el) el.scrollTop = el.scrollHeight;
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchMessages();
|
||||||
|
return undefined;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [chat?.id, chatUuid]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!chatUuid || !listRef.current || messages.length === 0) return undefined;
|
||||||
|
const container = listRef.current;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const toMark = [];
|
||||||
|
entries.forEach((e) => {
|
||||||
|
if (!e.isIntersecting) return;
|
||||||
|
const uuid = e.target.getAttribute('data-message-uuid');
|
||||||
|
const isMine = e.target.getAttribute('data-is-mine') === 'true';
|
||||||
|
if (uuid && !isMine && !markedRef.current.has(uuid)) {
|
||||||
|
toMark.push(uuid);
|
||||||
|
markedRef.current.add(uuid);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (toMark.length > 0) {
|
||||||
|
markMessagesAsRead(chatUuid, toMark).catch(() => {});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ root: container, threshold: 0.5 }
|
||||||
|
);
|
||||||
|
container.querySelectorAll('[data-message-uuid]').forEach((n) => observer.observe(n));
|
||||||
|
return () => observer.disconnect();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [chatUuid, messages]);
|
||||||
|
|
||||||
|
const loadOlder = useCallback(async () => {
|
||||||
|
if (!chat || loading || loadingMore || !hasMore) return;
|
||||||
|
const container = listRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
setLoadingMore(true);
|
||||||
|
const prevScrollHeight = container.scrollHeight;
|
||||||
|
const prevScrollTop = container.scrollTop;
|
||||||
|
try {
|
||||||
|
const nextPage = page + 1;
|
||||||
|
const PAGE_SIZE = 30;
|
||||||
|
const resp = chatUuid
|
||||||
|
? await getChatMessagesByUuid(chatUuid, { page: nextPage, page_size: PAGE_SIZE })
|
||||||
|
: await getMessages(chat.id, { page: nextPage, page_size: PAGE_SIZE });
|
||||||
|
const batch = (resp.results || []).slice().sort((a, b) => {
|
||||||
|
const ta = a.created_at ? new Date(a.created_at).getTime() : 0;
|
||||||
|
const tb = b.created_at ? new Date(b.created_at).getTime() : 0;
|
||||||
|
return ta - tb;
|
||||||
|
});
|
||||||
|
setMessages((prev) => {
|
||||||
|
const keys = new Set(prev.map((m) => m.uuid || m.id));
|
||||||
|
const toAdd = batch.filter((m) => !keys.has(m.uuid || m.id));
|
||||||
|
return [...toAdd, ...prev].sort((a, b) => {
|
||||||
|
const ta = a.created_at ? new Date(a.created_at).getTime() : 0;
|
||||||
|
const tb = b.created_at ? new Date(b.created_at).getTime() : 0;
|
||||||
|
return ta - tb;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
setPage(nextPage);
|
||||||
|
setHasMore(!!resp.next);
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => {
|
||||||
|
const c = listRef.current;
|
||||||
|
if (!c) return;
|
||||||
|
c.scrollTop = prevScrollTop + (c.scrollHeight - prevScrollHeight);
|
||||||
|
}, 0);
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
}, [chat, chatUuid, hasMore, loading, loadingMore, page]);
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!chat || !text.trim() || sending) return;
|
||||||
|
const content = text.trim();
|
||||||
|
setText('');
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
const msg = await sendMessage(chat.id, content);
|
||||||
|
lastSentRef.current = { id: msg.id, uuid: msg.uuid };
|
||||||
|
const safeMsg = { ...msg, created_at: msg.created_at || new Date().toISOString() };
|
||||||
|
setMessages((prev) => [...prev, safeMsg]);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = listRef.current;
|
||||||
|
if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setText(content);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!chat) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'text.secondary',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography>Выберите чат из списка</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set();
|
||||||
|
const uniqueMessages = messages.filter((m) => {
|
||||||
|
const k = String(m.uuid ?? m.id ?? '');
|
||||||
|
if (!k || seen.has(k)) return false;
|
||||||
|
seen.add(k);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const grouped = [];
|
||||||
|
let prevDay = '';
|
||||||
|
uniqueMessages.forEach((m, idx) => {
|
||||||
|
const day = dateKey(m.created_at);
|
||||||
|
if (day && day !== prevDay) {
|
||||||
|
grouped.push({ type: 'day', key: `day-${day}`, label: formatDayHeader(m.created_at) });
|
||||||
|
prevDay = day;
|
||||||
|
}
|
||||||
|
const senderId = m.sender_id ?? (typeof m.sender === 'number' ? m.sender : m.sender?.id ?? null);
|
||||||
|
const isMine = !!currentUserId && senderId === currentUserId;
|
||||||
|
const isSystem =
|
||||||
|
m.message_type === 'system' ||
|
||||||
|
(typeof m.sender === 'string' && m.sender.toLowerCase() === 'system') ||
|
||||||
|
(!senderId && m.sender_name === 'System');
|
||||||
|
grouped.push({
|
||||||
|
type: 'msg',
|
||||||
|
key: m.uuid || m.id || `msg-${idx}`,
|
||||||
|
msg: m,
|
||||||
|
isMine,
|
||||||
|
isSystem,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
spacing={1.5}
|
||||||
|
sx={{ px: 2, py: 1.5, borderBottom: '1px solid', borderColor: 'divider', flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{onBack && (
|
||||||
|
<IconButton onClick={onBack} size="small" sx={{ display: { md: 'none' } }}>
|
||||||
|
<Iconify icon="eva:arrow-back-outline" />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
<Avatar sx={{ width: 38, height: 38 }}>{getInitials(chat.participant_name)}</Avatar>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2">{chat.participant_name || 'Чат'}</Typography>
|
||||||
|
{chat.other_is_online && (
|
||||||
|
<Typography variant="caption" color="success.main">
|
||||||
|
Онлайн
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<Box
|
||||||
|
ref={listRef}
|
||||||
|
sx={{ flex: 1, overflowY: 'auto', px: 2, py: 1, display: 'flex', flexDirection: 'column', gap: 0.75 }}
|
||||||
|
onWheel={(e) => {
|
||||||
|
if (e.deltaY < 0) lastWheelUpRef.current = Date.now();
|
||||||
|
}}
|
||||||
|
onScroll={(e) => {
|
||||||
|
const el = e.currentTarget;
|
||||||
|
if (el.scrollTop < 40 && Date.now() - lastWheelUpRef.current < 200) loadOlder();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loadingMore && (
|
||||||
|
<Typography variant="caption" color="text.secondary" textAlign="center">
|
||||||
|
Загрузка…
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||||
|
<CircularProgress size={28} />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
grouped.map((item) => {
|
||||||
|
if (item.type === 'day') {
|
||||||
|
return (
|
||||||
|
<Box key={item.key} sx={{ alignSelf: 'center', my: 0.5 }}>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
px: 1.5,
|
||||||
|
py: 0.4,
|
||||||
|
borderRadius: 999,
|
||||||
|
bgcolor: 'background.neutral',
|
||||||
|
color: 'text.secondary',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { msg, isMine, isSystem } = item;
|
||||||
|
const msgUuid = msg.uuid ? String(msg.uuid) : null;
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={item.key}
|
||||||
|
data-message-uuid={msgUuid || undefined}
|
||||||
|
data-is-mine={String(isMine)}
|
||||||
|
sx={{
|
||||||
|
alignSelf: isSystem ? 'center' : isMine ? 'flex-end' : 'flex-start',
|
||||||
|
maxWidth: isSystem ? '85%' : '70%',
|
||||||
|
px: 1.5,
|
||||||
|
py: 0.75,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: isSystem
|
||||||
|
? 'action.hover'
|
||||||
|
: isMine
|
||||||
|
? 'primary.main'
|
||||||
|
: 'background.paper',
|
||||||
|
color: isSystem ? 'text.secondary' : isMine ? 'primary.contrastText' : 'text.primary',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: isSystem ? 'divider' : isMine ? 'transparent' : 'divider',
|
||||||
|
boxShadow: isMine ? 0 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||||
|
{stripHtml(msg.content || '')}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ display: 'block', textAlign: 'right', opacity: 0.6, mt: 0.25 }}
|
||||||
|
>
|
||||||
|
{formatTime(msg.created_at)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<Divider />
|
||||||
|
<Stack direction="row" spacing={1} sx={{ p: 1.5, flexShrink: 0 }}>
|
||||||
|
<TextField
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
placeholder="Сообщение…"
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={1}
|
||||||
|
maxRows={4}
|
||||||
|
size="small"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={!text.trim() || sending}
|
||||||
|
color="primary"
|
||||||
|
sx={{ alignSelf: 'flex-end' }}
|
||||||
|
>
|
||||||
|
{sending ? <CircularProgress size={20} /> : <Iconify icon="eva:paper-plane-outline" />}
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function ChatPlatformView() {
|
||||||
|
const { user } = useAuthContext();
|
||||||
|
const [chats, setChats] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedChat, setSelectedChat] = useState(null);
|
||||||
|
const [newChatOpen, setNewChatOpen] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const mobileShowWindow = !!selectedChat;
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await getConversations({ page_size: 50 });
|
||||||
|
setChats(res.results);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e?.response?.data?.detail || e?.message || 'Ошибка загрузки чатов');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const handleChatCreated = (chat) => {
|
||||||
|
setChats((prev) => {
|
||||||
|
const exists = prev.some((c) => c.uuid === chat.uuid || c.id === chat.id);
|
||||||
|
if (exists) return prev;
|
||||||
|
return [chat, ...prev];
|
||||||
|
});
|
||||||
|
setSelectedChat(chat);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardContent sx={{ p: 0, display: 'flex', flexDirection: 'column', flex: 1 }} maxWidth={false} disablePadding>
|
||||||
|
<Box sx={{ px: 3, py: 2, borderBottom: '1px solid', borderColor: 'divider' }}>
|
||||||
|
<CustomBreadcrumbs
|
||||||
|
heading="Чат"
|
||||||
|
links={[{ name: 'Главная', href: paths.dashboard.root }, { name: 'Чат' }]}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mx: 3, mt: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flex: 1, minHeight: 0, height: 'calc(100vh - 180px)' }}>
|
||||||
|
<Box sx={{ display: { xs: mobileShowWindow ? 'none' : 'flex', md: 'flex' }, height: '100%' }}>
|
||||||
|
<ChatList
|
||||||
|
chats={chats}
|
||||||
|
selectedUuid={selectedChat?.uuid}
|
||||||
|
onSelect={setSelectedChat}
|
||||||
|
onNew={() => setNewChatOpen(true)}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
display: { xs: mobileShowWindow ? 'flex' : 'none', md: 'flex' },
|
||||||
|
flexDirection: 'column',
|
||||||
|
minHeight: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChatWindow
|
||||||
|
chat={selectedChat}
|
||||||
|
currentUserId={user?.id || null}
|
||||||
|
onBack={() => setSelectedChat(null)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<NewChatDialog
|
||||||
|
open={newChatOpen}
|
||||||
|
onClose={() => setNewChatOpen(false)}
|
||||||
|
onCreated={handleChatCreated}
|
||||||
|
/>
|
||||||
|
</DashboardContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
export * from './chat-view';
|
export * from './chat-view';
|
||||||
|
export { ChatPlatformView } from './chat-platform-view';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,180 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Card from '@mui/material/Card';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
|
import Alert from '@mui/material/Alert';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import CardContent from '@mui/material/CardContent';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
|
||||||
|
import { paths } from 'src/routes/paths';
|
||||||
|
|
||||||
|
import axios from 'src/utils/axios';
|
||||||
|
|
||||||
|
import { DashboardContent } from 'src/layouts/dashboard';
|
||||||
|
|
||||||
|
import { Iconify } from 'src/components/iconify';
|
||||||
|
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function getChildProgress(childId) {
|
||||||
|
const params = childId ? `?child_id=${childId}` : '';
|
||||||
|
const res = await axios.get(`/users/student-progress/${params}`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function StatCard({ label, value, icon, color }) {
|
||||||
|
return (
|
||||||
|
<Card variant="outlined" sx={{ flex: 1, minWidth: 140 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 1 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
bgcolor: `${color}.lighter`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Iconify icon={icon} width={22} color={`${color}.main`} />
|
||||||
|
</Box>
|
||||||
|
<Typography variant="h5" fontWeight="bold">
|
||||||
|
{value ?? 0}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function ChildrenProgressView() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const childId = searchParams.get('child');
|
||||||
|
|
||||||
|
const [progress, setProgress] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!childId) return;
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await getChildProgress(childId);
|
||||||
|
setProgress(data);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e?.response?.data?.detail || e?.message || 'Ошибка загрузки прогресса');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [childId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
if (!childId) {
|
||||||
|
return (
|
||||||
|
<DashboardContent>
|
||||||
|
<CustomBreadcrumbs
|
||||||
|
heading="Прогресс ребёнка"
|
||||||
|
links={[
|
||||||
|
{ name: 'Главная', href: paths.dashboard.root },
|
||||||
|
{ name: 'Мои дети', href: paths.dashboard.children },
|
||||||
|
{ name: 'Прогресс' },
|
||||||
|
]}
|
||||||
|
sx={{ mb: 3 }}
|
||||||
|
/>
|
||||||
|
<Box sx={{ textAlign: 'center', py: 8 }}>
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
Выберите ребёнка для просмотра прогресса
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ mt: 2 }}
|
||||||
|
onClick={() => router.push(paths.dashboard.children)}
|
||||||
|
>
|
||||||
|
К списку детей
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</DashboardContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardContent>
|
||||||
|
<CustomBreadcrumbs
|
||||||
|
heading="Прогресс ребёнка"
|
||||||
|
links={[
|
||||||
|
{ name: 'Главная', href: paths.dashboard.root },
|
||||||
|
{ name: 'Мои дети', href: paths.dashboard.children },
|
||||||
|
{ name: 'Прогресс' },
|
||||||
|
]}
|
||||||
|
sx={{ mb: 3 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 3 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : progress ? (
|
||||||
|
<Stack direction="row" flexWrap="wrap" gap={2}>
|
||||||
|
<StatCard
|
||||||
|
label="Завершено занятий"
|
||||||
|
value={progress.completed_lessons}
|
||||||
|
icon="eva:checkmark-circle-outline"
|
||||||
|
color="success"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Выполнено заданий"
|
||||||
|
value={progress.completed_homework}
|
||||||
|
icon="eva:book-outline"
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
{progress.attendance_rate !== undefined && (
|
||||||
|
<StatCard
|
||||||
|
label="Посещаемость"
|
||||||
|
value={`${Math.round(progress.attendance_rate)}%`}
|
||||||
|
icon="eva:calendar-outline"
|
||||||
|
color="info"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{progress.avg_grade !== undefined && progress.avg_grade > 0 && (
|
||||||
|
<StatCard
|
||||||
|
label="Средняя оценка"
|
||||||
|
value={progress.avg_grade}
|
||||||
|
icon="eva:star-outline"
|
||||||
|
color="warning"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
Нет данных о прогрессе
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</DashboardContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Card from '@mui/material/Card';
|
||||||
|
import Grid from '@mui/material/Grid';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
|
import Alert from '@mui/material/Alert';
|
||||||
|
import Avatar from '@mui/material/Avatar';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import CardContent from '@mui/material/CardContent';
|
||||||
|
import CardActions from '@mui/material/CardActions';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
|
||||||
|
import { paths } from 'src/routes/paths';
|
||||||
|
|
||||||
|
import axios from 'src/utils/axios';
|
||||||
|
|
||||||
|
import { DashboardContent } from 'src/layouts/dashboard';
|
||||||
|
|
||||||
|
import { Iconify } from 'src/components/iconify';
|
||||||
|
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function getChildren() {
|
||||||
|
const res = await axios.get('/users/parents/children/');
|
||||||
|
const {data} = res;
|
||||||
|
if (Array.isArray(data)) return data;
|
||||||
|
return data?.results ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function ChildrenView() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [children, setChildren] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const list = await getChildren();
|
||||||
|
setChildren(list);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e?.response?.data?.detail || e?.message || 'Ошибка загрузки');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardContent>
|
||||||
|
<CustomBreadcrumbs
|
||||||
|
heading="Мои дети"
|
||||||
|
links={[{ name: 'Главная', href: paths.dashboard.root }, { name: 'Мои дети' }]}
|
||||||
|
sx={{ mb: 3 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 3 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : children.length === 0 ? (
|
||||||
|
<Box sx={{ textAlign: 'center', py: 8 }}>
|
||||||
|
<Iconify icon="eva:people-outline" width={64} color="text.disabled" />
|
||||||
|
<Typography variant="body1" color="text.secondary" sx={{ mt: 2 }}>
|
||||||
|
Нет привязанных детей
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{children.map((child) => {
|
||||||
|
const name = `${child.first_name || ''} ${child.last_name || ''}`.trim() || child.email;
|
||||||
|
return (
|
||||||
|
<Grid key={child.id} item xs={12} sm={6} md={4}>
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardContent>
|
||||||
|
<Stack direction="row" spacing={2} alignItems="center">
|
||||||
|
<Avatar sx={{ width: 48, height: 48, bgcolor: 'primary.lighter', color: 'primary.main' }}>
|
||||||
|
{name[0]?.toUpperCase()}
|
||||||
|
</Avatar>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle1">{name}</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{child.email}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
<CardActions sx={{ px: 2, pb: 2 }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
fullWidth
|
||||||
|
startIcon={<Iconify icon="eva:trending-up-outline" />}
|
||||||
|
onClick={() => router.push(`${paths.dashboard.childrenProgress}?child=${child.id}`)}
|
||||||
|
>
|
||||||
|
Прогресс
|
||||||
|
</Button>
|
||||||
|
</CardActions>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</DashboardContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { ChildrenView } from './children-view';
|
||||||
|
export { ChildrenProgressView } from './children-progress-view';
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { useState } from 'react';
|
import { lazy, Suspense, useState } from 'react';
|
||||||
import dynamic from 'next/dynamic';
|
|
||||||
|
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Skeleton from '@mui/material/Skeleton';
|
import Skeleton from '@mui/material/Skeleton';
|
||||||
|
|
@ -11,14 +10,7 @@ import { MapPopup, MapMarker, MapControl } from 'src/components/map';
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
const Map = dynamic(() => import('src/components/map').then((mod) => mod.Map), {
|
const Map = lazy(() => import('src/components/map').then((mod) => ({ default: mod.Map })));
|
||||||
loading: () => (
|
|
||||||
<Skeleton
|
|
||||||
variant="rectangular"
|
|
||||||
sx={{ top: 0, left: 0, width: 1, height: 1, position: 'absolute' }}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -39,6 +31,14 @@ export function ContactMap({ contacts }) {
|
||||||
height: { xs: 320, md: 560 },
|
height: { xs: 320, md: 560 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<Skeleton
|
||||||
|
variant="rectangular"
|
||||||
|
sx={{ top: 0, left: 0, width: 1, height: 1, position: 'absolute' }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
<Map
|
<Map
|
||||||
initialViewState={{ latitude: 12, longitude: 42, zoom: 2 }}
|
initialViewState={{ latitude: 12, longitude: 42, zoom: 2 }}
|
||||||
mapStyle={`mapbox://styles/mapbox/${lightMode ? 'light' : 'dark'}-v10`}
|
mapStyle={`mapbox://styles/mapbox/${lightMode ? 'light' : 'dark'}-v10`}
|
||||||
|
|
@ -82,6 +82,7 @@ export function ContactMap({ contacts }) {
|
||||||
</MapPopup>
|
</MapPopup>
|
||||||
)}
|
)}
|
||||||
</Map>
|
</Map>
|
||||||
|
</Suspense>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,339 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
|
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Chip from '@mui/material/Chip';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
|
import Paper from '@mui/material/Paper';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import Drawer from '@mui/material/Drawer';
|
||||||
|
import Divider from '@mui/material/Divider';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
|
||||||
|
import { Iconify } from 'src/components/iconify';
|
||||||
|
import { DashboardContent } from 'src/layouts/dashboard';
|
||||||
|
import { useAuthContext } from 'src/auth/hooks';
|
||||||
|
import { getLessons, completeLesson } from 'src/utils/dashboard-api';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function getSubjectName(lesson) {
|
||||||
|
if (typeof lesson.subject === 'string') return lesson.subject;
|
||||||
|
if (lesson.subject?.name) return lesson.subject.name;
|
||||||
|
return lesson.subject_name || 'Занятие';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClientName(lesson) {
|
||||||
|
if (typeof lesson.client === 'object' && lesson.client?.user) {
|
||||||
|
return `${lesson.client.user.first_name} ${lesson.client.user.last_name}`;
|
||||||
|
}
|
||||||
|
return lesson.client_name || 'Студент';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(s) {
|
||||||
|
return new Date(s).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(s) {
|
||||||
|
return new Date(s).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function FeedbackDrawer({ open, lesson, onClose, onSuccess }) {
|
||||||
|
const [form, setForm] = useState({ mentor_grade: '', school_grade: '', mentor_notes: '' });
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && lesson) {
|
||||||
|
setForm({
|
||||||
|
mentor_grade: lesson.mentor_grade?.toString() || '',
|
||||||
|
school_grade: lesson.school_grade?.toString() || '',
|
||||||
|
mentor_notes: lesson.mentor_notes || '',
|
||||||
|
});
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}, [open, lesson]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await completeLesson(
|
||||||
|
lesson.id,
|
||||||
|
form.mentor_notes.trim(),
|
||||||
|
form.mentor_grade ? parseInt(form.mentor_grade, 10) : undefined,
|
||||||
|
form.school_grade ? parseInt(form.school_grade, 10) : undefined,
|
||||||
|
);
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err?.response?.data?.detail || err?.message || 'Ошибка сохранения');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer anchor="right" open={open} onClose={loading ? undefined : onClose} PaperProps={{ sx: { width: { xs: '100%', sm: 480 } } }}>
|
||||||
|
<Stack direction="row" alignItems="center" justifyContent="space-between" px={3} py={2} borderBottom="1px solid" borderColor="divider">
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6">Обратная связь</Typography>
|
||||||
|
{lesson && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{lesson.title} — {getSubjectName(lesson)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<IconButton onClick={onClose} disabled={loading}>
|
||||||
|
<Iconify icon="mingcute:close-line" />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{lesson && (
|
||||||
|
<Box component="form" onSubmit={handleSubmit} sx={{ p: 3, overflowY: 'auto', flex: 1, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<Paper sx={{ p: 2, borderRadius: 2, bgcolor: 'primary.lighter' }}>
|
||||||
|
<Stack direction="row" flexWrap="wrap" gap={2}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary">Студент</Typography>
|
||||||
|
<Typography variant="body2" fontWeight={500}>{getClientName(lesson)}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary">Дата</Typography>
|
||||||
|
<Typography variant="body2" fontWeight={500}>{formatDate(lesson.start_time)}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary">Время</Typography>
|
||||||
|
<Typography variant="body2" fontWeight={500}>{formatTime(lesson.start_time)} — {formatTime(lesson.end_time)}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Typography variant="subtitle2">Оценки</Typography>
|
||||||
|
<Stack direction="row" spacing={2}>
|
||||||
|
<TextField
|
||||||
|
label="Оценка за занятие (1–5)"
|
||||||
|
type="number"
|
||||||
|
inputProps={{ min: 1, max: 5 }}
|
||||||
|
value={form.mentor_grade}
|
||||||
|
onChange={(e) => setForm((p) => ({ ...p, mentor_grade: e.target.value }))}
|
||||||
|
disabled={loading}
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Оценка в школе (1–5)"
|
||||||
|
type="number"
|
||||||
|
inputProps={{ min: 1, max: 5 }}
|
||||||
|
value={form.school_grade}
|
||||||
|
onChange={(e) => setForm((p) => ({ ...p, school_grade: e.target.value }))}
|
||||||
|
disabled={loading}
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Комментарий к занятию"
|
||||||
|
multiline
|
||||||
|
rows={4}
|
||||||
|
value={form.mentor_notes}
|
||||||
|
onChange={(e) => setForm((p) => ({ ...p, mentor_notes: e.target.value }))}
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="Что прошли на занятии, успехи студента, рекомендации..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Typography color="error" variant="body2">
|
||||||
|
{error}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1.5} mt="auto">
|
||||||
|
<Button variant="outlined" color="inherit" onClick={onClose} disabled={loading} fullWidth>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="contained" disabled={loading} fullWidth>
|
||||||
|
{loading ? 'Сохранение...' : 'Сохранить'}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function LessonCard({ lesson, onFill }) {
|
||||||
|
const hasFeedback = !!(lesson.mentor_grade || lesson.school_grade || lesson.mentor_notes?.trim());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
borderRadius: 2,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'box-shadow 0.2s',
|
||||||
|
'&:hover': { boxShadow: 4 },
|
||||||
|
}}
|
||||||
|
onClick={onFill}
|
||||||
|
>
|
||||||
|
<Chip label={getSubjectName(lesson)} size="small" color="primary" variant="soft" sx={{ mb: 1 }} />
|
||||||
|
<Typography variant="subtitle2" mb={0.5}>
|
||||||
|
{lesson.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="flex" alignItems="center" gap={0.5} mb={0.5}>
|
||||||
|
<Iconify icon="solar:user-linear" width={14} />
|
||||||
|
{getClientName(lesson)}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="flex" alignItems="center" gap={0.5} mb={0.5}>
|
||||||
|
<Iconify icon="solar:calendar-linear" width={14} />
|
||||||
|
{formatDate(lesson.start_time)}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="flex" alignItems="center" gap={0.5} mb={1.5}>
|
||||||
|
<Iconify icon="solar:clock-circle-linear" width={14} />
|
||||||
|
{formatTime(lesson.start_time)} — {formatTime(lesson.end_time)}
|
||||||
|
</Typography>
|
||||||
|
{lesson.mentor_grade != null && (
|
||||||
|
<Typography variant="caption" color="primary.main" fontWeight={600} display="block" mb={1}>
|
||||||
|
Оценка: {lesson.mentor_grade}/5
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant={hasFeedback ? 'soft' : 'contained'}
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
onClick={(e) => { e.stopPropagation(); onFill(); }}
|
||||||
|
>
|
||||||
|
{hasFeedback ? 'Редактировать' : 'Заполнить'}
|
||||||
|
</Button>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function FeedbackView() {
|
||||||
|
const { user } = useAuthContext();
|
||||||
|
const [lessons, setLessons] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [selected, setSelected] = useState(null);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (user?.role !== 'mentor') return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await getLessons({ status: 'completed' });
|
||||||
|
const list = Array.isArray(res) ? res : res?.results || [];
|
||||||
|
setLessons(list.filter((l) => !l.group));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err?.message || 'Ошибка загрузки');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [user?.role]);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const todoLessons = useMemo(
|
||||||
|
() => lessons.filter((l) => !(l.mentor_grade || l.school_grade || l.mentor_notes?.trim())),
|
||||||
|
[lessons],
|
||||||
|
);
|
||||||
|
const doneLessons = useMemo(
|
||||||
|
() => lessons.filter((l) => !!(l.mentor_grade || l.school_grade || l.mentor_notes?.trim())),
|
||||||
|
[lessons],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (user?.role !== 'mentor') {
|
||||||
|
return (
|
||||||
|
<DashboardContent>
|
||||||
|
<Typography color="text.secondary" align="center" mt={8}>
|
||||||
|
Страница доступна только менторам.
|
||||||
|
</Typography>
|
||||||
|
</DashboardContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardContent maxWidth="xl">
|
||||||
|
<Typography variant="h4" mb={3}>
|
||||||
|
Обратная связь
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Typography color="error" mb={2}>
|
||||||
|
{error}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Stack direction={{ xs: 'column', md: 'row' }} spacing={3} alignItems="flex-start">
|
||||||
|
{/* Ожидают */}
|
||||||
|
<Box flex={1} minWidth={0}>
|
||||||
|
<Typography variant="subtitle1" fontWeight={700} mb={2}>
|
||||||
|
Ожидают {todoLessons.length > 0 ? `(${todoLessons.length})` : ''}
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{todoLessons.map((l) => (
|
||||||
|
<LessonCard
|
||||||
|
key={l.id}
|
||||||
|
lesson={l}
|
||||||
|
onFill={() => { setSelected(l); setDrawerOpen(true); }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{todoLessons.length === 0 && (
|
||||||
|
<Typography color="text.secondary" variant="body2">
|
||||||
|
Нет занятий, ожидающих обратной связи
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider orientation="vertical" flexItem sx={{ display: { xs: 'none', md: 'block' } }} />
|
||||||
|
|
||||||
|
{/* Заполнено */}
|
||||||
|
<Box flex={1} minWidth={0}>
|
||||||
|
<Typography variant="subtitle1" fontWeight={700} mb={2}>
|
||||||
|
Заполнено {doneLessons.length > 0 ? `(${doneLessons.length})` : ''}
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{doneLessons.map((l) => (
|
||||||
|
<LessonCard
|
||||||
|
key={l.id}
|
||||||
|
lesson={l}
|
||||||
|
onFill={() => { setSelected(l); setDrawerOpen(true); }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{doneLessons.length === 0 && (
|
||||||
|
<Typography color="text.secondary" variant="body2">
|
||||||
|
Нет завершённых занятий
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FeedbackDrawer
|
||||||
|
open={drawerOpen}
|
||||||
|
lesson={selected}
|
||||||
|
onClose={() => setDrawerOpen(false)}
|
||||||
|
onSuccess={load}
|
||||||
|
/>
|
||||||
|
</DashboardContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './feedback-view';
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export { MyProgressView } from './my-progress-view';
|
||||||
|
|
@ -0,0 +1,354 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { useMemo, useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Card from '@mui/material/Card';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
|
import Alert from '@mui/material/Alert';
|
||||||
|
import Select from '@mui/material/Select';
|
||||||
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import InputLabel from '@mui/material/InputLabel';
|
||||||
|
import FormControl from '@mui/material/FormControl';
|
||||||
|
import CardContent from '@mui/material/CardContent';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
|
||||||
|
import { paths } from 'src/routes/paths';
|
||||||
|
|
||||||
|
import axios from 'src/utils/axios';
|
||||||
|
|
||||||
|
import { DashboardContent } from 'src/layouts/dashboard';
|
||||||
|
|
||||||
|
import { Iconify } from 'src/components/iconify';
|
||||||
|
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
|
||||||
|
|
||||||
|
import { useAuthContext } from 'src/auth/hooks';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const Chart = dynamic(() => import('react-apexcharts'), { ssr: false });
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function getDatesInRange(startStr, endStr) {
|
||||||
|
const dates = [];
|
||||||
|
let d = new Date(startStr);
|
||||||
|
const end = new Date(endStr);
|
||||||
|
while (d <= end) {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
dates.push(`${y}-${m}-${day}`);
|
||||||
|
d = new Date(d.getTime() + 86400000);
|
||||||
|
}
|
||||||
|
return dates;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateLabel(d) {
|
||||||
|
const [, m, day] = d.split('-');
|
||||||
|
return `${day}.${m}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLessons(params) {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (params?.start_date) q.append('start_date', params.start_date);
|
||||||
|
if (params?.end_date) q.append('end_date', params.end_date);
|
||||||
|
if (params?.child_id) q.append('child_id', params.child_id);
|
||||||
|
const res = await axios.get(`/lessons/lessons/?${q.toString()}`);
|
||||||
|
const {data} = res;
|
||||||
|
if (Array.isArray(data)) return { results: data };
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function StatTile({ label, value, sub, icon, color }) {
|
||||||
|
return (
|
||||||
|
<Card variant="outlined" sx={{ flex: 1, minWidth: 120 }}>
|
||||||
|
<CardContent sx={{ pb: '12px !important' }}>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 0.5 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
bgcolor: `${color}.lighter`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Iconify icon={icon} width={20} color={`${color}.main`} />
|
||||||
|
</Box>
|
||||||
|
<Typography variant="h5" fontWeight="bold">
|
||||||
|
{value}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
{sub && (
|
||||||
|
<Typography variant="caption" color="text.disabled">
|
||||||
|
{sub}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const TODAY = new Date().toISOString().slice(0, 10);
|
||||||
|
const WEEK_AGO = new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
export function MyProgressView() {
|
||||||
|
const { user } = useAuthContext();
|
||||||
|
const isParent = user?.role === 'parent';
|
||||||
|
|
||||||
|
const [startDate, setStartDate] = useState(WEEK_AGO);
|
||||||
|
const [endDate, setEndDate] = useState(TODAY);
|
||||||
|
const [lessons, setLessons] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [subjects, setSubjects] = useState([]);
|
||||||
|
const [selectedSubject, setSelectedSubject] = useState('');
|
||||||
|
|
||||||
|
const childId = isParent
|
||||||
|
? typeof window !== 'undefined'
|
||||||
|
? localStorage.getItem('selected_child_id') || ''
|
||||||
|
: ''
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const loadSubjects = useCallback(async () => {
|
||||||
|
const sixMonthsAgo = new Date(Date.now() - 180 * 86400000).toISOString().slice(0, 10);
|
||||||
|
const res = await getLessons({
|
||||||
|
start_date: sixMonthsAgo,
|
||||||
|
end_date: TODAY,
|
||||||
|
...(childId ? { child_id: childId } : {}),
|
||||||
|
});
|
||||||
|
const set = new Set();
|
||||||
|
(res.results || []).forEach((l) => {
|
||||||
|
if (l.subject && typeof l.subject === 'string' && l.subject.trim()) {
|
||||||
|
set.add(l.subject.trim());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const list = Array.from(set).sort();
|
||||||
|
setSubjects(list);
|
||||||
|
if (list.length > 0) setSelectedSubject(list[0]);
|
||||||
|
}, [childId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSubjects();
|
||||||
|
}, [loadSubjects]);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await getLessons({
|
||||||
|
start_date: startDate,
|
||||||
|
end_date: endDate,
|
||||||
|
...(childId ? { child_id: childId } : {}),
|
||||||
|
});
|
||||||
|
const filtered = (res.results || []).filter((l) =>
|
||||||
|
selectedSubject ? (l.subject || '').trim() === selectedSubject : true
|
||||||
|
);
|
||||||
|
filtered.sort((a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime());
|
||||||
|
setLessons(filtered);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e?.response?.data?.detail || e?.message || 'Ошибка загрузки');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [startDate, endDate, selectedSubject, childId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const completed = lessons.filter((l) => l.status === 'completed').length;
|
||||||
|
const total = lessons.length;
|
||||||
|
const attendanceRate = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||||
|
let sum = 0;
|
||||||
|
let count = 0;
|
||||||
|
lessons.forEach((l) => {
|
||||||
|
if (l.status === 'completed') {
|
||||||
|
if (l.mentor_grade != null) {
|
||||||
|
sum += l.mentor_grade;
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
if (l.school_grade != null) {
|
||||||
|
sum += l.school_grade;
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const avgGrade = count > 0 ? Math.round((sum / count) * 10) / 10 : 0;
|
||||||
|
return { completed, total, attendanceRate, avgGrade };
|
||||||
|
}, [lessons]);
|
||||||
|
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
const allDates = getDatesInRange(startDate, endDate);
|
||||||
|
const categories = allDates.map(formatDateLabel);
|
||||||
|
const byDate = {};
|
||||||
|
lessons.forEach((l) => {
|
||||||
|
if (l.status !== 'completed') return;
|
||||||
|
const key = (l.start_time || '').slice(0, 10);
|
||||||
|
if (!key) return;
|
||||||
|
byDate[key] = (byDate[key] || 0) + 1;
|
||||||
|
});
|
||||||
|
const data = allDates.map((d) => byDate[d] || 0);
|
||||||
|
const gradeByDate = {};
|
||||||
|
lessons.forEach((l) => {
|
||||||
|
if (l.status !== 'completed') return;
|
||||||
|
const key = (l.start_time || '').slice(0, 10);
|
||||||
|
if (!key) return;
|
||||||
|
if (l.mentor_grade != null) gradeByDate[key] = l.mentor_grade;
|
||||||
|
});
|
||||||
|
const grades = allDates.map((d) => gradeByDate[d] ?? null);
|
||||||
|
return { categories, attendanceSeries: [{ name: 'Занятия', data }], gradesSeries: [{ name: 'Оценка', data: grades }] };
|
||||||
|
}, [lessons, startDate, endDate]);
|
||||||
|
|
||||||
|
const chartOptionsBase = {
|
||||||
|
chart: { toolbar: { show: false } },
|
||||||
|
stroke: { curve: 'smooth', width: 2 },
|
||||||
|
dataLabels: { enabled: false },
|
||||||
|
grid: { borderColor: '#f0f0f0' },
|
||||||
|
xaxis: { categories: chartData.categories, axisBorder: { show: false }, axisTicks: { show: false } },
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardContent>
|
||||||
|
<CustomBreadcrumbs
|
||||||
|
heading="Мой прогресс"
|
||||||
|
links={[{ name: 'Главная', href: paths.dashboard.root }, { name: 'Прогресс' }]}
|
||||||
|
sx={{ mb: 3 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 3 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 3 }} flexWrap="wrap">
|
||||||
|
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||||
|
<InputLabel>Предмет</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={selectedSubject}
|
||||||
|
onChange={(e) => setSelectedSubject(e.target.value)}
|
||||||
|
label="Предмет"
|
||||||
|
disabled={subjects.length === 0}
|
||||||
|
>
|
||||||
|
<MenuItem value="">Все предметы</MenuItem>
|
||||||
|
{subjects.map((s) => (
|
||||||
|
<MenuItem key={s} value={s}>
|
||||||
|
{s}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
От:
|
||||||
|
</Typography>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
style={{ padding: '6px 10px', borderRadius: 8, border: '1px solid #ddd', fontSize: 14 }}
|
||||||
|
/>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
До:
|
||||||
|
</Typography>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
style={{ padding: '6px 10px', borderRadius: 8, border: '1px solid #ddd', fontSize: 14 }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Stack spacing={3}>
|
||||||
|
{/* Stats row */}
|
||||||
|
<Stack direction="row" flexWrap="wrap" gap={2}>
|
||||||
|
<StatTile
|
||||||
|
label="Занятий проведено"
|
||||||
|
value={stats.completed}
|
||||||
|
sub={`из ${stats.total}`}
|
||||||
|
icon="eva:checkmark-circle-outline"
|
||||||
|
color="success"
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label="Посещаемость"
|
||||||
|
value={`${stats.attendanceRate}%`}
|
||||||
|
icon="eva:calendar-outline"
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
<StatTile
|
||||||
|
label="Средняя оценка"
|
||||||
|
value={stats.avgGrade || '—'}
|
||||||
|
icon="eva:star-outline"
|
||||||
|
color="warning"
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Attendance chart */}
|
||||||
|
{typeof window !== 'undefined' && chartData.categories.length > 0 && (
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="subtitle1" sx={{ mb: 2 }}>
|
||||||
|
Посещаемость
|
||||||
|
</Typography>
|
||||||
|
<Chart
|
||||||
|
type="bar"
|
||||||
|
height={220}
|
||||||
|
series={chartData.attendanceSeries}
|
||||||
|
options={{ ...chartOptionsBase, colors: ['#5BE49B'] }}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Grades chart */}
|
||||||
|
{typeof window !== 'undefined' && chartData.categories.length > 0 && (
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="subtitle1" sx={{ mb: 2 }}>
|
||||||
|
Оценки
|
||||||
|
</Typography>
|
||||||
|
<Chart
|
||||||
|
type="line"
|
||||||
|
height={220}
|
||||||
|
series={chartData.gradesSeries}
|
||||||
|
options={{
|
||||||
|
...chartOptionsBase,
|
||||||
|
colors: ['#00A76F'],
|
||||||
|
yaxis: { min: 1, max: 5 },
|
||||||
|
markers: { size: 4 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lessons.length === 0 && (
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
Нет занятий за выбранный период
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</DashboardContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './overview-client-view';
|
||||||
|
|
@ -0,0 +1,389 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Card from '@mui/material/Card';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
|
import Divider from '@mui/material/Divider';
|
||||||
|
import Avatar from '@mui/material/Avatar';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
import LinearProgress from '@mui/material/LinearProgress';
|
||||||
|
import { useTheme } from '@mui/material/styles';
|
||||||
|
|
||||||
|
import { fDateTime } from 'src/utils/format-time';
|
||||||
|
import { getClientDashboard, getChildDashboard } from 'src/utils/dashboard-api';
|
||||||
|
import { DashboardContent } from 'src/layouts/dashboard';
|
||||||
|
import { useAuthContext } from 'src/auth/hooks';
|
||||||
|
import { CONFIG } from 'src/config-global';
|
||||||
|
import { varAlpha } from 'src/theme/styles';
|
||||||
|
import { Iconify } from 'src/components/iconify';
|
||||||
|
|
||||||
|
import { CourseWidgetSummary } from '../../course/course-widget-summary';
|
||||||
|
import { CourseProgress } from '../../course/course-progress';
|
||||||
|
import { CourseMyAccount } from '../../course/course-my-account';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const formatDateTime = (str) => {
|
||||||
|
if (!str) return '—';
|
||||||
|
try {
|
||||||
|
const date = new Date(str);
|
||||||
|
if (isNaN(date.getTime())) return '—';
|
||||||
|
const today = new Date();
|
||||||
|
const tomorrow = new Date(today);
|
||||||
|
tomorrow.setDate(today.getDate() + 1);
|
||||||
|
const time = date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
if (date.toDateString() === today.toDateString()) return `Сегодня, ${time}`;
|
||||||
|
if (date.toDateString() === tomorrow.toDateString()) return `Завтра, ${time}`;
|
||||||
|
return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
|
||||||
|
} catch {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function LessonItem({ lesson }) {
|
||||||
|
const mentorName = lesson.mentor
|
||||||
|
? `${lesson.mentor.first_name || ''} ${lesson.mentor.last_name || ''}`.trim() || 'Ментор'
|
||||||
|
: 'Ментор';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={2}
|
||||||
|
alignItems="center"
|
||||||
|
sx={{
|
||||||
|
py: 1.5,
|
||||||
|
px: 2,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
'&:hover': { bgcolor: 'action.hover' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar sx={{ width: 40, height: 40, bgcolor: 'primary.main', fontSize: 14 }}>
|
||||||
|
{mentorName[0]?.toUpperCase()}
|
||||||
|
</Avatar>
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Typography variant="subtitle2" noWrap>
|
||||||
|
{lesson.title || lesson.subject || 'Занятие'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
{mentorName}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary', flexShrink: 0 }}>
|
||||||
|
{formatDateTime(lesson.start_time)}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function HomeworkItem({ homework }) {
|
||||||
|
const statusColor = {
|
||||||
|
pending: 'warning',
|
||||||
|
submitted: 'info',
|
||||||
|
reviewed: 'success',
|
||||||
|
completed: 'success',
|
||||||
|
}[homework.status] || 'default';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={2}
|
||||||
|
alignItems="center"
|
||||||
|
sx={{
|
||||||
|
py: 1.5,
|
||||||
|
px: 2,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
'&:hover': { bgcolor: 'action.hover' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 1,
|
||||||
|
bgcolor: `${statusColor}.main`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
opacity: 0.8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Iconify icon="solar:document-bold" width={20} sx={{ color: 'white' }} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Typography variant="subtitle2" noWrap>
|
||||||
|
{homework.title}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
{homework.subject || 'Предмет не указан'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{homework.grade != null && (
|
||||||
|
<Typography variant="caption" sx={{ color: `${statusColor}.main`, fontWeight: 600, flexShrink: 0 }}>
|
||||||
|
{homework.grade}/5
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function OverviewClientView({ childId, childName }) {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { user } = useAuthContext();
|
||||||
|
const [stats, setStats] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const fetchData = useCallback(
|
||||||
|
async (signal) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = childId
|
||||||
|
? await getChildDashboard(childId, { signal })
|
||||||
|
: await getClientDashboard({ signal });
|
||||||
|
if (!signal?.aborted) setStats(data);
|
||||||
|
} catch (err) {
|
||||||
|
if (err?.name === 'AbortError' || err?.name === 'CanceledError') return;
|
||||||
|
console.error('Client dashboard error:', err);
|
||||||
|
} finally {
|
||||||
|
if (!signal?.aborted) setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[childId]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
fetchData(controller.signal);
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
const displayName = childName || user?.first_name || 'Студент';
|
||||||
|
|
||||||
|
const completionPct =
|
||||||
|
stats?.total_lessons > 0
|
||||||
|
? Math.round((stats.completed_lessons / stats.total_lessons) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
if (loading && !stats) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', height: '80vh', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardContent
|
||||||
|
maxWidth={false}
|
||||||
|
disablePadding
|
||||||
|
sx={{ borderTop: { lg: `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.12)}` } }}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', flex: '1 1 auto', flexDirection: { xs: 'column', lg: 'row' } }}>
|
||||||
|
{/* LEFT */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
gap: 3,
|
||||||
|
display: 'flex',
|
||||||
|
flex: '1 1 auto',
|
||||||
|
flexDirection: 'column',
|
||||||
|
px: { xs: 2, sm: 3, xl: 5 },
|
||||||
|
py: 3,
|
||||||
|
borderRight: { lg: `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.12)}` },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Greeting */}
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" sx={{ mb: 0.5 }}>
|
||||||
|
Привет, {displayName}! 👋
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ color: 'text.secondary' }}>
|
||||||
|
Твой прогресс и ближайшие занятия.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Stat widgets */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
gap: 3,
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: { xs: 'repeat(2, 1fr)', md: 'repeat(4, 1fr)' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CourseWidgetSummary
|
||||||
|
title="Занятий всего"
|
||||||
|
total={Number(stats?.total_lessons || 0)}
|
||||||
|
icon={`${CONFIG.site.basePath}/assets/icons/courses/ic-courses-progress.svg`}
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
<CourseWidgetSummary
|
||||||
|
title="Пройдено"
|
||||||
|
total={Number(stats?.completed_lessons || 0)}
|
||||||
|
icon={`${CONFIG.site.basePath}/assets/icons/courses/ic-courses-certificates.svg`}
|
||||||
|
color="success"
|
||||||
|
/>
|
||||||
|
<CourseWidgetSummary
|
||||||
|
title="ДЗ к выполнению"
|
||||||
|
total={Number(stats?.homework_pending || 0)}
|
||||||
|
icon={`${CONFIG.site.basePath}/assets/icons/courses/ic-courses-completed.svg`}
|
||||||
|
color="warning"
|
||||||
|
/>
|
||||||
|
<CourseWidgetSummary
|
||||||
|
title="Средняя оценка"
|
||||||
|
total={
|
||||||
|
stats?.average_grade
|
||||||
|
? `${parseFloat(Number(stats.average_grade).toFixed(1))}/5`
|
||||||
|
: '—'
|
||||||
|
}
|
||||||
|
icon={`${CONFIG.site.basePath}/assets/icons/glass/ic-glass-bag.svg`}
|
||||||
|
color="info"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<Card sx={{ p: 3 }}>
|
||||||
|
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
|
||||||
|
<Typography variant="h6">Прогресс занятий</Typography>
|
||||||
|
<Typography variant="h6" sx={{ color: 'primary.main' }}>
|
||||||
|
{completionPct}%
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={completionPct}
|
||||||
|
sx={{ height: 8, borderRadius: 1, bgcolor: 'background.neutral' }}
|
||||||
|
/>
|
||||||
|
<Stack direction="row" justifyContent="space-between" sx={{ mt: 1 }}>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
Пройдено: {stats?.completed_lessons || 0}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
Всего: {stats?.total_lessons || 0}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Upcoming lessons + homework */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
gap: 3,
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: { xs: '1fr', md: 'repeat(2, 1fr)' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Upcoming lessons */}
|
||||||
|
<Card>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="space-between"
|
||||||
|
sx={{ px: 3, py: 2 }}
|
||||||
|
>
|
||||||
|
<Typography variant="h6">Ближайшие занятия</Typography>
|
||||||
|
<Iconify icon="solar:calendar-bold" width={20} sx={{ color: 'primary.main' }} />
|
||||||
|
</Stack>
|
||||||
|
<Divider />
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||||
|
<CircularProgress size={24} />
|
||||||
|
</Box>
|
||||||
|
) : stats?.upcoming_lessons?.length > 0 ? (
|
||||||
|
<Box sx={{ py: 1 }}>
|
||||||
|
{stats.upcoming_lessons.slice(0, 4).map((lesson) => (
|
||||||
|
<LessonItem key={lesson.id} lesson={lesson} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ p: 3, textAlign: 'center', color: 'text.secondary' }}>
|
||||||
|
<Iconify icon="solar:calendar-bold" width={40} sx={{ mb: 1, opacity: 0.3 }} />
|
||||||
|
<Typography variant="body2">Нет запланированных занятий</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Homework */}
|
||||||
|
<Card>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="space-between"
|
||||||
|
sx={{ px: 3, py: 2 }}
|
||||||
|
>
|
||||||
|
<Typography variant="h6">Домашние задания</Typography>
|
||||||
|
<Iconify icon="solar:document-bold" width={20} sx={{ color: 'warning.main' }} />
|
||||||
|
</Stack>
|
||||||
|
<Divider />
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||||
|
<CircularProgress size={24} />
|
||||||
|
</Box>
|
||||||
|
) : stats?.recent_homework?.length > 0 ? (
|
||||||
|
<Box sx={{ py: 1 }}>
|
||||||
|
{stats.recent_homework.slice(0, 4).map((hw) => (
|
||||||
|
<HomeworkItem key={hw.id} homework={hw} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ p: 3, textAlign: 'center', color: 'text.secondary' }}>
|
||||||
|
<Iconify icon="solar:document-bold" width={40} sx={{ mb: 1, opacity: 0.3 }} />
|
||||||
|
<Typography variant="body2">Нет домашних заданий</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* RIGHT sidebar */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
px: { xs: 2, sm: 3, xl: 5 },
|
||||||
|
pt: { lg: 8 },
|
||||||
|
pb: { xs: 8, xl: 10 },
|
||||||
|
flexShrink: 0,
|
||||||
|
gap: 3,
|
||||||
|
maxWidth: { lg: 300, xl: 340 },
|
||||||
|
bgcolor: { lg: 'background.neutral' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CourseMyAccount user={childId ? { first_name: childName } : user} />
|
||||||
|
|
||||||
|
{/* Next lesson highlight */}
|
||||||
|
{stats?.next_lesson && (
|
||||||
|
<Card sx={{ p: 2.5, bgcolor: 'primary.lighter' }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1, color: 'primary.dark' }}>
|
||||||
|
Следующее занятие
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||||
|
{stats.next_lesson.title || 'Занятие'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
{formatDateTime(stats.next_lesson.start_time)}
|
||||||
|
</Typography>
|
||||||
|
{stats.next_lesson.mentor && (
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1} sx={{ mt: 1.5 }}>
|
||||||
|
<Avatar sx={{ width: 24, height: 24, fontSize: 10, bgcolor: 'primary.main' }}>
|
||||||
|
{(stats.next_lesson.mentor.first_name || 'М')[0]}
|
||||||
|
</Avatar>
|
||||||
|
<Typography variant="caption">
|
||||||
|
{`${stats.next_lesson.mentor.first_name || ''} ${stats.next_lesson.mentor.last_name || ''}`.trim()}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</DashboardContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
export * from './payment-view';
|
export * from './payment-view';
|
||||||
|
export { PaymentPlatformView } from './payment-platform-view';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,205 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Card from '@mui/material/Card';
|
||||||
|
import Chip from '@mui/material/Chip';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
|
import Alert from '@mui/material/Alert';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import Divider from '@mui/material/Divider';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import CardContent from '@mui/material/CardContent';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
|
||||||
|
import { paths } from 'src/routes/paths';
|
||||||
|
|
||||||
|
import axios from 'src/utils/axios';
|
||||||
|
|
||||||
|
import { DashboardContent } from 'src/layouts/dashboard';
|
||||||
|
|
||||||
|
import { Iconify } from 'src/components/iconify';
|
||||||
|
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function getPaymentInfo() {
|
||||||
|
const res = await axios.get('/payments/subscriptions/');
|
||||||
|
const {data} = res;
|
||||||
|
if (Array.isArray(data)) return data;
|
||||||
|
return data?.results ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPaymentHistory() {
|
||||||
|
const res = await axios.get('/payments/history/');
|
||||||
|
const {data} = res;
|
||||||
|
if (Array.isArray(data)) return data;
|
||||||
|
return data?.results ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function formatDate(ts) {
|
||||||
|
if (!ts) return '—';
|
||||||
|
try {
|
||||||
|
return new Date(ts).toLocaleDateString('ru-RU');
|
||||||
|
} catch {
|
||||||
|
return ts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAmount(amount, currency) {
|
||||||
|
if (amount == null) return '—';
|
||||||
|
return `${amount} ${currency || '₽'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function PaymentPlatformView() {
|
||||||
|
const [subscriptions, setSubscriptions] = useState([]);
|
||||||
|
const [history, setHistory] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const [subs, hist] = await Promise.all([
|
||||||
|
getPaymentInfo().catch(() => []),
|
||||||
|
getPaymentHistory().catch(() => []),
|
||||||
|
]);
|
||||||
|
setSubscriptions(subs);
|
||||||
|
setHistory(hist);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e?.response?.data?.detail || e?.message || 'Ошибка загрузки');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardContent>
|
||||||
|
<CustomBreadcrumbs
|
||||||
|
heading="Подписки и оплата"
|
||||||
|
links={[{ name: 'Главная', href: paths.dashboard.root }, { name: 'Оплата' }]}
|
||||||
|
sx={{ mb: 3 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 3 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Stack spacing={3}>
|
||||||
|
{/* Subscriptions */}
|
||||||
|
{subscriptions.length > 0 ? (
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="subtitle1" sx={{ mb: 2 }}>
|
||||||
|
Активные подписки
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={1.5}>
|
||||||
|
{subscriptions.map((sub, idx) => (
|
||||||
|
// eslint-disable-next-line react/no-array-index-key
|
||||||
|
<Box key={idx}>
|
||||||
|
{idx > 0 && <Divider sx={{ mb: 1.5 }} />}
|
||||||
|
<Stack direction="row" alignItems="center" justifyContent="space-between" flexWrap="wrap" gap={1}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2">
|
||||||
|
{sub.plan_name || sub.name || 'Подписка'}
|
||||||
|
</Typography>
|
||||||
|
{sub.expires_at && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
До {formatDate(sub.expires_at)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
|
{sub.status && (
|
||||||
|
<Chip
|
||||||
|
label={sub.status}
|
||||||
|
size="small"
|
||||||
|
color={sub.status === 'active' ? 'success' : 'default'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{sub.price != null && (
|
||||||
|
<Typography variant="subtitle2">
|
||||||
|
{formatAmount(sub.price, sub.currency)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardContent>
|
||||||
|
<Stack alignItems="center" spacing={2} sx={{ py: 3 }}>
|
||||||
|
<Iconify icon="eva:credit-card-outline" width={48} color="text.disabled" />
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
Нет активных подписок
|
||||||
|
</Typography>
|
||||||
|
<Button variant="contained" startIcon={<Iconify icon="eva:plus-fill" />}>
|
||||||
|
Подключить подписку
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Payment history */}
|
||||||
|
{history.length > 0 && (
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="subtitle1" sx={{ mb: 2 }}>
|
||||||
|
История платежей
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
{history.map((item, idx) => (
|
||||||
|
// eslint-disable-next-line react/no-array-index-key
|
||||||
|
<Stack key={idx} direction="row" alignItems="center" justifyContent="space-between" sx={{ py: 0.75 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{item.description || item.plan_name || 'Платёж'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{formatDate(item.created_at || item.date)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
|
<Typography variant="subtitle2">
|
||||||
|
{formatAmount(item.amount, item.currency)}
|
||||||
|
</Typography>
|
||||||
|
{item.status && (
|
||||||
|
<Chip
|
||||||
|
label={item.status}
|
||||||
|
size="small"
|
||||||
|
color={item.status === 'success' || item.status === 'paid' ? 'success' : 'default'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</DashboardContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export { ReferralsView } from './referrals-view';
|
||||||
|
|
@ -0,0 +1,298 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Card from '@mui/material/Card';
|
||||||
|
import Chip from '@mui/material/Chip';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
|
import Alert from '@mui/material/Alert';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import Divider from '@mui/material/Divider';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import CardContent from '@mui/material/CardContent';
|
||||||
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
|
||||||
|
import { paths } from 'src/routes/paths';
|
||||||
|
|
||||||
|
import { getMyReferrals, getReferralStats, getReferralProfile } from 'src/utils/referrals-api';
|
||||||
|
|
||||||
|
import { DashboardContent } from 'src/layouts/dashboard';
|
||||||
|
|
||||||
|
import { Iconify } from 'src/components/iconify';
|
||||||
|
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function StatCard({ label, value, icon, color }) {
|
||||||
|
return (
|
||||||
|
<Card variant="outlined" sx={{ flex: 1, minWidth: 140 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 1 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
bgcolor: `${color}.lighter`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Iconify icon={icon} width={22} color={`${color}.main`} />
|
||||||
|
</Box>
|
||||||
|
<Typography variant="h5" fontWeight="bold">
|
||||||
|
{value ?? '—'}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReferralTable({ title, items }) {
|
||||||
|
if (!items || items.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1.5, color: 'text.secondary' }}>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
{items.map((item, idx) => (
|
||||||
|
// eslint-disable-next-line react/no-array-index-key
|
||||||
|
<Stack key={idx} direction="row" alignItems="center" justifyContent="space-between" sx={{ px: 2, py: 1, bgcolor: 'background.neutral', borderRadius: 1 }}>
|
||||||
|
<Typography variant="body2">{item.email}</Typography>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
|
<Chip label={item.level} size="small" variant="outlined" />
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{item.total_points} pts
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function ReferralsView() {
|
||||||
|
const [profile, setProfile] = useState(null);
|
||||||
|
const [stats, setStats] = useState(null);
|
||||||
|
const [referrals, setReferrals] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const [p, s, r] = await Promise.all([
|
||||||
|
getReferralProfile(),
|
||||||
|
getReferralStats(),
|
||||||
|
getMyReferrals().catch(() => null),
|
||||||
|
]);
|
||||||
|
setProfile(p);
|
||||||
|
setStats(s);
|
||||||
|
setReferrals(r);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e?.response?.data?.detail || e?.message || 'Ошибка загрузки');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const handleCopyLink = () => {
|
||||||
|
const link = profile?.referral_link || stats?.referral_code || '';
|
||||||
|
if (!link) return;
|
||||||
|
navigator.clipboard.writeText(link).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyCode = () => {
|
||||||
|
const code = profile?.referral_code || stats?.referral_code || '';
|
||||||
|
if (!code) return;
|
||||||
|
navigator.clipboard.writeText(code).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const referralCode = profile?.referral_code || stats?.referral_code || '';
|
||||||
|
const referralLink = profile?.referral_link || '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardContent>
|
||||||
|
<CustomBreadcrumbs
|
||||||
|
heading="Реферальная программа"
|
||||||
|
links={[{ name: 'Главная', href: paths.dashboard.root }, { name: 'Рефералы' }]}
|
||||||
|
sx={{ mb: 3 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 3 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Stack spacing={3}>
|
||||||
|
{/* Stats */}
|
||||||
|
{stats && (
|
||||||
|
<Stack direction="row" flexWrap="wrap" gap={2}>
|
||||||
|
<StatCard
|
||||||
|
label="Прямые рефералы"
|
||||||
|
value={stats.referrals?.direct}
|
||||||
|
icon="eva:people-outline"
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Непрямые рефералы"
|
||||||
|
value={stats.referrals?.indirect}
|
||||||
|
icon="eva:person-add-outline"
|
||||||
|
color="info"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Всего баллов"
|
||||||
|
value={stats.total_points}
|
||||||
|
icon="eva:star-outline"
|
||||||
|
color="warning"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Заработано"
|
||||||
|
value={stats.earnings?.total !== undefined ? `${stats.earnings.total} ₽` : '—'}
|
||||||
|
icon="eva:credit-card-outline"
|
||||||
|
color="success"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Бонусный баланс"
|
||||||
|
value={stats.bonus_account?.balance !== undefined ? `${stats.bonus_account.balance} ₽` : '—'}
|
||||||
|
icon="eva:gift-outline"
|
||||||
|
color="error"
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Level */}
|
||||||
|
{stats?.current_level && (
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardContent>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={2}>
|
||||||
|
<Iconify icon="eva:award-outline" width={32} color="warning.main" />
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle1">
|
||||||
|
Уровень {stats.current_level.level} — {stats.current_level.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Ваш текущий реферальный уровень
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Referral code & link */}
|
||||||
|
{referralCode && (
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="subtitle1" sx={{ mb: 2 }}>
|
||||||
|
Ваш реферальный код
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<TextField
|
||||||
|
label="Реферальный код"
|
||||||
|
value={referralCode}
|
||||||
|
InputProps={{
|
||||||
|
readOnly: true,
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<Tooltip title={copied ? 'Скопировано!' : 'Копировать'}>
|
||||||
|
<IconButton onClick={handleCopyCode} size="small">
|
||||||
|
<Iconify icon={copied ? 'eva:checkmark-outline' : 'eva:copy-outline'} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
{referralLink && (
|
||||||
|
<TextField
|
||||||
|
label="Реферальная ссылка"
|
||||||
|
value={referralLink}
|
||||||
|
InputProps={{
|
||||||
|
readOnly: true,
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<Tooltip title={copied ? 'Скопировано!' : 'Копировать'}>
|
||||||
|
<IconButton onClick={handleCopyLink} size="small">
|
||||||
|
<Iconify icon={copied ? 'eva:checkmark-outline' : 'eva:copy-outline'} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<Iconify icon="eva:share-outline" />}
|
||||||
|
onClick={handleCopyLink || handleCopyCode}
|
||||||
|
sx={{ alignSelf: 'flex-start' }}
|
||||||
|
>
|
||||||
|
Поделиться
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Referrals list */}
|
||||||
|
{referrals && (referrals.direct?.length > 0 || referrals.indirect?.length > 0) && (
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="subtitle1" sx={{ mb: 2 }}>
|
||||||
|
Мои рефералы
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<ReferralTable title="Прямые" items={referrals.direct} />
|
||||||
|
{referrals.direct?.length > 0 && referrals.indirect?.length > 0 && <Divider />}
|
||||||
|
<ReferralTable title="Непрямые" items={referrals.indirect} />
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!referralCode && !loading && (
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
Реферальная программа недоступна
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</DashboardContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,423 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
import { createHomework } from 'src/utils/homework-api';
|
||||||
|
import { completeLesson, uploadLessonFile } from 'src/utils/dashboard-api';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const MAX_LESSON_FILES = 10;
|
||||||
|
const MAX_FILE_SIZE_MB = 10;
|
||||||
|
|
||||||
|
function formatSize(bytes) {
|
||||||
|
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(2)} МБ`;
|
||||||
|
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} КБ`;
|
||||||
|
return `${bytes} Б`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileKind(file) {
|
||||||
|
const t = file.type?.toLowerCase() ?? '';
|
||||||
|
const name = file.name.toLowerCase();
|
||||||
|
if (t.startsWith('image/') || /\.(jpe?g|png|gif|webp|bmp|svg|ico)$/i.test(name)) return 'image';
|
||||||
|
if (t.startsWith('video/') || /\.(mp4|webm|ogg|mov|avi|mkv)$/i.test(name)) return 'video';
|
||||||
|
if (t === 'application/pdf' || name.endsWith('.pdf')) return 'pdf';
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilePreviewChip({ file, onRemove, disabled }) {
|
||||||
|
const kind = getFileKind(file);
|
||||||
|
const [objectUrl, setObjectUrl] = useState(null);
|
||||||
|
const name = file.name.length > 28 ? `${file.name.slice(0, 25)}…` : file.name;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (kind === 'image' || kind === 'video' || kind === 'pdf') {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
setObjectUrl(url);
|
||||||
|
return () => URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [file, kind]);
|
||||||
|
|
||||||
|
let previewBlock;
|
||||||
|
if (kind === 'image' && objectUrl) {
|
||||||
|
previewBlock = (
|
||||||
|
<img src={objectUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 8 }} />
|
||||||
|
);
|
||||||
|
} else if (kind === 'video' && objectUrl) {
|
||||||
|
previewBlock = (
|
||||||
|
<video src={objectUrl} muted playsInline preload="metadata" style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 8 }} />
|
||||||
|
);
|
||||||
|
} else if (kind === 'pdf' && objectUrl) {
|
||||||
|
previewBlock = (
|
||||||
|
<iframe src={objectUrl} title={file.name} style={{ width: '100%', height: '100%', border: 'none', borderRadius: 8, minHeight: 140 }} />
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
previewBlock = (
|
||||||
|
<div style={{ width: '100%', height: '100%', minHeight: 60, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.08)', borderRadius: 8 }}>
|
||||||
|
<span style={{ fontSize: 28 }}>📄</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ background: 'rgba(0,0,0,0.06)', borderRadius: 12, border: '1px solid rgba(0,0,0,0.12)', overflow: 'hidden' }}>
|
||||||
|
<div style={{ aspectRatio: '4/3', minHeight: 80, maxHeight: 140 }}>{previewBlock}</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px' }}>
|
||||||
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{name}</div>
|
||||||
|
<div style={{ fontSize: 11, opacity: 0.6 }}>{formatSize(file.size)}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label="Удалить"
|
||||||
|
style={{ width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: 8, border: 'none', background: 'rgba(186,26,26,0.15)', color: '#ba1a1a', cursor: disabled ? 'not-allowed' : 'pointer' }}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function ExitLessonModal({ isOpen, lessonId, onClose, onExit }) {
|
||||||
|
const [step, setStep] = useState('choose_exit');
|
||||||
|
const [formData, setFormData] = useState({ mentor_grade: '', school_grade: '', mentor_notes: '' });
|
||||||
|
const [homeworkText, setHomeworkText] = useState('');
|
||||||
|
const [files, setFiles] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setMounted(true);
|
||||||
|
const t = requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => setVisible(true));
|
||||||
|
});
|
||||||
|
return () => cancelAnimationFrame(t);
|
||||||
|
}
|
||||||
|
setVisible(false);
|
||||||
|
return undefined;
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setStep('choose_exit');
|
||||||
|
setFormData({ mentor_grade: '', school_grade: '', mentor_notes: '' });
|
||||||
|
setHomeworkText('');
|
||||||
|
setFiles([]);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (!loading) {
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTransitionEnd = (e) => {
|
||||||
|
if (e.propertyName !== 'transform') return;
|
||||||
|
if (!isOpen) setMounted(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
const notes = (formData.mentor_notes || '').trim();
|
||||||
|
const mentorGrade = formData.mentor_grade ? parseInt(formData.mentor_grade, 10) : undefined;
|
||||||
|
const schoolGrade = formData.school_grade ? parseInt(formData.school_grade, 10) : undefined;
|
||||||
|
|
||||||
|
const handleJustExit = () => {
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
onExit();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCompleteNoHw = async () => {
|
||||||
|
if (lessonId == null) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await completeLesson(String(lessonId), notes, mentorGrade, schoolGrade, undefined);
|
||||||
|
if (!result?.success) {
|
||||||
|
setError(result?.message || 'Не удалось завершить занятие.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
onExit();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e?.message || 'Не удалось завершить занятие');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCompleteHwLater = async () => {
|
||||||
|
if (lessonId == null) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const completeResult = await completeLesson(String(lessonId), notes, mentorGrade, schoolGrade, undefined);
|
||||||
|
if (!completeResult?.success) {
|
||||||
|
setError(completeResult?.message || 'Не удалось завершить занятие.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await createHomework({ title: 'ДЗ (заполнить позже)', description: '', lesson_id: lessonId, status: 'draft', fill_later: true });
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
onExit();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e?.message || 'Не удалось завершить занятие');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveHw = async () => {
|
||||||
|
if (lessonId == null) return;
|
||||||
|
const text = homeworkText.trim();
|
||||||
|
if (!text && files.length === 0) {
|
||||||
|
setError('Добавьте текст или прикрепите файлы');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const lessonFileIds = [];
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
for (const file of files) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const created = await uploadLessonFile(lessonId, file);
|
||||||
|
const id = typeof created.id === 'number' ? created.id : parseInt(String(created.id), 10);
|
||||||
|
if (!Number.isNaN(id)) lessonFileIds.push(id);
|
||||||
|
}
|
||||||
|
await createHomework({ title: 'Домашнее задание', description: text || '', lesson_id: lessonId, status: 'published' });
|
||||||
|
const result = await completeLesson(String(lessonId), notes, mentorGrade, schoolGrade, text || undefined, true, lessonFileIds);
|
||||||
|
if (!result?.success) {
|
||||||
|
setError(result?.message || 'Занятие не удалось завершить.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
onExit();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e?.message || 'Не удалось сохранить ДЗ и завершить занятие');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (e) => {
|
||||||
|
const { files: list } = e.target;
|
||||||
|
if (!list?.length) return;
|
||||||
|
const add = [];
|
||||||
|
for (let i = 0; i < list.length; i += 1) {
|
||||||
|
const f = list[i];
|
||||||
|
if (f.size <= MAX_FILE_SIZE_MB * 1024 * 1024) add.push(f);
|
||||||
|
}
|
||||||
|
setFiles((prev) => [...prev, ...add].slice(0, MAX_LESSON_FILES));
|
||||||
|
e.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const stepTitle = {
|
||||||
|
choose_exit: 'Выйти из занятия',
|
||||||
|
grades: 'Оценки',
|
||||||
|
comment: 'Комментарий',
|
||||||
|
choose_hw: 'Завершить занятие',
|
||||||
|
hw_form: 'Домашнее задание',
|
||||||
|
}[step] || '';
|
||||||
|
|
||||||
|
const panelStyle = {
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: 420,
|
||||||
|
background: '#fff',
|
||||||
|
boxShadow: '-4px 0 24px rgba(0,0,0,0.15)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
zIndex: 10002,
|
||||||
|
transform: isOpen && visible ? 'translateX(0)' : 'translateX(100%)',
|
||||||
|
transition: 'transform 0.3s ease',
|
||||||
|
};
|
||||||
|
|
||||||
|
const btnBase = { padding: '14px 20px', borderRadius: 14, fontSize: 15, cursor: loading ? 'not-allowed' : 'pointer' };
|
||||||
|
const btnPrimary = { ...btnBase, border: 'none', background: '#1976d2', color: '#fff', fontWeight: 600 };
|
||||||
|
const btnSecondary = { ...btnBase, border: '1px solid #ddd', background: '#f5f5f5', color: '#333' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={panelStyle} onTransitionEnd={handleTransitionEnd}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '20px 24px', borderBottom: '1px solid #eee', flexShrink: 0 }}>
|
||||||
|
<h2 style={{ fontSize: 20, fontWeight: 600, margin: 0 }}>{stepTitle}</h2>
|
||||||
|
<button type="button" onClick={handleClose} disabled={loading} style={{ width: 44, height: 44, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'none', border: 'none', borderRadius: 12, cursor: 'pointer', fontSize: 20 }}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '24px 28px', overflowY: 'auto', flex: 1 }}>
|
||||||
|
{step === 'choose_exit' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<button type="button" onClick={handleJustExit} disabled={loading} style={{ ...btnSecondary, textAlign: 'left', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
🚪 Выйти
|
||||||
|
</button>
|
||||||
|
{lessonId != null && (
|
||||||
|
<button type="button" onClick={() => setStep('grades')} disabled={loading} style={{ ...btnPrimary, textAlign: 'left', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
✅ Выйти и завершить занятие
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'grades' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<p style={{ margin: '0 0 8px', fontSize: 14, color: '#666' }}>Необязательно</p>
|
||||||
|
<div>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
|
||||||
|
<label htmlFor="exit-mentor-grade" style={{ display: 'block', fontSize: 14, fontWeight: 500, marginBottom: 8, color: '#555' }}>
|
||||||
|
Оценка за занятие (1–5)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="exit-mentor-grade"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={5}
|
||||||
|
value={formData.mentor_grade}
|
||||||
|
onChange={(e) => setFormData((d) => ({ ...d, mentor_grade: e.target.value }))}
|
||||||
|
style={{ width: '100%', padding: '12px 16px', borderRadius: 12, border: '1px solid #ddd', fontSize: 15 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
|
||||||
|
<label htmlFor="exit-school-grade" style={{ display: 'block', fontSize: 14, fontWeight: 500, marginBottom: 8, color: '#555' }}>
|
||||||
|
Оценка в школе (1–5)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="exit-school-grade"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={5}
|
||||||
|
value={formData.school_grade}
|
||||||
|
onChange={(e) => setFormData((d) => ({ ...d, school_grade: e.target.value }))}
|
||||||
|
style={{ width: '100%', padding: '12px 16px', borderRadius: 12, border: '1px solid #ddd', fontSize: 15 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 12, marginTop: 8 }}>
|
||||||
|
<button type="button" onClick={() => setStep('choose_exit')} disabled={loading} style={btnSecondary}>Назад</button>
|
||||||
|
<button type="button" onClick={() => setStep('comment')} disabled={loading} style={{ ...btnPrimary, flex: 1 }}>Далее</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'comment' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<p style={{ margin: '0 0 8px', fontSize: 14, color: '#666' }}>Комментарий к занятию (необязательно)</p>
|
||||||
|
<textarea
|
||||||
|
value={formData.mentor_notes}
|
||||||
|
onChange={(e) => setFormData((d) => ({ ...d, mentor_notes: e.target.value }))}
|
||||||
|
rows={5}
|
||||||
|
placeholder="Что прошли, успехи, рекомендации..."
|
||||||
|
disabled={loading}
|
||||||
|
style={{ width: '100%', padding: '12px 16px', borderRadius: 12, border: '1px solid #ddd', fontSize: 15, resize: 'vertical' }}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: 12 }}>
|
||||||
|
<button type="button" onClick={() => setStep('grades')} disabled={loading} style={btnSecondary}>Назад</button>
|
||||||
|
<button type="button" onClick={() => setStep('choose_hw')} disabled={loading} style={{ ...btnPrimary, flex: 1 }}>Далее</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'choose_hw' && (
|
||||||
|
<>
|
||||||
|
<p style={{ margin: '0 0 16px', fontSize: 15, color: '#666' }}>Выдать домашнее задание?</p>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
<button type="button" onClick={() => setStep('comment')} disabled={loading} style={{ ...btnSecondary, textAlign: 'left', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
← Назад
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setStep('hw_form')} disabled={loading} style={{ ...btnSecondary, textAlign: 'left', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
📋 Да
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={handleCompleteNoHw} disabled={loading} style={{ ...btnSecondary, textAlign: 'left', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
✕ Нет
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={handleCompleteHwLater} disabled={loading} style={{ ...btnSecondary, textAlign: 'left', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
🕐 Позже (заполню на странице ДЗ)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'hw_form' && (
|
||||||
|
<>
|
||||||
|
<div style={{ marginBottom: 20 }}>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
|
||||||
|
<label htmlFor="exit-hw-text" style={{ display: 'block', fontSize: 14, fontWeight: 600, marginBottom: 8, color: '#555' }}>
|
||||||
|
Текст задания
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="exit-hw-text"
|
||||||
|
value={homeworkText}
|
||||||
|
onChange={(e) => setHomeworkText(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
placeholder="Опишите домашнее задание..."
|
||||||
|
disabled={loading}
|
||||||
|
style={{ width: '100%', padding: '12px 16px', borderRadius: 12, border: '1px solid #ddd', fontSize: 15, resize: 'vertical' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 20 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 8, color: '#555' }}>
|
||||||
|
Файлы (до {MAX_FILE_SIZE_MB} МБ, не более {MAX_LESSON_FILES} шт.)
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="exit-lesson-file-input"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
onChange={handleFileChange}
|
||||||
|
disabled={loading}
|
||||||
|
style={{ position: 'absolute', width: 1, height: 1, opacity: 0, pointerEvents: 'none' }}
|
||||||
|
/>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
|
||||||
|
<label
|
||||||
|
htmlFor="exit-lesson-file-input"
|
||||||
|
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10, padding: '16px 20px', borderRadius: 12, border: '2px dashed #ddd', background: '#f9f9f9', fontSize: 15, cursor: loading ? 'not-allowed' : 'pointer' }}
|
||||||
|
>
|
||||||
|
📎 Прикрепить файлы
|
||||||
|
</label>
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{files.map((f, i) => (
|
||||||
|
<FilePreviewChip
|
||||||
|
key={`${f.name}-${i}-${f.size}`}
|
||||||
|
file={f}
|
||||||
|
onRemove={() => setFiles((p) => p.filter((_, j) => j !== i))}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div style={{ background: 'rgba(186,26,26,0.1)', border: '1px solid #ba1a1a', borderRadius: 12, padding: 12, marginBottom: 16, fontSize: 14, color: '#ba1a1a' }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<button type="button" onClick={() => setStep('choose_hw')} disabled={loading} style={btnSecondary}>Назад</button>
|
||||||
|
<button type="button" onClick={handleCompleteHwLater} disabled={loading} style={{ ...btnSecondary, flex: 1, minWidth: 100 }}>Позже</button>
|
||||||
|
<button type="button" onClick={handleSaveHw} disabled={loading} style={{ ...btnPrimary, flex: 1, minWidth: 100, opacity: loading ? 0.8 : 1 }}>
|
||||||
|
{loading ? 'Сохранение...' : 'Сохранить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export { VideoCallView } from './video-call-view';
|
||||||
|
|
@ -0,0 +1,710 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import 'src/styles/livekit-theme.css';
|
||||||
|
import 'src/styles/livekit-components.css';
|
||||||
|
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { isTrackReference } from '@livekit/components-core';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { useRef, useState, useEffect, Component } from 'react';
|
||||||
|
import { Track, RoomEvent, VideoPresets } from 'livekit-client';
|
||||||
|
import {
|
||||||
|
useTracks,
|
||||||
|
LiveKitRoom,
|
||||||
|
useStartAudio,
|
||||||
|
useRoomContext,
|
||||||
|
VideoConference,
|
||||||
|
ParticipantTile,
|
||||||
|
RoomAudioRenderer,
|
||||||
|
ConnectionStateToast,
|
||||||
|
} from '@livekit/components-react';
|
||||||
|
|
||||||
|
import { getLesson } from 'src/utils/dashboard-api';
|
||||||
|
import { getOrCreateLessonBoard } from 'src/utils/board-api';
|
||||||
|
import { getLiveKitConfig, participantConnected } from 'src/utils/livekit-api';
|
||||||
|
|
||||||
|
import { ExitLessonModal } from 'src/sections/video-call/livekit/exit-lesson-modal';
|
||||||
|
|
||||||
|
import { useAuthContext } from 'src/auth/hooks';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const PRESET_2K = {
|
||||||
|
resolution: { width: 2560, height: 1440 },
|
||||||
|
encoding: { maxBitrate: 6_000_000, maxFramerate: 30 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHAT_PANEL_WIDTH = 420;
|
||||||
|
|
||||||
|
const LS_AUDIO_PLAYBACK_ALLOWED = 'videoConference_audioPlaybackAllowed';
|
||||||
|
const LS_AUDIO_ENABLED = 'videoConference_audioEnabled';
|
||||||
|
const LS_VIDEO_ENABLED = 'videoConference_videoEnabled';
|
||||||
|
const SS_PREJOIN_DONE = 'livekit_prejoin_done';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function isLiveKitLayoutError(error) {
|
||||||
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
|
return (
|
||||||
|
msg.includes('Element not part of the array') ||
|
||||||
|
msg.includes('updatePages') ||
|
||||||
|
msg.includes('_placeholder not in')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class LiveKitLayoutErrorBoundary extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = { error: null, recoverKey: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error) {
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error) {
|
||||||
|
if (isLiveKitLayoutError(error)) {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
this.setState((s) => ({ error: null, recoverKey: s.recoverKey + 1 }));
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { error, recoverKey } = this.state;
|
||||||
|
const { children } = this.props;
|
||||||
|
if (error && !isLiveKitLayoutError(error)) throw error;
|
||||||
|
if (error) return <div style={{ flex: 1, background: '#000', minHeight: 200 }} />;
|
||||||
|
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function getSavedAudioVideo() {
|
||||||
|
try {
|
||||||
|
const rawA = localStorage.getItem(LS_AUDIO_ENABLED);
|
||||||
|
const rawV = localStorage.getItem(LS_VIDEO_ENABLED);
|
||||||
|
return { audio: rawA !== 'false', video: rawV !== 'false' };
|
||||||
|
} catch {
|
||||||
|
return { audio: true, video: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitialShowPreJoin(sp) {
|
||||||
|
try {
|
||||||
|
if (typeof window !== 'undefined' && sessionStorage.getItem(SS_PREJOIN_DONE) === '1') return false;
|
||||||
|
return sp.get('skip_prejoin') !== '1';
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTokenMetadata(token) {
|
||||||
|
if (!token) return {};
|
||||||
|
try {
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 3) return {};
|
||||||
|
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
|
||||||
|
const { metadata } = payload;
|
||||||
|
if (!metadata || typeof metadata !== 'string') return {};
|
||||||
|
const parsed = JSON.parse(metadata);
|
||||||
|
return {
|
||||||
|
board_id: parsed?.board_id ?? undefined,
|
||||||
|
is_mentor: parsed?.is_mentor === true,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function StartAudioOverlay() {
|
||||||
|
const room = useRoomContext();
|
||||||
|
const { mergedProps, canPlayAudio } = useStartAudio({ room, props: {} });
|
||||||
|
const [dismissed, setDismissed] = useState(() => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(LS_AUDIO_PLAYBACK_ALLOWED) === 'true';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (canPlayAudio) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LS_AUDIO_PLAYBACK_ALLOWED, 'true');
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}, [canPlayAudio]);
|
||||||
|
|
||||||
|
if (canPlayAudio || dismissed) return null;
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LS_AUDIO_PLAYBACK_ALLOWED, 'true');
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
setDismissed(true);
|
||||||
|
mergedProps.onClick?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.75)', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 24, padding: 24 }}>
|
||||||
|
<p style={{ color: '#fff', fontSize: 18, textAlign: 'center', margin: 0 }}>Чтобы слышать собеседника, разрешите воспроизведение звука</p>
|
||||||
|
<button type="button" onClick={handleClick} style={{ padding: '16px 32px', fontSize: 18, fontWeight: 600, borderRadius: 12, border: 'none', background: '#1976d2', color: '#fff', cursor: 'pointer' }}>
|
||||||
|
🔊 Разрешить звук
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function RemoteParticipantPiP({ chatOpen }) {
|
||||||
|
const tracks = useTracks([Track.Source.Camera, Track.Source.ScreenShare], { onlySubscribed: true });
|
||||||
|
const remoteRef = tracks.find((ref) => isTrackReference(ref) && ref.participant && !ref.participant.isLocal);
|
||||||
|
if (!remoteRef || !isTrackReference(remoteRef)) return null;
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'fixed', bottom: 24, right: chatOpen ? CHAT_PANEL_WIDTH + 24 : 24, width: 280, height: 158, zIndex: 10000, borderRadius: 12, overflow: 'hidden', boxShadow: '0 8px 32px rgba(0,0,0,0.5)', border: '2px solid rgba(255,255,255,0.2)', background: '#000' }}>
|
||||||
|
<ParticipantTile trackRef={remoteRef} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function WhiteboardIframe({ boardId, showingBoard }) {
|
||||||
|
const excalidrawUrl = process.env.NEXT_PUBLIC_EXCALIDRAW_URL || '';
|
||||||
|
const iframeRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!excalidrawUrl || !boardId) return undefined;
|
||||||
|
const container = iframeRef.current;
|
||||||
|
if (!container) return undefined;
|
||||||
|
const iframe = document.createElement('iframe');
|
||||||
|
iframe.src = `${excalidrawUrl}?boardId=${boardId}`;
|
||||||
|
iframe.style.cssText = 'width:100%;height:100%;border:none;';
|
||||||
|
iframe.allow = 'camera; microphone; fullscreen';
|
||||||
|
container.innerHTML = '';
|
||||||
|
container.appendChild(iframe);
|
||||||
|
return () => {
|
||||||
|
container.innerHTML = '';
|
||||||
|
};
|
||||||
|
}, [boardId, excalidrawUrl]);
|
||||||
|
|
||||||
|
if (!excalidrawUrl) {
|
||||||
|
return (
|
||||||
|
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', background: '#111' }}>
|
||||||
|
Доска не настроена (NEXT_PUBLIC_EXCALIDRAW_URL)
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={iframeRef}
|
||||||
|
style={{ width: '100%', height: '100%', pointerEvents: showingBoard ? 'auto' : 'none' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function PreJoinScreen({ onJoin, onCancel }) {
|
||||||
|
const [audioEnabled, setAudioEnabled] = useState(true);
|
||||||
|
const [videoEnabled, setVideoEnabled] = useState(true);
|
||||||
|
const videoRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = getSavedAudioVideo();
|
||||||
|
setAudioEnabled(saved.audio);
|
||||||
|
setVideoEnabled(saved.video);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!videoEnabled) return undefined;
|
||||||
|
let stream = null;
|
||||||
|
navigator.mediaDevices
|
||||||
|
.getUserMedia({ video: { width: { ideal: 2560 }, height: { ideal: 1440 }, frameRate: { ideal: 30 } }, audio: false })
|
||||||
|
.then((s) => {
|
||||||
|
stream = s;
|
||||||
|
if (videoRef.current) videoRef.current.srcObject = s;
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
return () => {
|
||||||
|
stream?.getTracks().forEach((t) => t.stop());
|
||||||
|
};
|
||||||
|
}, [videoEnabled]);
|
||||||
|
|
||||||
|
const handleJoin = () => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LS_AUDIO_ENABLED, String(audioEnabled));
|
||||||
|
localStorage.setItem(LS_VIDEO_ENABLED, String(videoEnabled));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
onJoin(audioEnabled, videoEnabled);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', background: 'linear-gradient(135deg, #f5f7fa 0%, #e4e8ec 100%)' }}>
|
||||||
|
<div style={{ background: '#fff', borderRadius: 20, maxWidth: 520, width: '100%', overflow: 'hidden', boxShadow: '0 8px 32px rgba(0,0,0,0.1)' }}>
|
||||||
|
<div style={{ background: 'linear-gradient(135deg, #1976d2 0%, #7c4dff 100%)', padding: 24, color: '#fff' }}>
|
||||||
|
<h1 style={{ fontSize: 22, fontWeight: 700, margin: 0 }}>Настройки перед входом</h1>
|
||||||
|
<p style={{ margin: '8px 0 0 0', opacity: 0.9 }}>Настройте камеру и микрофон</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<div style={{ marginBottom: 24, background: '#000', borderRadius: 12, aspectRatio: '16/9', overflow: 'hidden' }}>
|
||||||
|
{videoEnabled ? (
|
||||||
|
<video ref={videoRef} autoPlay playsInline muted style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
) : (
|
||||||
|
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', color: '#666', minHeight: 120 }}>
|
||||||
|
<span style={{ fontSize: 48 }}>📵</span>
|
||||||
|
<p style={{ margin: 8 }}>Камера выключена</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 24 }}>
|
||||||
|
{[
|
||||||
|
{ label: 'Микрофон', enabled: audioEnabled, toggle: () => setAudioEnabled((v) => !v) },
|
||||||
|
{ label: 'Камера', enabled: videoEnabled, toggle: () => setVideoEnabled((v) => !v) },
|
||||||
|
].map(({ label, enabled, toggle }) => (
|
||||||
|
<div key={label} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: 16, background: '#f5f5f5', borderRadius: 12 }}>
|
||||||
|
<span>{label}</span>
|
||||||
|
<button onClick={toggle} type="button" style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: enabled ? '#1976d2' : '#888', color: '#fff', cursor: 'pointer' }}>
|
||||||
|
{enabled ? 'Выключить' : 'Включить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 12 }}>
|
||||||
|
<button type="button" onClick={onCancel} style={{ flex: 1, padding: '14px 24px', borderRadius: 14, border: '1px solid #ddd', background: 'transparent', cursor: 'pointer' }}>Отмена</button>
|
||||||
|
<button type="button" onClick={handleJoin} style={{ flex: 1, padding: '14px 24px', borderRadius: 14, border: 'none', background: '#1976d2', color: '#fff', fontWeight: 600, cursor: 'pointer' }}>
|
||||||
|
📹 Войти в конференцию
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function RoomContent({ lessonId, boardId, boardLoading, showBoard, setShowBoard }) {
|
||||||
|
const room = useRoomContext();
|
||||||
|
const router = useRouter();
|
||||||
|
const { user } = useAuthContext();
|
||||||
|
const [showPlatformChat, setShowPlatformChat] = useState(false);
|
||||||
|
const [showExitModal, setShowExitModal] = useState(false);
|
||||||
|
const [showNavMenu, setShowNavMenu] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onConnected = () => {
|
||||||
|
if (room.name || lessonId) {
|
||||||
|
participantConnected({ roomName: room.name || '', lessonId: lessonId ?? undefined }).catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
room.on(RoomEvent.Connected, onConnected);
|
||||||
|
if (room.state === 'connected' && (room.name || lessonId)) {
|
||||||
|
participantConnected({ roomName: room.name || '', lessonId: lessonId ?? undefined }).catch(() => {});
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
room.off(RoomEvent.Connected, onConnected);
|
||||||
|
};
|
||||||
|
}, [room, lessonId]);
|
||||||
|
|
||||||
|
// Inject exit and burger buttons into LiveKit control bar
|
||||||
|
useEffect(() => {
|
||||||
|
if (showBoard) return undefined;
|
||||||
|
const id = setTimeout(() => {
|
||||||
|
const bar = document.querySelector('.lk-control-bar');
|
||||||
|
if (!bar) return;
|
||||||
|
if (!bar.querySelector('.lk-burger-button')) {
|
||||||
|
const burger = document.createElement('button');
|
||||||
|
burger.type = 'button';
|
||||||
|
burger.className = 'lk-button lk-burger-button';
|
||||||
|
burger.title = 'Меню';
|
||||||
|
burger.textContent = '☰';
|
||||||
|
burger.addEventListener('click', () => window.dispatchEvent(new CustomEvent('livekit-burger-click')));
|
||||||
|
bar.insertBefore(burger, bar.firstChild);
|
||||||
|
}
|
||||||
|
if (!bar.querySelector('.lk-custom-exit-button')) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'lk-button lk-custom-exit-button';
|
||||||
|
btn.title = 'Выйти';
|
||||||
|
btn.textContent = '🚪';
|
||||||
|
btn.addEventListener('click', () => window.dispatchEvent(new CustomEvent('livekit-exit-click')));
|
||||||
|
bar.appendChild(btn);
|
||||||
|
}
|
||||||
|
}, 800);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}, [showBoard]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => setShowNavMenu((v) => !v);
|
||||||
|
window.addEventListener('livekit-burger-click', handler);
|
||||||
|
return () => window.removeEventListener('livekit-burger-click', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => {
|
||||||
|
if (user?.role === 'mentor') {
|
||||||
|
setShowExitModal(true);
|
||||||
|
} else {
|
||||||
|
room.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('livekit-exit-click', handler);
|
||||||
|
return () => window.removeEventListener('livekit-exit-click', handler);
|
||||||
|
}, [user?.role, room]);
|
||||||
|
|
||||||
|
// Save audio/video state on track events
|
||||||
|
useEffect(() => {
|
||||||
|
const lp = room.localParticipant;
|
||||||
|
if (!lp) return undefined;
|
||||||
|
const save = () => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LS_AUDIO_ENABLED, String(lp.isMicrophoneEnabled));
|
||||||
|
localStorage.setItem(LS_VIDEO_ENABLED, String(lp.isCameraEnabled));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
const onMuted = (pub, participant) => {
|
||||||
|
if (participant?.sid !== lp.sid) return;
|
||||||
|
if (pub?.source === Track.Source.Microphone) {
|
||||||
|
try { localStorage.setItem(LS_AUDIO_ENABLED, 'false'); } catch { /* ignore */ }
|
||||||
|
} else if (pub?.source === Track.Source.Camera) {
|
||||||
|
try { localStorage.setItem(LS_VIDEO_ENABLED, 'false'); } catch { /* ignore */ }
|
||||||
|
} else {
|
||||||
|
save();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onUnmuted = (pub, participant) => {
|
||||||
|
if (participant?.sid !== lp.sid) return;
|
||||||
|
if (pub?.source === Track.Source.Microphone) {
|
||||||
|
try { localStorage.setItem(LS_AUDIO_ENABLED, 'true'); } catch { /* ignore */ }
|
||||||
|
} else if (pub?.source === Track.Source.Camera) {
|
||||||
|
try { localStorage.setItem(LS_VIDEO_ENABLED, 'true'); } catch { /* ignore */ }
|
||||||
|
} else {
|
||||||
|
save();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
room.on(RoomEvent.TrackMuted, onMuted);
|
||||||
|
room.on(RoomEvent.TrackUnmuted, onUnmuted);
|
||||||
|
room.on(RoomEvent.LocalTrackPublished, save);
|
||||||
|
save();
|
||||||
|
return () => {
|
||||||
|
room.off(RoomEvent.TrackMuted, onMuted);
|
||||||
|
room.off(RoomEvent.TrackUnmuted, onUnmuted);
|
||||||
|
room.off(RoomEvent.LocalTrackPublished, save);
|
||||||
|
};
|
||||||
|
}, [room]);
|
||||||
|
|
||||||
|
const sidebarStyle = {
|
||||||
|
position: 'fixed',
|
||||||
|
right: showPlatformChat ? CHAT_PANEL_WIDTH + 16 : 16,
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 8,
|
||||||
|
padding: 8,
|
||||||
|
background: 'rgba(0,0,0,0.7)',
|
||||||
|
borderRadius: 12,
|
||||||
|
zIndex: 10001,
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconBtn = (active, disabled, title, icon, onClick) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
title={title}
|
||||||
|
style={{
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 12,
|
||||||
|
border: 'none',
|
||||||
|
background: active ? '#1976d2' : 'rgba(255,255,255,0.2)',
|
||||||
|
color: '#fff',
|
||||||
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: 20,
|
||||||
|
opacity: disabled ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100vh', position: 'relative', display: 'flex' }}>
|
||||||
|
<StartAudioOverlay />
|
||||||
|
<div style={{ flex: 1, position: 'relative', background: '#000' }}>
|
||||||
|
<div style={{ position: 'absolute', inset: 0, zIndex: showBoard ? 0 : 1 }}>
|
||||||
|
<LiveKitLayoutErrorBoundary>
|
||||||
|
<VideoConference chatMessageFormatter={(message) => message} />
|
||||||
|
</LiveKitLayoutErrorBoundary>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{typeof document !== 'undefined' &&
|
||||||
|
createPortal(
|
||||||
|
<>
|
||||||
|
{showBoard && <RemoteParticipantPiP chatOpen={showPlatformChat} />}
|
||||||
|
{showBoard && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowNavMenu((v) => !v)}
|
||||||
|
style={{ position: 'fixed', left: 16, bottom: 64, width: 48, height: 48, borderRadius: 12, border: 'none', background: 'rgba(0,0,0,0.7)', color: '#fff', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10002, fontSize: 20 }}
|
||||||
|
title="Меню"
|
||||||
|
>
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{showNavMenu && (
|
||||||
|
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||||
|
<div
|
||||||
|
onClick={() => setShowNavMenu(false)}
|
||||||
|
onKeyDown={(e) => e.key === 'Escape' && setShowNavMenu(false)}
|
||||||
|
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 10003, backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||||
|
<div onClick={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} style={{ background: '#fff', borderRadius: 16, padding: 24, minWidth: 240 }}>
|
||||||
|
<button type="button" onClick={() => { setShowNavMenu(false); router.push('/dashboard'); }} style={{ width: '100%', padding: '12px 16px', border: 'none', background: 'none', textAlign: 'left', cursor: 'pointer', fontSize: 16, borderRadius: 8 }}>
|
||||||
|
🏠 На главную
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setShowNavMenu(false)} style={{ width: '100%', padding: '12px 16px', border: 'none', background: 'none', textAlign: 'left', cursor: 'pointer', fontSize: 16, borderRadius: 8 }}>
|
||||||
|
✕ Закрыть
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={sidebarStyle}>
|
||||||
|
{iconBtn(!showBoard, false, 'Камера', '📹', () => setShowBoard(false))}
|
||||||
|
{iconBtn(showBoard, !boardId || boardLoading, boardLoading ? 'Загрузка доски...' : !boardId ? 'Доска недоступна' : 'Доска', boardLoading ? '⏳' : '🎨', () => boardId && !boardLoading && setShowBoard(true))}
|
||||||
|
{lessonId != null && iconBtn(showPlatformChat, false, 'Чат', '💬', () => setShowPlatformChat((v) => !v))}
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ExitLessonModal
|
||||||
|
isOpen={showExitModal}
|
||||||
|
lessonId={lessonId}
|
||||||
|
onClose={() => setShowExitModal(false)}
|
||||||
|
onExit={() => room.disconnect()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function VideoCallView() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const accessToken = searchParams.get('token');
|
||||||
|
const lessonIdParam = searchParams.get('lesson_id');
|
||||||
|
const { user } = useAuthContext();
|
||||||
|
|
||||||
|
const [serverUrl, setServerUrl] = useState('');
|
||||||
|
const [showPreJoin, setShowPreJoin] = useState(() => getInitialShowPreJoin(searchParams));
|
||||||
|
const [audioEnabled, setAudioEnabled] = useState(true);
|
||||||
|
const [videoEnabled, setVideoEnabled] = useState(true);
|
||||||
|
const [avReady, setAvReady] = useState(false);
|
||||||
|
const [lessonCompleted, setLessonCompleted] = useState(false);
|
||||||
|
const [effectiveLessonId, setEffectiveLessonId] = useState(null);
|
||||||
|
const [boardId, setBoardId] = useState(null);
|
||||||
|
const [boardLoading, setBoardLoading] = useState(false);
|
||||||
|
const [showBoard, setShowBoard] = useState(false);
|
||||||
|
const boardPollRef = useRef(null);
|
||||||
|
|
||||||
|
// Load audio/video preferences from localStorage after mount
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = getSavedAudioVideo();
|
||||||
|
setAudioEnabled(saved.audio);
|
||||||
|
setVideoEnabled(saved.video);
|
||||||
|
setAvReady(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Determine effective lesson ID
|
||||||
|
useEffect(() => {
|
||||||
|
const id = lessonIdParam ? parseInt(lessonIdParam, 10) : null;
|
||||||
|
if (id && !Number.isNaN(id)) {
|
||||||
|
setEffectiveLessonId(id);
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem('livekit_lesson_id', String(id));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const stored = sessionStorage.getItem('livekit_lesson_id');
|
||||||
|
if (stored) setEffectiveLessonId(parseInt(stored, 10));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}, [lessonIdParam]);
|
||||||
|
|
||||||
|
// Load server URL + check lesson status
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
if (lessonIdParam) {
|
||||||
|
try {
|
||||||
|
const l = await getLesson(lessonIdParam);
|
||||||
|
if (l.status === 'completed') {
|
||||||
|
const now = new Date();
|
||||||
|
const end = l.completed_at ? new Date(l.completed_at) : new Date(l.end_time);
|
||||||
|
if (now > new Date(end.getTime() + 10 * 60000)) setLessonCompleted(true);
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const config = await getLiveKitConfig();
|
||||||
|
setServerUrl(config.server_url || 'ws://127.0.0.1:7880');
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
}, [lessonIdParam]);
|
||||||
|
|
||||||
|
// Board: from token metadata or poll API
|
||||||
|
useEffect(() => {
|
||||||
|
const meta = getTokenMetadata(accessToken);
|
||||||
|
if (meta.board_id) {
|
||||||
|
setBoardId(meta.board_id);
|
||||||
|
setBoardLoading(false);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!effectiveLessonId) {
|
||||||
|
setBoardLoading(false);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
const stopPoll = () => {
|
||||||
|
if (boardPollRef.current) {
|
||||||
|
clearInterval(boardPollRef.current);
|
||||||
|
boardPollRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setBoardLoading(true);
|
||||||
|
getOrCreateLessonBoard(effectiveLessonId)
|
||||||
|
.then((b) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setBoardId(b.board_id);
|
||||||
|
stopPoll();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setBoardLoading(false);
|
||||||
|
});
|
||||||
|
boardPollRef.current = setInterval(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
getOrCreateLessonBoard(effectiveLessonId)
|
||||||
|
.then((b) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setBoardId(b.board_id);
|
||||||
|
stopPoll();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, 10_000);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
stopPoll();
|
||||||
|
};
|
||||||
|
}, [accessToken, effectiveLessonId]);
|
||||||
|
|
||||||
|
if (lessonCompleted) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', background: '#f5f5f5' }}>
|
||||||
|
<div style={{ textAlign: 'center', padding: 24 }}>
|
||||||
|
<p style={{ fontSize: 18, marginBottom: 16 }}>Урок завершён. Видеоконференция недоступна.</p>
|
||||||
|
<button type="button" onClick={() => router.push('/dashboard')} style={{ padding: '12px 24px', borderRadius: 12, border: 'none', background: '#1976d2', color: '#fff', cursor: 'pointer' }}>
|
||||||
|
На главную
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accessToken || !serverUrl) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
|
||||||
|
<p>Загрузка...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showPreJoin) {
|
||||||
|
return (
|
||||||
|
<PreJoinScreen
|
||||||
|
onJoin={(audio, video) => {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(SS_PREJOIN_DONE, '1');
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
setAudioEnabled(audio);
|
||||||
|
setVideoEnabled(video);
|
||||||
|
setShowPreJoin(false);
|
||||||
|
}}
|
||||||
|
onCancel={() => router.push('/dashboard')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!avReady) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
|
||||||
|
<p>Загрузка...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{boardId && (
|
||||||
|
<div
|
||||||
|
key="board-layer"
|
||||||
|
style={{ position: 'fixed', inset: 0, zIndex: showBoard ? 9999 : 0, pointerEvents: showBoard ? 'auto' : 'none' }}
|
||||||
|
>
|
||||||
|
<WhiteboardIframe boardId={boardId} showingBoard={showBoard} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<LiveKitRoom
|
||||||
|
token={accessToken}
|
||||||
|
serverUrl={serverUrl}
|
||||||
|
connect
|
||||||
|
audio={audioEnabled}
|
||||||
|
video={videoEnabled}
|
||||||
|
onDisconnected={() => router.push('/dashboard')}
|
||||||
|
style={{ height: '100vh' }}
|
||||||
|
data-lk-theme="default"
|
||||||
|
options={{
|
||||||
|
adaptiveStream: true,
|
||||||
|
dynacast: true,
|
||||||
|
videoCaptureDefaults: { resolution: PRESET_2K.resolution, frameRate: 30 },
|
||||||
|
publishDefaults: {
|
||||||
|
simulcast: true,
|
||||||
|
videoEncoding: PRESET_2K.encoding,
|
||||||
|
videoSimulcastLayers: [VideoPresets.h720, VideoPresets.h360],
|
||||||
|
screenShareEncoding: { maxBitrate: 6_000_000, maxFramerate: 30 },
|
||||||
|
screenShareSimulcastLayers: [VideoPresets.h720, VideoPresets.h360],
|
||||||
|
degradationPreference: 'maintain-resolution',
|
||||||
|
},
|
||||||
|
audioCaptureDefaults: { noiseSuppression: true, echoCancellation: true },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RoomContent
|
||||||
|
lessonId={effectiveLessonId}
|
||||||
|
boardId={boardId}
|
||||||
|
boardLoading={boardLoading}
|
||||||
|
showBoard={showBoard}
|
||||||
|
setShowBoard={setShowBoard}
|
||||||
|
/>
|
||||||
|
<RoomAudioRenderer />
|
||||||
|
<ConnectionStateToast />
|
||||||
|
</LiveKitRoom>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,429 @@
|
||||||
|
/**
|
||||||
|
* Кастомизация LiveKit через CSS переменные.
|
||||||
|
* Все стили и скрипты LiveKit отдаются с нашего сервера (бандл + этот файл).
|
||||||
|
*/
|
||||||
|
|
||||||
|
@keyframes lk-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Цвета фона */
|
||||||
|
--lk-bg: #1a1a1a;
|
||||||
|
--lk-bg2: #2a2a2a;
|
||||||
|
--lk-bg3: #3a3a3a;
|
||||||
|
|
||||||
|
/* Цвета текста */
|
||||||
|
--lk-fg: #ffffff;
|
||||||
|
--lk-fg2: rgba(255, 255, 255, 0.7);
|
||||||
|
|
||||||
|
/* Основные цвета */
|
||||||
|
--lk-control-bg: var(--md-sys-color-primary);
|
||||||
|
--lk-control-hover-bg: var(--md-sys-color-primary-container);
|
||||||
|
--lk-button-bg: rgba(255, 255, 255, 0.15);
|
||||||
|
--lk-button-hover-bg: rgba(255, 255, 255, 0.25);
|
||||||
|
|
||||||
|
/* Границы */
|
||||||
|
--lk-border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
--lk-border-radius: 12px;
|
||||||
|
|
||||||
|
/* Фокус */
|
||||||
|
--lk-focus-ring: var(--md-sys-color-primary);
|
||||||
|
|
||||||
|
/* Ошибки */
|
||||||
|
--lk-danger: var(--md-sys-color-error);
|
||||||
|
|
||||||
|
/* Размеры */
|
||||||
|
--lk-control-bar-height: 80px;
|
||||||
|
--lk-participant-tile-gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Панель управления — без ограничения по ширине */
|
||||||
|
.lk-control-bar {
|
||||||
|
background: rgba(0, 0, 0, 0.8) !important;
|
||||||
|
backdrop-filter: blur(20px) !important;
|
||||||
|
border-radius: 16px !important;
|
||||||
|
padding: 12px 16px !important;
|
||||||
|
margin: 16px !important;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4) !important;
|
||||||
|
max-width: none !important;
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lk-control-bar .lk-button-group,
|
||||||
|
.lk-control-bar .lk-button-group-menu {
|
||||||
|
max-width: none !important;
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Кнопки управления — ширина по контенту, без жёсткого ограничения */
|
||||||
|
.lk-control-bar .lk-button {
|
||||||
|
min-width: 48px !important;
|
||||||
|
width: auto !important;
|
||||||
|
height: 48px !important;
|
||||||
|
border-radius: 12px !important;
|
||||||
|
transition: all 0.2s ease !important;
|
||||||
|
padding-left: 12px !important;
|
||||||
|
padding-right: 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Русские подписи: скрываем английский текст, показываем свой */
|
||||||
|
.lk-control-bar .lk-button[data-lk-source="microphone"],
|
||||||
|
.lk-control-bar .lk-button[data-lk-source="camera"],
|
||||||
|
.lk-control-bar .lk-button[data-lk-source="screen_share"],
|
||||||
|
.lk-control-bar .lk-chat-toggle,
|
||||||
|
.lk-control-bar .lk-disconnect-button,
|
||||||
|
.lk-control-bar .lk-start-audio-button {
|
||||||
|
font-size: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lk-control-bar .lk-button[data-lk-source="microphone"] > svg,
|
||||||
|
.lk-control-bar .lk-button[data-lk-source="camera"] > svg,
|
||||||
|
.lk-control-bar .lk-button[data-lk-source="screen_share"] > svg,
|
||||||
|
.lk-control-bar .lk-chat-toggle > svg,
|
||||||
|
.lk-control-bar .lk-disconnect-button > svg {
|
||||||
|
width: 16px !important;
|
||||||
|
height: 16px !important;
|
||||||
|
flex-shrink: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lk-control-bar .lk-button[data-lk-source="microphone"]::after {
|
||||||
|
content: "Микрофон";
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lk-control-bar .lk-button[data-lk-source="camera"]::after {
|
||||||
|
content: "Камера";
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lk-control-bar .lk-button[data-lk-source="screen_share"]::after {
|
||||||
|
content: "Поделиться экраном";
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lk-control-bar .lk-button[data-lk-source="screen_share"][data-lk-enabled="true"]::after {
|
||||||
|
content: "Остановить демонстрацию";
|
||||||
|
}
|
||||||
|
|
||||||
|
.lk-control-bar .lk-chat-toggle::after {
|
||||||
|
content: "Чат";
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Кнопка бургер слева от микрофона — в панели LiveKit */
|
||||||
|
.lk-burger-button {
|
||||||
|
background: rgba(255, 255, 255, 0.15) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Скрываем стандартную кнопку «Выйти» — используем свою внутри панели (модалка: Выйти / Выйти и завершить занятие) */
|
||||||
|
.lk-control-bar .lk-disconnect-button {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.lk-control-bar .lk-disconnect-button::after {
|
||||||
|
content: "Выйти";
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Наша кнопка «Выйти» — внутри панели, рядом с «Поделиться экраном» */
|
||||||
|
.lk-control-bar .lk-custom-exit-button {
|
||||||
|
font-size: 0 !important;
|
||||||
|
background: var(--md-sys-color-error) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex !important;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.lk-control-bar .lk-custom-exit-button::after {
|
||||||
|
content: "Выйти";
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.lk-control-bar .lk-custom-exit-button > .material-symbols-outlined {
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Скрываем кнопку «Начать видео» — у нас свой StartAudioOverlay */
|
||||||
|
.lk-control-bar .lk-start-audio-button {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Кнопки без текста (только иконка) — минимальный размер */
|
||||||
|
.lk-button {
|
||||||
|
min-width: 48px !important;
|
||||||
|
width: auto !important;
|
||||||
|
height: 48px !important;
|
||||||
|
border-radius: 12px !important;
|
||||||
|
transition: all 0.2s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lk-button:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lk-button:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Активная кнопка */
|
||||||
|
.lk-button[data-lk-enabled="true"] {
|
||||||
|
background: var(--md-sys-color-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Кнопка отключения — белые иконка и текст */
|
||||||
|
.lk-disconnect-button {
|
||||||
|
background: var(--md-sys-color-error) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
.lk-disconnect-button > svg {
|
||||||
|
color: #fff !important;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Плитки участников */
|
||||||
|
.lk-participant-tile {
|
||||||
|
border-radius: 12px !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Плейсхолдер без камеры: скрываем дефолтную SVG, показываем аватар из API */
|
||||||
|
.lk-participant-tile .lk-participant-placeholder svg {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Контейнер для аватара — нужен для container queries */
|
||||||
|
.lk-participant-tile .lk-participant-placeholder {
|
||||||
|
container-type: size;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lk-participant-tile .lk-participant-placeholder img.lk-participant-avatar-img {
|
||||||
|
/* Квадрат: меньшая сторона контейнера, максимум 400px */
|
||||||
|
--avatar-size: min(min(80cqw, 80cqh), 400px);
|
||||||
|
width: var(--avatar-size);
|
||||||
|
height: var(--avatar-size);
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fallback для браузеров без container queries */
|
||||||
|
@supports not (width: 1cqw) {
|
||||||
|
.lk-participant-tile .lk-participant-placeholder img.lk-participant-avatar-img {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Имя участника — белый текст (Камера, PiP) */
|
||||||
|
.lk-participant-name {
|
||||||
|
background: rgba(0, 0, 0, 0.7) !important;
|
||||||
|
backdrop-filter: blur(10px) !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
padding: 6px 12px !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Чат LiveKit скрыт — используем чат сервиса (платформы) */
|
||||||
|
.lk-video-conference .lk-chat {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lk-control-bar .lk-chat-toggle {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стили чата платформы оставляем для других страниц */
|
||||||
|
.lk-chat {
|
||||||
|
background: var(--md-sys-color-surface) !important;
|
||||||
|
border-left: 1px solid var(--md-sys-color-outline) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lk-chat-entry {
|
||||||
|
background: var(--md-sys-color-surface-container) !important;
|
||||||
|
border-radius: 12px !important;
|
||||||
|
padding: 12px !important;
|
||||||
|
margin-bottom: 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Сетка участников */
|
||||||
|
.lk-grid-layout {
|
||||||
|
gap: 12px !important;
|
||||||
|
padding: 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Меню выбора устройств — без ограничения по ширине */
|
||||||
|
.lk-device-menu,
|
||||||
|
.lk-media-device-select {
|
||||||
|
max-width: none !important;
|
||||||
|
width: max-content !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lk-media-device-select {
|
||||||
|
background: rgba(0, 0, 0, 0.95) !important;
|
||||||
|
backdrop-filter: blur(20px) !important;
|
||||||
|
border-radius: 12px !important;
|
||||||
|
padding: 8px !important;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4) !important;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lk-media-device-select button {
|
||||||
|
border-radius: 8px !important;
|
||||||
|
padding: 10px 14px !important;
|
||||||
|
transition: background 0.2s ease !important;
|
||||||
|
width: 100% !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
white-space: normal !important;
|
||||||
|
text-align: left !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lk-media-device-select button:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lk-media-device-select button[data-lk-active="true"] {
|
||||||
|
background: var(--md-sys-color-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Индикатор говорящего */
|
||||||
|
.lk-participant-tile[data-lk-speaking="true"] {
|
||||||
|
box-shadow: 0 0 0 3px var(--md-sys-color-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout для 1-на-1: собеседник на весь экран, своя камера в углу */
|
||||||
|
/* Карусель position:absolute выходит из flow — остаётся только основной контент. */
|
||||||
|
/* Сетка 5fr 1fr: единственный grid-ребёнок (основное видео) получает 5fr (расширяется). */
|
||||||
|
.lk-focus-layout {
|
||||||
|
position: relative !important;
|
||||||
|
grid-template-columns: 5fr 1fr !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Основное видео (собеседник) на весь экран */
|
||||||
|
.lk-focus-layout .lk-focus-layout-wrapper {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lk-focus-layout .lk-focus-layout-wrapper .lk-participant-tile {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Демонстрация экрана — на весь экран только в режиме фокуса (после клика на раскрытие) */
|
||||||
|
/* Структура: .lk-focus-layout-wrapper > .lk-focus-layout > .lk-participant-tile */
|
||||||
|
.lk-focus-layout > .lk-participant-tile[data-lk-source="screen_share"] {
|
||||||
|
position: absolute !important;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
top: 0 !important;
|
||||||
|
left: 0 !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
z-index: 50 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Карусель с локальным видео (своя камера) */
|
||||||
|
.lk-focus-layout .lk-carousel {
|
||||||
|
position: absolute !important;
|
||||||
|
bottom: 80px !important;
|
||||||
|
right: 16px !important;
|
||||||
|
width: 280px !important;
|
||||||
|
height: auto !important;
|
||||||
|
z-index: 100 !important;
|
||||||
|
pointer-events: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lk-focus-layout .lk-carousel .lk-participant-tile {
|
||||||
|
width: 280px !important;
|
||||||
|
height: 158px !important;
|
||||||
|
border-radius: 12px !important;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6) !important;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Скрыть стрелки карусели (они не нужны для 1 участника) */
|
||||||
|
.lk-focus-layout .lk-carousel button[aria-label*="Previous"],
|
||||||
|
.lk-focus-layout .lk-carousel button[aria-label*="Next"] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Если используется grid layout (фоллбэк) */
|
||||||
|
.lk-grid-layout {
|
||||||
|
position: relative !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Для 2 участников: первый на весь экран, второй в углу */
|
||||||
|
.lk-grid-layout[data-lk-participants="2"] {
|
||||||
|
display: block !important;
|
||||||
|
position: relative !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lk-grid-layout[data-lk-participants="2"] .lk-participant-tile:first-child {
|
||||||
|
position: absolute !important;
|
||||||
|
top: 0 !important;
|
||||||
|
left: 0 !important;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lk-grid-layout[data-lk-participants="2"] .lk-participant-tile:last-child {
|
||||||
|
position: absolute !important;
|
||||||
|
bottom: 80px !important;
|
||||||
|
right: 16px !important;
|
||||||
|
width: 280px !important;
|
||||||
|
height: 158px !important;
|
||||||
|
border-radius: 12px !important;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6) !important;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.2) !important;
|
||||||
|
z-index: 100 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Адаптивность */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.lk-control-bar {
|
||||||
|
border-radius: 12px !important;
|
||||||
|
padding: 8px 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lk-control-bar .lk-button,
|
||||||
|
.lk-button {
|
||||||
|
min-width: 44px !important;
|
||||||
|
width: auto !important;
|
||||||
|
height: 44px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Уменьшаем размер локального видео на мобильных */
|
||||||
|
.lk-focus-layout .lk-carousel,
|
||||||
|
.lk-grid-layout[data-lk-participants="2"] .lk-participant-tile:last-child {
|
||||||
|
width: 160px !important;
|
||||||
|
height: 90px !important;
|
||||||
|
bottom: 70px !important;
|
||||||
|
right: 12px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Качество отображения видео в контейнере LiveKit */
|
||||||
|
.lk-participant-media-video {
|
||||||
|
background: #000 !important;
|
||||||
|
}
|
||||||
|
/* Демонстрация экрана: contain чтобы не обрезать, чёткое отображение */
|
||||||
|
.lk-participant-media-video[data-lk-source="screen_share"] {
|
||||||
|
object-fit: contain !important;
|
||||||
|
object-position: center !important;
|
||||||
|
image-rendering: -webkit-optimize-contrast;
|
||||||
|
image-rendering: crisp-edges;
|
||||||
|
}
|
||||||
|
/* Сетка: минимальная высота плиток для крупного видео */
|
||||||
|
.lk-grid-layout {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.lk-grid-layout .lk-participant-tile {
|
||||||
|
min-height: 240px;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
import axios from 'src/utils/axios';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Последние 30 дней (диапазон по умолчанию)
|
||||||
|
*/
|
||||||
|
export function getLast30DaysRange() {
|
||||||
|
const now = new Date();
|
||||||
|
const end = new Date(now);
|
||||||
|
const start = new Date(now);
|
||||||
|
start.setDate(start.getDate() - 29);
|
||||||
|
const fmt = (d) => d.toISOString().slice(0, 10);
|
||||||
|
return { start_date: fmt(start), end_date: fmt(end) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildParams(range) {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
p.set('period', range.period || 'custom');
|
||||||
|
p.set('start_date', range.start_date);
|
||||||
|
p.set('end_date', range.end_date);
|
||||||
|
return p.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /analytics/overview?period=custom&start_date=&end_date=
|
||||||
|
*/
|
||||||
|
export async function getAnalyticsOverview(range) {
|
||||||
|
const q = buildParams(range);
|
||||||
|
const res = await axios.get(`/analytics/overview?${q}`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /analytics/students?...
|
||||||
|
*/
|
||||||
|
export async function getAnalyticsStudents(range) {
|
||||||
|
const q = buildParams(range);
|
||||||
|
const res = await axios.get(`/analytics/students?${q}`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /analytics/revenue?...
|
||||||
|
*/
|
||||||
|
export async function getAnalyticsRevenue(range) {
|
||||||
|
const q = buildParams(range);
|
||||||
|
const res = await axios.get(`/analytics/revenue?${q}`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /analytics/grades_by_day?...
|
||||||
|
*/
|
||||||
|
export async function getAnalyticsGradesByDay(range) {
|
||||||
|
const q = buildParams(range);
|
||||||
|
const res = await axios.get(`/analytics/grades_by_day?${q}`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,7 @@ const axiosInstance = axios.create({ baseURL: CONFIG.site.serverUrl });
|
||||||
|
|
||||||
axiosInstance.interceptors.response.use(
|
axiosInstance.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => Promise.reject((error.response && error.response.data) || 'Something went wrong!')
|
(error) => Promise.reject(error)
|
||||||
);
|
);
|
||||||
|
|
||||||
export default axiosInstance;
|
export default axiosInstance;
|
||||||
|
|
@ -31,13 +31,17 @@ export const fetcher = async (args) => {
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
export const endpoints = {
|
export const endpoints = {
|
||||||
chat: '/api/chat',
|
chat: '/chat',
|
||||||
kanban: '/api/kanban',
|
kanban: '/kanban',
|
||||||
calendar: '/api/calendar',
|
calendar: '/calendar',
|
||||||
auth: {
|
auth: {
|
||||||
me: '/profile/me/',
|
me: '/profile/me/',
|
||||||
signIn: '/auth/login/',
|
signIn: '/auth/login/',
|
||||||
signUp: '/auth/register/',
|
signUp: '/auth/register/',
|
||||||
|
refresh: '/auth/token/refresh/',
|
||||||
|
passwordReset: '/auth/password-reset/',
|
||||||
|
passwordResetConfirm: '/auth/password-reset-confirm/',
|
||||||
|
verifyEmail: '/auth/verify-email/',
|
||||||
},
|
},
|
||||||
mail: {
|
mail: {
|
||||||
list: '/api/mail/list',
|
list: '/api/mail/list',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
import axios from 'src/utils/axios';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /board/boards/my_boards/ — доски текущего пользователя
|
||||||
|
*/
|
||||||
|
export async function getMyBoards() {
|
||||||
|
const res = await axios.get('/board/boards/my_boards/');
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /board/boards/shared_with_me/ — доски, к которым есть доступ
|
||||||
|
*/
|
||||||
|
export async function getSharedBoards() {
|
||||||
|
const res = await axios.get('/board/boards/shared_with_me/');
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /board/boards/get-or-create-mentor-student/?mentor=X&student=Y
|
||||||
|
*/
|
||||||
|
export async function getOrCreateMentorStudentBoard(mentorId, studentId) {
|
||||||
|
const res = await axios.get(`/board/boards/get-or-create-mentor-student/?mentor=${mentorId}&student=${studentId}`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET or create board for a lesson.
|
||||||
|
* Resolves lesson → mentor/student IDs → getOrCreateMentorStudentBoard
|
||||||
|
*/
|
||||||
|
export async function getOrCreateLessonBoard(lessonId) {
|
||||||
|
const lessonRes = await axios.get(`/schedule/lessons/${lessonId}/`);
|
||||||
|
const lesson = lessonRes.data;
|
||||||
|
const mentorId = typeof lesson.mentor === 'object' ? lesson.mentor?.id : lesson.mentor;
|
||||||
|
const client = lesson.client;
|
||||||
|
let studentId;
|
||||||
|
if (client && typeof client === 'object') {
|
||||||
|
studentId = client.user?.id ?? client.id;
|
||||||
|
} else {
|
||||||
|
studentId = client;
|
||||||
|
}
|
||||||
|
if (!mentorId || !studentId) throw new Error('Не удалось определить ментора и студента из занятия');
|
||||||
|
return getOrCreateMentorStudentBoard(mentorId, studentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /board/boards/ — создать новую доску
|
||||||
|
*/
|
||||||
|
export async function createBoard(data) {
|
||||||
|
const res = await axios.post('/board/boards/', data);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
import axios from 'src/utils/axios';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function getConversations(params) {
|
||||||
|
const res = await axios.get('/chat/chats/', { params });
|
||||||
|
const {data} = res;
|
||||||
|
if (Array.isArray(data)) return { count: data.length, next: null, previous: null, results: data };
|
||||||
|
return {
|
||||||
|
count: data?.count ?? (data?.results?.length ?? 0),
|
||||||
|
next: data?.next ?? null,
|
||||||
|
previous: data?.previous ?? null,
|
||||||
|
results: data?.results ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getChatById(uuid) {
|
||||||
|
const res = await axios.get(`/chat/chats/${uuid}/`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createChat(participantId) {
|
||||||
|
const res = await axios.post('/chat/chats/', { participants: [participantId] });
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getChatMessagesByUuid(chatUuid, params) {
|
||||||
|
const res = await axios.get(`/chat/chats/${chatUuid}/messages/`, { params });
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMessages(chatId, params) {
|
||||||
|
const res = await axios.get('/chat/messages/', { params: { ...params, chat: chatId } });
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendMessage(chatId, content, file) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('chat', String(chatId));
|
||||||
|
formData.append('content', content);
|
||||||
|
if (file) formData.append('file', file);
|
||||||
|
const res = await axios.post('/chat/messages/', formData);
|
||||||
|
const {data} = res;
|
||||||
|
if (data && typeof data === 'object' && 'data' in data) return data.data;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markMessagesAsRead(chatUuid, messageUuids) {
|
||||||
|
await axios.post(
|
||||||
|
`/chat/chats/${chatUuid}/mark_read/`,
|
||||||
|
messageUuids ? { message_uuids: messageUuids } : {}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchUsers(query) {
|
||||||
|
const res = await axios.get('/users/search/', { params: { q: query } });
|
||||||
|
const {data} = res;
|
||||||
|
if (Array.isArray(data)) return data;
|
||||||
|
return data?.results ?? [];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,203 @@
|
||||||
|
import axios from 'src/utils/axios';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /mentor/dashboard/
|
||||||
|
*/
|
||||||
|
export async function getMentorDashboard(options) {
|
||||||
|
const config = options?.signal ? { signal: options.signal } : undefined;
|
||||||
|
const res = await axios.get('/mentor/dashboard/', config);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /mentor/income/?period=week
|
||||||
|
*/
|
||||||
|
export async function getMentorIncome(period = 'week', startDate, endDate, options) {
|
||||||
|
let url = `/mentor/income/?period=${period}`;
|
||||||
|
if (period === 'range' && startDate && endDate) {
|
||||||
|
url += `&start_date=${startDate}&end_date=${endDate}`;
|
||||||
|
}
|
||||||
|
const config = options?.signal ? { signal: options.signal } : undefined;
|
||||||
|
const res = await axios.get(url, config);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /client/dashboard/
|
||||||
|
*/
|
||||||
|
export async function getClientDashboard(options) {
|
||||||
|
const config = options?.signal ? { signal: options.signal } : undefined;
|
||||||
|
const res = await axios.get('/client/dashboard/', config);
|
||||||
|
return normalizeClientDashboard(res.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /parent/{childId}/child_dashboard/
|
||||||
|
*/
|
||||||
|
export async function getChildDashboard(childId, options) {
|
||||||
|
const config = options?.signal ? { signal: options.signal } : undefined;
|
||||||
|
const res = await axios.get(`/parent/${childId}/child_dashboard/`, config);
|
||||||
|
return normalizeClientDashboard(res.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /parent/dashboard/
|
||||||
|
*/
|
||||||
|
export async function getParentDashboard(options) {
|
||||||
|
const config = options?.signal ? { signal: options.signal } : undefined;
|
||||||
|
const res = await axios.get('/parent/dashboard/', config);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// Calendar / Schedule
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /schedule/lessons/calendar/?start_date=&end_date=
|
||||||
|
*/
|
||||||
|
export async function getCalendarLessons(startDate, endDate, options) {
|
||||||
|
const params = new URLSearchParams({ start_date: startDate, end_date: endDate });
|
||||||
|
const config = options?.signal ? { signal: options.signal } : undefined;
|
||||||
|
const res = await axios.get(`/schedule/lessons/calendar/?${params}`, config);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /schedule/lessons/{id}/
|
||||||
|
*/
|
||||||
|
export async function getLesson(id) {
|
||||||
|
const res = await axios.get(`/schedule/lessons/${id}/`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /schedule/lessons/
|
||||||
|
*/
|
||||||
|
export async function createCalendarLesson(data) {
|
||||||
|
const res = await axios.post('/schedule/lessons/', data);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /schedule/lessons/{id}/
|
||||||
|
*/
|
||||||
|
export async function updateCalendarLesson(id, data) {
|
||||||
|
const res = await axios.patch(`/schedule/lessons/${id}/`, data);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /schedule/lessons/{id}/
|
||||||
|
*/
|
||||||
|
export async function deleteCalendarLesson(id, deleteAllFuture = false) {
|
||||||
|
await axios.delete(`/schedule/lessons/${id}/`, {
|
||||||
|
data: { delete_all_future: deleteAllFuture },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// Students & Subjects
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /manage/clients/?page=1&page_size=200
|
||||||
|
*/
|
||||||
|
export async function getMentorStudents() {
|
||||||
|
const res = await axios.get('/manage/clients/?page=1&page_size=200');
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /schedule/subjects/
|
||||||
|
*/
|
||||||
|
export async function getMentorSubjects() {
|
||||||
|
const res = await axios.get('/schedule/subjects/');
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /schedule/lessons/ — список занятий с фильтром по статусу
|
||||||
|
*/
|
||||||
|
export async function getLessons(params = {}) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params.status) query.set('status', params.status);
|
||||||
|
if (params.start_date) query.set('start_date', params.start_date);
|
||||||
|
if (params.end_date) query.set('end_date', params.end_date);
|
||||||
|
if (params.client_id) query.set('client_id', params.client_id);
|
||||||
|
if (params.child_id) query.set('child_id', params.child_id);
|
||||||
|
const q = query.toString();
|
||||||
|
const res = await axios.get(`/schedule/lessons/${q ? `?${q}` : ''}`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// Lesson completion / files
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /schedule/lessons/{id}/complete/
|
||||||
|
*/
|
||||||
|
export async function completeLesson(id, notes, mentorGrade, schoolGrade, homeworkText, hasHomeworkFiles, lessonFileIds) {
|
||||||
|
const body = {
|
||||||
|
notes: notes ?? '',
|
||||||
|
mentor_grade: mentorGrade,
|
||||||
|
school_grade: schoolGrade,
|
||||||
|
homework_text: homeworkText,
|
||||||
|
has_homework_files: hasHomeworkFiles,
|
||||||
|
};
|
||||||
|
if (lessonFileIds != null) {
|
||||||
|
body.lesson_file_ids = lessonFileIds;
|
||||||
|
}
|
||||||
|
const res = await axios.post(`/schedule/lessons/${id}/complete/`, body);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /schedule/lesson-files/ (multipart)
|
||||||
|
*/
|
||||||
|
export async function uploadLessonFile(lessonId, file) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('lesson', String(lessonId));
|
||||||
|
formData.append('file', file);
|
||||||
|
const res = await axios.post('/schedule/lesson-files/', formData);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function normalizeClientDashboard(raw) {
|
||||||
|
const s = raw?.summary ?? {};
|
||||||
|
const upcoming = raw?.upcoming_lessons ?? [];
|
||||||
|
return {
|
||||||
|
total_lessons: s.total_lessons ?? 0,
|
||||||
|
completed_lessons: s.completed_lessons ?? 0,
|
||||||
|
homework_pending: s.pending_homeworks ?? 0,
|
||||||
|
homework_completed: s.completed_homeworks ?? 0,
|
||||||
|
average_grade: s.average_score ?? 0,
|
||||||
|
next_lesson: upcoming[0]
|
||||||
|
? {
|
||||||
|
id: String(upcoming[0].id),
|
||||||
|
title: upcoming[0].title ?? '',
|
||||||
|
subject: '',
|
||||||
|
start_time: upcoming[0].start_time ?? '',
|
||||||
|
end_time: upcoming[0].end_time ?? '',
|
||||||
|
status: 'scheduled',
|
||||||
|
mentor: upcoming[0].mentor
|
||||||
|
? { id: String(upcoming[0].mentor.id), first_name: upcoming[0].mentor.name, last_name: '' }
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
upcoming_lessons: upcoming.map((l) => ({
|
||||||
|
id: String(l.id),
|
||||||
|
title: l.title ?? '',
|
||||||
|
subject: '',
|
||||||
|
start_time: l.start_time ?? '',
|
||||||
|
end_time: l.end_time ?? '',
|
||||||
|
status: 'scheduled',
|
||||||
|
mentor: l.mentor
|
||||||
|
? { id: String(l.mentor.id), first_name: l.mentor.name, last_name: '' }
|
||||||
|
: undefined,
|
||||||
|
})),
|
||||||
|
recent_homework: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import axios from 'src/utils/axios';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /video/livekit/create-room/
|
||||||
|
*/
|
||||||
|
export async function createLiveKitRoom(lessonId) {
|
||||||
|
const res = await axios.post('/video/livekit/create-room/', { lesson_id: lessonId });
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /video/livekit/config/
|
||||||
|
*/
|
||||||
|
export async function getLiveKitConfig() {
|
||||||
|
const res = await axios.get('/video/livekit/config/');
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /video/livekit/participant-connected/
|
||||||
|
*/
|
||||||
|
export async function participantConnected({ roomName, lessonId }) {
|
||||||
|
const body = { room_name: roomName };
|
||||||
|
if (lessonId != null) body.lesson_id = lessonId;
|
||||||
|
await axios.post('/video/livekit/participant-connected/', body);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
import axios from 'src/utils/axios';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function getProfileSettings() {
|
||||||
|
const res = await axios.get('/profile/settings/');
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProfileSettings(data) {
|
||||||
|
await axios.patch('/profile/update_settings/', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchCities(query, limit = 50) {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ q: query, limit: String(limit) });
|
||||||
|
const res = await axios.get(`/profile/cities/search/?${params.toString()}`);
|
||||||
|
return Array.isArray(res.data) ? res.data : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProfile(data) {
|
||||||
|
const hasFile = data.avatar instanceof File;
|
||||||
|
if (hasFile) {
|
||||||
|
const formData = new FormData();
|
||||||
|
if (data.first_name !== undefined) formData.append('first_name', data.first_name);
|
||||||
|
if (data.last_name !== undefined) formData.append('last_name', data.last_name);
|
||||||
|
if (data.phone !== undefined) formData.append('phone', data.phone);
|
||||||
|
if (data.bio !== undefined) formData.append('bio', data.bio);
|
||||||
|
formData.append('avatar', data.avatar);
|
||||||
|
const res = await axios.patch('/profile/me/', formData);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
const res = await axios.patch('/profile/me/', data);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAvatar() {
|
||||||
|
const res = await axios.patch('/profile/update_profile/', { avatar: null });
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadTelegramAvatar() {
|
||||||
|
const res = await axios.post('/profile/load_telegram_avatar/');
|
||||||
|
const {data} = res;
|
||||||
|
return data?.user ?? data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNotificationPreferences() {
|
||||||
|
try {
|
||||||
|
const res = await axios.get('/notifications/preferences/me/');
|
||||||
|
return res.data?.data ?? res.data;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateNotificationPreferences(preferences) {
|
||||||
|
const res = await axios.patch('/notifications/preferences/me/', preferences);
|
||||||
|
return res.data?.data ?? res.data;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
import axios from 'src/utils/axios';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function getReferralProfile() {
|
||||||
|
try {
|
||||||
|
const res = await axios.get('/referrals/my_profile/');
|
||||||
|
return res.data;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getReferralStats() {
|
||||||
|
try {
|
||||||
|
const res = await axios.get('/referrals/stats/');
|
||||||
|
return res.data;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMyReferrals() {
|
||||||
|
const res = await axios.get('/referrals/my_referrals/');
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setReferrer(referralCode) {
|
||||||
|
await axios.post('/referrals/set_referrer/', { referral_code: referralCode.trim() });
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import axios from 'src/utils/axios';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function generateTelegramCode() {
|
||||||
|
const res = await axios.post('/notifications/preferences/telegram/generate-code/');
|
||||||
|
return res.data ?? res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unlinkTelegram() {
|
||||||
|
const res = await axios.post('/notifications/preferences/telegram/unlink/');
|
||||||
|
return res.data ?? res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTelegramStatus() {
|
||||||
|
const res = await axios.get('/notifications/preferences/telegram/status/');
|
||||||
|
const {data} = res;
|
||||||
|
return data?.data ?? data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTelegramBotInfo() {
|
||||||
|
const res = await axios.get('/notifications/preferences/telegram/bot-info/');
|
||||||
|
const {data} = res;
|
||||||
|
return data?.data ?? data;
|
||||||
|
}
|
||||||
|
|
@ -1689,6 +1689,11 @@
|
||||||
"@babel/helper-validator-identifier" "^7.22.20"
|
"@babel/helper-validator-identifier" "^7.22.20"
|
||||||
to-fast-properties "^2.0.0"
|
to-fast-properties "^2.0.0"
|
||||||
|
|
||||||
|
"@bufbuild/protobuf@^1.10.0":
|
||||||
|
version "1.10.1"
|
||||||
|
resolved "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz"
|
||||||
|
integrity sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==
|
||||||
|
|
||||||
"@dnd-kit/accessibility@^3.1.0":
|
"@dnd-kit/accessibility@^3.1.0":
|
||||||
version "3.1.0"
|
version "3.1.0"
|
||||||
resolved "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.0.tgz"
|
resolved "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.0.tgz"
|
||||||
|
|
@ -2259,20 +2264,20 @@
|
||||||
resolved "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.0.tgz"
|
resolved "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.0.tgz"
|
||||||
integrity sha512-zuWxyfXNbsKbm96HhXzainONPFqRcoZblQ++e9cAIGUuHfl2cFSBzW01jtesqWG/lqaUyX3H8O1y9oWboGNQBA==
|
integrity sha512-zuWxyfXNbsKbm96HhXzainONPFqRcoZblQ++e9cAIGUuHfl2cFSBzW01jtesqWG/lqaUyX3H8O1y9oWboGNQBA==
|
||||||
|
|
||||||
"@floating-ui/core@^1.6.0":
|
"@floating-ui/core@^1.7.3":
|
||||||
version "1.6.0"
|
version "1.7.5"
|
||||||
resolved "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.0.tgz"
|
resolved "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz"
|
||||||
integrity sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==
|
integrity sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@floating-ui/utils" "^0.2.1"
|
"@floating-ui/utils" "^0.2.11"
|
||||||
|
|
||||||
"@floating-ui/dom@^1.6.1":
|
"@floating-ui/dom@^1.6.1", "@floating-ui/dom@1.7.4":
|
||||||
version "1.6.1"
|
version "1.7.4"
|
||||||
resolved "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.1.tgz"
|
resolved "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz"
|
||||||
integrity sha512-iA8qE43/H5iGozC3W0YSnVSW42Vh522yyM1gj+BqRwVsTNOyr231PsXDaV04yT39PsO0QL2QpbI/M0ZaLUQgRQ==
|
integrity sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@floating-ui/core" "^1.6.0"
|
"@floating-ui/core" "^1.7.3"
|
||||||
"@floating-ui/utils" "^0.2.1"
|
"@floating-ui/utils" "^0.2.10"
|
||||||
|
|
||||||
"@floating-ui/react-dom@^2.0.8":
|
"@floating-ui/react-dom@^2.0.8":
|
||||||
version "2.0.8"
|
version "2.0.8"
|
||||||
|
|
@ -2281,10 +2286,10 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
"@floating-ui/dom" "^1.6.1"
|
"@floating-ui/dom" "^1.6.1"
|
||||||
|
|
||||||
"@floating-ui/utils@^0.2.1":
|
"@floating-ui/utils@^0.2.10", "@floating-ui/utils@^0.2.11":
|
||||||
version "0.2.1"
|
version "0.2.11"
|
||||||
resolved "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz"
|
resolved "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz"
|
||||||
integrity sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==
|
integrity sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==
|
||||||
|
|
||||||
"@fontsource/barlow@^5.0.13":
|
"@fontsource/barlow@^5.0.13":
|
||||||
version "5.0.13"
|
version "5.0.13"
|
||||||
|
|
@ -2461,6 +2466,43 @@
|
||||||
"@jridgewell/resolve-uri" "^3.1.0"
|
"@jridgewell/resolve-uri" "^3.1.0"
|
||||||
"@jridgewell/sourcemap-codec" "^1.4.14"
|
"@jridgewell/sourcemap-codec" "^1.4.14"
|
||||||
|
|
||||||
|
"@livekit/components-core@^0.12.13", "@livekit/components-core@0.12.13":
|
||||||
|
version "0.12.13"
|
||||||
|
resolved "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.13.tgz"
|
||||||
|
integrity sha512-DQmi84afHoHjZ62wm8y+XPNIDHTwFHAltjd3lmyXj8UZHOY7wcza4vFt1xnghJOD5wLRY58L1dkAgAw59MgWvw==
|
||||||
|
dependencies:
|
||||||
|
"@floating-ui/dom" "1.7.4"
|
||||||
|
loglevel "1.9.1"
|
||||||
|
rxjs "7.8.2"
|
||||||
|
|
||||||
|
"@livekit/components-react@^2.9.20":
|
||||||
|
version "2.9.20"
|
||||||
|
resolved "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.20.tgz"
|
||||||
|
integrity sha512-hjkYOsJj9Jbghb7wM5cI8HoVisKeL6Zcy1VnRWTLm0sqVbto8GJp/17T4Udx85mCPY6Jgh8I1Cv0yVzgz7CQtg==
|
||||||
|
dependencies:
|
||||||
|
"@livekit/components-core" "0.12.13"
|
||||||
|
clsx "2.1.1"
|
||||||
|
events "^3.3.0"
|
||||||
|
jose "^6.0.12"
|
||||||
|
usehooks-ts "3.1.1"
|
||||||
|
|
||||||
|
"@livekit/components-styles@^1.2.0":
|
||||||
|
version "1.2.0"
|
||||||
|
resolved "https://registry.npmjs.org/@livekit/components-styles/-/components-styles-1.2.0.tgz"
|
||||||
|
integrity sha512-74/rt0lDh6aHmOPmWAeDE9C4OrNW9RIdmhX/YRbovQBVNGNVWojRjl3FgQZ5LPFXO6l1maKB4JhXcBFENVxVvw==
|
||||||
|
|
||||||
|
"@livekit/mutex@1.1.1":
|
||||||
|
version "1.1.1"
|
||||||
|
resolved "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz"
|
||||||
|
integrity sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==
|
||||||
|
|
||||||
|
"@livekit/protocol@1.44.0":
|
||||||
|
version "1.44.0"
|
||||||
|
resolved "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.44.0.tgz"
|
||||||
|
integrity sha512-/vfhDUGcUKO8Q43r6i+5FrDhl5oZjm/X3U4x2Iciqvgn5C8qbj+57YPcWSJ1kyIZm5Cm6AV2nAPjMm3ETD/iyg==
|
||||||
|
dependencies:
|
||||||
|
"@bufbuild/protobuf" "^1.10.0"
|
||||||
|
|
||||||
"@mapbox/jsonlint-lines-primitives@^2.0.2", "@mapbox/jsonlint-lines-primitives@~2.0.2":
|
"@mapbox/jsonlint-lines-primitives@^2.0.2", "@mapbox/jsonlint-lines-primitives@~2.0.2":
|
||||||
version "2.0.2"
|
version "2.0.2"
|
||||||
resolved "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz"
|
resolved "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz"
|
||||||
|
|
@ -2664,10 +2706,15 @@
|
||||||
resolved "https://registry.npmjs.org/@next/env/-/env-14.2.4.tgz"
|
resolved "https://registry.npmjs.org/@next/env/-/env-14.2.4.tgz"
|
||||||
integrity sha512-3EtkY5VDkuV2+lNmKlbkibIJxcO4oIHEhBWne6PaAp+76J9KoSsGvNikp6ivzAT8dhhBMYrm6op2pS1ApG0Hzg==
|
integrity sha512-3EtkY5VDkuV2+lNmKlbkibIJxcO4oIHEhBWne6PaAp+76J9KoSsGvNikp6ivzAT8dhhBMYrm6op2pS1ApG0Hzg==
|
||||||
|
|
||||||
"@next/swc-win32-x64-msvc@14.2.4":
|
"@next/swc-linux-x64-gnu@14.2.4":
|
||||||
version "14.2.4"
|
version "14.2.4"
|
||||||
resolved "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.4.tgz"
|
resolved "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.4.tgz"
|
||||||
integrity sha512-tkLrjBzqFTP8DVrAAQmZelEahfR9OxWpFR++vAI9FBhCiIxtwHwBHC23SBHCTURBtwB4kc/x44imVOnkKGNVGg==
|
integrity sha512-ze0ShQDBPCqxLImzw4sCdfnB3lRmN3qGMB2GWDRlq5Wqy4G36pxtNOo2usu/Nm9+V2Rh/QQnrRc2l94kYFXO6Q==
|
||||||
|
|
||||||
|
"@next/swc-linux-x64-musl@14.2.4":
|
||||||
|
version "14.2.4"
|
||||||
|
resolved "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.4.tgz"
|
||||||
|
integrity sha512-8dwC0UJoc6fC7PX70csdaznVMNr16hQrTDAMPvLPloazlcaWfdPogq+UpZX6Drqb1OBlwowz8iG7WR0Tzk/diQ==
|
||||||
|
|
||||||
"@nodelib/fs.scandir@2.1.5":
|
"@nodelib/fs.scandir@2.1.5":
|
||||||
version "2.1.5"
|
version "2.1.5"
|
||||||
|
|
@ -3716,6 +3763,11 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
"@types/ms" "*"
|
"@types/ms" "*"
|
||||||
|
|
||||||
|
"@types/dom-mediacapture-record@^1":
|
||||||
|
version "1.0.22"
|
||||||
|
resolved "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz"
|
||||||
|
integrity sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==
|
||||||
|
|
||||||
"@types/geojson@*":
|
"@types/geojson@*":
|
||||||
version "7946.0.13"
|
version "7946.0.13"
|
||||||
resolved "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.13.tgz"
|
resolved "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.13.tgz"
|
||||||
|
|
@ -4355,7 +4407,7 @@ clone@^2.1.2:
|
||||||
resolved "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz"
|
resolved "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz"
|
||||||
integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==
|
integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==
|
||||||
|
|
||||||
clsx@^2.1.0, clsx@^2.1.1:
|
clsx@^2.1.0, clsx@^2.1.1, clsx@2.1.1:
|
||||||
version "2.1.1"
|
version "2.1.1"
|
||||||
resolved "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz"
|
resolved "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz"
|
||||||
integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
|
integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
|
||||||
|
|
@ -4574,6 +4626,11 @@ data-view-byte-offset@^1.0.0:
|
||||||
es-errors "^1.3.0"
|
es-errors "^1.3.0"
|
||||||
is-data-view "^1.0.1"
|
is-data-view "^1.0.1"
|
||||||
|
|
||||||
|
"date-fns@^2.25.0 || ^3.2.0", date-fns@^3.6.0:
|
||||||
|
version "3.6.0"
|
||||||
|
resolved "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz"
|
||||||
|
integrity sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==
|
||||||
|
|
||||||
dayjs@^1.10.7, dayjs@^1.11.11:
|
dayjs@^1.10.7, dayjs@^1.11.11:
|
||||||
version "1.11.11"
|
version "1.11.11"
|
||||||
resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.11.11.tgz"
|
resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.11.11.tgz"
|
||||||
|
|
@ -6088,6 +6145,11 @@ jay-peg@^1.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
restructure "^3.0.0"
|
restructure "^3.0.0"
|
||||||
|
|
||||||
|
jose@^6.0.12, jose@^6.1.0:
|
||||||
|
version "6.2.0"
|
||||||
|
resolved "https://registry.npmjs.org/jose/-/jose-6.2.0.tgz"
|
||||||
|
integrity sha512-xsfE1TcSCbUdo6U07tR0mvhg0flGxU8tPLbF03mirl2ukGQENhUg4ubGYQnhVH0b5stLlPM+WOqDkEl1R1y5sQ==
|
||||||
|
|
||||||
js-cookie@^3.0.5:
|
js-cookie@^3.0.5:
|
||||||
version "3.0.5"
|
version "3.0.5"
|
||||||
resolved "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz"
|
resolved "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz"
|
||||||
|
|
@ -6223,6 +6285,22 @@ linkifyjs@^4.1.0:
|
||||||
resolved "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.1.3.tgz"
|
resolved "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.1.3.tgz"
|
||||||
integrity sha512-auMesunaJ8yfkHvK4gfg1K0SaKX/6Wn9g2Aac/NwX+l5VdmFZzo/hdPGxEOETj+ryRa4/fiOPjeeKURSAJx1sg==
|
integrity sha512-auMesunaJ8yfkHvK4gfg1K0SaKX/6Wn9g2Aac/NwX+l5VdmFZzo/hdPGxEOETj+ryRa4/fiOPjeeKURSAJx1sg==
|
||||||
|
|
||||||
|
livekit-client@^2.17.2:
|
||||||
|
version "2.17.2"
|
||||||
|
resolved "https://registry.npmjs.org/livekit-client/-/livekit-client-2.17.2.tgz"
|
||||||
|
integrity sha512-+67y2EtAWZabARlY7kANl/VT1Uu1EJYR5a8qwpT2ub/uBCltsEgEDOxCIMwE9HFR5w+z41HR6GL9hyEvW/y6CQ==
|
||||||
|
dependencies:
|
||||||
|
"@livekit/mutex" "1.1.1"
|
||||||
|
"@livekit/protocol" "1.44.0"
|
||||||
|
events "^3.3.0"
|
||||||
|
jose "^6.1.0"
|
||||||
|
loglevel "^1.9.2"
|
||||||
|
sdp-transform "^2.15.0"
|
||||||
|
ts-debounce "^4.0.0"
|
||||||
|
tslib "2.8.1"
|
||||||
|
typed-emitter "^2.1.0"
|
||||||
|
webrtc-adapter "^9.0.1"
|
||||||
|
|
||||||
locate-path@^6.0.0:
|
locate-path@^6.0.0:
|
||||||
version "6.0.0"
|
version "6.0.0"
|
||||||
resolved "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz"
|
resolved "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz"
|
||||||
|
|
@ -6265,6 +6343,16 @@ lodash@^4.17.21:
|
||||||
resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
|
resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
|
||||||
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
|
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
|
||||||
|
|
||||||
|
loglevel@^1.9.2:
|
||||||
|
version "1.9.2"
|
||||||
|
resolved "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz"
|
||||||
|
integrity sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==
|
||||||
|
|
||||||
|
loglevel@1.9.1:
|
||||||
|
version "1.9.1"
|
||||||
|
resolved "https://registry.npmjs.org/loglevel/-/loglevel-1.9.1.tgz"
|
||||||
|
integrity sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==
|
||||||
|
|
||||||
long@^5.0.0:
|
long@^5.0.0:
|
||||||
version "5.2.3"
|
version "5.2.3"
|
||||||
resolved "https://registry.npmjs.org/long/-/long-5.2.3.tgz"
|
resolved "https://registry.npmjs.org/long/-/long-5.2.3.tgz"
|
||||||
|
|
@ -7413,7 +7501,7 @@ react-apexcharts@^1.4.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
prop-types "^15.8.1"
|
prop-types "^15.8.1"
|
||||||
|
|
||||||
"react-dom@^15.x.x || ^16.x.x || ^17.x.x || ^18.x.x", "react-dom@^16.11.0 || ^17 || ^18", "react-dom@^16.7.0 || ^17 || ^18 || ^19", "react-dom@^17.0.0 || ^18.0.0", react-dom@^18.0.0, react-dom@^18.2.0, react-dom@^18.3.1, "react-dom@>= 16.12.0", react-dom@>=16.3.0, react-dom@>=16.6.0, react-dom@>=16.8, react-dom@>=16.8.0, "react-dom@15 - 18":
|
"react-dom@^15.x.x || ^16.x.x || ^17.x.x || ^18.x.x", "react-dom@^16.11.0 || ^17 || ^18", "react-dom@^16.7.0 || ^17 || ^18 || ^19", "react-dom@^17.0.0 || ^18.0.0", react-dom@^18.0.0, react-dom@^18.2.0, react-dom@^18.3.1, "react-dom@>= 16.12.0", react-dom@>=16.3.0, react-dom@>=16.6.0, react-dom@>=16.8, react-dom@>=16.8.0, react-dom@>=18, "react-dom@15 - 18":
|
||||||
version "18.3.1"
|
version "18.3.1"
|
||||||
resolved "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz"
|
resolved "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz"
|
||||||
integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==
|
integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==
|
||||||
|
|
@ -7551,7 +7639,7 @@ react-transition-group@^4.4.5:
|
||||||
loose-envify "^1.4.0"
|
loose-envify "^1.4.0"
|
||||||
prop-types "^15.6.2"
|
prop-types "^15.6.2"
|
||||||
|
|
||||||
"react@^15.x.x || ^16.x.x || ^17.x.x || ^18.x.x", "react@^16.11.0 || ^17 || ^18", "react@^16.11.0 || ^17.0.0 || ^18.0.0", "react@^16.7.0 || ^17 || ^18 || ^19", "react@^16.8.0 || ^17 || ^18", "react@^16.8.0 || ^17.0.0 || ^18.0.0", "react@^16.8.0 || ^17.0.1 || ^18.0.0", "react@^17.0.0 || ^18.0.0", react@^18.0.0, react@^18.2.0, react@^18.3.1, "react@>= 16.12.0", "react@>= 16.8 || 18.0.0", "react@>= 16.8.0", "react@>= 16.8.0 || 17.x.x || ^18.0.0-0", "react@>=0.0.0 <=99", react@>=0.13, react@>=16, react@>=16.3.0, react@>=16.6.0, react@>=16.8, react@>=16.8.0, react@>=18, "react@15 - 18":
|
"react@^15.x.x || ^16.x.x || ^17.x.x || ^18.x.x", "react@^16.11.0 || ^17 || ^18", "react@^16.11.0 || ^17.0.0 || ^18.0.0", "react@^16.7.0 || ^17 || ^18 || ^19", "react@^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react@^16.8.0 || ^17 || ^18", "react@^16.8.0 || ^17.0.0 || ^18.0.0", "react@^16.8.0 || ^17.0.1 || ^18.0.0", "react@^17.0.0 || ^18.0.0", react@^18.0.0, react@^18.2.0, react@^18.3.1, "react@>= 16.12.0", "react@>= 16.8 || 18.0.0", "react@>= 16.8.0", "react@>= 16.8.0 || 17.x.x || ^18.0.0-0", "react@>=0.0.0 <=99", react@>=0.13, react@>=16, react@>=16.3.0, react@>=16.6.0, react@>=16.8, react@>=16.8.0, react@>=18, "react@15 - 18":
|
||||||
version "18.3.1"
|
version "18.3.1"
|
||||||
resolved "https://registry.npmjs.org/react/-/react-18.3.1.tgz"
|
resolved "https://registry.npmjs.org/react/-/react-18.3.1.tgz"
|
||||||
integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==
|
integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==
|
||||||
|
|
@ -7769,10 +7857,10 @@ rw@^1.3.3:
|
||||||
resolved "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz"
|
resolved "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz"
|
||||||
integrity sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==
|
integrity sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==
|
||||||
|
|
||||||
rxjs@^7.8.1:
|
rxjs@*, rxjs@^7.8.1, rxjs@7.8.2:
|
||||||
version "7.8.1"
|
version "7.8.2"
|
||||||
resolved "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz"
|
resolved "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz"
|
||||||
integrity sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==
|
integrity sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib "^2.1.0"
|
tslib "^2.1.0"
|
||||||
|
|
||||||
|
|
@ -7825,6 +7913,16 @@ scrollparent@^2.1.0:
|
||||||
resolved "https://registry.npmjs.org/scrollparent/-/scrollparent-2.1.0.tgz"
|
resolved "https://registry.npmjs.org/scrollparent/-/scrollparent-2.1.0.tgz"
|
||||||
integrity sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA==
|
integrity sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA==
|
||||||
|
|
||||||
|
sdp-transform@^2.15.0:
|
||||||
|
version "2.15.0"
|
||||||
|
resolved "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz"
|
||||||
|
integrity sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==
|
||||||
|
|
||||||
|
sdp@^3.2.0:
|
||||||
|
version "3.2.1"
|
||||||
|
resolved "https://registry.npmjs.org/sdp/-/sdp-3.2.1.tgz"
|
||||||
|
integrity sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==
|
||||||
|
|
||||||
semver@^6.3.0, semver@^6.3.1:
|
semver@^6.3.0, semver@^6.3.1:
|
||||||
version "6.3.1"
|
version "6.3.1"
|
||||||
resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz"
|
resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz"
|
||||||
|
|
@ -8295,6 +8393,11 @@ ts-api-utils@^1.3.0:
|
||||||
resolved "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz"
|
resolved "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz"
|
||||||
integrity sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==
|
integrity sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==
|
||||||
|
|
||||||
|
ts-debounce@^4.0.0:
|
||||||
|
version "4.0.0"
|
||||||
|
resolved "https://registry.npmjs.org/ts-debounce/-/ts-debounce-4.0.0.tgz"
|
||||||
|
integrity sha512-+1iDGY6NmOGidq7i7xZGA4cm8DAa6fqdYcvO5Z6yBevH++Bdo9Qt/mN0TzHUgcCcKv1gmh9+W5dHqz8pMWbCbg==
|
||||||
|
|
||||||
tsconfig-paths@^3.15.0:
|
tsconfig-paths@^3.15.0:
|
||||||
version "3.15.0"
|
version "3.15.0"
|
||||||
resolved "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz"
|
resolved "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz"
|
||||||
|
|
@ -8310,10 +8413,10 @@ tslib@^1.11.1:
|
||||||
resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz"
|
resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz"
|
||||||
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
|
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
|
||||||
|
|
||||||
tslib@^2.0.0, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.1, tslib@^2.4.0, tslib@^2.5.0, tslib@^2.6.2:
|
tslib@^2.0.0, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.1, tslib@^2.4.0, tslib@^2.5.0, tslib@^2.6.2, tslib@2.8.1:
|
||||||
version "2.6.2"
|
version "2.8.1"
|
||||||
resolved "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz"
|
resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz"
|
||||||
integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==
|
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
|
||||||
|
|
||||||
turndown@^7.2.0:
|
turndown@^7.2.0:
|
||||||
version "7.2.0"
|
version "7.2.0"
|
||||||
|
|
@ -8388,6 +8491,13 @@ typed-array-length@^1.0.6:
|
||||||
is-typed-array "^1.1.13"
|
is-typed-array "^1.1.13"
|
||||||
possible-typed-array-names "^1.0.0"
|
possible-typed-array-names "^1.0.0"
|
||||||
|
|
||||||
|
typed-emitter@^2.1.0:
|
||||||
|
version "2.1.0"
|
||||||
|
resolved "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz"
|
||||||
|
integrity sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==
|
||||||
|
optionalDependencies:
|
||||||
|
rxjs "*"
|
||||||
|
|
||||||
typescript@^5.4.5, typescript@>=4.2.0, typescript@>=4.9.5:
|
typescript@^5.4.5, typescript@>=4.2.0, typescript@>=4.9.5:
|
||||||
version "5.4.5"
|
version "5.4.5"
|
||||||
resolved "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz"
|
resolved "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz"
|
||||||
|
|
@ -8565,6 +8675,13 @@ use-sync-external-store@^1.2.0:
|
||||||
resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz"
|
resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz"
|
||||||
integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==
|
integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==
|
||||||
|
|
||||||
|
usehooks-ts@3.1.1:
|
||||||
|
version "3.1.1"
|
||||||
|
resolved "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.1.tgz"
|
||||||
|
integrity sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==
|
||||||
|
dependencies:
|
||||||
|
lodash.debounce "^4.0.8"
|
||||||
|
|
||||||
util-deprecate@^1.0.1:
|
util-deprecate@^1.0.1:
|
||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"
|
resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"
|
||||||
|
|
@ -8638,6 +8755,13 @@ webidl-conversions@^3.0.0:
|
||||||
resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz"
|
resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz"
|
||||||
integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==
|
integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==
|
||||||
|
|
||||||
|
webrtc-adapter@^9.0.1:
|
||||||
|
version "9.0.4"
|
||||||
|
resolved "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.4.tgz"
|
||||||
|
integrity sha512-5ZZY1+lGq8LEKuDlg9M2RPJHlH3R7OVwyHqMcUsLKCgd9Wvf+QrFTCItkXXYPmrJn8H6gRLXbSgxLLdexiqHxw==
|
||||||
|
dependencies:
|
||||||
|
sdp "^3.2.0"
|
||||||
|
|
||||||
websocket-driver@>=0.5.1:
|
websocket-driver@>=0.5.1:
|
||||||
version "0.7.4"
|
version "0.7.4"
|
||||||
resolved "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz"
|
resolved "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue