From f679f0c0f42aa63a3d4d7238ee8fafb42bf08dff Mon Sep 17 00:00:00 2001 From: Dev Server Date: Mon, 9 Mar 2026 16:35:30 +0300 Subject: [PATCH] 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 --- docker-compose.yml | 4 +- excalidraw-server/Dockerfile | 4 +- front_minimal/package-lock.json | 253 ++++- front_minimal/package.json | 5 + front_minimal/src/actions/calendar.js | 138 ++- front_minimal/src/app.jsx | 43 + .../app/auth/jwt/forgot-password/layout.jsx | 13 + .../src/app/auth/jwt/forgot-password/page.jsx | 11 + .../app/auth/jwt/reset-password/layout.jsx | 13 + .../src/app/auth/jwt/reset-password/page.jsx | 11 + .../src/app/auth/jwt/verify-email/layout.jsx | 7 + .../src/app/auth/jwt/verify-email/page.jsx | 11 + .../src/app/dashboard/analytics/page.jsx | 6 +- .../src/app/dashboard/board/page.jsx | 11 + .../src/app/dashboard/chat-platform/page.jsx | 11 + .../app/dashboard/children-progress/page.jsx | 11 + .../src/app/dashboard/children/page.jsx | 11 + .../src/app/dashboard/feedback/page.jsx | 11 + .../src/app/dashboard/my-progress/page.jsx | 11 + front_minimal/src/app/dashboard/page.jsx | 57 +- .../app/dashboard/payment-platform/page.jsx | 11 + .../src/app/dashboard/profile/page.jsx | 11 + .../src/app/dashboard/referrals/page.jsx | 11 + front_minimal/src/app/video-call/layout.jsx | 5 + front_minimal/src/app/video-call/page.jsx | 11 + front_minimal/src/auth/context/jwt/action.js | 107 +- .../src/auth/context/jwt/auth-provider.jsx | 36 +- .../src/auth/context/jwt/constant.js | 1 + front_minimal/src/auth/context/jwt/utils.js | 20 +- front_minimal/src/components/chart/chart.jsx | 38 +- .../organizational-chart.jsx | 41 +- .../src/components/settings/server.js | 13 +- .../src/components/walktour/walktour.jsx | 8 +- front_minimal/src/config-global.js | 7 +- front_minimal/src/hooks/use-chat-websocket.js | 62 ++ .../src/layouts/config-nav-dashboard.jsx | 83 +- .../src/layouts/dashboard/layout.jsx | 7 +- front_minimal/src/locales/server.js | 51 +- front_minimal/src/main.jsx | 16 + front_minimal/src/routes/paths.js | 11 + front_minimal/src/routes/sections.jsx | 691 ++++++++++++ .../view/account-platform-view.jsx | 983 ++++++++++++++++++ .../sections/account-platform/view/index.js | 1 + .../analytics/view/analytics-view.jsx | 387 +++++++ .../src/sections/analytics/view/index.js | 1 + front_minimal/src/sections/auth/jwt/index.js | 4 +- .../auth/jwt/jwt-forgot-password-view.jsx | 111 ++ .../auth/jwt/jwt-reset-password-view.jsx | 204 ++++ .../sections/auth/jwt/jwt-sign-in-view.jsx | 6 +- .../sections/auth/jwt/jwt-sign-up-view.jsx | 153 ++- .../auth/jwt/jwt-verify-email-view.jsx | 111 ++ .../src/sections/board/view/board-view.jsx | 392 +++++++ .../src/sections/board/view/index.js | 1 + .../src/sections/calendar/calendar-form.jsx | 57 +- .../sections/calendar/calendar-toolbar.jsx | 6 - .../sections/calendar/view/calendar-view.jsx | 159 +-- .../sections/chat/view/chat-platform-view.jsx | 737 +++++++++++++ front_minimal/src/sections/chat/view/index.js | 1 + .../children/view/children-progress-view.jsx | 180 ++++ .../sections/children/view/children-view.jsx | 123 +++ .../src/sections/children/view/index.js | 2 + .../src/sections/contact/contact-map.jsx | 21 +- .../sections/feedback/view/feedback-view.jsx | 339 ++++++ .../src/sections/feedback/view/index.js | 1 + .../src/sections/my-progress/view/index.js | 1 + .../my-progress/view/my-progress-view.jsx | 354 +++++++ .../sections/overview/client/view/index.js | 1 + .../client/view/overview-client-view.jsx | 389 +++++++ .../src/sections/payment/view/index.js | 1 + .../payment/view/payment-platform-view.jsx | 205 ++++ .../src/sections/referrals/view/index.js | 1 + .../referrals/view/referrals-view.jsx | 298 ++++++ .../video-call/livekit/exit-lesson-modal.jsx | 423 ++++++++ .../src/sections/video-call/view/index.js | 1 + .../video-call/view/video-call-view.jsx | 710 +++++++++++++ .../src/styles/livekit-components.css | 6 + front_minimal/src/styles/livekit-theme.css | 429 ++++++++ front_minimal/src/utils/analytics-api.js | 59 ++ front_minimal/src/utils/axios.js | 12 +- front_minimal/src/utils/board-api.js | 54 + front_minimal/src/utils/chat-api.js | 60 ++ front_minimal/src/utils/dashboard-api.js | 203 ++++ front_minimal/src/utils/livekit-api.js | 28 + front_minimal/src/utils/profile-api.js | 63 ++ front_minimal/src/utils/referrals-api.js | 30 + front_minimal/src/utils/telegram-api.js | 25 + front_minimal/yarn.lock | 182 +++- 87 files changed, 8927 insertions(+), 471 deletions(-) create mode 100644 front_minimal/src/app.jsx create mode 100644 front_minimal/src/app/auth/jwt/forgot-password/layout.jsx create mode 100644 front_minimal/src/app/auth/jwt/forgot-password/page.jsx create mode 100644 front_minimal/src/app/auth/jwt/reset-password/layout.jsx create mode 100644 front_minimal/src/app/auth/jwt/reset-password/page.jsx create mode 100644 front_minimal/src/app/auth/jwt/verify-email/layout.jsx create mode 100644 front_minimal/src/app/auth/jwt/verify-email/page.jsx create mode 100644 front_minimal/src/app/dashboard/board/page.jsx create mode 100644 front_minimal/src/app/dashboard/chat-platform/page.jsx create mode 100644 front_minimal/src/app/dashboard/children-progress/page.jsx create mode 100644 front_minimal/src/app/dashboard/children/page.jsx create mode 100644 front_minimal/src/app/dashboard/feedback/page.jsx create mode 100644 front_minimal/src/app/dashboard/my-progress/page.jsx create mode 100644 front_minimal/src/app/dashboard/payment-platform/page.jsx create mode 100644 front_minimal/src/app/dashboard/profile/page.jsx create mode 100644 front_minimal/src/app/dashboard/referrals/page.jsx create mode 100644 front_minimal/src/app/video-call/layout.jsx create mode 100644 front_minimal/src/app/video-call/page.jsx create mode 100644 front_minimal/src/hooks/use-chat-websocket.js create mode 100644 front_minimal/src/main.jsx create mode 100644 front_minimal/src/routes/sections.jsx create mode 100644 front_minimal/src/sections/account-platform/view/account-platform-view.jsx create mode 100644 front_minimal/src/sections/account-platform/view/index.js create mode 100644 front_minimal/src/sections/analytics/view/analytics-view.jsx create mode 100644 front_minimal/src/sections/analytics/view/index.js create mode 100644 front_minimal/src/sections/auth/jwt/jwt-forgot-password-view.jsx create mode 100644 front_minimal/src/sections/auth/jwt/jwt-reset-password-view.jsx create mode 100644 front_minimal/src/sections/auth/jwt/jwt-verify-email-view.jsx create mode 100644 front_minimal/src/sections/board/view/board-view.jsx create mode 100644 front_minimal/src/sections/board/view/index.js create mode 100644 front_minimal/src/sections/chat/view/chat-platform-view.jsx create mode 100644 front_minimal/src/sections/children/view/children-progress-view.jsx create mode 100644 front_minimal/src/sections/children/view/children-view.jsx create mode 100644 front_minimal/src/sections/children/view/index.js create mode 100644 front_minimal/src/sections/feedback/view/feedback-view.jsx create mode 100644 front_minimal/src/sections/feedback/view/index.js create mode 100644 front_minimal/src/sections/my-progress/view/index.js create mode 100644 front_minimal/src/sections/my-progress/view/my-progress-view.jsx create mode 100644 front_minimal/src/sections/overview/client/view/index.js create mode 100644 front_minimal/src/sections/overview/client/view/overview-client-view.jsx create mode 100644 front_minimal/src/sections/payment/view/payment-platform-view.jsx create mode 100644 front_minimal/src/sections/referrals/view/index.js create mode 100644 front_minimal/src/sections/referrals/view/referrals-view.jsx create mode 100644 front_minimal/src/sections/video-call/livekit/exit-lesson-modal.jsx create mode 100644 front_minimal/src/sections/video-call/view/index.js create mode 100644 front_minimal/src/sections/video-call/view/video-call-view.jsx create mode 100644 front_minimal/src/styles/livekit-components.css create mode 100644 front_minimal/src/styles/livekit-theme.css create mode 100644 front_minimal/src/utils/analytics-api.js create mode 100644 front_minimal/src/utils/board-api.js create mode 100644 front_minimal/src/utils/chat-api.js create mode 100644 front_minimal/src/utils/dashboard-api.js create mode 100644 front_minimal/src/utils/livekit-api.js create mode 100644 front_minimal/src/utils/profile-api.js create mode 100644 front_minimal/src/utils/referrals-api.js create mode 100644 front_minimal/src/utils/telegram-api.js diff --git a/docker-compose.yml b/docker-compose.yml index f852f00..48f2c80 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -284,11 +284,13 @@ services: build: context: ./excalidraw-server dockerfile: Dockerfile + args: + - NEXT_PUBLIC_BASE_PATH=/devboard container_name: ${COMPOSE_PROJECT_NAME}_excalidraw restart: unless-stopped environment: - NODE_ENV=${NODE_ENV:-production} - - NEXT_PUBLIC_BASE_PATH= + - NEXT_PUBLIC_BASE_PATH=/devboard ports: - "${EXCALIDRAW_PORT:-3001}:3001" networks: diff --git a/excalidraw-server/Dockerfile b/excalidraw-server/Dockerfile index 6bf0de9..acacf92 100644 --- a/excalidraw-server/Dockerfile +++ b/excalidraw-server/Dockerfile @@ -16,7 +16,9 @@ RUN npx patch-package # Копируем исходный код COPY . . -# Собираем приложение +# Собираем приложение (NEXT_PUBLIC_* переменные нужны на этапе сборки) +ARG NEXT_PUBLIC_BASE_PATH= +ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH ENV NODE_ENV=production RUN npm run build diff --git a/front_minimal/package-lock.json b/front_minimal/package-lock.json index 1516666..2fe3e5f 100644 --- a/front_minimal/package-lock.json +++ b/front_minimal/package-lock.json @@ -29,6 +29,9 @@ "@fullcalendar/timeline": "^6.1.14", "@hookform/resolvers": "^3.6.0", "@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/material": "^5.15.20", "@mui/material-nextjs": "^5.15.11", @@ -51,6 +54,7 @@ "autosuggest-highlight": "^3.3.4", "aws-amplify": "^6.3.6", "axios": "^1.7.2", + "date-fns": "^3.6.0", "dayjs": "^1.11.11", "embla-carousel": "^8.1.5", "embla-carousel-auto-height": "^8.1.5", @@ -62,6 +66,7 @@ "i18next": "^23.11.5", "i18next-browser-languagedetector": "^8.0.0", "i18next-resources-to-backend": "^1.2.1", + "livekit-client": "^2.17.2", "lowlight": "^3.1.0", "mapbox-gl": "^3.4.0", "mui-one-time-password-input": "^2.0.2", @@ -3239,6 +3244,12 @@ "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": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.0.tgz", @@ -4076,20 +4087,22 @@ "integrity": "sha512-zuWxyfXNbsKbm96HhXzainONPFqRcoZblQ++e9cAIGUuHfl2cFSBzW01jtesqWG/lqaUyX3H8O1y9oWboGNQBA==" }, "node_modules/@floating-ui/core": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.0.tgz", - "integrity": "sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.1" + "@floating-ui/utils": "^0.2.11" } }, "node_modules/@floating-ui/dom": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.1.tgz", - "integrity": "sha512-iA8qE43/H5iGozC3W0YSnVSW42Vh522yyM1gj+BqRwVsTNOyr231PsXDaV04yT39PsO0QL2QpbI/M0ZaLUQgRQ==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.6.0", - "@floating-ui/utils": "^0.2.1" + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" } }, "node_modules/@floating-ui/react-dom": { @@ -4105,9 +4118,10 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz", - "integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==" + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" }, "node_modules/@fontsource/barlow": { "version": "5.0.13", @@ -4382,6 +4396,76 @@ "@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": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", @@ -6740,6 +6824,13 @@ "@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": { "version": "7946.0.13", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.13.tgz", @@ -7994,6 +8085,16 @@ "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": { "version": "1.11.11", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.11.tgz", @@ -10610,6 +10711,15 @@ "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": { "version": "3.0.5", "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", "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": { "version": "6.0.0", "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", "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": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", @@ -13237,9 +13394,10 @@ "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==" }, "node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", "dependencies": { "tslib": "^2.1.0" } @@ -13323,6 +13481,21 @@ "resolved": "https://registry.npmjs.org/scrollparent/-/scrollparent-2.1.0.tgz", "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": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -14044,6 +14217,12 @@ "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": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -14069,9 +14248,10 @@ } }, "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" }, "node_modules/turndown": { "version": "7.2.0", @@ -14185,6 +14365,15 @@ "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": { "version": "5.4.5", "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" } }, + "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": { "version": "1.0.2", "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", "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": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", diff --git a/front_minimal/package.json b/front_minimal/package.json index 4028691..0a93e0a 100644 --- a/front_minimal/package.json +++ b/front_minimal/package.json @@ -40,6 +40,9 @@ "@fullcalendar/timeline": "^6.1.14", "@hookform/resolvers": "^3.6.0", "@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/material": "^5.15.20", "@mui/material-nextjs": "^5.15.11", @@ -62,6 +65,7 @@ "autosuggest-highlight": "^3.3.4", "aws-amplify": "^6.3.6", "axios": "^1.7.2", + "date-fns": "^3.6.0", "dayjs": "^1.11.11", "embla-carousel": "^8.1.5", "embla-carousel-auto-height": "^8.1.5", @@ -73,6 +77,7 @@ "i18next": "^23.11.5", "i18next-browser-languagedetector": "^8.0.0", "i18next-resources-to-backend": "^1.2.1", + "livekit-client": "^2.17.2", "lowlight": "^3.1.0", "mapbox-gl": "^3.4.0", "mui-one-time-password-input": "^2.0.2", diff --git a/front_minimal/src/actions/calendar.js b/front_minimal/src/actions/calendar.js index 70cf622..790d282 100644 --- a/front_minimal/src/actions/calendar.js +++ b/front_minimal/src/actions/calendar.js @@ -1,44 +1,62 @@ import { useMemo } from 'react'; +import { format, startOfMonth, endOfMonth, addMonths, subMonths } from 'date-fns'; 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 SUBJECTS_ENDPOINT = '/schedule/subjects/'; const swrOptions = { revalidateIfStale: true, - revalidateOnFocus: true, + revalidateOnFocus: false, 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() { - const startDate = '2026-02-01'; - const endDate = '2026-04-30'; +export function useGetEvents(currentDate) { + const date = currentDate || new Date(); + 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( - [CALENDAR_ENDPOINT, startDate, endDate], - ([url, start, end]) => getCalendarLessons(start, end), + ['calendar', start, end], + ([, s, e]) => getCalendarLessons(s, e), swrOptions ); const memoizedValue = useMemo(() => { - const lessonsArray = response?.data?.lessons || []; - + const lessonsArray = response?.data?.lessons || response?.lessons || []; + const events = lessonsArray.map((lesson) => { const start = lesson.start_time || lesson.start; const end = lesson.end_time || lesson.end || start; - const startTimeStr = start ? new Date(start).toLocaleTimeString('ru-RU', { - hour: '2-digit', - minute: '2-digit', - hourCycle: 'h23' - }) : ''; - + const startTimeStr = start + ? new Date(start).toLocaleTimeString('ru-RU', { + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', + }) + : ''; + const subject = lesson.subject_name || lesson.subject || 'Урок'; const student = lesson.client_name || ''; const displayTitle = `${startTimeStr} ${subject}${student ? ` - ${student}` : ''}`; @@ -83,14 +101,16 @@ export function useGetEvents() { // ---------------------------------------------------------------------- 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(() => { const rawData = response?.data?.results || response?.results || response || []; - const studentsArray = Array.isArray(rawData) ? rawData : []; - return { - students: studentsArray, + students: Array.isArray(rawData) ? rawData : [], studentsLoading: isLoading, studentsError: error, }; @@ -98,14 +118,16 @@ export function useGetStudents() { } 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(() => { const rawData = response?.data || response?.results || response || []; - const subjectsArray = Array.isArray(rawData) ? rawData : []; - return { - subjects: subjectsArray, + subjects: Array.isArray(rawData) ? rawData : [], subjectsLoading: isLoading, subjectsError: error, }; @@ -114,25 +136,53 @@ export function useGetSubjects() { // ---------------------------------------------------------------------- -export async function createEvent(eventData) { - const payload = { - client: String(eventData.client), - title: eventData.title.replace(' - ', ' — '), - description: eventData.description, - 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; +function revalidateCalendar(date) { + const d = date || new Date(); + const start = format(startOfMonth(subMonths(d, 1)), 'yyyy-MM-dd'); + const end = format(endOfMonth(addMonths(d, 1)), 'yyyy-MM-dd'); + mutate(['calendar', start, end]); } -export async function updateEvent(eventData) { console.log('Update Event:', eventData); } -export async function deleteEvent(eventId) { console.log('Delete Event:', eventId); } +export async function createEvent(eventData, currentDate) { + 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); +} diff --git a/front_minimal/src/app.jsx b/front_minimal/src/app.jsx new file mode 100644 index 0000000..d204ec5 --- /dev/null +++ b/front_minimal/src/app.jsx @@ -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 ( + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/front_minimal/src/app/auth/jwt/forgot-password/layout.jsx b/front_minimal/src/app/auth/jwt/forgot-password/layout.jsx new file mode 100644 index 0000000..0b03c07 --- /dev/null +++ b/front_minimal/src/app/auth/jwt/forgot-password/layout.jsx @@ -0,0 +1,13 @@ +import { AuthSplitLayout } from 'src/layouts/auth-split'; + +import { GuestGuard } from 'src/auth/guard'; + +// ---------------------------------------------------------------------- + +export default function Layout({ children }) { + return ( + + {children} + + ); +} diff --git a/front_minimal/src/app/auth/jwt/forgot-password/page.jsx b/front_minimal/src/app/auth/jwt/forgot-password/page.jsx new file mode 100644 index 0000000..ea42561 --- /dev/null +++ b/front_minimal/src/app/auth/jwt/forgot-password/page.jsx @@ -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 ; +} diff --git a/front_minimal/src/app/auth/jwt/reset-password/layout.jsx b/front_minimal/src/app/auth/jwt/reset-password/layout.jsx new file mode 100644 index 0000000..0b03c07 --- /dev/null +++ b/front_minimal/src/app/auth/jwt/reset-password/layout.jsx @@ -0,0 +1,13 @@ +import { AuthSplitLayout } from 'src/layouts/auth-split'; + +import { GuestGuard } from 'src/auth/guard'; + +// ---------------------------------------------------------------------- + +export default function Layout({ children }) { + return ( + + {children} + + ); +} diff --git a/front_minimal/src/app/auth/jwt/reset-password/page.jsx b/front_minimal/src/app/auth/jwt/reset-password/page.jsx new file mode 100644 index 0000000..84da173 --- /dev/null +++ b/front_minimal/src/app/auth/jwt/reset-password/page.jsx @@ -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 ; +} diff --git a/front_minimal/src/app/auth/jwt/verify-email/layout.jsx b/front_minimal/src/app/auth/jwt/verify-email/layout.jsx new file mode 100644 index 0000000..3a3648c --- /dev/null +++ b/front_minimal/src/app/auth/jwt/verify-email/layout.jsx @@ -0,0 +1,7 @@ +import { AuthSplitLayout } from 'src/layouts/auth-split'; + +// ---------------------------------------------------------------------- + +export default function Layout({ children }) { + return {children}; +} diff --git a/front_minimal/src/app/auth/jwt/verify-email/page.jsx b/front_minimal/src/app/auth/jwt/verify-email/page.jsx new file mode 100644 index 0000000..0d656bb --- /dev/null +++ b/front_minimal/src/app/auth/jwt/verify-email/page.jsx @@ -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 ; +} diff --git a/front_minimal/src/app/dashboard/analytics/page.jsx b/front_minimal/src/app/dashboard/analytics/page.jsx index 9ffebeb..321da4c 100644 --- a/front_minimal/src/app/dashboard/analytics/page.jsx +++ b/front_minimal/src/app/dashboard/analytics/page.jsx @@ -1,11 +1,11 @@ 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() { - return ; + return ; } diff --git a/front_minimal/src/app/dashboard/board/page.jsx b/front_minimal/src/app/dashboard/board/page.jsx new file mode 100644 index 0000000..b3c945a --- /dev/null +++ b/front_minimal/src/app/dashboard/board/page.jsx @@ -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 ; +} diff --git a/front_minimal/src/app/dashboard/chat-platform/page.jsx b/front_minimal/src/app/dashboard/chat-platform/page.jsx new file mode 100644 index 0000000..7b7b1be --- /dev/null +++ b/front_minimal/src/app/dashboard/chat-platform/page.jsx @@ -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 ; +} diff --git a/front_minimal/src/app/dashboard/children-progress/page.jsx b/front_minimal/src/app/dashboard/children-progress/page.jsx new file mode 100644 index 0000000..e905e30 --- /dev/null +++ b/front_minimal/src/app/dashboard/children-progress/page.jsx @@ -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 ; +} diff --git a/front_minimal/src/app/dashboard/children/page.jsx b/front_minimal/src/app/dashboard/children/page.jsx new file mode 100644 index 0000000..fbe635e --- /dev/null +++ b/front_minimal/src/app/dashboard/children/page.jsx @@ -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 ; +} diff --git a/front_minimal/src/app/dashboard/feedback/page.jsx b/front_minimal/src/app/dashboard/feedback/page.jsx new file mode 100644 index 0000000..64db4e6 --- /dev/null +++ b/front_minimal/src/app/dashboard/feedback/page.jsx @@ -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 ; +} diff --git a/front_minimal/src/app/dashboard/my-progress/page.jsx b/front_minimal/src/app/dashboard/my-progress/page.jsx new file mode 100644 index 0000000..28b8e6f --- /dev/null +++ b/front_minimal/src/app/dashboard/my-progress/page.jsx @@ -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 ; +} diff --git a/front_minimal/src/app/dashboard/page.jsx b/front_minimal/src/app/dashboard/page.jsx index 6631cd8..b153a2b 100644 --- a/front_minimal/src/app/dashboard/page.jsx +++ b/front_minimal/src/app/dashboard/page.jsx @@ -1,38 +1,65 @@ '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 { 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(); + // Для родителя: выбранный ребёнок из 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) { - return
Загрузка...
; + return ( + + + + ); } - if (!user) { - return null; - } + if (!user) return null; - // Роутинг по ролям if (user.role === 'mentor') { return ; } - + if (user.role === 'client') { - return
Дашборд Клиента (в разработке)
; + return ; } - + if (user.role === 'parent') { - return
Дашборд Родителя (в разработке)
; + return ( + + ); } return ( -
-

Неизвестная роль пользователя: {user.role}

-
+ + Неизвестная роль: {user.role} + ); } diff --git a/front_minimal/src/app/dashboard/payment-platform/page.jsx b/front_minimal/src/app/dashboard/payment-platform/page.jsx new file mode 100644 index 0000000..8e195ad --- /dev/null +++ b/front_minimal/src/app/dashboard/payment-platform/page.jsx @@ -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 ; +} diff --git a/front_minimal/src/app/dashboard/profile/page.jsx b/front_minimal/src/app/dashboard/profile/page.jsx new file mode 100644 index 0000000..ec0d4f7 --- /dev/null +++ b/front_minimal/src/app/dashboard/profile/page.jsx @@ -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 ; +} diff --git a/front_minimal/src/app/dashboard/referrals/page.jsx b/front_minimal/src/app/dashboard/referrals/page.jsx new file mode 100644 index 0000000..21593cd --- /dev/null +++ b/front_minimal/src/app/dashboard/referrals/page.jsx @@ -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 ; +} diff --git a/front_minimal/src/app/video-call/layout.jsx b/front_minimal/src/app/video-call/layout.jsx new file mode 100644 index 0000000..db7d052 --- /dev/null +++ b/front_minimal/src/app/video-call/layout.jsx @@ -0,0 +1,5 @@ +// Fullscreen layout — no sidebar or header + +export default function VideoCallLayout({ children }) { + return children; +} diff --git a/front_minimal/src/app/video-call/page.jsx b/front_minimal/src/app/video-call/page.jsx new file mode 100644 index 0000000..aa3eefb --- /dev/null +++ b/front_minimal/src/app/video-call/page.jsx @@ -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 ; +} diff --git a/front_minimal/src/auth/context/jwt/action.js b/front_minimal/src/auth/context/jwt/action.js index fc5ba4f..db02f7d 100644 --- a/front_minimal/src/auth/context/jwt/action.js +++ b/front_minimal/src/auth/context/jwt/action.js @@ -3,25 +3,24 @@ import axios, { endpoints } from 'src/utils/axios'; import { setSession } from './utils'; -import { STORAGE_KEY } from './constant'; +import { STORAGE_KEY, REFRESH_STORAGE_KEY } from './constant'; /** ************************************** * Sign in *************************************** */ export const signInWithPassword = async ({ email, password }) => { try { - const params = { email, password }; + const res = await axios.post(endpoints.auth.signIn, { email, password }); - const res = await axios.post(endpoints.auth.signIn, params); - - // Адаптация под твой API: { data: { tokens: { access } } } - const accessToken = res.data?.data?.tokens?.access; + const data = res.data?.data; + const accessToken = data?.tokens?.access; + const refreshToken = data?.tokens?.refresh; if (!accessToken) { throw new Error('Access token not found in response'); } - setSession(accessToken); + await setSession(accessToken, refreshToken); } catch (error) { console.error('Error during sign in:', error); throw error; @@ -31,24 +30,32 @@ export const signInWithPassword = async ({ email, password }) => { /** ************************************** * Sign up *************************************** */ -export const signUp = async ({ email, password, firstName, lastName }) => { - const params = { - email, - password, - firstName, - lastName, - }; - +export const signUp = async ({ email, password, passwordConfirm, firstName, lastName, role, city, timezone }) => { 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 { accessToken } = res.data; + const data = res.data?.data; + const accessToken = data?.tokens?.access; + const refreshToken = data?.tokens?.refresh; 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) { console.error('Error during sign up:', error); throw error; @@ -66,3 +73,67 @@ export const signOut = async () => { 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; + } +}; diff --git a/front_minimal/src/auth/context/jwt/auth-provider.jsx b/front_minimal/src/auth/context/jwt/auth-provider.jsx index 43645f9..ecc4506 100644 --- a/front_minimal/src/auth/context/jwt/auth-provider.jsx +++ b/front_minimal/src/auth/context/jwt/auth-provider.jsx @@ -3,9 +3,10 @@ import { useMemo, useEffect, useCallback } from 'react'; import { useSetState } from 'src/hooks/use-set-state'; 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 { setSession, isValidToken } from './utils'; +import { refreshAccessToken } from './action'; export function AuthProvider({ children }) { const { state, setState } = useSetState({ @@ -15,25 +16,28 @@ export function AuthProvider({ children }) { const checkUserSession = useCallback(async () => { try { - const accessToken = sessionStorage.getItem(STORAGE_KEY); + let accessToken = localStorage.getItem(STORAGE_KEY); if (accessToken && isValidToken(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 { - 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) { console.error('[Auth Debug]:', error); setState({ user: null, loading: false }); @@ -52,7 +56,7 @@ export function AuthProvider({ children }) { user: state.user ? { ...state.user, - role: state.user?.role ?? 'admin', + role: state.user?.role ?? 'client', } : null, checkUserSession, diff --git a/front_minimal/src/auth/context/jwt/constant.js b/front_minimal/src/auth/context/jwt/constant.js index 14d75cb..052b067 100644 --- a/front_minimal/src/auth/context/jwt/constant.js +++ b/front_minimal/src/auth/context/jwt/constant.js @@ -1 +1,2 @@ export const STORAGE_KEY = 'jwt_access_token'; +export const REFRESH_STORAGE_KEY = 'jwt_refresh_token'; diff --git a/front_minimal/src/auth/context/jwt/utils.js b/front_minimal/src/auth/context/jwt/utils.js index ed72a61..18e7653 100644 --- a/front_minimal/src/auth/context/jwt/utils.js +++ b/front_minimal/src/auth/context/jwt/utils.js @@ -2,7 +2,7 @@ import { paths } from 'src/routes/paths'; 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(() => { try { - alert('Token expired!'); - sessionStorage.removeItem(STORAGE_KEY); + localStorage.removeItem(STORAGE_KEY); + localStorage.removeItem(REFRESH_STORAGE_KEY); window.location.href = paths.auth.jwt.signIn; } catch (error) { console.error('Error during token expiration:', error); - throw error; } }, timeLeft); } // ---------------------------------------------------------------------- -export async function setSession(accessToken) { +export async function setSession(accessToken, refreshToken) { try { 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}`; - const decodedToken = jwtDecode(accessToken); // ~3 days by minimals server + const decodedToken = jwtDecode(accessToken); if (decodedToken && 'exp' in decodedToken) { tokenExpired(decodedToken.exp); @@ -84,7 +87,8 @@ export async function setSession(accessToken) { throw new Error('Invalid access token!'); } } else { - sessionStorage.removeItem(STORAGE_KEY); + localStorage.removeItem(STORAGE_KEY); + localStorage.removeItem(REFRESH_STORAGE_KEY); delete axios.defaults.headers.common.Authorization; } } catch (error) { diff --git a/front_minimal/src/components/chart/chart.jsx b/front_minimal/src/components/chart/chart.jsx index 94bee23..901a776 100644 --- a/front_minimal/src/components/chart/chart.jsx +++ b/front_minimal/src/components/chart/chart.jsx @@ -1,21 +1,10 @@ -import dynamic from 'next/dynamic'; +import { lazy, Suspense } from 'react'; import Box from '@mui/material/Box'; -import { withLoadingProps } from 'src/utils/with-loading-props'; - import { ChartLoading } from './chart-loading'; -const ApexChart = withLoadingProps((props) => - dynamic(() => import('react-apexcharts').then((mod) => mod.default), { - ssr: false, - loading: () => { - const { loading, type } = props(); - - return loading?.disabled ? null : ; - }, - }) -); +const ApexChart = lazy(() => import('react-apexcharts').then((mod) => ({ default: mod.default }))); // ---------------------------------------------------------------------- @@ -42,14 +31,21 @@ export function Chart({ }} {...other} > - + + ) + } + > + + ); } diff --git a/front_minimal/src/components/organizational-chart/organizational-chart.jsx b/front_minimal/src/components/organizational-chart/organizational-chart.jsx index 2dc18bb..2c5e27b 100644 --- a/front_minimal/src/components/organizational-chart/organizational-chart.jsx +++ b/front_minimal/src/components/organizational-chart/organizational-chart.jsx @@ -1,5 +1,4 @@ -import dynamic from 'next/dynamic'; -import { cloneElement } from 'react'; +import { lazy, Suspense, cloneElement } from 'react'; 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), { - ssr: false, -}); +const Tree = lazy(() => + import('react-organizational-chart').then((mod) => ({ default: mod.Tree })) +); -const TreeNode = dynamic(() => import('react-organizational-chart').then((mod) => mod.TreeNode), { - ssr: false, -}); +const TreeNode = lazy(() => + import('react-organizational-chart').then((mod) => ({ default: mod.TreeNode })) +); // ---------------------------------------------------------------------- @@ -27,18 +26,20 @@ export function OrganizationalChart({ data, nodeItem, ...other }) { }); return ( - - {data.children.map((list, index) => ( - - ))} - + + + {data.children.map((list, index) => ( + + ))} + + ); } diff --git a/front_minimal/src/components/settings/server.js b/front_minimal/src/components/settings/server.js index 0cab146..263d6c0 100644 --- a/front_minimal/src/components/settings/server.js +++ b/front_minimal/src/components/settings/server.js @@ -1,13 +1,6 @@ -import { cookies } from 'next/headers'; - -import { STORAGE_KEY, defaultSettings } from './config-settings'; - -// ---------------------------------------------------------------------- +// Stub — server-side functions not used in Vite SPA +// Settings are always read from localStorage export async function detectSettings() { - const cookieStore = cookies(); - - const settingsStore = cookieStore.get(STORAGE_KEY); - - return settingsStore ? JSON.parse(settingsStore?.value) : defaultSettings; + return undefined; } diff --git a/front_minimal/src/components/walktour/walktour.jsx b/front_minimal/src/components/walktour/walktour.jsx index e665d07..7b31cbb 100644 --- a/front_minimal/src/components/walktour/walktour.jsx +++ b/front_minimal/src/components/walktour/walktour.jsx @@ -1,4 +1,4 @@ -import dynamic from 'next/dynamic'; +import { lazy, Suspense } from 'react'; 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), { - ssr: false, -}); +const Joyride = lazy(() => import('react-joyride').then((mod) => ({ default: mod.default }))); // ---------------------------------------------------------------------- @@ -32,6 +30,7 @@ export function Walktour({ }; return ( + + ); } diff --git a/front_minimal/src/config-global.js b/front_minimal/src/config-global.js index 0ccd068..fd3835b 100644 --- a/front_minimal/src/config-global.js +++ b/front_minimal/src/config-global.js @@ -6,16 +6,17 @@ import packageJson from '../package.json'; export const CONFIG = { site: { - name: 'Minimals', + name: 'Platform', serverUrl: process.env.NEXT_PUBLIC_SERVER_URL ?? '', assetURL: process.env.NEXT_PUBLIC_ASSET_URL ?? '', basePath: process.env.NEXT_PUBLIC_BASE_PATH ?? '', version: packageJson.version, }, - isStaticExport: JSON.parse(`${process.env.BUILD_STATIC_EXPORT}`), + // В Next.js всегда работаем как SPA (без SSR для наших страниц) + isStaticExport: true, /** * Auth - * @method jwt | amplify | firebase | supabase | auth0 + * @method jwt */ auth: { method: 'jwt', diff --git a/front_minimal/src/hooks/use-chat-websocket.js b/front_minimal/src/hooks/use-chat-websocket.js new file mode 100644 index 0000000..cdee81d --- /dev/null +++ b/front_minimal/src/hooks/use-chat-websocket.js @@ -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 }; +} diff --git a/front_minimal/src/layouts/config-nav-dashboard.jsx b/front_minimal/src/layouts/config-nav-dashboard.jsx index f4c7c22..7c4737e 100644 --- a/front_minimal/src/layouts/config-nav-dashboard.jsx +++ b/front_minimal/src/layouts/config-nav-dashboard.jsx @@ -14,32 +14,65 @@ const ICONS = { dashboard: icon('ic-dashboard'), kanban: icon('ic-kanban'), folder: icon('ic-folder'), + analytics: icon('ic-analytics'), + label: icon('ic-label'), }; // ---------------------------------------------------------------------- -export const navData = [ - /** - * Основное - */ - { - subheader: 'Главная', - items: [ - { title: 'Панель управления', path: paths.dashboard.root, icon: ICONS.dashboard }, - ], - }, - /** - * Управление - */ - { - subheader: 'Инструменты', - items: [ - { title: 'Ученики', path: paths.dashboard.students, icon: ICONS.user }, - { title: 'Расписание', path: paths.dashboard.calendar, icon: ICONS.calendar }, - { title: 'Домашние задания', path: paths.dashboard.homework, icon: ICONS.kanban }, - { title: 'Материалы', path: paths.dashboard.materials, icon: ICONS.folder }, - { title: 'Чат', path: paths.dashboard.chat, icon: ICONS.chat }, - { title: 'Уведомления', path: paths.dashboard.notifications, icon: icon('ic-label') }, - ], - }, -]; +export function getNavData(role) { + const isMentor = role === 'mentor'; + const isClient = role === 'client'; + const isParent = role === 'parent'; + + return [ + { + subheader: 'Главная', + items: [ + { title: 'Панель управления', path: paths.dashboard.root, icon: ICONS.dashboard }, + ], + }, + { + subheader: 'Инструменты', + items: [ + // Ученики/Менторы — для всех ролей (разный контент внутри) + { title: isMentor ? 'Ученики' : 'Менторы', path: paths.dashboard.students, icon: ICONS.user }, + { title: 'Расписание', path: paths.dashboard.calendar, icon: ICONS.calendar }, + { title: 'Домашние задания', path: paths.dashboard.homework, icon: ICONS.kanban }, + { title: 'Материалы', path: paths.dashboard.materials, icon: ICONS.folder }, + { title: 'Доска', path: paths.dashboard.board, icon: ICONS.kanban }, + { 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'); diff --git a/front_minimal/src/layouts/dashboard/layout.jsx b/front_minimal/src/layouts/dashboard/layout.jsx index 549c783..8744d47 100644 --- a/front_minimal/src/layouts/dashboard/layout.jsx +++ b/front_minimal/src/layouts/dashboard/layout.jsx @@ -24,7 +24,8 @@ import { _account } from '../config-nav-account'; import { HeaderBase } from '../core/header-base'; import { _workspaces } from '../config-nav-workspace'; 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 { user } = useAuthContext(); + const navColorVars = useNavColorVars(theme, settings); const layoutQuery = 'lg'; - const navData = data?.nav ?? dashboardNavData; + const navData = data?.nav ?? getNavData(user?.role); const isNavMini = settings.navLayout === 'mini'; diff --git a/front_minimal/src/locales/server.js b/front_minimal/src/locales/server.js index f445e50..aae45f0 100644 --- a/front_minimal/src/locales/server.js +++ b/front_minimal/src/locales/server.js @@ -1,51 +1,6 @@ -import { cache } from 'react'; -import { createInstance } from 'i18next'; -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 - */ +// Stub — server-side functions not used in Vite SPA +// Language detection is handled by i18n-provider via localStorage export async function detectLanguage() { - const cookies = getCookies(); - - const language = cookies.get(cookieName)?.value ?? fallbackLng; - - return language; + return undefined; } - -// ---------------------------------------------------------------------- - -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; -}; diff --git a/front_minimal/src/main.jsx b/front_minimal/src/main.jsx new file mode 100644 index 0000000..b1fcead --- /dev/null +++ b/front_minimal/src/main.jsx @@ -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( + + + +); diff --git a/front_minimal/src/routes/paths.js b/front_minimal/src/routes/paths.js index 0e8372f..4905505 100644 --- a/front_minimal/src/routes/paths.js +++ b/front_minimal/src/routes/paths.js @@ -17,6 +17,7 @@ const ROOTS = { // ---------------------------------------------------------------------- export const paths = { + videoCall: '/video-call', comingSoon: '/coming-soon', maintenance: '/maintenance', pricing: '/pricing', @@ -106,6 +107,16 @@ export const paths = { materials: `${ROOTS.DASHBOARD}/materials`, students: `${ROOTS.DASHBOARD}/students`, 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`, permission: `${ROOTS.DASHBOARD}/permission`, general: { diff --git a/front_minimal/src/routes/sections.jsx b/front_minimal/src/routes/sections.jsx new file mode 100644 index 0000000..12fe5a8 --- /dev/null +++ b/front_minimal/src/routes/sections.jsx @@ -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 ; +} + +function DashboardLayoutWrapper() { + return ( + + + + + + ); +} + +function AuthLayoutWrapper() { + return ( + + + + + + ); +} + +// ---------------------------------------------------------------------- + +export function Router() { + return useRoutes([ + // Root redirect + { + path: '/', + element: , + }, + + // Auth - JWT + { + path: 'auth/jwt', + element: , + children: [ + { + path: 'sign-in', + element: ( + }> + + + ), + }, + { + path: 'sign-up', + element: ( + }> + + + ), + }, + { + path: 'forgot-password', + element: ( + }> + + + ), + }, + { + path: 'reset-password', + element: ( + }> + + + ), + }, + ], + }, + + // Verify email — без GuestGuard + { + path: 'auth/jwt/verify-email', + element: ( + + }> + + + + ), + }, + + // Dashboard + { + path: 'dashboard', + element: , + children: [ + { index: true, element: }, + + // Overview + { + path: 'analytics', + element: ( + }> + + + ), + }, + { + path: 'ecommerce', + element: ( + }> + + + ), + }, + { + path: 'banking', + element: ( + }> + + + ), + }, + { + path: 'booking', + element: ( + }> + + + ), + }, + { + path: 'file', + element: ( + }> + + + ), + }, + { + path: 'course', + element: ( + }> + + + ), + }, + + // Features + { + path: 'schedule', + element: ( + }> + + + ), + }, + { + path: 'chat', + element: ( + }> + + + ), + }, + { + path: 'mail', + element: ( + }> + + + ), + }, + { + path: 'kanban', + element: ( + }> + + + ), + }, + { + path: 'file-manager', + element: ( + }> + + + ), + }, + { + path: 'permission', + element: ( + }> + + + ), + }, + { + path: 'blank', + element: ( + }> + + + ), + }, + + // User + { + path: 'user', + children: [ + { index: true, element: }, + { + path: 'profile', + element: ( + }> + + + ), + }, + { + path: 'list', + element: ( + }> + + + ), + }, + { + path: 'cards', + element: ( + }> + + + ), + }, + { + path: 'new', + element: ( + }> + + + ), + }, + { + path: ':id/edit', + element: ( + }> + + + ), + }, + { + path: 'account', + element: ( + }> + + + ), + }, + ], + }, + + // Product + { + path: 'product', + children: [ + { + index: true, + element: ( + }> + + + ), + }, + { + path: 'new', + element: ( + }> + + + ), + }, + { + path: ':id', + element: ( + }> + + + ), + }, + { + path: ':id/edit', + element: ( + }> + + + ), + }, + ], + }, + + // Order + { + path: 'order', + children: [ + { + index: true, + element: ( + }> + + + ), + }, + { + path: ':id', + element: ( + }> + + + ), + }, + ], + }, + + // Invoice + { + path: 'invoice', + children: [ + { + index: true, + element: ( + }> + + + ), + }, + { + path: 'new', + element: ( + }> + + + ), + }, + { + path: ':id', + element: ( + }> + + + ), + }, + { + path: ':id/edit', + element: ( + }> + + + ), + }, + ], + }, + + // Blog / Post + { + path: 'post', + children: [ + { + index: true, + element: ( + }> + + + ), + }, + { + path: 'new', + element: ( + }> + + + ), + }, + { + path: ':title', + element: ( + }> + + + ), + }, + { + path: ':title/edit', + element: ( + }> + + + ), + }, + ], + }, + + // Job + { + path: 'job', + children: [ + { + index: true, + element: ( + }> + + + ), + }, + { + path: 'new', + element: ( + }> + + + ), + }, + { + path: ':id', + element: ( + }> + + + ), + }, + { + path: ':id/edit', + element: ( + }> + + + ), + }, + ], + }, + + // Tour + { + path: 'tour', + children: [ + { + index: true, + element: ( + }> + + + ), + }, + { + path: 'new', + element: ( + }> + + + ), + }, + { + path: ':id', + element: ( + }> + + + ), + }, + { + path: ':id/edit', + element: ( + }> + + + ), + }, + ], + }, + ], + }, + + // Error pages + { path: '403', element: }> }, + { path: '404', element: }> }, + { path: '500', element: }> }, + + // Catch-all + { path: '*', element: }, + ]); +} diff --git a/front_minimal/src/sections/account-platform/view/account-platform-view.jsx b/front_minimal/src/sections/account-platform/view/account-platform-view.jsx new file mode 100644 index 0000000..8ac0940 --- /dev/null +++ b/front_minimal/src/sections/account-platform/view/account-platform-view.jsx @@ -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 ( + + + + ); + } + + const botLink = botInfo?.link || (botInfo?.username ? `https://t.me/${botInfo.username}` : null); + + return ( + <> + + {status?.linked ? ( + + + + + Telegram подключён + + {status.telegram_username && ( + + @{status.telegram_username} + + )} + + + } + > + Загрузить фото из Telegram + + } + > + Отвязать + + + + ) : ( + + + Подключите Telegram для уведомлений + + } + > + Привязать Telegram + + + )} + + + {/* Code Modal */} + + Привязать Telegram + + + + 1. Откройте бота{' '} + {botLink ? ( + + {botInfo?.username ? `@${botInfo.username}` : 'Telegram бот'} + + ) : ( + 'Telegram бот' + )}{' '} + в Telegram + + 2. Отправьте боту команду: + + + /link {code} + + + + + + + + {codeInstructions && ( + + {codeInstructions} + + )} + + + + Ожидаем подтверждения... + + + + + + + + + + ); +} + +// ---------------------------------------------------------------------- + +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 ( + + + + + Тип уведомления + {CHANNELS.map((ch) => ( + + {ch.label} + + ))} + + + + {visibleTypes.map((type) => ( + + + {type.label} + + {CHANNELS.map((ch) => ( + + handleToggle(type.key, ch.key)} + disabled={!prefs?.enabled} + /> + + ))} + + ))} + +
+
+ ); +} + +// ---------------------------------------------------------------------- + +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 ( + + + + + {/* ─── LEFT COLUMN ─── */} + + {/* Avatar card */} + + + + {/* Square avatar with hover overlay */} + setAvatarHovered(true)} + onMouseLeave={() => setAvatarHovered(false)} + > + + {!currentAvatar && displayName[0]?.toUpperCase()} + + + {/* Hover overlay */} + {avatarHovered && ( + + + + + {uploadingAvatar ? ( + + ) : ( + + )} + + + + {currentAvatar && ( + + + {deletingAvatar ? ( + + ) : ( + + )} + + + )} + + )} + + + + {displayName} + {user?.email} + {roleLabel && ( + + )} + + + + + + {/* Profile fields */} + + + + Личные данные + + + + setFirstName(e.target.value)} + onBlur={(e) => handleProfileBlur('first_name', e.target.value.trim())} + fullWidth + /> + setLastName(e.target.value)} + onBlur={(e) => handleProfileBlur('last_name', e.target.value.trim())} + fullWidth + /> + + setPhone(e.target.value)} + onBlur={(e) => handleProfileBlur('phone', e.target.value.trim())} + fullWidth + /> + + + + + + {/* City + Timezone */} + + + + Местоположение + + + {settingsLoading ? ( + + + + ) : ( + <> + + 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) => ( + { + const newSettings = { + ...settings, + preferences: { + ...settings?.preferences, + city: cityQuery, + }, + }; + scheduleSettingsSave(newSettings); + }} + InputProps={{ + ...params.InputProps, + endAdornment: ( + <> + {citySearching ? : null} + {params.InputProps.endAdornment} + + ), + }} + /> + )} + renderOption={(props, opt) => ( + + + + {opt.name}{opt.region ? `, ${opt.region}` : ''} + + {opt.timezone && ( + + {opt.timezone} + + )} + + + )} + /> + + + )} + + + + + {/* Telegram */} + + + + Telegram + + + + + + + {/* ─── RIGHT COLUMN ─── */} + + {/* Notification preferences */} + + + + Уведомления + {notifSaving && } + + + {settingsLoading ? ( + + + + ) : ( + + {/* Global toggle */} + handleNotifChange({ enabled: !notifPrefs?.enabled })} + /> + } + label="Включить уведомления" + /> + + {notifPrefs?.enabled && ( + <> + + {/* Channel toggles */} + + handleNotifChange({ email_enabled: !notifPrefs?.email_enabled })} + /> + } + label="Email" + /> + handleNotifChange({ telegram_enabled: !notifPrefs?.telegram_enabled })} + /> + } + label="Telegram" + /> + handleNotifChange({ in_app_enabled: !notifPrefs?.in_app_enabled })} + /> + } + label="В приложении" + /> + + + + + Настройки по типу уведомлений + + + + )} + + )} + + + + {/* AI homework settings (mentor only) */} + {user?.role === 'mentor' && settings && ( + + + + AI проверка домашних заданий + {settingsSaving && } + + + { + 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 сохраняет как черновик (нужна ваша проверка)" + /> + { + 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 публикует результат автоматически" + /> + + Эти опции взаимно исключают друг друга + + + + + )} + + + + setSnack((prev) => ({ ...prev, open: false }))} + anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} + > + setSnack((prev) => ({ ...prev, open: false }))}> + {snack.message} + + + + ); +} diff --git a/front_minimal/src/sections/account-platform/view/index.js b/front_minimal/src/sections/account-platform/view/index.js new file mode 100644 index 0000000..a272cda --- /dev/null +++ b/front_minimal/src/sections/account-platform/view/index.js @@ -0,0 +1 @@ +export { AccountPlatformView } from './account-platform-view'; diff --git a/front_minimal/src/sections/analytics/view/analytics-view.jsx b/front_minimal/src/sections/analytics/view/analytics-view.jsx new file mode 100644 index 0000000..d0bb13f --- /dev/null +++ b/front_minimal/src/sections/analytics/view/analytics-view.jsx @@ -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 ( + + + {label} + + + {value ?? '—'} + + + ); +} + +function DateRangeFilter({ value, onChange, disabled }) { + return ( + + + d && onChange({ ...value, start_date: d.format('YYYY-MM-DD') })} + disabled={disabled} + slotProps={{ textField: { size: 'small', sx: { width: 140 } } }} + /> + d && onChange({ ...value, end_date: d.format('YYYY-MM-DD') })} + disabled={disabled} + slotProps={{ textField: { size: 'small', sx: { width: 140 } } }} + /> + + + ); +} + +// ---------------------------------------------------------------------- + +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 ( + + + Доход + + + + {loading ? ( + + + + ) : ( + <> + + + + + + + {(data?.chart_data ?? []).length > 0 ? ( + + ) : ( + + Нет данных за период + + )} + + {(data?.top_lessons ?? []).length > 0 && ( + + + Топ занятий по доходам + + {data.top_lessons.slice(0, 10).map((item, i) => ( + + + {i + 1}. {item.lesson_title || item.target_name || 'Занятие'} + + + {formatCurrency(item.total_income)} + + + ))} + + )} + + )} + + ); +} + +// ---------------------------------------------------------------------- + +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 ( + + + Занятия + + + + {loading ? ( + + + + ) : ( + <> + + + + + + + {byDay.length > 0 ? ( + + ) : ( + + Нет данных за период + + )} + + )} + + ); +} + +// ---------------------------------------------------------------------- + +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 ( + + + Успех учеников + + + + {loading ? ( + + + + ) : ( + <> + + + + + + + {byDay.length > 0 ? ( + + ) : ( + + Нет данных за период + + )} + + {(students?.students ?? []).length > 0 && ( + + + Топ ученики + + {students.students.slice(0, 10).map((s, i) => ( + + {i + 1}. {s.name} + + {s.lessons_completed} занятий · ср. {s.average_grade} + + + ))} + + )} + + )} + + ); +} + +// ---------------------------------------------------------------------- + +export function AnalyticsView() { + const { user } = useAuthContext(); + const [tab, setTab] = useState(0); + + if (user?.role !== 'mentor') { + return ( + + + Аналитика доступна только менторам. + + + ); + } + + return ( + + + Аналитика + + + + setTab(v)} sx={{ px: 2, borderBottom: 1, borderColor: 'divider' }}> + + + + + + + {tab === 0 && } + {tab === 1 && } + {tab === 2 && } + + + + ); +} diff --git a/front_minimal/src/sections/analytics/view/index.js b/front_minimal/src/sections/analytics/view/index.js new file mode 100644 index 0000000..dba59a2 --- /dev/null +++ b/front_minimal/src/sections/analytics/view/index.js @@ -0,0 +1 @@ +export * from './analytics-view'; diff --git a/front_minimal/src/sections/auth/jwt/index.js b/front_minimal/src/sections/auth/jwt/index.js index 0e2428a..e078f8a 100644 --- a/front_minimal/src/sections/auth/jwt/index.js +++ b/front_minimal/src/sections/auth/jwt/index.js @@ -1,3 +1,5 @@ export * from './jwt-sign-in-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'; diff --git a/front_minimal/src/sections/auth/jwt/jwt-forgot-password-view.jsx b/front_minimal/src/sections/auth/jwt/jwt-forgot-password-view.jsx new file mode 100644 index 0000000..da41821 --- /dev/null +++ b/front_minimal/src/sections/auth/jwt/jwt-forgot-password-view.jsx @@ -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 ( + <> + + Forgot your password? + + + Enter your email address and we will send you a link to reset your password. + + + + {!!successMsg && ( + + {successMsg} + + )} + + {!!errorMsg && ( + + {errorMsg} + + )} + + {!successMsg && ( +
+ + + + + Send reset link + + +
+ )} + + + Back to sign in + + + ); +} diff --git a/front_minimal/src/sections/auth/jwt/jwt-reset-password-view.jsx b/front_minimal/src/sections/auth/jwt/jwt-reset-password-view.jsx new file mode 100644 index 0000000..81191ce --- /dev/null +++ b/front_minimal/src/sections/auth/jwt/jwt-reset-password-view.jsx @@ -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 ( + <> + + Reset password + + + Reset link is missing. Please follow the link from your email or request a new one. + + + Request new reset link + + + ); + } + + if (successMsg) { + return ( + <> + + Reset password + + + {successMsg} + + + Sign in + + + ); + } + + return ( + <> + + Set new password + + Enter your new password below. + + + + {!!errorMsg && ( + + {errorMsg} + + )} + +
+ + + + + + + ), + }} + /> + + + + + + + ), + }} + /> + + + Save new password + + +
+ + + Back to sign in + + + ); +} + +// ---------------------------------------------------------------------- + +export function JwtResetPasswordView() { + return ( + Loading...}> + + + ); +} diff --git a/front_minimal/src/sections/auth/jwt/jwt-sign-in-view.jsx b/front_minimal/src/sections/auth/jwt/jwt-sign-in-view.jsx index cc3b80a..5b1b840 100644 --- a/front_minimal/src/sections/auth/jwt/jwt-sign-in-view.jsx +++ b/front_minimal/src/sections/auth/jwt/jwt-sign-in-view.jsx @@ -50,8 +50,8 @@ export function JwtSignInView() { const password = useBoolean(); const defaultValues = { - email: 'demo@minimals.cc', - password: '@demo1', + email: 'mentor@demo.uchill.online', + password: 'demo123456', }; const methods = useForm({ @@ -99,7 +99,7 @@ export function JwtSignInView() { data.password === data.passwordConfirm, { + message: 'Passwords do not match!', + path: ['passwordConfirm'], + }); // ---------------------------------------------------------------------- export function JwtSignUpView() { const { checkUserSession } = useAuthContext(); - const router = useRouter(); const password = useBoolean(); + const passwordConfirm = useBoolean(); const [errorMsg, setErrorMsg] = useState(''); + const [successMsg, setSuccessMsg] = useState(''); + const [consent, setConsent] = useState(false); const defaultValues = { - firstName: 'Hello', - lastName: 'Friend', - email: 'hello@gmail.com', - password: '@demo1', + firstName: '', + lastName: '', + email: '', + role: 'client', + city: '', + password: '', + passwordConfirm: '', }; const methods = useForm({ @@ -69,19 +85,35 @@ export function JwtSignUpView() { } = methods; const onSubmit = handleSubmit(async (data) => { + if (!consent) { + setErrorMsg('Please agree to the Terms of Service and Privacy Policy.'); + return; + } + try { - await signUp({ + const result = await signUp({ email: data.email, password: data.password, + passwordConfirm: data.passwordConfirm, firstName: data.firstName, 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(); } catch (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() { ); + if (successMsg) { + return ( + <> + {renderHead} + {successMsg} + + + Back to sign in + + + + ); + } + const renderForm = ( @@ -110,6 +156,14 @@ export function JwtSignUpView() { + + Student + Mentor + Parent + + + + + + + + + + ), + }} + /> + + setConsent(e.target.checked)} />} + label={ + + I agree to the{' '} + + Terms of service + {' '} + and{' '} + + Privacy policy + + + } + /> + Create account ); - const renderTerms = ( - - {'By signing up, I agree to '} - - Terms of service - - {' and '} - - Privacy policy - - . - - ); - return ( <> {renderHead} @@ -176,8 +241,6 @@ export function JwtSignUpView() {
{renderForm}
- - {renderTerms} ); } diff --git a/front_minimal/src/sections/auth/jwt/jwt-verify-email-view.jsx b/front_minimal/src/sections/auth/jwt/jwt-verify-email-view.jsx new file mode 100644 index 0000000..ac326c4 --- /dev/null +++ b/front_minimal/src/sections/auth/jwt/jwt-verify-email-view.jsx @@ -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 ( + <> + + Email verification + + + {status === 'loading' && ( + + + + Verifying your email... + + + )} + + {status === 'success' && ( + <> + + {message} + + + Sign in to your account + + + )} + + {status === 'error' && ( + <> + + {message} + + + + Back to sign in + + + Create a new account + + + + )} + + ); +} + +// ---------------------------------------------------------------------- + +export function JwtVerifyEmailView() { + return ( + Loading...}> + + + ); +} diff --git a/front_minimal/src/sections/board/view/board-view.jsx b/front_minimal/src/sections/board/view/board-view.jsx new file mode 100644 index 0000000..a082b0d --- /dev/null +++ b/front_minimal/src/sections/board/view/board-view.jsx @@ -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
; +} + +// ---------------------------------------------------------------------- + +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 ( + + + {/* Preview area */} + + + {board.elements_count > 0 && ( + + + {board.elements_count} элем. + + + )} + + + + + {board.title || 'Без названия'} + + + + + {otherInitials} + + + {isMentor ? 'Ученик: ' : 'Ментор: '}{otherName} + + + + {lastEdited && ( + + Изменено {lastEdited} + + )} + + + + ); +} + +// ---------------------------------------------------------------------- + +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 ( + + + + ); + } + + const allBoards = [...myBoards, ...sharedBoards]; + const uniqueBoards = allBoards.filter((b, i, arr) => arr.findIndex((x) => x.board_id === b.board_id) === i); + + return ( + + {!excalidrawAvailable && ( + + + Excalidraw не настроен. Укажите NEXT_PUBLIC_EXCALIDRAW_URL или NEXT_PUBLIC_EXCALIDRAW_PATH в .env + + + )} + + + Доски + + {user?.role === 'mentor' && students.length > 0 && ( + + {students.slice(0, 6).map((s) => { + const name = getUserName(s.user || s); + const initials = getUserInitials(s.user || s); + return ( + + + + ); + })} + + )} + + + {uniqueBoards.length === 0 ? ( + + + Досок пока нет + {user?.role === 'mentor' && ( + + Нажмите на имя ученика выше, чтобы открыть совместную доску + + )} + + ) : ( + + {uniqueBoards.map((board) => ( + + onOpen(board.board_id)} /> + + ))} + + )} + + ); +} + +// ---------------------------------------------------------------------- + +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 ( + + + + + + Интерактивная доска + #{boardId} + + + + + + ); + } + + return ( + + + + ); +} diff --git a/front_minimal/src/sections/board/view/index.js b/front_minimal/src/sections/board/view/index.js new file mode 100644 index 0000000..b067e1a --- /dev/null +++ b/front_minimal/src/sections/board/view/index.js @@ -0,0 +1 @@ +export { BoardView } from './board-view'; diff --git a/front_minimal/src/sections/calendar/calendar-form.jsx b/front_minimal/src/sections/calendar/calendar-form.jsx index 2f0c074..78913e9 100644 --- a/front_minimal/src/sections/calendar/calendar-form.jsx +++ b/front_minimal/src/sections/calendar/calendar-form.jsx @@ -1,39 +1,45 @@ +import 'dayjs/locale/ru'; +import dayjs from 'dayjs'; import { z as zod } from 'zod'; import { useForm, Controller } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; -import { useMemo, useCallback } from 'react'; -import dayjs from 'dayjs'; -import 'dayjs/locale/ru'; +import { useMemo, useState, useCallback } from 'react'; // ---------------------------------------------------------------------- -dayjs.locale('ru'); - -// ---------------------------------------------------------------------- +import { useRouter } from 'next/navigation'; import Box from '@mui/material/Box'; import Stack from '@mui/material/Stack'; import Button from '@mui/material/Button'; import Dialog from '@mui/material/Dialog'; import Avatar from '@mui/material/Avatar'; +import Switch from '@mui/material/Switch'; import Tooltip from '@mui/material/Tooltip'; 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 LoadingButton from '@mui/lab/LoadingButton'; import DialogTitle from '@mui/material/DialogTitle'; import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; 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 { fIsAfter } from 'src/utils/format-time'; + import { Iconify } from 'src/components/iconify'; import { Form, Field } from 'src/components/hook-form'; // ---------------------------------------------------------------------- +dayjs.locale('ru'); + +// ---------------------------------------------------------------------- + const DURATION_OPTIONS = [ { value: 30, label: '30 минут' }, { value: 45, label: '45 минут' }, @@ -51,8 +57,10 @@ export function CalendarForm({ onUpdateEvent, onDeleteEvent, }) { + const router = useRouter(); const { students } = useGetStudents(); const { subjects } = useGetSubjects(); + const [joiningVideo, setJoiningVideo] = useState(false); const EventSchema = zod.object({ 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 payload = { - title: displayTitle, // Добавляем обязательное поле title + title: displayTitle, client: data.client, subject: data.subject, description: data.description, start_time: startTime.toISOString(), - end_time: endTime.toISOString(), + duration: data.duration, price: data.price, 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 () => { try { await onDeleteEvent(`${currentEvent?.id}`); @@ -270,6 +293,18 @@ export function CalendarForm({ + {currentEvent?.id && currentEvent?.extendedProps?.status !== 'completed' && ( + } + > + Войти + + )} + diff --git a/front_minimal/src/sections/calendar/calendar-toolbar.jsx b/front_minimal/src/sections/calendar/calendar-toolbar.jsx index 44e3f01..306889d 100644 --- a/front_minimal/src/sections/calendar/calendar-toolbar.jsx +++ b/front_minimal/src/sections/calendar/calendar-toolbar.jsx @@ -74,12 +74,6 @@ export function CalendarToolbar({ - - - - - - {loading && ( diff --git a/front_minimal/src/sections/calendar/view/calendar-view.jsx b/front_minimal/src/sections/calendar/view/calendar-view.jsx index e14f0c2..e881785 100644 --- a/front_minimal/src/sections/calendar/view/calendar-view.jsx +++ b/front_minimal/src/sections/calendar/view/calendar-view.jsx @@ -17,6 +17,7 @@ import Container from '@mui/material/Container'; import Typography from '@mui/material/Typography'; import { paths } from 'src/routes/paths'; +import { useAuthContext } from 'src/auth/hooks'; import { useBoolean } from 'src/hooks/use-boolean'; 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 { StyledCalendar } from '../styles'; import { CalendarToolbar } from '../calendar-toolbar'; -import { CalendarFilters } from '../calendar-filters'; import { useCalendar } from '../hooks/use-calendar'; // ---------------------------------------------------------------------- export function CalendarView() { 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 { - 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; + const { events, eventsLoading } = useGetEvents(date); useEffect(() => { onInitialView(); }, [onInitialView]); - const handleFilters = useCallback((name, value) => { - setFilters((prevState) => ({ - ...prevState, - [name]: value, - })); - }, []); + const handleCreateEvent = useCallback( + (eventData) => createEvent(eventData, date), + [date] + ); - const handleResetFilters = useCallback(() => { - setFilters({ - colors: [], - startDate: null, - endDate: null, - }); - }, []); + const handleUpdateEvent = useCallback( + (eventData) => updateEvent(eventData, date), + [date] + ); - const dataPrepared = events.filter((event) => { - const { colors, startDate, endDate } = filters; - - 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; - }); + const handleDeleteEvent = useCallback( + (eventId, deleteAllFuture) => deleteEvent(eventId, deleteAllFuture, date), + [date] + ); return ( <> - + Расписание - + {isMentor && ( + + )} @@ -136,16 +102,15 @@ export function CalendarView() { onPrevDate={onDatePrev} onToday={onDateToday} onChangeView={onChangeView} - onOpenFilters={openFilters.onTrue} /> - event.id === selectEventId)} - range={selectedRange} - open={openForm} - onClose={onCloseForm} - onCreateEvent={createEvent} - onUpdateEvent={updateEvent} - onDeleteEvent={deleteEvent} - /> - - + {isMentor && ( + event.id === selectEventId)} + range={selectedRange} + open={openForm} + onClose={onCloseForm} + onCreateEvent={handleCreateEvent} + onUpdateEvent={handleUpdateEvent} + onDeleteEvent={handleDeleteEvent} + /> + )} ); } diff --git a/front_minimal/src/sections/chat/view/chat-platform-view.jsx b/front_minimal/src/sections/chat/view/chat-platform-view.jsx new file mode 100644 index 0000000..47a4666 --- /dev/null +++ b/front_minimal/src/sections/chat/view/chat-platform-view.jsx @@ -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 ( + + Новый чат + + setQuery(e.target.value)} + fullWidth + autoFocus + sx={{ mt: 1 }} + InputProps={{ + endAdornment: searching ? ( + + + + ) : null, + }} + /> + {error && ( + + {error} + + )} + + {results.map((u) => { + const name = `${u.first_name || ''} ${u.last_name || ''}`.trim() || u.email || ''; + return ( + handleCreate(u.id)} + disabled={creating} + sx={{ borderRadius: 1 }} + > + {getInitials(name)} + + + ); + })} + {!searching && query.trim() && results.length === 0 && ( + + Пользователи не найдены + + )} + + + + + + + ); +} + +// ---------------------------------------------------------------------- + +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 ( + + + setQ(e.target.value)} + placeholder="Поиск" + size="small" + fullWidth + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + + + + + {loading ? ( + + + + ) : filtered.length === 0 ? ( + + {q ? 'Не найдено' : 'Нет чатов'} + + ) : ( + + {filtered.map((chat) => { + const selected = !!selectedUuid && chat.uuid === selectedUuid; + return ( + onSelect(chat)} + sx={{ py: 1.25, px: 1.5 }} + > + + {getInitials(chat.participant_name)} + + + + {chat.participant_name || 'Чат'} + + {!!chat.unread_count && ( + + )} + + } + secondary={ + + {stripHtml(chat.last_message || '')} + + } + primaryTypographyProps={{ component: 'div' }} + secondaryTypographyProps={{ component: 'div' }} + /> + + ); + })} + + )} + + + ); +} + +// ---------------------------------------------------------------------- + +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 ( + + Выберите чат из списка + + ); + } + + 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 ( + + {/* Header */} + + {onBack && ( + + + + )} + {getInitials(chat.participant_name)} + + {chat.participant_name || 'Чат'} + {chat.other_is_online && ( + + Онлайн + + )} + + + + {/* Messages */} + { + 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 && ( + + Загрузка… + + )} + {loading ? ( + + + + ) : ( + grouped.map((item) => { + if (item.type === 'day') { + return ( + + + {item.label} + + + ); + } + const { msg, isMine, isSystem } = item; + const msgUuid = msg.uuid ? String(msg.uuid) : null; + return ( + + + {stripHtml(msg.content || '')} + + + {formatTime(msg.created_at)} + + + ); + }) + )} + + + {/* Input */} + + + setText(e.target.value)} + placeholder="Сообщение…" + fullWidth + multiline + minRows={1} + maxRows={4} + size="small" + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }} + /> + + {sending ? : } + + + + ); +} + +// ---------------------------------------------------------------------- + +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 ( + + + + + + {error && ( + + {error} + + )} + + + + setNewChatOpen(true)} + loading={loading} + /> + + + + setSelectedChat(null)} + /> + + + + setNewChatOpen(false)} + onCreated={handleChatCreated} + /> + + ); +} diff --git a/front_minimal/src/sections/chat/view/index.js b/front_minimal/src/sections/chat/view/index.js index ecfab5f..39ed3fd 100644 --- a/front_minimal/src/sections/chat/view/index.js +++ b/front_minimal/src/sections/chat/view/index.js @@ -1 +1,2 @@ export * from './chat-view'; +export { ChatPlatformView } from './chat-platform-view'; diff --git a/front_minimal/src/sections/children/view/children-progress-view.jsx b/front_minimal/src/sections/children/view/children-progress-view.jsx new file mode 100644 index 0000000..6960999 --- /dev/null +++ b/front_minimal/src/sections/children/view/children-progress-view.jsx @@ -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 ( + + + + + + + + {value ?? 0} + + + + {label} + + + + ); +} + +// ---------------------------------------------------------------------- + +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 ( + + + + + Выберите ребёнка для просмотра прогресса + + + + + ); + } + + return ( + + + + {error && ( + + {error} + + )} + + {loading ? ( + + + + ) : progress ? ( + + + + {progress.attendance_rate !== undefined && ( + + )} + {progress.avg_grade !== undefined && progress.avg_grade > 0 && ( + + )} + + ) : ( + + Нет данных о прогрессе + + )} + + ); +} diff --git a/front_minimal/src/sections/children/view/children-view.jsx b/front_minimal/src/sections/children/view/children-view.jsx new file mode 100644 index 0000000..1079e6c --- /dev/null +++ b/front_minimal/src/sections/children/view/children-view.jsx @@ -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 ( + + + + {error && ( + + {error} + + )} + + {loading ? ( + + + + ) : children.length === 0 ? ( + + + + Нет привязанных детей + + + ) : ( + + {children.map((child) => { + const name = `${child.first_name || ''} ${child.last_name || ''}`.trim() || child.email; + return ( + + + + + + {name[0]?.toUpperCase()} + + + {name} + + {child.email} + + + + + + + + + + ); + })} + + )} + + ); +} diff --git a/front_minimal/src/sections/children/view/index.js b/front_minimal/src/sections/children/view/index.js new file mode 100644 index 0000000..fbda3d5 --- /dev/null +++ b/front_minimal/src/sections/children/view/index.js @@ -0,0 +1,2 @@ +export { ChildrenView } from './children-view'; +export { ChildrenProgressView } from './children-progress-view'; diff --git a/front_minimal/src/sections/contact/contact-map.jsx b/front_minimal/src/sections/contact/contact-map.jsx index 6691630..9d52672 100644 --- a/front_minimal/src/sections/contact/contact-map.jsx +++ b/front_minimal/src/sections/contact/contact-map.jsx @@ -1,5 +1,4 @@ -import { useState } from 'react'; -import dynamic from 'next/dynamic'; +import { lazy, Suspense, useState } from 'react'; import Box from '@mui/material/Box'; 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), { - loading: () => ( - - ), -}); +const Map = lazy(() => import('src/components/map').then((mod) => ({ default: mod.Map }))); // ---------------------------------------------------------------------- @@ -39,6 +31,14 @@ export function ContactMap({ contacts }) { height: { xs: 320, md: 560 }, }} > + + } + > )} + ); } diff --git a/front_minimal/src/sections/feedback/view/feedback-view.jsx b/front_minimal/src/sections/feedback/view/feedback-view.jsx new file mode 100644 index 0000000..76a210d --- /dev/null +++ b/front_minimal/src/sections/feedback/view/feedback-view.jsx @@ -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 ( + + + + Обратная связь + {lesson && ( + + {lesson.title} — {getSubjectName(lesson)} + + )} + + + + + + + {lesson && ( + + + + + Студент + {getClientName(lesson)} + + + Дата + {formatDate(lesson.start_time)} + + + Время + {formatTime(lesson.start_time)} — {formatTime(lesson.end_time)} + + + + + Оценки + + setForm((p) => ({ ...p, mentor_grade: e.target.value }))} + disabled={loading} + size="small" + fullWidth + /> + setForm((p) => ({ ...p, school_grade: e.target.value }))} + disabled={loading} + size="small" + fullWidth + /> + + + setForm((p) => ({ ...p, mentor_notes: e.target.value }))} + disabled={loading} + placeholder="Что прошли на занятии, успехи студента, рекомендации..." + /> + + {error && ( + + {error} + + )} + + + + + + + )} + + ); +} + +// ---------------------------------------------------------------------- + +function LessonCard({ lesson, onFill }) { + const hasFeedback = !!(lesson.mentor_grade || lesson.school_grade || lesson.mentor_notes?.trim()); + + return ( + + + + {lesson.title} + + + + {getClientName(lesson)} + + + + {formatDate(lesson.start_time)} + + + + {formatTime(lesson.start_time)} — {formatTime(lesson.end_time)} + + {lesson.mentor_grade != null && ( + + Оценка: {lesson.mentor_grade}/5 + + )} + + + ); +} + +// ---------------------------------------------------------------------- + +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 ( + + + Страница доступна только менторам. + + + ); + } + + return ( + + + Обратная связь + + + {error && ( + + {error} + + )} + + {loading ? ( + + + + ) : ( + + {/* Ожидают */} + + + Ожидают {todoLessons.length > 0 ? `(${todoLessons.length})` : ''} + + + {todoLessons.map((l) => ( + { setSelected(l); setDrawerOpen(true); }} + /> + ))} + {todoLessons.length === 0 && ( + + Нет занятий, ожидающих обратной связи + + )} + + + + + + {/* Заполнено */} + + + Заполнено {doneLessons.length > 0 ? `(${doneLessons.length})` : ''} + + + {doneLessons.map((l) => ( + { setSelected(l); setDrawerOpen(true); }} + /> + ))} + {doneLessons.length === 0 && ( + + Нет завершённых занятий + + )} + + + + )} + + setDrawerOpen(false)} + onSuccess={load} + /> + + ); +} diff --git a/front_minimal/src/sections/feedback/view/index.js b/front_minimal/src/sections/feedback/view/index.js new file mode 100644 index 0000000..4fec362 --- /dev/null +++ b/front_minimal/src/sections/feedback/view/index.js @@ -0,0 +1 @@ +export * from './feedback-view'; diff --git a/front_minimal/src/sections/my-progress/view/index.js b/front_minimal/src/sections/my-progress/view/index.js new file mode 100644 index 0000000..9a7c1a3 --- /dev/null +++ b/front_minimal/src/sections/my-progress/view/index.js @@ -0,0 +1 @@ +export { MyProgressView } from './my-progress-view'; diff --git a/front_minimal/src/sections/my-progress/view/my-progress-view.jsx b/front_minimal/src/sections/my-progress/view/my-progress-view.jsx new file mode 100644 index 0000000..22a2a09 --- /dev/null +++ b/front_minimal/src/sections/my-progress/view/my-progress-view.jsx @@ -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 ( + + + + + + + + {value} + + + + {label} + + {sub && ( + + {sub} + + )} + + + ); +} + +// ---------------------------------------------------------------------- + +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 ( + + + + {error && ( + + {error} + + )} + + {/* Controls */} + + + Предмет + + + + + От: + + setStartDate(e.target.value)} + style={{ padding: '6px 10px', borderRadius: 8, border: '1px solid #ddd', fontSize: 14 }} + /> + + До: + + setEndDate(e.target.value)} + style={{ padding: '6px 10px', borderRadius: 8, border: '1px solid #ddd', fontSize: 14 }} + /> + + + + {loading ? ( + + + + ) : ( + + {/* Stats row */} + + + + + + + {/* Attendance chart */} + {typeof window !== 'undefined' && chartData.categories.length > 0 && ( + + + + Посещаемость + + + + + )} + + {/* Grades chart */} + {typeof window !== 'undefined' && chartData.categories.length > 0 && ( + + + + Оценки + + + + + )} + + {lessons.length === 0 && ( + + Нет занятий за выбранный период + + )} + + )} + + ); +} diff --git a/front_minimal/src/sections/overview/client/view/index.js b/front_minimal/src/sections/overview/client/view/index.js new file mode 100644 index 0000000..a873866 --- /dev/null +++ b/front_minimal/src/sections/overview/client/view/index.js @@ -0,0 +1 @@ +export * from './overview-client-view'; diff --git a/front_minimal/src/sections/overview/client/view/overview-client-view.jsx b/front_minimal/src/sections/overview/client/view/overview-client-view.jsx new file mode 100644 index 0000000..0e3712e --- /dev/null +++ b/front_minimal/src/sections/overview/client/view/overview-client-view.jsx @@ -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 ( + + + {mentorName[0]?.toUpperCase()} + + + + {lesson.title || lesson.subject || 'Занятие'} + + + {mentorName} + + + + {formatDateTime(lesson.start_time)} + + + ); +} + +// ---------------------------------------------------------------------- + +function HomeworkItem({ homework }) { + const statusColor = { + pending: 'warning', + submitted: 'info', + reviewed: 'success', + completed: 'success', + }[homework.status] || 'default'; + + return ( + + + + + + + {homework.title} + + + {homework.subject || 'Предмет не указан'} + + + {homework.grade != null && ( + + {homework.grade}/5 + + )} + + ); +} + +// ---------------------------------------------------------------------- + +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 ( + + + + ); + } + + return ( + + + {/* LEFT */} + + {/* Greeting */} + + + Привет, {displayName}! 👋 + + + Твой прогресс и ближайшие занятия. + + + + {/* Stat widgets */} + + + + + + + + {/* Progress bar */} + + + Прогресс занятий + + {completionPct}% + + + + + + Пройдено: {stats?.completed_lessons || 0} + + + Всего: {stats?.total_lessons || 0} + + + + + {/* Upcoming lessons + homework */} + + {/* Upcoming lessons */} + + + Ближайшие занятия + + + + {loading ? ( + + + + ) : stats?.upcoming_lessons?.length > 0 ? ( + + {stats.upcoming_lessons.slice(0, 4).map((lesson) => ( + + ))} + + ) : ( + + + Нет запланированных занятий + + )} + + + {/* Homework */} + + + Домашние задания + + + + {loading ? ( + + + + ) : stats?.recent_homework?.length > 0 ? ( + + {stats.recent_homework.slice(0, 4).map((hw) => ( + + ))} + + ) : ( + + + Нет домашних заданий + + )} + + + + + {/* RIGHT sidebar */} + + + + {/* Next lesson highlight */} + {stats?.next_lesson && ( + + + Следующее занятие + + + {stats.next_lesson.title || 'Занятие'} + + + {formatDateTime(stats.next_lesson.start_time)} + + {stats.next_lesson.mentor && ( + + + {(stats.next_lesson.mentor.first_name || 'М')[0]} + + + {`${stats.next_lesson.mentor.first_name || ''} ${stats.next_lesson.mentor.last_name || ''}`.trim()} + + + )} + + )} + + + + ); +} diff --git a/front_minimal/src/sections/payment/view/index.js b/front_minimal/src/sections/payment/view/index.js index b890618..7d00f32 100644 --- a/front_minimal/src/sections/payment/view/index.js +++ b/front_minimal/src/sections/payment/view/index.js @@ -1 +1,2 @@ export * from './payment-view'; +export { PaymentPlatformView } from './payment-platform-view'; diff --git a/front_minimal/src/sections/payment/view/payment-platform-view.jsx b/front_minimal/src/sections/payment/view/payment-platform-view.jsx new file mode 100644 index 0000000..c4c470e --- /dev/null +++ b/front_minimal/src/sections/payment/view/payment-platform-view.jsx @@ -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 ( + + + + {error && ( + + {error} + + )} + + {loading ? ( + + + + ) : ( + + {/* Subscriptions */} + {subscriptions.length > 0 ? ( + + + + Активные подписки + + + {subscriptions.map((sub, idx) => ( + // eslint-disable-next-line react/no-array-index-key + + {idx > 0 && } + + + + {sub.plan_name || sub.name || 'Подписка'} + + {sub.expires_at && ( + + До {formatDate(sub.expires_at)} + + )} + + + {sub.status && ( + + )} + {sub.price != null && ( + + {formatAmount(sub.price, sub.currency)} + + )} + + + + ))} + + + + ) : ( + + + + + + Нет активных подписок + + + + + + )} + + {/* Payment history */} + {history.length > 0 && ( + + + + История платежей + + + {history.map((item, idx) => ( + // eslint-disable-next-line react/no-array-index-key + + + + {item.description || item.plan_name || 'Платёж'} + + + {formatDate(item.created_at || item.date)} + + + + + {formatAmount(item.amount, item.currency)} + + {item.status && ( + + )} + + + ))} + + + + )} + + )} + + ); +} diff --git a/front_minimal/src/sections/referrals/view/index.js b/front_minimal/src/sections/referrals/view/index.js new file mode 100644 index 0000000..aae1a65 --- /dev/null +++ b/front_minimal/src/sections/referrals/view/index.js @@ -0,0 +1 @@ +export { ReferralsView } from './referrals-view'; diff --git a/front_minimal/src/sections/referrals/view/referrals-view.jsx b/front_minimal/src/sections/referrals/view/referrals-view.jsx new file mode 100644 index 0000000..cc7869e --- /dev/null +++ b/front_minimal/src/sections/referrals/view/referrals-view.jsx @@ -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 ( + + + + + + + + {value ?? '—'} + + + + {label} + + + + ); +} + +function ReferralTable({ title, items }) { + if (!items || items.length === 0) return null; + return ( + + + {title} + + + {items.map((item, idx) => ( + // eslint-disable-next-line react/no-array-index-key + + {item.email} + + + + {item.total_points} pts + + + + ))} + + + ); +} + +// ---------------------------------------------------------------------- + +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 ( + + + + {error && ( + + {error} + + )} + + {loading ? ( + + + + ) : ( + + {/* Stats */} + {stats && ( + + + + + + + + )} + + {/* Level */} + {stats?.current_level && ( + + + + + + + Уровень {stats.current_level.level} — {stats.current_level.name} + + + Ваш текущий реферальный уровень + + + + + + )} + + {/* Referral code & link */} + {referralCode && ( + + + + Ваш реферальный код + + + + + + + + + + ), + }} + fullWidth + size="small" + /> + {referralLink && ( + + + + + + + + ), + }} + fullWidth + size="small" + /> + )} + + + + + )} + + {/* Referrals list */} + {referrals && (referrals.direct?.length > 0 || referrals.indirect?.length > 0) && ( + + + + Мои рефералы + + + + {referrals.direct?.length > 0 && referrals.indirect?.length > 0 && } + + + + + )} + + {!referralCode && !loading && ( + + Реферальная программа недоступна + + )} + + )} + + ); +} diff --git a/front_minimal/src/sections/video-call/livekit/exit-lesson-modal.jsx b/front_minimal/src/sections/video-call/livekit/exit-lesson-modal.jsx new file mode 100644 index 0000000..e89e697 --- /dev/null +++ b/front_minimal/src/sections/video-call/livekit/exit-lesson-modal.jsx @@ -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 = ( + + ); + } else if (kind === 'video' && objectUrl) { + previewBlock = ( +