From 42af69704bf6d4918c58c09ab6648ad0748cdbdf Mon Sep 17 00:00:00 2001 From: Ivan Popov Date: Thu, 17 Oct 2024 21:40:40 +0300 Subject: [PATCH 1/3] Add cycle realization --- .../yet_another_calendar/web/api/modeus/schema.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/backend/yet_another_calendar/web/api/modeus/schema.py b/backend/yet_another_calendar/web/api/modeus/schema.py index 0f0d8a4..23cf1dd 100644 --- a/backend/yet_another_calendar/web/api/modeus/schema.py +++ b/backend/yet_another_calendar/web/api/modeus/schema.py @@ -114,6 +114,7 @@ def id(self) -> uuid.UUID: class EventLinks(BaseModel): course_unit_realization: Href = Field(alias="course-unit-realization") + cycle_realization: Href = Field(alias="cycle-realization") class EventWithLinks(Event): @@ -140,17 +141,25 @@ class Course(BaseModel): name: str +class CycleRealization(BaseModel): + id: uuid.UUID + name: str + code: str + + class CalendarEmbedded(BaseModel): events: list[EventWithLinks] = Field(alias="events") locations: list[Location] = Field(alias="event-locations") attendees: list[Attender] = Field(alias="event-attendees") people: list[ShortPerson] = Field(alias="persons") courses: list[Course] = Field(alias="course-unit-realizations") + cycle_realizations: list[CycleRealization] = Field(alias="cycle-realizations") class FullEvent(Event, Location): teacher_full_name: str course_name: str + cycle_realization: CycleRealization class ModeusCalendar(BaseModel): @@ -163,11 +172,16 @@ def serialize_modeus_response(self) -> list[FullEvent]: locations = {location.id: location for location in self.embedded.locations} teachers = {teacher.id: teacher for teacher in self.embedded.people} courses = {course.id: course for course in self.embedded.courses} + cycle_realizations = {cycle_realization.id: cycle_realization for cycle_realization in + self.embedded.cycle_realizations} teachers_with_events = {teacher.links.event.id: teacher.links for teacher in self.embedded.attendees} full_events = [] for event in self.embedded.events: course_id = event.links.course_unit_realization.id + cycle_id = event.links.cycle_realization.id + cycle_realization = None try: + cycle_realization = cycle_realizations[cycle_id] course_name = courses[course_id].name teacher_event = teachers_with_events[event.id] teacher = teachers[teacher_event.person.id] @@ -180,6 +194,7 @@ def serialize_modeus_response(self) -> list[FullEvent]: continue full_events.append(FullEvent(**{ "teacher_full_name": teacher_full_name, "course_name": course_name, + "cycle_realization": cycle_realization, **event.model_dump(by_alias=True), **location.model_dump(by_alias=True), })) return full_events From 8a6d04d8b853b3e6fae469e0e0158ffbe57e6364 Mon Sep 17 00:00:00 2001 From: Ivan Popov Date: Thu, 24 Oct 2024 18:59:12 +0300 Subject: [PATCH 2/3] add more validation --- backend/yet_another_calendar/web/api/netology/schema.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/yet_another_calendar/web/api/netology/schema.py b/backend/yet_another_calendar/web/api/netology/schema.py index 90c3de5..ae67915 100644 --- a/backend/yet_another_calendar/web/api/netology/schema.py +++ b/backend/yet_another_calendar/web/api/netology/schema.py @@ -110,9 +110,12 @@ def deadline_validation(cls, data: Any) -> Any: match = re.search(_DATE_PATTERN, data.get('title', '')) if not match: return data - date = match.group(0) - data['deadline'] = datetime.datetime.strptime(date, "%d.%m.%y").astimezone(datetime.timezone.utc) - return data + try: + date = match.group(0).replace('00.', '01.') + data['deadline'] = datetime.datetime.strptime(date, "%d.%m.%y").astimezone(datetime.timezone.utc) + return data + except: + return data def is_suitable_time(self, time_min: datetime.datetime, time_max: datetime.datetime) -> bool: """Check if lesson have suitable time""" From 7bd7b6ba73e675a6e85bbac760e7cd57460b58be Mon Sep 17 00:00:00 2001 From: Ivan <60302361+depocoder@users.noreply.github.com> Date: Thu, 31 Oct 2024 15:09:54 +0300 Subject: [PATCH 3/3] big frontend release (#44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: fix params bulkEvents, add login import * feat: add select login form, datapicker calendar, events timezone * feat: add netology deadline * feat: open rectangle * feat: add logic exit app, loader * feat: add loader * feat: get event * feat: reset cache * feat: add style day now,prev,next * feat: date calendar * feat: date calendar * feat: ics export * add netology homework to .ics * Fix validation error with events modeus * Fix linter errors * refac: change componets * refactor: until folder * feat: show webinars --------- Co-authored-by: Карпович Александр --- .../web/api/bulk/integration.py | 10 + .../web/api/modeus/schema.py | 14 +- .../web/api/netology/schema.py | 2 +- frontend/package-lock.json | 236 +++++++++ frontend/package.json | 2 + frontend/src/App.jsx | 12 +- .../components/Calendar/CacheUpdateBtn.jsx | 56 +++ frontend/src/components/Calendar/Calendar.jsx | 208 -------- .../src/components/Calendar/DataPicker.jsx | 151 ++++-- frontend/src/components/Calendar/ExitBtn.jsx | 20 + .../src/components/Calendar/ICSExporter.jsx | 65 +++ .../src/components/Calendar/exportICS.jsx | 0 frontend/src/components/Header/Header.js | 2 +- frontend/src/elements/Loader.jsx | 77 +++ .../Calendar => elements}/PrivateRoute.jsx | 0 frontend/src/index.scss | 9 +- frontend/src/pages/CalendarRoute.jsx | 462 +++++++++++++---- frontend/src/pages/LoginRoute.jsx | 464 +++++++++++++++--- frontend/src/services/api.js | 101 ++-- frontend/src/style/DatePicker.scss | 34 +- frontend/src/style/calendar.scss | 85 +--- frontend/src/style/header.scss | 5 + frontend/src/style/login.scss | 3 + frontend/src/utils/dateUtils.js | 36 ++ 24 files changed, 1524 insertions(+), 530 deletions(-) create mode 100644 frontend/src/components/Calendar/CacheUpdateBtn.jsx delete mode 100644 frontend/src/components/Calendar/Calendar.jsx create mode 100644 frontend/src/components/Calendar/ExitBtn.jsx create mode 100644 frontend/src/components/Calendar/ICSExporter.jsx delete mode 100644 frontend/src/components/Calendar/exportICS.jsx create mode 100644 frontend/src/elements/Loader.jsx rename frontend/src/{components/Calendar => elements}/PrivateRoute.jsx (100%) create mode 100644 frontend/src/utils/dateUtils.js diff --git a/backend/yet_another_calendar/web/api/bulk/integration.py b/backend/yet_another_calendar/web/api/bulk/integration.py index 214292e..67d2865 100644 --- a/backend/yet_another_calendar/web/api/bulk/integration.py +++ b/backend/yet_another_calendar/web/api/bulk/integration.py @@ -50,6 +50,16 @@ def export_to_ics(calendar: schema.CalendarResponse) -> Iterable[bytes]: description=netology_lesson.title, url=netology_lesson.webinar_url) ics_calendar.add_component(event) + for netology_homework in calendar.netology.homework: + if not netology_homework.deadline: + continue + dt_end = netology_homework.deadline + datetime.timedelta(hours=18) + dt_start = dt_end - datetime.timedelta(hours=2) + event = create_ics_event(title=f"Netology ДЗ: {netology_homework.block_title}", starts_at=dt_start, + ends_at=dt_end, lesson_id=netology_homework.id, + description=netology_homework.title, + url=netology_homework.url) + ics_calendar.add_component(event) for modeus_lesson in calendar.utmn.modeus_events: event = create_ics_event(title=f"Modeus: {modeus_lesson.course_name}", starts_at=modeus_lesson.start_time, ends_at=modeus_lesson.end_time, lesson_id=modeus_lesson.id, diff --git a/backend/yet_another_calendar/web/api/modeus/schema.py b/backend/yet_another_calendar/web/api/modeus/schema.py index 23cf1dd..deeb47c 100644 --- a/backend/yet_another_calendar/web/api/modeus/schema.py +++ b/backend/yet_another_calendar/web/api/modeus/schema.py @@ -113,8 +113,8 @@ def id(self) -> uuid.UUID: class EventLinks(BaseModel): - course_unit_realization: Href = Field(alias="course-unit-realization") - cycle_realization: Href = Field(alias="cycle-realization") + course_unit_realization: Optional[Href] = Field(alias="course-unit-realization", default=None) + cycle_realization: Optional[Href] = Field(alias="cycle-realization", default=None) class EventWithLinks(Event): @@ -177,16 +177,16 @@ def serialize_modeus_response(self) -> list[FullEvent]: teachers_with_events = {teacher.links.event.id: teacher.links for teacher in self.embedded.attendees} full_events = [] for event in self.embedded.events: - course_id = event.links.course_unit_realization.id - cycle_id = event.links.cycle_realization.id - cycle_realization = None + course_id = event.links.course_unit_realization.id if event.links.course_unit_realization else None + cycle_id = event.links.cycle_realization.id if event.links.cycle_realization else None try: - cycle_realization = cycle_realizations[cycle_id] - course_name = courses[course_id].name + cycle_realization = cycle_realizations[cycle_id] if cycle_id else 'unknown' + course_name = courses[course_id].name if course_id else 'unknown' teacher_event = teachers_with_events[event.id] teacher = teachers[teacher_event.person.id] teacher_full_name = teacher.full_name except KeyError: + cycle_realization = 'unknown' course_name = 'unknown' teacher_full_name = 'unknown' location = locations[event.id] diff --git a/backend/yet_another_calendar/web/api/netology/schema.py b/backend/yet_another_calendar/web/api/netology/schema.py index ae67915..44436fa 100644 --- a/backend/yet_another_calendar/web/api/netology/schema.py +++ b/backend/yet_another_calendar/web/api/netology/schema.py @@ -114,7 +114,7 @@ def deadline_validation(cls, data: Any) -> Any: date = match.group(0).replace('00.', '01.') data['deadline'] = datetime.datetime.strptime(date, "%d.%m.%y").astimezone(datetime.timezone.utc) return data - except: + except Exception: return data def is_suitable_time(self, time_min: datetime.datetime, time_max: datetime.datetime) -> bool: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1808cd1..4613aa2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,12 +14,14 @@ "axios": "^1.7.7", "bootstrap": "^5.3.3", "flatpickr": "^4.6.13", + "framer-motion": "^11.11.9", "react": "^18.3.1", "react-bootstrap": "^2.10.5", "react-dom": "^18.3.1", "react-router": "^6.26.2", "react-router-dom": "^6.26.2", "react-scripts": "5.0.1", + "react-select": "^5.8.1", "sass-loader": "^16.0.2", "web-vitals": "^2.1.4" }, @@ -2291,6 +2293,133 @@ "postcss-selector-parser": "^6.0.10" } }, + "node_modules/@emotion/babel-plugin": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.12.0.tgz", + "integrity": "sha512-y2WQb+oP8Jqvvclh8Q55gLUyb7UFvgv7eJfsj7td5TToBrIUtPay2kMrZi4xjq9qw2vD0ZR5fSho0yqoFgX7Rw==", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.2.0", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" + }, + "node_modules/@emotion/babel-plugin/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.13.1", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.13.1.tgz", + "integrity": "sha512-iqouYkuEblRcXmylXIwwOodiEK5Ifl7JcX7o6V4jI3iW4mLXX3dmt5xwBtIkJiQEXFAI+pC8X0i67yiPkH9Ucw==", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.0", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==" + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==" + }, + "node_modules/@emotion/react": { + "version": "11.13.3", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.13.3.tgz", + "integrity": "sha512-lIsdU6JNrmYfJ5EbUCf4xW1ovy5wKQ2CkPRM4xogziOxH1nXxBSjpC9YqbFAP7circxMfYp+6x676BqWcEiixg==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.12.0", + "@emotion/cache": "^11.13.0", + "@emotion/serialize": "^1.3.1", + "@emotion/use-insertion-effect-with-fallbacks": "^1.1.0", + "@emotion/utils": "^1.4.0", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.2.tgz", + "integrity": "sha512-grVnMvVPK9yUVE6rkKfAJlYZgo0cu3l9iMC77V7DW6E1DUIrU68pSEXRmFZFOFB1QFo57TncmOcvcbMDWsL4yA==", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.1", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==" + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.1.0.tgz", + "integrity": "sha512-+wBOcIV5snwGgI2ya3u99D7/FJquOIniQT1IKyDsBmEgwvpxMNeS65Oib7OnE2d2aY+3BU4OiH+0Wchf8yk3Hw==", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.1.tgz", + "integrity": "sha512-BymCXzCG3r72VKJxaYVwOXATqXIZ85cuvg0YOUDxMGNrKc1DJRZk8MgV5wyXRyEayIMd4FuXJIUgTBXvDNW5cA==" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -2384,6 +2513,28 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.8.tgz", + "integrity": "sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==", + "dependencies": { + "@floating-ui/utils": "^0.2.8" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.11.tgz", + "integrity": "sha512-qkMCxSR24v2vGkhYDo/UzxfJN3D4syqSjyuTFz6C7XcpU1pASPRieNI0Kj5VP3/503mOfYiGY891ugBX1GlABQ==", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.8" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz", + "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==" + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -8900,6 +9051,11 @@ "url": "https://github.com/avajs/find-cache-dir?sponsor=1" } }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==" + }, "node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -9178,6 +9334,30 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/framer-motion": { + "version": "11.11.9", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.11.9.tgz", + "integrity": "sha512-XpdZseuCrZehdHGuW22zZt3SF5g6AHJHJi7JwQIigOznW4Jg1n0oGPMJQheMaKLC+0rp5gxUKMRYI6ytd3q4RQ==", + "dependencies": { + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -9569,6 +9749,19 @@ "he": "bin/he" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, "node_modules/hoopy": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/hoopy/-/hoopy-0.1.4.tgz", @@ -12964,6 +13157,11 @@ "node": ">= 4.0.0" } }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==" + }, "node_modules/merge-descriptors": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", @@ -15643,6 +15841,26 @@ } } }, + "node_modules/react-select": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/react-select/-/react-select-5.8.1.tgz", + "integrity": "sha512-RT1CJmuc+ejqm5MPgzyZujqDskdvB9a9ZqrdnVLsvAHjJ3Tj0hELnLeVPQlmYdVKCdCpxanepl6z7R5KhXhWzg==", + "dependencies": { + "@babel/runtime": "^7.12.0", + "@emotion/cache": "^11.4.0", + "@emotion/react": "^11.8.1", + "@floating-ui/dom": "^1.0.1", + "@types/react-transition-group": "^4.4.0", + "memoize-one": "^6.0.0", + "prop-types": "^15.6.0", + "react-transition-group": "^4.3.0", + "use-isomorphic-layout-effect": "^1.1.2" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -17090,6 +17308,11 @@ "postcss": "^8.2.15" } }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==" + }, "node_modules/sucrase": { "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", @@ -17940,6 +18163,19 @@ "requires-port": "^1.0.0" } }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz", + "integrity": "sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 91eb7a4..d025235 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,12 +9,14 @@ "axios": "^1.7.7", "bootstrap": "^5.3.3", "flatpickr": "^4.6.13", + "framer-motion": "^11.11.9", "react": "^18.3.1", "react-bootstrap": "^2.10.5", "react-dom": "^18.3.1", "react-router": "^6.26.2", "react-router-dom": "^6.26.2", "react-scripts": "5.0.1", + "react-select": "^5.8.1", "sass-loader": "^16.0.2", "web-vitals": "^2.1.4" }, diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 23562fc..4773dfd 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -3,22 +3,21 @@ import {BrowserRouter as Router, Routes, Route, Navigate} from 'react-router-dom import LoginRoute from './pages/LoginRoute'; import CalendarRoute from './pages/CalendarRoute'; import {loginModeus, searchModeus} from './services/api'; -import PrivateRoute from "./components/Calendar/PrivateRoute"; // Ваши API-запросы +import PrivateRoute from "./elements/PrivateRoute"; // Ваши API-запросы const App = () => { const [authData, setAuthData] = useState({ email: '', password: '', - personId: '' }); // Функция для обработки логина - const handleLogin = async (email, password, personId) => { + const handleLogin = async (email, password) => { try { let response = await loginModeus(email, password); if (response.status === 200) { - setAuthData({email, password, personId}); + setAuthData({email, password}); localStorage.setItem('token', response.data["_netology-on-rails_session"]); return {success: true}; @@ -36,6 +35,7 @@ const App = () => { let response = await searchModeus(fullName); if (response.status === 200) { + return {success: true, data: response.data}; } else { return {success: false, message: "Неверное ФИО. Попробуйте снова."}; @@ -50,14 +50,12 @@ const App = () => { }/> } diff --git a/frontend/src/components/Calendar/CacheUpdateBtn.jsx b/frontend/src/components/Calendar/CacheUpdateBtn.jsx new file mode 100644 index 0000000..a16bd19 --- /dev/null +++ b/frontend/src/components/Calendar/CacheUpdateBtn.jsx @@ -0,0 +1,56 @@ +import React, {useCallback, useEffect, useState} from 'react'; +import { + getCalendarIdLocalStorage, + getPersonIdLocalStorage, + getTokenFromLocalStorage, + refreshBulkEvents +} from "../../services/api"; + +const CacheUpdateBtn = ({date, onDataUpdate}) => { + const [cacheUpdated, setCacheUpdated] = useState(false); + const timeOffset = parseInt(process.env.REACT_APP_TIME_OFFSET, 10) || 6; + const handleRefreshEvents = useCallback(async () => { + try { + const refreshEventsResponse = await refreshBulkEvents({ + calendarId: getCalendarIdLocalStorage(), // ID календаря + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, // Часовой пояс + attendeePersonId: getPersonIdLocalStorage(), // ID участника + timeMin: date.start, // Дата начала + timeMax: date.end, // Дата окончания + sessionToken: getTokenFromLocalStorage(), + }); + + // Проверяем, что обновленные события получены + if (refreshEventsResponse && refreshEventsResponse.data) { + onDataUpdate(refreshEventsResponse.data); // Передаем данные родителю + setCacheUpdated(true); + setTimeout(() => setCacheUpdated(false), 3000); + } else { + throw new Error('Не удалось обновить события'); // Бросаем ошибку, если нет данных + } + + // Показываем сообщение и скрываем его через 3 секунды + setCacheUpdated(true); + setTimeout(() => { + setCacheUpdated(false); + }, 3000); // Скрыть через 3 секунды + } catch (error) { + console.error('Ошибка при обновлении событий:', error); + } + }, [date, onDataUpdate]); + + + useEffect(() => { + const intervalTime = timeOffset * 60 * 60 * 1000; + const intervalId = setInterval(() => handleRefreshEvents(), intervalTime); + return () => clearInterval(intervalId); + }, [handleRefreshEvents, timeOffset]); + + return ( + + ); +}; + +export default CacheUpdateBtn; \ No newline at end of file diff --git a/frontend/src/components/Calendar/Calendar.jsx b/frontend/src/components/Calendar/Calendar.jsx deleted file mode 100644 index c4ec2c3..0000000 --- a/frontend/src/components/Calendar/Calendar.jsx +++ /dev/null @@ -1,208 +0,0 @@ -import React from 'react'; -import arrow from "../../img/cross.png"; -import cross from "../../img/arrow.png"; -// import camera from "../../img/camera.png"; - -import '../../style/header.scss'; -import '../../style/calendar.scss'; -import DatePicker from "./DataPicker"; -import camera from "../../img/camera.png"; - -const Calendar = ({events}) => { - console.log('events:', events); - - if (!events) { - return
Нет данных для отображения.
; - } - - const modeus = events?.modeus; - const netology = events?.netology; - // const homework = netology?.homework; - // const webinars = netology?.webinars; - console.log('modeus', modeus) - // console.log('netology', netology) - // console.log('webinars', webinars) - // console.log('homework', homework) - - // Функция для получения дней недели - const getWeekDays = () => { - const today = new Date(); - const startOfWeek = today.getDate() - today.getDay() + (today.getDay() === 0 ? -6 : 1); // Начало недели с понедельника - const weekDays = []; - - // Генерация всех дней недели (с понедельника по воскресенье) - for (let i = 0; i < 7; i++) { - const day = new Date(today.setDate(startOfWeek + i)); - weekDays.push({ - day: day.toLocaleDateString('ru-RU', { weekday: 'short', day: '2-digit', month: '2-digit' }), - date: day.toISOString().split('T')[0] // Формат даты YYYY-MM-DD - }); - } - return weekDays; - }; - - const weekDays = getWeekDays(); - - // Функция для фильтрации занятий на определенный день - const getEventsForDay = (day) => { - return modeus.filter(event => { - const eventDate = event.start.split('T')[0]; // Извлекаем только дату в формате YYYY-MM-DD - return eventDate === day; - }); - }; - - // Функция для определения номера пары по времени - const getLessonNumber = (eventStart) => { - const startTime = new Date(eventStart).getHours(); - const startMinutes = new Date(eventStart).getMinutes(); - - // 1 пара: 08:00 - 09:30 - if (startTime === 8 || (startTime === 9 && startMinutes <= 30)) return 1; - - // 2 пара: 10:00 - 11:30 - if (startTime === 10 || (startTime === 11 && startMinutes <= 30)) return 2; - - // 3 пара: 12:00 - 13:30 - if (startTime === 12 || (startTime === 13 && startMinutes <= 30)) return 3; - - // 4 пара: 14:00 - 15:30 - if (startTime === 14 || (startTime === 15 && startMinutes <= 30)) return 4; - - // 5 пара: 15:45 - 17:15 - if ((startTime === 15 && startMinutes >= 45) || startTime === 16 || (startTime === 17 && startMinutes <= 15)) return 5; - - // 6 пара: 17:30 - 19:00 - if ((startTime === 17 && startMinutes >= 30) || startTime === 18 || (startTime === 19 && startMinutes === 0)) return 6; - - // 7 пара: 19:10 - 20:40 - if ((startTime === 19 && startMinutes >= 10) || (startTime === 20 && startMinutes <= 40)) return 7; - - return null; - }; - - return ( -
-
-
-
- Мое расписание - - -
- -
- - - - -
- -
- {/*TODO: написать логику движении линии, для отображения текущего дня*/} -
- - - - - - {weekDays.map((day, index) => ( - - ))} - - {/*TODO: написать логику*/} - - - - - - - - - - - - - {/*TODO: дописать логику, поправить стили*/} - - {[1, 2, 3, 4, 5, 6, 7].map((lessonNumber) => ( - - - {weekDays.map((day, index) => { - const eventsForDay = getEventsForDay(day.date); - const eventsForSlot = eventsForDay.filter(event => getLessonNumber(event.start) === lessonNumber); - return ( - - ); - })} - - ) - ) - } - -
{day.day}
дедлайны -
Скрыть
-
-
ТюмГУ
-
Нетология
-
-
ТюмГУ
-
-
Нетология
-
- {lessonNumber} пара
{lessonNumber * 2 + 8}:00 {lessonNumber * 2 + 9}:30 -
- {eventsForSlot.length > 0 ? ( - eventsForSlot.map(event => ( -
- {camera}/ ТюмГУ
-
{event.nameShort}
-
{event.name}
-
- {new Date(event.start).toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit' - })} - - {new Date(event.end).toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit' - })} -
- {event.teacher_full_name} -
- )) - ) : (
- )} -
-
-
- ); -}; - -export default Calendar; \ No newline at end of file diff --git a/frontend/src/components/Calendar/DataPicker.jsx b/frontend/src/components/Calendar/DataPicker.jsx index 92660af..ca5baba 100644 --- a/frontend/src/components/Calendar/DataPicker.jsx +++ b/frontend/src/components/Calendar/DataPicker.jsx @@ -1,67 +1,112 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; +import flatpickr from "flatpickr"; import "flatpickr/dist/flatpickr.css"; -// import flatpickr from "flatpickr"; -// import { Russian } from "flatpickr/dist/l10n/ru.js"; -import "../../style/DatePicker.scss"; // Для стилей компонента +import { Russian } from "flatpickr/dist/l10n/ru.js"; +import "../../style/DatePicker.scss"; -const DatePicker = () => { - // const datePickerRef = useRef(null); - const [currentDate, setCurrentDate] = useState(new Date()); // Текущая дата - const [weekRange, setWeekRange] = useState(""); +import leftWeek from "../../img/left-week.png"; +import rightWeek from "../../img/right-week.png"; +import weekSelect from "flatpickr/dist/plugins/weekSelect/weekSelect"; - // Рассчитать начало и конец недели - const calculateWeekRange = (date) => { - const startOfWeek = new Date(date); - const endOfWeek = new Date(date); +const DatePicker = ({ onWeekChange, disableButtons }) => { + const datePickerRef = useRef(null); + const [weekRange, setWeekRange] = useState(""); + const [selectedDate, setSelectedDate] = useState(new Date()); - // Получаем понедельник текущей недели - startOfWeek.setDate(date.getDate() - date.getDay() + 1); // Понедельник - endOfWeek.setDate(startOfWeek.getDate() + 6); // Воскресенье + const calculateWeekRange = (date) => { + const startOfWeek = new Date(date); + const endOfWeek = new Date(date); + startOfWeek.setDate(date.getDate() - date.getDay() + 1); // Пн + endOfWeek.setDate(startOfWeek.getDate() + 6); // Вс - const formatOptions = { day: "numeric", month: "long" }; - const startFormatted = startOfWeek.toLocaleDateString("ru-RU", formatOptions); - const endFormatted = endOfWeek.toLocaleDateString("ru-RU", formatOptions); + const formatOptions = { day: "numeric", month: "long" }; + const startFormatted = startOfWeek.toLocaleDateString("ru-RU", formatOptions); + const endFormatted = endOfWeek.toLocaleDateString("ru-RU", formatOptions); - return `${startFormatted} – ${endFormatted}`; - }; + return `${startFormatted} – ${endFormatted}`; + }; - // Обновить диапазон недели при изменении даты - useEffect(() => { - setWeekRange(calculateWeekRange(currentDate)); - }, [currentDate]); + useEffect(() => { + const fpInstance = flatpickr(datePickerRef.current, { + locale: Russian, + plugins: [weekSelect({})], + // onChange: function (selectedDates) { + // if (selectedDates.length > 0) { + // console.log("Selected date:", selectedDates[0]); // Это будет объект Date + // const selected = selectedDates[0]; + // setSelectedDate(selected); // Устанавливаем как Date объект + // setWeekRange(calculateWeekRange(selected)); + // if (onWeekChange) { + // onWeekChange(selected.toISOString()); // Передаем в формате ISO + // } + // } + // }, + onChange: function (selectedDates) { + if (selectedDates.length > 0) { + const selectedDate = selectedDates[0]; + setSelectedDate(selectedDate); // Установка состояния внутри компонента DatePicker + if (onWeekChange) { + onWeekChange(selectedDate); // Вызов функции обратного вызова + } + } + } + }); - // Обработчик для переключения недель - const handlePrevWeek = () => { - setCurrentDate((prevDate) => { - const newDate = new Date(prevDate); - newDate.setDate(prevDate.getDate() - 7); // Переключение на предыдущую неделю - return newDate; - }); - }; + return () => { + fpInstance.destroy(); + }; + }, [onWeekChange]); - const handleNextWeek = () => { - setCurrentDate((prevDate) => { - const newDate = new Date(prevDate); - newDate.setDate(prevDate.getDate() + 7); // Переключение на следующую неделю - return newDate; - }); - }; + useEffect(() => { + setWeekRange(calculateWeekRange(selectedDate)); + }, [selectedDate]); - return ( -
-
- {weekRange} -
- - + const handlePrevWeek = () => { + setSelectedDate((prev) => { + const newDate = new Date(prev); + newDate.setDate(prev.getDate() - 7); + setWeekRange(calculateWeekRange(newDate)); + if (onWeekChange) { + onWeekChange(newDate.toISOString()); // Передаем в формате ISO + } + return newDate; + }); + }; + + const handleNextWeek = () => { + setSelectedDate((prev) => { + const newDate = new Date(prev); + newDate.setDate(prev.getDate() + 7); + setWeekRange(calculateWeekRange(newDate)); + if (onWeekChange) { + onWeekChange(newDate.toISOString()); // Передаем в формате ISO + } + return newDate; + }); + }; + + return ( +
+ +
+ {/*{weekRange}*/} +
+ + +
+
-
-
- ); + ); }; export default DatePicker; diff --git a/frontend/src/components/Calendar/ExitBtn.jsx b/frontend/src/components/Calendar/ExitBtn.jsx new file mode 100644 index 0000000..6c0008a --- /dev/null +++ b/frontend/src/components/Calendar/ExitBtn.jsx @@ -0,0 +1,20 @@ +import React from 'react'; +import {useNavigate} from "react-router-dom"; +import cross from "../../img/arrow.png"; + +const ExitBtn = () => { + const navigate = useNavigate(); + + const exitApp = () => { + localStorage.clear(); + navigate("/login"); + }; + + return ( +
Выйти + exit +
+ ); +}; + +export default ExitBtn; \ No newline at end of file diff --git a/frontend/src/components/Calendar/ICSExporter.jsx b/frontend/src/components/Calendar/ICSExporter.jsx new file mode 100644 index 0000000..d66e42e --- /dev/null +++ b/frontend/src/components/Calendar/ICSExporter.jsx @@ -0,0 +1,65 @@ +import React from 'react'; + +const ICSExporter = ({ events }) => { + const generateICSFile = (events) => { + // Убедитесь, что events — это массив + if (!Array.isArray(events)) { + console.error('Events is not an array:', events); + return ''; // Возвращаем пустую строку, если это не массив + } + + const icsHeader = "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//Your Company//Your Product//EN\nCALSCALE:GREGORIAN\n"; + const icsFooter = "END:VCALENDAR\n"; + let icsBody = ""; + + events.forEach(event => { + const startDate = new Date(event.start); + const endDate = new Date(event.end); + + // Проверка на допустимость дат + if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { + console.error('Invalid date for event:', event); + return; // Пропустить это событие, если дата недопустима + } + + const formattedStartDate = startDate.toISOString().replace(/-|:|\.\d+/g, ""); // Форматирование даты + const formattedEndDate = endDate.toISOString().replace(/-|:|\.\d+/g, ""); + + const eventString = `BEGIN:VEVENT\n` + + `UID:${event.id}\n` + // Уникальный идентификатор события + `SUMMARY:${event.title}\n` + // Заголовок события + `DESCRIPTION:${event.description || ''}\n` + // Описание события + `DTSTART:${formattedStartDate}\n` + // Дата начала + `DTEND:${formattedEndDate}\n` + // Дата окончания + `END:VEVENT\n`; + icsBody += eventString; + }); + + return icsHeader + icsBody + icsFooter; + }; + + const downloadICSFile = () => { + const icsContent = generateICSFile(events); + if (icsContent === '') return; // Если не удалось создать файл, выходим + + const blob = new Blob([icsContent], { type: "text/calendar" }); + const url = URL.createObjectURL(blob); + + const a = document.createElement("a"); + a.href = url; + a.download = "schedule.ics"; // Имя файла для скачивания + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); // Освобождаем память + }; + + return ( + + ); +}; + +export default ICSExporter; + diff --git a/frontend/src/components/Calendar/exportICS.jsx b/frontend/src/components/Calendar/exportICS.jsx deleted file mode 100644 index e69de29..0000000 diff --git a/frontend/src/components/Header/Header.js b/frontend/src/components/Header/Header.js index a5a592c..34fadab 100644 --- a/frontend/src/components/Header/Header.js +++ b/frontend/src/components/Header/Header.js @@ -54,7 +54,7 @@ export default function Header() { дедлайны - + diff --git a/frontend/src/elements/Loader.jsx b/frontend/src/elements/Loader.jsx new file mode 100644 index 0000000..605eabd --- /dev/null +++ b/frontend/src/elements/Loader.jsx @@ -0,0 +1,77 @@ +import React from "react"; +import { motion } from "framer-motion"; + +const loadingContainer = { + width: "4rem", + height: "4rem", + display: "flex", + justifyContent: "space-around", +}; +const loadingCircle = { + display: "block", + width: "1rem", + height: "1rem", + backgroundColor: "#7B61FF", + borderRadius: "0.5rem", +}; + +const loadingContainerVariants = { + start: { + transition: { + staggerChildren: 0.2, + }, + }, + end: { + transition: { + staggerChildren: 0.2, + }, + }, +}; + +const loadingCircleVariants = { + start: { + y: "0%", + }, + end: { + y: "60%", + }, +}; +const loadingCircleTransition = { + duration : 0.4, + yoyo : Infinity, + ease: 'easeInOut' +} + +const Loader = () => { + return ( +
+
+
+ + + + + +
+
+ ); +}; + +export default Loader; \ No newline at end of file diff --git a/frontend/src/components/Calendar/PrivateRoute.jsx b/frontend/src/elements/PrivateRoute.jsx similarity index 100% rename from frontend/src/components/Calendar/PrivateRoute.jsx rename to frontend/src/elements/PrivateRoute.jsx diff --git a/frontend/src/index.scss b/frontend/src/index.scss index 6b576c4..90808f8 100644 --- a/frontend/src/index.scss +++ b/frontend/src/index.scss @@ -66,4 +66,11 @@ code { text-align: center; color: red; margin-top: 10px; -} \ No newline at end of file +} +// loader +.loader-container { + display: flex; + justify-content: center; + align-items: center; + height: 100vh; +} diff --git a/frontend/src/pages/CalendarRoute.jsx b/frontend/src/pages/CalendarRoute.jsx index b2548cd..f5682e3 100644 --- a/frontend/src/pages/CalendarRoute.jsx +++ b/frontend/src/pages/CalendarRoute.jsx @@ -1,94 +1,386 @@ -import React, { useEffect, useState } from 'react'; -import { getNetologyCourse, bulkEvents } from '../services/api'; // Ваши API-запросы -import Calendar from "../components/Calendar/Calendar"; -import Header from "../components/Header/Header"; - -const CalendarRoute = ({ email, password, personId, token }) => { - const [events, setEvents] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - // Функция для загрузки событий из localStorage - const loadEventsFromLocalStorage = () => { - const savedEvents = localStorage.getItem('events'); - if (savedEvents) { - return JSON.parse(savedEvents); - } - return null; - }; - - // Сохраняем события в localStorage - const saveEventsToLocalStorage = (eventsData) => { - localStorage.setItem('events', JSON.stringify(eventsData)); - }; - - useEffect(() => { - const fetchCourseAndEvents = async () => { - // Попытка загрузки событий из localStorage - const cachedEvents = loadEventsFromLocalStorage(); - if (cachedEvents) { - console.log("События загружены из localStorage:", cachedEvents); - setEvents(cachedEvents); - setLoading(false); - return; - } - - if (!token || !email || !password || !personId) { - console.error('Ошибка авторизации. Проверьте введенные данные.'); - return; - } - - try { - const courseData = await getNetologyCourse(token); - console.log('Данные курса:', courseData); - - const calendarId = courseData?.id; - console.log('calendarId add storage', calendarId) - localStorage.setItem('calendarId', calendarId); - - if (calendarId) { - const eventsResponse = await bulkEvents( - email, // Email пользователя - password, // Пароль пользователя - token, // Токен сессии - calendarId, // ID календаря - "2024-10-14T00:00:00+03:00", // Дата начала - "2024-10-20T23:59:59+03:00", // Дата окончания - personId // ID участника - ); - console.log('События:', eventsResponse.data); - setEvents(eventsResponse.data); - // cached_at - localStorage.setItem('cached_at', eventsResponse.data.cached_at); // Сохраняем cached_at localstorage - console.log('eventsResponse.data.cached_at', eventsResponse.data.cached_at) - // Сохраняем события в localStorage - saveEventsToLocalStorage(eventsResponse.data); +import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import { + getNetologyCourse, + bulkEvents, + getTokenFromLocalStorage, + getPersonIdLocalStorage, +} from '../services/api'; // Ваши API-запросы +import Loader from "../elements/Loader"; + +import arrow from "../img/arrow.png"; +import camera from "../img/camera.png"; + +import '../style/header.scss'; +import '../style/calendar.scss'; +import DatePicker from "../components/Calendar/DataPicker"; +import ExitBtn from "../components/Calendar/ExitBtn"; +import ICSExporter from "../components/Calendar/ICSExporter"; +import CacheUpdateBtn from "../components/Calendar/CacheUpdateBtn"; + +import {getCurrentWeekDates} from "../utils/dateUtils"; + + + +const CalendarRoute = () => { + // Используем useMemo для вызова getCurrentWeekDates один раз при инициализации + const initialDate = useMemo(() => getCurrentWeekDates(), []); + const [date, setDate] = useState(initialDate); + + const [events, setEvents] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // const navigate = useNavigate(); + const [selectedEvent, setSelectedEvent] = useState(null); + const [weekDays, setWeekDays] = useState(Array.from({length: 7}, () => [])); + console.log('weekDays', weekDays) + // const [date, setDate] = useState({ + // start: "2024-10-28T00:00:00+00:00", + // end: "2024-11-03T23:59:59+00:00" + // }); + + const populateWeekDays = (eventsData) => { + if (!eventsData) return; + const newWeekDays = Array.from({length: 7}, () => []); + + // Заполнение массива дней недели занятиями + if (eventsData?.utmn?.modeus_events) { + eventsData.utmn.modeus_events.forEach((lesson) => { + const startTime = new Date(lesson.start); + const endTime = new Date(lesson.end); + const dayOfWeek = (startTime.getDay() + 6) % 7; + + newWeekDays[dayOfWeek].push({ + ...lesson, + startTime, + endTime, + type: 'modeus' // Добавляем тип события + }); + }); + } + + // Заполнение массива дней недели вебинарами netology + if (eventsData?.netology?.webinars) { + eventsData.netology.webinars.forEach((webinar) => { + const startTime = new Date(webinar.starts_at); + const endTime = new Date(webinar.ends_at); + + const dayOfWeek = (startTime.getDay() + 6) % 7; + + newWeekDays[dayOfWeek].push({ + ...webinar, + startTime, + endTime, + type: 'netology' // Добавляем тип события + }); + }); + } + + setWeekDays(newWeekDays); // Сохраняем обновленный массив дней недели в состоянии + }; + + const fetchCourseAndEvents = useCallback(async () => { + setLoading(true); + try { + const courseData = await getNetologyCourse(getTokenFromLocalStorage()); + const calendarId = courseData?.id; + localStorage.setItem('calendarId', calendarId); + + if (calendarId) { + const eventsResponse = await bulkEvents({ + calendarId: calendarId, // ID календаря + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, // Часовой пояс + attendeePersonId: getPersonIdLocalStorage(), // ID участника + timeMin: date.start, // Дата начала + timeMax: date.end, // Дата окончания + sessionToken: getTokenFromLocalStorage(), + }); + + // Проверяем, что события получены + if (eventsResponse && eventsResponse.data) { + setEvents(eventsResponse.data); + populateWeekDays(eventsResponse.data); // Обновление weekDays + } else { + throw new Error('Не удалось получить события'); // Бросаем ошибку, если нет данных + } + } + } catch (error) { + console.error('Ошибка при получении данных с сервера:', error); + setError("Ошибка при получении данных с сервера. Перезагрузите страницу!"); + } finally { + setLoading(false); } - } catch (error) { - console.error('Ошибка при получении данных с сервера:', error); - setError("Ошибка при получении данных с сервера."); - } finally { - setLoading(false); - } + }, [date]); + + useEffect(() => { + fetchCourseAndEvents(); + + }, [fetchCourseAndEvents]); + + if (loading) { + return ( + //
+ + //
+ ); + } + + if (error) { + return
{error}
; + } + + const handleWeekChange = (newDate) => { + const startOfWeek = new Date(newDate); + startOfWeek.setDate(startOfWeek.getDate() - startOfWeek.getDay() + 1); // Пн + const endOfWeek = new Date(startOfWeek); + endOfWeek.setDate(startOfWeek.getDate() + 6); // Вс + + const currentTimeStart = date.start.split("T")[1]; + const currentTimeEnd = date.end.split("T")[1]; + + setDate({ + start: `${startOfWeek.toISOString().split("T")[0]}T${currentTimeStart}`, + end: `${endOfWeek.toISOString().split("T")[0]}T${currentTimeEnd}`, + }); + + fetchCourseAndEvents(); // Обновляем события после изменения даты + }; + + + // Массив временных интервалов пар, начиная с 10:00 + const lessonTimesArray = [ + "10:00 - 11:30", // 1 пара + "12:00 - 13:30", // 2 пара + "13:45 - 15:15", // 3 пара + "15:30 - 17:00", // 4 пара + "17:10 - 18:40", // 5 пара + "18:50 - 20:20" // 6 пара + ]; + + // Функция для форматирования даты + const formatDate = (dateString) => { + const dateObj = new Date(dateString); + const day = dateObj.getDate().toString().padStart(2, '0'); + const month = (dateObj.getMonth() + 1).toString().padStart(2, '0'); + return `${day}.${month}`; // Возвращаем строку в формате "дд.мм" + }; + + // Получение всех дат между началом и концом недели + const startDate = new Date(date.start); + const monthDays = []; + for (let i = 0; i < 7; i++) { + const currentDate = new Date(startDate); + currentDate.setDate(startDate.getDate() + i); + monthDays.push(currentDate.toISOString().split('T')[0]); + } + + const handleDataUpdate = (updatedEvents) => { + setEvents(updatedEvents); + populateWeekDays(updatedEvents); }; - fetchCourseAndEvents(); - }, [email, password, personId, token]); + const allEvents = [ + ...(events?.utmn?.modeus_events || []), + ...(events?.netology?.webinars || []) + ]; + + + return ( +
+
+
+
+
+ Мое расписание + + +
+ +
+ +
+ {selectedEvent && ( +
+
+ Событие: {selectedEvent.nameShort || selectedEvent.title} + + {arrow}/ {formatDate(selectedEvent.start || selectedEvent.deadline)} + +
+
+ {selectedEvent.name || 'Информация недоступна'} +
+
+ Преподаватель: {selectedEvent.teacher_full_name || 'Не указано'} +
+
+ )} +
+ {/*
*/} + + +
+ +
+ + + + + {["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"].map((day, index) => { + const dayDate = new Date(date.start); + dayDate.setDate(dayDate.getDate() + index); + + + const today = new Date(); // Получаем сегодняшнюю дату + today.setHours(0, 0, 0, 0); // Устанавливаем время на начало дня для корректного сравнения + + // Сравниваем даты и определяем нужный класс + let dayClass = ""; + if (dayDate < today) { + dayClass = "prev"; + } else if (dayDate.toDateString() === today.toDateString()) { + dayClass = "now"; + } else { + dayClass = "next"; + } + const formattedDate = formatDate(dayDate); + return ( + + ); + })} + + + + {monthDays.map((day, index) => { + const adjustedDay = new Date(new Date(date.start).setDate(new Date(date.start).getDate() + index + 1)).toISOString().split('T')[0]; + + const deadlines = events?.netology?.homework.filter(homework => { + const homeworkDeadline = new Date(homework.deadline).toISOString().split('T')[0]; + return homeworkDeadline === adjustedDay; + }); - if (loading) { - return
Загрузка данных...
; - } + return ( + + ); + })} + + + + {lessonTimesArray.map((timeSlot, index) => { + return ( + + + {weekDays.map((lessons, dayIndex) => { + const lesson = lessons.find(lesson => { + const lessonStartTime = new Date(lesson.start || lesson.starts_at); + const lessonEndTime = new Date(lesson.end || lesson.ends_at); + const lessonStartFormatted = lessonStartTime.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit' + }); + const lessonEndFormatted = lessonEndTime.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit' + }); - if (error) { - return
{error}
; - } + return ( + lessonStartFormatted === timeSlot.split(' - ')[0] || + lessonEndFormatted === timeSlot.split(' - ')[1] || + (lessonStartFormatted >= timeSlot.split(' - ')[0] && lessonEndFormatted <= timeSlot.split(' - ')[1]) + ); + }); + return ( + // + + ); + })} - return ( -
- -
-
- ); + + ); + })} + +
+ {`${day} ${formattedDate}`} +
+ Дедлайны + + + {deadlines && deadlines.length > 0 ? ( + deadlines.map((homework, hwIndex) => ( +
setSelectedEvent(homework)}> + + {homework.title} + +
+ )) + ) : ( +
+ )} +
{index + 1} пара
{timeSlot}
{ + // // Check if the clicked lesson is the same as the currently selected event + // if (selectedEvent && selectedEvent.id === lesson?.id) { + // setSelectedEvent(null); // Close the modal if the same lesson is clicked + // } else { + // setSelectedEvent(lesson); // Set the selected lesson + // } + // }}> + // {lesson ? ( + //
setSelectedEvent(lesson)}> + // {camera} ТюмГУ
+ //
{lesson.nameShort}
+ //
{lesson.name}
+ // {lesson.teacher_full_name} + //
+ // ) : ( + //
+ // )} + //
{ + if (selectedEvent && selectedEvent.id === lesson?.id) { + setSelectedEvent(null); // Close the modal if the same lesson is clicked + } else { + setSelectedEvent(lesson); // Set the selected lesson + } + }}> + {lesson ? ( +
+ {lesson.type === "modeus" ? ( +
+ {camera}/ ТюмГУ + {lesson?.cycle_realization?.code} +
+ ) : ( + + {camera}/ Нетология
+
+ )} + {/*{lesson.type === "netology" ? (*/} + {/* <>*/} + {/* */} + {/* {camera}/ Нетология
*/} + {/*
*/} + {/* /!**!/*/} + {/* */} + {/*) : (*/} + {/* */} + {/*)}*/} + {/*
{lesson.course_name || lesson.block_title}
*/} +
{lesson?.course_name || lesson?.block_title}
+ {/*{lesson.teacher_full_name}*/} +
+ ) : ( +
+ )} +
+
+
+
+ ); }; export default CalendarRoute; diff --git a/frontend/src/pages/LoginRoute.jsx b/frontend/src/pages/LoginRoute.jsx index c0b0617..c5f49ab 100644 --- a/frontend/src/pages/LoginRoute.jsx +++ b/frontend/src/pages/LoginRoute.jsx @@ -1,15 +1,383 @@ -import {useEffect, useState, useCallback} from "react"; -import {useNavigate} from "react-router-dom"; +// import React, {useEffect, useState, useCallback} from "react"; +// import {useNavigate} from "react-router-dom"; +// import Select from "react-select/base"; +// +// const LoginRoute = ({onLogin, onSearch}) => { +// const [email, setEmail] = useState(''); +// const [password, setPassword] = useState(''); +// const [fullName, setFullName] = useState(""); // Строка для поиска +// const [searchResults, setSearchResults] = useState([]); // Результаты поиска +// const [personId, setPersonId] = useState(null); // Здесь сохраняем personId +// // const [showSuggestions, setShowSuggestions] = useState(false); // Флаг показа списка +// const [errorMessage, setErrorMessage] = useState(""); // Сообщение об ошибке +// // const [debounceTimeout, setDebounceTimeout] = useState(null); // Для хранения таймера +// const [inputValue] = useState(''); // Замените на это +// +// const navigate = useNavigate(); // Инициализируем хук для навигации +// +// // Функция для выполнения поиска +// const onClickSearch = useCallback(async (fullName) => { +// const result = await onSearch(fullName); +// console.log('result', result) +// +// if (result.success) { +// setSearchResults(result?.data); +// setPersonId(result?.data[0]?.personId) +// console.log('onClickSearch personId', result?.data[0]?.personId) +// // setShowSuggestions(true); // Показываем список после поиска +// setErrorMessage(""); // Очищаем ошибку при успешном поиске +// } else { +// setErrorMessage(result.message); +// } +// }, [onSearch]); +// +// // Обрабатываем изменение поля поиска с задержкой +// useEffect(() => { +// // // Очищаем предыдущий таймер, чтобы избежать лишних вызовов +// // if (debounceTimeout) { +// // clearTimeout(debounceTimeout); +// // } +// +// // Устанавливаем новый таймер на 500 мс +// const newTimeout = setTimeout(() => { +// if (fullName.trim()) { +// onClickSearch(fullName); // Выполняем поиск после задержки +// } +// }, 500); +// +// // setDebounceTimeout(newTimeout); +// +// // Очищаем таймер при размонтировании или изменении fullName +// return () => clearTimeout(newTimeout); +// }, [fullName, onClickSearch]); +// +// // Функция для изменения значения ввода в Select +// // const handleInputChange = (inputValue) => { +// // setInputValue(inputValue); // Обновляем значение +// // setFullName(inputValue); // Привязываем значение к состоянию fullName +// // }; +// +// // Обработчик выбора варианта из списка +// // const handleSelect = (person) => { +// // setFullName(person.fullName); // Устанавливаем выбранное имя +// // // setShowSuggestions(false); // Скрываем список после выбора +// // }; +// +// // Обработчик изменения текста в Select +// const handleInputChange = (newValue) => { +// setInputValue(newValue); // Обновляем значение инпута +// }; +// +// // Обработчик выбора варианта из списка +// const handleSelect = (selectedOption) => { +// setFullName(selectedOption.label); // Устанавливаем выбранное имя +// setPersonId(selectedOption.value); // Устанавливаем personId +// console.log('Selected personId:', selectedOption.value); +// }; +// +// const onClickLogin = async () => { +// const result = await onLogin(email, password); +// +// if (result.success) { +// // setPersonId(result.data[0].personId); // Сохраняем personId +// console.log('onClickLogin personId', personId) +// localStorage.setItem('personId', personId); // Сохраняем personId localstorage +// +// setErrorMessage(""); // Очищаем ошибку при успешном логине +// navigate("/"); +// } else { +// setErrorMessage(result.message); +// } +// }; +// +// return ( +//
+//

