sender page

This commit is contained in:
2025-05-22 13:28:20 +03:00
parent 5ba9121f33
commit 4182708db7
8 changed files with 409 additions and 72 deletions

View File

@@ -6,6 +6,26 @@ from api.models import UserProfile
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
import pytz 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): class RouteSerializer(serializers.ModelSerializer):
from_city_name = serializers.SerializerMethodField() from_city_name = serializers.SerializerMethodField()
to_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) 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) 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({ raise serializers.ValidationError({
"contact_number": "Этот номер телефона уже используется другим пользователем" "phone_number": "Этот номер телефона уже используется другим пользователем"
}) })
user_profile.phone_number = contact_number user_profile.phone_number = phone_number
user_profile.save() user_profile.save()
# создаем маршрут # создаем маршрут

View File

@@ -7,12 +7,13 @@ from rest_framework.response import Response
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.core.validators import validate_email from django.core.validators import validate_email
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models
from api.auth.serializers import UserResponseSerializer from api.auth.serializers import UserResponseSerializer
from api.models import UserProfile from api.models import UserProfile
from api.utils.decorators import handle_exceptions from api.utils.decorators import handle_exceptions
from routes.models import Route from routes.models import Route, City, Country
from .serializers import RouteSerializer, CreateRouteSerializer from .serializers import RouteSerializer, CreateRouteSerializer, CitySerializer, CountrySerializer
class UserDataView(ViewSet): class UserDataView(ViewSet):
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
@@ -95,8 +96,53 @@ class AccountActionsView(ViewSet):
@action(detail=False, methods=['post']) @action(detail=False, methods=['post'])
@handle_exceptions @handle_exceptions
def create_sender_route(self, request): def create_route(self, request):
serializer = CreateRouteSerializer(data=request.data) serializer = CreateRouteSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
serializer.save() serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED) 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)

View File

@@ -8,7 +8,7 @@ LoginViewSet,
LogoutView, LogoutView,
RefreshTokenView) RefreshTokenView)
from api.account.client.views import UserDataView, AccountActionsView from api.account.client.views import UserDataView, AccountActionsView, CityView, CountryView
urlpatterns = [ urlpatterns = [
path("v1/faq/", FAQView.as_view(), name='faqMain'), 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/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/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')
] ]

View File

