account/responses page
This commit is contained in:
@@ -315,4 +315,38 @@ class LeadSerializer(serializers.ModelSerializer):
|
||||
try:
|
||||
return super().to_internal_value(data)
|
||||
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
|
||||
@@ -12,9 +12,9 @@ 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, City, Country
|
||||
from routes.models import Route, City, Country, Leads
|
||||
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):
|
||||
"""Эндпоинт для наполнения стора фронта данными"""
|
||||
@@ -259,4 +259,21 @@ class LeadViewSet(ViewSet):
|
||||
},
|
||||
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
|
||||
)
|
||||
@@ -37,6 +37,7 @@ urlpatterns = [
|
||||
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/leads/", LeadViewSet.as_view({'get':'get_leads'}), name='get_leads'),
|
||||
|
||||
path("v1/cities/", CityView.as_view({'get':'get_cities'}), name='get_cities'),
|
||||
path("v1/countries/", CountryView.as_view({'get':'get_countries'}), name='get_countries'),
|
||||
|
||||
@@ -8,6 +8,7 @@ import { RiUser3Line } from 'react-icons/ri'
|
||||
import { FaRoute } from 'react-icons/fa'
|
||||
import { GoPackageDependents, GoPackageDependencies } from 'react-icons/go'
|
||||
import { MdOutlinePayments, MdOutlineContactSupport } from 'react-icons/md'
|
||||
import { IoGitPullRequest } from 'react-icons/io5'
|
||||
import useUserStore from '@/app/store/userStore'
|
||||
|
||||
export default function AccountLayout({ children }: { children: React.ReactNode }) {
|
||||
@@ -35,6 +36,7 @@ export default function AccountLayout({ children }: { children: React.ReactNode
|
||||
const userNavigation = [
|
||||
{ name: 'Профиль', href: '/account', icon: RiUser3Line },
|
||||
{ name: 'Мои маршруты', href: '/account/routes', icon: FaRoute },
|
||||
{ name: 'Мои отклики', href: '/account/responses', icon: IoGitPullRequest },
|
||||
{
|
||||
name: 'Отправить посылку',
|
||||
href: '/account/create-as-sender',
|
||||
|
||||
139
frontend/app/(urls)/account/responses/page.tsx
Normal file
139
frontend/app/(urls)/account/responses/page.tsx
Normal 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
|
||||
@@ -50,10 +50,8 @@ export default async function UserRoutes() {
|
||||
</div>
|
||||
{(!routes || routes.length === 0) && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
|
||||
<p className="text-lg">У вас пока нет завершенных маршрутов</p>
|
||||
<p className="text-sm">
|
||||
Создавайте или принимайте заявки на перевозку, чтобы они отобразились тут
|
||||
</p>
|
||||
<p className="text-lg">У вас пока нет созданных маршрутов</p>
|
||||
<p className="text-sm">Создавайте заявки на перевозку, чтобы они отобразились тут</p>
|
||||
</div>
|
||||
)}
|
||||
{routes.length > 0 && (
|
||||
|
||||
38
frontend/app/api/account/requests/route.ts
Normal file
38
frontend/app/api/account/requests/route.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -157,7 +157,7 @@ export interface TextAreaProps {
|
||||
}
|
||||
|
||||
export interface Route {
|
||||
id: string
|
||||
id: string | number
|
||||
from_city_name: string
|
||||
to_city_name: string
|
||||
from_country_name: string
|
||||
@@ -168,6 +168,9 @@ export interface Route {
|
||||
formatted_transport: string
|
||||
comment?: string
|
||||
owner_type: string
|
||||
status: string
|
||||
owner_email: string
|
||||
owner_name: string
|
||||
}
|
||||
|
||||
export interface SelectOption {
|
||||
@@ -238,11 +241,20 @@ export interface SearchPageProps {
|
||||
}
|
||||
|
||||
export interface Lead {
|
||||
id: number
|
||||
name: string
|
||||
phone_number: string
|
||||
email: string
|
||||
route: Route
|
||||
moving_user: number
|
||||
moving_price: string
|
||||
moving_date: string
|
||||
comment: string
|
||||
route?: number
|
||||
comment?: string
|
||||
created_at: string
|
||||
status: 'actual' | 'canceled' | 'completed'
|
||||
}
|
||||
|
||||
export interface LeadPageProps extends Lead {
|
||||
owner_name: string
|
||||
owner_email: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user