Мое расписание

+// +//
+// +// +//
+// setEmail(e.target.value)} +// /> +// setPassword(e.target.value)} +// /> +//
+// {/* Сообщение об ошибке */} +// {errorMessage &&

{errorMessage}

} +// +// +//
+//
+// ); +// }; +// +// export default LoginRoute; -const LoginRoute = ({onLogin, onSearch}) => { +// +// import { useCallback } from "react"; +// import { useNavigate } from "react-router-dom"; +// import Select from "react-select"; +// +// const LoginRoute = ({ onLogin, onSearch }) => { +// const [email, setEmail] = useState(''); +// const [password, setPassword] = useState(''); +// const [searchResults, setSearchResults] = useState([]); // Результаты поиска +// const [personId, setPersonId] = useState(null); // Здесь сохраняем personId +// const [inputValue, setInputValue] = useState(''); // Контролируемое значение ввода для Select +// const [errorMessage, setErrorMessage] = useState(""); // Сообщение об ошибке +// +// const navigate = useNavigate(); // Инициализируем хук для навигации +// +// // Функция для выполнения поиска +// const onClickSearch = useCallback(async (fullName) => { +// const result = await onSearch(fullName); +// if (result.success) { +// setSearchResults(result?.data); +// setPersonId(result?.data[0]?.personId); +// setErrorMessage(""); // Очищаем ошибку при успешном поиске +// } else { +// setErrorMessage(result.message); +// } +// }, [onSearch]); +// +// // Функция для изменения значения ввода в Select и выполнения поиска +// const handleInputChange = (inputValue) => { +// setInputValue(inputValue); // Обновляем значение +// if (inputValue.trim()) { +// onClickSearch(inputValue); // Выполняем поиск сразу при изменении ввода +// } +// }; +// +// // Обработчик выбора варианта из списка +// const handleSelect = (selectedOption) => { +// setInputValue(selectedOption.label); // Устанавливаем выбранное имя +// setPersonId(selectedOption.value); // Устанавливаем personId +// }; +// +// const onClickLogin = async () => { +// const result = await onLogin(email, password); +// +// if (result.success) { +// localStorage.setItem('personId', personId); // Сохраняем personId localstorage +// setErrorMessage(""); // Очищаем ошибку при успешном логине +// navigate("/"); +// } else { +// setErrorMessage(result.message); +// } +// }; +// +// return ( +//
+//

