fixed bugs

This commit is contained in:
Alibek 2024-02-19 15:14:19 +06:00
parent d4c49a1061
commit b0870824ba
35 changed files with 1225 additions and 420 deletions

View File

@ -6,38 +6,16 @@
h2 { h2 {
width: fit-content; width: fit-content;
} }
&__list {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 30px;
}
}
@media screen and (max-width: 1024px) {
.news {
&__list {
grid-template-columns: 1fr 1fr 1fr;
}
}
} }
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
.news { .news {
gap: 30px; gap: 30px;
&__list {
grid-template-columns: 1fr 1fr;
}
} }
} }
@media screen and (max-width: 550px) { @media screen and (max-width: 550px) {
.news { .news {
gap: 20px; gap: 20px;
&__list {
grid-template-columns: 1fr;
}
} }
} }

View File

@ -2,41 +2,20 @@ import "./News.scss";
import Typography from "@/shared/ui/components/Typography/Typography"; import Typography from "@/shared/ui/components/Typography/Typography";
import { apiInstance } from "@/shared/config/apiConfig"; import { apiInstance } from "@/shared/config/apiConfig";
import { INewsList } from "@/shared/types/news-type"; import { INewsList } from "@/shared/types/news-type";
import NewsCard from "@/entities/NewsCard/NewsCard"; import NewsList from "@/widgets/NewsList/NewsList";
const News = async () => { const News = ({
const getNews = async () => { searchParams,
try { }: {
const response = await apiInstance.get<INewsList>("/news/"); searchParams: {
["страница-новостей"]: string;
return response.data;
} catch (error) {
console.log(error);
return {
results: [],
};
}
}; };
}) => {
const data = await getNews();
return ( return (
<div className="news page-padding"> <div className="news page-padding">
<Typography element="h2">Новости</Typography> <Typography element="h2">Новости</Typography>
<ul className="news__list"> <NewsList searchParams={searchParams} />
{data.results.map((news) => (
<li key={news.id}>
<NewsCard
id={news.id}
title={news.title}
description={news.description}
image={news.image}
date={news.created_at}
/>
</li>
))}
</ul>
</div> </div>
); );
}; };

View File

