diff --git a/BackEnd/administration/factories.py b/BackEnd/administration/factories.py index 251ec104b..78c8b336b 100644 --- a/BackEnd/administration/factories.py +++ b/BackEnd/administration/factories.py @@ -1,7 +1,7 @@ import factory.django from authentication.models import CustomUser -from profiles.models import Profile +from profiles.models import Profile, Category class AdminUserFactory(factory.django.DjangoModelFactory): @@ -39,3 +39,11 @@ class Meta: official_name = "Test official name" startup_idea = "Test startup idea" is_deleted = False + + +class AdminCategoryFactory(factory.django.DjangoModelFactory): + class Meta: + model = Category + django_get_or_create = ("name",) + + name = factory.Sequence(lambda n: f"category {n + 1}") diff --git a/BackEnd/administration/filters.py b/BackEnd/administration/filters.py index 103cdecba..2ed3235e6 100644 --- a/BackEnd/administration/filters.py +++ b/BackEnd/administration/filters.py @@ -11,13 +11,13 @@ class UsersFilter(FilterSet): /?ordering=id asc or /?ordering=-id desc """ - id = filters.CharFilter(lookup_expr="icontains") + id = filters.NumberFilter(lookup_expr="contains") surname = filters.CharFilter(lookup_expr="icontains") email = filters.CharFilter(lookup_expr="icontains") - is_active = filters.CharFilter(lookup_expr="icontains") - is_staff = filters.CharFilter(lookup_expr="icontains") - is_superuser = filters.CharFilter(lookup_expr="icontains") - is_deleted = filters.BooleanFilter(method="filter_is_deleted") + is_active = filters.BooleanFilter() + is_staff = filters.BooleanFilter() + is_superuser = filters.BooleanFilter() + is_deleted = filters.BooleanFilter(method="is_deleted_filter") company_name = filters.CharFilter( field_name="profile__name", lookup_expr="icontains" ) @@ -25,7 +25,7 @@ class UsersFilter(FilterSet): field_name="profile__created_at", ) - def filter_is_deleted(self, queryset, name, value): + def is_deleted_filter(self, queryset, name, value): if value: queryset = queryset.filter(email__startswith="is_deleted_") return queryset @@ -76,3 +76,14 @@ class ProfilesFilter(FilterSet): ("updated_at", "updated_at"), ) ) + + +class CategoriesFilter(FilterSet): + id = filters.NumberFilter(lookup_expr="contains") + name = filters.CharFilter(lookup_expr="icontains") + ordering = filters.OrderingFilter( + fields=( + ("id", "id"), + ("name", "name"), + ) + ) diff --git a/BackEnd/administration/serializers.py b/BackEnd/administration/serializers.py index 2ed389035..0d9347925 100644 --- a/BackEnd/administration/serializers.py +++ b/BackEnd/administration/serializers.py @@ -1,10 +1,12 @@ from django.contrib.auth import get_user_model from rest_framework import serializers +from rest_framework.validators import UniqueValidator from utils.administration.feedback_category import FeedbackCategory from authentication.models import CustomUser from profiles.models import ( Profile, Region, Activity, + Category, ) from utils.administration.profiles.profiles import format_company_type, format_representative, \ format_business_entity @@ -242,3 +244,31 @@ class FeedbackSerializer(serializers.Serializer): required=True, error_messages={"required": "Please select a category."}, ) + + +class CategoriesListSerializer(serializers.ModelSerializer): + name = serializers.CharField( + validators=[ + UniqueValidator( + queryset=Category.objects.all(), + message="Category with this name already exists.", + ) + ] + ) + + class Meta: + model = Category + fields = ("id", "name") + + +class CategoryDetailSerializer(serializers.ModelSerializer): + class Meta: + model = Category + fields = ("name",) + + +class StatisticsSerializer(serializers.Serializer): + companies_count = serializers.IntegerField() + investors_count = serializers.IntegerField() + startups_count = serializers.IntegerField() + blocked_companies_count = serializers.IntegerField() diff --git a/BackEnd/administration/tests/test_admin_category.py b/BackEnd/administration/tests/test_admin_category.py new file mode 100644 index 000000000..987813e86 --- /dev/null +++ b/BackEnd/administration/tests/test_admin_category.py @@ -0,0 +1,100 @@ +from rest_framework import status +from rest_framework.test import APITestCase + +from administration.factories import ( + AdminCategoryFactory, + AdminUserFactory, +) + +from utils.dump_response import dump # noqa +from utils.unittest_helper import AnyInt, AnyStr + + +class TestAdminCategoryAPIUserNotStaff(APITestCase): + def setUp(self): + self.user = AdminUserFactory( + is_staff=False, + is_active=True, + ) + self.category = AdminCategoryFactory() + + def test_get_category_users_not_staff(self): + self.client.force_authenticate(self.user) + response = self.client.get(path="/api/admin/categories/") + self.assertEqual(status.HTTP_403_FORBIDDEN, response.status_code) + + def test_get_category_id_users_not_staff(self): + self.client.force_authenticate(self.user) + response = self.client.get(path="/api/admin/categories/1/") + self.assertEqual(status.HTTP_403_FORBIDDEN, response.status_code) + + +class TestAdminCategoryAPIUserStaff(APITestCase): + def setUp(self): + self.user = AdminUserFactory( + is_staff=True, + is_active=True, + ) + self.categories = AdminCategoryFactory.create_batch(2) + + def test_get_categories_users_staff(self): + self.client.force_authenticate(self.user) + response = self.client.get(path="/api/admin/categories/") + data = [ + { + "id": AnyInt(), + "name": AnyStr(), + }, + { + "id": AnyInt(), + "name": AnyStr(), + }, + ] + self.assertEqual(data, response.json()["results"]) + self.assertEqual(status.HTTP_200_OK, response.status_code) + + def test_get_categories_id_users_staff(self): + self.client.force_authenticate(self.user) + response = self.client.get(path="/api/admin/categories/3/") + data = {"name": AnyStr()} + self.assertEqual(status.HTTP_200_OK, response.status_code) + self.assertEqual(data, response.json()) + + def test_post_new_categories_users_staff(self): + self.client.force_authenticate(self.user) + data = {"name": "some category"} + response = self.client.post(path="/api/admin/categories/", data=data) + self.assertEqual(status.HTTP_201_CREATED, response.status_code) + self.assertEqual(response.json()["name"], data["name"]) + self.assertIn("id", response.json()) + + def test_post_unique_categories_users_staff(self): + self.existing_name = AdminCategoryFactory.create( + name="existing category" + ) + self.client.force_authenticate(self.user) + data = {"name": "existing category"} + message = "Category with this name already exists." + response = self.client.post(path="/api/admin/categories/", data=data) + self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code) + self.assertEqual(response.json()["name"][0], message) + + def test_put_categories_id_users_staff(self): + self.category = AdminCategoryFactory.create() + self.client.force_authenticate(self.user) + data = {"name": "category 1212"} + response = self.client.put( + path=f"/api/admin/categories/{self.category.id}/", data=data + ) + self.assertEqual(status.HTTP_200_OK, response.status_code) + self.assertEqual(response.json()["name"], data["name"]) + + def test_patch_categories_id_users_staff(self): + self.category = AdminCategoryFactory.create() + self.client.force_authenticate(self.user) + data = {"name": "category 77"} + response = self.client.patch( + path=f"/api/admin/categories/{self.category.id}/", data=data + ) + self.assertEqual(status.HTTP_200_OK, response.status_code) + self.assertEqual(response.json()["name"], data["name"]) diff --git a/BackEnd/administration/tests/test_admin_profiles.py b/BackEnd/administration/tests/test_admin_profiles.py index c4d4fd36d..24bb4a27b 100644 --- a/BackEnd/administration/tests/test_admin_profiles.py +++ b/BackEnd/administration/tests/test_admin_profiles.py @@ -6,6 +6,7 @@ AdminProfileFactory, ) +from utils.unittest_helper import AnyInt, AnyStr from utils.dump_response import dump # noqa @@ -52,14 +53,14 @@ def test_get_profiles_structure_json(self): response = self.client.get(path="/api/admin/profiles/") data = [ { - "id": 4, + "id": AnyInt(), "name": "Test person", "is_registered": True, "is_startup": True, "person": { - "name": "Test person 7", - "surname": "Test person 7 surname", - "email": "test7@test.com", + "name": AnyStr(), + "surname": AnyStr(), + "email": AnyStr(), "is_active": True, "is_staff": True, "is_superuser": False, @@ -89,9 +90,9 @@ def test_get_profile_id_authenticated(self): "categories": [], "activities": [], "person": { - "name": "Test person 1", - "surname": "Test person 1 surname", - "email": "test1@test.com", + "name": AnyStr(), + "surname": AnyStr(), + "email": AnyStr(), "is_active": True, "is_staff": True, "is_superuser": False, diff --git a/BackEnd/administration/tests/test_profile_statistics.py b/BackEnd/administration/tests/test_profile_statistics.py new file mode 100644 index 000000000..ced04d307 --- /dev/null +++ b/BackEnd/administration/tests/test_profile_statistics.py @@ -0,0 +1,49 @@ +from rest_framework.test import APITestCase +from rest_framework import status + +from administration.factories import AdminUserFactory, AdminProfileFactory + + +class TestProfileStatisticsStaff(APITestCase): + def setUp(self): + self.user = AdminUserFactory() + self.client.force_authenticate(self.user) + self.test_startup_user = AdminUserFactory(is_staff=False) + self.test_investor_user = AdminUserFactory(is_staff=False) + self.test_blocked_company_user = AdminUserFactory(is_staff=False) + self.startup_company = AdminProfileFactory( + person_id=self.test_startup_user.id, is_registered=False + ) + self.investor_company = AdminProfileFactory( + person_id=self.test_investor_user.id, is_startup=False + ) + self.blocked_company = AdminProfileFactory( + person_id=self.test_blocked_company_user.id, status="blocked" + ) + + def test_get_profile_statistics(self): + response = self.client.get("/api/admin/profiles/statistics/") + data = { + "companies_count": 3, + "investors_count": 2, + "startups_count": 2, + "blocked_companies_count": 1, + } + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, data) + + +class TestProfileStatisticsNotStaff(APITestCase): + def setUp(self): + self.user = AdminUserFactory(is_staff=False) + self.client.force_authenticate(self.user) + + def test_get_profile_statistics(self): + response = self.client.get("/api/admin/profiles/statistics/") + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + +class TestProfileStatisticsUnauthorized(APITestCase): + def test_get_profile_statistics(self): + response = self.client.get("/api/admin/profiles/statistics/") + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) diff --git a/BackEnd/administration/urls.py b/BackEnd/administration/urls.py index b71b96953..83e19e306 100644 --- a/BackEnd/administration/urls.py +++ b/BackEnd/administration/urls.py @@ -4,12 +4,15 @@ ContactsView, ProfilesListView, ProfileDetailView, + ProfileStatisticsView, UsersListView, UserDetailView, AutoModerationHoursView, ModerationEmailView, FeedbackView, CreateAdminUserView, + CategoriesListView, + CategoryDetailView, SendMessageView, ) @@ -19,6 +22,11 @@ path("users/", UsersListView.as_view(), name="users-list"), path("users//", UserDetailView.as_view(), name="user-detail"), path("profiles/", ProfilesListView.as_view(), name="profile-list"), + path( + "profiles/statistics/", + ProfileStatisticsView.as_view(), + name="profile-statistics", + ), path("profiles//", ProfileDetailView.as_view(), name="profile-detail"), path( "automoderation/", @@ -29,6 +37,16 @@ path("contacts/", ContactsView.as_view(), name="contacts"), path("feedback/", FeedbackView.as_view(), name="feedback"), path("admin_create/", CreateAdminUserView.as_view(), name="admin-create"), + path( + "categories/", + CategoriesListView.as_view(), + name="categories", + ), + path( + "categories//", + CategoryDetailView.as_view(), + name="category_detail", + ), path( "users//send_message/", SendMessageView.as_view(), diff --git a/BackEnd/administration/views.py b/BackEnd/administration/views.py index a10870a60..67bdb4c8e 100644 --- a/BackEnd/administration/views.py +++ b/BackEnd/administration/views.py @@ -2,6 +2,9 @@ from django.db.models import F, Value, CharField from django.http import JsonResponse from django.views import View +from django.db.models import Count, Q +from django_filters.rest_framework import DjangoFilterBackend + from drf_spectacular.utils import ( extend_schema, OpenApiExample, @@ -9,6 +12,8 @@ ) from rest_framework.generics import ( ListAPIView, + ListCreateAPIView, + RetrieveAPIView, RetrieveUpdateDestroyAPIView, RetrieveUpdateAPIView, CreateAPIView, @@ -23,18 +28,22 @@ AdminUserDetailSerializer, AutoModerationHoursSerializer, ModerationEmailSerializer, + CategoriesListSerializer, + CategoryDetailSerializer, + StatisticsSerializer, ) from administration.pagination import ListPagination from administration.models import AutoModeration, ModerationEmail from authentication.models import CustomUser -from profiles.models import Profile +from profiles.models import Profile, Category from .permissions import IsStaffUser, IsStaffUserOrReadOnly, IsSuperUser from .serializers import FeedbackSerializer from utils.administration.send_email_feedback import send_email_feedback -from utils.administration.send_email_notification import send_email_to_user from django_filters.rest_framework import DjangoFilterBackend -from .filters import UsersFilter, ProfilesFilter +from .filters import UsersFilter, ProfilesFilter, CategoriesFilter +from utils.administration.send_email_notification import send_email_to_user +from .filters import UsersFilter class UsersListView(ListAPIView): @@ -115,6 +124,23 @@ class ProfileDetailView(RetrieveUpdateDestroyAPIView): ) +class ProfileStatisticsView(RetrieveAPIView): + """ + Count of companies + """ + + permission_classes = [IsStaffUser] + serializer_class = StatisticsSerializer + + def get_object(self): + return Profile.objects.aggregate( + companies_count=Count("pk"), + investors_count=Count("pk", filter=Q(is_registered=True)), + startups_count=Count("pk", filter=Q(is_startup=True)), + blocked_companies_count=Count("pk", filter=Q(status="blocked")), + ) + + @extend_schema( request=AutoModerationHoursSerializer, responses={ @@ -212,6 +238,38 @@ def perform_create(self, serializer): send_email_feedback(email, message, category) +class CategoriesListView(ListCreateAPIView): + """ + Manage categories + ### Query Parameters: + - **id** / **name** + + ### Ordering: + - Use the `ordering` parameter to sort the results. + - Example: `/categories/?ordering=id` (ascending by ID) or `/categories/?ordering=-id` (descending by ID). + + ### Filters: + - Filters are applied using `DjangoFilterBackend`. All the above query parameters are supported for filtering. + """ + + permission_classes = [IsStaffUser] + serializer_class = CategoriesListSerializer + filter_backends = [DjangoFilterBackend] + filterset_class = CategoriesFilter + pagination_class = ListPagination + queryset = Category.objects.all().order_by("id") + + +class CategoryDetailView(RetrieveUpdateAPIView): + """ + Modify activity category + """ + + permission_classes = [IsStaffUser] + serializer_class = CategoryDetailSerializer + queryset = Category.objects.all() + + class SendMessageView(CreateAPIView): """ API endpoint for sending a custom email message to a specific user. diff --git a/BackEnd/profiles/migrations/0022_alter_profile_name_alter_profile_official_name.py b/BackEnd/profiles/migrations/0022_alter_profile_name_alter_profile_official_name.py new file mode 100644 index 000000000..c51fe4669 --- /dev/null +++ b/BackEnd/profiles/migrations/0022_alter_profile_name_alter_profile_official_name.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.3 on 2024-12-08 17:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("profiles", "0021_savedcompany_is_updated"), + ] + + operations = [ + migrations.AlterField( + model_name="profile", + name="name", + field=models.CharField(default=None, max_length=45, null=True), + ), + migrations.AlterField( + model_name="profile", + name="official_name", + field=models.CharField(default=None, max_length=255, null=True), + ), + ] diff --git a/BackEnd/profiles/models.py b/BackEnd/profiles/models.py index 6614f187c..216c516b2 100644 --- a/BackEnd/profiles/models.py +++ b/BackEnd/profiles/models.py @@ -40,7 +40,7 @@ class Profile(models.Model): id = models.AutoField(primary_key=True) - name = models.CharField(max_length=100, default=None, null=True) + name = models.CharField(max_length=45, default=None, null=True) is_registered = models.BooleanField(default=None, null=True) is_startup = models.BooleanField(default=None, null=True) is_fop = models.BooleanField(default=False) @@ -51,9 +51,7 @@ class Profile(models.Model): person = models.OneToOneField(CustomUser, on_delete=models.PROTECT) person_position = models.CharField(max_length=50, blank=True, default="") - official_name = models.CharField( - max_length=255, null=True, blank=True, default=None - ) + official_name = models.CharField(max_length=255, null=True, default=None) regions = models.ManyToManyField("Region", blank=True) common_info = models.TextField( diff --git a/BackEnd/profiles/serializers.py b/BackEnd/profiles/serializers.py index 078edaf14..d6f30e7f2 100644 --- a/BackEnd/profiles/serializers.py +++ b/BackEnd/profiles/serializers.py @@ -319,6 +319,7 @@ def validate(self, data): edrpou = data.get("edrpou", self.instance.edrpou) rnokpp = data.get("rnokpp", self.instance.rnokpp) is_fop = data.get("is_fop", self.instance.is_fop) + name = data.get("name", self.instance.name) if rnokpp and not is_fop: raise serializers.ValidationError( { @@ -331,11 +332,16 @@ def validate(self, data): "is_fop": "For the EDRPOU field filled out, FOP must be set to False" } ) + if name and len(name) > 45: + raise serializers.ValidationError( + {"name": "Ensure this field has no more than 45 characters."} + ) + return data # set optional unique fields to None if they are empty def to_internal_value(self, data): - fields_to_check = ["official_name", "edrpou", "rnokpp"] + fields_to_check = ["edrpou", "rnokpp"] for field in fields_to_check: if data.get(field) == "": data[field] = None diff --git a/BackEnd/profiles/tests/test_crud_profile.py b/BackEnd/profiles/tests/test_crud_profile.py index b0d7a63d3..9252c7be5 100644 --- a/BackEnd/profiles/tests/test_crud_profile.py +++ b/BackEnd/profiles/tests/test_crud_profile.py @@ -533,8 +533,11 @@ def test_partial_update_profile_official_name_empty_value(self): data={"official_name": ""}, format="json", ) - self.assertEqual(status.HTTP_200_OK, response.status_code) - self.assertIsNone(response.data.get("official_name")) + self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code) + self.assertEqual( + response.json(), + {"official_name": ["This field may not be blank."]}, + ) def test_partial_update_profile_edrpou_empty_value(self): self.client.force_authenticate(self.user) @@ -563,11 +566,8 @@ def test_partial_update_profile_rnokpp_empty_value(self): self.assertIsNone(response.data.get("rnokpp")) # updating fields when another instance with empty fields already exists in db - def test_partial_update_profile_fields_with_empty_values( - self, - ): + def test_partial_update_profile_fields_with_empty_values(self): ProfileStartupFactory.create( - official_name=None, edrpou=None, ) self.client.force_authenticate(self.user) @@ -576,21 +576,16 @@ def test_partial_update_profile_fields_with_empty_values( path="/api/profiles/{profile_id}".format( profile_id=self.profile.id ), - data={"official_name": "", "edrpou": ""}, + data={"edrpou": ""}, format="json", ) self.assertEqual(status.HTTP_200_OK, response.status_code) + self.profile.refresh_from_db() + self.assertIsNone(self.profile.edrpou) + response = self.client.get(path="/api/profiles/") self.assertEqual(2, response.data["total_items"]) - self.assertTrue( - all( - [ - item.get("official_name") is None - for item in response.data["results"] - ] - ) - ) self.assertTrue( all( [ @@ -1154,3 +1149,41 @@ def test_create_profile_wrong_data(self): }, ) self.assertEqual(400, response.status_code) + + def test_partial_update_profile_name_exceeds_character_limit(self): + self.client.force_authenticate(self.user) + + long_name = "a" * 46 + response = self.client.patch( + path="/api/profiles/{profile_id}".format( + profile_id=self.profile.id + ), + data={"name": long_name}, + format="json", + ) + + self.assertEqual(status.HTTP_400_BAD_REQUEST, response.status_code) + + self.assertEqual( + response.json(), + {"name": ["Ensure this field has no more than 45 characters."]}, + ) + + def test_partial_update_profile_name_within_limit(self): + self.client.force_authenticate(self.user) + + valid_name = "Valid Company Name" + response = self.client.patch( + path="/api/profiles/{profile_id}".format( + profile_id=self.profile.id + ), + data={"name": valid_name}, + format="json", + ) + + self.assertEqual(status.HTTP_200_OK, response.status_code) + + self.assertEqual(response.data.get("name"), valid_name) + + self.profile.refresh_from_db() + self.assertEqual(self.profile.name, valid_name) diff --git a/BackEnd/profiles/tests/test_ordering.py b/BackEnd/profiles/tests/test_ordering.py index 0b410f0fe..6bc36057a 100644 --- a/BackEnd/profiles/tests/test_ordering.py +++ b/BackEnd/profiles/tests/test_ordering.py @@ -156,8 +156,8 @@ def test_get_list_of_profiles_completeness_order_asc(self): self.assertEqual(status.HTTP_200_OK, response.status_code) self.assertEqual( [ - "Bakery", "Winery", + "Bakery", "Delivery company", "Catering service", "Retail company", diff --git a/BackEnd/search/tests/test_advanced_search.py b/BackEnd/search/tests/test_advanced_search.py index 0014ada1e..7e2912210 100644 --- a/BackEnd/search/tests/test_advanced_search.py +++ b/BackEnd/search/tests/test_advanced_search.py @@ -57,7 +57,9 @@ def setUp(self) -> None: def test_get_search_in_all_companies_official_order_by_name(self): response = self.client.get(path="/api/search/advanced?search=official") - names_from_response = [prof["name"] for prof in response.data] + names_from_response = [ + prof["name"] for prof in response.data["results"] + ] self.assertEqual(200, response.status_code) self.assertEqual( names_from_response, @@ -74,24 +76,26 @@ def test_get_search_in_all_companies_official_order_by_name(self): def test_get_search_name_service_info_lower_case(self): response = self.client.get(path="/api/search/advanced?search=kyiv") self.assertEqual(200, response.status_code) - self.assertEqual(2, len(response.data)) + self.assertEqual(2, response.data["total_items"]) def test_get_search_name_service_info_authorized(self): self.client.force_authenticate(self.user) response = self.client.get(path="/api/search/advanced?search=kyiv") self.assertEqual(200, response.status_code) - self.assertEqual(2, len(response.data)) + self.assertEqual(2, response.data["total_items"]) def test_get_search_name_service_info_upper_case(self): response = self.client.get(path="/api/search/advanced?search=KYIV") self.assertEqual(200, response.status_code) - self.assertEqual(2, len(response.data)) + self.assertEqual(2, response.data["total_items"]) def test_get_search_parcial_item(self): response = self.client.get(path="/api/search/advanced?search=ch") - names_from_response = [prof["name"] for prof in response.data] + names_from_response = [ + prof["name"] for prof in response.data["results"] + ] self.assertEqual(200, response.status_code) - self.assertEqual(3, len(response.data)) + self.assertEqual(3, response.data["total_items"]) self.assertEqual( names_from_response, ["Charkiv", "Chernigiv", "Dnipro"] ) @@ -99,27 +103,31 @@ def test_get_search_parcial_item(self): def test_get_search_not_exist(self): response = self.client.get(path="/api/search/advanced?search=QQQ") self.assertEqual(200, response.status_code) - self.assertEqual([], response.json()) + self.assertEqual([], response.data["results"]) def test_get_search_name_product_info(self): response = self.client.get(path="/api/search/advanced?search=dnipro") self.assertEqual(200, response.status_code) - self.assertEqual(2, len(response.data)) + self.assertEqual(2, response.data["total_items"]) def test_get_search_name_service_product_common_info(self): response = self.client.get(path="/api/search/advanced?search=KYIV") - names_from_response = [prof["name"] for prof in response.data] + names_from_response = [ + prof["name"] for prof in response.data["results"] + ] self.assertEqual(200, response.status_code) - self.assertEqual(2, len(response.data)) + self.assertEqual(2, response.data["total_items"]) self.assertEqual(names_from_response, ["Kryvyi Rig", "Kyiv"]) def test_get_search_devide_item(self): response = self.client.get( path="/api/search/advanced?search=product info" ) - names_from_response = [prof["name"] for prof in response.data] + names_from_response = [ + prof["name"] for prof in response.data["results"] + ] self.assertEqual(200, response.status_code) - self.assertEqual(1, len(response.data)) + self.assertEqual(1, response.data["total_items"]) self.assertEqual(names_from_response, ["Kryvyi Rig"]) def test_advanced_search_serializer_fields(self): @@ -144,4 +152,4 @@ def test_advanced_search_serializer_fields(self): "person": self.company_kyiv.person_id, } ] - self.assertEqual(expected_result, response.json()) + self.assertEqual(expected_result, response.data["results"]) diff --git a/BackEnd/search/tests/test_search_by_name_region.py b/BackEnd/search/tests/test_search_by_name_region.py index b279afff2..42d7a4b89 100644 --- a/BackEnd/search/tests/test_search_by_name_region.py +++ b/BackEnd/search/tests/test_search_by_name_region.py @@ -40,12 +40,12 @@ def setUp(self) -> None: def test_get_profile_filtered_by_name_unauthorized(self): response = self.client.get(path="/api/search/?name=Kyivbud") self.assertEqual(200, response.status_code) - self.assertEqual(1, len(response.data)) + self.assertEqual(1, response.data["total_items"]) def test_get_profile_filtered_by_region_eng_unauthorized(self): response = self.client.get(path="/api/search/?regions_eng=Dnipro") self.assertEqual(200, response.status_code) - self.assertEqual(1, len(response.data)) + self.assertEqual(1, response.data["total_items"]) self.assertEqual( [ { @@ -69,14 +69,14 @@ def test_get_profile_filtered_by_region_eng_unauthorized(self): "is_saved": False, } ], - response.json(), + response.data["results"], ) def test_get_profile_filtered_by_region_eng_authorized(self): self.client.force_authenticate(self.user) response = self.client.get(path="/api/search/?regions_eng=Dnipro") self.assertEqual(200, response.status_code) - self.assertEqual(1, len(response.data)) + self.assertEqual(1, response.data["total_items"]) self.assertEqual( [ { @@ -100,14 +100,14 @@ def test_get_profile_filtered_by_region_eng_authorized(self): "is_saved": False, } ], - response.json(), + response.data["results"], ) def test_get_profile_filtered_by_region_ukr_authorized(self): self.client.force_authenticate(self.user) response = self.client.get(path="/api/search/?regions_ukr=Дніпро") self.assertEqual(200, response.status_code) - self.assertEqual(1, len(response.data)) + self.assertEqual(1, response.data["total_items"]) self.assertEqual( [ { @@ -131,13 +131,13 @@ def test_get_profile_filtered_by_region_ukr_authorized(self): "is_saved": False, } ], - response.json(), + response.data["results"], ) def test_get_profile_filtered_by_region_ukr_unauthorized(self): response = self.client.get(path="/api/search/?regions_ukr=Дніпро") self.assertEqual(200, response.status_code) - self.assertEqual(1, len(response.data)) + self.assertEqual(1, response.data["total_items"]) self.assertEqual( [ { @@ -161,14 +161,14 @@ def test_get_profile_filtered_by_region_ukr_unauthorized(self): "is_saved": False, } ], - response.json(), + response.data["results"], ) def test_get_profile_filtered_by_region_ukr_partial_authorized(self): self.client.force_authenticate(self.user) response = self.client.get(path="/api/search/?regions_ukr=Дніп") self.assertEqual(200, response.status_code) - self.assertEqual(1, len(response.data)) + self.assertEqual(1, response.data["total_items"]) self.assertEqual( [ { @@ -192,13 +192,13 @@ def test_get_profile_filtered_by_region_ukr_partial_authorized(self): "is_saved": False, } ], - response.json(), + response.data["results"], ) def test_get_profile_filtered_by_region_ukr_partial_unauthorized(self): response = self.client.get(path="/api/search/?regions_ukr=Дніпро") self.assertEqual(200, response.status_code) - self.assertEqual(1, len(response.data)) + self.assertEqual(1, response.data["total_items"]) self.assertEqual( [ { @@ -222,14 +222,14 @@ def test_get_profile_filtered_by_region_ukr_partial_unauthorized(self): "is_saved": False, } ], - response.json(), + response.data["results"], ) def test_get_profile_filtered_by_region_ukr_lower_case_authorized(self): self.client.force_authenticate(self.user) response = self.client.get(path="/api/search/?regions_ukr=дніпро") self.assertEqual(200, response.status_code) - self.assertEqual(1, len(response.data)) + self.assertEqual(1, response.data["total_items"]) self.assertEqual( [ { @@ -253,40 +253,40 @@ def test_get_profile_filtered_by_region_ukr_lower_case_authorized(self): "is_saved": False, } ], - response.json(), + response.data["results"], ) def test_get_profile_filtered_by_region_ukr_not_exist_authorized(self): self.client.force_authenticate(self.user) response = self.client.get(path="/api/search/?regions_ukr=УУ") self.assertEqual(200, response.status_code) - self.assertEqual(0, len(response.data)) - self.assertEqual([], response.json()) + self.assertEqual(0, response.data["total_items"]) + self.assertEqual([], response.data["results"]) def test_get_profile_filtered_by_region_eng_not_exist_authorized(self): self.client.force_authenticate(self.user) response = self.client.get(path="/api/search/?regions_eng=WWWW") self.assertEqual(200, response.status_code) - self.assertEqual(0, len(response.data)) - self.assertEqual([], response.json()) + self.assertEqual(0, response.data["total_items"]) + self.assertEqual([], response.data["results"]) def test_get_profile_filtered_by_region_ukr_not_exist_unauthorized(self): response = self.client.get(path="/api/search/?regions_ukr=УУ") self.assertEqual(200, response.status_code) - self.assertEqual(0, len(response.data)) - self.assertEqual([], response.json()) + self.assertEqual(0, response.data["total_items"]) + self.assertEqual([], response.data["results"]) def test_get_profile_filtered_by_region_eng_not_exist_unauthorized(self): response = self.client.get(path="/api/search/?regions_eng=WWWW") self.assertEqual(200, response.status_code) - self.assertEqual(0, len(response.data)) - self.assertEqual([], response.json()) + self.assertEqual(0, response.data["total_items"]) + self.assertEqual([], response.data["results"]) def test_get_profile_filtered_by_name_authorized(self): self.client.force_authenticate(self.user) response = self.client.get(path="/api/search/?name=Kyivbud") self.assertEqual(200, response.status_code) - self.assertEqual(1, len(response.data)) + self.assertEqual(1, response.data["total_items"]) self.assertEqual( [ { @@ -310,39 +310,39 @@ def test_get_profile_filtered_by_name_authorized(self): "is_saved": False, } ], - response.json(), + response.data["results"], ) def test_get_profiles_without_filter_unauthorized(self): response = self.client.get(path="/api/search/") self.assertEqual(200, response.status_code) - self.assertEqual(4, len(response.data)) + self.assertEqual(4, response.data["total_items"]) def test_get_profiles_without_filter_authorized(self): self.client.force_authenticate(self.user) response = self.client.get(path="/api/search/") self.assertEqual(200, response.status_code) - self.assertEqual(4, len(response.data)) + self.assertEqual(4, response.data["total_items"]) def test_get_profiles_filter_wrong_name_authorized(self): self.client.force_authenticate(self.user) response = self.client.get(path="/api/search/?name=Chernovcimilk") self.assertEqual(200, response.status_code) - self.assertEqual(0, len(response.data)) - self.assertEqual([], response.json()) + self.assertEqual(0, response.data["total_items"]) + self.assertEqual([], response.data["results"]) def test_get_profiles_filter_wrong_name_unauthorized(self): response = self.client.get(path="/api/search/?name=Chernovcimilk") self.assertEqual(200, response.status_code) - self.assertEqual(0, len(response.data)) - self.assertEqual([], response.json()) + self.assertEqual(0, response.data["total_items"]) + self.assertEqual([], response.data["results"]) def test_get_profile_filtered_by_name_and_region_eng_unauthorized(self): response = self.client.get( path="/api/search/?name=Kyivbud®ions_eng=Kyiv" ) self.assertEqual(200, response.status_code) - self.assertEqual(1, len(response.data)) + self.assertEqual(1, response.data["total_items"]) self.assertEqual( [ { @@ -366,7 +366,7 @@ def test_get_profile_filtered_by_name_and_region_eng_unauthorized(self): "is_saved": False, } ], - response.json(), + response.data["results"], ) def test_get_profile_filtered_by_name_and_region_eng_authorized(self): @@ -375,7 +375,7 @@ def test_get_profile_filtered_by_name_and_region_eng_authorized(self): path="/api/search/?name=Kyivbud®ion=Kyiv" ) self.assertEqual(200, response.status_code) - self.assertEqual(1, len(response.data)) + self.assertEqual(1, response.data["total_items"]) self.assertEqual( [ { @@ -399,7 +399,7 @@ def test_get_profile_filtered_by_name_and_region_eng_authorized(self): "is_saved": False, } ], - response.json(), + response.data["results"], ) def test_get_profile_filtered_by_wrong_name_and_region_ukr_authorized( @@ -410,16 +410,16 @@ def test_get_profile_filtered_by_wrong_name_and_region_ukr_authorized( path="/api/search/?name=Pizza®ions_ukr=Київ" ) self.assertEqual(200, response.status_code) - self.assertEqual(0, len(response.data)) - self.assertEqual([], response.json()) + self.assertEqual(0, response.data["total_items"]) + self.assertEqual([], response.data["results"]) def test_get_profile_filtered_by_wrong_name_and_region_ukr_unauthorized( self, ): response = self.client.get(path="/api/search/?name=Pizza®ion=Київ") self.assertEqual(200, response.status_code) - self.assertEqual(0, len(response.data)) - self.assertEqual([], response.json()) + self.assertEqual(0, response.data["total_items"]) + self.assertEqual([], response.data["results"]) def test_get_profile_filtered_by_name_and_wrong_region_eng_authorized( self, @@ -429,16 +429,16 @@ def test_get_profile_filtered_by_name_and_wrong_region_eng_authorized( path="/api/search/?name=Kyivbud®ions_eng=Ghernigiv" ) self.assertEqual(status.HTTP_200_OK, response.status_code) - self.assertEqual(0, len(response.data)) - self.assertEqual([], response.json()) + self.assertEqual(0, response.data["total_items"]) + self.assertEqual([], response.data["results"]) def test_get_profile_filtered_by_name_and_wrong_region_unauthorized(self): response = self.client.get( path="/api/search/?name=Kyivbud®ions_eng=Ghernigiv" ) self.assertEqual(200, response.status_code) - self.assertEqual(0, len(response.data)) - self.assertEqual([], response.json()) + self.assertEqual(0, response.data["total_items"]) + self.assertEqual([], response.data["results"]) def test_get_profile_filtered_by_wrong_name_and_wrong_region_authorized( self, @@ -448,8 +448,8 @@ def test_get_profile_filtered_by_wrong_name_and_wrong_region_authorized( path="/api/search/?name=Pizza®ions_eng=London" ) self.assertEqual(200, response.status_code) - self.assertEqual(0, len(response.data)) - self.assertEqual([], response.json()) + self.assertEqual(0, response.data["total_items"]) + self.assertEqual([], response.data["results"]) def test_get_profile_filtered_by_wrong_name_and_wrong_region_unauthorized( self, @@ -458,25 +458,25 @@ def test_get_profile_filtered_by_wrong_name_and_wrong_region_unauthorized( path="/api/search/?name=Pizza®ions_ukr=Лондон" ) self.assertEqual(200, response.status_code) - self.assertEqual(0, len(response.data)) - self.assertEqual([], response.json()) + self.assertEqual(0, response.data["total_items"]) + self.assertEqual([], response.data["results"]) def test_get_profile_filtered_by_region_eng_partially_authorized(self): self.client.force_authenticate(self.user) response = self.client.get(path="/api/search/?regions_eng=Dnip") self.assertEqual(200, response.status_code) - self.assertEqual(1, len(response.data)) + self.assertEqual(1, response.data["total_items"]) def test_get_profile_filtered_by_region_eng_partially_unauthorized(self): response = self.client.get(path="/api/search/?regions_eng=Dnip") self.assertEqual(200, response.status_code) - self.assertEqual(1, len(response.data)) + self.assertEqual(1, response.data["total_items"]) def test_get_profile_filtered_by_name_partially_authorized(self): self.client.force_authenticate(self.user) response = self.client.get(path="/api/search/?name=Kyiv") self.assertEqual(200, response.status_code) - self.assertEqual(1, len(response.data)) + self.assertEqual(1, response.data["total_items"]) self.assertEqual( [ { @@ -500,13 +500,13 @@ def test_get_profile_filtered_by_name_partially_authorized(self): "is_saved": False, } ], - response.json(), + response.data["results"], ) def test_get_profile_filtered_by_name_partially_unauthorized(self): response = self.client.get(path="/api/search/?name=Kyiv") self.assertEqual(200, response.status_code) - self.assertEqual(1, len(response.data)) + self.assertEqual(1, response.data["total_items"]) self.assertEqual( [ { @@ -530,7 +530,7 @@ def test_get_profile_filtered_by_name_partially_unauthorized(self): "is_saved": False, } ], - response.json(), + response.data["results"], ) def test_get_profile_filtered_by_name_and_region_eng_partialyy_authorized( @@ -541,7 +541,7 @@ def test_get_profile_filtered_by_name_and_region_eng_partialyy_authorized( path="/api/search/?name=Kyiv®ions_eng=Ky" ) self.assertEqual(200, response.status_code) - self.assertEqual(1, len(response.data)) + self.assertEqual(1, response.data["total_items"]) self.assertEqual( [ { @@ -565,7 +565,7 @@ def test_get_profile_filtered_by_name_and_region_eng_partialyy_authorized( "is_saved": False, } ], - response.json(), + response.data["results"], ) def test_get_profile_filtered_by_wrong_name_and_wrong_region_partially_authorized( @@ -576,8 +576,8 @@ def test_get_profile_filtered_by_wrong_name_and_wrong_region_partially_authorize path="/api/search/?name=Kyivf®ions_eng=Kyif" ) self.assertEqual(200, response.status_code) - self.assertEqual(0, len(response.data)) - self.assertEqual([], response.json()) + self.assertEqual(0, response.data["total_items"]) + self.assertEqual([], response.data["results"]) def test_get_profile_ordered_by_name_asc_unauthorized( self, @@ -667,8 +667,8 @@ def test_get_profile_ordered_by_name_asc_unauthorized( response = self.client.get(path="/api/search/?ordering=name") self.assertEqual(200, response.status_code) - self.assertEqual(4, len(response.data)) - self.assertEqual(ordered_response_data, response.json()) + self.assertEqual(4, response.data["total_items"]) + self.assertEqual(ordered_response_data, response.data["results"]) def test_get_profile_ordered_by_name_asc_authorized( self, @@ -759,8 +759,8 @@ def test_get_profile_ordered_by_name_asc_authorized( response = self.client.get(path="/api/search/?ordering=name") self.assertEqual(200, response.status_code) - self.assertEqual(4, len(response.data)) - self.assertEqual(ordered_response_data, response.json()) + self.assertEqual(4, response.data["total_items"]) + self.assertEqual(ordered_response_data, response.data["results"]) def test_get_profile_ordered_by_name_dsc_unauthorized( self, @@ -850,8 +850,8 @@ def test_get_profile_ordered_by_name_dsc_unauthorized( response = self.client.get(path="/api/search/?ordering=-name") self.assertEqual(200, response.status_code) - self.assertEqual(4, len(response.data)) - self.assertEqual(ordered_response_data, response.json()) + self.assertEqual(4, response.data["total_items"]) + self.assertEqual(ordered_response_data, response.data["results"]) def test_get_profile_ordered_by_name_dsc_authorized( self, @@ -942,5 +942,5 @@ def test_get_profile_ordered_by_name_dsc_authorized( response = self.client.get(path="/api/search/?ordering=-name") self.assertEqual(200, response.status_code) - self.assertEqual(4, len(response.data)) - self.assertEqual(ordered_response_data, response.json()) + self.assertEqual(4, response.data["total_items"]) + self.assertEqual(ordered_response_data, response.data["results"]) diff --git a/BackEnd/search/views.py b/BackEnd/search/views.py index 7667f807b..9d05e777a 100644 --- a/BackEnd/search/views.py +++ b/BackEnd/search/views.py @@ -2,14 +2,17 @@ import django_filters from rest_framework import filters +from forum.pagination import ForumPagination from profiles.models import Profile, SavedCompany from .serializers import CompanySerializers, CompanyAdvancedSerializers from search.filters import CompanyFilter class SearchCompanyView(ListAPIView): - queryset = Profile.objects.active_only().prefetch_related( - "regions", "categories", "activities" + queryset = ( + Profile.objects.active_only() + .prefetch_related("regions", "categories", "activities") + .order_by("id") ) serializer_class = CompanySerializers filter_backends = [ @@ -17,6 +20,7 @@ class SearchCompanyView(ListAPIView): filters.OrderingFilter, ] filterset_class = CompanyFilter + pagination_class = ForumPagination ordering_fields = ["name"] def get_serializer_context(self): @@ -32,10 +36,13 @@ def get_serializer_context(self): class AdvancedSearchView(ListAPIView): - queryset = Profile.objects.active_only().prefetch_related( - "regions", "categories", "activities" + queryset = ( + Profile.objects.active_only() + .prefetch_related("regions", "categories", "activities") + .order_by("id") ) serializer_class = CompanyAdvancedSerializers + pagination_class = ForumPagination filter_backends = [ filters.SearchFilter, filters.OrderingFilter, diff --git a/FrontEnd/src/components/Footer/FooterComponents/FooterNavigation.jsx b/FrontEnd/src/components/Footer/FooterComponents/FooterNavigation.jsx index ae64cf63f..851860d9a 100644 --- a/FrontEnd/src/components/Footer/FooterComponents/FooterNavigation.jsx +++ b/FrontEnd/src/components/Footer/FooterComponents/FooterNavigation.jsx @@ -1,77 +1,76 @@ -import { HashLink } from 'react-router-hash-link'; import { Link } from 'react-router-dom'; - import css from './FooterNavigation.module.css'; - const PAGE_NAVIGATION_LINKS = [ { title: 'Компанії', - link: '/profiles/companies', + params: { companyType: 'companies' }, }, { title: 'Стартапи', - link: '/profiles/startups', + params: { companyType: 'startups' }, }, ]; + const SERVICES_LINKS = [ { title: 'Виробники', - link: '/profiles/producers', + params: { activity: 'producers' }, }, { title: 'Імпортери', - link: '/profiles/importers', + params: { activity: 'importers' }, }, { title: 'Роздрібні мережі', - link: '/profiles/retailers', + params: { activity: 'retailers' }, }, { title: 'HORECA', - link: '/profiles/horeca', + params: { activity: 'horeca' }, }, { title: 'Інші послуги', - link: '/profiles/other-services', + params: { activity: 'other-services' }, }, ]; function FooterNavigation() { - + const generateQueryString = (params) => + new URLSearchParams(params).toString(); return (

