account/responses page

This commit is contained in:
2025-05-26 12:15:32 +03:00
parent 50e6da10f1
commit c761c60818
8 changed files with 252 additions and 11 deletions

View File

@@ -316,3 +316,37 @@ class LeadSerializer(serializers.ModelSerializer):
return super().to_internal_value(data) return super().to_internal_value(data)
except Exception as e: except Exception as e:
raise raise
class LeadResponseSerializer(serializers.ModelSerializer):
route = RouteSerializer(read_only=True)
moving_price = serializers.DecimalField(max_digits=10, decimal_places=2)
created_at = serializers.DateTimeField()
owner_name = serializers.SerializerMethodField()
owner_email = serializers.SerializerMethodField()
class Meta:
model = Leads
fields = [
'id',
'route',
'moving_user',
'moving_price',
'moving_date',
'comment',
'created_at',
'owner_name',
'owner_email'
]
def get_owner_name(self, obj):
owner = obj.route.owner
return f"{owner.first_name} {owner.last_name}".strip() if owner else None
def get_owner_email(self, obj):
return obj.route.owner.email if obj.route.owner else None
def to_representation(self, instance):
data = super().to_representation(instance)
if instance.created_at:
data['created_at'] = instance.created_at.strftime('%Y-%m-%dT%H:%M:%S.%f%z')
return data

View File

@@ -12,9 +12,9 @@ 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, City, Country from routes.models import Route, City, Country, Leads
from sitemanagement.models import Pricing from sitemanagement.models import Pricing
from .serializers import RouteSerializer, CreateRouteSerializer, CitySerializer, CountrySerializer, PlanChangeSerializer, PricingSerializer, LeadSerializer from .serializers import RouteSerializer, CreateRouteSerializer, CitySerializer, CountrySerializer, PlanChangeSerializer, PricingSerializer, LeadSerializer, LeadResponseSerializer
class UserDataView(ViewSet): class UserDataView(ViewSet):
"""Эндпоинт для наполнения стора фронта данными""" """Эндпоинт для наполнения стора фронта данными"""
@@ -260,3 +260,20 @@ class LeadViewSet(ViewSet):
status=status.HTTP_400_BAD_REQUEST status=status.HTTP_400_BAD_REQUEST
) )
@action(detail=False, methods=['get'])
@handle_exceptions
def get_leads(self, request):
"""Получаем список заявок на перевозку"""
leads = Leads.objects.select_related(
'route',
'route__from_city',
'route__to_city',
'route__from_city__country',
'route__to_city__country',
'route__owner'
).filter(moving_user=request.user).order_by('-created_at')
return Response(
LeadResponseSerializer(leads, many=True).data,
status=status.HTTP_200_OK
)

View File

