From f29fe3a18568a63f82d926263b81ae9d103a74ec Mon Sep 17 00:00:00 2001 From: olegvls Date: Tue, 23 Jun 2026 12:00:03 +0300 Subject: [PATCH 01/10] fix: resolve merge conflict and add linkButton styles --- .../pages/DriversPage/DriversPage.module.css | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/apps/AdminWebUi/src/pages/DriversPage/DriversPage.module.css b/apps/AdminWebUi/src/pages/DriversPage/DriversPage.module.css index 5a506705..73201a74 100644 --- a/apps/AdminWebUi/src/pages/DriversPage/DriversPage.module.css +++ b/apps/AdminWebUi/src/pages/DriversPage/DriversPage.module.css @@ -31,6 +31,30 @@ padding: 16px; } +.table { + width: 100%; + border-collapse: collapse; +} + +.table th, +.table td { + text-align: left; + padding: 10px 8px; + border-bottom: 1px solid var(--border-secondary); + font-size: 14px; +} + +.table th { + color: var(--text-secondary); + font-weight: 600; +} + +.empty { + text-align: center; + color: var(--text-secondary); + padding: 20px; +} + .footer { margin-top: 14px; display: flex; @@ -53,3 +77,20 @@ display: flex; gap: 8px; } + +.linkButton { + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border-primary); + border-radius: var(--radius-sm); + padding: 6px 10px; + color: var(--text-primary); + background-color: var(--bg-tertiary); + font-size: 13px; + font-weight: 500; +} + +.linkButton:hover { + background-color: var(--interactive-secondary-hover); +} From 1c8a1c78c7f6247e3e768c095d09ad8d66b194b2 Mon Sep 17 00:00:00 2001 From: olegvls Date: Tue, 23 Jun 2026 13:51:12 +0300 Subject: [PATCH 02/10] change form add car and change validation in backend --- .../Validators/CreateVehicleDtoValidator.cs | 12 +- apps/Mobile/src/api/authTokenBridge.ts | 15 + apps/Mobile/src/api/client.ts | 149 +-- .../src/components/AppAlert/AppAlert.tsx | 178 ++++ apps/Mobile/src/components/AppAlert/index.ts | 4 + .../src/components/AppAlert/useAppAlert.tsx | 64 ++ apps/Mobile/src/components/Select/Select.tsx | 23 +- apps/Mobile/src/context/AuthContext.tsx | 5 + apps/Mobile/src/queries/vehicleQueries.ts | 5 +- .../CarCardScreen/CarCardScreen.styles.ts | 69 +- .../screens/CarCardScreen/CarCardScreen.tsx | 885 ++++++++++++------ .../CarDetailsScreen/CarDetailsScreen.tsx | 19 +- .../src/screens/HomeScreen/HomeScreen.tsx | 8 +- apps/Mobile/src/utils/apiErrorUtils.ts | 49 + apps/Mobile/src/utils/vehicleFormUtils.ts | 134 +++ apps/Mobile/src/utils/vinUtils.ts | 12 + 16 files changed, 1205 insertions(+), 426 deletions(-) create mode 100644 apps/Mobile/src/api/authTokenBridge.ts create mode 100644 apps/Mobile/src/components/AppAlert/AppAlert.tsx create mode 100644 apps/Mobile/src/components/AppAlert/index.ts create mode 100644 apps/Mobile/src/components/AppAlert/useAppAlert.tsx create mode 100644 apps/Mobile/src/utils/apiErrorUtils.ts create mode 100644 apps/Mobile/src/utils/vehicleFormUtils.ts create mode 100644 apps/Mobile/src/utils/vinUtils.ts diff --git a/Services/Main/MainHub.Api/Validators/CreateVehicleDtoValidator.cs b/Services/Main/MainHub.Api/Validators/CreateVehicleDtoValidator.cs index 55217e6f..0dfa4822 100644 --- a/Services/Main/MainHub.Api/Validators/CreateVehicleDtoValidator.cs +++ b/Services/Main/MainHub.Api/Validators/CreateVehicleDtoValidator.cs @@ -61,12 +61,18 @@ public CreateVehicleDtoValidator() .When(x => x.BoughtAt.HasValue); RuleFor(x => x.EngineCapacity) - .NotEmpty() - .WithMessage("Engine capacity is required.") .GreaterThan(0) .WithMessage("Engine capacity must be greater than 0.") .LessThanOrEqualTo(10000) - .WithMessage("Engine capacity must be less than or equal to 10000."); + .WithMessage("Engine capacity must be less than or equal to 10000.") + .When(x => x.FuelType is not FuelType.Electric and not FuelType.Hydrogen); + + RuleFor(x => x.EngineCapacity) + .GreaterThanOrEqualTo(0) + .WithMessage("Engine capacity cannot be negative.") + .LessThanOrEqualTo(10000) + .WithMessage("Engine capacity must be less than or equal to 10000.") + .When(x => x.FuelType is FuelType.Electric or FuelType.Hydrogen); RuleFor(x => x.Mileage) .NotEmpty() diff --git a/apps/Mobile/src/api/authTokenBridge.ts b/apps/Mobile/src/api/authTokenBridge.ts new file mode 100644 index 00000000..721fd44e --- /dev/null +++ b/apps/Mobile/src/api/authTokenBridge.ts @@ -0,0 +1,15 @@ +type RefreshAccessTokenFn = () => Promise; + +let refreshAccessTokenFn: RefreshAccessTokenFn | null = null; + +export function registerRefreshAccessToken(fn: RefreshAccessTokenFn): void { + refreshAccessTokenFn = fn; +} + +export async function tryRefreshAccessToken(): Promise { + if (!refreshAccessTokenFn) { + return null; + } + + return refreshAccessTokenFn(); +} diff --git a/apps/Mobile/src/api/client.ts b/apps/Mobile/src/api/client.ts index ff6e92b2..74bd9b06 100644 --- a/apps/Mobile/src/api/client.ts +++ b/apps/Mobile/src/api/client.ts @@ -1,7 +1,10 @@ import { API_CONFIG, getNgrokHeaders } from "../config/api"; +import { tryRefreshAccessToken } from "./authTokenBridge"; const API_BASE_URL = API_CONFIG.BASE_URL; +type HttpMethod = "GET" | "POST" | "PUT" | "DELETE"; + export class ApiClient { private baseURL: string; private getAccessToken: () => Promise; @@ -14,149 +17,89 @@ export class ApiClient { this.getAccessToken = getAccessToken; } - private async getHeaders(): Promise { - const token = await this.getAccessToken(); + private async getHeaders(token?: string | null): Promise { + const accessToken = token ?? (await this.getAccessToken()); return { "Content-Type": "application/json", ...getNgrokHeaders(), - ...(token && { Authorization: `Bearer ${token}` }), + ...(accessToken && { Authorization: `Bearer ${accessToken}` }), }; } - async get(endpoint: string): Promise { - const response = await fetch(`${this.baseURL}${endpoint}`, { - method: "GET", - headers: await this.getHeaders(), - }); - - if (!response.ok) { - // Спробуємо отримати текст помилки - let errorMessage = `HTTP error! status: ${response.status}`; - try { - const errorText = await response.text(); - if (errorText) { - errorMessage = errorText; - } - } catch (e) { - // Ігноруємо помилки парсингу тексту + private async readErrorMessage(response: Response): Promise { + let errorMessage = `HTTP error! status: ${response.status}`; + try { + const errorText = await response.text(); + if (errorText) { + errorMessage = errorText; } - throw new Error(errorMessage); - } - - // Перевіряємо чи відповідь є JSON - const contentType = response.headers.get("content-type"); - if (!contentType || !contentType.includes("application/json")) { - const responseText = await response.text(); - throw new Error( - `Expected JSON response but got: ${contentType}. Response: ${responseText.substring(0, 200)}...` - ); + } catch { + // Ignore body read errors. } - - return response.json(); + return errorMessage; } - async post(endpoint: string, data: any): Promise { + private async request( + method: HttpMethod, + endpoint: string, + body?: unknown, + hasRetried = false + ): Promise { const response = await fetch(`${this.baseURL}${endpoint}`, { - method: "POST", + method, headers: await this.getHeaders(), - body: JSON.stringify(data), + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), }); - if (!response.ok) { - // Спробуємо отримати текст помилки - let errorMessage = `HTTP error! status: ${response.status}`; - try { - const errorText = await response.text(); - if (errorText) { - errorMessage = errorText; - } - } catch (e) { - // Ігноруємо помилки парсингу тексту + if (response.status === 401 && !hasRetried) { + const refreshedToken = await tryRefreshAccessToken(); + if (refreshedToken) { + return this.request(method, endpoint, body, true); } - throw new Error(errorMessage); } - // Перевіряємо чи є контент для парсингу - const contentType = response.headers.get("content-type"); - const contentLength = response.headers.get("content-length"); - - // Якщо немає контенту або content-length = 0, повертаємо порожній об'єкт - if (!contentLength || contentLength === "0") { - return {} as T; - } - - // Перевіряємо чи відповідь є JSON - if (!contentType || !contentType.includes("application/json")) { - const responseText = await response.text(); - throw new Error( - `Expected JSON response but got: ${contentType}. Response: ${responseText.substring(0, 200)}...` - ); + if (!response.ok) { + throw new Error(await this.readErrorMessage(response)); } - return response.json(); - } - - async put(endpoint: string, data: any): Promise { - const response = await fetch(`${this.baseURL}${endpoint}`, { - method: "PUT", - headers: await this.getHeaders(), - body: JSON.stringify(data), - }); - - if (!response.ok) { - // Спробуємо отримати текст помилки - let errorMessage = `HTTP error! status: ${response.status}`; - try { - const errorText = await response.text(); - if (errorText) { - errorMessage = errorText; - } - } catch (e) { - // Ігноруємо помилки парсингу тексту - } - throw new Error(errorMessage); + if (response.status === 204) { + return {} as T; } - // Перевіряємо чи є контент для парсингу const contentType = response.headers.get("content-type"); - const contentLength = response.headers.get("content-length"); + const responseText = await response.text(); - // Якщо немає контенту або content-length = 0, повертаємо порожній об'єкт - if (!contentLength || contentLength === "0") { + if (!responseText) { return {} as T; } - // Перевіряємо чи відповідь є JSON if (!contentType || !contentType.includes("application/json")) { - const responseText = await response.text(); throw new Error( `Expected JSON response but got: ${contentType}. Response: ${responseText.substring(0, 200)}...` ); } - return response.json(); + return JSON.parse(responseText) as T; } - async delete(endpoint: string): Promise { - const response = await fetch(`${this.baseURL}${endpoint}`, { - method: "DELETE", - headers: await this.getHeaders(), - }); + async get(endpoint: string): Promise { + return this.request("GET", endpoint); + } - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } + async post(endpoint: string, data: unknown): Promise { + return this.request("POST", endpoint, data); + } - // 204 No Content — тіла немає, не викликаємо .json() - if (response.status === 204) { - return {} as T; - } - return response.json(); + async put(endpoint: string, data: unknown): Promise { + return this.request("PUT", endpoint, data); + } + + async delete(endpoint: string): Promise { + return this.request("DELETE", endpoint); } } -// Функція для створення API клієнта з токеном export const createApiClient = ( getAccessToken: () => Promise ) => { diff --git a/apps/Mobile/src/components/AppAlert/AppAlert.tsx b/apps/Mobile/src/components/AppAlert/AppAlert.tsx new file mode 100644 index 00000000..69a033f1 --- /dev/null +++ b/apps/Mobile/src/components/AppAlert/AppAlert.tsx @@ -0,0 +1,178 @@ +import React from "react"; +import { + Modal, + View, + Text, + TouchableOpacity, + StyleSheet, +} from "react-native"; +import { globalStyles } from "../../styles/globalStyles"; + +export type AppAlertButtonStyle = "default" | "cancel" | "destructive"; + +export interface AppAlertButton { + text: string; + onPress?: () => void; + style?: AppAlertButtonStyle; +} + +export interface AppAlertProps { + visible: boolean; + title: string; + message?: string; + buttons?: AppAlertButton[]; + onDismiss: () => void; +} + +export function AppAlert({ + visible, + title, + message, + buttons = [{ text: "OK", style: "default" }], + onDismiss, +}: AppAlertProps) { + const handlePress = (button: AppAlertButton) => { + onDismiss(); + button.onPress?.(); + }; + + return ( + + + + + {title} + {message ? ( + + {message} + + ) : null} + + 1 && styles.actionsMultiple, + ]} + > + {buttons.map((button, index) => { + const isDestructive = button.style === "destructive"; + const isCancel = button.style === "cancel"; + const isPrimary = !isCancel && !isDestructive; + + return ( + 1 && styles.actionButtonMultiple, + isPrimary && styles.actionButtonPrimary, + isCancel && styles.actionButtonSecondary, + isDestructive && styles.actionButtonDestructive, + ]} + onPress={() => handlePress(button)} + activeOpacity={0.85} + > + + {button.text} + + + ); + })} + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + justifyContent: "center", + alignItems: "center", + paddingHorizontal: 24, + }, + backdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: "rgba(0, 0, 0, 0.7)", + }, + card: { + width: "100%", + maxWidth: 360, + backgroundColor: "#161B22", + borderRadius: 16, + borderWidth: 1, + borderColor: "#30363D", + paddingHorizontal: 20, + paddingTop: 20, + paddingBottom: 16, + }, + title: { + fontSize: 18, + marginBottom: 8, + textAlign: "center", + }, + message: { + textAlign: "center", + lineHeight: 22, + marginBottom: 20, + }, + actions: { + gap: 10, + }, + actionsMultiple: { + flexDirection: "column-reverse", + }, + actionButton: { + borderRadius: 12, + paddingVertical: 12, + paddingHorizontal: 16, + alignItems: "center", + justifyContent: "center", + }, + actionButtonMultiple: { + width: "100%", + }, + actionButtonPrimary: { + backgroundColor: "#4ade9e", + }, + actionButtonSecondary: { + backgroundColor: "#21262D", + borderWidth: 1, + borderColor: "#30363D", + }, + actionButtonDestructive: { + backgroundColor: "transparent", + borderWidth: 1, + borderColor: "#F85149", + }, + actionButtonText: { + fontSize: 16, + fontWeight: "600", + }, + actionButtonTextPrimary: { + color: "#121212", + }, + actionButtonTextSecondary: { + color: "#FFFFFF", + }, + actionButtonTextDestructive: { + color: "#F85149", + }, +}); diff --git a/apps/Mobile/src/components/AppAlert/index.ts b/apps/Mobile/src/components/AppAlert/index.ts new file mode 100644 index 00000000..f95596c1 --- /dev/null +++ b/apps/Mobile/src/components/AppAlert/index.ts @@ -0,0 +1,4 @@ +export { AppAlert } from "./AppAlert"; +export type { AppAlertButton, AppAlertButtonStyle, AppAlertProps } from "./AppAlert"; +export { useAppAlert } from "./useAppAlert"; +export type { ShowAppAlertOptions } from "./useAppAlert"; diff --git a/apps/Mobile/src/components/AppAlert/useAppAlert.tsx b/apps/Mobile/src/components/AppAlert/useAppAlert.tsx new file mode 100644 index 00000000..a520fe19 --- /dev/null +++ b/apps/Mobile/src/components/AppAlert/useAppAlert.tsx @@ -0,0 +1,64 @@ +import { useCallback, useState } from "react"; +import { AppAlert, AppAlertButton } from "./AppAlert"; + +export interface ShowAppAlertOptions { + title: string; + message?: string; + buttons?: AppAlertButton[]; +} + +interface AppAlertState extends ShowAppAlertOptions { + visible: boolean; +} + +export function useAppAlert() { + const [alertState, setAlertState] = useState(null); + + const hideAlert = useCallback(() => { + setAlertState(null); + }, []); + + const showAlert = useCallback((options: ShowAppAlertOptions) => { + setAlertState({ + ...options, + visible: true, + buttons: options.buttons ?? [{ text: "OK", style: "default" }], + }); + }, []); + + const showError = useCallback( + (message: string, title = "Помилка") => { + showAlert({ title, message }); + }, + [showAlert] + ); + + const showSuccess = useCallback( + (message: string, title = "Успішно!", buttons?: AppAlertButton[]) => { + showAlert({ + title, + message, + buttons: buttons ?? [{ text: "OK", style: "default" }], + }); + }, + [showAlert] + ); + + const alertModal = alertState ? ( + + ) : null; + + return { + showAlert, + showError, + showSuccess, + hideAlert, + alertModal, + }; +} diff --git a/apps/Mobile/src/components/Select/Select.tsx b/apps/Mobile/src/components/Select/Select.tsx index 37fafc19..8d675710 100644 --- a/apps/Mobile/src/components/Select/Select.tsx +++ b/apps/Mobile/src/components/Select/Select.tsx @@ -84,10 +84,7 @@ export const Select: React.FC = ({ disabled={disabled} > {selectedOption ? selectedOption.label : placeholder} @@ -109,7 +106,10 @@ export const Select: React.FC = ({ activeOpacity={1} onPress={handleModalClose} /> - true}> + true} + > {/* Заголовок */} @@ -171,9 +171,7 @@ export const Select: React.FC = ({ /> ) : ( - - Нічого не знайдено - + Нічого не знайдено )} @@ -240,6 +238,8 @@ const styles = StyleSheet.create({ maxHeight: 600, overflow: "hidden", shadowColor: "#000", + borderWidth: 1, + borderColor: "#21262d", // theme.colors.border.primary shadowOffset: { width: 0, height: 4, @@ -256,7 +256,7 @@ const styles = StyleSheet.create({ alignItems: "center", paddingHorizontal: 20, paddingVertical: 16, - borderBottomWidth: 1, + borderBottomColor: "#30363d", // theme.colors.border.primary }, modalTitle: { @@ -285,6 +285,8 @@ const styles = StyleSheet.create({ }, searchInput: { marginBottom: 0, + borderWidth: 1, + borderColor: "#3bc98e", }, optionsContainer: { flex: 1, @@ -330,6 +332,3 @@ const styles = StyleSheet.create({ marginLeft: 12, }, }); - - - diff --git a/apps/Mobile/src/context/AuthContext.tsx b/apps/Mobile/src/context/AuthContext.tsx index 032a1401..0ab5d8d5 100644 --- a/apps/Mobile/src/context/AuthContext.tsx +++ b/apps/Mobile/src/context/AuthContext.tsx @@ -15,6 +15,7 @@ import { refreshAccessToken, } from "../api/services/authService"; import { queryClient } from "../lib/queryClient"; +import { registerRefreshAccessToken } from "../api/authTokenBridge"; interface AuthContextType { isAuthenticated: boolean; @@ -137,6 +138,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { checkAuthStatus(); }, [checkAuthStatus]); + useEffect(() => { + registerRefreshAccessToken(refreshSession); + }, [refreshSession]); + const login = async () => { const result = await WebBrowser.openAuthSessionAsync( `${API_CONFIG.BASE_URL}/auth/login`, diff --git a/apps/Mobile/src/queries/vehicleQueries.ts b/apps/Mobile/src/queries/vehicleQueries.ts index 901fea99..0e0cdb96 100644 --- a/apps/Mobile/src/queries/vehicleQueries.ts +++ b/apps/Mobile/src/queries/vehicleQueries.ts @@ -11,9 +11,10 @@ export const useVehicles = () => { return useQuery({ queryKey: queryKeys.vehicles, - queryFn: () => { + queryFn: async () => { const apiClient = createApiClient(getAccessToken); - return apiClient.get(ENDPOINTS.VEHICLES); + const result = await apiClient.get(ENDPOINTS.VEHICLES); + return Array.isArray(result) ? result : []; }, staleTime: CACHE_STALE_TIME_MS, }); diff --git a/apps/Mobile/src/screens/CarCardScreen/CarCardScreen.styles.ts b/apps/Mobile/src/screens/CarCardScreen/CarCardScreen.styles.ts index d17f853e..1773868c 100644 --- a/apps/Mobile/src/screens/CarCardScreen/CarCardScreen.styles.ts +++ b/apps/Mobile/src/screens/CarCardScreen/CarCardScreen.styles.ts @@ -20,9 +20,44 @@ export const styles = StyleSheet.create({ form: { flex: 1, }, + stepHeaderContainer: { + marginBottom: 16, + }, + stepHeaderTop: { + marginBottom: 10, + }, + stepTitle: { + fontSize: 16, + fontWeight: "700", + marginBottom: 4, + }, + stepSubtitle: { + fontSize: 14, + }, + stepDots: { + flexDirection: "row", + gap: 8, + }, + stepDot: { + flex: 1, + height: 6, + borderRadius: 999, + backgroundColor: "#30363D", + }, + stepDotCompleted: { + backgroundColor: "#2EA043", + }, + stepDotActive: { + backgroundColor: "#F2CC60", + }, + stepCard: { + paddingHorizontal: 16, + paddingVertical: 18, + marginBottom: 12, + }, imageSection: { alignItems: "center", - marginBottom: 32, + marginBottom: 24, }, imageContainer: { width: 200, @@ -59,6 +94,36 @@ export const styles = StyleSheet.create({ input: { // Additional input styles if needed }, + dateInputText: { + paddingVertical: 12, + }, + datePickerModalRoot: { + flex: 1, + justifyContent: "flex-end", + }, + datePickerBackdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: "rgba(0, 0, 0, 0.6)", + }, + datePickerSheet: { + backgroundColor: "#161B22", + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + paddingBottom: 24, + }, + datePickerHeader: { + flexDirection: "row", + justifyContent: "flex-end", + alignItems: "center", + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: "#30363D", + }, + datePickerDoneText: { + color: "#4ade9e", + fontWeight: "600", + }, vinDecodeContainer: { flexDirection: "row", gap: 12, @@ -79,7 +144,7 @@ export const styles = StyleSheet.create({ buttonContainer: { flexDirection: "row", justifyContent: "space-between", - marginTop: 20, + marginTop: 8, marginBottom: 40, gap: 12, }, diff --git a/apps/Mobile/src/screens/CarCardScreen/CarCardScreen.tsx b/apps/Mobile/src/screens/CarCardScreen/CarCardScreen.tsx index b90b320e..807578c4 100644 --- a/apps/Mobile/src/screens/CarCardScreen/CarCardScreen.tsx +++ b/apps/Mobile/src/screens/CarCardScreen/CarCardScreen.tsx @@ -1,13 +1,15 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { View, Text, TextInput, TouchableOpacity, Image, - Alert, Platform, ActivityIndicator, + Animated, + Easing, + Modal, } from "react-native"; import { FormScreen } from "../../components/FormScreen"; import DateTimePicker from "@react-native-community/datetimepicker"; @@ -18,6 +20,7 @@ import { globalStyles } from "../../styles/globalStyles"; import { styles } from "./CarCardScreen.styles"; import { CarCardScreenProps } from "../../navigation/types"; import { Select, SelectOption } from "../../components/Select"; +import { useAppAlert } from "../../components/AppAlert"; import { getAllMakes, getModelsForMakeId, @@ -33,6 +36,21 @@ import { TransmissionType, WheelDriveType, } from "../../types/api"; +import { + isValidVin, + normalizeVinInput, + VIN_VALIDATION_MESSAGE, +} from "../../utils/vinUtils"; +import { parseApiErrorMessage } from "../../utils/apiErrorUtils"; +import { + getEnginePowerLabel, + getTransmissionOptionsForFuel, + isTransmissionAllowedForFuel, + normalizeFuelType, + normalizeTransmissionType, + requiresEngineDisplacement, + resolveEngineCapacityCc, +} from "../../utils/vehicleFormUtils"; const COLOR_OPTIONS: SelectOption[] = [ { label: "Білий", value: "white" }, @@ -50,15 +68,15 @@ const COLOR_OPTIONS: SelectOption[] = [ ]; const BODY_CLASS_OPTIONS: SelectOption[] = [ - { label: "Sedan", value: "Sedan" }, - { label: "Hatchback", value: "Hatchback" }, - { label: "Universal", value: "Universal" }, - { label: "Coupe", value: "Coupe" }, - { label: "Convertible", value: "Convertible" }, - { label: "SUV", value: "SUV" }, - { label: "Crossover", value: "Crossover" }, - { label: "Minivan", value: "Minivan" }, - { label: "Pickup", value: "Pickup" }, + { label: "Седан", value: "Sedan" }, + { label: "Хетчбек", value: "Hatchback" }, + { label: "Універсал", value: "Universal" }, + { label: "Купе", value: "Coupe" }, + { label: "Кабріолет", value: "Convertible" }, + { label: "Позашляховик", value: "SUV" }, + { label: "Кросовер", value: "Crossover" }, + { label: "Мінівен", value: "Minivan" }, + { label: "Пікап", value: "Pickup" }, ]; // Відповідають enum FuelType на бекенді (0–5) @@ -86,10 +104,21 @@ const WHEEL_DRIVE_API_OPTIONS: SelectOption[] = [ { label: "4WD", value: WheelDriveType.FourWD }, ]; +const TOTAL_STEPS = 6; +const STEP_TITLES = [ + "Базова інформація", + "Ідентифікація авто", + "Історія використання", + "Двигун", + "Трансмісія та привід", + "Фото та завершення", +]; + export default function CarCardScreen({ navigation }: CarCardScreenProps) { const { getAccessToken } = useAuth(); const queryClient = useQueryClient(); const theme = useTheme(); + const { showAlert, showError, showSuccess, alertModal } = useAppAlert(); const [carImage, setCarImage] = useState(null); const [brand, setBrand] = useState(undefined); const [model, setModel] = useState(undefined); @@ -97,12 +126,17 @@ export default function CarCardScreen({ navigation }: CarCardScreenProps) { const [color, setColor] = useState(undefined); const [licensePlate, setLicensePlate] = useState(""); const [vin, setVin] = useState(""); - const [bodyClass, setBodyClass] = useState(undefined); + const [bodyClass, setBodyClass] = useState( + undefined + ); const [fuelType, setFuelType] = useState(undefined); const [displacement, setDisplacement] = useState(""); - const [transmission, setTransmission] = useState(undefined); + const [transmission, setTransmission] = useState( + undefined + ); const [driveType, setDriveType] = useState(undefined); - const [boughtAt, setBoughtAt] = useState(null); + const [boughtAt, setBoughtAt] = useState(() => new Date()); + const [pickerDate, setPickerDate] = useState(() => new Date()); const [showBoughtAtPicker, setShowBoughtAtPicker] = useState(false); const [mileage, setMileage] = useState(""); const [enginePower, setEnginePower] = useState(""); @@ -114,6 +148,8 @@ export default function CarCardScreen({ navigation }: CarCardScreenProps) { const [yearOptions] = useState(generateYearOptions()); const [isLoadingBrands, setIsLoadingBrands] = useState(false); const [isLoadingModels, setIsLoadingModels] = useState(false); + const [currentStep, setCurrentStep] = useState(0); + const stepAnimation = useRef(new Animated.Value(1)).current; // Завантаження марок при монтуванні компонента useEffect(() => { @@ -128,17 +164,14 @@ export default function CarCardScreen({ navigation }: CarCardScreenProps) { setBrandOptions(options); } catch (error) { console.error("Error loading brands:", error); - Alert.alert( - "Помилка", - "Не вдалося завантажити список марок. Спробуйте пізніше." - ); + showError("Не вдалося завантажити список марок. Спробуйте пізніше."); } finally { setIsLoadingBrands(false); } }; loadBrands(); - }, []); + }, [showError]); // Завантаження моделей при зміні марки useEffect(() => { @@ -146,10 +179,10 @@ export default function CarCardScreen({ navigation }: CarCardScreenProps) { if (!brand || typeof brand !== "number") { setModelOptions([]); setModel(undefined); - return; - } + return; + } - try { + try { setIsLoadingModels(true); const models = await getModelsForMakeId(brand); const options: SelectOption[] = models.map((model) => ({ @@ -161,10 +194,7 @@ export default function CarCardScreen({ navigation }: CarCardScreenProps) { setModel(undefined); } catch (error) { console.error("Error loading models:", error); - Alert.alert( - "Помилка", - "Не вдалося завантажити список моделей. Спробуйте пізніше." - ); + showError("Не вдалося завантажити список моделей. Спробуйте пізніше."); setModelOptions([]); } finally { setIsLoadingModels(false); @@ -172,7 +202,17 @@ export default function CarCardScreen({ navigation }: CarCardScreenProps) { }; loadModels(); - }, [brand]); + }, [brand, showError]); + + useEffect(() => { + if (!requiresEngineDisplacement(fuelType)) { + setDisplacement(""); + } + + if (!isTransmissionAllowedForFuel(fuelType, transmission)) { + setTransmission(undefined); + } + }, [fuelType, transmission]); // const handleVinDecodeSuccess = async (data: { // brand: string; @@ -252,12 +292,26 @@ export default function CarCardScreen({ navigation }: CarCardScreenProps) { // } // }; + const openBoughtAtPicker = () => { + setPickerDate(boughtAt); + setShowBoughtAtPicker(true); + }; + + const closeBoughtAtPicker = () => { + setShowBoughtAtPicker(false); + }; + + const confirmBoughtAtPicker = () => { + setBoughtAt(pickerDate); + setShowBoughtAtPicker(false); + }; + const pickImage = async () => { const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (permissionResult.granted === false) { - Alert.alert("Дозвіл потрібен", "Потрібен дозвіл для доступу до галереї!"); + showError("Потрібен дозвіл для доступу до галереї!", "Дозвіл потрібен"); return; } @@ -283,14 +337,13 @@ export default function CarCardScreen({ navigation }: CarCardScreenProps) { vin.trim() || carImage || mileage.trim() !== "" || - enginePower.trim() !== "" || - boughtAt !== null; + enginePower.trim() !== ""; if (hasData) { - Alert.alert( - "Скасувати створення?", - "Ви вже ввели деякі дані. Ви впевнені, що хочете скасувати?", - [ + showAlert({ + title: "Скасувати створення?", + message: "Ви вже ввели деякі дані. Ви впевнені, що хочете скасувати?", + buttons: [ { text: "Продовжити редагування", style: "cancel", @@ -300,8 +353,8 @@ export default function CarCardScreen({ navigation }: CarCardScreenProps) { style: "destructive", onPress: () => navigation.navigate("Home"), }, - ] - ); + ], + }); } else { navigation.navigate("Home"); } @@ -309,71 +362,74 @@ export default function CarCardScreen({ navigation }: CarCardScreenProps) { const handleSave = async () => { if (!brand) { - Alert.alert("Помилка", "Будь ласка, вибери марку автомобіля"); + showError("Будь ласка, вибери марку автомобіля"); return; } if (!model) { - Alert.alert("Помилка", "Будь ласка, вибери модель автомобіля"); + showError("Будь ласка, вибери модель автомобіля"); return; } if (!year) { - Alert.alert("Помилка", "Будь ласка, вибери рік випуску"); + showError("Будь ласка, вибери рік випуску"); return; } if (!color) { - Alert.alert("Помилка", "Будь ласка, вибери колір автомобіля"); + showError("Будь ласка, вибери колір автомобіля"); return; } if (!licensePlate.trim()) { - Alert.alert("Помилка", "Будь ласка, введи номерний знак"); + showError("Будь ласка, введи номерний знак"); return; } if (vin.trim().length !== 0 && vin.trim().length !== 17) { - Alert.alert("Помилка", "VIN має містити рівно 17 символів"); + showError("VIN має містити рівно 17 символів"); return; } if (!vin.trim()) { - Alert.alert("Помилка", "Будь ласка, введи VIN номер (17 символів)"); + showError("Будь ласка, введи VIN номер (17 символів)"); + return; + } + if (!isValidVin(vin)) { + showError(VIN_VALIDATION_MESSAGE); return; } if (fuelType === undefined) { - Alert.alert("Помилка", "Будь ласка, вибери тип палива"); + showError("Будь ласка, вибери тип палива"); return; } if (transmission === undefined) { - Alert.alert("Помилка", "Будь ласка, вибери коробку передач"); + showError("Будь ласка, вибери коробку передач"); return; } - if (driveType === undefined) { - Alert.alert("Помилка", "Будь ласка, вибери тип приводу"); + if (!isTransmissionAllowedForFuel(fuelType, transmission)) { + showError("Для електрокарів доступні лише автомат або CVT"); return; } - const displacementNum = displacement.trim() ? parseFloat(displacement.replace(",", ".")) : 0; - if (!displacement.trim() || isNaN(displacementNum) || displacementNum <= 0) { - Alert.alert("Помилка", "Введіть об'єм двигуна в літрах (наприклад 2.0)"); + if (driveType === undefined) { + showError("Будь ласка, вибери тип приводу"); return; } - const engineCapacityCc = Math.round(displacementNum * 1000); - if (engineCapacityCc <= 0 || engineCapacityCc > 10000) { - Alert.alert("Помилка", "Об'єм двигуна має бути від 0.001 до 10 л"); + const engineCapacityCc = resolveEngineCapacityCc(fuelType, displacement); + if (requiresEngineDisplacement(fuelType) && engineCapacityCc === null) { + showError("Введіть об'єм двигуна в літрах (наприклад 2.0)"); return; } const mileageNum = parseInt(mileage.trim(), 10); if (mileage.trim() === "" || isNaN(mileageNum) || mileageNum < 0) { - Alert.alert("Помилка", "Введіть пробіг (км)"); + showError("Введіть пробіг (км)"); return; } if (mileageNum > 1000000) { - Alert.alert("Помилка", "Пробіг не може перевищувати 1 000 000 км"); + showError("Пробіг не може перевищувати 1 000 000 км"); return; } const enginePowerNum = parseInt(enginePower.trim(), 10); if (!enginePower.trim() || isNaN(enginePowerNum) || enginePowerNum <= 0) { - Alert.alert("Помилка", "Введіть потужність двигуна (к.с.)"); + showError("Введіть потужність двигуна (к.с.)"); return; } if (enginePowerNum > 1000) { - Alert.alert("Помилка", "Потужність не може перевищувати 1000 к.с."); + showError("Потужність не може перевищувати 1000 к.с."); return; } @@ -384,26 +440,30 @@ export default function CarCardScreen({ navigation }: CarCardScreenProps) { brandOptions.find((opt) => opt.value === brand)?.label || String(brand); const modelLabel = modelOptions.find((opt) => opt.value === model)?.label || String(model); - const yearNum = typeof year === "number" ? year : parseInt(String(year), 10); + const yearNum = + typeof year === "number" ? year : parseInt(String(year), 10); const colorLabel = COLOR_OPTIONS.find((opt) => opt.value === color)?.label || String(color); const dto: CreateVehicleDto = { licensePlate: licensePlate.trim(), - vin: vin.trim(), + vin: vin.trim().toUpperCase(), brand: brandLabel, model: modelLabel, year: yearNum, - boughtAt: boughtAt ? boughtAt.toISOString() : null, + boughtAt: boughtAt.toISOString(), wheelDriveType: driveType as WheelDriveType, - engineCapacity: engineCapacityCc, + engineCapacity: engineCapacityCc ?? 0, fuelType: fuelType as FuelType, enginePower: enginePowerNum, color: colorLabel, transmissionType: transmission as TransmissionType, mileage: mileageNum, - photoUrl: carImage && (carImage.startsWith("http") ? carImage : null) ? carImage : null, + photoUrl: + carImage && (carImage.startsWith("http") ? carImage : null) + ? carImage + : null, }; const apiClient = createApiClient(getAccessToken); @@ -411,7 +471,7 @@ export default function CarCardScreen({ navigation }: CarCardScreenProps) { queryClient.invalidateQueries({ queryKey: queryKeys.vehicles }); - Alert.alert("Успішно!", "Автомобіль додано в базу", [ + showSuccess("Автомобіль додано в базу", "Успішно!", [ { text: "OK", onPress: () => navigation.navigate("Home"), @@ -419,13 +479,145 @@ export default function CarCardScreen({ navigation }: CarCardScreenProps) { ]); } catch (error) { console.error("Error saving vehicle:", error); - const message = error instanceof Error ? error.message : "Не вдалося зберегти дані"; - Alert.alert("Помилка", message); + showError(parseApiErrorMessage(error)); } finally { setIsLoading(false); } }; + const validateStep = (step: number): boolean => { + if (step === 0) { + if (!brand) { + showError("Будь ласка, вибери марку автомобіля"); + return false; + } + if (!model) { + showError("Будь ласка, вибери модель автомобіля"); + return false; + } + if (!year) { + showError("Будь ласка, вибери рік випуску"); + return false; + } + return true; + } + + if (step === 1) { + if (!color) { + showError("Будь ласка, вибери колір автомобіля"); + return false; + } + if (!licensePlate.trim()) { + showError("Будь ласка, введи номерний знак"); + return false; + } + if (vin.trim().length !== 0 && vin.trim().length !== 17) { + showError("VIN має містити рівно 17 символів"); + return false; + } + if (!vin.trim()) { + showError("Будь ласка, введи VIN номер (17 символів)"); + return false; + } + if (!isValidVin(vin)) { + showError(VIN_VALIDATION_MESSAGE); + return false; + } + return true; + } + + if (step === 2) { + if ( + mileage.trim() === "" || + isNaN(parseInt(mileage.trim(), 10)) || + parseInt(mileage.trim(), 10) < 0 + ) { + showError("Введіть пробіг (км)"); + return false; + } + if (parseInt(mileage.trim(), 10) > 1000000) { + showError("Пробіг не може перевищувати 1 000 000 км"); + return false; + } + return true; + } + + if (step === 3) { + if (fuelType === undefined) { + showError("Будь ласка, вибери тип палива"); + return false; + } + if (requiresEngineDisplacement(fuelType)) { + const engineCapacityCc = resolveEngineCapacityCc(fuelType, displacement); + if (engineCapacityCc === null) { + showError("Введіть об'єм двигуна в літрах (наприклад 2.0)"); + return false; + } + } + const enginePowerNum = parseInt(enginePower.trim(), 10); + if (!enginePower.trim() || isNaN(enginePowerNum) || enginePowerNum <= 0) { + showError("Введіть потужність двигуна (к.с.)"); + return false; + } + if (enginePowerNum > 1000) { + showError("Потужність не може перевищувати 1000 к.с."); + return false; + } + return true; + } + + if (step === 4) { + if (transmission === undefined) { + showError("Будь ласка, вибери коробку передач"); + return false; + } + if (!isTransmissionAllowedForFuel(fuelType, transmission)) { + showError("Для електрокарів доступні лише автомат або CVT"); + return false; + } + if (driveType === undefined) { + showError("Будь ласка, вибери тип приводу"); + return false; + } + return true; + } + + return true; + }; + + const animateStepTransition = (nextStep: number) => { + Animated.sequence([ + Animated.timing(stepAnimation, { + toValue: 0, + duration: 140, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(stepAnimation, { + toValue: 1, + duration: 220, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }), + ]).start(); + setCurrentStep(nextStep); + }; + + const handleNextStep = () => { + if (!validateStep(currentStep)) { + return; + } + if (currentStep < TOTAL_STEPS - 1) { + animateStepTransition(currentStep + 1); + } + }; + + const handlePreviousStep = () => { + if (currentStep > 0) { + animateStepTransition(currentStep - 1); + } + }; + if (isLoading) { return ( - - - Додай свій автомобіль - - - Створи картку твого авто - - + + + Додай свій автомобіль + + + Створи картку твого авто + + - - {/* VIN Decode Field */} - {/* */} - - {/* Car Image */} - - - {carImage ? ( - - ) : ( - - 🚗 - Фото авто - - )} - + + + + + Крок {currentStep + 1} з {TOTAL_STEPS} + + + {STEP_TITLES[currentStep]} + + + + {Array.from({ length: TOTAL_STEPS }).map((_, index) => ( + + ))} + - {/* Brand Field */} - - + {isLoadingBrands && ( + + + + )} - )} - - {/* Model Field */} - - + {isLoadingModels && ( + + + + )} - )} - - {/* Year Field */} - + + )} + + {currentStep === 1 && ( + <> + + + + Номерний знак * + + + - {/* License Plate Field */} - - - Номерний знак * - - - + + + VIN номер * + + setVin(normalizeVinInput(text))} + placeholder="17 символів (латиниця, без I, O, Q)" + placeholderTextColor={theme.colors.special.placeholder} + autoCapitalize="characters" + maxLength={17} + /> + - {/* VIN Field */} - - - VIN номер * - - - + + )} + + {currentStep === 2 && ( + <> + + + Дата купівлі + + + + {boughtAt.toLocaleDateString("uk-UA")} + + + - {/* Date bought */} - - - Дата купівлі - - setShowBoughtAtPicker(true)} - > - + + Пробіг (км) * + + + + + )} + + {currentStep === 3 && ( + <> + + {requiresEngineDisplacement(fuelType) && ( + + + Об'єм двигуна (л) * + + + + )} - {/* Fuel Type Field */} - + + )} + + {currentStep === 4 && ( + <> + setDriveType(v as number)} + placeholder="Виберіть тип приводу" + /> + + )} + + {currentStep === 5 && ( + + + {carImage ? ( + + ) : ( + + 🚗 + Додати фото авто + + )} + + + )} + + + {/* Action Buttons */} + + + + {currentStep === 0 ? "Скасувати" : "Назад"} - - - - {/* Transmission Field */} - setDriveType(v as number)} - placeholder="Виберіть тип приводу" - /> - - {/* Action Buttons */} - + {currentStep < TOTAL_STEPS - 1 ? ( - Скасувати + Далі - + ) : ( Зберегти - + )} + + + {Platform.OS === "ios" ? ( + + + + + + + + Готово + + + + { + if (date) setPickerDate(date); + }} + /> + + + + ) : ( + showBoughtAtPicker && ( + { + setShowBoughtAtPicker(false); + if (event.type === "set" && date) { + setBoughtAt(date); + } + }} + /> + ) + )} + {alertModal} ); } diff --git a/apps/Mobile/src/screens/CarDetailsScreen/CarDetailsScreen.tsx b/apps/Mobile/src/screens/CarDetailsScreen/CarDetailsScreen.tsx index 18b12ae5..738e5f89 100644 --- a/apps/Mobile/src/screens/CarDetailsScreen/CarDetailsScreen.tsx +++ b/apps/Mobile/src/screens/CarDetailsScreen/CarDetailsScreen.tsx @@ -15,6 +15,7 @@ import { styles } from "./CarDetailsScreen.styles"; import { CarDetailsScreenProps } from "../../navigation/types"; import { useVehicle, useDeleteVehicle } from "../../queries"; import { FuelType, TransmissionType, WheelDriveType } from "../../types/api"; +import { requiresEngineDisplacement } from "../../utils/vehicleFormUtils"; const FUEL_LABELS: Record = { [FuelType.Gasoline]: "Бензин", @@ -248,14 +249,16 @@ export default function CarDetailsScreen({ - - - Об'єм двигуна: - - - {car.engineCapacity / 1000} л - - + {requiresEngineDisplacement(car.fuelType) && ( + + + Об'єм двигуна: + + + {(car.engineCapacity / 1000).toFixed(1)} л + + + )} diff --git a/apps/Mobile/src/screens/HomeScreen/HomeScreen.tsx b/apps/Mobile/src/screens/HomeScreen/HomeScreen.tsx index d011ca10..5597f1f8 100644 --- a/apps/Mobile/src/screens/HomeScreen/HomeScreen.tsx +++ b/apps/Mobile/src/screens/HomeScreen/HomeScreen.tsx @@ -23,6 +23,7 @@ import { RefreshStatusBar } from "../../components/RefreshStatusBar"; import { ServiceHistoryContent } from "../ServiceHistoryScreen/ServiceHistoryContent"; import { useTheme } from "../../hooks/useTheme"; import { FuelType, TransmissionType, WheelDriveType } from "../../types/api"; +import { formatEngineDisplay } from "../../utils/vehicleFormUtils"; const FUEL_LABELS: Record = { [FuelType.Gasoline]: "Бензин", @@ -487,8 +488,11 @@ export default function HomeScreen({ navigation }: HomeScreenProps) { Двигун - {(car.engineCapacity / 1000).toFixed(1)} л,{" "} - {car.enginePower} к.с. + {formatEngineDisplay( + car.engineCapacity, + car.enginePower, + car.fuelType as FuelType + )} diff --git a/apps/Mobile/src/utils/apiErrorUtils.ts b/apps/Mobile/src/utils/apiErrorUtils.ts new file mode 100644 index 00000000..824ae977 --- /dev/null +++ b/apps/Mobile/src/utils/apiErrorUtils.ts @@ -0,0 +1,49 @@ +const API_ERROR_MESSAGES_UK: Record = { + "Vin is required.": "Введіть VIN номер.", + "Vin must be 17 characters long.": "VIN має містити рівно 17 символів.", + "Vin must not exceed 17 characters.": "VIN має містити рівно 17 символів.", + "Vin must be a valid 17-character VIN.": + "VIN має містити 17 символів: латинські літери (без I, O, Q) та цифри.", + "License plate is required.": "Введіть номерний знак.", + "License plate must not exceed 10 characters.": + "Номерний знак не може перевищувати 10 символів.", +}; + +function translateApiMessage(message: string): string { + return API_ERROR_MESSAGES_UK[message] ?? message; +} + +export function parseApiErrorMessage(error: unknown): string { + if (!(error instanceof Error)) { + return "Не вдалося зберегти дані"; + } + + try { + const parsed = JSON.parse(error.message) as { + title?: string; + errors?: Record; + }; + + if (parsed.errors) { + const messages = Object.values(parsed.errors) + .flat() + .map(translateApiMessage); + + if (messages.length > 0) { + return messages.join("\n"); + } + } + + if (parsed.title) { + return translateApiMessage(parsed.title); + } + } catch { + // Response is not JSON — use raw message below. + } + + if (error.message.includes("401")) { + return "Сесія закінчилася. Будь ласка, увійдіть знову."; + } + + return translateApiMessage(error.message); +} diff --git a/apps/Mobile/src/utils/vehicleFormUtils.ts b/apps/Mobile/src/utils/vehicleFormUtils.ts new file mode 100644 index 00000000..8cc08fd7 --- /dev/null +++ b/apps/Mobile/src/utils/vehicleFormUtils.ts @@ -0,0 +1,134 @@ +import { SelectOption } from "../components/Select"; +import { FuelType, TransmissionType } from "../types/api"; + +export function normalizeFuelType( + fuelType?: string | number | null +): number | undefined { + if (fuelType === undefined || fuelType === null || fuelType === "") { + return undefined; + } + + if (typeof fuelType === "number") { + return fuelType; + } + + const parsed = Number(fuelType); + return Number.isNaN(parsed) ? undefined : parsed; +} + +export function normalizeTransmissionType( + transmission?: string | number | null +): number | undefined { + if (transmission === undefined || transmission === null || transmission === "") { + return undefined; + } + + if (typeof transmission === "number") { + return transmission; + } + + const parsed = Number(transmission); + return Number.isNaN(parsed) ? undefined : parsed; +} + +export function requiresEngineDisplacement( + fuelType?: string | number | null +): boolean { + const normalizedFuelType = normalizeFuelType(fuelType); + + if (normalizedFuelType === undefined) { + return false; + } + + return ( + normalizedFuelType !== FuelType.Electric && + normalizedFuelType !== FuelType.Hydrogen + ); +} + +export function getEnginePowerLabel(fuelType?: string | number | null): string { + const normalizedFuelType = normalizeFuelType(fuelType); + + if (normalizedFuelType === FuelType.Electric) { + return "Потужність електромотора (к.с.) *"; + } + + if (normalizedFuelType === FuelType.Hydrogen) { + return "Потужність силової установки (к.с.) *"; + } + + return "Потужність двигуна (к.с.) *"; +} + +export function getTransmissionOptionsForFuel( + fuelType: string | number | undefined, + allOptions: SelectOption[] +): SelectOption[] { + if (normalizeFuelType(fuelType) === FuelType.Electric) { + return allOptions.filter( + (option) => + normalizeTransmissionType(option.value) === TransmissionType.Automatic || + normalizeTransmissionType(option.value) === TransmissionType.CVT + ); + } + + return allOptions; +} + +export function isTransmissionAllowedForFuel( + fuelType: string | number | undefined, + transmission: string | number | undefined +): boolean { + if (normalizeFuelType(fuelType) !== FuelType.Electric) { + return true; + } + + const normalizedTransmission = normalizeTransmissionType(transmission); + if (normalizedTransmission === undefined) { + return true; + } + + return ( + normalizedTransmission === TransmissionType.Automatic || + normalizedTransmission === TransmissionType.CVT + ); +} + +export function resolveEngineCapacityCc( + fuelType: string | number, + displacement: string +): number | null { + if (!requiresEngineDisplacement(fuelType)) { + return 0; + } + + const displacementNum = displacement.trim() + ? parseFloat(displacement.replace(",", ".")) + : NaN; + + if (!displacement.trim() || isNaN(displacementNum) || displacementNum <= 0) { + return null; + } + + const engineCapacityCc = Math.round(displacementNum * 1000); + + if (engineCapacityCc <= 0 || engineCapacityCc > 10000) { + return null; + } + + return engineCapacityCc; +} + +export function formatEngineDisplay( + engineCapacity: number, + enginePower: number, + fuelType: FuelType +): string { + const power = `${enginePower} к.с.`; + + if (!requiresEngineDisplacement(fuelType) || engineCapacity <= 0) { + return power; + } + + return `${(engineCapacity / 1000).toFixed(1)} л, ${power}`; +} diff --git a/apps/Mobile/src/utils/vinUtils.ts b/apps/Mobile/src/utils/vinUtils.ts new file mode 100644 index 00000000..1c494516 --- /dev/null +++ b/apps/Mobile/src/utils/vinUtils.ts @@ -0,0 +1,12 @@ +const VIN_REGEX = /^[A-HJ-NPR-Z0-9]{17}$/i; + +export function normalizeVinInput(value: string): string { + return value.toUpperCase().replace(/[^A-HJ-NPR-Z0-9]/gi, "").slice(0, 17); +} + +export function isValidVin(vin: string): boolean { + return VIN_REGEX.test(vin.trim()); +} + +export const VIN_VALIDATION_MESSAGE = + "VIN має містити 17 символів: латинські літери (без I, O, Q) та цифри"; From 3fdb3cfba9c6de5573fb94a98b92c1a318d89b5b Mon Sep 17 00:00:00 2001 From: olegvls Date: Wed, 1 Jul 2026 13:51:01 +0300 Subject: [PATCH 03/10] fix review --- .../MainHub.Api/Services/ServiceHistoryService.cs | 11 ----------- Services/Main/MainHub.Api/Services/VehicleService.cs | 6 +++--- .../Validators/CreateVehicleDtoValidator.cs | 8 ++------ 3 files changed, 5 insertions(+), 20 deletions(-) diff --git a/Services/Main/MainHub.Api/Services/ServiceHistoryService.cs b/Services/Main/MainHub.Api/Services/ServiceHistoryService.cs index 95fe550a..4efc7c81 100644 --- a/Services/Main/MainHub.Api/Services/ServiceHistoryService.cs +++ b/Services/Main/MainHub.Api/Services/ServiceHistoryService.cs @@ -34,12 +34,6 @@ public interface IServiceHistoryService /// A task that represents the asynchronous operation. Task DeleteAllByVehicleIdAsync(Guid vehicleId, Guid userId); - /// - /// Deletes all service history for a vehicle without ownership validation. - /// Caller must verify that the user owns the vehicle before invoking. - /// - Task DeleteAllByVehicleIdUncheckedAsync(Guid vehicleId); - /// /// Retrieves a service history record by its unique identifier asynchronously. /// @@ -85,11 +79,6 @@ public async Task DeleteAllByVehicleIdAsync(Guid vehicleId, Guid userId) await _repository.DeleteAllByVehicleIdAsync(vehicleId); } - public Task DeleteAllByVehicleIdUncheckedAsync(Guid vehicleId) - { - return _repository.DeleteAllByVehicleIdAsync(vehicleId); - } - public async Task UpdateAsync(Guid vehicleId, Guid serviceHistoryId, CreateServiceHistoryDetailsDto updateServiceHistoryDetailsDto, Guid userId) { await EnsureValidVehicleRequest(userId, vehicleId); diff --git a/Services/Main/MainHub.Api/Services/VehicleService.cs b/Services/Main/MainHub.Api/Services/VehicleService.cs index 39b72c3b..52dd4320 100644 --- a/Services/Main/MainHub.Api/Services/VehicleService.cs +++ b/Services/Main/MainHub.Api/Services/VehicleService.cs @@ -63,12 +63,12 @@ public interface IVehicleService public class VehicleService( IVehicleRepository repository, IUserService userService, - IServiceHistoryRepository serviceHistoryRepository + IServiceHistoryService serviceHistoryService ) : IVehicleService { private readonly IVehicleRepository _repository = repository; private readonly IUserService _userService = userService; - private readonly IServiceHistoryRepository _serviceHistoryRepository = serviceHistoryRepository; + private readonly IServiceHistoryService _serviceHistoryService = serviceHistoryService; public async Task GetVehicleByIdAsync(Guid vehicleId, Guid userId) { @@ -210,7 +210,7 @@ public async Task DeleteAsync(Guid vehicleId, Guid userId) throw new ArgumentException("Vehicle not associated with the user.", nameof(vehicleId)); } - await _serviceHistoryRepository.DeleteAllByVehicleIdAsync(vehicleId); + await _serviceHistoryService.DeleteAllByVehicleIdAsync(vehicleId, userId); await _userService.DetachVehicleAsync(vehicleId, userId); var vehicle = await _repository.GetByIdAsync(vehicleId); diff --git a/Services/Main/MainHub.Api/Validators/CreateVehicleDtoValidator.cs b/Services/Main/MainHub.Api/Validators/CreateVehicleDtoValidator.cs index 3561645d..2a62e47d 100644 --- a/Services/Main/MainHub.Api/Validators/CreateVehicleDtoValidator.cs +++ b/Services/Main/MainHub.Api/Validators/CreateVehicleDtoValidator.cs @@ -1,4 +1,3 @@ -using System.Data; using System.Text.RegularExpressions; using FluentValidation; using MainHub.Api.DTOs; @@ -61,6 +60,8 @@ public CreateVehicleDtoValidator() .When(x => x.BoughtAt.HasValue); RuleFor(x => x.EngineCapacity) + .GreaterThanOrEqualTo(0) + .WithMessage("Engine capacity cannot be negative.") .LessThanOrEqualTo(10000) .WithMessage("Engine capacity must be less than or equal to 10000."); @@ -69,11 +70,6 @@ public CreateVehicleDtoValidator() .WithMessage("Engine capacity must be greater than 0.") .When(x => x.FuelType is not FuelType.Electric and not FuelType.Hydrogen); - RuleFor(x => x.EngineCapacity) - .GreaterThanOrEqualTo(0) - .WithMessage("Engine capacity cannot be negative.") - .When(x => x.FuelType is FuelType.Electric or FuelType.Hydrogen); - RuleFor(x => x.Mileage) .NotEmpty() .WithMessage("Mileage is required.") From 368cfbc0cb6f27bdaecff10b23f1b3af83379225 Mon Sep 17 00:00:00 2001 From: olegvls Date: Wed, 1 Jul 2026 14:59:37 +0300 Subject: [PATCH 04/10] add visits and online registration --- .../MainHub.Api/Services/VehicleService.cs | 7 + apps/AdminWebUi/src/App.tsx | 5 +- .../ActivityLog/ActivityLog.module.css | 233 ++++++++++++ .../components/ActivityLog/ActivityLog.tsx | 120 ++++++ .../src/components/ActivityLog/index.ts | 1 + .../BookingRequestCard.module.css | 152 ++++++++ .../BookingRequestCard/BookingRequestCard.tsx | 74 ++++ .../components/BookingRequestCard/index.ts | 1 + .../BookingRequestDetailModal.module.css | 358 ++++++++++++++++++ .../BookingRequestDetailModal.tsx | 293 ++++++++++++++ .../BookingRequestDetailModal/index.ts | 1 + .../BookingStatusBadge.module.css | 18 + .../BookingStatusBadge/BookingStatusBadge.tsx | 49 +++ .../components/BookingStatusBadge/index.ts | 1 + .../components/DataTable/DataTable.module.css | 9 + .../src/components/DataTable/DataTable.tsx | 8 +- .../StoVehicleActivityLog.tsx | 19 + .../components/StoVehicleActivityLog/index.ts | 1 + .../StoVehicleDetailModal.module.css | 2 +- .../StoVehicleDetailModal.tsx | 3 + .../VisitDetailModal.module.css | 248 ++++++++++++ .../VisitDetailModal/VisitDetailModal.tsx | 161 ++++++++ .../src/components/VisitDetailModal/index.ts | 1 + .../VisitStatusBadge.module.css | 18 + .../VisitStatusBadge/VisitStatusBadge.tsx | 28 ++ .../src/components/VisitStatusBadge/index.ts | 1 + apps/AdminWebUi/src/config/navigation.ts | 14 + .../src/context/BookingRequestContext.tsx | 119 ++++++ .../src/context/StoVehicleContext.tsx | 2 +- .../src/data/mockBookingRequests.ts | 124 ++++++ apps/AdminWebUi/src/data/mockVisits.ts | 209 ++++++++++ .../BookingRequestsPage.module.css | 175 +++++++++ .../BookingRequestsPage.tsx | 185 +++++++++ .../src/pages/BookingRequestsPage/index.ts | 1 + .../pages/VisitsPage/VisitsPage.module.css | 124 ++++++ .../src/pages/VisitsPage/VisitsPage.tsx | 162 ++++++++ apps/AdminWebUi/src/pages/VisitsPage/index.ts | 1 + apps/AdminWebUi/src/routes/AppRouter.tsx | 20 + apps/AdminWebUi/src/types/bookingRequest.ts | 34 ++ apps/AdminWebUi/src/types/stoVehicle.ts | 31 ++ apps/AdminWebUi/src/types/visit.ts | 33 ++ apps/AdminWebUi/src/utils/bookingLabels.ts | 82 ++++ .../src/utils/stoVehicleActivityLog.ts | 214 +++++++++++ apps/AdminWebUi/src/utils/visitActivityLog.ts | 131 +++++++ apps/AdminWebUi/src/utils/visitLabels.ts | 82 ++++ 45 files changed, 3551 insertions(+), 4 deletions(-) create mode 100644 apps/AdminWebUi/src/components/ActivityLog/ActivityLog.module.css create mode 100644 apps/AdminWebUi/src/components/ActivityLog/ActivityLog.tsx create mode 100644 apps/AdminWebUi/src/components/ActivityLog/index.ts create mode 100644 apps/AdminWebUi/src/components/BookingRequestCard/BookingRequestCard.module.css create mode 100644 apps/AdminWebUi/src/components/BookingRequestCard/BookingRequestCard.tsx create mode 100644 apps/AdminWebUi/src/components/BookingRequestCard/index.ts create mode 100644 apps/AdminWebUi/src/components/BookingRequestDetailModal/BookingRequestDetailModal.module.css create mode 100644 apps/AdminWebUi/src/components/BookingRequestDetailModal/BookingRequestDetailModal.tsx create mode 100644 apps/AdminWebUi/src/components/BookingRequestDetailModal/index.ts create mode 100644 apps/AdminWebUi/src/components/BookingStatusBadge/BookingStatusBadge.module.css create mode 100644 apps/AdminWebUi/src/components/BookingStatusBadge/BookingStatusBadge.tsx create mode 100644 apps/AdminWebUi/src/components/BookingStatusBadge/index.ts create mode 100644 apps/AdminWebUi/src/components/StoVehicleActivityLog/StoVehicleActivityLog.tsx create mode 100644 apps/AdminWebUi/src/components/StoVehicleActivityLog/index.ts create mode 100644 apps/AdminWebUi/src/components/VisitDetailModal/VisitDetailModal.module.css create mode 100644 apps/AdminWebUi/src/components/VisitDetailModal/VisitDetailModal.tsx create mode 100644 apps/AdminWebUi/src/components/VisitDetailModal/index.ts create mode 100644 apps/AdminWebUi/src/components/VisitStatusBadge/VisitStatusBadge.module.css create mode 100644 apps/AdminWebUi/src/components/VisitStatusBadge/VisitStatusBadge.tsx create mode 100644 apps/AdminWebUi/src/components/VisitStatusBadge/index.ts create mode 100644 apps/AdminWebUi/src/context/BookingRequestContext.tsx create mode 100644 apps/AdminWebUi/src/data/mockBookingRequests.ts create mode 100644 apps/AdminWebUi/src/data/mockVisits.ts create mode 100644 apps/AdminWebUi/src/pages/BookingRequestsPage/BookingRequestsPage.module.css create mode 100644 apps/AdminWebUi/src/pages/BookingRequestsPage/BookingRequestsPage.tsx create mode 100644 apps/AdminWebUi/src/pages/BookingRequestsPage/index.ts create mode 100644 apps/AdminWebUi/src/pages/VisitsPage/VisitsPage.module.css create mode 100644 apps/AdminWebUi/src/pages/VisitsPage/VisitsPage.tsx create mode 100644 apps/AdminWebUi/src/pages/VisitsPage/index.ts create mode 100644 apps/AdminWebUi/src/types/bookingRequest.ts create mode 100644 apps/AdminWebUi/src/types/visit.ts create mode 100644 apps/AdminWebUi/src/utils/bookingLabels.ts create mode 100644 apps/AdminWebUi/src/utils/stoVehicleActivityLog.ts create mode 100644 apps/AdminWebUi/src/utils/visitActivityLog.ts create mode 100644 apps/AdminWebUi/src/utils/visitLabels.ts diff --git a/Services/Main/MainHub.Api/Services/VehicleService.cs b/Services/Main/MainHub.Api/Services/VehicleService.cs index 52dd4320..90654fe5 100644 --- a/Services/Main/MainHub.Api/Services/VehicleService.cs +++ b/Services/Main/MainHub.Api/Services/VehicleService.cs @@ -334,6 +334,13 @@ IReadOnlyDictionary ownerMap Year = vehicle.Year, PhotoUrl = vehicle.PhotoUrl, OwnerUserId = ownerUserId, + Vin = vehicle.Vin, + FuelType = vehicle.FuelType, + TransmissionType = vehicle.TransmissionType, + WheelDriveType = vehicle.WheelDriveType, + Color = vehicle.Color, + Mileage = vehicle.Mileage, + CreatedAt = vehicle.CreatedAt, }; } } diff --git a/apps/AdminWebUi/src/App.tsx b/apps/AdminWebUi/src/App.tsx index 9630cc9c..57d38541 100644 --- a/apps/AdminWebUi/src/App.tsx +++ b/apps/AdminWebUi/src/App.tsx @@ -6,6 +6,7 @@ import { BrowserRouter } from "react-router-dom"; import { AuthProvider } from "./auth/AuthContext"; import { StoProvider } from "./context/StoContext"; import { StoVehicleProvider } from "./context/StoVehicleContext"; +import { BookingRequestProvider } from "./context/BookingRequestContext"; import { queryClient } from "./lib/queryClient"; import { AppRouter } from "./routes/AppRouter"; import { persistor, store } from "./store"; @@ -19,7 +20,9 @@ function App() { - + + + diff --git a/apps/AdminWebUi/src/components/ActivityLog/ActivityLog.module.css b/apps/AdminWebUi/src/components/ActivityLog/ActivityLog.module.css new file mode 100644 index 00000000..4cd79dee --- /dev/null +++ b/apps/AdminWebUi/src/components/ActivityLog/ActivityLog.module.css @@ -0,0 +1,233 @@ +.section { + margin: 20px 24px 24px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: var(--radius-lg); + background-color: #1a1c1e; + overflow: hidden; +} + +.toggle { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; + padding: 16px 20px; + border: none; + background: linear-gradient( + 180deg, + rgba(74, 222, 158, 0.04) 0%, + transparent 100% + ); + color: inherit; + cursor: pointer; + text-align: left; +} + +.toggle:hover { + background-color: rgba(255, 255, 255, 0.02); +} + +.toggleMain { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.toggleTitle { + margin: 0; + font-size: 13px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-secondary); +} + +.countBadge { + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + color: var(--accent-primary); + background-color: rgba(74, 222, 158, 0.12); + border: 1px solid rgba(74, 222, 158, 0.24); +} + +.toggleHint { + margin: 4px 0 0; + font-size: 12px; + color: var(--text-tertiary); + line-height: 1.4; +} + +.chevron { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + flex-shrink: 0; + border-radius: var(--radius-sm); + border: 1px solid rgba(255, 255, 255, 0.08); + color: var(--text-secondary); + transition: + transform 0.3s ease, + color 0.2s ease, + border-color 0.2s ease; +} + +.chevronExpanded { + transform: rotate(180deg); + color: var(--accent-primary); + border-color: rgba(74, 222, 158, 0.24); +} + +.bodyWrapper { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.32s ease; +} + +.bodyWrapperExpanded { + grid-template-rows: 1fr; +} + +.bodyInner { + overflow: hidden; + min-height: 0; + opacity: 0; + transition: opacity 0.24s ease; +} + +.bodyWrapperExpanded .bodyInner { + opacity: 1; +} + +.body { + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.scrollArea { + max-height: 360px; + overflow-y: auto; + padding: 12px 16px 16px; +} + +.scrollArea::-webkit-scrollbar { + width: 6px; +} + +.scrollArea::-webkit-scrollbar-thumb { + background-color: rgba(255, 255, 255, 0.12); + border-radius: 3px; +} + +.list { + display: flex; + flex-direction: column; + gap: 0; + margin: 0; + padding: 0; + list-style: none; +} + +.entry { + display: grid; + grid-template-columns: 20px 1fr; + gap: 12px; + padding: 12px 4px; + position: relative; +} + +.entry:not(:last-child)::after { + content: ""; + position: absolute; + left: 9px; + top: 32px; + bottom: -4px; + width: 2px; + background-color: rgba(255, 255, 255, 0.08); + border-radius: 1px; +} + +.marker { + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 4px; +} + +.dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; + box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.04); +} + +.entryMain { + min-width: 0; +} + +.entryHeader { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 8px; + margin-bottom: 4px; +} + +.entryTitle { + margin: 0; + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + line-height: 1.35; +} + +.entryTime { + font-size: 11px; + color: var(--text-tertiary); + white-space: nowrap; +} + +.entryMeta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin-bottom: 4px; +} + +.actorName { + font-size: 12px; + color: var(--text-secondary); +} + +.actorRole { + padding: 1px 6px; + border-radius: 4px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; + color: var(--text-tertiary); + background-color: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.entryDescription { + margin: 0; + font-size: 12px; + color: var(--text-secondary); + line-height: 1.45; +} + +.empty { + padding: 20px 16px; + font-size: 13px; + color: var(--text-tertiary); + text-align: center; +} diff --git a/apps/AdminWebUi/src/components/ActivityLog/ActivityLog.tsx b/apps/AdminWebUi/src/components/ActivityLog/ActivityLog.tsx new file mode 100644 index 00000000..baf77828 --- /dev/null +++ b/apps/AdminWebUi/src/components/ActivityLog/ActivityLog.tsx @@ -0,0 +1,120 @@ +import { useState } from "react"; +import type { StoVehicleActivityEntry, StoVehicleActivityType } from "../../types/stoVehicle"; +import { ACTIVITY_ACTOR_ROLE_LABELS } from "../../utils/stoVehicleActivityLog"; +import { formatEventDateTime } from "../../utils/stoVehicleLabels"; +import styles from "./ActivityLog.module.css"; + +const ACTIVITY_DOT_COLORS: Record = { + vehicle_created: "#6e7681", + appointment_scheduled: "#58a6ff", + vehicle_arrived: "#4ade9e", + status_changed: "#a371f7", + mechanic_assigned: "#d29922", + approval_requested: "#ffa657", + approval_sent_to_client: "#58a6ff", + approval_approved: "#4ade9e", + approval_rejected: "#f85149", +}; + +interface ActivityLogProps { + entries: StoVehicleActivityEntry[]; + hint?: string; +} + +function ChevronIcon({ expanded }: { expanded: boolean }) { + return ( + + ); +} + +export function ActivityLog({ + entries, + hint = "Повний життєвий цикл: статуси, погодження, призначення механіка", +}: ActivityLogProps) { + const [isExpanded, setIsExpanded] = useState(false); + + return ( +
+ + +
+
+
+ {entries.length === 0 ? ( +

Записів у журналі поки немає.

+ ) : ( +
+
    + {entries.map((entry) => ( +
  • +
    + +
    +
    +
    +

    {entry.title}

    + +
    +
    + {entry.actorName} + + {ACTIVITY_ACTOR_ROLE_LABELS[entry.actorRole]} + +
    + {entry.description ? ( +

    {entry.description}

    + ) : null} +
    +
  • + ))} +
+
+ )} +
+
+
+
+ ); +} diff --git a/apps/AdminWebUi/src/components/ActivityLog/index.ts b/apps/AdminWebUi/src/components/ActivityLog/index.ts new file mode 100644 index 00000000..bf5f12d2 --- /dev/null +++ b/apps/AdminWebUi/src/components/ActivityLog/index.ts @@ -0,0 +1 @@ +export { ActivityLog } from "./ActivityLog"; diff --git a/apps/AdminWebUi/src/components/BookingRequestCard/BookingRequestCard.module.css b/apps/AdminWebUi/src/components/BookingRequestCard/BookingRequestCard.module.css new file mode 100644 index 00000000..535fe375 --- /dev/null +++ b/apps/AdminWebUi/src/components/BookingRequestCard/BookingRequestCard.module.css @@ -0,0 +1,152 @@ +.card { + background-color: #1a1c1e; + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: var(--radius-lg); + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; + cursor: pointer; + transition: + border-color 0.15s ease, + transform 0.15s ease; +} + +.card:hover { + border-color: rgba(74, 222, 158, 0.28); + transform: translateY(-1px); +} + +.cardPending { + border-color: rgba(255, 166, 87, 0.2); +} + +.cardHeader { + display: flex; + gap: 12px; + align-items: flex-start; +} + +.icon { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + flex-shrink: 0; + background-color: rgba(74, 222, 158, 0.1); + border-radius: var(--radius-md); + color: var(--accent-primary); +} + +.icon svg { + width: 22px; + height: 22px; +} + +.headerContent { + flex: 1; + min-width: 0; +} + +.titleRow { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; + margin-bottom: 4px; +} + +.vehicleTitle { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + line-height: 1.3; +} + +.plateBadge { + flex-shrink: 0; + padding: 3px 8px; + background-color: #1a4038; + border-radius: 6px; + font-size: 11px; + font-weight: 600; + color: var(--accent-primary); +} + +.clientName { + margin: 0; + font-size: 13px; + color: var(--text-secondary); +} + +.reason { + margin: 0; + font-size: 13px; + color: var(--text-primary); + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.metaGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.metaItem { + padding: 8px 10px; + border-radius: var(--radius-sm); + background-color: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.05); +} + +.metaLabel { + display: block; + margin-bottom: 2px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.3px; + text-transform: uppercase; + color: var(--text-tertiary); +} + +.metaValue { + font-size: 12px; + color: var(--text-primary); + line-height: 1.35; +} + +.footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding-top: 4px; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.createdAt { + font-size: 11px; + color: var(--text-tertiary); +} + +.footerMeta { + display: flex; + align-items: center; + gap: 10px; +} + +.attachments { + font-size: 11px; + color: var(--text-secondary); +} + +@media (max-width: 700px) { + .metaGrid { + grid-template-columns: 1fr; + } +} diff --git a/apps/AdminWebUi/src/components/BookingRequestCard/BookingRequestCard.tsx b/apps/AdminWebUi/src/components/BookingRequestCard/BookingRequestCard.tsx new file mode 100644 index 00000000..05490f94 --- /dev/null +++ b/apps/AdminWebUi/src/components/BookingRequestCard/BookingRequestCard.tsx @@ -0,0 +1,74 @@ +import type { BookingRequest } from "../../types/bookingRequest"; +import { + formatPreferredSlot, + PARTS_OPTION_LABELS, + VISIT_REASON_LABELS, +} from "../../utils/bookingLabels"; +import { formatEventDateTime, formatStoVehicleTitle } from "../../utils/stoVehicleLabels"; +import { BookingStatusBadge } from "../BookingStatusBadge"; +import styles from "./BookingRequestCard.module.css"; + +interface BookingRequestCardProps { + request: BookingRequest; + onOpen: (request: BookingRequest) => void; +} + +export function BookingRequestCard({ request, onOpen }: BookingRequestCardProps) { + const title = formatStoVehicleTitle(request.brand, request.model, request.year); + const preferredSlot = formatPreferredSlot(request.preferredDate, request.preferredTimeSlot); + + return ( +
onOpen(request)} + > +
+ +
+
+

{title}

+ {request.licensePlate} +
+

{request.clientName}

+
+
+ +

+ {VISIT_REASON_LABELS[request.visitReason]} — {request.problemDescription} +

+ +
+
+ Зручно водію + {preferredSlot} +
+
+ Запчастини + {PARTS_OPTION_LABELS[request.partsOption]} +
+
+ +
+ +
+ {request.attachmentsCount ? ( + 📎 {request.attachmentsCount} + ) : null} + {formatEventDateTime(request.createdAt)} +
+
+
+ ); +} diff --git a/apps/AdminWebUi/src/components/BookingRequestCard/index.ts b/apps/AdminWebUi/src/components/BookingRequestCard/index.ts new file mode 100644 index 00000000..ced5ac6f --- /dev/null +++ b/apps/AdminWebUi/src/components/BookingRequestCard/index.ts @@ -0,0 +1 @@ +export { BookingRequestCard } from "./BookingRequestCard"; diff --git a/apps/AdminWebUi/src/components/BookingRequestDetailModal/BookingRequestDetailModal.module.css b/apps/AdminWebUi/src/components/BookingRequestDetailModal/BookingRequestDetailModal.module.css new file mode 100644 index 00000000..6f54eabe --- /dev/null +++ b/apps/AdminWebUi/src/components/BookingRequestDetailModal/BookingRequestDetailModal.module.css @@ -0,0 +1,358 @@ +.modalContent { + display: flex; + flex-direction: column; + min-height: 480px; +} + +.topBar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 24px 24px 20px; + border-bottom: 1px solid var(--border-secondary); + background: linear-gradient( + 180deg, + rgba(74, 222, 158, 0.06) 0%, + transparent 100% + ); +} + +.topBarMain { + display: flex; + gap: 14px; + align-items: flex-start; + min-width: 0; +} + +.topBarIcon { + display: flex; + align-items: center; + justify-content: center; + width: 52px; + height: 52px; + flex-shrink: 0; + border-radius: var(--radius-md); + background-color: rgba(74, 222, 158, 0.12); + color: var(--accent-primary); +} + +.topBarIcon svg { + width: 26px; + height: 26px; +} + +.topBarTitle { + font-size: 22px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 8px; +} + +.topBarMeta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.plateBadge { + padding: 4px 10px; + background-color: #1a4038; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + color: var(--accent-primary); +} + +.sourcePill { + padding: 4px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + color: #58a6ff; + background-color: rgba(88, 166, 255, 0.14); + border: 1px solid rgba(88, 166, 255, 0.28); +} + +.closeButton { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + flex-shrink: 0; + border: 1px solid var(--border-primary); + border-radius: var(--radius-sm); + background-color: var(--bg-tertiary); + color: var(--text-secondary); + font-size: 24px; + line-height: 1; + cursor: pointer; +} + +.closeButton:hover { + color: var(--text-primary); + background-color: var(--bg-quaternary); +} + +.summaryGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + padding: 20px 24px; + border-bottom: 1px solid var(--border-secondary); +} + +.summaryCard { + padding: 16px; + border-radius: var(--radius-md); + background-color: #1a1c1e; + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.summaryLabel { + margin: 0 0 12px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.5px; + text-transform: uppercase; + color: var(--accent-primary); +} + +.summaryList { + display: flex; + flex-direction: column; + gap: 10px; + margin: 0; +} + +.summaryRow { + display: grid; + grid-template-columns: 120px 1fr; + gap: 12px; +} + +.summaryRow dt { + font-size: 12px; + color: var(--text-tertiary); +} + +.summaryRow dd { + margin: 0; + font-size: 14px; + color: var(--text-primary); + line-height: 1.4; +} + +.problemSection { + padding: 20px 24px; + border-bottom: 1px solid var(--border-secondary); +} + +.sectionTitle { + margin: 0 0 12px; + font-size: 13px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-secondary); +} + +.problemCard { + padding: 14px 16px; + border-radius: var(--radius-md); + background-color: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.reasonBadge { + display: inline-block; + margin-bottom: 8px; + padding: 4px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + color: var(--accent-primary); + background-color: rgba(74, 222, 158, 0.12); + border: 1px solid rgba(74, 222, 158, 0.24); +} + +.problemText { + margin: 0; + font-size: 14px; + color: var(--text-primary); + line-height: 1.5; +} + +.schedulingSection { + padding: 20px 24px 24px; +} + +.schedulingGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-bottom: 16px; +} + +.preferenceCard, +.confirmCard { + padding: 16px; + border-radius: var(--radius-md); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.preferenceCard { + background-color: rgba(88, 166, 255, 0.06); + border-color: rgba(88, 166, 255, 0.18); +} + +.confirmCard { + background-color: rgba(74, 222, 158, 0.05); + border-color: rgba(74, 222, 158, 0.18); +} + +.cardTitle { + margin: 0 0 10px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.4px; +} + +.preferenceCard .cardTitle { + color: #58a6ff; +} + +.confirmCard .cardTitle { + color: var(--accent-primary); +} + +.preferenceValue { + margin: 0; + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + line-height: 1.4; +} + +.preferenceHint { + margin: 6px 0 0; + font-size: 12px; + color: var(--text-secondary); +} + +.datetimeInput { + width: 100%; + padding: 10px 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-primary); + background-color: var(--bg-tertiary); + color: var(--text-primary); + font-size: 14px; + font-family: inherit; +} + +.datetimeInput:focus { + outline: none; + border-color: var(--border-focus); +} + +.fieldError { + margin: 6px 0 0; + font-size: 12px; + color: var(--accent-error); +} + +.noteTextarea { + width: 100%; + margin-top: 12px; + padding: 10px 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-primary); + background-color: var(--bg-tertiary); + color: var(--text-primary); + font-size: 13px; + font-family: inherit; + resize: vertical; +} + +.noteTextarea:focus { + outline: none; + border-color: var(--border-focus); +} + +.attachmentsNote { + margin: 0 0 16px; + padding: 10px 12px; + border-radius: var(--radius-sm); + font-size: 12px; + color: var(--text-secondary); + background-color: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + padding-top: 4px; +} + +.rejectForm { + display: flex; + flex-direction: column; + gap: 10px; + flex-basis: 100%; + padding: 12px; + border-radius: var(--radius-sm); + background-color: rgba(248, 81, 73, 0.06); + border: 1px solid rgba(248, 81, 73, 0.18); +} + +.rejectHint { + margin: 0; + font-size: 12px; + color: var(--text-secondary); +} + +.resolvedBanner { + padding: 14px 16px; + border-radius: var(--radius-md); + background-color: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.resolvedTitle { + margin: 0 0 6px; + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +.resolvedText { + margin: 0; + font-size: 13px; + color: var(--text-secondary); + line-height: 1.45; +} + +.successMessage { + margin: 0; + flex-basis: 100%; + font-size: 13px; + color: var(--accent-primary); +} + +@media (max-width: 700px) { + .summaryGrid, + .schedulingGrid { + grid-template-columns: 1fr; + } + + .summaryRow { + grid-template-columns: 1fr; + gap: 4px; + } +} diff --git a/apps/AdminWebUi/src/components/BookingRequestDetailModal/BookingRequestDetailModal.tsx b/apps/AdminWebUi/src/components/BookingRequestDetailModal/BookingRequestDetailModal.tsx new file mode 100644 index 00000000..e8ad5ab4 --- /dev/null +++ b/apps/AdminWebUi/src/components/BookingRequestDetailModal/BookingRequestDetailModal.tsx @@ -0,0 +1,293 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { Button } from "../Button"; +import { Modal } from "../Modal"; +import { BookingStatusBadge } from "../BookingStatusBadge"; +import type { BookingRequest } from "../../types/bookingRequest"; +import { + formatBookingDateTime, + formatPreferredSlot, + fromDatetimeLocalValue, + PARTS_OPTION_LABELS, + suggestedAppointmentIso, + toDatetimeLocalValue, + VISIT_REASON_LABELS, +} from "../../utils/bookingLabels"; +import { formatEventDateTime, formatStoVehicleTitle } from "../../utils/stoVehicleLabels"; +import styles from "./BookingRequestDetailModal.module.css"; + +interface BookingRequestDetailModalProps { + request: BookingRequest | null; + isOpen: boolean; + onClose: () => void; + onConfirm: (requestId: string, appointmentAt: string, managerNote?: string) => void; + onReject: (requestId: string, reason: string) => void; +} + +export function BookingRequestDetailModal({ + request, + isOpen, + onClose, + onConfirm, + onReject, +}: BookingRequestDetailModalProps) { + const [appointmentLocal, setAppointmentLocal] = useState(""); + const [managerNote, setManagerNote] = useState(""); + const [appointmentError, setAppointmentError] = useState(null); + const [showRejectForm, setShowRejectForm] = useState(false); + const [rejectReason, setRejectReason] = useState(""); + const [successMessage, setSuccessMessage] = useState(null); + + useEffect(() => { + if (!request) return; + const suggested = suggestedAppointmentIso(request.preferredDate, request.preferredTimeSlot); + setAppointmentLocal(toDatetimeLocalValue(suggested)); + setManagerNote(""); + setAppointmentError(null); + setShowRejectForm(false); + setRejectReason(""); + setSuccessMessage(null); + }, [request?.id]); + + if (!request) return null; + + const vehicleTitle = formatStoVehicleTitle(request.brand, request.model, request.year); + const preferredSlot = formatPreferredSlot(request.preferredDate, request.preferredTimeSlot); + const isPending = request.status === "pending"; + + const handleConfirm = () => { + if (!appointmentLocal) { + setAppointmentError("Вкажіть дату та час запису на СТО"); + return; + } + + setAppointmentError(null); + onConfirm(request.id, fromDatetimeLocalValue(appointmentLocal), managerNote); + setSuccessMessage("Запис підтверджено. Авто додано до черги зі статусом «Записано»."); + setShowRejectForm(false); + }; + + const handleReject = () => { + if (!rejectReason.trim()) return; + onReject(request.id, rejectReason.trim()); + setShowRejectForm(false); + }; + + return ( + +
+
+
+ +
+

Онлайн-запис

+
+ {request.licensePlate} + + Додаток водія +
+
+
+ +
+ +
+
+

Автомобіль

+
+
+
Марка / модель
+
{vehicleTitle}
+
+
+
Номер
+
{request.licensePlate}
+
+ {request.vin ? ( +
+
VIN
+
{request.vin}
+
+ ) : null} +
+
Запчастини
+
{PARTS_OPTION_LABELS[request.partsOption]}
+
+
+
+ +
+

Водій

+
+
+
Ім'я
+
{request.clientName}
+
+
+
Телефон
+
{request.clientPhone ?? "—"}
+
+
+
Надіслано
+
{formatEventDateTime(request.createdAt)}
+
+ {request.attachmentsCount ? ( +
+
Медіа
+
{request.attachmentsCount} фото/відео від водія
+
+ ) : null} +
+
+
+ +
+

Опис проблеми

+
+ + {VISIT_REASON_LABELS[request.visitReason]} + +

{request.problemDescription}

+
+
+ +
+

Планування візиту

+ + {request.attachmentsCount ? ( +

+ Водій додав {request.attachmentsCount} медіафайл(ів) до запиту. Перегляд вкладень + буде доступний після підключення API. +

+ ) : null} + +
+
+

Зручний час для водія

+

{preferredSlot}

+

+ Врахуйте побажання клієнта при підтвердженні запису +

+
+ +
+

Реальний час на СТО

+ {isPending ? ( + <> + { + setAppointmentLocal(event.target.value); + setAppointmentError(null); + }} + /> + {appointmentError ? ( +

{appointmentError}

+ ) : null} +