Підприємства

- {PAGE_NAVIGATION_LINKS.map((element) => ( - - {element.title} - - ))} + {PAGE_NAVIGATION_LINKS.map((element) => ( + + {element.title} + + ))}

Сектори

-
- {SERVICES_LINKS.map((element) => ( - - {element.title} - - ))} +
+ {SERVICES_LINKS.map((element) => ( + + {element.title} + + ))}
); } -export default FooterNavigation; \ No newline at end of file +export default FooterNavigation; diff --git a/FrontEnd/src/components/Loader/Loader.module.css b/FrontEnd/src/components/Loader/Loader.module.css index c24dedb7a..f8ed507fc 100644 --- a/FrontEnd/src/components/Loader/Loader.module.css +++ b/FrontEnd/src/components/Loader/Loader.module.css @@ -1,4 +1,5 @@ .loader__container { padding-top: 30%; min-height: var(--min-height-block-main); + width: 100%; } diff --git a/FrontEnd/src/global.css b/FrontEnd/src/global.css index 7c91798b9..e2f4be0ba 100644 --- a/FrontEnd/src/global.css +++ b/FrontEnd/src/global.css @@ -159,4 +159,9 @@ --cookie-border-color: #e9e9e9; --cookie-overlay-color: rgba(0, 0, 0, 0.5); --cookie-shadow-color: rgba(0, 0, 0, 0.15); + + /* Tooltip profile page */ + --tooltip-bg-color: #f0f0f0; + --tooltip-border-radius: 50%; + --tooltip-font-color: #292E32; } diff --git a/FrontEnd/src/pages/AdminPage/AutoApproveDelay/AutoApproveDelay.jsx b/FrontEnd/src/pages/AdminPage/AutoApproveDelay/AutoApproveDelay.jsx index ab7239bfc..ca777f45d 100644 --- a/FrontEnd/src/pages/AdminPage/AutoApproveDelay/AutoApproveDelay.jsx +++ b/FrontEnd/src/pages/AdminPage/AutoApproveDelay/AutoApproveDelay.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { toast } from 'react-toastify'; import { Tooltip } from 'antd'; import useSWR from 'swr'; @@ -12,15 +12,10 @@ const AutoApproveDelay = () => { } }); const url = `${process.env.REACT_APP_BASE_API_URL}/api/admin/automoderation/`; - const { data, mutate } = useSWR(url, fetcher); const [delay, setDelay] = useState(''); const [error, setError] = useState(null); - - useEffect(() => { - if (data && data.auto_moderation_hours) { - setDelay(data.auto_moderation_hours); - } - }, [data]); + const { data, mutate } = useSWR(url, fetcher, + { onSuccess: (data) => setDelay(data.auto_moderation_hours) }); const handleInputChange = (e) => { const value = Number(e.target.value); @@ -31,6 +26,11 @@ const AutoApproveDelay = () => { } }; + const handleCancel = () => { + setDelay(data?.auto_moderation_hours); + setError(null); + }; + const handleSubmit = () => { !error && axios.put(`${process.env.REACT_APP_BASE_API_URL}/api/admin/automoderation/`, { 'auto_moderation_hours': delay }) .then(() => { toast.success('Зміни успішно застосовано.'); mutate({ ...data, auto_moderation_hours: delay }); }) @@ -38,15 +38,21 @@ const AutoApproveDelay = () => { }; return (
+