Мое расписание

+// +//
+// +// +//
+// setEmail(e.target.value)} +// /> +// setPassword(e.target.value)} +// /> +//
+// {/* Сообщение об ошибке */} +// {errorMessage &&

{errorMessage}

} +// +// +//
+//
+// ); +// }; +// +// export default LoginRoute; +// +// +// +// import React, { useState, useCallback } from "react"; +// import { useNavigate } from "react-router-dom"; +// import CustomSelect from "../components/login/CustomSelect"; // Импортируем кастомизированный Select +// +// const LoginRoute = ({ onLogin, onSearch }) => { +// const [email, setEmail] = useState(""); +// const [password, setPassword] = useState(""); +// const [searchResults, setSearchResults] = useState([]); // Результаты поиска +// const [personId, setPersonId] = useState(null); // Здесь сохраняем personId +// const [errorMessage, setErrorMessage] = useState(""); // Сообщение об ошибке +// const navigate = useNavigate(); // Инициализируем хук для навигации +// +// // Функция для выполнения поиска +// const onClickSearch = useCallback(async (fullName) => { +// const result = await onSearch(fullName); +// if (result.success) { +// setSearchResults(result?.data); +// setPersonId(result?.data[0]?.personId); +// setErrorMessage(""); // Очищаем ошибку при успешном поиске +// } else { +// setErrorMessage(result.message); +// } +// }, [onSearch]); +// +// const onClickLogin = async () => { +// const result = await onLogin(email, password); +// if (result.success) { +// localStorage.setItem("personId", personId); // Сохраняем personId в localstorage +// setErrorMessage(""); // Очищаем ошибку при успешном логине +// navigate("/"); +// } else { +// setErrorMessage(result.message); +// } +// }; +// +// return ( +//
+//

