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
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()
# создаем маршрут

View File

@@ -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)

View File

@@ -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')
]

View File

@@ -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 = () => {
{/* тип груза и транспорта */}
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div>
<label htmlFor="cargo_type" className="block text-sm font-medium text-gray-700">
Тип груза
</label>
<MultiSelect
value={values.cargo_type ? [parseInt(values.cargo_type)] : []}
handleChange={e => {
handleChange({
target: {
id: 'cargo_type',
value: e.target.value[0]?.toString() || '',
},
})
}}
<SingleSelect
name="cargo_type"
value={values.cargo_type}
handleChange={handleChange}
label="Тип груза"
placeholder="Выберите тип груза"
options={cargoOptions}
className="mt-1"
placeholder="Выберите тип груза"
noOptionsMessage="Нет доступных типов груза"
/>
</div>
<div>
<label htmlFor="transport" className="block text-sm font-medium text-gray-700">
Способ перевозки
</label>
<MultiSelect
value={values.transport ? [parseInt(values.transport)] : []}
handleChange={e => {
handleChange({
target: {
id: 'transport',
value: e.target.value[0]?.toString() || '',
},
})
}}
<SingleSelect
name="transport"
value={values.transport}
handleChange={handleChange}
label="Способ перевозки"
placeholder="Выберите способ перевозки"
options={transportOptions}
className="mt-1"
placeholder="Выберите способ перевозки"
noOptionsMessage="Нет доступных способов перевозки"
/>
</div>
@@ -165,41 +160,80 @@ const SenderPage = () => {
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div className="space-y-6">
<TextInput
<LocationSelect
name="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="Страна отправления"
placeholder="Введите страну отправления"
style="register"
placeholder="Выберите страну отправления"
/>
<TextInput
name="country_to"
value={values.country_to}
<LocationSelect
name="city_from"
value={values.city_from}
handleChange={handleChange}
label="Страна назначения"
placeholder="Введите страну назначения"
style="register"
label="Город отправления"
placeholder="Выберите город отправления"
countryId={values.country_from_id}
isCity
/>
</div>
<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"
value={values.city_to}
handleChange={handleChange}
label="Город назначения"
placeholder="Введите город назначения"
style="register"
/>
<TextInput
name="country_to"
value={values.country_to}
handleChange={handleChange}
label="Страна назначения"
placeholder="Введите страну назначения"
style="register"
placeholder="Выберите город назначения"
countryId={values.country_to_id}
isCity
/>
</div>
</div>

View File

@@ -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,

View File

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