Налаштуйте час, після якого зміни будуть автоматично затверджені у разі відсутності дій з боку модератора.

+ - + {error &&

{error}

} - +
+ + +
); diff --git a/FrontEnd/src/pages/AdminPage/AutoApproveDelay/AutoApproveDelay.module.css b/FrontEnd/src/pages/AdminPage/AutoApproveDelay/AutoApproveDelay.module.css index fbcd3693f..e7402c473 100644 --- a/FrontEnd/src/pages/AdminPage/AutoApproveDelay/AutoApproveDelay.module.css +++ b/FrontEnd/src/pages/AdminPage/AutoApproveDelay/AutoApproveDelay.module.css @@ -5,26 +5,55 @@ gap: 10px; } +.autoapprove-section__head { + font-size: 14px; + font-weight: normal; + margin: 20px 0; +} + +.autoapprove_label { + font-size: 14px; + margin: 20px 0; +} + +.autoapprove_label::before { + content: "*"; + color: #FA1B1F; + padding: 0 2px; +} + .autoapprove-input { - width: 150px; - height: 20px; + width: 269px; + height: 32px; + box-sizing: border-box; + padding-left: 4px; + font-size: 16px; + border: 1px solid var(--border-input__admin-panel); + border-radius: 2px; } -.save-button { + +.autoapprove-input:focus { + outline: none; +} + +.buttons-group { + display: flex; + flex-direction: row; + justify-content: space-between; + width: 269px; + margin-top: 24px; +} + +.button { display: flex; padding: 5px 15px; justify-content: center; align-items: center; gap: 10px; - width: 150px; - + width: 124px; border-radius: 4px; - border: 1px solid #1F9A7C; - background: #1F9A7C; - - box-shadow: 0px 2px 0px 0px rgba(0, 0, 0, 0.04); - - /* Button Text */ - color: #FFF; + background: #B4D27A; + color: black; text-align: center; font-feature-settings: 'calt' off; text-decoration: none; @@ -37,8 +66,14 @@ cursor: pointer; } +.cancel-button { + background-color: white; + border: 1px solid #B4D27A; +} + .error-message { color: rgb(212, 21, 21); font-family: var(--font-main); font-size: 14px; + max-width: 267px; } \ No newline at end of file diff --git a/FrontEnd/src/pages/AdminPage/Menu/Menu.jsx b/FrontEnd/src/pages/AdminPage/Menu/Menu.jsx index 70cd9910d..8bb537a71 100644 --- a/FrontEnd/src/pages/AdminPage/Menu/Menu.jsx +++ b/FrontEnd/src/pages/AdminPage/Menu/Menu.jsx @@ -1,5 +1,6 @@ -import { NavLink } from 'react-router-dom'; +import { NavLink, useLocation } from 'react-router-dom'; import { useAuth } from '../../../hooks'; +import ProfilesStatistics from '../UserProfilesTable/ProfilesStatistics'; import css from './Menu.module.css'; const MENU = [ @@ -25,6 +26,7 @@ const MENU = [ }, ]; + const SUPERUSER_MENU = [ { id: 'am5', @@ -41,6 +43,7 @@ const SUPERUSER_MENU = [ function Menu() { const { isSuperUser } = useAuth(); + const location = useLocation(); return (
@@ -53,6 +56,7 @@ function Menu() { key={element.id} to={element.link}>{element.title} ))} + {location.pathname === '/customadmin/profiles/' && }
); } diff --git a/FrontEnd/src/pages/AdminPage/Menu/Menu.module.css b/FrontEnd/src/pages/AdminPage/Menu/Menu.module.css index 93170f61f..a191141bf 100644 --- a/FrontEnd/src/pages/AdminPage/Menu/Menu.module.css +++ b/FrontEnd/src/pages/AdminPage/Menu/Menu.module.css @@ -22,3 +22,4 @@ .menu-section-element__active { font-weight: 600; } + diff --git a/FrontEnd/src/pages/AdminPage/UserProfilesTable/ProfilesStatistics.jsx b/FrontEnd/src/pages/AdminPage/UserProfilesTable/ProfilesStatistics.jsx new file mode 100644 index 000000000..7354b456a --- /dev/null +++ b/FrontEnd/src/pages/AdminPage/UserProfilesTable/ProfilesStatistics.jsx @@ -0,0 +1,69 @@ +import axios from 'axios'; +import useSWR from 'swr'; +import { Descriptions } from 'antd'; +import Loader from '../../../components/Loader/Loader'; +import css from './ProfilesStatistics.module.css'; + +async function fetcher(url) { + const response = await axios.get(url); + return response.data; +} + +function ProfilesStatistics() { + const baseUrl = process.env.REACT_APP_BASE_API_URL; + const url = `${baseUrl}/api/admin/profiles/statistics/`; + const { data: statistics, error, isLoading } = useSWR(url, fetcher); + + const items = statistics + ? [ + { + key: '1', + label: 'Кількість зареєстрованих компаній', + children: statistics.companies_count, + }, + { + key: '2', + label: 'Кількість Інвесторів', + children: statistics.investors_count, + }, + { + key: '3', + label: 'Кількість Cтратапів', + children: statistics.startups_count, + }, + { + key: '4', + label: 'Кількість заблокованих компаній', + children: statistics.blocked_companies_count, + }, + ] + : []; + + return isLoading ? ( +
+ +
+ ) : error ? ( +
Не вдалося отримати статистику компаній
+ ) : ( + ({ + ...item, + label: ( + {item.label} + ), + children: ( + + {item.children} + + ), + }))} + /> + ); +} + +export default ProfilesStatistics; diff --git a/FrontEnd/src/pages/AdminPage/UserProfilesTable/ProfilesStatistics.module.css b/FrontEnd/src/pages/AdminPage/UserProfilesTable/ProfilesStatistics.module.css new file mode 100644 index 000000000..4e2dcbb6e --- /dev/null +++ b/FrontEnd/src/pages/AdminPage/UserProfilesTable/ProfilesStatistics.module.css @@ -0,0 +1,24 @@ +.description-item-label { + max-width: 150px; +} + +.description-item-content { + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.error { + color: #f5222d; + background-color: #fff1f0; + border: 1px solid #ffa39e; + padding: 10px; + border-radius: 4px; + margin-top: 10px; +} + +.loader-container { + width: 200px; + height: 300px; +} \ No newline at end of file diff --git a/FrontEnd/src/pages/AdminPage/UserProfilesTable/UserTable.jsx b/FrontEnd/src/pages/AdminPage/UserProfilesTable/UserTable.jsx index 760185e14..0a1d4838f 100644 --- a/FrontEnd/src/pages/AdminPage/UserProfilesTable/UserTable.jsx +++ b/FrontEnd/src/pages/AdminPage/UserProfilesTable/UserTable.jsx @@ -21,8 +21,7 @@ function UserTable() { const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); const [sortInfo, setSortInfo] = useState({ field: null, order: null }); const [statusFilters, setStatusFilters] = useState([]); - const [searchText, setSearchText] = useState(''); - const [searchedColumn, setSearchedColumn] = useState(''); + const [searchParams, setSearchParams] = useState({}); useEffect(() => { const queryParams = new URLSearchParams(location.search); @@ -32,7 +31,8 @@ function UserTable() { const ordering = sortInfo.field ? `&ordering=${sortInfo.order === 'ascend' ? sortInfo.field : '-' + sortInfo.field}` : ''; const filtering = statusFilters ? statusFilters.map((filter) => `&${filter}=true`).join('') : ''; - const url = `${process.env.REACT_APP_BASE_API_URL}/api/admin/users?page=${currentPage}&page_size=${pageSize}${ordering}${filtering}`; + const query = new URLSearchParams(searchParams).toString(); + const url = `${process.env.REACT_APP_BASE_API_URL}/api/admin/users?page=${currentPage}&page_size=${pageSize}${ordering}${filtering}&${query}`; async function fetcher(url) { const response = await axios.get(url); @@ -78,59 +78,77 @@ function UserTable() { ); }; + const handleSearch = (selectedKeys, confirm, dataIndex) => { confirm(); - setSearchText(selectedKeys[0]); - setSearchedColumn(dataIndex); - }; - const handleReset = (clearFilters) => { + + if (selectedKeys[0]) { + setSearchParams((prev) => ({ ...prev, [dataIndex]: selectedKeys[0] })); + } else { + setSearchParams((prev) => { + const updatedParams = { ...prev }; + delete updatedParams[dataIndex]; + return updatedParams; + }); + } + }; + + const handleReset = (clearFilters, confirm, dataIndex) => { clearFilters(); - setSearchText(''); + setSearchParams((prev) => { + const updatedParams = { ...prev }; + delete updatedParams[dataIndex]; + return updatedParams; + }); + confirm(); }; + const getColumnSearchProps = (dataIndex) => ({ - filterDropdown: ({setSelectedKeys, selectedKeys, confirm, clearFilters}) => ( -
- setSelectedKeys(e.target.value ? [e.target.value]: [])} - onPressEnter={() => handleSearch(selectedKeys, confirm, dataIndex)} - className={css['antInput']} - > - - - + filterDropdown: ({ setSelectedKeys, selectedKeys, confirm, clearFilters }) => ( +
+ setSelectedKeys(e.target.value ? [e.target.value] : [])} + onPressEnter={() => handleSearch(selectedKeys, confirm, dataIndex)} + className={css['antInput']} + /> + + +
- ), - filterIcon: (filtered) => , - onFilter: (value, record) => - record[dataIndex]?.toString().toLowerCase().includes(value.toLowerCase()), - render: (text) => - searchedColumn === dataIndex ? ( + ), + filterIcon: (filtered) => ( + + ), + render: (text) => { + const searchValue = searchParams[dataIndex] || ''; + return searchValue ? ( ) : ( - text - ), + text + ); + } }); const renderStatusTags = (status) => { @@ -232,8 +250,7 @@ function UserTable() { key: 'registration_date', sorter: true, sortOrder: sortInfo.field === 'registration_date' ? sortInfo.order : null, - sortIcon: ({ sortOrder }) => getSortIcon(sortOrder), - ...getColumnSearchProps('registration_date'), + sortIcon: ({ sortOrder }) => getSortIcon(sortOrder) }, { title: 'Дії', diff --git a/FrontEnd/src/pages/AdminPage/UserProfilesTable/UserTable.module.scss b/FrontEnd/src/pages/AdminPage/UserProfilesTable/UserTable.module.scss index 13882c5bd..70d197be0 100644 --- a/FrontEnd/src/pages/AdminPage/UserProfilesTable/UserTable.module.scss +++ b/FrontEnd/src/pages/AdminPage/UserProfilesTable/UserTable.module.scss @@ -42,5 +42,5 @@ .filteredIcon { font-size: 25px; font-weight: bold; - color: #46c636; + color: #1f9a7c; } \ No newline at end of file diff --git a/FrontEnd/src/pages/CustomThemes/customAdminTheme.js b/FrontEnd/src/pages/CustomThemes/customAdminTheme.js index 1d661b3e9..630dca75f 100644 --- a/FrontEnd/src/pages/CustomThemes/customAdminTheme.js +++ b/FrontEnd/src/pages/CustomThemes/customAdminTheme.js @@ -5,7 +5,7 @@ const customAdminTheme = { colorPrimaryHover: '#0b6c61', fontWeight: 600, contentFontSize: 16, - fontFamilyCode: 'Inter', + fontFamilyCode: 'Geologica', colorLink: '#1f9a7c', colorLinkActive: '#1f9a7c', colorLinkHover: '#0b6c61' @@ -21,8 +21,21 @@ const customAdminTheme = { Table: { lineWidth: 3, colorPrimary: '#1f9a7c' + }, + Carousel: { + colorBgContainer: '#B4D27A', + dotActiveWidth: 32, + dotWidth: 32, + dotHeight: 6, + lineHeight: 1, } }, + token: { + fontFamily: 'Geologica', + colorPrimary: '#1f9a7c', + colorPrimaryHover: '#1f9a7c', + controlOutline: 'rgba(31, 154, 124, 0.4)' + } }; export default customAdminTheme; diff --git a/FrontEnd/src/pages/CustomThemes/customTheme.js b/FrontEnd/src/pages/CustomThemes/customTheme.js index 94ba1b9c0..652dca796 100644 --- a/FrontEnd/src/pages/CustomThemes/customTheme.js +++ b/FrontEnd/src/pages/CustomThemes/customTheme.js @@ -50,6 +50,7 @@ const customTheme = { }, List: { fontFamily: 'Geologica', + emptyTextPadding: 0, } }, token: { diff --git a/FrontEnd/src/pages/LandingPage/AboutSection/Cards/AboutCards.css b/FrontEnd/src/pages/LandingPage/AboutSection/Cards/AboutCards.css index f4891898c..c0c186144 100644 --- a/FrontEnd/src/pages/LandingPage/AboutSection/Cards/AboutCards.css +++ b/FrontEnd/src/pages/LandingPage/AboutSection/Cards/AboutCards.css @@ -1,132 +1,112 @@ .about-us-cards-main-container { - display: flex; - justify-content: center; - background: #fff; - width: 100vw; + display: flex; + justify-content: center; + background: #fff; + width: 100vw; } .about-us-cards-section { - display: flex; - flex-direction: column; - align-items: center; - max-width: 1260px; - width: 100%; - height: 448px; - padding: 80px 104px; + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; + width: 375px; + padding: 40px 16px; + box-sizing: border-box; } .about-us-cards-section__header-text { - font-family: Geologica, sans-serif; - font-size: 40px; - font-weight: bold; - line-height: 48px; - color: #232424; - margin-bottom: 40px; - text-align: center; -} - -.about-us-cards-section__cards { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 32px; - + font-family: var(--font-main); + font-size: 24px; + font-weight: bold; + line-height: 28px; + color: #232424; + text-align: center; } .about-us-cards-section__card { - display: flex; - flex-direction: column; - justify-content: space-between; - background-color: #f9f5ec; - border-radius: 12px; - box-shadow: 0 0 2px 0 rgba(65, 64, 69, 0.2); - transition: transform 0.2s ease; + display: flex; + flex-direction: column; + background-color: #f9f5ec; + border-radius: 6px; + box-shadow: 0 0 2px 0 rgba(65, 64, 69, 0.2); + box-sizing: border-box; + width: 345px; + height: 132px; + padding: 16px 24px; + gap: 16px; + transition: transform 0.2s ease; } .about-us-cards-section__cards-content { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 16px; - width: 367px; - height: 80px; - padding: 32px 23px 52px 23px; + display: flex; + flex-direction: column; + gap: 16px; } .about-us-cards-section__cards-content__text-header { - font-family: Geologica, sans-serif; - font-size: 20px; - font-weight: bold; - line-height: 1.2; - color: #232424; + font-family: var(--font-main); + font-size: 20px; + font-weight: bold; + line-height: 1.2; + color: #232424; } .about-us-cards-section__cards-content__text { - font-family: Geologica, sans-serif; - font-size: 16px; - line-height: 1.25; - color: #292e32; + font-family: var(--font-main); + font-size: 16px; + line-height: 1.25; + color: #292e32; } -@media (min-width: 768px) and (max-width: 1511px) { - .about-us-cards-section__cards { - grid-template-columns: repeat(2, 1fr); - justify-content: center; - box-sizing: border-box; - - } - - .about-us-cards-section { - height: auto; - padding: 48px 24px; - - } - - .about-us-cards-section__cards-content { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 16px; - width: 296px; - height: 100px; - padding: 32px 24px; - - } - - .about-us-cards-section__card { - width: 343px; - - } +@media only screen and (min-width: 768px) { + .about-us-cards-section { + width: 768px; + padding: 48px 24px; + } + .about-us-cards-section__header-text { + font-size: 40px; + line-height: 48px; + letter-spacing: 0.4px; + } + + .about-us-cards-section__card { + min-width: 344px; + height: 164px; + padding: 32px 24px; + } } -@media (max-width: 767px) { - .about-us-cards-section { - max-width: 345px; - height: auto; - padding: 24px 16px; - - } - - .about-us-cards-section__card { - width: 343px; +@media only screen and (min-width: 1200px) { + .about-us-cards-section { + width: 1200px; + padding: 64px 32px; - } + } - .about-us-cards-section__cards-content { - width: 296px; - height: 100px; - padding: 16px 24px; - - } - - .about-us-cards-section__header-text { - font-size: 24px; - margin-bottom: 24px; + .about-us-cards-section__card { + min-width: 357px; + height: 164px; + } +} - } +@media only screen and (min-width: 1512px) { + .about-us-cards-section { + width: 1512px; + padding: 80px 104px; + } - .about-us-cards-section__cards { - grid-template-columns: 1fr; + .about-us-cards-section__cards { + display: flex; + flex-wrap: wrap; + width: 1304px; + justify-content: space-between; + gap: 32px; + } - } + .about-us-cards-section__card { + min-width: 413px; + height: 164px; + } } diff --git a/FrontEnd/src/pages/LandingPage/AboutSection/Cards/AboutCards.jsx b/FrontEnd/src/pages/LandingPage/AboutSection/Cards/AboutCards.jsx index 41b4ae043..72880cb67 100644 --- a/FrontEnd/src/pages/LandingPage/AboutSection/Cards/AboutCards.jsx +++ b/FrontEnd/src/pages/LandingPage/AboutSection/Cards/AboutCards.jsx @@ -1,24 +1,31 @@ -import './AboutCards.css'; +import { Row, Col } from 'antd'; import AboutUsText from './text.js'; +import './AboutCards.css'; const AboutSectionCards = () => { return (

{AboutUsText.title}

-
+ {AboutUsText.content.map((card, index) => ( -
-
+ +

{card.title}

{card.text}

-
+ ))} -
+
); }; -export default AboutSectionCards; \ No newline at end of file +export default AboutSectionCards; diff --git a/FrontEnd/src/pages/ProfileList/ProfileList.jsx b/FrontEnd/src/pages/ProfileList/ProfileList.jsx index ade6f33df..a53206a6a 100644 --- a/FrontEnd/src/pages/ProfileList/ProfileList.jsx +++ b/FrontEnd/src/pages/ProfileList/ProfileList.jsx @@ -5,6 +5,7 @@ import CompanyCard from '../../components/CompanyCard/CompanyCard'; export default function ProfileList({ isAuthorized, + emptyText, current, items, profiles, @@ -34,7 +35,7 @@ export default function ProfileList({ grid={{ justify: 'center', align: 'stretch', - gutter: [32, 24], + gutter: [24, 24], xs: 1, md: 2, xl: 3, @@ -53,9 +54,13 @@ export default function ProfileList({ }} dataSource={profiles} split={false} - locale={{emptyText: 'Жодна компанія не відповідає обраному фільтру.'}} + locale={{emptyText: emptyText}} renderItem={(item) => ( - + { + setActiveTab(searchParams.get('companyType') || 'all'); + setActiveBtn(searchParams.get('activity') || 'all'); + }, [searchParams]); + const changeCompanies = (companyId, saved) => { setProfiles((prevProfiles) => prevProfiles.map((profile) => @@ -160,14 +165,6 @@ export default function ProfileListPage({ isAuthorized, isSaved }) { updateQueryParams(page); }; - const handleActiveTab = (activeTab) => { - setActiveTab(activeTab); - }; - - const handleActiveBtn = (activeBtn) => { - setActiveBtn(activeBtn); - }; - return (
{error && error.response.status !==401 ? ( @@ -188,7 +185,7 @@ export default function ProfileListPage({ isAuthorized, isSaved }) { className={activeTab === item.key ? css['company-list__tabs--element--active'] : css['company-list__tabs--element']} - onClick={() => (handleFilters(item.value, activity), handleActiveTab(item.key))} + onClick={() => (handleFilters(item.value, activity))} > {item.title === 'Усі підприємства' ? linkText : item.title} @@ -208,7 +205,7 @@ export default function ProfileListPage({ isAuthorized, isSaved }) { className={activeBtn === item.key ? css['company-list__btns--element--active'] : css['company-list__btns--element']} - onClick={() => (handleFilters(companyType, item.value), handleActiveBtn(item.key))} + onClick={() => (handleFilters(companyType, item.value))} > {item.title} @@ -222,7 +219,7 @@ export default function ProfileListPage({ isAuthorized, isSaved }) {
{ let isValid = true; const newFormState = {}; for (const key in profile) { - if ( - key in ERRORS && - (!profile[key] || - (Array.isArray(profile[key]) && profile[key]?.length === 0)) - ) { - isValid = false; - newFormState[key] = { - error: true, - message: 'Обов’язкове поле', - }; - } else { - newFormState[key] = { - error: false, - message: '', - }; + if (key in ERRORS) { + if (!profile[key] || (Array.isArray(profile[key]) && profile[key]?.length === 0)) { + isValid = false; + newFormState[key] = { + error: true, + message: 'Це поле є обов’язковим для заповнення.', + }; + } else if (key === 'name' && profile[key].length < 2) { + isValid = false; + newFormState[key] = { + error: true, + message: 'Введіть від 2 до 45 символів.', + }; + } else if (key === 'official_name' && profile[key].length < 2) { + isValid = false; + newFormState[key] = { + error: true, + message: 'Введіть від 2 до 200 символів.', + }; + } else { + newFormState[key] = { + error: false, + message: '', + }; + } } } setFormStateErr({ ...formStateErr, ...newFormState }); @@ -161,31 +176,54 @@ const GeneralInfo = (props) => { const onUpdateField = (e) => { const { value: fieldValue, name: fieldName } = e.target; - const symbolCount = fieldValue.replace(/[\s]/g, '')?.length; - setFormStateErr({ - ...formStateErr, + const symbolCount = fieldValue.replace(/[\s]/g, '').length; + const fieldValidationConfig = { + name: { + minLength: 2, + maxLength: 45, + errorMessage: 'Введіть від 2 до 45 символів.', + }, + official_name: { + minLength: 2, + maxLength: 200, + errorMessage: 'Введіть від 2 до 200 символів.', + }, + }; + setFormStateErr((prev) => ({ + ...prev, [fieldName]: { error: false, message: '' }, - }); - if (fieldName === 'name' && symbolCount < 2) { - setFormStateErr({ - ...formStateErr, - [fieldName]: { error: true, message: 'Введіть від 2 до 100 символів' }, - }); - } - if (fieldName === 'official_name' && symbolCount !== 0 && symbolCount < 2) { - setFormStateErr({ - ...formStateErr, - [fieldName]: { error: true, message: 'Введіть від 2 до 200 символів' }, - }); + })); + if (fieldValidationConfig[fieldName]) { + const { minLength, errorMessage } = fieldValidationConfig[fieldName]; + if (symbolCount !== 0 && symbolCount < minLength) { + setFormStateErr((prev) => ({ + ...prev, + [fieldName]: { + error: true, + message: errorMessage, + }, + })); + } } setProfile((prevState) => { return { ...prevState, [fieldName]: fieldValue }; }); }; + const onBlurHandler = (e) => { const { value: rawFieldValue, name: fieldName } = e.target; const fieldValue = rawFieldValue.replace(/\s{2,}/g, ' ').trim(); + const requiredFields = ['official_name', 'name']; + if (requiredFields.includes(fieldName) && !fieldValue) { + setFormStateErr((prev) => ({ + ...prev, + [fieldName]: { + error: true, + message: 'Це поле є обов’язковим для заповнення.', + }, + })); + } setProfile((prevState) => { return { ...prevState, [fieldName]: fieldValue }; }); @@ -402,7 +440,6 @@ const GeneralInfo = (props) => { } } }; - return (

Загальна інформація

@@ -415,11 +452,19 @@ const GeneralInfo = (props) => { noValidate >
-
- +
+ + {LABELS.name} + + ? + + + } updateHandler={onUpdateField} onBlur={onBlurHandler} error={ @@ -429,8 +474,9 @@ const GeneralInfo = (props) => { } requiredField={true} value={profile.name} - maxLength={100} + maxLength={45} /> +
{ ? formStateErr['official_name']['message'] : null } + requiredField={true} maxLength={200} />
diff --git a/FrontEnd/src/pages/SearchPage/Search.jsx b/FrontEnd/src/pages/SearchPage/Search.jsx index a5fdab3f1..055583d47 100644 --- a/FrontEnd/src/pages/SearchPage/Search.jsx +++ b/FrontEnd/src/pages/SearchPage/Search.jsx @@ -1,35 +1,33 @@ -import axios from 'axios'; import { useState, useEffect } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; -import SearchResults from './SearchField/SearchResults'; -import link_to_left from './img/link_to_left.svg'; -import link_to_right from './img/link_to_right.svg'; -import styles from './search.module.scss'; +import { useSearchParams } from 'react-router-dom'; import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import axios from 'axios'; import useSWR from 'swr'; -const ITEMS_PER_PAGE = 100; +import useWindowWidth from '../../hooks/useWindowWidth'; +import Loader from '../../components/Loader/Loader'; +import ProfileList from '../ProfileList/ProfileList'; +import styles from './Search.module.scss'; export function Search({ isAuthorized }) { const [searchResults, setSearchResults] = useState([]); - const [searchPerformed, setSearchPerformed] = useState(false); - - const location = useLocation(); - const navigate = useNavigate(); - const searchParams = new URLSearchParams(location.search); + const [searchParams, setSearchParams] = useSearchParams(); const searchTerm = searchParams.get('name'); const pageNumber = Number(searchParams.get('page')) || 1; + const [currentPage, setCurrentPage] = useState(pageNumber); + const [pageSize, setPageSize] = useState(16); const servedAddress = process.env.REACT_APP_BASE_API_URL; - const searchUrl = 'search'; - const fetcher = async (url) => { - const response = await axios.get(url); - setSearchResults(response.data); - }; + async function fetcher(url) { + return axios.get(url) + .then(res => res.data); + } - const { data: companylist, error } = useSWR( - `${servedAddress}/api/search/?name=${searchTerm}&ordering=name`, - fetcher + const { data: companylist, isLoading } = useSWR( + `${servedAddress}/api/search/?name=${searchTerm}&ordering=name&page_size=${pageSize}&page=${currentPage}`, + fetcher, + {onSuccess: (data) => setSearchResults(data.results)} ); const changeCompanies = (id, isSaved) => { @@ -42,116 +40,90 @@ export function Search({ isAuthorized }) { setSearchResults(newCompanies); }; - useEffect(() => { - if (searchTerm) { - setSearchPerformed(true); - } - }, [searchTerm, servedAddress, searchUrl, companylist]); + const windowWidth = useWindowWidth(); useEffect(() => { - setCurrentPage(pageNumber); - }, [pageNumber]); - - const [currentPage, setCurrentPage] = useState(pageNumber); - const totalItems = searchResults.length; - const totalPages = Math.ceil(totalItems / ITEMS_PER_PAGE); - - const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; - const endIndex = startIndex + ITEMS_PER_PAGE; - const displayedResults = searchResults.slice(startIndex, endIndex); + if (windowWidth < 768) { + setPageSize(4); + } else if (windowWidth >= 768 && windowWidth < 1200) { + setPageSize(16); + } else if (windowWidth >= 1200 && windowWidth < 1512) { + setPageSize(12); + } else if (windowWidth >= 1512) { + setPageSize(16); + } + }, [windowWidth]); - const handlePageChange = (newPage) => { - setCurrentPage(newPage); + const updateQueryParams = (newPage) => { searchParams.set('page', newPage); - navigate(`?${searchParams.toString()}`); + setSearchParams(searchParams); }; + const handlePageChange = (page) => { + setCurrentPage(page); + updateQueryParams(page); + }; + + useEffect(() => { + if (companylist?.total_items === 0) { + setCurrentPage(1); + searchParams.delete('page'); + setSearchParams(searchParams); + } else { + const totalPages = Math.ceil(companylist?.total_items / pageSize); + if (currentPage > totalPages) { + setCurrentPage(totalPages); + updateQueryParams(totalPages); + } + } + }, [companylist?.total_items, pageSize, currentPage]); + return ( -
-
-
- {searchResults && ( -
-

- Результати пошуку - - {` “${searchTerm}” `} - - : {searchResults.length > 0 ? searchResults.length : 0} -

-
- )} -
- {!error && searchResults.length > 0 && ( +
+
+ {isLoading ? + : ( <> - -
- {totalPages > 1 && ( -
- {currentPage > 1 && ( - - )} - {currentPage > 1 && ( - <> - - {currentPage > 2 && ( - ... - )} - - )} - {Array.from({ length: totalPages }, (_, i) => { - if ( - i === 2 || - i === totalPages || - (i >= currentPage - 1 && i <= currentPage) - ) { - return ( - - ); - } - return null; - })} - {currentPage < totalPages - 1 && ( - <> - {currentPage < totalPages - 1 && ( - ... - )} - - - )} - {currentPage < totalPages && ( - - )} +
+ {searchResults && ( +
+

+ Результати пошуку + + {` “${searchTerm}” `} + + : {companylist?.total_items || 0} +

)}
+
+ +
)}
- {searchResults.length === 0 && -
-

+ {companylist?.total_items === 0 && +

+

Пошук не дав результатів

-
} +
+ }
); } diff --git a/FrontEnd/src/pages/SearchPage/search.module.scss b/FrontEnd/src/pages/SearchPage/Search.module.scss similarity index 53% rename from FrontEnd/src/pages/SearchPage/search.module.scss rename to FrontEnd/src/pages/SearchPage/Search.module.scss index 7ed26a476..737cb8cf9 100644 --- a/FrontEnd/src/pages/SearchPage/search.module.scss +++ b/FrontEnd/src/pages/SearchPage/Search.module.scss @@ -1,46 +1,44 @@ -.main_block_outer { +.search-page__outer { display: flex; flex-direction: column; position: relative; } -.new-companies-result_pages { - padding: 40px; +.search-page { + display: flex; + align-self: center; + align-items: flex-start; + flex-direction: column; + background: var(--new-companies-background-color); + box-sizing: border-box; + width: 375px; + padding: 40px 15px; + gap: 32px; } -.new-companies-main__error { - width: 100%; - background-color: rgba(249, 245, 236, 1); - display: flex; - justify-content: center; - align-items: center; - min-height: 288px; - flex-grow: 1; +.search-page__empty { + height: 102px; } -.new-companies-main { +.search-list__content--items { display: flex; - align-self: center; - align-items: flex-start; flex-direction: column; - background: var(--new-companies-background-color); - min-width: 375px; + width: 100%; } -.new-companies-search_count { +.search-page__results-count { display: flex; flex-direction: row; - padding: 40px 16px; } -.search_field_entered_value { +.search-field__entered-value { color: #707070; font: var(--font-main); font-weight: 300; font-size: 18px ; } -.search_results_text { +.search-results__text { font: normal normal bold 18px/2 var(--font-main); font-size: 18px; font-weight: 700; @@ -48,72 +46,78 @@ line-height: 22px; } -.search_result_error { +.search-page__error { width: 100%; - color: var(--search-text-color); - font: normal normal bold 24px/2 var(--font-main); - text-align: center; - white-space: nowrap; -} - -.pagination { + background-color: rgba(249, 245, 236, 1); display: flex; justify-content: center; align-items: center; - margin-top: 20px; -} - -.pagination button { - all: unset; - font-size: 16px; - font-weight: 500; - padding: 8px 16px; - margin: 0 4px; - cursor: pointer; + min-height: 288px; + flex-grow: 1; } -.pagination button.active { - background-color: transparent; +.search_result__error { + width: 100%; color: var(--search-text-color); - border: 1px solid var(--search-text-color); - border-radius: 5px; - transition: background-color 0.3s, color 0.3s; + font: normal normal bold 24px/2 var(--font-main); + text-align: center; + white-space: nowrap; } - - - @media only screen and (min-width: 768px){ - .search_results_text { + .search-results__text { font-size: 34px; line-height: 41px; } - .search_field_entered_value{ + .search-field__entered-value{ font-size: 34px; } - .search_result_error { + .search-result__error { font: normal normal bold 40px/2 var(--font-main); } - .new-companies-search_count { - padding: 40px 24px; + .search-page { + width: 768px; + padding: 40px 27px; } - .new-companies-main { - width: 768px; + .search-page__empty { + height: 121px; } } -@media only screen and (min-width: 1512px){ - .new-companies-search_count { - padding: 40px 14px; +@media only screen and (min-width: 1200px){ + .search-results__text { + font-size: 34px; + line-height: 41px; + } + + .search-field__entered-value{ + font-size: 34px; + } + + .search-result__error { + font: normal normal bold 40px/2 var(--font-main); } - .new-companies-main { - width: 1304px; + .search-page { + width: 1200px; + padding: 40px 35px; + } + + .search-page__error { + min-height: 480px; + } + +} + +@media only screen and (min-width: 1512px){ + .search-page { + width: 1512px; + padding: 40px 107px; } } diff --git a/FrontEnd/src/pages/SearchPage/SearchField/SearchResults.jsx b/FrontEnd/src/pages/SearchPage/SearchField/SearchResults.jsx deleted file mode 100644 index 8871d4fac..000000000 --- a/FrontEnd/src/pages/SearchPage/SearchField/SearchResults.jsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; -import { Row, Col} from 'antd'; -import CompanyCard from '../../../components/CompanyCard/CompanyCard'; -import styles from './SearchResults.module.css'; -import PropTypes from 'prop-types'; - -const SearchResults = ({ - results, - displayedResults, - isAuthorized, - changeCompanies, -}) => { - let error = null; - - if (results && results.error) { - error = results.error; - } - - return ( -
- {!error && ( - - {displayedResults.map((result, resultIndex) => ( - - - - ))} - - )} -
- ); -}; - -export default SearchResults; - -SearchResults.propTypes = { - results: PropTypes.array, - displayedResults: PropTypes.array, - isAuthorized: PropTypes.bool, - changeCompanies: PropTypes.func, -}; diff --git a/FrontEnd/src/pages/SearchPage/SearchField/SearchResults.module.css b/FrontEnd/src/pages/SearchPage/SearchField/SearchResults.module.css deleted file mode 100644 index ad8067862..000000000 --- a/FrontEnd/src/pages/SearchPage/SearchField/SearchResults.module.css +++ /dev/null @@ -1,18 +0,0 @@ -.new-companies-block { - width: 375px; - padding: 18px 0px 38px 0px; -} - - -@media only screen and (min-width: 768px) { - .new-companies-block { - width: 768px; - } -} - -@media only screen and (min-width: 1512px) { - .new-companies-block { - width: 1304px; - } -} - diff --git a/FrontEnd/src/pages/SearchPage/SearchField/tests/SearchResults.test.js b/FrontEnd/src/pages/SearchPage/SearchField/tests/SearchResults.test.js deleted file mode 100644 index 2ec9fbc7f..000000000 --- a/FrontEnd/src/pages/SearchPage/SearchField/tests/SearchResults.test.js +++ /dev/null @@ -1,138 +0,0 @@ -import { render, screen, cleanup } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; - -import SearchResults from '../SearchResults'; - -const mockedUser = { - email: 'test@test.com', - id: 1, - name: 'Test', - profile_id: 1, - surname: 'Test' -}; -jest.mock('../../../../hooks/useAuth', () => ({ - useAuth: () => { - return mockedUser; - }, -})); - -afterEach(cleanup); - -describe('SearchResults component unit tests', () => { - test('renders search results', () => { - Object.defineProperty(window, 'matchMedia', { - writable: true, - value: jest.fn().mockImplementation(query => ({ - matches: false, - media: query, - onchange: null, - addListener: jest.fn(), - removeListener: jest.fn(), - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - dispatchEvent: jest.fn(), - })), - }); - const results = [ - { - id: 1, - name: 'saleonline', - categories: [ - { - id: 1, - name: 'trade', - }, - { - id: 2, - name: 'transport', - }, - ], - region: 'Dnipro', - founded: 1980, - service_info: null, - address: 'Kyiv', - banner: null, - is_saved: true, - }, - { - id: 2, - name: 'sale', - categories: [ - { - id: 1, - name: 'trade', - }, - ], - region: 'Dnipro', - founded: 2007, - service_info: null, - address: 'Dnipro', - banner: null, - is_saved: false, - }, - { - id: 3, - name: 'PizzaHousesale', - categories: [], - region: 'Charkiv', - founded: null, - service_info: null, - address: 'Zaporija', - banner: null, - }, - { - id: 4, - name: 'salefruits', - categories: [], - region: null, - founded: null, - service_info: null, - address: null, - banner: null, - }, - { - id: 5, - name: 'salevegetables', - categories: [], - region: null, - founded: null, - service_info: null, - address: null, - banner: null, - }, - { - id: 6, - name: 'salebushes', - categories: [], - region: null, - founded: null, - service_info: null, - address: null, - banner: null, - }, - { - id: 7, - name: 'GGGsale', - categories: [], - region: null, - founded: null, - service_info: null, - address: null, - banner: null, - }, - ]; - - const displayedResults = results.slice(0, 7); - render( - - - - ); - const nameElement = screen.getAllByText(/sale/i, { exact: false }); - expect(nameElement).toHaveLength(results.length); - }); -}); diff --git a/FrontEnd/src/pages/SearchPage/img/link_to_left.svg b/FrontEnd/src/pages/SearchPage/img/link_to_left.svg deleted file mode 100644 index 849379319..000000000 --- a/FrontEnd/src/pages/SearchPage/img/link_to_left.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/FrontEnd/src/pages/SearchPage/img/link_to_right.svg b/FrontEnd/src/pages/SearchPage/img/link_to_right.svg deleted file mode 100644 index 450b2ecdc..000000000 --- a/FrontEnd/src/pages/SearchPage/img/link_to_right.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/FrontEnd/src/pages/SearchPage/tests/Search.test.js b/FrontEnd/src/pages/SearchPage/tests/Search.test.js index 57ad370df..15e66ec70 100644 --- a/FrontEnd/src/pages/SearchPage/tests/Search.test.js +++ b/FrontEnd/src/pages/SearchPage/tests/Search.test.js @@ -1,95 +1,246 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, cleanup, act } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; +import axios from 'axios'; import Search from '../Search'; +import ProfileList from '../../ProfileList/ProfileList'; -jest.mock('../../../hooks', () => ({ - useAuth: () => ({isStaff: false}), +const mockedUser = { + email: 'test@test.com', + id: 1, + name: 'Test', + profile_id: 1, + surname: 'Test' +}; + +jest.mock('../../../hooks/useAuth', () => ({ + useAuth: () => { + return mockedUser; + }, })); -afterEach(() => { - jest.resetAllMocks(); +jest.mock('axios'); + +beforeEach(() => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation(query => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), + }); }); +afterEach(cleanup); + describe('Search component unit tests', () => { - test('renders sale search page', () => { - render( - - - - ); + test('renders sale search page', async () => { + await act(async () => { + render( + + + + ); + }); const counterElement = screen.getByText(/Результати пошуку/i, { exact: false, }); expect(counterElement).toBeInTheDocument(); }); - test('testing search', () => { - jest.mock('axios'); - - const axios = require('axios', () => { - jest.fn().mockResolvedValue({}); - - () => { - axios.get.mockResolvedValue({ - data: [ - { - id: 1, - name: 'saleonline', - categories: [ - { - id: 1, - name: 'trade', - }, - { - id: 2, - name: 'transport', - }, - ], - region: 'Dnipro', - founded: 1980, - service_info: null, - address: 'Kyiv', - banner: null, - }, - { - id: 2, - name: 'sale', - categories: [ - { - id: 1, - name: 'trade', - }, - ], - region: 'Dnipro', - founded: 2007, - service_info: null, - address: 'Dnipro', - banner: null, - }, - { - id: 3, - name: 'PizzaHousesale', - categories: [], - region: 'Charkiv', - founded: null, - service_info: null, - address: 'Zaporija', - banner: null, - }, - ], - }); - }; + test('testing search', async () => { + axios.get.mockResolvedValue({ + data: { + results: [ + { + id: 1, + name: 'saleonline', + categories: [ + { + id: 1, + name: 'trade', + }, + { + id: 2, + name: 'transport', + }, + ], + region: 'Dnipro', + founded: 1980, + service_info: null, + address: 'Kyiv', + banner: null, + }, + { + id: 2, + name: 'sale', + categories: [ + { + id: 1, + name: 'trade', + }, + ], + region: 'Dnipro', + founded: 2007, + service_info: null, + address: 'Dnipro', + banner: null, + }, + { + id: 3, + name: 'PizzaHousesale', + categories: [], + region: 'Charkiv', + founded: null, + service_info: null, + address: 'Zaporija', + banner: null, + }, + ], + total_items: 3, + }, + }); + await act(async () => { render( ); - expect(axios.get).toBeCalled(); - expect(screen.getByText(/назад/i, { exact: false })).toBeInTheDocument(); - expect(screen.getByRole('link')).toHaveAttribute('href', '/'); - expect(screen.getByText(/3/i, { exact: false })).toBeInTheDocument(); }); + + expect(axios.get).toBeCalled(); + const links = screen.getAllByRole('link'); + links.forEach(link => {expect(link).toHaveAttribute('href');}); + expect(screen.getByText(/Результати пошуку/i, { exact: false })).toBeInTheDocument(); + expect(screen.getByText(/3/i, { exact: false })).toBeInTheDocument(); + }); +}); + + +describe('Search results unit tests', () => { + test('renders search results', async () => { + const results = [ + { + id: 1, + name: 'saleonline', + categories: [ + { + id: 1, + name: 'trade', + }, + { + id: 2, + name: 'transport', + }, + ], + region: 'Dnipro', + founded: 1980, + service_info: null, + address: 'Kyiv', + banner: null, + is_saved: true, + }, + { + id: 2, + name: 'sale', + categories: [ + { + id: 1, + name: 'trade', + }, + ], + region: 'Dnipro', + founded: 2007, + service_info: null, + address: 'Dnipro', + banner: null, + is_saved: false, + }, + { + id: 3, + name: 'PizzaHousesale', + categories: [], + region: 'Charkiv', + founded: null, + service_info: null, + address: 'Zaporija', + banner: null, + }, + { + id: 4, + name: 'salefruits', + categories: [], + region: null, + founded: null, + service_info: null, + address: null, + banner: null, + }, + { + id: 5, + name: 'salevegetables', + categories: [], + region: null, + founded: null, + service_info: null, + address: null, + banner: null, + }, + { + id: 6, + name: 'salebushes', + categories: [], + region: null, + founded: null, + service_info: null, + address: null, + banner: null, + }, + { + id: 7, + name: 'GGGsale', + categories: [], + region: null, + founded: null, + service_info: null, + address: null, + banner: null, + }, + ]; + + const totalItems = 7; + + axios.get.mockResolvedValue({ + data: { + results: results, + total_items: totalItems, + }, + }); + + await act(async () => { + render( + + + + ); + }); + + const nameElements = screen.getAllByText(/sale/i, { exact: false }); + expect(nameElements).toHaveLength(results.length); }); }); diff --git a/FrontEnd/src/pages/SignUp/SignupForm/SignUpFormContent.jsx b/FrontEnd/src/pages/SignUp/SignupForm/SignUpFormContent.jsx index 60041f01e..71bfc238d 100644 --- a/FrontEnd/src/pages/SignUp/SignupForm/SignUpFormContent.jsx +++ b/FrontEnd/src/pages/SignUp/SignupForm/SignUpFormContent.jsx @@ -37,7 +37,7 @@ export function SignUpFormContentComponent(props) { password: 'Пароль не відповідає вимогам', confirmPassword: 'Паролі не співпадають. Будь ласка, введіть однакові паролі в обидва поля', nameSurnameFieldLength: 'Введіть від 2 до 50 символів', - companyFieldLength: 'Введіть від 2 до 100 символів', + companyFieldLength: 'Введіть від 2 до 45 символів', notAllowedSymbols: 'Поле містить недопустимі символи та/або цифри', maxLength: 'Кількість символів перевищує максимально допустиму (50 символів)', }; @@ -202,8 +202,8 @@ export function SignUpFormContentComponent(props) { message: errorMessageTemplates.companyFieldLength, }, }} - maxLength={100} - tooltip="Назва повинна містити від 2 до 100 символів" + maxLength={45} + tooltip="Назва повинна містити від 2 до 45 символів" tooltipTrigger="focus" error={errors.companyName} onBlur={() => {