Мое расписание

+// +//
+// +// +//
+// {/* Кастомизированный Select */} +// ({ +// value: person.personId, +// label: person.fullName +// }))} +// onChange={(selectedOption) => { +// setPersonId(selectedOption.value); // Устанавливаем personId +// }} +// placeholder="Введите ФИО" +// /> +//
+//
+// +//
+// +//
+// setEmail(e.target.value)} +// /> +// setPassword(e.target.value)} +// /> +//
+// {errorMessage &&

{errorMessage}

} +// +// +//
+//
+// ); +// }; +// +// export default LoginRoute; + + + + +import React, { useState, useCallback } from "react"; +import { useNavigate } from "react-router-dom"; +import Select from "react-select"; + +const LoginRoute = ({ onLogin, onSearch }) => { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); - const [fullName, setFullName] = useState(""); // Строка для поиска const [searchResults, setSearchResults] = useState([]); // Результаты поиска const [personId, setPersonId] = useState(null); // Здесь сохраняем personId - const [showSuggestions, setShowSuggestions] = useState(false); // Флаг показа списка + const [inputValue, setInputValue] = useState(''); // Контролируемое значение ввода для Select const [errorMessage, setErrorMessage] = useState(""); // Сообщение об ошибке - // const [debounceTimeout, setDebounceTimeout] = useState(null); // Для хранения таймера const navigate = useNavigate(); // Инициализируем хук для навигации @@ -17,48 +385,35 @@ const LoginRoute = ({onLogin, onSearch}) => { const onClickSearch = useCallback(async (fullName) => { const result = await onSearch(fullName); if (result.success) { - setSearchResults(result.data); - setShowSuggestions(true); // Показываем список после поиска + setSearchResults(result?.data); + setPersonId(result?.data[0]?.personId); setErrorMessage(""); // Очищаем ошибку при успешном поиске } else { setErrorMessage(result.message); } }, [onSearch]); - // Обрабатываем изменение поля поиска с задержкой - useEffect(() => { - // // Очищаем предыдущий таймер, чтобы избежать лишних вызовов - // if (debounceTimeout) { - // clearTimeout(debounceTimeout); - // } - - // Устанавливаем новый таймер на 500 мс - const newTimeout = setTimeout(() => { - if (fullName.trim()) { - onClickSearch(fullName); // Выполняем поиск после задержки - } - }, 500); - - // setDebounceTimeout(newTimeout); - - // Очищаем таймер при размонтировании или изменении fullName - return () => clearTimeout(newTimeout); - }, [fullName, onClickSearch]); + // Функция для изменения значения ввода в Select и выполнения поиска + const handleInputChange = (inputValue) => { + setInputValue(inputValue); // Обновляем значение + if (inputValue.trim()) { + onClickSearch(inputValue); // Выполняем поиск сразу при изменении ввода + } + }; // Обработчик выбора варианта из списка - const handleSelect = (person) => { - setFullName(person.fullName); // Устанавливаем выбранное имя - setPersonId(person.personId); // Сохраняем personId - localStorage.setItem('personId', personId); // Сохраняем personId localstorage - setShowSuggestions(false); // Скрываем список после выбора + const handleSelect = (selectedOption) => { + setInputValue(selectedOption.label); // Устанавливаем выбранное имя + setPersonId(selectedOption.value); // Устанавливаем personId }; const onClickLogin = async () => { - const result = await onLogin(email, password, personId); + const result = await onLogin(email, password); if (result.success) { + localStorage.setItem('personId', personId); // Сохраняем personId localstorage setErrorMessage(""); // Очищаем ошибку при успешном логине - navigate("/calendar"); + navigate("/"); } else { setErrorMessage(result.message); } @@ -71,35 +426,30 @@ const LoginRoute = ({onLogin, onSearch}) => {
-
- setFullName(e.target.value)} +
+ { + const today = new Date(); + const dayOfWeek = today.getDay(); + const mondayOffset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; + const sundayOffset = 7 - dayOfWeek; + + const startOfWeek = new Date(today); + startOfWeek.setDate(today.getDate() + mondayOffset); + startOfWeek.setUTCHours(0, 0, 0, 0); + + const endOfWeek = new Date(today); + endOfWeek.setDate(today.getDate() + sundayOffset); + endOfWeek.setUTCHours(23, 59, 59, 0); + + const formatToRequiredISO = (date) => { + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + const day = String(date.getUTCDate()).padStart(2, '0'); + const hours = String(date.getUTCHours()).padStart(2, '0'); + const minutes = String(date.getUTCMinutes()).padStart(2, '0'); + const seconds = String(date.getUTCSeconds()).padStart(2, '0'); + + return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}+00:00`; + }; + + const formattedStart = formatToRequiredISO(startOfWeek); + const formattedEnd = formatToRequiredISO(endOfWeek); + + // console.log("Formatted Start:", formattedStart); + // console.log("Formatted End:", formattedEnd); + + return { + start: formattedStart, + end: formattedEnd, + }; +}; \ No newline at end of file