From 4182708db7a295b6df2800d22de53a3b41a94847 Mon Sep 17 00:00:00 2001 From: Timofey Date: Thu, 22 May 2025 13:28:20 +0300 Subject: [PATCH] sender page --- backend/api/account/client/serializers.py | 28 +++- backend/api/account/client/views.py | 52 +++++- backend/api/urls.py | 7 +- .../(urls)/account/create-as-sender/page.tsx | 158 +++++++++++------- frontend/app/api/account/sender/route.ts | 3 +- frontend/app/types/index.ts | 2 + frontend/components/ui/LocationSelect.tsx | 134 +++++++++++++++ frontend/components/ui/SingleSelect.tsx | 97 +++++++++++ 8 files changed, 409 insertions(+), 72 deletions(-) create mode 100644 frontend/components/ui/LocationSelect.tsx create mode 100644 frontend/components/ui/SingleSelect.tsx diff --git a/backend/api/account/client/serializers.py b/backend/api/account/client/serializers.py index 3c50ccc..2369020 100644 --- a/backend/api/account/client/serializers.py +++ b/backend/api/account/client/serializers.py @@ -6,6 +6,26 @@ from api.models import UserProfile from django.shortcuts import get_object_or_404 import pytz +class CountrySerializer(serializers.ModelSerializer): + value = serializers.CharField(source='international_name') # для совместимости с селектом на фронте + label = serializers.SerializerMethodField() # для отображения в селекте + + class Meta: + model = Country + fields = ['id', 'value', 'label', 'flag_img_url'] + + def get_label(self, obj): + return obj.international_name or obj.official_name + +class CitySerializer(serializers.ModelSerializer): + value = serializers.CharField(source='name') # для совместимости с селектом + label = serializers.CharField(source='name') # для отображения в селекте + country_name = serializers.CharField(source='country.international_name') + + class Meta: + model = City + fields = ['id', 'value', 'label', 'country_name'] + class RouteSerializer(serializers.ModelSerializer): from_city_name = serializers.SerializerMethodField() to_city_name = serializers.SerializerMethodField() @@ -160,16 +180,16 @@ class CreateRouteSerializer(serializers.ModelSerializer): to_city = City.objects.get(name=validated_data.pop('city_to'), country=country_to) # обновляем номер телефона в профиле пользователя - contact_number = validated_data.pop('contact_number') + phone_number = validated_data.pop('phone_number') user_profile = get_object_or_404(UserProfile, user=self.context['request'].user) # проверяем, не используется ли этот номер другим пользователем - if UserProfile.objects.filter(phone_number=contact_number).exclude(user=self.context['request'].user).exists(): + if UserProfile.objects.filter(phone_number=phone_number).exclude(user=self.context['request'].user).exists(): raise serializers.ValidationError({ - "contact_number": "Этот номер телефона уже используется другим пользователем" + "phone_number": "Этот номер телефона уже используется другим пользователем" }) - user_profile.phone_number = contact_number + user_profile.phone_number = phone_number user_profile.save() # создаем маршрут diff --git a/backend/api/account/client/views.py b/backend/api/account/client/views.py index 7daedf1..843efc5 100644 --- a/backend/api/account/client/views.py +++ b/backend/api/account/client/views.py @@ -7,12 +7,13 @@ from rest_framework.response import Response from django.shortcuts import get_object_or_404 from django.core.validators import validate_email from django.core.exceptions import ValidationError +from django.db import models from api.auth.serializers import UserResponseSerializer from api.models import UserProfile from api.utils.decorators import handle_exceptions -from routes.models import Route -from .serializers import RouteSerializer, CreateRouteSerializer +from routes.models import Route, City, Country +from .serializers import RouteSerializer, CreateRouteSerializer, CitySerializer, CountrySerializer class UserDataView(ViewSet): permission_classes = [IsAuthenticated] @@ -95,8 +96,53 @@ class AccountActionsView(ViewSet): @action(detail=False, methods=['post']) @handle_exceptions - def create_sender_route(self, request): + def create_route(self, request): serializer = CreateRouteSerializer(data=request.data) serializer.is_valid(raise_exception=True) serializer.save() return Response(serializer.data, status=status.HTTP_201_CREATED) + +class CityView(ViewSet): + + @action(detail=False, methods=['get']) + @handle_exceptions + def get_cities(self, request): + # получаем параметр country_id из query params + country_id = request.query_params.get('country_id') + + # базовый QuerySet + cities = City.objects.all() + + # фильтруем города по стране, если указан country_id + if country_id: + cities = cities.filter(country_id=country_id) + + # поиск по названию города + search = request.query_params.get('search') + if search: + cities = cities.filter(name__icontains=search) + + # ограничиваем количество результатов и сортируем по имени + cities = cities.order_by('name')[:100] + + return Response(CitySerializer(cities, many=True).data, status=status.HTTP_200_OK) + +class CountryView(ViewSet): + @action(detail=False, methods=['get']) + @handle_exceptions + def get_countries(self, request): + # базовый QuerySet + countries = Country.objects.all() + + # поиск по названию страны + search = request.query_params.get('search') + if search: + countries = countries.filter( + models.Q(international_name__icontains=search) | + models.Q(official_name__icontains=search) + ) + + # сортируем по международному названию + countries = countries.order_by('international_name') + + return Response(CountrySerializer(countries, many=True).data, status=status.HTTP_200_OK) \ No newline at end of file diff --git a/backend/api/urls.py b/backend/api/urls.py index c8877b3..a1af164 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -8,7 +8,7 @@ LoginViewSet, LogoutView, RefreshTokenView) -from api.account.client.views import UserDataView, AccountActionsView +from api.account.client.views import UserDataView, AccountActionsView, CityView, CountryView urlpatterns = [ path("v1/faq/", FAQView.as_view(), name='faqMain'), @@ -24,5 +24,8 @@ urlpatterns = [ path("v1/account/change_main_data/", AccountActionsView.as_view({'patch':'change_data_main_tab'}), name='change_data_main_tab'), path("v1/account/routes/", AccountActionsView.as_view({'get':'user_routes'}), name='user_routes'), - path("v1/account/create_sender/", AccountActionsView.as_view({'post':'create_sender_route'}), name='create_sender_route') + path("v1/account/create_route/", AccountActionsView.as_view({'post':'create_route'}), name='create_route'), + + path("v1/cities/", CityView.as_view({'get':'get_cities'}), name='get_cities'), + path("v1/countries/", CountryView.as_view({'get':'get_countries'}), name='get_countries') ] \ No newline at end of file diff --git a/frontend/app/(urls)/account/create-as-sender/page.tsx b/frontend/app/(urls)/account/create-as-sender/page.tsx index 13102e5..e9bdb44 100644 --- a/frontend/app/(urls)/account/create-as-sender/page.tsx +++ b/frontend/app/(urls)/account/create-as-sender/page.tsx @@ -1,12 +1,12 @@ 'use client' import React from 'react' -import MultiSelect from '@/components/ui/Selector' -import TextInput from '@/components/ui/TextInput' import PhoneInput from '@/components/ui/PhoneInput' import Button from '@/components/ui/Button' import TextAreaInput from '@/components/ui/TextAreaInput' import CheckboxInput from '@/components/ui/CheckboxInput' +import LocationSelect from '@/components/ui/LocationSelect' +import SingleSelect from '@/components/ui/SingleSelect' import { useForm } from '@/app/hooks/useForm' import useUserStore from '@/app/store/userStore' import showToast from '@/components/ui/Toast' @@ -29,10 +29,10 @@ const formatDateToHTML = (date: Date) => { const validationRules = { transport: { required: true }, - country_from: { required: true, minLength: 2 }, - city_from: { required: true, minLength: 2 }, - country_to: { required: true, minLength: 2 }, - city_to: { required: true, minLength: 2 }, + country_from_id: { required: true }, + city_from: { required: true }, + country_to_id: { required: true }, + city_to: { required: true }, cargo_type: { required: true }, departure: { required: true, @@ -61,6 +61,8 @@ const SenderPage = () => { city_from: '', country_to: '', city_to: '', + country_from_id: '', + country_to_id: '', cargo_type: '', departure: '', arrival: '', @@ -86,15 +88,26 @@ const SenderPage = () => { validationRules, async values => { try { - // await addNewSpecialist(values, selectedImage || undefined) - showToast({ - type: 'success', - message: 'Маршрут успешно создан!', + const response = await fetch('/api/account/sender', { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(values), }) - } catch { + + if (!response.ok) { + const error = await response.json() + throw new Error(error.error || 'Ошибка при обновлении данных') + } + + const result = await response.json() + setUser(result.user) + showToast({ type: 'success', message: 'Данные успешно обновлены!' }) + } catch (error) { showToast({ type: 'error', - message: 'Упс, что то пошло не так...', + message: error instanceof Error ? error.message : 'Ой, что то пошло не так..', }) } } @@ -115,45 +128,27 @@ const SenderPage = () => { {/* тип груза и транспорта */}
- - { - handleChange({ - target: { - id: 'cargo_type', - value: e.target.value[0]?.toString() || '', - }, - }) - }} +
- - { - handleChange({ - target: { - id: 'transport', - value: e.target.value[0]?.toString() || '', - }, - }) - }} +
@@ -165,41 +160,80 @@ const SenderPage = () => {
- { + const selectedOption = e.target.selectedOption + handleChange({ + target: { + id: 'country_from', + value: e.target.value, + }, + }) + handleChange({ + target: { + id: 'country_from_id', + value: selectedOption?.id?.toString() || '', + }, + }) + handleChange({ + target: { + id: 'city_from', + value: '', + }, + }) + }} label="Страна отправления" - placeholder="Введите страну отправления" - style="register" + placeholder="Выберите страну отправления" /> -
- { + const selectedOption = e.target.selectedOption + handleChange({ + target: { + id: 'country_to', + value: e.target.value, + }, + }) + handleChange({ + target: { + id: 'country_to_id', + value: selectedOption?.id?.toString() || '', + }, + }) + handleChange({ + target: { + id: 'city_to', + value: '', + }, + }) + }} + label="Страна назначения" + placeholder="Выберите страну назначения" + /> + - -
diff --git a/frontend/app/api/account/sender/route.ts b/frontend/app/api/account/sender/route.ts index 51e5122..0bf2bc5 100644 --- a/frontend/app/api/account/sender/route.ts +++ b/frontend/app/api/account/sender/route.ts @@ -27,13 +27,14 @@ export async function POST(req: NextRequest) { email_notification, } = data - const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/account/create_sender/`, { + const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/account/create_route/`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}`, }, body: JSON.stringify({ + owner_type: 'sender', transport, country_from, city_from, diff --git a/frontend/app/types/index.ts b/frontend/app/types/index.ts index c8171ab..a9262c2 100644 --- a/frontend/app/types/index.ts +++ b/frontend/app/types/index.ts @@ -192,6 +192,8 @@ export interface SenderPageProps extends Record { city_from: string country_to: string city_to: string + country_from_id: string + country_to_id: string cargo_type: string departure: string arrival: string diff --git a/frontend/components/ui/LocationSelect.tsx b/frontend/components/ui/LocationSelect.tsx new file mode 100644 index 0000000..0284d09 --- /dev/null +++ b/frontend/components/ui/LocationSelect.tsx @@ -0,0 +1,134 @@ +'use client' + +import React, { useEffect, useState } from 'react' +import Select from 'react-select' +import { SelectOption } from '@/app/types' +import axios from 'axios' + +interface LocationOption extends SelectOption { + value: string // добавляем поле value к базовому интерфейсу SelectOption +} + +interface LocationSelectProps { + name: string + value: string + handleChange: (e: { + target: { id: string; value: string; selectedOption?: LocationOption } + }) => void + label: string + placeholder: string + countryId?: string + isCity?: boolean +} + +const LocationSelect: React.FC = ({ + name, + value, + handleChange, + label, + placeholder, + countryId, + isCity = false, +}) => { + const [options, setOptions] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [search, setSearch] = useState('') + const API_URL = process.env.NEXT_PUBLIC_API_URL + useEffect(() => { + const fetchOptions = async () => { + setIsLoading(true) + try { + const url = isCity + ? `${API_URL}/cities/?${countryId ? `country_id=${countryId}&` : ''}search=${search}` + : `${API_URL}/countries/?search=${search}` + + const response = await axios.get(url) + setOptions(response.data) + } catch (error) { + console.error('Error fetching options:', error) + } finally { + setIsLoading(false) + } + } + + // Debounce search + const timeoutId = setTimeout(() => { + fetchOptions() + }, 300) + + return () => clearTimeout(timeoutId) + }, [search, countryId, isCity, API_URL]) + + return ( +
+ + + inputId={name} + name={name} + options={options} + value={options.find(opt => opt.value === value)} + onChange={selectedOption => { + handleChange({ + target: { + id: name, + value: selectedOption?.value || '', + selectedOption: selectedOption || undefined, + }, + }) + }} + isLoading={isLoading} + onInputChange={newValue => setSearch(newValue)} + isSearchable + isClearable + placeholder={placeholder} + noOptionsMessage={() => (isLoading ? 'Загрузка...' : 'Нет доступных вариантов')} + classNamePrefix="select" + className="rounded-lg" + styles={{ + control: base => ({ + ...base, + borderRadius: '0.75rem', + backgroundColor: '#F3F4F6', + border: '1px solid #E5E7EB', + padding: '2px', + '&:hover': { + borderColor: '#E5E7EB', + }, + '&:focus-within': { + backgroundColor: '#FFFFFF', + borderColor: '#E5E7EB', + boxShadow: '0 0 0 2px rgba(59, 130, 246, 0.5)', + }, + }), + menu: base => ({ + ...base, + position: 'absolute', + width: '100%', + zIndex: 9999, + marginTop: '4px', + borderRadius: '0.75rem', + overflow: 'hidden', + }), + option: (base, state) => ({ + ...base, + fontSize: '0.875rem', + padding: '8px 12px', + backgroundColor: state.isSelected ? '#EFF6FF' : state.isFocused ? '#F3F4F6' : 'white', + color: state.isSelected ? '#2563EB' : '#1F2937', + cursor: 'pointer', + '&:active': { + backgroundColor: '#DBEAFE', + }, + '&:hover': { + backgroundColor: state.isSelected ? '#EFF6FF' : '#F3F4F6', + }, + }), + }} + /> +
+ ) +} + +export default LocationSelect diff --git a/frontend/components/ui/SingleSelect.tsx b/frontend/components/ui/SingleSelect.tsx new file mode 100644 index 0000000..5dbcf23 --- /dev/null +++ b/frontend/components/ui/SingleSelect.tsx @@ -0,0 +1,97 @@ +'use client' + +import React from 'react' +import Select from 'react-select' +import { SelectOption } from '@/app/types' + +interface SingleSelectProps { + name: string + value: string + handleChange: (e: { target: { id: string; value: string } }) => void + label: string + placeholder: string + options: SelectOption[] + className?: string + noOptionsMessage?: string +} + +const SingleSelect: React.FC = ({ + name, + value, + handleChange, + label, + placeholder, + options, + className = '', + noOptionsMessage = 'Нет доступных вариантов', +}) => { + return ( +
+ + + inputId={name} + name={name} + options={options} + value={options.find(opt => opt.id.toString() === value)} + onChange={selectedOption => { + handleChange({ + target: { + id: name, + value: selectedOption?.id.toString() || '', + }, + }) + }} + isSearchable + isClearable + placeholder={placeholder} + noOptionsMessage={() => noOptionsMessage} + classNamePrefix="select" + className="rounded-lg" + styles={{ + control: base => ({ + ...base, + borderRadius: '0.75rem', + backgroundColor: '#F3F4F6', + border: '1px solid #E5E7EB', + padding: '2px', + '&:hover': { + borderColor: '#E5E7EB', + }, + '&:focus-within': { + backgroundColor: '#FFFFFF', + borderColor: '#E5E7EB', + boxShadow: '0 0 0 2px rgba(59, 130, 246, 0.5)', + }, + }), + menu: base => ({ + ...base, + position: 'absolute', + width: '100%', + zIndex: 9999, + marginTop: '4px', + borderRadius: '0.75rem', + overflow: 'hidden', + }), + option: (base, state) => ({ + ...base, + fontSize: '0.875rem', + padding: '8px 12px', + backgroundColor: state.isSelected ? '#EFF6FF' : state.isFocused ? '#F3F4F6' : 'white', + color: state.isSelected ? '#2563EB' : '#1F2937', + cursor: 'pointer', + '&:active': { + backgroundColor: '#DBEAFE', + }, + '&:hover': { + backgroundColor: state.isSelected ? '#EFF6FF' : '#F3F4F6', + }, + }), + }} + /> +
+ ) +} + +export default SingleSelect