commit 84b34c9615fb0fe645893af918b56a00536847fe Author: Brunobrno Date: Thu Oct 2 00:54:34 2025 +0200 init diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..08c9c50 --- /dev/null +++ b/.gitignore @@ -0,0 +1,187 @@ +*/media/ +*/collectedstaticfiles/ + +celerybeat-schedule-shm +celerybeat-schedule-wal + +# Byte-compiled / optimized / DLL files +*/__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Cursor +# Cursor is an AI-powered code editor.`.cursorignore` specifies files/directories to +# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data +# refer to https://docs.cursor.com/context/ignore-files +.cursorignore +.cursorindexingignore \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..72a7572 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,114 @@ +{ + "editor.tokenColorCustomizations": { + "textMateRules": [ + { + "scope": [ + "comment", + "comment.line", + "comment.block", + "punctuation.definition.comment" + ], + "settings": { + "foreground": "#5fca5f" // světlá zelená + } + } + ] + }, + + "files.autoSave": "afterDelay", + "vscode-edge-devtools.webhintInstallNotification": true, + "explorer.confirmDelete": false, + "git.suggestSmartCommit": false, + "git.confirmSync": false, + "git.autofetch": true, + "editor.minimap.enabled": false, + "explorer.confirmPasteNative": false, + "explorer.confirmDragAndDrop": false, + "[php]": { + "editor.defaultFormatter": "bmewburn.vscode-intelephense-client" + }, + "redhat.telemetry.enabled": false, + "files.exclude": { + "**/.git": false + }, + + /*CUSTOM django-html SETTINGS*/ + "emmet.includeLanguages": { + "django-html": "html" + }, + "files.associations": { + "**/*.html": "html", + "**/templates/**/*.html": "django-html", + "**/templates/**/*": "django-txt" + }, + "[django-html]": { + "editor.defaultFormatter": "batisteo.vscode-django", + "editor.insertSpaces": false, + "editor.tabSize": 2 + }, + "terminal.integrated.enableMultiLinePasteWarning": false, + "workbench.colorCustomizations": {}, + "html.format.contentUnformatted": "", + "python.createEnvironment.trigger": "off", + "phpserver.phpConfigPath": "C:\\xampp\\php\\php.ini", + "phpserver.phpPath": "C:\\xampp\\php\\php.exe", + "explorer.fileNesting.patterns": { + "*.ts": "${capture}.js", + "*.js": "${capture}.js.map, ${capture}.min.js, ${capture}.d.ts", + "*.jsx": "${capture}.js", + "*.tsx": "${capture}.ts", + "tsconfig.json": "tsconfig.*.json", + "package.json": "package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb, bun.lock", + "*.sqlite": "${capture}.${extname}-*", + "*.db": "${capture}.${extname}-*", + "*.sqlite3": "${capture}.${extname}-*", + "*.db3": "${capture}.${extname}-*", + "*.sdb": "${capture}.${extname}-*", + "*.s3db": "${capture}.${extname}-*" + }, + "workbench.colorTheme": "Atom One Dark", + "workbench.iconTheme": "vscode-icons", + "github.copilot.nextEditSuggestions.enabled": true, + + "workbench.tree.enableStickyScroll": false, + "workbench.tree.indent": 15, + "workbench.tree.renderIndentGuides": "none", + "explorer.compactFolders": false, + "material-icon-theme.hidesExplorerArrows": true, + "material-icon-theme.folders.customClones": [ + { + "name": "features-folder", + "base": "connection", + "folderNames": ["features"], + "color": "blue-400", + "lightColor": "blue-600" + }, + { + "name": "api-folder", + "base": "api", + "color": "red-400", // new color for Dark theme + "lightColor": "red-600", // optional: color for Light theme + "folderNames": ["api"], + }, + { + "name": "forms-folder", + "base": "content", + "color": "red-800", // new color for Dark theme + "lightColor": "yellow-600", // optional: color for Light theme + "folderNames": ["forms", "form", "forms-templates"], + } + ], + + + "draw.folder.structure.exclude": ["**/node_modules", "**/dist", "**/.git", "**/.vscode"], + "draw.folder.structure.style": "EmojiDashes", + "draw.folder.structure.allowRecursion": true, + "draw.folder.structure.respectGitignore": false, + "github.copilot.enable": { + "*": true, + "plaintext": false, + "markdown": true, + "scminput": false + }, + "terminal.integrated.stickyScroll.enabled": false +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..137048b --- /dev/null +++ b/README.md @@ -0,0 +1,106 @@ +# e-rezervace-jih-vitkovice + +## venv +- windows + +``` +Set-ExecutionPolicy -Scope Process -ExecutionPolicy RemoteSigned +/ +Set-ExecutionPolicy RemoteSigned + +python -m venv venv +.\venv\Scripts\Activate + +#start server +daphne -b localhost -p 8000 trznice.asgi:application +``` + +# if run locally in backend folder: +## dont forget to run redis: +``` +docker run redis +``` + +## 1. create scheduled tasks in db +``` +python manage.py seed_celery_beat +``` +## 2. run CELERY Terminal 1 +``` +celery -A trznice worker --pool=solo --loglevel=info +``` + +## 3. run CELERY BEAT Terminal 2 +``` +celery -A trznice beat --loglevel=info +``` + +-------------------------------------------------------------------- + +# django command that will use barebones settings.py for basic work +```python manage.py runserver --settings=trznice.base_settings``` + + +# logovaní do dockeru (klasický print nefunguje kvůli bezpečnosti) +``` +import logging + +logger = logging.getLogger(__name__) +logger.debug("Tvoje hláška") +``` + +# Django Management Commands +``` +| Command | Description | Example | +|---------|-------------|---------| +| `startproject ` | Create a new Django project | `python manage.py startproject myproject` | +| `startapp ` | Create a new Django app | `python manage.py startapp myapp` | +| `runserver [port]` | Run the development server (default: `8000`) | `python manage.py runserver 8080` | +| `migrate` | Apply database migrations | `python manage.py migrate` | +| `makemigrations [app]` | Create migration files for an app | `python manage.py makemigrations myapp` | +| `createsuperuser` | Create an admin superuser | `python manage.py createsuperuser` | +| `check` | Check for any project errors | `python manage.py check` | +| `shell` | Open the Django shell | `python manage.py shell` | +| `dbshell` | Open the database shell | `python manage.py dbshell` | +| `collectstatic` | Collect static files for deployment | `python manage.py collectstatic` | +| `test [app]` | Run tests for an app | `python manage.py test myapp` | +| `sqlmigrate ` | Show the SQL of a migration | `python manage.py sqlmigrate myapp 0001_initial` | +| `flush` | Reset the database (removes all data) | `python manage.py flush` | +| `dumpdata [app]` | Export database data as JSON | `python manage.py dumpdata myapp > data.json` | +| `loaddata ` | Load data from a JSON file | `python manage.py loaddata data.json` | +| `help` | Show available commands | `python manage.py help` | +``` +Feel free to use or modify this table for your project! + + + +# Docker Compose + spuštění dockeru pro lokální hosting, s instantníma změnami během editace ve vscodu. + ``` + docker compose up --build + ``` +## Vytvoření superuživatele na serveru v docker compose konfiguraci +``` +sudo docker exec -it e-rezervace-jih-vitkovice-backend python manage.py createsuperuser +``` + +## dns reset windows +``` +ipconfig /flushdns +``` + +# NPM + +``` +cd frontend +npm install + + + +npm i react-router-dom +npm audit fix +npm run dev +``` +``` +ipconfig /flushdns +``` diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..07125d4 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,20 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd +*.sqlite3 +*.log +*.env +.env.* +*.db +node_modules/ +*.tgz +dist/ +build/ +.mypy_cache/ +.vscode/ +.idea/ + +db.sqlite3 +celerybeat-schedule-shm +celerybeat-schedule-wal \ No newline at end of file diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..6346bf8 Binary files /dev/null and b/backend/.gitignore differ diff --git a/backend/account/__init__.py b/backend/account/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/account/admin.py b/backend/account/admin.py new file mode 100644 index 0000000..5c90d30 --- /dev/null +++ b/backend/account/admin.py @@ -0,0 +1,105 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from .models import CustomUser +from trznice.admin import custom_admin_site +from django.core.exceptions import PermissionDenied +from .forms import CustomUserCreationForm +from django.db.models import Q + + +# @admin.register(CustomUser) +class CustomUserAdmin(UserAdmin): + model = CustomUser + add_form = CustomUserCreationForm + + list_display = ( + "id", "username", "first_name", "last_name", "email", "role", + "create_time", "account_type", "is_active", "is_staff", "email_verified", "is_deleted" + ) + + list_filter = ("role", "account_type", "is_deleted", "is_active", "is_staff", "email_verified") + search_fields = ("username", "email", "phone_number") + ordering = ("-create_time",) + + readonly_fields = ("create_time", "id") # zde + + fieldsets = ( + (None, {"fields": ("username", "first_name", "last_name", "email", "password")}), + ("Osobní údaje", {"fields": ("role", "account_type", "phone_number", "var_symbol", "bank_account", "ICO", "city", "street", "PSC")}), + ("Práva a stav", {"fields": ("is_active", "is_staff", "is_superuser", "email_verified", "is_deleted", "deleted_at", "groups", "user_permissions")}), + ("Důležité časy", {"fields": ("last_login",)}), # create_time vyjmuto odsud + ) + + add_fieldsets = ( + (None, { + "classes": ("wide",), + "fields": ( + "username", "email", "role", "account_type", + "password1", "password2", # ✅ REQUIRED! + ), + }), + ) + + def get_form(self, request, obj=None, **kwargs): + if not obj and getattr(request.user, "role", None) == "cityClerk": + form = CustomUserCreationForm + + # Modify choices of the role field in the form class itself + form.base_fields["role"].choices = [ + ("", "---------"), + ("seller", "Prodejce"), + ] + + return form + + return super().get_form(request, obj, **kwargs) + + def formfield_for_choice_field(self, db_field, request, **kwargs): + if db_field.name == "role" and request.user.role == "cityClerk": + # Restrict choices to only blank and "seller" + kwargs["choices"] = [ + ("", "---------"), + ("seller", "Prodejce"), + ] + return super().formfield_for_choice_field(db_field, request, **kwargs) + + def get_list_display(self, request): + if request.user.role == "cityClerk": + return ("email", "username", "role", "account_type", "email_verified") # Keep it minimal + return super().get_list_display(request) + + def get_fieldsets(self, request, obj=None): + # "add" view = creating a new user + if obj is None and request.user.role == "cityClerk": + return ( + (None, { + "classes": ("wide",), + "fields": ("username", "email", "role", "account_type", "password1", "password2"), + }), + ) + + # "change" view + if request.user.role == "cityClerk": + return ( + (None, {"fields": ("email", "username", "password")}), + ("Osobní údaje", {"fields": ("role", "account_type", "phone_number", "var_symbol", "bank_account", "ICO", "city", "street", "PSC")}), + ) + + # Default for other users + return super().get_fieldsets(request, obj) + + def get_queryset(self, request): + qs = self.model.all_objects.all() + if request.user.role == "cityClerk": + return qs.filter( + Q(role__in=["seller", ""]) | (Q(role__isnull=True)) & Q(is_superuser=False) | Q(is_deleted=False)) + return qs + + + def save_model(self, request, obj, form, change): + if request.user.role == "cityClerk": + if obj.role not in ["", None, "seller"]: + raise PermissionDenied("City clerk can't assign this role.") + super().save_model(request, obj, form, change) + +custom_admin_site.register(CustomUser, CustomUserAdmin) \ No newline at end of file diff --git a/backend/account/apps.py b/backend/account/apps.py new file mode 100644 index 0000000..2b08f1a --- /dev/null +++ b/backend/account/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'account' diff --git a/backend/account/email.py b/backend/account/email.py new file mode 100644 index 0000000..62c3425 --- /dev/null +++ b/backend/account/email.py @@ -0,0 +1,108 @@ +from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode +from django.utils.encoding import force_bytes +from django.urls import reverse +from django.core.mail import send_mail +from .tokens import * +from django.contrib.auth import get_user_model + +User = get_user_model() + +from django.conf import settings +from rest_framework.response import Response + + +import logging +logger = logging.getLogger(__name__) + +# This function sends a password reset email to the user. +def send_password_reset_email(user, request): + uid = urlsafe_base64_encode(force_bytes(user.pk)) + token = password_reset_token.make_token(user) + + url = f"{settings.FRONTEND_URL}/reset-password/{uid}/{token}" + + send_email_with_context( + subject="Obnova hesla", + message=f"Pro obnovu hesla klikni na následující odkaz:\n{url}", + recipients=[user.email], + ) + + + + + +# This function sends an email to the user for email verification after registration. +def send_email_verification(user): + uid = urlsafe_base64_encode(force_bytes(user.pk)) + token = account_activation_token.make_token(user) + + url = f"{settings.FRONTEND_URL}/email-verification/?uidb64={uid}&token={token}" + + message = f"Ověřte svůj e-mail kliknutím na odkaz:\n{url}" + + logger.debug(f"\nEMAIL OBSAH:\n {message}\nKONEC OBSAHU") + + send_email_with_context( + recipients=user.email, + subject="Ověření e-mailu", + message=f"{message}" + ) + + +def send_email_clerk_add_var_symbol(user): + uid = urlsafe_base64_encode(force_bytes(user.pk)) + token = account_activation_token.make_token(user) + # url = f"http://localhost:5173/clerk/add-var-symbol/{uid}/" # NEVIM + url = f"URL" + message = f"Byl vytvořen nový uživatel:\n {user.firstname} {user.secondname} {user.email} .\n Doplňte variabilní symbol {url} ." + + if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend': + logger.debug("\nEMAIL OBSAH:\n",message, "\nKONEC OBSAHU") + + + send_email_with_context( + recipients=user.email, + subject="Doplnění variabilního symbolu", + message=message + ) + +def send_email_clerk_accepted(user): + uid = urlsafe_base64_encode(force_bytes(user.pk)) + token = account_activation_token.make_token(user) + + message = f"Úředník potvrdil vaší registraci. Můžete se přihlásit." + + + if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend': + logger.debug("\nEMAIL OBSAH:\n",message, "\nKONEC OBSAHU") + + send_email_with_context( + recipients=user.email, + subject="Úředník potvrdil váší registraci", + message=message + ) + + + +def send_email_with_context(recipients, subject, message): + """ + General function to send emails with a specific context. + """ + if isinstance(recipients, str): + recipients = [recipients] + + try: + send_mail( + subject=subject, + message=message, + from_email=None, + recipient_list=recipients, + fail_silently=False, + ) + return True + except Exception as e: + if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend': + logger.error(f"email se neodeslal... DEBUG: {e}") + pass + else: + return Response({"error": f"E-mail se neodeslal, důvod: {e}"}, status=500) diff --git a/backend/account/filters.py b/backend/account/filters.py new file mode 100644 index 0000000..c1de518 --- /dev/null +++ b/backend/account/filters.py @@ -0,0 +1,30 @@ +import django_filters +from django.contrib.auth import get_user_model + +User = get_user_model() + +class UserFilter(django_filters.FilterSet): + role = django_filters.CharFilter(field_name="role", lookup_expr="exact") + account_type = django_filters.CharFilter(field_name="account_type", lookup_expr="exact") + email = django_filters.CharFilter(field_name="email", lookup_expr="icontains") + phone_number = django_filters.CharFilter(field_name="phone_number", lookup_expr="icontains") + city = django_filters.CharFilter(field_name="city", lookup_expr="icontains") + street = django_filters.CharFilter(field_name="street", lookup_expr="icontains") + PSC = django_filters.CharFilter(field_name="PSC", lookup_expr="exact") + ICO = django_filters.CharFilter(field_name="ICO", lookup_expr="exact") + RC = django_filters.CharFilter(field_name="RC", lookup_expr="exact") + var_symbol = django_filters.NumberFilter(field_name="var_symbol") + bank_account = django_filters.CharFilter(field_name="bank_account", lookup_expr="icontains") + GDPR = django_filters.BooleanFilter(field_name="GDPR") + is_active = django_filters.BooleanFilter(field_name="is_active") + email_verified = django_filters.BooleanFilter(field_name="email_verified") + create_time_after = django_filters.IsoDateTimeFilter(field_name="create_time", lookup_expr="gte") + create_time_before = django_filters.IsoDateTimeFilter(field_name="create_time", lookup_expr="lte") + + class Meta: + model = User + fields = [ + "role", "account_type", "email", "phone_number", "city", "street", "PSC", + "ICO", "RC", "var_symbol", "bank_account", "GDPR", "is_active", "email_verified", + "create_time_after", "create_time_before" + ] diff --git a/backend/account/forms.py b/backend/account/forms.py new file mode 100644 index 0000000..9ad54d9 --- /dev/null +++ b/backend/account/forms.py @@ -0,0 +1,16 @@ +from django import forms +from django.contrib.auth.forms import UserCreationForm +from .models import CustomUser # adjust import to your app + +#using: admin.py +class CustomUserCreationForm(UserCreationForm): + class Meta: + model = CustomUser + fields = ("username", "email", "role", "account_type", "password1", "password2") + + def save(self, commit=True): + user = super().save(commit=False) + # Optional logic: assign role-based permissions here if needed + if commit: + user.save() + return user diff --git a/backend/account/management/commands/createsuperuser_active.py b/backend/account/management/commands/createsuperuser_active.py new file mode 100644 index 0000000..79a628e --- /dev/null +++ b/backend/account/management/commands/createsuperuser_active.py @@ -0,0 +1,40 @@ +from django.core.management.base import BaseCommand +from django.contrib.auth import get_user_model +from getpass import getpass + +class Command(BaseCommand): + help = 'Vytvoří superuživatele s is_active=True a potvrzením hesla' + + def handle(self, *args, **kwargs): + User = get_user_model() + + # Zadání údajů + username = input("Username: ").strip() + email = input("Email: ").strip() + + # Heslo s potvrzením + while True: + password = getpass("Password: ") + password2 = getpass("Confirm password: ") + if password != password2: + self.stdout.write(self.style.ERROR("❌ Hesla se neshodují. Zkus to znovu.")) + else: + break + + # Kontrola duplicity + if User.objects.filter(username=username).exists(): + self.stdout.write(self.style.ERROR("⚠️ Uživatel s tímto username už existuje.")) + return + + # Vytvoření uživatele + user = User.objects.create_superuser( + username=username, + email=email, + password=password + ) + user.is_active = True + if hasattr(user, 'email_verified'): + user.email_verified = True + user.save() + + self.stdout.write(self.style.SUCCESS(f"✅ Superuživatel '{username}' úspěšně vytvořen.")) diff --git a/backend/account/migrations/0001_initial.py b/backend/account/migrations/0001_initial.py new file mode 100644 index 0000000..24eaa54 --- /dev/null +++ b/backend/account/migrations/0001_initial.py @@ -0,0 +1,59 @@ +# Generated by Django 5.2.4 on 2025-08-07 15:13 + +import account.models +import django.contrib.auth.validators +import django.core.validators +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('is_deleted', models.BooleanField(default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('role', models.CharField(blank=True, choices=[('admin', 'Administrátor'), ('seller', 'Prodejce'), ('squareManager', 'Správce tržiště'), ('cityClerk', 'Úředník'), ('checker', 'Kontrolor')], max_length=32, null=True)), + ('account_type', models.CharField(blank=True, choices=[('company', 'Firma'), ('individual', 'Fyzická osoba')], max_length=32, null=True)), + ('email_verified', models.BooleanField(default=False)), + ('phone_number', models.CharField(blank=True, max_length=16, unique=True, validators=[django.core.validators.RegexValidator('^\\+?\\d{9,15}$', message='Zadejte platné telefonní číslo.')])), + ('email', models.EmailField(db_index=True, max_length=254, unique=True)), + ('create_time', models.DateTimeField(auto_now_add=True)), + ('var_symbol', models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MaxValueValidator(9999999999), django.core.validators.MinValueValidator(0)])), + ('bank_account', models.CharField(blank=True, max_length=255, null=True, validators=[django.core.validators.RegexValidator(code='invalid_bank_account', message='Zadejte platné číslo účtu ve formátu [prefix-]číslo_účtu/kód_banky, např. 1234567890/0100 nebo 123-4567890/0100.', regex='^(\\d{0,6}-)?\\d{10}/\\d{4}$')])), + ('ICO', models.CharField(blank=True, max_length=8, null=True, validators=[django.core.validators.RegexValidator(code='invalid_ico', message='IČO musí obsahovat přesně 8 číslic.', regex='^\\d{8}$')])), + ('RC', models.CharField(blank=True, max_length=11, null=True, validators=[django.core.validators.RegexValidator(code='invalid_rc', message='Rodné číslo musí být ve formátu 123456/7890.', regex='^\\d{6}\\/\\d{3,4}$')])), + ('city', models.CharField(blank=True, max_length=100, null=True)), + ('street', models.CharField(blank=True, max_length=200, null=True)), + ('PSC', models.CharField(blank=True, max_length=5, null=True, validators=[django.core.validators.RegexValidator(code='invalid_psc', message='PSČ musí obsahovat přesně 5 číslic.', regex='^\\d{5}$')])), + ('GDPR', models.BooleanField(default=False)), + ('is_active', models.BooleanField(default=False)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to.', related_name='customuser_set', related_query_name='customuser', to='auth.group')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='customuser_set', related_query_name='customuser', to='auth.permission')), + ], + options={ + 'abstract': False, + }, + managers=[ + ('objects', account.models.CustomUserActiveManager()), + ('all_objects', account.models.CustomUserAllManager()), + ], + ), + ] diff --git a/backend/account/migrations/__init__.py b/backend/account/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/account/models.py b/backend/account/models.py new file mode 100644 index 0000000..02c23cb --- /dev/null +++ b/backend/account/models.py @@ -0,0 +1,199 @@ +import uuid +from django.db import models +from django.contrib.auth.models import AbstractUser, Group, Permission +from django.core.validators import RegexValidator, MinLengthValidator, MaxValueValidator, MinValueValidator + +from django.conf import settings +from django.db import models +from django.utils import timezone +from datetime import timedelta + +from trznice.models import SoftDeleteModel + +from django.contrib.auth.models import UserManager + +import logging + +logger = logging.getLogger(__name__) + +# Custom User Manager to handle soft deletion +class CustomUserActiveManager(UserManager): + def get_queryset(self): + return super().get_queryset().filter(is_deleted=False) + +# Custom User Manager to handle all users, including soft deleted +class CustomUserAllManager(UserManager): + def get_queryset(self): + return super().get_queryset() + + +class CustomUser(SoftDeleteModel, AbstractUser): + groups = models.ManyToManyField( + Group, + related_name="customuser_set", # <- přidáš related_name + blank=True, + help_text="The groups this user belongs to.", + related_query_name="customuser", + ) + user_permissions = models.ManyToManyField( + Permission, + related_name="customuser_set", # <- přidáš related_name + blank=True, + help_text="Specific permissions for this user.", + related_query_name="customuser", + ) + + ROLE_CHOICES = ( + ('admin', 'Administrátor'), + ('seller', 'Prodejce'), + ('squareManager', 'Správce tržiště'), + ('cityClerk', 'Úředník'), + ('checker', 'Kontrolor'), + ) + role = models.CharField(max_length=32, choices=ROLE_CHOICES, null=True, blank=True) + + ACCOUNT_TYPES = ( + ('company', 'Firma'), + ('individual', 'Fyzická osoba') + ) + account_type = models.CharField(max_length=32, choices=ACCOUNT_TYPES, null=True, blank=True) + + email_verified = models.BooleanField(default=False) + + phone_number = models.CharField( + unique=True, + max_length=16, + blank=True, + validators=[RegexValidator(r'^\+?\d{9,15}$', message="Zadejte platné telefonní číslo.")] + ) + + email = models.EmailField(unique=True, db_index=True) + create_time = models.DateTimeField(auto_now_add=True) + + var_symbol = models.PositiveIntegerField(null=True, blank=True, validators=[ + MaxValueValidator(9999999999), + MinValueValidator(0) + ], + ) + bank_account = models.CharField( + max_length=255, + null=True, + blank=True, + validators=[ + RegexValidator( + regex=r'^(\d{0,6}-)?\d{10}/\d{4}$', # r'^(\d{0,6}-)?\d{2,10}/\d{4}$' for range 2-10 digits + message="Zadejte platné číslo účtu ve formátu [prefix-]číslo_účtu/kód_banky, např. 1234567890/0100 nebo 123-4567890/0100.", + code='invalid_bank_account' + ) + ], + ) + + ICO = models.CharField( + max_length=8, + blank=True, + null=True, + validators=[ + RegexValidator( + regex=r'^\d{8}$', + message="IČO musí obsahovat přesně 8 číslic.", + code='invalid_ico' + ) + ] + ) + + RC = models.CharField( + max_length=11, + blank=True, + null=True, + validators=[ + RegexValidator( + regex=r'^\d{6}\/\d{3,4}$', + message="Rodné číslo musí být ve formátu 123456/7890.", + code='invalid_rc' + ) + ] + ) + + city = models.CharField(null=True, blank=True, max_length=100) + street = models.CharField(null=True, blank=True, max_length=200) + + PSC = models.CharField( + max_length=5, + blank=True, + null=True, + validators=[ + RegexValidator( + regex=r'^\d{5}$', + message="PSČ musí obsahovat přesně 5 číslic.", + code='invalid_psc' + ) + ] + ) + GDPR = models.BooleanField(default=False) + + is_active = models.BooleanField(default=False) + + objects = CustomUserActiveManager() + all_objects = CustomUserAllManager() + + REQUIRED_FIELDS = ['email'] + + + def __str__(self): + return f"{self.email} at {self.create_time.strftime('%d-%m-%Y %H:%M:%S')}" + + def generate_login(self, first_name, last_name): + """ + Vygeneruje login ve formátu: prijmeni + 2 písmena jména bez diakritiky. + Přidá číslo pokud už login existuje. + """ + from django.utils.text import slugify + base_login = slugify(f"{last_name}{first_name[:2]}") + login = base_login + counter = 1 + while CustomUser.objects.filter(username=login).exists(): + login = f"{base_login}{counter}" + counter += 1 + return login + + def delete(self, *args, **kwargs): + self.is_active = False + + self.tickets.all().update(is_deleted=True, deleted_at=timezone.now()) + self.user_reservations.all().update(is_deleted=True, deleted_at=timezone.now()) + self.orders.all().update(is_deleted=True, deleted_at=timezone.now()) + + return super().delete(*args, **kwargs) + + def save(self, *args, **kwargs): + is_new = self.pk is None # check BEFORE saving + + if is_new: + # Ensure first_name and last_name are provided before generating login + if self.first_name and self.last_name: + self.username = self.generate_login(self.first_name, self.last_name) + if self.is_superuser or self.role in ["admin", "cityClerk", "squareManager"]: + # self.is_staff = True + self.is_active = True + if self.role == 'admin': + self.is_staff = True + self.is_superuser = True + if self.is_superuser: + self.role = 'admin' + else: + self.is_staff = False + + return super().save(*args, **kwargs) + + # NEMAZAT prozatim to nechame, kdybychom to potrebovali + + # Now assign permissions after user exists + # if is_new and self.role: + if self.role: + from account.utils import assign_permissions_based_on_role + logger.debug(f"Assigning permissions to: {self.email} with role {self.role}") + assign_permissions_based_on_role(self) + + # super().save(*args, **kwargs) # save once, after prep + + diff --git a/backend/account/permissions.py b/backend/account/permissions.py new file mode 100644 index 0000000..5b54b0b --- /dev/null +++ b/backend/account/permissions.py @@ -0,0 +1,72 @@ +from rest_framework.permissions import BasePermission, SAFE_METHODS +from rest_framework.permissions import IsAuthenticated +from rest_framework_api_key.permissions import HasAPIKey + + +#Podle svého uvážení (NEPOUŽÍVAT!!!) +class RolePermission(BasePermission): + allowed_roles = [] + + def has_permission(self, request, view): + # Je uživatel přihlášený a má roli z povolených? + user_has_role = ( + request.user and + request.user.is_authenticated and + getattr(request.user, "role", None) in self.allowed_roles + ) + + # Má API klíč? + has_api_key = HasAPIKey().has_permission(request, view) + + + return user_has_role or has_api_key + + +#TOHLE POUŽÍT!!! +#Prostě stačí vložit: RoleAllowed('seller','cityClerk') +def RoleAllowed(*roles): + class SafeOrRolePermission(BasePermission): + """ + Allows safe methods for any authenticated user. + Allows unsafe methods only for users with specific roles. + + Args: + RolerAllowed('seller', 'cityClerk') + """ + + def has_permission(self, request, view): + # Allow safe methods for any authenticated user + if request.method in SAFE_METHODS: + return IsAuthenticated().has_permission(request, view) + + # Otherwise, check the user's role + user = request.user + return user and user.is_authenticated and getattr(user, "role", None) in roles + + return SafeOrRolePermission + +# FIXME: je tohle nutné??? +def OnlyRolesAllowed(*roles): + class SafeOrRolePermission(BasePermission): + """ + Allows all methods only for users with specific roles. + """ + + def has_permission(self, request, view): + # Otherwise, check the user's role + user = request.user + return user and user.is_authenticated and getattr(user, "role", None) in roles + + return SafeOrRolePermission + + +# For Settings.py +class AdminOnly(BasePermission): + """ Allows access only to users with the 'admin' role. + + Args: + BasePermission (rest_framework.permissions.BasePermission): Base class for permission classes. + """ + def has_permission(self, request, view): + return request.user and request.user.is_authenticated and getattr(request.user, 'role', None) == 'admin' + diff --git a/backend/account/serializers.py b/backend/account/serializers.py new file mode 100644 index 0000000..aca553f --- /dev/null +++ b/backend/account/serializers.py @@ -0,0 +1,224 @@ +import re +from django.utils.text import slugify +from django.core.validators import MinValueValidator, MaxValueValidator +from rest_framework import serializers +from rest_framework.exceptions import NotFound +from django.contrib.auth import get_user_model +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer +from django.utils.translation import gettext_lazy as _ +from django.utils.text import slugify + +from .permissions import * +from .email import * + + +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer +from rest_framework.exceptions import PermissionDenied + + +User = get_user_model() + +class CustomUserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = [ + "id", + "username", + "first_name", + "last_name", + "email", + "role", + "account_type", + "email_verified", + "phone_number", + "create_time", + "var_symbol", + "bank_account", + "ICO", + "RC", + "city", + "street", + "PSC", + "GDPR", + "is_active", + ] + read_only_fields = ["id", "create_time", "GDPR", "username"] # <-- removed "account_type" + + def update(self, instance, validated_data): + user = self.context["request"].user + staff_only_fields = ["role", "email_verified", "var_symbol", "is_active"] + + if user.role not in ["admin", "cityClerk"]: + unauthorized = [f for f in staff_only_fields if f in validated_data] + if unauthorized: + raise PermissionDenied(f"You are not allowed to modify: {', '.join(unauthorized)}") + + return super().update(instance, validated_data) + + + + +# Token obtaining Default Serializer +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + username_field = User.USERNAME_FIELD + + def validate(self, attrs): + login = attrs.get("username") + password = attrs.get("password") + + # Allow login by username or email + user = User.objects.filter(email__iexact=login).first() or \ + User.objects.filter(username__iexact=login).first() + + if user is None or not user.check_password(password): + raise serializers.ValidationError(_("No active account found with the given credentials")) + + # Call the parent validation to create token + data = super().validate({ + self.username_field: user.username, + "password": password + }) + + data["user_id"] = user.id + data["username"] = user.username + data["email"] = user.email + return data + + +# user creating section start ------------------------------------------ +class UserRegistrationSerializer(serializers.ModelSerializer): + password = serializers.CharField( + write_only=True, + help_text="Heslo musí mít alespoň 8 znaků, obsahovat velká a malá písmena a číslici." + ) + + class Meta: + model = User + fields = [ + 'first_name', 'last_name', 'email', 'phone_number', 'account_type', + 'password','city', 'street', 'PSC', 'bank_account', 'RC', 'ICO', 'GDPR' + ] + extra_kwargs = { + 'first_name': {'required': True, 'help_text': 'Křestní jméno uživatele'}, + 'last_name': {'required': True, 'help_text': 'Příjmení uživatele'}, + 'email': {'required': True, 'help_text': 'Emailová adresa uživatele'}, + 'phone_number': {'required': True, 'help_text': 'Telefonní číslo uživatele'}, + 'account_type': {'required': True, 'help_text': 'Typ účtu'}, + 'city': {'required': True, 'help_text': 'Město uživatele'}, + 'street': {'required': True, 'help_text': 'Ulice uživatele'}, + 'PSC': {'required': True, 'help_text': 'Poštovní směrovací číslo'}, + 'bank_account': {'required': True, 'help_text': 'Číslo bankovního účtu'}, + 'RC': {'required': True, 'help_text': 'Rodné číslo'}, + 'ICO': {'required': True, 'help_text': 'Identifikační číslo organizace'}, + 'GDPR': {'required': True, 'help_text': 'Souhlas se zpracováním osobních údajů'}, + } + + def validate_password(self, value): + if len(value) < 8: + raise serializers.ValidationError("Heslo musí mít alespoň 8 znaků.") + if not re.search(r"[A-Z]", value): + raise serializers.ValidationError("Heslo musí obsahovat alespoň jedno velké písmeno.") + if not re.search(r"[a-z]", value): + raise serializers.ValidationError("Heslo musí obsahovat alespoň jedno malé písmeno.") + if not re.search(r"\d", value): + raise serializers.ValidationError("Heslo musí obsahovat alespoň jednu číslici.") + return value + + def validate(self, data): + email = data.get("email") + phone = data.get("phone_number") + dgpr = data.get("GDPR") + if not dgpr: + raise serializers.ValidationError({"GDPR": "Pro registraci musíte souhlasit s GDPR"}) + if User.objects.filter(email=email).exists(): + raise serializers.ValidationError({"email": "Účet s tímto emailem již existuje."}) + if phone and User.objects.filter(phone_number=phone).exists(): + raise serializers.ValidationError({"phone_number": "Účet s tímto telefonem již existuje."}) + return data + + def generate_username(self, first_name, last_name): + # Převod na ascii (bez diakritiky) + base_login = slugify(f"{last_name}{first_name[:2]}") + login = base_login + counter = 1 + while User.objects.filter(username=login).exists(): + login = f"{base_login}{counter}" + counter += 1 + return login + + def create(self, validated_data): + password = validated_data.pop("password") + first_name = validated_data.get("first_name", "") + last_name = validated_data.get("last_name", "") + username = self.generate_username(first_name, last_name) + user = User.objects.create( + username=username, + is_active=False, #uživatel je defaultně deaktivovaný + **validated_data + ) + user.set_password(password) + user.save() + + return user + +class UserActivationSerializer(serializers.Serializer): + user_id = serializers.IntegerField() + var_symbol = serializers.IntegerField(validators=[MinValueValidator(0), MaxValueValidator(9999999999)]) + + def save(self, **kwargs): + try: + user = User.objects.get(pk=self.validated_data['user_id']) + except User.DoesNotExist: + raise NotFound("Uživatel s tímto ID neexistuje.") + user.var_symbol = self.validated_data['var_symbol'] + user.is_active = True + user.save() + return user + + def to_representation(self, instance): + return { + "id": instance.id, + "email": instance.email, + "var_symbol": instance.var_symbol, + "is_active": instance.is_active, + } + + class Meta: + model = User + fields = [ + 'user_id', 'var_symbol' + ] + extra_kwargs = { + 'user_id': {'required': True, 'help_text': 'ID uživatele'}, + 'var_symbol': {'required': True, 'help_text': 'Variablní symbol, zadán úředníkem'}, + } +# user creating section end -------------------------------------------- + + +class PasswordResetRequestSerializer(serializers.Serializer): + email = serializers.EmailField( + help_text="E-mail registrovaného a aktivního uživatele, na který bude zaslán reset hesla." + ) + + def validate_email(self, value): + if not User.objects.filter(email=value, is_active=True).exists(): + raise serializers.ValidationError("Účet s tímto emailem neexistuje nebo není aktivní.") + return value + +class PasswordResetConfirmSerializer(serializers.Serializer): + password = serializers.CharField( + write_only=True, + help_text="Nové heslo musí mít alespoň 8 znaků, obsahovat velká a malá písmena a číslici." + ) + + def validate_password(self, value): + import re + if len(value) < 8: + raise serializers.ValidationError("Heslo musí mít alespoň 8 znaků.") + if not re.search(r"[A-Z]", value): + raise serializers.ValidationError("Musí obsahovat velké písmeno.") + if not re.search(r"[a-z]", value): + raise serializers.ValidationError("Musí obsahovat malé písmeno.") + if not re.search(r"\d", value): + raise serializers.ValidationError("Musí obsahovat číslici.") + return value \ No newline at end of file diff --git a/backend/account/tasks.py b/backend/account/tasks.py new file mode 100644 index 0000000..35f82d9 --- /dev/null +++ b/backend/account/tasks.py @@ -0,0 +1,130 @@ +from celery import shared_task +from celery.utils.log import get_task_logger +from django.core.mail import send_mail +from django.conf import settings +from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode +from django.utils.encoding import force_bytes +from .tokens import * + +from .models import CustomUser + +logger = get_task_logger(__name__) + + +# This function sends a password reset email to the user. +@shared_task +def send_password_reset_email_task(user_id): + try: + user = CustomUser.objects.get(pk=user_id) + except user.DoesNotExist: + logger.info(f"Task send_password_reset_email has failed. Invalid User ID was sent.") + return 0 + + uid = urlsafe_base64_encode(force_bytes(user.pk)) + token = password_reset_token.make_token(user) + + url = f"{settings.FRONTEND_URL}/reset-password/{uid}/{token}" + + send_email_with_context( + subject="Obnova hesla", + message=f"Pro obnovu hesla klikni na následující odkaz:\n{url}", + recipients=[user.email], + ) + + +# This function sends an email to the user for email verification after registration. +@shared_task +def send_email_verification_task(user_id): + try: + user = CustomUser.objects.get(pk=user_id) + except user.DoesNotExist: + logger.info(f"Task send_password_reset_email has failed. Invalid User ID was sent.") + return 0 + + uid = urlsafe_base64_encode(force_bytes(user.pk)) + token = account_activation_token.make_token(user) + + url = f"{settings.FRONTEND_URL}/email-verification/?uidb64={uid}&token={token}" + + message = f"Ověřte svůj e-mail kliknutím na odkaz:\n{url}" + + logger.debug(f"\nEMAIL OBSAH:\n {message}\nKONEC OBSAHU") + + send_email_with_context( + recipients=user.email, + subject="Ověření e-mailu", + message=f"{message}" + ) + + +@shared_task +def send_email_clerk_add_var_symbol_task(user_id): + try: + user = CustomUser.objects.get(pk=user_id) + except user.DoesNotExist: + logger.info(f"Task send_password_reset_email has failed. Invalid User ID was sent.") + return 0 + + uid = urlsafe_base64_encode(force_bytes(user.pk)) + # url = f"http://localhost:5173/clerk/add-var-symbol/{uid}/" # NEVIM + # TODO: Replace with actual URL once frontend route is ready + url = f"{settings.FRONTEND_URL}/clerk/add-var-symbol/{uid}/" + message = f"Byl vytvořen nový uživatel:\n {user.firstname} {user.secondname} {user.email} .\n Doplňte variabilní symbol {url} ." + + if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend': + logger.debug("\nEMAIL OBSAH:\n",message, "\nKONEC OBSAHU") + + + send_email_with_context( + recipients=user.email, + subject="Doplnění variabilního symbolu", + message=message + ) + + +@shared_task +def send_email_clerk_accepted_task(user_id): + try: + user = CustomUser.objects.get(pk=user_id) + except user.DoesNotExist: + logger.info(f"Task send_password_reset_email has failed. Invalid User ID was sent.") + return 0 + + uid = urlsafe_base64_encode(force_bytes(user.pk)) + token = account_activation_token.make_token(user) + + message = f"Úředník potvrdil vaší registraci. Můžete se přihlásit." + + + if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend': + logger.debug("\nEMAIL OBSAH:\n",message, "\nKONEC OBSAHU") + + send_email_with_context( + recipients=user.email, + subject="Úředník potvrdil váší registraci", + message=message + ) + + + +def send_email_with_context(recipients, subject, message): + """ + General function to send emails with a specific context. + """ + if isinstance(recipients, str): + recipients = [recipients] + + try: + send_mail( + subject=subject, + message=message, + from_email=None, + recipient_list=recipients, + fail_silently=False, + ) + if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend': + logger.debug("\nEMAIL OBSAH:\n",message, "\nKONEC OBSAHU") + return True + except Exception as e: + logger.error(f"E-mail se neodeslal: {e}") + return False diff --git a/backend/account/tests.py b/backend/account/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/account/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/account/tokens.py b/backend/account/tokens.py new file mode 100644 index 0000000..08e9bb2 --- /dev/null +++ b/backend/account/tokens.py @@ -0,0 +1,33 @@ +from django.contrib.auth.tokens import PasswordResetTokenGenerator + +# Subclass PasswordResetTokenGenerator to create a separate token generator +# for account activation. This allows future customization specific to activation tokens, +# even though it currently behaves exactly like the base class. +class AccountActivationTokenGenerator(PasswordResetTokenGenerator): + pass # No changes yet; inherits all behavior from PasswordResetTokenGenerator + +# Create an instance of AccountActivationTokenGenerator to be used for generating +# and validating account activation tokens throughout the app. +account_activation_token = AccountActivationTokenGenerator() + +# Create an instance of the base PasswordResetTokenGenerator to be used +# for password reset tokens. +password_reset_token = PasswordResetTokenGenerator() + + + + +from rest_framework_simplejwt.authentication import JWTAuthentication + +#NEMĚNIT CUSTOM SBÍRANÍ COOKIE TOKENU +class CookieJWTAuthentication(JWTAuthentication): + def authenticate(self, request): + + raw_token = request.COOKIES.get('access_token') + + if not raw_token: + return None + + validated_token = self.get_validated_token(raw_token) + return self.get_user(validated_token), validated_token + diff --git a/backend/account/urls.py b/backend/account/urls.py new file mode 100644 index 0000000..28993da --- /dev/null +++ b/backend/account/urls.py @@ -0,0 +1,28 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import * + + +router = DefaultRouter() +router.register(r'users', UserView, basename='user') # change URL to plural users ? + +urlpatterns = [ + path('', include(router.urls)), # automaticky přidá všechny cesty z viewsetu + path("user/me/", CurrentUserView.as_view(), name="user-me"), # get current user data + + path('token/', CookieTokenObtainPairView.as_view(), name='token_obtain_pair'), #přihlášení (get token) + path('token/refresh/', CookieTokenRefreshView.as_view(), name='token_refresh'), #refresh token + #potom co access token vyprší tak se pomocí refresh tokenu získa další + + path('logout/', LogoutView.as_view(), name='logout'), # odhlášení (smaže tokeny) + + path('registration/', UserRegistrationViewSet.as_view({'post': 'create'}), name='create_seller'), + + #slouží čistě pro email + path("registration/verify-email///", EmailVerificationView.as_view(), name="verify-email"), + + path("registration/activation-varsymbol/", UserActivationViewSet.as_view(), name="activate_user_and_input_var_symbol"), + + path("reset-password/", PasswordResetRequestView.as_view(), name="reset-password-request"), + path("reset-password///", PasswordResetConfirmView.as_view(), name="reset-password-confirm"), +] \ No newline at end of file diff --git a/backend/account/utils.py b/backend/account/utils.py new file mode 100644 index 0000000..158638f --- /dev/null +++ b/backend/account/utils.py @@ -0,0 +1,62 @@ +from django.contrib.auth.models import Permission +from django.contrib.contenttypes.models import ContentType +from booking.models import Event, Reservation, MarketSlot, Square +from product.models import Product, EventProduct +from servicedesk.models import ServiceTicket +from django.contrib.auth import get_user_model + +import logging + +logger = logging.getLogger(__name__) + +def assign_permissions_based_on_role(user): + role_perms = { + "cityClerk": { + "view": [Event, Reservation, MarketSlot, get_user_model(), Product, EventProduct, ServiceTicket], + "add": [Reservation, get_user_model()], + "change": [Reservation, get_user_model()], + # "delete": [Reservation], + }, + "squareManager": { + "view": [Event, MarketSlot, Square, Product, EventProduct], + "add": [Event, MarketSlot, Square, Product, EventProduct], + "change": [Event, MarketSlot, Square, Product, EventProduct], + }, + # "admin": { + # "view": [Event, Reservation, get_user_model()], + # "add": [Event, Reservation], + # "change": [Event, Reservation], + # "delete": [Event, Reservation], + # }, + # etc. + "admin": "all", # Mark this role specially + } + + if not user.role: + logger.info("User has no role set") + return + + if user.role == "admin": + user.is_staff = True + user.is_superuser = True + # user.save() + return + + # Reset in case role changed away from admin + user.is_superuser = False + + + perms_for_role = role_perms.get(user.role, {}) + + + for action, models in perms_for_role.items(): + for model in models: + content_type = ContentType.objects.get_for_model(model) + codename = f"{action}_{model._meta.model_name}" + try: + permission = Permission.objects.get(codename=codename, content_type=content_type) + user.user_permissions.add(permission) + except Permission.DoesNotExist: + # You may log this + pass + # user.save() \ No newline at end of file diff --git a/backend/account/views.py b/backend/account/views.py new file mode 100644 index 0000000..173bc13 --- /dev/null +++ b/backend/account/views.py @@ -0,0 +1,409 @@ +from django.contrib.auth import get_user_model, authenticate +from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode +from django.utils.encoding import force_bytes, force_str +from django.conf import settings +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import ensure_csrf_cookie + +from .serializers import * +from .permissions import * +from .tasks import * +from .models import CustomUser +from .tokens import * +from .filters import UserFilter + +from rest_framework import generics, permissions, status, viewsets +from rest_framework.response import Response +from rest_framework_simplejwt.tokens import RefreshToken +from rest_framework.views import APIView +from rest_framework.viewsets import ModelViewSet +from rest_framework.permissions import IsAuthenticated, AllowAny + +from rest_framework_simplejwt.tokens import RefreshToken +from rest_framework_simplejwt.exceptions import TokenError, AuthenticationFailed +from django_filters.rest_framework import DjangoFilterBackend + +from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiExample, OpenApiParameter + + +User = get_user_model() + +#general user view API + +import logging +logger = logging.getLogger(__name__) + + +from rest_framework_simplejwt.views import TokenObtainPairView + +#---------------------------------------------TOKENY------------------------------------------------ + +# Custom Token obtaining view +@extend_schema( + tags=["api"], + summary="Obtain JWT access and refresh tokens (cookie-based)", + request=CustomTokenObtainPairSerializer, + description="Authentication - získaš Access a Refresh token... lze do vložit E-mail nebo username" +) +@method_decorator(ensure_csrf_cookie, name="dispatch") +class CookieTokenObtainPairView(TokenObtainPairView): + permission_classes = [AllowAny] + serializer_class = CustomTokenObtainPairSerializer + + + def post(self, request, *args, **kwargs): + response = super().post(request, *args, **kwargs) + + # Získáme tokeny z odpovědi + access = response.data.get("access") + refresh = response.data.get("refresh") + + if not access or not refresh: + return response # Např. při chybě přihlášení + + jwt_settings = settings.SIMPLE_JWT + + # Access token cookie + response.set_cookie( + key=jwt_settings.get("AUTH_COOKIE", "access_token"), + value=access, + httponly=jwt_settings.get("AUTH_COOKIE_HTTP_ONLY", True), + secure=jwt_settings.get("AUTH_COOKIE_SECURE", not settings.DEBUG), + samesite=jwt_settings.get("AUTH_COOKIE_SAMESITE", "Lax"), + path=jwt_settings.get("AUTH_COOKIE_PATH", "/"), + max_age=int(settings.ACCESS_TOKEN_LIFETIME.total_seconds()), + ) + + # Refresh token cookie + response.set_cookie( + key=jwt_settings.get("AUTH_COOKIE_REFRESH", "refresh_token"), + value=refresh, + httponly=jwt_settings.get("AUTH_COOKIE_HTTP_ONLY", True), + secure=jwt_settings.get("AUTH_COOKIE_SECURE", not settings.DEBUG), + samesite=jwt_settings.get("AUTH_COOKIE_SAMESITE", "Lax"), + path=jwt_settings.get("AUTH_COOKIE_PATH", "/"), + max_age=int(settings.REFRESH_TOKEN_LIFETIME.total_seconds()), + ) + + return response + + def validate(self, attrs): + username = attrs.get("username") + password = attrs.get("password") + + # Přihlaš uživatele ručně + user = authenticate(request=self.context.get('request'), username=username, password=password) + + if not user: + raise AuthenticationFailed("Špatné uživatelské jméno nebo heslo.") + + if not user.is_active: + raise AuthenticationFailed("Uživatel je deaktivován.") + + # Nastav validní uživatele (přebere další logiku ze SimpleJWT) + self.user = user + + # Vrátí access a refresh token jako obvykle + return super().validate(attrs) + +@extend_schema( + tags=["api"], + summary="Refresh JWT token using cookie", + description="Refresh JWT token" +) +@method_decorator(ensure_csrf_cookie, name="dispatch") +class CookieTokenRefreshView(APIView): + permission_classes = [AllowAny] + + def post(self, request): + refresh_token = request.COOKIES.get('refresh_token') or request.data.get('refresh') + if not refresh_token: + return Response({"detail": "Refresh token cookie not found."}, status=status.HTTP_400_BAD_REQUEST) + + try: + refresh = RefreshToken(refresh_token) + access_token = str(refresh.access_token) + new_refresh_token = str(refresh) # volitelně nový refresh token + + response = Response({ + "access": access_token, + "refresh": new_refresh_token, + }) + + jwt_settings = settings.SIMPLE_JWT + + # Access token cookie + response.set_cookie( + key=jwt_settings.get("AUTH_COOKIE", "access_token"), + value=access_token, + httponly=jwt_settings.get("AUTH_COOKIE_HTTP_ONLY", True), + secure=jwt_settings.get("AUTH_COOKIE_SECURE", not settings.DEBUG), + samesite=jwt_settings.get("AUTH_COOKIE_SAMESITE", "Lax"), + path=jwt_settings.get("AUTH_COOKIE_PATH", "/"), + max_age=int(5), + ) + + # Refresh token cookie + response.set_cookie( + key=jwt_settings.get("AUTH_COOKIE_REFRESH", "refresh_token"), + value=new_refresh_token, + httponly=jwt_settings.get("AUTH_COOKIE_HTTP_ONLY", True), + secure=jwt_settings.get("AUTH_COOKIE_SECURE", not settings.DEBUG), + samesite=jwt_settings.get("AUTH_COOKIE_SAMESITE", "Lax"), + path=jwt_settings.get("AUTH_COOKIE_PATH", "/"), + max_age=int(settings.REFRESH_TOKEN_LIFETIME.total_seconds()), + ) + + return response + + except TokenError: + logger.error("Invalid refresh token used.") + return Response({"detail": "Invalid refresh token."}, status=status.HTTP_401_UNAUTHORIZED) + +#---------------------------------------------LOGIN/LOGOUT------------------------------------------------ + +@extend_schema( + tags=["api"], + summary="Logout user (delete access and refresh token cookies)", + description="Odhlásí uživatele – smaže access a refresh token cookies" +) +class LogoutView(APIView): + permission_classes = [AllowAny] + + def post(self, request): + response = Response({"detail": "Logout successful"}, status=status.HTTP_200_OK) + + # Smazání cookies + response.delete_cookie("access_token", path="/") + response.delete_cookie("refresh_token", path="/") + + return response + +#-------------------------------------------------------------------------------------------------------------- + +@extend_schema( + tags=["User"], + responses={200: CustomUserSerializer}, + description="Zobrazí všechny uživatele s možností filtrování a řazení.", +) +class UserView(viewsets.ModelViewSet): + queryset = User.objects.all() + serializer_class = CustomUserSerializer + filter_backends = [DjangoFilterBackend] + filterset_class = UserFilter + + # Require authentication and role permission + permission_classes = [IsAuthenticated] + + class Meta: + model = CustomUser + extra_kwargs = { + "email": {"help_text": "Unikátní e-mailová adresa uživatele."}, + "phone_number": {"help_text": "Telefonní číslo ve formátu +420123456789."}, + "role": {"help_text": "Role uživatele určující jeho oprávnění v systému."}, + "account_type": {"help_text": "Typ účtu – firma nebo fyzická osoba."}, + "email_verified": {"help_text": "Určuje, zda je e-mail ověřen."}, + "create_time": {"help_text": "Datum a čas registrace uživatele (pouze pro čtení).", "read_only": True}, + "var_symbol": {"help_text": "Variabilní symbol pro platby, pokud je vyžadován."}, + "bank_account": {"help_text": "Číslo bankovního účtu uživatele."}, + "ICO": {"help_text": "IČO firmy, pokud se jedná o firemní účet."}, + "RC": {"help_text": "Rodné číslo pro fyzické osoby."}, + "city": {"help_text": "Město trvalého pobytu / sídla."}, + "street": {"help_text": "Ulice a číslo popisné."}, + "PSC": {"help_text": "PSČ místa pobytu / sídla."}, + "GDPR": {"help_text": "Souhlas se zpracováním osobních údajů."}, + "is_active": {"help_text": "Stav aktivace uživatele."}, + } + + def get_permissions(self): + if self.action in ['list', 'create']: # GET / POST /api/account/users/ + return [OnlyRolesAllowed("cityClerk", "admin")()] + + elif self.action in ['update', 'partial_update', 'destroy']: # PUT / PATCH / DELETE /api/account/users/{id} + if self.request.user.role in ['cityClerk', 'admin']: + return [OnlyRolesAllowed("cityClerk", "admin")()] + elif self.kwargs.get('pk') and str(self.request.user.id) == self.kwargs['pk']: + return [IsAuthenticated] + else: + # fallback - deny access + return [OnlyRolesAllowed("cityClerk", "admin")()] # or custom DenyAll() + + elif self.action == 'retrieve': # GET /api/account/users/{id} + if self.request.user.role in ['cityClerk', 'admin']: + return [OnlyRolesAllowed("cityClerk", "admin")()] + elif self.kwargs.get('pk') and str(self.request.user.id) == self.kwargs['pk']: + return [IsAuthenticated()] + else: + return [OnlyRolesAllowed("cityClerk", "admin")()] # or a custom read-only self-access permission + + return super().get_permissions() + + + +# Get current user data +@extend_schema( + tags=["User"], + summary="Get current authenticated user", + description="Vrátí detail aktuálně přihlášeného uživatele podle JWT tokenu nebo session.", + responses={ + 200: OpenApiResponse(response=CustomUserSerializer), + 401: OpenApiResponse(description="Unauthorized, uživatel není přihlášen"), + } +) +class CurrentUserView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + serializer = CustomUserSerializer(request.user) + return Response(serializer.data) + + +#------------------------------------------------REGISTRACE-------------------------------------------------------------- + +#1. registration API +@extend_schema( + tags=["User Registration"], + summary="Register a new user (company or individual)", + request=UserRegistrationSerializer, + responses={201: UserRegistrationSerializer}, + description="1. Registrace nového uživatele(firmy). Uživateli přijde email s odkazem na ověření.", +) +class UserRegistrationViewSet(ModelViewSet): + queryset = CustomUser.objects.all() + serializer_class = UserRegistrationSerializer + http_method_names = ['post'] + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = serializer.save() + + try: + send_email_verification_task.delay(user.id) # posílaní emailu pro potvrzení registrace - CELERY TASK + except Exception as e: + logger.error(f"Celery not available, using fallback. Error: {e}") + send_email_verification_task(user.id) # posílaní emailu pro potvrzení registrace + + + + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + +#2. confirming email +@extend_schema( + tags=["User Registration"], + summary="Verify user email via link", + responses={ + 200: OpenApiResponse(description="Email úspěšně ověřen."), + 400: OpenApiResponse(description="Chybný nebo expirovaný token.") + }, + parameters=[ + OpenApiParameter(name='uidb64', type=str, location='path', description="Token z E-mailu"), + OpenApiParameter(name='token', type=str, location='path', description="Token uživatele"), + ], + description="2. Ověření emailu pomocí odkazu s uid a tokenem. (stačí jenom převzít a poslat)", +) +class EmailVerificationView(APIView): + def get(self, request, uidb64, token): + try: + uid = force_str(urlsafe_base64_decode(uidb64)) + user = User.objects.get(pk=uid) + except (User.DoesNotExist, ValueError, TypeError): + return Response({"error": "Neplatný odkaz."}, status=400) + + if account_activation_token.check_token(user, token): + user.email_verified = True + user.save() + + return Response({"detail": "E-mail byl úspěšně ověřen. Účet čeká na schválení."}) + else: + return Response({"error": "Token je neplatný nebo expirovaný."}, status=400) + +#3. seller activation API (var_symbol) +@extend_schema( + tags=["User Registration"], + summary="Activate user and set variable symbol (admin/cityClerk only)", + request=UserActivationSerializer, + responses={200: UserActivationSerializer}, + description="3. Aktivace uživatele a zadání variabilního symbolu (pouze pro adminy a úředníky).", +) +class UserActivationViewSet(APIView): + permission_classes = [OnlyRolesAllowed('cityClerk', 'admin')] + + def patch(self, request, *args, **kwargs): + serializer = UserActivationSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = serializer.save() + + try: + send_email_clerk_accepted_task.delay(user.id) # posílaní emailu pro informování uživatele o dokončení registrace, uředník doplnil variabilní symbol - CELERY TASK + except Exception as e: + logger.error(f"Celery not available, using fallback. Error: {e}") + send_email_clerk_accepted_task(user.id) # posílaní emailu pro informování uživatele o dokončení registrace, uředník doplnil variabilní symbol + + return Response(serializer.to_representation(user), status=status.HTTP_200_OK) + +#-------------------------------------------------END REGISTRACE------------------------------------------------------------- + +#1. PasswordReset + send Email +@extend_schema( + tags=["User password reset"], + summary="Request password reset (send email)", + request=PasswordResetRequestSerializer, + responses={ + 200: OpenApiResponse(description="Odeslán email s instrukcemi."), + 400: OpenApiResponse(description="Neplatný email.") + }, + description="1(a). Požadavek na reset hesla - uživatel zadá svůj email." +) +class PasswordResetRequestView(APIView): + def post(self, request): + serializer = PasswordResetRequestSerializer(data=request.data) + if serializer.is_valid(): + try: + user = User.objects.get(email=serializer.validated_data['email']) + except User.DoesNotExist: + # Always return 200 even if user doesn't exist to avoid user enumeration + return Response({"detail": "E-mail s odkazem byl odeslán."}) + try: + send_password_reset_email_task.delay(user.id) # posílaní emailu pro obnovení hesla - CELERY TASK + except Exception as e: + logger.error(f"Celery not available, using fallback. Error: {e}") + send_password_reset_email_task(user.id) # posílaní emailu pro obnovení hesla registrace + + return Response({"detail": "E-mail s odkazem byl odeslán."}) + + return Response(serializer.errors, status=400) + +#2. Confirming reset +@extend_schema( + tags=["User password reset"], + summary="Confirm password reset via token", + request=PasswordResetConfirmSerializer, + parameters=[ + OpenApiParameter(name='uidb64', type=str, location=OpenApiParameter.PATH), + OpenApiParameter(name='token', type=str, location=OpenApiParameter.PATH), + ], + responses={ + 200: OpenApiResponse(description="Heslo bylo změněno."), + 400: OpenApiResponse(description="Chybný token nebo data.") + }, + description="1(a). Potvrzení resetu hesla pomocí tokenu z emailu." +) +class PasswordResetConfirmView(APIView): + def post(self, request, uidb64, token): + try: + uid = force_str(urlsafe_base64_decode(uidb64)) + user = User.objects.get(pk=uid) + except (TypeError, ValueError, OverflowError, User.DoesNotExist): + return Response({"error": "Neplatný odkaz."}, status=400) + + if not password_reset_token.check_token(user, token): + return Response({"error": "Token je neplatný nebo expirovaný."}, status=400) + + serializer = PasswordResetConfirmSerializer(data=request.data) + if serializer.is_valid(): + user.set_password(serializer.validated_data['password']) + user.save() + return Response({"detail": "Heslo bylo úspěšně změněno."}) + return Response(serializer.errors, status=400) \ No newline at end of file diff --git a/backend/booking/__init__.py b/backend/booking/__init__.py new file mode 100644 index 0000000..78768a9 --- /dev/null +++ b/backend/booking/__init__.py @@ -0,0 +1 @@ +# from . import tasks diff --git a/backend/booking/admin.py b/backend/booking/admin.py new file mode 100644 index 0000000..3947f40 --- /dev/null +++ b/backend/booking/admin.py @@ -0,0 +1,135 @@ +from django.contrib import admin + +from .models import Event, Reservation, MarketSlot, Square, ReservationCheck +from .forms import ReservationAdminForm +from trznice.admin import custom_admin_site + +class SquareAdmin(admin.ModelAdmin): + list_display = ("id", "name", "description", "street", "city", "width", "height", "is_deleted") + list_filter = ("name", "is_deleted") + search_fields = ("name", "description") + ordering = ("name",) + + base_fields = ['name', 'description', 'street', 'city', 'psc', 'width', 'height', 'grid_rows', 'grid_cols', 'cellsize', 'image'] + + def get_fields(self, request, obj=None): + fields = self.base_fields.copy() + if request.user.role == "admin": + fields += ['is_deleted', 'deleted_at'] + return fields + + def get_queryset(self, request): + # Use the all_objects manager to show even soft-deleted entries + if request.user.role == "admin": + qs = self.model.all_objects.all() + else: + qs = self.model.objects.all() + return qs + +custom_admin_site.register(Square, SquareAdmin) + +# @admin.register(Event) +class EventAdmin(admin.ModelAdmin): + list_display = ("id", "name", "square", "start", "end", "price_per_m2", "is_deleted") + list_filter = ("start", "end", "is_deleted") + search_fields = ("name", "description") + ordering = ("-start",) + + base_fields = ['name', 'description', 'square', 'price_per_m2', 'start', 'end', 'image'] + + def get_fields(self, request, obj=None): + fields = self.base_fields.copy() + if request.user.role == "admin": + fields += ['is_deleted', 'deleted_at'] + return fields + + def get_queryset(self, request): + # Use the all_objects manager to show even soft-deleted entries + if request.user.role == "admin": + qs = self.model.all_objects.all() + else: + qs = self.model.objects.all() + return qs + +custom_admin_site.register(Event, EventAdmin) + +# @admin.register(Reservation) +class ReservationAdmin(admin.ModelAdmin): + form = ReservationAdminForm + + list_display = ("id", "event", "user", "reserved_from", "reserved_to", "status", "created_at", "is_checked", "is_deleted") + list_filter = ("status", "user", "event", "is_deleted") + search_fields = ("user__username", "user__email", "event__name", "note") + ordering = ("-created_at",) + filter_horizontal = ['event_products'] # adds a nice widget for selection + + base_fields = ['event', 'market_slot', 'user', 'status', 'used_extension', 'event_products', 'reserved_to', 'reserved_from', 'final_price', 'note', "is_checked", "last_checked_at", "last_checked_by"] + + def get_fields(self, request, obj=None): + fields = self.base_fields.copy() + if request.user.role == "admin": + fields += ['is_deleted', 'deleted_at'] + return fields + + def get_queryset(self, request): + # Use the all_objects manager to show even soft-deleted entries + if request.user.role == "admin": + qs = self.model.all_objects.all() + else: + qs = self.model.objects.all() + return qs + +custom_admin_site.register(Reservation, ReservationAdmin) + + +class MarketSlotAdmin(admin.ModelAdmin): + list_display = ("id", "event", "number", "status", "base_size", "available_extension", "price_per_m2", "x", "y", "width", "height", "is_deleted") + list_filter = ("status", "event", "is_deleted") + search_fields = ("event__name",) + ordering = ("event", "status") + + base_fields = ['event', 'status', 'number', 'base_size', 'available_extension', 'price_per_m2', 'width', 'height', 'x', 'y'] + + readonly_fields = ("id", "number") # zde + + def get_fields(self, request, obj=None): + fields = self.base_fields.copy() + if request.user.role == "admin": + fields += ['is_deleted', 'deleted_at'] + return fields + + def get_queryset(self, request): + # Use the all_objects manager to show even soft-deleted entries + if request.user.role == "admin": + qs = self.model.all_objects.all() + else: + qs = self.model.objects.all() + return qs + +custom_admin_site.register(MarketSlot, MarketSlotAdmin) + + +class ReservationCheckAdmin(admin.ModelAdmin): + list_display = ("id", "reservation", "checker", "checked_at", "is_deleted") + list_filter = ("reservation", "checker", "is_deleted") + search_fields = ("checker__email", "reservation__event__name") + ordering = ("-checked_at",) + + base_fields = ["reservation", "checker", "checked_at"] + + readonly_fields = ("id", "checked_at") # zde + + def get_fields(self, request, obj=None): + fields = self.base_fields.copy() + if request.user.role == "admin": + fields += ['is_deleted', 'deleted_at'] + return fields + + def get_queryset(self, request): + # Use the all_objects manager to show even soft-deleted entries + if request.user.role == "admin": + qs = self.model.all_objects.all() + else: + qs = self.model.objects.all() + return qs +custom_admin_site.register(ReservationCheck, ReservationCheckAdmin) \ No newline at end of file diff --git a/backend/booking/apps.py b/backend/booking/apps.py new file mode 100644 index 0000000..08457ca --- /dev/null +++ b/backend/booking/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class BookingConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'booking' + + def ready(self): + import booking.signals # <-- this line is important diff --git a/backend/booking/filters.py b/backend/booking/filters.py new file mode 100644 index 0000000..f5dfd9d --- /dev/null +++ b/backend/booking/filters.py @@ -0,0 +1,23 @@ +import django_filters +from .models import Event, Reservation + +class EventFilter(django_filters.FilterSet): + start_after = django_filters.IsoDateTimeFilter(field_name="start", lookup_expr="gte") + end_before = django_filters.IsoDateTimeFilter(field_name="end", lookup_expr="lte") + city = django_filters.CharFilter(field_name="square__city", lookup_expr="icontains") + square = django_filters.NumberFilter(field_name="square__id") # přidáno filtrování podle ID náměstí + + class Meta: + model = Event + fields = ["start_after", "end_before", "city", "square"] # přidáno "square" + + + +class ReservationFilter(django_filters.FilterSet): + event = django_filters.NumberFilter(field_name="event__id") + user = django_filters.NumberFilter(field_name="user__id") + status = django_filters.ChoiceFilter(choices=Reservation.STATUS_CHOICES) + + class Meta: + model = Reservation + fields = ["event", "user", "status"] diff --git a/backend/booking/forms.py b/backend/booking/forms.py new file mode 100644 index 0000000..f0edf6e --- /dev/null +++ b/backend/booking/forms.py @@ -0,0 +1,21 @@ +from django import forms +from django.core.exceptions import ValidationError +from .models import Reservation + +class ReservationAdminForm(forms.ModelForm): + class Meta: + model = Reservation + fields = '__all__' + + def clean(self): + cleaned_data = super().clean() + event = cleaned_data.get('event') + products = cleaned_data.get('event_products') + + if event and products: + invalid_products = [p for p in products if p.event != event] + if invalid_products: + product_names = ', '.join(str(p) for p in invalid_products) + raise ValidationError(f"Některé produkty nepatří k této akci: {product_names}") + + return cleaned_data diff --git a/backend/booking/management/__init__.py b/backend/booking/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/booking/management/commands/__init__.py b/backend/booking/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/booking/management/commands/seed_celery_beat.py b/backend/booking/management/commands/seed_celery_beat.py new file mode 100644 index 0000000..1430596 --- /dev/null +++ b/backend/booking/management/commands/seed_celery_beat.py @@ -0,0 +1,55 @@ +# yourapp/management/commands/seed_celery_beat.py +import json +from django.utils import timezone +from django.core.management.base import BaseCommand +from django_celery_beat.models import PeriodicTask, IntervalSchedule, CrontabSchedule + +class Command(BaseCommand): + help = "Seeds the database with predefined Celery Beat tasks." + + def handle(self, *args, **kwargs): + # # Example 1 — Run every 10 minutes + # schedule, _ = IntervalSchedule.objects.get_or_create( + # every=10, + # period=IntervalSchedule.MINUTES, + # ) + + # Example 2 — Run each 5 minutes + crontab_delete_unpayed, _ = CrontabSchedule.objects.get_or_create( + minute='*/5', + hour='*', + day_of_week='*', + day_of_month='*', + month_of_year='*', + timezone=timezone.get_current_timezone_name(), + ) + + PeriodicTask.objects.get_or_create( + name='Zrušení nezaplacených rezervací', + task='booking.tasks.cancel_unpayed_reservations_task', + crontab=crontab_delete_unpayed, + args=json.dumps([]), # Optional arguments + kwargs=json.dumps({"minutes": 30}), + description="Maže Rezervace podle Objednávky, pokud ta nebyla zaplacena v době 30 minut. Tím se uvolní Prodejní Místa pro nové rezervace.\nJako vstupní argument může být zadán počet minut, podle kterého nezaplacená rezervaace bude stornovana." + ) + + + crontab_delete_soft, _ = CrontabSchedule.objects.get_or_create( + minute='0', + hour='1', + day_of_week='*', + day_of_month='1', + month_of_year='*', + timezone=timezone.get_current_timezone_name(), + ) + + PeriodicTask.objects.get_or_create( + name='Skartace soft-smazaných záznamů', + task='booking.tasks.hard_delete_soft_deleted_records_task', + crontab=crontab_delete_soft, + args=json.dumps([]), # Optional arguments + kwargs=json.dumps({"years": 10, "days": 0}), # Optional kwargs + description="Mazání všech záznamů označených jako smazané v databázi.\nJako vstupní argument lze zadat počet let nebo dnů, podle kterého se určí, jak staré záznamy budou trvale odstraněny." + ) + + self.stdout.write(self.style.SUCCESS("✅ Celery Beat tasks have been seeded.")) diff --git a/backend/booking/migrations/0001_initial.py b/backend/booking/migrations/0001_initial.py new file mode 100644 index 0000000..2d598ed --- /dev/null +++ b/backend/booking/migrations/0001_initial.py @@ -0,0 +1,111 @@ +# Generated by Django 5.2.4 on 2025-08-07 15:13 + +import django.core.validators +import django.db.models.deletion +from decimal import Decimal +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Event', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_deleted', models.BooleanField(default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('name', models.CharField(max_length=255)), + ('description', models.TextField(blank=True, null=True)), + ('start', models.DateField()), + ('end', models.DateField()), + ('price_per_m2', models.DecimalField(decimal_places=2, help_text='Cena za m² pro rezervaci', max_digits=8, validators=[django.core.validators.MinValueValidator(0)])), + ('image', models.ImageField(blank=True, null=True, upload_to='squares-imgs/')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='ReservationCheck', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_deleted', models.BooleanField(default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('checked_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Square', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_deleted', models.BooleanField(default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('name', models.CharField(default='', max_length=255)), + ('description', models.TextField(blank=True, null=True)), + ('street', models.CharField(default='Ulice není zadaná', max_length=255)), + ('city', models.CharField(default='Město není zadané', max_length=255)), + ('psc', models.PositiveIntegerField(default=12345, help_text='Zadejte platné PSČ (5 číslic)', validators=[django.core.validators.MaxValueValidator(99999), django.core.validators.MinValueValidator(10000)])), + ('width', models.PositiveIntegerField(default=10)), + ('height', models.PositiveIntegerField(default=10)), + ('grid_rows', models.PositiveSmallIntegerField(default=60)), + ('grid_cols', models.PositiveSmallIntegerField(default=45)), + ('cellsize', models.PositiveIntegerField(default=10)), + ('image', models.ImageField(blank=True, null=True, upload_to='squares-imgs/')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='MarketSlot', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_deleted', models.BooleanField(default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('status', models.CharField(choices=[('allowed', 'Povoleno'), ('blocked', 'Zablokováno')], default='allowed', max_length=20)), + ('number', models.PositiveSmallIntegerField(default=1, editable=False, help_text='Pořadové číslo prodejního místa na svém Eventu')), + ('base_size', models.FloatField(default=0, help_text='Základní velikost (m²)', validators=[django.core.validators.MinValueValidator(0.0)])), + ('available_extension', models.FloatField(default=0, help_text='Možnost rozšíření (m²)', validators=[django.core.validators.MinValueValidator(0.0)])), + ('x', models.SmallIntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)])), + ('y', models.SmallIntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)])), + ('width', models.PositiveIntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)])), + ('height', models.PositiveIntegerField(default=0, validators=[django.core.validators.MinValueValidator(0)])), + ('price_per_m2', models.DecimalField(decimal_places=2, default=Decimal('0.00'), help_text='Cena za m² pro toto prodejní místo. Neuvádět, pokud chcete nechat výchozí cenu za m² na tomto Eventu.', max_digits=8, validators=[django.core.validators.MinValueValidator(0)])), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='event_marketSlots', to='booking.event')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Reservation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_deleted', models.BooleanField(default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('used_extension', models.FloatField(default=0, help_text='Použité rozšíření (m2)', validators=[django.core.validators.MinValueValidator(0.0)])), + ('reserved_from', models.DateField()), + ('reserved_to', models.DateField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('status', models.CharField(choices=[('reserved', 'Zarezervováno'), ('cancelled', 'Zrušeno')], default='reserved', max_length=20)), + ('note', models.TextField(blank=True, null=True)), + ('final_price', models.DecimalField(decimal_places=2, default=0, help_text='Cena vypočtena automaticky na zakladě ceny za m² prodejního místa a počtu dní rezervace.', max_digits=8, validators=[django.core.validators.MinValueValidator(0)])), + ('price', models.DecimalField(decimal_places=2, default=0, max_digits=10)), + ('is_checked', models.BooleanField(default=False)), + ('last_checked_at', models.DateTimeField(blank=True, null=True)), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='event_reservations', to='booking.event')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/backend/booking/migrations/0002_initial.py b/backend/booking/migrations/0002_initial.py new file mode 100644 index 0000000..9e08374 --- /dev/null +++ b/backend/booking/migrations/0002_initial.py @@ -0,0 +1,54 @@ +# Generated by Django 5.2.4 on 2025-08-07 15:13 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('booking', '0001_initial'), + ('product', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='reservation', + name='event_products', + field=models.ManyToManyField(blank=True, related_name='reservations', to='product.eventproduct'), + ), + migrations.AddField( + model_name='reservation', + name='last_checked_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reservations_checker', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='reservation', + name='market_slot', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='booking.marketslot'), + ), + migrations.AddField( + model_name='reservation', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_reservations', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='reservationcheck', + name='checker', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='performed_checks', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='reservationcheck', + name='reservation', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='checks', to='booking.reservation'), + ), + migrations.AddField( + model_name='event', + name='square', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='square_events', to='booking.square'), + ), + ] diff --git a/backend/booking/migrations/__init__.py b/backend/booking/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/booking/models.py b/backend/booking/models.py new file mode 100644 index 0000000..a352eb5 --- /dev/null +++ b/backend/booking/models.py @@ -0,0 +1,395 @@ +from decimal import Decimal +from django.db import models +from django.core.exceptions import ValidationError +from django.core.validators import MaxValueValidator, MinValueValidator +from django.conf import settings +from django.db.models import Max +from django.utils import timezone + +from trznice.models import SoftDeleteModel +from trznice.utils import truncate_to_minutes + + +#náměstí +class Square(SoftDeleteModel): + name = models.CharField(max_length=255, default="", null=False, blank=False) + + description = models.TextField(null=True, blank=True) + + street = models.CharField(max_length=255, default="Ulice není zadaná", null=False, blank=False) + city = models.CharField(max_length=255, default="Město není zadané", null=False, blank=False) + psc = models.PositiveIntegerField( + default=12345, + validators=[ + MaxValueValidator(99999), + MinValueValidator(10000) + ], + help_text="Zadejte platné PSČ (5 číslic)", + null=False, blank=False, + ) + + width = models.PositiveIntegerField(default=10) + height = models.PositiveIntegerField(default=10) + + #Grid Parameters + grid_rows = models.PositiveSmallIntegerField(default=60) + grid_cols = models.PositiveSmallIntegerField(default=45) + cellsize = models.PositiveIntegerField(default=10) + + image = models.ImageField(upload_to="squares-imgs/", blank=True, null=True) + + def clean(self): + if self.width <= 0 : + raise ValidationError("Šířka náměstí nemůže být menší nebo rovna nule.") + + if self.height <= 0: + raise ValidationError("Výška náměstí nemůže být menší nebo rovna nule.") + + if self.grid_rows <= 0: + raise ValidationError("Počet řádků mapy nemůže být menší nebo rovna nule.") + + if self.grid_cols <= 0: + raise ValidationError("Počet sloupců mapy nemůže být menší nebo rovna nule.") + + if self.cellsize <= 0: + raise ValidationError("Velikost mapové buňky nemůže být menší nebo rovna nule.") + + return super().clean() + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) + + def __str__(self): + return self.name + + def delete(self, *args, **kwargs): + for event in self.square_events.all(): + event.delete() # ✅ This triggers Event.delete() + super().delete(*args, **kwargs) + + +class Event(SoftDeleteModel): + """Celé náměstí + + Args: + models (args): w,h skutečné rozměry náměstí | x,y souřadnice levého horního rohu + + """ + name = models.CharField(max_length=255, null=False, blank=False) + description = models.TextField(blank=True, null=True) + + square = models.ForeignKey(Square, on_delete=models.CASCADE, related_name="square_events", null=False, blank=False) + + start = models.DateField() + end = models.DateField() + + price_per_m2 = models.DecimalField(max_digits=8, decimal_places=2, help_text="Cena za m² pro rezervaci", validators=[MinValueValidator(0)], null=False, blank=False) + + + image = models.ImageField(upload_to="squares-imgs/", blank=True, null=True) + + + def clean(self): + if not (self.start and self.end): + raise ValidationError("Datum začátku a konce musí být neprázdné.") + + # Remove truncate_to_minutes and timezone logic + if self.start >= self.end: + raise ValidationError("Datum začátku musí být před datem konce.") + + overlapping = Event.objects.exclude(id=self.id).filter( + square=self.square, + start__lt=self.end, + end__gt=self.start, + ) + if overlapping.exists(): + raise ValidationError("V tomto termínu už na daném náměstí probíhá jiná událost.") + + return super().clean() + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) + + def __str__(self): + return self.name + + def delete(self, *args, **kwargs): + for market_slot in self.event_marketSlots.all(): + market_slot.delete() + # self.event_marketSlots.all().update(is_deleted=True, deleted_at=timezone.now()) + # self.event_reservations.all().update(is_deleted=True, deleted_at=timezone.now()) + self.event_products.all().update(is_deleted=True, deleted_at=timezone.now()) + + return super().delete(*args, **kwargs) + + +class MarketSlot(SoftDeleteModel): + event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="event_marketSlots", null=False, blank=False) + + STATUS_CHOICES = [ + ("allowed", "Povoleno"), + ("blocked", "Zablokováno"), + ] + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="allowed") + number = models.PositiveSmallIntegerField(default=1, help_text="Pořadové číslo prodejního místa na svém Eventu", editable=False) + + base_size = models.FloatField(default=0, help_text="Základní velikost (m²)", validators=[MinValueValidator(0.0)], null=False, blank=False) + available_extension = models.FloatField(default=0, help_text="Možnost rozšíření (m²)", validators=[MinValueValidator(0.0)], null=False, blank=False) + + x = models.SmallIntegerField(default=0, blank=False, validators=[MinValueValidator(0)]) + y = models.SmallIntegerField(default=0, blank=False, validators=[MinValueValidator(0)]) + + width = models.PositiveIntegerField(default=0, blank=False, validators=[MinValueValidator(0)]) + height = models.PositiveIntegerField(default=0, blank=False, validators=[MinValueValidator(0)]) + + price_per_m2 = models.DecimalField( + default=Decimal("0.00"), + max_digits=8, + decimal_places=2, + validators=[MinValueValidator(0)], + help_text="Cena za m² pro toto prodejní místo. Neuvádět, pokud chcete nechat výchozí cenu za m² na tomto Eventu." + ) + + def clean(self): + if self.base_size <= 0: + raise ValidationError("Základní velikost prodejního místa musí být větší než nula.") + + return super().clean() + + def save(self, *args, **kwargs): + self.full_clean() + # TODO: Fix this hovno logic, kdy uyivatel zada 0, se nastavi cena. Vymyslet neco noveho + # If price_per_m2 is 0, use the event default + # if self.event and hasattr(self.event, 'price_per_m2'): + if self.price_per_m2 == 0 and self.event and hasattr(self.event, 'price_per_m2'): + self.price_per_m2 = self.event.price_per_m2 + + # Automatically assign next available number within the same event + if self._state.adding: + max_number = MarketSlot.objects.filter(event=self.event).aggregate(Max('number'))['number__max'] or 0 + self.number = max_number + 1 + + super().save(*args, **kwargs) + + def __str__(self): + return f"Prodejní místo {self.number} na {self.event}" + + def delete(self, *args, **kwargs): + + for reservation in self.reservations.all(): + reservation.delete() + # self.marketslot_reservations.all().update(is_deleted=True, deleted_at=timezone.now()) + + return super().delete(*args, **kwargs) + + + +class Reservation(SoftDeleteModel): + STATUS_CHOICES = [ + ("reserved", "Zarezervováno"), + ("cancelled", "Zrušeno"), + ] + + event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="event_reservations", null=False, blank=False) + market_slot = models.ForeignKey( + 'MarketSlot', + on_delete=models.CASCADE, + related_name='reservations', + null=True, + blank=True + ) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="user_reservations", null=False, blank=False) + + used_extension = models.FloatField(default=0 ,help_text="Použité rozšíření (m2)", validators=[MinValueValidator(0.0)]) + reserved_from = models.DateField(null=False, blank=False) + reserved_to = models.DateField(null=False, blank=False) + created_at = models.DateTimeField(auto_now_add=True) + + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="reserved") + note = models.TextField(blank=True, null=True) + + final_price = models.DecimalField( + default=0, + blank=False, + null=False, + max_digits=8, + decimal_places=2, + validators=[MinValueValidator(0)], + help_text="Cena vypočtena automaticky na zakladě ceny za m² prodejního místa a počtu dní rezervace." + ) + price = models.DecimalField( + max_digits=10, + decimal_places=2, + default=0, + null=False, + blank=False + ) + + event_products = models.ManyToManyField("product.EventProduct", related_name="reservations", blank=True) + + # Datails about checking + #TODO: Dodelat frontend + is_checked = models.BooleanField(default=False) + last_checked_at = models.DateTimeField(null=True, blank=True) + last_checked_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="reservations_checker" + ) + + def update_check_status(self): + last_check = self.checks.filter(is_deleted=False).order_by("-checked_at").first() + if last_check: + self.is_checked = True + self.last_checked_at = last_check.checked_at + self.last_checked_by = last_check.checker + else: + self.is_checked = False + self.last_checked_at = None + self.last_checked_by = None + + def calculate_price(self): + # Use market_slot width and height for area + if not self.event or not self.event.square: + raise ValidationError("Rezervace musí mít přiřazenou akci s náměstím.") + if not self.market_slot: + raise ValidationError("Rezervace musí mít přiřazené prodejní místo.") + + area = self.market_slot.width * self.market_slot.height + + price_per_m2 = None + if self.market_slot.price_per_m2 and self.market_slot.price_per_m2 > 0: + price_per_m2 = self.market_slot.price_per_m2 + else: + price_per_m2 = self.event.price_per_m2 + + if not price_per_m2 or price_per_m2 < 0: + raise ValidationError("Cena za m² není dostupná nebo je záporná.") + + # Calculate number of days + days = (self.reserved_to - self.reserved_from).days + 1 + + # Calculate final price using slot area and reserved days + final_price = Decimal(area) * Decimal(price_per_m2) * Decimal(days) + final_price = final_price.quantize(Decimal("0.01")) + return final_price + + def clean(self): + if not self.reserved_from or not self.reserved_to: + raise ValidationError("Datum rezervace nemůže být prázdný.") + + # Remove truncate_to_minutes and timezone logic + if self.reserved_from > self.reserved_to: + raise ValidationError("Datum začátku rezervace musí být dříve než její konec.") + if self.reserved_from == self.reserved_to: + raise ValidationError("Začátek a konec rezervace nemohou být stejné.") + + # Only check for overlapping reservations on the same market_slot + if self.market_slot: + overlapping = Reservation.objects.exclude(id=self.id).filter( + market_slot=self.market_slot, + status="reserved", + reserved_from__lt=self.reserved_to, + reserved_to__gt=self.reserved_from, + ) + else: + raise ValidationError("Rezervace musí mít v sobě prodejní místo (MarketSlot).") + + if overlapping.exists(): + raise ValidationError("Rezervace se překrývá s jinou rezervací na stejném místě.") + + # Check event bounds (date only) + if self.event: + event_start = self.event.start + event_end = self.event.end + + if self.reserved_from < event_start or self.reserved_to > event_end: + raise ValidationError("Rezervace musí být v rámci trvání akce.") + + if self.used_extension > self.market_slot.available_extension: + raise ValidationError("Požadované rozšíření je větší než možné rožšíření daného prodejního místa.") + + if self.market_slot and self.event != self.market_slot.event: + raise ValidationError(f"Prodejní místo {self.market_slot} není část této akce, musí být ze stejné akce jako rezervace.") + + if self.user: + if self.user.user_reservations.all().count() > 5: + raise ValidationError(f"{self.user} už má 5 rezervací, víc není možno rezervovat pro jednoho uživatele.") + else: + raise ValidationError("Rezervace musí mít v sobě uživatele.") + + if self.final_price == 0 or self.final_price is None: + self.final_price = self.calculate_price() + elif self.final_price < 0: + raise ValidationError("Cena nemůže být záporná.") + + return super().clean() + + + def save(self, *args, validate=True, **kwargs): + if validate: + self.full_clean() + + super().save(*args, **kwargs) + + def __str__(self): + return f"Rezervace {self.user} na event {self.event.name}" + + def delete(self, *args, **kwargs): + order = getattr(self, "order", None) + if order is not None: + order.delete() + + # Fix: Use a valid status value for MarketSlot + if self.market_slot: + event_end_date = self.market_slot.event.end + now_date = timezone.now().date() + if event_end_date > now_date: + self.market_slot.status = "allowed" + self.market_slot.save() + + self.checks.all().update(is_deleted=True, deleted_at=timezone.now()) + + return super().delete(*args, **kwargs) + + +class ReservationCheck(SoftDeleteModel): + reservation = models.ForeignKey( + Reservation, + on_delete=models.CASCADE, + related_name="checks" + ) + checker = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + related_name="performed_checks" + ) + checked_at = models.DateTimeField(auto_now_add=True) + + def clean(self): + # Check checker role + if not self.checker or not hasattr(self.checker, "role") or self.checker.role not in ["admin", "checker"]: + raise ValidationError("Uživatel není Kontrolor.") + + # Validate reservation existence (safe check) + if not Reservation.objects.filter(pk=self.reservation_id).exists(): + raise ValidationError("Neplatné ID Rezervace.") + + super().clean() + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) + + def delete(self, *args, **kwargs): + self.is_deleted = True + self.deleted_at = timezone.now() + self.save() + from .signals import update_reservation_check_status + # Simulate post_delete behavior + update_reservation_check_status(sender=ReservationCheck, instance=self) diff --git a/backend/booking/serializers.py b/backend/booking/serializers.py new file mode 100644 index 0000000..3f323dc --- /dev/null +++ b/backend/booking/serializers.py @@ -0,0 +1,602 @@ +from rest_framework import serializers +from datetime import timedelta +from booking.models import Event, MarketSlot +import logging +from decimal import Decimal, ROUND_HALF_UP, InvalidOperation +try: + from commerce.serializers import PriceCalculationSerializer +except ImportError: + PriceCalculationSerializer = None + +from trznice.utils import RoundedDateTimeField +from .models import Event, MarketSlot, Reservation, Square, ReservationCheck +from account.models import CustomUser +from product.serializers import EventProductSerializer + +logger = logging.getLogger(__name__) + + +#----------------------SHORT SERIALIZERS--------------------------------- + +class EventShortSerializer(serializers.ModelSerializer): + class Meta: + model = Square + fields = ["id", "name"] + extra_kwargs = { + "id": {"read_only": True}, + "name": {"read_only": True, "help_text": "Název náměstí"} + } + +class UserShortSerializer(serializers.ModelSerializer): + class Meta: + model = CustomUser + fields = ["id", "username"] + extra_kwargs = { + "id": {"read_only": True}, + "username": {"read_only": True, "help_text": "username uživatele"} + } + +class SquareShortSerializer(serializers.ModelSerializer): + class Meta: + model = Square + fields = ["id", "name"] + extra_kwargs = { + "id": {"read_only": True}, + "name": {"read_only": True, "help_text": "Název náměstí"} + } + +class ReservationShortSerializer(serializers.ModelSerializer): + user = UserShortSerializer(read_only=True) + event = EventShortSerializer(read_only=True) + + class Meta: + model = Reservation + fields = ["id", "user", "event"] + extra_kwargs = { + "id": {"read_only": True}, + "user": {"read_only": True, "help_text": "Majitel rezervace"}, + "event": {"read_only": True, "help_text": "Akce na které je vytvořena rezervace"} + } + +#------------------------------------------------------------------------ + + + + +#------------------------NORMAL SERIALIZERS------------------------------ + +class ReservationCheckSerializer(serializers.ModelSerializer): + reservation = serializers.PrimaryKeyRelatedField( + queryset=Reservation.objects.all(), + write_only=True, + help_text="ID rezervace, která se kontroluje." + ) + reservation_info = ReservationShortSerializer(source="reservation", read_only=True) + + checker = serializers.HiddenField(default=serializers.CurrentUserDefault()) + checker_info = UserShortSerializer(source="checker", read_only=True) + + class Meta: + model = ReservationCheck + fields = [ + "id", "reservation", "reservation_info", + "checker", "checker_info", "checked_at" + ] + read_only_fields = ["id", "checked_at"] + + def validate_reservation(self, value): + if value.status != "reserved": + raise serializers.ValidationError("Rezervaci lze kontrolovat pouze pokud je ve stavu 'reserved'.") + return value + + def validate_checker(self, value): + user = self.context["request"].user + if not user.is_staff and value != user: + raise serializers.ValidationError("Pouze administrátor může nastavit jiného uživatele jako kontrolora.") + return value + + +class ReservationSerializer(serializers.ModelSerializer): + reserved_from = serializers.DateField() + reserved_to = serializers.DateField() + + event = EventShortSerializer(read_only=True) + user = UserShortSerializer(read_only=True) + market_slot = serializers.PrimaryKeyRelatedField( + queryset=MarketSlot.objects.filter(is_deleted=False), required=True + ) + + last_checked_by = UserShortSerializer(read_only=True) + + class Meta: + model = Reservation + fields = [ + "id", "market_slot", + "used_extension", "reserved_from", "reserved_to", + "created_at", "status", "note", "final_price", + "event", "user", "is_checked", "last_checked_by", "last_checked_at" + ] + read_only_fields = ["id", "created_at", "is_checked", "last_checked_by", "last_checked_at"] + extra_kwargs = { + "event": {"help_text": "ID (Event), ke které rezervace patří", "required": True}, + "market_slot": {"help_text": "ID konkrétního prodejního místa (MarketSlot)", "required": True}, + "user": {"help_text": "ID a název uživatele, který rezervaci vytváří", "required": True}, + "used_extension": {"help_text": "Velikost rozšíření v m², které chce uživatel využít", "required": True}, + "reserved_from": {"help_text": "Datum a čas začátku rezervace", "required": True}, + "reserved_to": {"help_text": "Datum a čas konce rezervace", "required": True}, + "status": {"help_text": "Stav rezervace (reserved / cancelled)", "required": False, "default": "reserved"}, + "note": {"help_text": "Poznámka k rezervaci (volitelné)", "required": False}, + "final_price": {"help_text": "Cena za Rezervaci, počítá se podle plochy prodejního místa a počtů dní.", "required": False, "default": 0}, + + "is_checked": {"help_text": "Stav je True, pokud již byla provedena aspoň jedna kontrola.", "required": False, "read_only": True}, + "last_checked_by": {"help_text": "Kontrolor, který provedl poslední kontrolu.", "required": False, "read_only": True}, + "last_checked_at": {"help_text": "Čas kdy byla provedena poslední kontrola.", "required": False, "read_only": True} + } + + def to_internal_value(self, data): + # Accept both "market_slot" and legacy "marketSlot" keys for compatibility + if "marketSlot" in data and "market_slot" not in data: + data["market_slot"] = data["marketSlot"] + # Debug: log incoming data for troubleshooting + logger.debug(f"ReservationSerializer.to_internal_value input data: {data}") + return super().to_internal_value(data) + + + def to_internal_value(self, data): + # Accept both "market_slot" and legacy "marketSlot" keys for compatibility + if "marketSlot" in data and "market_slot" not in data: + data["market_slot"] = data["marketSlot"] + # Debug: log incoming data for troubleshooting + logger.debug(f"ReservationSerializer.to_internal_value input data: {data}") + return super().to_internal_value(data) + + def validate(self, data): + logger.debug(f"ReservationSerializer.validate market_slot: {data.get('market_slot')}, event: {data.get('event')}") + # Get the event object from the provided event id (if present) + event_id = self.initial_data.get("event") + if event_id: + try: + event = Event.objects.get(pk=event_id) + data["event"] = event + except Event.DoesNotExist: + raise serializers.ValidationError({"event": "Zadaná akce (event) neexistuje."}) + else: + event = data.get("event") + + market_slot = data.get("market_slot") + # --- FIX: Ensure event is set before permission check in views --- + if event is None and market_slot is not None: + event = market_slot.event + data["event"] = event + logger.debug(f"ReservationSerializer.validate auto-filled event from market_slot: {event}") + + + + user = data.get("user") + request_user = self.context["request"].user if "request" in self.context else None + + # If user is not specified, use the logged-in user + if user is None and request_user is not None: + user = request_user + data["user"] = user + + # If user is specified and differs from logged-in user, check permissions + if user is not None and request_user is not None and user != request_user: + if request_user.role not in ["admin", "cityClerk", "squareManager"]: + raise serializers.ValidationError("Pouze administrátor, úředník nebo správce tržiště může vytvářet rezervace pro jiné uživatele.") + + + + if user is None: + raise serializers.ValidationError("Rezervace musí mít přiřazeného uživatele.") + if user.user_reservations.filter(status="reserved").count() >= 5: + raise serializers.ValidationError("Uživatel už má 5 aktivních rezervací.") + + reserved_from = data.get("reserved_from") + reserved_to = data.get("reserved_to") + used_extension = data.get("used_extension", 0) + final_price = data.get("final_price", 0) + + if "status" in data: + if self.instance: # update + if data["status"] != self.instance.status and user.role not in ["admin", "cityClerk"]: + raise serializers.ValidationError({ + "status": "Pouze administrátor nebo úředník může upravit status rezervace." + }) + else: + data["status"] = "reserved" + + privileged_roles = ["admin", "cityClerk"] + + # Define max allowed price based on model's decimal constraints (8 digits, 2 decimal places) + MAX_FINAL_PRICE = Decimal("999999.99") + + if user and getattr(user, "role", None) in privileged_roles: + # 🧠 Automatický výpočet ceny rezervace pokud není zadána + if not final_price or final_price == 0: + market_slot = data.get("market_slot") + event = data.get("event") + reserved_from = data.get("reserved_from") + reserved_to = data.get("reserved_to") + used_extension = data.get("used_extension", 0) + # --- Prefer PriceCalculationSerializer if available --- + if PriceCalculationSerializer: + try: + price_serializer = PriceCalculationSerializer(data={ + "market_slot": market_slot.id if market_slot else None, + "used_extension": used_extension, + "reserved_from": reserved_from, + "reserved_to": reserved_to, + "event": event.id if event else None, + "user": user.id if user else None, + }) + price_serializer.is_valid(raise_exception=True) + calculated_price = price_serializer.validated_data.get("final_price") + if calculated_price is not None: + try: + # Always quantize to two decimals + decimal_price = Decimal(str(calculated_price)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + # Clamp value to max allowed and raise error if exceeded + if decimal_price > MAX_FINAL_PRICE: + logger.error(f"ReservationSerializer: final_price ({decimal_price}) exceeds max allowed ({MAX_FINAL_PRICE})") + data["final_price"] = MAX_FINAL_PRICE + raise serializers.ValidationError({"final_price": f"Cena je příliš vysoká, maximálně {MAX_FINAL_PRICE} Kč."}) + else: + data["final_price"] = decimal_price + except (InvalidOperation, TypeError, ValueError): + raise serializers.ValidationError("Výsledná cena není platné číslo.") + else: + raise serializers.ValidationError("Výpočet ceny selhal.") + except Exception as e: + logger.error(f"PriceCalculationSerializer failed: {e}", exc_info=True) + market_slot = data.get("market_slot") + event = data.get("event") + reserved_from = data.get("reserved_from") + reserved_to = data.get("reserved_to") + used_extension = data.get("used_extension", 0) + price_per_m2 = data.get("price_per_m2") + if price_per_m2 is None: + if market_slot and hasattr(market_slot, "price_per_m2"): + price_per_m2 = market_slot.price_per_m2 + elif event and hasattr(event, "price_per_m2"): + price_per_m2 = event.price_per_m2 + else: + raise serializers.ValidationError("Cena za m² není dostupná.") + base_size = getattr(market_slot, "base_size", None) + if base_size is None: + raise serializers.ValidationError("Základní velikost (base_size) není dostupná.") + duration_days = (reserved_to - reserved_from).days + base_size_decimal = Decimal(str(base_size)) + used_extension_decimal = Decimal(str(used_extension)) + duration_days_decimal = Decimal(str(duration_days)) + price_per_m2_decimal = Decimal(str(price_per_m2)) + calculated_price = duration_days_decimal * (price_per_m2_decimal * (base_size_decimal + used_extension_decimal)) + try: + decimal_price = Decimal(str(calculated_price)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + # Clamp value to max allowed and raise error if exceeded + if decimal_price > MAX_FINAL_PRICE: + logger.error(f"ReservationSerializer: final_price ({decimal_price}) exceeds max allowed ({MAX_FINAL_PRICE})") + data["final_price"] = MAX_FINAL_PRICE + raise serializers.ValidationError({"final_price": f"Cena je příliš vysoká, maximálně {MAX_FINAL_PRICE} Kč."}) + else: + data["final_price"] = decimal_price + except (InvalidOperation, TypeError, ValueError): + raise serializers.ValidationError("Výsledná cena není platné číslo.") + else: + price_per_m2 = data.get("price_per_m2") + if price_per_m2 is None: + if market_slot and hasattr(market_slot, "price_per_m2"): + price_per_m2 = market_slot.price_per_m2 + elif event and hasattr(event, "price_per_m2"): + price_per_m2 = event.price_per_m2 + else: + raise serializers.ValidationError("Cena za m² není dostupná.") + resolution = event.square.cellsize if event and hasattr(event, "square") else 1 + width = getattr(market_slot, "width", 1) + height = getattr(market_slot, "height", 1) + # If you want to include used_extension, add it to area + area_m2 = Decimal(width) * Decimal(height) * Decimal(resolution) * Decimal(resolution) + duration_days = (reserved_to - reserved_from).days + + price_per_m2_decimal = Decimal(str(price_per_m2)) + calculated_price = Decimal(duration_days) * area_m2 * price_per_m2_decimal + try: + decimal_price = Decimal(str(calculated_price)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + # Clamp value to max allowed and raise error if exceeded + if decimal_price > MAX_FINAL_PRICE: + logger.error(f"ReservationSerializer: final_price ({decimal_price}) exceeds max allowed ({MAX_FINAL_PRICE})") + data["final_price"] = MAX_FINAL_PRICE + raise serializers.ValidationError({"final_price": f"Cena je příliš vysoká, maximálně {MAX_FINAL_PRICE} Kč."}) + else: + data["final_price"] = decimal_price + except (InvalidOperation, TypeError, ValueError): + raise serializers.ValidationError("Výsledná cena není platné číslo.") + else: + if self.instance: # update + if final_price != self.instance.final_price and (not user or user.role not in privileged_roles): + raise serializers.ValidationError({ + "final_price": "Pouze administrátor nebo úředník může upravit finální cenu." + }) + else: # create + if not user or user.role not in privileged_roles: + raise serializers.ValidationError({ + "final_price": "Pouze administrátor nebo úředník může nastavit finální cenu." + }) + if data.get("final_price") is not None: + try: + decimal_price = Decimal(str(data["final_price"])).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + # Clamp value to max allowed and raise error if exceeded + if decimal_price > MAX_FINAL_PRICE: + logger.error(f"ReservationSerializer: final_price ({decimal_price}) exceeds max allowed ({MAX_FINAL_PRICE})") + data["final_price"] = MAX_FINAL_PRICE + raise serializers.ValidationError({"final_price": f"Cena je příliš vysoká, maximálně {MAX_FINAL_PRICE} Kč."}) + data["final_price"] = decimal_price + except (InvalidOperation, TypeError, ValueError): + raise serializers.ValidationError("Výsledná cena není platné číslo.") + if data.get("final_price") < 0: + raise serializers.ValidationError("Cena za m² nemůže být záporná.") + else: + # Remove final_price if not privileged + data.pop("final_price", None) + + if reserved_from >= reserved_to: + raise serializers.ValidationError("Datum začátku rezervace musí být dříve než její konec.") + + if reserved_from < event.start or reserved_to > event.end: + raise serializers.ValidationError("Rezervace musí být v rámci trvání akce.") + + overlapping = None + if market_slot: + if market_slot.event != event: + raise serializers.ValidationError("Prodejní místo nepatří do dané akce.") + + if used_extension > market_slot.available_extension: + raise serializers.ValidationError("Požadované rozšíření překračuje dostupné rozšíření.") + + overlapping = Reservation.objects.exclude(id=self.instance.id if self.instance else None).filter( + event=event, + market_slot=market_slot, + reserved_from__lt=reserved_to, + reserved_to__gt=reserved_from, + status="reserved" + ) + + if overlapping is not None and overlapping.exists(): + logger.debug(f"ReservationSerializer.validate: Found overlapping reservations for market_slot {market_slot.id} in event {event.id}") + raise serializers.ValidationError("Rezervace se překrývá s jinou rezervací na stejném místě.") + + return data + +class ReservationAvailabilitySerializer(serializers.Serializer): + event_id = serializers.IntegerField() + market_slot_id = serializers.IntegerField() + reserved_from = serializers.DateField() + reserved_to = serializers.DateField() + + class Meta: + model = Reservation + fields = ["event", "market_slot", "reserved_from", "reserved_to"] + extra_kwargs = { + "event": {"help_text": "ID of the event"}, + "market_slot": {"help_text": "ID of the market slot"}, + "reserved_from": {"help_text": "Start date of the reservation"}, + "reserved_to": {"help_text": "End date of the reservation"}, + } + + def validate(self, data): + event_id = data.get("event_id") + market_slot_id = data.get("market_slot_id") + reserved_from = data.get("reserved_from") + reserved_to = data.get("reserved_to") + + if reserved_from >= reserved_to: + raise serializers.ValidationError("Konec rezervace musí být po začátku.") + + # Zkontroluj existenci Eventu a Slotu + try: + event = Event.objects.get(id=event_id) + except Event.DoesNotExist: + raise serializers.ValidationError("Událost neexistuje.") + + try: + market_slot = MarketSlot.objects.get(id=market_slot_id) + except MarketSlot.DoesNotExist: + raise serializers.ValidationError("Slot neexistuje.") + + # Zkontroluj status slotu + if market_slot.status == "blocked": + raise serializers.ValidationError("Tento slot je zablokovaný správcem.") + + # Zkontroluj, že datumy spadají do rozsahu události + if reserved_from < event.date_from or reserved_to > event.date_to: + raise serializers.ValidationError("Vybrané datumy nespadají do trvání akce.") + + # Zkontroluj, jestli už neexistuje kolizní rezervace + conflict = Reservation.objects.filter( + event=event, + market_slot=market_slot, + reserved_from__lt=reserved_to, + reserved_to__gt=reserved_from, + status="reserved" + ).exists() + + if conflict: + raise serializers.ValidationError("Tento slot je v daném termínu již rezervován.") + + return data + +#--- Reservation end ---- + + +class MarketSlotSerializer(serializers.ModelSerializer): + class Meta: + model = MarketSlot + fields = [ + "id", "event", "number", "status", + "base_size", "available_extension", + "x", "y", "width", "height", + "price_per_m2" + ] + + read_only_fields = ["id", "number"] + extra_kwargs = { + "event": {"help_text": "ID akce (Event), ke které toto místo patří", "required": True}, + "number": {"help_text": "Pořadové číslo prodejního místa u Akce, ke které toto místo patří", "required": False}, + "status": {"help_text": "Stav prodejního místa", "required": False}, + "base_size": {"help_text": "Základní velikost (m²)", "required": True}, + "available_extension": {"help_text": "Možnost rozšíření (m²)", "required": False, "default": 0}, + "x": {"help_text": "X souřadnice levého horního rohu", "required": True}, + "y": {"help_text": "Y souřadnice levého horního rohu", "required": True}, + "width": {"help_text": "Šířka Slotu", "required": True}, + "height": {"help_text": "Výška Slotu", "required": True}, + "price_per_m2": {"help_text": "Cena za m² tohoto místa", "required": False, "default": 0}, + } + + def validate_base_size(self, value): + if value <= 0: + raise serializers.ValidationError("Základní velikost musí být větší než nula.") + return value + + def validate(self, data): + price_per_m2 = data.setdefault("price_per_m2", 0) + if price_per_m2 < 0: + raise serializers.ValidationError("Cena za m² nemůže být záporná.") + + if data.setdefault("available_extension", 0) < 0: + raise serializers.ValidationError("Velikost možného rozšíření musí být větší než nula.") + + if data.get("width", 0) <= 0 or data.get("height", 0) <= 0: + raise serializers.ValidationError("Šířka a výška místa musí být větší než nula.") + + return data + + +class EventSerializer(serializers.ModelSerializer): + square = SquareShortSerializer(read_only=True) + square_id = serializers.PrimaryKeyRelatedField( + queryset=Square.objects.all(), source="square", write_only=True + ) + + + market_slots = MarketSlotSerializer(many=True, read_only=True, source="event_marketSlots") + event_products = EventProductSerializer(many=True, read_only=True) + + start = serializers.DateField() + end = serializers.DateField() + + class Meta: + model = Event + fields = [ + "id", "name", "description", "start", "end", "price_per_m2", "image", "market_slots", "event_products", + "square", # nested read-only + "square_id" # required in POST/PUT + ] + read_only_fields = ["id"] + extra_kwargs = { + "name": {"help_text": "Název události", "required": True}, + "description": {"help_text": "Popis události", "required": False}, + "start": {"help_text": "Datum a čas začátku události", "required": True}, + "end": {"help_text": "Datum a čas konce události", "required": True}, + "price_per_m2": {"help_text": "Cena za m² pro rezervaci", "required": True}, + "image": {"help_text": "Obrázek nebo plán náměstí", "required": False, "allow_null": True}, + + "market_slots": {"help_text": "Seznam prodejních míst vytvořených v rámci této události", "required": False}, + "event_products": {"help_text": "Seznam povolených zboží k prodeji v rámci této události", "required": False}, + + "square": {"help_text": "Náměstí, na kterém se akce koná (jen ke čtení)", "required": False}, + "square_id": {"help_text": "ID Náměstí, na kterém se akce koná (jen ke zápis)", "required": True}, + } + + def validate(self, data): + start = data.get("start") + end = data.get("end") + square = data.get("square") + + if not start or not end or not square: + raise serializers.ValidationError("Pole start, end a square musí být vyplněné.") + + if start >= end: + raise serializers.ValidationError("Datum začátku musí být před datem konce.") + + if data.get("price_per_m2", 0) <= 0: + raise serializers.ValidationError("Cena za m² plochy pro rezervaci musí být větší než 0.") + + overlapping = Event.objects.exclude(id=self.instance.id if self.instance else None).filter( + square=square, + start__lt=end, + end__gt=start, + ) + + if overlapping.exists(): + raise serializers.ValidationError("V tomto termínu už na daném náměstí probíhá jiná událost.") + + return data + + +class SquareSerializer(serializers.ModelSerializer): + + image = serializers.ImageField(required=False, allow_null=True) # Ensure DRF handles image upload + + class Meta: + model = Square + fields = [ + "id", "name", "description", "street", "city", "psc", + "width", "height", "grid_rows", "grid_cols", "cellsize", + "image" + ] + read_only_fields = ["id"] + extra_kwargs = { + "name": {"help_text": "Název náměstí", "required": True}, + "description": {"help_text": "Popis náměstí", "required": False}, + "street": {"help_text": "Ulice, kde se náměstí nachází", "required": False}, + "city": {"help_text": "Město, kde se náměstí nachází", "required": False}, + "psc": {"help_text": "PSČ (5 číslic)", "required": False}, + "width": {"help_text": "Šířka náměstí v metrech", "required": True}, + "height": {"help_text": "Výška náměstí v metrech", "required": True}, + "grid_rows": {"help_text": "Počet řádků gridu", "required": True}, + "grid_cols": {"help_text": "Počet sloupců gridu", "required": True}, + "cellsize": {"help_text": "Velikost buňky gridu v pixelech", "required": True}, + "image": {"help_text": "Obrázek / mapa náměstí", "required": False}, + } + +#----------------------------------------------------------------------- +class ReservedDaysSerializer(serializers.Serializer): + market_slot_id = serializers.IntegerField() + reserved_days = serializers.ListField(child=serializers.DateField(), read_only=True) + + def to_representation(self, instance): + # Accept instance as dict or int + if isinstance(instance, dict): + market_slot_id = instance.get("market_slot_id") + else: + market_slot_id = instance # assume int + + try: + market_slot = MarketSlot.objects.get(id=market_slot_id) + except MarketSlot.DoesNotExist: + return {"market_slot_id": market_slot_id, "reserved_days": []} + + # Get all reserved days for this slot, return each day individually + reservations = Reservation.objects.filter( + market_slot_id=market_slot_id, + status="reserved" + ) + reserved_days = set() + for reservation in reservations: + current = reservation.reserved_from + end = reservation.reserved_to + # Convert to date if it's a datetime + if hasattr(current, "date"): + current = current.date() + if hasattr(end, "date"): + end = end.date() + # Include both start and end dates + while current <= end: + reserved_days.add(current) + current += timedelta(days=1) + + # Return reserved days as a sorted list of individual dates + return { + "market_slot_id": market_slot_id, + "reserved_days": sorted(reserved_days) + } diff --git a/backend/booking/signals.py b/backend/booking/signals.py new file mode 100644 index 0000000..01897fd --- /dev/null +++ b/backend/booking/signals.py @@ -0,0 +1,9 @@ +from django.db.models.signals import post_save, post_delete +from django.dispatch import receiver +from booking.models import ReservationCheck + +@receiver([post_save, post_delete], sender=ReservationCheck) +def update_reservation_check_status(sender, instance, **kwargs): + reservation = instance.reservation + reservation.update_check_status() + reservation.save(update_fields=["is_checked", "last_checked_at", "last_checked_by"]) diff --git a/backend/booking/tasks.py b/backend/booking/tasks.py new file mode 100644 index 0000000..756819a --- /dev/null +++ b/backend/booking/tasks.py @@ -0,0 +1,116 @@ +from celery import shared_task +from celery.utils.log import get_task_logger +from django.conf import settings +from rest_framework.response import Response +from django.utils import timezone +from datetime import timedelta, datetime +from django.apps import apps + +from trznice.models import SoftDeleteModel +from booking.models import Reservation, MarketSlot +from commerce.models import Order +from account.tasks import send_email_with_context + +logger = get_task_logger(__name__) + +@shared_task +def test_celery_task(): + logger.info("✅ Test task executed successfully!") + return "Hello from Celery!" + + +def _validate_days_input(years=None, days=None): + if years is not None: + return years * 365 if years > 0 else 365 + if days is not None: + return days if days > 0 else 365 + return 365 # default fallback + +@shared_task +def hard_delete_soft_deleted_records_task(years=None, days=None): + """ + Hard delete všech objektů, které jsou soft-deleted (is_deleted=True) + a zároveň byly označeny jako smazané (deleted_at) před více než zadaným časovým obdobím. + Jako vstupní argument může být zadán počet let nebo dnů, podle kterého se data skartují. + """ + + total_days = _validate_days_input(years, days) + + time_period = timezone.now() - timedelta(days=total_days) + + # Pro všechny modely, které dědí z SoftDeleteModel, smaž staré smazané záznamy + for model in apps.get_models(): + if not issubclass(model, SoftDeleteModel): + continue + if not model._meta.managed or model._meta.abstract: + continue + if not hasattr(model, "all_objects"): + continue + + # Filtrování soft-deleted a starých + deleted_qs = model.all_objects.filter(is_deleted=True, deleted_at__lt=time_period) + count = deleted_qs.count() + + # Pokud budeme chtit použit custom logiku + # for obj in deleted_qs: + # obj.hard_delete() + + deleted_qs.delete() + + if count > 0: + logger.info(f"Hard deleted {count} records from {model.__name__}") + + return "Successfully completed hard_delete_soft_deleted_records_task" + + +@shared_task +def cancel_unpayed_reservations_task(minutes=30): + """ + Smaže Rezervace podle Objednávky, pokud ta nebyla zaplacena v době 30 minut. Tím se uvolní Prodejní Místa pro nové rezervace. + Jako vstupní argument může být zadán počet minut, podle kterého nezaplacená rezervaace bude stornovana. + """ + if minutes <= 0: + minutes = 30 + + cutoff_time = timezone.now() - timedelta(minutes=minutes) + + orders_qs = Order.objects.select_related("user", "reservation__event").filter( + status="pending", + created_at__lte=cutoff_time, + payed_at__isnull=True + ) + + count = orders_qs.count() + + for order in orders_qs: + order.status = "cancelled" + send_email_with_context( + recipients=order.user.email, + subject="Stornování objednávky", + message=( + f"Vaše objednávka {order.order_number} má rezervaci prodejního místa " + f"na akci {order.reservation.event} a byla stornována po {minutes} minutách nezaplacení." + ) + ) + order.save() + + if count > 0: + logger.info(f"Canceled {count} unpaid orders and released their slots.") + + return "Successfully completed delete_unpayed_reservations_task" + + +# @shared_task +# def delete_old_reservations_task(): +# """ +# Smaže rezervace starší než 10 let počítané od začátku příštího roku. +# """ +# now = timezone.now() +# next_january_1 = datetime(year=now.year + 1, month=1, day=1, tzinfo=timezone.get_current_timezone()) +# cutoff_date = next_january_1 - timedelta(days=365 * 10) + +# deleted, _ = Reservation.objects.filter(created__lt=cutoff_date).delete() +# print(f"Deleted {deleted} old reservations.") + + # return "Successfully completed delete_old_reservations_task" + diff --git a/backend/booking/tests.py b/backend/booking/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/booking/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/booking/urls.py b/backend/booking/urls.py new file mode 100644 index 0000000..929983c --- /dev/null +++ b/backend/booking/urls.py @@ -0,0 +1,16 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import EventViewSet, ReservationViewSet, SquareViewSet, MarketSlotViewSet, ReservationAvailabilityCheckView, ReservedDaysView, ReservationCheckViewSet + +router = DefaultRouter() +router.register(r'events', EventViewSet, basename='event') +router.register(r'reservations', ReservationViewSet, basename='reservation') +router.register(r'squares', SquareViewSet, basename='square') +router.register(r'market-slots', MarketSlotViewSet, basename='market-slot') +router.register(r'checks', ReservationCheckViewSet, basename='reservation-checks') + +urlpatterns = [ + path('', include(router.urls)), + path('reservations/check', ReservationAvailabilityCheckView.as_view(), name='event-reservation-check'), + path('reserved-days-check/', ReservedDaysView.as_view(), name='reserved-days'), +] \ No newline at end of file diff --git a/backend/booking/views.py b/backend/booking/views.py new file mode 100644 index 0000000..1933251 --- /dev/null +++ b/backend/booking/views.py @@ -0,0 +1,257 @@ +from rest_framework import viewsets, filters +from rest_framework.decorators import api_view, permission_classes +from rest_framework.response import Response +from rest_framework import status +from rest_framework.parsers import MultiPartParser, FormParser + +from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiResponse, OpenApiExample + +from .models import Event, Reservation, MarketSlot, Square, ReservationCheck +from .serializers import EventSerializer, ReservationSerializer, MarketSlotSerializer, SquareSerializer, ReservationAvailabilitySerializer, ReservedDaysSerializer, ReservationCheckSerializer +from .filters import EventFilter, ReservationFilter + +from rest_framework.permissions import IsAuthenticated +from rest_framework.exceptions import PermissionDenied +from django.core.exceptions import ObjectDoesNotExist +from rest_framework.views import APIView + +from account.permissions import * + +import logging + +import logging + +from account.tasks import send_email_verification_task + + +@extend_schema( + tags=["Square"], + description=( + "Správa náměstí – vytvoření, aktualizace a výpis s doplňkovými informacemi (`quarks`) " + "a připojenými eventy. Možno filtrovat podle města, PSČ a velikosti.\n\n" + "🔍 **Fulltextové vyhledávání (`?search=`)** prohledává následující pole:\n" + "- název náměstí (`name`)\n" + "- popis (`description`)\n" + "- ulice (`street`)\n" + "- město (`city`)\n\n" + "**Příklady:** `?search=Ostrava`, `?search=Hlavní třída`" + ) +) +class SquareViewSet(viewsets.ModelViewSet): + queryset = Square.objects.prefetch_related("square_events").all().order_by("name") + serializer_class = SquareSerializer + parser_classes = [MultiPartParser, FormParser] # Accept image uploads + filter_backends = [DjangoFilterBackend, filters.OrderingFilter, filters.SearchFilter] + filterset_fields = ["city", "psc", "width", "height"] + ordering_fields = ["name", "width", "height"] + search_fields = [ + "name", # název náměstí + "description", # popis + "street", # ulice + "city", # město + # "psc" je číslo, obvykle do search_fields nepatří, ale můžeš ho filtrovat přes filterset_fields + ] + + permission_classes = [RoleAllowed("admin", "squareManager")] + + def get_queryset(self): + return super().get_queryset() + + + +@extend_schema( + tags=["Event"], + description=( + "Základní operace pro správu událostí (Event). Lze filtrovat podle času, města a velikosti náměstí.\n\n" + "🔍 **Fulltextové vyhledávání (`?search=`)** prohledává:\n" + "- název události (`name`)\n" + "- popis (`description`)\n" + "- název náměstí (`square.name`)\n" + "- město (`square.city`)\n" + "- popis náměstí (`square.description`)\n" + "- ulice (`square.street`)\n\n" + "**Příklady:** `?search=Jarmark`, `?search=Ostrava`, `?search=Masarykovo`" + ) +) +class EventViewSet(viewsets.ModelViewSet): + queryset = Event.objects.prefetch_related("event_marketSlots", "event_products").all().order_by("start") + serializer_class = EventSerializer + filter_backends = [DjangoFilterBackend, filters.OrderingFilter, filters.SearchFilter] + filterset_class = EventFilter + ordering_fields = ["start", "end", "price_per_m2"] + search_fields = [ + "name", # název události + "description", # popis události + "square__name", # název náměstí + "square__city", # město + "square__description", # popis náměstí (volitelný) + "square__street", # ulice + ] + + permission_classes = [RoleAllowed("admin", "squareManager")] + + +@extend_schema( + tags=["MarketSlot"], + description="Vytváření, aktualizace a mazání konkrétních prodejních míst pro události." +) +class MarketSlotViewSet(viewsets.ModelViewSet): + # queryset = MarketSlot.objects.select_related("event").all().order_by("event") + queryset = MarketSlot.objects.all().order_by("event") + serializer_class = MarketSlotSerializer + filter_backends = [DjangoFilterBackend, filters.OrderingFilter] + filterset_fields = ["event", "status"] + ordering_fields = ["price_per_m2", "x", "y"] + + permission_classes = [RoleAllowed("admin", "squareManager")] + + +@extend_schema( + tags=["Reservation"], + description=( + "Správa rezervací – vytvoření, úprava a výpis. Filtrování podle eventu, statusu, uživatele atd." + ) +) +class ReservationViewSet(viewsets.ModelViewSet): + queryset = Reservation.objects.all() + serializer_class = ReservationSerializer + filter_backends = [DjangoFilterBackend, filters.OrderingFilter] + filterset_class = ReservationFilter + ordering_fields = ["reserved_from", "reserved_to", "created_at"] + search_fields = [ + "event__name", + "event__square__name", + "event__square__city", + "note", + "user__email", + "user__first_name", + "user__last_name", + ] + permission_classes = [RoleAllowed("admin", "squareManager", "seller")] + + def get_queryset(self): + # queryset = Reservation.objects.select_related("event", "marketSlot", "user").prefetch_related("event_products").order_by("-created_at") + queryset = Reservation.objects.all().order_by("-created_at") + user = self.request.user + if hasattr(user, "role") and user.role == "seller": + return queryset.filter(user=user) + return queryset + + # Optionally, override create() to add logging or debug info + def create(self, request, *args, **kwargs): + logger = logging.getLogger(__name__) + logger.debug(f"Reservation create POST data: {request.data}") + try: + return super().create(request, *args, **kwargs) + except Exception as e: + logger.error(f"Error in ReservationViewSet.create: {e}", exc_info=True) + raise + + def perform_create(self, serializer): + self._check_blocked_permission(serializer.validated_data) + serializer.save() + + def perform_update(self, serializer): + self._check_blocked_permission(serializer.validated_data) + serializer.save() + + def _check_blocked_permission(self, data): + # FIX: Always get the MarketSlot instance, not just the ID + # Accept both "market_slot" (object or int) and "marketSlot" (legacy) + slot = data.get("market_slot") or data.get("marketSlot") + + # If slot is a MarketSlot instance, get its id + if hasattr(slot, "id"): + slot_id = slot.id + else: + slot_id = slot + + if not isinstance(slot_id, int): + raise PermissionDenied("Neplatné ID prodejního místa.") + + try: + market_slot = MarketSlot.objects.get(pk=slot_id) + except ObjectDoesNotExist: + raise PermissionDenied("Prodejní místo nebylo nalezeno.") + + if market_slot.status == "blocked": + user = self.request.user + if getattr(user, "role", None) not in ["admin", "clerk"]: + raise PermissionDenied("Toto prodejní místo je zablokované.") + +@extend_schema( + tags=["Reservation"], + summary="Check reservation availability", + request=ReservationAvailabilitySerializer, + responses={200: OpenApiExample( + 'Availability Response', + value={"available": True}, + response_only=True + )} +) +class ReservationAvailabilityCheckView(APIView): + def post(self, request): + serializer = ReservationAvailabilitySerializer(data=request.data) + if serializer.is_valid(): + return Response({"available": True}, status=status.HTTP_200_OK) + return Response({"available": False}, status=status.HTTP_200_OK) + +logger = logging.getLogger(__name__) + +@extend_schema( + tags=["Reservation"], + summary="Get reserved days for a market slot in an event", + description=( + "Returns a list of reserved days for a given event and market slot. " + "Useful for visualizing slot occupancy and preventing double bookings. " + "Provide `event_id` and `market_slot_id` as query parameters." + ), + parameters=[ + OpenApiParameter( + name="market_slot_id", + type=int, + location=OpenApiParameter.QUERY, + required=True, + description="ID of the market slot" + ), + ], + responses={200: ReservedDaysSerializer} +) +class ReservedDaysView(APIView): + """ + Returns reserved days for a given event and market slot. + GET params: event_id, market_slot_id + """ + def get(self, request, *args, **kwargs): + market_slot_id = request.query_params.get("market_slot_id") + if not market_slot_id: + return Response( + {"detail": "market_slot_id is required."}, + status=status.HTTP_400_BAD_REQUEST + ) + serializer = ReservedDaysSerializer({ + "market_slot_id": market_slot_id + }) + logger.debug(f"ReservedDaysView GET market_slot_id={market_slot_id}") + return Response(serializer.data) + + + +@extend_schema( + tags=["Reservation Checks"], + description="Správa kontrol rezervací – vytváření záznamů o kontrole a jejich výpis." +) +class ReservationCheckViewSet(viewsets.ModelViewSet): + queryset = ReservationCheck.objects.select_related("reservation", "checker").all().order_by("-checked_at") + serializer_class = ReservationCheckSerializer + permission_classes = [OnlyRolesAllowed("admin", "checker")] # Only checkers & admins can use it + + def get_queryset(self): + user = self.request.user + if hasattr(user, "role") and user.role == "checker": + return self.queryset.filter(checker=user) # Checkers only see their own logs + return self.queryset + + def perform_create(self, serializer): + serializer.save() \ No newline at end of file diff --git a/backend/commerce/__init__.py b/backend/commerce/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/commerce/admin.py b/backend/commerce/admin.py new file mode 100644 index 0000000..1ff8d75 --- /dev/null +++ b/backend/commerce/admin.py @@ -0,0 +1,30 @@ +from django.contrib import admin + +from trznice.admin import custom_admin_site +from .models import Order + +class OrderAdmin(admin.ModelAdmin): + list_display = ("id", "status", "user", "price_to_pay", "reservation", "is_deleted") + list_filter = ("user", "status", "reservation", "is_deleted") + search_fields = ("user__email", "reservation__event") + ordering = ("id",) + + base_fields = ["status", "reservation", "created_at", "user", "price_to_pay", "payed_at", "note"] + + readonly_fields = ("id", "created_at", "payed_at") + + def get_fields(self, request, obj=None): + fields = self.base_fields.copy() + if request.user.role == "admin": + fields += ['is_deleted', 'deleted_at'] + return fields + + def get_queryset(self, request): + # Use the all_objects manager to show even soft-deleted entries + if request.user.role == "admin": + qs = self.model.all_objects.all() + else: + qs = self.model.objects.all() + return qs + +custom_admin_site.register(Order, OrderAdmin) \ No newline at end of file diff --git a/backend/commerce/apps.py b/backend/commerce/apps.py new file mode 100644 index 0000000..b996052 --- /dev/null +++ b/backend/commerce/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CommerceConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'commerce' diff --git a/backend/commerce/filters.py b/backend/commerce/filters.py new file mode 100644 index 0000000..66be268 --- /dev/null +++ b/backend/commerce/filters.py @@ -0,0 +1,12 @@ +import django_filters +from .models import Order + + +class OrderFilter(django_filters.FilterSet): + reservation = django_filters.NumberFilter(field_name="reservation__id") + user = django_filters.NumberFilter(field_name="user__id") + status = django_filters.ChoiceFilter(choices=Order.STATUS_CHOICES) + + class Meta: + model = Order + fields = ["reservation", "user", "status"] diff --git a/backend/commerce/migrations/0001_initial.py b/backend/commerce/migrations/0001_initial.py new file mode 100644 index 0000000..74f2104 --- /dev/null +++ b/backend/commerce/migrations/0001_initial.py @@ -0,0 +1,37 @@ +# Generated by Django 5.2.4 on 2025-08-07 15:13 + +import django.core.validators +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('booking', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Order', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_deleted', models.BooleanField(default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('status', models.CharField(choices=[('payed', 'Zaplaceno'), ('pending', 'Čeká na zaplacení'), ('cancelled', 'Stornovano')], default='pending', max_length=20)), + ('note', models.TextField(blank=True, null=True)), + ('price_to_pay', models.DecimalField(blank=True, decimal_places=2, default=0, help_text='Cena k zaplacení. Počítá se automaticky z Rezervace.', max_digits=8, validators=[django.core.validators.MinValueValidator(0)])), + ('payed_at', models.DateTimeField(blank=True, null=True)), + ('reservation', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='order', to='booking.reservation')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='orders', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/backend/commerce/migrations/__init__.py b/backend/commerce/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/commerce/models.py b/backend/commerce/models.py new file mode 100644 index 0000000..254e27e --- /dev/null +++ b/backend/commerce/models.py @@ -0,0 +1,113 @@ +import uuid + +from django.db import models +from django.conf import settings +from django.core.validators import MinValueValidator +from django.core.exceptions import ValidationError + +from trznice.models import SoftDeleteModel +from booking.models import Reservation +from account.models import CustomUser + +class Order(SoftDeleteModel): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="orders", null=False, blank=False) + reservation = models.OneToOneField(Reservation, on_delete=models.CASCADE, related_name="order", null=False, blank=False) + created_at = models.DateTimeField(auto_now_add=True) + + STATUS_CHOICES = [ + ("payed", "Zaplaceno"), + ("pending", "Čeká na zaplacení"), + ("cancelled", "Stornovano"), + ] + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="pending") + + note = models.TextField(blank=True, null=True) + + price_to_pay = models.DecimalField(blank=True, + default=0, + max_digits=8, + decimal_places=2, + validators=[MinValueValidator(0)], + help_text="Cena k zaplacení. Počítá se automaticky z Rezervace.", + ) + + payed_at = models.DateTimeField(null=True, blank=True) + + + def __str__(self): + return f"Objednávka {self.id} od uživatele {self.user}" + + def clean(self): + + if not self.user_id: + raise ValidationError("Zadejte ID Uživatele.") + + if not self.reservation_id: + raise ValidationError("Zadejte ID Rezervace.") + + # Safely get product and event objects for error messages and validation + try: + reservation_obj = Reservation.objects.get(pk=self.reservation_id) + except Reservation.DoesNotExist: + raise ValidationError("Neplatné ID Rezervace.") + + """try: + user_obj = CustomUser.objects.get(pk=self.user_id) + if reservation_obj.user != user_obj: + raise ValidationError("Tato rezervace naleží jinému Uživatelovi.") + except CustomUser.DoesNotExist: + raise ValidationError("Neplatné ID Uživatele.")""" + + # Overlapping sales window check + overlapping = Order.objects.exclude(id=self.id).filter( + reservation_id=self.reservation_id, + ) + if overlapping.exists(): + raise ValidationError("Tato Rezervace už je zaplacena.") + + errors = {} + + # If order is marked as payed, it must have a payed_at timestamp + if self.status == "payed" and not self.payed_at: + errors["payed_at"] = "Musíte zadat datum a čas zaplacení, pokud je objednávka zaplacena." + + # If order is not payed, payed_at must be null + if self.status != "payed" and self.payed_at: + errors["payed_at"] = "Datum zaplacení může být uvedeno pouze u zaplacených objednávek." + + if self.reservation.final_price: + self.price_to_pay = self.reservation.final_price + else: + errors["price_to_pay"] = "Chyba v Rezervaci, neplatná cena." + + # Price must be greater than zero + if self.price_to_pay: + if self.price_to_pay < 0: + errors["price_to_pay"] = "Cena musí být větší než 0." + # if self.price_to_pay == 0 and self.reservation: + else: + errors["price_to_pay"] = "Nemůže být prázdné." + + if errors: + raise ValidationError(errors) + + + def save(self, *args, **kwargs): + self.full_clean() + + if self.status == "cancelled": + self.reservation.status = "cancelled" + else: + self.reservation.status = "reserved" + self.reservation.save() + + # if self.reservation: + # self.price_to_pay = self.reservation.final_price + + super().save(*args, **kwargs) + + def delete(self, *args, **kwargs): + self.reservation.status = "cancelled" + self.reservation.save() + + return super().delete(*args, **kwargs) diff --git a/backend/commerce/serializers.py b/backend/commerce/serializers.py new file mode 100644 index 0000000..bb92849 --- /dev/null +++ b/backend/commerce/serializers.py @@ -0,0 +1,178 @@ +from rest_framework import serializers +from django.utils import timezone + +from trznice.utils import RoundedDateTimeField +from account.serializers import CustomUserSerializer +from booking.serializers import ReservationSerializer +from account.models import CustomUser +from booking.models import Event, MarketSlot, Reservation +from .models import Order + +from decimal import Decimal +import logging + +logger = logging.getLogger(__name__) + +#počítaní ceny!!! (taky validní) +class SlotPriceInputSerializer(serializers.Serializer): + slot_id = serializers.PrimaryKeyRelatedField(queryset=MarketSlot.objects.all()) + used_extension = serializers.FloatField(min_value=0) + +#počítaní ceny!!! (počítá správně!!) +class PriceCalculationSerializer(serializers.Serializer): + slot = serializers.PrimaryKeyRelatedField(queryset=MarketSlot.objects.all()) + reserved_from = RoundedDateTimeField() + reserved_to = RoundedDateTimeField() + used_extension = serializers.FloatField(min_value=0, required=False) + + final_price = serializers.DecimalField(max_digits=8, decimal_places=2, read_only=True) + + def validate(self, data): + from django.utils.timezone import make_aware, is_naive + + reserved_from = data["reserved_from"] + reserved_to = data["reserved_to"] + + if is_naive(reserved_from): + reserved_from = make_aware(reserved_from) + if is_naive(reserved_to): + reserved_to = make_aware(reserved_to) + + duration = reserved_to - reserved_from + days = duration.days + 1 # zahrnujeme první den + + data["reserved_from"] = reserved_from + data["reserved_to"] = reserved_to + data["duration"] = days + + market_slot = data["slot"] + event = market_slot.event if hasattr(market_slot, "event") else None + + if not event or not event.square: + raise serializers.ValidationError("Slot musí být přiřazen k akci, která má náměstí.") + + # Get width and height from market_slot + area = market_slot.width * market_slot.height + + price_per_m2 = market_slot.price_per_m2 if market_slot.price_per_m2 and market_slot.price_per_m2 > 0 else event.price_per_m2 + + if not price_per_m2 or price_per_m2 < 0: + raise serializers.ValidationError("Cena za m² není dostupná nebo je záporná.") + + # Calculate final price using slot area and reserved days + final_price = Decimal(area) * Decimal(price_per_m2) * Decimal(days) + final_price = final_price.quantize(Decimal("0.01")) + + data["final_price"] = final_price + return data + + + +class OrderSerializer(serializers.ModelSerializer): + created_at = RoundedDateTimeField(read_only=True, required=False) + payed_at = RoundedDateTimeField(read_only=True, required=False) + + user = CustomUserSerializer(read_only=True) + reservation = ReservationSerializer(read_only=True) + + user_id = serializers.PrimaryKeyRelatedField( + queryset=CustomUser.objects.all(), source="user", write_only=True, required=False, allow_null=True + ) + reservation_id = serializers.PrimaryKeyRelatedField( + queryset=Reservation.objects.all(), source="reservation", write_only=True + ) + + price_to_pay = serializers.DecimalField( + max_digits=10, decimal_places=2, required=False, allow_null=True + ) + + class Meta: + model = Order + fields = [ + "id", + "user", # nested read-only + "user_id", # required in POST/PUT + "reservation", # nested read-only + "reservation_id", # required in POST/PUT + "created_at", + "status", + "note", + "price_to_pay", + "payed_at", + ] + read_only_fields = ["id", "created_at", "price_to_pay", "payed_at"] + + extra_kwargs = { + "user_id": {"help_text": "ID uživatele, který objednávku vytvořil", "required": False}, + "reservation_id": {"help_text": "ID rezervace, ke které se objednávka vztahuje", "required": True}, + "status": {"help_text": "Stav objednávky (např. new / paid / cancelled)", "required": False}, + "note": {"help_text": "Poznámka k objednávce (volitelné)", "required": False}, + "price_to_pay": { + "help_text": "Celková cena, kterou má uživatel zaplatit. Pokud není zadána, převezme se z rezervace.", + "required": False, + "allow_null": True, + }, + "payed_at": {"help_text": "Datum a čas, kdy byla objednávka zaplacena", "required": False}, + } + + def validate(self, data): + if "status" in data and data["status"] not in dict(Order.STATUS_CHOICES): + raise serializers.ValidationError({"status": "Neplatný stav objednávky."}) + + # status = data.get("status", getattr(self.instance, "status", "pending")) + # payed_at = data.get("payed_at", getattr(self.instance, "payed_at", None)) + reservation = data.get("reservation", getattr(self.instance, "reservation", None)) + price = data.get("price_to_pay", getattr(self.instance, "price_to_pay", 0)) + + errors = {} + + # if status == "payed" and not payed_at: + # errors["payed_at"] = "Musíte zadat datum a čas zaplacení, pokud je objednávka zaplacena." + + # if status != "payed" and payed_at: + # errors["payed_at"] = "Datum zaplacení může být uvedeno pouze u zaplacených objednávek." + + if price is not None and price < 0: + errors["price_to_pay"] = "Cena musí být větší nebo rovna 0." + + if reservation: + if self.instance is None and hasattr(reservation, "order"): + errors["reservation"] = "Tato rezervace již má přiřazenou objednávku." + + + user = data.get("user") + request_user = self.context["request"].user if "request" in self.context else None + + # If user is not specified, use the logged-in user + if user is None and request_user is not None: + user = request_user + data["user"] = user + + # If user is specified and differs from logged-in user, check permissions + if user is not None and request_user is not None and user != request_user: + if request_user.role not in ["admin", "cityClerk", "squareManager"]: + errors["user"] = "Pouze administrátor, úředník nebo správce tržiště může vytvářet rezervace pro jiné uživatele." + + if errors: + raise serializers.ValidationError(errors) + + return data + + def create(self, validated_data): + if validated_data.get("reservation"): + validated_data["price_to_pay"] = validated_data["reservation"].final_price + + validated_data["user"] = validated_data.pop("user_id", validated_data.get("user")) + validated_data["reservation"] = validated_data.pop("reservation_id", validated_data.get("reservation")) + + return super().create(validated_data) + + def update(self, instance, validated_data): + old_status = instance.status + new_status = validated_data.get("status", old_status) + + logger.debug(f"\n\nUpdating order {instance.id} from status {old_status} to {new_status}\n\n") + + if old_status != "payed" and new_status == "payed": + validated_data["payed_at"] = timezone.now() + return super().update(instance, validated_data) \ No newline at end of file diff --git a/backend/commerce/tests.py b/backend/commerce/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/commerce/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/commerce/urls.py b/backend/commerce/urls.py new file mode 100644 index 0000000..45b8184 --- /dev/null +++ b/backend/commerce/urls.py @@ -0,0 +1,11 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import OrderViewSet, CalculateReservationPriceView + +router = DefaultRouter() +router.register(r'orders', OrderViewSet, basename='order') + +urlpatterns = [ + path('', include(router.urls)), + path("calculate_price/", CalculateReservationPriceView.as_view(), name="calculate_price"), +] \ No newline at end of file diff --git a/backend/commerce/views.py b/backend/commerce/views.py new file mode 100644 index 0000000..ec5d5c2 --- /dev/null +++ b/backend/commerce/views.py @@ -0,0 +1,74 @@ +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import viewsets, filters, status +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework.decorators import api_view + +from decimal import Decimal + +from drf_spectacular.utils import extend_schema + + +from account.permissions import RoleAllowed +from rest_framework.permissions import IsAuthenticated +from .serializers import OrderSerializer, PriceCalculationSerializer +from .filters import OrderFilter + +from .models import Order + + + +@extend_schema( + tags=["Order"], + description=( + "Správa objednávek – vytvoření, úprava a výpis. Filtrování podle rezervace, uživatele atd.\n\n" + "🔍 **Fulltextové vyhledávání (`?search=`)** prohledává:\n" + "- poznámku (`note`)\n" + "- e-mail uživatele (`user.email`)\n" + "- jméno a příjmení uživatele (`user.first_name`, `user.last_name`)\n" + "- poznámku rezervace (`reservation.note`)\n\n" + "**Příklady:** `?search=jan.novak@example.com`, `?search=poznámka`" + ) +) +class OrderViewSet(viewsets.ModelViewSet): + queryset = Order.objects.all().select_related("user", "reservation").order_by("-created_at") + serializer_class = OrderSerializer + filter_backends = [DjangoFilterBackend, filters.OrderingFilter, filters.SearchFilter] + filterset_class = OrderFilter + ordering_fields = ["created_at", "price_to_pay", "payed_at"] + search_fields = [ + "note", + "user__email", + "user__first_name", + "user__last_name", + "reservation__note", + ] + permission_classes = [RoleAllowed("admin", "cityClerk", "seller")] + # permission_classes = [IsAuthenticated] + + def get_queryset(self): + queryset = Order.objects.select_related("user", "reservation").order_by("-created_at") + user = self.request.user + if hasattr(user, "role") and user.role == "seller": + return queryset.filter(user=user) + return queryset + + + + +class CalculateReservationPriceView(APIView): + + @extend_schema( + request=PriceCalculationSerializer, + responses={200: {"type": "object", "properties": {"final_price": {"type": "number"}}}}, + tags=["Order"], + summary="Calculate reservation price", + description="Spočítá celkovou cenu rezervace pro zvolený slot, použitá rozšíření a trvání rezervace" + ) + def post(self, request): + serializer = PriceCalculationSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + data = serializer.validated_data + # PriceCalculationSerializer now returns 'final_price' in validated_data + return Response({"final_price": data["final_price"]}, status=status.HTTP_200_OK) \ No newline at end of file diff --git a/backend/configuration/__init__.py b/backend/configuration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/configuration/admin.py b/backend/configuration/admin.py new file mode 100644 index 0000000..f9c7e96 --- /dev/null +++ b/backend/configuration/admin.py @@ -0,0 +1,22 @@ +from django.contrib import admin +from .models import AppConfig + +from trznice.admin import custom_admin_site + + +class AppConfigAdmin(admin.ModelAdmin): + def has_add_permission(self, request): + # Prevent adding more than one instance + return not AppConfig.objects.exists() + + def has_delete_permission(self, request, obj=None): + # Prevent deletion + return False + + readonly_fields = ('last_changed_by', 'last_changed_at',) + + def save_model(self, request, obj, form, change): + obj.last_changed_by = request.user + super().save_model(request, obj, form, change) + +custom_admin_site.register(AppConfig, AppConfigAdmin) diff --git a/backend/configuration/apps.py b/backend/configuration/apps.py new file mode 100644 index 0000000..eab8aa0 --- /dev/null +++ b/backend/configuration/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ConfigurationConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'configuration' diff --git a/backend/configuration/migrations/0001_initial.py b/backend/configuration/migrations/0001_initial.py new file mode 100644 index 0000000..b996c63 --- /dev/null +++ b/backend/configuration/migrations/0001_initial.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.4 on 2025-08-07 15:13 + +import django.core.validators +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='AppConfig', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('bank_account', models.CharField(blank=True, max_length=255, null=True, validators=[django.core.validators.RegexValidator(code='invalid_bank_account', message='Zadejte platné číslo účtu ve formátu [prefix-]číslo_účtu/kód_banky, např. 1234567890/0100 nebo 123-4567890/0100.', regex='^(\\d{0,6}-)?\\d{10}/\\d{4}$')])), + ('sender_email', models.EmailField(max_length=254)), + ('last_changed_at', models.DateTimeField(auto_now=True, verbose_name='Kdy byly naposled udělany změny.')), + ('last_changed_by', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='app_config', to=settings.AUTH_USER_MODEL, verbose_name='Kdo naposled udělal změny.')), + ], + ), + ] diff --git a/backend/configuration/migrations/0002_appconfig_background_image_appconfig_contact_email_and_more.py b/backend/configuration/migrations/0002_appconfig_background_image_appconfig_contact_email_and_more.py new file mode 100644 index 0000000..56151a9 --- /dev/null +++ b/backend/configuration/migrations/0002_appconfig_background_image_appconfig_contact_email_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 5.2.4 on 2025-09-25 14:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('configuration', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='appconfig', + name='background_image', + field=models.ImageField(blank=True, help_text='Obrázek pozadí webu (nepovinné).', null=True, upload_to='config/'), + ), + migrations.AddField( + model_name='appconfig', + name='contact_email', + field=models.EmailField(blank=True, help_text='Kontaktní e-mail pro veřejnost (může se lišit od odesílací adresy).', max_length=254, null=True), + ), + migrations.AddField( + model_name='appconfig', + name='contact_phone', + field=models.CharField(blank=True, help_text='Kontaktní telefon veřejně zobrazený na webu.', max_length=50, null=True), + ), + migrations.AddField( + model_name='appconfig', + name='logo', + field=models.ImageField(blank=True, help_text='Logo webu (transparentní PNG doporučeno).', null=True, upload_to='config/'), + ), + migrations.AddField( + model_name='appconfig', + name='max_reservations_per_event', + field=models.PositiveIntegerField(default=1, help_text='Maximální počet rezervací (slotů) povolených pro jednoho uživatele na jednu akci.'), + ), + migrations.AddField( + model_name='appconfig', + name='variable_symbol', + field=models.PositiveIntegerField(blank=True, help_text='Výchozí variabilní symbol pro platby (pokud není specifikováno jinde).', null=True), + ), + ] diff --git a/backend/configuration/migrations/__init__.py b/backend/configuration/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/configuration/models.py b/backend/configuration/models.py new file mode 100644 index 0000000..53bd2b9 --- /dev/null +++ b/backend/configuration/models.py @@ -0,0 +1,88 @@ +from django.db import models +from django.core.exceptions import ValidationError +from django.core.validators import RegexValidator +from django.conf import settings + + +class AppConfig(models.Model): + bank_account = models.CharField( + max_length=255, + null=True, + blank=True, + validators=[ + RegexValidator( + regex=r'^(\d{0,6}-)?\d{10}/\d{4}$', + message=( + "Zadejte platné číslo účtu ve formátu [prefix-]číslo_účtu/kód_banky, " + "např. 1234567890/0100 nebo 123-4567890/0100." + ), + code='invalid_bank_account' + ) + ], + ) + sender_email = models.EmailField() + + # ---- New configurable site settings ---- + background_image = models.ImageField( + upload_to="config/", + null=True, + blank=True, + help_text="Obrázek pozadí webu (nepovinné)." + ) + logo = models.ImageField( + upload_to="config/", + null=True, + blank=True, + help_text="Logo webu (transparentní PNG doporučeno)." + ) + variable_symbol = models.PositiveIntegerField( + null=True, + blank=True, + help_text="Výchozí variabilní symbol pro platby (pokud není specifikováno jinde)." + ) + max_reservations_per_event = models.PositiveIntegerField( + default=1, + help_text="Maximální počet rezervací (slotů) povolených pro jednoho uživatele na jednu akci." + ) + contact_phone = models.CharField( + max_length=50, + null=True, + blank=True, + help_text="Kontaktní telefon veřejně zobrazený na webu." + ) + contact_email = models.EmailField( + null=True, + blank=True, + help_text="Kontaktní e-mail pro veřejnost (může se lišit od odesílací adresy)." + ) + + last_changed_by = models.OneToOneField( + settings.AUTH_USER_MODEL, + verbose_name="Kdo naposled udělal změny.", + on_delete=models.SET_NULL, # 🔄 Better than CASCADE to preserve data + related_name="app_config", + null=True, + blank=True + ) + last_changed_at = models.DateTimeField( + auto_now=True, # 🔄 Use auto_now to update on every save + verbose_name="Kdy byly naposled udělany změny." + ) + + def save(self, *args, **kwargs): + if not self.pk and AppConfig.objects.exists(): + raise ValidationError('Only one AppConfig instance allowed.') + return super().save(*args, **kwargs) + + def __str__(self): + return "App Configuration" + + @classmethod + def get_instance(cls): + return cls.objects.first() + +# Usage: + +# config = AppConfig.get_instance() +# if config: +# print(config.bank_account) \ No newline at end of file diff --git a/backend/configuration/serializers.py b/backend/configuration/serializers.py new file mode 100644 index 0000000..7e2e529 --- /dev/null +++ b/backend/configuration/serializers.py @@ -0,0 +1,159 @@ +from django.apps import apps +from django.conf import settings +from django.db.models.fields.related import ForeignObjectRel +from rest_framework import serializers + +from trznice.utils import RoundedDateTimeField # noqa: F401 (kept if used elsewhere later) +from .models import AppConfig + + +class AppConfigSerializer(serializers.ModelSerializer): + class Meta: + model = AppConfig + fields = "__all__" + read_only_fields = ["last_changed_by", "last_changed_at"] + + +class AppConfigPublicSerializer(serializers.ModelSerializer): + """Public-facing limited subset used for navbar assets and basic contact info.""" + + class Meta: + model = AppConfig + fields = [ + "id", + "logo", + "background_image", + "contact_email", + "contact_phone", + "max_reservations_per_event", + ] + + +class TrashItemSerializer(serializers.Serializer): + """Represents a single soft-deleted instance across any model. + + Fields: + model: + id: primary key value + deleted_at: timestamp (if model defines it) + data: remaining field values (excluding soft-delete bookkeeping fields) + """ + + model = serializers.CharField() + id = serializers.CharField() # CharField to allow UUIDs as well + deleted_at = serializers.DateTimeField(allow_null=True, required=False) + data = serializers.DictField(child=serializers.CharField(allow_blank=True, allow_null=True)) + + +class TrashSerializer(serializers.Serializer): + """Aggregates all soft-deleted objects (is_deleted=True) from selected apps. + + This dynamically inspects registered models and collects those that: + * Have a concrete field named `is_deleted` + * (Optional) Have a manager named `all_objects`; otherwise fall back to default `objects` + + Usage: Serialize with `TrashSerializer()` (no instance needed) and access `.data`. + Optionally you can pass a context key `apps` with an iterable of app labels to restrict search + (default: account, booking, commerce, product, servicedesk). + """ + + items = serializers.SerializerMethodField() + + SETTINGS_APPS = set(getattr(settings, "MY_CREATED_APPS", [])) + EXCLUDE_FIELD_NAMES = {"is_deleted", "deleted_at"} + + def get_items(self, _obj): # _obj unused (serializer acts as a data provider) + # Allow overriding via context['apps']; otherwise use all custom apps from settings + target_apps = set(self.context.get("apps", self.SETTINGS_APPS)) + results = [] + + for model in apps.get_models(): + app_label = model._meta.app_label + if app_label not in target_apps: + continue + + # Fast check for is_deleted field + field_names = {f.name for f in model._meta.get_fields() if not isinstance(f, ForeignObjectRel)} + if "is_deleted" not in field_names: + continue + + manager = getattr(model, "all_objects", model._default_manager) + queryset = manager.filter(is_deleted=True) + if not queryset.exists(): + continue + + # Prepare list of simple (non-relational) field objects for extraction + concrete_fields = [ + f for f in model._meta.get_fields() + if not isinstance(f, ForeignObjectRel) and getattr(f, "concrete", False) + ] + + for instance in queryset: + data = {} + for f in concrete_fields: + if f.name in self.EXCLUDE_FIELD_NAMES: + continue + try: + value = f.value_from_object(instance) + # Represent related FK by its PK only + if f.is_relation and hasattr(value, "pk"): + value = value.pk + except Exception: # noqa: BLE001 - defensive; skip problematic field + value = None + data[f.name] = None if value == "" else value + + results.append({ + "model": f"{app_label}.{model._meta.model_name}", + "id": instance.pk, + "deleted_at": getattr(instance, "deleted_at", None), + "data": data, + }) + + # Optional: sort by deleted_at descending if available + results.sort(key=lambda i: (i.get("deleted_at") is None, i.get("deleted_at")), reverse=True) + return results + + def to_representation(self, instance): # instance unused + all_items = self.get_items(instance) + + request = self.context.get("request") + + # ---- Pagination params ---- + def _to_int(val, default): + try: + return max(1, int(val)) + except Exception: + return default + + if request is not None: + page = _to_int(request.query_params.get("page", 1), 1) + page_size = _to_int(request.query_params.get("page_size") or request.query_params.get("limit", 20), 20) + else: + # Fallback when no request in context (e.g., manual usage) + page = 1 + page_size = 20 + + # Enforce reasonable upper bound + MAX_PAGE_SIZE = 200 + if page_size > MAX_PAGE_SIZE: + page_size = MAX_PAGE_SIZE + + total_items = len(all_items) + total_pages = (total_items + page_size - 1) // page_size if page_size else 1 + if page > total_pages and total_pages != 0: + page = total_pages + + start = (page - 1) * page_size + end = start + page_size + page_items = all_items[start:end] + + pagination = { + "page": page, + "page_size": page_size, + "total_items": total_items, + "total_pages": total_pages, + "has_next": page < total_pages, + "has_previous": page > 1, + } + + return {"trash": page_items, "pagination": pagination} diff --git a/backend/configuration/tests.py b/backend/configuration/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/configuration/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/configuration/urls.py b/backend/configuration/urls.py new file mode 100644 index 0000000..6dad495 --- /dev/null +++ b/backend/configuration/urls.py @@ -0,0 +1,12 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import AppConfigViewSet, TrashView, AppConfigPublicView + +router = DefaultRouter() +router.register(r'', AppConfigViewSet, basename='app_config') # handles /api/config/ + +urlpatterns = [ + path('', include(router.urls)), + path('trash/', TrashView.as_view(), name='trash'), + path('public/', AppConfigPublicView.as_view(), name='app-config-public'), +] \ No newline at end of file diff --git a/backend/configuration/views.py b/backend/configuration/views.py new file mode 100644 index 0000000..77b9bca --- /dev/null +++ b/backend/configuration/views.py @@ -0,0 +1,200 @@ +from rest_framework import viewsets +from rest_framework.exceptions import ValidationError +from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes +from rest_framework.views import APIView +from rest_framework.response import Response +from django.utils import timezone +from django.apps import apps as django_apps + +from .models import AppConfig +from .serializers import AppConfigSerializer, TrashSerializer, AppConfigPublicSerializer +from account.permissions import OnlyRolesAllowed + + +@extend_schema( + tags=["AppConfig"], + description=( + "Globální konfigurace aplikace – správa bankovního účtu, e-mailu odesílatele a dalších nastavení. " + "Umožňuje úpravu přes administrační rozhraní nebo API.\n\n" + "🛠️ **Singleton model** – lze vytvořit pouze jednu instanci konfigurace.\n\n" + "📌 **Přístup pouze pro administrátory** (`role=admin`).\n\n" + "**Dostupné akce:**\n" + "- `GET /api/config/` – Získání aktuální konfigurace (singleton)\n" + "- `PUT /api/config/` – Úprava konfigurace\n\n" + "**Poznámka:** pokus o vytvoření více než jedné konfigurace vrací chybu 400." + ) +) +class AppConfigViewSet(viewsets.ModelViewSet): + queryset = AppConfig.objects.all() + serializer_class = AppConfigSerializer + permission_classes = [OnlyRolesAllowed("admin")] + + def get_object(self): + # Always return the singleton instance + return AppConfig.get_instance() + + def perform_update(self, serializer): + serializer.save(last_changed_by=self.request.user) + + def perform_create(self, serializer): + if AppConfig.objects.exists(): + raise ValidationError("Only one AppConfig instance allowed.") + serializer.save(last_changed_by=self.request.user) + + +class AppConfigPublicView(APIView): + """Read-only public endpoint with limited AppConfig data (logo, background, contact info). + + Returns 404 if no configuration exists yet. + """ + authentication_classes = [] # allow anonymous + permission_classes = [] + + ALLOWED_FIELDS = { + "id", + "logo", + "background_image", + "contact_email", + "contact_phone", + "max_reservations_per_event", + } + + def get(self, request): + cfg = AppConfig.get_instance() + if not cfg: + return Response({"detail": "Not configured"}, status=404) + + fields_param = request.query_params.get("fields") + if fields_param: + requested = {f.strip() for f in fields_param.split(",") if f.strip()} + valid = [f for f in requested if f in self.ALLOWED_FIELDS] + if not valid: + return Response({ + "detail": "No valid fields requested. Allowed: " + ", ".join(sorted(self.ALLOWED_FIELDS)) + }, status=400) + data = {} + for f in valid: + data[f] = getattr(cfg, f, None) + return Response(data) + + # default full public subset + return Response(AppConfigPublicSerializer(cfg).data) + + +@extend_schema( + tags=["Trash"], + description=( + "Agregovaný seznam všech soft-smazaných (is_deleted=True) objektů napříč aplikacemi definovanými v `settings.MY_CREATED_APPS`.\n\n" + "Pagination params:\n" + "- `page` (int, default=1)\n" + "- `page_size` nebo `limit` (int, default=20, max=200)\n\n" + "Volitelné parametry do budoucna: `apps` (comma-separated) – pokud bude přidána filtrace.\n\n" + "Response obsahuje pole `trash` a objekt `pagination`. Každá položka má strukturu:\n" + "`{ model: 'app_label.model', id: , deleted_at: , data: { ...fields } }`." + ), + parameters=[ + OpenApiParameter(name="page", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=False, description="Číslo stránky (>=1)"), + OpenApiParameter(name="page_size", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=False, description="Počet záznamů na stránce (default 20, max 200)"), + OpenApiParameter(name="limit", type=OpenApiTypes.INT, location=OpenApiParameter.QUERY, required=False, description="Alias pro page_size"), + ], +) +class TrashView(APIView): + permission_classes = [OnlyRolesAllowed("admin")] + + def get(self, request): + # Optional filtering by apps (?apps=account,booking) + ctx = {"request": request} + apps_param = request.query_params.get("apps") + if apps_param: + ctx["apps"] = [a.strip() for a in apps_param.split(",") if a.strip()] + serializer = TrashSerializer(context=ctx) + return Response(serializer.data) + + @extend_schema( + request={ + 'application/json': { + 'type': 'object', + 'properties': { + 'model': {'type': 'string', 'example': 'booking.event', 'description': 'app_label.model_name (lowercase)'}, + 'id': {'type': 'string', 'example': '5', 'description': 'Primární klíč objektu'}, + }, + 'required': ['model', 'id'] + } + }, + responses={200: dict, 400: dict, 404: dict}, + methods=["PATCH"], + description=( + "Obnovení (undelete) jednoho objektu dle model labelu a ID. Nastaví `is_deleted=False` a `deleted_at=None`.\n\n" + "Body JSON:\n" + "{ 'model': 'booking.event', 'id': '5' }\n\n" + "Pokud už objekt není smazaný, operace je idempotentní a jen vrátí informaci, že je aktivní." + ), + ) + def patch(self, request): + model_label = request.data.get("model") + obj_id = request.data.get("id") + + if not model_label or not obj_id: + return Response({ + "success": False, + "error": "Missing 'model' or 'id' in request body" + }, status=400) + + if "." not in model_label: + return Response({"success": False, "error": "'model' must be in format app_label.model_name"}, status=400) + + app_label, model_name = model_label.split(".", 1) + try: + model = django_apps.get_model(app_label, model_name) + except LookupError: + return Response({"success": False, "error": f"Model '{model_label}' not found"}, status=404) + + # Ensure model has is_deleted + if not hasattr(model, 'is_deleted') and 'is_deleted' not in [f.name for f in model._meta.fields]: + return Response({"success": False, "error": f"Model '{model_label}' is not soft-deletable"}, status=400) + + manager = getattr(model, 'all_objects', model._default_manager) + try: + instance = manager.get(pk=obj_id) + except model.DoesNotExist: + return Response({"success": False, "error": f"Object with id={obj_id} not found"}, status=404) + + current_state = getattr(instance, 'is_deleted', False) + + if current_state: + # Restore + setattr(instance, 'is_deleted', False) + if hasattr(instance, 'deleted_at'): + setattr(instance, 'deleted_at', None) + instance.save(update_fields=[f.name for f in instance._meta.fields if f.name in ('is_deleted', 'deleted_at')]) + state_changed = True + message = "Object restored" + else: + state_changed = False + message = "No state change – already active" + + # Build minimal representation + data_repr = {} + for f in instance._meta.fields: + if f.name in ('is_deleted', 'deleted_at'): + continue + try: + val = getattr(instance, f.name) + if f.is_relation and hasattr(val, 'pk'): + val = val.pk + except Exception: + val = None + data_repr[f.name] = val + + return Response({ + "success": True, + "changed": state_changed, + "message": message, + "item": { + "model": model_label.lower(), + "id": instance.pk, + "is_deleted": getattr(instance, 'is_deleted', False), + "deleted_at": getattr(instance, 'deleted_at', None), + "data": data_repr, + } + }, status=200) diff --git a/backend/dockerfile b/backend/dockerfile new file mode 100644 index 0000000..1dbabf7 --- /dev/null +++ b/backend/dockerfile @@ -0,0 +1,28 @@ +# Use the official Python image from the Docker Hub +FROM python:3.11-slim + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 +ENV SSL False + +# Set the working directory +WORKDIR /app + +# Copy the requirements file and install dependencies +COPY requirements.txt . + +RUN pip config set global.trusted-host \ + "pypi.org files.pythonhosted.org pypi.python.org" \ + --trusted-host=pypi.python.org \ + --trusted-host=pypi.org \ + --trusted-host=files.pythonhosted.org + +RUN pip install -r requirements.txt + +RUN apt-get update + + + +# Copy the project files +COPY . . diff --git a/backend/globalstaticfiles/js/index.js b/backend/globalstaticfiles/js/index.js new file mode 100644 index 0000000..fbe7bfa --- /dev/null +++ b/backend/globalstaticfiles/js/index.js @@ -0,0 +1,36 @@ +document.getElementById('sendEmailBtn').addEventListener('click', function () { + const recipient = document.getElementById('email-recipient').value; + + fetch(`${window.location.origin}/test/email`, { + method: 'POST', + headers: { + 'X-CSRFToken': getCookie('csrftoken'), + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ recipient: recipient }) + }) + .then(response => response.json()) + .then(data => { + alert('Success: ' + JSON.stringify(data)); + }) + .catch(error => { + console.error('Error:', error); + alert('Failed to send request.'); + }); +}); + +// Helper function to get CSRF token from cookies +function getCookie(name) { + let cookieValue = null; + if (document.cookie && document.cookie !== '') { + const cookies = document.cookie.split(';'); + for (const cookie of cookies) { + const trimmed = cookie.trim(); + if (trimmed.startsWith(name + '=')) { + cookieValue = decodeURIComponent(trimmed.substring(name.length + 1)); + break; + } + } + } + return cookieValue; +} \ No newline at end of file diff --git a/backend/manage.py b/backend/manage.py new file mode 100644 index 0000000..bab9d28 --- /dev/null +++ b/backend/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'trznice.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/backend/populate_db.py b/backend/populate_db.py new file mode 100644 index 0000000..3221734 --- /dev/null +++ b/backend/populate_db.py @@ -0,0 +1,309 @@ +# Renewed populate_db.py: fills all models with relations and validation +import os +import django +import random +from faker import Faker +from decimal import Decimal +from datetime import datetime, timedelta +from django.core.exceptions import ValidationError +from django.utils import timezone + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "trznice.settings") +django.setup() + +from booking.models import Square, Event, MarketSlot, Reservation +from account.models import CustomUser +from product.models import Product, EventProduct +from commerce.models import Order +from servicedesk.models import ServiceTicket + +fake = Faker("cs_CZ") + +def create_users(n=10): + roles = ['admin', 'seller', 'squareManager', 'cityClerk', 'checker', None] + account_types = ['company', 'individual'] + users = [] + for _ in range(n): + first_name = fake.first_name() + last_name = fake.last_name() + role = random.choice(roles) + email = fake.unique.email() + prefix = random.choice(["601", "602", "603", "604", "605", "606", "607", "608", "720", "721", "722", "723", "724", "725", "730", "731", "732", "733", "734", "735", "736", "737", "738", "739"]) + phone_number = "+420" + prefix + ''.join([str(random.randint(0, 9)) for _ in range(6)]) + ico = fake.unique.msisdn()[0:8] + rc = f"{fake.random_int(100000, 999999)}/{fake.random_int(100, 9999)}" + psc = fake.postcode().replace(" ", "")[:5] + bank_prefix = f"{random.randint(0, 999999)}-" if random.random() > 0.5 else "" + bank_number = f"{random.randint(1000000000, 9999999999)}/0100" + bank_account = f"{bank_prefix}{bank_number}" + user = CustomUser( + first_name=first_name, + last_name=last_name, + email=email, + role=role, + account_type=random.choice(account_types), + phone_number=phone_number, + ICO=ico, + RC=rc, + city=fake.city(), + street=fake.street_name() + " " + str(fake.building_number()), + PSC=psc, + GDPR=True, + email_verified=random.choice([True, False]), + bank_account=bank_account, + is_active=True, + ) + user.username = user.generate_login(first_name, last_name) + user.set_password("password123") + user.full_clean() + user.save() + users.append(user) + print(f"✅ Vytvořeno {len(users)} uživatelů") + return users + +def create_squares(n=3): + squares = [] + for _ in range(n): + sq = Square( + name=fake.city() + " náměstí", + description=fake.text(max_nb_chars=200), + street=fake.street_name(), + city=fake.city(), + psc=int(fake.postcode().replace(" ", "")), + width=random.randint(20, 50), + height=random.randint(20, 50), + grid_rows=random.randint(40, 60), + grid_cols=random.randint(40, 60), + cellsize=10, + ) + sq.full_clean() + sq.save() + squares.append(sq) + print(f"✅ Vytvořeno {len(squares)} náměstí") + return squares + +def create_events(squares, n=7): + events = [] + attempts = 0 + while len(events) < n and attempts < n * 5: + sq = random.choice(squares) + start = datetime.now() + timedelta(days=random.randint(1, 60)) + end = start + timedelta(days=random.randint(1, 5)) + overlap = Event.objects.filter(square=sq, start__lt=end, end__gt=start).exists() + if overlap: + attempts += 1 + continue + try: + event = Event( + name=fake.catch_phrase(), + description=fake.text(max_nb_chars=300), + square=sq, + start=start, + end=end, + price_per_m2=Decimal(f"{random.randint(10, 100)}.00") + ) + event.full_clean() + event.save() + events.append(event) + except ValidationError as e: + continue + print(f"✅ Vytvořeno {len(events)} eventů") + return events + +def create_products(n=10): + products = [] + for _ in range(n): + name = fake.word().capitalize() + " " + fake.word().capitalize() + code = random.randint(10000, 99999) + product = Product(name=name, code=code) + product.full_clean() + product.save() + products.append(product) + print(f"✅ Vytvořeno {len(products)} produktů") + return products + +def create_event_products(events, products, n=15): + event_products = [] + for _ in range(n): + product = random.choice(products) + event = random.choice(events) + start = event.start + timedelta(days=random.randint(0, 1)) + end = min(event.end, start + timedelta(days=random.randint(1, 3))) + # Ensure timezone-aware datetimes + if timezone.is_naive(start): + start = timezone.make_aware(start) + if timezone.is_naive(end): + end = timezone.make_aware(end) + if timezone.is_naive(event.start): + event_start = timezone.make_aware(event.start) + else: + event_start = event.start + if timezone.is_naive(event.end): + event_end = timezone.make_aware(event.end) + else: + event_end = event.end + # Ensure end is not after event_end and start is not before event_start + if start < event_start: + start = event_start + if end > event_end: + end = event_end + ep = EventProduct( + product=product, + event=event, + start_selling_date=start, + end_selling_date=end + ) + try: + ep.full_clean() + ep.save() + event_products.append(ep) + except ValidationError as e: + print(f"❌ EventProduct error: {e}") + continue + print(f"✅ Vytvořeno {len(event_products)} event produktů") + return event_products + +def create_market_slots(events, max_slots=8): + slots = [] + for event in events: + count = random.randint(3, max_slots) + for _ in range(count): + slot = MarketSlot( + event=event, + status=random.choice(["allowed", "blocked"]), + base_size=round(random.uniform(2, 10), 2), + available_extension=round(random.uniform(0, 5), 2), + x=random.randint(0, 30), + y=random.randint(0, 30), + width=random.randint(2, 10), + height=random.randint(2, 10), + price_per_m2=Decimal(f"{random.randint(10, 100)}.00") + ) + slot.full_clean() + slot.save() + # Check fields and relations + assert slot.event == event + assert slot.status in ["allowed", "blocked"] + assert isinstance(slot.base_size, float) or isinstance(slot.base_size, Decimal) + assert isinstance(slot.price_per_m2, Decimal) + slots.append(slot) + print(f"✅ Vytvořeno {len(slots)} prodejních míst") + return slots + +def create_reservations(users, slots, event_products, max_per_user=2): + reservations = [] + for user in users: + max_res_for_user = min(max_per_user, 5) + user_slots = random.sample(slots, k=min(len(slots), max_res_for_user)) + for slot in user_slots: + event = slot.event + event_start = event.start + event_end = event.end + if timezone.is_naive(event_start): + event_start = timezone.make_aware(event_start) + if timezone.is_naive(event_end): + event_end = timezone.make_aware(event_end) + allowed_durations = [1, 7, 30] + duration_days = random.choice(allowed_durations) + max_start = event_end - timedelta(days=duration_days) + if max_start <= event_start: + continue + start = event_start + timedelta(seconds=random.randint(0, int((max_start - event_start).total_seconds()))) + end = start + timedelta(days=duration_days) + if timezone.is_naive(start): + start = timezone.make_aware(start) + if timezone.is_naive(end): + end = timezone.make_aware(end) + used_extension = round(random.uniform(0, slot.available_extension), 2) + base_size = Decimal(str(slot.base_size)) + price_per_m2 = slot.price_per_m2 + final_price = (price_per_m2 * (base_size + Decimal(str(used_extension))) * Decimal(duration_days)).quantize(Decimal("0.01")) + price = final_price # <-- set price field as well + if final_price >= Decimal("1000000.00"): + continue + if user.user_reservations.count() >= 5: + break + try: + res = Reservation( + event=event, + market_slot=slot, + user=user, + used_extension=used_extension, + reserved_from=start, + reserved_to=end, + status="reserved", + final_price=final_price, + price=price, + ) + res.full_clean() + res.save() + # Check fields and relations + assert res.event == event + assert res.market_slot == slot + assert res.user == user + assert res.status == "reserved" + # Add event_products to reservation + if event_products: + chosen_eps = random.sample(event_products, k=min(len(event_products), random.randint(0, 2))) + res.event_products.add(*chosen_eps) + reservations.append(res) + except ValidationError: + continue + print(f"✅ Vytvořeno {len(reservations)} rezervací") + return reservations + +def create_orders(users, reservations): + orders = [] + for res in reservations: + user = res.user + order = Order( + user=user, + reservation=res, + status=random.choice(["payed", "pending", "cancelled"]), + price_to_pay=res.final_price, + note=fake.sentence(), + ) + try: + order.full_clean() + order.save() + # Check fields and relations + assert order.user == user + assert order.reservation == res + assert order.status in ["payed", "pending", "cancelled"] + orders.append(order) + except ValidationError: + continue + print(f"✅ Vytvořeno {len(orders)} objednávek") + return orders + +def create_service_tickets(users, n=10): + tickets = [] + for _ in range(n): + user = random.choice(users) + ticket = ServiceTicket( + title=fake.sentence(nb_words=6), + description=fake.text(max_nb_chars=200), + user=user, + status=random.choice(["new", "in_progress", "resolved", "closed"]), + category=random.choice(["tech", "reservation", "payment", "account", "content", "suggestion", "other"]), + ) + try: + ticket.full_clean() + ticket.save() + tickets.append(ticket) + except ValidationError: + continue + print(f"✅ Vytvořeno {len(tickets)} servisních tiketů") + return tickets + +if __name__ == "__main__": + users = create_users(10) + squares = create_squares(3) + events = create_events(squares, 7) + products = create_products(10) + event_products = create_event_products(events, products, 15) + slots = create_market_slots(events, max_slots=8) + reservations = create_reservations(users, slots, event_products, max_per_user=2) + orders = create_orders(users, reservations) + tickets = create_service_tickets(users, 10) + print("🎉 Naplnění databáze dokončeno.") diff --git a/backend/product/__init__.py b/backend/product/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/product/admin.py b/backend/product/admin.py new file mode 100644 index 0000000..73b130b --- /dev/null +++ b/backend/product/admin.py @@ -0,0 +1,60 @@ +from django.contrib import admin +from trznice.admin import custom_admin_site +from .models import Product, EventProduct + + +class ProductAdmin(admin.ModelAdmin): + base_list_display = ("id", "name", "code") + admin_extra_display = ("is_deleted",) + list_filter = ("name", "is_deleted") + search_fields = ("name", "code") + ordering = ("name",) + + base_fields = ['name', 'code'] + + + def get_fields(self, request, obj=None): + fields = self.base_fields.copy() + if request.user.role == "admin": + fields += ['is_deleted', 'deleted_at'] + return fields + + def get_queryset(self, request): + # Use the all_objects manager to show even soft-deleted entries + if request.user.role == "admin": + qs = self.model.all_objects.all() + else: + qs = self.model.objects.all() + return qs + + def get_list_display(self, request): + if request.user.role == "admin": + return self.base_list_display + self.admin_extra_display + return self.base_list_display + +custom_admin_site.register(Product, ProductAdmin) + + +class EventProductAdmin(admin.ModelAdmin): + list_display = ("id", "event", "product", "start_selling_date", "end_selling_date", "is_deleted") + list_filter = ("event", "product", "start_selling_date", "end_selling_date", "is_deleted") + search_fields = ("product__name", "event__name") + ordering = ("-start_selling_date",) + + base_fields = ['product', 'event', 'start_selling_date', 'end_selling_date'] + + def get_fields(self, request, obj=None): + fields = self.base_fields.copy() + if request.user.role == "admin": + fields += ['is_deleted', 'deleted_at'] + return fields + + def get_queryset(self, request): + # Use the all_objects manager to show even soft-deleted entries + if request.user.role == "admin": + qs = self.model.all_objects.all() + else: + qs = self.model.objects.all() + return qs + +custom_admin_site.register(EventProduct, EventProductAdmin) \ No newline at end of file diff --git a/backend/product/apps.py b/backend/product/apps.py new file mode 100644 index 0000000..235a333 --- /dev/null +++ b/backend/product/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ProductConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'product' diff --git a/backend/product/migrations/0001_initial.py b/backend/product/migrations/0001_initial.py new file mode 100644 index 0000000..726906c --- /dev/null +++ b/backend/product/migrations/0001_initial.py @@ -0,0 +1,44 @@ +# Generated by Django 5.2.4 on 2025-08-07 15:13 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('booking', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Product', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_deleted', models.BooleanField(default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('name', models.CharField(max_length=255, verbose_name='Název produktu')), + ('code', models.PositiveIntegerField(unique=True, verbose_name='Unitatní kód produktu')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='EventProduct', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_deleted', models.BooleanField(default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('start_selling_date', models.DateTimeField()), + ('end_selling_date', models.DateTimeField()), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='event_products', to='booking.event')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='event_products', to='product.product')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/backend/product/migrations/0002_alter_product_code.py b/backend/product/migrations/0002_alter_product_code.py new file mode 100644 index 0000000..3bd02c0 --- /dev/null +++ b/backend/product/migrations/0002_alter_product_code.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.4 on 2025-09-25 15:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('product', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='product', + name='code', + field=models.PositiveIntegerField(blank=True, null=True, unique=True, verbose_name='Unitatní kód produktu'), + ), + ] diff --git a/backend/product/migrations/__init__.py b/backend/product/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/product/models.py b/backend/product/models.py new file mode 100644 index 0000000..5a533ba --- /dev/null +++ b/backend/product/models.py @@ -0,0 +1,77 @@ +from django.db import models +from django.utils import timezone +from django.core.exceptions import ValidationError + +from trznice.models import SoftDeleteModel +from booking.models import Event +from trznice.utils import truncate_to_minutes + +class Product(SoftDeleteModel): + name = models.CharField(max_length=255, verbose_name="Název produktu") + code = models.PositiveIntegerField(unique=True, verbose_name="Unitatní kód produktu", null=True, blank=True) + + def __str__(self): + return f"{self.name} : {self.code}" + + def delete(self, *args, **kwargs): + + self.event_products.all().update(is_deleted=True, deleted_at=timezone.now()) + + return super().delete(*args, **kwargs) + + +class EventProduct(SoftDeleteModel): + product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="event_products") + event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="event_products") + start_selling_date = models.DateTimeField() + end_selling_date = models.DateTimeField() + + def clean(self): + if not (self.start_selling_date and self.end_selling_date): + raise ValidationError("Datum začátku a konce musí být neprázné.") + + # Vynecháme sekunky, mikrosecundy atd. + self.start_selling_date = truncate_to_minutes(self.start_selling_date) + self.end_selling_date = truncate_to_minutes(self.end_selling_date) + + if not self.product_id or not self.event_id: + raise ValidationError("Zadejte Akci a Produkt.") + + # Safely get product and event objects for error messages and validation + try: + product_obj = Product.objects.get(pk=self.product_id) + except Product.DoesNotExist: + raise ValidationError("Neplatné ID Zboží (Produktu).") + + try: + event_obj = Event.objects.get(pk=self.event_id) + except Event.DoesNotExist: + raise ValidationError("Neplatné ID Akce (Eventu).") + + # Overlapping sales window check + overlapping = EventProduct.objects.exclude(id=self.id).filter( + event_id=self.event_id, + product_id=self.product_id, + start_selling_date__lt=self.end_selling_date, + end_selling_date__gt=self.start_selling_date, + ) + if overlapping.exists(): + raise ValidationError("Toto zboží už se prodává v tomto období na této akci.") + + # Ensure sale window is inside event bounds + # Event has DateFields (date), while these are DateTimeFields -> compare by date component + start_date = self.start_selling_date.date() + end_date = self.end_selling_date.date() + if start_date < event_obj.start or end_date > event_obj.end: + raise ValidationError("Prodej zboží musí být v rámci trvání akce.") + + # Ensure product+event pair is unique + if EventProduct.objects.exclude(pk=self.pk).filter(product_id=self.product_id, event_id=self.event_id).exists(): + raise ValidationError(f"V rámci akce {event_obj} už je {product_obj} zaregistrováno.") + + def save(self, *args, **kwargs): + self.full_clean() # This includes clean_fields() + clean() + validate_unique() + super().save(*args, **kwargs) + + def __str__(self): + return f"{self.product} at {self.event}" \ No newline at end of file diff --git a/backend/product/serializers.py b/backend/product/serializers.py new file mode 100644 index 0000000..b0286d9 --- /dev/null +++ b/backend/product/serializers.py @@ -0,0 +1,155 @@ +from rest_framework import serializers +from rest_framework.validators import UniqueValidator + +from trznice.utils import RoundedDateTimeField +from .models import Product, EventProduct +from booking.models import Event +# from booking.serializers import EventSerializer + +class ProductSerializer(serializers.ModelSerializer): + code = serializers.CharField( + required=False, + allow_null=True, + allow_blank=True, + help_text="Unikátní číselný kód produktu (volitelné)", + ) + events = serializers.SerializerMethodField(help_text="Seznam akcí (eventů), ve kterých se tento produkt prodává.") + + class Meta: + model = Product + fields = ["id", "name", "code", "events"] + read_only_fields = ["id"] + extra_kwargs = { + "name": { + "help_text": "Název zboží (max. 255 znaků).", + "required": True, + }, + "code": { + "help_text": "Unikátní kód zboží (např. 'FOOD-001'). Volitelné; pokud vyplněno, musí být jedinečný.", + "required": False, + "allow_null": True, + "allow_blank": True, + }, + } + + def validate_name(self, value): + value = value.strip() + + if not value: + raise serializers.ValidationError("Název Zboží (Produktu) nemůže být prázdný.") + + if len(value) > 255: + raise serializers.ValidationError("Název nesmí být delší než 255 znaků.") + return value + + def validate_code(self, value): + # Accept empty/null + if value in (None, ""): + return None + # Uniqueness manual check (since we removed built-in validator to permit null/blank) + qs = Product.objects.filter(code=value) + if self.instance: + qs = qs.exclude(pk=self.instance.pk) + if qs.exists(): + raise serializers.ValidationError("Produkt s tímto kódem už existuje.") + return value + + def get_events(self, obj): + # Expect prefetch: event_products__event + events = [] + # Access prefetched related if available to avoid N+1 + event_products = getattr(obj, 'event_products_all', None) + if event_products is None: + # Fallback query (should be avoided if queryset is optimized) + event_products = obj.event_products.select_related('event').all() + for ep in event_products: + if ep.event_id and hasattr(ep, 'event'): + events.append({"id": ep.event_id, "name": ep.event.name}) + return events + + +class EventProductSerializer(serializers.ModelSerializer): + product = ProductSerializer(read_only=True) + product_id = serializers.PrimaryKeyRelatedField( + queryset=Product.objects.all(), write_only=True + ) + + start_selling_date = RoundedDateTimeField() + end_selling_date = RoundedDateTimeField() + + class Meta: + model = EventProduct + fields = [ + 'id', + 'product', # nested read-only + 'product_id', # required in POST/PUT + 'event', + 'start_selling_date', + 'end_selling_date', + ] + + read_only_fields = ["id", "product"] + extra_kwargs = { + "product": { + "help_text": "Detail zboží (jen pro čtení).", + "required": False, + "read_only": True, + }, + "product_id": { + "help_text": "ID zboží, které je povoleno prodávat na akci.", + "required": True, + "write_only": True, + }, + "event": { + "help_text": "ID akce (Event), pro kterou je zboží povoleno.", + "required": True, + }, + "start_selling_date": { + "help_text": "Začátek prodeje v rámci akce (musí spadat do [event.start, event.end]).", + "required": True, + }, + "end_selling_date": { + "help_text": "Konec prodeje v rámci akce (po start_selling_date, také v rámci [event.start, event.end]).", + "required": True, + }, + } + + + def create(self, validated_data): + validated_data["product"] = validated_data.pop("product_id") + return super().create(validated_data) + + def validate(self, data): + product = data.get("product_id") + event = data.get("event") + start = data.get("start_selling_date") + end = data.get("end_selling_date") + + if start >= end: + raise serializers.ValidationError("Datum začátku prodeje musí být dříve než jeho konec.") + + if event and (start < event.start or end > event.end): + raise serializers.ValidationError("Prodej zboží musí být v rámci trvání akce.") + + # When updating, exclude self instance + instance_id = self.instance.id if self.instance else None + + # Check for overlapping EventProducts for the same product/event + overlapping = EventProduct.objects.exclude(id=instance_id).filter( + event=event, + product_id=product, + start_selling_date__lt=end, + end_selling_date__gt=start, + ) + if overlapping.exists(): + raise serializers.ValidationError("Toto zboží už se prodává v tomto období na této akci.") + + # # Check for duplicate product-event pair + # duplicate = EventProduct.objects.exclude(id=instance_id).filter( + # event=event, + # product_id=product, + # ) + # if duplicate.exists(): + # raise serializers.ValidationError(f"V rámci akce {event} už je {product} zaregistrováno.") + + return data diff --git a/backend/product/tests.py b/backend/product/tests.py new file mode 100644 index 0000000..408e6f5 --- /dev/null +++ b/backend/product/tests.py @@ -0,0 +1,66 @@ +from django.test import TestCase +from django.utils import timezone +from datetime import timedelta +from django.core.exceptions import ValidationError + +from booking.models import Square, Event +from .models import Product, EventProduct + + +class EventProductDateComparisonTests(TestCase): + def setUp(self): + self.square = Square.objects.create( + name="Test Square", + street="Test Street", + city="Test City", + psc=12345, + width=10, + height=10, + grid_rows=10, + grid_cols=10, + cellsize=10, + ) + today = timezone.now().date() + self.event = Event.objects.create( + name="Test Event", + square=self.square, + start=today, + end=today + timedelta(days=2), + price_per_m2=10, + ) + self.product = Product.objects.create(name="Prod 1") + + def test_event_product_inside_event_range_passes(self): + now = timezone.now() + ep = EventProduct( + product=self.product, + event=self.event, + start_selling_date=now, + end_selling_date=now + timedelta(hours=2), + ) + # Should not raise (specifically regression for datetime.date vs datetime comparison) + ep.full_clean() # Will call clean() + ep.save() + self.assertIsNotNone(ep.id) + + def test_event_product_outside_event_range_fails(self): + now = timezone.now() + ep = EventProduct( + product=self.product, + event=self.event, + start_selling_date=now - timedelta(days=1), # before event start + end_selling_date=now, + ) + with self.assertRaises(ValidationError): + ep.full_clean() + + def test_event_product_end_after_event_range_fails(self): + now = timezone.now() + ep = EventProduct( + product=self.product, + event=self.event, + start_selling_date=now, + end_selling_date=now + timedelta(days=5), # after event end + ) + with self.assertRaises(ValidationError): + ep.full_clean() diff --git a/backend/product/urls.py b/backend/product/urls.py new file mode 100644 index 0000000..4c8f909 --- /dev/null +++ b/backend/product/urls.py @@ -0,0 +1,12 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import ProductViewSet, EventProductViewSet + + +router = DefaultRouter() +router.register(r'products', ProductViewSet, basename='products') +router.register(r'event-products', EventProductViewSet, basename='event-products') + +urlpatterns = [ + path('', include(router.urls)), +] \ No newline at end of file diff --git a/backend/product/views.py b/backend/product/views.py new file mode 100644 index 0000000..d4b460e --- /dev/null +++ b/backend/product/views.py @@ -0,0 +1,50 @@ +from rest_framework import viewsets +from django.db import models +from .models import Product, EventProduct +from .serializers import ProductSerializer, EventProductSerializer +from rest_framework.permissions import IsAuthenticated +from account.permissions import RoleAllowed + +from rest_framework import viewsets, filters +from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.utils import extend_schema + +@extend_schema( + tags=["Product"], + description="Seznam produktů, jejich vytváření a úprava. Produkty lze filtrovat a třídit dle názvu nebo kódu." +) +class ProductViewSet(viewsets.ModelViewSet): + queryset = ( + Product.objects.all() + .prefetch_related( + models.Prefetch( + 'event_products', + queryset=EventProduct.objects.select_related('event').all(), + to_attr='event_products_all' + ) + ) + .order_by("name") + ) + serializer_class = ProductSerializer + filter_backends = [DjangoFilterBackend, filters.OrderingFilter, filters.SearchFilter] + filterset_fields = ["code"] + ordering_fields = ["name", "code"] + search_fields = ["name", "code", "event_products__event__name"] + + permission_classes = [RoleAllowed("admin", "squareManager")] + + +@extend_schema( + tags=["EventProduct"], + description="Propojení produktů s událostmi. Zde se nastavují data prodeje konkrétního produktu na konkrétní události." +) +class EventProductViewSet(viewsets.ModelViewSet): + # queryset = EventProduct.objects.select_related("product", "event").all().order_by("start_selling_date") + queryset = EventProduct.objects.select_related("product").order_by("start_selling_date") + serializer_class = EventProductSerializer + filter_backends = [DjangoFilterBackend, filters.OrderingFilter, filters.SearchFilter] + filterset_fields = ["product", "event"] + ordering_fields = ["start_selling_date", "end_selling_date"] + search_fields = ["product__name", "event__name"] + + permission_classes = [RoleAllowed("admin", "squareManager")] diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..657bd90 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,89 @@ +# -- BASE -- +requests + +pip +python-dotenv # .env support +virtualenv #venv + +Django + +numpy # NumPy je knihovna programovacího jazyka Python, která poskytuje infrastrukturu pro práci s vektory, maticemi a obecně vícerozměrnými poli. + + +# -- DATABASE -- +sqlparse #non-validating SQL parser for Python. It provides support for parsing, splitting and formatting SQL statements. +tzdata #timezone + + +psycopg[binary] #PostgreSQL database adapter for the Python + +django-filter + +django-constance #allows you to store and manage settings of page in the Django admin interface!!!! + +# -- OBJECT STORAGE -- +Pillow #adds image processing capabilities to your Python interpreter + +whitenoise #pomáha se spuštěním serveru a načítaní static files + + +django-cleanup #odstraní zbytečné media soubory které nejsou v databázi/modelu +django-storages # potřeba k S3 bucket storage +boto3 + + +# -- PROTOCOLS (asgi, websockets) -- +redis + +channels_redis + +channels #django channels + +#channels requried package +uvicorn[standard] +daphne + +gunicorn + +Twisted[tls,http2] #slouží aby fungovali jwt a CORS bezpečnost na localhostu + +# -- REST API -- +djangorestframework #REST Framework + +djangorestframework-api-key #API key + +djangorestframework-simplejwt #JWT authentication for Django REST Framework +PyJWT #JSON Web Token implementation in Python + +asgiref #ASGI reference implementation, to be used with Django Channels +pytz +# pytz brings the Olson tz database into Python and allows +# accurate and cross platform timezone calculations. +# It also solves the issue of ambiguous times at the end of daylight saving time. + +#documentation for frontend dev +drf-spectacular + +# -- APPS -- + +django-tinymce + +django-cors-headers #csfr + +celery #slouží k vytvaření asynchoních úkolu (třeba každou hodinu vyčistit cache atd.) +django-celery-beat #slouží k plánování úkolů pro Celery + + +# -- EDITING photos, gifs, videos -- + +#aiofiles +#opencv-python #moviepy use this better instead of pillow +#moviepy + +#yt-dlp + +weasyprint #tvoření PDFek z html dokumentu + css styly + +## -- MISCELLANEOUS -- + +faker #generates fake data for testing purposes \ No newline at end of file diff --git a/backend/servicedesk/__init__.py b/backend/servicedesk/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/servicedesk/admin.py b/backend/servicedesk/admin.py new file mode 100644 index 0000000..a5da8f9 --- /dev/null +++ b/backend/servicedesk/admin.py @@ -0,0 +1,30 @@ +from django.contrib import admin +from .models import ServiceTicket +from trznice.admin import custom_admin_site + + +class ServiceTicketAdmin(admin.ModelAdmin): + list_display = ("id", "title", "status", "user", "created_at", "is_deleted") + list_filter = ("status", "is_deleted") + search_fields = ("title", "description", "user__username", "user__email") + ordering = ("-created_at",) + + readonly_fields = ['created_at'] + base_fields = ['title', 'category', 'description', 'user', 'status', 'created_at'] + + + def get_fields(self, request, obj=None): + fields = self.base_fields.copy() + if request.user.role == "admin": + fields += ['is_deleted', 'deleted_at'] + return fields + + def get_queryset(self, request): + # Use the all_objects manager to show even soft-deleted entries + if request.user.role == "admin": + qs = self.model.all_objects.all() + else: + qs = self.model.objects.all() + return qs + +custom_admin_site.register(ServiceTicket, ServiceTicketAdmin) \ No newline at end of file diff --git a/backend/servicedesk/apps.py b/backend/servicedesk/apps.py new file mode 100644 index 0000000..eca6f7c --- /dev/null +++ b/backend/servicedesk/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ServicedeskConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'servicedesk' diff --git a/backend/servicedesk/filters.py b/backend/servicedesk/filters.py new file mode 100644 index 0000000..e72d41e --- /dev/null +++ b/backend/servicedesk/filters.py @@ -0,0 +1,11 @@ +import django_filters +from .models import ServiceTicket + +class ServiceTicketFilter(django_filters.FilterSet): + user = django_filters.NumberFilter(field_name="user__id") + status = django_filters.ChoiceFilter(choices=ServiceTicket.STATUS_CHOICES) + category = django_filters.ChoiceFilter(choices=ServiceTicket.CATEGORY_CHOICES) + + class Meta: + model = ServiceTicket + fields = ["user", "status", "category"] diff --git a/backend/servicedesk/migrations/0001_initial.py b/backend/servicedesk/migrations/0001_initial.py new file mode 100644 index 0000000..79ffc82 --- /dev/null +++ b/backend/servicedesk/migrations/0001_initial.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2.4 on 2025-08-07 15:13 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ServiceTicket', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_deleted', models.BooleanField(default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('title', models.CharField(max_length=255, verbose_name='Název')), + ('description', models.TextField(blank=True, null=True, verbose_name='Popis problému')), + ('status', models.CharField(blank=True, choices=[('new', 'Nový'), ('in_progress', 'Řeší se'), ('resolved', 'Vyřešeno'), ('closed', 'Uzavřeno')], default='new', max_length=20, verbose_name='Stav')), + ('category', models.CharField(blank=True, choices=[('tech', 'Technická chyba'), ('reservation', 'Chyba při rezervaci'), ('payment', 'Problém s platbou'), ('account', 'Problém s účtem'), ('content', 'Nesrovnalost v obsahu'), ('suggestion', 'Návrh na zlepšení'), ('other', 'Jiný')], default='tech', max_length=20, verbose_name='Kategorie')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Datum')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tickets', to=settings.AUTH_USER_MODEL, verbose_name='Zadavatel')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/backend/servicedesk/migrations/__init__.py b/backend/servicedesk/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/servicedesk/models.py b/backend/servicedesk/models.py new file mode 100644 index 0000000..7e2fa54 --- /dev/null +++ b/backend/servicedesk/models.py @@ -0,0 +1,32 @@ +from django.db import models +from django.conf import settings +from trznice.models import SoftDeleteModel + +class ServiceTicket(SoftDeleteModel): + STATUS_CHOICES = [ + ("new", "Nový"), + ("in_progress", "Řeší se"), + ("resolved", "Vyřešeno"), + ("closed", "Uzavřeno"), + ] + + CATEGORY_CHOICES = [ + ("tech", "Technická chyba"), + ("reservation", "Chyba při rezervaci"), + ("payment", "Problém s platbou"), + ("account", "Problém s účtem"), + ("content", "Nesrovnalost v obsahu"), + ("suggestion", "Návrh na zlepšení"), + ("other", "Jiný"), + ] + + title = models.CharField(max_length=255, verbose_name="Název") + description = models.TextField(verbose_name="Popis problému", null=True, blank=True) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, verbose_name="Zadavatel", related_name="tickets", null=False, blank=False) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="new", verbose_name="Stav", blank=True) + category = models.CharField(max_length=20, choices=CATEGORY_CHOICES, default="tech", verbose_name="Kategorie", blank=True) + created_at = models.DateTimeField(auto_now_add=True, verbose_name="Datum", editable=False) + + def __str__(self): + return f"{self.title} ({self.get_status_display()})" + \ No newline at end of file diff --git a/backend/servicedesk/serializers.py b/backend/servicedesk/serializers.py new file mode 100644 index 0000000..bcec498 --- /dev/null +++ b/backend/servicedesk/serializers.py @@ -0,0 +1,47 @@ +from rest_framework import serializers + +from .models import ServiceTicket +from account.models import CustomUser + + +class ServiceTicketSerializer(serializers.ModelSerializer): + class Meta: + model = ServiceTicket + fields = [ + "id", "title", "description", "user", + "status", "category", "created_at" + ] + read_only_fields = ["id", "created_at"] + + extra_kwargs = { + "title": {"help_text": "Stručný název požadavku", "required": True}, + "description": {"help_text": "Detailní popis problému", "required": False}, + "user": {"help_text": "ID uživatele, který požadavek zadává", "required": True}, + "status": {"help_text": "Stav požadavku (new / in_progress / resolved / closed)", "required": False}, + "category": {"help_text": "Kategorie požadavku (tech / reservation / payment / account / content / suggestion / other)", "required": True}, + } + + def validate(self, data): + user = data.get("user", None) + + # if user is None: + # raise serializers.ValidationError("Product is a required field.") + # # Check if user exists in DB + # if not CustomUser.objects.filter(pk=user.pk if hasattr(user, 'pk') else user).exists(): + # raise serializers.ValidationError("Neplatné ID Užívatele.") + + # Example validation: status must be one of the defined choices + if "status" in data and data["status"] not in dict(ServiceTicket.STATUS_CHOICES): + raise serializers.ValidationError({"status": "Neplatný stav požadavku."}) + + if "category" in data and data["category"] not in dict(ServiceTicket.CATEGORY_CHOICES): + raise serializers.ValidationError({"category": "Neplatná kategorie požadavku."}) + + title = data.get("title", "").strip() + if not title: + raise serializers.ValidationError("Název požadavku nemůže být prázdný.") + if len(title) > 255: + raise serializers.ValidationError("Název požadavku nemůže být delší než 255 znaků.") + data["title"] = title # Optional: overwrite with trimmed version + + return data diff --git a/backend/servicedesk/tests.py b/backend/servicedesk/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/servicedesk/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/servicedesk/urls.py b/backend/servicedesk/urls.py new file mode 100644 index 0000000..ffb247b --- /dev/null +++ b/backend/servicedesk/urls.py @@ -0,0 +1,10 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import ServiceTicketViewSet + +router = DefaultRouter() +router.register(r'', ServiceTicketViewSet, basename='tickets') + +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/backend/servicedesk/views.py b/backend/servicedesk/views.py new file mode 100644 index 0000000..c56bf5a --- /dev/null +++ b/backend/servicedesk/views.py @@ -0,0 +1,84 @@ +from rest_framework import viewsets, filters +from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.utils import extend_schema +from django.contrib.auth import get_user_model + +from .models import ServiceTicket +from .serializers import ServiceTicketSerializer +from .filters import ServiceTicketFilter +from account.email import send_email_with_context + +from rest_framework.permissions import IsAuthenticated +from rest_framework.exceptions import PermissionDenied + +# from account.permissions import RoleAllowed + + +@extend_schema( + tags=["ServiceTicket"], + description="Správa uživatelských požadavků – vytvoření, úprava a výpis. Filtrování podle stavu, urgence, uživatele atd." +) +class ServiceTicketViewSet(viewsets.ModelViewSet): + # queryset = ServiceTicket.objects.select_related("user").all().order_by("-created_at") + queryset = ServiceTicket.objects.all().order_by("-created_at") + serializer_class = ServiceTicketSerializer + filter_backends = [DjangoFilterBackend, filters.OrderingFilter, filters.SearchFilter] + filterset_class = ServiceTicketFilter + ordering_fields = ["created_at"] + search_fields = ["title", "description", "user__username"] + permission_classes = [IsAuthenticated] + + def get_queryset(self): + user = self.request.user + if user.role in ["admin", "cityClerk"]: # Adjust as needed for staff roles + # return ServiceTicket.objects.select_related("user").all().order_by("-created_at") + return ServiceTicket.objects.all().order_by("-created_at") + else: + # return ServiceTicket.objects.select_related("user").filter(user=user).order_by("-created_at") + return ServiceTicket.objects.filter(user=user).order_by("-created_at") + + def get_object(self): + obj = super().get_object() + if self.request.user.role not in ["admin", "cityClerk"] and obj.user != self.request.user: + raise PermissionDenied("Nemáte oprávnění pracovat s tímto požadavkem.") + return obj + + def perform_create(self, serializer): + user_request = serializer.save(user=self.request.user) + + # Map categories to roles responsible for handling them + category_role_map = { + "tech": "admin", + "reservation": "cityClerk", + "payment": "admin", + "account": "admin", + "content": "admin", + "suggestion": "admin", + "other": "admin" + } + + role = category_role_map.get(user_request.category) + if not role: + return # Or log: unknown category, no notification sent + + User = get_user_model() + recipients = User.objects.filter(role=role, email__isnull=False).exclude(email="").values_list("email", flat=True) + + if not recipients: + recipients = User.objects.filter(role='admin', email__isnull=False).exclude(email="").values_list("email", flat=True) + if not recipients: + return + + subject = "Nový uživatelský požadavek" + message = f""" + Nový požadavek byl vytvořen: + + Název: {user_request.title} + Kategorie: {user_request.get_category_display()} + Popis: {user_request.description or "—"} + Vytvořeno: {user_request.created_at.strftime('%d.%m.%Y %H:%M')} + Zadal: {user_request.user.get_full_name()} ({user_request.user.email}) + + Spravujte požadavky v systému. + """ + send_email_with_context(list(recipients), subject, message) diff --git a/backend/templates/emails/create_password.html b/backend/templates/emails/create_password.html new file mode 100644 index 0000000..a0e427a --- /dev/null +++ b/backend/templates/emails/create_password.html @@ -0,0 +1,15 @@ + + + + + Váš přístup do systému e-Rezervace + + +

Dobrý den {{ username }},

+

byl vám vytvořen účet v systému e-Rezervace.

+

Přihlašte se kliknutím na následující odkaz:

+

{{ login_url }}

+
+

S pozdravem,
Váš tým

+ + diff --git a/backend/templates/emails/create_password.txt b/backend/templates/emails/create_password.txt new file mode 100644 index 0000000..0654f21 --- /dev/null +++ b/backend/templates/emails/create_password.txt @@ -0,0 +1,8 @@ +Dobrý den {{ username }}, + +byl vám vytvořen účet v systému e-Rezervace. Přihlašte se přes následující odkaz: + +{{ login_url }} + +S pozdravem, +Váš tým diff --git a/backend/templates/html/index.html b/backend/templates/html/index.html new file mode 100644 index 0000000..5b7a001 --- /dev/null +++ b/backend/templates/html/index.html @@ -0,0 +1,74 @@ +{% load static %} + + + + + + + Home - Backend + + + + + {% if user.is_authenticated %} +

Logged as: {{user.username}} | Role: {{user.role}}

+ {% endif %} + + + + + + + \ No newline at end of file diff --git a/backend/templates/login.html b/backend/templates/login.html new file mode 100644 index 0000000..e69de29 diff --git a/backend/trznice/__init__.py b/backend/trznice/__init__.py new file mode 100644 index 0000000..fb989c4 --- /dev/null +++ b/backend/trznice/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ('celery_app',) diff --git a/backend/trznice/admin.py b/backend/trznice/admin.py new file mode 100644 index 0000000..53f63c9 --- /dev/null +++ b/backend/trznice/admin.py @@ -0,0 +1,48 @@ +from django.contrib.admin import AdminSite +from django.contrib import admin +from django_celery_beat.models import PeriodicTask, IntervalSchedule, CrontabSchedule, SolarSchedule, ClockedSchedule + + +class RoleBasedAdminSite(AdminSite): + site_header = "Tržiště Admin" + site_title = "Tržiště Admin" + index_title = "Přehled" + + def get_app_list(self, request): + app_list = super().get_app_list(request) + + if not hasattr(request.user, "role"): + return [] + + role = request.user.role + + # define allowed models per role + role_model_access = { + "squareManager": ["Square", "Event", "MarketSlot", "Product", "EventProduct"], + "cityClerk": ["CustomUser", "Event", "MarketSlot", "Reservation", "Product", "EventProduct", "ServiceTicket"], + # admin will see everything + } + + # only restrict if user has limited access + if role in role_model_access: + allowed = role_model_access[role] + + for app in app_list: + app["models"] = [ + model for model in app["models"] + if model["object_name"] in allowed + ] + + return app_list + + +# Initialize the custom admin site +custom_admin_site = RoleBasedAdminSite(name='custom_admin') + + +# # Register your models to the custom admin site +custom_admin_site.register(PeriodicTask) +custom_admin_site.register(IntervalSchedule) +custom_admin_site.register(CrontabSchedule) +custom_admin_site.register(SolarSchedule) +custom_admin_site.register(ClockedSchedule) diff --git a/backend/trznice/asgi.py b/backend/trznice/asgi.py new file mode 100644 index 0000000..bfb1653 --- /dev/null +++ b/backend/trznice/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for trznice project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'trznice.settings') + +application = get_asgi_application() diff --git a/backend/trznice/celery.py b/backend/trznice/celery.py new file mode 100644 index 0000000..272ef5f --- /dev/null +++ b/backend/trznice/celery.py @@ -0,0 +1,18 @@ +import os +from celery import Celery +from django.conf import settings + +# Nastav environment variable pro Django settings modul +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'trznice.settings') + +app = Celery('trznice') + +# Načti konfiguraci z Django settings (prefix "CELERY_") +app.config_from_object('django.conf:settings', namespace='CELERY') + +# Automaticky najdi tasks.py ve všech appkách +# app.autodiscover_tasks() +app.autodiscover_tasks(lambda: settings.INSTALLED_APPS) + +# Optional but recommended for beat to use DB scheduler +# from django_celery_beat.schedulers import DatabaseScheduler \ No newline at end of file diff --git a/backend/trznice/models.py b/backend/trznice/models.py new file mode 100644 index 0000000..a4e05d8 --- /dev/null +++ b/backend/trznice/models.py @@ -0,0 +1,61 @@ +from django.db import models +from django.utils import timezone + +class ActiveManager(models.Manager): + def get_queryset(self): + return super().get_queryset().filter(is_deleted=False) + +class AllManager(models.Manager): + def get_queryset(self): + return super().get_queryset() + +# How to use custom object Managers: add these fields to your model, to override objects behaviour and all_objects behaviour +# objects = ActiveManager() +# all_objects = AllManager() + + +class SoftDeleteModel(models.Model): + is_deleted = models.BooleanField(default=False) + deleted_at = models.DateTimeField(null=True, blank=True) + + def delete(self, using=None, keep_parents=False): + self.is_deleted = True + self.deleted_at = timezone.now() + self.save() + + objects = ActiveManager() + all_objects = AllManager() + + class Meta: + abstract = True + + def delete(self, *args, **kwargs): + # Soft delete self + self.is_deleted = True + self.deleted_at = timezone.now() + self.save() + + def hard_delete(self, using=None, keep_parents=False): + super().delete(using=using, keep_parents=keep_parents) + + + +# SiteSettings model for managing site-wide settings +"""class SiteSettings(models.Model): + bank = models.CharField(max_length=100, blank=True) + support_email = models.EmailField(blank=True) + logo = models.ImageField(upload_to='settings/', blank=True, null=True) + + def __str__(self): + return "Site Settings" + + class Meta: + verbose_name = "Site Settings" + verbose_name_plural = "Site Settings" + + @classmethod + def get_solo(cls): + obj, created = cls.objects.get_or_create(id=1) + return obj + +""" diff --git a/backend/trznice/settings.py b/backend/trznice/settings.py new file mode 100644 index 0000000..cbae117 --- /dev/null +++ b/backend/trznice/settings.py @@ -0,0 +1,951 @@ +""" +Django settings for trznice project. + +Generated by 'django-admin startproject' using Django 5.1.3. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.1/ref/settings/ +""" +import os +from typing import Dict, Any +from pathlib import Path + +from django.core.management.utils import get_random_secret_key +from django.db import OperationalError, connections + +from datetime import timedelta + +from dotenv import load_dotenv +load_dotenv() # Pouze načte proměnné lokálně, pokud nejsou dostupné + +#---------------- ENV VARIABLES USECASE-------------- +# v jiné app si to importneš skrz: from django.conf import settings +# a použiješ takto: settings.FRONTEND_URL + +FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:5173") +#-------------------------BASE ⚙️------------------------ + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Pavel +# from django.conf.locale.en import formats as en_formats + +DATETIME_INPUT_FORMATS = [ + "%Y-%m-%d", # '2025-07-25' + "%Y-%m-%d %H:%M", # '2025-07-25 14:30' + "%Y-%m-%d %H:%M:%S", # '2025-07-25 14:30:59' + "%Y-%m-%dT%H:%M", # '2025-07-25T14:30' + "%Y-%m-%dT%H:%M:%S", # '2025-07-25T14:30:59' +] + +LANGUAGE_CODE = 'cs' + +TIME_ZONE = 'Europe/Prague' + +USE_I18N = True + +USE_TZ = True + + + + +# SECURITY WARNING: don't run with debug turned on in production! +if os.getenv("DEBUG", "") == "True": + DEBUG = True +else: + DEBUG = False + +print(f"\nDEBUG state: {str(DEBUG)}\nDEBUG .env raw: {os.getenv('DEBUG', '')}\n") + +#-----------------------BASE END⚙️-------------------------- + +#--------------- URLS 🌐 ------------------- + +ASGI_APPLICATION = 'trznice.asgi.application' #daphne +ROOT_URLCONF = 'trznice.urls' +LOGIN_URL = '/admin' #nastavení Login adresy + +#----------------------------------------- + + + +#----------------------------------- LOGS ------------------------------------------- +#slouží pro tisknutí do konzole v dockeru skrz: logger.debug("content") +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "verbose", + }, + }, + "formatters": { + "verbose": { + "format": "{levelname} {asctime} {name}: {message}", + "style": "{", + }, + }, + "root": { + "handlers": ["console"], + "level": "DEBUG" if DEBUG else "INFO", + }, +} + +""" +import logging + +# Vytvoř si logger podle názvu souboru (modulu) +logger = logging.getLogger(__name__) + + +logger.debug("Ladicí zpráva – vidíš jen když je DEBUG = True") +logger.info("Informace – např. že uživatel klikl na tlačítko") +logger.warning("Varování – něco nečekaného, ale ne kritického") +logger.error("Chyba – něco se pokazilo, ale aplikace jede dál") +logger.critical("Kritická chyba – selhání systému, třeba pád služby") +""" + +#---------------------------------- END LOGS --------------------------------------- + +#-------------------------------------SECURITY 🔐------------------------------------ + +if DEBUG: + SECRET_KEY = 'pernament' +else: + SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", get_random_secret_key()) + +# Honor reverse proxy host/port even without SSL +USE_X_FORWARDED_HOST = True +# Optionally honor proto if you terminate SSL at proxy +# SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') + +SESSION_COOKIE_AGE = 86400 # one day + + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + +AUTHENTICATION_BACKENDS = [ + #'trznice.backend.EmailOrUsernameModelBackend', #custom backend z authentication aplikace + 'django.contrib.auth.backends.ModelBackend', +] + +#--------------------------------END SECURITY 🔐------------------------------------- + +#-------------------------------------CORS + HOSTs 🌐🔐------------------------------------ + +ALLOWED_HOSTS = ["*"] + +from urllib.parse import urlparse +parsed = urlparse(FRONTEND_URL) + +CSRF_TRUSTED_ORIGINS = [ + f"{parsed.scheme}://{parsed.hostname}:{parsed.port or (443 if parsed.scheme=='https' else 80)}", + + "http://192.168.67.98", + "https://itsolutions.vontor.cz", + "https://react.vontor.cz", + + "http://localhost:5173", + "http://localhost:3000", + + "http://127.0.0.1:5173", + "http://127.0.0.1:3000", + + #server + "http://192.168.67.98", + "https://itsolutions.vontor.cz", + "https://react.vontor.cz", + + #nginx docker (local) + "http://localhost", + "http://localhost:80", + "http://127.0.0.1", +] + +if DEBUG: + CORS_ALLOWED_ORIGINS = [ + f"{parsed.scheme}://{parsed.hostname}:{parsed.port or (443 if parsed.scheme=='https' else 80)}", + + "http://localhost:5173", + "http://localhost:3000", + "http://127.0.0.1:5173", + "http://127.0.0.1:3000", + + #server + "http://192.168.67.98", + "https://itsolutions.vontor.cz", + "https://react.vontor.cz", + + #nginx docker (local) + "http://localhost", + "http://localhost:80", + "http://127.0.0.1", + ] +else: + CORS_ALLOWED_ORIGINS = [ + "http://192.168.67.98", + "https://itsolutions.vontor.cz", + "https://react.vontor.cz", + ] + +CORS_ALLOW_CREDENTIALS = True +CORS_ALLOW_ALL_ORIGINS = False # Tohle musí být false, když používáš credentials + +SESSION_COOKIE_SAMESITE = None +CSRF_COOKIE_SAMESITE = None + +print("CORS_ALLOWED_ORIGINS =", CORS_ALLOWED_ORIGINS) +print("CSRF_TRUSTED_ORIGINS =", CSRF_TRUSTED_ORIGINS) +print("ALLOWED_HOSTS =", ALLOWED_HOSTS) + + + +#--------------------------------END CORS + HOSTs 🌐🔐--------------------------------- + + +#--------------------------------------SSL 🧾------------------------------------ + +if os.getenv("SSL", "") == "True": + USE_SSL = True +else: + USE_SSL = False + + +if USE_SSL is True: + print("SSL turned on!") + SESSION_COOKIE_SECURE = True + CSRF_COOKIE_SECURE = True + SECURE_SSL_REDIRECT = True + SECURE_BROWSER_XSS_FILTER = True + SECURE_CONTENT_TYPE_NOSNIFF = True + # USE_X_FORWARDED_HOST stays True (set above) + SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') +else: + SESSION_COOKIE_SECURE = False + CSRF_COOKIE_SECURE = False + SECURE_SSL_REDIRECT = False + SECURE_BROWSER_XSS_FILTER = False + SECURE_CONTENT_TYPE_NOSNIFF = False + # USE_X_FORWARDED_HOST stays True (set above) +print(f"\nUsing SSL: {USE_SSL}\n") + +#--------------------------------END-SSL 🧾--------------------------------- + + + + + +#-------------------------------------REST FRAMEWORK 🛠️------------------------------------ + +# ⬇️ Základní lifetime konfigurace +ACCESS_TOKEN_LIFETIME = timedelta(minutes=60) +REFRESH_TOKEN_LIFETIME = timedelta(days=5) + +# ⬇️ Nastavení SIMPLE_JWT podle režimu +if DEBUG: + SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": ACCESS_TOKEN_LIFETIME, + "REFRESH_TOKEN_LIFETIME": REFRESH_TOKEN_LIFETIME, + + "AUTH_COOKIE": "access_token", + "AUTH_COOKIE_REFRESH": "refresh_token", + + "AUTH_COOKIE_DOMAIN": None, + "AUTH_COOKIE_SECURE": False, + "AUTH_COOKIE_HTTP_ONLY": True, + + "ROTATE_REFRESH_TOKENS": False, + "BLACKLIST_AFTER_ROTATION": False, + + "AUTH_COOKIE_PATH": "/", + "AUTH_COOKIE_SAMESITE": "Lax", # change to "None" only if you serve via HTTPS; keep Lax if using same-origin + # ...existing code... + + } +else: + SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": ACCESS_TOKEN_LIFETIME, + "REFRESH_TOKEN_LIFETIME": REFRESH_TOKEN_LIFETIME, + + "AUTH_COOKIE": "access_token", + "AUTH_COOKIE_REFRESH": "refresh_token", # ensure refresh cookie is recognized/used + "AUTH_COOKIE_DOMAIN": None, + "AUTH_COOKIE_SECURE": True, # HTTPS only + "AUTH_COOKIE_HTTP_ONLY": True, + "AUTH_COOKIE_PATH": "/", + "AUTH_COOKIE_SAMESITE": "None", # potřebné pro cross-origin + + "ROTATE_REFRESH_TOKENS": True, + "BLACKLIST_AFTER_ROTATION": True, + } + + +REST_FRAMEWORK = { + "DATETIME_FORMAT": "%Y-%m-%d %H:%M", + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'account.tokens.CookieJWTAuthentication', + 'rest_framework_simplejwt.authentication.JWTAuthentication', # <-- allow Bearer Authorization + 'rest_framework.authentication.SessionAuthentication', + ), + 'DEFAULT_PERMISSION_CLASSES': ( + 'rest_framework.permissions.AllowAny', + ), + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', + 'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'], +} + + +#--------------------------------END REST FRAMEWORK 🛠️------------------------------------- + + + +#-------------------------------------APPS 📦------------------------------------ +MY_CREATED_APPS = [ + 'account', + 'booking', + 'product', + 'servicedesk', + 'commerce', + 'configuration', +] + +INSTALLED_APPS = [ + 'daphne', #asgi bude fungovat lokálně (musí být na začátku) + + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + + 'corsheaders', #cors + + 'django_celery_beat', #slouží k plánování úkolů pro Celery + + + #'chat.apps.GlobalChatCheck', #tohle se spusti při každé django inicializaci (migration, createmigration, runserver) + + #'authentication', + + 'storages',# Adds support for external storage services like Amazon S3 via django-storages + 'django_filters', + + 'channels' ,# django channels + + 'rest_framework', + 'rest_framework_api_key', + 'rest_framework_simplejwt.token_blacklist', + + 'drf_spectacular', #rest framework, grafické zobrazení + + #Nastavení stránky + #'constance', + #'constance.backends.database', + + 'django.contrib.sitemaps', + + 'tinymce', + + + #kvůli bugum je lepší to dát na poslední místo v INSTALLED_APPS + 'django_cleanup.apps.CleanupConfig', #app která maže nepoužité soubory(media) z databáze na S3 +] + +#skládaní dohromady INSTALLED_APPS +INSTALLED_APPS = INSTALLED_APPS[:-1] + MY_CREATED_APPS + INSTALLED_APPS[-1:] + +# -------------------------------------END APPS 📦------------------------------------ + + + + + +#-------------------------------------MIDDLEWARE 🧩------------------------------------ +# Middleware is a framework of hooks into Django's request/response processing. + +MIDDLEWARE = [ + # Middleware that allows your backend to accept requests from other domains (CORS) + "corsheaders.middleware.CorsMiddleware", + "django.middleware.common.CommonMiddleware", + + + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + + #CUSTOM + #'tools.middleware.CustomMaxUploadSizeMiddleware', + + + 'whitenoise.middleware.WhiteNoiseMiddleware',# díky tomu funguje načítaní static files +] + +#--------------------------------END MIDDLEWARE 🧩--------------------------------- + + + + + +#-------------------------------------CACHE + CHANNELS(ws) 📡🗄️------------------------------------ + +# Caching settings for Redis (using Docker's internal network name for Redis) +if DEBUG is False: + #PRODUCTION + CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.redis.RedisCache', + 'LOCATION': 'redis://redis:6379/0', # Using the service name `redis` from Docker Compose + 'OPTIONS': { + 'CLIENT_CLASS': 'django_redis.client.DefaultClient', + 'PASSWORD': os.getenv('REDIS_PASSWORD'), # Make sure to set REDIS_PASSWORD in your environment + }, + } + } + + # WebSockets Channel Layers (using Redis in production) + CHANNEL_LAYERS = { + 'default': { + 'BACKEND': 'channels_redis.core.RedisChannelLayer', + 'CONFIG': { + 'hosts': [('redis', 6379)], # Use `redis` service in Docker Compose + }, + } + } + +else: + #DEVELOPMENT + # Use in-memory channel layer for development (when DEBUG is True) + CHANNEL_LAYERS = { + 'default': { + 'BACKEND': 'channels.layers.InMemoryChannelLayer', + } + } + + # Use in-memory cache for development (when DEBUG is True) + CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + } + } + +#--------------------------------END CACHE + CHANNELS(ws) 📡🗄️--------------------------------- + +#-------------------------------------CELERY 📅------------------------------------ + +# CELERY_BROKER_URL = 'redis://localhost:6379/0' +CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL") +CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND") + +try: + import redis + # test connection + r = redis.Redis(host='localhost', port=6379, db=0) + r.ping() +except Exception: + CELERY_BROKER_URL = 'memory://' + +CELERY_ACCEPT_CONTENT = os.getenv("CELERY_ACCEPT_CONTENT") +CELERY_TASK_SERIALIZER = os.getenv("CELERY_TASK_SERIALIZER") +CELERY_TIMEZONE = os.getenv("CELERY_TIMEZONE") + +CELERY_BEAT_SCHEDULER = os.getenv("CELERY_BEAT_SCHEDULER") +# if DEBUG: +# CELERY_BROKER_URL = 'redis://localhost:6379/0' +# try: +# import redis +# # test connection +# r = redis.Redis(host='localhost', port=6379, db=0) +# r.ping() +# except Exception: +# CELERY_BROKER_URL = 'memory://' + +# CELERY_ACCEPT_CONTENT = ['json'] +# CELERY_TASK_SERIALIZER = 'json' +# CELERY_TIMEZONE = 'Europe/Prague' + +# CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler' + + # from celery.schedules import crontab + + # CELERY_BEAT_SCHEDULE = { + # 'hard_delete_soft_deleted_monthly': { + # 'task': 'trznice.tasks.hard_delete_soft_deleted_records', + # 'schedule': crontab(minute=0, hour=0, day_of_month=1), # každý první den v měsíci o půlnoci + # }, + # 'delete_old_reservations_monthly': { + # 'task': 'account.tasks.delete_old_reservations', + # 'schedule': crontab(minute=0, hour=1, day_of_month=1), # každý první den v měsíci v 1:00 ráno + # }, + # } +# else: +# # Nebo nastav dummy broker, aby se úlohy neodesílaly +# CELERY_BROKER_URL = 'memory://' # broker v paměti, pro testování bez Redis + +#-------------------------------------END CELERY 📅------------------------------------ + + +#-------------------------------------DATABASE 💾------------------------------------ + +# Nastavuje výchozí typ primárního klíče pro modely. +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# říka že se úkladá do databáze, místo do cookie +SESSION_ENGINE = 'django.contrib.sessions.backends.db' + +USE_DOCKER_DB = os.getenv("USE_DOCKER_DB", "False") in ["True", "true", "1", True] + +if USE_DOCKER_DB is False: + # DEV + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', # Database engine + 'NAME': BASE_DIR / 'db.sqlite3', # Path to the SQLite database file + } + } +else: + #DOCKER + DATABASES = { + 'default': { + 'ENGINE': os.getenv('DATABASE_ENGINE'), + 'NAME': os.getenv('POSTGRES_DB'), + 'USER': os.getenv('POSTGRES_USER'), + 'PASSWORD': os.getenv('POSTGRES_PASSWORD'), + 'HOST': os.getenv('DATABASE_HOST'), + 'PORT': os.getenv('DATABASE_PORT'), + } + } + +print(f"\nUsing Docker DB: {USE_DOCKER_DB}\nDatabase settings: {DATABASES}\n") + +AUTH_USER_MODEL = 'account.CustomUser' #class CustomUser(AbstractUser) best practice to use AbstractUser + +#--------------------------------END DATABASE 💾--------------------------------- + +#--------------------------------------PAGE SETTINGS ------------------------------------- +CONSTANCE_BACKEND = 'constance.backends.database.DatabaseBackend' + +# Configuration for Constance(variables) +CONSTANCE_CONFIG = { + 'BITCOIN_WALLET': ('', 'Public BTC wallet address'), + 'SUPPORT_EMAIL': ('admin@example.com', 'Support email'), +} + +#--------------------------------------EMAIL 📧-------------------------------------- + +if DEBUG: + # DEVELOPMENT + EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' # Use console backend for development + # EMAILY SE BUDOU POSÍLAT DO KONZOLE!!! +else: + # PRODUCTION + EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' + +EMAIL_HOST = os.getenv("EMAIL_HOST_DEV") +EMAIL_PORT = int(os.getenv("EMAIL_PORT_DEV", 465)) +EMAIL_USE_TLS = True # ❌ Keep this OFF when using SSL +EMAIL_USE_SSL = False # ✅ Must be True for port 465 +EMAIL_HOST_USER = os.getenv("EMAIL_USER_DEV") +EMAIL_HOST_PASSWORD = os.getenv("EMAIL_USER_PASSWORD_DEV") +DEFAULT_FROM_EMAIL = EMAIL_HOST_USER +EMAIL_TIMEOUT = 10 + +print("---------EMAIL----------\nEMAIL_HOST =", os.getenv("EMAIL_HOST_DEV")) +print("EMAIL_PORT =", os.getenv("EMAIL_PORT_DEV")) +print("EMAIL_USER =", os.getenv("EMAIL_USER_DEV")) +print("EMAIL_USER_PASSWORD =", os.getenv("EMAIL_USER_PASSWORD_DEV"), "\n------------------------") + +#----------------------------------EMAIL END 📧------------------------------------- + + + + +#-------------------------------------TEMPLATES 🗂️------------------------------------ + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + "DIRS": [BASE_DIR / 'templates'], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +#--------------------------------END TEMPLATES 🗂️--------------------------------- + + + + + + + +#-------------------------------------MEDIA + STATIC 🖼️, AWS ☁️------------------------------------ + +# nastavení složky pro globalstaticfiles (static složky django hledá samo) +STATICFILES_DIRS = [ + BASE_DIR / 'globalstaticfiles', +] + + + +if os.getenv("USE_AWS", "") == "True": + USE_AWS = True +else: + USE_AWS = False + +print(f"\n-------------- USE_AWS: {USE_AWS} --------------") + +if USE_AWS is False: + # DEVELOPMENT + + + # Development: Use local file system storage for static files + STORAGES = { + "default": { + "BACKEND": "django.core.files.storage.FileSystemStorage", + }, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", + }, + } + + # Media and Static URL for local dev + MEDIA_URL = os.getenv("MEDIA_URL", "/media/") # URL prefix for media files + MEDIA_ROOT = os.path.join(BASE_DIR, 'media') # Local folder for user-uploaded files + + STATIC_URL = '/static/' + + # Local folder for collected static files + STATIC_ROOT = BASE_DIR / 'collectedstaticfiles' + +elif USE_AWS: + # PRODUCTION + + AWS_LOCATION = "static" + + # Production: Use S3 storage + STORAGES = { + "default": { + "BACKEND" : "storages.backends.s3boto3.S3StaticStorage", + }, + + "staticfiles": { + "BACKEND" : "storages.backends.s3boto3.S3StaticStorage", + }, + } + + # Media and Static URL for AWS S3 + MEDIA_URL = f'https://{os.getenv("AWS_STORAGE_BUCKET_NAME")}.s3.amazonaws.com/media/' + STATIC_URL = f'https://{os.getenv("AWS_STORAGE_BUCKET_NAME")}.s3.amazonaws.com/static/' + + CSRF_TRUSTED_ORIGINS.append(STATIC_URL) + + # Static files should be collected to a local directory and then uploaded to S3 + STATIC_ROOT = BASE_DIR / 'collectedstaticfiles' + + AWS_ACCESS_KEY_ID = os.getenv('AWS_ACCESS_KEY_ID') + AWS_SECRET_ACCESS_KEY = os.getenv('AWS_SECRET_ACCESS_KEY') + AWS_STORAGE_BUCKET_NAME = os.getenv('AWS_STORAGE_BUCKET_NAME') + AWS_S3_REGION_NAME = os.getenv('AWS_S3_REGION_NAME', 'us-east-1') # Default to 'us-east-1' if not set + AWS_S3_SIGNATURE_VERSION = 's3v4' # Use AWS Signature Version 4 + AWS_S3_USE_SSL = True + AWS_S3_FILE_OVERWRITE = True + AWS_DEFAULT_ACL = None # Set to None to avoid setting a default ACL + + + +print(f"Static url: {STATIC_URL}\nStatic storage: {STORAGES}\n----------------------------") + +#--------------------------------END: MEDIA + STATIC 🖼️, AWS ☁️--------------------------------- + + + +#-------------------------------------TINY MCE ✍️------------------------------------ + +TINYMCE_JS_URL = 'https://cdn.tiny.cloud/1/no-api-key/tinymce/7/tinymce.min.js' + +TINYMCE_DEFAULT_CONFIG = { + "height": "320px", + "width": "960px", + "menubar": "file edit view insert format tools table help", + "plugins": "advlist autolink lists link image charmap print preview anchor searchreplace visualblocks code " + "fullscreen insertdatetime media table paste code help wordcount spellchecker", + "toolbar": "undo redo | bold italic underline strikethrough | fontselect fontsizeselect formatselect | alignleft " + "aligncenter alignright alignjustify | outdent indent | numlist bullist checklist | forecolor " + "backcolor casechange permanentpen formatpainter removeformat | pagebreak | charmap emoticons | " + "fullscreen preview save print | insertfile image media pageembed template link anchor codesample | " + "a11ycheck ltr rtl | showcomments addcomment code", + "custom_undo_redo_levels": 10, +} +TINYMCE_SPELLCHECKER = True +TINYMCE_COMPRESSOR = True + +#--------------------------------END-TINY-MCE-SECTION ✍️--------------------------------- + + + +#-------------------------------------DRF SPECTACULAR 📊------------------------------------ + +SPECTACULAR_DEFAULTS: Dict[str, Any] = { + # A regex specifying the common denominator for all operation paths. If + # SCHEMA_PATH_PREFIX is set to None, drf-spectacular will attempt to estimate + # a common prefix. Use '' to disable. + # Mainly used for tag extraction, where paths like '/api/v1/albums' with + # a SCHEMA_PATH_PREFIX regex '/api/v[0-9]' would yield the tag 'albums'. + 'SCHEMA_PATH_PREFIX': None, + + # Remove matching SCHEMA_PATH_PREFIX from operation path. Usually used in + # conjunction with appended prefixes in SERVERS. + 'SCHEMA_PATH_PREFIX_TRIM': False, + + # Insert a manual path prefix to the operation path, e.g. '/service/backend'. + # Use this for example to align paths when the API is mounted as a sub-resource + # behind a proxy and Django is not aware of that. Alternatively, prefixes can + # also specified via SERVERS, but this makes the operation path more explicit. + 'SCHEMA_PATH_PREFIX_INSERT': '', + + # Coercion of {pk} to {id} is controlled by SCHEMA_COERCE_PATH_PK. Additionally, + # some libraries (e.g. drf-nested-routers) use "_pk" suffixed path variables. + # This setting globally coerces path variables like "{user_pk}" to "{user_id}". + 'SCHEMA_COERCE_PATH_PK_SUFFIX': False, + + # Schema generation parameters to influence how components are constructed. + # Some schema features might not translate well to your target. + # Demultiplexing/modifying components might help alleviate those issues. + 'DEFAULT_GENERATOR_CLASS': 'drf_spectacular.generators.SchemaGenerator', + + # Create separate components for PATCH endpoints (without required list) + 'COMPONENT_SPLIT_PATCH': True, + + # Split components into request and response parts where appropriate + # This setting is highly recommended to achieve the most accurate API + # description, however it comes at the cost of having more components. + 'COMPONENT_SPLIT_REQUEST': True, + + # Aid client generator targets that have trouble with read-only properties. + 'COMPONENT_NO_READ_ONLY_REQUIRED': False, + + # Adds "minLength: 1" to fields that do not allow blank strings. Deactivated + # by default because serializers do not strictly enforce this on responses and + # so "minLength: 1" may not always accurately describe API behavior. + # Gets implicitly enabled by COMPONENT_SPLIT_REQUEST, because this can be + # accurately modeled when request and response components are separated. + 'ENFORCE_NON_BLANK_FIELDS': False, + + # This version string will end up the in schema header. The default OpenAPI + # version is 3.0.3, which is heavily tested. We now also support 3.1.0, + # which contains the same features and a few mandatory, but minor changes. + 'OAS_VERSION': '3.0.3', + + # Configuration for serving a schema subset with SpectacularAPIView + 'SERVE_URLCONF': None, + + # complete public schema or a subset based on the requesting user + 'SERVE_PUBLIC': True, + + # include schema endpoint into schema + 'SERVE_INCLUDE_SCHEMA': True, + + # list of authentication/permission classes for spectacular's views. + 'SERVE_PERMISSIONS': ['rest_framework.permissions.AllowAny'], #account.permissions.AdminOnly + + # None will default to DRF's AUTHENTICATION_CLASSES + 'SERVE_AUTHENTICATION': None, + + # Dictionary of general configuration to pass to the SwaggerUI({ ... }) + # https://swagger.io/docs/open-source-tools/swagger-ui/usage/configuration/ + # The settings are serialized with json.dumps(). If you need customized JS, use a + # string instead. The string must then contain valid JS and is passed unchanged. + 'SWAGGER_UI_SETTINGS': { + 'deepLinking': True, + }, + + # Initialize SwaggerUI with additional OAuth2 configuration. + # https://swagger.io/docs/open-source-tools/swagger-ui/usage/oauth2/ + 'SWAGGER_UI_OAUTH2_CONFIG': {}, + + # Dictionary of general configuration to pass to the Redoc.init({ ... }) + # https://redocly.com/docs/redoc/config/#functional-settings + # The settings are serialized with json.dumps(). If you need customized JS, use a + # string instead. The string must then contain valid JS and is passed unchanged. + 'REDOC_UI_SETTINGS': {}, + + # CDNs for swagger and redoc. You can change the version or even host your + # own depending on your requirements. For self-hosting, have a look at + # the sidecar option in the README. + 'SWAGGER_UI_DIST': 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@latest', + 'SWAGGER_UI_FAVICON_HREF': 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@latest/favicon-32x32.png', + 'REDOC_DIST': 'https://cdn.jsdelivr.net/npm/redoc@latest', + + # Append OpenAPI objects to path and components in addition to the generated objects + 'APPEND_PATHS': {}, + 'APPEND_COMPONENTS': {}, + + + # Postprocessing functions that run at the end of schema generation. + # must satisfy interface result = hook(generator, request, public, result) + 'POSTPROCESSING_HOOKS': [ + 'drf_spectacular.hooks.postprocess_schema_enums' + ], + + # Preprocessing functions that run before schema generation. + # must satisfy interface result = hook(endpoints=result) where result + # is a list of Tuples (path, path_regex, method, callback). + # Example: 'drf_spectacular.hooks.preprocess_exclude_path_format' + 'PREPROCESSING_HOOKS': [], + + # Determines how operations should be sorted. If you intend to do sorting with a + # PREPROCESSING_HOOKS, be sure to disable this setting. If configured, the sorting + # is applied after the PREPROCESSING_HOOKS. Accepts either + # True (drf-spectacular's alpha-sorter), False, or a callable for sort's key arg. + 'SORT_OPERATIONS': True, + + # enum name overrides. dict with keys "YourEnum" and their choice values "field.choices" + # e.g. {'SomeEnum': ['A', 'B'], 'OtherEnum': 'import.path.to.choices'} + 'ENUM_NAME_OVERRIDES': {}, + + # Adds "blank" and "null" enum choices where appropriate. disable on client generation issues + 'ENUM_ADD_EXPLICIT_BLANK_NULL_CHOICE': True, + + # Add/Append a list of (``choice value`` - choice name) to the enum description string. + 'ENUM_GENERATE_CHOICE_DESCRIPTION': True, + + # Optional suffix for generated enum. + # e.g. {'ENUM_SUFFIX': "Type"} would produce an enum name 'StatusType'. + 'ENUM_SUFFIX': 'Enum', + + # function that returns a list of all classes that should be excluded from doc string extraction + 'GET_LIB_DOC_EXCLUDES': 'drf_spectacular.plumbing.get_lib_doc_excludes', + + # Function that returns a mocked request for view processing. For CLI usage + # original_request will be None. + # interface: request = build_mock_request(method, path, view, original_request, **kwargs) + 'GET_MOCK_REQUEST': 'drf_spectacular.plumbing.build_mock_request', + + # Camelize names like "operationId" and path parameter names + # Camelization of the operation schema itself requires the addition of + # 'drf_spectacular.contrib.djangorestframework_camel_case.camelize_serializer_fields' + # to POSTPROCESSING_HOOKS. Please note that the hook depends on + # ``djangorestframework_camel_case``, while CAMELIZE_NAMES itself does not. + 'CAMELIZE_NAMES': False, + + # Changes the location of the action/method on the generated OperationId. For example, + # "POST": "group_person_list", "group_person_create" + # "PRE": "list_group_person", "create_group_person" + 'OPERATION_ID_METHOD_POSITION': 'POST', + + # Determines if and how free-form 'additionalProperties' should be emitted in the schema. Some + # code generator targets are sensitive to this. None disables generic 'additionalProperties'. + # allowed values are 'dict', 'bool', None + 'GENERIC_ADDITIONAL_PROPERTIES': 'dict', + + # Path converter schema overrides (e.g. ). Can be used to either modify default + # behavior or provide a schema for custom converters registered with register_converter(...). + # Takes converter labels as keys and either basic python types, OpenApiType, or raw schemas + # as values. Example: {'aint': OpenApiTypes.INT, 'bint': str, 'cint': {'type': ...}} + 'PATH_CONVERTER_OVERRIDES': {}, + + # Determines whether operation parameters should be sorted alphanumerically or just in + # the order they arrived. Accepts either True, False, or a callable for sort's key arg. + 'SORT_OPERATION_PARAMETERS': True, + + # @extend_schema allows to specify status codes besides 200. This functionality is usually used + # to describe error responses, which rarely make use of list mechanics. Therefore, we suppress + # listing (pagination and filtering) on non-2XX status codes by default. Toggle this to enable + # list responses with ListSerializers/many=True irrespective of the status code. + 'ENABLE_LIST_MECHANICS_ON_NON_2XX': False, + + # This setting allows you to deviate from the default manager by accessing a different model + # property. We use "objects" by default for compatibility reasons. Using "_default_manager" + # will likely fix most issues, though you are free to choose any name. + "DEFAULT_QUERY_MANAGER": 'objects', + + # Controls which authentication methods are exposed in the schema. If not None, will hide + # authentication classes that are not contained in the whitelist. Use full import paths + # like ['rest_framework.authentication.TokenAuthentication', ...]. + # Empty list ([]) will hide all authentication methods. The default None will show all. + 'AUTHENTICATION_WHITELIST': None, + # Controls which parsers are exposed in the schema. Works analog to AUTHENTICATION_WHITELIST. + # List of allowed parsers or None to allow all. + 'PARSER_WHITELIST': None, + # Controls which renderers are exposed in the schema. Works analog to AUTHENTICATION_WHITELIST. + # rest_framework.renderers.BrowsableAPIRenderer is ignored by default if whitelist is None + 'RENDERER_WHITELIST': None, + + # Option for turning off error and warn messages + 'DISABLE_ERRORS_AND_WARNINGS': False, + + # Runs exemplary schema generation and emits warnings as part of "./manage.py check --deploy" + 'ENABLE_DJANGO_DEPLOY_CHECK': True, + + # General schema metadata. Refer to spec for valid inputs + # https://spec.openapis.org/oas/v3.0.3#openapi-object + 'TITLE': 'e-Tržnice API', + 'DESCRIPTION': 'This is the API documentation for e-Tržnice.', + 'TOS': None, + # Optional: MAY contain "name", "url", "email" + 'CONTACT': {}, + # Optional: MUST contain "name", MAY contain URL + + 'LICENSE': {}, + # Statically set schema version. May also be an empty string. When used together with + # view versioning, will become '0.0.0 (v2)' for 'v2' versioned requests. + # Set VERSION to None if only the request version should be rendered. + 'VERSION': '1.0.0', + # Optional list of servers. + # Each entry MUST contain "url", MAY contain "description", "variables" + # e.g. [{'url': 'https://example.com/v1', 'description': 'Text'}, ...] + 'SERVERS': [], + # Tags defined in the global scope + 'TAGS': [], + # Optional: List of OpenAPI 3.1 webhooks. Each entry should be an import path to an + # OpenApiWebhook instance. + 'WEBHOOKS': [], + # Optional: MUST contain 'url', may contain "description" + 'EXTERNAL_DOCS': {}, + + # Arbitrary specification extensions attached to the schema's info object. + # https://swagger.io/specification/#specification-extensions + 'EXTENSIONS_INFO': {}, + + # Arbitrary specification extensions attached to the schema's root object. + # https://swagger.io/specification/#specification-extensions + 'EXTENSIONS_ROOT': {}, + + # Oauth2 related settings. used for example by django-oauth2-toolkit. + # https://spec.openapis.org/oas/v3.0.3#oauth-flows-object + 'OAUTH2_FLOWS': [], + 'OAUTH2_AUTHORIZATION_URL': None, + 'OAUTH2_TOKEN_URL': None, + 'OAUTH2_REFRESH_URL': None, + 'OAUTH2_SCOPES': None, +} diff --git a/backend/trznice/urls.py b/backend/trznice/urls.py new file mode 100644 index 0000000..87fc6e1 --- /dev/null +++ b/backend/trznice/urls.py @@ -0,0 +1,59 @@ +""" +URL configuration for trznice project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path, include +from django.contrib.auth import views as auth_views +from django.conf.urls.static import static +from django.conf import settings + +from rest_framework import permissions + +from . import views + +from drf_spectacular.views import ( + SpectacularAPIView, + SpectacularSwaggerView, + SpectacularRedocView, +) + +from .admin import custom_admin_site + + +urlpatterns = [ + + path('login/', auth_views.LoginView.as_view(), name='login'), # pro Swagger + path('logout/', auth_views.LogoutView.as_view(), name='logout'), + + # path('admin/', admin.site.urls), + path("admin/", custom_admin_site.urls), # override default admin + + path('api/account/', include('account.urls')), + path('api/booking/', include('booking.urls')), + path('api/', include('product.urls')), + path('api/service-tickets/', include('servicedesk.urls')), + path('api/commerce/', include('commerce.urls')), + path('api/config/', include('configuration.urls')), + + #rest framework, map of api + path("api/schema/", SpectacularAPIView.as_view(), name="schema"), + path("swagger/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"), + path("redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), + + path('', views.index, name='index'), + path('test/email', views.test_mail, name='test-email') + +] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/backend/trznice/utils.py b/backend/trznice/utils.py new file mode 100644 index 0000000..d7f0427 --- /dev/null +++ b/backend/trznice/utils.py @@ -0,0 +1,12 @@ +from rest_framework.fields import DateTimeField +from datetime import datetime + + +def truncate_to_minutes(dt: datetime) -> datetime: + return dt.replace(second=0, microsecond=0) + + +class RoundedDateTimeField(DateTimeField): + def to_internal_value(self, value): + dt = super().to_internal_value(value) + return truncate_to_minutes(dt) \ No newline at end of file diff --git a/backend/trznice/views.py b/backend/trznice/views.py new file mode 100644 index 0000000..bd0922d --- /dev/null +++ b/backend/trznice/views.py @@ -0,0 +1,38 @@ +from django.shortcuts import render, redirect +from django.core.mail import send_mail +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt +import json + +def index(request): + return render(request, "html/index.html", context={'user': request.user}) + + +@csrf_exempt +def test_mail(request): + if request.method != "POST": + return JsonResponse({"error": "Only POST allowed"}, status=405) + + try: + data = json.loads(request.body) + recipient = data.get("recipient") + if not recipient: + return JsonResponse({"error": "Missing recipient"}, status=400) + + send_mail( + subject="Test", + message="Django test mail", + from_email=None, # použije defaultní FROM_EMAIL ze settings + recipient_list=[recipient], + fail_silently=False, + ) + + return JsonResponse({"success": f"E-mail sent to {recipient}"}) + + except Exception as e: + import traceback + traceback.print_exc() # vypíše do konzole + return JsonResponse({"error": str(e)}, status=500) + + + diff --git a/backend/trznice/wsgi.py b/backend/trznice/wsgi.py new file mode 100644 index 0000000..8c7012c --- /dev/null +++ b/backend/trznice/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for trznice project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'trznice.settings') + +application = get_wsgi_application() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..81b8e8b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,117 @@ +services: + backend: + container_name: backend-e-trznice + build: + context: ./backend + dockerfile: Dockerfile + restart: always + env_file: + - ./backend/.env + networks: + - app_network + depends_on: + - db + - redis + volumes: + - static-data:/app/collectedstaticfiles + - media-data:/app/media + command: sh -c " + python manage.py check && + python manage.py collectstatic --clear --noinput --verbosity 3 && + python manage.py makemigrations --noinput && + python manage.py migrate --verbosity 3 --noinput && + gunicorn -k uvicorn.workers.UvicornWorker trznice.asgi:application --bind 0.0.0.0:8000" + ports: + - "8000:8000" + + db: + image: postgres:15-alpine + container_name: postgres-e-trznice + restart: always + env_file: + - ./backend/.env + volumes: + - db-data:/var/lib/postgresql/data + ports: + - "5432:5432" + networks: + - app_network + + + redis: #extremly fast db, stores data in RAM memory + container_name: redis-e-trznice + image: redis:alpine + restart: always + env_file: + - ./backend/.env + environment: + - REDIS_PASSWORD=${REDIS_PASSWORD} + volumes: + - redis-data:/data + expose: + - "6379" + networks: + - app_network + + celery: #task queue for handling asynchronous/hard tasks + container_name: celery-e-trznice + build: + context: ./backend + command: celery -A trznice worker --loglevel=info + volumes: + - ./backend:/code + env_file: + - ./backend/.env + depends_on: + - redis + - db + - backend + networks: + - app_network + + celery-beat: #periodic tasks scheduler + container_name: celery-beat-e-trznice + build: + context: ./backend + command: celery -A trznice beat --loglevel=info + volumes: + - ./backend:/code + env_file: + - ./backend/.env + depends_on: + - redis + - db + - backend + networks: + - app_network + +#end of backend services ----------------------- + + nginx: #web server, reverse proxy, serves static files + container_name: nginx-e-trznice + build: + context: ./frontend + dockerfile: Dockerfile.prod + env_file: + - ./frontend/.env + ports: + - 3000:80 + depends_on: + - backend + networks: + - app_network + volumes: + - static-data:/app/collectedstaticfiles # static (Django) + - media-data:/app/media # media (Django) + + +networks: + app_network: + driver: bridge + +volumes: + redis-data: + db-data: + static-data: + media-data: + diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..f2e3d31 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,3 @@ +node_modules +dist +.git \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod new file mode 100644 index 0000000..c0d9db8 --- /dev/null +++ b/frontend/Dockerfile.prod @@ -0,0 +1,16 @@ +# Step 1: Build React (Vite) app +FROM node:22-alpine AS build +WORKDIR /app +COPY package*.json ./ +# If package-lock.json exists, npm ci is faster and reproducible +RUN npm ci || npm install +COPY . . +ENV NODE_ENV=production +RUN npm run build + +# Step 2: Nginx runtime +FROM nginx:1.27-alpine +COPY ./nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..e69de29 diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..cee1e2c --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,29 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{js,jsx}'], + extends: [ + js.configs.recommended, + reactHooks.configs['recommended-latest'], + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + rules: { + 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..eb42cb6 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..689c381 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,4298 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@apidevtools/swagger-parser": "^10.0.2", + "@fortawesome/fontawesome-svg-core": "^6.7.2", + "@fortawesome/free-solid-svg-icons": "^6.7.2", + "@fortawesome/react-fontawesome": "^0.2.2", + "@mantine/core": "^8.2.3", + "@mantine/dates": "^8.2.3", + "@mantine/hooks": "^8.2.3", + "@tabler/icons-react": "^3.34.1", + "@tanstack/react-table": "^8.21.3", + "axios": "^1.10.0", + "bootstrap": "^5.3.7", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "dayjs": "^1.11.13", + "lodash": "^4.17.21", + "qrcode.react": "^4.2.0", + "react": "^19.1.0", + "react-bootstrap": "^2.10.10", + "react-dom": "^19.1.0", + "react-grid-layout": "^1.5.2", + "react-qr-code": "^2.0.18", + "react-router-dom": "^7.7.1", + "use-debounce": "^10.0.5" + }, + "devDependencies": { + "@eslint/js": "^9.29.0", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.5.2", + "eslint": "^9.29.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.2.0", + "postcss": "^8.5.6", + "postcss-preset-mantine": "^1.18.0", + "postcss-simple-vars": "^7.0.1", + "vite": "^7.0.5" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "11.7.2", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.7.2.tgz", + "integrity": "sha512-4gY54eEGEstClvEkGnwVkTkrx0sqwemEFG5OSRRn3tD91XH0+Q8XIkYIfo7IwEWPpJZwILb9GUXeShtplRc/eA==", + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, + "node_modules/@apidevtools/openapi-schemas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", + "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@apidevtools/swagger-methods": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", + "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", + "license": "MIT" + }, + "node_modules/@apidevtools/swagger-parser": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.1.1.tgz", + "integrity": "sha512-u/kozRnsPO/x8QtKYJOqoGtC4kH6yg1lfYkB9Au0WhYB0FNLpyFusttQtvhlwjtG3rOwiRz4D8DnnXa8iEpIKA==", + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "11.7.2", + "@apidevtools/openapi-schemas": "^2.1.0", + "@apidevtools/swagger-methods": "^3.0.2", + "@jsdevtools/ono": "^7.1.3", + "ajv": "^8.17.1", + "ajv-draft-04": "^1.0.0", + "call-me-maybe": "^1.0.2" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, + "node_modules/@apidevtools/swagger-parser/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@apidevtools/swagger-parser/node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "license": "MIT", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/@apidevtools/swagger-parser/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", + "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", + "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.27.3", + "@babel/helpers": "^7.27.6", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.0", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", + "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.0", + "@babel/types": "^7.28.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", + "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz", + "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", + "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", + "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", + "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz", + "integrity": "sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", + "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", + "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", + "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", + "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", + "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", + "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", + "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", + "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", + "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", + "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", + "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", + "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", + "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", + "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", + "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", + "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", + "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", + "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", + "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", + "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", + "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", + "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", + "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", + "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", + "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", + "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", + "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.30.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.1.tgz", + "integrity": "sha512-zXhuECFlyep42KZUhWjfvsmXGX39W8K8LFb8AWXM9gSV9dQB+MrJGLKvW6Zw0Ggnbpw0VHTtrhFXYe3Gym18jg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz", + "integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.15.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", + "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.2.tgz", + "integrity": "sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.2.tgz", + "integrity": "sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.2", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.26.28", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz", + "integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.2", + "@floating-ui/utils": "^0.2.8", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.4.tgz", + "integrity": "sha512-JbbpPhp38UmXDDAu60RJmbeme37Jbgsm7NrHGgzYYFKmblzRUh6Pa641dII6LsjwF4XlScDrde2UAzDo/b9KPw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.2" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@fortawesome/fontawesome-common-types": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.7.2.tgz", + "integrity": "sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/fontawesome-svg-core": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.7.2.tgz", + "integrity": "sha512-yxtOBWDrdi5DD5o1pmVdq3WMCvnobT0LU6R8RyyVXPvFRd2o79/0NCuQoCjNTeZz9EzA9xS3JxNWfv54RIHFEA==", + "license": "MIT", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.7.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-solid-svg-icons": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.7.2.tgz", + "integrity": "sha512-GsBrnOzU8uj0LECDfD5zomZJIjrPhIlWU82AHwa2s40FKH+kcxQaBvBo3Z4TxyZHIyX8XTDxsyA33/Vx9eFuQA==", + "license": "(CC-BY-4.0 AND MIT)", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.7.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/react-fontawesome": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.2.2.tgz", + "integrity": "sha512-EnkrprPNqI6SXJl//m29hpaNzOp1bruISWaOiRtkMi/xSvHJlzc2j2JAYS7egxt/EbjSNV/k6Xy0AQI6vB2+1g==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "@fortawesome/fontawesome-svg-core": "~1 || ~6", + "react": ">=16.3" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", + "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.29", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "license": "MIT" + }, + "node_modules/@mantine/core": { + "version": "8.2.3", + "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.2.3.tgz", + "integrity": "sha512-8vR1xAhNzL4g9/8fANhWpjFuguDbubMFo0sgw4WjO32x4PuBrmckGP9qwCopgYfWt8F49sXiVI8UDZG66gzkHA==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.26.28", + "clsx": "^2.1.1", + "react-number-format": "^5.4.3", + "react-remove-scroll": "^2.6.2", + "react-textarea-autosize": "8.5.9", + "type-fest": "^4.27.0" + }, + "peerDependencies": { + "@mantine/hooks": "8.2.3", + "react": "^18.x || ^19.x", + "react-dom": "^18.x || ^19.x" + } + }, + "node_modules/@mantine/dates": { + "version": "8.2.3", + "resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-8.2.3.tgz", + "integrity": "sha512-NSFhczcyIGfFK5VmNnHfepuoYOvl1RVwMumotnqbyoikPlWmTPwvLd7yeDAWHEoutqqmL5IKWvh1yyemwH8zPg==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1" + }, + "peerDependencies": { + "@mantine/core": "8.2.3", + "@mantine/hooks": "8.2.3", + "dayjs": ">=1.0.0", + "react": "^18.x || ^19.x", + "react-dom": "^18.x || ^19.x" + } + }, + "node_modules/@mantine/hooks": { + "version": "8.2.3", + "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.2.3.tgz", + "integrity": "sha512-RBXAYqmxLk2DBIqN2DnWa9ShFEL1zpbQb0kgc8JIERmJyNwTjHKjHBDgX0jD7oeZjYfGh/g8du2MQEgF9BpsfQ==", + "license": "MIT", + "peerDependencies": { + "react": "^18.x || ^19.x" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@react-aria/ssr": { + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.9.tgz", + "integrity": "sha512-2P5thfjfPy/np18e5wD4WPt8ydNXhij1jwA8oehxZTFqlgVMGXzcWKxTb4RtJrLFsqPO7RUQTiY8QJk0M4Vy2g==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@restart/hooks": { + "version": "0.4.16", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz", + "integrity": "sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@restart/ui": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@restart/ui/-/ui-1.9.4.tgz", + "integrity": "sha512-N4C7haUc3vn4LTwVUPlkJN8Ach/+yIMvRuTVIhjilNHqegY60SGLrzud6errOMNJwSnmYFnt1J0H/k8FE3A4KA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@popperjs/core": "^2.11.8", + "@react-aria/ssr": "^3.5.0", + "@restart/hooks": "^0.5.0", + "@types/warning": "^3.0.3", + "dequal": "^2.0.3", + "dom-helpers": "^5.2.0", + "uncontrollable": "^8.0.4", + "warning": "^4.0.3" + }, + "peerDependencies": { + "react": ">=16.14.0", + "react-dom": ">=16.14.0" + } + }, + "node_modules/@restart/ui/node_modules/@restart/hooks": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.5.1.tgz", + "integrity": "sha512-EMoH04NHS1pbn07iLTjIjgttuqb7qu4+/EyhAx27MHpoENcB2ZdSsLTNxmKD+WEPnZigo62Qc8zjGnNxoSE/5Q==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@restart/ui/node_modules/uncontrollable": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-8.0.4.tgz", + "integrity": "sha512-ulRWYWHvscPFc0QQXvyJjY6LIXU56f0h8pQFvhxiKk5V1fcI8gp9Ht9leVAhrVjzqMw0BgjspBINx9r6oyJUvQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.14.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.19", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.19.tgz", + "integrity": "sha512-3FL3mnMbPu0muGOCaKAhhFEYmqv9eTfPSJRJmANrCwtgK8VuxpsZDGK+m0LYAGoyO8+0j5uRe4PeyPDK1yA/hA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.2.tgz", + "integrity": "sha512-g0dF8P1e2QYPOj1gu7s/3LVP6kze9A7m6x0BZ9iTdXK8N5c2V7cpBKHV3/9A4Zd8xxavdhK0t4PnqjkqVmUc9Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.44.2.tgz", + "integrity": "sha512-Yt5MKrOosSbSaAK5Y4J+vSiID57sOvpBNBR6K7xAaQvk3MkcNVV0f9fE20T+41WYN8hDn6SGFlFrKudtx4EoxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.2.tgz", + "integrity": "sha512-EsnFot9ZieM35YNA26nhbLTJBHD0jTwWpPwmRVDzjylQT6gkar+zenfb8mHxWpRrbn+WytRRjE0WKsfaxBkVUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.2.tgz", + "integrity": "sha512-dv/t1t1RkCvJdWWxQ2lWOO+b7cMsVw5YFaS04oHpZRWehI1h0fV1gF4wgGCTyQHHjJDfbNpwOi6PXEafRBBezw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.44.2.tgz", + "integrity": "sha512-W4tt4BLorKND4qeHElxDoim0+BsprFTwb+vriVQnFFtT/P6v/xO5I99xvYnVzKWrK6j7Hb0yp3x7V5LUbaeOMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.44.2.tgz", + "integrity": "sha512-tdT1PHopokkuBVyHjvYehnIe20fxibxFCEhQP/96MDSOcyjM/shlTkZZLOufV3qO6/FQOSiJTBebhVc12JyPTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.44.2.tgz", + "integrity": "sha512-+xmiDGGaSfIIOXMzkhJ++Oa0Gwvl9oXUeIiwarsdRXSe27HUIvjbSIpPxvnNsRebsNdUo7uAiQVgBD1hVriwSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.44.2.tgz", + "integrity": "sha512-bDHvhzOfORk3wt8yxIra8N4k/N0MnKInCW5OGZaeDYa/hMrdPaJzo7CSkjKZqX4JFUWjUGm88lI6QJLCM7lDrA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.2.tgz", + "integrity": "sha512-NMsDEsDiYghTbeZWEGnNi4F0hSbGnsuOG+VnNvxkKg0IGDvFh7UVpM/14mnMwxRxUf9AdAVJgHPvKXf6FpMB7A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.2.tgz", + "integrity": "sha512-lb5bxXnxXglVq+7imxykIp5xMq+idehfl+wOgiiix0191av84OqbjUED+PRC5OA8eFJYj5xAGcpAZ0pF2MnW+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.44.2.tgz", + "integrity": "sha512-Yl5Rdpf9pIc4GW1PmkUGHdMtbx0fBLE1//SxDmuf3X0dUC57+zMepow2LK0V21661cjXdTn8hO2tXDdAWAqE5g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.44.2.tgz", + "integrity": "sha512-03vUDH+w55s680YYryyr78jsO1RWU9ocRMaeV2vMniJJW/6HhoTBwyyiiTPVHNWLnhsnwcQ0oH3S9JSBEKuyqw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.44.2.tgz", + "integrity": "sha512-iYtAqBg5eEMG4dEfVlkqo05xMOk6y/JXIToRca2bAWuqjrJYJlx/I7+Z+4hSrsWU8GdJDFPL4ktV3dy4yBSrzg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.44.2.tgz", + "integrity": "sha512-e6vEbgaaqz2yEHqtkPXa28fFuBGmUJ0N2dOJK8YUfijejInt9gfCSA7YDdJ4nYlv67JfP3+PSWFX4IVw/xRIPg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.44.2.tgz", + "integrity": "sha512-evFOtkmVdY3udE+0QKrV5wBx7bKI0iHz5yEVx5WqDJkxp9YQefy4Mpx3RajIVcM6o7jxTvVd/qpC1IXUhGc1Mw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.2.tgz", + "integrity": "sha512-/bXb0bEsWMyEkIsUL2Yt5nFB5naLAwyOWMEviQfQY1x3l5WsLKgvZf66TM7UTfED6erckUVUJQ/jJ1FSpm3pRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.2.tgz", + "integrity": "sha512-3D3OB1vSSBXmkGEZR27uiMRNiwN08/RVAcBKwhUYPaiZ8bcvdeEwWPvbnXvvXHY+A/7xluzcN+kaiOFNiOZwWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.2.tgz", + "integrity": "sha512-VfU0fsMK+rwdK8mwODqYeM2hDrF2WiHaSmCBrS7gColkQft95/8tphyzv2EupVxn3iE0FI78wzffoULH1G+dkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.44.2.tgz", + "integrity": "sha512-+qMUrkbUurpE6DVRjiJCNGZBGo9xM4Y0FXU5cjgudWqIBWbcLkjE3XprJUsOFgC6xjBClwVa9k6O3A7K3vxb5Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.2.tgz", + "integrity": "sha512-3+QZROYfJ25PDcxFF66UEk8jGWigHJeecZILvkPkyQN7oc5BvFo4YEXFkOs154j3FTMp9mn9Ky8RCOwastduEA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@swc/helpers": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", + "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tabler/icons": { + "version": "3.34.1", + "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.34.1.tgz", + "integrity": "sha512-9gTnUvd7Fd/DmQgr3MKY+oJLa1RfNsQo8c/ir3TJAWghOuZXodbtbVp0QBY2DxWuuvrSZFys0HEbv1CoiI5y6A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + } + }, + "node_modules/@tabler/icons-react": { + "version": "3.34.1", + "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.34.1.tgz", + "integrity": "sha512-Ld6g0NqOO05kyyHsfU8h787PdHBm7cFmOycQSIrGp45XcXYDuOK2Bs0VC4T2FWSKZ6bx5g04imfzazf/nqtk1A==", + "license": "MIT", + "dependencies": { + "@tabler/icons": "3.34.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + }, + "peerDependencies": { + "react": ">= 16" + } + }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", + "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.1.8", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", + "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.1.6", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz", + "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/warning": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz", + "integrity": "sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.6.0.tgz", + "integrity": "sha512-5Kgff+m8e2PB+9j51eGHEpn5kUzRKH2Ry0qGoe8ItJg7pqnkPrYPkDQZGgGmTa0EGarHrkjLvOdU3b1fzI8otQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.19", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/bootstrap": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.7.tgz", + "integrity": "sha512-7KgiD8UHjfcPBHEpDNg+zGz8L3LqR3GVwqZiBRFX04a1BCArZOz1r2kjly2HQ0WokqTO0v1nF+QAt8dsW4lKlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT", + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.25.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", + "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001726", + "electron-to-chromium": "^1.5.173", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", + "license": "MIT" + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001727", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", + "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.179", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.179.tgz", + "integrity": "sha512-UWKi/EbBopgfFsc5k61wFpV7WrnnSlSzW/e2XcBmS6qKYTivZlLtoll5/rdqRTxGglGHkmkW0j0pFNJG10EUIQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", + "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.5", + "@esbuild/android-arm": "0.25.5", + "@esbuild/android-arm64": "0.25.5", + "@esbuild/android-x64": "0.25.5", + "@esbuild/darwin-arm64": "0.25.5", + "@esbuild/darwin-x64": "0.25.5", + "@esbuild/freebsd-arm64": "0.25.5", + "@esbuild/freebsd-x64": "0.25.5", + "@esbuild/linux-arm": "0.25.5", + "@esbuild/linux-arm64": "0.25.5", + "@esbuild/linux-ia32": "0.25.5", + "@esbuild/linux-loong64": "0.25.5", + "@esbuild/linux-mips64el": "0.25.5", + "@esbuild/linux-ppc64": "0.25.5", + "@esbuild/linux-riscv64": "0.25.5", + "@esbuild/linux-s390x": "0.25.5", + "@esbuild/linux-x64": "0.25.5", + "@esbuild/netbsd-arm64": "0.25.5", + "@esbuild/netbsd-x64": "0.25.5", + "@esbuild/openbsd-arm64": "0.25.5", + "@esbuild/openbsd-x64": "0.25.5", + "@esbuild/sunos-x64": "0.25.5", + "@esbuild/win32-arm64": "0.25.5", + "@esbuild/win32-ia32": "0.25.5", + "@esbuild/win32-x64": "0.25.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.30.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.30.1.tgz", + "integrity": "sha512-zmxXPNMOXmwm9E0yQLi5uqXHs7uq2UIiqEKo3Gq+3fwo1XrJ+hijAZImyF7hclW3E6oHz43Yk3RP8at6OTKflQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.0", + "@eslint/core": "^0.14.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.30.1", + "@eslint/plugin-kit": "^0.3.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.20", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.20.tgz", + "integrity": "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz", + "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", + "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz", + "integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT", + "peer": true + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-mixins": { + "version": "12.1.2", + "resolved": "https://registry.npmjs.org/postcss-mixins/-/postcss-mixins-12.1.2.tgz", + "integrity": "sha512-90pSxmZVfbX9e5xCv7tI5RV1mnjdf16y89CJKbf/hD7GyOz1FCxcYMl8ZYA8Hc56dbApTKKmU9HfvgfWdCxlwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-js": "^4.0.1", + "postcss-simple-vars": "^7.0.1", + "sugarss": "^5.0.0", + "tinyglobby": "^0.2.14" + }, + "engines": { + "node": "^20.0 || ^22.0 || >=24.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-nested": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-7.0.2.tgz", + "integrity": "sha512-5osppouFc0VR9/VYzYxO03VaDa3e8F23Kfd6/9qcZTUI8P58GIYlArOET2Wq0ywSl2o2PjELhYOFI4W7l5QHKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-preset-mantine": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/postcss-preset-mantine/-/postcss-preset-mantine-1.18.0.tgz", + "integrity": "sha512-sP6/s1oC7cOtBdl4mw/IRKmKvYTuzpRrH/vT6v9enMU/EQEQ31eQnHcWtFghOXLH87AAthjL/Q75rLmin1oZoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-mixins": "^12.0.0", + "postcss-nested": "^7.0.2" + }, + "peerDependencies": { + "postcss": ">=8.0.0" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", + "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-simple-vars": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-simple-vars/-/postcss-simple-vars-7.0.1.tgz", + "integrity": "sha512-5GLLXaS8qmzHMOjVxqkk1TZPf1jMqesiI7qLhnlyERalG0sMbHIbJqrcnrpmZdKCLglHnRHoEBB61RtGTsj++A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.2.1" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types-extra": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/prop-types-extra/-/prop-types-extra-1.1.1.tgz", + "integrity": "sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew==", + "license": "MIT", + "dependencies": { + "react-is": "^16.3.2", + "warning": "^4.0.0" + }, + "peerDependencies": { + "react": ">=0.14.0" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qr.js": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/qr.js/-/qr.js-0.0.0.tgz", + "integrity": "sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==", + "license": "MIT" + }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-bootstrap": { + "version": "2.10.10", + "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.10.tgz", + "integrity": "sha512-gMckKUqn8aK/vCnfwoBpBVFUGT9SVQxwsYrp9yDHt0arXMamxALerliKBxr1TPbntirK/HGrUAHYbAeQTa9GHQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7", + "@restart/hooks": "^0.4.9", + "@restart/ui": "^1.9.4", + "@types/prop-types": "^15.7.12", + "@types/react-transition-group": "^4.4.6", + "classnames": "^2.3.2", + "dom-helpers": "^5.2.1", + "invariant": "^2.2.4", + "prop-types": "^15.8.1", + "prop-types-extra": "^1.1.0", + "react-transition-group": "^4.4.5", + "uncontrollable": "^7.2.1", + "warning": "^4.0.3" + }, + "peerDependencies": { + "@types/react": ">=16.14.8", + "react": ">=16.14.0", + "react-dom": ">=16.14.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-dom": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, + "node_modules/react-draggable": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz", + "integrity": "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-grid-layout": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.5.2.tgz", + "integrity": "sha512-vT7xmQqszTT+sQw/LfisrEO4le1EPNnSEMVHy6sBZyzS3yGkMywdOd+5iEFFwQwt0NSaGkxuRmYwa1JsP6OJdw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "fast-equals": "^4.0.3", + "prop-types": "^15.8.1", + "react-draggable": "^4.4.6", + "react-resizable": "^3.0.5", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/react-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", + "license": "MIT" + }, + "node_modules/react-number-format": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.4.tgz", + "integrity": "sha512-wOmoNZoOpvMminhifQYiYSTCLUDOiUbBunrMrMjA+dV52sY+vck1S4UhR6PkgnoCquvvMSeJjErXZ4qSaWCliA==", + "license": "MIT", + "peerDependencies": { + "react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-qr-code": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/react-qr-code/-/react-qr-code-2.0.18.tgz", + "integrity": "sha512-v1Jqz7urLMhkO6jkgJuBYhnqvXagzceg3qJUWayuCK/c6LTIonpWbwxR1f1APGd4xrW/QcQEovNrAojbUz65Tg==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.8.1", + "qr.js": "0.0.0" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz", + "integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-resizable": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.0.5.tgz", + "integrity": "sha512-vKpeHhI5OZvYn82kXOs1bC8aOXktGU5AmKAgaZS4F5JPburCtbmDPqE7Pzp+1kN4+Wb81LlF33VpGwWwtXem+w==", + "license": "MIT", + "dependencies": { + "prop-types": "15.x", + "react-draggable": "^4.0.3" + }, + "peerDependencies": { + "react": ">= 16.3" + } + }, + "node_modules/react-router": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.7.1.tgz", + "integrity": "sha512-jVKHXoWRIsD/qS6lvGveckwb862EekvapdHJN/cGmzw40KnJH5gg53ujOJ4qX6EKIK9LSBfFed/xiQ5yeXNrUA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.7.1.tgz", + "integrity": "sha512-bavdk2BA5r3MYalGKZ01u8PGuDBloQmzpBZVhDLrOOv1N943Wq6dcM9GhB3x8b7AbqPMEezauv4PeGkAJfy7FQ==", + "license": "MIT", + "dependencies": { + "react-router": "7.7.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-textarea-autosize": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz", + "integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "use-composed-ref": "^1.3.0", + "use-latest": "^1.2.1" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.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", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.2.tgz", + "integrity": "sha512-PVoapzTwSEcelaWGth3uR66u7ZRo6qhPHc0f2uRO9fX6XDVNrIiGYS0Pj9+R8yIIYSD/mCx2b16Ws9itljKSPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.44.2", + "@rollup/rollup-android-arm64": "4.44.2", + "@rollup/rollup-darwin-arm64": "4.44.2", + "@rollup/rollup-darwin-x64": "4.44.2", + "@rollup/rollup-freebsd-arm64": "4.44.2", + "@rollup/rollup-freebsd-x64": "4.44.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.44.2", + "@rollup/rollup-linux-arm-musleabihf": "4.44.2", + "@rollup/rollup-linux-arm64-gnu": "4.44.2", + "@rollup/rollup-linux-arm64-musl": "4.44.2", + "@rollup/rollup-linux-loongarch64-gnu": "4.44.2", + "@rollup/rollup-linux-powerpc64le-gnu": "4.44.2", + "@rollup/rollup-linux-riscv64-gnu": "4.44.2", + "@rollup/rollup-linux-riscv64-musl": "4.44.2", + "@rollup/rollup-linux-s390x-gnu": "4.44.2", + "@rollup/rollup-linux-x64-gnu": "4.44.2", + "@rollup/rollup-linux-x64-musl": "4.44.2", + "@rollup/rollup-win32-arm64-msvc": "4.44.2", + "@rollup/rollup-win32-ia32-msvc": "4.44.2", + "@rollup/rollup-win32-x64-msvc": "4.44.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sugarss": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/sugarss/-/sugarss-5.0.1.tgz", + "integrity": "sha512-ctS5RYCBVvPoZAnzIaX5QSShK8ZiZxD5HUqSxlusvEMC+QZQIPCPOIJg6aceFX+K2rf4+SH89eu++h1Zmsr2nw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "postcss": "^8.3.3" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "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/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/uncontrollable": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz", + "integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.6.3", + "@types/react": ">=16.9.11", + "invariant": "^2.2.4", + "react-lifecycles-compat": "^3.0.4" + }, + "peerDependencies": { + "react": ">=15.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-composed-ref": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.4.0.tgz", + "integrity": "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-debounce": { + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.0.5.tgz", + "integrity": "sha512-Q76E3lnIV+4YT9AHcrHEHYmAd9LKwUAbPXDm7FlqVGDHiSOhX3RDjT8dm0AxbJup6WgOb1YEcKyCr11kBJR5KQ==", + "license": "MIT", + "engines": { + "node": ">= 16.0.0" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", + "integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-latest": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.3.0.tgz", + "integrity": "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==", + "license": "MIT", + "dependencies": { + "use-isomorphic-layout-effect": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "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", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.7.tgz", + "integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..9cb9aa6 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,51 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@apidevtools/swagger-parser": "^10.0.2", + "@fortawesome/fontawesome-svg-core": "^6.7.2", + "@fortawesome/free-solid-svg-icons": "^6.7.2", + "@fortawesome/react-fontawesome": "^0.2.2", + "@mantine/core": "^8.2.3", + "@mantine/dates": "^8.2.3", + "@mantine/hooks": "^8.2.3", + "@tabler/icons-react": "^3.34.1", + "@tanstack/react-table": "^8.21.3", + "axios": "^1.10.0", + "bootstrap": "^5.3.7", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "dayjs": "^1.11.13", + "lodash": "^4.17.21", + "qrcode.react": "^4.2.0", + "react": "^19.1.0", + "react-bootstrap": "^2.10.10", + "react-dom": "^19.1.0", + "react-grid-layout": "^1.5.2", + "react-qr-code": "^2.0.18", + "react-router-dom": "^7.7.1", + "use-debounce": "^10.0.5" + }, + "devDependencies": { + "@eslint/js": "^9.29.0", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.5.2", + "eslint": "^9.29.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.2.0", + "postcss": "^8.5.6", + "postcss-preset-mantine": "^1.18.0", + "postcss-simple-vars": "^7.0.1", + "vite": "^7.0.5" + } +} diff --git a/frontend/postcss.config.cjs b/frontend/postcss.config.cjs new file mode 100644 index 0000000..c759b74 --- /dev/null +++ b/frontend/postcss.config.cjs @@ -0,0 +1,14 @@ +module.exports = { + plugins: { + 'postcss-preset-mantine': {}, + 'postcss-simple-vars': { + variables: { + 'mantine-breakpoint-xs': '36em', + 'mantine-breakpoint-sm': '48em', + 'mantine-breakpoint-md': '62em', + 'mantine-breakpoint-lg': '75em', + 'mantine-breakpoint-xl': '88em', + }, + }, + }, +}; \ No newline at end of file diff --git a/frontend/public/img/bg.png b/frontend/public/img/bg.png new file mode 100644 index 0000000..cc6a318 Binary files /dev/null and b/frontend/public/img/bg.png differ diff --git a/frontend/public/img/logo.png b/frontend/public/img/logo.png new file mode 100644 index 0000000..ef03b92 Binary files /dev/null and b/frontend/public/img/logo.png differ diff --git a/frontend/public/img/namest-1.png b/frontend/public/img/namest-1.png new file mode 100644 index 0000000..6deed70 Binary files /dev/null and b/frontend/public/img/namest-1.png differ diff --git a/frontend/public/img/register-bg.jpg b/frontend/public/img/register-bg.jpg new file mode 100644 index 0000000..2f46b58 Binary files /dev/null and b/frontend/public/img/register-bg.jpg differ diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..0ed49ff --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,19 @@ +.tab-content{ + margin: auto; + width: -webkit-fill-available; +} +.nav-tabs{ + margin: 0 !important; +} + +.app-container { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.app-content { + flex: 1 0 auto; + /* Zabrání překrytí footerem */ + overflow-y: scroll; +} \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..b48eced --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,155 @@ +import { useState } from "react"; +import "./App.css"; +import "/node_modules/react-grid-layout/css/styles.css"; +import "/node_modules/react-resizable/css/styles.css"; +import { Routes, Route } from "react-router-dom"; +import NavBar from "./components/NavBar"; +import Login from "./pages/Login"; +import Register from "./pages/register/Register"; +import Test from "./pages/Test"; + +import EmailVerificationPage from "./pages/register/EmailVerification"; + +import Home from "./pages/Home"; +import PaymentPage from "./pages/PaymentPage"; +import ResetPasswordPage from "./pages/PasswordReset"; +import UserSettings from "./pages/manager/UserSettings"; + +{/* Security routes */} +import RequireRole from "./components/security/RequireRole"; +import RequireAuthLayout from "./components/security/RequireAuthLayout"; + +{/* manager */} +import Events from "./pages/manager/Events"; +import MapEditor from "./pages/manager/edit/MapEditor"; {/* Map editor for events */ } +import CreateEvent from "./pages/manager/create/create-event"; + +import Squares from "./pages/manager/Squares"; +import SquareDesigner from "./pages/manager/create/SquareDesigner"; {/* Square designer for creating squares */ } + +import Reservations from "./pages/manager/Reservations"; + +import Orders from "./pages/manager/Orders"; + +import Ticket from "./pages/Ticket"; + +import Users from "./pages/manager/Users"; +import CreateUser from "./pages/manager/create/create-user"; + +{/* Cart for reservations (multipurpouse)*/} +import ReservationCart from "./pages/Reservation-cart" + +import { UserProvider } from './context/UserContext'; + +// Add products pages +import Products from "./pages/manager/Products"; +import CreateProduct from "./pages/manager/create/create-product"; + +import SettingsPage from "./pages/Settings"; + +function App() { + return ( +
+ + + +
+ +
+
+ + } /> + } /> + } /> + // after user registers, they will be redirected from email, to the + email verification page + } /> + }/> + + } /> + }/> + + {/*test*/} + + {/*} />*/} + + + {/* AUTHENTICATED */} + }> + } /> + + } /> + + + + } /> + + } /> + + {/* ADMIN */} + }> + } /> + + + + {/* SELLER && ADMIN */} + }> + } /> + + + {/* CLERK & ADMIN */} + } > + + } /> + } /> + + } /> + } /> {/* Designer for squares (creation) */} + + } /> + } /> + + } /> + } /> + } /> + } /> + + } /> + + {/* Products */} + } /> + } /> + + } /> + + {/* Settings */} + } /> + + + + +
+
+ + + +
+ ); +} +import { useParams } from "react-router-dom"; + +function PaymentPageWrapper() { + const { orderId } = useParams(); + return ; +} + +export default App; diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js new file mode 100644 index 0000000..114a11a --- /dev/null +++ b/frontend/src/api/auth.js @@ -0,0 +1,254 @@ +import axios from "axios"; + +const API_URL = `${import.meta.env.VITE_BACKEND_URL}/api`; + +// Axios instance for authenticated requests +const axios_instance = axios.create({ + baseURL: API_URL, + withCredentials: true, +}); +axios_instance.defaults.xsrfCookieName = "csrftoken"; +axios_instance.defaults.xsrfHeaderName = "X-CSRFToken"; + +// Axios instance without Authorization for auth endpoints (refresh/login/logout) +const axios_no_auth = axios.create({ + baseURL: API_URL, + withCredentials: true, +}); +axios_no_auth.defaults.xsrfCookieName = "csrftoken"; +axios_no_auth.defaults.xsrfHeaderName = "X-CSRFToken"; + +// CSRF helper for authless client +const addCsrfHeader = (config) => { + const getCookie = (name) => { + let cookieValue = null; + if (document.cookie && document.cookie !== "") { + const cookies = document.cookie.split(";"); + for (let cookie of cookies) { + cookie = cookie.trim(); + if (cookie.startsWith(name + "=")) { + cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); + break; + } + } + } + return cookieValue; + }; + const token = getCookie("csrftoken"); + const method = (config.method || "").toLowerCase(); + if (token && ["post", "put", "patch", "delete"].includes(method)) { + config.headers["X-CSRFToken"] = token; + } + // Ensure no Authorization on authless client + if (config.headers && "Authorization" in config.headers) { + delete config.headers.Authorization; + } + return config; +}; + +// Attach CSRF only to authless client +axios_no_auth.interceptors.request.use(addCsrfHeader); + +// Flag to prevent multiple simultaneous refresh attempts +let isRefreshing = false; +let failedQueue = []; + +const processQueue = (error, token = null) => { + failedQueue.forEach((prom) => { + if (error) { + prom.reject(error); + } else { + prom.resolve(token); + } + }); + + failedQueue = []; +}; + +// Response interceptor for token refresh +axios_instance.interceptors.response.use( + (response) => response, + async (error) => { + const { response, config } = error; + + // Only handle 401 errors + if (!response || response.status !== 401) { + return Promise.reject(error); + } + + const originalRequest = config || {}; + const url = (originalRequest?.url || "").toString(); + + // Skip auth endpoints, redirect directly + if (url.includes("/account/token/") || url.includes("/account/logout/")) { + window.location.href = "/login"; + return Promise.reject(error); + } + + // If already tried to refresh, redirect to login + if (originalRequest._retry) { + window.location.href = "/login"; + return Promise.reject(error); + } + + // If currently refreshing, queue the request + if (isRefreshing) { + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }); + }) + .then((token) => { + originalRequest.headers["Authorization"] = `Bearer ${token}`; + return axios_instance(originalRequest); + }) + .catch((err) => { + return Promise.reject(err); + }); + } + + originalRequest._retry = true; + isRefreshing = true; + + try { + const refreshResponse = await refreshAccessToken(); + + if (refreshResponse && refreshResponse.access) { + const newToken = refreshResponse.access; + axios_instance.defaults.headers.common["Authorization"] = `Bearer ${newToken}`; + originalRequest.headers["Authorization"] = `Bearer ${newToken}`; + + processQueue(null, newToken); + + return axios_instance(originalRequest); + } else { + processQueue(error, null); + window.location.href = "/login"; + return Promise.reject(error); + } + } catch (refreshError) { + processQueue(refreshError, null); + window.location.href = "/login"; + return Promise.reject(refreshError); + } finally { + isRefreshing = false; + } + } +); + +// Token refresh function - uses axios_no_auth to avoid interceptor loops +export const refreshAccessToken = async () => { + const refreshToken = localStorage.getItem("refresh_token"); + if (!refreshToken) return null; + try { + const res = await axios_no_auth.post(`/account/token/refresh/`, { + refresh: refreshToken, + }); + if (res?.data?.access) { + // Don't set the Authorization header here, let the interceptor handle it + return res.data; + } + return null; + } catch (err) { + console.error("Token refresh failed", err); + return null; + } +}; + +// Login function +export const login = async (username, password) => { + clearTokens(); + try { + const response = await axios_no_auth.post(`/account/token/`, { username, password }); + if (response?.data?.access) { + localStorage.setItem("access_token", response.data.access); + axios_instance.defaults.headers.common.Authorization = `Bearer ${response.data.access}`; + } + if (response?.data?.refresh) { + localStorage.setItem("refresh_token", response.data.refresh); + } + return response.data; + } catch (err) { + if (err.response) { + console.log("Login error status:", err.response.status); + } else if (err.request) { + console.log("Login network error:", err.request); + } else { + console.log("Login setup error:", err.message); + } + throw err; + } +}; + +// Logout function +export const logout = async () => { + try { + const response = await axios_no_auth.post("/account/logout/", {}); + + // Clear the Authorization header + delete axios_instance.defaults.headers.common.Authorization; + + console.log("Logout successful:", response.data); + return response.data; + } catch (err) { + console.error("Logout failed", err); + // Still clear the header even if logout fails + delete axios_instance.defaults.headers.common.Authorization; + throw err; + } +}; + +// API request function +export const apiRequest = async (method, endpoint, data = {}, config = {}) => { + const url = endpoint.startsWith("/") ? endpoint : `/${endpoint}`; + + try { + const response = await axios_instance({ + method, + url, + data: ["post", "put", "patch"].includes(method.toLowerCase()) ? data : undefined, + params: ["get", "delete"].includes(method.toLowerCase()) ? data : undefined, + ...config, + }); + + return response.data; + } catch (err) { + if (err.response) { + console.error("API Error:", { + status: err.response.status, + data: err.response.data, + headers: err.response.headers, + }); + } else if (err.request) { + console.error("No response received:", err.request); + } else { + console.error("Request setup error:", err.message); + } + + throw err; + } +}; + +// Get current user +export async function getCurrentUser() { + const response = await axios_instance.get(`/account/user/me/`); + return response.data; +} + +// Check if authenticated +export async function isAuthenticated() { + try { + const user = await getCurrentUser(); + return user != null; + } catch (err) { + return false; // pokud padne 401, není přihlášen + } +} + +// Clear tokens function +function clearTokens() { + localStorage.removeItem("access_token"); + localStorage.removeItem("refresh_token"); + delete axios_instance.defaults.headers.common.Authorization; +} + +export default axios_instance; +export { axios_instance, API_URL }; \ No newline at end of file diff --git a/frontend/src/api/get_chocies.js b/frontend/src/api/get_chocies.js new file mode 100644 index 0000000..e334eb1 --- /dev/null +++ b/frontend/src/api/get_chocies.js @@ -0,0 +1,41 @@ +import { apiRequest } from "./auth"; + +/** + * Načte enum hodnoty z OpenAPI schématu pro zadanou cestu, metodu a pole (např. category). + * + * @param {string} path - API cesta, např. "/api/service-tickets/" + * @param {"get"|"post"|"patch"|"put"} method - HTTP metoda + * @param {string} field - název pole v parametrech nebo requestu + * @param {string} schemaUrl - URL JSON schématu, výchozí "/api/schema/?format=json" + * @returns {Promise>} + */ +export async function fetchEnumFromSchemaJson( + path, + method, + field, + schemaUrl = "/schema/?format=json" +) { + try { + const schema = await apiRequest("get", schemaUrl); + + const methodDef = schema.paths?.[path]?.[method]; + if (!methodDef) { + throw new Error(`Metoda ${method.toUpperCase()} pro ${path} nebyla nalezena ve schématu.`); + } + + // Hledáme ve "parameters" (např. GET query parametry) + const param = methodDef.parameters?.find((p) => p.name === field); + + if (param?.schema?.enum) { + return param.schema.enum.map((val) => ({ + value: val, + label: val, + })); + } + + throw new Error(`Pole '${field}' neobsahuje enum`); + } catch (error) { + console.error("Chyba při načítání enum hodnot:", error); + throw error; + } +} diff --git a/frontend/src/api/model/Settings.js b/frontend/src/api/model/Settings.js new file mode 100644 index 0000000..4098af0 --- /dev/null +++ b/frontend/src/api/model/Settings.js @@ -0,0 +1,89 @@ +import axios_instance from '../auth'; + +/** + * Django REST endpoints (configuration app - AppConfig singleton): + * Base router mounted under /config/ => list/create/retrieve/update. + * Standard DRF router patterns (basename='app_config'): + * - GET /config/ -> list (will contain max 1 item) + * - POST /config/ -> create (only when no instance exists) + * - GET /config/{id}/ -> retrieve singleton (id ignored internally) + * - PATCH /config/{id}/ -> partial update + * - PUT /config/{id}/ -> full update + */ +const API_CONFIG_BASE = '/config'; +const API_CONFIG_PUBLIC = '/config/public'; + +/** + * @typedef {Object} AppConfig + * @property {number} id + * @property {string|null} bank_account + * @property {string} sender_email + * @property {string|null} background_image URL + * @property {string|null} logo URL + * @property {number|null} variable_symbol + * @property {number} max_reservations_per_event + * @property {string|null} contact_phone + * @property {string|null} contact_email + * @property {string} last_changed_at ISO timestamp + * @property {number|null} last_changed_by user id + */ + +/** + * Fetch current AppConfig singleton (convenience helper). + * @returns {Promise} + */ +export const getAppConfig = async () => { + const response = await axios_instance.get(`${API_CONFIG_BASE}/`); + const data = response.data; + // If ViewSet returns list + if (Array.isArray(data)) { + return data[0] ?? null; + } + // In some customizations it might return object directly + return data; +}; + +/** + * Fetch public (read-only) subset for anonymous / navbar usage. + * @returns {Promise<{logo:string|null, background_image:string|null, contact_email:string|null, contact_phone:string|null}|null>} + */ +export const getPublicAppConfig = async (fields) => { + try { + const params = fields ? { fields: Array.isArray(fields) ? fields.join(',') : fields } : undefined; + const response = await axios_instance.get(`${API_CONFIG_PUBLIC}/`, { params }); + return response.data; + } catch (e) { + return null; // not configured yet or invalid fields + } +}; + +/** + * Create AppConfig (only when none exists). + * @param {Partial} payload + * @returns {Promise} + */ +export const createAppConfig = async (payload) => { + const response = await axios_instance.post(`${API_CONFIG_BASE}/`, payload); + return response.data; +}; + +/** + * Update (PATCH) existing AppConfig. + * Supports JSON or multipart form (for image uploads). Pass FormData for files. + * @param {number} id + * @param {Object|FormData} payload + * @returns {Promise} + */ +export const updateAppConfig = async (id, payload) => { + const isFormData = (typeof FormData !== 'undefined') && payload instanceof FormData; + // Don't set Content-Type manually for FormData (axios will add boundary automatically) + const response = await axios_instance.patch(`${API_CONFIG_BASE}/${id}/`, payload, isFormData ? {} : undefined); + return response.data; +}; + +export default { + getAppConfig, + createAppConfig, + updateAppConfig, + getPublicAppConfig, +}; diff --git a/frontend/src/api/model/bin.js b/frontend/src/api/model/bin.js new file mode 100644 index 0000000..b0d8f37 --- /dev/null +++ b/frontend/src/api/model/bin.js @@ -0,0 +1,78 @@ +import axios_instance from '../auth'; + +const API_BASE_URL = '/booking/bins'; + +/** + * GET seznam košů (Bin). + * + * Query parametry: + * @param {Object} params + * - search: {string} - fulltextové hledání v poli name, description, location, atd. + * - status: {string} - stav koše (např. "active") + * - location: {string} - umístění + * - capacity: {string|number} - kapacita + * - ordering: {string} - např. "name" nebo "-capacity" + * + * @returns {Promise>} + */ +export const getBins = async (params = {}) => { + const response = await axios_instance.get(API_BASE_URL + '/', { params }); + return response.data; +}; + +/** + * GET detail konkrétního koše. + * + * @param {number} id - ID koše + * @returns {Promise} + */ +export const getBinById = async (id) => { + const response = await axios_instance.get(`${API_BASE_URL}/${id}/`); + return response.data; +}; + +/** + * PATCH - částečná aktualizace koše. + * + * @param {number} id - ID koše + * @param {Object} data - Libovolná pole z modelu Bin, která se mají změnit: + * - name?: {string} + * - description?: {string} + * - status?: {string} + * - location?: {string} + * - capacity?: {number} + * @returns {Promise} + */ +export const updateBin = async (id, data) => { + const response = await axios_instance.patch(`${API_BASE_URL}/${id}/`, data); + return response.data; +}; + +/** + * POST - vytvoření nového koše. + * + * @param {Object} data - Data pro nový koš + * @returns {Promise} + */ +export const createBin = async (data) => { + const response = await axios_instance.post(`${API_BASE_URL}/`, data); + return response.data; +}; + +/** + * DELETE - odstranění koše podle ID. + * + * @param {number} id - ID koše + * @returns {Promise} - HTTP 204 No Content při úspěchu + */ +export const deleteBin = async (id) => { + await axios_instance.delete(`${API_BASE_URL}/${id}/`); +}; + +export default { + getBins, + getBinById, + updateBin, + deleteBin, + createBin, +}; \ No newline at end of file diff --git a/frontend/src/api/model/event-product.js b/frontend/src/api/model/event-product.js new file mode 100644 index 0000000..7212d32 --- /dev/null +++ b/frontend/src/api/model/event-product.js @@ -0,0 +1,79 @@ +import axios_instance from '../auth'; + +const API_BASE_URL = '/products/event-products'; + +/** + * GET seznam produktů. + * + * @param {Object} params - Možné query parametry (dle backendu), např.: + * - search: {string} hledání v názvu/popisu + * - ordering: {string} např. "-created_at" + * - is_active: {boolean} filtr aktivních + * + * @returns {Promise>} + */ +export const getEventProducts = async (params = {}) => { + const response = await axios_instance.get(`${API_BASE_URL}/`, { params }); + return response.data; +}; + +/** + * GET detail konkrétního produktu. + * + * @param {number} id - ID produktu + * @returns {Promise} + */ +export const getEventProductById = async (id) => { + const response = await axios_instance.get(`${API_BASE_URL}/${id}/`); + return response.data; +}; + +/** + * POST - vytvoření nového produktu. + * + * @param {Object} data - Data nového produktu: + * - name: {string} název produktu + * - description?: {string} popis + * - price?: {number} cena v Kč + * - is_active?: {boolean} zda je aktivní + * + * @returns {Promise} + */ +export const createEventProduct = async (data) => { + const response = await axios_instance.post(`${API_BASE_URL}/`, data); + return response.data; +}; + +/** + * PATCH - částečná aktualizace produktu. + * + * @param {number} id - ID produktu + * @param {Object} data - Libovolné pole z: + * - name?: {string} + * - description?: {string} + * - price?: {number} + * - is_active?: {boolean} + * @returns {Promise} + */ +export const updateEventProduct = async (id, data) => { + const response = await axios_instance.patch(`${API_BASE_URL}/${id}/`, data); + return response.data; +}; + +/** + * DELETE - smazání produktu. + * + * @param {number} id - ID produktu + * @returns {Promise} HTTP 204 při úspěchu + */ +export const deleteEventProduct = async (id) => { + await axios_instance.delete(`${API_BASE_URL}/${id}/`); +}; + +export default { + getEventProducts, + getEventProductById, + createEventProduct, + updateEventProduct, + deleteEventProduct, +}; diff --git a/frontend/src/api/model/event.js b/frontend/src/api/model/event.js new file mode 100644 index 0000000..108b088 --- /dev/null +++ b/frontend/src/api/model/event.js @@ -0,0 +1,76 @@ +import axios_instance from '../auth'; + +const API_BASE_URL = '/booking/events'; + +/** + * GET seznam událostí (Event). + * + * Query parametry: + * @param {Object} params + * - search: {string} - fulltextové hledání v poli name, description, square.name, atd. + * - city: {string} - název města (např. "Ostrava") + * - start_after: {string} - od data (ISO datetime) + * - end_before: {string} - do data (ISO datetime) + * - square_size: {string} - velikost náměstí (např. "100" pro 100 m²) + * - ordering: {string} - např. "name" nebo "-start" + * + * @returns {Promise>} + */ +export const getEvents = async (params = {}) => { + const response = await axios_instance.get(API_BASE_URL + '/', { params }); + return response.data; +}; + +/** + * GET detail konkrétní události. + * + * @param {number} id - ID události + * @returns {Promise} + */ +export const getEventById = async (id) => { + const response = await axios_instance.get(`${API_BASE_URL}/${id}/`); + return response.data; +}; + +/** + * PATCH - částečná aktualizace události. + * + * @param {number} id - ID události + * @param {Object} data - Libovolná pole z modelu Event, která se mají změnit: + * - name?: {string} + * - description?: {string} + * - start?: {string} ISO datetime + * - end?: {string} ISO datetime + * - square?: {number} + * @returns {Promise} + */ +export const updateEvent = async (id, data) => { + const response = await axios_instance.patch(`${API_BASE_URL}/${id}/`, data); + return response.data; +}; + +export const createEvent = async (formData) => { + const response = await axios_instance.post(`${API_BASE_URL}/`, formData, { + headers: { "Content-Type": "multipart/form-data" }, + }); + return response.data; +}; + + +/** + * DELETE - odstranění události podle ID. + * + * @param {number} id - ID události + * @returns {Promise} - HTTP 204 No Content při úspěchu + */ +export const deleteEvent = async (id) => { + await axios_instance.delete(`${API_BASE_URL}/${id}/`); +}; + +export default { + getEvents, + getEventById, + updateEvent, + deleteEvent, + createEvent, +}; \ No newline at end of file diff --git a/frontend/src/api/model/market_slot.js b/frontend/src/api/model/market_slot.js new file mode 100644 index 0000000..44432ea --- /dev/null +++ b/frontend/src/api/model/market_slot.js @@ -0,0 +1,74 @@ +import axios_instance from '../auth'; + +const MARKET_SLOTS_API_URL = '/booking/market-slots/'; + +/** + * Získá seznam všech prodejních míst s možností filtrování. + * @param {Object} params - Volitelné parametry: + * - event: ID události (integer) + * - status: stav slotu (empty/blocked/taken) + * - ordering: řazení podle pole (např. `x`, `-y`, ...) + * @returns {Promise} - Pole objektů `MarketSlot` + */ +export const getMarketSlots = async (params = {}) => { + const response = await axios_instance.get(MARKET_SLOTS_API_URL, { params }); + return response.data; +}; + +/** + * Vytvoří nové prodejní místo. + * @param {Object} data - Objekt s daty pro nové prodejní místo ve formátu dle API: + * - event: ID události (povinné) + * - status: stav (empty/blocked/taken) + * - base_size: základní velikost v m² + * - available_extension: možnost rozšíření v m² + * - x: X souřadnice + * - y: Y souřadnice + * - width: šířka slotu + * - height: výška slotu + * - price_per_m2: cena za m² + * @returns {Promise} - Vytvořený objekt `MarketSlot` + */ +export const createMarketSlot = async (data) => { + const response = await axios_instance.post(MARKET_SLOTS_API_URL, data); + return response.data; +}; + +/** + * Získá detail konkrétního prodejního místa podle ID. + * @param {number} id - ID prodejního místa + * @returns {Promise} - Objekt `MarketSlot` + */ +export const getMarketSlotById = async (id) => { + const response = await axios_instance.get(`${MARKET_SLOTS_API_URL}${id}/`); + return response.data; +}; + +/** + * Částečně aktualizuje prodejní místo (PATCH). + * @param {number} id - ID prodejního místa k úpravě + * @param {Object} data - Částečný objekt s vlastnostmi k aktualizaci + * @returns {Promise} - Aktualizovaný objekt `MarketSlot` + */ +export const updateMarketSlot = async (id, data) => { + const response = await axios_instance.patch(`${MARKET_SLOTS_API_URL}${id}/`, data); + return response.data; +}; + +/** + * Smaže konkrétní prodejní místo podle ID. + * @param {number} id - ID prodejního místa + * @returns {Promise} - Úspěšný DELETE vrací 204 bez obsahu + */ +export const deleteMarketSlot = async (id) => { + const response = await axios_instance.delete(`${MARKET_SLOTS_API_URL}${id}/`); + return response.data; +}; + +export default { + getMarketSlots, + getMarketSlotById, + createMarketSlot, + updateMarketSlot, + deleteMarketSlot, +}; \ No newline at end of file diff --git a/frontend/src/api/model/order.js b/frontend/src/api/model/order.js new file mode 100644 index 0000000..b0a1f0e --- /dev/null +++ b/frontend/src/api/model/order.js @@ -0,0 +1,82 @@ +import axios_instance from '../auth'; + +const API_BASE_URL = '/commerce/orders'; + +/** + * GET seznam objednávek. + * + * @param {Object} params - Možné query parametry: + * - reservation: {number} ID rezervace + * - user: {number} ID uživatele + * - ordering: {string} např. "-created_at" + * - search: {string} hledání napříč uživatelem, poznámkou, názvem události atd. + * + * @returns {Promise>} + */ +export const getOrders = async (params = {}) => { + const response = await axios_instance.get(`${API_BASE_URL}/`, { params }); + return response.data; +}; + +/** + * GET detail konkrétní objednávky. + * + * @param {number} id - ID objednávky + * @returns {Promise} + */ +export const getOrderById = async (id) => { + const response = await axios_instance.get(`${API_BASE_URL}/${id}/`); + return response.data; +}; + +/** + * POST - vytvoření nové objednávky. + * + * @param {Object} data - Data nové objednávky: + * - reservation: {number} ID rezervace + * - price?: {string} vlastní cena (volitelné, pokud se liší od ceny rezervace) + * + * @returns {Promise} + */ +export const createOrder = async (data) => { + const response = await axios_instance.post(`${API_BASE_URL}/`, data); + return response.data; +}; + +/** + * PATCH - částečná aktualizace objednávky. + * + * @param {number} id - ID objednávky + * @param {Object} data - Libovolné pole z: + * - price?: {string} + * + * @returns {Promise} + */ +export const updateOrder = async (id, data) => { + const response = await axios_instance.patch(`${API_BASE_URL}/${id}/`, data); + return response.data; +}; + +/** + * DELETE - smazání objednávky. + * + * @param {number} id - ID objednávky + * @returns {Promise} HTTP 204 při úspěchu + */ +export const deleteOrder = async (id) => { + await axios_instance.delete(`${API_BASE_URL}/${id}/`); +}; + +export const calculatePrice = async (data) => { + const res = await axios_instance.post("/commerce/calculate_price/", data); + return res.data; +}; + +export default { + calculatePrice, + getOrders, + getOrderById, + createOrder, + updateOrder, + deleteOrder, +}; \ No newline at end of file diff --git a/frontend/src/api/model/product.js b/frontend/src/api/model/product.js new file mode 100644 index 0000000..bbbcaeb --- /dev/null +++ b/frontend/src/api/model/product.js @@ -0,0 +1,98 @@ +import axios_instance from '../auth'; + +/** + * Django REST endpoints (product app): + * - Products: /products/products/ + * + * NOTE: These paths assume `backend/product/urls.py` is included under `/products/` in the root urls. + */ +const API_PRODUCTS_BASE = '/products'; + +/** + * @typedef {Object} Product + * @property {number} id + * @property {string} name Název zboží (max 255 znaků) + * @property {string} code Unikátní kód (např. "FOOD-001") + */ + +/** + * GET seznam produktů. + * + * Podporované query parametry (pokud povolí viewset/filter backend): + * - search?: string full-text dle backendu + * - ordering?: string např. "name" nebo "-name" + * + * @param {Object} params + * @returns {Promise>} + * + * Příklad: + * getProducts({ search: 'med', ordering: 'name' }) + */ +export const getProducts = async (params = {}) => { + const response = await axios_instance.get(`${API_PRODUCTS_BASE}/`, { params }); + return response.data; +}; + +/** + * GET detail produktu. + * @param {number} id + * @returns {Promise} + */ +export const getProductById = async (id) => { + const response = await axios_instance.get(`${API_PRODUCTS_BASE}/${id}/`); + return response.data; +}; + +/** + * POST vytvoření produktu. + * + * Body: + * - name: string (required) + * - code: string (required, unikátní) + * + * @param {{name:string, code:string}} data + * @returns {Promise} + * + * Příklad: + * createProduct({ name: 'Med květový', code: 'FOOD-001' }) + */ +export const createProduct = async (data) => { + const response = await axios_instance.post(`${API_PRODUCTS_BASE}/`, data); + return response.data; +}; + +/** + * PATCH částečná aktualizace produktu. + * + * Body (libovolná kombinace): + * - name?: string + * - code?: string + * + * @param {number} id + * @param {{name?:string, code?:string}} data + * @returns {Promise} + * + * Příklad: + * updateProduct(12, { name: 'Med lesní' }) + */ +export const updateProduct = async (id, data) => { + const response = await axios_instance.patch(`${API_PRODUCTS_BASE}/${id}/`, data); + return response.data; +}; + +/** + * DELETE produkt. + * @param {number} id + * @returns {Promise} HTTP 204 on success + */ +export const deleteProduct = async (id) => { + await axios_instance.delete(`${API_PRODUCTS_BASE}/${id}/`); +}; + +export default { + getProducts, + getProductById, + createProduct, + updateProduct, + deleteProduct, +}; diff --git a/frontend/src/api/model/reservation.js b/frontend/src/api/model/reservation.js new file mode 100644 index 0000000..04ae006 --- /dev/null +++ b/frontend/src/api/model/reservation.js @@ -0,0 +1,96 @@ +import axios_instance from '../auth'; + +const API_BASE_URL = '/booking/reservations'; + +/** + * GET seznam rezervací. + * + * @param {Object} params - Možné query parametry: + * - event: {number} ID události + * - user: {number} ID uživatele + * - status: {'reserved'|'cancelled'} Filtr na stav rezervace + * - Reservationing: {string} např. "-created_at" + * - search: {string} hledání v poli poznámka, uživatel, název události atd. + * + * @returns {Promise>} + */ +export const getReservations = async (params = {}) => { + const response = await axios_instance.get(`${API_BASE_URL}/`, { params }); + return response.data; +}; + +/** + * GET detail konkrétní rezervace. + * + * @param {number} id - ID rezervace + * @returns {Promise} + */ +export const getReservationById = async (id) => { + const response = await axios_instance.get(`${API_BASE_URL}/${id}/`); + return response.data; +}; + +/** + * POST - vytvoření nové rezervace. + * + * @param {Object} data - Data nové rezervace: + * - event: {number} ID události + * - user: {number} ID uživatele (většinou backend vyplní automaticky podle tokenu) + * - note?: {string} poznámka k rezervaci + * - status?: {'reserved'|'cancelled'} (výchozí "reserved") + * - cells: {number[]} seznam ID rezervovaných buněk + * + * @returns {Promise} + */ +export const createReservation = async (data) => { + const response = await axios_instance.post(`${API_BASE_URL}/`, data); + return response.data; +}; + +/** + * PATCH - částečná aktualizace rezervace. + * + * @param {number} id - ID rezervace + * @param {Object} data - Libovolné pole z: + * - event?: {number} + * - note?: {string} + * - status?: {'reserved'|'cancelled'} + * - cells?: {number[]} + * @returns {Promise} + */ +export const updateReservation = async (id, data) => { + const response = await axios_instance.patch(`${API_BASE_URL}/${id}/`, data); + return response.data; +}; + +/** + * DELETE - smazání rezervace. + * + * @param {number} id - ID rezervace + * @returns {Promise} HTTP 204 při úspěchu + */ +export const deleteReservation = async (id) => { + await axios_instance.delete(`${API_BASE_URL}/${id}/`); +}; + +/** + * GET rezervované rozsahy pro konkrétní slot. + * + * @param {number} slotId - ID slotu + * @returns {Promise>} + */ +export const getReservedRanges = async (market_slot_id) => { + const response = await axios_instance.get(`/booking/reserved-days-check/`, { + params: { market_slot_id: market_slot_id } + }); + return response.data; +}; + +export default { + getReservations, + getReservationById, + getReservedRanges, + createReservation, + updateReservation, + deleteReservation, +}; \ No newline at end of file diff --git a/frontend/src/api/model/square.js b/frontend/src/api/model/square.js new file mode 100644 index 0000000..c348e9e --- /dev/null +++ b/frontend/src/api/model/square.js @@ -0,0 +1,75 @@ +import axios_instance from '../auth'; + +const SQUARE_API_URL = '/booking/squares/'; + +/** + * Získá seznam všech náměstí s možností filtrování a fulltextového vyhledávání. + * @param {Object} params - Volitelné parametry: + * - city: podle města (string) + * - psc: podle PSČ (integer) + * - width: šířka (integer) + * - height: výška (integer) + * - search: fulltext (string) + * - ordering: řazení podle pole (např. `name`, `-city`, ...) + * @returns {Promise} - Pole objektů `Square` + */ +export const getSquares = async (params = {}) => { + const response = await axios_instance.get(SQUARE_API_URL, { params }); + return response.data; +}; + +/** + * Získá detail konkrétního náměstí podle ID. + * @param {number} id - ID náměstí + * @returns {Promise} - Objekt `Square` + */ +export const getSquareById = async (id) => { + const response = await axios_instance.get(`${SQUARE_API_URL}${id}/`); + console.log(response.data); + return response.data; +}; + +/** + * Aktualizuje celé náměstí (PATCH). + * @param {number} id - ID náměstí k úpravě + * @param {Object} data - Kompletní objekt náměstí ve formátu dle API (např. `name`, `city`, `width`, `height`, `description`) + * @returns {Promise} - Aktualizovaný objekt `Square` + */ + export const updateSquare = async (id, data) => { + const response = await axios_instance.patch(`${SQUARE_API_URL}${id}/`, data); + return response.data; +}; + + +/** + * Vytvoří nové náměstí (POST). + * @param {Object} data - Objekt náměstí + * @returns {Promise} - Vytvořené náměstí + */ +export const createSquare = async (data) => { + const response = await axios_instance.post(SQUARE_API_URL, data, { + headers: { 'Content-Type': 'multipart/form-data' } + }); + return response.data; +}; + + + +/** + * Smaže konkrétní náměstí podle ID. + * @param {number} id - ID náměstí + * @returns {Promise} - Úspěšný DELETE vrací 204 bez obsahu + */ +export const deleteSquare = async (id) => { + const response = await axios_instance.delete(`${SQUARE_API_URL}${id}/`); + return response.data; +}; + + +export default { + getSquares, + getSquareById, + updateSquare, + deleteSquare, + createSquare +}; \ No newline at end of file diff --git a/frontend/src/api/model/ticket.js b/frontend/src/api/model/ticket.js new file mode 100644 index 0000000..b97525a --- /dev/null +++ b/frontend/src/api/model/ticket.js @@ -0,0 +1,85 @@ +import axios_instance from "../auth"; + +const API_BASE_URL = "/service-tickets"; + +/** + * GET seznam tiketů. + * + * @param {Object} params - Možné query parametry: + * - user: {number} ID uživatele + * - status: {'new'|'in_progress'|'resolved'|'closed'} + * - category: {'tech'|'ServiceTicket'|'payment'|'account'|'content'|'suggestion'|'other'} + * - ordering: {string} např. "-created_at" + * - search: {string} hledání v názvu nebo popisu + * + * @returns {Promise>} + */ +export const getServiceTickets = async (params = {}) => { + const response = await axios_instance.get(`${API_BASE_URL}/`, { params }); + return response.data; +}; + +/** + * GET detail konkrétního tiketu. + * + * @param {number} id - ID tiketu + * @returns {Promise} + */ +export const getServiceTicketById = async (id) => { + const response = await axios_instance.get(`${API_BASE_URL}/${id}/`); + return response.data; +}; + +/** + * POST - vytvoření nového tiketu. + * + * @param {Object} data - Data nového tiketu: + * - title: {string} + * - description?: {string} + * - user?: {number} (volitelné – backend často určí automaticky dle tokenu) + * - category?: {'tech'|'ServiceTicket'|'payment'|'account'|'content'|'suggestion'|'other'} + * - status?: {'new'|'in_progress'|'resolved'|'closed'} (výchozí "new") + * + * @returns {Promise} + */ +export const createServiceTicket = async (data) => { + const response = await axios_instance.post(`${API_BASE_URL}/`, data); + return response.data; +}; + +/** + * PATCH - částečná aktualizace tiketu. + * + * @param {number} id - ID tiketu + * @param {Object} data - Libovolná pole z: + * - title?: {string} + * - description?: {string} + * - category?: {string} + * - status?: {string} + * + * @returns {Promise} + */ +export const updateServiceTicket = async (id, data) => { + const response = await axios_instance.patch(`${API_BASE_URL}/${id}/`, data); + return response.data; +}; + +/** + * DELETE - smazání tiketu. + * + * @param {number} id - ID tiketu + * @returns {Promise} HTTP 204 při úspěchu + */ +export const deleteServiceTicket = async (id) => { + await axios_instance.delete(`${API_BASE_URL}/${id}/`); +}; + + + +export default { + getServiceTickets, + getServiceTicketById, + createServiceTicket, + updateServiceTicket, + deleteServiceTicket, +}; \ No newline at end of file diff --git a/frontend/src/api/model/user.js b/frontend/src/api/model/user.js new file mode 100644 index 0000000..189a669 --- /dev/null +++ b/frontend/src/api/model/user.js @@ -0,0 +1,73 @@ +// frontend/src/api/model/user.js +// User API model for searching users by username +// Structure matches other model files (see order.js for reference) + +import axios_instance from '../auth'; + +const API_BASE_URL = "/account/users"; + +const userAPI = { + /** + * Get all users + * @returns {Promise>} + */ + async getUsers(params) { + const response = await axios_instance.get(`${API_BASE_URL}/`, { params }); + return response.data; + }, + + /** + * Get a single user by ID + * @param {number|string} id + * @returns {Promise} + */ + async getUser(id) { + const response = await axios_instance.get(`${API_BASE_URL}/${id}/`); + return response.data; + }, + + /** + * Update a user by ID + * @param {number|string} id + * @param {Object} data + * @returns {Promise} + */ + async updateUser(id, data) { + const response = await axios_instance.patch(`${API_BASE_URL}/${id}/`, data); + return response.data; + }, + + /** + * Delete a user by ID + * @param {number|string} id + * @returns {Promise} + */ + async deleteUser(id) { + const response = await axios_instance.delete(`${API_BASE_URL}/${id}/`); + return response.data; + }, + + /** + * Create a new user + * @param {Object} data + * @returns {Promise} + */ + async createUser(data) { + const response = await axios_instance.post(`${API_BASE_URL}/`, data); + return response.data; + }, + + /** + * Search users by username (partial match) + * @param {Object} params - { username: string } + * @returns {Promise>} + */ + async searchUsers(params) { + // Adjust the endpoint as needed for your backend + const response = await axios_instance.get(`${API_BASE_URL}/`, { params }); + console.log("User search response:", response.data); + return response.data; + }, +}; + +export default userAPI; diff --git a/frontend/src/api/tutorialy/user.js b/frontend/src/api/tutorialy/user.js new file mode 100644 index 0000000..a7ee4a8 --- /dev/null +++ b/frontend/src/api/tutorialy/user.js @@ -0,0 +1,34 @@ +// ❌ Odhlášení: + +import { logout } from "../api/auth"; + +logout(); + + + + +//✅ Přihlášení uživatele: + +import { login } from "../api/auth"; + +const success = await login("username", "password"); +if (success) { + console.log("Přihlášení úspěšné"); +} else { + alert("Chybné přihlášení"); +} + + + +// 👤 Získání přihlášeného uživatele: + +import { getCurrentUser } from "../api/auth"; + +const user = await getCurrentUser(); + +if (user) { + console.log("Přihlášený uživatel:", user); +} else { + console.log("Nikdo není přihlášen"); +} +// pokud dojde k 401, pokusí se obnovit token \ No newline at end of file diff --git a/frontend/src/api/tutorialy/volání api.js b/frontend/src/api/tutorialy/volání api.js new file mode 100644 index 0000000..58b8e08 --- /dev/null +++ b/frontend/src/api/tutorialy/volání api.js @@ -0,0 +1,46 @@ + +/* PŘÍKLAD FETCH Z VEŘEJNÉHO API (v swaggeru to poznáte podle odemknutého zámečku) */ + +import API_URL from "../auth"; // musíš si importovat API_URL z auth.js + +const response = await fetch(`${API_URL}/account/registration/`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "exampleUser", + email: "example@example.com", + password: "tajneheslo123", + }), +}); + +const data = await response.json(); +console.log(data); + + + +/*---------------PRO CHRÁNĚNÉ ENDPOINTY----------------*/ + +import { apiRequest } from "../auth"; // důležitý helper pro chráněné API + +// ✅ GET – Načtení dat +const userData = await apiRequest("get", "/account/profile/"); + +// ✅ POST – Např. vytvoření nové rezervace +const newItem = await apiRequest("post", "/reservation/create/", { + name: "Stánek s medem", + location: "A5", +}); + +// ✅ PUT – Úplná aktualizace +const updatedItem = await apiRequest("put", "/reservation/42/", { + name: "Upravený stánek", + location: "B1", +}); + +// ✅ PATCH – Částečná aktualizace +const partiallyUpdated = await apiRequest("patch", "/reservation/42/", { + location: "C3", +}); + +// ✅ DELETE – Smazání záznamu +await apiRequest("delete", "/reservation/42/"); diff --git a/frontend/src/assets/json/data.json b/frontend/src/assets/json/data.json new file mode 100644 index 0000000..d6ea62c --- /dev/null +++ b/frontend/src/assets/json/data.json @@ -0,0 +1,362 @@ +[ + { + "id": 1, + "name": "Tržiště Hlavní", + "description": "Centrální tržnice města s širokou nabídkou zboží.", + "street": "Hlavní 25", + "city": "Praha", + "psc": 11000, + "width": 6000, + "height": 4000, + "grid_rows": 60, + "grid_cols": 40, + "cellsize": 100, + "image": "/img/namest-1.png", + "events": [ + { + "id": 201, + "name": "Letní jarmark", + "description": "Událost plná rukodělných výrobků a tradičních pokrmů.", + "start": "2025-07-22T09:00:00.000Z", + "end": "2025-07-22T19:00:00.000Z", + "price_per_m2": "380", + "image": "/img/namest-1.png", + "market_slots": [ + { + "id": 3001, + "event": 201, + "status": "empty", + "base_size": 6, + "available_extension": 3, + "x": 200, + "y": 300, + "width": 300, + "height": 300, + "price_per_m2": "380" + } + ] + } + ] + }, + { + "id": 2, + "name": "Farmářský dvůr", + "description": "Místní trhy s bio produkcí a sezónní zeleninou.", + "street": "Zahradní 3", + "city": "Olomouc", + "psc": 77900, + "width": 4000, + "height": 2500, + "grid_rows": 40, + "grid_cols": 25, + "cellsize": 100, + "image": "/img/namest-1.png", + "events": [ + { + "id": 202, + "name": "Podzimní sklizeň", + "description": "Speciální prodejní den se zaměřením na jablka a dýně.", + "start": "2025-09-15T08:00:00.000Z", + "end": "2025-09-15T16:00:00.000Z", + "price_per_m2": "320", + "image": "/img/namest-1.png", + "market_slots": [ + { + "id": 3002, + "event": 202, + "status": "occupied", + "base_size": 8, + "available_extension": 4, + "x": 150, + "y": 250, + "width": 400, + "height": 300, + "price_per_m2": "320" + }, + { + "id": 3003, + "event": 202, + "status": "empty", + "base_size": 6, + "available_extension": 2, + "x": 600, + "y": 250, + "width": 300, + "height": 300, + "price_per_m2": "320" + } + ] + }, + { + "id": 203, + "name": "Letní jarmark", + "description": "Událost plná rukodělných výrobků a tradičních pokrmů.", + "start": "2025-07-22T09:00:00.000Z", + "end": "2025-07-22T19:00:00.000Z", + "price_per_m2": "380", + "image": "/img/namest-1.png", + "market_slots": [ + { + "id": 3001, + "event": 201, + "status": "empty", + "base_size": 6, + "available_extension": 3, + "x": 200, + "y": 300, + "width": 300, + "height": 300, + "price_per_m2": "380" + } + ] + } + ] + }, + { + "id": 3, + "name": "Jihotržnice", + "description": "Moderní tržiště na okraji města s parkováním.", + "street": "U Výstaviště 10", + "city": "Ostrava", + "psc": 70030, + "width": 5000, + "height": 3000, + "grid_rows": 50, + "grid_cols": 30, + "cellsize": 100, + "image": "/img/namest-1.png", + "events": [ + { + "id": 203, + "name": "Festival chutí", + "description": "Gastronomický zážitek s mezinárodní kuchyní.", + "start": "2025-08-05T10:00:00.000Z", + "end": "2025-08-05T22:00:00.000Z", + "price_per_m2": "550", + "image": "/img/namest-1.png", + "market_slots": [ + { + "id": 3004, + "event": 203, + "status": "reserved", + "base_size": 10, + "available_extension": 5, + "x": 100, + "y": 100, + "width": 500, + "height": 300, + "price_per_m2": "550" + } + ] + } + ] + }, + { + "id": 4, + "name": "Tržiště Jih", + "description": "Trh v jižní části města zaměřený na lokální produkty.", + "street": "Jižní 5", + "city": "Brno", + "psc": 60200, + "width": 5000, + "height": 3000, + "grid_rows": 50, + "grid_cols": 30, + "cellsize": 100, + "image": "/img/namest-1.png", + "events": [ + { + "id": 100, + "name": "Letní trhy", + "description": "Prodej ovoce, zeleniny a domácích výrobků.", + "start": "2025-08-01T08:00:00.000Z", + "end": "2025-08-01T16:00:00.000Z", + "price_per_m2": "420", + "image": "/img/namest-1.png", + "market_slots": [ + { + "id": 1001, + "event": 100, + "status": "empty", + "base_size": 6, + "available_extension": 2, + "x": 100, + "y": 100, + "width": 300, + "height": 300, + "price_per_m2": "420" + } + ] + } + ] + }, + { + "id": 5, + "name": "Staroměstská tržnice", + "description": "Historické tržiště s tradičními řemeslnými stánky.", + "street": "Náměstí 1", + "city": "Praha", + "psc": 11000, + "width": 6000, + "height": 3500, + "grid_rows": 60, + "grid_cols": 35, + "cellsize": 100, + "image": "/img/namest-1.png", + "events": [ + { + "id": 101, + "name": "Řemeslný den", + "description": "Ukázky tradičních řemesel a rukodělných výrobků.", + "start": "2025-09-10T10:00:00.000Z", + "end": "2025-09-10T18:00:00.000Z", + "price_per_m2": "500", + "image": "/img/namest-1.png", + "market_slots": [ + { + "id": 1002, + "event": 101, + "status": "occupied", + "base_size": 8, + "available_extension": 3, + "x": 200, + "y": 150, + "width": 400, + "height": 300, + "price_per_m2": "500" + }, + { + "id": 1003, + "event": 101, + "status": "empty", + "base_size": 6, + "available_extension": 1, + "x": 650, + "y": 150, + "width": 300, + "height": 300, + "price_per_m2": "500" + } + ] + } + ] + }, + { + "id": 6, + "name": "Tržiště Sever", + "description": "Nové moderní tržiště v severní části města.", + "street": "Severní 99", + "city": "Ostrava", + "psc": 70030, + "width": 4000, + "height": 2800, + "grid_rows": 40, + "grid_cols": 28, + "cellsize": 100, + "image": "/img/namest-1.png", + "events": [ + { + "id": 102, + "name": "Festival chutí", + "description": "Ochutnávky světových kuchyní a místních specialit.", + "start": "2025-07-25T12:00:00.000Z", + "end": "2025-07-25T22:00:00.000Z", + "price_per_m2": "620", + "image": "/img/namest-1.png", + "market_slots": [ + { + "id": 1004, + "event": 102, + "status": "reserved", + "base_size": 10, + "available_extension": 5, + "x": 300, + "y": 200, + "width": 500, + "height": 300, + "price_per_m2": "620" + } + ] + } + ] + }, + { + "id": 7, + "name": "Náměstí svobody", + "description": "Centrální náměstí s možností konání kulturních akcí.", + "street": "Svobody 1", + "city": "Zlín", + "psc": 76001, + "width": 5500, + "height": 3300, + "grid_rows": 55, + "grid_cols": 33, + "cellsize": 100, + "image": "/img/namest-1.png", + "events": [ + { + "id": 103, + "name": "Letní slavnosti", + "description": "Hudební program, stánky a večerní ohňostroj.", + "start": "2025-08-10T15:00:00.000Z", + "end": "2025-08-10T23:00:00.000Z", + "price_per_m2": "700", + "image": "/img/namest-1.png", + "market_slots": [ + { + "id": 1005, + "event": 103, + "status": "empty", + "base_size": 7, + "available_extension": 2, + "x": 150, + "y": 300, + "width": 350, + "height": 300, + "price_per_m2": "700" + } + ] + } + ] + }, + { + "id": 8, + "name": "Městská tržnice", + "description": "Tradiční městské tržiště s krytým i venkovním prostorem.", + "street": "Masarykova 88", + "city": "Plzeň", + "psc": 30100, + "width": 4800, + "height": 2900, + "grid_rows": 48, + "grid_cols": 29, + "cellsize": 100, + "image": "/img/namest-1.png", + "events": [ + { + "id": 104, + "name": "Plzeňský trh", + "description": "Speciální nabídka pivních specialit a suvenýrů.", + "start": "2025-08-28T10:00:00.000Z", + "end": "2025-08-28T20:00:00.000Z", + "price_per_m2": "560", + "image": "/img/namest-1.png", + "market_slots": [ + { + "id": 1006, + "event": 104, + "status": "occupied", + "base_size": 9, + "available_extension": 3, + "x": 500, + "y": 400, + "width": 450, + "height": 300, + "price_per_m2": "560" + } + ] + } + ] + } + +] diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/ConfirmEmailBar.jsx b/frontend/src/components/ConfirmEmailBar.jsx new file mode 100644 index 0000000..f483f64 --- /dev/null +++ b/frontend/src/components/ConfirmEmailBar.jsx @@ -0,0 +1,114 @@ +import React, { useState } from 'react'; +import { Container, Button, Alert, Spinner } from 'react-bootstrap'; +import { Link } from 'react-router-dom'; // Umožňuje odkazování v rámci SPA + +const backendURL = import.meta.env.VITE_BACKEND_URL; //import url backendu + +// Komponenta pro ověření e-mailu +function EmailVerificationPage() { + // Stavy komponenty: + // - idle: čeká se na kliknutí + // - loading: probíhá požadavek + // - success: ověření proběhlo úspěšně + // - error: něco se pokazilo + const [status, setStatus] = useState('idle'); + const [errorMsg, setErrorMsg] = useState(null); + + // Načtení parametrů z URL (např. ?uidb64=abc&token=xyz) + const searchParams = new URLSearchParams(window.location.search); + const uidb64 = searchParams.get('uidb64'); + const token = searchParams.get('token'); + + // Funkce spuštěná po kliknutí na tlačítko "Verifikovat" + const handleVerify = async () => { + // Zkontroluj, zda v URL jsou potřebné parametry + if (!uidb64 || !token) { + setErrorMsg('Chybí potřebné parametry v URL.'); + setStatus('error'); + return; + } + + // Zobrazíme loading stav + setStatus('loading'); + setErrorMsg(null); + + try { + // Sestavíme URL pro API volání + const url = `${backendURL}/api/account/registration/verify-email/${encodeURIComponent(uidb64)}/${encodeURIComponent(token)}`; + + // Pošleme GET požadavek na backend + const response = await fetch(url, { method: 'GET' }); + + // Pokud vše proběhlo OK + if (response.ok) { + setStatus('success'); + } else { + // Jinak zobrazíme chybovou zprávu od backendu + const data = await response.json(); + setErrorMsg(data.detail || 'Ověření selhalo.'); + setStatus('error'); + } + } catch (err) { + // Chyba při spojení se serverem + setErrorMsg('Chyba při spojení se serverem.'); + setStatus('error'); + } + }; + + return ( + +

Ověření e-mailu

+ + {/* Výchozí stav: uživatel může kliknout na tlačítko */} + {status === 'idle' && ( + <> +

Kliknutím ověříš svůj e-mailový účet.

+
+ +
+ + )} + + {/* Stav: načítání – zobrazí spinner */} + {status === 'loading' && ( +
+ +

Probíhá ověřování...

+
+ )} + + {/* Stav: úspěšné ověření – zobrazí success hlášku a tlačítko na přihlášení */} + {status === 'success' && ( + <> + + E-mail byl úspěšně ověřen! + +
+ {/* Odkaz na přihlášení – používá react-router */} + +
+ + )} + + {/* Stav: chyba – zobrazí error hlášku a možnost zkusit znovu */} + {status === 'error' && ( + <> + + Chyba: {errorMsg} + +
+ +
+ + )} +
+ ); +} + +export default EmailVerificationPage; \ No newline at end of file diff --git a/frontend/src/components/DynamicGrid.jsx b/frontend/src/components/DynamicGrid.jsx new file mode 100644 index 0000000..08fc481 --- /dev/null +++ b/frontend/src/components/DynamicGrid.jsx @@ -0,0 +1,525 @@ +// Exported default config for use in other components +export const DEFAULT_CONFIG = { + rows: 28, + cols: 20, + cellSize: 30, + statusColors: { + allowed: "rgba(0, 128, 0, 0.6)", + taken: "rgba(255, 165, 0, 0.6)", + blocked: "rgba(255, 0, 0, 0.6)", + }, +}; +// DynamicGrid.jsx +// This component renders a dynamic grid for managing reservations. + +import React, { + useState, + useRef, + useCallback, + useMemo, + useEffect, +} from "react"; + +const DynamicGrid = ({ + config = DEFAULT_CONFIG, + reservations, + onReservationsChange, + selectedIndex, + onSelectedIndexChange, + static: isStatic = false, //možnost editovaní prostorů + multiSelect = false, //možnost zvolit více rezervací + clickableStatic = false, //možnost volit rezervace i ve ,,static,, = true + backgroundImage, // <-- add this prop +}) => { + const { + rows = DEFAULT_CONFIG.rows, + cols = DEFAULT_CONFIG.cols, + cellSize = DEFAULT_CONFIG.cellSize, + statusColors = DEFAULT_CONFIG.statusColors, + } = config; + + const statusLabels = { + allowed: "Povoleno", + taken: "Rezervováno", + blocked: "Blokováno", + }; + + const [startCell, setStartCell] = useState(null); + const [hoverCell, setHoverCell] = useState(null); + const [isDragging, setIsDragging] = useState(false); + const [draggedIndex, setDraggedIndex] = useState(null); + const [resizingIndex, setResizingIndex] = useState(null); + const gridRef = useRef(null); + const dragOffsetRef = useRef({ x: 0, y: 0 }); + const lastCoordsRef = useRef(null); + + // Selection is now fully controlled by parent + // Selection is now fully controlled by parent + const getSelectedIndices = () => { + if (multiSelect) { + return Array.isArray(selectedIndex) ? selectedIndex : []; + } else { + return selectedIndex !== null && selectedIndex !== undefined ? [selectedIndex] : []; + } + }; + const selectedIndices = getSelectedIndices(); + + // Selection is now fully controlled by parent + + + // Clamp function to ensure values stay within bounds + // This function restricts a value to be within a specified range. + const clamp = useCallback( + (val, min, max) => Math.max(min, Math.min(max, val)), + [] + ); + + // Function to get cell coordinates based on mouse event + // This function calculates the grid cell coordinates based on the mouse position. + const getCellCoords = useCallback( + (e) => { + const rect = gridRef.current.getBoundingClientRect(); + const cellWidth = rect.width / cols; + const cellHeight = rect.height / rows; + + const x = clamp(Math.floor((e.clientX - rect.left) / cellWidth), 0, cols - 1); + const y = clamp(Math.floor((e.clientY - rect.top) / cellHeight), 0, rows - 1); + + return { x, y }; + }, + [clamp, rows, cols] + ); + + // Function to check if two rectangles overlap + // This function determines if two rectangles defined by their coordinates overlap. + const rectanglesOverlap = useCallback( + (a, b) => + a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y, + [] + ); + + // Function to check if a new rectangle collides with existing reservations + // This function checks if a new rectangle overlaps with any existing reservations, + const hasCollision = useCallback( + (newRect, ignoreIndex = -1) => + reservations.some( + (r, i) => i !== ignoreIndex && rectanglesOverlap(newRect, r) + ), + [reservations, rectanglesOverlap] + ); + + // Function to handle mouse down events + // This function initiates dragging or resizing of reservations based on mouse events. + const handleMouseDown = useCallback( + (e) => { + if (e.button !== 0) return; + lastCoordsRef.current = null; + + const coords = getCellCoords(e); + let isReservationClicked = false; + + const resIndex = reservations.findIndex( + (r) => + coords.x >= r.x && + coords.x < r.x + r.w && + coords.y >= r.y && + coords.y < r.y + r.h + ); + + if (resIndex !== -1) { + const res = reservations[resIndex]; + isReservationClicked = true; + + if (!isStatic || res.status === "allowed") { + if (multiSelect) { + let newSelected; + if (selectedIndices.includes(resIndex)) { + newSelected = selectedIndices.filter(i => i !== resIndex); + } else { + newSelected = [...selectedIndices, resIndex]; + } + // setSelectedIndices removed; selection is now controlled by parent + onSelectedIndexChange(newSelected); + } else { + // setSelectedIndices removed; selection is now controlled by parent + onSelectedIndexChange(resIndex); + } + } + + if (!isStatic) { + dragOffsetRef.current = { + x: coords.x - res.x, + y: coords.y - res.y, + }; + + if (e.target.classList.contains("resize-handle")) { + setResizingIndex(resIndex); + setDraggedIndex(null); + } else { + setDraggedIndex(resIndex); + setResizingIndex(null); + } + } + } else if (!isStatic) { + setStartCell(coords); + setIsDragging(true); + setDraggedIndex(null); + setResizingIndex(null); + } + + // Deselect if clicking outside any reservation + if (!isReservationClicked) { + onSelectedIndexChange(null); + } + }, + [ + getCellCoords, + reservations, + isStatic, + multiSelect, + selectedIndices, + onSelectedIndexChange, + // setSelectedIndices removed; selection is now controlled by parent + dragOffsetRef, + setDraggedIndex, + setResizingIndex, + setStartCell, + setIsDragging, + ] + ); + + // Function to handle mouse move events + // This function updates the hover cell and handles dragging/resizing. + const handleMouseMove = useCallback( + (e) => { + if (isStatic) return; + const coords = getCellCoords(e); + + if (isDragging && startCell) { + setHoverCell(coords); + } + + if (draggedIndex !== null) { + const res = reservations[draggedIndex]; + const offset = dragOffsetRef.current; + const newX = clamp(coords.x - offset.x, 0, cols - res.w); + const newY = clamp(coords.y - offset.y, 0, rows - res.h); + + if ( + !hasCollision( + { ...res, x: newX, y: newY }, + draggedIndex + ) + ) { + onReservationsChange((prev) => + prev.map((r, i) => + i === draggedIndex ? { ...r, x: newX, y: newY } : r + ) + ); + } + } + + if (resizingIndex !== null) { + const res = reservations[resizingIndex]; + const minW = 1; + const minH = 1; + const newW = clamp(coords.x - res.x + 1, minW, cols - res.x); + const newH = clamp(coords.y - res.y + 1, minH, rows - res.y); + + if ( + !hasCollision( + { ...res, w: newW, h: newH }, + resizingIndex + ) + ) { + onReservationsChange((prev) => + prev.map((r, i) => + i === resizingIndex ? { ...r, w: newW, h: newH } : r + ) + ); + } + } + }, + [ + isStatic, + isDragging, + startCell, + getCellCoords, + setHoverCell, + draggedIndex, + reservations, + dragOffsetRef, + clamp, + cols, + rows, + hasCollision, + onReservationsChange, + resizingIndex, + setResizingIndex, + ] + ); + + // Function to handle mouse up events + // This function finalizes the creation of a new reservation or ends dragging/resizing. + const handleMouseUp = useCallback( + (e) => { + if (isStatic) return; + + if (isDragging && startCell && hoverCell) { + const minX = Math.min(startCell.x, hoverCell.x); + const minY = Math.min(startCell.y, hoverCell.y); + const w = Math.abs(startCell.x - hoverCell.x) + 1; + const h = Math.abs(startCell.y - hoverCell.y) + 1; + + const newRect = { + x: minX, + y: minY, + w, + h, + name: `Cell ${reservations.length + 1}`, + status: "allowed", + }; + + if ( + !hasCollision(newRect) && + minX >= 0 && + minY >= 0 && + minX + w <= cols && + minY + h <= rows + ) { + onReservationsChange((prev) => [...prev, newRect]); + } + } + + setStartCell(null); + setHoverCell(null); + setIsDragging(false); + setDraggedIndex(null); + setResizingIndex(null); + lastCoordsRef.current = null; + }, + [ + isDragging, + startCell, + hoverCell, + reservations, + hasCollision, + onReservationsChange, + rows, + cols, + isStatic, + ] + ); + + // Function to handle reservation deletion + // This function removes a reservation from the grid based on its index. + const handleDeleteReservation = useCallback( + (index) => { + if (isStatic) return; // Disable for static + onReservationsChange((prev) => prev.filter((_, i) => i !== index)); + + // Aktualizuj vybraný stav + if (multiSelect) { + const newSelected = selectedIndices.filter(i => i !== index); + // setSelectedIndices removed; selection is now controlled by parent + onSelectedIndexChange(newSelected); + } else { + if (selectedIndex === index) { + onSelectedIndexChange(null); + } else if (selectedIndex > index) { + onSelectedIndexChange(selectedIndex - 1); + } + } + }, + [onReservationsChange, onSelectedIndexChange, selectedIndex, selectedIndices, multiSelect, isStatic] + ); + + + // Function to handle status change of a reservation + // This function updates the status of a reservation based on user selection. + const handleStatusChange = useCallback( + (index, newStatus) => { + if (isStatic) return; // Disable for static + onReservationsChange((prev) => + prev.map((res, i) => + i === index ? { ...res, status: newStatus } : res + ) + ); + }, + [onReservationsChange, isStatic] + ); + + // Generate grid cells based on rows and columns + // This function creates a grid of cells based on the specified number of rows and columns. + const gridCells = useMemo( + () => + [...Array(rows * cols)].map((_, index) => { + const x = index % cols; + const y = Math.floor(index / cols); + return ( +
+ ); + }), + [rows, cols, cellSize] + ); + + + // Filter out-of-bounds reservations and keep mapping to original indices + const filteredReservationsWithIndex = reservations + .map((res, idx) => ({ ...res, _originalIndex: idx })) + .filter( + (res) => + res.x >= 0 && res.y >= 0 && + res.x + res.w <= cols && + res.y + res.h <= rows + ); + + return ( +
(isStatic ? undefined : e.preventDefault())} + style={{ + width: "100%", + height: "auto", + aspectRatio: `${cols} / ${rows}`, + display: "grid", + gridTemplateColumns: `repeat(${cols}, 1fr)`, + gridTemplateRows: `repeat(${rows}, 1fr)`, + cursor: isStatic ? "default" : "crosshair", + position: "relative", + boxSizing: "border-box", + userSelect: "none", + backgroundImage: backgroundImage ? `url(${backgroundImage})` : undefined, + backgroundSize: "contain", + backgroundRepeat: "no-repeat", + backgroundPosition: "center", + }} + > + {/* Grid buňky (pozadí) */} + {gridCells.map((cell, i) => React.cloneElement(cell, { key: i }))} + + {/* Rezervace */} + {filteredReservationsWithIndex.map((res, i) => { + const origIdx = res._originalIndex; + return ( +
{ + if (!isStatic) { + e.preventDefault(); + handleDeleteReservation(origIdx); + } + }} + style={{ + position: "absolute", + left: (res.x / cols) * 100 + "%", + top: (res.y / rows) * 100 + "%", + width: (res.w / cols) * 100 + "%", + height: (res.h / rows) * 100 + "%", + backgroundColor: statusColors[res.status], + border: selectedIndices.includes(origIdx) ? "2px solid black" : "none", + boxShadow: selectedIndices.includes(origIdx) ? "0 0 8px 2px rgba(0,0,0,0.3)" : "none", + borderRadius: 4, + fontSize: "0.8rem", + textAlign: "center", + transition: draggedIndex === origIdx || resizingIndex === origIdx ? "none" : "all 0.2s ease", + zIndex: 2, + cursor: isStatic ? (res.status === "allowed" ? "pointer" : "default") : "move", + overflow: "hidden", + userSelect: "none", + }} + onClick={(e) => { + e.stopPropagation(); + if (!isStatic || (clickableStatic && res.status === "allowed")) { + // Always notify parent of clicked index; parent manages selection array + onSelectedIndexChange(origIdx); + } + }} + > +
+
+ {i + 1} +
+ {isStatic ? ( +
+ {statusLabels[res.status]} +
+ ) : ( + + )} +
+ {!isStatic && ( +
+ )} +
+ ); + })} + + {/* Výběr nové rezervace (draft) */} + {!isStatic && isDragging && startCell && hoverCell && ( +
+ )} +
+ ); +}; + + +export default DynamicGrid; diff --git a/frontend/src/components/DynamicMap.jsx b/frontend/src/components/DynamicMap.jsx new file mode 100644 index 0000000..3ddf5b0 --- /dev/null +++ b/frontend/src/components/DynamicMap.jsx @@ -0,0 +1,155 @@ +import React, { useState } from "react"; +import { Responsive, WidthProvider } from "react-grid-layout"; +import "react-grid-layout/css/styles.css"; +import "react-resizable/css/styles.css"; + +const ResponsiveGridLayout = WidthProvider(Responsive); + +const MyResponsiveGrid = () => { + +const maxGridHeight = 700; // Your fixed height +const rowHeight = 30; // Should match your rowHeight prop +const maxRows = Math.floor(maxGridHeight / rowHeight) - 6; + + const [layoutData, setLayoutData] = useState([]); + const [lockMode, setLockMode] = useState(false); + + const cleanLayout = (layout) => { + return layout.map(item => { + if (item.y + item.h > maxRows) { + return { ...item, y: Math.max(0, maxRows - item.h) }; + } + return item; + }); +}; + +const handleLayoutChange = (currentLayout) => { + const filtered = currentLayout.filter(item => item.i !== "__dropping-elem__"); + const cleaned = cleanLayout(filtered); + setLayoutData(cleaned); +}; + + const handleDrop = (layout, layoutItem, _event) => { + const newItemId = new Date().getTime().toString(); + const { w = 2, h = 2, status } = JSON.parse(_event.dataTransfer.getData("text/plain")) || {}; + + // Prevent dropping below max rows + if (layoutItem.y + h > maxRows) { + layoutItem.y = Math.max(0, maxRows - h); + } + + const newItem = { + i: newItemId, + x: layoutItem.x, + y: layoutItem.y, + w, + h, + static: status === "reserved" || status === "blocked", + status, + }; + setLayoutData((prev) => [...prev, newItem]); +}; + + const toggleLockMode = () => setLockMode(prev => !prev); + + const handleItemClick = (id) => { + if (!lockMode) return; + setLayoutData(prev => + prev.map(item => + item.i === id ? { ...item, static: !item.static } : item + ) + ); + }; + + const getItemClass = (item) => { + if (item.status === "reserved") return "bg-warning"; + if (item.status === "blocked") return "bg-danger"; + return "bg-white"; + }; + + return ( +
+
+ + +
{ + e.dataTransfer.setData( + "text/plain", + JSON.stringify({ w: 2, h: 2 }) + ); + }} + > + Free +
+
{ + e.dataTransfer.setData( + "text/plain", + JSON.stringify({ w: 3, h: 2, status: "reserved" }) + ); + }} + > + Reserved Block +
+
{ + e.dataTransfer.setData( + "text/plain", + JSON.stringify({ w: 3, h: 2, status: "blocked" }) + ); + }} + > + Blocked Block +
+
+ + + {layoutData.map((item) => ( +
handleItemClick(item.i)} + className={`${getItemClass(item)} p-2 border rounded text-center cursor-pointer`} + > + Item {item.i} {item.static ? "(Locked)" : ""} +
+ ))} +
+ +
+        {JSON.stringify(layoutData, null, 2)}
+      
+
+ ); +}; + +export default MyResponsiveGrid; diff --git a/frontend/src/components/LoginCard.jsx b/frontend/src/components/LoginCard.jsx new file mode 100644 index 0000000..e9530d9 --- /dev/null +++ b/frontend/src/components/LoginCard.jsx @@ -0,0 +1,175 @@ +import Button from "react-bootstrap/Button"; +import Form from "react-bootstrap/Form"; +import Card from "react-bootstrap/Card"; +import Row from "react-bootstrap/Row"; +import Col from "react-bootstrap/Col"; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faEnvelope } from '@fortawesome/free-solid-svg-icons'; +import { faKey } from '@fortawesome/free-solid-svg-icons'; +import { useState } from "react"; +import Spinner from "react-bootstrap/Spinner"; + +import { login } from "../api/auth"; +import { useNavigate } from "react-router-dom"; + +function LoginCard() { + const navigate = useNavigate(); + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e) => { + e.preventDefault(); + setLoading(true); + setErrorMessage(""); + try { + const success = await login(email, password); + if (success) { + navigate("/home"); + console.log("Přihlášení bylo úspěšné"); + } + } catch (error) { + console.error("Chyba při přihlášení:", error); + // Rozlišení typu chyby + if (error.response) { + if (error.response.status === 0 || error.response.status >= 500) { + setErrorMessage("Chyba sítě nebo serveru. Zkuste to později."); + } else if (error.response.status === 401 || error.response.status === 400) { + setErrorMessage("Neplatné přihlašovací údaje."); + } else { + setErrorMessage("Neočekávaná chyba při přihlášení."); + } + } else if (error.request) { + setErrorMessage("Nelze se spojit se serverem. Zkontrolujte připojení k internetu."); + } else { + setErrorMessage("Chyba aplikace: " + error.message); + } + } finally { + setLoading(false); + } + }; + + return ( + + +

Přihlášení

+
+ + +
+ + + + + + + + setEmail(e.target.value)} + value={email} + disabled={loading} + /> + + + + + + + + + + setPassword(e.target.value)} + value={password} + disabled={loading} + /> + + + + + +
+ + + +
+ + + + + + +
+
+ + + + {/* Zobrazení chyby */} + {errorMessage && ( +
+ {errorMessage} +
+ )} +
+
+
+ + + + + + + + + + +
+ ); +} + +export default LoginCard; \ No newline at end of file diff --git a/frontend/src/components/NavBar.jsx b/frontend/src/components/NavBar.jsx new file mode 100644 index 0000000..c35da84 --- /dev/null +++ b/frontend/src/components/NavBar.jsx @@ -0,0 +1,71 @@ +import React, { useContext, useEffect, useState } from 'react'; +import { Container, Nav, Navbar } from 'react-bootstrap'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faRightFromBracket, faUser, faTicket } from '@fortawesome/free-solid-svg-icons'; +import { Link, useNavigate } from 'react-router-dom'; + +import staticLogo from '/img/logo.png'; +import { logout } from '../api/auth'; +import { UserContext } from '../context/UserContext'; +import { getPublicAppConfig } from '../api/model/Settings'; + +function NavBar() { + const { user, setUser } = useContext(UserContext); + const navigate = useNavigate(); + const [logoUrl, setLogoUrl] = useState(staticLogo); + + useEffect(() => { + (async () => { + const data = await getPublicAppConfig(['logo']); + if (data?.logo) setLogoUrl(data.logo); + })(); + }, []); + + const handleLogout = async () => { + try { + await logout(); + setUser(null); + navigate('/login'); + } catch (err) { + console.error('Logout failed', err); + } + }; + + return ( + + + + Logo + + + + + + + + ); +} + +export default NavBar; diff --git a/frontend/src/components/RegisterCard.jsx b/frontend/src/components/RegisterCard.jsx new file mode 100644 index 0000000..7b62d94 --- /dev/null +++ b/frontend/src/components/RegisterCard.jsx @@ -0,0 +1,480 @@ +import { Button, Form, Card, Row, Col, ToggleButton, Container, InputGroup, Modal } from "react-bootstrap"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faUser, + faUniversity, + faKey, + faBuilding, + faPhone, + faEnvelope, + faLock, + faBook, + faAddressCard, + faBriefcase, + faRoad, + faEnvelopeSquare, +} from "@fortawesome/free-solid-svg-icons"; + +import React, { use, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { API_URL } from "../api/auth"; + +function RegisterCard() { + const [isFirm, setIsFirm] = useState(false); // false = Individual, true = Company + const handleSwitchChange = (e) => { + setIsFirm(!isFirm); + setAccountType(!isFirm ? "Company" : "Individual"); + }; + const [error, setError] = useState(""); + const navigate = useNavigate(); + const [show, setShow] = useState(false); + const handleClose = () => setShow(false); + const handleShow = () => setShow(true); + const [first_name, setFirstName] = useState(""); + const [last_name, setLastName] = useState(""); + const [email, setEmail] = useState("@"); + const [password, setPassword] = useState(""); + const [phone_number, setPhoneNumber] = useState("+420"); + const [street, setStreet] = useState(""); + const [city, setCity] = useState(""); + const [PSC, setPSC] = useState(""); + const [bank_account, setBankAccount] = useState(""); + const [ICO, setICO] = useState(""); + const [RC, setRC] = useState(""); + const [GDPR, setGDPR] = useState(true); + const [account_type, setAccountType] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async (e) => { + e.preventDefault(); + + if (isSubmitting) return; // ⛔ Prevent multiple submits + + setIsSubmitting(true); + setError(""); + + try { + const response = await fetch(`${API_URL}/account/registration/`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + first_name, + last_name, + email, + phone_number, + street, + city, + PSC, + bank_account, + RC, + ICO, + GDPR, + account_type, + password, + }), + }); + + if (!response.ok) { + throw new Error("Neplatné přihlašovací údaje"); + } + + // přesměruj na dashboard nebo domovskou stránku + navigate("/reservation"); + } catch (err) { + setIsSubmitting(false); + navigate("/register"); + setError(err.message || "Přihlášení selhalo"); + console.log(error); + + } + }; + + return ( + <> + + +
+

Registrační formulář

+

Vyplňte níže požadované údaje

+
+ +
+ +
+
+ + +
+ + {isFirm ? ( + <> + + + +
+ + +   Název + +
+ + setFirstName(e.target.value)} + /> +
+
+ + ) : ( + <> + + + +
+ + +   Jméno + +
+ + setFirstName(e.target.value)} + /> +
+
+ + + + +
+ + +   Příjmení + +
+ + setLastName(e.target.value)} + /> +
+
+ + )} + + + + +
+ + +   Email + +
+ + setEmail(e.target.value)} + /> +
+
+ + + + +
+ + +   Heslo + +
+ + setPassword(e.target.value)} + /> +
+
+ + + + +
+ + +   Telefon + +
+ + setPhoneNumber(e.target.value)} + /> +
+
+ + + + +
+ + +   Ulice + +
+ + setStreet(e.target.value)} + /> +
+
+ + + + + + + +   Město + + setCity(e.target.value)} + style={{ minWidth: 0 }} // klíčové pro rozbití šířky + /> + + + + + + + + +   PSČ + + setPSC(e.target.value)} + style={{ minWidth: 0 }} + /> + + + + + + + +
+ + +   Číslo účtu + +
+ + setBankAccount(e.target.value)} + /> +
+
+ + + + +
+ + {isFirm ? ( + + ) : ( + + )} +   {isFirm ? "IČ" : "RČ"} + +
+ + setICO(e.target.value) + : (e) => setRC(e.target.value) + } + /> +
+
+ + +
+ + + Souhlasím se zpracováním osobních údajů + +
+
+ +
+ + + +
+
+ +
+
+ + + Informace o zpracování osobních údajů + + +

+ Při použití Elektronické přepážky a při vyřízení požadavků uživatelů + Elektronické přepážky dochází ke zpracováním osobních údajů + uživatelů správcem{" "} + + - statutárním městem Ostrava – městským obvodem Ostrava-Jih, + + se sídlem Horní 791/3, 700 30 Ostrava, IČO: 00845451, v rozsahu + jména a příjmení, tel. kontaktu, e-mailové adresy, č. SIPO, č. + nájemní smlouvy, pro níže vymezené účely zpracování. +

+

+ Kontaktní údaje správce: statutární město Ostrava – městský + obvod Ostrava-Jih, adresa: Horní 791/3, 700 30 Ostrava +

+

+ e-mail: posta@ovajih.cz +

+

ID datové schránky: 2s3brdz

+

+ Kontaktní údaje pověřence: Martin Krupa, e-mail: + martin.krupa@gdpr-opava.cz, tel. kontakt: +420 724 356 825; + advokátní kancelář KLIMUS & PARTNERS s.r.o., se sídlem Vídeňská + 188/119d, 619 00 Brno - Dolní Heršpice, zastoupena Mgr. Romanem + Klimusem, tel. č. +420 602 705 686, e-mail: roman@klimus.cz, ID + datové schránky: ewann52. +

+

+ Účelem zpracování poskytnutých osobních údajů je vyřízení požadavků + uživatelů Elektronické přepážky – plnění povinností z uzavřených + nájemních smluv. +

+

+ Osobní údaje mohou být v nezbytně nutném rozsahu poskytovány + následujícím příjemcům – externím subjektům zajišťujícím plnění + povinností správce jakožto pronajímatele na základě požadavků + uživatelů Elektronické přepážky. +

+

+ Zpracování výše uvedených osobních údajů bude probíhat po dobu + vyřízení požadavku zaslaného Elektronickou přepážkou a následně + mohou být osobní údaje uživatele uchovávány v nezbytném rozsahu a po + nezbytnou dobu za účelem ochrany práv a právem chráněných zájmů + správce, subjektů údajů nebo jiné dotčené osoby. +

+

+ Zpracování osobních údajů je prováděno na základě právního titulu + plnění smlouvy, splnění právní povinnosti správce. +

+

+ Bližší informace o právech uživatele Elektronické přepážky jako + subjektu údajů, jakož i o možnostech jejich uplatnění, jsou uvedeny + ve Vnitřní směrnici o ochraně osobních údajů. +

+
+ + + +
+ + ); +} + +export default RegisterCard; diff --git a/frontend/src/components/ReportForm.jsx b/frontend/src/components/ReportForm.jsx new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/components/Settings.jsx b/frontend/src/components/Settings.jsx new file mode 100644 index 0000000..1e1b256 --- /dev/null +++ b/frontend/src/components/Settings.jsx @@ -0,0 +1,209 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import { Form, Container, Row, Col, Alert, Spinner, Image, Button as BsButton } from 'react-bootstrap'; +import { getAppConfig, createAppConfig, updateAppConfig } from '../api/model/Settings'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faPen, faUpload, faRotateRight } from '@fortawesome/free-solid-svg-icons'; + +/* + Admin Site Settings (AppConfig singleton) + Mirrors UX pattern of UserSettings with inline field editing. + + Fields managed: + bank_account, sender_email, + background_image (file), logo (file), + variable_symbol, max_reservations_per_event, + contact_phone, contact_email + + Image updates use FormData PATCH (only changed files submitted). +*/ + +export default function Settings() { + const [config, setConfig] = useState(null); + const [formData, setFormData] = useState({}); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + const [editing, setEditing] = useState({}); + const [filePreviews, setFilePreviews] = useState({}); + + // Load singleton + useEffect(() => { + let isMounted = true; + (async () => { + try { + const cur = await getAppConfig(); + if (isMounted) { + setConfig(cur); + setFormData(cur || {}); + } + } catch (e) { + setError('Nepodařilo se načíst konfiguraci.'); + } finally { + if (isMounted) setLoading(false); + } + })(); + return () => { isMounted = false; }; + }, []); + + const startEdit = (name) => setEditing(prev => ({ ...prev, [name]: true })); + const stopEdit = (name) => setEditing(prev => { const c = { ...prev }; delete c[name]; return c; }); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + }; + + const handleFile = (name, file) => { + if (!file) return; + setFormData(prev => ({ ...prev, [name]: file })); + setEditing(prev => ({ ...prev, [name]: true })); + const url = URL.createObjectURL(file); + setFilePreviews(prev => ({ ...prev, [name]: url })); + }; + + const resetFile = (name) => { + setFormData(prev => ({ ...prev, [name]: null })); + setFilePreviews(prev => { const c = { ...prev }; delete c[name]; return c; }); + }; + + const save = useCallback(async (e) => { + e.preventDefault(); + setSaving(true); setError(''); setSuccess(''); + try { + // Decide if we need multipart (only when NEW files selected) + let result; + const fileFields = ['background_image','logo']; + const hasFiles = fileFields.some(f => formData[f] instanceof File); + if (!config) { + // Creating new + if (hasFiles) { + const fd = new FormData(); + ['bank_account','sender_email','variable_symbol','max_reservations_per_event','contact_phone','contact_email'].forEach(f => { + if (formData[f] !== undefined && formData[f] !== null && formData[f] !== '') fd.append(f, formData[f]); + }); + fileFields.forEach(f => { if (formData[f] instanceof File) fd.append(f, formData[f]); }); + result = await createAppConfig(fd); + } else { + result = await createAppConfig(formData); + } + } else { + // Updating existing + if (hasFiles) { + const fd = new FormData(); + // Append changed non-file fields + Object.keys(editing).forEach(k => { + if (!fileFields.includes(k)) { + const v = formData[k]; + if (v !== undefined && v !== null && v !== '') fd.append(k, v); + } + }); + // Append only new file objects + fileFields.forEach(f => { if (formData[f] instanceof File) fd.append(f, formData[f]); }); + result = await updateAppConfig(config.id, fd); + } else { + // JSON patch; include edited fields + any file fields cleared (value === null) + const payload = {}; + Object.keys(editing).forEach(k => { payload[k] = formData[k]; }); + fileFields.forEach(f => { if (formData[f] === null) payload[f] = null; }); + result = await updateAppConfig(config.id, payload); + } + } + setConfig(result); + setFormData(result); + setEditing({}); + setSuccess('✅ Nastavení uloženo.'); + } catch (err) { + setError('Nepodařilo se uložit nastavení.'); + } finally { + setSaving(false); + } + }, [config, formData, editing]); + + if (loading) return
Načítání...
; + + const renderField = (label, name, type='text', props={}) => { + const isEditing = !!editing[name]; + const value = formData[name] ?? ''; + return ( + + {label} + {isEditing ? ( + stopEdit(name)} + {...props} + autoFocus + /> + ) : ( +
+ {value || (neuvedeno)} + startEdit(name)} aria-label={`edit ${label}`} style={{textDecoration:'none'}}> + + +
+ )} +
+ ); + }; + + const renderImageField = (label, name) => { + const currentUrl = filePreviews[name] || (config && config[name]); + return ( + + {label} +
+
+ {currentUrl ? ( + {label} + ) :
Žádný
} +
+
+ handleFile(name, e.target.files[0])} /> + {(filePreviews[name] || formData[name] === null) && ( + resetFile(name)}> Reset + )} +
+
+
+ ); + }; + + return ( + +

Nastavení webu

+ {error && {error}} + {success && {success}} +
+

Obecné

+ + {renderField('Číslo účtu','bank_account')} + {renderField('Odesílací e-mail','sender_email','email')} + + + {renderField('Variabilní symbol','variable_symbol','number')} + {renderField('Max. rezervací na akci','max_reservations_per_event','number',{min:1})} + {renderField('Kontaktní telefon','contact_phone','tel')} + + + {renderField('Kontaktní e-mail','contact_email','email')} + +
+

Vizuální

+ + {renderImageField('Logo','logo')} + {renderImageField('Pozadí','background_image')} + +
+ + {saving ? '💾 Ukládání...' : 'Uložit změny'} + +
+
+
+ ); +} + diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx new file mode 100644 index 0000000..3926c9c --- /dev/null +++ b/frontend/src/components/Sidebar.jsx @@ -0,0 +1,72 @@ +import { + Container, + Nav, + Navbar, + NavDropdown, + Form, + Button, +} from "react-bootstrap"; +import { + IconHome, + IconCalendarEvent, + IconClipboardList, + IconMapPin, + IconUsers, + IconReceipt2 , + IconPackage, + IconBox, + IconTrash, + IconWorldCog, +} from "@tabler/icons-react"; + +function Sidebar() { + return ( +
+ +
+ ); +} + +export default Sidebar; diff --git a/frontend/src/components/Table.jsx b/frontend/src/components/Table.jsx new file mode 100644 index 0000000..c323bbb --- /dev/null +++ b/frontend/src/components/Table.jsx @@ -0,0 +1,197 @@ +import sortBy from "lodash/sortBy"; +import { + useReactTable, + getCoreRowModel, + getSortedRowModel, + flexRender, +} from '@tanstack/react-table'; +import { useEffect, useMemo, useState } from "react"; + + +function Table({ + data = [], + columns = [], + fetching = false, + defaultSort = "id", + modalTitle = "Details", + renderModalContent, + onQueryChange, + initialQuery = "", + withGlobalSearch = true, + withActionsColumn = true, + withTableBorder, + borderRadius, + highlightOnHover, + verticalAlign, + titlePadding = "6px 8px", // default smaller padding + ...props +}) { + const [sortStatus, setSortStatus] = useState({ + columnAccessor: defaultSort, + direction: "asc", + }); + + const [records, setRecords] = useState([]); + const [query, setQuery] = useState(initialQuery); + const [debouncedQuery, setDebouncedQuery] = useState(query); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedQuery(query); + }, 200); + return () => clearTimeout(handler); + }, [query]); + const [filters, setFilters] = useState({}); + // Remove filter edit state, always show filter fields + + // Expose query changes to parent + useEffect(() => { + if (onQueryChange) { + onQueryChange(query); + } + }, [query, onQueryChange]); + + // Apply sorting and filtering + useEffect(() => { + if (!data || data.length === 0) { + setRecords([]); + return; + } + console.log(data) + let filteredData = [...data]; + + + // Apply column filters (substring search, case-insensitive) + Object.entries(filters).forEach(([key, value]) => { + if (value && value.length > 0) { + filteredData = filteredData.filter(item => { + const cellValue = item[key]; + return cellValue !== undefined && String(cellValue).toLowerCase().includes(value.toLowerCase()); + }); + } + }); + + // Apply sorting + const sorted = sortBy(filteredData, sortStatus.columnAccessor); + const sortedRecords = sortStatus.direction === "desc" + ? sorted.reverse() + : sorted; + + setRecords(sortedRecords); + }, [data, sortStatus, debouncedQuery, filters]); + + // Enhanced columns with actions + const enhancedColumns = [...columns]; + + // Prepare columns for TanStack Table + const tableColumns = useMemo(() => + enhancedColumns.map(col => ({ + accessorKey: col.accessor, + header: col.title || col.accessor, + cell: col.render ? info => col.render(info.row.original) : info => info.getValue(), + enableSorting: col.accessor !== 'actions', + })), + [enhancedColumns] + ); + + // TanStack Table instance + const table = useReactTable({ + data: records, + columns: tableColumns, + state: { + sorting: sortStatus.columnAccessor ? [{ + id: sortStatus.columnAccessor, + desc: sortStatus.direction === 'desc', + }] : [], + }, + onSortingChange: updater => { + const sort = Array.isArray(updater) ? updater[0] : updater?.[0]; + if (sort) { + setSortStatus({ + columnAccessor: sort.id, + direction: sort.desc ? 'desc' : 'asc', + }); + } + }, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + manualSorting: true, + }); + + return ( +
+ + + {columns.map(col => ( + + ))} + + + + + {columns.map((col, idx) => ( + + ))} + + + + {table.getRowModel().rows.length === 0 ? ( + + + + ) : ( + table.getRowModel().rows.map(row => ( + + {row.getVisibleCells().map(cell => ( + + ))} + + )) + )} + +
+ {col.title} + {col.filter &&
{col.filter}
} +
+ No data +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ ); +} + +export default Table; \ No newline at end of file diff --git a/frontend/src/components/User-Settings.jsx b/frontend/src/components/User-Settings.jsx new file mode 100644 index 0000000..b827012 --- /dev/null +++ b/frontend/src/components/User-Settings.jsx @@ -0,0 +1,178 @@ +import React, { useEffect, useState } from "react"; +import { Form, Container, Alert, Col, Row } from "react-bootstrap"; + +import Button from "react-bootstrap/Button"; +import { apiRequest } from "../api/auth"; +import { useNavigate } from "react-router-dom"; + +// Import FontAwesome +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faPen } from "@fortawesome/free-solid-svg-icons"; + +export default function UserSettings() { + const [user, setUser] = useState(null); + const [formData, setFormData] = useState({}); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + const [editingFields, setEditingFields] = useState({}); + const navigate = useNavigate(); + + useEffect(() => { + const loadUser = async () => { + try { + const data = await apiRequest("get", "/account/user/me/"); + setUser(data); + setFormData(data); + } catch { + setError("Nepodařilo se načíst profil."); + } finally { + setLoading(false); + } + }; + loadUser(); + }, []); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + }; + + const startEdit = (field) => { + setEditingFields((prev) => ({ ...prev, [field]: true })); + }; + + const stopEdit = (field) => { + setEditingFields((prev) => { + const copy = { ...prev }; + delete copy[field]; + return copy; + }); + }; + + const handleSave = async (e) => { + e.preventDefault(); + if (!user?.id) return; + + setSaving(true); + setError(""); + + try { + const updated = await apiRequest("patch", `/account/users/${user.id}/`, { + first_name: formData.first_name, + last_name: formData.last_name, + RC: formData.RC, + ICO: formData.ICO, + street: formData.street, + city: formData.city, + PSC: formData.PSC, + bank_account: formData.bank_account, + phone_number: formData.phone_number, + email: formData.email, + }); + setUser(updated); + setFormData(updated); + setEditingFields({}); + alert("✅ Údaje byly uloženy."); + } catch { + setError("❌ Nepodařilo se uložit změny."); + } finally { + setSaving(false); + } + }; + + const handleResetPassword = () => { + navigate("/reset-password"); + }; + + if (loading) return

⏳ Načítání...

; + + const renderField = (label, name, type = "text") => { + const isEditing = !!editingFields[name]; + const value = formData[name] ?? ""; + + return ( + + {label} + + {isEditing ? ( + stopEdit(name)} + autoFocus + /> + ) : ( +
+ {value || (neuvedeno)} + +
+ )} +
+ ); + }; + + return ( + +

Nastavení uživatele

+ + {error && {error}} + +
+ + {renderField("Jméno", "first_name")} + {renderField("Příjmení", "last_name")} + +
+

Sídlo

+ + {renderField("Ulice a č.p.", "street")} + {renderField("Město", "city")} + + + {renderField("PSČ", "PSC")} + + +
+ + {renderField("Číslo účtu", "bank_account")} + {renderField("Telefon", "phone_number", "tel")} + + + + {renderField("Email", "email", "email")} + +
+ + + +
+
+
+ ); +} diff --git a/frontend/src/components/forms/ticket.jsx b/frontend/src/components/forms/ticket.jsx new file mode 100644 index 0000000..71b8467 --- /dev/null +++ b/frontend/src/components/forms/ticket.jsx @@ -0,0 +1,135 @@ +import { useEffect, useState } from "react"; +import { Form, Button, Alert, Spinner } from "react-bootstrap"; +import { fetchEnumFromSchemaJson } from "../../api/get_chocies"; +import { apiRequest } from "../../api/auth"; + +import { useContext } from "react"; +import { UserContext } from "../../context/UserContext"; + + + +function TicketForm() { + const { user } = useContext(UserContext) || {}; + + const [categoryOptions, setCategoryOptions] = useState([]); + const [formData, setFormData] = useState({ + title: "", + description: "", + category: "", + }); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + useEffect(() => { + const loadEnums = async () => { + try { + const [categories, urgencies] = await Promise.all([ + fetchEnumFromSchemaJson("/api/service-tickets/", "get", "category"), + ]); + setCategoryOptions(categories); + } catch (err) { + console.error("Chyba při načítání enum hodnot:", err); + setError("Nepodařilo se načíst možnosti formuláře."); + } finally { + setLoading(false); + } + }; + + loadEnums(); + }, []); + + const handleChange = (e) => { + setFormData((prev) => ({ ...prev, [e.target.name]: e.target.value })); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setSubmitting(true); + setError(null); + setSuccess(false); + + try { + const payload = { + ...formData, + user: user?.id, + }; + + await apiRequest("post", "/service-tickets/", payload); + setSuccess(true); + setFormData({ + title: "", + description: "", + category: "", + }); + } catch (err) { + console.error(err); + setError("Chyba při odesílání formuláře."); + } finally { + setSubmitting(false); + } + }; + + if (loading) + return ( + + Načítání… + + ); + + return ( +
+

Odeslat Ticket

+ + {error && {error}} + {success && Ticket byl úspěšně odeslán.} + + + Název + + + + + Popis + + + + + Kategorie + + + {categoryOptions.map((opt) => ( + + ))} + + + + +
+ ); +} + +export default TicketForm; diff --git a/frontend/src/components/reservation/ReservationWizard.jsx b/frontend/src/components/reservation/ReservationWizard.jsx new file mode 100644 index 0000000..fad054d --- /dev/null +++ b/frontend/src/components/reservation/ReservationWizard.jsx @@ -0,0 +1,345 @@ +import { useState, useEffect } from 'react'; +import { ProgressBar, Form, InputGroup, Table, Spinner, Alert, Card, Container, Row, Col } from 'react-bootstrap'; +import dayjs from 'dayjs'; +import { useNavigate } from 'react-router-dom'; + +import Step1SelectSquare from './Step1SelectSquare'; +import Step2SelectEvent from './Step2SelectEvent'; +import Step3Map from './Step3Map'; +import Step4Summary from './Step4Summary'; + +import orderAPI from '../../api/model/order'; +import reservationAPI from '../../api/model/reservation'; +import userAPI from '../../api/model/user'; +import { fetchEnumFromSchemaJson } from '../../api/get_chocies'; + +// TODO: Replace this with real user role detection (e.g., from context or props) +const isAdminOrClerk = true; // Set to true for demonstration + +// List of available filters (should match backend filters.py) +const USER_FILTERS_BASE = [ + { key: "role", label: "Role", type: "select" }, + { key: "account_type", label: "Typ účtu", type: "select" }, + { key: "email", label: "Email", type: "text" }, + { key: "phone_number", label: "Telefon", type: "text" }, + { key: "city", label: "Město", type: "text" }, + { key: "street", label: "Ulice", type: "text" }, + { key: "PSC", label: "PSČ", type: "text" }, + { key: "ICO", label: "IČO", type: "text" }, + { key: "RC", label: "Rodné číslo", type: "text" }, + { key: "var_symbol", label: "Variabilní symbol", type: "number" }, + { key: "bank_account", label: "Bankovní účet", type: "text" }, + { key: "is_active", label: "Aktivní", type: "checkbox" }, + { key: "email_verified", label: "Email ověřen", type: "checkbox" }, + { key: "create_time_after", label: "Vytvořeno po", type: "date" }, + { key: "create_time_before", label: "Vytvořeno před", type: "date" }, +]; + +const ReservationWizard = () => { + const [data, setData] = useState({ + square: null, + event: null, + slots: [], + user: '', // New field for user ID + date: null, // Ensure date is present for reservation + note: '', // Ensure note is present + }); + const [step, setStep] = useState(1); + const [userSearch, setUserSearch] = useState(''); + const [userResults, setUserResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [duration, setDuration] = useState(1); // 1, 7, or 30 + const [userFilters, setUserFilters] = useState({}); + const [roleChoices, setRoleChoices] = useState([]); + const [accountTypeChoices, setAccountTypeChoices] = useState([]); + const navigate = useNavigate(); + + // Fetch choices for select fields on mount (inspired by create-user.jsx) + useEffect(() => { + fetchEnumFromSchemaJson("/api/account/users/", "get", "role") + .then((choices) => setRoleChoices(choices)) + .catch(() => setRoleChoices([ + { value: "admin", label: "Administrátor" }, + { value: "seller", label: "Prodejce" }, + { value: "squareManager", label: "Správce tržiště" }, + { value: "cityClerk", label: "Úředník" }, + { value: "checker", label: "Kontrolor" }, + ])); + fetchEnumFromSchemaJson("/api/account/users/", "get", "account_type") + .then((choices) => setAccountTypeChoices(choices)) + .catch(() => setAccountTypeChoices([ + { value: "company", label: "Firma" }, + { value: "individual", label: "Fyzická osoba" }, + ])); + }, []); + + // Update filter value + const handleFilterChange = (key, value, type) => { + setUserFilters(f => ({ + ...f, + [key]: type === "checkbox" ? value.target.checked : value.target.value + })); + }; + + // Search users with all filters + const handleUserSearch = async () => { + setIsSearching(true); + // Remove empty values + const params = Object.fromEntries( + Object.entries(userFilters).filter(([_, v]) => v !== "" && v !== undefined && v !== null) + ); + try { + let results = await userAPI.searchUsers(params); + if (results && typeof results === 'object' && !Array.isArray(results) && Array.isArray(results.results)) { + results = results.results; + } + setUserResults(Array.isArray(results) ? results : []); + } catch (e) { + setUserResults([]); + } + setIsSearching(false); + }; + + const next = () => setStep((s) => Math.min(s + 1, 4)); + const prev = () => setStep((s) => Math.max(s - 1, 1)); + + const handleSubmit = async () => { + try { + const slot = data.slots[0]; + // Ensure slot and date are present + if (!slot || !data.date || !data.date.start || !data.date.end) { + alert('Vyberte termín a slot.'); + return; + } + + // Ensure event is present and valid + if (!data.event || typeof data.event !== 'object' || !data.event.id) { + alert('Chybí událost (event). Vyberte prosím událost.'); + return; + } + + // Use selected date range from Step3Map (date only) + let reserved_from = data.date.start; + let reserved_to = data.date.end; + + // Clamp reserved_from and reserved_to to event boundaries + const eventStart = dayjs(data.event.start, "YYYY-MM-DD"); + const eventEnd = dayjs(data.event.end, "YYYY-MM-DD"); + if (dayjs(reserved_from).isBefore(eventStart)) reserved_from = eventStart.format("YYYY-MM-DD"); + if (dayjs(reserved_to).isAfter(eventEnd)) reserved_to = eventEnd.format("YYYY-MM-DD"); + + const reservationData = { + event: data.event.id, + market_slot: slot.id, + reserved_from, + reserved_to, + used_extension: slot.used_extension || 0, + note: data.note || null, + }; + if (isAdminOrClerk && data.user) { + reservationData.user = data.user; + } + + console.log('Odesílaná rezervace:', reservationData); + + // Create reservation and get its ID + const ResponseReservation = await reservationAPI.createReservation(reservationData); + console.log('Response:', ResponseReservation); + + const response = await orderAPI.createOrder({ + user_id: data.user || null, + note: data.note || null, + reservation_id: ResponseReservation.id, // Use the reservation ID + }); + alert('Objednávka byla úspěšně odeslána!'); + console.log('📦 Objednáno:', response); + // Redirect to payment page after alert confirmation + navigate(`/payment/${response.id}`); + } catch (error) { + // Log the error and show backend validation errors if present + console.error('❌ Chyba při odesílání objednávky:', error); + if (error.response) { + console.error('Backend response:', error.response); + // Log backend error details for debugging + if (error.response.data) { + console.error('Backend error details:', error.response.data); + } + } + if (error.response && error.response.data) { + alert( + 'Chyba při odesílání objednávky:\n' + + JSON.stringify(error.response.data, null, 2) + ); + } else { + alert('Něco se pokazilo při odesílání objednávky.'); + } + } + }; + + return ( + <> + + + {/* Admin/Clerk user filter bar */} + {isAdminOrClerk && ( + + + + Výběr uživatele pro objednávku
+ Vyplňte libovolné pole pro filtrování uživatelů. Pokud pole zůstanou prázdná, objednávka bude vytvořena na vaš momentálně přihlášený účet !!! +
+
+
+ {/* Render non-checkbox fields */} + {USER_FILTERS_BASE.filter(f => f.type !== "checkbox").map(f => ( +
+ + {f.label} + {f.type === "select" && f.key === "role" ? ( + handleFilterChange(f.key, e, f.type)} + > + + {roleChoices.map(opt => ( + + ))} + + ) : f.type === "select" && f.key === "account_type" ? ( + handleFilterChange(f.key, e, f.type)} + > + + {accountTypeChoices.map(opt => ( + + ))} + + ) : f.type === "select" ? ( + handleFilterChange(f.key, e, f.type)} + > + + + ) : ( + handleFilterChange(f.key, e, f.type)} + autoComplete="off" + /> + )} + +
+ ))} +
+ {/* Render each checkbox and label in a separate row */} + + {USER_FILTERS_BASE.filter(f => f.type === "checkbox").map(f => ( + + + handleFilterChange(f.key, e, f.type)} + id={`user-filter-${f.key}`} + /> + + + {f.label} + + + ))} + + +
+ {userResults.length > 0 && ( + + + + + + + + + + {userResults.map(user => ( + + + + + + ))} + +
Uživatelské jménoIDAkce
{user.username}{user.id} + +
+ )} + {data.user && ( + + Vybraný uživatel ID: {data.user} + + )} +
+
+ )} + + {step === 1 && ( + + )} + {step === 2 && ( + + )} + {step === 3 && ( + <> + {/* Pass duration and setDuration to Step3Map */} + + + )} + {step === 4 && ( + setData(d => ({ ...d, note }))} + /> + )} + + ); +}; + +export default ReservationWizard; diff --git a/frontend/src/components/reservation/Step1SelectSquare.jsx b/frontend/src/components/reservation/Step1SelectSquare.jsx new file mode 100644 index 0000000..91846fa --- /dev/null +++ b/frontend/src/components/reservation/Step1SelectSquare.jsx @@ -0,0 +1,88 @@ +import React, { useEffect, useState } from 'react'; +import { Card, Button, Row, Col, Spinner } from 'react-bootstrap'; +import squareAPI from '../../api/model/square'; + +const Step1SelectSquare = ({ data, setData, next }) => { + const [squares, setSquares] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + squareAPI.getSquares() + .then(result => { + setSquares(result); + setLoading(false); + }) + .catch(() => { + setSquares([]); + setLoading(false); + }); + }, []); + + const selectSquare = (square) => { + setData(prev => ({ + ...prev, + square, + event: null, + slots: [], + })); + }; + + if (loading) return Načítám...; + + return ( + <> +

Vyber náměstí

+ + {squares.map(sq => { + const selected = data.square?.id === sq.id; + return ( + + selectSquare(sq)} + border={selected ? 'primary' : undefined} + className={`h-100 cursor-pointer ${selected ? 'shadow-lg' : ''}`} + style={{ userSelect: 'none' }} + > + {sq.image + ? + :
+ Obrázek chybí +
+ } + + {sq.name} + + {sq.street}, {sq.city} ({sq.psc}) + + + {sq.description} + + +
+ + ); + })} +
+
+ +
+ + ); +}; + +export default Step1SelectSquare; diff --git a/frontend/src/components/reservation/Step2SelectEvent.jsx b/frontend/src/components/reservation/Step2SelectEvent.jsx new file mode 100644 index 0000000..5aa007c --- /dev/null +++ b/frontend/src/components/reservation/Step2SelectEvent.jsx @@ -0,0 +1,120 @@ +import React, { useEffect, useState } from 'react'; +import { Card, Button, Row, Col, Spinner, Alert } from 'react-bootstrap'; +import eventAPI from '../../api/model/event'; +import dayjs from 'dayjs'; + +const Step2SelectEvent = ({ data, setData, next, prev }) => { + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!data.square?.id) { + setEvents([]); + return; + } + + setLoading(true); + setError(null); + + eventAPI.getEvents({ square: data.square.id }) + .then(result => { + setEvents(result); + setLoading(false); + }) + .catch(() => { + setError("Nepodařilo se načíst události"); + setLoading(false); + }); + }, [data.square]); + + const selectEvent = (event) => { + setData(prev => ({ + ...prev, + event, + slots: [], + })); + }; + + if (!data.square) { + return ( + <> + + Nejprve vyberte náměstí v předchozím kroku. + + + + ); + } + + if (loading) return Načítám...; + if (error) return {error}; + if (events.length === 0) return

Pro vybrané náměstí nebyly nalezeny žádné události.

; + + // Filter events to only show current or future events + const now = dayjs().startOf('day'); + const filteredEvents = events.filter(event => { + // event.start and event.end are now date strings (YYYY-MM-DD) + const end = dayjs(event.end, "YYYY-MM-DD"); + return end.isAfter(now) || end.isSame(now, 'day'); + }); + + return ( + <> +

Vyber událost

+ + {filteredEvents.map(ev => { + const selected = data.event?.id === ev.id; + return ( + + selectEvent(ev)} + border={selected ? 'primary' : undefined} + className={`h-100 cursor-pointer ${selected ? 'shadow-lg' : ''}`} + style={{ userSelect: 'none' }} + > + {ev.image + ? + :
+ Obrázek chybí +
+ } + + {ev.name} + + {ev.start} – {ev.end} + + + {ev.description} + + Cena za m²: {ev.price_per_m2} Kč + +
+ + ); + })} +
+
+ + +
+ + ); +}; + +export default Step2SelectEvent; diff --git a/frontend/src/components/reservation/Step3Map.jsx b/frontend/src/components/reservation/Step3Map.jsx new file mode 100644 index 0000000..3f36dfd --- /dev/null +++ b/frontend/src/components/reservation/Step3Map.jsx @@ -0,0 +1,223 @@ +import { useEffect, useState, useCallback } from "react"; +import { Button, Alert, Spinner, Col, Row, Container, Modal } from "react-bootstrap"; +import DynamicGrid from "../DynamicGrid"; +import eventAPI from "../../api/model/event"; +import orderAPI from "../../api/model/order"; +import reservationAPI from "../../api/model/reservation"; +import { format } from "date-fns"; +import DaySelectorCalendar from "./step3/Calendar"; +import dayjs from "dayjs"; + +export default function Step3Map({ data, setData, next, prev }) { + const [slots, setSlots] = useState([]); + const [selectedSlotIdx, setSelectedSlotIdx] = useState(null); + const [showDateModal, setShowDateModal] = useState(false); + const [modalSlot, setModalSlot] = useState(null); + const [selectedRange, setSelectedRange] = useState(null); + const [price, setPrice] = useState(null); + const [loadingPrice, setLoadingPrice] = useState(false); + const [priceError, setPriceError] = useState(null); + const [validationError, setValidationError] = useState(''); + const [bookedRanges, setBookedRanges] = useState([]); + + // Load all slots for the selected event on initial load + useEffect(() => { + if (!data?.event?.id) return; + eventAPI.getEventById(data.event.id).then((eventData) => { + if (eventData?.market_slots) { + const mappedSlots = eventData.market_slots.map((slot) => ({ + ...slot, + x: slot.x, + y: slot.y, + w: slot.width, + h: slot.height, + status: slot.status, + })); + setSlots(mappedSlots); + } + }); + }, [data?.event?.id]); + + // When user clicks a slot, open date picker modal (with delay) + const handleSlotSelect = async (idx) => { + setSelectedSlotIdx(idx); + setModalSlot(slots[idx]); + setValidationError(''); + setPrice(null); + setPriceError(null); + setSelectedRange(null); + + // Fetch reserved days for this slot (expects array of dates) + const slotId = slots[idx]?.id; + if (slotId) { + try { + const res = await reservationAPI.getReservedRanges(slotId); + // Expecting { reserved_days: [date1, date2, ...] } + setBookedRanges(res?.reserved_days ?? []); + } catch (e) { + setBookedRanges([]); + } + } else { + setBookedRanges([]); + } + + setTimeout(() => { + setShowDateModal(true); + console.log("data:", data); + }, 500); // small delay for state propagation + }; + + // When user picks a date range, submit to backend + const handleDateRangeSubmit = async (rangeObj) => { + if (!modalSlot?.id || !rangeObj?.start || !rangeObj?.end) return; + setLoadingPrice(true); + setPriceError(null); + setValidationError(''); + + try { + // Use date only (YYYY-MM-DD) + const reserved_from = dayjs(rangeObj.start).format("YYYY-MM-DD"); + const reserved_to = dayjs(rangeObj.end).format("YYYY-MM-DD"); + + // Call backend to check reservation and get price + const res = await orderAPI.calculatePrice({ + slot: modalSlot.id, + reserved_from, + reserved_to, + used_extension: 0, + }); + + // If backend returns error (e.g., slot reserved), show validation error + if (res?.error) { + setValidationError(res.error || "Toto místo je již rezervováno pro tento termín."); + setPrice(null); + } else { + setPrice(res.final_price ?? null); + setSelectedRange(rangeObj); + setData((prevData) => ({ + ...prevData, + slots: [{ ...modalSlot }], + date: { + start: reserved_from, + end: reserved_to, + }, + })); + setValidationError(''); + } + } catch (error) { + setPriceError("Nepodařilo se spočítat cenu rezervace."); + setPrice(null); + } finally { + setLoadingPrice(false); + setShowDateModal(false); + console.log("Data:", data); + } + }; + + // Validate before next + const validateSelection = () => { + if (!data.slots || data.slots.length === 0 || !selectedRange) { + setValidationError('Musíte vybrat místo a termín.'); + return false; + } + setValidationError(''); + return true; + }; + + const handleNext = () => { + if (validateSelection()) { + next(); + } + }; + + // Get grid config from selected square or fallback to defaults + const gridConfig = data.square + ? { + cols: data.square.grid_cols || 60, + rows: data.square.grid_rows || 44, + cellSize: data.square.cellsize || 20, + } + : { cols: 60, rows: 44, cellSize: 20 }; + + return ( +
+ + + +
Vyberte místo na mapě:
+

Klikněte na volné místo pro výběr termínu rezervace.

+ + + {loadingPrice ? ( + + ) : priceError ? ( + {priceError} + ) : price !== null ? ( + + Cena za rezervaci: {price} Kč + + ) : null} + +
+
+ + {/* Always show map with all slots */} + {}} + selectedIndex={selectedSlotIdx} + onSelectedIndexChange={handleSlotSelect} + static={true} + multiSelect={false} + clickableStatic={true} + backgroundImage={data.square?.image} // <-- use image from API if present + ref={el => { + if (el) { + console.log('[Step3Map] DynamicGrid props:', { + selectedIndex: selectedSlotIdx, + slots, + }); + } + }} + /> + + {/* Date picker modal for slot selection */} + setShowDateModal(false)} + centered + > + + Vyberte termín pro místo + + + + + + + {validationError && ( +
{validationError}
+ )} + +
+ + +
+
+ ); +} diff --git a/frontend/src/components/reservation/Step4Summary.jsx b/frontend/src/components/reservation/Step4Summary.jsx new file mode 100644 index 0000000..5421a4c --- /dev/null +++ b/frontend/src/components/reservation/Step4Summary.jsx @@ -0,0 +1,138 @@ +import React from 'react'; +import { Card, Button, Table, Form } from 'react-bootstrap'; +import { useEffect, useState } from 'react'; +import orderAPI from '../../api/model/order'; + +const Step4Summary = ({ formData, onBack, onSubmit, note = '', setNote }) => { + const { selectedSquare, selectedEvent, selectedSlot } = formData; + + if (!selectedSquare || !selectedEvent || !selectedSlot || selectedSlot.length === 0) { + return

Chybí informace o výběru. Vraťte se zpět a doplňte potřebné údaje.

; + } + + // Spočítat celkovou cenu všech slotů pomocí API (podobně jako ve Step3Map.jsx) + + const [totalPrice, setTotalPrice] = useState(0); + + useEffect(() => { + // Volání API pro získání ceny + async function fetchTotalPrice() { + if (!selectedSlot || selectedSlot.length === 0) { + setTotalPrice(0); + return; + } + let total = 0; + for (const s of selectedSlot) { + try { + const data = await orderAPI.calculatePrice({ + slot: s.id, + reserved_from: selectedEvent.start, + reserved_to: selectedEvent.end, + used_extension: s.used_extension || 0, + }); + total += parseFloat(data.final_price || 0); + } catch { + // fallback: ignore error, continue + } + } + setTotalPrice(total); + } + fetchTotalPrice(); + }, [selectedEvent.id, selectedSlot]); + + // Helper to calculate reserved days + function getReservedDays(start, end) { + const startDate = new Date(start); + const endDate = new Date(end); + return Math.floor((endDate - startDate) / (1000 * 60 * 60 * 24)) + 1; + } + + return ( + +

🧾 Shrnutí objednávky

+ +
📍 Náměstí:
+

{selectedSquare.name}
{selectedSquare.street}, {selectedSquare.city} {selectedSquare.psc}

+ +
📅 Událost:
+

+ {selectedEvent.name}
+ {selectedEvent.start} – {selectedEvent.end}
+ Cena za m²: {selectedEvent.price_per_m2} Kč +

+ +
📦 Vybrané sloty:
+
+ Tabulka níže zobrazuje vybrané sloty, jejich rozměry, cenu za metr čtvereční, počet dní rezervace a vypočtenou cenu za každý slot. +
+ + + + + + + + + + {selectedSlot.map((slot) => { + const pricePerM2 = parseFloat(slot.price_per_m2 || selectedEvent.price_per_m2); + const area = slot.width * slot.height; + const days = getReservedDays(selectedEvent.start, selectedEvent.end); + // const subtotal = area * pricePerM2 * days; + return ( + <> + + + + + + + + + + + + + + + ); + })} + + + + + + + +
SlotDetailHodnota
{slot.number}Rozměry (šířka × výška){slot.width} × {slot.height} m = {area} m²
Počet dní{days}
Cena za m²{pricePerM2.toFixed(2)} Kč
+ Celková cena objednávky: + {totalPrice.toFixed(2)} Kč
+ + {/* Note input (optional) using Bootstrap */} + + + Poznámka (volitelné) + + setNote && setNote(e.target.value)} + placeholder="Zde můžete přidat poznámku k objednávce..." + rows={3} + /> + + +
+ + +
+
+ ); +}; + +export default Step4Summary; + diff --git a/frontend/src/components/reservation/step3/Calendar.jsx b/frontend/src/components/reservation/step3/Calendar.jsx new file mode 100644 index 0000000..c6e61c0 --- /dev/null +++ b/frontend/src/components/reservation/step3/Calendar.jsx @@ -0,0 +1,219 @@ +import '@mantine/core/styles.layer.css'; // základní styly komponent +import '@mantine/dates/styles.css'; // styly pro kalendář + +import { useState } from "react"; +import { SegmentedControl, Group } from "@mantine/core"; +import { DatePicker } from "@mantine/dates"; +import dayjs from "dayjs"; +import { Container, Row, Col } from 'react-bootstrap'; + +/** + * Komponenta pro výběr rozsahu rezervace s vizuálním označením rezervovaných dní a omezením na povolený interval. + */ +export default function DaySelectorCalendar({ + onSelectDate, + bookedRanges = [], + eventStart, + eventEnd, + defaultDate, +}) { + const [range, setRange] = useState([null, null]); + const [mode, setMode] = useState("manual"); + const [quickStart, setQuickStart] = useState(null); + const [quickType, setQuickType] = useState("day"); + + const normalizeMinDate = (d) => dayjs(d).startOf("day").toDate(); + const normalizeMaxDate = (d) => dayjs(d).endOf("day").toDate(); + + // Helper to check if a date is reserved + const isReserved = (date) => { + // bookedRanges is now array of dates (string or Date) + return bookedRanges.some((reserved) => { + // Normalize both to YYYY-MM-DD for comparison + const d = dayjs(date).format("YYYY-MM-DD"); + const r = dayjs(reserved).format("YYYY-MM-DD"); + return d === r; + }); + }; + + const isOutOfBounds = (date) => { + if (eventStart && dayjs(date).isBefore(dayjs(eventStart), "day")) return true; + if (eventEnd && dayjs(date).isAfter(dayjs(eventEnd), "day")) return true; + return false; + }; + + const getQuickRange = (start, type) => { + if (!start) return [null, null]; + const d = dayjs(start); + if (type === "day") return [d.toDate(), d.toDate()]; + if (type === "week") return [d.startOf("week").toDate(), d.endOf("week").toDate()]; + if (type === "month") return [d.startOf("month").toDate(), d.endOf("month").toDate()]; + return [null, null]; + }; + + const handleChange = (value) => { + let normalized = Array.isArray(value) ? [value[0] ?? null, value[1] ?? null] : [null, null]; + setRange(normalized); + if (normalized[0] && normalized[1]) { + onSelectDate?.({ start: normalized[0], end: normalized[1] }); + } + }; + + const handleQuickPick = (date) => { + setQuickStart(date); + const [start, end] = getQuickRange(date, quickType); + setRange([start, end]); + if (start && end) onSelectDate?.({ start, end }); + }; + + const handleQuickTypeChange = (type) => { + setQuickType(type); + if (quickStart) { + const [start, end] = getQuickRange(quickStart, type); + setRange([start, end]); + if (start && end) onSelectDate?.({ start, end }); + } + }; + + const handleModeChange = (val) => { + setMode(val); + setRange([null, null]); + setQuickStart(null); + }; + + return ( +
+ + + + +
Režim kalendáře:
+
+ + + + + {mode === "quick" && ( + + + + )} + +
+ + +
+ + { + if (isReserved(date)) { + return { + style: { + backgroundColor: "#f8d7da", + color: "#721c24", + borderRadius: 4, + }, + disabled: true, + }; + } + if (isOutOfBounds(date)) { + return { disabled: true, style: { opacity: 0.5 } }; + } + if ( + mode === "quick" && + quickStart && + range[0] && + range[1] && + ( + (dayjs(date).isAfter(dayjs(range[0]), "day") || dayjs(date).isSame(dayjs(range[0]), "day")) && + (dayjs(date).isBefore(dayjs(range[1]), "day") || dayjs(date).isSame(dayjs(range[1]), "day")) + ) + ) { + return { + style: { + backgroundColor: "#e3f6fc", + color: "#186fa7", + borderRadius: 4, + }, + }; + } + return {}; + }} + /> + +
+ Legenda: +
    +
  • + + Červená + + : Rezervováno +
  • +
  • + + Modrá + + : Vybraný rozsah (rychlý výběr) +
  • +
  • + Bez barvy: Volno +
  • +
+
+ + {range[0] && range[1] && ( +
+ Vybráno: {dayjs(range[0]).format("D. M. YYYY")} – {dayjs(range[1]).format("D. M. YYYY")} +
+ )} +
+ ); +} diff --git a/frontend/src/components/reservation/step3/Pricebox.jsx b/frontend/src/components/reservation/step3/Pricebox.jsx new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/components/reset-password/Create.jsx b/frontend/src/components/reset-password/Create.jsx new file mode 100644 index 0000000..3259451 --- /dev/null +++ b/frontend/src/components/reset-password/Create.jsx @@ -0,0 +1,149 @@ +import React, { useState } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import axios from "axios"; +import { + Form, + Button, + Card, + Alert, + Spinner, + Row, + Col, +} from "react-bootstrap"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faKey } from "@fortawesome/free-solid-svg-icons"; + +const CreateNewPassoword = () => { + const { uidb64, token } = useParams(); + const navigate = useNavigate(); + + const [newPassword, setNewPassword] = useState(""); + const [reNewPassword, setReNewPassword] = useState(""); + const [status, setStatus] = useState("idle"); // idle | loading | success | error + const [error, setError] = useState(""); + + const handleSubmit = async (e) => { + e.preventDefault(); + setStatus("loading"); + setError(""); + + if (newPassword !== reNewPassword) { + setStatus("error"); + setError("Hesla se neshodují."); + return; + } + + try { + const response = await axios.post( + `/api/account/reset-password/${uidb64}/${token}/`, + { + new_password: newPassword, + re_new_password: reNewPassword, + } + ); + + if (response.status === 200) { + setStatus("success"); + setTimeout(() => { + navigate("/"); // přesměrování na login page + }, 3000); + } + } catch (err) { + setStatus("error"); + setError( + err.response?.data?.detail || + "Nepodařilo se resetovat heslo. Token může být neplatný nebo expirovaný." + ); + } + }; + + return ( + + +

Reset hesla

+
+ + + {status === "success" ? ( + + Heslo bylo úspěšně změněno. Přesměrování na přihlášení... + + ) : ( +
+ {/* Nové heslo */} + + + + + + + + setNewPassword(e.target.value)} + value={newPassword} + /> + + + {/* Potvrzení hesla */} + + + + + + + + setReNewPassword(e.target.value)} + value={reNewPassword} + /> + + + {/* Submit */} + + + + + {/* Chyba */} + {status === "error" && ( + + {error} + + )} +
+ )} +
+ + + + + Zpět na přihlášení + + + +
+ ); +}; + +export default CreateNewPassoword; diff --git a/frontend/src/components/reset-password/Request.jsx b/frontend/src/components/reset-password/Request.jsx new file mode 100644 index 0000000..11b506f --- /dev/null +++ b/frontend/src/components/reset-password/Request.jsx @@ -0,0 +1,109 @@ +import React, { useState } from "react"; +import { + Form, + Button, + Card, + Alert, + Spinner, + Row, + Col, +} from "react-bootstrap"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faEnvelope } from "@fortawesome/free-solid-svg-icons"; + +import { apiRequest } from "../../api/auth"; + +const ResetPasswordRequest = () => { + const [email, setEmail] = useState(""); + const [status, setStatus] = useState("idle"); // idle | loading | success | error + const [error, setError] = useState(""); + + const handleSubmit = async (e) => { + e.preventDefault(); + setStatus("loading"); + setError(""); + + try { + await apiRequest("post", "/account/reset-password/", { email }); + setStatus("success"); + } catch (err) { + // Pokud apiRequest vrací error jako objekt, zkus ho správně zachytit + const message = + err?.response?.data?.detail || + err?.message || + "Nepodařilo se odeslat požadavek. Zkuste to prosím znovu."; + + setError(message); + setStatus("error"); + } + }; + + return ( + + +

Obnovení hesla

+
+ + + {status === "success" ? ( + Odeslán email s instrukcemi. + ) : ( +
+ + + + + + + + + setEmail(e.target.value)} + value={email} + /> + + + + + + + {status === "error" && ( + + {error} + + )} +
+ )} +
+ + + + + Zpět na přihlášení + + + +
+ ); +}; + +export default ResetPasswordRequest; \ No newline at end of file diff --git a/frontend/src/components/save.txt b/frontend/src/components/save.txt new file mode 100644 index 0000000..0e79fca --- /dev/null +++ b/frontend/src/components/save.txt @@ -0,0 +1,161 @@ + + + + {/* Legend */} + + + Aktivní + + + Rezervováno + + + Blokováno + + + + {/* Action Buttons */} + + + + + + + + + {/* Grid Container */} +
e.preventDefault()} + style={{ + width: cols * cellSize, + height: rows * cellSize, + display: "grid", + gridTemplateColumns: `repeat(${cols}, ${cellSize}px)`, + gridTemplateRows: `repeat(${rows}, ${cellSize}px)`, + }} + > + {gridCells} + + {/* Reservation Boxes */} + {reservations.map((res, i) => ( +
{ + e.preventDefault(); + handleDeleteReservation(i); + }} + style={{ + position: "absolute", + left: res.x * cellSize, + top: res.y * cellSize, + width: res.w * cellSize, + height: res.h * cellSize, + backgroundColor: statusColors[res.status], + border: i === selectedIndex ? "2px solid black" : "none", + fontSize: 12, + textAlign: "center", + transition: + draggedIndex === i || resizingIndex === i + ? "none" + : "all 0.2s ease", + zIndex: 2, + }} + > +
+
+ {i + 1} +
+ handleStatusChange(i, e.target.value)} + onClick={(e) => e.stopPropagation()} + > + + + + +
+
+
+ ))} + + {/* Draft preview box */} + {isDragging && startCell && hoverCell && ( +
+ )} +
+ + {/* File Upload */} + + Nahrát rezervace ze souboru: + { + const file = e.target.files[0]; + if (!file) return; + handleFileUpload(file); + e.target.value = ""; // Reset input + }} + /> + + + + \ No newline at end of file diff --git a/frontend/src/components/security/RequireAuthLayout.jsx b/frontend/src/components/security/RequireAuthLayout.jsx new file mode 100644 index 0000000..08bd27c --- /dev/null +++ b/frontend/src/components/security/RequireAuthLayout.jsx @@ -0,0 +1,56 @@ +// /components/RequireAuthLayout.jsx +import { Outlet, useNavigate, useLocation } from "react-router-dom"; +import { useEffect, useState, useContext } from "react"; +import { getCurrentUser } from "../../api/auth"; +import { UserContext } from "../../context/UserContext"; + +// Layout which ensures user is authenticated before rendering nested routes. +// Issues fixed: +// 1. Previously ignored the fetched currentUser and used stale context (always null on first load) causing redirects. +// 2. setUser was called with "user" instead of the fetched "currentUser". +// 3. Navigation happened before context could be populated, causing protected pages to crash accessing user.role. +export default function RequireAuthLayout() { + const [checking, setChecking] = useState(true); + const navigate = useNavigate(); + const location = useLocation(); + const { user, setUser } = useContext(UserContext); + + useEffect(() => { + let cancelled = false; + const check = async () => { + try { + const currentUser = await getCurrentUser(); + if (!currentUser) { + // Not authenticated -> go to login + navigate("/login", { + state: { from: location.pathname }, + replace: true, + }); + return; + } + if (!cancelled) { + setUser(currentUser); // store fetched user in context + } + } catch (err) { + // 401 or network error -> treat as unauthenticated + navigate("/login", { + state: { from: location.pathname }, + replace: true, + }); + } finally { + if (!cancelled) setChecking(false); + } + }; + check(); + return () => { cancelled = true; }; + // Run on initial mount & path change (e.g. deep link) – not on user change to avoid loop. + }, [location.pathname, navigate, setUser]); + + // While checking auth, render nothing (or a small loader placeholder if desired) + if (checking) return null; + + // If user somehow missing after check (edge race) redirect handled above; return null to avoid crashes. + if (!user) return null; + + return ; +} diff --git a/frontend/src/components/security/RequireRole.jsx b/frontend/src/components/security/RequireRole.jsx new file mode 100644 index 0000000..aab40e7 --- /dev/null +++ b/frontend/src/components/security/RequireRole.jsx @@ -0,0 +1,32 @@ +import { useNavigate, useLocation, Outlet } from "react-router-dom"; +import { useEffect, useState } from "react"; + +import { useContext } from "react"; +import { UserContext } from "../../context/UserContext"; + +export default function RequireRole({ roles = [] }) { + const { user } = useContext(UserContext) || {}; + const [allowed, setAllowed] = useState(null); // null = loading + const navigate = useNavigate(); + const location = useLocation(); + + useEffect(() => { + if (!user) { + navigate("/login", { state: { from: location.pathname } }); + return; + } + + const userRoles = Array.isArray(user.role) ? user.role : [user.role]; + const hasRole = roles.some((role) => userRoles.includes(role)); + + if (!hasRole) { + navigate("/unauthorized"); + } else { + setAllowed(true); + } + }, [user, roles, navigate, location.pathname]); + + //if (allowed === null) return

Kontroluji oprávnění...

; + + return allowed ? : null; +} diff --git a/frontend/src/context/UserContext.jsx b/frontend/src/context/UserContext.jsx new file mode 100644 index 0000000..d0ac8a1 --- /dev/null +++ b/frontend/src/context/UserContext.jsx @@ -0,0 +1,30 @@ +// /context/UserContext.jsx +import { createContext, useState } from 'react'; + +export const UserContext = createContext({ + user: null, + setUser: () => {}, +}); + +export function UserProvider({ children }) { + const [user, setUser] = useState(null); + + return ( + + {children} + + ); +} + + + +/* + +POUŽIJ TOHLE PRO ZÍSKANÍ USERA: + +import { useContext } from "react"; +import { UserContext } from "../context/UserContext"; <-- CESTA K TOMUHLE SOUBORU + +const { user } = useContext(UserContext) || {}; + +*/ \ No newline at end of file diff --git a/frontend/src/css/index.css b/frontend/src/css/index.css new file mode 100644 index 0000000..a63bcc5 --- /dev/null +++ b/frontend/src/css/index.css @@ -0,0 +1,18109 @@ +@charset "UTF-8"; + +/*! + * Bootstrap v4.3.1 (https://getbootstrap.com/) + * Copyright 2011-2019 The Bootstrap Authors + * Copyright 2011-2019 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */ +:root, +[data-bs-theme=light] { + --bs-blue: #0d6efd; + --bs-indigo: #6610f2; + --bs-purple: #6f42c1; + --bs-pink: #d63384; + --bs-red: #dc3545; + --bs-orange: #fd7e14; + --bs-yellow: #ffc107; + --bs-green: #198754; + --bs-teal: #20c997; + --bs-cyan: #0dcaf0; + --bs-black: #000; + --bs-white: #fff; + --bs-gray: #6c757d; + --bs-gray-dark: #343a40; + --bs-gray-100: #f8f9fa; + --bs-gray-200: #e9ecef; + --bs-gray-300: #dee2e6; + --bs-gray-400: #ced4da; + --bs-gray-500: #adb5bd; + --bs-gray-600: #6c757d; + --bs-gray-700: #495057; + --bs-gray-800: #343a40; + --bs-gray-900: #212529; + --bs-primary: #0d6efd; + --bs-secondary: #6c757d; + --bs-success: #198754; + --bs-info: #0dcaf0; + --bs-warning: #ffc107; + --bs-danger: #dc3545; + --bs-light: #f8f9fa; + --bs-dark: #212529; + --bs-primary-rgb: 13, 110, 253; + --bs-secondary-rgb: 108, 117, 125; + --bs-success-rgb: 25, 135, 84; + --bs-info-rgb: 13, 202, 240; + --bs-warning-rgb: 255, 193, 7; + --bs-danger-rgb: 220, 53, 69; + --bs-light-rgb: 248, 249, 250; + --bs-dark-rgb: 33, 37, 41; + --bs-primary-text-emphasis: #052c65; + --bs-secondary-text-emphasis: #2b2f32; + --bs-success-text-emphasis: #0a3622; + --bs-info-text-emphasis: #055160; + --bs-warning-text-emphasis: #664d03; + --bs-danger-text-emphasis: #58151c; + --bs-light-text-emphasis: #495057; + --bs-dark-text-emphasis: #495057; + --bs-primary-bg-subtle: #cfe2ff; + --bs-secondary-bg-subtle: #e2e3e5; + --bs-success-bg-subtle: #d1e7dd; + --bs-info-bg-subtle: #cff4fc; + --bs-warning-bg-subtle: #fff3cd; + --bs-danger-bg-subtle: #f8d7da; + --bs-light-bg-subtle: #fcfcfd; + --bs-dark-bg-subtle: #ced4da; + --bs-primary-border-subtle: #9ec5fe; + --bs-secondary-border-subtle: #c4c8cb; + --bs-success-border-subtle: #a3cfbb; + --bs-info-border-subtle: #9eeaf9; + --bs-warning-border-subtle: #ffe69c; + --bs-danger-border-subtle: #f1aeb5; + --bs-light-border-subtle: #e9ecef; + --bs-dark-border-subtle: #adb5bd; + --bs-white-rgb: 255, 255, 255; + --bs-black-rgb: 0, 0, 0; + --bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + --bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + --bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0)); + --bs-body-font-family: var(--bs-font-sans-serif); + --bs-body-font-size: 1rem; + --bs-body-font-weight: 400; + --bs-body-line-height: 1.5; + --bs-body-color: #212529; + --bs-body-color-rgb: 33, 37, 41; + --bs-body-bg: #fff; + --bs-body-bg-rgb: 255, 255, 255; + --bs-emphasis-color: #000; + --bs-emphasis-color-rgb: 0, 0, 0; + --bs-secondary-color: rgba(33, 37, 41, 0.75); + --bs-secondary-color-rgb: 33, 37, 41; + --bs-secondary-bg: #e9ecef; + --bs-secondary-bg-rgb: 233, 236, 239; + --bs-tertiary-color: rgba(33, 37, 41, 0.5); + --bs-tertiary-color-rgb: 33, 37, 41; + --bs-tertiary-bg: #f8f9fa; + --bs-tertiary-bg-rgb: 248, 249, 250; + --bs-heading-color: inherit; + --bs-link-color: #0d6efd; + --bs-link-color-rgb: 13, 110, 253; + --bs-link-decoration: underline; + --bs-link-hover-color: #0a58ca; + --bs-link-hover-color-rgb: 10, 88, 202; + --bs-code-color: #d63384; + --bs-highlight-color: #212529; + --bs-highlight-bg: #fff3cd; + --bs-border-width: 1px; + --bs-border-style: solid; + --bs-border-color: #dee2e6; + --bs-border-color-translucent: rgba(0, 0, 0, 0.175); + --bs-border-radius: 0.375rem; + --bs-border-radius-sm: 0.25rem; + --bs-border-radius-lg: 0.5rem; + --bs-border-radius-xl: 1rem; + --bs-border-radius-xxl: 2rem; + --bs-border-radius-2xl: var(--bs-border-radius-xxl); + --bs-border-radius-pill: 50rem; + --bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + --bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); + --bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175); + --bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075); + --bs-focus-ring-width: 0.25rem; + --bs-focus-ring-opacity: 0.25; + --bs-focus-ring-color: rgba(13, 110, 253, 0.25); + --bs-form-valid-color: #198754; + --bs-form-valid-border-color: #198754; + --bs-form-invalid-color: #dc3545; + --bs-form-invalid-border-color: #dc3545; +} + +[data-bs-theme=dark] { + color-scheme: dark; + --bs-body-color: #dee2e6; + --bs-body-color-rgb: 222, 226, 230; + --bs-body-bg: #212529; + --bs-body-bg-rgb: 33, 37, 41; + --bs-emphasis-color: #fff; + --bs-emphasis-color-rgb: 255, 255, 255; + --bs-secondary-color: rgba(222, 226, 230, 0.75); + --bs-secondary-color-rgb: 222, 226, 230; + --bs-secondary-bg: #343a40; + --bs-secondary-bg-rgb: 52, 58, 64; + --bs-tertiary-color: rgba(222, 226, 230, 0.5); + --bs-tertiary-color-rgb: 222, 226, 230; + --bs-tertiary-bg: #2b3035; + --bs-tertiary-bg-rgb: 43, 48, 53; + --bs-primary-text-emphasis: #6ea8fe; + --bs-secondary-text-emphasis: #a7acb1; + --bs-success-text-emphasis: #75b798; + --bs-info-text-emphasis: #6edff6; + --bs-warning-text-emphasis: #ffda6a; + --bs-danger-text-emphasis: #ea868f; + --bs-light-text-emphasis: #f8f9fa; + --bs-dark-text-emphasis: #dee2e6; + --bs-primary-bg-subtle: #031633; + --bs-secondary-bg-subtle: #161719; + --bs-success-bg-subtle: #051b11; + --bs-info-bg-subtle: #032830; + --bs-warning-bg-subtle: #332701; + --bs-danger-bg-subtle: #2c0b0e; + --bs-light-bg-subtle: #343a40; + --bs-dark-bg-subtle: #1a1d20; + --bs-primary-border-subtle: #084298; + --bs-secondary-border-subtle: #41464b; + --bs-success-border-subtle: #0f5132; + --bs-info-border-subtle: #087990; + --bs-warning-border-subtle: #997404; + --bs-danger-border-subtle: #842029; + --bs-light-border-subtle: #495057; + --bs-dark-border-subtle: #343a40; + --bs-heading-color: inherit; + --bs-link-color: #6ea8fe; + --bs-link-hover-color: #8bb9fe; + --bs-link-color-rgb: 110, 168, 254; + --bs-link-hover-color-rgb: 139, 185, 254; + --bs-code-color: #e685b5; + --bs-highlight-color: #dee2e6; + --bs-highlight-bg: #664d03; + --bs-border-color: #495057; + --bs-border-color-translucent: rgba(255, 255, 255, 0.15); + --bs-form-valid-color: #75b798; + --bs-form-valid-border-color: #75b798; + --bs-form-invalid-color: #ea868f; + --bs-form-invalid-border-color: #ea868f; +} +:root { + --blue: #3490dc; + --indigo: #6574cd; + --purple: #9561e2; + --pink: #f66d9b; + --red: #e3342f; + --orange: #f6993f; + --yellow: #ffed4a; + --green: #38c172; + --teal: #4dc0b5; + --cyan: #6cb2eb; + --white: #fff; + --gray: #6c757d; + --gray-dark: #343a40; + --primary: #3490dc; + --secondary: #6c757d; + --success: #38c172; + --info: #6cb2eb; + --warning: #ffed4a; + --danger: #e3342f; + --light: #f8f9fa; + --dark: #343a40; + --breakpoint-xs: 0; + --breakpoint-sm: 576px; + --breakpoint-md: 768px; + --breakpoint-lg: 992px; + --breakpoint-xl: 1200px; + --font-family-sans-serif: "Nunito", sans-serif; + --font-family-monospace: SFMono-Regular, Menlo, Monaco, Consolas, + "Liberation Mono", "Courier New", monospace; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + font-family: sans-serif; + line-height: 1.15; + -webkit-text-size-adjust: 100%; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); +} + +article, +aside, +figcaption, +figure, +footer, +header, +hgroup, +main, +nav, +section { + display: block; +} + +body { + margin: 0; + font-family: "Nunito", sans-serif; + font-size: 0.83rem; + font-weight: 400; + line-height: 1.6; + color: #212529; + text-align: left; + background-color: #f8fafc; +} + +[tabindex="-1"]:focus { + outline: 0 !important; +} + +hr { + box-sizing: content-box; + height: 0; + overflow: visible; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + margin-top: 0; + margin-bottom: 0.5rem; +} + +p { + margin-top: 0; + margin-bottom: 1rem; +} + +abbr[title], +abbr[data-original-title] { + text-decoration: underline; + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; + cursor: help; + border-bottom: 0; + -webkit-text-decoration-skip-ink: none; + text-decoration-skip-ink: none; +} + +address { + margin-bottom: 1rem; + font-style: normal; + line-height: inherit; +} + +ol, +ul, +dl { + margin-top: 0; + margin-bottom: 1rem; +} + +ol ol, +ul ul, +ol ul, +ul ol { + margin-bottom: 0; +} + +dt { + font-weight: 700; +} + +dd { + margin-bottom: 0.5rem; + margin-left: 0; +} + +blockquote { + margin: 0 0 1rem; +} + +b, +strong { + font-weight: bolder; +} + +small { + font-size: 80%; +} + +sub, +sup { + position: relative; + font-size: 75%; + line-height: 0; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +a { + color: #3490dc; + text-decoration: none; + background-color: transparent; +} + +a:hover { + color: #1d68a7; + text-decoration: underline; +} + +a:not([href]):not([tabindex]) { + color: inherit; + text-decoration: none; +} + +a:not([href]):not([tabindex]):hover, +a:not([href]):not([tabindex]):focus { + color: inherit; + text-decoration: none; +} + +a:not([href]):not([tabindex]):focus { + outline: 0; +} + +pre, +code, +kbd, +samp { + font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", + "Courier New", monospace; + font-size: 1em; +} + +pre { + margin-top: 0; + margin-bottom: 1rem; + overflow: auto; +} + +figure { + margin: 0 0 1rem; +} + +img { + vertical-align: middle; + border-style: none; +} + +svg { + overflow: hidden; + vertical-align: middle; +} + +table { + border-collapse: collapse; +} + +caption { + padding-top: 0.75rem; + padding-bottom: 0.75rem; + color: #6c757d; + text-align: left; + caption-side: bottom; +} + +th { + text-align: inherit; +} + +label { + display: inline-block; + margin-bottom: 0.5rem; +} + +button { + border-radius: 0; +} + +button:focus { + outline: 1px dotted; + outline: 5px auto -webkit-focus-ring-color; +} + +input, +button, +select, +optgroup, +textarea { + margin: 0; + font-family: inherit; + font-size: inherit; + line-height: inherit; +} + +button, +input { + overflow: visible; +} + +button, +select { + text-transform: none; +} + +select { + word-wrap: normal; +} + +button, +[type="button"], +[type="reset"], +[type="submit"] { + -webkit-appearance: button; +} + +button:not(:disabled), +[type="button"]:not(:disabled), +[type="reset"]:not(:disabled), +[type="submit"]:not(:disabled) { + cursor: pointer; +} + +button::-moz-focus-inner, +[type="button"]::-moz-focus-inner, +[type="reset"]::-moz-focus-inner, +[type="submit"]::-moz-focus-inner { + padding: 0; + border-style: none; +} + +input[type="radio"], +input[type="checkbox"] { + box-sizing: border-box; + padding: 0; +} + +input[type="date"], +input[type="time"], +input[type="datetime-local"], +input[type="month"] { + -webkit-appearance: listbox; +} + +textarea { + overflow: auto; + resize: vertical; +} + +fieldset { + min-width: 0; + padding: 0; + margin: 0; + border: 0; +} + +legend { + display: block; + width: 100%; + max-width: 100%; + padding: 0; + margin-bottom: 0.5rem; + font-size: 1.5rem; + line-height: inherit; + color: inherit; + white-space: normal; +} + +progress { + vertical-align: baseline; +} + +[type="number"]::-webkit-inner-spin-button, +[type="number"]::-webkit-outer-spin-button { + height: auto; +} + +[type="search"] { + outline-offset: -2px; + -webkit-appearance: none; +} + +[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} + +::-webkit-file-upload-button { + font: inherit; + -webkit-appearance: button; +} + +output { + display: inline-block; +} + +summary { + display: list-item; + cursor: pointer; +} + +template { + display: none; +} + +[hidden] { + display: none !important; +} + +h1, +h2, +h3, +h4, +h5, +h6, +.h1, +.h2, +.h3, +.h4, +.h5, +.h6 { + margin-bottom: 0.5rem; + font-weight: 500; + line-height: 1.2; +} + +h1, +.h1 { + font-size: 2.075rem; +} + +h2, +.h2 { + font-size: 1.66rem; +} + +h3, +.h3 { + font-size: 1.4525rem; +} + +h4, +.h4 { + font-size: 1.245rem; +} + +h5, +.h5 { + font-size: 1.0375rem; +} + +h6, +.h6 { + font-size: 0.83rem; +} + +.lead { + font-size: 1.0375rem; + font-weight: 300; +} + +.display-1 { + font-size: 6rem; + font-weight: 300; + line-height: 1.2; +} + +.display-2 { + font-size: 5.5rem; + font-weight: 300; + line-height: 1.2; +} + +.display-3 { + font-size: 4.5rem; + font-weight: 300; + line-height: 1.2; +} + +.display-4 { + font-size: 3.5rem; + font-weight: 300; + line-height: 1.2; +} + +hr { + margin-top: 1rem; + margin-bottom: 1rem; + border: 0; + border-top: 1px solid rgba(0, 0, 0, 0.1); +} + +small, +.small { + font-size: 80%; + font-weight: 400; +} + +mark, +.mark { + padding: 0.2em; + background-color: #fcf8e3; +} + +.list-unstyled { + padding-left: 0; + list-style: none; +} + +.list-inline { + padding-left: 0; + list-style: none; +} + +.list-inline-item { + display: inline-block; +} + +.list-inline-item:not(:last-child) { + margin-right: 0.5rem; +} + +.initialism { + font-size: 90%; + text-transform: uppercase; +} + +.blockquote { + margin-bottom: 1rem; + font-size: 1.0375rem; +} + +.blockquote-footer { + display: block; + font-size: 80%; + color: #6c757d; +} + +.blockquote-footer::before { + content: "— "; +} + +.img-fluid { + max-width: 100%; + height: auto; +} + +.img-thumbnail { + padding: 0.25rem; + background-color: #f8fafc; + border: 1px solid #dee2e6; + border-radius: 0.25rem; + max-width: 100%; + height: auto; +} + +.figure { + display: inline-block; +} + +.figure-img { + margin-bottom: 0.5rem; + line-height: 1; +} + +.figure-caption { + font-size: 90%; + color: #6c757d; +} + +code { + font-size: 87.5%; + color: #f66d9b; + word-break: break-word; +} + +a > code { + color: inherit; +} + +kbd { + padding: 0.2rem 0.4rem; + font-size: 87.5%; + color: #fff; + background-color: #212529; + border-radius: 0.2rem; +} + +kbd kbd { + padding: 0; + font-size: 100%; + font-weight: 700; +} + +pre { + display: block; + font-size: 87.5%; + color: #212529; +} + +pre code { + font-size: inherit; + color: inherit; + word-break: normal; +} + +.pre-scrollable { + max-height: 340px; + overflow-y: scroll; +} + +.container { + width: 100%; + padding-right: 15px; + padding-left: 15px; + margin-right: auto; + margin-left: auto; +} + +@media (min-width: 576px) { + .container { + max-width: 540px; + } +} + +@media (min-width: 768px) { + .container { + max-width: 720px; + } +} + +@media (min-width: 992px) { + .container { + max-width: 960px; + } +} + +@media (min-width: 1200px) { + .container { + max-width: 1140px; + } +} + +.container-fluid { + width: 100%; + padding-right: 15px; + padding-left: 15px; + margin-right: auto; + margin-left: auto; +} + +.row { + display: flex; + flex-wrap: wrap; + margin-right: -15px; + margin-left: -15px; +} + +.no-gutters { + margin-right: 0; + margin-left: 0; +} + +.no-gutters > .col, +.no-gutters > [class*="col-"] { + padding-right: 0; + padding-left: 0; +} + +.col-xl, +.col-xl-auto, +.col-xl-12, +.col-xl-11, +.col-xl-10, +.col-xl-9, +.col-xl-8, +.col-xl-7, +.col-xl-6, +.col-xl-5, +.col-xl-4, +.col-xl-3, +.col-xl-2, +.col-xl-1, +.col-lg, +.col-lg-auto, +.col-lg-12, +.col-lg-11, +.col-lg-10, +.col-lg-9, +.col-lg-8, +.col-lg-7, +.col-lg-6, +.col-lg-5, +.col-lg-4, +.col-lg-3, +.col-lg-2, +.col-lg-1, +.col-md, +.col-md-auto, +.col-md-12, +.col-md-11, +.col-md-10, +.col-md-9, +.col-md-8, +.col-md-7, +.col-md-6, +.col-md-5, +.col-md-4, +.col-md-3, +.col-md-2, +.col-md-1, +.col-sm, +.col-sm-auto, +.col-sm-12, +.col-sm-11, +.col-sm-10, +.col-sm-9, +.col-sm-8, +.col-sm-7, +.col-sm-6, +.col-sm-5, +.col-sm-4, +.col-sm-3, +.col-sm-2, +.col-sm-1, +.col, +.col-auto, +.col-12, +.col-11, +.col-10, +.col-9, +.col-8, +.col-7, +.col-6, +.col-5, +.col-4, +.col-3, +.col-2, +.col-1 { + position: relative; + width: 100%; + padding-right: 15px; + padding-left: 15px; +} + +.col { + flex-basis: 0; + flex-grow: 1; + max-width: 100%; +} + +.col-auto { + flex: 0 0 auto; + width: auto; + max-width: 100%; +} + +.col-1 { + flex: 0 0 8.3333333333%; + max-width: 8.3333333333%; +} + +.col-2 { + flex: 0 0 16.6666666667%; + max-width: 16.6666666667%; +} + +.col-3 { + flex: 0 0 25%; + max-width: 25%; +} + +.col-4 { + flex: 0 0 33.3333333333%; + max-width: 33.3333333333%; +} + +.col-5 { + flex: 0 0 41.6666666667%; + max-width: 41.6666666667%; +} + +.col-6 { + flex: 0 0 50%; + max-width: 50%; +} + +.col-7 { + flex: 0 0 58.3333333333%; + max-width: 58.3333333333%; +} + +.col-8 { + flex: 0 0 66.6666666667%; + max-width: 66.6666666667%; +} + +.col-9 { + flex: 0 0 75%; + max-width: 75%; +} + +.col-10 { + flex: 0 0 83.3333333333%; + max-width: 83.3333333333%; +} + +.col-11 { + flex: 0 0 91.6666666667%; + max-width: 91.6666666667%; +} + +.col-12 { + flex: 0 0 100%; + max-width: 100%; +} + +.order-first { + order: -1; +} + +.order-last { + order: 13; +} + +.order-0 { + order: 0; +} + +.order-1 { + order: 1; +} + +.order-2 { + order: 2; +} + +.order-3 { + order: 3; +} + +.order-4 { + order: 4; +} + +.order-5 { + order: 5; +} + +.order-6 { + order: 6; +} + +.order-7 { + order: 7; +} + +.order-8 { + order: 8; +} + +.order-9 { + order: 9; +} + +.order-10 { + order: 10; +} + +.order-11 { + order: 11; +} + +.order-12 { + order: 12; +} + +.offset-1 { + margin-left: 8.3333333333%; +} + +.offset-2 { + margin-left: 16.6666666667%; +} + +.offset-3 { + margin-left: 25%; +} + +.offset-4 { + margin-left: 33.3333333333%; +} + +.offset-5 { + margin-left: 41.6666666667%; +} + +.offset-6 { + margin-left: 50%; +} + +.offset-7 { + margin-left: 58.3333333333%; +} + +.offset-8 { + margin-left: 66.6666666667%; +} + +.offset-9 { + margin-left: 75%; +} + +.offset-10 { + margin-left: 83.3333333333%; +} + +.offset-11 { + margin-left: 91.6666666667%; +} + +@media (min-width: 576px) { + .col-sm { + flex-basis: 0; + flex-grow: 1; + max-width: 100%; + } + + .col-sm-auto { + flex: 0 0 auto; + width: auto; + max-width: 100%; + } + + .col-sm-1 { + flex: 0 0 8.3333333333%; + max-width: 8.3333333333%; + } + + .col-sm-2 { + flex: 0 0 16.6666666667%; + max-width: 16.6666666667%; + } + + .col-sm-3 { + flex: 0 0 25%; + max-width: 25%; + } + + .col-sm-4 { + flex: 0 0 33.3333333333%; + max-width: 33.3333333333%; + } + + .col-sm-5 { + flex: 0 0 41.6666666667%; + max-width: 41.6666666667%; + } + + .col-sm-6 { + flex: 0 0 50%; + max-width: 50%; + } + + .col-sm-7 { + flex: 0 0 58.3333333333%; + max-width: 58.3333333333%; + } + + .col-sm-8 { + flex: 0 0 66.6666666667%; + max-width: 66.6666666667%; + } + + .col-sm-9 { + flex: 0 0 75%; + max-width: 75%; + } + + .col-sm-10 { + flex: 0 0 83.3333333333%; + max-width: 83.3333333333%; + } + + .col-sm-11 { + flex: 0 0 91.6666666667%; + max-width: 91.6666666667%; + } + + .col-sm-12 { + flex: 0 0 100%; + max-width: 100%; + } + + .order-sm-first { + order: -1; + } + + .order-sm-last { + order: 13; + } + + .order-sm-0 { + order: 0; + } + + .order-sm-1 { + order: 1; + } + + .order-sm-2 { + order: 2; + } + + .order-sm-3 { + order: 3; + } + + .order-sm-4 { + order: 4; + } + + .order-sm-5 { + order: 5; + } + + .order-sm-6 { + order: 6; + } + + .order-sm-7 { + order: 7; + } + + .order-sm-8 { + order: 8; + } + + .order-sm-9 { + order: 9; + } + + .order-sm-10 { + order: 10; + } + + .order-sm-11 { + order: 11; + } + + .order-sm-12 { + order: 12; + } + + .offset-sm-0 { + margin-left: 0; + } + + .offset-sm-1 { + margin-left: 8.3333333333%; + } + + .offset-sm-2 { + margin-left: 16.6666666667%; + } + + .offset-sm-3 { + margin-left: 25%; + } + + .offset-sm-4 { + margin-left: 33.3333333333%; + } + + .offset-sm-5 { + margin-left: 41.6666666667%; + } + + .offset-sm-6 { + margin-left: 50%; + } + + .offset-sm-7 { + margin-left: 58.3333333333%; + } + + .offset-sm-8 { + margin-left: 66.6666666667%; + } + + .offset-sm-9 { + margin-left: 75%; + } + + .offset-sm-10 { + margin-left: 83.3333333333%; + } + + .offset-sm-11 { + margin-left: 91.6666666667%; + } +} + +@media (min-width: 768px) { + .col-md { + flex-basis: 0; + flex-grow: 1; + max-width: 100%; + } + + .col-md-auto { + flex: 0 0 auto; + width: auto; + max-width: 100%; + } + + .col-md-1 { + flex: 0 0 8.3333333333%; + max-width: 8.3333333333%; + } + + .col-md-2 { + flex: 0 0 16.6666666667%; + max-width: 16.6666666667%; + } + + .col-md-3 { + flex: 0 0 25%; + max-width: 25%; + } + + .col-md-4 { + flex: 0 0 33.3333333333%; + max-width: 33.3333333333%; + } + + .col-md-5 { + flex: 0 0 41.6666666667%; + max-width: 41.6666666667%; + } + + .col-md-6 { + flex: 0 0 50%; + max-width: 50%; + } + + .col-md-7 { + flex: 0 0 58.3333333333%; + max-width: 58.3333333333%; + } + + .col-md-8 { + flex: 0 0 66.6666666667%; + max-width: 66.6666666667%; + } + + .col-md-9 { + flex: 0 0 75%; + max-width: 75%; + } + + .col-md-10 { + flex: 0 0 83.3333333333%; + max-width: 83.3333333333%; + } + + .col-md-11 { + flex: 0 0 91.6666666667%; + max-width: 91.6666666667%; + } + + .col-md-12 { + flex: 0 0 100%; + max-width: 100%; + } + + .order-md-first { + order: -1; + } + + .order-md-last { + order: 13; + } + + .order-md-0 { + order: 0; + } + + .order-md-1 { + order: 1; + } + + .order-md-2 { + order: 2; + } + + .order-md-3 { + order: 3; + } + + .order-md-4 { + order: 4; + } + + .order-md-5 { + order: 5; + } + + .order-md-6 { + order: 6; + } + + .order-md-7 { + order: 7; + } + + .order-md-8 { + order: 8; + } + + .order-md-9 { + order: 9; + } + + .order-md-10 { + order: 10; + } + + .order-md-11 { + order: 11; + } + + .order-md-12 { + order: 12; + } + + .offset-md-0 { + margin-left: 0; + } + + .offset-md-1 { + margin-left: 8.3333333333%; + } + + .offset-md-2 { + margin-left: 16.6666666667%; + } + + .offset-md-3 { + margin-left: 25%; + } + + .offset-md-4 { + margin-left: 33.3333333333%; + } + + .offset-md-5 { + margin-left: 41.6666666667%; + } + + .offset-md-6 { + margin-left: 50%; + } + + .offset-md-7 { + margin-left: 58.3333333333%; + } + + .offset-md-8 { + margin-left: 66.6666666667%; + } + + .offset-md-9 { + margin-left: 75%; + } + + .offset-md-10 { + margin-left: 83.3333333333%; + } + + .offset-md-11 { + margin-left: 91.6666666667%; + } +} + +@media (min-width: 992px) { + .col-lg { + flex-basis: 0; + flex-grow: 1; + max-width: 100%; + } + + .col-lg-auto { + flex: 0 0 auto; + width: auto; + max-width: 100%; + } + + .col-lg-1 { + flex: 0 0 8.3333333333%; + max-width: 8.3333333333%; + } + + .col-lg-2 { + flex: 0 0 16.6666666667%; + max-width: 16.6666666667%; + } + + .col-lg-3 { + flex: 0 0 25%; + max-width: 25%; + } + + .col-lg-4 { + flex: 0 0 33.3333333333%; + max-width: 33.3333333333%; + } + + .col-lg-5 { + flex: 0 0 41.6666666667%; + max-width: 41.6666666667%; + } + + .col-lg-6 { + flex: 0 0 50%; + max-width: 50%; + } + + .col-lg-7 { + flex: 0 0 58.3333333333%; + max-width: 58.3333333333%; + } + + .col-lg-8 { + flex: 0 0 66.6666666667%; + max-width: 66.6666666667%; + } + + .col-lg-9 { + flex: 0 0 75%; + max-width: 75%; + } + + .col-lg-10 { + flex: 0 0 83.3333333333%; + max-width: 83.3333333333%; + } + + .col-lg-11 { + flex: 0 0 91.6666666667%; + max-width: 91.6666666667%; + } + + .col-lg-12 { + flex: 0 0 100%; + max-width: 100%; + } + + .order-lg-first { + order: -1; + } + + .order-lg-last { + order: 13; + } + + .order-lg-0 { + order: 0; + } + + .order-lg-1 { + order: 1; + } + + .order-lg-2 { + order: 2; + } + + .order-lg-3 { + order: 3; + } + + .order-lg-4 { + order: 4; + } + + .order-lg-5 { + order: 5; + } + + .order-lg-6 { + order: 6; + } + + .order-lg-7 { + order: 7; + } + + .order-lg-8 { + order: 8; + } + + .order-lg-9 { + order: 9; + } + + .order-lg-10 { + order: 10; + } + + .order-lg-11 { + order: 11; + } + + .order-lg-12 { + order: 12; + } + + .offset-lg-0 { + margin-left: 0; + } + + .offset-lg-1 { + margin-left: 8.3333333333%; + } + + .offset-lg-2 { + margin-left: 16.6666666667%; + } + + .offset-lg-3 { + margin-left: 25%; + } + + .offset-lg-4 { + margin-left: 33.3333333333%; + } + + .offset-lg-5 { + margin-left: 41.6666666667%; + } + + .offset-lg-6 { + margin-left: 50%; + } + + .offset-lg-7 { + margin-left: 58.3333333333%; + } + + .offset-lg-8 { + margin-left: 66.6666666667%; + } + + .offset-lg-9 { + margin-left: 75%; + } + + .offset-lg-10 { + margin-left: 83.3333333333%; + } + + .offset-lg-11 { + margin-left: 91.6666666667%; + } +} + +@media (min-width: 1200px) { + .col-xl { + flex-basis: 0; + flex-grow: 1; + max-width: 100%; + } + + .col-xl-auto { + flex: 0 0 auto; + width: auto; + max-width: 100%; + } + + .col-xl-1 { + flex: 0 0 8.3333333333%; + max-width: 8.3333333333%; + } + + .col-xl-2 { + flex: 0 0 16.6666666667%; + max-width: 16.6666666667%; + } + + .col-xl-3 { + flex: 0 0 25%; + max-width: 25%; + } + + .col-xl-4 { + flex: 0 0 33.3333333333%; + max-width: 33.3333333333%; + } + + .col-xl-5 { + flex: 0 0 41.6666666667%; + max-width: 41.6666666667%; + } + + .col-xl-6 { + flex: 0 0 50%; + max-width: 50%; + } + + .col-xl-7 { + flex: 0 0 58.3333333333%; + max-width: 58.3333333333%; + } + + .col-xl-8 { + flex: 0 0 66.6666666667%; + max-width: 66.6666666667%; + } + + .col-xl-9 { + flex: 0 0 75%; + max-width: 75%; + } + + .col-xl-10 { + flex: 0 0 83.3333333333%; + max-width: 83.3333333333%; + } + + .col-xl-11 { + flex: 0 0 91.6666666667%; + max-width: 91.6666666667%; + } + + .col-xl-12 { + flex: 0 0 100%; + max-width: 100%; + } + + .order-xl-first { + order: -1; + } + + .order-xl-last { + order: 13; + } + + .order-xl-0 { + order: 0; + } + + .order-xl-1 { + order: 1; + } + + .order-xl-2 { + order: 2; + } + + .order-xl-3 { + order: 3; + } + + .order-xl-4 { + order: 4; + } + + .order-xl-5 { + order: 5; + } + + .order-xl-6 { + order: 6; + } + + .order-xl-7 { + order: 7; + } + + .order-xl-8 { + order: 8; + } + + .order-xl-9 { + order: 9; + } + + .order-xl-10 { + order: 10; + } + + .order-xl-11 { + order: 11; + } + + .order-xl-12 { + order: 12; + } + + .offset-xl-0 { + margin-left: 0; + } + + .offset-xl-1 { + margin-left: 8.3333333333%; + } + + .offset-xl-2 { + margin-left: 16.6666666667%; + } + + .offset-xl-3 { + margin-left: 25%; + } + + .offset-xl-4 { + margin-left: 33.3333333333%; + } + + .offset-xl-5 { + margin-left: 41.6666666667%; + } + + .offset-xl-6 { + margin-left: 50%; + } + + .offset-xl-7 { + margin-left: 58.3333333333%; + } + + .offset-xl-8 { + margin-left: 66.6666666667%; + } + + .offset-xl-9 { + margin-left: 75%; + } + + .offset-xl-10 { + margin-left: 83.3333333333%; + } + + .offset-xl-11 { + margin-left: 91.6666666667%; + } +} + +.table { + width: 100%; + margin-bottom: 1rem; + color: #212529; +} + +.table th, +.table td { + padding: 0.75rem; + vertical-align: top; + border-top: 1px solid #dee2e6; +} + +.table thead th { + vertical-align: bottom; + border-bottom: 2px solid #dee2e6; +} + +.table tbody + tbody { + border-top: 2px solid #dee2e6; +} + +.table-sm th, +.table-sm td { + padding: 0.3rem; +} + +.table-bordered { + border: 1px solid #dee2e6; +} + +.table-bordered th, +.table-bordered td { + border: 1px solid #dee2e6; +} + +.table-bordered thead th, +.table-bordered thead td { + border-bottom-width: 2px; +} + +.table-borderless th, +.table-borderless td, +.table-borderless thead th, +.table-borderless tbody + tbody { + border: 0; +} + +.table-striped tbody tr:nth-of-type(odd) { + background-color: rgba(0, 0, 0, 0.05); +} + +.table-hover tbody tr:hover { + color: #212529; + background-color: rgba(0, 0, 0, 0.075); +} + +.table-primary, +.table-primary > th, +.table-primary > td { + background-color: #c6e0f5; +} + +.table-primary th, +.table-primary td, +.table-primary thead th, +.table-primary tbody + tbody { + border-color: #95c5ed; +} + +.table-hover .table-primary:hover { + background-color: #b0d4f1; +} + +.table-hover .table-primary:hover > td, +.table-hover .table-primary:hover > th { + background-color: #b0d4f1; +} + +.table-secondary, +.table-secondary > th, +.table-secondary > td { + background-color: #d6d8db; +} + +.table-secondary th, +.table-secondary td, +.table-secondary thead th, +.table-secondary tbody + tbody { + border-color: #b3b7bb; +} + +.table-hover .table-secondary:hover { + background-color: #c8cbcf; +} + +.table-hover .table-secondary:hover > td, +.table-hover .table-secondary:hover > th { + background-color: #c8cbcf; +} + +.table-success, +.table-success > th, +.table-success > td { + background-color: #c7eed8; +} + +.table-success th, +.table-success td, +.table-success thead th, +.table-success tbody + tbody { + border-color: #98dfb6; +} + +.table-hover .table-success:hover { + background-color: #b3e8ca; +} + +.table-hover .table-success:hover > td, +.table-hover .table-success:hover > th { + background-color: #b3e8ca; +} + +.table-info, +.table-info > th, +.table-info > td { + background-color: #d6e9f9; +} + +.table-info th, +.table-info td, +.table-info thead th, +.table-info tbody + tbody { + border-color: #b3d7f5; +} + +.table-hover .table-info:hover { + background-color: #c0ddf6; +} + +.table-hover .table-info:hover > td, +.table-hover .table-info:hover > th { + background-color: #c0ddf6; +} + +.table-warning, +.table-warning > th, +.table-warning > td { + background-color: #fffacc; +} + +.table-warning th, +.table-warning td, +.table-warning thead th, +.table-warning tbody + tbody { + border-color: #fff6a1; +} + +.table-hover .table-warning:hover { + background-color: #fff8b3; +} + +.table-hover .table-warning:hover > td, +.table-hover .table-warning:hover > th { + background-color: #fff8b3; +} + +.table-danger, +.table-danger > th, +.table-danger > td { + background-color: #f7c6c5; +} + +.table-danger th, +.table-danger td, +.table-danger thead th, +.table-danger tbody + tbody { + border-color: #f09593; +} + +.table-hover .table-danger:hover { + background-color: #f4b0af; +} + +.table-hover .table-danger:hover > td, +.table-hover .table-danger:hover > th { + background-color: #f4b0af; +} + +.table-light, +.table-light > th, +.table-light > td { + background-color: #fdfdfe; +} + +.table-light th, +.table-light td, +.table-light thead th, +.table-light tbody + tbody { + border-color: #fbfcfc; +} + +.table-hover .table-light:hover { + background-color: #ececf6; +} + +.table-hover .table-light:hover > td, +.table-hover .table-light:hover > th { + background-color: #ececf6; +} + +.table-dark, +.table-dark > th, +.table-dark > td { + background-color: #c6c8ca; +} + +.table-dark th, +.table-dark td, +.table-dark thead th, +.table-dark tbody + tbody { + border-color: #95999c; +} + +.table-hover .table-dark:hover { + background-color: #b9bbbe; +} + +.table-hover .table-dark:hover > td, +.table-hover .table-dark:hover > th { + background-color: #b9bbbe; +} + +.table-active, +.table-active > th, +.table-active > td { + background-color: rgba(0, 0, 0, 0.075); +} + +.table-hover .table-active:hover { + background-color: rgba(0, 0, 0, 0.075); +} + +.table-hover .table-active:hover > td, +.table-hover .table-active:hover > th { + background-color: rgba(0, 0, 0, 0.075); +} + +.table .thead-dark th { + color: #fff; + background-color: #343a40; + border-color: #454d55; +} + +.table .thead-light th { + color: #495057; + background-color: #e9ecef; + border-color: #dee2e6; +} + +.table-dark { + color: #fff; + background-color: #343a40; +} + +.table-dark th, +.table-dark td, +.table-dark thead th { + border-color: #454d55; +} + +.table-dark.table-bordered { + border: 0; +} + +.table-dark.table-striped tbody tr:nth-of-type(odd) { + background-color: rgba(255, 255, 255, 0.05); +} + +.table-dark.table-hover tbody tr:hover { + color: #fff; + background-color: rgba(255, 255, 255, 0.075); +} + +@media (max-width: 575.98px) { + .table-responsive-sm { + display: block; + width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + .table-responsive-sm > .table-bordered { + border: 0; + } +} + +@media (max-width: 767.98px) { + .table-responsive-md { + display: block; + width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + .table-responsive-md > .table-bordered { + border: 0; + } +} + +@media (max-width: 991.98px) { + .table-responsive-lg { + display: block; + width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + .table-responsive-lg > .table-bordered { + border: 0; + } +} + +@media (max-width: 1199.98px) { + .table-responsive-xl { + display: block; + width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + .table-responsive-xl > .table-bordered { + border: 0; + } +} + +.table-responsive { + display: block; + width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +.table-responsive > .table-bordered { + border: 0; +} + +.form-control { + display: block; + width: 100%; + height: calc(1.6em + 0.75rem + 2px); + padding: 0.375rem 0.75rem; + font-size: 0.83rem; + font-weight: 400; + line-height: 1.6; + color: #495057; + background-color: #fff; + background-clip: padding-box; + border: 1px solid #ced4da; + border-radius: 0.25rem; + transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; +} + +@media (prefers-reduced-motion: reduce) { + .form-control { + transition: none; + } +} + +.form-control::-ms-expand { + background-color: transparent; + border: 0; +} + +.form-control:focus { + color: #495057; + background-color: #fff; + border-color: #a1cbef; + outline: 0; + box-shadow: 0 0 0 0.2rem rgba(52, 144, 220, 0.25); +} + +.form-control::-moz-placeholder { + color: #6c757d; + opacity: 1; +} + +.form-control::placeholder { + color: #6c757d; + opacity: 1; +} + +.form-control:disabled, +.form-control[readonly] { + background-color: #e9ecef; + opacity: 1; +} + +select.form-control:focus::-ms-value { + color: #495057; + background-color: #fff; +} + +.form-control-file, +.form-control-range { + display: block; + width: 100%; +} + +.col-form-label { + padding-top: calc(0.375rem + 1px); + padding-bottom: calc(0.375rem + 1px); + margin-bottom: 0; + font-size: inherit; + line-height: 1.6; +} + +.col-form-label-lg { + padding-top: calc(0.5rem + 1px); + padding-bottom: calc(0.5rem + 1px); + font-size: 1.0375rem; + line-height: 1.5; +} + +.col-form-label-sm { + padding-top: calc(0.25rem + 1px); + padding-bottom: calc(0.25rem + 1px); + font-size: 0.72625rem; + line-height: 1.5; +} + +.form-control-plaintext { + display: block; + width: 100%; + padding-top: 0.375rem; + padding-bottom: 0.375rem; + margin-bottom: 0; + line-height: 1.6; + color: #212529; + background-color: transparent; + border: solid transparent; + border-width: 1px 0; +} + +.form-control-plaintext.form-control-sm, +.form-control-plaintext.form-control-lg { + padding-right: 0; + padding-left: 0; +} + +.form-control-sm { + height: calc(1.5em + 0.5rem + 2px); + padding: 0.25rem 0.5rem; + font-size: 0.72625rem; + line-height: 1.5; + border-radius: 0.2rem; +} + +.form-control-lg { + height: calc(1.5em + 1rem + 2px); + padding: 0.5rem 1rem; + font-size: 1.0375rem; + line-height: 1.5; + border-radius: 0.3rem; +} + +select.form-control[size], +select.form-control[multiple] { + height: auto; +} + +textarea.form-control { + height: auto; +} + +.form-group { + margin-bottom: 1rem; +} + +.form-text { + display: block; + margin-top: 0.25rem; +} + +.form-row { + display: flex; + flex-wrap: wrap; + margin-right: -5px; + margin-left: -5px; +} + +.form-row > .col, +.form-row > [class*="col-"] { + padding-right: 5px; + padding-left: 5px; +} + +.form-check { + position: relative; + display: block; + padding-left: 1.25rem; +} + +.form-check-input { + position: absolute; + margin-top: 0.3rem; + margin-left: -1.25rem; +} + +.form-check-input:disabled ~ .form-check-label { + color: #6c757d; +} + +.form-check-label { + margin-bottom: 0; +} + +.form-check-inline { + display: inline-flex; + align-items: center; + padding-left: 0; + margin-right: 0.75rem; +} + +.form-check-inline .form-check-input { + position: static; + margin-top: 0; + margin-right: 0.3125rem; + margin-left: 0; +} + +.valid-feedback { + display: none; + width: 100%; + margin-top: 0.25rem; + font-size: 80%; + color: #38c172; +} + +.valid-tooltip { + position: absolute; + top: 100%; + z-index: 5; + display: none; + max-width: 100%; + padding: 0.25rem 0.5rem; + margin-top: 0.1rem; + font-size: 0.72625rem; + line-height: 1.6; + color: #fff; + background-color: rgba(56, 193, 114, 0.9); + border-radius: 0.25rem; +} + +.was-validated .form-control:valid, +.form-control.is-valid { + border-color: #38c172; + padding-right: calc(1.6em + 0.75rem); + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%2338c172' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e"); + background-repeat: no-repeat; + background-position: center right calc(0.4em + 0.1875rem); + background-size: calc(0.8em + 0.375rem) calc(0.8em + 0.375rem); +} + +.was-validated .form-control:valid:focus, +.form-control.is-valid:focus { + border-color: #38c172; + box-shadow: 0 0 0 0.2rem rgba(56, 193, 114, 0.25); +} + +.was-validated .form-control:valid ~ .valid-feedback, +.was-validated .form-control:valid ~ .valid-tooltip, +.form-control.is-valid ~ .valid-feedback, +.form-control.is-valid ~ .valid-tooltip { + display: block; +} + +.was-validated textarea.form-control:valid, +textarea.form-control.is-valid { + padding-right: calc(1.6em + 0.75rem); + background-position: top calc(0.4em + 0.1875rem) right calc(0.4em + 0.1875rem); +} + +.was-validated .custom-select:valid, +.custom-select.is-valid { + border-color: #38c172; + padding-right: calc((1em + 0.75rem) * 3 / 4 + 1.75rem); + background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") + no-repeat right 0.75rem center/8px 10px, + url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%2338c172' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e") + #fff no-repeat center right 1.75rem / calc(0.8em + 0.375rem) + calc(0.8em + 0.375rem); +} + +.was-validated .custom-select:valid:focus, +.custom-select.is-valid:focus { + border-color: #38c172; + box-shadow: 0 0 0 0.2rem rgba(56, 193, 114, 0.25); +} + +.was-validated .custom-select:valid ~ .valid-feedback, +.was-validated .custom-select:valid ~ .valid-tooltip, +.custom-select.is-valid ~ .valid-feedback, +.custom-select.is-valid ~ .valid-tooltip { + display: block; +} + +.was-validated .form-control-file:valid ~ .valid-feedback, +.was-validated .form-control-file:valid ~ .valid-tooltip, +.form-control-file.is-valid ~ .valid-feedback, +.form-control-file.is-valid ~ .valid-tooltip { + display: block; +} + +.was-validated .form-check-input:valid ~ .form-check-label, +.form-check-input.is-valid ~ .form-check-label { + color: #38c172; +} + +.was-validated .form-check-input:valid ~ .valid-feedback, +.was-validated .form-check-input:valid ~ .valid-tooltip, +.form-check-input.is-valid ~ .valid-feedback, +.form-check-input.is-valid ~ .valid-tooltip { + display: block; +} + +.was-validated .custom-control-input:valid ~ .custom-control-label, +.custom-control-input.is-valid ~ .custom-control-label { + color: #38c172; +} + +.was-validated .custom-control-input:valid ~ .custom-control-label::before, +.custom-control-input.is-valid ~ .custom-control-label::before { + border-color: #38c172; +} + +.was-validated .custom-control-input:valid ~ .valid-feedback, +.was-validated .custom-control-input:valid ~ .valid-tooltip, +.custom-control-input.is-valid ~ .valid-feedback, +.custom-control-input.is-valid ~ .valid-tooltip { + display: block; +} + +.was-validated + .custom-control-input:valid:checked + ~ .custom-control-label::before, +.custom-control-input.is-valid:checked ~ .custom-control-label::before { + border-color: #5cd08d; + background-color: #5cd08d; +} + +.was-validated + .custom-control-input:valid:focus + ~ .custom-control-label::before, +.custom-control-input.is-valid:focus ~ .custom-control-label::before { + box-shadow: 0 0 0 0.2rem rgba(56, 193, 114, 0.25); +} + +.was-validated + .custom-control-input:valid:focus:not(:checked) + ~ .custom-control-label::before, +.custom-control-input.is-valid:focus:not(:checked) + ~ .custom-control-label::before { + border-color: #38c172; +} + +.was-validated .custom-file-input:valid ~ .custom-file-label, +.custom-file-input.is-valid ~ .custom-file-label { + border-color: #38c172; +} + +.was-validated .custom-file-input:valid ~ .valid-feedback, +.was-validated .custom-file-input:valid ~ .valid-tooltip, +.custom-file-input.is-valid ~ .valid-feedback, +.custom-file-input.is-valid ~ .valid-tooltip { + display: block; +} + +.was-validated .custom-file-input:valid:focus ~ .custom-file-label, +.custom-file-input.is-valid:focus ~ .custom-file-label { + border-color: #38c172; + box-shadow: 0 0 0 0.2rem rgba(56, 193, 114, 0.25); +} + +.invalid-feedback { + display: none; + width: 100%; + margin-top: 0.25rem; + font-size: 80%; + color: #e3342f; +} + +.invalid-tooltip { + position: absolute; + top: 100%; + z-index: 5; + display: none; + max-width: 100%; + padding: 0.25rem 0.5rem; + margin-top: 0.1rem; + font-size: 0.72625rem; + line-height: 1.6; + color: #fff; + background-color: rgba(227, 52, 47, 0.9); + border-radius: 0.25rem; +} + +.was-validated .form-control:invalid, +.form-control.is-invalid { + border-color: #e3342f; + padding-right: calc(1.6em + 0.75rem); + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23e3342f' viewBox='-2 -2 7 7'%3e%3cpath stroke='%23e3342f' d='M0 0l3 3m0-3L0 3'/%3e%3ccircle r='.5'/%3e%3ccircle cx='3' r='.5'/%3e%3ccircle cy='3' r='.5'/%3e%3ccircle cx='3' cy='3' r='.5'/%3e%3c/svg%3E"); + background-repeat: no-repeat; + background-position: center right calc(0.4em + 0.1875rem); + background-size: calc(0.8em + 0.375rem) calc(0.8em + 0.375rem); +} + +.was-validated .form-control:invalid:focus, +.form-control.is-invalid:focus { + border-color: #e3342f; + box-shadow: 0 0 0 0.2rem rgba(227, 52, 47, 0.25); +} + +.was-validated .form-control:invalid ~ .invalid-feedback, +.was-validated .form-control:invalid ~ .invalid-tooltip, +.form-control.is-invalid ~ .invalid-feedback, +.form-control.is-invalid ~ .invalid-tooltip { + display: block; +} + +.was-validated textarea.form-control:invalid, +textarea.form-control.is-invalid { + padding-right: calc(1.6em + 0.75rem); + background-position: top calc(0.4em + 0.1875rem) right calc(0.4em + 0.1875rem); +} + +.was-validated .custom-select:invalid, +.custom-select.is-invalid { + border-color: #e3342f; + padding-right: calc((1em + 0.75rem) * 3 / 4 + 1.75rem); + background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") + no-repeat right 0.75rem center/8px 10px, + url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23e3342f' viewBox='-2 -2 7 7'%3e%3cpath stroke='%23e3342f' d='M0 0l3 3m0-3L0 3'/%3e%3ccircle r='.5'/%3e%3ccircle cx='3' r='.5'/%3e%3ccircle cy='3' r='.5'/%3e%3ccircle cx='3' cy='3' r='.5'/%3e%3c/svg%3E") + #fff no-repeat center right 1.75rem / calc(0.8em + 0.375rem) + calc(0.8em + 0.375rem); +} + +.was-validated .custom-select:invalid:focus, +.custom-select.is-invalid:focus { + border-color: #e3342f; + box-shadow: 0 0 0 0.2rem rgba(227, 52, 47, 0.25); +} + +.was-validated .custom-select:invalid ~ .invalid-feedback, +.was-validated .custom-select:invalid ~ .invalid-tooltip, +.custom-select.is-invalid ~ .invalid-feedback, +.custom-select.is-invalid ~ .invalid-tooltip { + display: block; +} + +.was-validated .form-control-file:invalid ~ .invalid-feedback, +.was-validated .form-control-file:invalid ~ .invalid-tooltip, +.form-control-file.is-invalid ~ .invalid-feedback, +.form-control-file.is-invalid ~ .invalid-tooltip { + display: block; +} + +.was-validated .form-check-input:invalid ~ .form-check-label, +.form-check-input.is-invalid ~ .form-check-label { + color: #e3342f; +} + +.was-validated .form-check-input:invalid ~ .invalid-feedback, +.was-validated .form-check-input:invalid ~ .invalid-tooltip, +.form-check-input.is-invalid ~ .invalid-feedback, +.form-check-input.is-invalid ~ .invalid-tooltip { + display: block; +} + +.was-validated .custom-control-input:invalid ~ .custom-control-label, +.custom-control-input.is-invalid ~ .custom-control-label { + color: #e3342f; +} + +.was-validated .custom-control-input:invalid ~ .custom-control-label::before, +.custom-control-input.is-invalid ~ .custom-control-label::before { + border-color: #e3342f; +} + +.was-validated .custom-control-input:invalid ~ .invalid-feedback, +.was-validated .custom-control-input:invalid ~ .invalid-tooltip, +.custom-control-input.is-invalid ~ .invalid-feedback, +.custom-control-input.is-invalid ~ .invalid-tooltip { + display: block; +} + +.was-validated + .custom-control-input:invalid:checked + ~ .custom-control-label::before, +.custom-control-input.is-invalid:checked ~ .custom-control-label::before { + border-color: #e9605c; + background-color: #e9605c; +} + +.was-validated + .custom-control-input:invalid:focus + ~ .custom-control-label::before, +.custom-control-input.is-invalid:focus ~ .custom-control-label::before { + box-shadow: 0 0 0 0.2rem rgba(227, 52, 47, 0.25); +} + +.was-validated + .custom-control-input:invalid:focus:not(:checked) + ~ .custom-control-label::before, +.custom-control-input.is-invalid:focus:not(:checked) + ~ .custom-control-label::before { + border-color: #e3342f; +} + +.was-validated .custom-file-input:invalid ~ .custom-file-label, +.custom-file-input.is-invalid ~ .custom-file-label { + border-color: #e3342f; +} + +.was-validated .custom-file-input:invalid ~ .invalid-feedback, +.was-validated .custom-file-input:invalid ~ .invalid-tooltip, +.custom-file-input.is-invalid ~ .invalid-feedback, +.custom-file-input.is-invalid ~ .invalid-tooltip { + display: block; +} + +.was-validated .custom-file-input:invalid:focus ~ .custom-file-label, +.custom-file-input.is-invalid:focus ~ .custom-file-label { + border-color: #e3342f; + box-shadow: 0 0 0 0.2rem rgba(227, 52, 47, 0.25); +} + +.form-inline { + display: flex; + flex-flow: row wrap; + align-items: center; +} + +.form-inline .form-check { + width: 100%; +} + +@media (min-width: 576px) { + .form-inline label { + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 0; + } + + .form-inline .form-group { + display: flex; + flex: 0 0 auto; + flex-flow: row wrap; + align-items: center; + margin-bottom: 0; + } + + .form-inline .form-control { + display: inline-block; + width: auto; + vertical-align: middle; + } + + .form-inline .form-control-plaintext { + display: inline-block; + } + + .form-inline .input-group, + .form-inline .custom-select { + width: auto; + } + + .form-inline .form-check { + display: flex; + align-items: center; + justify-content: center; + width: auto; + padding-left: 0; + } + + .form-inline .form-check-input { + position: relative; + flex-shrink: 0; + margin-top: 0; + margin-right: 0.25rem; + margin-left: 0; + } + + .form-inline .custom-control { + align-items: center; + justify-content: center; + } + + .form-inline .custom-control-label { + margin-bottom: 0; + } +} + +.btn { + display: inline-block; + font-weight: 400; + color: #212529; + text-align: center; + vertical-align: middle; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + background-color: transparent; + border: 1px solid transparent; + padding: 0.375rem 0.75rem; + font-size: 0.83rem; + line-height: 1.6; + border-radius: 0.25rem; + transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, + border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; +} + +@media (prefers-reduced-motion: reduce) { + .btn { + transition: none; + } +} + +.btn:hover { + color: #212529; + text-decoration: none; +} + +.btn:focus, +.btn.focus { + outline: 0; + box-shadow: 0 0 0 0.2rem rgba(52, 144, 220, 0.25); +} + +.btn.disabled, +.btn:disabled { + opacity: 0.65; +} + +a.btn.disabled, +fieldset:disabled a.btn { + pointer-events: none; +} + +.btn-primary { + color: #fff; + background-color: #3490dc; + border-color: #3490dc; +} + +.btn-primary:hover { + color: #fff; + background-color: #227dc7; + border-color: #2176bd; +} + +.btn-primary:focus, +.btn-primary.focus { + box-shadow: 0 0 0 0.2rem rgba(82, 161, 225, 0.5); +} + +.btn-primary.disabled, +.btn-primary:disabled { + color: #fff; + background-color: #3490dc; + border-color: #3490dc; +} + +.btn-primary:not(:disabled):not(.disabled):active, +.btn-primary:not(:disabled):not(.disabled).active, +.show > .btn-primary.dropdown-toggle { + color: #fff; + background-color: #2176bd; + border-color: #1f6fb2; +} + +.btn-primary:not(:disabled):not(.disabled):active:focus, +.btn-primary:not(:disabled):not(.disabled).active:focus, +.show > .btn-primary.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(82, 161, 225, 0.5); +} + +.btn-secondary { + color: #fff; + background-color: #6c757d; + border-color: #6c757d; +} + +.btn-secondary:hover { + color: #fff; + background-color: #5a6268; + border-color: #545b62; +} + +.btn-secondary:focus, +.btn-secondary.focus { + box-shadow: 0 0 0 0.2rem rgba(130, 138, 145, 0.5); +} + +.btn-secondary.disabled, +.btn-secondary:disabled { + color: #fff; + background-color: #6c757d; + border-color: #6c757d; +} + +.btn-secondary:not(:disabled):not(.disabled):active, +.btn-secondary:not(:disabled):not(.disabled).active, +.show > .btn-secondary.dropdown-toggle { + color: #fff; + background-color: #545b62; + border-color: #4e555b; +} + +.btn-secondary:not(:disabled):not(.disabled):active:focus, +.btn-secondary:not(:disabled):not(.disabled).active:focus, +.show > .btn-secondary.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(130, 138, 145, 0.5); +} + +.btn-success { + color: #fff; + background-color: #38c172; + border-color: #38c172; +} + +.btn-success:hover { + color: #fff; + background-color: #2fa360; + border-color: #2d995b; +} + +.btn-success:focus, +.btn-success.focus { + box-shadow: 0 0 0 0.2rem rgba(86, 202, 135, 0.5); +} + +.btn-success.disabled, +.btn-success:disabled { + color: #fff; + background-color: #38c172; + border-color: #38c172; +} + +.btn-success:not(:disabled):not(.disabled):active, +.btn-success:not(:disabled):not(.disabled).active, +.show > .btn-success.dropdown-toggle { + color: #fff; + background-color: #2d995b; + border-color: #2a9055; +} + +.btn-success:not(:disabled):not(.disabled):active:focus, +.btn-success:not(:disabled):not(.disabled).active:focus, +.show > .btn-success.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(86, 202, 135, 0.5); +} + +.btn-info { + color: #212529; + background-color: #6cb2eb; + border-color: #6cb2eb; +} + +.btn-info:hover { + color: #fff; + background-color: #4aa0e6; + border-color: #3f9ae5; +} + +.btn-info:focus, +.btn-info.focus { + box-shadow: 0 0 0 0.2rem rgba(97, 157, 206, 0.5); +} + +.btn-info.disabled, +.btn-info:disabled { + color: #212529; + background-color: #6cb2eb; + border-color: #6cb2eb; +} + +.btn-info:not(:disabled):not(.disabled):active, +.btn-info:not(:disabled):not(.disabled).active, +.show > .btn-info.dropdown-toggle { + color: #fff; + background-color: #3f9ae5; + border-color: #3495e3; +} + +.btn-info:not(:disabled):not(.disabled):active:focus, +.btn-info:not(:disabled):not(.disabled).active:focus, +.show > .btn-info.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(97, 157, 206, 0.5); +} + +.btn-warning { + color: #212529; + background-color: #ffed4a; + border-color: #ffed4a; +} + +.btn-warning:hover { + color: #212529; + background-color: #ffe924; + border-color: #ffe817; +} + +.btn-warning:focus, +.btn-warning.focus { + box-shadow: 0 0 0 0.2rem rgba(222, 207, 69, 0.5); +} + +.btn-warning.disabled, +.btn-warning:disabled { + color: #212529; + background-color: #ffed4a; + border-color: #ffed4a; +} + +.btn-warning:not(:disabled):not(.disabled):active, +.btn-warning:not(:disabled):not(.disabled).active, +.show > .btn-warning.dropdown-toggle { + color: #212529; + background-color: #ffe817; + border-color: #ffe70a; +} + +.btn-warning:not(:disabled):not(.disabled):active:focus, +.btn-warning:not(:disabled):not(.disabled).active:focus, +.show > .btn-warning.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(222, 207, 69, 0.5); +} + +.btn-danger { + color: #fff; + background-color: #e3342f; + border-color: #e3342f; +} + +.btn-danger:hover { + color: #fff; + background-color: #d0211c; + border-color: #c51f1a; +} + +.btn-danger:focus, +.btn-danger.focus { + box-shadow: 0 0 0 0.2rem rgba(231, 82, 78, 0.5); +} + +.btn-danger.disabled, +.btn-danger:disabled { + color: #fff; + background-color: #e3342f; + border-color: #e3342f; +} + +.btn-danger:not(:disabled):not(.disabled):active, +.btn-danger:not(:disabled):not(.disabled).active, +.show > .btn-danger.dropdown-toggle { + color: #fff; + background-color: #c51f1a; + border-color: #b91d19; +} + +.btn-danger:not(:disabled):not(.disabled):active:focus, +.btn-danger:not(:disabled):not(.disabled).active:focus, +.show > .btn-danger.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(231, 82, 78, 0.5); +} + +.btn-light { + color: #212529; + background-color: #f8f9fa; + border-color: #f8f9fa; +} + +.btn-light:hover { + color: #212529; + background-color: #e2e6ea; + border-color: #dae0e5; +} + +.btn-light:focus, +.btn-light.focus { + box-shadow: 0 0 0 0.2rem rgba(216, 217, 219, 0.5); +} + +.btn-light.disabled, +.btn-light:disabled { + color: #212529; + background-color: #f8f9fa; + border-color: #f8f9fa; +} + +.btn-light:not(:disabled):not(.disabled):active, +.btn-light:not(:disabled):not(.disabled).active, +.show > .btn-light.dropdown-toggle { + color: #212529; + background-color: #dae0e5; + border-color: #d3d9df; +} + +.btn-light:not(:disabled):not(.disabled):active:focus, +.btn-light:not(:disabled):not(.disabled).active:focus, +.show > .btn-light.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(216, 217, 219, 0.5); +} + +.btn-dark { + color: #fff; + background-color: #343a40; + border-color: #343a40; +} + +.btn-dark:hover { + color: #fff; + background-color: #23272b; + border-color: #1d2124; +} + +.btn-dark:focus, +.btn-dark.focus { + box-shadow: 0 0 0 0.2rem rgba(82, 88, 93, 0.5); +} + +.btn-dark.disabled, +.btn-dark:disabled { + color: #fff; + background-color: #343a40; + border-color: #343a40; +} + +.btn-dark:not(:disabled):not(.disabled):active, +.btn-dark:not(:disabled):not(.disabled).active, +.show > .btn-dark.dropdown-toggle { + color: #fff; + background-color: #1d2124; + border-color: #171a1d; +} + +.btn-dark:not(:disabled):not(.disabled):active:focus, +.btn-dark:not(:disabled):not(.disabled).active:focus, +.show > .btn-dark.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(82, 88, 93, 0.5); +} + +.btn-outline-primary { + color: #3490dc; + border-color: #3490dc; +} + +.btn-outline-primary:hover { + color: #fff; + background-color: #3490dc; + border-color: #3490dc; +} + +.btn-outline-primary:focus, +.btn-outline-primary.focus { + box-shadow: 0 0 0 0.2rem rgba(52, 144, 220, 0.5); +} + +.btn-outline-primary.disabled, +.btn-outline-primary:disabled { + color: #3490dc; + background-color: transparent; +} + +.btn-outline-primary:not(:disabled):not(.disabled):active, +.btn-outline-primary:not(:disabled):not(.disabled).active, +.show > .btn-outline-primary.dropdown-toggle { + color: #fff; + background-color: #3490dc; + border-color: #3490dc; +} + +.btn-outline-primary:not(:disabled):not(.disabled):active:focus, +.btn-outline-primary:not(:disabled):not(.disabled).active:focus, +.show > .btn-outline-primary.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(52, 144, 220, 0.5); +} + +.btn-outline-secondary { + color: #6c757d; + border-color: #6c757d; +} + +.btn-outline-secondary:hover { + color: #fff; + background-color: #6c757d; + border-color: #6c757d; +} + +.btn-outline-secondary:focus, +.btn-outline-secondary.focus { + box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.5); +} + +.btn-outline-secondary.disabled, +.btn-outline-secondary:disabled { + color: #6c757d; + background-color: transparent; +} + +.btn-outline-secondary:not(:disabled):not(.disabled):active, +.btn-outline-secondary:not(:disabled):not(.disabled).active, +.show > .btn-outline-secondary.dropdown-toggle { + color: #fff; + background-color: #6c757d; + border-color: #6c757d; +} + +.btn-outline-secondary:not(:disabled):not(.disabled):active:focus, +.btn-outline-secondary:not(:disabled):not(.disabled).active:focus, +.show > .btn-outline-secondary.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.5); +} + +.btn-outline-success { + color: #38c172; + border-color: #38c172; +} + +.btn-outline-success:hover { + color: #fff; + background-color: #38c172; + border-color: #38c172; +} + +.btn-outline-success:focus, +.btn-outline-success.focus { + box-shadow: 0 0 0 0.2rem rgba(56, 193, 114, 0.5); +} + +.btn-outline-success.disabled, +.btn-outline-success:disabled { + color: #38c172; + background-color: transparent; +} + +.btn-outline-success:not(:disabled):not(.disabled):active, +.btn-outline-success:not(:disabled):not(.disabled).active, +.show > .btn-outline-success.dropdown-toggle { + color: #fff; + background-color: #38c172; + border-color: #38c172; +} + +.btn-outline-success:not(:disabled):not(.disabled):active:focus, +.btn-outline-success:not(:disabled):not(.disabled).active:focus, +.show > .btn-outline-success.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(56, 193, 114, 0.5); +} + +.btn-outline-info { + color: #6cb2eb; + border-color: #6cb2eb; +} + +.btn-outline-info:hover { + color: #212529; + background-color: #6cb2eb; + border-color: #6cb2eb; +} + +.btn-outline-info:focus, +.btn-outline-info.focus { + box-shadow: 0 0 0 0.2rem rgba(108, 178, 235, 0.5); +} + +.btn-outline-info.disabled, +.btn-outline-info:disabled { + color: #6cb2eb; + background-color: transparent; +} + +.btn-outline-info:not(:disabled):not(.disabled):active, +.btn-outline-info:not(:disabled):not(.disabled).active, +.show > .btn-outline-info.dropdown-toggle { + color: #212529; + background-color: #6cb2eb; + border-color: #6cb2eb; +} + +.btn-outline-info:not(:disabled):not(.disabled):active:focus, +.btn-outline-info:not(:disabled):not(.disabled).active:focus, +.show > .btn-outline-info.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(108, 178, 235, 0.5); +} + +.btn-outline-warning { + color: #ffed4a; + border-color: #ffed4a; +} + +.btn-outline-warning:hover { + color: #212529; + background-color: #ffed4a; + border-color: #ffed4a; +} + +.btn-outline-warning:focus, +.btn-outline-warning.focus { + box-shadow: 0 0 0 0.2rem rgba(255, 237, 74, 0.5); +} + +.btn-outline-warning.disabled, +.btn-outline-warning:disabled { + color: #ffed4a; + background-color: transparent; +} + +.btn-outline-warning:not(:disabled):not(.disabled):active, +.btn-outline-warning:not(:disabled):not(.disabled).active, +.show > .btn-outline-warning.dropdown-toggle { + color: #212529; + background-color: #ffed4a; + border-color: #ffed4a; +} + +.btn-outline-warning:not(:disabled):not(.disabled):active:focus, +.btn-outline-warning:not(:disabled):not(.disabled).active:focus, +.show > .btn-outline-warning.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(255, 237, 74, 0.5); +} + +.btn-outline-danger { + color: #e3342f; + border-color: #e3342f; +} + +.btn-outline-danger:hover { + color: #fff; + background-color: #e3342f; + border-color: #e3342f; +} + +.btn-outline-danger:focus, +.btn-outline-danger.focus { + box-shadow: 0 0 0 0.2rem rgba(227, 52, 47, 0.5); +} + +.btn-outline-danger.disabled, +.btn-outline-danger:disabled { + color: #e3342f; + background-color: transparent; +} + +.btn-outline-danger:not(:disabled):not(.disabled):active, +.btn-outline-danger:not(:disabled):not(.disabled).active, +.show > .btn-outline-danger.dropdown-toggle { + color: #fff; + background-color: #e3342f; + border-color: #e3342f; +} + +.btn-outline-danger:not(:disabled):not(.disabled):active:focus, +.btn-outline-danger:not(:disabled):not(.disabled).active:focus, +.show > .btn-outline-danger.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(227, 52, 47, 0.5); +} + +.btn-outline-light { + color: #f8f9fa; + border-color: #f8f9fa; +} + +.btn-outline-light:hover { + color: #212529; + background-color: #f8f9fa; + border-color: #f8f9fa; +} + +.btn-outline-light:focus, +.btn-outline-light.focus { + box-shadow: 0 0 0 0.2rem rgba(248, 249, 250, 0.5); +} + +.btn-outline-light.disabled, +.btn-outline-light:disabled { + color: #f8f9fa; + background-color: transparent; +} + +.btn-outline-light:not(:disabled):not(.disabled):active, +.btn-outline-light:not(:disabled):not(.disabled).active, +.show > .btn-outline-light.dropdown-toggle { + color: #212529; + background-color: #f8f9fa; + border-color: #f8f9fa; +} + +.btn-outline-light:not(:disabled):not(.disabled):active:focus, +.btn-outline-light:not(:disabled):not(.disabled).active:focus, +.show > .btn-outline-light.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(248, 249, 250, 0.5); +} + +.btn-outline-dark { + color: #343a40; + border-color: #343a40; +} + +.btn-outline-dark:hover { + color: #fff; + background-color: #343a40; + border-color: #343a40; +} + +.btn-outline-dark:focus, +.btn-outline-dark.focus { + box-shadow: 0 0 0 0.2rem rgba(52, 58, 64, 0.5); +} + +.btn-outline-dark.disabled, +.btn-outline-dark:disabled { + color: #343a40; + background-color: transparent; +} + +.btn-outline-dark:not(:disabled):not(.disabled):active, +.btn-outline-dark:not(:disabled):not(.disabled).active, +.show > .btn-outline-dark.dropdown-toggle { + color: #fff; + background-color: #343a40; + border-color: #343a40; +} + +.btn-outline-dark:not(:disabled):not(.disabled):active:focus, +.btn-outline-dark:not(:disabled):not(.disabled).active:focus, +.show > .btn-outline-dark.dropdown-toggle:focus { + box-shadow: 0 0 0 0.2rem rgba(52, 58, 64, 0.5); +} + +.btn-link { + font-weight: 400; + color: #3490dc; + text-decoration: none; +} + +.btn-link:hover { + color: #1d68a7; + text-decoration: underline; +} + +.btn-link:focus, +.btn-link.focus { + text-decoration: underline; + box-shadow: none; +} + +.btn-link:disabled, +.btn-link.disabled { + color: #6c757d; + pointer-events: none; +} + +.btn-lg, +.btn-group-lg > .btn { + padding: 0.5rem 1rem; + font-size: 1.0375rem; + line-height: 1.5; + border-radius: 0.3rem; +} + +.btn-sm, +.btn-group-sm > .btn { + padding: 0.25rem 0.5rem; + font-size: 0.72625rem; + line-height: 1.5; + border-radius: 0.2rem; +} + +.btn-block { + display: block; + width: 100%; +} + +.btn-block + .btn-block { + margin-top: 0.5rem; +} + +input[type="submit"].btn-block, +input[type="reset"].btn-block, +input[type="button"].btn-block { + width: 100%; +} + +.fade { + transition: opacity 0.15s linear; +} + +@media (prefers-reduced-motion: reduce) { + .fade { + transition: none; + } +} + +.fade:not(.show) { + opacity: 0; +} + +.collapse:not(.show) { + display: none; +} + +.collapsing { + position: relative; + height: 0; + overflow: hidden; + transition: height 0.35s ease; +} + +@media (prefers-reduced-motion: reduce) { + .collapsing { + transition: none; + } +} + +.dropup, +.dropright, +.dropdown, +.dropleft { + position: relative; +} + +.dropdown-toggle { + white-space: nowrap; +} + +.dropdown-toggle::after { + display: inline-block; + margin-left: 0.255em; + vertical-align: 0.255em; + content: ""; + border-top: 0.3em solid; + border-right: 0.3em solid transparent; + border-bottom: 0; + border-left: 0.3em solid transparent; +} + +.dropdown-toggle:empty::after { + margin-left: 0; +} + +.dropdown-menu { + position: absolute; + top: 100%; + left: 0; + z-index: 1000; + display: none; + float: left; + min-width: 10rem; + padding: 0.5rem 0; + margin: 0.125rem 0 0; + font-size: 0.83rem; + color: #212529; + text-align: left; + list-style: none; + background-color: #fff; + background-clip: padding-box; + border: 1px solid rgba(0, 0, 0, 0.15); + border-radius: 0.25rem; +} + +.dropdown-menu-left { + right: auto; + left: 0; +} + +.dropdown-menu-right { + right: 0; + left: auto; +} + +@media (min-width: 576px) { + .dropdown-menu-sm-left { + right: auto; + left: 0; + } + + .dropdown-menu-sm-right { + right: 0; + left: auto; + } +} + +@media (min-width: 768px) { + .dropdown-menu-md-left { + right: auto; + left: 0; + } + + .dropdown-menu-md-right { + right: 0; + left: auto; + } +} + +@media (min-width: 992px) { + .dropdown-menu-lg-left { + right: auto; + left: 0; + } + + .dropdown-menu-lg-right { + right: 0; + left: auto; + } +} + +@media (min-width: 1200px) { + .dropdown-menu-xl-left { + right: auto; + left: 0; + } + + .dropdown-menu-xl-right { + right: 0; + left: auto; + } +} + +.dropup .dropdown-menu { + top: auto; + bottom: 100%; + margin-top: 0; + margin-bottom: 0.125rem; +} + +.dropup .dropdown-toggle::after { + display: inline-block; + margin-left: 0.255em; + vertical-align: 0.255em; + content: ""; + border-top: 0; + border-right: 0.3em solid transparent; + border-bottom: 0.3em solid; + border-left: 0.3em solid transparent; +} + +.dropup .dropdown-toggle:empty::after { + margin-left: 0; +} + +.dropright .dropdown-menu { + top: 0; + right: auto; + left: 100%; + margin-top: 0; + margin-left: 0.125rem; +} + +.dropright .dropdown-toggle::after { + display: inline-block; + margin-left: 0.255em; + vertical-align: 0.255em; + content: ""; + border-top: 0.3em solid transparent; + border-right: 0; + border-bottom: 0.3em solid transparent; + border-left: 0.3em solid; +} + +.dropright .dropdown-toggle:empty::after { + margin-left: 0; +} + +.dropright .dropdown-toggle::after { + vertical-align: 0; +} + +.dropleft .dropdown-menu { + top: 0; + right: 100%; + left: auto; + margin-top: 0; + margin-right: 0.125rem; +} + +.dropleft .dropdown-toggle::after { + display: inline-block; + margin-left: 0.255em; + vertical-align: 0.255em; + content: ""; +} + +.dropleft .dropdown-toggle::after { + display: none; +} + +.dropleft .dropdown-toggle::before { + display: inline-block; + margin-right: 0.255em; + vertical-align: 0.255em; + content: ""; + border-top: 0.3em solid transparent; + border-right: 0.3em solid; + border-bottom: 0.3em solid transparent; +} + +.dropleft .dropdown-toggle:empty::after { + margin-left: 0; +} + +.dropleft .dropdown-toggle::before { + vertical-align: 0; +} + +.dropdown-menu[x-placement^="top"], +.dropdown-menu[x-placement^="right"], +.dropdown-menu[x-placement^="bottom"], +.dropdown-menu[x-placement^="left"] { + right: auto; + bottom: auto; +} + +.dropdown-divider { + height: 0; + margin: 0.5rem 0; + overflow: hidden; + border-top: 1px solid #e9ecef; +} + +.dropdown-item { + display: block; + width: 100%; + padding: 0.25rem 1.5rem; + clear: both; + font-weight: 400; + color: #212529; + text-align: inherit; + white-space: nowrap; + background-color: transparent; + border: 0; +} + +.dropdown-item:hover, +.dropdown-item:focus { + color: #16181b; + text-decoration: none; + background-color: #f8f9fa; +} + +.dropdown-item.active, +.dropdown-item:active { + color: #fff; + text-decoration: none; + background-color: #3490dc; +} + +.dropdown-item.disabled, +.dropdown-item:disabled { + color: #6c757d; + pointer-events: none; + background-color: transparent; +} + +.dropdown-menu.show { + display: block; +} + +.dropdown-header { + display: block; + padding: 0.5rem 1.5rem; + margin-bottom: 0; + font-size: 0.72625rem; + color: #6c757d; + white-space: nowrap; +} + +.dropdown-item-text { + display: block; + padding: 0.25rem 1.5rem; + color: #212529; +} + +.btn-group, +.btn-group-vertical { + position: relative; + display: inline-flex; + vertical-align: middle; +} + +.btn-group > .btn, +.btn-group-vertical > .btn { + position: relative; + flex: 1 1 auto; +} + +.btn-group > .btn:hover, +.btn-group-vertical > .btn:hover { + z-index: 1; +} + +.btn-group > .btn:focus, +.btn-group > .btn:active, +.btn-group > .btn.active, +.btn-group-vertical > .btn:focus, +.btn-group-vertical > .btn:active, +.btn-group-vertical > .btn.active { + z-index: 1; +} + +.btn-toolbar { + display: flex; + flex-wrap: wrap; + justify-content: flex-start; +} + +.btn-toolbar .input-group { + width: auto; +} + +.btn-group > .btn:not(:first-child), +.btn-group > .btn-group:not(:first-child) { + margin-left: -1px; +} + +.btn-group > .btn:not(:last-child):not(.dropdown-toggle), +.btn-group > .btn-group:not(:last-child) > .btn { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.btn-group > .btn:not(:first-child), +.btn-group > .btn-group:not(:first-child) > .btn { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.dropdown-toggle-split { + padding-right: 0.5625rem; + padding-left: 0.5625rem; +} + +.dropdown-toggle-split::after, +.dropup .dropdown-toggle-split::after, +.dropright .dropdown-toggle-split::after { + margin-left: 0; +} + +.dropleft .dropdown-toggle-split::before { + margin-right: 0; +} + +.btn-sm + .dropdown-toggle-split, +.btn-group-sm > .btn + .dropdown-toggle-split { + padding-right: 0.375rem; + padding-left: 0.375rem; +} + +.btn-lg + .dropdown-toggle-split, +.btn-group-lg > .btn + .dropdown-toggle-split { + padding-right: 0.75rem; + padding-left: 0.75rem; +} + +.btn-group-vertical { + flex-direction: column; + align-items: flex-start; + justify-content: center; +} + +.btn-group-vertical > .btn, +.btn-group-vertical > .btn-group { + width: 100%; +} + +.btn-group-vertical > .btn:not(:first-child), +.btn-group-vertical > .btn-group:not(:first-child) { + margin-top: -1px; +} + +.btn-group-vertical > .btn:not(:last-child):not(.dropdown-toggle), +.btn-group-vertical > .btn-group:not(:last-child) > .btn { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} + +.btn-group-vertical > .btn:not(:first-child), +.btn-group-vertical > .btn-group:not(:first-child) > .btn { + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.btn-group-toggle > .btn, +.btn-group-toggle > .btn-group > .btn { + margin-bottom: 0; +} + +.btn-group-toggle > .btn input[type="radio"], +.btn-group-toggle > .btn input[type="checkbox"], +.btn-group-toggle > .btn-group > .btn input[type="radio"], +.btn-group-toggle > .btn-group > .btn input[type="checkbox"] { + position: absolute; + clip: rect(0, 0, 0, 0); + pointer-events: none; +} + +.input-group { + position: relative; + display: flex; + flex-wrap: wrap; + align-items: stretch; + width: 100%; +} + +.input-group > .form-control, +.input-group > .form-control-plaintext, +.input-group > .custom-select, +.input-group > .custom-file { + position: relative; + flex: 1 1 auto; + width: 1%; + margin-bottom: 0; +} + +.input-group > .form-control + .form-control, +.input-group > .form-control + .custom-select, +.input-group > .form-control + .custom-file, +.input-group > .form-control-plaintext + .form-control, +.input-group > .form-control-plaintext + .custom-select, +.input-group > .form-control-plaintext + .custom-file, +.input-group > .custom-select + .form-control, +.input-group > .custom-select + .custom-select, +.input-group > .custom-select + .custom-file, +.input-group > .custom-file + .form-control, +.input-group > .custom-file + .custom-select, +.input-group > .custom-file + .custom-file { + margin-left: -1px; +} + +.input-group > .form-control:focus, +.input-group > .custom-select:focus, +.input-group > .custom-file .custom-file-input:focus ~ .custom-file-label { + z-index: 3; +} + +.input-group > .custom-file .custom-file-input:focus { + z-index: 4; +} + +.input-group > .form-control:not(:last-child), +.input-group > .custom-select:not(:last-child) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.input-group > .form-control:not(:first-child), +.input-group > .custom-select:not(:first-child) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.input-group > .custom-file { + display: flex; + align-items: center; +} + +.input-group > .custom-file:not(:last-child) .custom-file-label, +.input-group > .custom-file:not(:last-child) .custom-file-label::after { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.input-group > .custom-file:not(:first-child) .custom-file-label { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.input-group-prepend, +.input-group-append { + display: flex; +} + +.input-group-prepend .btn, +.input-group-append .btn { + position: relative; + z-index: 2; +} + +.input-group-prepend .btn:focus, +.input-group-append .btn:focus { + z-index: 3; +} + +.input-group-prepend .btn + .btn, +.input-group-prepend .btn + .input-group-text, +.input-group-prepend .input-group-text + .input-group-text, +.input-group-prepend .input-group-text + .btn, +.input-group-append .btn + .btn, +.input-group-append .btn + .input-group-text, +.input-group-append .input-group-text + .input-group-text, +.input-group-append .input-group-text + .btn { + margin-left: -1px; +} + +.input-group-prepend { + margin-right: -1px; +} + +.input-group-append { + margin-left: -1px; +} + +.input-group-text { + display: flex; + align-items: center; + padding: 0.375rem 0.75rem; + margin-bottom: 0; + font-size: 0.83rem; + font-weight: 400; + line-height: 1.6; + color: #495057; + text-align: center; + white-space: nowrap; + background-color: #e9ecef; + border: 1px solid #ced4da; + border-radius: 0.25rem; +} + +.input-group-text input[type="radio"], +.input-group-text input[type="checkbox"] { + margin-top: 0; +} + +.input-group-lg > .form-control:not(textarea), +.input-group-lg > .custom-select { + height: calc(1.5em + 1rem + 2px); +} + +.input-group-lg > .form-control, +.input-group-lg > .custom-select, +.input-group-lg > .input-group-prepend > .input-group-text, +.input-group-lg > .input-group-append > .input-group-text, +.input-group-lg > .input-group-prepend > .btn, +.input-group-lg > .input-group-append > .btn { + padding: 0.5rem 1rem; + font-size: 1.0375rem; + line-height: 1.5; + border-radius: 0.3rem; +} + +.input-group-sm > .form-control:not(textarea), +.input-group-sm > .custom-select { + height: calc(1.5em + 0.5rem + 2px); +} + +.input-group-sm > .form-control, +.input-group-sm > .custom-select, +.input-group-sm > .input-group-prepend > .input-group-text, +.input-group-sm > .input-group-append > .input-group-text, +.input-group-sm > .input-group-prepend > .btn, +.input-group-sm > .input-group-append > .btn { + padding: 0.25rem 0.5rem; + font-size: 0.72625rem; + line-height: 1.5; + border-radius: 0.2rem; +} + +.input-group-lg > .custom-select, +.input-group-sm > .custom-select { + padding-right: 1.75rem; +} + +.input-group > .input-group-prepend > .btn, +.input-group > .input-group-prepend > .input-group-text, +.input-group > .input-group-append:not(:last-child) > .btn, +.input-group > .input-group-append:not(:last-child) > .input-group-text, +.input-group + > .input-group-append:last-child + > .btn:not(:last-child):not(.dropdown-toggle), +.input-group + > .input-group-append:last-child + > .input-group-text:not(:last-child) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.input-group > .input-group-append > .btn, +.input-group > .input-group-append > .input-group-text, +.input-group > .input-group-prepend:not(:first-child) > .btn, +.input-group > .input-group-prepend:not(:first-child) > .input-group-text, +.input-group > .input-group-prepend:first-child > .btn:not(:first-child), +.input-group + > .input-group-prepend:first-child + > .input-group-text:not(:first-child) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.custom-control { + position: relative; + display: block; + min-height: 1.328rem; + padding-left: 1.5rem; +} + +.custom-control-inline { + display: inline-flex; + margin-right: 1rem; +} + +.custom-control-input { + position: absolute; + z-index: -1; + opacity: 0; +} + +.custom-control-input:checked ~ .custom-control-label::before { + color: #fff; + border-color: #3490dc; + background-color: #3490dc; +} + +.custom-control-input:focus ~ .custom-control-label::before { + box-shadow: 0 0 0 0.2rem rgba(52, 144, 220, 0.25); +} + +.custom-control-input:focus:not(:checked) ~ .custom-control-label::before { + border-color: #a1cbef; +} + +.custom-control-input:not(:disabled):active ~ .custom-control-label::before { + color: #fff; + background-color: #cce3f6; + border-color: #cce3f6; +} + +.custom-control-input:disabled ~ .custom-control-label { + color: #6c757d; +} + +.custom-control-input:disabled ~ .custom-control-label::before { + background-color: #e9ecef; +} + +.custom-control-label { + position: relative; + margin-bottom: 0; + vertical-align: top; +} + +.custom-control-label::before { + position: absolute; + top: 0.164rem; + left: -1.5rem; + display: block; + width: 1rem; + height: 1rem; + pointer-events: none; + content: ""; + background-color: #fff; + border: #adb5bd solid 1px; +} + +.custom-control-label::after { + position: absolute; + top: 0.164rem; + left: -1.5rem; + display: block; + width: 1rem; + height: 1rem; + content: ""; + background: no-repeat 50%/50% 50%; +} + +.custom-checkbox .custom-control-label::before { + border-radius: 0.25rem; +} + +.custom-checkbox .custom-control-input:checked ~ .custom-control-label::after { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23fff' d='M6.564.75l-3.59 3.612-1.538-1.55L0 4.26 2.974 7.25 8 2.193z'/%3e%3c/svg%3e"); +} + +.custom-checkbox + .custom-control-input:indeterminate + ~ .custom-control-label::before { + border-color: #3490dc; + background-color: #3490dc; +} + +.custom-checkbox + .custom-control-input:indeterminate + ~ .custom-control-label::after { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 4'%3e%3cpath stroke='%23fff' d='M0 2h4'/%3e%3c/svg%3e"); +} + +.custom-checkbox + .custom-control-input:disabled:checked + ~ .custom-control-label::before { + background-color: rgba(52, 144, 220, 0.5); +} + +.custom-checkbox + .custom-control-input:disabled:indeterminate + ~ .custom-control-label::before { + background-color: rgba(52, 144, 220, 0.5); +} + +.custom-radio .custom-control-label::before { + border-radius: 50%; +} + +.custom-radio .custom-control-input:checked ~ .custom-control-label::after { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e"); +} + +.custom-radio + .custom-control-input:disabled:checked + ~ .custom-control-label::before { + background-color: rgba(52, 144, 220, 0.5); +} + +.custom-switch { + padding-left: 2.25rem; +} + +.custom-switch .custom-control-label::before { + left: -2.25rem; + width: 1.75rem; + pointer-events: all; + border-radius: 0.5rem; +} + +.custom-switch .custom-control-label::after { + top: calc(0.164rem + 2px); + left: calc(-2.25rem + 2px); + width: calc(1rem - 4px); + height: calc(1rem - 4px); + background-color: #adb5bd; + border-radius: 0.5rem; + transition: transform 0.15s ease-in-out, background-color 0.15s ease-in-out, + border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; +} + +@media (prefers-reduced-motion: reduce) { + .custom-switch .custom-control-label::after { + transition: none; + } +} + +.custom-switch .custom-control-input:checked ~ .custom-control-label::after { + background-color: #fff; + transform: translateX(0.75rem); +} + +.custom-switch + .custom-control-input:disabled:checked + ~ .custom-control-label::before { + background-color: rgba(52, 144, 220, 0.5); +} + +.custom-select { + display: inline-block; + width: 100%; + height: calc(1.6em + 0.75rem + 2px); + padding: 0.375rem 1.75rem 0.375rem 0.75rem; + font-size: 0.83rem; + font-weight: 400; + line-height: 1.6; + color: #495057; + vertical-align: middle; + background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") + no-repeat right 0.75rem center/8px 10px; + background-color: #fff; + border: 1px solid #ced4da; + border-radius: 0.25rem; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; +} + +.custom-select:focus { + border-color: #a1cbef; + outline: 0; + box-shadow: 0 0 0 0.2rem rgba(52, 144, 220, 0.25); +} + +.custom-select:focus::-ms-value { + color: #495057; + background-color: #fff; +} + +.custom-select[multiple], +.custom-select[size]:not([size="1"]) { + height: auto; + padding-right: 0.75rem; + background-image: none; +} + +.custom-select:disabled { + color: #6c757d; + background-color: #e9ecef; +} + +.custom-select::-ms-expand { + display: none; +} + +.custom-select-sm { + height: calc(1.5em + 0.5rem + 2px); + padding-top: 0.25rem; + padding-bottom: 0.25rem; + padding-left: 0.5rem; + font-size: 0.72625rem; +} + +.custom-select-lg { + height: calc(1.5em + 1rem + 2px); + padding-top: 0.5rem; + padding-bottom: 0.5rem; + padding-left: 1rem; + font-size: 1.0375rem; +} + +.custom-file { + position: relative; + display: inline-block; + width: 100%; + height: calc(1.6em + 0.75rem + 2px); + margin-bottom: 0; +} + +.custom-file-input { + position: relative; + z-index: 2; + width: 100%; + height: calc(1.6em + 0.75rem + 2px); + margin: 0; + opacity: 0; +} + +.custom-file-input:focus ~ .custom-file-label { + border-color: #a1cbef; + box-shadow: 0 0 0 0.2rem rgba(52, 144, 220, 0.25); +} + +.custom-file-input:disabled ~ .custom-file-label { + background-color: #e9ecef; +} + +.custom-file-input:lang(en) ~ .custom-file-label::after { + content: "Browse"; +} + +.custom-file-input ~ .custom-file-label[data-browse]::after { + content: attr(data-browse); +} + +.custom-file-label { + position: absolute; + top: 0; + right: 0; + left: 0; + z-index: 1; + height: calc(1.6em + 0.75rem + 2px); + padding: 0.375rem 0.75rem; + font-weight: 400; + line-height: 1.6; + color: #495057; + background-color: #fff; + border: 1px solid #ced4da; + border-radius: 0.25rem; +} + +.custom-file-label::after { + position: absolute; + top: 0; + right: 0; + bottom: 0; + z-index: 3; + display: block; + height: calc(1.6em + 0.75rem); + padding: 0.375rem 0.75rem; + line-height: 1.6; + color: #495057; + content: "Browse"; + background-color: #e9ecef; + border-left: inherit; + border-radius: 0 0.25rem 0.25rem 0; +} + +.custom-range { + width: 100%; + height: calc(1rem + 0.4rem); + padding: 0; + background-color: transparent; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; +} + +.custom-range:focus { + outline: none; +} + +.custom-range:focus::-webkit-slider-thumb { + box-shadow: 0 0 0 1px #f8fafc, 0 0 0 0.2rem rgba(52, 144, 220, 0.25); +} + +.custom-range:focus::-moz-range-thumb { + box-shadow: 0 0 0 1px #f8fafc, 0 0 0 0.2rem rgba(52, 144, 220, 0.25); +} + +.custom-range:focus::-ms-thumb { + box-shadow: 0 0 0 1px #f8fafc, 0 0 0 0.2rem rgba(52, 144, 220, 0.25); +} + +.custom-range::-moz-focus-outer { + border: 0; +} + +.custom-range::-webkit-slider-thumb { + width: 1rem; + height: 1rem; + margin-top: -0.25rem; + background-color: #3490dc; + border: 0; + border-radius: 1rem; + -webkit-transition: background-color 0.15s ease-in-out, + border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, + box-shadow 0.15s ease-in-out; + -webkit-appearance: none; + appearance: none; +} + +@media (prefers-reduced-motion: reduce) { + .custom-range::-webkit-slider-thumb { + -webkit-transition: none; + transition: none; + } +} + +.custom-range::-webkit-slider-thumb:active { + background-color: #cce3f6; +} + +.custom-range::-webkit-slider-runnable-track { + width: 100%; + height: 0.5rem; + color: transparent; + cursor: pointer; + background-color: #dee2e6; + border-color: transparent; + border-radius: 1rem; +} + +.custom-range::-moz-range-thumb { + width: 1rem; + height: 1rem; + background-color: #3490dc; + border: 0; + border-radius: 1rem; + -moz-transition: background-color 0.15s ease-in-out, + border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, + box-shadow 0.15s ease-in-out; + -moz-appearance: none; + appearance: none; +} + +@media (prefers-reduced-motion: reduce) { + .custom-range::-moz-range-thumb { + -moz-transition: none; + transition: none; + } +} + +.custom-range::-moz-range-thumb:active { + background-color: #cce3f6; +} + +.custom-range::-moz-range-track { + width: 100%; + height: 0.5rem; + color: transparent; + cursor: pointer; + background-color: #dee2e6; + border-color: transparent; + border-radius: 1rem; +} + +.custom-range::-ms-thumb { + width: 1rem; + height: 1rem; + margin-top: 0; + margin-right: 0.2rem; + margin-left: 0.2rem; + background-color: #3490dc; + border: 0; + border-radius: 1rem; + -ms-transition: background-color 0.15s ease-in-out, + border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, + box-shadow 0.15s ease-in-out; + appearance: none; +} + +@media (prefers-reduced-motion: reduce) { + .custom-range::-ms-thumb { + -ms-transition: none; + transition: none; + } +} + +.custom-range::-ms-thumb:active { + background-color: #cce3f6; +} + +.custom-range::-ms-track { + width: 100%; + height: 0.5rem; + color: transparent; + cursor: pointer; + background-color: transparent; + border-color: transparent; + border-width: 0.5rem; +} + +.custom-range::-ms-fill-lower { + background-color: #dee2e6; + border-radius: 1rem; +} + +.custom-range::-ms-fill-upper { + margin-right: 15px; + background-color: #dee2e6; + border-radius: 1rem; +} + +.custom-range:disabled::-webkit-slider-thumb { + background-color: #adb5bd; +} + +.custom-range:disabled::-webkit-slider-runnable-track { + cursor: default; +} + +.custom-range:disabled::-moz-range-thumb { + background-color: #adb5bd; +} + +.custom-range:disabled::-moz-range-track { + cursor: default; +} + +.custom-range:disabled::-ms-thumb { + background-color: #adb5bd; +} + +.custom-control-label::before, +.custom-file-label, +.custom-select { + transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, + box-shadow 0.15s ease-in-out; +} + +@media (prefers-reduced-motion: reduce) { + .custom-control-label::before, + .custom-file-label, + .custom-select { + transition: none; + } +} + +.nav { + display: flex; + flex-wrap: wrap; + padding-left: 0; + margin-bottom: 0; + list-style: none; +} + +.nav-link { + display: block; + padding: 0.5rem 1rem; +} + +.nav-link:hover, +.nav-link:focus { + text-decoration: none; +} + +.nav-link.disabled { + color: #6c757d; + pointer-events: none; + cursor: default; +} + +.nav-tabs { + border-bottom: 1px solid #dee2e6; +} + +.nav-tabs .nav-item { + margin-bottom: -1px; +} + +.nav-tabs .nav-link { + border: 1px solid transparent; + border-top-left-radius: 0.25rem; + border-top-right-radius: 0.25rem; +} + +.nav-tabs .nav-link:hover, +.nav-tabs .nav-link:focus { + border-color: #e9ecef #e9ecef #dee2e6; +} + +.nav-tabs .nav-link.disabled { + color: #6c757d; + background-color: transparent; + border-color: transparent; +} + +.nav-tabs .nav-link.active, +.nav-tabs .nav-item.show .nav-link { + color: #495057; + background-color: #f8fafc; + border-color: #dee2e6 #dee2e6 #f8fafc; +} + +.nav-tabs .dropdown-menu { + margin-top: -1px; + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.nav-pills .nav-link { + border-radius: 0.25rem; +} + +.nav-pills .nav-link.active, +.nav-pills .show > .nav-link { + color: #fff; + background-color: #3490dc; +} + +.nav-fill .nav-item { + flex: 1 1 auto; + text-align: center; +} + +.nav-justified .nav-item { + flex-basis: 0; + flex-grow: 1; + text-align: center; +} + +.tab-content > .tab-pane { + display: none; +} + +.tab-content > .active { + display: block; +} + +.navbar { + position: relative; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + padding: 0.5rem 1rem; +} + +.navbar > .container, +.navbar > .container-fluid { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; +} + +.navbar-brand { + display: inline-block; + padding-top: 0.334rem; + padding-bottom: 0.334rem; + margin-right: 1rem; + font-size: 1.0375rem; + line-height: inherit; + white-space: nowrap; +} + +.navbar-brand:hover, +.navbar-brand:focus { + text-decoration: none; +} + +.navbar-nav { + display: flex; + flex-direction: column; + padding-left: 0; + margin-bottom: 0; + list-style: none; +} + +.navbar-nav .nav-link { + padding-right: 0; + padding-left: 0; +} + +.navbar-nav .dropdown-menu { + position: static; + float: none; +} + +.navbar-text { + display: inline-block; + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +.navbar-collapse { + flex-basis: 100%; + flex-grow: 1; + align-items: center; +} + +.navbar-toggler { + padding: 0.25rem 0.75rem; + font-size: 1.0375rem; + line-height: 1; + background-color: transparent; + border: 1px solid transparent; + border-radius: 0.25rem; +} + +.navbar-toggler:hover, +.navbar-toggler:focus { + text-decoration: none; +} + +.navbar-toggler-icon { + display: inline-block; + width: 1.5em; + height: 1.5em; + vertical-align: middle; + content: ""; + background: no-repeat center center; + background-size: 100% 100%; +} + +@media (max-width: 575.98px) { + .navbar-expand-sm > .container, + .navbar-expand-sm > .container-fluid { + padding-right: 0; + padding-left: 0; + } +} + +@media (min-width: 576px) { + .navbar-expand-sm { + flex-flow: row nowrap; + justify-content: flex-start; + } + + .navbar-expand-sm .navbar-nav { + flex-direction: row; + } + + .navbar-expand-sm .navbar-nav .dropdown-menu { + position: absolute; + } + + .navbar-expand-sm .navbar-nav .nav-link { + padding-right: 0.5rem; + padding-left: 0.5rem; + } + + .navbar-expand-sm > .container, + .navbar-expand-sm > .container-fluid { + flex-wrap: nowrap; + } + + .navbar-expand-sm .navbar-collapse { + display: flex !important; + flex-basis: auto; + } + + .navbar-expand-sm .navbar-toggler { + display: none; + } +} + +@media (max-width: 767.98px) { + .navbar-expand-md > .container, + .navbar-expand-md > .container-fluid { + padding-right: 0; + padding-left: 0; + } +} + +@media (min-width: 768px) { + .navbar-expand-md { + flex-flow: row nowrap; + justify-content: flex-start; + } + + .navbar-expand-md .navbar-nav { + flex-direction: row; + } + + .navbar-expand-md .navbar-nav .dropdown-menu { + position: absolute; + } + + .navbar-expand-md .navbar-nav .nav-link { + padding-right: 0.5rem; + padding-left: 0.5rem; + } + + .navbar-expand-md > .container, + .navbar-expand-md > .container-fluid { + flex-wrap: nowrap; + } + + .navbar-expand-md .navbar-collapse { + display: flex !important; + flex-basis: auto; + } + + .navbar-expand-md .navbar-toggler { + display: none; + } +} + +@media (max-width: 991.98px) { + .navbar-expand-lg > .container, + .navbar-expand-lg > .container-fluid { + padding-right: 0; + padding-left: 0; + } +} + +@media (min-width: 992px) { + .navbar-expand-lg { + flex-flow: row nowrap; + justify-content: flex-start; + } + + .navbar-expand-lg .navbar-nav { + flex-direction: row; + } + + .navbar-expand-lg .navbar-nav .dropdown-menu { + position: absolute; + } + + .navbar-expand-lg .navbar-nav .nav-link { + padding-right: 0.5rem; + padding-left: 0.5rem; + } + + .navbar-expand-lg > .container, + .navbar-expand-lg > .container-fluid { + flex-wrap: nowrap; + } + + .navbar-expand-lg .navbar-collapse { + display: flex !important; + flex-basis: auto; + } + + .navbar-expand-lg .navbar-toggler { + display: none; + } +} + +@media (max-width: 1199.98px) { + .navbar-expand-xl > .container, + .navbar-expand-xl > .container-fluid { + padding-right: 0; + padding-left: 0; + } +} + +@media (min-width: 1200px) { + .navbar-expand-xl { + flex-flow: row nowrap; + justify-content: flex-start; + } + + .navbar-expand-xl .navbar-nav { + flex-direction: row; + } + + .navbar-expand-xl .navbar-nav .dropdown-menu { + position: absolute; + } + + .navbar-expand-xl .navbar-nav .nav-link { + padding-right: 0.5rem; + padding-left: 0.5rem; + } + + .navbar-expand-xl > .container, + .navbar-expand-xl > .container-fluid { + flex-wrap: nowrap; + } + + .navbar-expand-xl .navbar-collapse { + display: flex !important; + flex-basis: auto; + } + + .navbar-expand-xl .navbar-toggler { + display: none; + } +} + +.navbar-expand { + flex-flow: row nowrap; + justify-content: flex-start; +} + +.navbar-expand > .container, +.navbar-expand > .container-fluid { + padding-right: 0; + padding-left: 0; +} + +.navbar-expand .navbar-nav { + flex-direction: row; +} + +.navbar-expand .navbar-nav .dropdown-menu { + position: absolute; +} + +.navbar-expand .navbar-nav .nav-link { + padding-right: 0.5rem; + padding-left: 0.5rem; +} + +.navbar-expand > .container, +.navbar-expand > .container-fluid { + flex-wrap: nowrap; +} + +.navbar-expand .navbar-collapse { + display: flex !important; + flex-basis: auto; +} + +.navbar-expand .navbar-toggler { + display: none; +} + +.navbar-light .navbar-brand { + color: rgba(0, 0, 0, 0.9); +} + +.navbar-light .navbar-brand:hover, +.navbar-light .navbar-brand:focus { + color: rgba(0, 0, 0, 0.9); +} + +.navbar-light .navbar-nav .nav-link { + color: rgba(0, 0, 0, 0.5); +} + +.navbar-light .navbar-nav .nav-link:hover, +.navbar-light .navbar-nav .nav-link:focus { + color: rgba(0, 0, 0, 0.7); +} + +.navbar-light .navbar-nav .nav-link.disabled { + color: rgba(0, 0, 0, 0.3); +} + +.navbar-light .navbar-nav .show > .nav-link, +.navbar-light .navbar-nav .active > .nav-link, +.navbar-light .navbar-nav .nav-link.show, +.navbar-light .navbar-nav .nav-link.active { + color: rgba(0, 0, 0, 0.9); +} + +.navbar-light .navbar-toggler { + color: rgba(0, 0, 0, 0.5); + border-color: rgba(0, 0, 0, 0.1); +} + +.navbar-light .navbar-toggler-icon { + background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3e%3cpath stroke='rgba(0, 0, 0, 0.5)' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e"); +} + +.navbar-light .navbar-text { + color: rgba(0, 0, 0, 0.5); +} + +.navbar-light .navbar-text a { + color: rgba(0, 0, 0, 0.9); +} + +.navbar-light .navbar-text a:hover, +.navbar-light .navbar-text a:focus { + color: rgba(0, 0, 0, 0.9); +} + +.navbar-dark .navbar-brand { + color: #fff; +} + +.navbar-dark .navbar-brand:hover, +.navbar-dark .navbar-brand:focus { + color: #fff; +} + +.navbar-dark .navbar-nav .nav-link { + color: rgba(255, 255, 255, 0.5); +} + +.navbar-dark .navbar-nav .nav-link:hover, +.navbar-dark .navbar-nav .nav-link:focus { + color: rgba(255, 255, 255, 0.75); +} + +.navbar-dark .navbar-nav .nav-link.disabled { + color: rgba(255, 255, 255, 0.25); +} + +.navbar-dark .navbar-nav .show > .nav-link, +.navbar-dark .navbar-nav .active > .nav-link, +.navbar-dark .navbar-nav .nav-link.show, +.navbar-dark .navbar-nav .nav-link.active { + color: #fff; +} + +.navbar-dark .navbar-toggler { + color: rgba(255, 255, 255, 0.5); + border-color: rgba(255, 255, 255, 0.1); +} + +.navbar-dark .navbar-toggler-icon { + background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3e%3cpath stroke='rgba(255, 255, 255, 0.5)' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e"); +} + +.navbar-dark .navbar-text { + color: rgba(255, 255, 255, 0.5); +} + +.navbar-dark .navbar-text a { + color: #fff; +} + +.navbar-dark .navbar-text a:hover, +.navbar-dark .navbar-text a:focus { + color: #fff; +} + +.card { + position: relative; + display: flex; + flex-direction: column; + min-width: 0; + word-wrap: break-word; + background-color: #fff; + background-clip: border-box; + border: 1px solid rgba(0, 0, 0, 0.125); + border-radius: 0.25rem; +} + +.card > hr { + margin-right: 0; + margin-left: 0; +} + +.card > .list-group:first-child .list-group-item:first-child { + border-top-left-radius: 0.25rem; + border-top-right-radius: 0.25rem; +} + +.card > .list-group:last-child .list-group-item:last-child { + border-bottom-right-radius: 0.25rem; + border-bottom-left-radius: 0.25rem; +} + +.card-body { + flex: 1 1 auto; + padding: 1.25rem; +} + +.card-title { + margin-bottom: 0.75rem; +} + +.card-subtitle { + margin-top: -0.375rem; + margin-bottom: 0; +} + +.card-text:last-child { + margin-bottom: 0; +} + +.card-link:hover { + text-decoration: none; +} + +.card-link + .card-link { + margin-left: 1.25rem; +} + +.card-header { + padding: 0.75rem 1.25rem; + margin-bottom: 0; + background-color: rgba(0, 0, 0, 0.03); + border-bottom: 1px solid rgba(0, 0, 0, 0.125); +} + +.card-header:first-child { + border-radius: calc(0.25rem - 1px) calc(0.25rem - 1px) 0 0; +} + +.card-header + .list-group .list-group-item:first-child { + border-top: 0; +} + +.card-footer { + padding: 0.75rem 1.25rem; + background-color: rgba(0, 0, 0, 0.03); + border-top: 1px solid rgba(0, 0, 0, 0.125); +} + +.card-footer:last-child { + border-radius: 0 0 calc(0.25rem - 1px) calc(0.25rem - 1px); +} + +.card-header-tabs { + margin-right: -0.625rem; + margin-bottom: -0.75rem; + margin-left: -0.625rem; + border-bottom: 0; +} + +.card-header-pills { + margin-right: -0.625rem; + margin-left: -0.625rem; +} + +.card-img-overlay { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + padding: 1.25rem; +} + +.card-img { + width: 100%; + border-radius: calc(0.25rem - 1px); +} + +.card-img-top { + width: 100%; + border-top-left-radius: calc(0.25rem - 1px); + border-top-right-radius: calc(0.25rem - 1px); +} + +.card-img-bottom { + width: 100%; + border-bottom-right-radius: calc(0.25rem - 1px); + border-bottom-left-radius: calc(0.25rem - 1px); +} + +.card-deck { + display: flex; + flex-direction: column; +} + +.card-deck .card { + margin-bottom: 15px; +} + +@media (min-width: 576px) { + .card-deck { + flex-flow: row wrap; + margin-right: -15px; + margin-left: -15px; + } + + .card-deck .card { + display: flex; + flex: 1 0 0%; + flex-direction: column; + margin-right: 15px; + margin-bottom: 0; + margin-left: 15px; + } +} + +.card-group { + display: flex; + flex-direction: column; +} + +.card-group > .card { + margin-bottom: 15px; +} + +@media (min-width: 576px) { + .card-group { + flex-flow: row wrap; + } + + .card-group > .card { + flex: 1 0 0%; + margin-bottom: 0; + } + + .card-group > .card + .card { + margin-left: 0; + border-left: 0; + } + + .card-group > .card:not(:last-child) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + .card-group > .card:not(:last-child) .card-img-top, + .card-group > .card:not(:last-child) .card-header { + border-top-right-radius: 0; + } + + .card-group > .card:not(:last-child) .card-img-bottom, + .card-group > .card:not(:last-child) .card-footer { + border-bottom-right-radius: 0; + } + + .card-group > .card:not(:first-child) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + + .card-group > .card:not(:first-child) .card-img-top, + .card-group > .card:not(:first-child) .card-header { + border-top-left-radius: 0; + } + + .card-group > .card:not(:first-child) .card-img-bottom, + .card-group > .card:not(:first-child) .card-footer { + border-bottom-left-radius: 0; + } +} + +.card-columns .card { + margin-bottom: 0.75rem; +} + +@media (min-width: 576px) { + .card-columns { + -moz-column-count: 3; + column-count: 3; + -moz-column-gap: 1.25rem; + column-gap: 1.25rem; + orphans: 1; + widows: 1; + } + + .card-columns .card { + display: inline-block; + width: 100%; + } +} + +.accordion > .card { + overflow: hidden; +} + +.accordion > .card:not(:first-of-type) .card-header:first-child { + border-radius: 0; +} + +.accordion > .card:not(:first-of-type):not(:last-of-type) { + border-bottom: 0; + border-radius: 0; +} + +.accordion > .card:first-of-type { + border-bottom: 0; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} + +.accordion > .card:last-of-type { + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.accordion > .card .card-header { + margin-bottom: -1px; +} + +.breadcrumb { + display: flex; + flex-wrap: wrap; + padding: 0.75rem 1rem; + margin-bottom: 1rem; + list-style: none; + background-color: #e9ecef; + border-radius: 0.25rem; +} + +.breadcrumb-item + .breadcrumb-item { + padding-left: 0.5rem; +} + +.breadcrumb-item + .breadcrumb-item::before { + display: inline-block; + padding-right: 0.5rem; + color: #6c757d; + content: "/"; +} + +.breadcrumb-item + .breadcrumb-item:hover::before { + text-decoration: underline; +} + +.breadcrumb-item + .breadcrumb-item:hover::before { + text-decoration: none; +} + +.breadcrumb-item.active { + color: #6c757d; +} + +.pagination { + display: flex; + padding-left: 0; + list-style: none; + border-radius: 0.25rem; +} + +.page-link { + position: relative; + display: block; + padding: 0.5rem 0.75rem; + margin-left: -1px; + line-height: 1.25; + color: #3490dc; + background-color: #fff; + border: 1px solid #dee2e6; +} + +.page-link:hover { + z-index: 2; + color: #1d68a7; + text-decoration: none; + background-color: #e9ecef; + border-color: #dee2e6; +} + +.page-link:focus { + z-index: 2; + outline: 0; + box-shadow: 0 0 0 0.2rem rgba(52, 144, 220, 0.25); +} + +.page-item:first-child .page-link { + margin-left: 0; + border-top-left-radius: 0.25rem; + border-bottom-left-radius: 0.25rem; +} + +.page-item:last-child .page-link { + border-top-right-radius: 0.25rem; + border-bottom-right-radius: 0.25rem; +} + +.page-item.active .page-link { + z-index: 1; + color: #fff; + background-color: #3490dc; + border-color: #3490dc; +} + +.page-item.disabled .page-link { + color: #6c757d; + pointer-events: none; + cursor: auto; + background-color: #fff; + border-color: #dee2e6; +} + +.pagination-lg .page-link { + padding: 0.75rem 1.5rem; + font-size: 1.0375rem; + line-height: 1.5; +} + +.pagination-lg .page-item:first-child .page-link { + border-top-left-radius: 0.3rem; + border-bottom-left-radius: 0.3rem; +} + +.pagination-lg .page-item:last-child .page-link { + border-top-right-radius: 0.3rem; + border-bottom-right-radius: 0.3rem; +} + +.pagination-sm .page-link { + padding: 0.25rem 0.5rem; + font-size: 0.72625rem; + line-height: 1.5; +} + +.pagination-sm .page-item:first-child .page-link { + border-top-left-radius: 0.2rem; + border-bottom-left-radius: 0.2rem; +} + +.pagination-sm .page-item:last-child .page-link { + border-top-right-radius: 0.2rem; + border-bottom-right-radius: 0.2rem; +} + +.badge { + display: inline-block; + padding: 0.25em 0.4em; + font-size: 75%; + font-weight: 700; + line-height: 1; + text-align: center; + white-space: nowrap; + vertical-align: baseline; + border-radius: 0.25rem; + transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, + border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; +} + +@media (prefers-reduced-motion: reduce) { + .badge { + transition: none; + } +} + +a.badge:hover, +a.badge:focus { + text-decoration: none; +} + +.badge:empty { + display: none; +} + +.btn .badge { + position: relative; + top: -1px; +} + +.badge-pill { + padding-right: 0.6em; + padding-left: 0.6em; + border-radius: 10rem; +} + +.badge-primary { + color: #fff; + background-color: #3490dc; +} + +a.badge-primary:hover, +a.badge-primary:focus { + color: #fff; + background-color: #2176bd; +} + +a.badge-primary:focus, +a.badge-primary.focus { + outline: 0; + box-shadow: 0 0 0 0.2rem rgba(52, 144, 220, 0.5); +} + +.badge-secondary { + color: #fff; + background-color: #6c757d; +} + +a.badge-secondary:hover, +a.badge-secondary:focus { + color: #fff; + background-color: #545b62; +} + +a.badge-secondary:focus, +a.badge-secondary.focus { + outline: 0; + box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.5); +} + +.badge-success { + color: #fff; + background-color: #38c172; +} + +a.badge-success:hover, +a.badge-success:focus { + color: #fff; + background-color: #2d995b; +} + +a.badge-success:focus, +a.badge-success.focus { + outline: 0; + box-shadow: 0 0 0 0.2rem rgba(56, 193, 114, 0.5); +} + +.badge-info { + color: #212529; + background-color: #6cb2eb; +} + +a.badge-info:hover, +a.badge-info:focus { + color: #212529; + background-color: #3f9ae5; +} + +a.badge-info:focus, +a.badge-info.focus { + outline: 0; + box-shadow: 0 0 0 0.2rem rgba(108, 178, 235, 0.5); +} + +.badge-warning { + color: #212529; + background-color: #ffed4a; +} + +a.badge-warning:hover, +a.badge-warning:focus { + color: #212529; + background-color: #ffe817; +} + +a.badge-warning:focus, +a.badge-warning.focus { + outline: 0; + box-shadow: 0 0 0 0.2rem rgba(255, 237, 74, 0.5); +} + +.badge-danger { + color: #fff; + background-color: #e3342f; +} + +a.badge-danger:hover, +a.badge-danger:focus { + color: #fff; + background-color: #c51f1a; +} + +a.badge-danger:focus, +a.badge-danger.focus { + outline: 0; + box-shadow: 0 0 0 0.2rem rgba(227, 52, 47, 0.5); +} + +.badge-light { + color: #212529; + background-color: #f8f9fa; +} + +a.badge-light:hover, +a.badge-light:focus { + color: #212529; + background-color: #dae0e5; +} + +a.badge-light:focus, +a.badge-light.focus { + outline: 0; + box-shadow: 0 0 0 0.2rem rgba(248, 249, 250, 0.5); +} + +.badge-dark { + color: #fff; + background-color: #343a40; +} + +a.badge-dark:hover, +a.badge-dark:focus { + color: #fff; + background-color: #1d2124; +} + +a.badge-dark:focus, +a.badge-dark.focus { + outline: 0; + box-shadow: 0 0 0 0.2rem rgba(52, 58, 64, 0.5); +} + +.jumbotron { + padding: 2rem 1rem; + margin-bottom: 2rem; + background-color: #e9ecef; + border-radius: 0.3rem; +} + +@media (min-width: 576px) { + .jumbotron { + padding: 4rem 2rem; + } +} + +.jumbotron-fluid { + padding-right: 0; + padding-left: 0; + border-radius: 0; +} + +.alert { + position: relative; + padding: 0.75rem 1.25rem; + margin-bottom: 1rem; + border: 1px solid transparent; + border-radius: 0.25rem; +} + +.alert-heading { + color: inherit; +} + +.alert-link { + font-weight: 700; +} + +.alert-dismissible { + padding-right: 3.745rem; +} + +.alert-dismissible .close { + position: absolute; + top: 0; + right: 0; + padding: 0.75rem 1.25rem; + color: inherit; +} + +.alert-primary { + color: #1b4b72; + background-color: #d6e9f8; + border-color: #c6e0f5; +} + +.alert-primary hr { + border-top-color: #b0d4f1; +} + +.alert-primary .alert-link { + color: #113049; +} + +.alert-secondary { + color: #383d41; + background-color: #e2e3e5; + border-color: #d6d8db; +} + +.alert-secondary hr { + border-top-color: #c8cbcf; +} + +.alert-secondary .alert-link { + color: #202326; +} + +.alert-success { + color: #1d643b; + background-color: #d7f3e3; + border-color: #c7eed8; +} + +.alert-success hr { + border-top-color: #b3e8ca; +} + +.alert-success .alert-link { + color: #123c24; +} + +.alert-info { + color: #385d7a; + background-color: #e2f0fb; + border-color: #d6e9f9; +} + +.alert-info hr { + border-top-color: #c0ddf6; +} + +.alert-info .alert-link { + color: #284257; +} + +.alert-warning { + color: #857b26; + background-color: #fffbdb; + border-color: #fffacc; +} + +.alert-warning hr { + border-top-color: #fff8b3; +} + +.alert-warning .alert-link { + color: #5d561b; +} + +.alert-danger { + color: #761b18; + background-color: #f9d6d5; + border-color: #f7c6c5; +} + +.alert-danger hr { + border-top-color: #f4b0af; +} + +.alert-danger .alert-link { + color: #4c110f; +} + +.alert-light { + color: #818182; + background-color: #fefefe; + border-color: #fdfdfe; +} + +.alert-light hr { + border-top-color: #ececf6; +} + +.alert-light .alert-link { + color: #686868; +} + +.alert-dark { + color: #1b1e21; + background-color: #d6d8d9; + border-color: #c6c8ca; +} + +.alert-dark hr { + border-top-color: #b9bbbe; +} + +.alert-dark .alert-link { + color: #040505; +} + +@keyframes progress-bar-stripes { + from { + background-position: 1rem 0; + } + + to { + background-position: 0 0; + } +} + +.progress { + display: flex; + height: 1rem; + overflow: hidden; + font-size: 0.6225rem; + background-color: #e9ecef; + border-radius: 0.25rem; +} + +.progress-bar { + display: flex; + flex-direction: column; + justify-content: center; + color: #fff; + text-align: center; + white-space: nowrap; + background-color: #3490dc; + transition: width 0.6s ease; +} + +@media (prefers-reduced-motion: reduce) { + .progress-bar { + transition: none; + } +} + +.progress-bar-striped { + background-image: linear-gradient( + 45deg, + rgba(255, 255, 255, 0.15) 25%, + transparent 25%, + transparent 50%, + rgba(255, 255, 255, 0.15) 50%, + rgba(255, 255, 255, 0.15) 75%, + transparent 75%, + transparent + ); + background-size: 1rem 1rem; +} + +.progress-bar-animated { + animation: progress-bar-stripes 1s linear infinite; +} + +@media (prefers-reduced-motion: reduce) { + .progress-bar-animated { + animation: none; + } +} + +.media { + display: flex; + align-items: flex-start; +} + +.media-body { + flex: 1; +} + +.list-group { + display: flex; + flex-direction: column; + padding-left: 0; + margin-bottom: 0; +} + +.list-group-item-action { + width: 100%; + color: #495057; + text-align: inherit; +} + +.list-group-item-action:hover, +.list-group-item-action:focus { + z-index: 1; + color: #495057; + text-decoration: none; + background-color: #f8f9fa; +} + +.list-group-item-action:active { + color: #212529; + background-color: #e9ecef; +} + +.list-group-item { + position: relative; + display: block; + padding: 0.75rem 1.25rem; + margin-bottom: -1px; + background-color: #fff; + border: 1px solid rgba(0, 0, 0, 0.125); +} + +.list-group-item:first-child { + border-top-left-radius: 0.25rem; + border-top-right-radius: 0.25rem; +} + +.list-group-item:last-child { + margin-bottom: 0; + border-bottom-right-radius: 0.25rem; + border-bottom-left-radius: 0.25rem; +} + +.list-group-item.disabled, +.list-group-item:disabled { + color: #6c757d; + pointer-events: none; + background-color: #fff; +} + +.list-group-item.active { + z-index: 2; + color: #fff; + background-color: #3490dc; + border-color: #3490dc; +} + +.list-group-horizontal { + flex-direction: row; +} + +.list-group-horizontal .list-group-item { + margin-right: -1px; + margin-bottom: 0; +} + +.list-group-horizontal .list-group-item:first-child { + border-top-left-radius: 0.25rem; + border-bottom-left-radius: 0.25rem; + border-top-right-radius: 0; +} + +.list-group-horizontal .list-group-item:last-child { + margin-right: 0; + border-top-right-radius: 0.25rem; + border-bottom-right-radius: 0.25rem; + border-bottom-left-radius: 0; +} + +@media (min-width: 576px) { + .list-group-horizontal-sm { + flex-direction: row; + } + + .list-group-horizontal-sm .list-group-item { + margin-right: -1px; + margin-bottom: 0; + } + + .list-group-horizontal-sm .list-group-item:first-child { + border-top-left-radius: 0.25rem; + border-bottom-left-radius: 0.25rem; + border-top-right-radius: 0; + } + + .list-group-horizontal-sm .list-group-item:last-child { + margin-right: 0; + border-top-right-radius: 0.25rem; + border-bottom-right-radius: 0.25rem; + border-bottom-left-radius: 0; + } +} + +@media (min-width: 768px) { + .list-group-horizontal-md { + flex-direction: row; + } + + .list-group-horizontal-md .list-group-item { + margin-right: -1px; + margin-bottom: 0; + } + + .list-group-horizontal-md .list-group-item:first-child { + border-top-left-radius: 0.25rem; + border-bottom-left-radius: 0.25rem; + border-top-right-radius: 0; + } + + .list-group-horizontal-md .list-group-item:last-child { + margin-right: 0; + border-top-right-radius: 0.25rem; + border-bottom-right-radius: 0.25rem; + border-bottom-left-radius: 0; + } +} + +@media (min-width: 992px) { + .list-group-horizontal-lg { + flex-direction: row; + } + + .list-group-horizontal-lg .list-group-item { + margin-right: -1px; + margin-bottom: 0; + } + + .list-group-horizontal-lg .list-group-item:first-child { + border-top-left-radius: 0.25rem; + border-bottom-left-radius: 0.25rem; + border-top-right-radius: 0; + } + + .list-group-horizontal-lg .list-group-item:last-child { + margin-right: 0; + border-top-right-radius: 0.25rem; + border-bottom-right-radius: 0.25rem; + border-bottom-left-radius: 0; + } +} + +@media (min-width: 1200px) { + .list-group-horizontal-xl { + flex-direction: row; + } + + .list-group-horizontal-xl .list-group-item { + margin-right: -1px; + margin-bottom: 0; + } + + .list-group-horizontal-xl .list-group-item:first-child { + border-top-left-radius: 0.25rem; + border-bottom-left-radius: 0.25rem; + border-top-right-radius: 0; + } + + .list-group-horizontal-xl .list-group-item:last-child { + margin-right: 0; + border-top-right-radius: 0.25rem; + border-bottom-right-radius: 0.25rem; + border-bottom-left-radius: 0; + } +} + +.list-group-flush .list-group-item { + border-right: 0; + border-left: 0; + border-radius: 0; +} + +.list-group-flush .list-group-item:last-child { + margin-bottom: -1px; +} + +.list-group-flush:first-child .list-group-item:first-child { + border-top: 0; +} + +.list-group-flush:last-child .list-group-item:last-child { + margin-bottom: 0; + border-bottom: 0; +} + +.list-group-item-primary { + color: #1b4b72; + background-color: #c6e0f5; +} + +.list-group-item-primary.list-group-item-action:hover, +.list-group-item-primary.list-group-item-action:focus { + color: #1b4b72; + background-color: #b0d4f1; +} + +.list-group-item-primary.list-group-item-action.active { + color: #fff; + background-color: #1b4b72; + border-color: #1b4b72; +} + +.list-group-item-secondary { + color: #383d41; + background-color: #d6d8db; +} + +.list-group-item-secondary.list-group-item-action:hover, +.list-group-item-secondary.list-group-item-action:focus { + color: #383d41; + background-color: #c8cbcf; +} + +.list-group-item-secondary.list-group-item-action.active { + color: #fff; + background-color: #383d41; + border-color: #383d41; +} + +.list-group-item-success { + color: #1d643b; + background-color: #c7eed8; +} + +.list-group-item-success.list-group-item-action:hover, +.list-group-item-success.list-group-item-action:focus { + color: #1d643b; + background-color: #b3e8ca; +} + +.list-group-item-success.list-group-item-action.active { + color: #fff; + background-color: #1d643b; + border-color: #1d643b; +} + +.list-group-item-info { + color: #385d7a; + background-color: #d6e9f9; +} + +.list-group-item-info.list-group-item-action:hover, +.list-group-item-info.list-group-item-action:focus { + color: #385d7a; + background-color: #c0ddf6; +} + +.list-group-item-info.list-group-item-action.active { + color: #fff; + background-color: #385d7a; + border-color: #385d7a; +} + +.list-group-item-warning { + color: #857b26; + background-color: #fffacc; +} + +.list-group-item-warning.list-group-item-action:hover, +.list-group-item-warning.list-group-item-action:focus { + color: #857b26; + background-color: #fff8b3; +} + +.list-group-item-warning.list-group-item-action.active { + color: #fff; + background-color: #857b26; + border-color: #857b26; +} + +.list-group-item-danger { + color: #761b18; + background-color: #f7c6c5; +} + +.list-group-item-danger.list-group-item-action:hover, +.list-group-item-danger.list-group-item-action:focus { + color: #761b18; + background-color: #f4b0af; +} + +.list-group-item-danger.list-group-item-action.active { + color: #fff; + background-color: #761b18; + border-color: #761b18; +} + +.list-group-item-light { + color: #818182; + background-color: #fdfdfe; +} + +.list-group-item-light.list-group-item-action:hover, +.list-group-item-light.list-group-item-action:focus { + color: #818182; + background-color: #ececf6; +} + +.list-group-item-light.list-group-item-action.active { + color: #fff; + background-color: #818182; + border-color: #818182; +} + +.list-group-item-dark { + color: #1b1e21; + background-color: #c6c8ca; +} + +.list-group-item-dark.list-group-item-action:hover, +.list-group-item-dark.list-group-item-action:focus { + color: #1b1e21; + background-color: #b9bbbe; +} + +.list-group-item-dark.list-group-item-action.active { + color: #fff; + background-color: #1b1e21; + border-color: #1b1e21; +} + +.close { + float: right; + font-size: 1.245rem; + font-weight: 700; + line-height: 1; + color: #000; + text-shadow: 0 1px 0 #fff; + opacity: 0.5; +} + +.close:hover { + color: #000; + text-decoration: none; +} + +.close:not(:disabled):not(.disabled):hover, +.close:not(:disabled):not(.disabled):focus { + opacity: 0.75; +} + +button.close { + padding: 0; + background-color: transparent; + border: 0; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; +} + +a.close.disabled { + pointer-events: none; +} + +.toast { + max-width: 350px; + overflow: hidden; + font-size: 0.875rem; + background-color: rgba(255, 255, 255, 0.85); + background-clip: padding-box; + border: 1px solid rgba(0, 0, 0, 0.1); + box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.1); + -webkit-backdrop-filter: blur(10px); + backdrop-filter: blur(10px); + opacity: 0; + border-radius: 0.25rem; +} + +.toast:not(:last-child) { + margin-bottom: 0.75rem; +} + +.toast.showing { + opacity: 1; +} + +.toast.show { + display: block; + opacity: 1; +} + +.toast.hide { + display: none; +} + +.toast-header { + display: flex; + align-items: center; + padding: 0.25rem 0.75rem; + color: #6c757d; + background-color: rgba(255, 255, 255, 0.85); + background-clip: padding-box; + border-bottom: 1px solid rgba(0, 0, 0, 0.05); +} + +.toast-body { + padding: 0.75rem; +} + +.modal-open { + overflow: hidden; +} + +.modal-open .modal { + overflow-x: hidden; + overflow-y: auto; +} + +.modal { + position: fixed; + top: 0; + left: 0; + z-index: 1050; + display: none; + width: 100%; + height: 100%; + overflow: hidden; + outline: 0; +} + +.modal-dialog { + position: relative; + width: auto; + margin: 0.5rem; + pointer-events: none; +} + +.modal.fade .modal-dialog { + transition: transform 0.3s ease-out; + transform: translate(0, -50px); +} + +@media (prefers-reduced-motion: reduce) { + .modal.fade .modal-dialog { + transition: none; + } +} + +.modal.show .modal-dialog { + transform: none; +} + +.modal-dialog-scrollable { + display: flex; + max-height: calc(100% - 1rem); +} + +.modal-dialog-scrollable .modal-content { + max-height: calc(100vh - 1rem); + overflow: hidden; +} + +.modal-dialog-scrollable .modal-header, +.modal-dialog-scrollable .modal-footer { + flex-shrink: 0; +} + +.modal-dialog-scrollable .modal-body { + overflow-y: auto; +} + +.modal-dialog-centered { + display: flex; + align-items: center; + min-height: calc(100% - 1rem); +} + +.modal-dialog-centered::before { + display: block; + height: calc(100vh - 1rem); + content: ""; +} + +.modal-dialog-centered.modal-dialog-scrollable { + flex-direction: column; + justify-content: center; + height: 100%; +} + +.modal-dialog-centered.modal-dialog-scrollable .modal-content { + max-height: none; +} + +.modal-dialog-centered.modal-dialog-scrollable::before { + content: none; +} + +.modal-content { + position: relative; + display: flex; + flex-direction: column; + width: 100%; + pointer-events: auto; + background-color: #fff; + background-clip: padding-box; + border: 1px solid rgba(0, 0, 0, 0.2); + border-radius: 0.3rem; + outline: 0; +} + +.modal-backdrop { + position: fixed; + top: 0; + left: 0; + z-index: 1040; + width: 100vw; + height: 100vh; + background-color: #000; +} + +.modal-backdrop.fade { + opacity: 0; +} + +.modal-backdrop.show { + opacity: 0.5; +} + +.modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + padding: 1rem 1rem; + border-bottom: 1px solid #dee2e6; + border-top-left-radius: 0.3rem; + border-top-right-radius: 0.3rem; +} + +.modal-header .close { + padding: 1rem 1rem; + margin: -1rem -1rem -1rem auto; +} + +.modal-title { + margin-bottom: 0; + line-height: 1.6; +} + +.modal-body { + position: relative; + flex: 1 1 auto; + padding: 1rem; +} + +.modal-footer { + display: flex; + align-items: center; + justify-content: flex-end; + padding: 1rem; + border-top: 1px solid #dee2e6; + border-bottom-right-radius: 0.3rem; + border-bottom-left-radius: 0.3rem; +} + +.modal-footer > :not(:first-child) { + margin-left: 0.25rem; +} + +.modal-footer > :not(:last-child) { + margin-right: 0.25rem; +} + +.modal-scrollbar-measure { + position: absolute; + top: -9999px; + width: 50px; + height: 50px; + overflow: scroll; +} + +@media (min-width: 576px) { + .modal-dialog { + max-width: 500px; + margin: 1.75rem auto; + } + + .modal-dialog-scrollable { + max-height: calc(100% - 3.5rem); + } + + .modal-dialog-scrollable .modal-content { + max-height: calc(100vh - 3.5rem); + } + + .modal-dialog-centered { + min-height: calc(100% - 3.5rem); + } + + .modal-dialog-centered::before { + height: calc(100vh - 3.5rem); + } + + .modal-sm { + max-width: 300px; + } +} + +@media (min-width: 992px) { + .modal-lg, + .modal-xl { + max-width: 800px; + } +} + +@media (min-width: 1200px) { + .modal-xl { + max-width: 1140px; + } +} + +.tooltip { + position: absolute; + z-index: 1070; + display: block; + margin: 0; + font-family: "Nunito", sans-serif; + font-style: normal; + font-weight: 400; + line-height: 1.6; + text-align: left; + text-align: start; + text-decoration: none; + text-shadow: none; + text-transform: none; + letter-spacing: normal; + word-break: normal; + word-spacing: normal; + white-space: normal; + line-break: auto; + font-size: 0.72625rem; + word-wrap: break-word; + opacity: 0; +} + +.tooltip.show { + opacity: 0.9; +} + +.tooltip .arrow { + position: absolute; + display: block; + width: 0.8rem; + height: 0.4rem; +} + +.tooltip .arrow::before { + position: absolute; + content: ""; + border-color: transparent; + border-style: solid; +} + +.bs-tooltip-top, +.bs-tooltip-auto[x-placement^="top"] { + padding: 0.4rem 0; +} + +.bs-tooltip-top .arrow, +.bs-tooltip-auto[x-placement^="top"] .arrow { + bottom: 0; +} + +.bs-tooltip-top .arrow::before, +.bs-tooltip-auto[x-placement^="top"] .arrow::before { + top: 0; + border-width: 0.4rem 0.4rem 0; + border-top-color: #000; +} + +.bs-tooltip-right, +.bs-tooltip-auto[x-placement^="right"] { + padding: 0 0.4rem; +} + +.bs-tooltip-right .arrow, +.bs-tooltip-auto[x-placement^="right"] .arrow { + left: 0; + width: 0.4rem; + height: 0.8rem; +} + +.bs-tooltip-right .arrow::before, +.bs-tooltip-auto[x-placement^="right"] .arrow::before { + right: 0; + border-width: 0.4rem 0.4rem 0.4rem 0; + border-right-color: #000; +} + +.bs-tooltip-bottom, +.bs-tooltip-auto[x-placement^="bottom"] { + padding: 0.4rem 0; +} + +.bs-tooltip-bottom .arrow, +.bs-tooltip-auto[x-placement^="bottom"] .arrow { + top: 0; +} + +.bs-tooltip-bottom .arrow::before, +.bs-tooltip-auto[x-placement^="bottom"] .arrow::before { + bottom: 0; + border-width: 0 0.4rem 0.4rem; + border-bottom-color: #000; +} + +.bs-tooltip-left, +.bs-tooltip-auto[x-placement^="left"] { + padding: 0 0.4rem; +} + +.bs-tooltip-left .arrow, +.bs-tooltip-auto[x-placement^="left"] .arrow { + right: 0; + width: 0.4rem; + height: 0.8rem; +} + +.bs-tooltip-left .arrow::before, +.bs-tooltip-auto[x-placement^="left"] .arrow::before { + left: 0; + border-width: 0.4rem 0 0.4rem 0.4rem; + border-left-color: #000; +} + +.tooltip-inner { + max-width: 200px; + padding: 0.25rem 0.5rem; + color: #fff; + text-align: center; + background-color: #000; + border-radius: 0.25rem; +} + +.popover { + position: absolute; + top: 0; + left: 0; + z-index: 1060; + display: block; + max-width: 276px; + font-family: "Nunito", sans-serif; + font-style: normal; + font-weight: 400; + line-height: 1.6; + text-align: left; + text-align: start; + text-decoration: none; + text-shadow: none; + text-transform: none; + letter-spacing: normal; + word-break: normal; + word-spacing: normal; + white-space: normal; + line-break: auto; + font-size: 0.72625rem; + word-wrap: break-word; + background-color: #fff; + background-clip: padding-box; + border: 1px solid rgba(0, 0, 0, 0.2); + border-radius: 0.3rem; +} + +.popover .arrow { + position: absolute; + display: block; + width: 1rem; + height: 0.5rem; + margin: 0 0.3rem; +} + +.popover .arrow::before, +.popover .arrow::after { + position: absolute; + display: block; + content: ""; + border-color: transparent; + border-style: solid; +} + +.bs-popover-top, +.bs-popover-auto[x-placement^="top"] { + margin-bottom: 0.5rem; +} + +.bs-popover-top > .arrow, +.bs-popover-auto[x-placement^="top"] > .arrow { + bottom: calc((0.5rem + 1px) * -1); +} + +.bs-popover-top > .arrow::before, +.bs-popover-auto[x-placement^="top"] > .arrow::before { + bottom: 0; + border-width: 0.5rem 0.5rem 0; + border-top-color: rgba(0, 0, 0, 0.25); +} + +.bs-popover-top > .arrow::after, +.bs-popover-auto[x-placement^="top"] > .arrow::after { + bottom: 1px; + border-width: 0.5rem 0.5rem 0; + border-top-color: #fff; +} + +.bs-popover-right, +.bs-popover-auto[x-placement^="right"] { + margin-left: 0.5rem; +} + +.bs-popover-right > .arrow, +.bs-popover-auto[x-placement^="right"] > .arrow { + left: calc((0.5rem + 1px) * -1); + width: 0.5rem; + height: 1rem; + margin: 0.3rem 0; +} + +.bs-popover-right > .arrow::before, +.bs-popover-auto[x-placement^="right"] > .arrow::before { + left: 0; + border-width: 0.5rem 0.5rem 0.5rem 0; + border-right-color: rgba(0, 0, 0, 0.25); +} + +.bs-popover-right > .arrow::after, +.bs-popover-auto[x-placement^="right"] > .arrow::after { + left: 1px; + border-width: 0.5rem 0.5rem 0.5rem 0; + border-right-color: #fff; +} + +.bs-popover-bottom, +.bs-popover-auto[x-placement^="bottom"] { + margin-top: 0.5rem; +} + +.bs-popover-bottom > .arrow, +.bs-popover-auto[x-placement^="bottom"] > .arrow { + top: calc((0.5rem + 1px) * -1); +} + +.bs-popover-bottom > .arrow::before, +.bs-popover-auto[x-placement^="bottom"] > .arrow::before { + top: 0; + border-width: 0 0.5rem 0.5rem 0.5rem; + border-bottom-color: rgba(0, 0, 0, 0.25); +} + +.bs-popover-bottom > .arrow::after, +.bs-popover-auto[x-placement^="bottom"] > .arrow::after { + top: 1px; + border-width: 0 0.5rem 0.5rem 0.5rem; + border-bottom-color: #fff; +} + +.bs-popover-bottom .popover-header::before, +.bs-popover-auto[x-placement^="bottom"] .popover-header::before { + position: absolute; + top: 0; + left: 50%; + display: block; + width: 1rem; + margin-left: -0.5rem; + content: ""; + border-bottom: 1px solid #f7f7f7; +} + +.bs-popover-left, +.bs-popover-auto[x-placement^="left"] { + margin-right: 0.5rem; +} + +.bs-popover-left > .arrow, +.bs-popover-auto[x-placement^="left"] > .arrow { + right: calc((0.5rem + 1px) * -1); + width: 0.5rem; + height: 1rem; + margin: 0.3rem 0; +} + +.bs-popover-left > .arrow::before, +.bs-popover-auto[x-placement^="left"] > .arrow::before { + right: 0; + border-width: 0.5rem 0 0.5rem 0.5rem; + border-left-color: rgba(0, 0, 0, 0.25); +} + +.bs-popover-left > .arrow::after, +.bs-popover-auto[x-placement^="left"] > .arrow::after { + right: 1px; + border-width: 0.5rem 0 0.5rem 0.5rem; + border-left-color: #fff; +} + +.popover-header { + padding: 0.5rem 0.75rem; + margin-bottom: 0; + font-size: 0.83rem; + background-color: #f7f7f7; + border-bottom: 1px solid #ebebeb; + border-top-left-radius: calc(0.3rem - 1px); + border-top-right-radius: calc(0.3rem - 1px); +} + +.popover-header:empty { + display: none; +} + +.popover-body { + padding: 0.5rem 0.75rem; + color: #212529; +} + +.carousel { + position: relative; +} + +.carousel.pointer-event { + touch-action: pan-y; +} + +.carousel-inner { + position: relative; + width: 100%; + overflow: hidden; +} + +.carousel-inner::after { + display: block; + clear: both; + content: ""; +} + +.carousel-item { + position: relative; + display: none; + float: left; + width: 100%; + margin-right: -100%; + backface-visibility: hidden; + transition: transform 0.6s ease-in-out; +} + +@media (prefers-reduced-motion: reduce) { + .carousel-item { + transition: none; + } +} + +.carousel-item.active, +.carousel-item-next, +.carousel-item-prev { + display: block; +} + +.carousel-item-next:not(.carousel-item-left), +.active.carousel-item-right { + transform: translateX(100%); +} + +.carousel-item-prev:not(.carousel-item-right), +.active.carousel-item-left { + transform: translateX(-100%); +} + +.carousel-fade .carousel-item { + opacity: 0; + transition-property: opacity; + transform: none; +} + +.carousel-fade .carousel-item.active, +.carousel-fade .carousel-item-next.carousel-item-left, +.carousel-fade .carousel-item-prev.carousel-item-right { + z-index: 1; + opacity: 1; +} + +.carousel-fade .active.carousel-item-left, +.carousel-fade .active.carousel-item-right { + z-index: 0; + opacity: 0; + transition: 0s 0.6s opacity; +} + +@media (prefers-reduced-motion: reduce) { + .carousel-fade .active.carousel-item-left, + .carousel-fade .active.carousel-item-right { + transition: none; + } +} + +.carousel-control-prev, +.carousel-control-next { + position: absolute; + top: 0; + bottom: 0; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + width: 15%; + color: #fff; + text-align: center; + opacity: 0.5; + transition: opacity 0.15s ease; +} + +@media (prefers-reduced-motion: reduce) { + .carousel-control-prev, + .carousel-control-next { + transition: none; + } +} + +.carousel-control-prev:hover, +.carousel-control-prev:focus, +.carousel-control-next:hover, +.carousel-control-next:focus { + color: #fff; + text-decoration: none; + outline: 0; + opacity: 0.9; +} + +.carousel-control-prev { + left: 0; +} + +.carousel-control-next { + right: 0; +} + +.carousel-control-prev-icon, +.carousel-control-next-icon { + display: inline-block; + width: 20px; + height: 20px; + background: no-repeat 50%/100% 100%; +} + +.carousel-control-prev-icon { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 8 8'%3e%3cpath d='M5.25 0l-4 4 4 4 1.5-1.5-2.5-2.5 2.5-2.5-1.5-1.5z'/%3e%3c/svg%3e"); +} + +.carousel-control-next-icon { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 8 8'%3e%3cpath d='M2.75 0l-1.5 1.5 2.5 2.5-2.5 2.5 1.5 1.5 4-4-4-4z'/%3e%3c/svg%3e"); +} + +.carousel-indicators { + position: absolute; + right: 0; + bottom: 0; + left: 0; + z-index: 15; + display: flex; + justify-content: center; + padding-left: 0; + margin-right: 15%; + margin-left: 15%; + list-style: none; +} + +.carousel-indicators li { + box-sizing: content-box; + flex: 0 1 auto; + width: 30px; + height: 3px; + margin-right: 3px; + margin-left: 3px; + text-indent: -999px; + cursor: pointer; + background-color: #fff; + background-clip: padding-box; + border-top: 10px solid transparent; + border-bottom: 10px solid transparent; + opacity: 0.5; + transition: opacity 0.6s ease; +} + +@media (prefers-reduced-motion: reduce) { + .carousel-indicators li { + transition: none; + } +} + +.carousel-indicators .active { + opacity: 1; +} + +.carousel-caption { + position: absolute; + right: 15%; + bottom: 20px; + left: 15%; + z-index: 10; + padding-top: 20px; + padding-bottom: 20px; + color: #fff; + text-align: center; +} + +@keyframes spinner-border { + to { + transform: rotate(360deg); + } +} + +.spinner-border { + display: inline-block; + width: 2rem; + height: 2rem; + vertical-align: text-bottom; + border: 0.25em solid currentColor; + border-right-color: transparent; + border-radius: 50%; + animation: spinner-border 0.75s linear infinite; +} + +.spinner-border-sm { + width: 1rem; + height: 1rem; + border-width: 0.2em; +} + +@keyframes spinner-grow { + 0% { + transform: scale(0); + } + + 50% { + opacity: 1; + } +} + +.spinner-grow { + display: inline-block; + width: 2rem; + height: 2rem; + vertical-align: text-bottom; + background-color: currentColor; + border-radius: 50%; + opacity: 0; + animation: spinner-grow 0.75s linear infinite; +} + +.spinner-grow-sm { + width: 1rem; + height: 1rem; +} + +.align-baseline { + vertical-align: baseline !important; +} + +.align-top { + vertical-align: top !important; +} + +.align-middle { + vertical-align: middle !important; +} + +.align-bottom { + vertical-align: bottom !important; +} + +.align-text-bottom { + vertical-align: text-bottom !important; +} + +.align-text-top { + vertical-align: text-top !important; +} + +.bg-primary { + background-color: #3490dc !important; +} + +a.bg-primary:hover, +a.bg-primary:focus, +button.bg-primary:hover, +button.bg-primary:focus { + background-color: #2176bd !important; +} + +.bg-secondary { + background-color: #6c757d !important; +} + +a.bg-secondary:hover, +a.bg-secondary:focus, +button.bg-secondary:hover, +button.bg-secondary:focus { + background-color: #545b62 !important; +} + +.bg-success { + background-color: #38c172 !important; +} + +a.bg-success:hover, +a.bg-success:focus, +button.bg-success:hover, +button.bg-success:focus { + background-color: #2d995b !important; +} + +.bg-info { + background-color: #6cb2eb !important; +} + +a.bg-info:hover, +a.bg-info:focus, +button.bg-info:hover, +button.bg-info:focus { + background-color: #3f9ae5 !important; +} + +.bg-warning { + background-color: #ffed4a !important; +} + +a.bg-warning:hover, +a.bg-warning:focus, +button.bg-warning:hover, +button.bg-warning:focus { + background-color: #ffe817 !important; +} + +.bg-danger { + background-color: #e3342f !important; +} + +a.bg-danger:hover, +a.bg-danger:focus, +button.bg-danger:hover, +button.bg-danger:focus { + background-color: #c51f1a !important; +} + +.bg-light { + background-color: #f8f9fa !important; +} + +a.bg-light:hover, +a.bg-light:focus, +button.bg-light:hover, +button.bg-light:focus { + background-color: #dae0e5 !important; +} + +.bg-dark { + background-color: #343a40 !important; +} + +a.bg-dark:hover, +a.bg-dark:focus, +button.bg-dark:hover, +button.bg-dark:focus { + background-color: #1d2124 !important; +} + +.bg-white { + background-color: #fff !important; +} + +.bg-transparent { + background-color: transparent !important; +} + + +.border { + border: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important; +} + +.border-0 { + border: 0 !important; +} + +.border-top { + border-top: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important; +} + +.border-top-0 { + border-top: 0 !important; +} + +.border-end { + border-right: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important; +} + +.border-end-0 { + border-right: 0 !important; +} + +.border-bottom { + border-bottom: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important; +} + +.border-bottom-0 { + border-bottom: 0 !important; +} + +.border-start { + border-left: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important; +} + +.border-start-0 { + border-left: 0 !important; +} + +.border-primary { + --bs-border-opacity: 1; + border-color: rgba(var(--bs-primary-rgb), var(--bs-border-opacity)) !important; +} + +.border-secondary { + --bs-border-opacity: 1; + border-color: rgba(var(--bs-secondary-rgb), var(--bs-border-opacity)) !important; +} + +.border-success { + --bs-border-opacity: 1; + border-color: rgba(var(--bs-success-rgb), var(--bs-border-opacity)) !important; +} + +.border-info { + --bs-border-opacity: 1; + border-color: rgba(var(--bs-info-rgb), var(--bs-border-opacity)) !important; +} + +.border-warning { + --bs-border-opacity: 1; + border-color: rgba(var(--bs-warning-rgb), var(--bs-border-opacity)) !important; +} + +.border-danger { + --bs-border-opacity: 1; + border-color: rgba(var(--bs-danger-rgb), var(--bs-border-opacity)) !important; +} + +.border-light { + --bs-border-opacity: 1; + border-color: rgba(var(--bs-light-rgb), var(--bs-border-opacity)) !important; +} + +.border-dark { + --bs-border-opacity: 1; + border-color: rgba(var(--bs-dark-rgb), var(--bs-border-opacity)) !important; +} + +.border-black { + --bs-border-opacity: 1; + border-color: rgba(var(--bs-black-rgb), var(--bs-border-opacity)) !important; +} + +.border-white { + --bs-border-opacity: 1; + border-color: rgba(var(--bs-white-rgb), var(--bs-border-opacity)) !important; +} + +.border-primary-subtle { + border-color: var(--bs-primary-border-subtle) !important; +} + +.border-secondary-subtle { + border-color: var(--bs-secondary-border-subtle) !important; +} + +.border-success-subtle { + border-color: var(--bs-success-border-subtle) !important; +} + +.border-info-subtle { + border-color: var(--bs-info-border-subtle) !important; +} + +.border-warning-subtle { + border-color: var(--bs-warning-border-subtle) !important; +} + +.border-danger-subtle { + border-color: var(--bs-danger-border-subtle) !important; +} + +.border-light-subtle { + border-color: var(--bs-light-border-subtle) !important; +} + +.border-dark-subtle { + border-color: var(--bs-dark-border-subtle) !important; +} + +.border-1 { + border-width: 1px !important; +} + +.border-2 { + border-width: 2px !important; +} + +.border-3 { + border-width: 3px !important; +} + +.border-4 { + border-width: 4px !important; +} + +.border-5 { + border-width: 5px !important; +} + +.border-opacity-10 { + --bs-border-opacity: 0.1; +} + +.border-opacity-25 { + --bs-border-opacity: 0.25; +} + +.border-opacity-50 { + --bs-border-opacity: 0.5; +} + +.border-opacity-75 { + --bs-border-opacity: 0.75; +} + +.border-opacity-100 { + --bs-border-opacity: 1; +} +.rounded-sm { + border-radius: 0.2rem !important; +} + +.rounded { + border-radius: 0.25rem !important; +} + +.rounded-top { + border-top-left-radius: 0.25rem !important; + border-top-right-radius: 0.25rem !important; +} + +.rounded-right { + border-top-right-radius: 0.25rem !important; + border-bottom-right-radius: 0.25rem !important; +} + +.rounded-bottom { + border-bottom-right-radius: 0.25rem !important; + border-bottom-left-radius: 0.25rem !important; +} + +.rounded-left { + border-top-left-radius: 0.25rem !important; + border-bottom-left-radius: 0.25rem !important; +} + +.rounded-lg { + border-radius: 0.3rem !important; +} + +.rounded-circle { + border-radius: 50% !important; +} + +.rounded-pill { + border-radius: 50rem !important; +} + +.rounded-0 { + border-radius: 0 !important; +} + +.clearfix::after { + display: block; + clear: both; + content: ""; +} + +.d-none { + display: none !important; +} + +.d-inline { + display: inline !important; +} + +.d-inline-block { + display: inline-block !important; +} + +.d-block { + display: block !important; +} + +.d-table { + display: table !important; +} + +.d-table-row { + display: table-row !important; +} + +.d-table-cell { + display: table-cell !important; +} + +.d-flex { + display: flex !important; +} + +.d-inline-flex { + display: inline-flex !important; +} + +@media (min-width: 576px) { + .d-sm-none { + display: none !important; + } + + .d-sm-inline { + display: inline !important; + } + + .d-sm-inline-block { + display: inline-block !important; + } + + .d-sm-block { + display: block !important; + } + + .d-sm-table { + display: table !important; + } + + .d-sm-table-row { + display: table-row !important; + } + + .d-sm-table-cell { + display: table-cell !important; + } + + .d-sm-flex { + display: flex !important; + } + + .d-sm-inline-flex { + display: inline-flex !important; + } +} + +@media (min-width: 768px) { + .d-md-none { + display: none !important; + } + + .d-md-inline { + display: inline !important; + } + + .d-md-inline-block { + display: inline-block !important; + } + + .d-md-block { + display: block !important; + } + + .d-md-table { + display: table !important; + } + + .d-md-table-row { + display: table-row !important; + } + + .d-md-table-cell { + display: table-cell !important; + } + + .d-md-flex { + display: flex !important; + } + + .d-md-inline-flex { + display: inline-flex !important; + } +} + +@media (min-width: 992px) { + .d-lg-none { + display: none !important; + } + + .d-lg-inline { + display: inline !important; + } + + .d-lg-inline-block { + display: inline-block !important; + } + + .d-lg-block { + display: block !important; + } + + .d-lg-table { + display: table !important; + } + + .d-lg-table-row { + display: table-row !important; + } + + .d-lg-table-cell { + display: table-cell !important; + } + + .d-lg-flex { + display: flex !important; + } + + .d-lg-inline-flex { + display: inline-flex !important; + } +} + +@media (min-width: 1200px) { + .d-xl-none { + display: none !important; + } + + .d-xl-inline { + display: inline !important; + } + + .d-xl-inline-block { + display: inline-block !important; + } + + .d-xl-block { + display: block !important; + } + + .d-xl-table { + display: table !important; + } + + .d-xl-table-row { + display: table-row !important; + } + + .d-xl-table-cell { + display: table-cell !important; + } + + .d-xl-flex { + display: flex !important; + } + + .d-xl-inline-flex { + display: inline-flex !important; + } +} + +@media print { + .d-print-none { + display: none !important; + } + + .d-print-inline { + display: inline !important; + } + + .d-print-inline-block { + display: inline-block !important; + } + + .d-print-block { + display: block !important; + } + + .d-print-table { + display: table !important; + } + + .d-print-table-row { + display: table-row !important; + } + + .d-print-table-cell { + display: table-cell !important; + } + + .d-print-flex { + display: flex !important; + } + + .d-print-inline-flex { + display: inline-flex !important; + } +} + +.embed-responsive { + position: relative; + display: block; + width: 100%; + padding: 0; + overflow: hidden; +} + +.embed-responsive::before { + display: block; + content: ""; +} + +.embed-responsive .embed-responsive-item, +.embed-responsive iframe, +.embed-responsive embed, +.embed-responsive object, +.embed-responsive video { + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 100%; + height: 100%; + border: 0; +} + +.embed-responsive-21by9::before { + padding-top: 42.8571428571%; +} + +.embed-responsive-16by9::before { + padding-top: 56.25%; +} + +.embed-responsive-4by3::before { + padding-top: 75%; +} + +.embed-responsive-1by1::before { + padding-top: 100%; +} + +.flex-row { + flex-direction: row !important; +} + +.flex-column { + flex-direction: column !important; +} + +.flex-row-reverse { + flex-direction: row-reverse !important; +} + +.flex-column-reverse { + flex-direction: column-reverse !important; +} + +.flex-wrap { + flex-wrap: wrap !important; +} + +.flex-nowrap { + flex-wrap: nowrap !important; +} + +.flex-wrap-reverse { + flex-wrap: wrap-reverse !important; +} + +.flex-fill { + flex: 1 1 auto !important; +} + +.flex-grow-0 { + flex-grow: 0 !important; +} + +.flex-grow-1 { + flex-grow: 1 !important; +} + +.flex-shrink-0 { + flex-shrink: 0 !important; +} + +.flex-shrink-1 { + flex-shrink: 1 !important; +} + +.justify-content-start { + justify-content: flex-start !important; +} + +.justify-content-end { + justify-content: flex-end !important; +} + +.justify-content-center { + justify-content: center !important; +} + +.justify-content-between { + justify-content: space-between !important; +} + +.justify-content-around { + justify-content: space-around !important; +} + +.align-items-start { + align-items: flex-start !important; +} + +.align-items-end { + align-items: flex-end !important; +} + +.align-items-center { + align-items: center !important; +} + +.align-items-baseline { + align-items: baseline !important; +} + +.align-items-stretch { + align-items: stretch !important; +} + +.align-content-start { + align-content: flex-start !important; +} + +.align-content-end { + align-content: flex-end !important; +} + +.align-content-center { + align-content: center !important; +} + +.align-content-between { + align-content: space-between !important; +} + +.align-content-around { + align-content: space-around !important; +} + +.align-content-stretch { + align-content: stretch !important; +} + +.align-self-auto { + align-self: auto !important; +} + +.align-self-start { + align-self: flex-start !important; +} + +.align-self-end { + align-self: flex-end !important; +} + +.align-self-center { + align-self: center !important; +} + +.align-self-baseline { + align-self: baseline !important; +} + +.align-self-stretch { + align-self: stretch !important; +} + +@media (min-width: 576px) { + .flex-sm-row { + flex-direction: row !important; + } + + .flex-sm-column { + flex-direction: column !important; + } + + .flex-sm-row-reverse { + flex-direction: row-reverse !important; + } + + .flex-sm-column-reverse { + flex-direction: column-reverse !important; + } + + .flex-sm-wrap { + flex-wrap: wrap !important; + } + + .flex-sm-nowrap { + flex-wrap: nowrap !important; + } + + .flex-sm-wrap-reverse { + flex-wrap: wrap-reverse !important; + } + + .flex-sm-fill { + flex: 1 1 auto !important; + } + + .flex-sm-grow-0 { + flex-grow: 0 !important; + } + + .flex-sm-grow-1 { + flex-grow: 1 !important; + } + + .flex-sm-shrink-0 { + flex-shrink: 0 !important; + } + + .flex-sm-shrink-1 { + flex-shrink: 1 !important; + } + + .justify-content-sm-start { + justify-content: flex-start !important; + } + + .justify-content-sm-end { + justify-content: flex-end !important; + } + + .justify-content-sm-center { + justify-content: center !important; + } + + .justify-content-sm-between { + justify-content: space-between !important; + } + + .justify-content-sm-around { + justify-content: space-around !important; + } + + .align-items-sm-start { + align-items: flex-start !important; + } + + .align-items-sm-end { + align-items: flex-end !important; + } + + .align-items-sm-center { + align-items: center !important; + } + + .align-items-sm-baseline { + align-items: baseline !important; + } + + .align-items-sm-stretch { + align-items: stretch !important; + } + + .align-content-sm-start { + align-content: flex-start !important; + } + + .align-content-sm-end { + align-content: flex-end !important; + } + + .align-content-sm-center { + align-content: center !important; + } + + .align-content-sm-between { + align-content: space-between !important; + } + + .align-content-sm-around { + align-content: space-around !important; + } + + .align-content-sm-stretch { + align-content: stretch !important; + } + + .align-self-sm-auto { + align-self: auto !important; + } + + .align-self-sm-start { + align-self: flex-start !important; + } + + .align-self-sm-end { + align-self: flex-end !important; + } + + .align-self-sm-center { + align-self: center !important; + } + + .align-self-sm-baseline { + align-self: baseline !important; + } + + .align-self-sm-stretch { + align-self: stretch !important; + } +} + +@media (min-width: 768px) { + .flex-md-row { + flex-direction: row !important; + } + + .flex-md-column { + flex-direction: column !important; + } + + .flex-md-row-reverse { + flex-direction: row-reverse !important; + } + + .flex-md-column-reverse { + flex-direction: column-reverse !important; + } + + .flex-md-wrap { + flex-wrap: wrap !important; + } + + .flex-md-nowrap { + flex-wrap: nowrap !important; + } + + .flex-md-wrap-reverse { + flex-wrap: wrap-reverse !important; + } + + .flex-md-fill { + flex: 1 1 auto !important; + } + + .flex-md-grow-0 { + flex-grow: 0 !important; + } + + .flex-md-grow-1 { + flex-grow: 1 !important; + } + + .flex-md-shrink-0 { + flex-shrink: 0 !important; + } + + .flex-md-shrink-1 { + flex-shrink: 1 !important; + } + + .justify-content-md-start { + justify-content: flex-start !important; + } + + .justify-content-md-end { + justify-content: flex-end !important; + } + + .justify-content-md-center { + justify-content: center !important; + } + + .justify-content-md-between { + justify-content: space-between !important; + } + + .justify-content-md-around { + justify-content: space-around !important; + } + + .align-items-md-start { + align-items: flex-start !important; + } + + .align-items-md-end { + align-items: flex-end !important; + } + + .align-items-md-center { + align-items: center !important; + } + + .align-items-md-baseline { + align-items: baseline !important; + } + + .align-items-md-stretch { + align-items: stretch !important; + } + + .align-content-md-start { + align-content: flex-start !important; + } + + .align-content-md-end { + align-content: flex-end !important; + } + + .align-content-md-center { + align-content: center !important; + } + + .align-content-md-between { + align-content: space-between !important; + } + + .align-content-md-around { + align-content: space-around !important; + } + + .align-content-md-stretch { + align-content: stretch !important; + } + + .align-self-md-auto { + align-self: auto !important; + } + + .align-self-md-start { + align-self: flex-start !important; + } + + .align-self-md-end { + align-self: flex-end !important; + } + + .align-self-md-center { + align-self: center !important; + } + + .align-self-md-baseline { + align-self: baseline !important; + } + + .align-self-md-stretch { + align-self: stretch !important; + } +} + +@media (min-width: 992px) { + .flex-lg-row { + flex-direction: row !important; + } + + .flex-lg-column { + flex-direction: column !important; + } + + .flex-lg-row-reverse { + flex-direction: row-reverse !important; + } + + .flex-lg-column-reverse { + flex-direction: column-reverse !important; + } + + .flex-lg-wrap { + flex-wrap: wrap !important; + } + + .flex-lg-nowrap { + flex-wrap: nowrap !important; + } + + .flex-lg-wrap-reverse { + flex-wrap: wrap-reverse !important; + } + + .flex-lg-fill { + flex: 1 1 auto !important; + } + + .flex-lg-grow-0 { + flex-grow: 0 !important; + } + + .flex-lg-grow-1 { + flex-grow: 1 !important; + } + + .flex-lg-shrink-0 { + flex-shrink: 0 !important; + } + + .flex-lg-shrink-1 { + flex-shrink: 1 !important; + } + + .justify-content-lg-start { + justify-content: flex-start !important; + } + + .justify-content-lg-end { + justify-content: flex-end !important; + } + + .justify-content-lg-center { + justify-content: center !important; + } + + .justify-content-lg-between { + justify-content: space-between !important; + } + + .justify-content-lg-around { + justify-content: space-around !important; + } + + .align-items-lg-start { + align-items: flex-start !important; + } + + .align-items-lg-end { + align-items: flex-end !important; + } + + .align-items-lg-center { + align-items: center !important; + } + + .align-items-lg-baseline { + align-items: baseline !important; + } + + .align-items-lg-stretch { + align-items: stretch !important; + } + + .align-content-lg-start { + align-content: flex-start !important; + } + + .align-content-lg-end { + align-content: flex-end !important; + } + + .align-content-lg-center { + align-content: center !important; + } + + .align-content-lg-between { + align-content: space-between !important; + } + + .align-content-lg-around { + align-content: space-around !important; + } + + .align-content-lg-stretch { + align-content: stretch !important; + } + + .align-self-lg-auto { + align-self: auto !important; + } + + .align-self-lg-start { + align-self: flex-start !important; + } + + .align-self-lg-end { + align-self: flex-end !important; + } + + .align-self-lg-center { + align-self: center !important; + } + + .align-self-lg-baseline { + align-self: baseline !important; + } + + .align-self-lg-stretch { + align-self: stretch !important; + } +} + +@media (min-width: 1200px) { + .flex-xl-row { + flex-direction: row !important; + } + + .flex-xl-column { + flex-direction: column !important; + } + + .flex-xl-row-reverse { + flex-direction: row-reverse !important; + } + + .flex-xl-column-reverse { + flex-direction: column-reverse !important; + } + + .flex-xl-wrap { + flex-wrap: wrap !important; + } + + .flex-xl-nowrap { + flex-wrap: nowrap !important; + } + + .flex-xl-wrap-reverse { + flex-wrap: wrap-reverse !important; + } + + .flex-xl-fill { + flex: 1 1 auto !important; + } + + .flex-xl-grow-0 { + flex-grow: 0 !important; + } + + .flex-xl-grow-1 { + flex-grow: 1 !important; + } + + .flex-xl-shrink-0 { + flex-shrink: 0 !important; + } + + .flex-xl-shrink-1 { + flex-shrink: 1 !important; + } + + .justify-content-xl-start { + justify-content: flex-start !important; + } + + .justify-content-xl-end { + justify-content: flex-end !important; + } + + .justify-content-xl-center { + justify-content: center !important; + } + + .justify-content-xl-between { + justify-content: space-between !important; + } + + .justify-content-xl-around { + justify-content: space-around !important; + } + + .align-items-xl-start { + align-items: flex-start !important; + } + + .align-items-xl-end { + align-items: flex-end !important; + } + + .align-items-xl-center { + align-items: center !important; + } + + .align-items-xl-baseline { + align-items: baseline !important; + } + + .align-items-xl-stretch { + align-items: stretch !important; + } + + .align-content-xl-start { + align-content: flex-start !important; + } + + .align-content-xl-end { + align-content: flex-end !important; + } + + .align-content-xl-center { + align-content: center !important; + } + + .align-content-xl-between { + align-content: space-between !important; + } + + .align-content-xl-around { + align-content: space-around !important; + } + + .align-content-xl-stretch { + align-content: stretch !important; + } + + .align-self-xl-auto { + align-self: auto !important; + } + + .align-self-xl-start { + align-self: flex-start !important; + } + + .align-self-xl-end { + align-self: flex-end !important; + } + + .align-self-xl-center { + align-self: center !important; + } + + .align-self-xl-baseline { + align-self: baseline !important; + } + + .align-self-xl-stretch { + align-self: stretch !important; + } +} + +.float-left { + float: left !important; +} + +.float-right { + float: right !important; +} + +.float-none { + float: none !important; +} + +@media (min-width: 576px) { + .float-sm-left { + float: left !important; + } + + .float-sm-right { + float: right !important; + } + + .float-sm-none { + float: none !important; + } +} + +@media (min-width: 768px) { + .float-md-left { + float: left !important; + } + + .float-md-right { + float: right !important; + } + + .float-md-none { + float: none !important; + } +} + +@media (min-width: 992px) { + .float-lg-left { + float: left !important; + } + + .float-lg-right { + float: right !important; + } + + .float-lg-none { + float: none !important; + } +} + +@media (min-width: 1200px) { + .float-xl-left { + float: left !important; + } + + .float-xl-right { + float: right !important; + } + + .float-xl-none { + float: none !important; + } +} + +.overflow-auto { + overflow: auto !important; +} + +.overflow-hidden { + overflow: hidden !important; +} + +.position-static { + position: static !important; +} + +.position-relative { + position: relative !important; +} + +.position-absolute { + position: absolute !important; +} + +.position-fixed { + position: fixed !important; +} + +.position-sticky { + position: sticky !important; +} + +.fixed-top { + position: fixed; + top: 0; + right: 0; + left: 0; + z-index: 1030; +} + +.fixed-bottom { + position: fixed; + right: 0; + bottom: 0; + left: 0; + z-index: 1030; +} + +@supports (position: sticky) { + .sticky-top { + position: sticky; + top: 0; + z-index: 1020; + } +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.sr-only-focusable:active, +.sr-only-focusable:focus { + position: static; + width: auto; + height: auto; + overflow: visible; + clip: auto; + white-space: normal; +} + +.shadow-sm { + box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075) !important; +} + +.shadow { + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important; +} + +.shadow-lg { + box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.175) !important; +} + +.shadow-none { + box-shadow: none !important; +} + +.w-25 { + width: 25% !important; +} + +.w-50 { + width: 50% !important; +} + +.w-75 { + width: 75% !important; +} + +.w-100 { + width: 100% !important; +} + +.w-auto { + width: auto !important; +} + +.h-25 { + height: 25% !important; +} + +.h-50 { + height: 50% !important; +} + +.h-75 { + height: 75% !important; +} + +.h-100 { + height: 100% !important; +} + +.h-auto { + height: auto !important; +} + +.mw-100 { + max-width: 100% !important; +} + +.mh-100 { + max-height: 100% !important; +} + +.min-vw-100 { + min-width: 100vw !important; +} + +.min-vh-100 { + min-height: 100vh !important; +} + +.vw-100 { + width: 100vw !important; +} + +.vh-100 { + height: 100vh !important; +} + +.stretched-link::after { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 1; + pointer-events: auto; + content: ""; + background-color: rgba(0, 0, 0, 0); +} + +.m-0 { + margin: 0 !important; +} + +.mt-0, +.my-0 { + margin-top: 0 !important; +} + +.mr-0, +.mx-0 { + margin-right: 0 !important; +} + +.mb-0, +.my-0 { + margin-bottom: 0 !important; +} + +.ml-0, +.mx-0 { + margin-left: 0 !important; +} + +.m-1 { + margin: 0.25rem !important; +} + +.mt-1, +.my-1 { + margin-top: 0.25rem !important; +} + +.mr-1, +.mx-1 { + margin-right: 0.25rem !important; +} + +.mb-1, +.my-1 { + margin-bottom: 0.25rem !important; +} + +.ml-1, +.mx-1 { + margin-left: 0.25rem !important; +} + +.m-2 { + margin: 0.5rem !important; +} + +.mt-2, +.my-2 { + margin-top: 0.5rem !important; +} + +.mr-2, +.mx-2 { + margin-right: 0.5rem !important; +} + +.mb-2, +.my-2 { + margin-bottom: 0.5rem !important; +} + +.ml-2, +.mx-2 { + margin-left: 0.5rem !important; +} + +.m-3 { + margin: 1rem !important; +} + +.mt-3, +.my-3 { + margin-top: 1rem !important; +} + +.mr-3, +.mx-3 { + margin-right: 1rem !important; +} + +.mb-3, +.my-3 { + margin-bottom: 1rem !important; +} + +.ml-3, +.mx-3 { + margin-left: 1rem !important; +} + +.m-4 { + margin: 1.5rem !important; +} + +.mt-4, +.my-4 { + margin-top: 1.5rem !important; +} + +.mr-4, +.mx-4 { + margin-right: 1.5rem !important; +} + +.mb-4, +.my-4 { + margin-bottom: 1.5rem !important; +} + +.ml-4, +.mx-4 { + margin-left: 1.5rem !important; +} + +.m-5 { + margin: 3rem !important; +} + +.mt-5, +.my-5 { + margin-top: 3rem !important; +} + +.mr-5, +.mx-5 { + margin-right: 3rem !important; +} + +.mb-5, +.my-5 { + margin-bottom: 3rem !important; +} + +.ml-5, +.mx-5 { + margin-left: 3rem !important; +} + +.p-0 { + padding: 0 !important; +} + +.pt-0, +.py-0 { + padding-top: 0 !important; +} + +.pr-0, +.px-0 { + padding-right: 0 !important; +} + +.pb-0, +.py-0 { + padding-bottom: 0 !important; +} + +.pl-0, +.px-0 { + padding-left: 0 !important; +} + +.p-1 { + padding: 0.25rem !important; +} + +.pt-1, +.py-1 { + padding-top: 0.25rem !important; +} + +.pr-1, +.px-1 { + padding-right: 0.25rem !important; +} + +.pb-1, +.py-1 { + padding-bottom: 0.25rem !important; +} + +.pl-1, +.px-1 { + padding-left: 0.25rem !important; +} + +.p-2 { + padding: 0.5rem !important; +} + +.pt-2, +.py-2 { + padding-top: 0.5rem !important; +} + +.pr-2, +.px-2 { + padding-right: 0.5rem !important; +} + +.pb-2, +.py-2 { + padding-bottom: 0.5rem !important; +} + +.pl-2, +.px-2 { + padding-left: 0.5rem !important; +} + +.p-3 { + padding: 1rem !important; +} + +.pt-3, +.py-3 { + padding-top: 1rem !important; +} + +.pr-3, +.px-3 { + padding-right: 1rem !important; +} + +.pb-3, +.py-3 { + padding-bottom: 1rem !important; +} + +.pl-3, +.px-3 { + padding-left: 1rem !important; +} + +.p-4 { + padding: 1.5rem !important; +} + +.pt-4, +.py-4 { + padding-top: 1.5rem !important; +} + +.pr-4, +.px-4 { + padding-right: 1.5rem !important; +} + +.pb-4, +.py-4 { + padding-bottom: 1.5rem !important; +} + +.pl-4, +.px-4 { + padding-left: 1.5rem !important; +} + +.p-5 { + padding: 3rem !important; +} + +.pt-5, +.py-5 { + padding-top: 3rem !important; +} + +.pr-5, +.px-5 { + padding-right: 3rem !important; +} + +.pb-5, +.py-5 { + padding-bottom: 3rem !important; +} + +.pl-5, +.px-5 { + padding-left: 3rem !important; +} + +.m-n1 { + margin: -0.25rem !important; +} + +.mt-n1, +.my-n1 { + margin-top: -0.25rem !important; +} + +.mr-n1, +.mx-n1 { + margin-right: -0.25rem !important; +} + +.mb-n1, +.my-n1 { + margin-bottom: -0.25rem !important; +} + +.ml-n1, +.mx-n1 { + margin-left: -0.25rem !important; +} + +.m-n2 { + margin: -0.5rem !important; +} + +.mt-n2, +.my-n2 { + margin-top: -0.5rem !important; +} + +.mr-n2, +.mx-n2 { + margin-right: -0.5rem !important; +} + +.mb-n2, +.my-n2 { + margin-bottom: -0.5rem !important; +} + +.ml-n2, +.mx-n2 { + margin-left: -0.5rem !important; +} + +.m-n3 { + margin: -1rem !important; +} + +.mt-n3, +.my-n3 { + margin-top: -1rem !important; +} + +.mr-n3, +.mx-n3 { + margin-right: -1rem !important; +} + +.mb-n3, +.my-n3 { + margin-bottom: -1rem !important; +} + +.ml-n3, +.mx-n3 { + margin-left: -1rem !important; +} + +.m-n4 { + margin: -1.5rem !important; +} + +.mt-n4, +.my-n4 { + margin-top: -1.5rem !important; +} + +.mr-n4, +.mx-n4 { + margin-right: -1.5rem !important; +} + +.mb-n4, +.my-n4 { + margin-bottom: -1.5rem !important; +} + +.ml-n4, +.mx-n4 { + margin-left: -1.5rem !important; +} + +.m-n5 { + margin: -3rem !important; +} + +.mt-n5, +.my-n5 { + margin-top: -3rem !important; +} + +.mr-n5, +.mx-n5 { + margin-right: -3rem !important; +} + +.mb-n5, +.my-n5 { + margin-bottom: -3rem !important; +} + +.ml-n5, +.mx-n5 { + margin-left: -3rem !important; +} + +.m-auto { + margin: auto !important; +} + +.mt-auto, +.my-auto { + margin-top: auto !important; +} + +.mr-auto, +.mx-auto { + margin-right: auto !important; +} + +.mb-auto, +.my-auto { + margin-bottom: auto !important; +} + +.ml-auto, +.mx-auto { + margin-left: auto !important; +} + +@media (min-width: 576px) { + .m-sm-0 { + margin: 0 !important; + } + + .mt-sm-0, + .my-sm-0 { + margin-top: 0 !important; + } + + .mr-sm-0, + .mx-sm-0 { + margin-right: 0 !important; + } + + .mb-sm-0, + .my-sm-0 { + margin-bottom: 0 !important; + } + + .ml-sm-0, + .mx-sm-0 { + margin-left: 0 !important; + } + + .m-sm-1 { + margin: 0.25rem !important; + } + + .mt-sm-1, + .my-sm-1 { + margin-top: 0.25rem !important; + } + + .mr-sm-1, + .mx-sm-1 { + margin-right: 0.25rem !important; + } + + .mb-sm-1, + .my-sm-1 { + margin-bottom: 0.25rem !important; + } + + .ml-sm-1, + .mx-sm-1 { + margin-left: 0.25rem !important; + } + + .m-sm-2 { + margin: 0.5rem !important; + } + + .mt-sm-2, + .my-sm-2 { + margin-top: 0.5rem !important; + } + + .mr-sm-2, + .mx-sm-2 { + margin-right: 0.5rem !important; + } + + .mb-sm-2, + .my-sm-2 { + margin-bottom: 0.5rem !important; + } + + .ml-sm-2, + .mx-sm-2 { + margin-left: 0.5rem !important; + } + + .m-sm-3 { + margin: 1rem !important; + } + + .mt-sm-3, + .my-sm-3 { + margin-top: 1rem !important; + } + + .mr-sm-3, + .mx-sm-3 { + margin-right: 1rem !important; + } + + .mb-sm-3, + .my-sm-3 { + margin-bottom: 1rem !important; + } + + .ml-sm-3, + .mx-sm-3 { + margin-left: 1rem !important; + } + + .m-sm-4 { + margin: 1.5rem !important; + } + + .mt-sm-4, + .my-sm-4 { + margin-top: 1.5rem !important; + } + + .mr-sm-4, + .mx-sm-4 { + margin-right: 1.5rem !important; + } + + .mb-sm-4, + .my-sm-4 { + margin-bottom: 1.5rem !important; + } + + .ml-sm-4, + .mx-sm-4 { + margin-left: 1.5rem !important; + } + + .m-sm-5 { + margin: 3rem !important; + } + + .mt-sm-5, + .my-sm-5 { + margin-top: 3rem !important; + } + + .mr-sm-5, + .mx-sm-5 { + margin-right: 3rem !important; + } + + .mb-sm-5, + .my-sm-5 { + margin-bottom: 3rem !important; + } + + .ml-sm-5, + .mx-sm-5 { + margin-left: 3rem !important; + } + + .p-sm-0 { + padding: 0 !important; + } + + .pt-sm-0, + .py-sm-0 { + padding-top: 0 !important; + } + + .pr-sm-0, + .px-sm-0 { + padding-right: 0 !important; + } + + .pb-sm-0, + .py-sm-0 { + padding-bottom: 0 !important; + } + + .pl-sm-0, + .px-sm-0 { + padding-left: 0 !important; + } + + .p-sm-1 { + padding: 0.25rem !important; + } + + .pt-sm-1, + .py-sm-1 { + padding-top: 0.25rem !important; + } + + .pr-sm-1, + .px-sm-1 { + padding-right: 0.25rem !important; + } + + .pb-sm-1, + .py-sm-1 { + padding-bottom: 0.25rem !important; + } + + .pl-sm-1, + .px-sm-1 { + padding-left: 0.25rem !important; + } + + .p-sm-2 { + padding: 0.5rem !important; + } + + .pt-sm-2, + .py-sm-2 { + padding-top: 0.5rem !important; + } + + .pr-sm-2, + .px-sm-2 { + padding-right: 0.5rem !important; + } + + .pb-sm-2, + .py-sm-2 { + padding-bottom: 0.5rem !important; + } + + .pl-sm-2, + .px-sm-2 { + padding-left: 0.5rem !important; + } + + .p-sm-3 { + padding: 1rem !important; + } + + .pt-sm-3, + .py-sm-3 { + padding-top: 1rem !important; + } + + .pr-sm-3, + .px-sm-3 { + padding-right: 1rem !important; + } + + .pb-sm-3, + .py-sm-3 { + padding-bottom: 1rem !important; + } + + .pl-sm-3, + .px-sm-3 { + padding-left: 1rem !important; + } + + .p-sm-4 { + padding: 1.5rem !important; + } + + .pt-sm-4, + .py-sm-4 { + padding-top: 1.5rem !important; + } + + .pr-sm-4, + .px-sm-4 { + padding-right: 1.5rem !important; + } + + .pb-sm-4, + .py-sm-4 { + padding-bottom: 1.5rem !important; + } + + .pl-sm-4, + .px-sm-4 { + padding-left: 1.5rem !important; + } + + .p-sm-5 { + padding: 3rem !important; + } + + .pt-sm-5, + .py-sm-5 { + padding-top: 3rem !important; + } + + .pr-sm-5, + .px-sm-5 { + padding-right: 3rem !important; + } + + .pb-sm-5, + .py-sm-5 { + padding-bottom: 3rem !important; + } + + .pl-sm-5, + .px-sm-5 { + padding-left: 3rem !important; + } + + .m-sm-n1 { + margin: -0.25rem !important; + } + + .mt-sm-n1, + .my-sm-n1 { + margin-top: -0.25rem !important; + } + + .mr-sm-n1, + .mx-sm-n1 { + margin-right: -0.25rem !important; + } + + .mb-sm-n1, + .my-sm-n1 { + margin-bottom: -0.25rem !important; + } + + .ml-sm-n1, + .mx-sm-n1 { + margin-left: -0.25rem !important; + } + + .m-sm-n2 { + margin: -0.5rem !important; + } + + .mt-sm-n2, + .my-sm-n2 { + margin-top: -0.5rem !important; + } + + .mr-sm-n2, + .mx-sm-n2 { + margin-right: -0.5rem !important; + } + + .mb-sm-n2, + .my-sm-n2 { + margin-bottom: -0.5rem !important; + } + + .ml-sm-n2, + .mx-sm-n2 { + margin-left: -0.5rem !important; + } + + .m-sm-n3 { + margin: -1rem !important; + } + + .mt-sm-n3, + .my-sm-n3 { + margin-top: -1rem !important; + } + + .mr-sm-n3, + .mx-sm-n3 { + margin-right: -1rem !important; + } + + .mb-sm-n3, + .my-sm-n3 { + margin-bottom: -1rem !important; + } + + .ml-sm-n3, + .mx-sm-n3 { + margin-left: -1rem !important; + } + + .m-sm-n4 { + margin: -1.5rem !important; + } + + .mt-sm-n4, + .my-sm-n4 { + margin-top: -1.5rem !important; + } + + .mr-sm-n4, + .mx-sm-n4 { + margin-right: -1.5rem !important; + } + + .mb-sm-n4, + .my-sm-n4 { + margin-bottom: -1.5rem !important; + } + + .ml-sm-n4, + .mx-sm-n4 { + margin-left: -1.5rem !important; + } + + .m-sm-n5 { + margin: -3rem !important; + } + + .mt-sm-n5, + .my-sm-n5 { + margin-top: -3rem !important; + } + + .mr-sm-n5, + .mx-sm-n5 { + margin-right: -3rem !important; + } + + .mb-sm-n5, + .my-sm-n5 { + margin-bottom: -3rem !important; + } + + .ml-sm-n5, + .mx-sm-n5 { + margin-left: -3rem !important; + } + + .m-sm-auto { + margin: auto !important; + } + + .mt-sm-auto, + .my-sm-auto { + margin-top: auto !important; + } + + .mr-sm-auto, + .mx-sm-auto { + margin-right: auto !important; + } + + .mb-sm-auto, + .my-sm-auto { + margin-bottom: auto !important; + } + + .ml-sm-auto, + .mx-sm-auto { + margin-left: auto !important; + } +} + +@media (min-width: 768px) { + .m-md-0 { + margin: 0 !important; + } + + .mt-md-0, + .my-md-0 { + margin-top: 0 !important; + } + + .mr-md-0, + .mx-md-0 { + margin-right: 0 !important; + } + + .mb-md-0, + .my-md-0 { + margin-bottom: 0 !important; + } + + .ml-md-0, + .mx-md-0 { + margin-left: 0 !important; + } + + .m-md-1 { + margin: 0.25rem !important; + } + + .mt-md-1, + .my-md-1 { + margin-top: 0.25rem !important; + } + + .mr-md-1, + .mx-md-1 { + margin-right: 0.25rem !important; + } + + .mb-md-1, + .my-md-1 { + margin-bottom: 0.25rem !important; + } + + .ml-md-1, + .mx-md-1 { + margin-left: 0.25rem !important; + } + + .m-md-2 { + margin: 0.5rem !important; + } + + .mt-md-2, + .my-md-2 { + margin-top: 0.5rem !important; + } + + .mr-md-2, + .mx-md-2 { + margin-right: 0.5rem !important; + } + + .mb-md-2, + .my-md-2 { + margin-bottom: 0.5rem !important; + } + + .ml-md-2, + .mx-md-2 { + margin-left: 0.5rem !important; + } + + .m-md-3 { + margin: 1rem !important; + } + + .mt-md-3, + .my-md-3 { + margin-top: 1rem !important; + } + + .mr-md-3, + .mx-md-3 { + margin-right: 1rem !important; + } + + .mb-md-3, + .my-md-3 { + margin-bottom: 1rem !important; + } + + .ml-md-3, + .mx-md-3 { + margin-left: 1rem !important; + } + + .m-md-4 { + margin: 1.5rem !important; + } + + .mt-md-4, + .my-md-4 { + margin-top: 1.5rem !important; + } + + .mr-md-4, + .mx-md-4 { + margin-right: 1.5rem !important; + } + + .mb-md-4, + .my-md-4 { + margin-bottom: 1.5rem !important; + } + + .ml-md-4, + .mx-md-4 { + margin-left: 1.5rem !important; + } + + .m-md-5 { + margin: 3rem !important; + } + + .mt-md-5, + .my-md-5 { + margin-top: 3rem !important; + } + + .mr-md-5, + .mx-md-5 { + margin-right: 3rem !important; + } + + .mb-md-5, + .my-md-5 { + margin-bottom: 3rem !important; + } + + .ml-md-5, + .mx-md-5 { + margin-left: 3rem !important; + } + + .p-md-0 { + padding: 0 !important; + } + + .pt-md-0, + .py-md-0 { + padding-top: 0 !important; + } + + .pr-md-0, + .px-md-0 { + padding-right: 0 !important; + } + + .pb-md-0, + .py-md-0 { + padding-bottom: 0 !important; + } + + .pl-md-0, + .px-md-0 { + padding-left: 0 !important; + } + + .p-md-1 { + padding: 0.25rem !important; + } + + .pt-md-1, + .py-md-1 { + padding-top: 0.25rem !important; + } + + .pr-md-1, + .px-md-1 { + padding-right: 0.25rem !important; + } + + .pb-md-1, + .py-md-1 { + padding-bottom: 0.25rem !important; + } + + .pl-md-1, + .px-md-1 { + padding-left: 0.25rem !important; + } + + .p-md-2 { + padding: 0.5rem !important; + } + + .pt-md-2, + .py-md-2 { + padding-top: 0.5rem !important; + } + + .pr-md-2, + .px-md-2 { + padding-right: 0.5rem !important; + } + + .pb-md-2, + .py-md-2 { + padding-bottom: 0.5rem !important; + } + + .pl-md-2, + .px-md-2 { + padding-left: 0.5rem !important; + } + + .p-md-3 { + padding: 1rem !important; + } + + .pt-md-3, + .py-md-3 { + padding-top: 1rem !important; + } + + .pr-md-3, + .px-md-3 { + padding-right: 1rem !important; + } + + .pb-md-3, + .py-md-3 { + padding-bottom: 1rem !important; + } + + .pl-md-3, + .px-md-3 { + padding-left: 1rem !important; + } + + .p-md-4 { + padding: 1.5rem !important; + } + + .pt-md-4, + .py-md-4 { + padding-top: 1.5rem !important; + } + + .pr-md-4, + .px-md-4 { + padding-right: 1.5rem !important; + } + + .pb-md-4, + .py-md-4 { + padding-bottom: 1.5rem !important; + } + + .pl-md-4, + .px-md-4 { + padding-left: 1.5rem !important; + } + + .p-md-5 { + padding: 3rem !important; + } + + .pt-md-5, + .py-md-5 { + padding-top: 3rem !important; + } + + .pr-md-5, + .px-md-5 { + padding-right: 3rem !important; + } + + .pb-md-5, + .py-md-5 { + padding-bottom: 3rem !important; + } + + .pl-md-5, + .px-md-5 { + padding-left: 3rem !important; + } + + .m-md-n1 { + margin: -0.25rem !important; + } + + .mt-md-n1, + .my-md-n1 { + margin-top: -0.25rem !important; + } + + .mr-md-n1, + .mx-md-n1 { + margin-right: -0.25rem !important; + } + + .mb-md-n1, + .my-md-n1 { + margin-bottom: -0.25rem !important; + } + + .ml-md-n1, + .mx-md-n1 { + margin-left: -0.25rem !important; + } + + .m-md-n2 { + margin: -0.5rem !important; + } + + .mt-md-n2, + .my-md-n2 { + margin-top: -0.5rem !important; + } + + .mr-md-n2, + .mx-md-n2 { + margin-right: -0.5rem !important; + } + + .mb-md-n2, + .my-md-n2 { + margin-bottom: -0.5rem !important; + } + + .ml-md-n2, + .mx-md-n2 { + margin-left: -0.5rem !important; + } + + .m-md-n3 { + margin: -1rem !important; + } + + .mt-md-n3, + .my-md-n3 { + margin-top: -1rem !important; + } + + .mr-md-n3, + .mx-md-n3 { + margin-right: -1rem !important; + } + + .mb-md-n3, + .my-md-n3 { + margin-bottom: -1rem !important; + } + + .ml-md-n3, + .mx-md-n3 { + margin-left: -1rem !important; + } + + .m-md-n4 { + margin: -1.5rem !important; + } + + .mt-md-n4, + .my-md-n4 { + margin-top: -1.5rem !important; + } + + .mr-md-n4, + .mx-md-n4 { + margin-right: -1.5rem !important; + } + + .mb-md-n4, + .my-md-n4 { + margin-bottom: -1.5rem !important; + } + + .ml-md-n4, + .mx-md-n4 { + margin-left: -1.5rem !important; + } + + .m-md-n5 { + margin: -3rem !important; + } + + .mt-md-n5, + .my-md-n5 { + margin-top: -3rem !important; + } + + .mr-md-n5, + .mx-md-n5 { + margin-right: -3rem !important; + } + + .mb-md-n5, + .my-md-n5 { + margin-bottom: -3rem !important; + } + + .ml-md-n5, + .mx-md-n5 { + margin-left: -3rem !important; + } + + .m-md-auto { + margin: auto !important; + } + + .mt-md-auto, + .my-md-auto { + margin-top: auto !important; + } + + .mr-md-auto, + .mx-md-auto { + margin-right: auto !important; + } + + .mb-md-auto, + .my-md-auto { + margin-bottom: auto !important; + } + + .ml-md-auto, + .mx-md-auto { + margin-left: auto !important; + } +} + +@media (min-width: 992px) { + .m-lg-0 { + margin: 0 !important; + } + + .mt-lg-0, + .my-lg-0 { + margin-top: 0 !important; + } + + .mr-lg-0, + .mx-lg-0 { + margin-right: 0 !important; + } + + .mb-lg-0, + .my-lg-0 { + margin-bottom: 0 !important; + } + + .ml-lg-0, + .mx-lg-0 { + margin-left: 0 !important; + } + + .m-lg-1 { + margin: 0.25rem !important; + } + + .mt-lg-1, + .my-lg-1 { + margin-top: 0.25rem !important; + } + + .mr-lg-1, + .mx-lg-1 { + margin-right: 0.25rem !important; + } + + .mb-lg-1, + .my-lg-1 { + margin-bottom: 0.25rem !important; + } + + .ml-lg-1, + .mx-lg-1 { + margin-left: 0.25rem !important; + } + + .m-lg-2 { + margin: 0.5rem !important; + } + + .mt-lg-2, + .my-lg-2 { + margin-top: 0.5rem !important; + } + + .mr-lg-2, + .mx-lg-2 { + margin-right: 0.5rem !important; + } + + .mb-lg-2, + .my-lg-2 { + margin-bottom: 0.5rem !important; + } + + .ml-lg-2, + .mx-lg-2 { + margin-left: 0.5rem !important; + } + + .m-lg-3 { + margin: 1rem !important; + } + + .mt-lg-3, + .my-lg-3 { + margin-top: 1rem !important; + } + + .mr-lg-3, + .mx-lg-3 { + margin-right: 1rem !important; + } + + .mb-lg-3, + .my-lg-3 { + margin-bottom: 1rem !important; + } + + .ml-lg-3, + .mx-lg-3 { + margin-left: 1rem !important; + } + + .m-lg-4 { + margin: 1.5rem !important; + } + + .mt-lg-4, + .my-lg-4 { + margin-top: 1.5rem !important; + } + + .mr-lg-4, + .mx-lg-4 { + margin-right: 1.5rem !important; + } + + .mb-lg-4, + .my-lg-4 { + margin-bottom: 1.5rem !important; + } + + .ml-lg-4, + .mx-lg-4 { + margin-left: 1.5rem !important; + } + + .m-lg-5 { + margin: 3rem !important; + } + + .mt-lg-5, + .my-lg-5 { + margin-top: 3rem !important; + } + + .mr-lg-5, + .mx-lg-5 { + margin-right: 3rem !important; + } + + .mb-lg-5, + .my-lg-5 { + margin-bottom: 3rem !important; + } + + .ml-lg-5, + .mx-lg-5 { + margin-left: 3rem !important; + } + + .p-lg-0 { + padding: 0 !important; + } + + .pt-lg-0, + .py-lg-0 { + padding-top: 0 !important; + } + + .pr-lg-0, + .px-lg-0 { + padding-right: 0 !important; + } + + .pb-lg-0, + .py-lg-0 { + padding-bottom: 0 !important; + } + + .pl-lg-0, + .px-lg-0 { + padding-left: 0 !important; + } + + .p-lg-1 { + padding: 0.25rem !important; + } + + .pt-lg-1, + .py-lg-1 { + padding-top: 0.25rem !important; + } + + .pr-lg-1, + .px-lg-1 { + padding-right: 0.25rem !important; + } + + .pb-lg-1, + .py-lg-1 { + padding-bottom: 0.25rem !important; + } + + .pl-lg-1, + .px-lg-1 { + padding-left: 0.25rem !important; + } + + .p-lg-2 { + padding: 0.5rem !important; + } + + .pt-lg-2, + .py-lg-2 { + padding-top: 0.5rem !important; + } + + .pr-lg-2, + .px-lg-2 { + padding-right: 0.5rem !important; + } + + .pb-lg-2, + .py-lg-2 { + padding-bottom: 0.5rem !important; + } + + .pl-lg-2, + .px-lg-2 { + padding-left: 0.5rem !important; + } + + .p-lg-3 { + padding: 1rem !important; + } + + .pt-lg-3, + .py-lg-3 { + padding-top: 1rem !important; + } + + .pr-lg-3, + .px-lg-3 { + padding-right: 1rem !important; + } + + .pb-lg-3, + .py-lg-3 { + padding-bottom: 1rem !important; + } + + .pl-lg-3, + .px-lg-3 { + padding-left: 1rem !important; + } + + .p-lg-4 { + padding: 1.5rem !important; + } + + .pt-lg-4, + .py-lg-4 { + padding-top: 1.5rem !important; + } + + .pr-lg-4, + .px-lg-4 { + padding-right: 1.5rem !important; + } + + .pb-lg-4, + .py-lg-4 { + padding-bottom: 1.5rem !important; + } + + .pl-lg-4, + .px-lg-4 { + padding-left: 1.5rem !important; + } + + .p-lg-5 { + padding: 3rem !important; + } + + .pt-lg-5, + .py-lg-5 { + padding-top: 3rem !important; + } + + .pr-lg-5, + .px-lg-5 { + padding-right: 3rem !important; + } + + .pb-lg-5, + .py-lg-5 { + padding-bottom: 3rem !important; + } + + .pl-lg-5, + .px-lg-5 { + padding-left: 3rem !important; + } + + .m-lg-n1 { + margin: -0.25rem !important; + } + + .mt-lg-n1, + .my-lg-n1 { + margin-top: -0.25rem !important; + } + + .mr-lg-n1, + .mx-lg-n1 { + margin-right: -0.25rem !important; + } + + .mb-lg-n1, + .my-lg-n1 { + margin-bottom: -0.25rem !important; + } + + .ml-lg-n1, + .mx-lg-n1 { + margin-left: -0.25rem !important; + } + + .m-lg-n2 { + margin: -0.5rem !important; + } + + .mt-lg-n2, + .my-lg-n2 { + margin-top: -0.5rem !important; + } + + .mr-lg-n2, + .mx-lg-n2 { + margin-right: -0.5rem !important; + } + + .mb-lg-n2, + .my-lg-n2 { + margin-bottom: -0.5rem !important; + } + + .ml-lg-n2, + .mx-lg-n2 { + margin-left: -0.5rem !important; + } + + .m-lg-n3 { + margin: -1rem !important; + } + + .mt-lg-n3, + .my-lg-n3 { + margin-top: -1rem !important; + } + + .mr-lg-n3, + .mx-lg-n3 { + margin-right: -1rem !important; + } + + .mb-lg-n3, + .my-lg-n3 { + margin-bottom: -1rem !important; + } + + .ml-lg-n3, + .mx-lg-n3 { + margin-left: -1rem !important; + } + + .m-lg-n4 { + margin: -1.5rem !important; + } + + .mt-lg-n4, + .my-lg-n4 { + margin-top: -1.5rem !important; + } + + .mr-lg-n4, + .mx-lg-n4 { + margin-right: -1.5rem !important; + } + + .mb-lg-n4, + .my-lg-n4 { + margin-bottom: -1.5rem !important; + } + + .ml-lg-n4, + .mx-lg-n4 { + margin-left: -1.5rem !important; + } + + .m-lg-n5 { + margin: -3rem !important; + } + + .mt-lg-n5, + .my-lg-n5 { + margin-top: -3rem !important; + } + + .mr-lg-n5, + .mx-lg-n5 { + margin-right: -3rem !important; + } + + .mb-lg-n5, + .my-lg-n5 { + margin-bottom: -3rem !important; + } + + .ml-lg-n5, + .mx-lg-n5 { + margin-left: -3rem !important; + } + + .m-lg-auto { + margin: auto !important; + } + + .mt-lg-auto, + .my-lg-auto { + margin-top: auto !important; + } + + .mr-lg-auto, + .mx-lg-auto { + margin-right: auto !important; + } + + .mb-lg-auto, + .my-lg-auto { + margin-bottom: auto !important; + } + + .ml-lg-auto, + .mx-lg-auto { + margin-left: auto !important; + } +} + +@media (min-width: 1200px) { + .m-xl-0 { + margin: 0 !important; + } + + .mt-xl-0, + .my-xl-0 { + margin-top: 0 !important; + } + + .mr-xl-0, + .mx-xl-0 { + margin-right: 0 !important; + } + + .mb-xl-0, + .my-xl-0 { + margin-bottom: 0 !important; + } + + .ml-xl-0, + .mx-xl-0 { + margin-left: 0 !important; + } + + .m-xl-1 { + margin: 0.25rem !important; + } + + .mt-xl-1, + .my-xl-1 { + margin-top: 0.25rem !important; + } + + .mr-xl-1, + .mx-xl-1 { + margin-right: 0.25rem !important; + } + + .mb-xl-1, + .my-xl-1 { + margin-bottom: 0.25rem !important; + } + + .ml-xl-1, + .mx-xl-1 { + margin-left: 0.25rem !important; + } + + .m-xl-2 { + margin: 0.5rem !important; + } + + .mt-xl-2, + .my-xl-2 { + margin-top: 0.5rem !important; + } + + .mr-xl-2, + .mx-xl-2 { + margin-right: 0.5rem !important; + } + + .mb-xl-2, + .my-xl-2 { + margin-bottom: 0.5rem !important; + } + + .ml-xl-2, + .mx-xl-2 { + margin-left: 0.5rem !important; + } + + .m-xl-3 { + margin: 1rem !important; + } + + .mt-xl-3, + .my-xl-3 { + margin-top: 1rem !important; + } + + .mr-xl-3, + .mx-xl-3 { + margin-right: 1rem !important; + } + + .mb-xl-3, + .my-xl-3 { + margin-bottom: 1rem !important; + } + + .ml-xl-3, + .mx-xl-3 { + margin-left: 1rem !important; + } + + .m-xl-4 { + margin: 1.5rem !important; + } + + .mt-xl-4, + .my-xl-4 { + margin-top: 1.5rem !important; + } + + .mr-xl-4, + .mx-xl-4 { + margin-right: 1.5rem !important; + } + + .mb-xl-4, + .my-xl-4 { + margin-bottom: 1.5rem !important; + } + + .ml-xl-4, + .mx-xl-4 { + margin-left: 1.5rem !important; + } + + .m-xl-5 { + margin: 3rem !important; + } + + .mt-xl-5, + .my-xl-5 { + margin-top: 3rem !important; + } + + .mr-xl-5, + .mx-xl-5 { + margin-right: 3rem !important; + } + + .mb-xl-5, + .my-xl-5 { + margin-bottom: 3rem !important; + } + + .ml-xl-5, + .mx-xl-5 { + margin-left: 3rem !important; + } + + .p-xl-0 { + padding: 0 !important; + } + + .pt-xl-0, + .py-xl-0 { + padding-top: 0 !important; + } + + .pr-xl-0, + .px-xl-0 { + padding-right: 0 !important; + } + + .pb-xl-0, + .py-xl-0 { + padding-bottom: 0 !important; + } + + .pl-xl-0, + .px-xl-0 { + padding-left: 0 !important; + } + + .p-xl-1 { + padding: 0.25rem !important; + } + + .pt-xl-1, + .py-xl-1 { + padding-top: 0.25rem !important; + } + + .pr-xl-1, + .px-xl-1 { + padding-right: 0.25rem !important; + } + + .pb-xl-1, + .py-xl-1 { + padding-bottom: 0.25rem !important; + } + + .pl-xl-1, + .px-xl-1 { + padding-left: 0.25rem !important; + } + + .p-xl-2 { + padding: 0.5rem !important; + } + + .pt-xl-2, + .py-xl-2 { + padding-top: 0.5rem !important; + } + + .pr-xl-2, + .px-xl-2 { + padding-right: 0.5rem !important; + } + + .pb-xl-2, + .py-xl-2 { + padding-bottom: 0.5rem !important; + } + + .pl-xl-2, + .px-xl-2 { + padding-left: 0.5rem !important; + } + + .p-xl-3 { + padding: 1rem !important; + } + + .pt-xl-3, + .py-xl-3 { + padding-top: 1rem !important; + } + + .pr-xl-3, + .px-xl-3 { + padding-right: 1rem !important; + } + + .pb-xl-3, + .py-xl-3 { + padding-bottom: 1rem !important; + } + + .pl-xl-3, + .px-xl-3 { + padding-left: 1rem !important; + } + + .p-xl-4 { + padding: 1.5rem !important; + } + + .pt-xl-4, + .py-xl-4 { + padding-top: 1.5rem !important; + } + + .pr-xl-4, + .px-xl-4 { + padding-right: 1.5rem !important; + } + + .pb-xl-4, + .py-xl-4 { + padding-bottom: 1.5rem !important; + } + + .pl-xl-4, + .px-xl-4 { + padding-left: 1.5rem !important; + } + + .p-xl-5 { + padding: 3rem !important; + } + + .pt-xl-5, + .py-xl-5 { + padding-top: 3rem !important; + } + + .pr-xl-5, + .px-xl-5 { + padding-right: 3rem !important; + } + + .pb-xl-5, + .py-xl-5 { + padding-bottom: 3rem !important; + } + + .pl-xl-5, + .px-xl-5 { + padding-left: 3rem !important; + } + + .m-xl-n1 { + margin: -0.25rem !important; + } + + .mt-xl-n1, + .my-xl-n1 { + margin-top: -0.25rem !important; + } + + .mr-xl-n1, + .mx-xl-n1 { + margin-right: -0.25rem !important; + } + + .mb-xl-n1, + .my-xl-n1 { + margin-bottom: -0.25rem !important; + } + + .ml-xl-n1, + .mx-xl-n1 { + margin-left: -0.25rem !important; + } + + .m-xl-n2 { + margin: -0.5rem !important; + } + + .mt-xl-n2, + .my-xl-n2 { + margin-top: -0.5rem !important; + } + + .mr-xl-n2, + .mx-xl-n2 { + margin-right: -0.5rem !important; + } + + .mb-xl-n2, + .my-xl-n2 { + margin-bottom: -0.5rem !important; + } + + .ml-xl-n2, + .mx-xl-n2 { + margin-left: -0.5rem !important; + } + + .m-xl-n3 { + margin: -1rem !important; + } + + .mt-xl-n3, + .my-xl-n3 { + margin-top: -1rem !important; + } + + .mr-xl-n3, + .mx-xl-n3 { + margin-right: -1rem !important; + } + + .mb-xl-n3, + .my-xl-n3 { + margin-bottom: -1rem !important; + } + + .ml-xl-n3, + .mx-xl-n3 { + margin-left: -1rem !important; + } + + .m-xl-n4 { + margin: -1.5rem !important; + } + + .mt-xl-n4, + .my-xl-n4 { + margin-top: -1.5rem !important; + } + + .mr-xl-n4, + .mx-xl-n4 { + margin-right: -1.5rem !important; + } + + .mb-xl-n4, + .my-xl-n4 { + margin-bottom: -1.5rem !important; + } + + .ml-xl-n4, + .mx-xl-n4 { + margin-left: -1.5rem !important; + } + + .m-xl-n5 { + margin: -3rem !important; + } + + .mt-xl-n5, + .my-xl-n5 { + margin-top: -3rem !important; + } + + .mr-xl-n5, + .mx-xl-n5 { + margin-right: -3rem !important; + } + + .mb-xl-n5, + .my-xl-n5 { + margin-bottom: -3rem !important; + } + + .ml-xl-n5, + .mx-xl-n5 { + margin-left: -3rem !important; + } + + .m-xl-auto { + margin: auto !important; + } + + .mt-xl-auto, + .my-xl-auto { + margin-top: auto !important; + } + + .mr-xl-auto, + .mx-xl-auto { + margin-right: auto !important; + } + + .mb-xl-auto, + .my-xl-auto { + margin-bottom: auto !important; + } + + .ml-xl-auto, + .mx-xl-auto { + margin-left: auto !important; + } +} + +.text-monospace { + font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", + "Courier New", monospace !important; +} + +.text-justify { + text-align: justify !important; +} + +.text-wrap { + white-space: normal !important; +} + +.text-nowrap { + white-space: nowrap !important; +} + +.text-truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.text-left { + text-align: left !important; +} + +.text-right { + text-align: right !important; +} + +.text-center { + text-align: center !important; +} + +@media (min-width: 576px) { + .text-sm-left { + text-align: left !important; + } + + .text-sm-right { + text-align: right !important; + } + + .text-sm-center { + text-align: center !important; + } +} + +@media (min-width: 768px) { + .text-md-left { + text-align: left !important; + } + + .text-md-right { + text-align: right !important; + } + + .text-md-center { + text-align: center !important; + } +} + +@media (min-width: 992px) { + .text-lg-left { + text-align: left !important; + } + + .text-lg-right { + text-align: right !important; + } + + .text-lg-center { + text-align: center !important; + } +} + +@media (min-width: 1200px) { + .text-xl-left { + text-align: left !important; + } + + .text-xl-right { + text-align: right !important; + } + + .text-xl-center { + text-align: center !important; + } +} + +.text-lowercase { + text-transform: lowercase !important; +} + +.text-uppercase { + text-transform: uppercase !important; +} + +.text-capitalize { + text-transform: capitalize !important; +} + +.font-weight-light { + font-weight: 300 !important; +} + +.font-weight-lighter { + font-weight: lighter !important; +} + +.font-weight-normal { + font-weight: 400 !important; +} + +.font-weight-bold { + font-weight: 700 !important; +} + +.font-weight-bolder { + font-weight: bolder !important; +} + +.font-italic { + font-style: italic !important; +} + +.text-white { + color: #fff !important; +} + +.text-primary { + color: #3490dc !important; +} + +a.text-primary:hover, +a.text-primary:focus { + color: #1d68a7 !important; +} + +.text-secondary { + color: #6c757d !important; +} + +a.text-secondary:hover, +a.text-secondary:focus { + color: #494f54 !important; +} + +.text-success { + color: #38c172 !important; +} + +a.text-success:hover, +a.text-success:focus { + color: #27864f !important; +} + +.text-info { + color: #6cb2eb !important; +} + +a.text-info:hover, +a.text-info:focus { + color: #298fe2 !important; +} + +.text-warning { + color: #ffed4a !important; +} + +a.text-warning:hover, +a.text-warning:focus { + color: #fde300 !important; +} + +.text-danger { + color: #e3342f !important; +} + +a.text-danger:hover, +a.text-danger:focus { + color: #ae1c17 !important; +} + +.text-light { + color: #f8f9fa !important; +} + +a.text-light:hover, +a.text-light:focus { + color: #cbd3da !important; +} + +.text-dark { + color: #343a40 !important; +} + +a.text-dark:hover, +a.text-dark:focus { + color: #121416 !important; +} + +.text-body { + color: #212529 !important; +} + +.text-muted { + color: #6c757d !important; +} + +.text-black-50 { + color: rgba(0, 0, 0, 0.5) !important; +} + +.text-white-50 { + color: rgba(255, 255, 255, 0.5) !important; +} + +.text-hide { + font: 0/0 a; + color: transparent; + text-shadow: none; + background-color: transparent; + border: 0; +} + +.text-decoration-none { + text-decoration: none !important; +} + +.text-break { + word-break: break-word !important; + overflow-wrap: break-word !important; +} + +.text-reset { + color: inherit !important; +} + +.visible { + visibility: visible !important; +} + +.invisible { + visibility: hidden !important; +} + +@media print { + *, + *::before, + *::after { + text-shadow: none !important; + box-shadow: none !important; + } + + a:not(.btn) { + text-decoration: underline; + } + + abbr[title]::after { + content: " (" attr(title) ")"; + } + + pre { + white-space: pre-wrap !important; + } + + pre, + blockquote { + border: 1px solid #adb5bd; + page-break-inside: avoid; + } + + thead { + display: table-header-group; + } + + tr, + img { + page-break-inside: avoid; + } + + p, + h2, + h3 { + orphans: 3; + widows: 3; + } + + h2, + h3 { + page-break-after: avoid; + } + + @page { + size: a3; + } + + body { + min-width: 992px !important; + } + + .container { + min-width: 992px !important; + } + + .navbar { + display: none; + } + + .badge { + border: 1px solid #000; + } + + .table { + border-collapse: collapse !important; + } + + .table td, + .table th { + background-color: #fff !important; + } + + .table-bordered th, + .table-bordered td { + border: 1px solid #dee2e6 !important; + } + + .table-dark { + color: inherit; + } + + .table-dark th, + .table-dark td, + .table-dark thead th, + .table-dark tbody + tbody { + border-color: #dee2e6; + } + + .table .thead-dark th { + color: inherit; + border-color: #dee2e6; + } +} + +/* + * The MIT License + * Copyright (c) 2012 Matias Meno + */ + +@keyframes passing-through { + 0% { + opacity: 0; + transform: translateY(40px); + } + + 30%, + 70% { + opacity: 1; + transform: translateY(0px); + } + + 100% { + opacity: 0; + transform: translateY(-40px); + } +} + +@keyframes slide-in { + 0% { + opacity: 0; + transform: translateY(40px); + } + + 30% { + opacity: 1; + transform: translateY(0px); + } +} + +@keyframes pulse { + 0% { + transform: scale(1); + } + + 10% { + transform: scale(1.1); + } + + 20% { + transform: scale(1); + } +} + +.dropzone, +.dropzone * { + box-sizing: border-box; +} + +.dropzone { + min-height: 150px; + border: 2px solid rgba(0, 0, 0, 0.3); + background: white; + padding: 20px 20px; +} + +.dropzone.dz-clickable { + cursor: pointer; +} + +.dropzone.dz-clickable * { + cursor: default; +} + +.dropzone.dz-clickable .dz-message, +.dropzone.dz-clickable .dz-message * { + cursor: pointer; +} + +.dropzone.dz-started .dz-message { + display: none; +} + +.dropzone.dz-drag-hover { + border-style: solid; +} + +.dropzone.dz-drag-hover .dz-message { + opacity: 0.5; +} + +.dropzone .dz-message { + text-align: center; + margin: 2em 0; +} + +.dropzone .dz-message .dz-button { + background: none; + color: inherit; + border: none; + padding: 0; + font: inherit; + cursor: pointer; + outline: inherit; +} + +.dropzone .dz-preview { + position: relative; + display: inline-block; + vertical-align: top; + margin: 16px; + min-height: 100px; +} + +.dropzone .dz-preview:hover { + z-index: 1000; +} + +.dropzone .dz-preview:hover .dz-details { + opacity: 1; +} + +.dropzone .dz-preview.dz-file-preview .dz-image { + border-radius: 20px; + background: #999; + background: linear-gradient(to bottom, #eee, #ddd); +} + +.dropzone .dz-preview.dz-file-preview .dz-details { + opacity: 1; +} + +.dropzone .dz-preview.dz-image-preview { + background: white; +} + +.dropzone .dz-preview.dz-image-preview .dz-details { + transition: opacity 0.2s linear; +} + +.dropzone .dz-preview .dz-remove { + font-size: 14px; + text-align: center; + display: block; + cursor: pointer; + border: none; +} + +.dropzone .dz-preview .dz-remove:hover { + text-decoration: underline; +} + +.dropzone .dz-preview:hover .dz-details { + opacity: 1; +} + +.dropzone .dz-preview .dz-details { + z-index: 20; + position: absolute; + top: 0; + left: 0; + opacity: 0; + font-size: 13px; + min-width: 100%; + max-width: 100%; + padding: 2em 1em; + text-align: center; + color: rgba(0, 0, 0, 0.9); + line-height: 150%; +} + +.dropzone .dz-preview .dz-details .dz-size { + margin-bottom: 1em; + font-size: 16px; +} + +.dropzone .dz-preview .dz-details .dz-filename { + white-space: nowrap; +} + +.dropzone .dz-preview .dz-details .dz-filename:hover span { + border: 1px solid rgba(200, 200, 200, 0.8); + background-color: rgba(255, 255, 255, 0.8); +} + +.dropzone .dz-preview .dz-details .dz-filename:not(:hover) { + overflow: hidden; + text-overflow: ellipsis; +} + +.dropzone .dz-preview .dz-details .dz-filename:not(:hover) span { + border: 1px solid transparent; +} + +.dropzone .dz-preview .dz-details .dz-filename span, +.dropzone .dz-preview .dz-details .dz-size span { + background-color: rgba(255, 255, 255, 0.4); + padding: 0 0.4em; + border-radius: 3px; +} + +.dropzone .dz-preview:hover .dz-image img { + transform: scale(1.05, 1.05); + filter: blur(8px); +} + +.dropzone .dz-preview .dz-image { + border-radius: 20px; + overflow: hidden; + width: 120px; + height: 120px; + position: relative; + display: block; + z-index: 10; +} + +.dropzone .dz-preview .dz-image img { + display: block; +} + +.dropzone .dz-preview.dz-success .dz-success-mark { + animation: passing-through 3s cubic-bezier(0.77, 0, 0.175, 1); +} + +.dropzone .dz-preview.dz-error .dz-error-mark { + opacity: 1; + animation: slide-in 3s cubic-bezier(0.77, 0, 0.175, 1); +} + +.dropzone .dz-preview .dz-success-mark, +.dropzone .dz-preview .dz-error-mark { + pointer-events: none; + opacity: 0; + z-index: 500; + position: absolute; + display: block; + top: 50%; + left: 50%; + margin-left: -27px; + margin-top: -27px; +} + +.dropzone .dz-preview .dz-success-mark svg, +.dropzone .dz-preview .dz-error-mark svg { + display: block; + width: 54px; + height: 54px; +} + +.dropzone .dz-preview.dz-processing .dz-progress { + opacity: 1; + transition: all 0.2s linear; +} + +.dropzone .dz-preview.dz-complete .dz-progress { + opacity: 0; + transition: opacity 0.4s ease-in; +} + +.dropzone .dz-preview:not(.dz-processing) .dz-progress { + animation: pulse 6s ease infinite; +} + +.dropzone .dz-preview .dz-progress { + opacity: 1; + z-index: 1000; + pointer-events: none; + position: absolute; + height: 16px; + left: 50%; + top: 50%; + margin-top: -8px; + width: 80px; + margin-left: -40px; + background: rgba(255, 255, 255, 0.9); + -webkit-transform: scale(1); + border-radius: 8px; + overflow: hidden; +} + +.dropzone .dz-preview .dz-progress .dz-upload { + background: #333; + background: linear-gradient(to bottom, #666, #444); + position: absolute; + top: 0; + left: 0; + bottom: 0; + width: 0; + transition: width 300ms ease-in-out; +} + +.dropzone .dz-preview.dz-error .dz-error-message { + display: block; +} + +.dropzone .dz-preview.dz-error:hover .dz-error-message { + opacity: 1; + pointer-events: auto; +} + +.dropzone .dz-preview .dz-error-message { + pointer-events: none; + z-index: 1000; + position: absolute; + display: block; + display: none; + opacity: 0; + transition: opacity 0.3s ease; + border-radius: 8px; + font-size: 13px; + top: 130px; + left: -10px; + width: 140px; + background: rgb(190, 38, 38); + background: linear-gradient(to bottom, rgb(190, 38, 38), #a92222); + padding: 0.5em 1.2em; + color: white; +} + +.dropzone .dz-preview .dz-error-message:after { + content: ""; + position: absolute; + top: -6px; + left: 64px; + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-bottom: 6px solid rgb(190, 38, 38); +} + +/*! + * Font Awesome Free 5.8.2 by @fontawesome - https://fontawesome.com + * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) + */ + +@font-face { + font-family: "Font Awesome 5 Brands"; + font-style: normal; + font-weight: normal; + font-display: auto; + src: url(/fonts/vendor/@fortawesome/fontawesome-free/webfa-brands-400.eot?189a0edff801d2044be248986b2b2253); + src: url(/fonts/vendor/@fortawesome/fontawesome-free/webfa-brands-400.eot?189a0edff801d2044be248986b2b2253) + format("embedded-opentype"), + url(/fonts/vendor/@fortawesome/fontawesome-free/webfa-brands-400.woff2?68e431f84f6b98081d9d1019d8e773d7) + format("woff2"), + url(/fonts/vendor/@fortawesome/fontawesome-free/webfa-brands-400.woff?74722b5e4403093e9d8e9a6cd8ccd12b) + format("woff"), + url(/fonts/vendor/@fortawesome/fontawesome-free/webfa-brands-400.ttf?f91f38bd74ca6faa255eba6095e386d8) + format("truetype"), + url(/fonts/vendor/@fortawesome/fontawesome-free/webfa-brands-400.svg?b5f2568d87f7096e3e6ade1f0a12dbd2) + format("svg"); +} + +.fab { + font-family: "Font Awesome 5 Brands"; +} + +/*! + * Font Awesome Free 5.8.2 by @fontawesome - https://fontawesome.com + * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) + */ + +@font-face { + font-family: "Font Awesome 5 Free"; + font-style: normal; + font-weight: 400; + font-display: auto; + src: url(/fonts/vendor/@fortawesome/fontawesome-free/webfa-regular-400.eot?4dff12925df6cd4cdac222a99ddfdf59); + src: url(/fonts/vendor/@fortawesome/fontawesome-free/webfa-regular-400.eot?4dff12925df6cd4cdac222a99ddfdf59) + format("embedded-opentype"), + url(/fonts/vendor/@fortawesome/fontawesome-free/webfa-regular-400.woff2?19a6bd8f5a17397a392d83a6b1be6caf) + format("woff2"), + url(/fonts/vendor/@fortawesome/fontawesome-free/webfa-regular-400.woff?7b735d1083d2cc1383b149d47b37b6ea) + format("woff"), + url(/fonts/vendor/@fortawesome/fontawesome-free/webfa-regular-400.ttf?7a39d2695bfaef2880f1ff46618e94ed) + format("truetype"), + url(/fonts/vendor/@fortawesome/fontawesome-free/webfa-regular-400.svg?7d3d94085c631941a749d79dd35a4139) + format("svg"); +} + +.far { + font-family: "Font Awesome 5 Free"; + font-weight: 400; +} + +/*! + * Font Awesome Free 5.8.2 by @fontawesome - https://fontawesome.com + * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) + */ + +@font-face { + font-family: "Font Awesome 5 Free"; + font-style: normal; + font-weight: 900; + font-display: auto; + src: url(/fonts/vendor/@fortawesome/fontawesome-free/webfa-solid-900.eot?660ef93f73492f669a3a5fb2c51b93b4); + src: url(/fonts/vendor/@fortawesome/fontawesome-free/webfa-solid-900.eot?660ef93f73492f669a3a5fb2c51b93b4) + format("embedded-opentype"), + url(/fonts/vendor/@fortawesome/fontawesome-free/webfa-solid-900.woff2?f840beca95c7727dd3601ed40b30b644) + format("woff2"), + url(/fonts/vendor/@fortawesome/fontawesome-free/webfa-solid-900.woff?bad05f4de8f8346b790bf342c62eb9fc) + format("woff"), + url(/fonts/vendor/@fortawesome/fontawesome-free/webfa-solid-900.ttf?718a680674f5ca6721068ad92e3bf356) + format("truetype"), + url(/fonts/vendor/@fortawesome/fontawesome-free/webfa-solid-900.svg?096358967491cdb1f78bce8848a45094) + format("svg"); +} + +.fa, +.fas { + font-family: "Font Awesome 5 Free"; + font-weight: 900; +} + +/*! + * Font Awesome Free 5.8.2 by @fontawesome - https://fontawesome.com + * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) + */ + +.fa, +.fas, +.far, +.fal, +.fab { + -moz-osx-font-smoothing: grayscale; + -webkit-font-smoothing: antialiased; + display: inline-block; + font-style: normal; + font-variant: normal; + text-rendering: auto; + line-height: 1; +} + +.fa-lg { + font-size: 1.3333333333em; + line-height: 0.75em; + vertical-align: -0.0667em; +} + +.fa-xs { + font-size: 0.75em; +} + +.fa-sm { + font-size: 0.875em; +} + +.fa-1x { + font-size: 1em; +} + +.fa-2x { + font-size: 2em; +} + +.fa-3x { + font-size: 3em; +} + +.fa-4x { + font-size: 4em; +} + +.fa-5x { + font-size: 5em; +} + +.fa-6x { + font-size: 6em; +} + +.fa-7x { + font-size: 7em; +} + +.fa-8x { + font-size: 8em; +} + +.fa-9x { + font-size: 9em; +} + +.fa-10x { + font-size: 10em; +} + +.fa-fw { + text-align: center; + width: 1.25em; +} + +.fa-ul { + list-style-type: none; + margin-left: 2.5em; + padding-left: 0; +} + +.fa-ul > li { + position: relative; +} + +.fa-li { + left: -2em; + position: absolute; + text-align: center; + width: 2em; + line-height: inherit; +} + +.fa-border { + border: solid 0.08em #eee; + border-radius: 0.1em; + padding: 0.2em 0.25em 0.15em; +} + +.fa-pull-left { + float: left; +} + +.fa-pull-right { + float: right; +} + +.fa.fa-pull-left, +.fas.fa-pull-left, +.far.fa-pull-left, +.fal.fa-pull-left, +.fab.fa-pull-left { + margin-right: 0.3em; +} + +.fa.fa-pull-right, +.fas.fa-pull-right, +.far.fa-pull-right, +.fal.fa-pull-right, +.fab.fa-pull-right { + margin-left: 0.3em; +} + +.fa-spin { + animation: fa-spin 2s infinite linear; +} + +.fa-pulse { + animation: fa-spin 1s infinite steps(8); +} + +@keyframes fa-spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +.fa-rotate-90 { + -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=1)"; + transform: rotate(90deg); +} + +.fa-rotate-180 { + -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=2)"; + transform: rotate(180deg); +} + +.fa-rotate-270 { + -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=3)"; + transform: rotate(270deg); +} + +.fa-flip-horizontal { + -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)"; + transform: scale(-1, 1); +} + +.fa-flip-vertical { + -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)"; + transform: scale(1, -1); +} + +.fa-flip-both, +.fa-flip-horizontal.fa-flip-vertical { + -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)"; + transform: scale(-1, -1); +} + +:root .fa-rotate-90, +:root .fa-rotate-180, +:root .fa-rotate-270, +:root .fa-flip-horizontal, +:root .fa-flip-vertical, +:root .fa-flip-both { + filter: none; +} + +.fa-stack { + display: inline-block; + height: 2em; + line-height: 2em; + position: relative; + vertical-align: middle; + width: 2.5em; +} + +.fa-stack-1x, +.fa-stack-2x { + left: 0; + position: absolute; + text-align: center; + width: 100%; +} + +.fa-stack-1x { + line-height: inherit; +} + +.fa-stack-2x { + font-size: 2em; +} + +.fa-inverse { + color: #fff; +} + +/* Font Awesome uses the Unicode Private Use Area (PUA) to ensure screen +readers do not read off random characters that represent icons */ + +.fa-500px:before { + content: "\f26e"; +} + +.fa-accessible-icon:before { + content: "\f368"; +} + +.fa-accusoft:before { + content: "\f369"; +} + +.fa-acquisitions-incorporated:before { + content: "\f6af"; +} + +.fa-ad:before { + content: "\f641"; +} + +.fa-address-book:before { + content: "\f2b9"; +} + +.fa-address-card:before { + content: "\f2bb"; +} + +.fa-adjust:before { + content: "\f042"; +} + +.fa-adn:before { + content: "\f170"; +} + +.fa-adobe:before { + content: "\f778"; +} + +.fa-adversal:before { + content: "\f36a"; +} + +.fa-affiliatetheme:before { + content: "\f36b"; +} + +.fa-air-freshener:before { + content: "\f5d0"; +} + +.fa-airbnb:before { + content: "\f834"; +} + +.fa-algolia:before { + content: "\f36c"; +} + +.fa-align-center:before { + content: "\f037"; +} + +.fa-align-justify:before { + content: "\f039"; +} + +.fa-align-left:before { + content: "\f036"; +} + +.fa-align-right:before { + content: "\f038"; +} + +.fa-alipay:before { + content: "\f642"; +} + +.fa-allergies:before { + content: "\f461"; +} + +.fa-amazon:before { + content: "\f270"; +} + +.fa-amazon-pay:before { + content: "\f42c"; +} + +.fa-ambulance:before { + content: "\f0f9"; +} + +.fa-american-sign-language-interpreting:before { + content: "\f2a3"; +} + +.fa-amilia:before { + content: "\f36d"; +} + +.fa-anchor:before { + content: "\f13d"; +} + +.fa-android:before { + content: "\f17b"; +} + +.fa-angellist:before { + content: "\f209"; +} + +.fa-angle-double-down:before { + content: "\f103"; +} + +.fa-angle-double-left:before { + content: "\f100"; +} + +.fa-angle-double-right:before { + content: "\f101"; +} + +.fa-angle-double-up:before { + content: "\f102"; +} + +.fa-angle-down:before { + content: "\f107"; +} + +.fa-angle-left:before { + content: "\f104"; +} + +.fa-angle-right:before { + content: "\f105"; +} + +.fa-angle-up:before { + content: "\f106"; +} + +.fa-angry:before { + content: "\f556"; +} + +.fa-angrycreative:before { + content: "\f36e"; +} + +.fa-angular:before { + content: "\f420"; +} + +.fa-ankh:before { + content: "\f644"; +} + +.fa-app-store:before { + content: "\f36f"; +} + +.fa-app-store-ios:before { + content: "\f370"; +} + +.fa-apper:before { + content: "\f371"; +} + +.fa-apple:before { + content: "\f179"; +} + +.fa-apple-alt:before { + content: "\f5d1"; +} + +.fa-apple-pay:before { + content: "\f415"; +} + +.fa-archive:before { + content: "\f187"; +} + +.fa-archway:before { + content: "\f557"; +} + +.fa-arrow-alt-circle-down:before { + content: "\f358"; +} + +.fa-arrow-alt-circle-left:before { + content: "\f359"; +} + +.fa-arrow-alt-circle-right:before { + content: "\f35a"; +} + +.fa-arrow-alt-circle-up:before { + content: "\f35b"; +} + +.fa-arrow-circle-down:before { + content: "\f0ab"; +} + +.fa-arrow-circle-left:before { + content: "\f0a8"; +} + +.fa-arrow-circle-right:before { + content: "\f0a9"; +} + +.fa-arrow-circle-up:before { + content: "\f0aa"; +} + +.fa-arrow-down:before { + content: "\f063"; +} + +.fa-arrow-left:before { + content: "\f060"; +} + +.fa-arrow-right:before { + content: "\f061"; +} + +.fa-arrow-up:before { + content: "\f062"; +} + +.fa-arrows-alt:before { + content: "\f0b2"; +} + +.fa-arrows-alt-h:before { + content: "\f337"; +} + +.fa-arrows-alt-v:before { + content: "\f338"; +} + +.fa-artstation:before { + content: "\f77a"; +} + +.fa-assistive-listening-systems:before { + content: "\f2a2"; +} + +.fa-asterisk:before { + content: "\f069"; +} + +.fa-asymmetrik:before { + content: "\f372"; +} + +.fa-at:before { + content: "\f1fa"; +} + +.fa-atlas:before { + content: "\f558"; +} + +.fa-atlassian:before { + content: "\f77b"; +} + +.fa-atom:before { + content: "\f5d2"; +} + +.fa-audible:before { + content: "\f373"; +} + +.fa-audio-description:before { + content: "\f29e"; +} + +.fa-autoprefixer:before { + content: "\f41c"; +} + +.fa-avianex:before { + content: "\f374"; +} + +.fa-aviato:before { + content: "\f421"; +} + +.fa-award:before { + content: "\f559"; +} + +.fa-aws:before { + content: "\f375"; +} + +.fa-baby:before { + content: "\f77c"; +} + +.fa-baby-carriage:before { + content: "\f77d"; +} + +.fa-backspace:before { + content: "\f55a"; +} + +.fa-backward:before { + content: "\f04a"; +} + +.fa-bacon:before { + content: "\f7e5"; +} + +.fa-balance-scale:before { + content: "\f24e"; +} + +.fa-ban:before { + content: "\f05e"; +} + +.fa-band-aid:before { + content: "\f462"; +} + +.fa-bandcamp:before { + content: "\f2d5"; +} + +.fa-barcode:before { + content: "\f02a"; +} + +.fa-bars:before { + content: "\f0c9"; +} + +.fa-baseball-ball:before { + content: "\f433"; +} + +.fa-basketball-ball:before { + content: "\f434"; +} + +.fa-bath:before { + content: "\f2cd"; +} + +.fa-battery-empty:before { + content: "\f244"; +} + +.fa-battery-full:before { + content: "\f240"; +} + +.fa-battery-half:before { + content: "\f242"; +} + +.fa-battery-quarter:before { + content: "\f243"; +} + +.fa-battery-three-quarters:before { + content: "\f241"; +} + +.fa-battle-net:before { + content: "\f835"; +} + +.fa-bed:before { + content: "\f236"; +} + +.fa-beer:before { + content: "\f0fc"; +} + +.fa-behance:before { + content: "\f1b4"; +} + +.fa-behance-square:before { + content: "\f1b5"; +} + +.fa-bell:before { + content: "\f0f3"; +} + +.fa-bell-slash:before { + content: "\f1f6"; +} + +.fa-bezier-curve:before { + content: "\f55b"; +} + +.fa-bible:before { + content: "\f647"; +} + +.fa-bicycle:before { + content: "\f206"; +} + +.fa-bimobject:before { + content: "\f378"; +} + +.fa-binoculars:before { + content: "\f1e5"; +} + +.fa-biohazard:before { + content: "\f780"; +} + +.fa-birthday-cake:before { + content: "\f1fd"; +} + +.fa-bitbucket:before { + content: "\f171"; +} + +.fa-bitcoin:before { + content: "\f379"; +} + +.fa-bity:before { + content: "\f37a"; +} + +.fa-black-tie:before { + content: "\f27e"; +} + +.fa-blackberry:before { + content: "\f37b"; +} + +.fa-blender:before { + content: "\f517"; +} + +.fa-blender-phone:before { + content: "\f6b6"; +} + +.fa-blind:before { + content: "\f29d"; +} + +.fa-blog:before { + content: "\f781"; +} + +.fa-blogger:before { + content: "\f37c"; +} + +.fa-blogger-b:before { + content: "\f37d"; +} + +.fa-bluetooth:before { + content: "\f293"; +} + +.fa-bluetooth-b:before { + content: "\f294"; +} + +.fa-bold:before { + content: "\f032"; +} + +.fa-bolt:before { + content: "\f0e7"; +} + +.fa-bomb:before { + content: "\f1e2"; +} + +.fa-bone:before { + content: "\f5d7"; +} + +.fa-bong:before { + content: "\f55c"; +} + +.fa-book:before { + content: "\f02d"; +} + +.fa-book-dead:before { + content: "\f6b7"; +} + +.fa-book-medical:before { + content: "\f7e6"; +} + +.fa-book-open:before { + content: "\f518"; +} + +.fa-book-reader:before { + content: "\f5da"; +} + +.fa-bookmark:before { + content: "\f02e"; +} + +.fa-bootstrap:before { + content: "\f836"; +} + +.fa-bowling-ball:before { + content: "\f436"; +} + +.fa-box:before { + content: "\f466"; +} + +.fa-box-open:before { + content: "\f49e"; +} + +.fa-boxes:before { + content: "\f468"; +} + +.fa-braille:before { + content: "\f2a1"; +} + +.fa-brain:before { + content: "\f5dc"; +} + +.fa-bread-slice:before { + content: "\f7ec"; +} + +.fa-briefcase:before { + content: "\f0b1"; +} + +.fa-briefcase-medical:before { + content: "\f469"; +} + +.fa-broadcast-tower:before { + content: "\f519"; +} + +.fa-broom:before { + content: "\f51a"; +} + +.fa-brush:before { + content: "\f55d"; +} + +.fa-btc:before { + content: "\f15a"; +} + +.fa-buffer:before { + content: "\f837"; +} + +.fa-bug:before { + content: "\f188"; +} + +.fa-building:before { + content: "\f1ad"; +} + +.fa-bullhorn:before { + content: "\f0a1"; +} + +.fa-bullseye:before { + content: "\f140"; +} + +.fa-burn:before { + content: "\f46a"; +} + +.fa-buromobelexperte:before { + content: "\f37f"; +} + +.fa-bus:before { + content: "\f207"; +} + +.fa-bus-alt:before { + content: "\f55e"; +} + +.fa-business-time:before { + content: "\f64a"; +} + +.fa-buysellads:before { + content: "\f20d"; +} + +.fa-calculator:before { + content: "\f1ec"; +} + +.fa-calendar:before { + content: "\f133"; +} + +.fa-calendar-alt:before { + content: "\f073"; +} + +.fa-calendar-check:before { + content: "\f274"; +} + +.fa-calendar-day:before { + content: "\f783"; +} + +.fa-calendar-minus:before { + content: "\f272"; +} + +.fa-calendar-plus:before { + content: "\f271"; +} + +.fa-calendar-times:before { + content: "\f273"; +} + +.fa-calendar-week:before { + content: "\f784"; +} + +.fa-camera:before { + content: "\f030"; +} + +.fa-camera-retro:before { + content: "\f083"; +} + +.fa-campground:before { + content: "\f6bb"; +} + +.fa-canadian-maple-leaf:before { + content: "\f785"; +} + +.fa-candy-cane:before { + content: "\f786"; +} + +.fa-cannabis:before { + content: "\f55f"; +} + +.fa-capsules:before { + content: "\f46b"; +} + +.fa-car:before { + content: "\f1b9"; +} + +.fa-car-alt:before { + content: "\f5de"; +} + +.fa-car-battery:before { + content: "\f5df"; +} + +.fa-car-crash:before { + content: "\f5e1"; +} + +.fa-car-side:before { + content: "\f5e4"; +} + +.fa-caret-down:before { + content: "\f0d7"; +} + +.fa-caret-left:before { + content: "\f0d9"; +} + +.fa-caret-right:before { + content: "\f0da"; +} + +.fa-caret-square-down:before { + content: "\f150"; +} + +.fa-caret-square-left:before { + content: "\f191"; +} + +.fa-caret-square-right:before { + content: "\f152"; +} + +.fa-caret-square-up:before { + content: "\f151"; +} + +.fa-caret-up:before { + content: "\f0d8"; +} + +.fa-carrot:before { + content: "\f787"; +} + +.fa-cart-arrow-down:before { + content: "\f218"; +} + +.fa-cart-plus:before { + content: "\f217"; +} + +.fa-cash-register:before { + content: "\f788"; +} + +.fa-cat:before { + content: "\f6be"; +} + +.fa-cc-amazon-pay:before { + content: "\f42d"; +} + +.fa-cc-amex:before { + content: "\f1f3"; +} + +.fa-cc-apple-pay:before { + content: "\f416"; +} + +.fa-cc-diners-club:before { + content: "\f24c"; +} + +.fa-cc-discover:before { + content: "\f1f2"; +} + +.fa-cc-jcb:before { + content: "\f24b"; +} + +.fa-cc-mastercard:before { + content: "\f1f1"; +} + +.fa-cc-paypal:before { + content: "\f1f4"; +} + +.fa-cc-stripe:before { + content: "\f1f5"; +} + +.fa-cc-visa:before { + content: "\f1f0"; +} + +.fa-centercode:before { + content: "\f380"; +} + +.fa-centos:before { + content: "\f789"; +} + +.fa-certificate:before { + content: "\f0a3"; +} + +.fa-chair:before { + content: "\f6c0"; +} + +.fa-chalkboard:before { + content: "\f51b"; +} + +.fa-chalkboard-teacher:before { + content: "\f51c"; +} + +.fa-charging-station:before { + content: "\f5e7"; +} + +.fa-chart-area:before { + content: "\f1fe"; +} + +.fa-chart-bar:before { + content: "\f080"; +} + +.fa-chart-line:before { + content: "\f201"; +} + +.fa-chart-pie:before { + content: "\f200"; +} + +.fa-check:before { + content: "\f00c"; +} + +.fa-check-circle:before { + content: "\f058"; +} + +.fa-check-double:before { + content: "\f560"; +} + +.fa-check-square:before { + content: "\f14a"; +} + +.fa-cheese:before { + content: "\f7ef"; +} + +.fa-chess:before { + content: "\f439"; +} + +.fa-chess-bishop:before { + content: "\f43a"; +} + +.fa-chess-board:before { + content: "\f43c"; +} + +.fa-chess-king:before { + content: "\f43f"; +} + +.fa-chess-knight:before { + content: "\f441"; +} + +.fa-chess-pawn:before { + content: "\f443"; +} + +.fa-chess-queen:before { + content: "\f445"; +} + +.fa-chess-rook:before { + content: "\f447"; +} + +.fa-chevron-circle-down:before { + content: "\f13a"; +} + +.fa-chevron-circle-left:before { + content: "\f137"; +} + +.fa-chevron-circle-right:before { + content: "\f138"; +} + +.fa-chevron-circle-up:before { + content: "\f139"; +} + +.fa-chevron-down:before { + content: "\f078"; +} + +.fa-chevron-left:before { + content: "\f053"; +} + +.fa-chevron-right:before { + content: "\f054"; +} + +.fa-chevron-up:before { + content: "\f077"; +} + +.fa-child:before { + content: "\f1ae"; +} + +.fa-chrome:before { + content: "\f268"; +} + +.fa-chromecast:before { + content: "\f838"; +} + +.fa-church:before { + content: "\f51d"; +} + +.fa-circle:before { + content: "\f111"; +} + +.fa-circle-notch:before { + content: "\f1ce"; +} + +.fa-city:before { + content: "\f64f"; +} + +.fa-clinic-medical:before { + content: "\f7f2"; +} + +.fa-clipboard:before { + content: "\f328"; +} + +.fa-clipboard-check:before { + content: "\f46c"; +} + +.fa-clipboard-list:before { + content: "\f46d"; +} + +.fa-clock:before { + content: "\f017"; +} + +.fa-clone:before { + content: "\f24d"; +} + +.fa-closed-captioning:before { + content: "\f20a"; +} + +.fa-cloud:before { + content: "\f0c2"; +} + +.fa-cloud-download-alt:before { + content: "\f381"; +} + +.fa-cloud-meatball:before { + content: "\f73b"; +} + +.fa-cloud-moon:before { + content: "\f6c3"; +} + +.fa-cloud-moon-rain:before { + content: "\f73c"; +} + +.fa-cloud-rain:before { + content: "\f73d"; +} + +.fa-cloud-showers-heavy:before { + content: "\f740"; +} + +.fa-cloud-sun:before { + content: "\f6c4"; +} + +.fa-cloud-sun-rain:before { + content: "\f743"; +} + +.fa-cloud-upload-alt:before { + content: "\f382"; +} + +.fa-cloudscale:before { + content: "\f383"; +} + +.fa-cloudsmith:before { + content: "\f384"; +} + +.fa-cloudversify:before { + content: "\f385"; +} + +.fa-cocktail:before { + content: "\f561"; +} + +.fa-code:before { + content: "\f121"; +} + +.fa-code-branch:before { + content: "\f126"; +} + +.fa-codepen:before { + content: "\f1cb"; +} + +.fa-codiepie:before { + content: "\f284"; +} + +.fa-coffee:before { + content: "\f0f4"; +} + +.fa-cog:before { + content: "\f013"; +} + +.fa-cogs:before { + content: "\f085"; +} + +.fa-coins:before { + content: "\f51e"; +} + +.fa-columns:before { + content: "\f0db"; +} + +.fa-comment:before { + content: "\f075"; +} + +.fa-comment-alt:before { + content: "\f27a"; +} + +.fa-comment-dollar:before { + content: "\f651"; +} + +.fa-comment-dots:before { + content: "\f4ad"; +} + +.fa-comment-medical:before { + content: "\f7f5"; +} + +.fa-comment-slash:before { + content: "\f4b3"; +} + +.fa-comments:before { + content: "\f086"; +} + +.fa-comments-dollar:before { + content: "\f653"; +} + +.fa-compact-disc:before { + content: "\f51f"; +} + +.fa-compass:before { + content: "\f14e"; +} + +.fa-compress:before { + content: "\f066"; +} + +.fa-compress-arrows-alt:before { + content: "\f78c"; +} + +.fa-concierge-bell:before { + content: "\f562"; +} + +.fa-confluence:before { + content: "\f78d"; +} + +.fa-connectdevelop:before { + content: "\f20e"; +} + +.fa-contao:before { + content: "\f26d"; +} + +.fa-cookie:before { + content: "\f563"; +} + +.fa-cookie-bite:before { + content: "\f564"; +} + +.fa-copy:before { + content: "\f0c5"; +} + +.fa-copyright:before { + content: "\f1f9"; +} + +.fa-couch:before { + content: "\f4b8"; +} + +.fa-cpanel:before { + content: "\f388"; +} + +.fa-creative-commons:before { + content: "\f25e"; +} + +.fa-creative-commons-by:before { + content: "\f4e7"; +} + +.fa-creative-commons-nc:before { + content: "\f4e8"; +} + +.fa-creative-commons-nc-eu:before { + content: "\f4e9"; +} + +.fa-creative-commons-nc-jp:before { + content: "\f4ea"; +} + +.fa-creative-commons-nd:before { + content: "\f4eb"; +} + +.fa-creative-commons-pd:before { + content: "\f4ec"; +} + +.fa-creative-commons-pd-alt:before { + content: "\f4ed"; +} + +.fa-creative-commons-remix:before { + content: "\f4ee"; +} + +.fa-creative-commons-sa:before { + content: "\f4ef"; +} + +.fa-creative-commons-sampling:before { + content: "\f4f0"; +} + +.fa-creative-commons-sampling-plus:before { + content: "\f4f1"; +} + +.fa-creative-commons-share:before { + content: "\f4f2"; +} + +.fa-creative-commons-zero:before { + content: "\f4f3"; +} + +.fa-credit-card:before { + content: "\f09d"; +} + +.fa-critical-role:before { + content: "\f6c9"; +} + +.fa-crop:before { + content: "\f125"; +} + +.fa-crop-alt:before { + content: "\f565"; +} + +.fa-cross:before { + content: "\f654"; +} + +.fa-crosshairs:before { + content: "\f05b"; +} + +.fa-crow:before { + content: "\f520"; +} + +.fa-crown:before { + content: "\f521"; +} + +.fa-crutch:before { + content: "\f7f7"; +} + +.fa-css3:before { + content: "\f13c"; +} + +.fa-css3-alt:before { + content: "\f38b"; +} + +.fa-cube:before { + content: "\f1b2"; +} + +.fa-cubes:before { + content: "\f1b3"; +} + +.fa-cut:before { + content: "\f0c4"; +} + +.fa-cuttlefish:before { + content: "\f38c"; +} + +.fa-d-and-d:before { + content: "\f38d"; +} + +.fa-d-and-d-beyond:before { + content: "\f6ca"; +} + +.fa-dashcube:before { + content: "\f210"; +} + +.fa-database:before { + content: "\f1c0"; +} + +.fa-deaf:before { + content: "\f2a4"; +} + +.fa-delicious:before { + content: "\f1a5"; +} + +.fa-democrat:before { + content: "\f747"; +} + +.fa-deploydog:before { + content: "\f38e"; +} + +.fa-deskpro:before { + content: "\f38f"; +} + +.fa-desktop:before { + content: "\f108"; +} + +.fa-dev:before { + content: "\f6cc"; +} + +.fa-deviantart:before { + content: "\f1bd"; +} + +.fa-dharmachakra:before { + content: "\f655"; +} + +.fa-dhl:before { + content: "\f790"; +} + +.fa-diagnoses:before { + content: "\f470"; +} + +.fa-diaspora:before { + content: "\f791"; +} + +.fa-dice:before { + content: "\f522"; +} + +.fa-dice-d20:before { + content: "\f6cf"; +} + +.fa-dice-d6:before { + content: "\f6d1"; +} + +.fa-dice-five:before { + content: "\f523"; +} + +.fa-dice-four:before { + content: "\f524"; +} + +.fa-dice-one:before { + content: "\f525"; +} + +.fa-dice-six:before { + content: "\f526"; +} + +.fa-dice-three:before { + content: "\f527"; +} + +.fa-dice-two:before { + content: "\f528"; +} + +.fa-digg:before { + content: "\f1a6"; +} + +.fa-digital-ocean:before { + content: "\f391"; +} + +.fa-digital-tachograph:before { + content: "\f566"; +} + +.fa-directions:before { + content: "\f5eb"; +} + +.fa-discord:before { + content: "\f392"; +} + +.fa-discourse:before { + content: "\f393"; +} + +.fa-divide:before { + content: "\f529"; +} + +.fa-dizzy:before { + content: "\f567"; +} + +.fa-dna:before { + content: "\f471"; +} + +.fa-dochub:before { + content: "\f394"; +} + +.fa-docker:before { + content: "\f395"; +} + +.fa-dog:before { + content: "\f6d3"; +} + +.fa-dollar-sign:before { + content: "\f155"; +} + +.fa-dolly:before { + content: "\f472"; +} + +.fa-dolly-flatbed:before { + content: "\f474"; +} + +.fa-donate:before { + content: "\f4b9"; +} + +.fa-door-closed:before { + content: "\f52a"; +} + +.fa-door-open:before { + content: "\f52b"; +} + +.fa-dot-circle:before { + content: "\f192"; +} + +.fa-dove:before { + content: "\f4ba"; +} + +.fa-download:before { + content: "\f019"; +} + +.fa-draft2digital:before { + content: "\f396"; +} + +.fa-drafting-compass:before { + content: "\f568"; +} + +.fa-dragon:before { + content: "\f6d5"; +} + +.fa-draw-polygon:before { + content: "\f5ee"; +} + +.fa-dribbble:before { + content: "\f17d"; +} + +.fa-dribbble-square:before { + content: "\f397"; +} + +.fa-dropbox:before { + content: "\f16b"; +} + +.fa-drum:before { + content: "\f569"; +} + +.fa-drum-steelpan:before { + content: "\f56a"; +} + +.fa-drumstick-bite:before { + content: "\f6d7"; +} + +.fa-drupal:before { + content: "\f1a9"; +} + +.fa-dumbbell:before { + content: "\f44b"; +} + +.fa-dumpster:before { + content: "\f793"; +} + +.fa-dumpster-fire:before { + content: "\f794"; +} + +.fa-dungeon:before { + content: "\f6d9"; +} + +.fa-dyalog:before { + content: "\f399"; +} + +.fa-earlybirds:before { + content: "\f39a"; +} + +.fa-ebay:before { + content: "\f4f4"; +} + +.fa-edge:before { + content: "\f282"; +} + +.fa-edit:before { + content: "\f044"; +} + +.fa-egg:before { + content: "\f7fb"; +} + +.fa-eject:before { + content: "\f052"; +} + +.fa-elementor:before { + content: "\f430"; +} + +.fa-ellipsis-h:before { + content: "\f141"; +} + +.fa-ellipsis-v:before { + content: "\f142"; +} + +.fa-ello:before { + content: "\f5f1"; +} + +.fa-ember:before { + content: "\f423"; +} + +.fa-empire:before { + content: "\f1d1"; +} + +.fa-envelope:before { + content: "\f0e0"; +} + +.fa-envelope-open:before { + content: "\f2b6"; +} + +.fa-envelope-open-text:before { + content: "\f658"; +} + +.fa-envelope-square:before { + content: "\f199"; +} + +.fa-envira:before { + content: "\f299"; +} + +.fa-equals:before { + content: "\f52c"; +} + +.fa-eraser:before { + content: "\f12d"; +} + +.fa-erlang:before { + content: "\f39d"; +} + +.fa-ethereum:before { + content: "\f42e"; +} + +.fa-ethernet:before { + content: "\f796"; +} + +.fa-etsy:before { + content: "\f2d7"; +} + +.fa-euro-sign:before { + content: "\f153"; +} + +.fa-evernote:before { + content: "\f839"; +} + +.fa-exchange-alt:before { + content: "\f362"; +} + +.fa-exclamation:before { + content: "\f12a"; +} + +.fa-exclamation-circle:before { + content: "\f06a"; +} + +.fa-exclamation-triangle:before { + content: "\f071"; +} + +.fa-expand:before { + content: "\f065"; +} + +.fa-expand-arrows-alt:before { + content: "\f31e"; +} + +.fa-expeditedssl:before { + content: "\f23e"; +} + +.fa-external-link-alt:before { + content: "\f35d"; +} + +.fa-external-link-square-alt:before { + content: "\f360"; +} + +.fa-eye:before { + content: "\f06e"; +} + +.fa-eye-dropper:before { + content: "\f1fb"; +} + +.fa-eye-slash:before { + content: "\f070"; +} + +.fa-facebook:before { + content: "\f09a"; +} + +.fa-facebook-f:before { + content: "\f39e"; +} + +.fa-facebook-messenger:before { + content: "\f39f"; +} + +.fa-facebook-square:before { + content: "\f082"; +} + +.fa-fantasy-flight-games:before { + content: "\f6dc"; +} + +.fa-fast-backward:before { + content: "\f049"; +} + +.fa-fast-forward:before { + content: "\f050"; +} + +.fa-fax:before { + content: "\f1ac"; +} + +.fa-feather:before { + content: "\f52d"; +} + +.fa-feather-alt:before { + content: "\f56b"; +} + +.fa-fedex:before { + content: "\f797"; +} + +.fa-fedora:before { + content: "\f798"; +} + +.fa-female:before { + content: "\f182"; +} + +.fa-fighter-jet:before { + content: "\f0fb"; +} + +.fa-figma:before { + content: "\f799"; +} + +.fa-file:before { + content: "\f15b"; +} + +.fa-file-alt:before { + content: "\f15c"; +} + +.fa-file-archive:before { + content: "\f1c6"; +} + +.fa-file-audio:before { + content: "\f1c7"; +} + +.fa-file-code:before { + content: "\f1c9"; +} + +.fa-file-contract:before { + content: "\f56c"; +} + +.fa-file-csv:before { + content: "\f6dd"; +} + +.fa-file-download:before { + content: "\f56d"; +} + +.fa-file-excel:before { + content: "\f1c3"; +} + +.fa-file-export:before { + content: "\f56e"; +} + +.fa-file-image:before { + content: "\f1c5"; +} + +.fa-file-import:before { + content: "\f56f"; +} + +.fa-file-invoice:before { + content: "\f570"; +} + +.fa-file-invoice-dollar:before { + content: "\f571"; +} + +.fa-file-medical:before { + content: "\f477"; +} + +.fa-file-medical-alt:before { + content: "\f478"; +} + +.fa-file-pdf:before { + content: "\f1c1"; +} + +.fa-file-powerpoint:before { + content: "\f1c4"; +} + +.fa-file-prescription:before { + content: "\f572"; +} + +.fa-file-signature:before { + content: "\f573"; +} + +.fa-file-upload:before { + content: "\f574"; +} + +.fa-file-video:before { + content: "\f1c8"; +} + +.fa-file-word:before { + content: "\f1c2"; +} + +.fa-fill:before { + content: "\f575"; +} + +.fa-fill-drip:before { + content: "\f576"; +} + +.fa-film:before { + content: "\f008"; +} + +.fa-filter:before { + content: "\f0b0"; +} + +.fa-fingerprint:before { + content: "\f577"; +} + +.fa-fire:before { + content: "\f06d"; +} + +.fa-fire-alt:before { + content: "\f7e4"; +} + +.fa-fire-extinguisher:before { + content: "\f134"; +} + +.fa-firefox:before { + content: "\f269"; +} + +.fa-first-aid:before { + content: "\f479"; +} + +.fa-first-order:before { + content: "\f2b0"; +} + +.fa-first-order-alt:before { + content: "\f50a"; +} + +.fa-firstdraft:before { + content: "\f3a1"; +} + +.fa-fish:before { + content: "\f578"; +} + +.fa-fist-raised:before { + content: "\f6de"; +} + +.fa-flag:before { + content: "\f024"; +} + +.fa-flag-checkered:before { + content: "\f11e"; +} + +.fa-flag-usa:before { + content: "\f74d"; +} + +.fa-flask:before { + content: "\f0c3"; +} + +.fa-flickr:before { + content: "\f16e"; +} + +.fa-flipboard:before { + content: "\f44d"; +} + +.fa-flushed:before { + content: "\f579"; +} + +.fa-fly:before { + content: "\f417"; +} + +.fa-folder:before { + content: "\f07b"; +} + +.fa-folder-minus:before { + content: "\f65d"; +} + +.fa-folder-open:before { + content: "\f07c"; +} + +.fa-folder-plus:before { + content: "\f65e"; +} + +.fa-font:before { + content: "\f031"; +} + +.fa-font-awesome:before { + content: "\f2b4"; +} + +.fa-font-awesome-alt:before { + content: "\f35c"; +} + +.fa-font-awesome-flag:before { + content: "\f425"; +} + +.fa-font-awesome-logo-full:before { + content: "\f4e6"; +} + +.fa-fonticons:before { + content: "\f280"; +} + +.fa-fonticons-fi:before { + content: "\f3a2"; +} + +.fa-football-ball:before { + content: "\f44e"; +} + +.fa-fort-awesome:before { + content: "\f286"; +} + +.fa-fort-awesome-alt:before { + content: "\f3a3"; +} + +.fa-forumbee:before { + content: "\f211"; +} + +.fa-forward:before { + content: "\f04e"; +} + +.fa-foursquare:before { + content: "\f180"; +} + +.fa-free-code-camp:before { + content: "\f2c5"; +} + +.fa-freebsd:before { + content: "\f3a4"; +} + +.fa-frog:before { + content: "\f52e"; +} + +.fa-frown:before { + content: "\f119"; +} + +.fa-frown-open:before { + content: "\f57a"; +} + +.fa-fulcrum:before { + content: "\f50b"; +} + +.fa-funnel-dollar:before { + content: "\f662"; +} + +.fa-futbol:before { + content: "\f1e3"; +} + +.fa-galactic-republic:before { + content: "\f50c"; +} + +.fa-galactic-senate:before { + content: "\f50d"; +} + +.fa-gamepad:before { + content: "\f11b"; +} + +.fa-gas-pump:before { + content: "\f52f"; +} + +.fa-gavel:before { + content: "\f0e3"; +} + +.fa-gem:before { + content: "\f3a5"; +} + +.fa-genderless:before { + content: "\f22d"; +} + +.fa-get-pocket:before { + content: "\f265"; +} + +.fa-gg:before { + content: "\f260"; +} + +.fa-gg-circle:before { + content: "\f261"; +} + +.fa-ghost:before { + content: "\f6e2"; +} + +.fa-gift:before { + content: "\f06b"; +} + +.fa-gifts:before { + content: "\f79c"; +} + +.fa-git:before { + content: "\f1d3"; +} + +.fa-git-alt:before { + content: "\f841"; +} + +.fa-git-square:before { + content: "\f1d2"; +} + +.fa-github:before { + content: "\f09b"; +} + +.fa-github-alt:before { + content: "\f113"; +} + +.fa-github-square:before { + content: "\f092"; +} + +.fa-gitkraken:before { + content: "\f3a6"; +} + +.fa-gitlab:before { + content: "\f296"; +} + +.fa-gitter:before { + content: "\f426"; +} + +.fa-glass-cheers:before { + content: "\f79f"; +} + +.fa-glass-martini:before { + content: "\f000"; +} + +.fa-glass-martini-alt:before { + content: "\f57b"; +} + +.fa-glass-whiskey:before { + content: "\f7a0"; +} + +.fa-glasses:before { + content: "\f530"; +} + +.fa-glide:before { + content: "\f2a5"; +} + +.fa-glide-g:before { + content: "\f2a6"; +} + +.fa-globe:before { + content: "\f0ac"; +} + +.fa-globe-africa:before { + content: "\f57c"; +} + +.fa-globe-americas:before { + content: "\f57d"; +} + +.fa-globe-asia:before { + content: "\f57e"; +} + +.fa-globe-europe:before { + content: "\f7a2"; +} + +.fa-gofore:before { + content: "\f3a7"; +} + +.fa-golf-ball:before { + content: "\f450"; +} + +.fa-goodreads:before { + content: "\f3a8"; +} + +.fa-goodreads-g:before { + content: "\f3a9"; +} + +.fa-google:before { + content: "\f1a0"; +} + +.fa-google-drive:before { + content: "\f3aa"; +} + +.fa-google-play:before { + content: "\f3ab"; +} + +.fa-google-plus:before { + content: "\f2b3"; +} + +.fa-google-plus-g:before { + content: "\f0d5"; +} + +.fa-google-plus-square:before { + content: "\f0d4"; +} + +.fa-google-wallet:before { + content: "\f1ee"; +} + +.fa-gopuram:before { + content: "\f664"; +} + +.fa-graduation-cap:before { + content: "\f19d"; +} + +.fa-gratipay:before { + content: "\f184"; +} + +.fa-grav:before { + content: "\f2d6"; +} + +.fa-greater-than:before { + content: "\f531"; +} + +.fa-greater-than-equal:before { + content: "\f532"; +} + +.fa-grimace:before { + content: "\f57f"; +} + +.fa-grin:before { + content: "\f580"; +} + +.fa-grin-alt:before { + content: "\f581"; +} + +.fa-grin-beam:before { + content: "\f582"; +} + +.fa-grin-beam-sweat:before { + content: "\f583"; +} + +.fa-grin-hearts:before { + content: "\f584"; +} + +.fa-grin-squint:before { + content: "\f585"; +} + +.fa-grin-squint-tears:before { + content: "\f586"; +} + +.fa-grin-stars:before { + content: "\f587"; +} + +.fa-grin-tears:before { + content: "\f588"; +} + +.fa-grin-tongue:before { + content: "\f589"; +} + +.fa-grin-tongue-squint:before { + content: "\f58a"; +} + +.fa-grin-tongue-wink:before { + content: "\f58b"; +} + +.fa-grin-wink:before { + content: "\f58c"; +} + +.fa-grip-horizontal:before { + content: "\f58d"; +} + +.fa-grip-lines:before { + content: "\f7a4"; +} + +.fa-grip-lines-vertical:before { + content: "\f7a5"; +} + +.fa-grip-vertical:before { + content: "\f58e"; +} + +.fa-gripfire:before { + content: "\f3ac"; +} + +.fa-grunt:before { + content: "\f3ad"; +} + +.fa-guitar:before { + content: "\f7a6"; +} + +.fa-gulp:before { + content: "\f3ae"; +} + +.fa-h-square:before { + content: "\f0fd"; +} + +.fa-hacker-news:before { + content: "\f1d4"; +} + +.fa-hacker-news-square:before { + content: "\f3af"; +} + +.fa-hackerrank:before { + content: "\f5f7"; +} + +.fa-hamburger:before { + content: "\f805"; +} + +.fa-hammer:before { + content: "\f6e3"; +} + +.fa-hamsa:before { + content: "\f665"; +} + +.fa-hand-holding:before { + content: "\f4bd"; +} + +.fa-hand-holding-heart:before { + content: "\f4be"; +} + +.fa-hand-holding-usd:before { + content: "\f4c0"; +} + +.fa-hand-lizard:before { + content: "\f258"; +} + +.fa-hand-middle-finger:before { + content: "\f806"; +} + +.fa-hand-paper:before { + content: "\f256"; +} + +.fa-hand-peace:before { + content: "\f25b"; +} + +.fa-hand-point-down:before { + content: "\f0a7"; +} + +.fa-hand-point-left:before { + content: "\f0a5"; +} + +.fa-hand-point-right:before { + content: "\f0a4"; +} + +.fa-hand-point-up:before { + content: "\f0a6"; +} + +.fa-hand-pointer:before { + content: "\f25a"; +} + +.fa-hand-rock:before { + content: "\f255"; +} + +.fa-hand-scissors:before { + content: "\f257"; +} + +.fa-hand-spock:before { + content: "\f259"; +} + +.fa-hands:before { + content: "\f4c2"; +} + +.fa-hands-helping:before { + content: "\f4c4"; +} + +.fa-handshake:before { + content: "\f2b5"; +} + +.fa-hanukiah:before { + content: "\f6e6"; +} + +.fa-hard-hat:before { + content: "\f807"; +} + +.fa-hashtag:before { + content: "\f292"; +} + +.fa-hat-wizard:before { + content: "\f6e8"; +} + +.fa-haykal:before { + content: "\f666"; +} + +.fa-hdd:before { + content: "\f0a0"; +} + +.fa-heading:before { + content: "\f1dc"; +} + +.fa-headphones:before { + content: "\f025"; +} + +.fa-headphones-alt:before { + content: "\f58f"; +} + +.fa-headset:before { + content: "\f590"; +} + +.fa-heart:before { + content: "\f004"; +} + +.fa-heart-broken:before { + content: "\f7a9"; +} + +.fa-heartbeat:before { + content: "\f21e"; +} + +.fa-helicopter:before { + content: "\f533"; +} + +.fa-highlighter:before { + content: "\f591"; +} + +.fa-hiking:before { + content: "\f6ec"; +} + +.fa-hippo:before { + content: "\f6ed"; +} + +.fa-hips:before { + content: "\f452"; +} + +.fa-hire-a-helper:before { + content: "\f3b0"; +} + +.fa-history:before { + content: "\f1da"; +} + +.fa-hockey-puck:before { + content: "\f453"; +} + +.fa-holly-berry:before { + content: "\f7aa"; +} + +.fa-home:before { + content: "\f015"; +} + +.fa-hooli:before { + content: "\f427"; +} + +.fa-hornbill:before { + content: "\f592"; +} + +.fa-horse:before { + content: "\f6f0"; +} + +.fa-horse-head:before { + content: "\f7ab"; +} + +.fa-hospital:before { + content: "\f0f8"; +} + +.fa-hospital-alt:before { + content: "\f47d"; +} + +.fa-hospital-symbol:before { + content: "\f47e"; +} + +.fa-hot-tub:before { + content: "\f593"; +} + +.fa-hotdog:before { + content: "\f80f"; +} + +.fa-hotel:before { + content: "\f594"; +} + +.fa-hotjar:before { + content: "\f3b1"; +} + +.fa-hourglass:before { + content: "\f254"; +} + +.fa-hourglass-end:before { + content: "\f253"; +} + +.fa-hourglass-half:before { + content: "\f252"; +} + +.fa-hourglass-start:before { + content: "\f251"; +} + +.fa-house-damage:before { + content: "\f6f1"; +} + +.fa-houzz:before { + content: "\f27c"; +} + +.fa-hryvnia:before { + content: "\f6f2"; +} + +.fa-html5:before { + content: "\f13b"; +} + +.fa-hubspot:before { + content: "\f3b2"; +} + +.fa-i-cursor:before { + content: "\f246"; +} + +.fa-ice-cream:before { + content: "\f810"; +} + +.fa-icicles:before { + content: "\f7ad"; +} + +.fa-id-badge:before { + content: "\f2c1"; +} + +.fa-id-card:before { + content: "\f2c2"; +} + +.fa-id-card-alt:before { + content: "\f47f"; +} + +.fa-igloo:before { + content: "\f7ae"; +} + +.fa-image:before { + content: "\f03e"; +} + +.fa-images:before { + content: "\f302"; +} + +.fa-imdb:before { + content: "\f2d8"; +} + +.fa-inbox:before { + content: "\f01c"; +} + +.fa-indent:before { + content: "\f03c"; +} + +.fa-industry:before { + content: "\f275"; +} + +.fa-infinity:before { + content: "\f534"; +} + +.fa-info:before { + content: "\f129"; +} + +.fa-info-circle:before { + content: "\f05a"; +} + +.fa-instagram:before { + content: "\f16d"; +} + +.fa-intercom:before { + content: "\f7af"; +} + +.fa-internet-explorer:before { + content: "\f26b"; +} + +.fa-invision:before { + content: "\f7b0"; +} + +.fa-ioxhost:before { + content: "\f208"; +} + +.fa-italic:before { + content: "\f033"; +} + +.fa-itch-io:before { + content: "\f83a"; +} + +.fa-itunes:before { + content: "\f3b4"; +} + +.fa-itunes-note:before { + content: "\f3b5"; +} + +.fa-java:before { + content: "\f4e4"; +} + +.fa-jedi:before { + content: "\f669"; +} + +.fa-jedi-order:before { + content: "\f50e"; +} + +.fa-jenkins:before { + content: "\f3b6"; +} + +.fa-jira:before { + content: "\f7b1"; +} + +.fa-joget:before { + content: "\f3b7"; +} + +.fa-joint:before { + content: "\f595"; +} + +.fa-joomla:before { + content: "\f1aa"; +} + +.fa-journal-whills:before { + content: "\f66a"; +} + +.fa-js:before { + content: "\f3b8"; +} + +.fa-js-square:before { + content: "\f3b9"; +} + +.fa-jsfiddle:before { + content: "\f1cc"; +} + +.fa-kaaba:before { + content: "\f66b"; +} + +.fa-kaggle:before { + content: "\f5fa"; +} + +.fa-key:before { + content: "\f084"; +} + +.fa-keybase:before { + content: "\f4f5"; +} + +.fa-keyboard:before { + content: "\f11c"; +} + +.fa-keycdn:before { + content: "\f3ba"; +} + +.fa-khanda:before { + content: "\f66d"; +} + +.fa-kickstarter:before { + content: "\f3bb"; +} + +.fa-kickstarter-k:before { + content: "\f3bc"; +} + +.fa-kiss:before { + content: "\f596"; +} + +.fa-kiss-beam:before { + content: "\f597"; +} + +.fa-kiss-wink-heart:before { + content: "\f598"; +} + +.fa-kiwi-bird:before { + content: "\f535"; +} + +.fa-korvue:before { + content: "\f42f"; +} + +.fa-landmark:before { + content: "\f66f"; +} + +.fa-language:before { + content: "\f1ab"; +} + +.fa-laptop:before { + content: "\f109"; +} + +.fa-laptop-code:before { + content: "\f5fc"; +} + +.fa-laptop-medical:before { + content: "\f812"; +} + +.fa-laravel:before { + content: "\f3bd"; +} + +.fa-lastfm:before { + content: "\f202"; +} + +.fa-lastfm-square:before { + content: "\f203"; +} + +.fa-laugh:before { + content: "\f599"; +} + +.fa-laugh-beam:before { + content: "\f59a"; +} + +.fa-laugh-squint:before { + content: "\f59b"; +} + +.fa-laugh-wink:before { + content: "\f59c"; +} + +.fa-layer-group:before { + content: "\f5fd"; +} + +.fa-leaf:before { + content: "\f06c"; +} + +.fa-leanpub:before { + content: "\f212"; +} + +.fa-lemon:before { + content: "\f094"; +} + +.fa-less:before { + content: "\f41d"; +} + +.fa-less-than:before { + content: "\f536"; +} + +.fa-less-than-equal:before { + content: "\f537"; +} + +.fa-level-down-alt:before { + content: "\f3be"; +} + +.fa-level-up-alt:before { + content: "\f3bf"; +} + +.fa-life-ring:before { + content: "\f1cd"; +} + +.fa-lightbulb:before { + content: "\f0eb"; +} + +.fa-line:before { + content: "\f3c0"; +} + +.fa-link:before { + content: "\f0c1"; +} + +.fa-linkedin:before { + content: "\f08c"; +} + +.fa-linkedin-in:before { + content: "\f0e1"; +} + +.fa-linode:before { + content: "\f2b8"; +} + +.fa-linux:before { + content: "\f17c"; +} + +.fa-lira-sign:before { + content: "\f195"; +} + +.fa-list:before { + content: "\f03a"; +} + +.fa-list-alt:before { + content: "\f022"; +} + +.fa-list-ol:before { + content: "\f0cb"; +} + +.fa-list-ul:before { + content: "\f0ca"; +} + +.fa-location-arrow:before { + content: "\f124"; +} + +.fa-lock:before { + content: "\f023"; +} + +.fa-lock-open:before { + content: "\f3c1"; +} + +.fa-long-arrow-alt-down:before { + content: "\f309"; +} + +.fa-long-arrow-alt-left:before { + content: "\f30a"; +} + +.fa-long-arrow-alt-right:before { + content: "\f30b"; +} + +.fa-long-arrow-alt-up:before { + content: "\f30c"; +} + +.fa-low-vision:before { + content: "\f2a8"; +} + +.fa-luggage-cart:before { + content: "\f59d"; +} + +.fa-lyft:before { + content: "\f3c3"; +} + +.fa-magento:before { + content: "\f3c4"; +} + +.fa-magic:before { + content: "\f0d0"; +} + +.fa-magnet:before { + content: "\f076"; +} + +.fa-mail-bulk:before { + content: "\f674"; +} + +.fa-mailchimp:before { + content: "\f59e"; +} + +.fa-male:before { + content: "\f183"; +} + +.fa-mandalorian:before { + content: "\f50f"; +} + +.fa-map:before { + content: "\f279"; +} + +.fa-map-marked:before { + content: "\f59f"; +} + +.fa-map-marked-alt:before { + content: "\f5a0"; +} + +.fa-map-marker:before { + content: "\f041"; +} + +.fa-map-marker-alt:before { + content: "\f3c5"; +} + +.fa-map-pin:before { + content: "\f276"; +} + +.fa-map-signs:before { + content: "\f277"; +} + +.fa-markdown:before { + content: "\f60f"; +} + +.fa-marker:before { + content: "\f5a1"; +} + +.fa-mars:before { + content: "\f222"; +} + +.fa-mars-double:before { + content: "\f227"; +} + +.fa-mars-stroke:before { + content: "\f229"; +} + +.fa-mars-stroke-h:before { + content: "\f22b"; +} + +.fa-mars-stroke-v:before { + content: "\f22a"; +} + +.fa-mask:before { + content: "\f6fa"; +} + +.fa-mastodon:before { + content: "\f4f6"; +} + +.fa-maxcdn:before { + content: "\f136"; +} + +.fa-medal:before { + content: "\f5a2"; +} + +.fa-medapps:before { + content: "\f3c6"; +} + +.fa-medium:before { + content: "\f23a"; +} + +.fa-medium-m:before { + content: "\f3c7"; +} + +.fa-medkit:before { + content: "\f0fa"; +} + +.fa-medrt:before { + content: "\f3c8"; +} + +.fa-meetup:before { + content: "\f2e0"; +} + +.fa-megaport:before { + content: "\f5a3"; +} + +.fa-meh:before { + content: "\f11a"; +} + +.fa-meh-blank:before { + content: "\f5a4"; +} + +.fa-meh-rolling-eyes:before { + content: "\f5a5"; +} + +.fa-memory:before { + content: "\f538"; +} + +.fa-mendeley:before { + content: "\f7b3"; +} + +.fa-menorah:before { + content: "\f676"; +} + +.fa-mercury:before { + content: "\f223"; +} + +.fa-meteor:before { + content: "\f753"; +} + +.fa-microchip:before { + content: "\f2db"; +} + +.fa-microphone:before { + content: "\f130"; +} + +.fa-microphone-alt:before { + content: "\f3c9"; +} + +.fa-microphone-alt-slash:before { + content: "\f539"; +} + +.fa-microphone-slash:before { + content: "\f131"; +} + +.fa-microscope:before { + content: "\f610"; +} + +.fa-microsoft:before { + content: "\f3ca"; +} + +.fa-minus:before { + content: "\f068"; +} + +.fa-minus-circle:before { + content: "\f056"; +} + +.fa-minus-square:before { + content: "\f146"; +} + +.fa-mitten:before { + content: "\f7b5"; +} + +.fa-mix:before { + content: "\f3cb"; +} + +.fa-mixcloud:before { + content: "\f289"; +} + +.fa-mizuni:before { + content: "\f3cc"; +} + +.fa-mobile:before { + content: "\f10b"; +} + +.fa-mobile-alt:before { + content: "\f3cd"; +} + +.fa-modx:before { + content: "\f285"; +} + +.fa-monero:before { + content: "\f3d0"; +} + +.fa-money-bill:before { + content: "\f0d6"; +} + +.fa-money-bill-alt:before { + content: "\f3d1"; +} + +.fa-money-bill-wave:before { + content: "\f53a"; +} + +.fa-money-bill-wave-alt:before { + content: "\f53b"; +} + +.fa-money-check:before { + content: "\f53c"; +} + +.fa-money-check-alt:before { + content: "\f53d"; +} + +.fa-monument:before { + content: "\f5a6"; +} + +.fa-moon:before { + content: "\f186"; +} + +.fa-mortar-pestle:before { + content: "\f5a7"; +} + +.fa-mosque:before { + content: "\f678"; +} + +.fa-motorcycle:before { + content: "\f21c"; +} + +.fa-mountain:before { + content: "\f6fc"; +} + +.fa-mouse-pointer:before { + content: "\f245"; +} + +.fa-mug-hot:before { + content: "\f7b6"; +} + +.fa-music:before { + content: "\f001"; +} + +.fa-napster:before { + content: "\f3d2"; +} + +.fa-neos:before { + content: "\f612"; +} + +.fa-network-wired:before { + content: "\f6ff"; +} + +.fa-neuter:before { + content: "\f22c"; +} + +.fa-newspaper:before { + content: "\f1ea"; +} + +.fa-nimblr:before { + content: "\f5a8"; +} + +.fa-nintendo-switch:before { + content: "\f418"; +} + +.fa-node:before { + content: "\f419"; +} + +.fa-node-js:before { + content: "\f3d3"; +} + +.fa-not-equal:before { + content: "\f53e"; +} + +.fa-notes-medical:before { + content: "\f481"; +} + +.fa-npm:before { + content: "\f3d4"; +} + +.fa-ns8:before { + content: "\f3d5"; +} + +.fa-nutritionix:before { + content: "\f3d6"; +} + +.fa-object-group:before { + content: "\f247"; +} + +.fa-object-ungroup:before { + content: "\f248"; +} + +.fa-odnoklassniki:before { + content: "\f263"; +} + +.fa-odnoklassniki-square:before { + content: "\f264"; +} + +.fa-oil-can:before { + content: "\f613"; +} + +.fa-old-republic:before { + content: "\f510"; +} + +.fa-om:before { + content: "\f679"; +} + +.fa-opencart:before { + content: "\f23d"; +} + +.fa-openid:before { + content: "\f19b"; +} + +.fa-opera:before { + content: "\f26a"; +} + +.fa-optin-monster:before { + content: "\f23c"; +} + +.fa-osi:before { + content: "\f41a"; +} + +.fa-otter:before { + content: "\f700"; +} + +.fa-outdent:before { + content: "\f03b"; +} + +.fa-page4:before { + content: "\f3d7"; +} + +.fa-pagelines:before { + content: "\f18c"; +} + +.fa-pager:before { + content: "\f815"; +} + +.fa-paint-brush:before { + content: "\f1fc"; +} + +.fa-paint-roller:before { + content: "\f5aa"; +} + +.fa-palette:before { + content: "\f53f"; +} + +.fa-palfed:before { + content: "\f3d8"; +} + +.fa-pallet:before { + content: "\f482"; +} + +.fa-paper-plane:before { + content: "\f1d8"; +} + +.fa-paperclip:before { + content: "\f0c6"; +} + +.fa-parachute-box:before { + content: "\f4cd"; +} + +.fa-paragraph:before { + content: "\f1dd"; +} + +.fa-parking:before { + content: "\f540"; +} + +.fa-passport:before { + content: "\f5ab"; +} + +.fa-pastafarianism:before { + content: "\f67b"; +} + +.fa-paste:before { + content: "\f0ea"; +} + +.fa-patreon:before { + content: "\f3d9"; +} + +.fa-pause:before { + content: "\f04c"; +} + +.fa-pause-circle:before { + content: "\f28b"; +} + +.fa-paw:before { + content: "\f1b0"; +} + +.fa-paypal:before { + content: "\f1ed"; +} + +.fa-peace:before { + content: "\f67c"; +} + +.fa-pen:before { + content: "\f304"; +} + +.fa-pen-alt:before { + content: "\f305"; +} + +.fa-pen-fancy:before { + content: "\f5ac"; +} + +.fa-pen-nib:before { + content: "\f5ad"; +} + +.fa-pen-square:before { + content: "\f14b"; +} + +.fa-pencil-alt:before { + content: "\f303"; +} + +.fa-pencil-ruler:before { + content: "\f5ae"; +} + +.fa-penny-arcade:before { + content: "\f704"; +} + +.fa-people-carry:before { + content: "\f4ce"; +} + +.fa-pepper-hot:before { + content: "\f816"; +} + +.fa-percent:before { + content: "\f295"; +} + +.fa-percentage:before { + content: "\f541"; +} + +.fa-periscope:before { + content: "\f3da"; +} + +.fa-person-booth:before { + content: "\f756"; +} + +.fa-phabricator:before { + content: "\f3db"; +} + +.fa-phoenix-framework:before { + content: "\f3dc"; +} + +.fa-phoenix-squadron:before { + content: "\f511"; +} + +.fa-phone:before { + content: "\f095"; +} + +.fa-phone-slash:before { + content: "\f3dd"; +} + +.fa-phone-square:before { + content: "\f098"; +} + +.fa-phone-volume:before { + content: "\f2a0"; +} + +.fa-php:before { + content: "\f457"; +} + +.fa-pied-piper:before { + content: "\f2ae"; +} + +.fa-pied-piper-alt:before { + content: "\f1a8"; +} + +.fa-pied-piper-hat:before { + content: "\f4e5"; +} + +.fa-pied-piper-pp:before { + content: "\f1a7"; +} + +.fa-piggy-bank:before { + content: "\f4d3"; +} + +.fa-pills:before { + content: "\f484"; +} + +.fa-pinterest:before { + content: "\f0d2"; +} + +.fa-pinterest-p:before { + content: "\f231"; +} + +.fa-pinterest-square:before { + content: "\f0d3"; +} + +.fa-pizza-slice:before { + content: "\f818"; +} + +.fa-place-of-worship:before { + content: "\f67f"; +} + +.fa-plane:before { + content: "\f072"; +} + +.fa-plane-arrival:before { + content: "\f5af"; +} + +.fa-plane-departure:before { + content: "\f5b0"; +} + +.fa-play:before { + content: "\f04b"; +} + +.fa-play-circle:before { + content: "\f144"; +} + +.fa-playstation:before { + content: "\f3df"; +} + +.fa-plug:before { + content: "\f1e6"; +} + +.fa-plus:before { + content: "\f067"; +} + +.fa-plus-circle:before { + content: "\f055"; +} + +.fa-plus-square:before { + content: "\f0fe"; +} + +.fa-podcast:before { + content: "\f2ce"; +} + +.fa-poll:before { + content: "\f681"; +} + +.fa-poll-h:before { + content: "\f682"; +} + +.fa-poo:before { + content: "\f2fe"; +} + +.fa-poo-storm:before { + content: "\f75a"; +} + +.fa-poop:before { + content: "\f619"; +} + +.fa-portrait:before { + content: "\f3e0"; +} + +.fa-pound-sign:before { + content: "\f154"; +} + +.fa-power-off:before { + content: "\f011"; +} + +.fa-pray:before { + content: "\f683"; +} + +.fa-praying-hands:before { + content: "\f684"; +} + +.fa-prescription:before { + content: "\f5b1"; +} + +.fa-prescription-bottle:before { + content: "\f485"; +} + +.fa-prescription-bottle-alt:before { + content: "\f486"; +} + +.fa-print:before { + content: "\f02f"; +} + +.fa-procedures:before { + content: "\f487"; +} + +.fa-product-hunt:before { + content: "\f288"; +} + +.fa-project-diagram:before { + content: "\f542"; +} + +.fa-pushed:before { + content: "\f3e1"; +} + +.fa-puzzle-piece:before { + content: "\f12e"; +} + +.fa-python:before { + content: "\f3e2"; +} + +.fa-qq:before { + content: "\f1d6"; +} + +.fa-qrcode:before { + content: "\f029"; +} + +.fa-question:before { + content: "\f128"; +} + +.fa-question-circle:before { + content: "\f059"; +} + +.fa-quidditch:before { + content: "\f458"; +} + +.fa-quinscape:before { + content: "\f459"; +} + +.fa-quora:before { + content: "\f2c4"; +} + +.fa-quote-left:before { + content: "\f10d"; +} + +.fa-quote-right:before { + content: "\f10e"; +} + +.fa-quran:before { + content: "\f687"; +} + +.fa-r-project:before { + content: "\f4f7"; +} + +.fa-radiation:before { + content: "\f7b9"; +} + +.fa-radiation-alt:before { + content: "\f7ba"; +} + +.fa-rainbow:before { + content: "\f75b"; +} + +.fa-random:before { + content: "\f074"; +} + +.fa-raspberry-pi:before { + content: "\f7bb"; +} + +.fa-ravelry:before { + content: "\f2d9"; +} + +.fa-react:before { + content: "\f41b"; +} + +.fa-reacteurope:before { + content: "\f75d"; +} + +.fa-readme:before { + content: "\f4d5"; +} + +.fa-rebel:before { + content: "\f1d0"; +} + +.fa-receipt:before { + content: "\f543"; +} + +.fa-recycle:before { + content: "\f1b8"; +} + +.fa-red-river:before { + content: "\f3e3"; +} + +.fa-reddit:before { + content: "\f1a1"; +} + +.fa-reddit-alien:before { + content: "\f281"; +} + +.fa-reddit-square:before { + content: "\f1a2"; +} + +.fa-redhat:before { + content: "\f7bc"; +} + +.fa-redo:before { + content: "\f01e"; +} + +.fa-redo-alt:before { + content: "\f2f9"; +} + +.fa-registered:before { + content: "\f25d"; +} + +.fa-renren:before { + content: "\f18b"; +} + +.fa-reply:before { + content: "\f3e5"; +} + +.fa-reply-all:before { + content: "\f122"; +} + +.fa-replyd:before { + content: "\f3e6"; +} + +.fa-republican:before { + content: "\f75e"; +} + +.fa-researchgate:before { + content: "\f4f8"; +} + +.fa-resolving:before { + content: "\f3e7"; +} + +.fa-restroom:before { + content: "\f7bd"; +} + +.fa-retweet:before { + content: "\f079"; +} + +.fa-rev:before { + content: "\f5b2"; +} + +.fa-ribbon:before { + content: "\f4d6"; +} + +.fa-ring:before { + content: "\f70b"; +} + +.fa-road:before { + content: "\f018"; +} + +.fa-robot:before { + content: "\f544"; +} + +.fa-rocket:before { + content: "\f135"; +} + +.fa-rocketchat:before { + content: "\f3e8"; +} + +.fa-rockrms:before { + content: "\f3e9"; +} + +.fa-route:before { + content: "\f4d7"; +} + +.fa-rss:before { + content: "\f09e"; +} + +.fa-rss-square:before { + content: "\f143"; +} + +.fa-ruble-sign:before { + content: "\f158"; +} + +.fa-ruler:before { + content: "\f545"; +} + +.fa-ruler-combined:before { + content: "\f546"; +} + +.fa-ruler-horizontal:before { + content: "\f547"; +} + +.fa-ruler-vertical:before { + content: "\f548"; +} + +.fa-running:before { + content: "\f70c"; +} + +.fa-rupee-sign:before { + content: "\f156"; +} + +.fa-sad-cry:before { + content: "\f5b3"; +} + +.fa-sad-tear:before { + content: "\f5b4"; +} + +.fa-safari:before { + content: "\f267"; +} + +.fa-salesforce:before { + content: "\f83b"; +} + +.fa-sass:before { + content: "\f41e"; +} + +.fa-satellite:before { + content: "\f7bf"; +} + +.fa-satellite-dish:before { + content: "\f7c0"; +} + +.fa-save:before { + content: "\f0c7"; +} + +.fa-schlix:before { + content: "\f3ea"; +} + +.fa-school:before { + content: "\f549"; +} + +.fa-screwdriver:before { + content: "\f54a"; +} + +.fa-scribd:before { + content: "\f28a"; +} + +.fa-scroll:before { + content: "\f70e"; +} + +.fa-sd-card:before { + content: "\f7c2"; +} + +.fa-search:before { + content: "\f002"; +} + +.fa-search-dollar:before { + content: "\f688"; +} + +.fa-search-location:before { + content: "\f689"; +} + +.fa-search-minus:before { + content: "\f010"; +} + +.fa-search-plus:before { + content: "\f00e"; +} + +.fa-searchengin:before { + content: "\f3eb"; +} + +.fa-seedling:before { + content: "\f4d8"; +} + +.fa-sellcast:before { + content: "\f2da"; +} + +.fa-sellsy:before { + content: "\f213"; +} + +.fa-server:before { + content: "\f233"; +} + +.fa-servicestack:before { + content: "\f3ec"; +} + +.fa-shapes:before { + content: "\f61f"; +} + +.fa-share:before { + content: "\f064"; +} + +.fa-share-alt:before { + content: "\f1e0"; +} + +.fa-share-alt-square:before { + content: "\f1e1"; +} + +.fa-share-square:before { + content: "\f14d"; +} + +.fa-shekel-sign:before { + content: "\f20b"; +} + +.fa-shield-alt:before { + content: "\f3ed"; +} + +.fa-ship:before { + content: "\f21a"; +} + +.fa-shipping-fast:before { + content: "\f48b"; +} + +.fa-shirtsinbulk:before { + content: "\f214"; +} + +.fa-shoe-prints:before { + content: "\f54b"; +} + +.fa-shopping-bag:before { + content: "\f290"; +} + +.fa-shopping-basket:before { + content: "\f291"; +} + +.fa-shopping-cart:before { + content: "\f07a"; +} + +.fa-shopware:before { + content: "\f5b5"; +} + +.fa-shower:before { + content: "\f2cc"; +} + +.fa-shuttle-van:before { + content: "\f5b6"; +} + +.fa-sign:before { + content: "\f4d9"; +} + +.fa-sign-in-alt:before { + content: "\f2f6"; +} + +.fa-sign-language:before { + content: "\f2a7"; +} + +.fa-sign-out-alt:before { + content: "\f2f5"; +} + +.fa-signal:before { + content: "\f012"; +} + +.fa-signature:before { + content: "\f5b7"; +} + +.fa-sim-card:before { + content: "\f7c4"; +} + +.fa-simplybuilt:before { + content: "\f215"; +} + +.fa-sistrix:before { + content: "\f3ee"; +} + +.fa-sitemap:before { + content: "\f0e8"; +} + +.fa-sith:before { + content: "\f512"; +} + +.fa-skating:before { + content: "\f7c5"; +} + +.fa-sketch:before { + content: "\f7c6"; +} + +.fa-skiing:before { + content: "\f7c9"; +} + +.fa-skiing-nordic:before { + content: "\f7ca"; +} + +.fa-skull:before { + content: "\f54c"; +} + +.fa-skull-crossbones:before { + content: "\f714"; +} + +.fa-skyatlas:before { + content: "\f216"; +} + +.fa-skype:before { + content: "\f17e"; +} + +.fa-slack:before { + content: "\f198"; +} + +.fa-slack-hash:before { + content: "\f3ef"; +} + +.fa-slash:before { + content: "\f715"; +} + +.fa-sleigh:before { + content: "\f7cc"; +} + +.fa-sliders-h:before { + content: "\f1de"; +} + +.fa-slideshare:before { + content: "\f1e7"; +} + +.fa-smile:before { + content: "\f118"; +} + +.fa-smile-beam:before { + content: "\f5b8"; +} + +.fa-smile-wink:before { + content: "\f4da"; +} + +.fa-smog:before { + content: "\f75f"; +} + +.fa-smoking:before { + content: "\f48d"; +} + +.fa-smoking-ban:before { + content: "\f54d"; +} + +.fa-sms:before { + content: "\f7cd"; +} + +.fa-snapchat:before { + content: "\f2ab"; +} + +.fa-snapchat-ghost:before { + content: "\f2ac"; +} + +.fa-snapchat-square:before { + content: "\f2ad"; +} + +.fa-snowboarding:before { + content: "\f7ce"; +} + +.fa-snowflake:before { + content: "\f2dc"; +} + +.fa-snowman:before { + content: "\f7d0"; +} + +.fa-snowplow:before { + content: "\f7d2"; +} + +.fa-socks:before { + content: "\f696"; +} + +.fa-solar-panel:before { + content: "\f5ba"; +} + +.fa-sort:before { + content: "\f0dc"; +} + +.fa-sort-alpha-down:before { + content: "\f15d"; +} + +.fa-sort-alpha-up:before { + content: "\f15e"; +} + +.fa-sort-amount-down:before { + content: "\f160"; +} + +.fa-sort-amount-up:before { + content: "\f161"; +} + +.fa-sort-down:before { + content: "\f0dd"; +} + +.fa-sort-numeric-down:before { + content: "\f162"; +} + +.fa-sort-numeric-up:before { + content: "\f163"; +} + +.fa-sort-up:before { + content: "\f0de"; +} + +.fa-soundcloud:before { + content: "\f1be"; +} + +.fa-sourcetree:before { + content: "\f7d3"; +} + +.fa-spa:before { + content: "\f5bb"; +} + +.fa-space-shuttle:before { + content: "\f197"; +} + +.fa-speakap:before { + content: "\f3f3"; +} + +.fa-speaker-deck:before { + content: "\f83c"; +} + +.fa-spider:before { + content: "\f717"; +} + +.fa-spinner:before { + content: "\f110"; +} + +.fa-splotch:before { + content: "\f5bc"; +} + +.fa-spotify:before { + content: "\f1bc"; +} + +.fa-spray-can:before { + content: "\f5bd"; +} + +.fa-square:before { + content: "\f0c8"; +} + +.fa-square-full:before { + content: "\f45c"; +} + +.fa-square-root-alt:before { + content: "\f698"; +} + +.fa-squarespace:before { + content: "\f5be"; +} + +.fa-stack-exchange:before { + content: "\f18d"; +} + +.fa-stack-overflow:before { + content: "\f16c"; +} + +.fa-stackpath:before { + content: "\f842"; +} + +.fa-stamp:before { + content: "\f5bf"; +} + +.fa-star:before { + content: "\f005"; +} + +.fa-star-and-crescent:before { + content: "\f699"; +} + +.fa-star-half:before { + content: "\f089"; +} + +.fa-star-half-alt:before { + content: "\f5c0"; +} + +.fa-star-of-david:before { + content: "\f69a"; +} + +.fa-star-of-life:before { + content: "\f621"; +} + +.fa-staylinked:before { + content: "\f3f5"; +} + +.fa-steam:before { + content: "\f1b6"; +} + +.fa-steam-square:before { + content: "\f1b7"; +} + +.fa-steam-symbol:before { + content: "\f3f6"; +} + +.fa-step-backward:before { + content: "\f048"; +} + +.fa-step-forward:before { + content: "\f051"; +} + +.fa-stethoscope:before { + content: "\f0f1"; +} + +.fa-sticker-mule:before { + content: "\f3f7"; +} + +.fa-sticky-note:before { + content: "\f249"; +} + +.fa-stop:before { + content: "\f04d"; +} + +.fa-stop-circle:before { + content: "\f28d"; +} + +.fa-stopwatch:before { + content: "\f2f2"; +} + +.fa-store:before { + content: "\f54e"; +} + +.fa-store-alt:before { + content: "\f54f"; +} + +.fa-strava:before { + content: "\f428"; +} + +.fa-stream:before { + content: "\f550"; +} + +.fa-street-view:before { + content: "\f21d"; +} + +.fa-strikethrough:before { + content: "\f0cc"; +} + +.fa-stripe:before { + content: "\f429"; +} + +.fa-stripe-s:before { + content: "\f42a"; +} + +.fa-stroopwafel:before { + content: "\f551"; +} + +.fa-studiovinari:before { + content: "\f3f8"; +} + +.fa-stumbleupon:before { + content: "\f1a4"; +} + +.fa-stumbleupon-circle:before { + content: "\f1a3"; +} + +.fa-subscript:before { + content: "\f12c"; +} + +.fa-subway:before { + content: "\f239"; +} + +.fa-suitcase:before { + content: "\f0f2"; +} + +.fa-suitcase-rolling:before { + content: "\f5c1"; +} + +.fa-sun:before { + content: "\f185"; +} + +.fa-superpowers:before { + content: "\f2dd"; +} + +.fa-superscript:before { + content: "\f12b"; +} + +.fa-supple:before { + content: "\f3f9"; +} + +.fa-surprise:before { + content: "\f5c2"; +} + +.fa-suse:before { + content: "\f7d6"; +} + +.fa-swatchbook:before { + content: "\f5c3"; +} + +.fa-swimmer:before { + content: "\f5c4"; +} + +.fa-swimming-pool:before { + content: "\f5c5"; +} + +.fa-symfony:before { + content: "\f83d"; +} + +.fa-synagogue:before { + content: "\f69b"; +} + +.fa-sync:before { + content: "\f021"; +} + +.fa-sync-alt:before { + content: "\f2f1"; +} + +.fa-syringe:before { + content: "\f48e"; +} + +.fa-table:before { + content: "\f0ce"; +} + +.fa-table-tennis:before { + content: "\f45d"; +} + +.fa-tablet:before { + content: "\f10a"; +} + +.fa-tablet-alt:before { + content: "\f3fa"; +} + +.fa-tablets:before { + content: "\f490"; +} + +.fa-tachometer-alt:before { + content: "\f3fd"; +} + +.fa-tag:before { + content: "\f02b"; +} + +.fa-tags:before { + content: "\f02c"; +} + +.fa-tape:before { + content: "\f4db"; +} + +.fa-tasks:before { + content: "\f0ae"; +} + +.fa-taxi:before { + content: "\f1ba"; +} + +.fa-teamspeak:before { + content: "\f4f9"; +} + +.fa-teeth:before { + content: "\f62e"; +} + +.fa-teeth-open:before { + content: "\f62f"; +} + +.fa-telegram:before { + content: "\f2c6"; +} + +.fa-telegram-plane:before { + content: "\f3fe"; +} + +.fa-temperature-high:before { + content: "\f769"; +} + +.fa-temperature-low:before { + content: "\f76b"; +} + +.fa-tencent-weibo:before { + content: "\f1d5"; +} + +.fa-tenge:before { + content: "\f7d7"; +} + +.fa-terminal:before { + content: "\f120"; +} + +.fa-text-height:before { + content: "\f034"; +} + +.fa-text-width:before { + content: "\f035"; +} + +.fa-th:before { + content: "\f00a"; +} + +.fa-th-large:before { + content: "\f009"; +} + +.fa-th-list:before { + content: "\f00b"; +} + +.fa-the-red-yeti:before { + content: "\f69d"; +} + +.fa-theater-masks:before { + content: "\f630"; +} + +.fa-themeco:before { + content: "\f5c6"; +} + +.fa-themeisle:before { + content: "\f2b2"; +} + +.fa-thermometer:before { + content: "\f491"; +} + +.fa-thermometer-empty:before { + content: "\f2cb"; +} + +.fa-thermometer-full:before { + content: "\f2c7"; +} + +.fa-thermometer-half:before { + content: "\f2c9"; +} + +.fa-thermometer-quarter:before { + content: "\f2ca"; +} + +.fa-thermometer-three-quarters:before { + content: "\f2c8"; +} + +.fa-think-peaks:before { + content: "\f731"; +} + +.fa-thumbs-down:before { + content: "\f165"; +} + +.fa-thumbs-up:before { + content: "\f164"; +} + +.fa-thumbtack:before { + content: "\f08d"; +} + +.fa-ticket-alt:before { + content: "\f3ff"; +} + +.fa-times:before { + content: "\f00d"; +} + +.fa-times-circle:before { + content: "\f057"; +} + +.fa-tint:before { + content: "\f043"; +} + +.fa-tint-slash:before { + content: "\f5c7"; +} + +.fa-tired:before { + content: "\f5c8"; +} + +.fa-toggle-off:before { + content: "\f204"; +} + +.fa-toggle-on:before { + content: "\f205"; +} + +.fa-toilet:before { + content: "\f7d8"; +} + +.fa-toilet-paper:before { + content: "\f71e"; +} + +.fa-toolbox:before { + content: "\f552"; +} + +.fa-tools:before { + content: "\f7d9"; +} + +.fa-tooth:before { + content: "\f5c9"; +} + +.fa-torah:before { + content: "\f6a0"; +} + +.fa-torii-gate:before { + content: "\f6a1"; +} + +.fa-tractor:before { + content: "\f722"; +} + +.fa-trade-federation:before { + content: "\f513"; +} + +.fa-trademark:before { + content: "\f25c"; +} + +.fa-traffic-light:before { + content: "\f637"; +} + +.fa-train:before { + content: "\f238"; +} + +.fa-tram:before { + content: "\f7da"; +} + +.fa-transgender:before { + content: "\f224"; +} + +.fa-transgender-alt:before { + content: "\f225"; +} + +.fa-trash:before { + content: "\f1f8"; +} + +.fa-trash-alt:before { + content: "\f2ed"; +} + +.fa-trash-restore:before { + content: "\f829"; +} + +.fa-trash-restore-alt:before { + content: "\f82a"; +} + +.fa-tree:before { + content: "\f1bb"; +} + +.fa-trello:before { + content: "\f181"; +} + +.fa-tripadvisor:before { + content: "\f262"; +} + +.fa-trophy:before { + content: "\f091"; +} + +.fa-truck:before { + content: "\f0d1"; +} + +.fa-truck-loading:before { + content: "\f4de"; +} + +.fa-truck-monster:before { + content: "\f63b"; +} + +.fa-truck-moving:before { + content: "\f4df"; +} + +.fa-truck-pickup:before { + content: "\f63c"; +} + +.fa-tshirt:before { + content: "\f553"; +} + +.fa-tty:before { + content: "\f1e4"; +} + +.fa-tumblr:before { + content: "\f173"; +} + +.fa-tumblr-square:before { + content: "\f174"; +} + +.fa-tv:before { + content: "\f26c"; +} + +.fa-twitch:before { + content: "\f1e8"; +} + +.fa-twitter:before { + content: "\f099"; +} + +.fa-twitter-square:before { + content: "\f081"; +} + +.fa-typo3:before { + content: "\f42b"; +} + +.fa-uber:before { + content: "\f402"; +} + +.fa-ubuntu:before { + content: "\f7df"; +} + +.fa-uikit:before { + content: "\f403"; +} + +.fa-umbrella:before { + content: "\f0e9"; +} + +.fa-umbrella-beach:before { + content: "\f5ca"; +} + +.fa-underline:before { + content: "\f0cd"; +} + +.fa-undo:before { + content: "\f0e2"; +} + +.fa-undo-alt:before { + content: "\f2ea"; +} + +.fa-uniregistry:before { + content: "\f404"; +} + +.fa-universal-access:before { + content: "\f29a"; +} + +.fa-university:before { + content: "\f19c"; +} + +.fa-unlink:before { + content: "\f127"; +} + +.fa-unlock:before { + content: "\f09c"; +} + +.fa-unlock-alt:before { + content: "\f13e"; +} + +.fa-untappd:before { + content: "\f405"; +} + +.fa-upload:before { + content: "\f093"; +} + +.fa-ups:before { + content: "\f7e0"; +} + +.fa-usb:before { + content: "\f287"; +} + +.fa-user:before { + content: "\f007"; +} + +.fa-user-alt:before { + content: "\f406"; +} + +.fa-user-alt-slash:before { + content: "\f4fa"; +} + +.fa-user-astronaut:before { + content: "\f4fb"; +} + +.fa-user-check:before { + content: "\f4fc"; +} + +.fa-user-circle:before { + content: "\f2bd"; +} + +.fa-user-clock:before { + content: "\f4fd"; +} + +.fa-user-cog:before { + content: "\f4fe"; +} + +.fa-user-edit:before { + content: "\f4ff"; +} + +.fa-user-friends:before { + content: "\f500"; +} + +.fa-user-graduate:before { + content: "\f501"; +} + +.fa-user-injured:before { + content: "\f728"; +} + +.fa-user-lock:before { + content: "\f502"; +} + +.fa-user-md:before { + content: "\f0f0"; +} + +.fa-user-minus:before { + content: "\f503"; +} + +.fa-user-ninja:before { + content: "\f504"; +} + +.fa-user-nurse:before { + content: "\f82f"; +} + +.fa-user-plus:before { + content: "\f234"; +} + +.fa-user-secret:before { + content: "\f21b"; +} + +.fa-user-shield:before { + content: "\f505"; +} + +.fa-user-slash:before { + content: "\f506"; +} + +.fa-user-tag:before { + content: "\f507"; +} + +.fa-user-tie:before { + content: "\f508"; +} + +.fa-user-times:before { + content: "\f235"; +} + +.fa-users:before { + content: "\f0c0"; +} + +.fa-users-cog:before { + content: "\f509"; +} + +.fa-usps:before { + content: "\f7e1"; +} + +.fa-ussunnah:before { + content: "\f407"; +} + +.fa-utensil-spoon:before { + content: "\f2e5"; +} + +.fa-utensils:before { + content: "\f2e7"; +} + +.fa-vaadin:before { + content: "\f408"; +} + +.fa-vector-square:before { + content: "\f5cb"; +} + +.fa-venus:before { + content: "\f221"; +} + +.fa-venus-double:before { + content: "\f226"; +} + +.fa-venus-mars:before { + content: "\f228"; +} + +.fa-viacoin:before { + content: "\f237"; +} + +.fa-viadeo:before { + content: "\f2a9"; +} + +.fa-viadeo-square:before { + content: "\f2aa"; +} + +.fa-vial:before { + content: "\f492"; +} + +.fa-vials:before { + content: "\f493"; +} + +.fa-viber:before { + content: "\f409"; +} + +.fa-video:before { + content: "\f03d"; +} + +.fa-video-slash:before { + content: "\f4e2"; +} + +.fa-vihara:before { + content: "\f6a7"; +} + +.fa-vimeo:before { + content: "\f40a"; +} + +.fa-vimeo-square:before { + content: "\f194"; +} + +.fa-vimeo-v:before { + content: "\f27d"; +} + +.fa-vine:before { + content: "\f1ca"; +} + +.fa-vk:before { + content: "\f189"; +} + +.fa-vnv:before { + content: "\f40b"; +} + +.fa-volleyball-ball:before { + content: "\f45f"; +} + +.fa-volume-down:before { + content: "\f027"; +} + +.fa-volume-mute:before { + content: "\f6a9"; +} + +.fa-volume-off:before { + content: "\f026"; +} + +.fa-volume-up:before { + content: "\f028"; +} + +.fa-vote-yea:before { + content: "\f772"; +} + +.fa-vr-cardboard:before { + content: "\f729"; +} + +.fa-vuejs:before { + content: "\f41f"; +} + +.fa-walking:before { + content: "\f554"; +} + +.fa-wallet:before { + content: "\f555"; +} + +.fa-warehouse:before { + content: "\f494"; +} + +.fa-water:before { + content: "\f773"; +} + +.fa-wave-square:before { + content: "\f83e"; +} + +.fa-waze:before { + content: "\f83f"; +} + +.fa-weebly:before { + content: "\f5cc"; +} + +.fa-weibo:before { + content: "\f18a"; +} + +.fa-weight:before { + content: "\f496"; +} + +.fa-weight-hanging:before { + content: "\f5cd"; +} + +.fa-weixin:before { + content: "\f1d7"; +} + +.fa-whatsapp:before { + content: "\f232"; +} + +.fa-whatsapp-square:before { + content: "\f40c"; +} + +.fa-wheelchair:before { + content: "\f193"; +} + +.fa-whmcs:before { + content: "\f40d"; +} + +.fa-wifi:before { + content: "\f1eb"; +} + +.fa-wikipedia-w:before { + content: "\f266"; +} + +.fa-wind:before { + content: "\f72e"; +} + +.fa-window-close:before { + content: "\f410"; +} + +.fa-window-maximize:before { + content: "\f2d0"; +} + +.fa-window-minimize:before { + content: "\f2d1"; +} + +.fa-window-restore:before { + content: "\f2d2"; +} + +.fa-windows:before { + content: "\f17a"; +} + +.fa-wine-bottle:before { + content: "\f72f"; +} + +.fa-wine-glass:before { + content: "\f4e3"; +} + +.fa-wine-glass-alt:before { + content: "\f5ce"; +} + +.fa-wix:before { + content: "\f5cf"; +} + +.fa-wizards-of-the-coast:before { + content: "\f730"; +} + +.fa-wolf-pack-battalion:before { + content: "\f514"; +} + +.fa-won-sign:before { + content: "\f159"; +} + +.fa-wordpress:before { + content: "\f19a"; +} + +.fa-wordpress-simple:before { + content: "\f411"; +} + +.fa-wpbeginner:before { + content: "\f297"; +} + +.fa-wpexplorer:before { + content: "\f2de"; +} + +.fa-wpforms:before { + content: "\f298"; +} + +.fa-wpressr:before { + content: "\f3e4"; +} + +.fa-wrench:before { + content: "\f0ad"; +} + +.fa-x-ray:before { + content: "\f497"; +} + +.fa-xbox:before { + content: "\f412"; +} + +.fa-xing:before { + content: "\f168"; +} + +.fa-xing-square:before { + content: "\f169"; +} + +.fa-y-combinator:before { + content: "\f23b"; +} + +.fa-yahoo:before { + content: "\f19e"; +} + +.fa-yammer:before { + content: "\f840"; +} + +.fa-yandex:before { + content: "\f413"; +} + +.fa-yandex-international:before { + content: "\f414"; +} + +.fa-yarn:before { + content: "\f7e3"; +} + +.fa-yelp:before { + content: "\f1e9"; +} + +.fa-yen-sign:before { + content: "\f157"; +} + +.fa-yin-yang:before { + content: "\f6ad"; +} + +.fa-yoast:before { + content: "\f2b1"; +} + +.fa-youtube:before { + content: "\f167"; +} + +.fa-youtube-square:before { + content: "\f431"; +} + +.fa-zhihu:before { + content: "\f63f"; +} + +.sr-only { + border: 0; + clip: rect(0, 0, 0, 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; +} + +.sr-only-focusable:active, +.sr-only-focusable:focus { + clip: auto; + height: auto; + margin: 0; + overflow: visible; + position: static; + width: auto; +} + +.text-title1 { + color: #003a6b; +} + +.text-title2 { + color: #00aed2; +} + +.text-card-red { + color: #ff5757; +} + +.text-card-yellow { + color: #f78542; +} + +.text-card-blue { + color: #8dc6c3; +} + +.text-card-green { + color: #4ac66a; +} + +.text-card-blue-dark { + color: #004085; +} + +body { + font-family: "Open Sans", sans-serif; + font-size: 17px; + line-height: 1.6; + color: #7f7f7f; + background-color: #f2f2f2; +} + +.flex-grow { + flex: 1; + -ms-flex: 1 1 auto; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + color: #003a6b; +} + +a { + color: #003a6b; +} + +a:hover { + color: #00aed2; +} + +nav.navbar-light { + z-index: 999; + background-color: #fff; + box-shadow: 0 0 15px #4f4f4f; +} + +nav.navbar-light .navbar-brand img { + height: 5rem; +} + +nav.navbar-light .navbar-nav { + font-size: 1.3rem; + font-weight: bold; +} + +nav.navbar-light .navbar-nav .active > .nav-link { + color: #00aed2; +} + +nav.navbar-light .navbar-nav .nav-link { + color: #003a6b; +} + +nav.navbar-light .navbar-nav .nav-link:hover { + color: #00aed2; +} + +footer { + text-align: center; + background-color: #f2f2f2; +} + +footer p { + padding: 2rem 0; +} + +.login-bg { + min-height: 77vh; +} +.login-bg { + height: 100%; + align-content: center; + background-image: url(/img/bg.png); + background-size: cover; + background-repeat: no-repeat; + background-position: center center; +} + +.login-bg .card { + margin-top: auto; + margin-bottom: auto; + width: 400px; + background-color: rgba(0, 0, 0, 0.7); +} + +.login-bg .card .card-header h3 { + color: white; +} + +.login-bg .card .card-header.error { + background-color: rgba(213, 20, 47, 0.7); +} + +.login-bg .card .card-header.error ul.error { + color: white; + border-radius: 5px; + background-color: #a52034; + font-size: 15px; + list-style: none; + padding: 5px; + margin: 0; +} + +.login-bg .card .card-body .input-group-prepend span { + width: 50px; + background-color: #00aed2; + color: black; + border: 0; +} + +.login-bg .card .card-body input:focus { + outline: 0 0 0 0 !important; + box-shadow: 0 0 0 0 !important; +} + +.login-bg .card .card-body .remember { + color: white; +} + +.login-bg .card .card-body .remember input { + width: 20px; + height: 20px; + margin-left: 15px; + margin-right: 5px; +} + +.login-bg .card .card-body .login_btn { + color: black; + background-color: #00aed2; + width: 75%; +} + +.login-bg .card .card-body .login_btn:hover { + color: black; + background-color: white; +} + +.login-bg .card .card-footer .links { + color: white; +} + +.login-bg .card .card-footer .links a { + margin-left: 4px; + color: #00aed2; +} + +.registerPortal { + color: white; + padding: 60px 0 80px 0; + background-image: url(/img/register-bg.jpg); + background-size: cover; + background-repeat: no-repeat; +} + +.registerPortal h1 { + color: white; +} + +.registerPortal .description a { + font-size: 1.2rem; + color: #003a6b; +} + +.registerPortal .description a:hover { + color: #e5f5fa; +} + +.registerPortal .card { + background: rgba(0, 0, 0, 0.35); +} + +.registerPortal .card a { + color: #00aed2; +} + +.registerPortal .card a:hover { + color: #8dc6c3; +} + +.registerPortal .form-top { + overflow: hidden; + padding: 0 25px 15px 25px; + background: #444; + background: rgba(0, 0, 0, 0.35); + border-radius: 4px 4px 0 0; + text-align: left; +} + +.registerPortal .form-top .form-top-left { + float: left; + width: 75%; + padding-top: 25px; +} + +.registerPortal .form-top .form-top-left h3 { + margin-top: 0; + color: #fff; +} + +.registerPortal .form-top .form-top-left p { + opacity: 0.8; + color: #fff; +} + +.registerPortal .form-top .form-top-right { + float: left; + width: 25%; + padding-top: 5px; + font-size: 66px; + color: #fff; + line-height: 100px; + text-align: right; + opacity: 0.3; +} + +.registerPortal .form-bottom { + padding: 25px 25px 30px 25px; + background: #444; + background: rgba(0, 0, 0, 0.3); + border-radius: 0 0 4px 4px; + text-align: left; +} + +.registerPortal .form-bottom .isize { + width: 100px; +} + +.registerPortal .form-bottom .links a { + color: #00aed2; + cursor: pointer; +} + +.registerPortal .form-bottom .links a:hover { + color: #fff; +} + +.registerPortal .middle-border { + min-height: 300px; + margin-top: 20px; + border-right: 1px solid #fff; + border-right: 1px solid rgba(255, 255, 255, 0.6); +} + +#gdprModal .modal-content .modal-header h5 { + color: #003a6b; +} + +#gdprModal .modal-content .modal-body { + color: #7f7f7f; +} + +#requirementsList .card.card-body .row-requests .col-md-4:not(:last-of-type) { + border-right: 1px solid #e2e8ee; +} + +#requirementsList .card.card-body .row-requests i { + height: 60px; + line-height: 60px; + font-size: 2rem; +} + +#requirementsList .card table { + box-sizing: border-box; + border-bottom: 1px solid #e8e8e8; +} + +#requirementsList .card table tbody tr:hover { + background-color: #fbfbfb; + box-shadow: 1px 1px 30px -22px rgba(0, 0, 0, 0.61); +} + +#requirementsList .card table tbody tr td { + font-size: 1rem; + line-height: 1rem; +} + +#requirementsList .card table tbody tr td.reqType { + font-size: 1.8rem; +} + +#requirementsList .card table tbody tr td .text-request { + font-size: 0.9rem; +} + +#requirementsList .card table tbody tr td .text-muted { + font-size: 0.8rem; +} + +.progressBar-container { + width: 99%; +} + +.progressbar { + counter-reset: step; +} + +.progressbar li { + list-style-type: none; + width: 25%; + position: relative; + float: left; +} + +.progressbar li div.progress-text { + margin: 5px auto 0; + width: 55%; + text-align: center; +} + +.progressbar li:before { + content: counter(step); + counter-increment: step; + color: black; + width: 50px; + height: 50px; + line-height: 50px; + border: 1px solid #a39f9a; + text-align: center; + display: block; + border-radius: 50%; + background-color: #f2f2f2; + margin: 0 auto; +} + +.progressbar li.active:before { + color: white; + background: #3c73a2; + border: 5px solid #ffffff; + width: 50px; + height: 50px; + line-height: 44.5px; + box-shadow: 0px 0px 0px 1px #3c73a2; + font-size: 16px; +} + +.progressbar li.completed:before { + font-family: "Font Awesome 5 Free"; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + content: "\f00c"; + background-repeat: no-repeat; + background-position: center; + background-color: #bfffb8; + font-weight: 900; + color: #0ddf06; + border: 1px solid white; +} + +.progressbar li:after { + content: ""; + position: absolute; + width: calc(100% - 80px); + height: 2px; + background-color: lightgray; + top: 25px; + left: calc(-50% + 40px); + z-index: -1; +} + +.progressbar li:first-child:after { + content: none; +} + +#requirement.bg-gra-red { + background: linear-gradient(to top right, #6c4079 0%, #ff5757 100%); +} + +#requirement.bg-gra-blue { + background: linear-gradient(to top right, #375879 0%, #8dc6c3 100%); +} + +#requirement.bg-gra-light-blue { + background: linear-gradient(to top right, #6b8ab7 0%, #8dc6c3 100%); +} + +#requirement.bg-gra-green { + background: linear-gradient(to top right, #2b7859 0%, #4ac66a 100%); +} + +#requirement .wrapper { + margin: 0 auto; +} + +#requirement .wrapper.wrapper--w960 { + max-width: 960px; +} + +#requirement .wrapper.wrapper--w780 { + max-width: 780px; +} + +#requirement .wrapper.wrapper--w680 { + max-width: 680px; +} + +#requirement .wrapper .card form .form-style-1 { + box-shadow: 1px 1px 12px 0px rgba(0, 0, 0, 0.15); + border: 1px solid #e0e0e0; + margin-top: 5px; +} + +#requirement .wrapper .card form textarea { + font-size: 1.4em; +} + +#requirements { + background-color: #f2f2f2; +} + +#requirements h2 { + padding: 2rem 0 0 0; + font-size: 2rem; +} + +#requirements .requests-cards { + text-align: left; +} + +#requirements .requests-cards a { + text-decoration: none; + color: #003a6b; +} + +#requirements .requests-cards .my-card { + color: #fff; + border: 1px solid #7f7f7f; + border-radius: 4px; + transition: 0.3s all ease-in-out; +} + +#requirements .requests-cards .my-card:hover { + border: 1px solid #7f7f7f; + transform: translateY(-0.25rem); + box-shadow: 0 2.25rem 1.5rem -1.5rem rgba(33, 37, 41, 0.3), + 0 0 1.5rem 0.5rem rgba(33, 37, 41, 0.05) !important; +} + +#requirements .requests-cards .my-card i { + padding-bottom: 1rem; + font-size: 3rem; + color: #fff; +} + +#requirements .requests-cards .my-card h3 { + color: #fff; + margin: 0; +} + +#requirements .requests-cards .my-card .card-text { + text-decoration: none; +} + +#requirements .requests-cards .my-card.card-bg-red { + background-color: #ff5757; +} + +#requirements .requests-cards .my-card.card-bg-yellow { + background-color: #f78542; +} + +#requirements .requests-cards .my-card.card-bg-blue { + background-color: #8dc6c3; +} + +#requirements .requests-cards .my-card.card-bg-green { + background-color: #4ac66a; +} + +#requirements .requests-cards .my-card.card-bg-blue-dark { + background-color: #004085; +} + +@media (max-width: 500px) { + nav.navbar-light .navbar-brand img { + height: 4rem; + } +} + +@media (max-width: 400px) { + nav.navbar-light .navbar-brand img { + height: 3rem; + } +} + +.font-monospace { + font-family: var(--bs-font-monospace) !important; +} + +.fs-1 { + font-size: calc(1.375rem + 1.5vw) !important; +} + +.fs-2 { + font-size: calc(1.325rem + 0.9vw) !important; +} + +.fs-3 { + font-size: calc(1.3rem + 0.6vw) !important; +} + +.fs-4 { + font-size: calc(1.275rem + 0.3vw) !important; +} + +.fs-5 { + font-size: 1.25rem !important; +} + +.fs-6 { + font-size: 1rem !important; +} + +.fst-italic { + font-style: italic !important; +} + +.fst-normal { + font-style: normal !important; +} + +.fw-lighter { + font-weight: lighter !important; +} + +.fw-light { + font-weight: 300 !important; +} + +.fw-normal { + font-weight: 400 !important; +} + +.fw-medium { + font-weight: 500 !important; +} + +.fw-semibold { + font-weight: 600 !important; +} + +.fw-bold { + font-weight: 700 !important; +} + +.fw-bolder { + font-weight: bolder !important; +} + +.lh-1 { + line-height: 1 !important; +} + +.lh-sm { + line-height: 1.25 !important; +} + +.lh-base { + line-height: 1.5 !important; +} + +.lh-lg { + line-height: 2 !important; +} + +.registerPortal h1 { + color: white; +} + +.registerPortal .description a { + font-size: 1.2rem; + color: #003a6b; +} + +.registerPortal .description a:hover { + color: #e5f5fa; +} + +.registerPortal .card { + background: rgba(0, 0, 0, 0.35); +} + +.registerPortal .card a { + color: #00aed2; +} + +.registerPortal .card a:hover { + color: #8dc6c3; +} + +.registerPortal .form-top { + overflow: hidden; + padding: 0 25px 15px 25px; + background: #444; + background: rgba(0, 0, 0, 0.35); + border-radius: 4px 4px 0 0; + text-align: left; +} + +.registerPortal .form-top .form-top-left { + float: left; + width: 75%; + padding-top: 25px; +} + +.registerPortal .form-top .form-top-left h3 { + margin-top: 0; + color: #fff; +} + +.registerPortal .form-top .form-top-left p { + opacity: 0.8; + color: #fff; +} + +.registerPortal .form-top .form-top-right { + float: left; + width: 25%; + padding-top: 5px; + font-size: 66px; + color: #fff; + line-height: 100px; + text-align: right; + opacity: 0.3; +} + +.registerPortal .form-bottom { + padding: 25px 25px 30px 25px; + background: #444; + background: rgba(0, 0, 0, 0.3); + border-radius: 0 0 4px 4px; + text-align: left; +} + +.registerPortal .form-bottom .isize { + width: 110px; +} + +.registerPortal .form-bottom .links a { + color: #00aed2; + cursor: pointer; +} + +.registerPortal .form-bottom .links a:hover { + color: #fff; +} + +.registerPortal .middle-border { + min-height: 300px; + margin-top: 20px; + border-right: 1px solid #fff; + border-right: 1px solid rgba(255, 255, 255, 0.6); +} + +.form-check .form-check-input { + float: left; + margin-left: -1.5em; +} +.form-check-reverse { + padding-right: 1.5em; + padding-left: 0; + text-align: right; +} +.form-check-reverse .form-check-input { + float: right; + margin-right: -1.5em; + margin-left: 0; +} +.form-check-input { + --bs-form-check-bg: var(--bs-body-bg); + flex-shrink: 0; + width: 1em; + height: 1em; + margin-top: 0.25em; + vertical-align: top; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background-color: var(--bs-form-check-bg); + background-image: var(--bs-form-check-bg-image); + background-repeat: no-repeat; + background-position: center; + background-size: contain; + border: var(--bs-border-width) solid var(--bs-border-color); + -webkit-print-color-adjust: exact; + color-adjust: exact; + print-color-adjust: exact; +} +.form-check-input[type="checkbox"] { + border-radius: 0.25em; +} +.form-check-input[type="radio"] { + border-radius: 50%; +} +.form-check-input:active { + filter: brightness(90%); +} +.form-check-input:focus { + border-color: #86b7fe; + outline: 0; + box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); +} +.form-check-input:checked { + background-color: #0d6efd; + border-color: #0d6efd; +} +.form-check-input:checked[type="checkbox"] { + --bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e"); +} +.form-check-input:checked[type="radio"] { + --bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e"); +} +.form-check-input[type="checkbox"]:indeterminate { + background-color: #0d6efd; + border-color: #0d6efd; + --bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e"); +} +.form-check-input:disabled { + pointer-events: none; + filter: none; + opacity: 0.5; +} +.form-check-input:disabled ~ .form-check-label, +.form-check-input[disabled] ~ .form-check-label { + cursor: default; + opacity: 0.5; +} +.form-switch { + padding-left: 2.5em; +} +.form-switch .form-check-input { + --bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e"); + width: 2em; + margin-left: -2.5em; + background-image: var(--bs-form-switch-bg); + background-position: left center; + border-radius: 2em; + transition: background-position 0.15s ease-in-out; +} +@media (prefers-reduced-motion: reduce) { + .form-switch .form-check-input { + transition: none; + } +} +.form-switch .form-check-input:focus { + --bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2386b7fe'/%3e%3c/svg%3e"); +} +.form-switch .form-check-input:checked { + background-position: right center; + --bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e"); +} +.form-switch.form-check-reverse { + padding-right: 2.5em; + padding-left: 0; +} +.form-switch.form-check-reverse .form-check-input { + margin-right: -2.5em; + margin-left: 0; +} +.form-check-inline { + display: inline-block; + margin-right: 1rem; +} +.btn-check { + position: absolute; + clip: rect(0, 0, 0, 0); + pointer-events: none; +} +.btn-check:disabled + .btn, +.btn-check[disabled] + .btn { + pointer-events: none; + filter: none; + opacity: 0.65; +} +[data-bs-theme="dark"] + .form-switch + .form-check-input:not(:checked):not(:focus) { + --bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%28255, 255, 255, 0.25%29'/%3e%3c/svg%3e"); +} +.form-check-input.is-valid, +.was-validated .form-check-input:valid { + border-color: var(--bs-form-valid-border-color); +} +.form-check-input.is-valid:checked, +.was-validated .form-check-input:valid:checked { + background-color: var(--bs-form-valid-color); +} +.form-check-input.is-valid:focus, +.was-validated .form-check-input:valid:focus { + box-shadow: 0 0 0 0.25rem rgba(var(--bs-success-rgb), 0.25); +} +.form-check-input.is-valid ~ .form-check-label, +.was-validated .form-check-input:valid ~ .form-check-label { + color: var(--bs-form-valid-color); +} +.form-check-input.is-invalid, +.was-validated .form-check-input:invalid { + border-color: var(--bs-form-invalid-border-color); +} +.form-check-input.is-invalid:checked, +.was-validated .form-check-input:invalid:checked { + background-color: var(--bs-form-invalid-color); +} +.form-check-input.is-invalid:focus, +.was-validated .form-check-input:invalid:focus { + box-shadow: 0 0 0 0.25rem rgba(var(--bs-danger-rgb), 0.25); +} +.form-check-input.is-invalid ~ .form-check-label, +.was-validated .form-check-input:invalid ~ .form-check-label { + color: var(--bs-form-invalid-color); +} +.form-check-inline .form-check-input ~ .invalid-feedback { + margin-left: 0.5em; +} + +.alert-dismissible .btn-close { + position: absolute; + top: 0; + right: 0; + z-index: 2; + padding: 1.25rem 1rem; +} + +.btn-close { + --bs-btn-close-color: #000; + --bs-btn-close-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414'/%3e%3c/svg%3e"); + --bs-btn-close-opacity: 0.5; + --bs-btn-close-hover-opacity: 0.75; + --bs-btn-close-focus-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); + --bs-btn-close-focus-opacity: 1; + --bs-btn-close-disabled-opacity: 0.25; + box-sizing: content-box; + width: 1em; + height: 1em; + padding: 0.25em 0.25em; + color: var(--bs-btn-close-color); + background: transparent var(--bs-btn-close-bg) center/1em auto no-repeat; + filter: var(--bs-btn-close-filter); + border: 0; + border-radius: 0.375rem; + opacity: var(--bs-btn-close-opacity); +} +.btn-close:hover { + color: var(--bs-btn-close-color); + text-decoration: none; + opacity: var(--bs-btn-close-hover-opacity); +} +.btn-close:focus { + outline: 0; + box-shadow: var(--bs-btn-close-focus-shadow); + opacity: var(--bs-btn-close-focus-opacity); +} +.btn-close:disabled, .btn-close.disabled { + pointer-events: none; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + opacity: var(--bs-btn-close-disabled-opacity); +} + +.btn-close-white { + --bs-btn-close-filter: invert(1) grayscale(100%) brightness(200%); +} + +.toast-header .btn-close { + margin-right: calc(-0.5 * var(--bs-toast-padding-x)); + margin-left: var(--bs-toast-padding-x); +} + +.modal-header .btn-close { + padding: calc(var(--bs-modal-header-padding-y) * 0.5) calc(var(--bs-modal-header-padding-x) * 0.5); + margin-top: calc(-0.5 * var(--bs-modal-header-padding-y)); + margin-right: calc(-0.5 * var(--bs-modal-header-padding-x)); + margin-bottom: calc(-0.5 * var(--bs-modal-header-padding-y)); + margin-left: auto; +} + +.offcanvas-header .btn-close { + padding: calc(var(--bs-offcanvas-padding-y) * 0.5) calc(var(--bs-offcanvas-padding-x) * 0.5); + margin-top: calc(-0.5 * var(--bs-offcanvas-padding-y)); + margin-right: calc(-0.5 * var(--bs-offcanvas-padding-x)); + margin-bottom: calc(-0.5 * var(--bs-offcanvas-padding-y)); + margin-left: auto; +} + + +/* Custom CSS for the reservation editor */ +.grid { + user-select: none; + background-color: #f8f9fa; +} + +.cell { + border: 1px solid #dee2e6; + background-color: white; +} + +.reservation { + cursor: grab; + border-radius: 4px; + overflow: hidden; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.reservation.dragging { + cursor: grabbing; + box-shadow: 0 4px 8px rgba(0,0,0,0.2); + z-index: 3 !important; +} + +.reservation-draft { + border: 1px dashed #0d6efd; + box-sizing: border-box; +} + +.status-select { + cursor: pointer; + z-index: 4; +} + +.resize-handle { + border-radius: 2px 0 0 0; +} + +.input-group-city-psc{ + flex-wrap: nowrap; +} + +.grid-bg{ + height: 100%; + align-content: center; + background-image: url(/img/namest-1.png); + background-size: cover; + background-repeat: no-repeat; + background-position: center center; +} + +.sidebar { + position: fixed; + top: 0; + bottom: 0; + left: 0; + z-index: 100; + padding: 48px 0 0; + box-shadow: inset -1px 0 0 rgba(0, 0, 0, 0.1); +} + +.sidebar .nav-link { + font-weight: 500; + color: #333; +} + +.sidebar .nav-link .feather { + margin-right: 4px; + color: #999; +} + +.sidebar .nav-link.active { + color: #007bff; +} + +.sidebar .nav-link.active .feather { + color: inherit; +} + +.sidebar .nav-link:hover .feather { + color: inherit; +} + +.sidebar-sticky { + position: relative; + top: 0; + height: calc(100vh - 48px); + padding-top: 0.5rem; + overflow-x: hidden; + overflow-y: auto; +} + +@supports (position: sticky) { + .sidebar-sticky { + position: sticky; + } +} + +.sidebar-heading { + font-size: 0.75rem; + text-transform: uppercase; +} + +.g-0, +.gx-0 { + --bs-gutter-x: 0; +} + +.g-0, +.gy-0 { + --bs-gutter-y: 0; +} + +.g-1, +.gx-1 { + --bs-gutter-x: 0.25rem; +} + +.g-1, +.gy-1 { + --bs-gutter-y: 0.25rem; +} + +.g-2, +.gx-2 { + --bs-gutter-x: 0.5rem; +} + +.g-2, +.gy-2 { + --bs-gutter-y: 0.5rem; +} + +.g-3, +.gx-3 { + --bs-gutter-x: 1rem; +} + +.g-3, +.gy-3 { + --bs-gutter-y: 1rem; +} + +.g-4, +.gx-4 { + --bs-gutter-x: 1.5rem; +} + +.g-4, +.gy-4 { + --bs-gutter-y: 1.5rem; +} + +.g-5, +.gx-5 { + --bs-gutter-x: 3rem; +} + +.g-5, +.gy-5 { + --bs-gutter-y: 3rem; +} \ No newline at end of file diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..c29bd9f --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,18 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import "./css/index.css"; +import "@mantine/core/styles.layer.css"; +import App from "./App.jsx"; +import { MantineProvider } from "@mantine/core"; +//import 'bootstrap/dist/css/bootstrap.min.css'; + +createRoot(document.getElementById("app")).render( + + + + + + + +); diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx new file mode 100644 index 0000000..cdf5b2d --- /dev/null +++ b/frontend/src/pages/Admin.jsx @@ -0,0 +1,11 @@ + +function Admin(){ + + return( +
+
Admin page
+
+ ) +} + +export default Admin \ No newline at end of file diff --git a/frontend/src/pages/HelpDesk.jsx b/frontend/src/pages/HelpDesk.jsx new file mode 100644 index 0000000..6850af7 --- /dev/null +++ b/frontend/src/pages/HelpDesk.jsx @@ -0,0 +1,17 @@ +import {Container, Button, Card, Row, Col} from "react-bootstrap"; +import ReportForm from "../components/ReportForm"; + +function ReportForm() { + return ( + +
+ +
+
+

eTržnice

+
+
+ ); +} + +export default ReportForm; \ No newline at end of file diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx new file mode 100644 index 0000000..7753f28 --- /dev/null +++ b/frontend/src/pages/Home.jsx @@ -0,0 +1,674 @@ +import { + Container, Row, Col, + Card, + Badge, + Button, + Tabs, Tab, + Modal, Form, Alert +} from "react-bootstrap"; +import { faGear, faPlus } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Link } from "react-router-dom"; +import { UserContext } from "../context/UserContext"; +import { useState, useEffect, useContext } from "react"; +import dayjs from "dayjs"; + +import Table from "../components/Table"; + +import ordersAPI from "../api/model/order"; +import reservationsAPI from "../api/model/reservation"; +import ticketsAPI from "../api/model/ticket"; +import { IconEye, IconEdit, IconTrash, IconCreditCard } from "@tabler/icons-react"; +import { Group, ActionIcon, Text, Stack } from "@mantine/core"; +import { useNavigate } from "react-router-dom"; + + +function Home() { + const { user } = useContext(UserContext) || {}; + + // Guard: until RequireAuthLayout sets user, avoid rendering (prevents user.role null errors) + if (!user) { + return null; // or return a loader component + } + + const [user_reservations, setReservations] = useState([]); + const [user_orders, setOrders] = useState([]); + const [user_tickets, setTickets] = useState([]); + + const navigate = useNavigate(); + + useEffect(() => { + const fetchReservations = async () => { + try { + var data = await reservationsAPI.getReservations({ user: user.id }); + setReservations(data); + data = undefined; + + data = await ordersAPI.getOrders({ user: user.id }); + setOrders(data); + data = undefined; + + data = await ticketsAPI.getServiceTickets({ user: user.id }); + setTickets(data); // <-- FIX: was setOrders(data) + } catch (err) { + console.error("Chyba při načítání:", err); + } + }; + + if (user?.id) { + fetchReservations(); + } + }, [user?.id]); + + // Modal state + const [showModal, setShowModal] = useState(false); + const [modalType, setModalType] = useState(""); // 'view', 'edit' + const [selectedRecord, setSelectedRecord] = useState(null); + const [formData, setFormData] = useState({}); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + // Reservation actions + const handleShowReservation = (record) => { + setSelectedRecord(record); + setModalType("view-reservation"); + setShowModal(true); + }; + const handleEditReservation = (record) => { + setSelectedRecord(record); + setFormData({ + ...record, + // Add more fields if needed + }); + setModalType("edit-reservation"); + setShowModal(true); + setError(null); + }; + const handleDeleteReservation = async (record) => { + if (window.confirm(`Opravdu smazat rezervaci #${record.id}?`)) { + // Implement delete API call here + // await reservationAPI.deleteReservation(record.id); + setReservations((prev) => prev.filter(r => r.id !== record.id)); + } + }; + + // Order actions + const handleShowOrder = (record) => { + setSelectedRecord(record); + setModalType("view-order"); + setShowModal(true); + }; + const handleEditOrder = (record) => { + setSelectedRecord(record); + setFormData({ + ...record, + // Add more fields if needed + }); + setModalType("edit-order"); + setShowModal(true); + setError(null); + }; + const handleDeleteOrder = async (record) => { + if (window.confirm(`Opravdu smazat objednávku #${record.id}?`)) { + // Implement delete API call here + // await orderAPI.deleteOrder(record.id); + setOrders((prev) => prev.filter(r => r.id !== record.id)); + } + }; + const handlePayOrder = (record) => { + navigate(`/payment/${record.id}`); + }; + + // Ticket actions + const handleShowTicket = (record) => { + setSelectedRecord(record); + setModalType("view-ticket"); + setShowModal(true); + }; + const handleEditTicket = (record) => { + setSelectedRecord(record); + setFormData({ + ...record, + // Add more fields if needed + }); + setModalType("edit-ticket"); + setShowModal(true); + setError(null); + }; + const handleDeleteTicket = async (record) => { + if (window.confirm(`Opravdu smazat ticket #${record.id}?`)) { + // Implement delete API call here + // await ticketAPI.deleteTicket(record.id); + setTickets((prev) => prev.filter(r => r.id !== record.id)); + } + }; + + // Edit modal submit handlers (example for reservation) + const handleEditModalSubmit = async (e) => { + e.preventDefault(); + setError(null); + setSubmitting(true); + try { + // Implement update API call here, e.g.: + // await reservationAPI.updateReservation(selectedRecord.id, formData); + setShowModal(false); + // Refresh data if needed + } catch (err) { + setError("Chyba při ukládání: " + (err.message || "Neznámá chyba")); + } finally { + setSubmitting(false); + } + }; + + const reservation_columns = [ + { accessor: "id", title: "ID", sortable: true }, + { accessor: "event.name", title: "Událost", sortable: true }, + { accessor: "marketSlot", title: "Slot", sortable: true }, + { accessor: "used_extension", title: "Prodlouženo", sortable: true }, + { + accessor: "reserved_from", + title: "Od", + sortable: true, + render: ({ reserved_from }) => dayjs(reserved_from).format("DD.MM.YYYY HH:mm"), + }, + { + accessor: "reserved_to", + title: "Do", + sortable: true, + render: ({ reserved_to }) => dayjs(reserved_to).format("DD.MM.YYYY HH:mm"), + }, + { + accessor: "final_price", + title: "Cena", + sortable: true, + render: ({ final_price }) => `${Number(final_price).toFixed(2)} Kč`, + }, + { accessor: "status", title: "Stav", sortable: true }, + { + accessor: "actions", + title: "Akce", + width: "5.5%", + render: (record) => ( + + handleShowReservation(record)}> + + + handleEditReservation(record)}> + + + handleDeleteReservation(record)}> + + + + ), + }, + ]; + + const order_columns = [ + { accessor: "id", title: "ID", sortable: true }, + { + accessor: "reservation.name", + title: "Rezervace", + sortable: true, + }, + { + accessor: "created_at", + title: "Vytvořeno", + sortable: true, + render: ({ created_at }) => dayjs(created_at).format("DD.MM.YYYY HH:mm"), + }, + { + accessor: "price_to_pay", + title: "Částka k zaplacení", + sortable: true, + render: ({ price_to_pay }) => `${Number(price_to_pay).toFixed(2)} Kč`, + }, + { + accessor: "payed_at", + title: "Zaplaceno v čase", + sortable: true, + render: ({ payed_at }) => payed_at ? dayjs(payed_at).format("DD.MM.YYYY HH:mm") : "-", + }, + { + accessor: "status", + title: "Stav", + sortable: true, + }, + { + accessor: "note", + title: "Poznámka", + sortable: false, + render: ({ note }) => note?.slice(0, 50) || "-", + }, + { + accessor: "actions", + title: "Akce", + width: "7%", + render: (record) => ( + + handleShowOrder(record)}> + + + handlePayOrder(record)} title="Zaplatit"> + + + + ), + }, + ]; + + + const ticket_columns = [ + { accessor: "id", title: "ID", sortable: true }, + { accessor: "title", title: "Název", sortable: true }, + { + accessor: "created_at", + title: "Vytvořeno", + sortable: true, + render: ({ created_at }) => dayjs(created_at).format("DD.MM.YYYY HH:mm"), + }, + { accessor: "status", title: "Stav", sortable: true }, + { accessor: "category", title: "Kategorie", sortable: true }, + { + accessor: "description", + title: "Popis", + sortable: false, + render: ({ description }) => description?.slice(0, 50) || "-", + }, + { + accessor: "actions", + title: "Akce", + width: "5.5%", + render: (record) => ( + + handleShowTicket(record)}> + + + handleEditTicket(record)}> + + + handleDeleteTicket(record)}> + + + + ), + }, + ]; + + + + const RezervaceModalContent = (record, close) => ( + + Detail rezervace + Událost: {record.event?.name} + Slot: {record.marketSlot} + Prodlouženo: {record.used_extension ? "Ano" : "Ne"} + Od: {dayjs(record.reserved_from).format("DD.MM.YYYY HH:mm")} + Do: {dayjs(record.reserved_to).format("DD.MM.YYYY HH:mm")} + Stav: {record.status} + Poznámka: {record.note} + Cena: {Number(record.final_price).toFixed(2)} Kč + + + + + ); + + const OrderModalContent = (record, close) => ( + + Detail Objednávky + Rezervace: {record.reservation?.name} + Vytvořeno: {dayjs(record.created_at).format("DD.MM.YYYY HH:mm")} + Částka k zaplacení: {Number(record.price_to_pay).toFixed(2)} Kč + Zaplaceno v čase: {record.payed_at ? dayjs(record.payed_at).format("DD.MM.YYYY HH:mm") : "-"} + Stav: {record.status} + Poznámka: {record.note} + + + + + + ); + + const TicketModalContent = (record, close) => ( + + Detail Objednávky + Název: {record.title} + Vytvořeno: {record.created_at.format("DD.MM.YYYY HH:mm")} + Stav: {record.status} + Kategorie: {record.category} + Popis: {record.description} + + + + + ); + + + return ( + + + + + + {/* Badge s rolí v pravém horním rohu */} + + { + user.role === "admin" + ? "Admin" + : user.role === "seller" + ? "Prodejce" + : user.role === "squareManager" + ? "Správce tržiště" + : user.role === "cityClerk" + ? "Úředník" + : user.role === "checker" + ? "Kontrolor" + : "Neznámá role" + } + +
+ + + + + + + + Přihlášen jako: {user.username} + {user.account_type} + +
+ + Jméno: {user.first_name} + Příjmení: {user.last_name} + + + +
+

Adresa

+ + + Město: {user.city} + Ulice: {user.street} + + + PSČ: {user.PSC} + + +
+

Kontaktní údaje

+ + + Tel: {user.phone_number} + + + E-mail: {user.email} + +
+

Platby

+ + Účet: {user.bank_account} + + + Variabilní číslo: {user.var_symbol} + +
+ +
+ + + +
+
+
+ + + {/* Buttons */} + + + + + + Vytvořit Rezervaci + + + + + Problém? + + + { + user.role === "admin" ? ( + + + Manager + + + ) : user.role === "seller" ? ( + "" + ) : user.role === "squareManager" ? ( + + + Manager + + + ) : user.role === "cityClerk" ? ( + + + Manager + + + ) : user.role === "checker" ? ( + "" + ) : ( + "Neznámá role" + ) + } + + + +
+ + {/* TAB TABULKY */} + + + + + + + +
+ + + {user_tickets && ( + +
+ + )} + + + + {/* Bootstrap Modal for view/edit */} + setShowModal(false)} centered> + + Detail rezervace + + + {selectedRecord && ( + <> +

ID: {selectedRecord.id}

+

Událost: {selectedRecord.event?.name}

+

Slot: {selectedRecord.marketSlot}

+

Prodlouženo: {selectedRecord.used_extension ? "Ano" : "Ne"}

+

Od: {dayjs(selectedRecord.reserved_from).format("DD.MM.YYYY HH:mm")}

+

Do: {dayjs(selectedRecord.reserved_to).format("DD.MM.YYYY HH:mm")}

+

Stav: {selectedRecord.status}

+

Poznámka: {selectedRecord.note}

+

Cena: {Number(selectedRecord.final_price).toFixed(2)} Kč

+ + )} +
+ + + + +
+ + setShowModal(false)} centered> + + Upravit rezervaci + +
+ + + Poznámka + setFormData(f => ({ ...f, note: e.target.value }))} + /> + + {/* Add more editable fields as needed */} + {error && {error}} + + + + + + +
+ + setShowModal(false)} centered> + + Detail objednávky + + + {selectedRecord && ( + <> +

ID: {selectedRecord.id}

+

Rezervace: {selectedRecord.reservation?.name}

+

Vytvořeno: {dayjs(selectedRecord.created_at).format("DD.MM.YYYY HH:mm")}

+

Částka k zaplacení: {selectedRecord.price_to_pay}

+

Zaplaceno v čase: {selectedRecord.payed_at ? dayjs(selectedRecord.payed_at).format("DD.MM.YYYY HH:mm") : "-"}

+

Stav: {selectedRecord.status}

+

Poznámka: {selectedRecord.note}

+ + )} +
+ + + + +
+ + setShowModal(false)} centered> + + Upravit objednávku + +
+ + + Poznámka + setFormData(f => ({ ...f, note: e.target.value }))} + /> + + {/* Add more editable fields as needed */} + {error && {error}} + + + + + + +
+ + setShowModal(false)} centered> + + Detail ticketu + + + {selectedRecord && ( + <> +

ID: {selectedRecord.id}

+

Název: {selectedRecord.title}

+

Vytvořeno: {dayjs(selectedRecord.created_at).format("DD.MM.YYYY HH:mm")}

+

Stav: {selectedRecord.status}

+

Kategorie: {selectedRecord.category}

+

Popis: {selectedRecord.description}

+ + )} +
+ + + + +
+ + setShowModal(false)} centered> + + Upravit ticket + +
+ + + Popis + setFormData(f => ({ ...f, description: e.target.value }))} + /> + + {/* Add more editable fields as needed */} + {error && {error}} + + + + + + +
+ + + ); +} +export default Home; diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx new file mode 100644 index 0000000..4054ca1 --- /dev/null +++ b/frontend/src/pages/Login.jsx @@ -0,0 +1,41 @@ + +import { Container } from "react-bootstrap"; +import LoginCard from "../components/LoginCard"; +import React, { useEffect, useState } from 'react'; +import { getPublicAppConfig } from '../api/model/Settings'; + +function Login() { + const [bgUrl, setBgUrl] = useState(null); + + useEffect(() => { + (async () => { + const data = await getPublicAppConfig(['background_image']); + if (data?.background_image) setBgUrl(data.background_image); + })(); + }, []); + + const bgStyle = bgUrl ? { + backgroundImage: `url(${bgUrl})`, + backgroundSize: 'cover', + backgroundPosition: 'center', + backgroundRepeat: 'no-repeat', + minHeight: '100vh' + } : {}; + + return ( + +
+ +
+
+

eTržnice

+
+
+ ); +} + +export default Login; diff --git a/frontend/src/pages/PasswordReset.jsx b/frontend/src/pages/PasswordReset.jsx new file mode 100644 index 0000000..be9bea2 --- /dev/null +++ b/frontend/src/pages/PasswordReset.jsx @@ -0,0 +1,45 @@ +import { useParams } from "react-router-dom"; +import { Container, Alert } from "react-bootstrap"; +import ResetPasswordRequest from "../components/reset-password/Request"; +import CreateNewPassword from "../components/reset-password/Create"; + +//vytáhne z URL parametry uidb64 a token +/** + * @typedef {Object} Params + * @property {string=} uidb64 + * @property {string=} token + */ + +function ResetPasswordPage() { + const { uidb64, token } = useParams(); + + const hasBothParams = uidb64 && token; + const hasOnlyOneParam = (uidb64 && !token) || (!uidb64 && token); + + let content; + + if (hasBothParams) { + content = ; + } else if (hasOnlyOneParam) { + content = ( + + Neplatný odkaz pro resetování hesla. + + ); + } else { + content = ; + } + + return ( + +
+ {content} +
+
+

eTržnice

+
+
+ ); +} + +export default ResetPasswordPage; diff --git a/frontend/src/pages/PaymentPage.jsx b/frontend/src/pages/PaymentPage.jsx new file mode 100644 index 0000000..31560da --- /dev/null +++ b/frontend/src/pages/PaymentPage.jsx @@ -0,0 +1,84 @@ +import React, { useEffect, useState } from "react"; +import axios from "axios"; +import QRCode from "react-qr-code"; // use this instead of qrcode.react for Vite +import { Container, Card, Row, Col, Table } from 'react-bootstrap'; + +// import apiOrder from "../api/model/order" +import apiOrders from '../api/model/order'; // adjust the path if needed + + +export default function PaymentPage({ orderId }) { + const [order, setOrder] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchOrder = async () => { + try { + const data = await apiOrders.getOrderById(orderId); // use your imported function + console.log("Order loaded:", data); + setOrder(data); + } catch (err) { + console.error("Error fetching order:", err); + setError("Nepodařilo se načíst objednávku."); + } + }; + + if (orderId) { + fetchOrder(); + } +}, [orderId]); + + + if (error) return

{error}

; + if (!order || !order.user || !order.reservation) return

Načítání...

; + + + // Extract relevant data + const user = order.user; + const reservation = order.reservation; + + const bankAccount = user.bank_account; + const varSymbol = user.var_symbol; + const amount = order.price_to_pay; + const currency = "CZK"; // adjust if needed + + const statusMap = { + payed: "Zaplaceno", + pending: "Čeká na zaplacení", + cancelled: "Stornováno" + }; + + const qrString = `SPD*1.0*ACC:${bankAccount}*AM:${amount}*CC:${currency}*X-VS:${varSymbol}`; + + return ( + + + + ZAPLAŤTE OBJEDNÁVKU + + {/* LEFT - Order Info */} +
+
+ + + + + + + + + +
Platba:Bankovní převod
Platce:{user.first_name} {user.last_name}
Účet:{bankAccount}
Var. symbol:{varSymbol}
Částka:{amount} CZK
Číslo objednávky:{order?.id}
Status:{statusMap[order?.status] || "Neznámý"}
+ + + {/* RIGHT - QR Code */} + +
QR Platba
+ + +
+ + +
+); +} diff --git a/frontend/src/pages/Reservation-cart.jsx b/frontend/src/pages/Reservation-cart.jsx new file mode 100644 index 0000000..f28e893 --- /dev/null +++ b/frontend/src/pages/Reservation-cart.jsx @@ -0,0 +1,21 @@ +import { + Container, + Nav, + Navbar, + NavDropdown, + Form, + Button, +} from "react-bootstrap"; + +import ReservationWizard from "../components/reservation/ReservationWizard" + +function ReservationCart() { + return ( + +

Rezervace

+ +
+ ); +} + +export default ReservationCart; diff --git a/frontend/src/pages/SelectReservation.jsx b/frontend/src/pages/SelectReservation.jsx new file mode 100644 index 0000000..122fa8e --- /dev/null +++ b/frontend/src/pages/SelectReservation.jsx @@ -0,0 +1,109 @@ +// SelectReservation.jsx +// This page displays a reservation system with a dynamic grid and a list of reservations. + +import DynamicGrid, { DEFAULT_CONFIG } from "../components/DynamicGrid"; +import React, { useState, useEffect } from "react"; +import { Container, Row, Col, Card, ListGroup } from "react-bootstrap"; + + +// Reservation component +// This component manages the state of reservations and provides functionality to export and clear them. +function CreateReservation() { + const gridConfig = DEFAULT_CONFIG; + const storageKey = `reservationData_${gridConfig.rows}x${gridConfig.cols}`; + + const [reservations, setReservations] = useState(() => { + const saved = localStorage.getItem(storageKey); + return saved ? JSON.parse(saved) : []; + }); + + const [selectedIndex, setSelectedIndex] = useState(null); + + useEffect(() => { + localStorage.setItem(storageKey, JSON.stringify(reservations)); + }, [reservations, storageKey]); + + // Function to export reservations as a JSON file + // This function creates a JSON file from the reservations state and triggers a download. + const getReservations = () => { + const dataStr = JSON.stringify(reservations, null, 2); + const blob = new Blob([dataStr], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "reservations.json"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + }; + + // Function to clear all reservations + // This function removes all reservations from the state and local storage. + const clearAll = () => { + localStorage.removeItem(storageKey); + setReservations([]); + setSelectedIndex(null); + }; + + return ( + + + + + + + + +
Seznam rezervací
+ {reservations.length} +
+ + {reservations.map((res, i) => ( + setSelectedIndex(i)} + > +
+
+ {i + 1}. {res.name} +
+ + {res.w}×{res.h} + +
+
+ [{res.x},{res.y}] → [{res.x + res.w - 1},{res.y + res.h - 1} + ] +
+
+ ))} +
+
+ +
+
+ + +
+ +
+
{JSON.stringify(reservations, null, 2)}
+
+
+ ); +} + +export default CreateReservation; \ No newline at end of file diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx new file mode 100644 index 0000000..323fd21 --- /dev/null +++ b/frontend/src/pages/Settings.jsx @@ -0,0 +1,29 @@ +import SettingsComponent from "../components/Settings"; +import { Container, Row, Col } from 'react-bootstrap'; +import Sidebar from '../components/Sidebar'; + +// Page wrapper for site settings (admin) +function SettingsPage(){ + return ( + + + + + + + + + + + ); +} + +export default SettingsPage; \ No newline at end of file diff --git a/frontend/src/pages/Test.jsx b/frontend/src/pages/Test.jsx new file mode 100644 index 0000000..aeda0c2 --- /dev/null +++ b/frontend/src/pages/Test.jsx @@ -0,0 +1,16 @@ +import Button from "react-bootstrap/Button"; +import React from "react"; + + +function Test(){ + + return( +
+ + + +
+ ) +} + +export default Test; \ No newline at end of file diff --git a/frontend/src/pages/Ticket.jsx b/frontend/src/pages/Ticket.jsx new file mode 100644 index 0000000..3b3bce4 --- /dev/null +++ b/frontend/src/pages/Ticket.jsx @@ -0,0 +1,15 @@ +import Button from "react-bootstrap/Button"; +import React from "react"; +import TicketForm from "../components/forms/ticket" + + +function Ticket(){ + + return( +
+ +
+ ) +} + +export default Ticket; \ No newline at end of file diff --git a/frontend/src/pages/error/403.jsx b/frontend/src/pages/error/403.jsx new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/pages/error/404.jsx b/frontend/src/pages/error/404.jsx new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/pages/error/500.jsx b/frontend/src/pages/error/500.jsx new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/pages/manager/Bin.jsx b/frontend/src/pages/manager/Bin.jsx new file mode 100644 index 0000000..c3543cb --- /dev/null +++ b/frontend/src/pages/manager/Bin.jsx @@ -0,0 +1,421 @@ +import React, { useEffect, useMemo, useState } from "react"; +import Table from "../../components/Table"; +import Sidebar from "../../components/Sidebar"; +import { + Container, + Row, + Col, + Button as BootstrapButton, + Modal, + Form, + Alert, +} from "react-bootstrap"; +import { + ActionIcon, + Group, + TextInput, + Text, + Stack, + Button, + Badge +} from "@mantine/core"; +import { IconSearch, IconX, IconEye, IconEdit, IconTrash, IconPlus, IconBox } from "@tabler/icons-react"; +import apiBin from "../../api/model/bin"; +import { useNavigate } from "react-router-dom"; + +function BinManager() { + const [bins, setBins] = useState([]); + const [fetching, setFetching] = useState(true); + const [query, setQuery] = useState(""); + const [selectedStatus, setSelectedStatus] = useState([]); + const [selectedBin, setSelectedBin] = useState(null); + const [showModal, setShowModal] = useState(false); + const [modalType, setModalType] = useState("view"); + const [formState, setFormState] = useState({ + name: "", + description: "", + status: "", + location: "", + capacity: "", + }); + + const navigate = useNavigate(); + + // Status options for filter (adjust as needed) + const statusOptions = [ + { value: "active", label: "Aktivní" }, + { value: "archived", label: "Archivováno" }, + { value: "draft", label: "Koncept" }, + { value: "disabled", label: "Neaktivní" }, + ]; + + // Fetch bins + const fetchBins = async () => { + setFetching(true); + try { + const params = { search: query }; + const data = await apiBin.getBins(params); + setBins(data); + } finally { + setFetching(false); + } + }; + + useEffect(() => { + fetchBins(); + }, [query]); + + // When editing, fill formState + useEffect(() => { + if (modalType === "edit" && selectedBin) { + setFormState({ + name: selectedBin.name || "", + description: selectedBin.description || "", + status: selectedBin.status || "", + location: selectedBin.location || "", + capacity: selectedBin.capacity || "", + }); + } + if (modalType === "edit" && !selectedBin) { + setFormState({ + name: "", + description: "", + status: "", + location: "", + capacity: "", + }); + } + }, [modalType, selectedBin]); + + // Handler for input changes + const handleFormChange = (e) => { + const { name, value } = e.target; + setFormState((old) => ({ ...old, [name]: value })); + }; + + // Save bin + const handleSaveBin = async (e) => { + e.preventDefault(); + try { + if (modalType === "edit" && selectedBin) { + await apiBin.updateBin(selectedBin.id, formState); + } else { + await apiBin.createBin(formState); + } + setShowModal(false); + fetchBins(); + } catch (err) { + // handle error + } + }; + + const handleShowBin = (bin) => { + setSelectedBin(bin); + setModalType('view'); + setShowModal(true); + }; + + const handleEditBin = (bin) => { + setSelectedBin(bin); + setFormState({ + name: bin.name || "", + description: bin.description || "", + status: bin.status || "", + location: bin.location || "", + capacity: bin.capacity || "", + }); + setModalType('edit'); + setShowModal(true); + }; + + const handleDeleteBin = async (bin) => { + if (window.confirm(`Opravdu smazat koš: ${bin.name}?`)) { + await apiBin.deleteBin(bin.id); + fetchBins(); + } + }; + + const handleConfirmDelete = async () => { + if (selectedBin) { + await apiBin.deleteBin(selectedBin.id); + setShowModal(false); + fetchBins(); + } + }; + + // Filtering logic + const filteredBins = useMemo(() => { + let data = Array.isArray(bins) ? bins : []; + if (query) { + const q = query.toLowerCase(); + data = data.filter( + b => + b.name?.toLowerCase().includes(q) || + b.location?.toLowerCase().includes(q) || + String(b.id).includes(q) || + b.status?.toLowerCase().includes(q) + ); + } + if (selectedStatus.length > 0) { + data = data.filter(b => selectedStatus.includes(b.status)); + } + return data; + }, [bins, query, selectedStatus]); + + // Table columns + const columns = [ + { accessor: "id", title: "#", sortable: true, width: "4%" }, + { + accessor: "name", + title: "Název", + sortable: true, + width: "20%", + filter: ( + } + rightSection={ + setQuery("")}> + + + } + value={query} + onChange={e => setQuery(e.currentTarget.value)} + /> + ), + filtering: query !== "", + }, + { + accessor: "description", + title: "Popis", + sortable: false, + width: "20%", + render: row => row.description || , + }, + { + accessor: "location", + title: "Umístění", + sortable: true, + width: "15%", + render: row => row.location || , + }, + { + accessor: "capacity", + title: "Kapacita", + sortable: true, + width: "10%", + render: row => row.capacity ? `${row.capacity}` : "—", + }, + { + accessor: "status", + title: "Stav", + sortable: true, + width: "10%", + render: row => { + const statusLabel = statusOptions.find(opt => opt.value === row.status)?.label || row.status; + return {statusLabel}; + }, + filter: (() => { + const toggle = (val) => { + setSelectedStatus(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val]); + }; + return ( + + Filtrovat stav + + {statusOptions.map(opt => { + const active = selectedStatus.includes(opt.value); + const color = opt.value === 'active' ? 'green' : opt.value === 'archived' ? 'gray' : 'yellow'; + return ( + toggle(opt.value)} + aria-pressed={active} + role='button' + > + {opt.label} + + ); + })} + + {selectedStatus.length > 0 && ( + + )} + + ); + })(), + filtering: selectedStatus.length > 0, + }, + { + accessor: "actions", + title: "Akce", + width: "10%", + render: (bin) => ( + + handleShowBin(bin)}> + + + handleEditBin(bin)}> + + + handleDeleteBin(bin)}> + + + + ), + }, + ]; + + // Modal content + const renderModalContent = () => { + if (modalType === "view" && selectedBin) { + return ( + + ID: {selectedBin.id} + Název: {selectedBin.name} + Popis: {selectedBin.description || "—"} + Umístění: {selectedBin.location || "—"} + Kapacita: {selectedBin.capacity || "—"} + Stav: {selectedBin.status || "—"} + + + + + + ); + } + + if (modalType === "edit") { + return ( +
+ + + + + + + + + + + +
+ ); + } + + if (modalType === "delete" && selectedBin) { + return ( + + Opravdu chcete smazat koš "{selectedBin.name}"? + + + + + + ); + } + + return Žádný obsah; + }; + + const getModalTitle = () => { + if (!selectedBin && modalType !== "edit") return "Detail koše"; + switch (modalType) { + case "view": + return `Detail: ${selectedBin?.name}`; + case "edit": + return selectedBin ? `Upravit: ${selectedBin.name}` : "Přidat koš"; + case "delete": + return `Smazat koš`; + default: + return "Detail koše"; + } + }; + + return ( + + + + + + + +

+ + Koše +

+ +
+ + setShowModal(false)} + title={modalType === "edit" && !selectedBin ? "Přidat koš" : getModalTitle()} + size="lg" + centered + > + + {renderModalContent()} + + + setShowModal(false)}>Zavřít + {modalType === "view" && ( + { setShowModal(false); handleEditBin(selectedBin); }}>Upravit + )} + + + + + + ); +} + +export default BinManager; \ No newline at end of file diff --git a/frontend/src/pages/manager/Events.jsx b/frontend/src/pages/manager/Events.jsx new file mode 100644 index 0000000..ccaf1b3 --- /dev/null +++ b/frontend/src/pages/manager/Events.jsx @@ -0,0 +1,621 @@ +import React, { useEffect, useMemo, useState } from "react"; +import Table from "../../components/Table"; +import Sidebar from "../../components/Sidebar"; +import { + Container, + Row, + Col, + Button as BootstrapButton, + Modal, + Form, + Alert, +} from "react-bootstrap"; +import { + ActionIcon, + Group, + TextInput, + Text, + Stack, + Button, + Badge +} from "@mantine/core"; +import { IconSearch, IconX, IconEye, IconEdit, IconTrash, IconPlus, IconMap, IconCalendarEvent } from "@tabler/icons-react"; +import apiEvents from "../../api/model/event"; +import dayjs from "dayjs"; +import "dayjs/locale/cs"; +import { useNavigate } from "react-router-dom"; + +function Events() { + const [events, setEvents] = useState([]); + const [fetching, setFetching] = useState(true); + const [query, setQuery] = useState(""); + const [selectedStatus, setSelectedStatus] = useState([]); + + const [selectedEvent, setSelectedEvent] = useState(null); + const [showModal, setShowModal] = useState(false); + const [modalType, setModalType] = useState("view"); + const [formState, setFormState] = useState({ + name: "", + description: "", + start: "", + end: "", + price_per_m2: "", + image: null, + square_id: "", + }); + const [squares, setSquares] = useState([]); + const [squareSearch, setSquareSearch] = useState(""); + + const [selectedSquareIds, setSelectedSquareIds] = useState([]); + const [startDateRange, setStartDateRange] = useState([null, null]); + const [endDateRange, setEndDateRange] = useState([null, null]); + + const navigate = useNavigate(); + + // Status options for filter (adjust as needed) + const statusOptions = [ + { value: "active", label: "Aktivní" }, + { value: "archived", label: "Archivováno" }, + { value: "draft", label: "Koncept" }, + { value: "cancelled", label: "Zrušeno" }, + ]; + + // Fetch squares for dropdown + useEffect(() => { + const fetchSquares = async () => { + try { + const data = await import("../../api/model/square").then(mod => mod.default.getSquares()); + setSquares(data); + } catch (err) { + // ignore + } + }; + fetchSquares(); + }, []); + + // Když se vybere event pro editaci, naplníme formState + useEffect(() => { + if (modalType === "edit" && selectedEvent) { + setFormState({ + name: selectedEvent.name || "", + description: selectedEvent.description || "", + start: selectedEvent.start || "", // YYYY-MM-DD + end: selectedEvent.end || "", + price_per_m2: selectedEvent.price_per_m2 || "", + image: null, + square_id: selectedEvent.square_id || selectedEvent.square?.id || "", + }); + } + if (modalType === "edit" && !selectedEvent) { + // Přidávání nového eventu: vyčistit form + setFormState({ + name: "", + description: "", + start: "", + end: "", + price_per_m2: "", + image: null, + square_id: "", + }); + } + }, [modalType, selectedEvent]); + + // Handler pro změnu inputů + const handleFormChange = (e) => { + const { name, value, files } = e.target; + if (name === "image") { + setFormState((old) => ({ ...old, image: files[0] || null })); + } else { + setFormState((old) => ({ ...old, [name]: value })); + } + }; + + // Odeslání formuláře + const handleSaveEvent = async (e) => { + e.preventDefault(); + + try { + const formData = new FormData(); + formData.append("name", formState.name); + formData.append("description", formState.description); + formData.append("start", formState.start); // YYYY-MM-DD + formData.append("end", formState.end); // YYYY-MM-DD + formData.append("price_per_m2", formState.price_per_m2); + formData.append("square_id", formState.square_id); + + if (formState.image instanceof File) { + formData.append("image", formState.image); + } + + if (modalType === "edit" && selectedEvent) { + await apiEvents.updateEvent(selectedEvent.id, formData); + } else { + await apiEvents.createEvent(formData); + } + + setShowModal(false); + fetchEvents(); + } catch (err) { + console.error("Chyba při ukládání akce:", err); + } + }; + + const fetchEvents = async () => { + setFetching(true); + try { + const params = { search: query }; + const data = await apiEvents.getEvents(params); + setEvents(data); + } finally { + setFetching(false); + } + }; + + useEffect(() => { + fetchEvents(); + }, [query]); + + const handleShowEvent = (event) => { + setSelectedEvent(event); + setModalType('view'); + setShowModal(true); + }; + + const handleEditEvent = (event) => { + setSelectedEvent(event); + setFormState({ + name: event.name || "", + description: event.description || "", + start: event.start ? event.start.slice(0, 16) : "", + end: event.end ? event.end.slice(0, 16) : "", + price_per_m2: event.price_per_m2 || "", + image: null, + square_id: event.square_id || event.square?.id || "", + }); + setModalType('edit'); + setShowModal(true); + // Optionally clear error state if you add error handling + }; + + const handleDeleteEvent = async (event) => { + if (window.confirm(`Opravdu smazat akci: ${event.name}?`)) { + await apiEvents.deleteEvent(event.id); + fetchEvents(); + } + }; + + const handleConfirmDelete = async () => { + if (selectedEvent) { + await apiEvents.deleteEvent(selectedEvent.id); + setShowModal(false); + fetchEvents(); + } + }; + + const handleRedirectToMap = (event) => { + navigate(`/manage/events/map/${event.id}`); + }; + + // Set dayjs locale to Czech + useEffect(() => { + dayjs.locale("cs"); + }, []); + + // Upravený renderModalContent s formulářem + const renderModalContent = () => { + if (modalType === "view" && selectedEvent) { + return ( + + ID: {selectedEvent.id} + Název: {selectedEvent.name} + Popis: {selectedEvent.description || "—"} + Náměstí: {selectedEvent.square?.name || "Neznámé"} + Začátek: {new Date(selectedEvent.start).toLocaleString()} + Konec: {new Date(selectedEvent.end).toLocaleString()} + + + + + + ); + } + + if (modalType === "edit") { + return ( + + + + + + + + {/* Square search and select */} + setSquareSearch(e.target.value)} + /> + + + + + + + + + ); + } + + if (modalType === "delete" && selectedEvent) { + return ( + + Opravdu chcete smazat akci "{selectedEvent.name}"? + + + + + + ); + } + + return Žádný obsah; + }; + + // getModalTitle můžeš použít stejný, např: + const getModalTitle = () => { + if (!selectedEvent && modalType !== "edit") return "Detail akce"; + + switch (modalType) { + case "view": + return `Detail: ${selectedEvent?.name}`; + case "edit": + return selectedEvent ? `Upravit: ${selectedEvent.name}` : "Přidat akci"; + case "delete": + return `Smazat akci`; + default: + return "Detail akce"; + } + }; + + // Squares for filter + const squareOptions = useMemo(() => { + if (!Array.isArray(squares)) return []; + return squares.map(sq => ({ + value: String(sq.id), + label: `${sq.name} (${sq.city})` + })); + }, [squares]); + + // Filtering logic update + const filteredEvents = useMemo(() => { + let data = Array.isArray(events) ? events : []; + if (query) { + const q = query.toLowerCase(); + data = data.filter( + e => + e.name?.toLowerCase().includes(q) || + e.location?.toLowerCase().includes(q) || + String(e.id).includes(q) || + e.status?.toLowerCase().includes(q) + ); + } + if (selectedStatus.length > 0) { + data = data.filter(e => selectedStatus.includes(e.status)); + } + if (selectedSquareIds.length > 0) { + data = data.filter(e => + selectedSquareIds.includes(String(e.square_id || e.square?.id)) + ); + } + // Začátek (start) date filter: show only events where start date (YYYY-MM-DD) matches the filter + if (startDateRange[0]) { + data = data.filter(e => + e.start && dayjs(e.start).format("YYYY-MM-DD") === startDateRange[0] + ); + } + // Konec (end) date filter: show only events where end date (YYYY-MM-DD) matches the filter + if (endDateRange[0]) { + data = data.filter(e => + e.end && dayjs(e.end).format("YYYY-MM-DD") === endDateRange[0] + ); + } + return data; + }, [events, query, selectedStatus, selectedSquareIds, startDateRange, endDateRange]); + + // Show all fields in the table, based on EventSerializer in backend/booking/serializers.py + const columns = [ + { accessor: "id", title: "#", sortable: true, width: "4%" }, + { + accessor: "name", + title: "Název", + sortable: true, + width: "15%", + filter: ( + } + rightSection={ + setQuery("")}> + + + } + value={query} + onChange={e => setQuery(e.currentTarget.value)} + /> + ), + filtering: query !== "", + }, + { + accessor: "description", + title: "Popis", + sortable: false, + width: "10%", + render: row => row.description || , + }, + { + accessor: "start", + title: "Začátek", + sortable: true, + width: "14%", + render: row => row.start ? dayjs(row.start, "YYYY-MM-DD").format("DD.MM.YYYY") : "—", + filter: ( + + setStartDateRange([e.target.value || null, null])} + style={{ width: 140 }} + /> + {startDateRange[0] && ( + setStartDateRange([null, null])}> + + + )} + + ), + filtering: !!startDateRange[0], + }, + { + accessor: "end", + title: "Konec", + sortable: true, + width: "14%", + render: row => row.end ? dayjs(row.end, "YYYY-MM-DD").format("DD.MM.YYYY") : "—", + filter: ( + + setEndDateRange([e.target.value || null, null])} + style={{ width: 140 }} + /> + {endDateRange[0] && ( + setEndDateRange([null, null])}> + + + )} + + ), + filtering: !!endDateRange[0], + }, + { + accessor: "price_per_m2", + title: "Cena za m²", + sortable: true, + width: "9%", + render: row => row.price_per_m2 ? `${row.price_per_m2} Kč` : "—", + }, + { + accessor: "square", + title: "Náměstí", + sortable: false, + width: "16%", + render: row => row.square?.name ? `${row.square.name}` : , + filter: (() => { + const toggle = (val) => { + setSelectedSquareIds(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val]); + }; + return ( + + Filtrovat náměstí + + {squareOptions.map(opt => { + const active = selectedSquareIds.includes(opt.value); + return ( + toggle(opt.value)} + aria-pressed={active} + role='button' + > + {opt.label} + + ); + })} + + {selectedSquareIds.length > 0 && ( + + )} + + ); + })(), + filtering: selectedSquareIds.length > 0, + }, + { + accessor: "image", + title: "Obrázek", + sortable: false, + width: "11%", + render: row => + row.image ? ( + {row.name} + ) : ( + Žádný obrázek + ), + }, + { + accessor: "actions", + title: "Akce", + width: "5.5%", + render: (event) => ( + + + + handleShowEvent(event)}> + + + + + handleEditEvent(event)}> + + + + + + + handleRedirectToMap(event)} title="Mapa"> + + + + + handleDeleteEvent(event)}> + + + + + + + + + + ), + }, + ]; + + return ( + + + + + + + +

+ + Akce +

+ +
+
+ setShowModal(false)} + title={modalType === "edit" && !selectedEvent ? "Přidat akci" : getModalTitle()} + size="lg" + centered + > + {modalType === "view" ? ( + + {selectedEvent && ( + <> +

ID: {selectedEvent.id}

+

Název: {selectedEvent.name}

+

Popis: {selectedEvent.description || "—"}

+

Náměstí: {selectedEvent.square?.name || "Neznámé"}

+

Začátek: {selectedEvent.start ? dayjs(selectedEvent.start).format("DD.MM.YYYY HH:mm") : "—"}

+

Konec: {selectedEvent.end ? dayjs(selectedEvent.end).format("DD.MM.YYYY HH:mm") : "—"}

+

Cena za m²: {selectedEvent.price_per_m2 ? `${selectedEvent.price_per_m2} Kč` : "—"}

+

Počet míst: {Array.isArray(selectedEvent.market_slots) ? selectedEvent.market_slots.length : "—"}

+

Produkty: {Array.isArray(selectedEvent.event_products) && selectedEvent.event_products.length > 0 + ? selectedEvent.event_products.map(p => p.name).join(", ") + : "—"}

+

Obrázek: {selectedEvent.image ? {selectedEvent.name} : "Žádný obrázek"}

+ + )} +
+ ) : ( + + {renderModalContent()} + + )} + + setShowModal(false)}>Zavřít + {modalType === "view" && ( + { setShowModal(false); handleEditEvent(selectedEvent); }}>Upravit + )} + +
+ + + + ); +} + +export default Events; diff --git a/frontend/src/pages/manager/Orders.jsx b/frontend/src/pages/manager/Orders.jsx new file mode 100644 index 0000000..be68baf --- /dev/null +++ b/frontend/src/pages/manager/Orders.jsx @@ -0,0 +1,411 @@ +import React, { useEffect, useMemo, useState } from "react"; +import Table from "../../components/Table"; +import Sidebar from "../../components/Sidebar"; +import { + Container, + Row, + Col, + Button as BootstrapButton, + Modal, + Form, + Alert, +} from "react-bootstrap"; +import { + ActionIcon, + Group, + TextInput, + Text, + Stack, + Button, + Badge +} from "@mantine/core"; +import { IconSearch, IconX, IconEye, IconEdit, IconTrash, IconPlus, IconReceipt2 } from "@tabler/icons-react"; +import orderAPI from "../../api/model/order"; +import userAPI from "../../api/model/user"; + +function Orders() { + // Delete handler + const handleDeleteOrder = async (order) => { + if (window.confirm(`Opravdu smazat objednávku: ${order.id}?`)) { + await orderAPI.deleteOrder(order.id); + const data = await orderAPI.getOrders(); + setOrders(data); + } + }; + + // Bootstrap Modal state for edit + const [showEditModal, setShowEditModal] = useState(false); + + const handleEditOrder = (order) => { + setSelectedOrder(order); + setFormData({ + note: order.note || "", + status: order.status || "", + price_to_pay: order.price_to_pay || "", + payed_at: order.payed_at || "", + }); + setShowEditModal(true); + }; + + const handleEditModalSubmit = async (e) => { + e.preventDefault(); + setError(null); + setSubmitting(true); + try { + // Explicitly send all formData fields, including status + console.log(formData); + await orderAPI.updateOrder(selectedOrder.id, formData); + setShowEditModal(false); + setFormData({ + note: "", + status: "", + price_to_pay: "", + payed_at: "", + }); + const data = await orderAPI.getOrders(); + setOrders(data); + } catch (err) { + const apiErrors = err.response?.data; + if (typeof apiErrors === "object") { + const messages = Object.entries(apiErrors) + .map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(", ") : value}`) + .join("\n"); + setError("Chyba při ukládání:\n" + messages); + } else { + setError("Chyba při ukládání: " + (err.message || "Neznámá chyba")); + } + } finally { + setSubmitting(false); + } + }; + + const [orders, setOrders] = useState([]); + const [fetching, setFetching] = useState(true); + const [query, setQuery] = useState(""); + const [selectedStatus, setSelectedStatus] = useState([]); + const [selectedUsers, setSelectedUsers] = useState([]); + const [userOptions, setUserOptions] = useState([]); + const [userQuery, setUserQuery] = useState(""); + + // Modal state + const [showModal, setShowModal] = useState(false); + const [modalType, setModalType] = useState('view'); // 'view', 'edit' + const [selectedOrder, setSelectedOrder] = useState(null); + const [formData, setFormData] = useState({ + note: "", + status: "", + price_to_pay: "", + payed_at: "", + }); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + // Fetch data from API + useEffect(() => { + const fetchData = async () => { + try { + const data = await orderAPI.getOrders(); + setOrders(data); + } finally { + setFetching(false); + } + }; + fetchData(); + }, []); + + // Fetch user options for filter + useEffect(() => { + async function fetchUsers() { + try { + const users = await userAPI.getUsers(); + setUserOptions( + users.map(u => ({ + value: String(u.id), // Mantine expects string values + label: `${u.first_name} ${u.last_name} (${u.email})` + })) + ); + } catch (e) { + setUserOptions([]); + } + } + fetchUsers(); + }, []); + + // Status options for filter + const statusOptions = [ + { value: "pending", label: "Čeká na zaplacení" }, + { value: "payed", label: "Zaplaceno" }, + { value: "cancelled", label: "Stornováno" }, + ]; + + // Status colors + const statusColors = { + pending: "yellow", + payed: "green", + cancelled: "red", + }; + + // Filtering + const filteredOrders = useMemo(() => { + let data = Array.isArray(orders) ? orders : []; + if (query) { + const q = query.toLowerCase(); + data = data.filter( + r => + r.order_number?.toLowerCase().includes(q) || + r.note?.toLowerCase().includes(q) || + r.user?.email?.toLowerCase().includes(q) || + r.user?.first_name?.toLowerCase().includes(q) || + r.user?.last_name?.toLowerCase().includes(q) || + r.reservation?.note?.toLowerCase().includes(q) + ); + } + if (userQuery) { + const uq = userQuery.toLowerCase(); + data = data.filter(r => + r.user && + ( + r.user.email?.toLowerCase().includes(uq) || + r.user.first_name?.toLowerCase().includes(uq) || + r.user.last_name?.toLowerCase().includes(uq) + ) + ); + } + if (selectedStatus.length > 0) { + data = data.filter(r => selectedStatus.includes(r.status)); + } + return data; + }, [orders, query, selectedStatus, userQuery]); + + // Handle form field changes + const handleChange = (e) => { + const { name, value } = e.target; + setFormData((old) => ({ ...old, [name]: value })); + }; + + // Handlers for modal actions + const handleShowOrder = (order) => { + setSelectedOrder(order); + setModalType('view'); + setShowModal(true); + }; + + const columns = [ + { accessor: "id", title: "ID objednávky", sortable: true, width: "14%" }, + { + accessor: "user", + title: "Uživatel", + width: "16%", + filter: ( + } + rightSection={ + setUserQuery("")}> + + + } + value={userQuery} + onChange={e => setUserQuery(e.currentTarget.value)} + /> + ), + filtering: userQuery !== "", + render: (row) => row.user ? `${row.user.first_name} ${row.user.last_name} (${row.user.email})` : "—", + }, + { + accessor: "reservation", + title: "Rezervace", + width: "16%", + render: (row) => row.reservation ? `ID: ${row.reservation.id}` : "—", + }, + { accessor: "created_at", title: "Vytvořeno", sortable: true, width: "12%" }, + { + accessor: "status", + title: "Stav", + width: "12%", + filter: (() => { + const toggle = (value) => { + setSelectedStatus(prev => prev.includes(value) ? prev.filter(v => v !== value) : [...prev, value]); + }; + return ( + + Filtrovat stav + + {statusOptions.map(opt => { + const active = selectedStatus.includes(opt.value); + const color = statusColors[opt.value] || 'gray'; + return ( + toggle(opt.value)} + aria-pressed={active} + role="button" + > + {opt.label} + + ); + })} + + {selectedStatus.length > 0 && ( + + )} + + ); + })(), + filtering: selectedStatus.length > 0, + render: (row) => { + const statusObj = statusOptions.find(opt => opt.value === row.status); + const color = statusColors[row.status] || "gray"; + return ( + + {statusObj ? statusObj.label : row.status} + + ); + }, + }, + { accessor: "price_to_pay", title: "Cena", width: "8%" }, + { accessor: "payed_at", title: "Zaplaceno dne", width: "12%" }, + { accessor: "note", title: "Poznámka", width: "12%" }, + { + accessor: "actions", + title: "Akce", + width: "8%", + render: (order) => ( + + handleShowOrder(order)}> + + + handleEditOrder(order)}> + + + handleDeleteOrder(order)}> + + + + ), + }, + ]; + + // Modal content for view/edit + const renderModalContent = () => { + return Žádný obsah; + }; + + return ( + + + + + + + +

+ + Objednávky +

+ {/* You can add a button for creating new orders if needed */} +
+ +
+ + {/* Mantine modal for add only (not used for orders) */} + setShowModal(false)} + title={'Přidat objednávku'} + size="lg" + centered + > + {renderModalContent()} + + + {/* Bootstrap Modal for view */} + setShowModal(false)} centered> + + Detail objednávky + + + {selectedOrder && ( + <> +

ID objednávky: {selectedOrder.id}

+

Uživatel: {selectedOrder.user ? `${selectedOrder.user.first_name} ${selectedOrder.user.last_name} (${selectedOrder.user.email})` : "—"}

+

Rezervace: {selectedOrder.reservation ? `ID: ${selectedOrder.reservation.id}` : "—"}

+

Vytvořeno: {selectedOrder.created_at}

+

+ Stav:{" "} + + {statusOptions.find(opt => opt.value === selectedOrder.status)?.label || selectedOrder.status} + +

+

Cena: {selectedOrder.price_to_pay}

+

Zaplaceno dne: {selectedOrder.payed_at || "—"}

+

Poznámka: {selectedOrder.note || "—"}

+ + )} +
+ + setShowModal(false)}>Zavřít + { setShowModal(false); handleEditOrder(selectedOrder); }}>Upravit + +
+ + {/* Bootstrap Modal for edit */} + setShowEditModal(false)} centered> + + Upravit objednávku + +
+ + + Poznámka + + + + Stav + + + {statusOptions.map(opt => ( + + ))} + + + + Cena + + + + Zaplaceno dne + + + {error && {error}} + + + setShowEditModal(false)}>Zrušit + Uložit změny + + +
+ + + + ); +} + +export default Orders; diff --git a/frontend/src/pages/manager/Products.jsx b/frontend/src/pages/manager/Products.jsx new file mode 100644 index 0000000..7c75b5a --- /dev/null +++ b/frontend/src/pages/manager/Products.jsx @@ -0,0 +1,251 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import Table from '../../components/Table'; +import Sidebar from '../../components/Sidebar'; +import { Container, Row, Col, Modal, Form, Alert, Button as BootstrapButton } from 'react-bootstrap'; +import { ActionIcon, Group, TextInput, Button, Text, Badge, Stack } from '@mantine/core'; +import { IconEye, IconEdit, IconTrash, IconPlus, IconSearch, IconX, IconPackage } from '@tabler/icons-react'; +import { getProducts, createProduct, updateProduct, deleteProduct } from '../../api/model/product'; + +function Products() { + const [products, setProducts] = useState([]); + const [fetching, setFetching] = useState(true); + const [query, setQuery] = useState(''); + const [showModal, setShowModal] = useState(false); + const [modalType, setModalType] = useState('view'); // view | edit | create + const [selectedProduct, setSelectedProduct] = useState(null); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const [formData, setFormData] = useState({ name: '', code: '' }); + const [eventFilter, setEventFilter] = useState([]); + const [availableEvents, setAvailableEvents] = useState([]); + + const fetchData = async () => { + setFetching(true); + try { + const data = await getProducts(); + setProducts(Array.isArray(data) ? data : []); + } finally { + setFetching(false); + } + }; + + useEffect(() => { fetchData(); }, []); + + const filtered = useMemo(() => { + let data = Array.isArray(products) ? products : []; + if (query) { + const q = query.toLowerCase(); + data = data.filter(p => p.name?.toLowerCase().includes(q) || String(p.id).includes(q) || String(p.code || '').includes(q)); + } + if (eventFilter.length > 0) { + data = data.filter(p => { + const evIds = (p.events || []).map(ev => String(ev.id)); + return eventFilter.every(f => evIds.includes(f)); + }); + } + return data; + }, [products, query, eventFilter]); + + useEffect(() => { + const map = new Map(); + products.forEach(p => (p.events || []).forEach(ev => { if (!map.has(ev.id)) map.set(ev.id, ev.name); })); + setAvailableEvents(Array.from(map.entries()).map(([id, name]) => ({ value: String(id), label: name }))); + }, [products]); + + const openView = (product) => { setSelectedProduct(product); setModalType('view'); setShowModal(true); }; + const openEdit = (product) => { setSelectedProduct(product); setFormData({ name: product.name || '', code: product.code || '' }); setModalType('edit'); setShowModal(true); }; + const openCreate = () => { setSelectedProduct(null); setFormData({ name: '', code: '' }); setModalType('create'); setShowModal(true); }; + + const handleDelete = async (product) => { + if (!product) return; + if (window.confirm(`Opravdu smazat produkt: ${product.name || product.id}?`)) { + await deleteProduct(product.id); + fetchData(); + } + }; + + const handleFieldChange = (e) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + }; + + const submitEdit = async (e) => { + e.preventDefault(); + setSubmitting(true); setError(null); + try { + await updateProduct(selectedProduct.id, { name: formData.name, code: formData.code || null }); + setShowModal(false); setSelectedProduct(null); fetchData(); + } catch (err) { + setError(formatErrors(err)); + } finally { setSubmitting(false); } + }; + + const submitCreate = async (e) => { + e.preventDefault(); + setSubmitting(true); setError(null); + try { + await createProduct({ name: formData.name, code: formData.code || null }); + setShowModal(false); fetchData(); + } catch (err) { + setError(formatErrors(err)); + } finally { setSubmitting(false); } + }; + + const formatErrors = (err) => { + const apiErrors = err.response?.data; + if (typeof apiErrors === 'object') { + return Object.entries(apiErrors).map(([k,v]) => `${k}: ${Array.isArray(v)? v.join(', '): v}`).join('\n'); + } + return err.message || 'Neznámá chyba'; + }; + + const columns = [ + { accessor: 'id', title: 'ID', sortable: true, width: '70px' }, + { + accessor: 'name', + title: 'Název', + sortable: true, + width: '2fr', + render: row => row.name || '—', + filter: ( + } + rightSection={ setQuery('')}>} + value={query} + onChange={e => setQuery(e.currentTarget.value)} + /> + ), + filtering: query !== '', + }, + { + accessor: 'code', + title: 'Kód', + sortable: true, + width: '1fr', + render: row => row.code ?? '—', + }, + { + accessor: 'events', + title: 'Akce', + width: '2fr', + render: row => (row.events && row.events.length > 0 ? row.events.map(e => e.name).join(', ') : '—'), + filter: (() => { + const toggle = (val) => { + setEventFilter(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val]); + }; + return ( + + Filtrovat akce + + {availableEvents.map(opt => { + const active = eventFilter.includes(opt.value); + return ( + toggle(opt.value)} + aria-pressed={active} + role='button' + > + {opt.label} + + ); + })} + + {eventFilter.length > 0 && ( + + )} + + ); + })(), + filtering: eventFilter.length > 0, + }, + { + accessor: 'actions', + title: 'Akce', + width: '110px', + render: (product) => ( + + openView(product)}> + openEdit(product)}> + handleDelete(product)}> + + ), + }, + ]; + + return ( + + + + + + + +

Produkty

+ +
+
+ + {/* Unified modal */} + setShowModal(false)} centered> + {modalType === 'create' ? 'Nový produkt' : modalType === 'edit' ? 'Upravit produkt' : 'Detail produktu'} + {modalType === 'view' && ( + <> + + {selectedProduct ? ( + <> +

ID: {selectedProduct.id}

+

Název: {selectedProduct.name || '—'}

+

Kód: {selectedProduct.code ?? '—'}

+

Akce: {selectedProduct.events?.length ? selectedProduct.events.map(e => e.name).join(', ') : '—'}

+ + ) : Produkt nebyl nalezen} +
+ + setShowModal(false)}>Zavřít + {selectedProduct && { openEdit(selectedProduct); }}>Upravit} + + + )} + {(modalType === 'edit' || modalType === 'create') && ( +
+ + + Název + + + + Kód (volitelné) + + + {error && {error}} + + + setShowModal(false)}>Zrušit + {modalType === 'edit' ? 'Uložit změny' : 'Vytvořit'} + + + )} +
+ + + + ); +} + +export default Products; diff --git a/frontend/src/pages/manager/Reservations.jsx b/frontend/src/pages/manager/Reservations.jsx new file mode 100644 index 0000000..867731f --- /dev/null +++ b/frontend/src/pages/manager/Reservations.jsx @@ -0,0 +1,670 @@ +import Table from "../../components/Table"; +import Sidebar from "../../components/Sidebar"; +import { getReservations, deleteReservation, updateReservation } from "../../api/model/reservation"; +import { IconEye, IconEdit, IconTrash, IconPlus, IconSearch, IconX, IconReceipt2 } from "@tabler/icons-react"; +import { useEffect, useState } from "react"; +import { Container, Row, Col, Form, Modal, Button as BootstrapButton } from "react-bootstrap"; +import { + ActionIcon, + Button, + Stack, + Text, + Group, + Badge, + TextInput, + NumberInput +} from "@mantine/core"; +import { DateInput } from '@mantine/dates'; +import dayjs from "dayjs"; +import 'dayjs/locale/cs'; +// Set global locale for dayjs (affects formatting) +dayjs.locale('cs'); + +function Reservations() { + // Modal state for view/edit + const [showModal, setShowModal] = useState(false); + const [modalType, setModalType] = useState('view'); // 'view', 'edit' + const [selectedReservation, setSelectedReservation] = useState(null); + const [formData, setFormData] = useState({ + status: "", + note: "", + final_price: "", + }); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + // Open view modal + const handleShowReservationModal = (reservation) => { + setSelectedReservation(reservation); + setModalType('view'); + setShowModal(true); + }; + + // Open edit modal + const handleEditReservationModal = (reservation) => { + setSelectedReservation(reservation); + setFormData({ + status: reservation.status || "", + note: reservation.note || "", + final_price: reservation.final_price || "", + }); + setModalType('edit'); + setShowModal(true); + }; + + // Handle form field changes + const handleChange = (e) => { + const { name, value } = e.target; + setFormData((old) => ({ ...old, [name]: value })); + }; + + // Submit edit modal + const handleEditModalSubmit = async (e) => { + e.preventDefault(); + setError(null); + setSubmitting(true); + try { + await updateReservation(selectedReservation.id, { + status: formData.status, + note: formData.note, + final_price: formData.final_price, + }); + setShowModal(false); + setFormData({ status: "", note: "", final_price: "" }); + fetchReservations(); + } catch (err) { + const apiErrors = err.response?.data; + if (typeof apiErrors === "object") { + const messages = Object.entries(apiErrors) + .map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(", ") : value}`) + .join("\n"); + setError("Chyba při ukládání:\n" + messages); + } else { + setError("Chyba při ukládání: " + (err.message || "Neznámá chyba")); + } + } finally { + setSubmitting(false); + } + }; + const [reservations, setReservations] = useState([]); + const [fetching, setFetching] = useState(true); + const [query, setQuery] = useState(""); // generic search across several text fields + const [selectedStatus, setSelectedStatus] = useState([]); + // New filter states + const [eventFilter, setEventFilter] = useState(""); + const [createdFrom, setCreatedFrom] = useState(null); + const [createdTo, setCreatedTo] = useState(null); + const [reservedFromDate, setReservedFromDate] = useState(null); // single date for "Od" + const [reservedToDate, setReservedToDate] = useState(null); // single date for "Do" + const [priceMin, setPriceMin] = useState(""); + const [priceMax, setPriceMax] = useState(""); + + // Status options for filter + const statusOptions = [ + { value: "reserved", label: "Rezervováno" }, + { value: "cancelled", label: "Zrušeno" }, + { value: "completed", label: "Dokončeno" }, + { value: "pending", label: "Čekající" }, + ]; + + // Filtering (pattern as in Users.jsx) + const filteredReservations = reservations.filter(r => { + let match = true; + if (query) { + const q = query.toLowerCase(); + match = ( + r.user?.username?.toLowerCase().includes(q) || + r.event?.name?.toLowerCase().includes(q) || + String(r.id).includes(q) || + r.status?.toLowerCase().includes(q) || + r.note?.toLowerCase().includes(q) + ); + } + if (match && eventFilter) { + const ev = eventFilter.toLowerCase(); + match = r.event?.name?.toLowerCase().includes(ev); + } + if (match && selectedStatus.length > 0) { + match = selectedStatus.includes(r.status); + } + if (match && createdFrom) { + match = r.created_at && dayjs(r.created_at).isAfter(dayjs(createdFrom).startOf('day').subtract(1, 'millisecond')); + } + if (match && createdTo) { + match = r.created_at && dayjs(r.created_at).isBefore(dayjs(createdTo).endOf('day').add(1, 'millisecond')); + } + if (match && reservedFromDate) { + match = r.reserved_from && dayjs(r.reserved_from, 'YYYY-MM-DD').isSame(dayjs(reservedFromDate), 'day'); + } + if (match && reservedToDate) { + match = r.reserved_to && dayjs(r.reserved_to, 'YYYY-MM-DD').isSame(dayjs(reservedToDate), 'day'); + } + if (match && priceMin !== "") { + match = (r.final_price ?? 0) >= parseFloat(priceMin || 0); + } + if (match && priceMax !== "") { + match = (r.final_price ?? 0) <= parseFloat(priceMax || 0); + } + return match; + }); + + const fetchReservations = async () => { + setFetching(true); + try { + const params = { search: query }; + const data = await getReservations(params); + setReservations(data); + } finally { + setFetching(false); + } + }; + + useEffect(() => { + fetchReservations(); + }, [query]); + + // Remove unused Mantine modal logic + // Use only Bootstrap modals for view/edit + + const handleDeleteReservation = async (reservation) => { + if (window.confirm(`Opravdu smazat rezervaci ID: ${reservation.id}?`)) { + await deleteReservation(reservation.id); + fetchReservations(); + } + }; + + const statusColors = { + reserved: "blue", + cancelled: "red", + completed: "green", + pending: "yellow", + }; + + const columns = [ + { accessor: "id", title: "#", sortable: true, width: "48px" }, + { + accessor: "user", + title: "Uživatel", + sortable: true, + width: "1.5fr", + render: row => row.user?.username || row.user || "—", + filter: ( + } + rightSection={ + setQuery("")}> + + + } + value={query} + onChange={e => setQuery(e.currentTarget.value)} + /> + ), + filtering: query !== "", + }, + { + accessor: "event", + title: "Událost", + sortable: true, + width: "2fr", + render: row => row.event?.name || "—", + filter: ( + } + rightSection={ + setEventFilter("")}> + + + } + value={eventFilter} + onChange={e => setEventFilter(e.currentTarget.value)} + /> + ), + filtering: !!eventFilter, + }, + { + accessor: "market_slot", + title: "Prodejní místo", + sortable: true, + width: "1.2fr", + render: row => row.market_slot?.name || row.market_slot?.id || "—", + }, + { + accessor: "used_extension", + title: "Rozšíření (m²)", + sortable: true, + width: "1fr", + render: row => row.used_extension ?? 0, + }, + { + accessor: "reserved_from", + title: "Od", + sortable: true, + width: "1.2fr", + render: row => row.reserved_from ? dayjs(row.reserved_from, "YYYY-MM-DD").format("DD.MM.YYYY") : "—", + filter: ( + + ), + filtering: !!reservedFromDate, + }, + { + accessor: "reserved_to", + title: "Do", + sortable: true, + width: "1.2fr", + render: row => row.reserved_to ? dayjs(row.reserved_to, "YYYY-MM-DD").format("DD.MM.YYYY") : "—", + filter: ( + + ), + filtering: !!reservedToDate, + }, + { + accessor: "created_at", + title: "Vytvořeno", + sortable: true, + width: "1.2fr", + render: row => row.created_at ? dayjs(row.created_at).format("DD.MM.YYYY HH:mm") : "—", + filter: ( + + + + + ), + filtering: !!(createdFrom || createdTo), + }, + { + accessor: "status", + title: "Stav", + sortable: true, + width: "1fr", + render: row => { + const color = statusColors[row.status] || "gray"; + const label = statusOptions.find(opt => opt.value === row.status)?.label || row.status; + return {label}; + }, + filter: (() => { + const toggleStatus = (value) => { + setSelectedStatus(prev => prev.includes(value) ? prev.filter(v => v !== value) : [...prev, value]); + }; + return ( + + Filtrovat stav + + {statusOptions.map(opt => { + const active = selectedStatus.includes(opt.value); + return ( + toggleStatus(opt.value)} + aria-pressed={active} + role="button" + > + {opt.label} + + ); + })} + + {selectedStatus.length > 0 && ( + + )} + + ); + })(), + filtering: selectedStatus.length > 0, + }, + { + accessor: "note", + title: "Poznámka", + sortable: false, + width: "2fr", + render: row => row.note || "—", + }, + { + accessor: "final_price", + title: "Cena (Kč)", + sortable: true, + width: "1fr", + render: row => row.final_price ?? 0, + filter: ( + + setPriceMin(v === undefined || v === null ? "" : String(v))} + min={0} + thousandSeparator=" " + /> + setPriceMax(v === undefined || v === null ? "" : String(v))} + min={0} + thousandSeparator=" " + /> + {(priceMin !== "" || priceMax !== "") && ( + + )} + + ), + filtering: priceMin !== "" || priceMax !== "", + }, + { + accessor: "actions", + title: "Akce", + width: "80px", + render: (reservation) => ( + + handleShowReservationModal(reservation)}> + + + handleEditReservationModal(reservation)}> + + + handleDeleteReservation(reservation)}> + + + + ), + }, + ]; + + const renderModalContent = () => { + if (!selectedReservation) return Rezervace nebyla nalezena; + + switch (modalType) { + case "view": + return ( + + + ID: {selectedReservation.id} + + + Stav:{" "} + + {selectedReservation.status} + + + + Událost: #{selectedReservation.event} + + + Pozice: #{selectedReservation.marketSlot} + + + Uživatel: #{selectedReservation.user} + + + Rozšíření:{" "} + {selectedReservation.used_extension || "Žádné"} + + + Od:{" "} + {dayjs(selectedReservation.reserved_from).format( + "DD.MM.YYYY HH:mm" + )} + + + Do:{" "} + {dayjs(selectedReservation.reserved_to).format("DD.MM.YYYY HH:mm")} + + + Vytvořeno:{" "} + {dayjs(selectedReservation.created_at).format("DD.MM.YYYY HH:mm")} + + + Poznámka: {selectedReservation.note || "—"} + + + Cena: {selectedReservation.final_price} Kč + + + + + + + ); + + case "edit": + return ( + +
+ + {/* Bootstrap Modal for view */} + setShowModal(false)} centered> + + Detail rezervace + + + {selectedReservation && ( + <> +

ID: {selectedReservation.id}

+

Stav: {selectedReservation.status}

+

Událost: {selectedReservation.event?.name || "Neznámá událost"}

+

Uživatel: {selectedReservation.user?.username || "Neznámý"}

+

Od: {dayjs(selectedReservation.reserved_from, "YYYY-MM-DD").format("DD.MM.YYYY")}

+

Do: {dayjs(selectedReservation.reserved_to, "YYYY-MM-DD").format("DD.MM.YYYY")}

+

Poznámka: {selectedReservation.note || "—"}

+

Cena: {selectedReservation.final_price} Kč

+ + )} +
+ + setShowModal(false)}>Zavřít + { setShowModal(false); handleEditReservationModal(selectedReservation); }}>Upravit + +
+ + {/* Bootstrap Modal for edit */} + setShowModal(false)} centered> + + Upravit rezervaci + +
+ + + Stav rezervace + + + + + + + + + Poznámka + + + + Cena (Kč) + + + {error && {error}} + + + setShowModal(false)}>Zrušit + Uložit změny + + +
+ + + + ); +} + +export default Reservations; \ No newline at end of file diff --git a/frontend/src/pages/manager/SquareDetail.jsx b/frontend/src/pages/manager/SquareDetail.jsx new file mode 100644 index 0000000..da48468 --- /dev/null +++ b/frontend/src/pages/manager/SquareDetail.jsx @@ -0,0 +1,269 @@ +import React, { useState, useRef } from "react"; +import { useNavigate } from "react-router-dom"; +import squareAPI from "../../../api/model/square"; +import { Container, Row, Col, Form, Button, Alert, Modal } from "react-bootstrap"; +import DynamicGrid, { DEFAULT_CONFIG } from "../../../components/DynamicGrid"; + + +export default function SquareDesigner() { + const navigate = useNavigate(); + const [step, setStep] = useState(1); + const [image, setImage] = useState(null); + const [imageUrl, setImageUrl] = useState(""); + const [formData, setFormData] = useState({ + name: "", + description: "", + street: "", + city: "", + psc: "", + width: 40, + height: 30, + cell_area: 4, // m2, default + }); + const [isSubmitting, setIsSubmitting] = useState(false); + const [success, setSuccess] = useState(false); + const [showSuccessModal, setShowSuccessModal] = useState(false); + const [imageAspect, setImageAspect] = useState(4/3); // default aspect ratio + const [error, setError] = useState(null); + const fileInputRef = useRef(); + + // Calculate grid size from width, height, and cell_area + const cellArea = Math.max(1, Math.floor(Number(formData.cell_area) || 1)); + // cell is always square in m², so side = sqrt(cellArea) + const cellSide = Math.sqrt(cellArea); + const safeWidth = Math.max(1, Number(formData.width) || 1); + const safeHeight = Math.max(1, Number(formData.height) || 1); + let grid_cols = Math.max(1, Math.round(safeWidth / cellSide)); + let grid_rows = Math.max(1, Math.round(safeHeight / cellSide)); + // Prevent NaN or Infinity + if (!isFinite(grid_cols) || grid_cols < 1) grid_cols = 1; + if (!isFinite(grid_rows) || grid_rows < 1) grid_rows = 1; + const cellWidth = safeWidth / grid_cols; + const cellHeight = safeHeight / grid_rows; + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData((old) => ({ ...old, [name]: value })); + }; + + const handleImageChange = (e) => { + const file = e.target.files[0]; + if (file) { + setImage(file); + const url = URL.createObjectURL(file); + setImageUrl(url); + // Get image aspect ratio + const img = new window.Image(); + img.onload = () => { + const aspect = img.width / img.height; + setImageAspect(aspect); + // Adjust width/height to match aspect + setFormData((old) => ({ + ...old, + width: Math.sqrt((old.cell_area || 1) * aspect), + height: Math.sqrt((old.cell_area || 1) / aspect), + })); + }; + img.src = url; + setStep(2); + } + }; + // Only allow width/height to be changed together (scaling) + const handleScale = (delta) => { + setFormData((old) => { + const scale = Math.max(0.1, 1 + delta); + const newWidth = old.width * scale; + const newHeight = newWidth / imageAspect; + return { + ...old, + width: newWidth, + height: newHeight, + area: newWidth * newHeight, + }; + }); + }; + + // When user sets area, recalc width/height + const handleAreaChange = (e) => { + const area = Number(e.target.value) || 1; + setFormData((old) => ({ + ...old, + area, + width: Math.sqrt(area * imageAspect), + height: Math.sqrt(area / imageAspect), + })); + }; + + const handleImageRemove = () => { + setImage(null); + setImageUrl(""); + setStep(1); + if (fileInputRef.current) fileInputRef.current.value = ""; + }; + + return ( + +

Návrh náměstí / Square Designer

+ {step === 1 && ( + +
+ + Nejprve nahrajte obrázek náměstí (doporučeno z ptačí perspektivy) + + + + + )} + {step === 2 && ( + <> + + +
Editor mapy náměstí
+
+ {grid_cols > 0 && grid_rows > 0 && isFinite(grid_cols) && isFinite(grid_rows) && ( + + )} +
+
+
Každá buňka: {cellWidth.toFixed(2)}m × {cellHeight.toFixed(2)}m ({cellArea} m²)
+
Počet řádků: {grid_rows} | Počet sloupců: {grid_cols}
+
+ + + + + Šířka náměstí (m) + + + + Výška náměstí (m) + + + + Velikost jedné buňky (m², pouze celé číslo) + + + + + + + + + + Název náměstí + + + + Popis + + + + Ulice + + + + Město + + + + PSČ + + + + {success && Náměstí bylo úspěšně vytvořeno!} + {error && {error}} + + + + + + )} + {error && {error}} + + + {/* Success Modal with animated checkmark */} + +
+ + + + + + +
+

Náměstí bylo úspěšně vytvořeno!

+
+ + + + ); +} \ No newline at end of file diff --git a/frontend/src/pages/manager/Squares.jsx b/frontend/src/pages/manager/Squares.jsx new file mode 100644 index 0000000..f85899c --- /dev/null +++ b/frontend/src/pages/manager/Squares.jsx @@ -0,0 +1,368 @@ +import React, { useEffect, useMemo, useState } from "react"; +import Table from "../../components/Table"; +import Sidebar from "../../components/Sidebar"; +import { + Container, + Row, + Col, + Button as BootstrapButton, + Modal, + Form, + Alert, +} from "react-bootstrap"; +import { + ActionIcon, + Group, + TextInput, + Text, + MultiSelect, + Stack, + Button +} from "@mantine/core"; +import { IconSearch, IconX, IconEye, IconEdit, IconTrash, IconPlus, IconReceipt2 } from "@tabler/icons-react"; +import apiSquares from "../../api/model/square"; + +function Squares() { + // Delete handler + const handleDeleteEvent = async (square) => { + if (window.confirm(`Opravdu smazat náměstí: ${square.name}?`)) { + await apiSquares.deleteSquare(square.id); + const data = await apiSquares.getSquares(); + setSquares(data); + } + }; + + // Bootstrap Modal state for edit + const [showEditModal, setShowEditModal] = useState(false); + + const handleEditSquare = (square) => { + setSelectedSquare(square); + setFormData({ + name: square.name || "", + street: square.street || "", + city: square.city || "", + psc: square.psc || "", + }); + setShowEditModal(true); + }; + + const handleEditModalSubmit = async (e) => { + e.preventDefault(); + setError(null); + setSubmitting(true); + try { + const form = new FormData(); + form.append("name", formData.name); + form.append("description", formData.description); + form.append("street", formData.street); + form.append("city", formData.city); + form.append("psc", Number(formData.psc)); + if (formData.image instanceof File) { + form.append("image", formData.image); + } + await apiSquares.updateSquare(selectedSquare.id, form); + setShowEditModal(false); + setFormData({ + name: "", + description: "", + street: "", + city: "", + psc: "", + }); + const data = await apiSquares.getSquares(); + setSquares(data); + } catch (err) { + const apiErrors = err.response?.data; + if (typeof apiErrors === "object") { + const messages = Object.entries(apiErrors) + .map(([key, value]) => `${key}: ${value.join(", ")}`) + .join("\n"); + setError("Chyba při ukládání:\n" + messages); + } else { + setError("Chyba při ukládání: " + (err.message || "Neznámá chyba")); + } + } finally { + setSubmitting(false); + } + }; + + const [squares, setSquares] = useState([]); + const [fetching, setFetching] = useState(true); + const [query, setQuery] = useState(""); + const [selectedCities, setSelectedCities] = useState([]); + + // Modal state + const [showModal, setShowModal] = useState(false); + const [modalType, setModalType] = useState('view'); // 'view', 'edit' + const [selectedSquare, setSelectedSquare] = useState(null); + const [formData, setFormData] = useState({ + name: "", + description: "", + street: "", + city: "", + psc: "", + }); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + // Fetch data from API + useEffect(() => { + const fetchData = async () => { + try { + const data = await apiSquares.getSquares(); + setSquares(data); + } finally { + setFetching(false); + } + }; + fetchData(); + }, []); + + // City options for filter + const cityOptions = useMemo(() => { + if (!Array.isArray(squares)) return []; + const uniqueCities = [...new Set(squares.map(r => r.city).filter(Boolean))]; + return uniqueCities; + }, [squares]); + + // Filtering (same pattern as Users.jsx) + const filteredSquares = useMemo(() => { + let data = Array.isArray(squares) ? squares : []; + if (query) { + const q = query.toLowerCase(); + data = data.filter( + r => + r.name?.toLowerCase().includes(q) || + r.street?.toLowerCase().includes(q) || + r.city?.toLowerCase().includes(q) + ); + } + if (selectedCities.length > 0) { + data = data.filter(r => selectedCities.includes(r.city)); + } + return data; + }, [squares, query, selectedCities]); + + // Handle form field changes + const handleChange = (e) => { + const { name, value } = e.target; + setFormData((old) => ({ ...old, [name]: value })); + }; + + // Handlers for modal actions + const handleShowSquare = (square) => { + setSelectedSquare(square); + setModalType('view'); + setShowModal(true); + }; + + const columns = [ + { accessor: "id", title: "#", sortable: true, width: "2%" }, + { + accessor: "name", + title: "Název", + sortable: true, + width: "15%", + filter: ( + } + rightSection={ + setQuery("")}> + + + } + value={query} + onChange={(e) => setQuery(e.currentTarget.value)} + /> + ), + filtering: query !== "", + }, + { accessor: "description", title: "Popis", width: "18%" }, + { accessor: "street", title: "Ulice", sortable: true, width: "12%" }, + { accessor: "city", title: "Město", sortable: true, width: "15%", + filter: ( + } + comboboxProps={{ withinPortal: false }} + /> + ), + filtering: selectedCities.length > 0, + }, + { accessor: "psc", title: "PSČ", width: "4%" }, + { + accessor: "velikost", + title: "Velikost m²", + width: "6%", + render: (row) => (row.width && row.height ? row.width * row.height + " m²" : "—"), + }, + { accessor: "cellsize", title: "Velikost buňky (m²)", width: "6%" }, + { + accessor: "image", + title: "Obrázek", + width: "8%", + render: (row) => + row.image ? ( + {row.name} + ) : ( + + Žádný obrázek + + ), + }, + { + accessor: "events", + title: "Počet událostí", + width: "5%", + textAlign: "center", + render: (row) => row.events?.length || 0, + sortable: true, + }, + { + accessor: "actions", + title: "Akce", + width: "5.5%", + render: (square) => ( + + handleShowSquare(square)}> + + + handleEditSquare(square)}> + + + handleDeleteEvent(square)}> + + + + ), + }, + ]; + + // Modal content for view/edit + const renderModalContent = () => { + // No longer used for view/edit, handled by Bootstrap modals below + return Žádný obsah; + }; + + return ( + + + + + + + +

+ + Náměstí +

+ +
+ +
+ + {/* Mantine modal for add only */} + setShowModal(false)} + title={'Přidat náměstí'} + size="lg" + centered + > + {renderModalContent()} + + + {/* Bootstrap Modal for view */} + setShowModal(false)} centered> + + Detail náměstí + + + {selectedSquare && ( + <> +

ID: {selectedSquare.id}

+

Název: {selectedSquare.name}

+

Popis: {selectedSquare.description || "—"}

+

Ulice: {selectedSquare.street || "Ulice není zadaná"}

+

Město: {selectedSquare.city || "Město není zadané"}

+

PSC: {selectedSquare.psc || 12345}

+

Šířka: {selectedSquare.width ?? 10}

+

Výška: {selectedSquare.height ?? 10}

+

Grid řádky: {selectedSquare.grid_rows ?? 60}

+

Grid sloupce: {selectedSquare.grid_cols ?? 45}

+

Velikost buňky: {selectedSquare.cellsize ?? 10}

+

Počet událostí: {selectedSquare.events?.length || 0}

+

Obrázek:
+ {selectedSquare.image + ? {selectedSquare.name} + : Žádný obrázek + } +

+ + )} +
+ + setShowModal(false)}>Zavřít + { setShowModal(false); handleEditSquare(selectedSquare); }}>Upravit + +
+ + {/* Bootstrap Modal for edit */} + setShowEditModal(false)} centered> + + Upravit náměstí + +
+ + + Název + + + + Popis + + + + Ulice + + + + Město + + + + PSC + + + {error && {error}} + + + setShowEditModal(false)}>Zrušit + Uložit změny + + +
+ + + + ); +} + +export default Squares; \ No newline at end of file diff --git a/frontend/src/pages/manager/UserSettings.jsx b/frontend/src/pages/manager/UserSettings.jsx new file mode 100644 index 0000000..58ee42c --- /dev/null +++ b/frontend/src/pages/manager/UserSettings.jsx @@ -0,0 +1,13 @@ +import UserSettings from "../../components/User-Settings"; + + +function Settings(){ + + return( +
+ +
+ ) +} + +export default Settings \ No newline at end of file diff --git a/frontend/src/pages/manager/Users.jsx b/frontend/src/pages/manager/Users.jsx new file mode 100644 index 0000000..689a6e9 --- /dev/null +++ b/frontend/src/pages/manager/Users.jsx @@ -0,0 +1,795 @@ +import React, { useEffect, useMemo, useState } from "react"; +import Table from "../../components/Table"; +import Sidebar from "../../components/Sidebar"; +import { + Container, + Row, + Col, + Button as BootstrapButton, + Modal, + Form, + Alert, +} from "react-bootstrap"; +import { + ActionIcon, + Group, + TextInput, + Text, + Stack, + Button, + Switch, + Badge, + Tooltip, +} from "@mantine/core"; +import { IconSearch, IconX, IconEye, IconEdit, IconTrash, IconPlus, IconReceipt2 } from "@tabler/icons-react"; +import userAPI from "../../api/model/user"; +import { fetchEnumFromSchemaJson } from "../../api/get_chocies"; + +function Users() { + // State + const [users, setUsers] = useState([]); + const [fetching, setFetching] = useState(true); + + // Separate filter states for each field + const [filterUsername, setFilterUsername] = useState(""); + const [filterEmail, setFilterEmail] = useState(""); + const [filterFirstName, setFilterFirstName] = useState(""); + const [filterLastName, setFilterLastName] = useState(""); + const [selectedRoles, setSelectedRoles] = useState([]); + const [selectedAccountTypes, setSelectedAccountTypes] = useState([]); + const [selectedActive, setSelectedActive] = useState([]); + const [selectedEmailVerified, setSelectedEmailVerified] = useState([]); // new filter state + const [filterCity, setFilterCity] = useState(""); + const [filterPSC, setFilterPSC] = useState(""); + + const [showModal, setShowModal] = useState(false); + const [modalType, setModalType] = useState('view'); // 'view', 'edit' + const [selectedUser, setSelectedUser] = useState(null); + // Add more fields to formData for editing + const [formData, setFormData] = useState({ + username: "", + email: "", + first_name: "", + last_name: "", + role: "", + account_type: "", + email_verified: false, + phone_number: "", + city: "", + street: "", + PSC: "", + bank_account: "", + ICO: "", + RC: "", + GDPR: false, + is_active: true, + var_symbol: "", // <-- add this line + }); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [roleDropdownOptions, setRoleDropdownOptions] = useState([]); + const [accountTypeDropdownOptions, setAccountTypeDropdownOptions] = useState([]); + + // Fetch users + useEffect(() => { + const fetchData = async () => { + try { + console.log("Fetching users..."); + const data = await userAPI.getUsers(); + // Defensive: check if response is array, otherwise log error and set empty array + if (Array.isArray(data)) { + console.log("Fetched users:", data); + setUsers(data); + } else if (data && Array.isArray(data.results)) { + // DRF pagination: { count, next, previous, results } + console.log("Fetched users (paginated):", data.results); + setUsers(data.results); + } else { + console.error("Fetched users is not an array:", data); + setUsers([]); + } + } catch (err) { + console.error("Error fetching users:", err); + setUsers([]); + } finally { + setFetching(false); + } + }; + fetchData(); + }, []); + + useEffect(() => { + // Načti možnosti pro role + fetchEnumFromSchemaJson("/api/account/users/", "post", "role") + .then((choices) => setRoleDropdownOptions(choices)) + .catch(() => setRoleDropdownOptions([ + { value: "admin", label: "Administrátor" }, + { value: "seller", label: "Prodejce" }, + { value: "squareManager", label: "Správce tržiště" }, + { value: "cityClerk", label: "Úředník" }, + { value: "checker", label: "Kontrolor" }, + ])); + // Načti možnosti pro typ účtu + fetchEnumFromSchemaJson("/api/account/users/", "post", "account_type") + .then((choices) => setAccountTypeDropdownOptions(choices)) + .catch(() => setAccountTypeDropdownOptions([ + { value: "company", label: "Firma" }, + { value: "individual", label: "Fyzická osoba" }, + ])); + }, []); + + // Role/group options + const roleOptions = useMemo(() => { + if (!Array.isArray(users)) return []; + const allRoles = users.map(u => u.role).filter(Boolean); + return [...new Set(allRoles)]; + }, [users]); + + const accountTypeOptions = useMemo(() => { + if (!Array.isArray(users)) return []; + const allTypes = users.map(u => u.account_type).filter(Boolean); + return [...new Set(allTypes)]; + }, [users]); + + // Filtering + const filteredUsers = useMemo(() => { + let data = Array.isArray(users) ? users : []; + if (filterUsername) { + const q = filterUsername.toLowerCase(); + data = data.filter(u => u.username?.toLowerCase().includes(q)); + } + if (filterEmail) { + const q = filterEmail.toLowerCase(); + data = data.filter(u => u.email?.toLowerCase().includes(q)); + } + if (filterFirstName) { + const q = filterFirstName.toLowerCase(); + data = data.filter(u => u.first_name?.toLowerCase().includes(q)); + } + if (filterLastName) { + const q = filterLastName.toLowerCase(); + data = data.filter(u => u.last_name?.toLowerCase().includes(q)); + } + if (selectedRoles.length > 0) { + data = data.filter(u => selectedRoles.includes(u.role)); + } + if (selectedAccountTypes.length > 0) { + data = data.filter(u => selectedAccountTypes.includes(u.account_type)); + } + if (selectedActive.length > 0) { + data = data.filter(u => + selectedActive.includes(u.is_active ? "true" : "false") + ); + } + if (selectedEmailVerified.length > 0) { + data = data.filter(u => + selectedEmailVerified.includes(u.email_verified ? "true" : "false") + ); + } + if (filterCity) { + const q = filterCity.toLowerCase(); + data = data.filter(u => u.city?.toLowerCase().includes(q)); + } + if (filterPSC) { + const q = filterPSC.toLowerCase(); + data = data.filter(u => u.PSC?.toLowerCase().includes(q)); + } + return data; + }, [users, filterUsername, filterEmail, filterFirstName, filterLastName, selectedRoles, selectedAccountTypes, selectedActive, selectedEmailVerified, filterCity, filterPSC]); + + // Handlers + const handleShowUser = (user) => { + console.log("Show user:", user); + setSelectedUser(user); + setModalType('view'); + setShowModal(true); + }; + + const handleEditUser = (user) => { + console.log("Edit user:", user); + setSelectedUser(user); + setFormData({ + username: user.username || "", + email: user.email || "", + first_name: user.first_name || "", + last_name: user.last_name || "", + role: user.role || "", + account_type: user.account_type || "", + email_verified: user.email_verified || false, + phone_number: user.phone_number || "", + city: user.city || "", + street: user.street || "", + PSC: user.PSC || "", + bank_account: user.bank_account || "", + ICO: user.ICO || "", + RC: user.RC || "", + GDPR: user.GDPR || false, + is_active: user.is_active ?? true, + var_symbol: user.var_symbol || "", + }); + setModalType('edit'); + setShowModal(true); + setError(null); + }; + + const handleDeleteUser = async (user) => { + console.log("Delete user:", user); + if (window.confirm(`Opravdu smazat uživatele: ${user.username}?`)) { + await userAPI.deleteUser(user.id); + const data = await userAPI.getUsers(); + setUsers(data); + } + }; + + const handleChange = (e) => { + const { name, value, type, checked } = e.target; + console.log("Form change:", name, type === "checkbox" ? checked : value); + setFormData((old) => ({ + ...old, + [name]: type === "checkbox" ? checked : value, + })); + }; + + const handleGroupsChange = (groups) => { + console.log("Groups changed:", groups); + setFormData((old) => ({ ...old, groups })); + }; + + const handleEditModalSubmit = async (e) => { + e.preventDefault(); + setError(null); + setSubmitting(true); + console.log("Submitting edit:", formData); + try { + await userAPI.updateUser(selectedUser.id, { + ...formData, + account_type: formData.account_type, + var_symbol: formData.var_symbol, // <-- ensure this is sent + }); + setShowModal(false); + const data = await userAPI.getUsers(); + setUsers(data); + } catch (err) { + const apiErrors = err.response?.data; + if (typeof apiErrors === "object") { + const messages = Object.entries(apiErrors) + .map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(", ") : value}`) + .join("\n"); + setError("Chyba při ukládání:\n" + messages); + console.log("API error:", apiErrors); + } else { + setError("Chyba při ukládání: " + (err.message || "Neznámá chyba")); + console.log("Unknown error:", err); + } + } finally { + setSubmitting(false); + } + }; + + // Table columns + const columns = [ + { accessor: "id", + title: "#", + sortable: true, + width: "2%" }, + { + accessor: "username", + title: "Uživatelské jméno", + sortable: true, + width: "7%", + filter: ( + } + rightSection={ + setFilterUsername("")}> + + + } + value={filterUsername} + onChange={(e) => setFilterUsername(e.currentTarget.value)} + /> + ), + filtering: !!filterUsername, + }, + { + accessor: "email", + title: "Email", + sortable: true, + width: "7%", + filter: ( + } + rightSection={ + setFilterEmail("")}> + + + } + value={filterEmail} + onChange={(e) => setFilterEmail(e.currentTarget.value)} + /> + ), + filtering: !!filterEmail, + }, + { + accessor: "first_name", + title: "Jméno", + sortable: true, + width: "5%", + filter: ( + } + rightSection={ + setFilterFirstName("")}> + + + } + value={filterFirstName} + onChange={(e) => setFilterFirstName(e.currentTarget.value)} + /> + ), + filtering: !!filterFirstName, + }, + { + accessor: "last_name", + title: "Příjmení", + sortable: true, + width: "5%", + filter: ( + } + rightSection={ + setFilterLastName("")}> + + + } + value={filterLastName} + onChange={(e) => setFilterLastName(e.currentTarget.value)} + /> + ), + filtering: !!filterLastName, + }, + { + accessor: "role", + title: "Role", + width: "7%", + render: (row) => + row.role ? ( + + { + { + "admin": "Administrátor", + "seller": "Prodejce", + "squareManager": "Správce tržiště", + "cityClerk": "Úředník", + "checker": "Kontrolor", + }[row.role] || row.role + } + + ) : ( + Žádná + ), + filter: (() => { + const toggle = (val) => { + setSelectedRoles(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val]); + }; + const roleBadges = roleOptions.map(role => { + const label = { + "admin": "Administrátor", + "seller": "Prodejce", + "squareManager": "Správce tržiště", + "cityClerk": "Úředník", + "checker": "Kontrolor", + }[role] || role; + const active = selectedRoles.includes(role); + return ( + toggle(role)} + aria-pressed={active} + role="button" + > + {label} + + ); + }); + return ( + + Filtrovat role + {roleBadges} + {selectedRoles.length > 0 && ( + + )} + + ); + })(), + filtering: selectedRoles.length > 0, + }, + { + accessor: "account_type", + title: "Typ účtu", + width: "7%", + render: (row) => + row.account_type ? ( + + { + accountTypeDropdownOptions.find(opt => opt.value === row.account_type)?.label || row.account_type + } + + ) : ( + + ), + sortable: true, + filter: (() => { + const toggle = (val) => { + setSelectedAccountTypes(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val]); + }; + return ( + + Filtrovat typ účtu + + {accountTypeDropdownOptions.map(opt => { + const active = selectedAccountTypes.includes(opt.value); + return ( + toggle(opt.value)} + aria-pressed={active} + role='button' + > + {opt.label} + + ); + })} + + {selectedAccountTypes.length > 0 && ( + + )} + + ); + })(), + filtering: selectedAccountTypes.length > 0, + }, + { + accessor: "email_verified", + title: "E-mail ověřen", + width: "4%", + render: (row) => + row.email_verified ? ( + Ano + ) : ( + Ne + ), + sortable: true, + filter: (() => { + const toggle = (val) => { + setSelectedEmailVerified(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val]); + }; + const options = [ + { value: 'true', label: 'Ano', color: 'green' }, + { value: 'false', label: 'Ne', color: 'red' } + ]; + return ( + + Ověření + + {options.map(opt => { + const active = selectedEmailVerified.includes(opt.value); + return ( + toggle(opt.value)} + aria-pressed={active} + role='button' + > + {opt.label} + + ); + })} + + {selectedEmailVerified.length > 0 && ( + + )} + + ); + })(), + filtering: selectedEmailVerified.length > 0, + }, + { + accessor: "is_active", + title: "Aktivní", + width: "4%", + render: (row) => row.is_active ? ( + Ano + ) : ( + Ne + ), + sortable: true, + filter: (() => { + const toggle = (val) => { + setSelectedActive(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val]); + }; + const options = [ + { value: 'true', label: 'Ano', color: 'green' }, + { value: 'false', label: 'Ne', color: 'red' } + ]; + return ( + + Aktivita + + {options.map(opt => { + const active = selectedActive.includes(opt.value); + return ( + toggle(opt.value)} + aria-pressed={active} + role='button' + > + {opt.label} + + ); + })} + + {selectedActive.length > 0 && ( + + )} + + ); + })(), + filtering: selectedActive.length > 0, + }, + { + accessor: "city", + title: "Město", + width: "5%", + filter: ( + } + rightSection={ + setFilterCity("")}> + + + } + value={filterCity} + onChange={(e) => setFilterCity(e.currentTarget.value)} + /> + ), + filtering: !!filterCity, + }, + { + accessor: "PSC", + title: "PSČ", + width: "3%", + filter: ( + } + rightSection={ + setFilterPSC("")}> + + + } + value={filterPSC} + onChange={(e) => setFilterPSC(e.currentTarget.value)} + /> + ), + filtering: !!filterPSC, + }, + { + accessor: "actions", + title: "Akce", + width: "3.5%", + render: (user) => ( + + handleShowUser(user)}> + + + handleEditUser(user)}> + + + handleDeleteUser(user)}> + + + + ), + }, + ]; + + return ( + + + + + + + +

+ + Uživatelé +

+ +
+ +
+ + {/* Bootstrap Modal for view */} + setShowModal(false)} centered> + + Detail uživatele + + + {selectedUser && ( + <> +

ID: {selectedUser.id}

+

Uživatelské jméno: {selectedUser.username}

+

Email: {selectedUser.email || "—"}

+

Jméno: {selectedUser.first_name || "—"}

+

Příjmení: {selectedUser.last_name || "—"}

+

Role: {(selectedUser.groups && selectedUser.groups.length > 0) ? selectedUser.groups.join(", ") : "—"}

+

Aktivní: {selectedUser.is_active ? "Ano" : "Ne"}

+ + )} +
+ + setShowModal(false)}>Zavřít + { setShowModal(false); handleEditUser(selectedUser); }}>Upravit + +
+ + {/* Bootstrap Modal for edit */} + setShowModal(false)} centered> + + Upravit uživatele + +
+ + + Uživatelské jméno + + + + Email + + + + Jméno + + + + Příjmení + + + + Role + + + {roleDropdownOptions.map(opt => ( + + ))} + + + + Typ účtu + + + {accountTypeDropdownOptions.map(opt => ( + + ))} + + + + Telefon + + + + Město + + + + Ulice + + + + PSČ + + + + Bankovní účet + + + + IČO + + + + Rodné číslo + + + + Variabilní symbol + + + + + + + + + + setFormData(old => ({ ...old, is_active: e.target.checked }))} + /> + + {error && {error}} + + + setShowModal(false)}>Zrušit + Uložit změny + + +
+ + + + ); +} + +export default Users; diff --git a/frontend/src/pages/manager/create/Kde je zbytek.md b/frontend/src/pages/manager/create/Kde je zbytek.md new file mode 100644 index 0000000..e506f22 --- /dev/null +++ b/frontend/src/pages/manager/create/Kde je zbytek.md @@ -0,0 +1,6 @@ +vytváření akce má vlastní formulář + +náměstí používa squaredesigner.jsx + +rezervace používa košík kde admin jenom zadá uživatele v horní části formuláře které vidí jenom on + diff --git a/frontend/src/pages/manager/create/SquareDesigner.jsx b/frontend/src/pages/manager/create/SquareDesigner.jsx new file mode 100644 index 0000000..bb2242a --- /dev/null +++ b/frontend/src/pages/manager/create/SquareDesigner.jsx @@ -0,0 +1,273 @@ +import React, { useState, useRef } from "react"; +import { useNavigate } from "react-router-dom"; +import squareAPI from "../../../api/model/square"; +import { Container, Row, Col, Form, Button, Alert, Modal } from "react-bootstrap"; +import DynamicGrid, { DEFAULT_CONFIG } from "../../../components/DynamicGrid"; + + +export default function SquareDesigner() { + const navigate = useNavigate(); + const [step, setStep] = useState(1); + const [image, setImage] = useState(null); + const [imageUrl, setImageUrl] = useState(""); + const [formData, setFormData] = useState({ + name: "", + description: "", + street: "", + city: "", + psc: "", + width: 40, + height: 30, + cell_area: 4, // m2, default + }); + const [isSubmitting, setIsSubmitting] = useState(false); + const [success, setSuccess] = useState(false); + const [showSuccessModal, setShowSuccessModal] = useState(false); + const [imageAspect, setImageAspect] = useState(4/3); // default aspect ratio + const [error, setError] = useState(null); + const fileInputRef = useRef(); + + // Calculate grid size from width, height, and cell_area + const cellArea = Math.max(1, Math.floor(Number(formData.cell_area) || 1)); + // cell is always square in m², so side = sqrt(cellArea) + const cellSide = Math.sqrt(cellArea); + const safeWidth = Math.max(1, Number(formData.width) || 1); + const safeHeight = Math.max(1, Number(formData.height) || 1); + let grid_cols = Math.max(1, Math.round(safeWidth / cellSide)); + let grid_rows = Math.max(1, Math.round(safeHeight / cellSide)); + // Prevent NaN or Infinity + if (!isFinite(grid_cols) || grid_cols < 1) grid_cols = 1; + if (!isFinite(grid_rows) || grid_rows < 1) grid_rows = 1; + const cellWidth = safeWidth / grid_cols; + const cellHeight = safeHeight / grid_rows; + + // Demo: select N cells (for preview only) + const [selectedCells, setSelectedCells] = useState([]); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData((old) => ({ ...old, [name]: value })); + }; + + const handleImageChange = (e) => { + const file = e.target.files[0]; + if (file) { + setImage(file); + const url = URL.createObjectURL(file); + setImageUrl(url); + // Get image aspect ratio + const img = new window.Image(); + img.onload = () => { + const aspect = img.width / img.height; + setImageAspect(aspect); + // Adjust width/height to match aspect + setFormData((old) => ({ + ...old, + width: Math.sqrt((old.cell_area || 1) * aspect), + height: Math.sqrt((old.cell_area || 1) / aspect), + })); + }; + img.src = url; + setStep(2); + } + }; + // Only allow width/height to be changed together (scaling) + const handleScale = (delta) => { + setFormData((old) => { + const scale = Math.max(0.1, 1 + delta); + const newWidth = old.width * scale; + const newHeight = newWidth / imageAspect; + return { + ...old, + width: newWidth, + height: newHeight, + area: newWidth * newHeight, + }; + }); + }; + + // When user sets area, recalc width/height + const handleAreaChange = (e) => { + const area = Number(e.target.value) || 1; + setFormData((old) => ({ + ...old, + area, + width: Math.sqrt(area * imageAspect), + height: Math.sqrt(area / imageAspect), + })); + }; + + const handleImageRemove = () => { + setImage(null); + setImageUrl(""); + setStep(1); + setSelectedCells([]); + if (fileInputRef.current) fileInputRef.current.value = ""; + }; + + return ( + +

Návrh náměstí / Square Designer

+ {step === 1 && ( + +
+ + Nejprve nahrajte obrázek náměstí (doporučeno z ptačí perspektivy) + + + + + )} + {step === 2 && ( + <> + + +
Editor mapy náměstí
+
+ {grid_cols > 0 && grid_rows > 0 && isFinite(grid_cols) && isFinite(grid_rows) && ( + + )} +
+
+
Každá buňka: {cellWidth.toFixed(2)}m × {cellHeight.toFixed(2)}m ({cellArea} m²)
+
Vybráno buněk: {selectedCells.length}
+
Počet řádků: {grid_rows} | Počet sloupců: {grid_cols}
+
+ + + + + Šířka náměstí (m) + + + + Výška náměstí (m) + + + + Velikost jedné buňky (m², pouze celé číslo) + + + + + + + + + + Název náměstí + + + + Popis + + + + Ulice + + + + Město + + + + PSČ + + + + {success && Náměstí bylo úspěšně vytvořeno!} + {error && {error}} + + + + + + )} + {error && {error}} + + + {/* Success Modal with animated checkmark */} + +
+ + + + + + +
+

Náměstí bylo úspěšně vytvořeno!

+
+ + + + ); +} diff --git a/frontend/src/pages/manager/create/create-event.jsx b/frontend/src/pages/manager/create/create-event.jsx new file mode 100644 index 0000000..16d04c3 --- /dev/null +++ b/frontend/src/pages/manager/create/create-event.jsx @@ -0,0 +1,244 @@ +import React, { useState, useEffect } from "react"; +import { Container, Row, Col, Form, Button, Table, Alert } from "react-bootstrap"; +import { useNavigate } from "react-router-dom"; +import eventAPI from "../../../api/model/event"; +import squareAPI from "../../../api/model/square"; + +export default function CreateEvent({ onCreated }) { + const [form, setForm] = useState({ ...eventAPI.defaultEvent }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [errorDetail, setErrorDetail] = useState(null); + const [squares, setSquares] = useState([]); + const [squaresLoading, setSquaresLoading] = useState(false); + const [confirmed, setConfirmed] = useState(false); + const navigate = useNavigate(); + + useEffect(() => { + setSquaresLoading(true); + squareAPI.getSquares() + .then(data => setSquares(data)) + .catch(() => setSquares([])) + .finally(() => setSquaresLoading(false)); + }, []); + + const handleChange = e => { + const { name, value } = e.target; + setForm(f => ({ ...f, [name]: value })); + }; + + const handleSquareSelect = square => { + setForm(f => ({ ...f, square_id: square.id })); + }; + + const handleSubmit = async e => { + e.preventDefault(); + setLoading(true); + setError(""); + setErrorDetail(null); + // Require square selection + if (!form.square_id) { + setError("Vyberte náměstí (plocha) pro událost."); + setLoading(false); + return; + } + try { + const response = await eventAPI.createEvent(form); + setForm({ ...eventAPI.defaultEvent, square: null }); + setConfirmed(true); + if (onCreated) onCreated(); + } catch (err) { + // Show error message in UI + let msg = "Chyba při vytváření události."; + if (err && err.response && err.response.data) { + if (typeof err.response.data === "string") { + msg = `Chyba: ${err.response.data}`; + } else if (err.response.data.detail) { + msg = `Chyba: ${err.response.data.detail}`; + } else { + // Validation errors: show all fields + msg = Object.entries(err.response.data) + .map(([field, val]) => `${field}: ${Array.isArray(val) ? val.join(", ") : val}`) + .join("\n"); + } + setErrorDetail(err.response.data); + } else { + setErrorDetail(err); + } + setError(msg); + } finally { + setLoading(false); + } + }; + + const handleConfirmOk = () => { + setConfirmed(false); + window.location.href = "/manage/events"; + }; + + return ( + + {confirmed ? ( + + Událost byla úspěšně vytvořena. +
+ +
+
+ ) : ( +
+ {/* Row for Název události and Popis */} + +
+ + Název události + + + + + + + + Popis + + + + + {/* Row for dates */} + + + + Datum začátku + + + + + + Datum konce + + + + + {/* Row for Výběr náměstí */} + + + + Výběr náměstí +
+
+ + + + + + + + {squaresLoading ? ( + + + + ) : squares.length === 0 ? ( + + + + ) : ( + squares.map(sq => ( + + + + + )) + )} + +
NáměstíAkce
Načítání...
Žádné plochy
{sq.name} + +
+
+ {form.square_id && ( +
+ Vybraná plocha ID: {form.square_id} +
+ )} + + + + {/* Row for Cena za m² */} + + + + Cena za m² + + + + + {/* Error and submit */} + {error && ( + +
{error}
+ {errorDetail && ( +
+ Detail chyby +
+                    {typeof errorDetail === "object"
+                      ? JSON.stringify(errorDetail, null, 2)
+                      : String(errorDetail)}
+                  
+
+ )} +
+ )} + + + + + + + )} + + ); +} \ No newline at end of file diff --git a/frontend/src/pages/manager/create/create-product.jsx b/frontend/src/pages/manager/create/create-product.jsx new file mode 100644 index 0000000..d659724 --- /dev/null +++ b/frontend/src/pages/manager/create/create-product.jsx @@ -0,0 +1,93 @@ +import React, { useState } from "react"; +import { Container, Row, Col, Form, Alert, Button as BootstrapButton } from "react-bootstrap"; +import Sidebar from "../../../components/Sidebar"; +import { Group } from "@mantine/core"; +import { useNavigate } from "react-router-dom"; +import productAPI from "../../../api/model/product"; + +function CreateProduct() { + const navigate = useNavigate(); + const [formData, setFormData] = useState({ + name: "", + description: "", + price: "", + is_active: true, + }); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const handleChange = (e) => { + const { name, value, type, checked } = e.target; + setFormData(prev => ({ ...prev, [name]: type === "checkbox" ? checked : value })); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setSubmitting(true); + setError(null); + try { + await productAPI.createProduct({ + name: formData.name, + description: formData.description, + price: formData.price === "" ? null : Number(formData.price), + is_active: !!formData.is_active, + }); + navigate("/manage/products"); + } catch (err) { + const apiErrors = err.response?.data; + if (typeof apiErrors === "object") { + const messages = Object.entries(apiErrors) + .map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(", ") : v}`) + .join("\n"); + setError(messages); + } else { + setError(err.message || "Neznámá chyba"); + } + } finally { + setSubmitting(false); + } + }; + + return ( + + + + + + +
+

Vytvořit produkt

+
+ + Název + + + + Popis + + + + Cena (Kč) + + + + + + {error && {error}} + + navigate("/manager/products")}> + Zpět + + + Vytvořit + + +
+
+ +
+
+ ); +} + +export default CreateProduct; diff --git a/frontend/src/pages/manager/create/create-user.jsx b/frontend/src/pages/manager/create/create-user.jsx new file mode 100644 index 0000000..dd04da5 --- /dev/null +++ b/frontend/src/pages/manager/create/create-user.jsx @@ -0,0 +1,283 @@ +import React, { useState, useEffect } from "react"; +import userAPI from "../../../api/model/user"; // adjust import if needed +import Form from 'react-bootstrap/Form'; +import { fetchEnumFromSchemaJson } from "../../../api/get_chocies"; + +const initialForm = { + first_name: "", + last_name: "", + email: "", + phone_number: "", + account_type: "", + role: "", + password: "", + city: "", + street: "", + PSC: "", + bank_account: "", + RC: "", + ICO: "", + GDPR: false, +}; + +export default function CreateUser() { + const [form, setForm] = useState(initialForm); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const [roleChoices, setRoleChoices] = useState([]); + const [accountTypeChoices, setAccountTypeChoices] = useState([]); + + useEffect(() => { + // Fetch choices from OpenAPI schema for role and account_type + fetchEnumFromSchemaJson("/api/account/users/", "post", "role") + .then((choices) => setRoleChoices(choices)) + .catch(() => setRoleChoices([ + { value: "admin", label: "Administrátor" }, + { value: "seller", label: "Prodejce" }, + { value: "squareManager", label: "Správce tržiště" }, + { value: "cityClerk", label: "Úředník" }, + { value: "checker", label: "Kontrolor" }, + ])); + fetchEnumFromSchemaJson("/api/account/users/", "post", "account_type") + .then((choices) => setAccountTypeChoices(choices)) + .catch(() => setAccountTypeChoices([ + { value: "company", label: "Firma" }, + { value: "individual", label: "Fyzická osoba" }, + ])); + }, []); + + const handleChange = (e) => { + const { name, value, type, checked } = e.target; + setForm((prev) => ({ + ...prev, + [name]: type === "checkbox" ? checked : value, + })); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setLoading(true); + setError(null); + setSuccess(false); + try { + await userAPI.createUser(form); + setSuccess(true); + setForm(initialForm); + } catch (err) { + setError(err.message || "Chyba při vytváření uživatele."); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+
+
+

Registrace nového uživatele

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + {accountTypeChoices.map((opt) => ( + + ))} + +
+
+ + + + {roleChoices.map((opt) => ( + + ))} + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + {error && ( +
{error}
+ )} + {success && ( +
+ Uživatel byl úspěšně vytvořen. +
+ )} +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/manager/edit/MapEditor.jsx b/frontend/src/pages/manager/edit/MapEditor.jsx new file mode 100644 index 0000000..3f6e6a4 --- /dev/null +++ b/frontend/src/pages/manager/edit/MapEditor.jsx @@ -0,0 +1,266 @@ +import React, { useState, useEffect } from "react"; +import { useParams } from "react-router-dom"; +import { Container, Row, Col, Card, ListGroup } from "react-bootstrap"; +import DynamicGrid, { DEFAULT_CONFIG } from "../../../components/DynamicGrid"; +import apiEvent from "../../../api/model/event"; +import apiSquare from "../../../api/model/square"; +import apiMarketSlot from "../../../api/model/market_slot"; + +function MapEditor() { + const { eventId } = useParams(); + const [gridConfig, setGridConfig] = useState(DEFAULT_CONFIG); + + const [eventObject, setEventObject] = useState(null); + const [marketSlots, setMarketSlots] = useState([]); + const [reservations, setReservations] = useState([]); + const [selectedIndex, setSelectedIndex] = useState(null); + const [squareObject, setSquareObject] = useState(null); + + // 🟡 Načtení eventu a slotů z databáze + useEffect(() => { + if (!eventId) return; + + async function fetchSlots() { + try { + const data = await apiEvent.getEventById(eventId); + setEventObject(data); + setMarketSlots((data.market_slots || []).map((slot) => ({ + ...slot, + status: slot.status || "active", // <- doplní výchozí hodnotu + })) + ); + + const sqData = await apiSquare.getSquareById(data.square.id) + setSquareObject(sqData); + + const rows = sqData.grid_rows; + const cols = sqData.grid_cols; + + setGridConfig({ + ...DEFAULT_CONFIG, + rows, + cols, + }); + + // Převedení slotů na "reservations" formát + const loadedReservations = (data.market_slots || []).map((slot, index) => ({ + id: slot.id, + name: slot.label || `Slot #${index + 1}`, + x: slot.x, + y: slot.y, + w: slot.width || 1, + h: slot.height || 1, + locked: slot.locked || false, + status: slot.status, + base_size: slot.base_size ?? undefined, + available_extension: slot.available_extension ?? 0, + })); + + setReservations(loadedReservations); + } catch (error) { + console.error("Chyba při načítání eventu a slotů:", error); + } + } + + fetchSlots(); + }, [eventId]); + + /* + // (Volitelně) Uložení rezervací do localStorage – pokud chceš mít zálohu + useEffect(() => { + const storageKey = `reservationData_${eventId}_${gridConfig.rows}x${gridConfig.cols}`; + + localStorage.setItem(storageKey, JSON.stringify(reservations)); + }, [reservations, storageKey]); + */ + + const exportReservations = () => { + const dataStr = JSON.stringify(reservations, null, 2); + const blob = new Blob([dataStr], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "reservations.json"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + }; + + const clearAll = () => { + localStorage.removeItem(storageKey); + setReservations([]); + setSelectedIndex(null); + }; + + {/* UKLÁDANÍ */} + const saveAndSend = async () => { + try { + console.log("Ukládám rezervace:", reservations); + + // Pro každou rezervaci: update pokud je id, create pokud ne + for (const res of reservations) { + // Připrav data ve formátu API + const data = { + event: eventId, + status: res.status || "active", + base_size: res.base_size ?? (res.w || 1) * (res.h || 1), + available_extension: res.available_extension ?? 0, + x: res.x, + y: res.y, + width: res.w, + height: res.h, + price_per_m2: 0, // případně doplnit podle potřeby + }; + + if (res.id) { + // Update existujícího slotu + await apiMarketSlot.updateMarketSlot(res.id, data); + } else { + // Vytvoření nového slotu + const created = await apiMarketSlot.createMarketSlot(data); + // Aktualizuj ID v state, aby bylo aktuální + res.id = created.id; + } + } + + alert("Rezervace byly úspěšně uloženy."); + } catch (error) { + console.error("Chyba při ukládání rezervací:", error); + alert("Chyba při ukládání rezervací, zkontrolujte konzoli."); + } + }; + + return ( + +

+ Rezervace pro event:{" "} + {eventObject?.name || `#${eventId}`} +

+ + {eventObject && ( + + +
{eventObject.name}
+

{eventObject.description}

+

+ Náměstí:{" "} + {eventObject.square?.name || "Neznámé náměstí"} +

+

+ Termín:{" "} + {new Date(eventObject.start).toLocaleString()} –{" "} + {new Date(eventObject.end).toLocaleString()} +

+
+
+ )} + + + + + + + + +
Seznam slotu
+ + {reservations.length} + +
+ {/* Make the list scrollable */} +
+ + {reservations.map((res, i) => ( + setSelectedIndex(i)} + > +
+
+ {i + 1}. {res.name} +
+ + {res.w}×{res.h} + +
+
+ [{res.x},{res.y}] → [{res.x + res.w - 1},{res.y + res.h - 1}] +
+ {/* Editable fields */} +
+ +
+
+ +
+
+ ))} +
+
+
+ +
+ +
+ + + +
+
+ ); +} + +export default MapEditor; diff --git a/frontend/src/pages/register/EmailVerification.jsx b/frontend/src/pages/register/EmailVerification.jsx new file mode 100644 index 0000000..354b4de --- /dev/null +++ b/frontend/src/pages/register/EmailVerification.jsx @@ -0,0 +1,17 @@ +import {Container, Button, Card, Row, Col} from "react-bootstrap"; +import ConfirmEmailBar from "../../components/ConfirmEmailBar"; + +function Login() { + return ( + +
+ +
+
+

eTržnice

+
+
+ ); +} + +export default Login; \ No newline at end of file diff --git a/frontend/src/pages/register/Register.jsx b/frontend/src/pages/register/Register.jsx new file mode 100644 index 0000000..306161a --- /dev/null +++ b/frontend/src/pages/register/Register.jsx @@ -0,0 +1,84 @@ + +import RegisterCard from "../../components/RegisterCard"; + +import { Modal, Col, Row, Container, Button, Form, Card, ToggleButton, InputGroup } from "react-bootstrap"; + +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faEnvelope, faMobileAlt } from "@fortawesome/free-solid-svg-icons"; + +function Register() { + return ( +
+ + + +

Registrace nájemníků městského obvodu Ostrava-Jih

+

+ Mokrý stín tiše stékal po svahu, zatímco{" "} + mlžné chvění vířilo nad klidnou plání pod večerní + oblohou. +

+

+ Klopýtající zrnka páry mizela v houstnoucí šedi, kde{" "} + beztvaré ozvěny{" "} + tlumeně tančily pod rytmem vzdálených kapek. +

+

+ Jemné šustění závanu rozléhalo se tichem.
+ Drobné úlomky snu klouzaly po + struktuře bez cíle + .
+ Nezřetelný obraz mizel v jemném odlesku nedořečeného rána. +

+

+ Neumíte se přihlásit? Kontaktujte nás: +

+

+ + +

+

+ + + + + + +
+
+
+ ); +} + +export default Register; diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..132032e --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + +}) diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..b88f13f --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,63 @@ +# nginx.conf +user nginx; +worker_processes auto; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + sendfile on; + keepalive_timeout 65; + + server { + listen 80; + server_name _; + + # ------------------------- + # React frontend + # ------------------------- + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri /index.html; + } + + # ------------------------- + # Django backend API + # ------------------------- + location /api { + return 301 /api/; + } + location /api/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # ------------------------- + # Static & media files (Django) + # ------------------------- + location /static/ { + alias /app/collectedstaticfiles/; + } + + location /media/ { + alias /app/media/; + } + + # ------------------------- + # Security headers + # ------------------------- + add_header X-Frame-Options "SAMEORIGIN"; + add_header X-Content-Type-Options "nosniff"; + add_header Referrer-Policy "strict-origin-when-cross-origin"; + add_header Content-Security-Policy "default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:;"; + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..42e2dee --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "e-rezervace-jih-vitkovice", + "lockfileVersion": 3, + "requires": true, + "packages": {} +}