@@ -37,6 +37,7 @@ urlpatterns = [
path("v1/account/create_route/", AccountActionsView.as_view({'post':'create_route'}), name='create_route'), path("v1/account/create_route/", AccountActionsView.as_view({'post':'create_route'}), name='create_route'),
path("v1/account/send_lead/", LeadViewSet.as_view({'post':'send_lead'}), name='send_lead'), path("v1/account/send_lead/", LeadViewSet.as_view({'post':'send_lead'}), name='send_lead'),
path("v1/account/leads/", LeadViewSet.as_view({'get':'get_leads'}), name='get_leads'),
path("v1/cities/", CityView.as_view({'get':'get_cities'}), name='get_cities'), path("v1/cities/", CityView.as_view({'get':'get_cities'}), name='get_cities'),
path("v1/countries/", CountryView.as_view({'get':'get_countries'}), name='get_countries'), path("v1/countries/", CountryView.as_view({'get':'get_countries'}), name='get_countries'),

View File

@@ -8,6 +8,7 @@ import { RiUser3Line } from 'react-icons/ri'
import { FaRoute } from 'react-icons/fa' import { FaRoute } from 'react-icons/fa'
import { GoPackageDependents, GoPackageDependencies } from 'react-icons/go' import { GoPackageDependents, GoPackageDependencies } from 'react-icons/go'
import { MdOutlinePayments, MdOutlineContactSupport } from 'react-icons/md' import { MdOutlinePayments, MdOutlineContactSupport } from 'react-icons/md'
import { IoGitPullRequest } from 'react-icons/io5'
import useUserStore from '@/app/store/userStore' import useUserStore from '@/app/store/userStore'
export default function AccountLayout({ children }: { children: React.ReactNode }) { export default function AccountLayout({ children }: { children: React.ReactNode }) {
@@ -35,6 +36,7 @@ export default function AccountLayout({ children }: { children: React.ReactNode
const userNavigation = [ const userNavigation = [
{ name: 'Профиль', href: '/account', icon: RiUser3Line }, { name: 'Профиль', href: '/account', icon: RiUser3Line },
{ name: 'Мои маршруты', href: '/account/routes', icon: FaRoute }, { name: 'Мои маршруты', href: '/account/routes', icon: FaRoute },
{ name: 'Мои отклики', href: '/account/responses', icon: IoGitPullRequest },
{ {
name: 'Отправить посылку', name: 'Отправить посылку',
href: '/account/create-as-sender', href: '/account/create-as-sender',

View File

@@ -0,0 +1,139 @@
'use client'
import React, { useEffect, useState } from 'react'
import Loader from '@/components/ui/Loader'
import { LeadPageProps } from '@/app/types'
const ResponsesPage = () => {
const [leads, setLeads] = useState<LeadPageProps[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const fetchLeads = async () => {
try {
const response = await fetch('/api/account/requests')
if (!response.ok) {
throw new Error('Failed to fetch leads')
}
const data = await response.json()
setLeads(data)
} catch (err) {
console.error('Component error:', err)
setError(err instanceof Error ? err.message : 'Failed to load leads')
} finally {
setIsLoading(false)
}
}
fetchLeads()
}, [])
if (isLoading) {
return <Loader />
}
if (error) {
return <div className="p-8 text-center text-red-500">{error}</div>
}
return (
<div className="space-y-3">
<div className="overflow-hidden rounded-2xl shadow">
<div className="bg-white p-6 sm:p-8">
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl">Мои отклики</h1>
</div>
</div>
{leads.length > 0 && (
<div className="mt-4 space-y-4">
{leads.map(lead => (
<div
key={lead.id}
className="space-y-4 rounded-2xl border bg-white p-6 transition-shadow hover:shadow-md"
>
<div className="flex flex-col space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="text-sm text-gray-500">Маршрут</div>
<div className="font-medium">#{lead.route.id}</div>
<div className="mt-1 text-sm text-gray-600">
{lead.route.from_city_name}, {lead.route.from_country_name} {' '}
{lead.route.to_city_name}, {lead.route.to_country_name}
</div>
<div className="mt-1 text-xs text-gray-500">
{lead.route.formatted_departure} - {lead.route.formatted_arrival}
</div>
<div className="mt-1 text-xs text-gray-500">
<span className="font-medium">Тип груза:</span>{' '}
{lead.route.formatted_cargo_type} {' '}
<span className="font-medium">Транспорт:</span>{' '}
{lead.route.formatted_transport}
</div>
{lead.route.comment && (
<div className="mt-1 text-xs text-gray-500">
<span className="font-medium">Комментарий к маршруту:</span>{' '}
{lead.route.comment}
</div>
)}
<div className="mt-2 flex items-center text-xs text-gray-500">
<svg
className="mr-1 h-4 w-4 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
{lead.owner_name}
<span className="mx-1"></span>
<a
href={`mailto:${lead.owner_email}`}
className="text-blue-600 hover:text-blue-800"
>
{lead.owner_email}
</a>
</div>
</div>
<div className="mt-4 sm:mt-0 sm:text-right">
<div className="text-sm text-gray-500">Предложенная цена</div>
<div className="text-lg font-medium">{lead.moving_price} тенге</div>
</div>
</div>
{lead.comment && (
<div className="rounded-lg bg-gray-50 p-3">
<div className="text-sm text-gray-500">Комментарий к отклику:</div>
<div className="mt-1">{lead.comment}</div>
</div>
)}
<div className="text-sm text-gray-500">
Отправлено:{' '}
{new Date(lead.created_at).toLocaleString('ru-RU', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
)
}
export default ResponsesPage

View File

@@ -50,10 +50,8 @@ export default async function UserRoutes() {
</div> </div>
{(!routes || routes.length === 0) && ( {(!routes || routes.length === 0) && (
<div className="flex flex-col items-center justify-center py-12 text-gray-500"> <div className="flex flex-col items-center justify-center py-12 text-gray-500">
<p className="text-lg">У вас пока нет завершенных маршрутов</p> <p className="text-lg">У вас пока нет созданных маршрутов</p>
<p className="text-sm"> <p className="text-sm">Создавайте заявки на перевозку, чтобы они отобразились тут</p>
Создавайте или принимайте заявки на перевозку, чтобы они отобразились тут
</p>
</div> </div>
)} )}
{routes.length > 0 && ( {routes.length > 0 && (

View File

@@ -0,0 +1,38 @@
import { NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/api/auth/[...nextauth]/route'
export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session) {
console.error('No session found')
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
})
}
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/account/leads/`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${session.accessToken}`,
},
})
if (!response.ok) {
const error = await response.json()
console.error('API error:', error)
return new Response(JSON.stringify(error), { status: response.status })
}
const result = await response.json()
return new Response(JSON.stringify(result), { status: 200 })
} catch (error) {
console.error('Route handler error:', error)
return new Response(JSON.stringify({ error: 'Internal Server Error' }), {
status: 500,
})
}
}

View File

@@ -157,7 +157,7 @@ export interface TextAreaProps {
} }
export interface Route { export interface Route {
id: string id: string | number
from_city_name: string from_city_name: string
to_city_name: string to_city_name: string
from_country_name: string from_country_name: string
@@ -168,6 +168,9 @@ export interface Route {
formatted_transport: string formatted_transport: string
comment?: string comment?: string
owner_type: string owner_type: string
status: string
owner_email: string
owner_name: string
} }
export interface SelectOption { export interface SelectOption {
@@ -238,11 +241,20 @@ export interface SearchPageProps {
} }
export interface Lead { export interface Lead {
id: number
name: string name: string
phone_number: string phone_number: string
email: string email: string
route: Route
moving_user: number
moving_price: string moving_price: string
moving_date: string moving_date: string
comment: string comment?: string
route?: number created_at: string
status: 'actual' | 'canceled' | 'completed'
}
export interface LeadPageProps extends Lead {
owner_name: string
owner_email: string
} }