diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a1f07bc..17c5f1d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,9 @@ "@mui/icons-material": "^7.3.5", "@mui/lab": "^7.0.1-beta.19", "@mui/material": "^7.3.5", + "@mui/x-date-pickers": "^8.18.0", + "date-fns": "^4.1.0", + "dayjs": "^1.11.19", "jwt-decode": "^4.0.0", "maplibre-gl": "^5.12.0", "react": "^19.2.0", @@ -988,6 +991,7 @@ "node_modules/@mui/system": { "version": "7.3.5", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4", "@mui/private-theming": "^7.3.5", @@ -1066,6 +1070,94 @@ } } }, + "node_modules/@mui/x-date-pickers": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-8.18.0.tgz", + "integrity": "sha512-lgq60mOhOf5AKfiCl37eOVSkCZQo3sHhE6tbwbcS93aNkdtlsTQlqy/s6O89RoIi8QS3/7rgCKy+WuC6YzwZrA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/utils": "^7.3.5", + "@mui/x-internals": "8.18.0", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0", + "@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0", + "date-fns": "^2.25.0 || ^3.2.0 || ^4.0.0", + "date-fns-jalali": "^2.13.0-0 || ^3.2.0-0 || ^4.0.0-0", + "dayjs": "^1.10.7", + "luxon": "^3.0.2", + "moment": "^2.29.4", + "moment-hijri": "^2.1.2 || ^3.0.0", + "moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "date-fns": { + "optional": true + }, + "date-fns-jalali": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + }, + "moment-hijri": { + "optional": true + }, + "moment-jalaali": { + "optional": true + } + } + }, + "node_modules/@mui/x-internals": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.18.0.tgz", + "integrity": "sha512-iM2SJALLo4kNqxTel8lfjIymYV9MgTa6021/rAlfdh/vwPMglaKyXQHrxkkWs2Eu/JFKkCKr5Fd34Gsdp63wIg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/utils": "^7.3.5", + "reselect": "^5.1.1", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "dev": true, @@ -1875,6 +1967,24 @@ "version": "3.1.3", "license": "MIT" }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT", + "peer": true + }, "node_modules/debug": { "version": "4.4.3", "license": "MIT", @@ -3177,6 +3287,12 @@ "react-dom": ">=16.6.0" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "license": "MIT", @@ -3687,6 +3803,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "7.2.2", "dev": true, diff --git a/frontend/package.json b/frontend/package.json index 4a79e2e..7040130 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,9 @@ "@mui/icons-material": "^7.3.5", "@mui/lab": "^7.0.1-beta.19", "@mui/material": "^7.3.5", + "@mui/x-date-pickers": "^8.18.0", + "date-fns": "^4.1.0", + "dayjs": "^1.11.19", "jwt-decode": "^4.0.0", "maplibre-gl": "^5.12.0", "react": "^19.2.0", diff --git a/frontend/src/AppBarContext.tsx b/frontend/src/AppBarContext.tsx index dd6345e..6f94b83 100644 --- a/frontend/src/AppBarContext.tsx +++ b/frontend/src/AppBarContext.tsx @@ -3,7 +3,7 @@ import {createContext, useContext, useState, useCallback, type ReactNode} from " export type AppBarContextAction = { actionName: string; link: string } const INITIAL_ACTION: AppBarContextAction = { - actionName: "Мои объявления", + actionName: "Мои поездки", link: "/my_trips" } diff --git a/frontend/src/api/generated/services/AuthService.ts b/frontend/src/api/generated/services/AuthService.ts index 68a72a5..14a6129 100644 --- a/frontend/src/api/generated/services/AuthService.ts +++ b/frontend/src/api/generated/services/AuthService.ts @@ -4,7 +4,6 @@ /* eslint-disable */ import type { LoginRequest } from '../models/LoginRequest'; import type { LoginResponse } from '../models/LoginResponse'; -import type { RefreshRequest } from '../models/RefreshRequest'; import type { CancelablePromise } from '../core/CancelablePromise'; import { OpenAPI } from '../../OpenAPI.custom'; import { request as __request } from '../../request.custom'; @@ -30,18 +29,19 @@ export class AuthService { } /** * Refresh token - * @param requestBody + * @param refresh Refresh token * @returns LoginResponse Successful operation * @throws ApiError */ public static postApiV1AuthRefresh( - requestBody: RefreshRequest, + refresh: string, ): CancelablePromise { return __request(OpenAPI, { method: 'POST', url: '/api/v1/auth/refresh', - body: requestBody, - mediaType: 'application/json', + headers: { + 'Refresh': refresh, + }, errors: { 401: `Unauthorized. Please check your credentials`, }, diff --git a/frontend/src/api/generated/services/TripsService.ts b/frontend/src/api/generated/services/TripsService.ts index 90008ed..fce0e76 100644 --- a/frontend/src/api/generated/services/TripsService.ts +++ b/frontend/src/api/generated/services/TripsService.ts @@ -35,7 +35,7 @@ export class TripsService { departureTime?: string, arrivalLocationId?: string, departureLocationId?: string, - transportTypeId?: number, + transportTypeId?: string, ): CancelablePromise> { return __request(OpenAPI, { method: 'GET', @@ -66,6 +66,9 @@ export class TripsService { url: '/api/v1/trips', body: requestBody, mediaType: 'application/json', + errors: { + 400: `Invalid trip data`, + }, }); } /** diff --git a/frontend/src/components/LocationField.tsx b/frontend/src/components/LocationField.tsx new file mode 100644 index 0000000..14ac3c6 --- /dev/null +++ b/frontend/src/components/LocationField.tsx @@ -0,0 +1,143 @@ +import React, {useEffect, useState} from "react"; +import {CircularProgress, InputAdornment, TextField, Typography} from "@mui/material"; +import Map, {Marker} from "react-map-gl/maplibre"; +import RoomIcon from "@mui/icons-material/Room"; + +const mapStyle = { + version: 8, + sources: { + "osm-tiles": { + type: "raster", + tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"], + tileSize: 256, + }, + }, + layers: [ + { + id: "osm-tiles-layer", + type: "raster", + source: "osm-tiles", + minzoom: 0, + maxzoom: 19, + }, + ], +} as any; + +interface LocationFieldProps { + value: string; + coords: [number, number] | null; + onAddressChange: (address: string) => void; + onCoordsChange: (coords: [number, number]) => void; +} + +export const LocationField: React.FC = ({ + value, + coords, + onAddressChange, + onCoordsChange, + }) => { + const [loading, setLoading] = useState(false); + + + const handleAddressBlur = async () => { + if (!value.trim()) return; + try { + setLoading(true); + const url = `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent( + value + )}&countrycodes=ru&limit=1`; + const res = await fetch(url); + const data = await res.json(); + if (data.length > 0) { + const lat = parseFloat(data[0].lat); + const lon = parseFloat(data[0].lon); + onCoordsChange([lat, lon]); + await handleMapClickInternal(lat, lon) + } + } catch (err) { + console.error("Ошибка геокодирования:", err); + } finally { + setLoading(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAddressBlur(); + } + }; + + const handleMapClick = async (e: any) => { + const {lat, lng} = e.lngLat; + await handleMapClickInternal(lat, lng) + }; + + const handleMapClickInternal = async (lat: number, lng: number) => { + onCoordsChange([lat, lng]); + try { + setLoading(true); + const url = `https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}&zoom=16&addressdetails=1`; + const res = await fetch(url); + const data = await res.json(); + if (data.display_name) { + onAddressChange(data.display_name); + } + } catch (err) { + console.error("Ошибка обратного геокодирования:", err); + } finally { + setLoading(false); + } + }; + + return ( +
+ onAddressChange(e.target.value)} + onBlur={handleAddressBlur} + onKeyDown={handleKeyDown} + disabled={loading} + sx={{mb: 1}} + InputLabelProps={{ + shrink: true, + }} + InputProps={{ + endAdornment: ( + + {loading && } + + ), + }} + /> +
+ + {coords && ( + + + + )} + +
+ {coords && ( + + {coords[0].toFixed(5)}, {coords[1].toFixed(5)} + + )} +
+ ); +}; diff --git a/frontend/src/components/MyTripsTape.tsx b/frontend/src/components/MyTripsTape.tsx index e1d0515..078f141 100644 --- a/frontend/src/components/MyTripsTape.tsx +++ b/frontend/src/components/MyTripsTape.tsx @@ -1,28 +1,21 @@ import Box from '@mui/material/Box'; import {trips} from "../TestData.ts"; import Masonry from '@mui/lab/Masonry'; -import {useEffect} from "react"; +import {useEffect, useState} from "react"; import {useAppBarAction} from "../AppBarContext.tsx"; import MyTrip from "./MyTrip.tsx"; import {UserInfo} from "./UserInfo.tsx"; import Grid from "@mui/material/Grid"; import {Button} from "@mui/material"; +import NewTripModal from "./NewTripModal.tsx"; export default function MyTripsTape() { - - // useEffect(() => { - // getTrips() - // }, []) - // - // const getTrips = async () => { - // const trips = await TripsService.getApiV1Trips(); - // } - + const [newTripOpen, setNewTripOpen] = useState(false); const {setAction, reset} = useAppBarAction(); useEffect(() => { setAction({ - actionName: "Все объявления", + actionName: "Все поездки", link: `/`, }); @@ -32,10 +25,10 @@ export default function MyTripsTape() { return ( - + - + @@ -73,6 +66,12 @@ export default function MyTripsTape() { {/*))}*/} + + {newTripOpen && + setNewTripOpen(false)} + />} ); } \ No newline at end of file diff --git a/frontend/src/components/NewTripModal.tsx b/frontend/src/components/NewTripModal.tsx new file mode 100644 index 0000000..7e879a6 --- /dev/null +++ b/frontend/src/components/NewTripModal.tsx @@ -0,0 +1,378 @@ +import {useState, type FC, type FormEvent, useEffect} from "react"; +import { + LocationsService, + LocationTypesService, + TransportTypesService, + type TripRequest, + TripsService +} from "../api/generated"; +import { + Button, + Dialog, DialogActions, + DialogContent, + DialogTitle, MenuItem, Select, Step, StepLabel, Stepper, + TextField, +} from "@mui/material"; +import Grid from "@mui/material/Grid"; +import DateTimeUtils from "../services/DateTimeUtils.ts"; +import {LocalizationProvider} from "@mui/x-date-pickers"; +import {AdapterDateFns} from "@mui/x-date-pickers/AdapterDateFns"; +import {LocationField} from "./LocationField.tsx"; +import Utils from "../services/Utils.ts"; + +interface NewTripProps { + isOpen: boolean; + close: any; +} + +interface NewTripState { + arrival_time?: Date; + departure_time?: Date; + transport_type_id?: number; + comment?: string; +} + +interface TransportType { + id: number, + name: string +} + +interface LocationType { + id: number, + code: string +} + +interface LocationPoint { + alias: string; + address: string; + latitude: number | undefined; + longitude: number | undefined; + type_id: number | undefined; +} + +const steps = ["Отправление", "Прибытие", "Дополнительно"]; + +const NewTripModal: FC = (props) => { + const [newTripState, setNewTripState] = useState({ + arrival_time: undefined, + departure_time: new Date(), + transport_type_id: undefined, + comment: "", + }) + + const [activeStep, setActiveStep] = useState(0); + const [errors, setErrors] = useState(); + + const [transportTypes, setTransportTypes] = useState(); + const [locationTypes, setLocationTypes] = useState(); + + const [departurePoint, setDeparturePoint] = useState({ + alias: "", + address: "", + latitude: 59.9388, + longitude: 30.3150, + type_id: undefined + }); + const [arrivalPoint, setArrivalPoint] = useState({ + alias: "", + address: "", + latitude: 59.9388, + longitude: 30.3150, + type_id: undefined + }); + + + const fetchTransportTypes = async () => { + const response = await TransportTypesService.getApiV1TransportTypes(); + const transportTypes: TransportType[] = response.map(tr => ({ + id: tr.id, + name: tr.name_ru + })); + + setTransportTypes(transportTypes); + } + + const fetchLocationTypes = async () => { + const response = await LocationTypesService.getApiV1LocationTypes(); + const locationTypes: LocationType[] = response.map(lt => ({ + id: lt.id, + code: lt.code + })); + setLocationTypes(locationTypes); + } + + useEffect(() => { + fetchTransportTypes() + fetchLocationTypes() + }, []); + + const handleNext = () => setActiveStep((prev) => Math.min(prev + 1, steps.length - 1)); + const handleBack = () => setActiveStep((prev) => Math.max(prev - 1, 0)); + + const handleSubmit = async (e: FormEvent) => { + if (!newTripState.transport_type_id || !departurePoint.type_id || !arrivalPoint.type_id) { + setErrors(['Пожалуйста, заполните все необходимые поля']) + return + } + + e.preventDefault() + try { + const departureLocationResponse = await LocationsService.postApiV1Locations({ + latitude: departurePoint.latitude!, + location_type_id: departurePoint.type_id, + longitude: departurePoint.longitude!, + name: departurePoint.alias + }) + + const arrivalLocationResponse = await LocationsService.postApiV1Locations({ + latitude: arrivalPoint.latitude!, + location_type_id: arrivalPoint.type_id, + longitude: arrivalPoint.longitude!, + name: arrivalPoint.alias + }) + + const tripRequest: TripRequest = { + arrival_location_id: arrivalLocationResponse.id, + arrival_time: newTripState.arrival_time?.toISOString(), + departure_location_id: departureLocationResponse.id, + departure_time: newTripState.departure_time?.toISOString(), + transport_type_id: newTripState.transport_type_id, + comment: newTripState.comment, + + status: "ACTIVE" + } + + await TripsService.postApiV1Trips(tripRequest) + clearModal() + props.close + } catch (e) { + setErrors(['Ошибка создания новой поездки']) + } + } + + const clearModal = () => + setNewTripState({ + arrival_time: undefined, + departure_time: undefined, + transport_type_id: 0, + comment: "", + }); + + return ( +
+ + + Новая поездка + + + + {steps.map((label) => ( + + {label} + + ))} + + + {activeStep === 0 && ( + + + + setDeparturePoint(prevState => ({ + ...prevState, + alias: e.target.value + }))} + sx={{mb: 1}} + InputLabelProps={{ + shrink: true, + }} + /> + + + setDeparturePoint(prevState => ({ + ...prevState, + latitude: coords[0], + longitude: coords[1] + }))} + onAddressChange={(addr: string) => setDeparturePoint(prevState => ({ + ...prevState, + address: addr + }))} + /> + + )} + + {activeStep === 1 && ( + + + { + setNewTripState(prevState => ({ + ...prevState, + arrival_time: e.target.value === '' + ? undefined + : new Date(e.target.value) + })) + }} + InputLabelProps={{ + shrink: true, + }} + required + /> + + + setArrivalPoint(prevState => ({ + ...prevState, + alias: e.target.value + }))} + sx={{mb: 1}} + InputLabelProps={{ + shrink: true, + }} + /> + + + setArrivalPoint(prevState => ({ + ...prevState, + latitude: coords[0], + longitude: coords[1] + }))} + onAddressChange={(addr: string) => setArrivalPoint(prevState => ({ + ...prevState, + address: addr + }))} + /> + + )} + {activeStep === 2 && ( + + + setNewTripState(prevState => ({ + ...prevState, + comment: e.target.value + }))} + sx={{mb: 1}} + InputLabelProps={{ + shrink: true, + }} + /> + + )} + + + + {activeStep === steps.length - 1 ? ( + + ) : ( + + )} + + +
+ ) +} + +export default NewTripModal \ No newline at end of file diff --git a/frontend/src/services/DateTimeUtils.ts b/frontend/src/services/DateTimeUtils.ts index e4a5eac..c2c8b3c 100644 --- a/frontend/src/services/DateTimeUtils.ts +++ b/frontend/src/services/DateTimeUtils.ts @@ -12,4 +12,21 @@ export default class DateTimeUtils { return format.format(date).replace(' ', 'T'); } + + static toMoscowDate(date: Date) { + return new Date(date.setHours(date.getHours() + 3)) + } + + static toISOString(date: Date | undefined) { + if (date == null) return undefined + + const pad = (num: number) => (num < 10 ? '0' : '') + num + + return date.getFullYear() + + '-' + pad(date.getMonth() + 1) + + '-' + pad(date.getDate()) + + 'T' + pad(date.getHours()) + + ':' + pad(date.getMinutes()) + + ':' + pad(date.getSeconds()) + } } \ No newline at end of file diff --git a/frontend/src/services/Utils.ts b/frontend/src/services/Utils.ts new file mode 100644 index 0000000..e5c25ed --- /dev/null +++ b/frontend/src/services/Utils.ts @@ -0,0 +1,9 @@ +export default class Utils { + static getLocationTypeName(locationTypeCode: string) { + if (locationTypeCode === 'METRO_STATION') return 'Метро' + else if (locationTypeCode === 'UNIVERSITY_CAMPUS') return 'Кампус ИТМО' + else if (locationTypeCode === 'DORMITORY') return 'Общежитие ИТМО' + else if (locationTypeCode === 'CUSTOM') return 'Свой адрес' + else return locationTypeCode + } +}