@@ -1,12 +1,12 @@
'use client' 'use client'
import React from 'react' import React from 'react'
import MultiSelect from '@/components/ui/Selector'
import TextInput from '@/components/ui/TextInput'
import PhoneInput from '@/components/ui/PhoneInput' import PhoneInput from '@/components/ui/PhoneInput'
import Button from '@/components/ui/Button' import Button from '@/components/ui/Button'
import TextAreaInput from '@/components/ui/TextAreaInput' import TextAreaInput from '@/components/ui/TextAreaInput'
import CheckboxInput from '@/components/ui/CheckboxInput' 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 { useForm } from '@/app/hooks/useForm'
import useUserStore from '@/app/store/userStore' import useUserStore from '@/app/store/userStore'
import showToast from '@/components/ui/Toast' import showToast from '@/components/ui/Toast'
@@ -29,10 +29,10 @@ const formatDateToHTML = (date: Date) => {
const validationRules = { const validationRules = {
transport: { required: true }, transport: { required: true },
country_from: { required: true, minLength: 2 }, country_from_id: { required: true },
city_from: { required: true, minLength: 2 }, city_from: { required: true },
country_to: { required: true, minLength: 2 }, country_to_id: { required: true },
city_to: { required: true, minLength: 2 }, city_to: { required: true },
cargo_type: { required: true }, cargo_type: { required: true },
departure: { departure: {
required: true, required: true,
@@ -61,6 +61,8 @@ const SenderPage = () => {
city_from: '', city_from: '',
country_to: '', country_to: '',
city_to: '', city_to: '',
country_from_id: '',
country_to_id: '',
cargo_type: '', cargo_type: '',
departure: '', departure: '',
arrival: '', arrival: '',
@@ -86,15 +88,26 @@ const SenderPage = () => {
validationRules, validationRules,
async values => { async values => {
try { try {
// await addNewSpecialist(values, selectedImage || undefined) const response = await fetch('/api/account/sender', {
showToast({ method: 'PATCH',
type: 'success', headers: {
message: 'Маршрут успешно создан!', '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({ showToast({
type: 'error', type: 'error',
message: 'Упс, что то пошло не так...', message: error instanceof Error ? error.message : 'Ой, что то пошло не так..',
}) })
} }
} }
@@ -115,45 +128,27 @@ const SenderPage = () => {
{/* тип груза и транспорта */} {/* тип груза и транспорта */}
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div> <div>
<label htmlFor="cargo_type" className="block text-sm font-medium text-gray-700"> <SingleSelect
Тип груза
</label>
<MultiSelect
value={values.cargo_type ? [parseInt(values.cargo_type)] : []}
handleChange={e => {
handleChange({
target: {
id: 'cargo_type',
value: e.target.value[0]?.toString() || '',
},
})
}}
name="cargo_type" name="cargo_type"
value={values.cargo_type}
handleChange={handleChange}
label="Тип груза"
placeholder="Выберите тип груза"
options={cargoOptions} options={cargoOptions}
className="mt-1" className="mt-1"
placeholder="Выберите тип груза"
noOptionsMessage="Нет доступных типов груза" noOptionsMessage="Нет доступных типов груза"
/> />
</div> </div>
<div> <div>
<label htmlFor="transport" className="block text-sm font-medium text-gray-700"> <SingleSelect
Способ перевозки
</label>
<MultiSelect
value={values.transport ? [parseInt(values.transport)] : []}
handleChange={e => {
handleChange({
target: {
id: 'transport',
value: e.target.value[0]?.toString() || '',
},
})
}}
name="transport" name="transport"
value={values.transport}
handleChange={handleChange}
label="Способ перевозки"
placeholder="Выберите способ перевозки"
options={transportOptions} options={transportOptions}
className="mt-1" className="mt-1"
placeholder="Выберите способ перевозки"
noOptionsMessage="Нет доступных способов перевозки" noOptionsMessage="Нет доступных способов перевозки"
/> />
</div> </div>
@@ -165,41 +160,80 @@ const SenderPage = () => {
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div className="space-y-6"> <div className="space-y-6">
<TextInput <LocationSelect
name="country_from" name="country_from"
value={values.country_from} value={values.country_from}
handleChange={handleChange} handleChange={e => {
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="Страна отправления" label="Страна отправления"
placeholder="Введите страну отправления" placeholder="Выберите страну отправления"
style="register"
/> />
<TextInput <LocationSelect
name="country_to" name="city_from"
value={values.country_to} value={values.city_from}
handleChange={handleChange} handleChange={handleChange}
label="Страна назначения" label="Город отправления"
placeholder="Введите страну назначения" placeholder="Выберите город отправления"
style="register" countryId={values.country_from_id}
isCity
/> />
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
<TextInput <LocationSelect
name="country_to"
value={values.country_to}
handleChange={e => {
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="Выберите страну назначения"
/>
<LocationSelect
name="city_to" name="city_to"
value={values.city_to} value={values.city_to}
handleChange={handleChange} handleChange={handleChange}
label="Город назначения" label="Город назначения"
placeholder="Введите город назначения" placeholder="Выберите город назначения"
style="register" countryId={values.country_to_id}
/> isCity
<TextInput
name="country_to"
value={values.country_to}
handleChange={handleChange}
label="Страна назначения"
placeholder="Введите страну назначения"
style="register"
/> />
</div> </div>
</div> </div>

View File

@@ -27,13 +27,14 @@ export async function POST(req: NextRequest) {
email_notification, email_notification,
} = data } = 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', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${session.accessToken}`, Authorization: `Bearer ${session.accessToken}`,
}, },
body: JSON.stringify({ body: JSON.stringify({
owner_type: 'sender',
transport, transport,
country_from, country_from,
city_from, city_from,

View File

@@ -192,6 +192,8 @@ export interface SenderPageProps extends Record<string, FormValue> {
city_from: string city_from: string
country_to: string country_to: string
city_to: string city_to: string
country_from_id: string
country_to_id: string
cargo_type: string cargo_type: string
departure: string departure: string
arrival: string arrival: string

View File

@@ -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<LocationSelectProps> = ({
name,
value,
handleChange,
label,
placeholder,
countryId,
isCity = false,
}) => {
const [options, setOptions] = useState<LocationOption[]>([])
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 (
<div>
<label htmlFor={name} className="mb-1 block text-sm font-medium text-gray-700">
{label}
</label>
<Select<LocationOption>
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',
},
}),
}}
/>
</div>
)
}
export default LocationSelect

View File

@@ -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<SingleSelectProps> = ({
name,
value,
handleChange,
label,
placeholder,
options,
className = '',
noOptionsMessage = 'Нет доступных вариантов',
}) => {
return (
<div className={className}>
<label htmlFor={name} className="mb-1 block text-sm font-medium text-gray-700">
{label}
</label>
<Select<SelectOption>
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',
},
}),
}}
/>
</div>
)
}
export default SingleSelect