@ -3,13 +3,7 @@ import Header from "@/widgets/Header/Header";
import StatisticsSection from "@/widgets/StatisticsSection/StatisticsSection"; import StatisticsSection from "@/widgets/StatisticsSection/StatisticsSection";
import RatingSection from "@/widgets/RatingSection/RatingSection"; import RatingSection from "@/widgets/RatingSection/RatingSection";
import NewsSection from "@/widgets/NewsSection/NewsSection"; import NewsSection from "@/widgets/NewsSection/NewsSection";
import MapSection from "@/widgets/MapSection/MapSection";
const DynamicMap = dynamic(
() => import("@/widgets/MapSection/MapSection"),
{
ssr: false,
}
);
const Home = async ({ const Home = async ({
searchParams, searchParams,
@ -25,18 +19,8 @@ const Home = async ({
<div className="home"> <div className="home">
<Header /> <Header />
<StatisticsSection /> <StatisticsSection />
<DynamicMap {/* <MapSection searchParams={searchParams} /> */}
categories={searchParams["тип-дороги"]} <RatingSection searchParams={searchParams} />
queryMap={searchParams["поиск-на-карте"]}
queryRating={searchParams["поиск-рейтинг"]}
page={searchParams["страница-рейтинга"]}
/>
<RatingSection
categories={searchParams["тип-дороги"]}
queryMap={searchParams["поиск-на-карте"]}
queryRating={searchParams["поиск-рейтинг"]}
page={searchParams["страница-рейтинга"]}
/>
<NewsSection /> <NewsSection />
</div> </div>
); );

View File

@ -2,16 +2,43 @@ import Typography from "@/shared/ui/components/Typography/Typography";
import "./Profile.scss"; import "./Profile.scss";
import ProfileNav from "@/widgets/ProfileNav/ProfileNav"; import ProfileNav from "@/widgets/ProfileNav/ProfileNav";
import AuthGuard from "./AuthGuard"; import AuthGuard from "./AuthGuard";
import { AxiosError } from "axios";
import { apiInstance } from "@/shared/config/apiConfig";
import { getServerSession } from "next-auth";
import { authConfig } from "@/shared/config/authConfig";
import { IProfile } from "@/shared/types/profile-type";
const Profile = ({ const Profile = async ({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) => { }>) => {
const session = await getServerSession(authConfig);
const getProfile = async () => {
const Authorization = `Bearer ${session?.access_token}`;
const config = {
headers: {
Authorization,
},
};
try {
const response = await apiInstance.get<{
report_count: number;
}>("/users/profile/", config);
return response.data;
} catch (error: unknown) {
if (error instanceof AxiosError) console.log(error.message);
}
};
const data = await getProfile();
return ( return (
<div className="profile page-padding"> <div className="profile page-padding">
<Typography element="h2">Личный кабинет</Typography> <Typography element="h2">Личный кабинет</Typography>
<ProfileNav /> <ProfileNav report_count={data?.report_count as number} />
<AuthGuard>{children}</AuthGuard> <AuthGuard>{children}</AuthGuard>
</div> </div>

View File

@ -1,32 +1,14 @@
import { apiInstance } from "@/shared/config/apiConfig";
import { authConfig } from "@/shared/config/authConfig";
import { IMyReportsList } from "@/shared/types/my-reports";
import ProfileTable from "@/widgets/ProfileTable/ProfileTable"; import ProfileTable from "@/widgets/ProfileTable/ProfileTable";
import { getServerSession } from "next-auth";
import React from "react"; import React from "react";
const MyReports = async () => { const MyReports = async ({
const session = await getServerSession(authConfig); searchParams,
}: {
const getMyReports = async () => { searchParams: { ["страница-обращений"]: string };
const Authorization = `Bearer ${session?.access_token}`; }) => {
const config = {
headers: {
Authorization,
},
};
const res = await apiInstance.get<IMyReportsList>(
"/users/reports/",
config
);
return res.data;
};
const data: IMyReportsList = await getMyReports();
return ( return (
<div> <div>
<ProfileTable reports={data} /> <ProfileTable searchParams={searchParams} />
</div> </div>
); );
}; };

View File

@ -5,28 +5,15 @@ import { IStatistics } from "@/shared/types/statistics-type";
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import StatisticsTable from "@/widgets/StatisticsTable/StatisticsTable"; import StatisticsTable from "@/widgets/StatisticsTable/StatisticsTable";
const Statistics = async () => { const Statistics = ({
const getStatistics = async (): Promise<IStatistics[] | string> => { searchParams,
try { }: {
const response = await apiInstance.get<IStatistics[]>( searchParams: { ["поиск-населенного-пункта"]: string };
"/report/city/stats/" }) => {
);
return response.data;
} catch (error: unknown) {
if (error instanceof AxiosError) {
return error.message;
} else {
return "Произошла непредвиденная ошибк";
}
}
};
const data = await getStatistics();
return ( return (
<div className="statistics page-padding"> <div className="statistics page-padding">
<Typography element="h2">Статистика</Typography> <Typography element="h2">Статистика</Typography>
<StatisticsTable firstData={data} /> <StatisticsTable searchParams={searchParams} />
</div> </div>
); );
}; };

View File

@ -5,30 +5,11 @@ import { AxiosError } from "axios";
import { IUserRatings } from "@/shared/types/user-rating-type"; import { IUserRatings } from "@/shared/types/user-rating-type";
import VolunteersTable from "@/widgets/VolunteersTable/VolunteersTable"; import VolunteersTable from "@/widgets/VolunteersTable/VolunteersTable";
const Volunteers = async () => { const Volunteers = () => {
const getVolunteers = async (): Promise<
IUserRatings[] | string
> => {
try {
const response = await apiInstance.get<IUserRatings[]>(
"/report/user_ratings/"
);
return response.data;
} catch (error: unknown) {
if (error instanceof AxiosError) {
return error.message;
} else {
return "Произошла непредвиденная ошибк";
}
}
};
const data = await getVolunteers();
return ( return (
<div className="volunteers page-padding"> <div className="volunteers page-padding">
<Typography element="h2">Волонтеры</Typography> <Typography element="h2">Волонтеры</Typography>
<VolunteersTable firstData={data} /> <VolunteersTable />
</div> </div>
); );
}; };

View File

@ -11,6 +11,7 @@ interface IPaginationProps {
next: string | null; next: string | null;
count: number; count: number;
current_count: number; current_count: number;
limit: number;
} }
const Pagination: React.FC<IPaginationProps> = ({ const Pagination: React.FC<IPaginationProps> = ({
@ -20,8 +21,9 @@ const Pagination: React.FC<IPaginationProps> = ({
next, next,
count, count,
current_count, current_count,
limit,
}: IPaginationProps) => { }: IPaginationProps) => {
const pages_count = count % 8; const pages_count = Math.ceil(count / limit);
const showPages = () => { const showPages = () => {
const btns = []; const btns = [];

View File

@ -6,4 +6,5 @@ export interface IProfile {
image: string; image: string;
role: number; role: number;
govern_status: null; govern_status: null;
report_count: number;
} }

View File

@ -16,6 +16,8 @@ import geo_pink_icon from "./icons/geo-pink.svg";
import geo_purple_icon from "./icons/geo-purple.svg"; import geo_purple_icon from "./icons/geo-purple.svg";
import geo_red_icon from "./icons/geo-red.svg"; import geo_red_icon from "./icons/geo-red.svg";
import geo_yellow_icon from "./icons/geo-yellow.svg"; import geo_yellow_icon from "./icons/geo-yellow.svg";
import geo_white_icon from "./icons/geo-white.svg";
import { import {
DivIcon, DivIcon,
Icon, Icon,
@ -27,6 +29,7 @@ import Link from "next/link";
import { Fragment, useEffect, useState } from "react"; import { Fragment, useEffect, useState } from "react";
import L from "leaflet"; import L from "leaflet";
import { ILocation } from "@/shared/types/location-type"; import { ILocation } from "@/shared/types/location-type";
import { useMapStore } from "../mapSectionStore";
interface IData { interface IData {
id: number; id: number;
@ -40,14 +43,13 @@ interface ILatLng {
} }
interface IHomeMapProps { interface IHomeMapProps {
data: IData[]; reports: IData[];
latLng: ILatLng;
} }
const HomeMap: React.FC<IHomeMapProps> = ({ const HomeMap: React.FC<IHomeMapProps> = ({
data, reports,
latLng,
}: IHomeMapProps) => { }: IHomeMapProps) => {
const { display_location, latLng } = useMapStore();
const [position, setPosition] = useState<ILatLng>({ const [position, setPosition] = useState<ILatLng>({
lat: 42.8746, lat: 42.8746,
lng: 74.606, lng: 74.606,
@ -70,6 +72,11 @@ const HomeMap: React.FC<IHomeMapProps> = ({
6: createCustomIcon(geo_green_icon), 6: createCustomIcon(geo_green_icon),
}; };
const searchResultIcon = new Icon({
iconUrl: geo_white_icon.src,
iconSize: [100, 100],
});
const categoryToPolyline: Record<number, { color: string }> = { const categoryToPolyline: Record<number, { color: string }> = {
1: { color: "rgba(230, 68, 82, 0.8)" }, 1: { color: "rgba(230, 68, 82, 0.8)" },
2: { color: "rgba(198, 152, 224, 0.8)" }, 2: { color: "rgba(198, 152, 224, 0.8)" },
@ -88,10 +95,23 @@ const HomeMap: React.FC<IHomeMapProps> = ({
setPosition(latLng); setPosition(latLng);
}, [latLng]); }, [latLng]);
return null; const defPosition = {
}; lat: 42.8746,
lng: 74.606,
};
const defPos = [42.8746, 74.606]; if (
latLng.lat === defPosition.lat &&
latLng.lng === defPosition.lng
)
return null;
return (
<Marker position={latLng} icon={searchResultIcon}>
<Popup>{display_location}</Popup>
</Marker>
);
};
return ( return (
<MapContainer <MapContainer
@ -105,7 +125,7 @@ const HomeMap: React.FC<IHomeMapProps> = ({
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/> />
{data.map((report) => {reports.map((report) =>
report.location.length === 2 ? ( report.location.length === 2 ? (
<Polyline <Polyline
key={report.id} key={report.id}
@ -124,7 +144,7 @@ const HomeMap: React.FC<IHomeMapProps> = ({
) : null ) : null
)} )}
{data?.map((report) => {reports.map((report) =>
report.location.map((marker) => ( report.location.map((marker) => (
<Marker <Marker
key={marker.id} key={marker.id}

View File

@ -0,0 +1,17 @@
<svg width="118" height="130" viewBox="0 0 118 130" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_d_122_42709)">
<path d="M59 20C48.5233 20 40 28.7481 40 39.501C40 52.8456 57.0031 68.5901 57.7271 69.4175C58.407 70.1948 59.5942 70.1935 60.2729 69.4175C60.9969 68.5901 78 52.8456 78 39.501C77.9998 28.7481 69.4766 20 59 20Z" fill="#3998E8"/>
</g>
<ellipse cx="58.7872" cy="39" rx="9.88775" ry="10" fill="white"/>
<defs>
<filter id="filter0_d_122_42709" x="0" y="0" width="118" height="130" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="20"/>
<feGaussianBlur stdDeviation="20"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.314315 0 0 0 0 0.376772 0 0 0 0 0.406356 0 0 0 0.5 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_122_42709"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_122_42709" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -3,35 +3,68 @@
import "./MapSearch.scss"; import "./MapSearch.scss";
import Image from "next/image"; import Image from "next/image";
import search from "./icons/search.svg"; import search from "./icons/search.svg";
import { IDisplayMap } from "@/shared/types/map-type"; import { useMapStore } from "../mapSectionStore";
import { useEffect, useState } from "react";
import { useDebounce } from "use-debounce";
import { useRouter } from "next/navigation";
interface IMapSearchProps interface IMapSearchProps
extends React.InputHTMLAttributes<HTMLInputElement> { extends React.InputHTMLAttributes<HTMLInputElement> {
options: IDisplayMap[]; searchParams: {
setMapSearch: (string: string) => void; ["тип-дороги"]: string;
setLatLng: ({ lat, lng }: { lat: number; lng: number }) => void; ["поиск-на-карте"]: string;
["поиск-рейтинг"]: string;
["страница-рейтинга"]: string;
};
} }
const MapSearch: React.FC<IMapSearchProps> = ({ const MapSearch: React.FC<IMapSearchProps> = ({
name, searchParams,
placeholder,
value,
onChange,
options,
setMapSearch,
setLatLng,
}: IMapSearchProps) => { }: IMapSearchProps) => {
const router = useRouter();
const [searchMap, setSearchMap] = useState<string>(
searchParams["поиск-на-карте"] || ""
);
const [query] = useDebounce(searchMap, 500);
const {
setLatLng,
searchData: options,
getLocations,
setDisplayLocation,
} = useMapStore();
const handleSubmit = ( const handleSubmit = (
display_name: string, display_name: string,
latLng: { lat: number; lng: number } latLng: { lat: number; lng: number }
) => { ) => {
setMapSearch(display_name); setSearchMap(display_name);
setDisplayLocation(display_name);
setLatLng(latLng); setLatLng(latLng);
window.scrollTo({
top: 1400,
behavior: "smooth",
});
}; };
useEffect(() => {
getLocations(searchMap);
router.push(
`?тип-дороги=${
searchParams["тип-дороги"] || "1,2,3,4,5,6"
}&поиск-на-карте=${searchMap}&поиск-рейтинг=${
searchParams["поиск-рейтинг"] || ""
}&страница-рейтинга=${searchParams["страница-рейтинга"] || ""}`,
{ scroll: false }
);
}, [query]);
return ( return (
<div className="map-search"> <div className="map-search">
<form <form
onSubmit={(e) => { onSubmit={(e: React.MouseEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
handleSubmit(options[0].display_name, { handleSubmit(options[0].display_name, {
lat: +options[0].lat, lat: +options[0].lat,
@ -42,10 +75,10 @@ const MapSearch: React.FC<IMapSearchProps> = ({
<div className="map-search__input"> <div className="map-search__input">
<Image src={search} alt="Search Icon" /> <Image src={search} alt="Search Icon" />
<input <input
onChange={onChange} value={searchMap}
value={value} placeholder="Введите город, село или регион"
placeholder={placeholder} onChange={(e) => setSearchMap(e.target.value)}
name={name} name="map-search"
type="text" type="text"
/> />
</div> </div>
@ -59,11 +92,16 @@ const MapSearch: React.FC<IMapSearchProps> = ({
<li key={opt.place_id}> <li key={opt.place_id}>
<button <button
onClick={() => { onClick={() => {
setMapSearch(opt.display_name); setSearchMap(opt.display_name);
setDisplayLocation(opt.display_name);
setLatLng({ setLatLng({
lat: +opt.lat, lat: +opt.lat,
lng: +opt.lon, lng: +opt.lon,
}); });
window.scrollTo({
top: 1400,
behavior: "smooth",
});
}} }}
> >
{opt.display_name} {opt.display_name}

View File

@ -0,0 +1,7 @@
"use server";
export const handleSearch = async (formData: FormData) => {
"use server";
console.log(formData.get("map-search"));
};

View File

@ -1,96 +1,63 @@
"use client"; import Typography from "@/shared/ui/components/Typography/Typography";
import "./MapSection.scss"; import "./MapSection.scss";
import { useEffect, useState } from "react"; import Paragraph from "@/shared/ui/components/Paragraph/Paragraph";
import MapSearch from "./MapSearch/MapSearch";
import dynamic from "next/dynamic";
import { apiInstance } from "@/shared/config/apiConfig";
import { IReport } from "@/shared/types/report-type";
import Switch from "./Switch/Switch";
import { import {
ROAD_TYPES, ROAD_TYPES,
ROAD_TYPES_COLORS, ROAD_TYPES_COLORS,
} from "@/shared/variables/road-types"; } from "@/shared/variables/road-types";
import HomeMap from "./HomeMap/HomeMap";
import { useRouter } from "next/navigation"; const DynamicMap = dynamic(() => import("./HomeMap/HomeMap"), {
import { useMapStore } from "./mapSectionStore"; ssr: false,
import Switch from "./Switch/Switch"; });
import { useShallow } from "zustand/react/shallow";
import Typography from "@/shared/ui/components/Typography/Typography";
import Paragraph from "@/shared/ui/components/Paragraph/Paragraph";
import { useDebounce } from "use-debounce";
import MapSearch from "./MapSearch/MapSearch";
interface IMapSectionProps { interface IMapSectionProps {
[key: string]: string; searchParams: {
["тип-дороги"]: string;
["поиск-на-карте"]: string;
["поиск-рейтинг"]: string;
["страница-рейтинга"]: string;
};
} }
interface ILatLng { const MapSection: React.FC<IMapSectionProps> = async ({
lat: number; searchParams,
lng: number;
}
const MapSection: React.FC<IMapSectionProps> = ({
categories = "1,2,3,4,5,6",
queryMap,
queryRating,
page = "1",
}: IMapSectionProps) => { }: IMapSectionProps) => {
const [mapSearch, setMapSearch] = useState<string>(queryMap || ""); const getReports = async (categories: string) => {
const [latLng, setLatLng] = useState<ILatLng>({ const res = await apiInstance<IReport[]>(
lat: 42.8746, `/report/?category=${categories}`
lng: 74.606,
});
const [query] = useDebounce(mapSearch, 500);
const data = useMapStore(useShallow((state) => state.data));
const searchedData = useMapStore(
useShallow((state) => state.searchData)
);
const getReports = useMapStore(
useShallow((state) => state.getReports)
);
const getLocations = useMapStore(
useShallow((state) => state.getLocations)
);
const router = useRouter();
useEffect(() => {
getReports(categories);
}, [categories]);
useEffect(() => {
router.push(
`/?тип-дороги=${categories}${
mapSearch ? `&поиск-на-карте=${mapSearch}` : ""
}${
queryRating ? `&поиск-рейтинг=${queryRating}` : ""
}&страница-рейтинга=${page}`,
{
scroll: false,
}
); );
getLocations(query); return res.data;
}, [categories, query, queryRating]); };
const setSearchParams = (category: string) => { const data = await getReports(
const availableCategories = ["1", "2", "3", "4", "5", "6"]; searchParams["тип-дороги"] || "1,2,3,4,5,6"
);
if (!categories || !availableCategories.includes(category)) const setCategories = (category: string) => {
return categories; if (searchParams["тип-дороги"] === undefined) {
const categories = ["1", "2", "3", "4", "5", "6"];
if (categories?.includes(category)) { return categories.filter((cat) => cat !== category).join(",");
const updatedCategories = categories
?.replace(category + ",", "")
.replace("," + category, "")
.replace(category, "");
return updatedCategories;
} else {
const newValue = category + ",";
const updatedCategories = newValue + categories;
return updatedCategories;
} }
const categories = Array.from(searchParams["тип-дороги"]).filter(
(part) => part !== ","
);
if (categories.includes(category))
return categories.filter((cat) => cat !== category).join(",");
categories.push(category);
return categories.join(",");
}; };
return ( return (
<section className="map-section"> <div className="map-section">
<div className="map-section__header"> <div className="map-section__header">
<Typography element="h3">Карта дорог</Typography> <Typography element="h3">Карта дорог</Typography>
<Paragraph> <Paragraph>
@ -99,48 +66,71 @@ const MapSection: React.FC<IMapSectionProps> = ({
</Paragraph> </Paragraph>
</div> </div>
<MapSearch
setLatLng={setLatLng}
setMapSearch={setMapSearch}
options={searchedData}
onChange={(e) => setMapSearch(e.target.value)}
name="map-search"
value={mapSearch}
placeholder="Введите город, село или регион"
/>
<div className="map-section__categories"> <div className="map-section__categories">
<ul className="map-section__categories_left"> <ul>
{[1, 2, 3].map((sw) => ( {[1, 2, 3].map((sw) => (
<li key={sw}> <li key={sw}>
<Switch <Switch
href={`/?тип-дороги=${setSearchParams( defaultState={
sw.toString() searchParams["тип-дороги"]
)}`} ? searchParams["тип-дороги"]?.includes(
sw.toString()
)
: true
}
color={ROAD_TYPES_COLORS[sw]} color={ROAD_TYPES_COLORS[sw]}
defaultState={categories.includes(sw.toString())} href={`?${new URLSearchParams({
["тип-дороги"]: setCategories(
sw.toString()
) as string,
["поиск-на-карте"]:
searchParams["поиск-на-карте"] || "",
["поиск-рейтинг"]:
searchParams["поиск-рейтинг"] || "",
["страница-рейтинга"]:
searchParams["страница-рейтинга"] || "",
})}`}
/> />
<label>{ROAD_TYPES[sw]}</label> <p>{ROAD_TYPES[sw]}</p>
</li> </li>
))} ))}
</ul> </ul>
<ul className="map-section__categories_right"> <ul>
{[4, 5, 6].map((sw) => ( {[4, 5, 6].map((sw) => (
<li key={sw}> <li key={sw}>
<Switch <Switch
href={`/?тип-дороги=${setSearchParams( defaultState={
sw.toString() searchParams["тип-дороги"]
)}`} ? searchParams["тип-дороги"]?.includes(
sw.toString()
)
: true
}
color={ROAD_TYPES_COLORS[sw]} color={ROAD_TYPES_COLORS[sw]}
defaultState={categories.includes(sw.toString())} href={`?${new URLSearchParams({
["тип-дороги"]: setCategories(
sw.toString()
) as string,
["поиск-на-карте"]:
searchParams["поиск-на-карте"] || "",
["поиск-рейтинг"]:
searchParams["поиск-рейтинг"] || "",
["страница-рейтинга"]:
searchParams["страница-рейтинга"] || "",
})}`}
/> />
<label>{ROAD_TYPES[sw]}</label> <p>{ROAD_TYPES[sw]}</p>
</li> </li>
))} ))}
</ul> </ul>
</div> </div>
<HomeMap data={data.results} latLng={latLng} /> <MapSearch searchParams={searchParams} />
</section>
<div className="map-section__categories"></div>
<DynamicMap reports={data} />
</div>
); );
}; };

View File

@ -5,7 +5,7 @@ import "./Switch.scss";
import Link from "next/link"; import Link from "next/link";
interface ISwitchProps { interface ISwitchProps {
defaultState?: boolean; defaultState: boolean;
color?: string; color?: string;
href: string; href: string;
} }
@ -25,7 +25,7 @@ const Switch: React.FC<ISwitchProps> = ({
onClick={() => setToggleSwitch((prev) => !prev)} onClick={() => setToggleSwitch((prev) => !prev)}
style={{ style={{
backgroundColor: !toggleSwitch backgroundColor: !toggleSwitch
? "rgb(50, 48, 58)" ? "rgb(71, 85, 105)"
: color : color
? color ? color
: "rgb(230, 68, 82)", : "rgb(230, 68, 82)",

View File

@ -1,46 +1,22 @@
import { apiInstance } from "@/shared/config/apiConfig";
import { IFetch } from "@/shared/types/fetch-type";
import { IList } from "@/shared/types/list-type";
import { IDisplayMap } from "@/shared/types/map-type"; import { IDisplayMap } from "@/shared/types/map-type";
import { IReport } from "@/shared/types/report-type";
import axios from "axios"; import axios from "axios";
import { create } from "zustand"; import { create } from "zustand";
interface IFetchReports extends IList { interface IMapStore {
results: IReport[]; setLatLng: (latLng: { lat: number; lng: number }) => void;
} latLng: { lat: number; lng: number };
interface IMapStore extends IFetch {
data: IFetchReports;
searchData: IDisplayMap[];
getReports: (categories: string) => Promise<void>;
getLocations: (query: string) => void; getLocations: (query: string) => void;
searchData: IDisplayMap[];
setDisplayLocation: (display_location: string) => void;
display_location: string;
} }
export const useMapStore = create<IMapStore>((set) => ({ export const useMapStore = create<IMapStore>((set) => ({
data: {
count: 0,
previous: null,
next: null,
results: [],
},
searchData: [], searchData: [],
isLoading: false, display_location: "",
error: "", latLng: {
getReports: async (categories: string = "1,2,3,4,5,6") => { lat: 42.8746,
try { lng: 74.606,
set({ isLoading: true });
const response = await apiInstance.get<IFetchReports>(
`/report/?category=${categories}`
);
set({ data: response.data });
} catch (error: any) {
set({ error: error.message });
} finally {
set({ isLoading: false });
}
}, },
getLocations: async (query: string = "") => { getLocations: async (query: string = "") => {
const params: Record<string, any> = { const params: Record<string, any> = {
@ -60,4 +36,10 @@ export const useMapStore = create<IMapStore>((set) => ({
set({ searchData: inKG }); set({ searchData: inKG });
}, },
setLatLng: (latLng: { lat: number; lng: number }) => {
set({ latLng: latLng });
},
setDisplayLocation: (display_location: string) => {
set({ display_location: display_location });
},
})); }));

View File

@ -0,0 +1,38 @@
.news-list {
display: flex;
flex-direction: column;
gap: 20px;
ul {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 30px;
}
}
@media screen and (max-width: 1024px) {
.news-list {
ul {
grid-template-columns: 1fr 1fr 1fr;
}
}
}
@media screen and (max-width: 768px) {
.news-list {
gap: 30px;
ul {
grid-template-columns: 1fr 1fr;
}
}
}
@media screen and (max-width: 550px) {
.news-list {
gap: 20px;
ul {
grid-template-columns: 1fr;
}
}
}

View File

@ -0,0 +1,54 @@
"use client";
import "./NewsList.scss";
import NewsCard from "@/entities/NewsCard/NewsCard";
import { useNewsStore } from "./newsStore";
import { useEffect, useState } from "react";
import Pagination from "@/features/Pagination/Pagination";
interface INewsListProps {
searchParams: {
["страница-новостей"]: string;
};
}
const NewsList: React.FC<INewsListProps> = ({
searchParams,
}: INewsListProps) => {
const [activePage, setActivePage] = useState<number>(
+searchParams["страница-новостей"] || 1
);
const { data: news, getNews, isLoading, error } = useNewsStore();
useEffect(() => {
getNews(activePage);
}, []);
return (
<div className="news-list">
<ul>
{news.results.map((news) => (
<li key={news.id}>
<NewsCard
id={news.id}
title={news.title}
description={news.description}
image={news.image}
date={news.created_at}
/>
</li>
))}
</ul>
<Pagination
activePage={activePage}
setActivePage={setActivePage}
prev={news.previous}
next={news.next}
count={news.count as number}
current_count={news.results.length}
limit={20}
/>
</div>
);
};
export default NewsList;

View File

@ -0,0 +1,38 @@
import { apiInstance } from "@/shared/config/apiConfig";
import { IFetch } from "@/shared/types/fetch-type";
import { INewsList } from "@/shared/types/news-type";
import { AxiosError } from "axios";
import { create } from "zustand";
interface useNewsStore extends IFetch {
data: INewsList;
getNews: (page: number) => void;
}
export const useNewsStore = create<useNewsStore>((set) => ({
data: {
count: 0,
previous: null,
next: null,
results: [],
},
error: "",
isLoading: false,
getNews: async (page: number) => {
try {
set({ isLoading: true });
const res = await apiInstance.get(`/news/?page${page}`);
set({ data: res.data });
} catch (error: unknown) {
if (error instanceof AxiosError) {
set({ error: error.message });
} else {
set({ error: "An error ocured" });
}
} finally {
set({ isLoading: false });
}
},
}));

View File

@ -1,7 +1,7 @@
import { apiInstance } from "@/shared/config/apiConfig"; import { apiInstance } from "@/shared/config/apiConfig";
import { IList } from "@/shared/types/list-type"; import { IList } from "@/shared/types/list-type";
import { INews } from "@/shared/types/news-type"; import { INews } from "@/shared/types/news-type";
import axios, { AxiosError } from "axios"; import { AxiosError } from "axios";
interface IFetchNews extends IList { interface IFetchNews extends IList {
results: INews[]; results: INews[];

View File

@ -5,7 +5,13 @@ import LogoutButton from "@/features/LogoutButton/LogoutButton";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
const ProfileNav = () => { interface IProfileNavProps {
report_count: number;
}
const ProfileNav: React.FC<IProfileNavProps> = ({
report_count,
}: IProfileNavProps) => {
const pathname = usePathname(); const pathname = usePathname();
return ( return (
<nav className="profile-nav"> <nav className="profile-nav">
@ -30,7 +36,7 @@ const ProfileNav = () => {
> >
Мои обращения Мои обращения
</Link> </Link>
<span>3</span> <span>{report_count}</span>
</div> </div>
{pathname === "/profile/personal" ? ( {pathname === "/profile/personal" ? (

View File

@ -76,8 +76,11 @@
} }
#my-report-location { #my-report-location {
font-size: 20px; a {
color: rgb(50, 48, 58); display: flex;
font-size: 20px;
color: rgb(50, 48, 58);
}
} }
#my-report-status { #my-report-status {
@ -102,7 +105,7 @@
color: rgb(102, 102, 102); color: rgb(102, 102, 102);
} }
a { #my-reports-link {
display: none; display: none;
padding: 10px; padding: 10px;
border-radius: 8px; border-radius: 8px;

View File

@ -1,33 +1,122 @@
"use client";
import Image from "next/image"; import Image from "next/image";
import "./ProfileTable.scss"; import "./ProfileTable.scss";
import arrows from "@/shared/icons/arrows.svg"; import arrows from "@/shared/icons/arrows.svg";
import { IMyReportsList } from "@/shared/types/my-reports";
import { import {
REPORT_STATUS, REPORT_STATUS,
REPORT_STATUS_COLORS, REPORT_STATUS_COLORS,
} from "@/shared/variables/report-status"; } from "@/shared/variables/report-status";
import Link from "next/link"; import Link from "next/link";
import Pagination from "@/features/Pagination/Pagination";
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import { sliceDate } from "./helpers";
import { useProfileReportsStore } from "./profile-reports.store";
import { useRouter } from "next/navigation";
interface IProfileTableProps { interface IProfileTableProps {
reports: IMyReportsList; searchParams: { ["страница-обращений"]: string };
} }
const ProfileTable: React.FC<IProfileTableProps> = ({ const ProfileTable: React.FC<IProfileTableProps> = ({
reports, searchParams,
}: IProfileTableProps) => { }: IProfileTableProps) => {
const {
getMyReports,
data: reports,
isLoading,
error,
} = useProfileReportsStore();
const session = useSession();
const router = useRouter();
const [filter, setFilter] = useState({
option: "date",
toggle: true,
});
const [activePage, setActivePage] = useState<number>(
+searchParams["страница-обращений"] || 1
);
const params = [ const params = [
{ param: "Дата", handleClick() {} }, {
param: "Дата",
handleClick() {
if (filter.option !== "date") {
return setFilter({ option: "date", toggle: false });
}
setFilter((prev) => {
return { option: "date", toggle: !prev.toggle };
});
getMyReports(
filter,
activePage,
session.data?.access_token as string
);
},
},
{ param: "Адрес" }, { param: "Адрес" },
{ param: "Статус" }, { param: "Статус" },
{ param: "Комментарии", handleClick() {} }, {
{ param: "Рейтинг", handleClick() {} }, param: "Комментарии",
handleClick() {
if (filter.option !== "count_reviews") {
return setFilter({
option: "count_reviews",
toggle: false,
});
}
setFilter((prev) => {
return { option: "count_reviews", toggle: !prev.toggle };
});
getMyReports(
filter,
activePage,
session.data?.access_token as string
);
},
},
{
param: "Рейтинг",
handleClick() {
if (filter.option !== "total_likes") {
return setFilter({ option: "total_likes", toggle: false });
}
setFilter((prev) => {
return { option: "total_likes", toggle: !prev.toggle };
});
getMyReports(
filter,
activePage,
session.data?.access_token as string
);
},
},
]; ];
const sliceDate = (date: string) => {
return `${date.slice(8, 10)}.${date.slice(5, 7)}.${date.slice( useEffect(() => {
0, if (session.status === "loading") return;
4 getMyReports(
)}`; filter,
}; activePage,
session.data?.access_token as string
);
}, [session]);
useEffect(() => {
router.push(
`/profile/my-reports?страница-обращений=${activePage}`,
{ scroll: false }
);
getMyReports(
filter,
activePage,
session.data?.access_token as string
);
}, [activePage]);
return ( return (
<div className="profile-table"> <div className="profile-table">
<div <div
@ -42,7 +131,7 @@ const ProfileTable: React.FC<IProfileTableProps> = ({
{params.map((p) => ( {params.map((p) => (
<td key={p.param}> <td key={p.param}>
{p.handleClick ? ( {p.handleClick ? (
<button> <button onClick={p.handleClick}>
{p.param}{" "} {p.param}{" "}
<Image src={arrows} alt="Arrows Icon" /> <Image src={arrows} alt="Arrows Icon" />
</button> </button>
@ -60,7 +149,13 @@ const ProfileTable: React.FC<IProfileTableProps> = ({
{sliceDate(report.created_at)} {sliceDate(report.created_at)}
</td> </td>
<td id="my-report-location"> <td id="my-report-location">
{report.location[0].address} {report.status === 2 ? (
<Link href={`/report/${report.id}`}>
{report.location[0].address}
</Link>
) : (
report.location[0].address
)}
</td> </td>
<td <td
id="my-report-status" id="my-report-status"
@ -84,6 +179,15 @@ const ProfileTable: React.FC<IProfileTableProps> = ({
</p> </p>
)} )}
</div> </div>
<Pagination
activePage={activePage}
setActivePage={setActivePage}
count={reports.count as number}
prev={reports.previous}
next={reports.next}
current_count={reports.results.length}
limit={8}
/>
<Link id="my-reports-link" href="/create-report"> <Link id="my-reports-link" href="/create-report">
Написать обращение Написать обращение
</Link> </Link>

View File

@ -0,0 +1,6 @@
export const sliceDate = (date: string) => {
return `${date.slice(8, 10)}.${date.slice(5, 7)}.${date.slice(
0,
4
)}`;
};

View File

@ -0,0 +1,106 @@
import { apiInstance } from "@/shared/config/apiConfig";
import { IFetch } from "@/shared/types/fetch-type";
import {
IMyReports,
IMyReportsList,
} from "@/shared/types/my-reports";
import { AxiosError } from "axios";
import { create } from "zustand";
const filterCategories: Record<string, string> = {
count_reviews: "count_reviews",
total_likes: "total_likes",
};
interface IProfileReportsStore extends IFetch {
data: IMyReportsList;
getMyReports: (
filter: { option: string; toggle: boolean },
page: number,
access_token: string
) => void;
}
export const useProfileReportsStore = create<IProfileReportsStore>(
(set) => ({
isLoading: false,
error: "",
data: {
count: 0,
previous: null,
next: null,
results: [],
},
getMyReports: async (
filter: {
option: string;
toggle: boolean;
},
page: number,
access_token: string
) => {
try {
const Authorization = `Bearer ${access_token}`;
const config = {
headers: {
Authorization,
},
};
set({ isLoading: true });
const res = await apiInstance.get<IMyReportsList>(
`/users/reports/?page=${page}`,
config
);
let data = res.data;
if (filter.option === "date" && filter.toggle === false) {
data.results = data.results.sort((a, b) => {
const dateA = new Date(a.created_at) as unknown as number;
const dateB = new Date(b.created_at) as unknown as number;
return dateA - dateB;
});
} else if (
filter.option === "date" &&
filter.toggle === true
) {
data.results = data.results.sort((a, b) => {
const dateA = new Date(a.created_at) as unknown as number;
const dateB = new Date(b.created_at) as unknown as number;
return dateB - dateA;
});
}
if (
filter.option === filterCategories[filter.option] &&
filter.toggle === false
) {
data.results = data.results.sort((a, b) => {
const optionKey = filter.option as keyof IMyReports;
return (Number(a[optionKey]) -
Number(b[optionKey])) as number;
});
} else if (
filter.option === "rating" &&
filter.toggle === true
) {
data.results = data.results.sort((a, b) => {
const optionKey = filter.option as keyof IMyReports;
return (Number(a[optionKey]) -
Number(b[optionKey])) as number;
});
}
set({ data: data });
} catch (error: unknown) {
if (error instanceof AxiosError) {
set({ error: error.message });
} else {
set({ error: "an error ocured" });
}
} finally {
set({ isLoading: false });
}
},
})
);

View File

@ -25,19 +25,23 @@ import {
} from "./helpers"; } from "./helpers";
interface IRatingSectionProps { interface IRatingSectionProps {
[key: string]: string; searchParams: {
["тип-дороги"]: string;
["поиск-на-карте"]: string;
["поиск-рейтинг"]: string;
["страница-рейтинга"]: string;
};
} }
const RatingSection: React.FC<IRatingSectionProps> = ({ const RatingSection: React.FC<IRatingSectionProps> = ({
categories = "1,2,3,4,5,6", searchParams,
queryMap,
queryRating,
page = "1",
}: IRatingSectionProps) => { }: IRatingSectionProps) => {
const [ratingSearch, setRatingSearch] = useState<string>( const [ratingSearch, setRatingSearch] = useState<string>(
queryRating || "" searchParams["поиск-рейтинг"] || ""
);
const [activePage, setActivePage] = useState<number>(
+searchParams["страница-рейтинга"] || 1
); );
const [activePage, setActivePage] = useState<number>(+page);
const [filter, setFilter] = useState({ const [filter, setFilter] = useState({
option: "date", option: "date",
toggle: false, toggle: false,
@ -50,7 +54,7 @@ const RatingSection: React.FC<IRatingSectionProps> = ({
); );
useEffect(() => { useEffect(() => {
getReports(ratingSearch, +page, filter); getReports(ratingSearch, activePage, filter);
}, []); }, []);
const handleSubmit: React.FormEventHandler< const handleSubmit: React.FormEventHandler<
@ -61,11 +65,13 @@ const RatingSection: React.FC<IRatingSectionProps> = ({
setRatingSearch(formData.get("rating-search") as string); setRatingSearch(formData.get("rating-search") as string);
router.push( router.push(
`/?тип-дороги=${categories}${ `/?тип-дороги=${searchParams["тип-дороги"] || "1,2,3,4,5,6"}${
queryMap ? `&поиск-на-карте=${queryMap}` : "" searchParams["поиск-на-карте"]
? `&поиск-на-карте=${searchParams["поиск-на-карте"] || ""}`
: ""
}${ }${
ratingSearch ? `&поиск-рейтинг=${ratingSearch}` : "" ratingSearch ? `&поиск-рейтинг=${ratingSearch || ""}` : ""
}&страница-рейтинга=${page}`, }&страница-рейтинга=${searchParams["страница-рейтинга"]}`,
{ {
scroll: false, scroll: false,
} }
@ -73,16 +79,21 @@ const RatingSection: React.FC<IRatingSectionProps> = ({
getReports(ratingSearch, activePage, filter); getReports(ratingSearch, activePage, filter);
if (reports.results.length < 8 && page !== "1") { if (
reports.results.length < 8 &&
searchParams["страница-рейтинга"] !== "1"
) {
setActivePage(1); setActivePage(1);
} }
}; };
useEffect(() => { useEffect(() => {
router.push( router.push(
`/?тип-дороги=${categories}${ `/?тип-дороги=${searchParams["тип-дороги"] || "1,2,3,4,5,6"}${
queryMap ? `&поиск-на-карте=${queryMap}` : "" searchParams["поиск-на-карте"]
}${ratingSearch ? `&поиск-рейтинг=${ratingSearch}` : ""}${ ? `&поиск-на-карте=${searchParams["поиск-на-карте"] || ""}`
: ""
}${ratingSearch ? `&поиск-рейтинг=${ratingSearch || ""}` : ""}${
activePage === 1 ? "" : `&страница-рейтинг=${activePage}` activePage === 1 ? "" : `&страница-рейтинг=${activePage}`
}`, }`,
{ {
@ -216,6 +227,7 @@ const RatingSection: React.FC<IRatingSectionProps> = ({
next={reports.next} next={reports.next}
prev={reports.previous} prev={reports.previous}
current_count={reports.results.length} current_count={reports.results.length}
limit={8}
/> />
</section> </section>
); );

View File

@ -35,7 +35,9 @@ export const useRatingStore = create<IRatingStore>((set) => ({
set({ isLoading: true }); set({ isLoading: true });
const data = ( const data = (
await apiInstance.get<IFetchReports>(`/report/?page=${page}`) await apiInstance.get<IFetchReports>(
`/report/?page=${page}&page_size=${8}`
)
).data; ).data;
const searched = data.results.filter((rating) => { const searched = data.results.filter((rating) => {

View File

@ -44,6 +44,14 @@
line-height: 22px; line-height: 22px;
color: rgb(197, 198, 197); color: rgb(197, 198, 197);
} }
p {
color: rgb(240, 68, 56);
}
}
&__error {
color: rgb(240, 68, 56);
} }
&__map { &__map {
@ -102,6 +110,10 @@
color: rgb(102, 102, 102); color: rgb(102, 102, 102);
} }
&-error {
color: rgb(240, 68, 56) !important;
}
input[type="file"] { input[type="file"] {
display: none; display: none;
} }

View File

@ -6,7 +6,6 @@ import Image from "next/image";
import { import {
MapContainer, MapContainer,
Marker, Marker,
Popup,
TileLayer, TileLayer,
useMapEvents, useMapEvents,
} from "react-leaflet"; } from "react-leaflet";
@ -16,9 +15,12 @@ import pin_image from "./icons/pin-image.svg";
import { ChangeEventHandler, useState } from "react"; import { ChangeEventHandler, useState } from "react";
import { Icon } from "leaflet"; import { Icon } from "leaflet";
import pin_icon from "./icons/pin_icon.svg"; import pin_icon from "./icons/pin_icon.svg";
import axios from "axios"; import axios, { AxiosError } from "axios";
import { apiInstance } from "@/shared/config/apiConfig"; import { apiInstance } from "@/shared/config/apiConfig";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { IDisplayMap } from "@/shared/types/map-type";
import { useRouter } from "next/navigation";
import Loader from "@/shared/ui/components/Loader/Loader";
interface ILatLng { interface ILatLng {
lat: number; lat: number;
@ -27,6 +29,7 @@ interface ILatLng {
const ReportForm = () => { const ReportForm = () => {
const session = useSession(); const session = useSession();
const router = useRouter();
const [latLng, setLatLng] = useState<ILatLng[]>([]); const [latLng, setLatLng] = useState<ILatLng[]>([]);
const [displayLatLng, setDisplayLatLng] = useState<string[]>([]); const [displayLatLng, setDisplayLatLng] = useState<string[]>([]);
const [images, setImages] = useState<File[]>([]); const [images, setImages] = useState<File[]>([]);
@ -34,6 +37,12 @@ const ReportForm = () => {
lat: 42.8746, lat: 42.8746,
lng: 74.606, lng: 74.606,
}; };
const [loader, setLoader] = useState<boolean>(false);
const [locationWarning, setLocationWarning] = useState<string>("");
const [descriptionWarning, setDescriptionWarning] =
useState<string>("");
const [imageWarning, setImageWarning] = useState<string>("");
const [error, setError] = useState<string>("");
const customIcon = new Icon({ const customIcon = new Icon({
iconUrl: pin_icon.src, iconUrl: pin_icon.src,
@ -55,13 +64,45 @@ const ReportForm = () => {
HTMLFormElement HTMLFormElement
> = async (e) => { > = async (e) => {
e.preventDefault(); e.preventDefault();
const formData = new FormData(e.currentTarget);
if (displayLatLng.length === 0) {
setDescriptionWarning("");
setImageWarning("");
setError("");
setLocationWarning("Выберите хотя бы одну точку!");
return;
}
if (!formData.get("description")) {
setLocationWarning("");
setImageWarning("");
setError("");
setDescriptionWarning("Пожалуйста опишите свое обращение");
return;
}
if (images.length === 0) {
setLocationWarning("");
setDescriptionWarning("");
setError("");
setImageWarning("Загрузите по меньшей мере одну фотографию");
return;
}
setLocationWarning("");
setDescriptionWarning("");
setError("");
setImageWarning("");
const Authorization = `Bearer ${session.data?.access_token}`; const Authorization = `Bearer ${session.data?.access_token}`;
const config = { const config = {
headers: { headers: {
Authorization, Authorization,
}, },
}; };
const formData = new FormData(e.currentTarget);
images.forEach((image) => { images.forEach((image) => {
formData.append("image", image); formData.append("image", image);
@ -70,13 +111,27 @@ const ReportForm = () => {
formData.append("longitude1", latLng[0].lng.toString()); formData.append("longitude1", latLng[0].lng.toString());
formData.append("latitude2", latLng[1].lat.toString()); formData.append("latitude2", latLng[1].lat.toString());
formData.append("longitude2", latLng[1].lng.toString()); formData.append("longitude2", latLng[1].lng.toString());
const res = await apiInstance.post(
"/report/create/",
formData,
config
);
console.log(res.status); try {
setLoader(true);
const res = await apiInstance.post(
"/report/create/",
formData,
config
);
if ([200, 201].includes(res.status)) {
router.push("/");
}
} catch (error: unknown) {
if (error instanceof AxiosError) {
setError(error.message);
} else {
setError("Произошла непредвиденная ошибка");
}
} finally {
setLoader(false);
}
}; };
const MapPins = (e: any) => { const MapPins = (e: any) => {
@ -100,8 +155,32 @@ const ReportForm = () => {
}, },
}); });
const removeMarker = (latLng: { lat: number; lng: number }) => {
setLatLng((prev) => prev.filter((l) => l !== latLng));
axios
.get<IDisplayMap>(
`https://nominatim.openstreetmap.org/reverse?lat=${latLng.lat}&lon=${latLng.lng}&format=json`
)
.then((res) =>
setDisplayLatLng((prev) =>
prev.filter((loc) => loc !== res.data.display_name)
)
)
.catch((error) => console.log(error));
};
return latLng.map((l) => ( return latLng.map((l) => (
<Marker key={l.lat} icon={customIcon} position={l} /> <Marker
eventHandlers={{
click: () => {
removeMarker(l);
},
}}
key={l.lat}
icon={customIcon}
position={l}
/>
)); ));
}; };
@ -115,6 +194,7 @@ const ReportForm = () => {
placeholder="Отметьте точки на карте" placeholder="Отметьте точки на карте"
type="text" type="text"
/> />
{locationWarning ? <p>{locationWarning}</p> : null}
</div> </div>
<div className="report-form__map"> <div className="report-form__map">
@ -146,6 +226,7 @@ const ReportForm = () => {
<div className="report-form__input"> <div className="report-form__input">
<label>Добавьте описание проблемы</label> <label>Добавьте описание проблемы</label>
<textarea name="description" placeholder="Введите описание" /> <textarea name="description" placeholder="Введите описание" />
{descriptionWarning ? <p>{descriptionWarning}</p> : null}
</div> </div>
<div className="report-form__add-images"> <div className="report-form__add-images">
@ -162,6 +243,12 @@ const ReportForm = () => {
Прикрепить файл Прикрепить файл
<span>(до 5 МБ)</span> <span>(до 5 МБ)</span>
</label> </label>
{imageWarning ? (
<p className="report-form__add-images-error">
{imageWarning}
</p>
) : null}
<input <input
onChange={handleImages} onChange={handleImages}
multiple multiple
@ -184,10 +271,11 @@ const ReportForm = () => {
</div> </div>
) : null} ) : null}
</div> </div>
<button type="submit"> <button disabled={loader} type="submit">
Отправить на модерацию {loader ? <Loader /> : "Отправить на модерацию"}
<Image src={arrow_right} alt="Arrow Right Icon" /> <Image src={arrow_right} alt="Arrow Right Icon" />
</button> </button>
{error ? <p className="report-form__error">{error}</p> : null}
</form> </form>
); );
}; };

View File

@ -104,7 +104,7 @@ const ReviewSection: React.FC<IReviewsSectionProps> = ({
/> />
<div className="review__header"> <div className="review__header">
<h5 className="review__author-name"> <h5 className="review__author-name">
{review.author.first_name} {review.author.first_name}{" "}
{review.author.last_name} {review.author.last_name}
</h5> </h5>
<div className="review__date"> <div className="review__date">

View File

@ -24,6 +24,14 @@
line-height: 24px; line-height: 24px;
color: $gray-900; color: $gray-900;
} }
button:hover {
background-color: #f5f5f5;
}
&-current {
background-color: #f5f5f5;
}
} }
&__wrapper { &__wrapper {
@ -98,6 +106,15 @@
color: black; color: black;
} }
} }
#statistics-table__no-data-warning {
grid-template-columns: 1fr;
justify-content: center;
td {
text-align: center;
}
}
} }
} }
} }

View File

@ -7,66 +7,201 @@ import { useShallow } from "zustand/react/shallow";
import SearchForm from "@/features/SearchForm/SearchForm"; import SearchForm from "@/features/SearchForm/SearchForm";
import chevron_down from "./icons/chevron-down.svg"; import chevron_down from "./icons/chevron-down.svg";
import arrows from "@/shared/icons/arrows.svg"; import arrows from "@/shared/icons/arrows.svg";
import Image from "next/image"; import Image from "next/image";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useDebounce } from "use-debounce";
import { useRouter } from "next/navigation";
interface IStatisticsTableProps { interface IStatisticsTableProps {
firstData: IStatistics[] | string; searchParams: {
["поиск-населенного-пункта"]: string;
};
} }
const StatisticsTable: React.FC<IStatisticsTableProps> = ({ const StatisticsTable: React.FC<IStatisticsTableProps> = ({
firstData, searchParams,
}: IStatisticsTableProps) => { }: IStatisticsTableProps) => {
const { data, error, isLoading, getStatistics } = const router = useRouter();
useStatisticsStore(useShallow((state) => state)); const [location, setLocation] = useState<string>("city");
const [queryStatistics, setQueryStatistics] = useState<string>(
const [statistics, setStatistics] = useState<IStatistics[]>( searchParams["поиск-населенного-пункта"] || ""
typeof firstData === "string" ? [] : firstData
); );
const [query] = useDebounce(queryStatistics, 500);
const {
error,
isLoading,
getStatistics,
data: statistics,
} = useStatisticsStore(useShallow((state) => state));
const [filter, setFilter] = useState({
option: "broken_road",
toggle: false,
});
const [openPopup, setOpenPopup] = useState<boolean>(false); const [openPopup, setOpenPopup] = useState<boolean>(false);
const handleSubmit = () => {}; const handleSubmit = () => {};
const params = [ const params = [
{ param: "Добавлено дорог", handleSubmit() {} }, {
{ param: "Локальных дефектов", handleSubmit() {} }, param: "Добавлено дорог",
{ param: "Очагов аварийности", handleSubmit() {} }, async handleClick() {
{ param: "Локальных дефектов исправлено", handleSubmit() {} }, if (filter.option !== "broken_road_1") {
{ param: "В планах ремонта", handleSubmit() {} }, return setFilter({
{ param: "Отремонтировано", handleSubmit() {} }, option: "broken_road_1",
toggle: false,
});
}
setFilter((prev) => {
return { option: "broken_road_1", toggle: !prev.toggle };
});
getStatistics(location, filter, query);
},
},
{
param: "Локальных дефектов",
async handleClick() {
if (filter.option !== "local_defect_3") {
return setFilter({
option: "local_defect_3",
toggle: false,
});
}
setFilter((prev) => {
return { option: "local_defect_3", toggle: !prev.toggle };
});
getStatistics(location, filter, query);
},
},
{
param: "Очагов аварийности",
async handleClick() {
if (filter.option !== "hotbed_of_accidents_2") {
return setFilter({
option: "hotbed_of_accidents_2",
toggle: false,
});
}
setFilter((prev) => {
return {
option: "hotbed_of_accidents_2",
toggle: !prev.toggle,
};
});
getStatistics(location, filter, query);
},
},
{
param: "Локальных дефектов исправлено",
async handleClick() {
if (filter.option !== "local_defect_fixed_6") {
return setFilter({
option: "local_defect_fixed_6",
toggle: false,
});
}
setFilter((prev) => {
return {
option: "local_defect_fixed_6",
toggle: !prev.toggle,
};
});
getStatistics(location, filter, query);
},
},
{
param: "В планах ремонта",
async handleClick() {
if (filter.option !== "repair_plans_4") {
return setFilter({
option: "repair_plans_4",
toggle: false,
});
}
setFilter((prev) => {
return { option: "repair_plans_4", toggle: !prev.toggle };
});
getStatistics(location, filter, query);
},
},
{
param: "Отремонтировано",
async handleClick() {
if (filter.option !== "repaired_5") {
return setFilter({ option: "repaired_5", toggle: false });
}
setFilter((prev) => {
return { option: "repaired_5", toggle: !prev.toggle };
});
getStatistics(location, filter, query);
},
},
]; ];
const getStatsByLocation = async (location: string) => { const getStatsByLocation = async (location: string) => {
try { setLocation(location);
const data: IStatistics[] | undefined = await getStatistics( getStatistics(location, filter, query);
location console.error("Error fetching statistics:", error);
);
if (data === undefined)
throw new Error("Ошибка на стороне сервера");
setStatistics(data);
} catch (error) {
console.error("Error fetching statistics:", error);
}
}; };
useEffect(() => {
getStatistics(location, filter, query);
router.push(`/statistics?поиск-населенного-пункта=${query}`);
}, [query]);
return ( return (
<div className="statistics-table"> <div className="statistics-table">
<SearchForm <SearchForm
onChange={(e) => setQueryStatistics(e.target.value)}
value={queryStatistics}
placeholder="Введите населенный пункт"
style={{ width: "100%" }} style={{ width: "100%" }}
handleSubmit={handleSubmit} handleSubmit={handleSubmit}
/> />
{openPopup && ( {openPopup && (
<div className="statistics-table__popup"> <div className="statistics-table__popup">
<button onClick={() => getStatsByLocation("state")}> <button
className={
location === "state"
? "statistics-table__popup-current"
: ""
}
onClick={() => getStatsByLocation("state")}
>
Область Область
</button> </button>
<button onClick={() => getStatsByLocation("city")}> <button
className={
location === "city"
? "statistics-table__popup-current"
: ""
}
onClick={() => getStatsByLocation("city")}
>
Город Город
</button> </button>
<button onClick={() => getStatsByLocation("village")}> <button
className={
location === "village"
? "statistics-table__popup-current"
: ""
}
onClick={() => getStatsByLocation("village")}
>
Деревня Деревня
</button> </button>
</div> </div>
@ -84,7 +219,7 @@ const StatisticsTable: React.FC<IStatisticsTableProps> = ({
</td> </td>
{params.map((param) => ( {params.map((param) => (
<td key={param.param}> <td key={param.param}>
<button> <button onClick={param.handleClick}>
{param.param} {param.param}
<Image src={arrows} alt=" Arrows Icon" /> <Image src={arrows} alt=" Arrows Icon" />
</button> </button>
@ -93,17 +228,27 @@ const StatisticsTable: React.FC<IStatisticsTableProps> = ({
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{statistics.map((stat) => ( {statistics.length ? (
<tr key={stat.name}> statistics.map((stat) => (
<td id="statistics-table-stat-name">{stat.name}</td> <tr key={stat.name}>
<td>{stat.broken_road_1}</td> <td id="statistics-table-stat-name">{stat.name}</td>
<td>{stat.local_defect_3}</td> <td>{stat.broken_road_1}</td>
<td>{stat.hotbed_of_accidents_2}</td> <td>{stat.local_defect_3}</td>
<td>{stat.local_defect_fixed_6}</td> <td>{stat.hotbed_of_accidents_2}</td>
<td>{stat.repair_plans_4}</td> <td>{stat.local_defect_fixed_6}</td>
<td>{stat.repaired_5}</td> <td>{stat.repair_plans_4}</td>
<td>{stat.repaired_5}</td>
</tr>
))
) : (
<tr id="statistics-table__no-data-warning">
<td>
{error
? error
: "Oops, looks like there is no data"}
</td>
</tr> </tr>
))} )}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@ -1,29 +1,68 @@
import { apiInstance } from "@/shared/config/apiConfig"; import { apiInstance } from "@/shared/config/apiConfig";
import { IFetch } from "@/shared/types/fetch-type"; import { IFetch } from "@/shared/types/fetch-type";
import { IList } from "@/shared/types/list-type";
import { IStatistics } from "@/shared/types/statistics-type"; import { IStatistics } from "@/shared/types/statistics-type";
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import { create } from "zustand"; import { create } from "zustand";
const filterCategories: Record<string, string> = {
broken_road_1: "broken_road_1",
hotbed_of_accidents_2: "hotbed_of_accidents_2",
local_defect_3: "local_defect_3",
repair_plans_4: "repair_plans_4",
repaired_5: "repaired_5",
local_defect_fixed_6: "local_defect_fixed_6",
};
interface IStatisticsStore extends IFetch { interface IStatisticsStore extends IFetch {
data: IStatistics[];
getStatistics: ( getStatistics: (
endpoint: string endpoint: string,
) => Promise<IStatistics[] | undefined>; filter: { option: string; toggle: boolean },
query: string
) => void;
} }
export const useStatisticsStore = create<IStatisticsStore>((set) => ({ export const useStatisticsStore = create<IStatisticsStore>((set) => ({
isLoading: false, isLoading: false,
error: "", error: "",
data: [],
getStatistics: async ( getStatistics: async (
endpoint: string endpoint: string,
): Promise<IStatistics[] | undefined> => { filter: { option: string; toggle: boolean },
query: string = ""
) => {
try { try {
set({ isLoading: true }); set({ isLoading: true });
const response = await apiInstance.get<IStatistics[]>( const response = await apiInstance.get<IStatistics[]>(
`/report/${endpoint}/stats` `/report/${endpoint}/stats`
); );
return response.data; let data = response.data.filter((loc) =>
loc.name.toLowerCase().includes(query.toLowerCase())
);
if (
filter.option === filterCategories[filter.option] &&
filter.toggle === false
) {
data = data.sort((a, b) => {
const optionKey = filter.option as keyof IStatistics;
return (Number(a[optionKey]) -
Number(b[optionKey])) as number;
});
} else if (
filter.option === "rating" &&
filter.toggle === true
) {
data = data.sort((a, b) => {
const optionKey = filter.option as keyof IStatistics;
return (Number(a[optionKey]) -
Number(b[optionKey])) as number;
});
}
set({ data: data });
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof AxiosError) { if (error instanceof AxiosError) {
set({ error: error.message }); set({ error: error.message });

View File

@ -1,23 +1,112 @@
import { IUserRatings } from "@/shared/types/user-rating-type"; "use client";
import "./VolunteersTable.scss"; import "./VolunteersTable.scss";
import Image from "next/image"; import Image from "next/image";
import arrows from "@/shared/icons/arrows.svg"; import arrows from "@/shared/icons/arrows.svg";
import { useEffect, useState } from "react";
import { useVolunteersStore } from "./volunteers.store";
interface IVolunteersTableProps { const VolunteersTable = () => {
firstData: IUserRatings[] | string; const { data, isLoading, error, getVolunteers } =
} useVolunteersStore();
const [filter, setFilter] = useState({
const VolunteersTable: React.FC<IVolunteersTableProps> = ({ option: "report_count",
firstData, toggle: false,
}: IVolunteersTableProps) => { });
const params = [ const params = [
{ param: "№" }, { param: "№" },
{ param: "Активист" }, { param: "Активист" },
{ param: "Добавлено дорог", handleClick() {} }, {
{ param: "Получено голосов", handleClick() {} }, param: "Добавлено дорог",
{ param: "Оставлено голосов", handleClick() {} }, handleClick() {
{ param: "Рейтинг", handleClick() {} }, if (filter.option !== "report_count") {
return setFilter({
option: "report_count",
toggle: false,
});
}
setFilter((prev) => {
return { option: "report_count", toggle: !prev.toggle };
});
getVolunteers(filter);
},
},
{
param: "Получено голосов",
handleClick() {
if (filter.option !== "likes_given_count") {
return setFilter({
option: "likes_given_count",
toggle: false,
});
}
setFilter((prev) => {
return {
option: "likes_given_count",
toggle: !prev.toggle,
};
});
getVolunteers(filter);
},
},
{
param: "Оставлено голосов",
handleClick() {
if (filter.option !== "likes_received_count") {
return setFilter({
option: "likes_received_count",
toggle: false,
});
}
setFilter((prev) => {
return {
option: "likes_received_count",
toggle: !prev.toggle,
};
});
getVolunteers(filter);
},
},
{
param: "Рейтинг",
handleClick() {
if (filter.option !== "average_rating") {
return setFilter({
option: "average_rating",
toggle: false,
});
}
setFilter((prev) => {
return { option: "average_rating", toggle: !prev.toggle };
});
getVolunteers(filter);
},
},
]; ];
const hideEmail = (email: string) => {
const atIndex = email.indexOf("@");
if (atIndex !== -1) {
const prefix = email.slice(0, 3);
const domain = email.slice(atIndex);
return `${prefix}******${domain}`;
} else {
console.error("Invalid email format");
return email;
}
};
useEffect(() => {
getVolunteers(filter);
}, []);
return ( return (
<div className="volunteers-table"> <div className="volunteers-table">
<table> <table>
@ -26,7 +115,7 @@ const VolunteersTable: React.FC<IVolunteersTableProps> = ({
{params.map((param) => ( {params.map((param) => (
<td key={param.param}> <td key={param.param}>
{param.handleClick ? ( {param.handleClick ? (
<button> <button onClick={param.handleClick}>
{param.param} {param.param}
<Image src={arrows} alt="Arrows Icon" /> <Image src={arrows} alt="Arrows Icon" />
</button> </button>
@ -38,20 +127,18 @@ const VolunteersTable: React.FC<IVolunteersTableProps> = ({
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{typeof firstData === "string" ? ( {data.map((user, index) => (
<h3>{firstData}</h3> <tr key={user.user_id}>
) : ( <td>{index + 1}</td>
firstData.map((user, index) => ( <td id="volunteers-user-cell">
<tr key={user.user_id}> {hideEmail(user.username)}
<td>{index + 1}</td> </td>
<td id="volunteers-user-cell">{user.username}</td> <td>{user.report_count}</td>
<td>{user.report_count}</td> <td>{user.likes_received_count}</td>
<td>{user.likes_received_count}</td> <td>{user.likes_given_count}</td>
<td>{user.likes_given_count}</td> <td>{user.average_rating}</td>
<td>{user.average_rating}</td> </tr>
</tr> ))}
))
)}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@ -0,0 +1,73 @@
import { apiInstance } from "@/shared/config/apiConfig";
import { IFetch } from "@/shared/types/fetch-type";
import { IList } from "@/shared/types/list-type";
import { IStatistics } from "@/shared/types/statistics-type";
import { IUserRatings } from "@/shared/types/user-rating-type";
import { AxiosError } from "axios";
import { create } from "zustand";
const filterCategories: Record<string, string> = {
report_count: "report_count",
likes_given_count: "likes_given_count",
likes_received_count: "likes_received_count",
average_rating: "average_rating",
};
interface IVolunteersStore extends IFetch {
data: IUserRatings[];
getVolunteers: (filter: {
option: string;
toggle: boolean;
}) => void;
}
export const useVolunteersStore = create<IVolunteersStore>((set) => ({
isLoading: false,
error: "",
data: [],
getVolunteers: async (filter: {
option: string;
toggle: boolean;
}) => {
try {
set({ isLoading: true });
const response = await apiInstance.get<IUserRatings[]>(
`/report/user_ratings/`
);
let data = response.data;
console.log(filter);
if (
filter.option === filterCategories[filter.option] &&
filter.toggle === false
) {
data = data.sort((a, b) => {
const optionKey = filter.option as keyof IUserRatings;
return (Number(a[optionKey]) -
Number(b[optionKey])) as number;
});
} else if (
filter.option === "rating" &&
filter.toggle === true
) {
data = data.sort((a, b) => {
const optionKey = filter.option as keyof IUserRatings;
return (Number(a[optionKey]) -
Number(b[optionKey])) as number;
});
}
set({ data: data });
} catch (error: unknown) {
if (error instanceof AxiosError) {
set({ error: error.message });
} else {
set({ error: "an error ocured" });
}
} finally {
set({ isLoading: false });
}
},
}));