onRowClick(row) : undefined}
+ >
{columns.map((column) => (
{column.render(row)}
diff --git a/apps/AdminWebUi/src/components/StoVehicleActivityLog/StoVehicleActivityLog.tsx b/apps/AdminWebUi/src/components/StoVehicleActivityLog/StoVehicleActivityLog.tsx
new file mode 100644
index 000000000..4966c1c72
--- /dev/null
+++ b/apps/AdminWebUi/src/components/StoVehicleActivityLog/StoVehicleActivityLog.tsx
@@ -0,0 +1,19 @@
+import { useMemo } from "react";
+import { ActivityLog } from "../ActivityLog";
+import type { StoQueueVehicle } from "../../types/stoVehicle";
+import { buildVehicleActivityLog } from "../../utils/stoVehicleActivityLog";
+
+interface StoVehicleActivityLogProps {
+ vehicle: StoQueueVehicle;
+}
+
+export function StoVehicleActivityLog({ vehicle }: StoVehicleActivityLogProps) {
+ const entries = useMemo(() => buildVehicleActivityLog(vehicle), [vehicle]);
+
+ return (
+
+ );
+}
diff --git a/apps/AdminWebUi/src/components/StoVehicleActivityLog/index.ts b/apps/AdminWebUi/src/components/StoVehicleActivityLog/index.ts
new file mode 100644
index 000000000..74ffad021
--- /dev/null
+++ b/apps/AdminWebUi/src/components/StoVehicleActivityLog/index.ts
@@ -0,0 +1 @@
+export { StoVehicleActivityLog } from "./StoVehicleActivityLog";
diff --git a/apps/AdminWebUi/src/components/StoVehicleDetailModal/StoVehicleDetailModal.module.css b/apps/AdminWebUi/src/components/StoVehicleDetailModal/StoVehicleDetailModal.module.css
index 25b3e8f17..9da852ae6 100644
--- a/apps/AdminWebUi/src/components/StoVehicleDetailModal/StoVehicleDetailModal.module.css
+++ b/apps/AdminWebUi/src/components/StoVehicleDetailModal/StoVehicleDetailModal.module.css
@@ -272,7 +272,7 @@
.stepPanel {
display: flex;
- margin: 20px 24px 24px;
+ margin: 20px 24px 0;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--radius-lg);
overflow: hidden;
diff --git a/apps/AdminWebUi/src/components/StoVehicleDetailModal/StoVehicleDetailModal.tsx b/apps/AdminWebUi/src/components/StoVehicleDetailModal/StoVehicleDetailModal.tsx
index c1e6bb046..9cf2d52d4 100644
--- a/apps/AdminWebUi/src/components/StoVehicleDetailModal/StoVehicleDetailModal.tsx
+++ b/apps/AdminWebUi/src/components/StoVehicleDetailModal/StoVehicleDetailModal.tsx
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ApprovalRequestCard } from "../ApprovalRequestCard";
import { Button } from "../Button";
import { Modal } from "../Modal";
+import { StoVehicleActivityLog } from "../StoVehicleActivityLog";
import { StoVehicleStatusBadge } from "../StoVehicleStatusBadge";
import type {
StoQueueVehicle,
@@ -539,6 +540,8 @@ export function StoVehicleDetailModal({
) : null}
+
+
);
diff --git a/apps/AdminWebUi/src/components/VisitDetailModal/VisitDetailModal.module.css b/apps/AdminWebUi/src/components/VisitDetailModal/VisitDetailModal.module.css
new file mode 100644
index 000000000..170304772
--- /dev/null
+++ b/apps/AdminWebUi/src/components/VisitDetailModal/VisitDetailModal.module.css
@@ -0,0 +1,248 @@
+.modalContent {
+ display: flex;
+ flex-direction: column;
+ min-height: 420px;
+}
+
+.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);
+}
+
+.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: 110px 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;
+}
+
+.recordsSection {
+ padding: 20px 24px 0;
+}
+
+.sectionTitle {
+ margin: 0 0 14px;
+ font-size: 13px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--text-secondary);
+}
+
+.recordsPanel {
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+ background-color: color-mix(in srgb, var(--accent-primary) 4%, #1a1c1e);
+}
+
+.recordsPanelAccent {
+ height: 4px;
+ background-color: var(--accent-primary);
+}
+
+.recordsList {
+ display: flex;
+ flex-direction: column;
+}
+
+.recordRow {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 14px 18px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.recordRow:last-child {
+ border-bottom: none;
+}
+
+.recordTitle {
+ margin: 0 0 4px;
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.recordDescription {
+ margin: 0;
+ font-size: 12px;
+ color: var(--text-secondary);
+ line-height: 1.45;
+}
+
+.recordPrice {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--accent-primary);
+ white-space: nowrap;
+}
+
+.totalRow {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 16px 18px;
+ background-color: rgba(74, 222, 158, 0.06);
+ border-top: 1px solid rgba(74, 222, 158, 0.16);
+}
+
+.totalLabel {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--text-secondary);
+}
+
+.totalValue {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 700;
+ color: var(--accent-primary);
+}
+
+.emptyRecords {
+ padding: 24px 18px;
+ font-size: 13px;
+ color: var(--text-tertiary);
+ text-align: center;
+}
+
+@media (max-width: 700px) {
+ .summaryGrid {
+ grid-template-columns: 1fr;
+ }
+
+ .summaryRow {
+ grid-template-columns: 1fr;
+ gap: 4px;
+ }
+
+ .recordRow {
+ flex-direction: column;
+ gap: 8px;
+ }
+}
diff --git a/apps/AdminWebUi/src/components/VisitDetailModal/VisitDetailModal.tsx b/apps/AdminWebUi/src/components/VisitDetailModal/VisitDetailModal.tsx
new file mode 100644
index 000000000..34d582da9
--- /dev/null
+++ b/apps/AdminWebUi/src/components/VisitDetailModal/VisitDetailModal.tsx
@@ -0,0 +1,161 @@
+import { useMemo } from "react";
+import { Modal } from "../Modal";
+import { ActivityLog } from "../ActivityLog";
+import { VisitStatusBadge } from "../VisitStatusBadge";
+import type { VisitListItem } from "../../types/visit";
+import { buildVisitActivityLog } from "../../utils/visitActivityLog";
+import {
+ calcVisitTotal,
+ formatVisitDateTime,
+ formatVisitPrice,
+ formatVisitTitle,
+} from "../../utils/visitLabels";
+import styles from "./VisitDetailModal.module.css";
+
+interface VisitDetailModalProps {
+ visit: VisitListItem | null;
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+export function VisitDetailModal({ visit, isOpen, onClose }: VisitDetailModalProps) {
+ const activityEntries = useMemo(
+ () => (visit ? buildVisitActivityLog(visit) : []),
+ [visit],
+ );
+
+ if (!visit) return null;
+
+ const vehicleTitle = formatVisitTitle(visit.brand, visit.model, visit.year);
+ const total = calcVisitTotal(visit.records);
+
+ return (
+
+
+
+
+
+
+ {visit.title}
+
+ {visit.licensePlate}
+
+
+
+
+
+
+
+
+
+ Візит
+
+
+ - СТО
+ - {visit.stoName}
+
+
+ - Механік
+ - {visit.mechanicName ?? "Не призначено"}
+
+
+ - Початок
+ - {formatVisitDateTime(visit.createdAt)}
+
+ {visit.completedAt ? (
+
+ - Завершено
+ - {formatVisitDateTime(visit.completedAt)}
+
+ ) : visit.updatedAt ? (
+
+ - Оновлено
+ - {formatVisitDateTime(visit.updatedAt)}
+
+ ) : null}
+ {visit.description ? (
+
+ - Опис
+ - {visit.description}
+
+ ) : null}
+
+
+
+
+ Авто та клієнт
+
+
+ - Автомобіль
+ - {vehicleTitle}
+
+
+ - Номер
+ - {visit.licensePlate}
+
+
+ - Клієнт
+ - {visit.clientName}
+
+
+ - Телефон
+ - {visit.clientPhone ?? "—"}
+
+
+
+
+
+
+
+ Роботи та деталі ({visit.records.length})
+
+
+
+ {visit.records.length > 0 ? (
+ <>
+
+ {visit.records.map((record) => (
+
+
+ {record.title}
+ {record.description ? (
+ {record.description}
+ ) : null}
+
+ {formatVisitPrice(record.price)}
+
+ ))}
+
+
+ Загальна сума
+ {formatVisitPrice(total)}
+
+ >
+ ) : (
+ Список робіт порожній.
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/AdminWebUi/src/components/VisitDetailModal/index.ts b/apps/AdminWebUi/src/components/VisitDetailModal/index.ts
new file mode 100644
index 000000000..08418336d
--- /dev/null
+++ b/apps/AdminWebUi/src/components/VisitDetailModal/index.ts
@@ -0,0 +1 @@
+export { VisitDetailModal } from "./VisitDetailModal";
diff --git a/apps/AdminWebUi/src/components/VisitStatusBadge/VisitStatusBadge.module.css b/apps/AdminWebUi/src/components/VisitStatusBadge/VisitStatusBadge.module.css
new file mode 100644
index 000000000..b1f4230e0
--- /dev/null
+++ b/apps/AdminWebUi/src/components/VisitStatusBadge/VisitStatusBadge.module.css
@@ -0,0 +1,18 @@
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 10px;
+ border-radius: 999px;
+ border: 1px solid transparent;
+ font-size: 12px;
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
diff --git a/apps/AdminWebUi/src/components/VisitStatusBadge/VisitStatusBadge.tsx b/apps/AdminWebUi/src/components/VisitStatusBadge/VisitStatusBadge.tsx
new file mode 100644
index 000000000..1d6b8093f
--- /dev/null
+++ b/apps/AdminWebUi/src/components/VisitStatusBadge/VisitStatusBadge.tsx
@@ -0,0 +1,28 @@
+import type { VisitStatus } from "../../types/visit";
+import { getVisitStatusBadgeStyle, VISIT_STATUS_LABELS } from "../../utils/visitLabels";
+import styles from "./VisitStatusBadge.module.css";
+
+interface VisitStatusBadgeProps {
+ status: VisitStatus;
+ showDot?: boolean;
+}
+
+export function VisitStatusBadge({ status, showDot = true }: VisitStatusBadgeProps) {
+ const badgeStyle = getVisitStatusBadgeStyle(status);
+
+ return (
+
+ {showDot ? (
+
+ ) : null}
+ {VISIT_STATUS_LABELS[status]}
+
+ );
+}
diff --git a/apps/AdminWebUi/src/components/VisitStatusBadge/index.ts b/apps/AdminWebUi/src/components/VisitStatusBadge/index.ts
new file mode 100644
index 000000000..15a25ace4
--- /dev/null
+++ b/apps/AdminWebUi/src/components/VisitStatusBadge/index.ts
@@ -0,0 +1 @@
+export { VisitStatusBadge } from "./VisitStatusBadge";
diff --git a/apps/AdminWebUi/src/config/navigation.ts b/apps/AdminWebUi/src/config/navigation.ts
index 1dc1b9f00..eda650a69 100644
--- a/apps/AdminWebUi/src/config/navigation.ts
+++ b/apps/AdminWebUi/src/config/navigation.ts
@@ -27,6 +27,20 @@ export const NAV_ITEMS: NavItemConfig[] = [
icon: "M4 6h16v2h2v10H6V8H4V6zm4 2h8v2H8V8zm0 4h8v2H8v-2zm0 4h5v2H8v-2z",
requiredPermissions: [Permission.StoView],
},
+ {
+ id: "booking-requests",
+ label: "Онлайн-записи",
+ path: "/sto/bookings",
+ icon: "M19 4h-1V2h-2v2H8V2H6v2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm0 16H5V9h14v11zM7 11h5v2H7v-2zm7 0h5v2h-5v-2z",
+ requiredPermissions: [Permission.StoView],
+ },
+ {
+ id: "visits",
+ label: "Візити",
+ path: "/visits",
+ icon: "M4 4h16v2H4V4zm0 4h10v2H4V8zm0 4h14v2H4v-2zm0 4h8v2H4v-2z",
+ requiredPermissions: [Permission.StoView],
+ },
{
id: "users",
label: "Клієнти",
diff --git a/apps/AdminWebUi/src/context/BookingRequestContext.tsx b/apps/AdminWebUi/src/context/BookingRequestContext.tsx
new file mode 100644
index 000000000..f97775c2f
--- /dev/null
+++ b/apps/AdminWebUi/src/context/BookingRequestContext.tsx
@@ -0,0 +1,119 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useMemo,
+ useState,
+ type ReactNode,
+} from "react";
+import { MOCK_BOOKING_REQUESTS } from "../data/mockBookingRequests";
+import { useStoVehicles } from "./StoVehicleContext";
+import type { BookingRequest, BookingRequestStatus } from "../types/bookingRequest";
+import { buildProblemSummary } from "../utils/bookingLabels";
+
+interface BookingRequestContextValue {
+ requests: BookingRequest[];
+ getRequestsBySto: (stoId: string, status?: BookingRequestStatus) => BookingRequest[];
+ confirmRequest: (
+ requestId: string,
+ appointmentAt: string,
+ managerNote?: string,
+ ) => BookingRequest | null;
+ rejectRequest: (requestId: string, reason: string) => void;
+}
+
+const BookingRequestContext = createContext(null);
+
+export function BookingRequestProvider({ children }: { children: ReactNode }) {
+ const { addVehicle } = useStoVehicles();
+ const [requests, setRequests] = useState(
+ () => [...MOCK_BOOKING_REQUESTS],
+ );
+
+ const getRequestsBySto = useCallback(
+ (stoId: string, status?: BookingRequestStatus) => {
+ const filtered = requests.filter((request) => request.stoId === stoId);
+ if (!status) return filtered;
+ return filtered.filter((request) => request.status === status);
+ },
+ [requests],
+ );
+
+ const confirmRequest = useCallback(
+ (requestId: string, appointmentAt: string, managerNote?: string) => {
+ const request = requests.find((item) => item.id === requestId);
+ if (!request || request.status !== "pending") return null;
+
+ const vehicle = addVehicle({
+ stoId: request.stoId,
+ brand: request.brand,
+ model: request.model,
+ year: request.year,
+ licensePlate: request.licensePlate,
+ clientName: request.clientName,
+ clientPhone: request.clientPhone,
+ problemSummary: buildProblemSummary(request.visitReason, request.problemDescription),
+ status: "scheduled",
+ appointmentAt,
+ isManualEntry: false,
+ });
+
+ const now = new Date().toISOString();
+ const updated: BookingRequest = {
+ ...request,
+ status: "confirmed",
+ managerAppointmentAt: appointmentAt,
+ managerNote: managerNote?.trim() || undefined,
+ queueVehicleId: vehicle.id,
+ updatedAt: now,
+ };
+
+ setRequests((current) =>
+ current.map((item) => (item.id === requestId ? updated : item)),
+ );
+
+ return updated;
+ },
+ [requests, addVehicle],
+ );
+
+ const rejectRequest = useCallback((requestId: string, reason: string) => {
+ const now = new Date().toISOString();
+ setRequests((current) =>
+ current.map((request) =>
+ request.id === requestId && request.status === "pending"
+ ? {
+ ...request,
+ status: "rejected",
+ rejectionReason: reason.trim(),
+ updatedAt: now,
+ }
+ : request,
+ ),
+ );
+ }, []);
+
+ const value = useMemo(
+ () => ({
+ requests,
+ getRequestsBySto,
+ confirmRequest,
+ rejectRequest,
+ }),
+ [requests, getRequestsBySto, confirmRequest, rejectRequest],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useBookingRequests(): BookingRequestContextValue {
+ const context = useContext(BookingRequestContext);
+ if (!context) {
+ throw new Error("useBookingRequests must be used within BookingRequestProvider");
+ }
+ return context;
+}
diff --git a/apps/AdminWebUi/src/context/StoVehicleContext.tsx b/apps/AdminWebUi/src/context/StoVehicleContext.tsx
index 21e24efa2..0e2f75bc2 100644
--- a/apps/AdminWebUi/src/context/StoVehicleContext.tsx
+++ b/apps/AdminWebUi/src/context/StoVehicleContext.tsx
@@ -86,7 +86,7 @@ export function StoVehicleProvider({ children }: { children: ReactNode }) {
? 100
: undefined,
approvalRequests: [],
- isManualEntry: true,
+ isManualEntry: payload.isManualEntry ?? true,
createdAt: now,
updatedAt: now,
};
diff --git a/apps/AdminWebUi/src/data/mockBookingRequests.ts b/apps/AdminWebUi/src/data/mockBookingRequests.ts
new file mode 100644
index 000000000..b241c9b98
--- /dev/null
+++ b/apps/AdminWebUi/src/data/mockBookingRequests.ts
@@ -0,0 +1,124 @@
+import type { BookingRequest } from "../types/bookingRequest";
+
+export const MOCK_BOOKING_REQUESTS: BookingRequest[] = [
+ {
+ id: "br-001",
+ stoId: "sto-001",
+ vehicleId: "veh-m-001",
+ driverId: "drv-001",
+ brand: "Toyota",
+ model: "Camry",
+ year: 2019,
+ licensePlate: "AA 1234 BC",
+ vin: "JTDBR32E720123456",
+ clientName: "Олександр Коваленко",
+ clientPhone: "+380 67 111 2233",
+ visitReason: "suspension",
+ problemDescription: "Стукає справа попереду на нерівностях, веде вліво на швидкості.",
+ preferredDate: "2026-07-05",
+ preferredTimeSlot: "morning",
+ partsOption: "shop",
+ attachmentsCount: 2,
+ status: "pending",
+ createdAt: "2026-07-01T08:20:00.000Z",
+ updatedAt: "2026-07-01T08:20:00.000Z",
+ },
+ {
+ id: "br-002",
+ stoId: "sto-001",
+ vehicleId: "veh-m-002",
+ driverId: "drv-002",
+ brand: "Volkswagen",
+ model: "Passat",
+ year: 2017,
+ licensePlate: "KA 5678 AB",
+ vin: "WVWZZZ3CZHE123789",
+ clientName: "Марія Петренко",
+ clientPhone: "+380 50 444 5566",
+ visitReason: "maintenance",
+ problemDescription: "Планове ТО, пробіг 95 000 км. Потрібна заміна масла та фільтрів.",
+ preferredDate: "2026-07-04",
+ preferredTimeSlot: "day",
+ partsOption: "shop",
+ attachmentsCount: 0,
+ status: "pending",
+ createdAt: "2026-07-01T10:45:00.000Z",
+ updatedAt: "2026-07-01T10:45:00.000Z",
+ },
+ {
+ id: "br-003",
+ stoId: "sto-001",
+ brand: "BMW",
+ model: "X5",
+ year: 2021,
+ licensePlate: "BC 9012 DE",
+ clientName: "Ігор Сидоренко",
+ clientPhone: "+380 63 777 8899",
+ visitReason: "diagnostics",
+ problemDescription: "Горить Check Engine, інколи троїть на холостих. Потрібна комп'ютерна діагностика.",
+ preferredDate: "2026-07-06",
+ preferredTimeSlot: "evening",
+ partsOption: "own",
+ attachmentsCount: 1,
+ status: "pending",
+ createdAt: "2026-07-01T14:10:00.000Z",
+ updatedAt: "2026-07-01T14:10:00.000Z",
+ },
+ {
+ id: "br-004",
+ stoId: "sto-002",
+ brand: "Audi",
+ model: "A4",
+ year: 2020,
+ licensePlate: "BC 7788 PQ",
+ clientName: "Сергій Ткаченко",
+ clientPhone: "+380 96 555 4433",
+ visitReason: "other",
+ problemDescription: "Після ДТП не закривається багажник, потрібна оцінка кузовних робіт.",
+ preferredDate: "2026-07-07",
+ preferredTimeSlot: "morning",
+ partsOption: "shop",
+ attachmentsCount: 3,
+ status: "pending",
+ createdAt: "2026-07-01T16:30:00.000Z",
+ updatedAt: "2026-07-01T16:30:00.000Z",
+ },
+ {
+ id: "br-005",
+ stoId: "sto-001",
+ brand: "Hyundai",
+ model: "Tucson",
+ year: 2020,
+ licensePlate: "AI 3456 FG",
+ clientName: "Анна Мельник",
+ visitReason: "maintenance",
+ problemDescription: "Заміна гальмівних колодок, скрипить при гальмуванні.",
+ preferredDate: "2026-06-28",
+ preferredTimeSlot: "day",
+ partsOption: "shop",
+ status: "confirmed",
+ managerAppointmentAt: "2026-06-29T09:10:00.000Z",
+ managerNote: "Запис підтверджено телефоном",
+ queueVehicleId: "sv-004",
+ createdAt: "2026-06-27T18:00:00.000Z",
+ updatedAt: "2026-06-28T09:00:00.000Z",
+ },
+ {
+ id: "br-006",
+ stoId: "sto-001",
+ brand: "Ford",
+ model: "Focus",
+ year: 2015,
+ licensePlate: "AA 5566 NO",
+ clientName: "Катерина Лисенко",
+ visitReason: "diagnostics",
+ problemDescription: "Не працює кондиціонер, потрібна діагностика.",
+ preferredDate: "2026-06-30",
+ preferredTimeSlot: "evening",
+ partsOption: "own",
+ status: "rejected",
+ rejectionReason: "Немає вільного вікна у зазначений час. Запропоновано іншу дату — клієнт відмовився.",
+ createdAt: "2026-06-29T11:00:00.000Z",
+ updatedAt: "2026-06-29T15:20:00.000Z",
+ },
+];
diff --git a/apps/AdminWebUi/src/data/mockVisits.ts b/apps/AdminWebUi/src/data/mockVisits.ts
new file mode 100644
index 000000000..19fc70e10
--- /dev/null
+++ b/apps/AdminWebUi/src/data/mockVisits.ts
@@ -0,0 +1,209 @@
+import type { VisitListItem } from "../types/visit";
+
+export const MOCK_VISITS: VisitListItem[] = [
+ {
+ id: "visit-001",
+ stoId: "sto-001",
+ stoName: "AutoService Kyiv",
+ vehicleId: "veh-001",
+ brand: "Mercedes-Benz",
+ model: "E-Class",
+ year: 2018,
+ licensePlate: "AA 7890 HI",
+ clientName: "Віктор Бондаренко",
+ clientPhone: "+380 97 222 3344",
+ title: "Ремонт кондиціонера",
+ description: "Діагностика та заміна компресора кондиціонера, вакуумування системи.",
+ status: "in_progress",
+ mechanicName: "Петро Механік",
+ records: [
+ { id: "vr-001-1", title: "Діагностика системи кондиціонування", price: 800 },
+ { id: "vr-001-2", title: "Заміна компресора", price: 3200 },
+ { id: "vr-001-3", title: "Вакуумування та заправка фреоном", price: 1400 },
+ ],
+ createdAt: "2026-06-28T11:00:00.000Z",
+ updatedAt: "2026-06-29T07:30:00.000Z",
+ },
+ {
+ id: "visit-002",
+ stoId: "sto-001",
+ stoName: "AutoService Kyiv",
+ vehicleId: "veh-002",
+ brand: "Mazda",
+ model: "CX-5",
+ year: 2018,
+ licensePlate: "KA 4455 RS",
+ clientName: "Юрій Романюк",
+ clientPhone: "+380 68 333 4455",
+ title: "Заміна ГРМ",
+ description: "Заміна комплекту ГРМ, помпи охолодження та усунення витоку ОР.",
+ status: "in_progress",
+ mechanicName: "Андрій Сервіс",
+ records: [
+ { id: "vr-002-1", title: "Зняття/встановлення ГРМ", price: 4500 },
+ { id: "vr-002-2", title: "Заміна помпи охолодження", price: 2800 },
+ { id: "vr-002-3", title: "Промивка системи охолодження", price: 1800 },
+ { id: "vr-002-4", title: "Комплект ГРМ (деталі)", price: 7200 },
+ { id: "vr-002-5", title: "Помпа охолодження (деталь)", price: 3400 },
+ ],
+ createdAt: "2026-06-27T09:30:00.000Z",
+ updatedAt: "2026-06-29T10:20:00.000Z",
+ },
+ {
+ id: "visit-003",
+ stoId: "sto-001",
+ stoName: "AutoService Kyiv",
+ vehicleId: "veh-003",
+ brand: "BMW",
+ model: "X5",
+ year: 2021,
+ licensePlate: "BC 9012 DE",
+ clientName: "Ігор Сидоренко",
+ clientPhone: "+380 63 777 8899",
+ title: "Діагностика двигуна",
+ description: "Комп'ютерна діагностика, зчитування помилок Check Engine.",
+ status: "waiting_payment",
+ mechanicName: "Олег Діагност",
+ records: [
+ { id: "vr-003-1", title: "Комп'ютерна діагностика", price: 900 },
+ { id: "vr-003-2", title: "Заміна свічок запалення", price: 2200 },
+ { id: "vr-003-3", title: "Діагностика котушок запалювання", price: 800 },
+ ],
+ createdAt: "2026-06-26T12:00:00.000Z",
+ updatedAt: "2026-06-29T09:20:00.000Z",
+ },
+ {
+ id: "visit-004",
+ stoId: "sto-001",
+ stoName: "AutoService Kyiv",
+ vehicleId: "veh-004",
+ brand: "Ford",
+ model: "Focus",
+ year: 2015,
+ licensePlate: "AA 5566 NO",
+ clientName: "Катерина Лисенко",
+ clientPhone: "+380 93 888 9900",
+ title: "Заміна акумулятора",
+ description: "Заміна акумулятора, перевірка системи зарядки.",
+ status: "completed",
+ mechanicName: "Петро Механік",
+ records: [
+ { id: "vr-004-1", title: "Діагностика електросистеми", price: 500 },
+ { id: "vr-004-2", title: "Заміна акумулятора", price: 4200 },
+ ],
+ createdAt: "2026-06-26T16:00:00.000Z",
+ updatedAt: "2026-06-29T09:45:00.000Z",
+ completedAt: "2026-06-29T09:45:00.000Z",
+ },
+ {
+ id: "visit-005",
+ stoId: "sto-001",
+ stoName: "AutoService Kyiv",
+ vehicleId: "veh-005",
+ brand: "Renault",
+ model: "Duster",
+ year: 2019,
+ licensePlate: "AB 3344 LM",
+ clientName: "Дмитро Гриценко",
+ title: "ТО-2",
+ description: "Планове технічне обслуговування: заміна масла, фільтрів, перевірка ходової.",
+ status: "completed",
+ records: [
+ { id: "vr-005-1", title: "Заміна моторної оливи", price: 1200 },
+ { id: "vr-005-2", title: "Заміна масляного фільтра", price: 350 },
+ { id: "vr-005-3", title: "Заміна повітряного фільтра", price: 450 },
+ { id: "vr-005-4", title: "Заміна салонного фільтра", price: 380 },
+ { id: "vr-005-5", title: "Перевірка ходової частини", price: 600 },
+ ],
+ createdAt: "2026-06-23T11:00:00.000Z",
+ updatedAt: "2026-06-29T10:00:00.000Z",
+ completedAt: "2026-06-29T10:00:00.000Z",
+ },
+ {
+ id: "visit-006",
+ stoId: "sto-001",
+ stoName: "AutoService Kyiv",
+ vehicleId: "veh-006",
+ brand: "Skoda",
+ model: "Octavia",
+ year: 2016,
+ licensePlate: "KA 1122 JK",
+ clientName: "Олена Шевченко",
+ clientPhone: "+380 66 555 6677",
+ title: "Заміна зчеплення",
+ description: "Заміна комплекту зчеплення, перевірка маховика.",
+ status: "in_progress",
+ mechanicName: "Андрій Сервіс",
+ records: [
+ { id: "vr-006-1", title: "Зняття КПП", price: 3800 },
+ { id: "vr-006-2", title: "Заміна комплекту зчеплення", price: 5200 },
+ { id: "vr-006-3", title: "Комплект зчеплення (деталі)", price: 8900 },
+ ],
+ createdAt: "2026-06-24T14:00:00.000Z",
+ updatedAt: "2026-06-29T06:00:00.000Z",
+ },
+ {
+ id: "visit-007",
+ stoId: "sto-002",
+ stoName: "ProMotors Lviv",
+ vehicleId: "veh-007",
+ brand: "Audi",
+ model: "A4",
+ year: 2020,
+ licensePlate: "BC 7788 PQ",
+ clientName: "Сергій Ткаченко",
+ title: "Діагностика ходової",
+ description: "Перевірка підвіски, рульового управління та гальмівної системи.",
+ status: "in_progress",
+ mechanicName: "Михайло Львів",
+ records: [
+ { id: "vr-007-1", title: "Діагностика ходової частини", price: 700 },
+ { id: "vr-007-2", title: "Перевірка гальмівних механізмів", price: 500 },
+ ],
+ createdAt: "2026-06-28T17:00:00.000Z",
+ updatedAt: "2026-06-29T10:00:00.000Z",
+ },
+ {
+ id: "visit-008",
+ stoId: "sto-002",
+ stoName: "ProMotors Lviv",
+ vehicleId: "veh-008",
+ brand: "Toyota",
+ model: "Camry",
+ year: 2019,
+ licensePlate: "AA 1234 BC",
+ clientName: "Олександр Коваленко",
+ clientPhone: "+380 67 111 2233",
+ title: "Перевірка підвіски",
+ description: "Клієнт скасував візит після попередньої оцінки вартості.",
+ status: "cancelled",
+ records: [
+ { id: "vr-008-1", title: "Огляд підвіски", price: 600 },
+ ],
+ createdAt: "2026-06-28T10:00:00.000Z",
+ updatedAt: "2026-06-28T14:30:00.000Z",
+ },
+ {
+ id: "visit-009",
+ stoId: "sto-001",
+ stoName: "AutoService Kyiv",
+ vehicleId: "veh-009",
+ brand: "Hyundai",
+ model: "Tucson",
+ year: 2020,
+ licensePlate: "AI 3456 FG",
+ clientName: "Анна Мельник",
+ title: "Заміна гальмівних колодок",
+ description: "Заміна передніх і задніх гальмівних колодок.",
+ status: "waiting_payment",
+ mechanicName: "Петро Механік",
+ records: [
+ { id: "vr-009-1", title: "Заміна передніх колодок", price: 1600 },
+ { id: "vr-009-2", title: "Заміна задніх колодок", price: 1400 },
+ { id: "vr-009-3", title: "Передні колодки (деталі)", price: 2100 },
+ { id: "vr-009-4", title: "Задні колодки (деталі)", price: 1800 },
+ ],
+ createdAt: "2026-06-28T08:30:00.000Z",
+ updatedAt: "2026-06-29T09:10:00.000Z",
+ },
+];
diff --git a/apps/AdminWebUi/src/pages/BookingRequestsPage/BookingRequestsPage.module.css b/apps/AdminWebUi/src/pages/BookingRequestsPage/BookingRequestsPage.module.css
new file mode 100644
index 000000000..16030029c
--- /dev/null
+++ b/apps/AdminWebUi/src/pages/BookingRequestsPage/BookingRequestsPage.module.css
@@ -0,0 +1,175 @@
+.page {
+ width: 100%;
+ max-width: none;
+}
+
+.header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 24px;
+ margin-bottom: 20px;
+}
+
+.headerText {
+ flex: 1;
+ min-width: 0;
+}
+
+.title {
+ font-size: 28px;
+ font-weight: 700;
+ color: var(--text-primary);
+ margin-bottom: 8px;
+}
+
+.subtitle {
+ font-size: 15px;
+ color: var(--text-secondary);
+ max-width: 560px;
+}
+
+.headerActions {
+ display: flex;
+ align-items: flex-end;
+ gap: 16px;
+ flex-shrink: 0;
+}
+
+.stoSelect {
+ min-width: 220px;
+}
+
+.stats {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ margin-bottom: 24px;
+}
+
+.statCard {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ min-width: 150px;
+ padding: 14px 16px;
+ border-radius: var(--radius-md);
+ background-color: #1a1c1e;
+ border: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.statCardAttention {
+ border-color: rgba(255, 166, 87, 0.28);
+ background-color: rgba(255, 166, 87, 0.04);
+}
+
+.statLabel {
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.4px;
+ text-transform: uppercase;
+ color: var(--text-tertiary);
+}
+
+.statValue {
+ font-size: 22px;
+ font-weight: 700;
+ color: var(--text-primary);
+}
+
+.board {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(300px, 1fr));
+ gap: 16px;
+ align-items: start;
+}
+
+.column {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ min-width: 0;
+}
+
+.columnHeader {
+ padding: 14px 16px;
+ border-radius: var(--radius-md);
+ background-color: #1a1c1e;
+ border: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.columnTitleRow {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.columnTitle {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--text-primary);
+}
+
+.columnCount {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 24px;
+ height: 24px;
+ padding: 0 8px;
+ border-radius: 999px;
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--text-secondary);
+ background-color: rgba(255, 255, 255, 0.06);
+}
+
+.columnDescription {
+ margin: 6px 0 0;
+ font-size: 12px;
+ color: var(--text-tertiary);
+ line-height: 1.4;
+}
+
+.columnCards {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.emptyColumn {
+ padding: 24px 16px;
+ border-radius: var(--radius-md);
+ border: 1px dashed rgba(255, 255, 255, 0.1);
+ text-align: center;
+ font-size: 13px;
+ color: var(--text-tertiary);
+}
+
+.hint {
+ margin: 0 0 16px;
+ font-size: 12px;
+ color: var(--text-tertiary);
+}
+
+@media (max-width: 1100px) {
+ .board {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (max-width: 700px) {
+ .header {
+ flex-direction: column;
+ }
+
+ .headerActions {
+ width: 100%;
+ }
+
+ .stoSelect {
+ width: 100%;
+ }
+}
diff --git a/apps/AdminWebUi/src/pages/BookingRequestsPage/BookingRequestsPage.tsx b/apps/AdminWebUi/src/pages/BookingRequestsPage/BookingRequestsPage.tsx
new file mode 100644
index 000000000..e2bee7594
--- /dev/null
+++ b/apps/AdminWebUi/src/pages/BookingRequestsPage/BookingRequestsPage.tsx
@@ -0,0 +1,185 @@
+import { useMemo, useState } from "react";
+import { BookingRequestCard } from "../../components/BookingRequestCard";
+import { BookingRequestDetailModal } from "../../components/BookingRequestDetailModal";
+import { Select, type SelectOption } from "../../components/Select";
+import { useBookingRequests } from "../../context/BookingRequestContext";
+import { useStos } from "../../context/StoContext";
+import type { BookingRequest, BookingRequestStatus } from "../../types/bookingRequest";
+import { BOOKING_STATUS_LABELS } from "../../utils/bookingLabels";
+import styles from "./BookingRequestsPage.module.css";
+
+const COLUMN_ORDER: BookingRequestStatus[] = ["pending", "confirmed", "rejected"];
+
+const COLUMN_DESCRIPTIONS: Record = {
+ pending: "Нові запити з мобільного додатку — потребують рішення менеджера",
+ confirmed: "Підтверджені записи, авто додані до черги «Записано»",
+ rejected: "Відхилені запити з поясненням для водія",
+};
+
+interface BookingColumnProps {
+ status: BookingRequestStatus;
+ requests: BookingRequest[];
+ onOpen: (request: BookingRequest) => void;
+}
+
+function BookingColumn({ status, requests, onOpen }: BookingColumnProps) {
+ const label = BOOKING_STATUS_LABELS[status];
+
+ return (
+
+
+
+ {label}
+ {requests.length}
+
+ {COLUMN_DESCRIPTIONS[status]}
+
+
+
+ {requests.length === 0 ? (
+ Немає запитів
+ ) : (
+ requests.map((request) => (
+
+ ))
+ )}
+
+
+ );
+}
+
+export function BookingRequestsPage() {
+ const { stos } = useStos();
+ const { getRequestsBySto, confirmRequest, rejectRequest } = useBookingRequests();
+
+ const activeStos = useMemo(
+ () => stos.filter((sto) => sto.status === "active"),
+ [stos],
+ );
+
+ const [selectedStoId, setSelectedStoId] = useState(
+ () => activeStos[0]?.id ?? stos[0]?.id ?? "",
+ );
+ const [selectedRequest, setSelectedRequest] = useState(null);
+
+ const stoOptions: SelectOption[] = useMemo(
+ () =>
+ (activeStos.length > 0 ? activeStos : stos).map((sto) => ({
+ value: sto.id,
+ label: sto.name,
+ })),
+ [activeStos, stos],
+ );
+
+ const allRequests = useMemo(
+ () => getRequestsBySto(selectedStoId),
+ [getRequestsBySto, selectedStoId],
+ );
+
+ const requestsByStatus = useMemo(() => {
+ const grouped = Object.fromEntries(
+ COLUMN_ORDER.map((status) => [status, [] as BookingRequest[]]),
+ ) as Record;
+
+ for (const request of allRequests) {
+ grouped[request.status].push(request);
+ }
+
+ for (const status of COLUMN_ORDER) {
+ grouped[status].sort(
+ (left, right) =>
+ new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(),
+ );
+ }
+
+ return grouped;
+ }, [allRequests]);
+
+ const detailRequest = useMemo(() => {
+ if (!selectedRequest) return null;
+ return allRequests.find((request) => request.id === selectedRequest.id) ?? selectedRequest;
+ }, [allRequests, selectedRequest]);
+
+ const selectedSto = stos.find((sto) => sto.id === selectedStoId);
+ const pendingCount = requestsByStatus.pending.length;
+
+ const handleConfirm = (requestId: string, appointmentAt: string, managerNote?: string) => {
+ const updated = confirmRequest(requestId, appointmentAt, managerNote);
+ if (updated) {
+ setSelectedRequest(updated);
+ }
+ };
+
+ const handleReject = (requestId: string, reason: string) => {
+ rejectRequest(requestId, reason);
+ setSelectedRequest((current) =>
+ current?.id === requestId
+ ? { ...current, status: "rejected", rejectionReason: reason }
+ : current,
+ );
+ };
+
+ return (
+
+
+
+
+ 0 ? styles.statCardAttention : ""}`}>
+ Очікують обробки
+ {pendingCount}
+
+
+ Підтверджено
+ {requestsByStatus.confirmed.length}
+
+
+ Відхилено
+ {requestsByStatus.rejected.length}
+
+
+ Всього
+ {allRequests.length}
+
+
+
+ Натисніть на картку запиту, щоб переглянути деталі та обробити
+
+
+ {COLUMN_ORDER.map((status) => (
+
+ ))}
+
+
+ setSelectedRequest(null)}
+ onConfirm={handleConfirm}
+ onReject={handleReject}
+ />
+
+ );
+}
diff --git a/apps/AdminWebUi/src/pages/BookingRequestsPage/index.ts b/apps/AdminWebUi/src/pages/BookingRequestsPage/index.ts
new file mode 100644
index 000000000..e3b428790
--- /dev/null
+++ b/apps/AdminWebUi/src/pages/BookingRequestsPage/index.ts
@@ -0,0 +1 @@
+export { BookingRequestsPage } from "./BookingRequestsPage";
diff --git a/apps/AdminWebUi/src/pages/DriversPage/DriversPage.module.css b/apps/AdminWebUi/src/pages/DriversPage/DriversPage.module.css
index 5a5067058..73201a74d 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);
+}
diff --git a/apps/AdminWebUi/src/pages/VisitsPage/VisitsPage.module.css b/apps/AdminWebUi/src/pages/VisitsPage/VisitsPage.module.css
new file mode 100644
index 000000000..4d3d3de3f
--- /dev/null
+++ b/apps/AdminWebUi/src/pages/VisitsPage/VisitsPage.module.css
@@ -0,0 +1,124 @@
+.page {
+ width: 100%;
+ max-width: none;
+}
+
+.header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 24px;
+ margin-bottom: 20px;
+}
+
+.headerText {
+ flex: 1;
+ min-width: 0;
+}
+
+.title {
+ font-size: 28px;
+ font-weight: 700;
+ color: var(--text-primary);
+ margin-bottom: 8px;
+}
+
+.subtitle {
+ font-size: 15px;
+ color: var(--text-secondary);
+ max-width: 560px;
+}
+
+.headerActions {
+ display: flex;
+ align-items: flex-end;
+ gap: 16px;
+ flex-shrink: 0;
+}
+
+.stoSelect {
+ min-width: 220px;
+}
+
+.stats {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ margin-bottom: 20px;
+}
+
+.statCard {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ min-width: 140px;
+ padding: 14px 16px;
+ border-radius: var(--radius-md);
+ background-color: #1a1c1e;
+ border: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.statLabel {
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.4px;
+ text-transform: uppercase;
+ color: var(--text-tertiary);
+}
+
+.statValue {
+ font-size: 22px;
+ font-weight: 700;
+ color: var(--text-primary);
+}
+
+.tableCard {
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: var(--radius-lg);
+ background-color: #1a1c1e;
+ padding: 4px 16px 16px;
+ overflow: hidden;
+}
+
+.plateCell {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 6px;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--accent-primary);
+ background-color: #1a4038;
+}
+
+.amountCell {
+ font-weight: 600;
+ color: var(--accent-primary);
+ white-space: nowrap;
+}
+
+.hint {
+ margin: 0 0 12px;
+ font-size: 12px;
+ color: var(--text-tertiary);
+}
+
+.empty {
+ padding: 32px 16px;
+ text-align: center;
+ font-size: 14px;
+ color: var(--text-secondary);
+}
+
+@media (max-width: 700px) {
+ .header {
+ flex-direction: column;
+ }
+
+ .headerActions {
+ width: 100%;
+ }
+
+ .stoSelect {
+ width: 100%;
+ }
+}
diff --git a/apps/AdminWebUi/src/pages/VisitsPage/VisitsPage.tsx b/apps/AdminWebUi/src/pages/VisitsPage/VisitsPage.tsx
new file mode 100644
index 000000000..941591431
--- /dev/null
+++ b/apps/AdminWebUi/src/pages/VisitsPage/VisitsPage.tsx
@@ -0,0 +1,162 @@
+import { useMemo, useState } from "react";
+import { DataTable, type DataTableColumn } from "../../components/DataTable";
+import { Select, type SelectOption } from "../../components/Select";
+import { VisitDetailModal } from "../../components/VisitDetailModal";
+import { VisitStatusBadge } from "../../components/VisitStatusBadge";
+import { useStos } from "../../context/StoContext";
+import { MOCK_VISITS } from "../../data/mockVisits";
+import type { VisitListItem, VisitStatus } from "../../types/visit";
+import {
+ calcVisitTotal,
+ formatVisitDate,
+ formatVisitPrice,
+ formatVisitTitle,
+ VISIT_STATUS_LABELS,
+} from "../../utils/visitLabels";
+import styles from "./VisitsPage.module.css";
+
+const VISIT_COLUMNS: DataTableColumn[] = [
+ {
+ key: "createdAt",
+ header: "Дата",
+ render: (visit) => formatVisitDate(visit.createdAt),
+ },
+ {
+ key: "vehicle",
+ header: "Автомобіль",
+ render: (visit) => (
+ <>
+ {formatVisitTitle(visit.brand, visit.model, visit.year)}
+ {visit.licensePlate}
+ >
+ ),
+ },
+ {
+ key: "client",
+ header: "Клієнт",
+ render: (visit) => visit.clientName,
+ },
+ {
+ key: "title",
+ header: "Візит",
+ render: (visit) => visit.title,
+ },
+ {
+ key: "sto",
+ header: "СТО",
+ render: (visit) => visit.stoName,
+ },
+ {
+ key: "mechanic",
+ header: "Механік",
+ render: (visit) => visit.mechanicName ?? "—",
+ },
+ {
+ key: "status",
+ header: "Статус",
+ render: (visit) => ,
+ },
+ {
+ key: "total",
+ header: "Сума",
+ render: (visit) => (
+ {formatVisitPrice(calcVisitTotal(visit.records))}
+ ),
+ },
+ ];
+
+function countByStatus(visits: VisitListItem[], status: VisitStatus): number {
+ return visits.filter((visit) => visit.status === status).length;
+}
+
+export function VisitsPage() {
+ const { stos } = useStos();
+ const [selectedStoId, setSelectedStoId] = useState("all");
+ const [selectedVisit, setSelectedVisit] = useState(null);
+
+ const activeStos = useMemo(
+ () => stos.filter((sto) => sto.status === "active"),
+ [stos],
+ );
+
+ const stoOptions: SelectOption[] = useMemo(
+ () => [
+ { value: "all", label: "Усі СТО" },
+ ...(activeStos.length > 0 ? activeStos : stos).map((sto) => ({
+ value: sto.id,
+ label: sto.name,
+ })),
+ ],
+ [activeStos, stos],
+ );
+
+ const visits = useMemo(() => {
+ const sorted = [...MOCK_VISITS].sort(
+ (left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(),
+ );
+ if (selectedStoId === "all") return sorted;
+ return sorted.filter((visit) => visit.stoId === selectedStoId);
+ }, [selectedStoId]);
+
+ const columns = VISIT_COLUMNS;
+
+ return (
+
+
+
+
+
+ Всього
+ {visits.length}
+
+
+ {VISIT_STATUS_LABELS.in_progress}
+ {countByStatus(visits, "in_progress")}
+
+
+ {VISIT_STATUS_LABELS.waiting_payment}
+ {countByStatus(visits, "waiting_payment")}
+
+
+ {VISIT_STATUS_LABELS.completed}
+ {countByStatus(visits, "completed")}
+
+
+
+
+ Натисніть на рядок, щоб відкрити деталі візиту
+ visit.id}
+ emptyMessage="Візитів за обраним СТО не знайдено."
+ minWidth={980}
+ onRowClick={setSelectedVisit}
+ />
+
+
+ setSelectedVisit(null)}
+ />
+
+ );
+}
diff --git a/apps/AdminWebUi/src/pages/VisitsPage/index.ts b/apps/AdminWebUi/src/pages/VisitsPage/index.ts
new file mode 100644
index 000000000..ebab7d7e9
--- /dev/null
+++ b/apps/AdminWebUi/src/pages/VisitsPage/index.ts
@@ -0,0 +1 @@
+export { VisitsPage } from "./VisitsPage";
diff --git a/apps/AdminWebUi/src/routes/AppRouter.tsx b/apps/AdminWebUi/src/routes/AppRouter.tsx
index e20166348..4f5ab3425 100644
--- a/apps/AdminWebUi/src/routes/AppRouter.tsx
+++ b/apps/AdminWebUi/src/routes/AppRouter.tsx
@@ -8,8 +8,10 @@ import { LoginPage } from "../pages/LoginPage";
import { PlaceholderPage } from "../pages/PlaceholderPage";
import { StoListPage } from "../pages/StoListPage";
import { StoQueuePage } from "../pages/StoQueuePage";
+import { BookingRequestsPage } from "../pages/BookingRequestsPage";
import { VehicleServiceHistoryPage } from "../pages/VehicleServiceHistoryPage";
import { VehiclesPage } from "../pages/VehiclesPage";
+import { VisitsPage } from "../pages/VisitsPage";
import { PermissionGate } from "./PermissionGate";
import { ProtectedRoute } from "./ProtectedRoute";
@@ -45,6 +47,24 @@ export function AppRouter() {
}
/>
+
+
+
+ }
+ />
+
+
+
+
+ }
+ />
+
= {
+ maintenance: "Регулярне ТО",
+ suspension: "Ходова та підвіска",
+ diagnostics: "Діагностика / Електрика",
+ other: "Інше / Складна поломка",
+};
+
+export const TIME_SLOT_LABELS: Record = {
+ morning: "Ранок",
+ day: "День",
+ evening: "Вечір",
+};
+
+export const TIME_SLOT_RANGES: Record = {
+ morning: "09:00 – 13:00",
+ day: "13:00 – 17:00",
+ evening: "17:00 – 19:00",
+};
+
+export const PARTS_OPTION_LABELS: Record = {
+ shop: "Запчастини від СТО",
+ own: "Свої запчастини",
+};
+
+export const BOOKING_STATUS_LABELS: Record = {
+ pending: "Очікує обробки",
+ confirmed: "Підтверджено",
+ rejected: "Відхилено",
+};
+
+export function formatPreferredSlot(date: string, slot: TimeSlotId): string {
+ const formattedDate = new Intl.DateTimeFormat("uk-UA", {
+ weekday: "short",
+ day: "2-digit",
+ month: "long",
+ }).format(new Date(`${date}T12:00:00`));
+
+ return `${formattedDate}, ${TIME_SLOT_LABELS[slot]} (${TIME_SLOT_RANGES[slot]})`;
+}
+
+export function buildProblemSummary(
+ visitReason: VisitReasonId,
+ problemDescription: string,
+): string {
+ return `${VISIT_REASON_LABELS[visitReason]}: ${problemDescription}`;
+}
+
+export function suggestedAppointmentIso(date: string, slot: TimeSlotId): string {
+ const hour = slot === "morning" ? 11 : slot === "day" ? 15 : 18;
+ const local = new Date(`${date}T${String(hour).padStart(2, "0")}:00:00`);
+ return local.toISOString();
+}
+
+export function toDatetimeLocalValue(isoDate: string): string {
+ const date = new Date(isoDate);
+ if (Number.isNaN(date.getTime())) return "";
+
+ const pad = (value: number) => String(value).padStart(2, "0");
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
+}
+
+export function fromDatetimeLocalValue(value: string): string {
+ return new Date(value).toISOString();
+}
+
+export function formatBookingDateTime(isoDate: string): string {
+ return new Intl.DateTimeFormat("uk-UA", {
+ weekday: "short",
+ day: "2-digit",
+ month: "long",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ }).format(new Date(isoDate));
+}
diff --git a/apps/AdminWebUi/src/utils/stoVehicleActivityLog.ts b/apps/AdminWebUi/src/utils/stoVehicleActivityLog.ts
new file mode 100644
index 000000000..509dccf90
--- /dev/null
+++ b/apps/AdminWebUi/src/utils/stoVehicleActivityLog.ts
@@ -0,0 +1,214 @@
+import type {
+ StoQueueVehicle,
+ StoVehicleActivityEntry,
+ StoVehicleApprovalRequest,
+} from "../types/stoVehicle";
+import { APPROVAL_TYPE_LABELS, formatPriceUah } from "./stoVehicleApprovals";
+import {
+ formatEventDateTime,
+ getVehicleStepIndex,
+ STO_VEHICLE_STATUS_LABELS,
+ STO_VEHICLE_STATUS_ORDER,
+} from "./stoVehicleLabels";
+
+function interpolateIsoTime(startIso: string, endIso: string, ratio: number): string {
+ const start = new Date(startIso).getTime();
+ const end = new Date(endIso).getTime();
+ if (Number.isNaN(start) || Number.isNaN(end) || end <= start) {
+ return endIso;
+ }
+ return new Date(start + (end - start) * ratio).toISOString();
+}
+
+function formatApprovalItemsSummary(request: StoVehicleApprovalRequest): string | undefined {
+ const items = request.lineItems ?? [];
+ if (items.length === 0) return undefined;
+
+ const preview = items
+ .slice(0, 3)
+ .map((item) => {
+ const qty = item.quantity && item.quantity > 1 ? ` ×${item.quantity}` : "";
+ return `${item.title}${qty}`;
+ })
+ .join(", ");
+
+ const suffix = items.length > 3 ? ` та ще ${items.length - 3}` : "";
+ const total = request.totalAmount;
+ const totalPart = total != null ? ` · ${formatPriceUah(total)}` : "";
+
+ return `${preview}${suffix}${totalPart}`;
+}
+
+function buildApprovalEntries(
+ vehicle: StoQueueVehicle,
+ request: StoVehicleApprovalRequest,
+): StoVehicleActivityEntry[] {
+ const entries: StoVehicleActivityEntry[] = [];
+ const typeLabel = APPROVAL_TYPE_LABELS[request.type];
+ const itemsSummary = formatApprovalItemsSummary(request);
+
+ entries.push({
+ id: `${request.id}-requested`,
+ type: "approval_requested",
+ occurredAt: request.createdAt,
+ actorName: request.requestedBy,
+ actorRole: "mechanic",
+ title: `Створено запит: ${typeLabel}`,
+ description: itemsSummary ?? request.description,
+ });
+
+ if (request.sentToClientAt) {
+ entries.push({
+ id: `${request.id}-sent`,
+ type: "approval_sent_to_client",
+ occurredAt: request.sentToClientAt,
+ actorName: "Менеджер СТО",
+ actorRole: "manager",
+ title: `Надіслано клієнту на погодження: ${typeLabel}`,
+ description: `Очікує відповіді від ${vehicle.clientName}`,
+ });
+ }
+
+ if (request.clientNotifiedAt) {
+ entries.push({
+ id: `${request.id}-notified`,
+ type: "approval_sent_to_client",
+ occurredAt: request.clientNotifiedAt,
+ actorName: "Система",
+ actorRole: "system",
+ title: `Нагадування клієнту: ${typeLabel}`,
+ description: `Повторне сповіщення для ${vehicle.clientName}`,
+ });
+ }
+
+ if (request.status === "approved" && request.reviewedAt) {
+ const byManager = request.approverRole === "manager";
+ entries.push({
+ id: `${request.id}-approved`,
+ type: "approval_approved",
+ occurredAt: request.reviewedAt,
+ actorName: request.approvedByName ?? (byManager ? "Менеджер СТО" : vehicle.clientName),
+ actorRole: byManager ? "manager" : "client",
+ title: `Погоджено: ${typeLabel}`,
+ description: request.managerOverrideReason ?? itemsSummary,
+ });
+ }
+
+ if (request.status === "rejected" && request.reviewedAt) {
+ entries.push({
+ id: `${request.id}-rejected`,
+ type: "approval_rejected",
+ occurredAt: request.reviewedAt,
+ actorName: request.approvedByName ?? vehicle.clientName,
+ actorRole: request.approverRole === "manager" ? "manager" : "client",
+ title: `Відхилено: ${typeLabel}`,
+ description: request.rejectionReason,
+ });
+ }
+
+ return entries;
+}
+
+function buildDerivedActivityLog(vehicle: StoQueueVehicle): StoVehicleActivityEntry[] {
+ const entries: StoVehicleActivityEntry[] = [];
+
+ entries.push({
+ id: `${vehicle.id}-created`,
+ type: "vehicle_created",
+ occurredAt: vehicle.createdAt,
+ actorName: vehicle.isManualEntry ? "Оператор СТО" : "Система",
+ actorRole: vehicle.isManualEntry ? "operator" : "system",
+ title: vehicle.isManualEntry ? "Автомобіль додано вручну" : "Автомобіль зареєстровано в черзі",
+ description: vehicle.problemSummary,
+ });
+
+ if (vehicle.appointmentAt) {
+ entries.push({
+ id: `${vehicle.id}-appointment`,
+ type: "appointment_scheduled",
+ occurredAt: vehicle.appointmentAt,
+ actorName: vehicle.clientName,
+ actorRole: "client",
+ title: "Запис на прийом",
+ description: formatEventDateTime(vehicle.appointmentAt),
+ });
+ }
+
+ if (vehicle.arrivedAt) {
+ entries.push({
+ id: `${vehicle.id}-arrived`,
+ type: "vehicle_arrived",
+ occurredAt: vehicle.arrivedAt,
+ actorName: "Оператор СТО",
+ actorRole: "operator",
+ title: "Автомобіль прибув на СТО",
+ });
+ }
+
+ const currentIndex = getVehicleStepIndex(vehicle.status);
+ const statusesToLog = STO_VEHICLE_STATUS_ORDER.slice(0, currentIndex + 1);
+ const statusTimelineEnd = vehicle.updatedAt;
+
+ statusesToLog.forEach((status, index) => {
+ const ratio =
+ statusesToLog.length === 1 ? 0 : index / Math.max(statusesToLog.length - 1, 1);
+ const occurredAt = interpolateIsoTime(vehicle.createdAt, statusTimelineEnd, ratio);
+ const label = STO_VEHICLE_STATUS_LABELS[status];
+
+ entries.push({
+ id: `${vehicle.id}-status-${status}`,
+ type: "status_changed",
+ occurredAt,
+ actorName: "Оператор СТО",
+ actorRole: "operator",
+ title:
+ index === 0
+ ? `Початковий етап: ${label}`
+ : `Етап змінено на «${label}»`,
+ });
+ });
+
+ if (vehicle.mechanicName) {
+ const mechanicTime = vehicle.arrivedAt
+ ? interpolateIsoTime(vehicle.arrivedAt, vehicle.updatedAt, 0.35)
+ : interpolateIsoTime(vehicle.createdAt, vehicle.updatedAt, 0.4);
+
+ entries.push({
+ id: `${vehicle.id}-mechanic`,
+ type: "mechanic_assigned",
+ occurredAt: mechanicTime,
+ actorName: "Менеджер СТО",
+ actorRole: "manager",
+ title: "Призначено механіка",
+ description: vehicle.mechanicName,
+ });
+ }
+
+ for (const request of vehicle.approvalRequests ?? []) {
+ entries.push(...buildApprovalEntries(vehicle, request));
+ }
+
+ return entries;
+}
+
+export function buildVehicleActivityLog(vehicle: StoQueueVehicle): StoVehicleActivityEntry[] {
+ const entries =
+ vehicle.activityLog && vehicle.activityLog.length > 0
+ ? [...vehicle.activityLog]
+ : buildDerivedActivityLog(vehicle);
+
+ return entries.sort(
+ (left, right) => new Date(right.occurredAt).getTime() - new Date(left.occurredAt).getTime(),
+ );
+}
+
+export const ACTIVITY_ACTOR_ROLE_LABELS: Record<
+ StoVehicleActivityEntry["actorRole"],
+ string
+> = {
+ system: "Система",
+ operator: "Оператор",
+ manager: "Менеджер",
+ mechanic: "Механік",
+ client: "Клієнт",
+};
diff --git a/apps/AdminWebUi/src/utils/visitActivityLog.ts b/apps/AdminWebUi/src/utils/visitActivityLog.ts
new file mode 100644
index 000000000..3dbeec842
--- /dev/null
+++ b/apps/AdminWebUi/src/utils/visitActivityLog.ts
@@ -0,0 +1,131 @@
+import type { StoVehicleActivityEntry } from "../types/stoVehicle";
+import type { VisitListItem, VisitStatus } from "../types/visit";
+import { formatVisitPrice, VISIT_STATUS_LABELS } from "./visitLabels";
+
+function interpolateIsoTime(startIso: string, endIso: string, ratio: number): string {
+ const start = new Date(startIso).getTime();
+ const end = new Date(endIso).getTime();
+ if (Number.isNaN(start) || Number.isNaN(end) || end <= start) {
+ return endIso;
+ }
+ return new Date(start + (end - start) * ratio).toISOString();
+}
+
+const VISIT_STATUS_ORDER: VisitStatus[] = [
+ "in_progress",
+ "waiting_payment",
+ "completed",
+];
+
+function getVisitStatusIndex(status: VisitStatus): number {
+ if (status === "cancelled") return -1;
+ return VISIT_STATUS_ORDER.indexOf(status);
+}
+
+function buildDerivedVisitActivityLog(visit: VisitListItem): StoVehicleActivityEntry[] {
+ const entries: StoVehicleActivityEntry[] = [];
+ const endTime = visit.completedAt ?? visit.updatedAt ?? visit.createdAt;
+
+ entries.push({
+ id: `${visit.id}-created`,
+ type: "vehicle_created",
+ occurredAt: visit.createdAt,
+ actorName: "Оператор СТО",
+ actorRole: "operator",
+ title: "Візит відкрито",
+ description: visit.description || visit.title,
+ });
+
+ if (visit.mechanicName) {
+ entries.push({
+ id: `${visit.id}-mechanic`,
+ type: "mechanic_assigned",
+ occurredAt: interpolateIsoTime(visit.createdAt, endTime, 0.15),
+ actorName: "Менеджер СТО",
+ actorRole: "manager",
+ title: "Призначено механіка",
+ description: visit.mechanicName,
+ });
+ }
+
+ const currentIndex = getVisitStatusIndex(visit.status);
+ if (currentIndex >= 0) {
+ const statusesToLog = VISIT_STATUS_ORDER.slice(0, currentIndex + 1);
+ statusesToLog.forEach((status, index) => {
+ const ratio =
+ statusesToLog.length === 1 ? 0.25 : 0.2 + index / Math.max(statusesToLog.length, 1) * 0.35;
+
+ entries.push({
+ id: `${visit.id}-status-${status}`,
+ type: "status_changed",
+ occurredAt: interpolateIsoTime(visit.createdAt, endTime, ratio),
+ actorName: "Оператор СТО",
+ actorRole: "operator",
+ title: `Статус: ${VISIT_STATUS_LABELS[status]}`,
+ });
+ });
+ }
+
+ visit.records.forEach((record, index) => {
+ const ratio = 0.35 + (index / Math.max(visit.records.length, 1)) * 0.4;
+ entries.push({
+ id: `${visit.id}-record-${record.id}`,
+ type: "approval_requested",
+ occurredAt: interpolateIsoTime(visit.createdAt, endTime, ratio),
+ actorName: visit.mechanicName ?? "Механік",
+ actorRole: "mechanic",
+ title: `Додано до кошторису: ${record.title}`,
+ description: formatVisitPrice(record.price),
+ });
+ });
+
+ if (visit.status === "waiting_payment") {
+ entries.push({
+ id: `${visit.id}-payment`,
+ type: "approval_sent_to_client",
+ occurredAt: visit.updatedAt ?? endTime,
+ actorName: "Менеджер СТО",
+ actorRole: "manager",
+ title: "Надіслано клієнту на оплату",
+ description: `Очікує оплати від ${visit.clientName}`,
+ });
+ }
+
+ if (visit.status === "completed" && visit.completedAt) {
+ entries.push({
+ id: `${visit.id}-completed`,
+ type: "approval_approved",
+ occurredAt: visit.completedAt,
+ actorName: visit.clientName,
+ actorRole: "client",
+ title: "Візит завершено",
+ description: `Загальна сума: ${formatVisitPrice(
+ visit.records.reduce((sum, record) => sum + record.price, 0),
+ )}`,
+ });
+ }
+
+ if (visit.status === "cancelled") {
+ entries.push({
+ id: `${visit.id}-cancelled`,
+ type: "approval_rejected",
+ occurredAt: visit.updatedAt ?? endTime,
+ actorName: "Менеджер СТО",
+ actorRole: "manager",
+ title: "Візит скасовано",
+ });
+ }
+
+ return entries;
+}
+
+export function buildVisitActivityLog(visit: VisitListItem): StoVehicleActivityEntry[] {
+ const entries =
+ visit.activityLog && visit.activityLog.length > 0
+ ? [...visit.activityLog]
+ : buildDerivedVisitActivityLog(visit);
+
+ return entries.sort(
+ (left, right) => new Date(right.occurredAt).getTime() - new Date(left.occurredAt).getTime(),
+ );
+}
diff --git a/apps/AdminWebUi/src/utils/visitLabels.ts b/apps/AdminWebUi/src/utils/visitLabels.ts
new file mode 100644
index 000000000..0471b20be
--- /dev/null
+++ b/apps/AdminWebUi/src/utils/visitLabels.ts
@@ -0,0 +1,82 @@
+import type { VisitStatus } from "../types/visit";
+
+export const VISIT_STATUS_LABELS: Record = {
+ in_progress: "В роботі",
+ completed: "Завершено",
+ cancelled: "Скасовано",
+ waiting_payment: "Очікує оплати",
+};
+
+export interface VisitStatusBadgeStyle {
+ backgroundColor: string;
+ borderColor: string;
+ textColor: string;
+ dotColor: string;
+}
+
+export function getVisitStatusBadgeStyle(status: VisitStatus): VisitStatusBadgeStyle {
+ switch (status) {
+ case "in_progress":
+ return {
+ backgroundColor: "rgba(88, 166, 255, 0.15)",
+ borderColor: "rgba(88, 166, 255, 0.3)",
+ textColor: "#58a6ff",
+ dotColor: "#58a6ff",
+ };
+ case "completed":
+ return {
+ backgroundColor: "rgba(74, 222, 158, 0.15)",
+ borderColor: "rgba(74, 222, 158, 0.3)",
+ textColor: "#4ade9e",
+ dotColor: "#4ade9e",
+ };
+ case "waiting_payment":
+ return {
+ backgroundColor: "rgba(255, 166, 87, 0.14)",
+ borderColor: "rgba(255, 166, 87, 0.28)",
+ textColor: "#ffa657",
+ dotColor: "#ffa657",
+ };
+ case "cancelled":
+ return {
+ backgroundColor: "rgba(248, 81, 73, 0.12)",
+ borderColor: "rgba(248, 81, 73, 0.28)",
+ textColor: "#f85149",
+ dotColor: "#f85149",
+ };
+ }
+}
+
+export function formatVisitTitle(brand: string, model: string, year?: number): string {
+ return year ? `${brand} ${model} · ${year}` : `${brand} ${model}`;
+}
+
+export function formatVisitPrice(amount: number): string {
+ return new Intl.NumberFormat("uk-UA", {
+ style: "currency",
+ currency: "UAH",
+ maximumFractionDigits: 0,
+ }).format(amount);
+}
+
+export function calcVisitTotal(records: { price: number }[]): number {
+ return records.reduce((sum, record) => sum + record.price, 0);
+}
+
+export function formatVisitDate(isoDate: string): string {
+ return new Intl.DateTimeFormat("uk-UA", {
+ day: "2-digit",
+ month: "2-digit",
+ year: "numeric",
+ }).format(new Date(isoDate));
+}
+
+export function formatVisitDateTime(isoDate: string): string {
+ return new Intl.DateTimeFormat("uk-UA", {
+ day: "2-digit",
+ month: "short",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ }).format(new Date(isoDate));
+}
diff --git a/apps/Mobile/App.tsx b/apps/Mobile/App.tsx
index 7b74a8327..9964ce802 100644
--- a/apps/Mobile/App.tsx
+++ b/apps/Mobile/App.tsx
@@ -6,6 +6,7 @@ import { SafeAreaProvider } from "react-native-safe-area-context";
import { AuthProvider } from "./src/context/AuthContext";
import { CarProvider } from "./src/context/CarContext";
import { NotificationsProvider } from "./src/context/NotificationsContext";
+import { AppAlertProvider } from "./src/components/AppAlert";
import AppNavigator from "./src/navigation/AppNavigator";
import { queryClient } from "./src/lib/queryClient";
import { configureStatusBar } from "./src/styles/globalStyles";
@@ -26,8 +27,10 @@ export default function App() {
-
-
+
+
+
+
diff --git a/apps/Mobile/src/api/authTokenBridge.ts b/apps/Mobile/src/api/authTokenBridge.ts
new file mode 100644
index 000000000..721fd44e7
--- /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 ff6e92b27..74bd9b06b 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 000000000..69a033f1e
--- /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/AppAlertContext.tsx b/apps/Mobile/src/components/AppAlert/AppAlertContext.tsx
new file mode 100644
index 000000000..3f5101694
--- /dev/null
+++ b/apps/Mobile/src/components/AppAlert/AppAlertContext.tsx
@@ -0,0 +1,94 @@
+import React, {
+ createContext,
+ useCallback,
+ useContext,
+ useState,
+} from "react";
+import { AppAlert, AppAlertButton } from "./AppAlert";
+
+export interface ShowAppAlertOptions {
+ title: string;
+ message?: string;
+ buttons?: AppAlertButton[];
+}
+
+interface AppAlertState extends ShowAppAlertOptions {
+ visible: boolean;
+}
+
+interface AppAlertContextValue {
+ showAlert: (options: ShowAppAlertOptions) => void;
+ showError: (message: string, title?: string) => void;
+ showSuccess: (
+ message: string,
+ title?: string,
+ buttons?: AppAlertButton[]
+ ) => void;
+ hideAlert: () => void;
+}
+
+const AppAlertContext = createContext(null);
+
+export function AppAlertProvider({ children }: { children: React.ReactNode }) {
+ 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 value: AppAlertContextValue = {
+ showAlert,
+ showError,
+ showSuccess,
+ hideAlert,
+ };
+
+ return (
+
+ {children}
+ {alertState ? (
+
+ ) : null}
+
+ );
+}
+
+export function useAppAlert() {
+ const context = useContext(AppAlertContext);
+ if (!context) {
+ throw new Error("useAppAlert must be used within an AppAlertProvider");
+ }
+ return context;
+}
diff --git a/apps/Mobile/src/components/AppAlert/index.ts b/apps/Mobile/src/components/AppAlert/index.ts
new file mode 100644
index 000000000..e74dbad36
--- /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 { AppAlertProvider, useAppAlert } from "./AppAlertContext";
+export type { ShowAppAlertOptions } from "./AppAlertContext";
diff --git a/apps/Mobile/src/components/AppAlert/useAppAlert.tsx b/apps/Mobile/src/components/AppAlert/useAppAlert.tsx
new file mode 100644
index 000000000..f74c9fa18
--- /dev/null
+++ b/apps/Mobile/src/components/AppAlert/useAppAlert.tsx
@@ -0,0 +1,2 @@
+export { AppAlertProvider, useAppAlert } from "./AppAlertContext";
+export type { ShowAppAlertOptions } from "./AppAlertContext";
diff --git a/apps/Mobile/src/components/Select/Select.styles.ts b/apps/Mobile/src/components/Select/Select.styles.ts
index c88bcac21..98c9c04b2 100644
--- a/apps/Mobile/src/components/Select/Select.styles.ts
+++ b/apps/Mobile/src/components/Select/Select.styles.ts
@@ -72,9 +72,12 @@ export const styles = StyleSheet.create({
borderWidth: 1,
borderBottomWidth: 0,
borderColor: "rgba(255, 255, 255, 0.08)",
- maxHeight: "85%",
+ height: "85%",
overflow: "hidden",
},
+ optionsBody: {
+ flex: 1,
+ },
handle: {
alignSelf: "center",
width: 40,
@@ -83,23 +86,27 @@ export const styles = StyleSheet.create({
backgroundColor: "rgba(255, 255, 255, 0.15)",
marginTop: 10,
marginBottom: 4,
+ flexShrink: 0,
},
sheetHeader: {
flexDirection: "row",
- alignItems: "center",
+ alignItems: "flex-start",
justifyContent: "space-between",
paddingHorizontal: 16,
paddingTop: 8,
paddingBottom: 12,
+ flexShrink: 0,
},
sheetHeaderContent: {
flex: 1,
+ minWidth: 0,
+ marginRight: 12,
},
sheetTitle: {
color: "#FFFFFF",
fontSize: 18,
fontWeight: "700",
- flex: 1,
+ lineHeight: 24,
},
sheetSubtitle: {
color: "#8E8E93",
@@ -113,10 +120,12 @@ export const styles = StyleSheet.create({
backgroundColor: "rgba(255, 255, 255, 0.06)",
alignItems: "center",
justifyContent: "center",
+ marginTop: 2,
},
searchContainer: {
paddingHorizontal: 16,
paddingBottom: 12,
+ flexShrink: 0,
},
searchInputWrapper: {
flexDirection: "row",
@@ -135,7 +144,7 @@ export const styles = StyleSheet.create({
paddingVertical: 11,
},
optionsList: {
- flexGrow: 0,
+ flex: 1,
},
optionsListContent: {
paddingHorizontal: 12,
@@ -167,9 +176,11 @@ export const styles = StyleSheet.create({
fontWeight: "600",
},
noResultsContainer: {
+ flex: 1,
paddingVertical: 36,
paddingHorizontal: 24,
alignItems: "center",
+ justifyContent: "center",
gap: 8,
},
noResultsTitle: {
@@ -187,6 +198,7 @@ export const styles = StyleSheet.create({
paddingTop: 8,
borderTopWidth: 1,
borderTopColor: "rgba(255, 255, 255, 0.06)",
+ flexShrink: 0,
},
doneButton: {
alignItems: "center",
diff --git a/apps/Mobile/src/components/Select/Select.tsx b/apps/Mobile/src/components/Select/Select.tsx
index af48ca048..7d3324ef9 100644
--- a/apps/Mobile/src/components/Select/Select.tsx
+++ b/apps/Mobile/src/components/Select/Select.tsx
@@ -6,8 +6,6 @@ import {
TouchableOpacity,
Modal,
FlatList,
- Platform,
- Dimensions,
} from "react-native";
import { Feather } from "@expo/vector-icons";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -70,7 +68,6 @@ export const Select: React.FC = ({
const selectedOption = options.find((option) => option.value === value);
const showSearch = options.length > searchThreshold;
const modalTitle = label || "Виберіть опцію";
- const listMaxHeight = Dimensions.get("window").height * 0.42;
const filteredOptions = useMemo(() => {
if (!searchQuery.trim()) {
@@ -211,7 +208,6 @@ export const Select: React.FC = ({
onChangeText={setSearchQuery}
autoCapitalize="none"
autoCorrect={false}
- autoFocus={Platform.OS === "ios"}
/>
{searchQuery.length > 0 ? (
= ({
) : null}
- {filteredOptions.length > 0 ? (
- String(item.value)}
- style={[styles.optionsList, { maxHeight: listMaxHeight }]}
- contentContainerStyle={styles.optionsListContent}
- renderItem={renderOption}
- keyboardShouldPersistTaps="handled"
- showsVerticalScrollIndicator={false}
- />
- ) : (
-
-
- Нічого не знайдено
-
- Спробуйте інший пошуковий запит
-
-
- )}
+
+ {filteredOptions.length > 0 ? (
+ String(item.value)}
+ style={styles.optionsList}
+ contentContainerStyle={styles.optionsListContent}
+ renderItem={renderOption}
+ keyboardShouldPersistTaps="handled"
+ showsVerticalScrollIndicator={false}
+ />
+ ) : (
+
+
+ Нічого не знайдено
+
+ Спробуйте інший пошуковий запит
+
+
+ )}
+
= ({
containerStyle,
}) => {
const theme = useTheme();
+ const { showSuccess, showError } = useAppAlert();
const [vinDecode, setVinDecode] = useState("");
const [isDecodingVin, setIsDecodingVin] = useState(false);
@@ -54,14 +56,14 @@ export const VinDecoder: React.FC = ({
driveType: vinData.DriveType,
});
- Alert.alert("Успішно!", "Дані про автомобіль отримано та заповнено");
+ showSuccess("Дані про автомобіль отримано та заповнено", "Успішно!");
} catch (error) {
console.error("Error decoding VIN:", error);
const errorMessage =
error instanceof Error
? error.message
: "Не вдалося розшифрувати VIN номер. Перевірте правильність введення.";
- Alert.alert("Помилка", errorMessage);
+ showError(errorMessage);
} finally {
setIsDecodingVin(false);
}
@@ -75,7 +77,7 @@ export const VinDecoder: React.FC = ({
}, 500); // 500ms затримка після останнього введення
return () => clearTimeout(timeoutId);
- }, [vinDecode, onDecodeSuccess]);
+ }, [vinDecode, onDecodeSuccess, showSuccess, showError]);
return (
diff --git a/apps/Mobile/src/context/AuthContext.tsx b/apps/Mobile/src/context/AuthContext.tsx
index 032a14019..0ab5d8d5b 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 901fea998..aa5389d7d 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,
});
@@ -66,6 +67,7 @@ export const useDeleteVehicle = () => {
},
onSuccess: (_, vehicleId) => {
queryClient.removeQueries({ queryKey: queryKeys.vehicle(vehicleId) });
+ queryClient.removeQueries({ queryKey: queryKeys.serviceHistory(vehicleId) });
queryClient.invalidateQueries({ queryKey: queryKeys.vehicles });
},
});
diff --git a/apps/Mobile/src/screens/CarCardScreen/CarCardScreen.styles.ts b/apps/Mobile/src/screens/CarCardScreen/CarCardScreen.styles.ts
index d17f853e8..bd4c3b959 100644
--- a/apps/Mobile/src/screens/CarCardScreen/CarCardScreen.styles.ts
+++ b/apps/Mobile/src/screens/CarCardScreen/CarCardScreen.styles.ts
@@ -1,90 +1,219 @@
import { StyleSheet } from "react-native";
+import { theme } from "../../styles/theme";
export const styles = StyleSheet.create({
- scrollContainer: {
- flexGrow: 1,
- paddingHorizontal: 24,
+ contentContainer: {
+ paddingHorizontal: 16,
+ paddingTop: 8,
+ paddingBottom: 24,
},
- header: {
- alignItems: "center",
- marginTop: 20,
- marginBottom: 40,
+ pageTitle: {
+ color: "#FFFFFF",
+ fontSize: 24,
+ fontWeight: "700",
+ marginBottom: 4,
},
- title: {
- marginBottom: 8,
- textAlign: "center",
- },
- subtitle: {
- textAlign: "center",
+ pageSubtitle: {
+ color: "#8E8E93",
+ fontSize: 14,
+ marginBottom: 18,
+ lineHeight: 20,
+ },
+ stepHeader: {
+ marginBottom: 14,
+ gap: 6,
+ },
+ stepCounter: {
+ color: theme.colors.accent.primary,
+ fontSize: 12,
+ fontWeight: "600",
+ textTransform: "uppercase",
+ letterSpacing: 0.5,
+ },
+ stepTitle: {
+ color: "#FFFFFF",
+ fontSize: 17,
+ fontWeight: "600",
+ },
+ stepProgress: {
+ flexDirection: "row",
+ gap: 6,
+ marginTop: 4,
},
- form: {
+ stepDot: {
flex: 1,
+ height: 4,
+ borderRadius: 999,
+ backgroundColor: "rgba(255, 255, 255, 0.08)",
},
- imageSection: {
- alignItems: "center",
- marginBottom: 32,
- },
- imageContainer: {
- width: 200,
- height: 120,
- borderRadius: 12,
- borderWidth: 2,
- borderColor: "#30363D", // Using theme color directly
- borderStyle: "dashed",
- justifyContent: "center",
- alignItems: "center",
+ stepDotCompleted: {
+ backgroundColor: theme.colors.accent.primary,
+ },
+ stepDotActive: {
+ backgroundColor: "#FFFFFF",
+ },
+ section: {
+ backgroundColor: "#1A1C1E",
+ borderRadius: 16,
+ padding: 16,
+ marginBottom: 14,
+ borderWidth: 1,
+ borderColor: "rgba(255, 255, 255, 0.05)",
+ gap: 14,
+ },
+ field: {
+ gap: 6,
},
- carImage: {
- width: 196,
- height: 116,
+ label: {
+ color: "#8E8E93",
+ fontSize: 13,
+ },
+ input: {
+ backgroundColor: "#232527",
borderRadius: 10,
+ borderWidth: 1,
+ borderColor: "rgba(255, 255, 255, 0.08)",
+ color: "#FFFFFF",
+ paddingHorizontal: 12,
+ paddingVertical: 12,
+ fontSize: 15,
+ },
+ dateInput: {
+ justifyContent: "center",
},
- placeholderImage: {
- alignItems: "center",
+ dateInputText: {
+ color: "#FFFFFF",
+ fontSize: 15,
},
- placeholderText: {
- fontSize: 32,
- marginBottom: 8,
+ selectContainer: {
+ marginBottom: 0,
},
- placeholderLabel: {
- fontSize: 14,
- color: "#8B949E", // Using theme color directly
+ loadingIndicator: {
+ marginTop: 4,
+ alignItems: "flex-start",
},
- inputContainer: {
- marginBottom: 20,
+ photoCard: {
+ borderRadius: 14,
+ overflow: "hidden",
+ backgroundColor: "#121212",
+ borderWidth: 1,
+ borderColor: "rgba(255, 255, 255, 0.06)",
+ },
+ photoImage: {
+ width: "100%",
+ height: 180,
+ backgroundColor: "#121212",
+ },
+ photoPlaceholder: {
+ width: "100%",
+ height: 180,
+ backgroundColor: theme.colors.accent.dark,
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 8,
},
- label: {
- marginBottom: 8,
+ photoPlaceholderText: {
+ color: "#FFFFFF",
+ fontSize: 14,
+ fontWeight: "500",
},
- input: {
- // Additional input styles if needed
+ photoOverlay: {
+ position: "absolute",
+ bottom: 0,
+ left: 0,
+ right: 0,
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 8,
+ paddingVertical: 12,
+ backgroundColor: "rgba(0, 0, 0, 0.55)",
+ },
+ photoOverlayText: {
+ color: "#FFFFFF",
+ fontSize: 14,
+ fontWeight: "600",
},
- vinDecodeContainer: {
+ actionsRow: {
flexDirection: "row",
- gap: 12,
- alignItems: "flex-start",
+ gap: 10,
+ marginTop: 4,
},
- vinDecodeInput: {
+ halfButton: {
flex: 1,
},
- decodeButton: {
- minWidth: 120,
- justifyContent: "center",
+ primaryButton: {
+ flexDirection: "row",
alignItems: "center",
- paddingHorizontal: 16,
+ justifyContent: "center",
+ gap: 8,
+ backgroundColor: theme.colors.interactive.primary,
+ borderRadius: 14,
+ paddingVertical: 14,
},
- decodeButtonDisabled: {
- opacity: 0.6,
+ primaryButtonText: {
+ color: theme.colors.text.inverse,
+ fontSize: 15,
+ fontWeight: "600",
},
- buttonContainer: {
+ secondaryButton: {
flexDirection: "row",
- justifyContent: "space-between",
- marginTop: 20,
- marginBottom: 40,
- gap: 12,
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 8,
+ backgroundColor: theme.colors.accent.dark,
+ borderRadius: 14,
+ paddingVertical: 14,
+ borderWidth: 1,
+ borderColor: "rgba(74, 222, 158, 0.15)",
+ },
+ secondaryButtonText: {
+ color: theme.colors.accent.primary,
+ fontSize: 15,
+ fontWeight: "600",
+ },
+ datePickerModalRoot: {
+ flex: 1,
+ justifyContent: "flex-end",
+ },
+ datePickerBackdrop: {
+ ...StyleSheet.absoluteFillObject,
+ backgroundColor: "rgba(0, 0, 0, 0.55)",
+ },
+ datePickerSheet: {
+ backgroundColor: "#1A1C1E",
+ borderTopLeftRadius: 20,
+ borderTopRightRadius: 20,
+ borderWidth: 1,
+ borderBottomWidth: 0,
+ borderColor: "rgba(255, 255, 255, 0.08)",
+ paddingBottom: 24,
+ },
+ datePickerHandle: {
+ alignSelf: "center",
+ width: 40,
+ height: 4,
+ borderRadius: 2,
+ backgroundColor: "rgba(255, 255, 255, 0.15)",
+ marginTop: 10,
+ marginBottom: 8,
},
- loadingIndicator: {
- marginTop: 8,
+ datePickerHeader: {
+ flexDirection: "row",
+ justifyContent: "flex-end",
+ alignItems: "center",
+ paddingHorizontal: 16,
+ paddingBottom: 8,
+ },
+ datePickerDoneText: {
+ color: theme.colors.accent.primary,
+ fontSize: 15,
+ fontWeight: "600",
+ },
+ loadingContainer: {
+ flex: 1,
+ justifyContent: "center",
alignItems: "center",
+ paddingHorizontal: 24,
},
});
diff --git a/apps/Mobile/src/screens/CarCardScreen/CarCardScreen.tsx b/apps/Mobile/src/screens/CarCardScreen/CarCardScreen.tsx
index b90b320eb..e378468db 100644
--- a/apps/Mobile/src/screens/CarCardScreen/CarCardScreen.tsx
+++ b/apps/Mobile/src/screens/CarCardScreen/CarCardScreen.tsx
@@ -1,23 +1,29 @@
-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 ReanimatedAnimated, { FadeIn } from "react-native-reanimated";
+import { Feather } from "@expo/vector-icons";
import { FormScreen } from "../../components/FormScreen";
import DateTimePicker from "@react-native-community/datetimepicker";
import * as ImagePicker from "expo-image-picker";
import { useAuth } from "../../context/AuthContext";
import { useTheme } from "../../hooks/useTheme";
+import { useStatusBar } from "../../hooks/useStatusBar";
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 +39,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 +71,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 +107,22 @@ const WHEEL_DRIVE_API_OPTIONS: SelectOption[] = [
{ label: "4WD", value: WheelDriveType.FourWD },
];
+const TOTAL_STEPS = 6;
+const STEP_TITLES = [
+ "Базова інформація",
+ "Ідентифікація авто",
+ "Історія використання",
+ "Двигун",
+ "Трансмісія та привід",
+ "Фото та завершення",
+];
+
export default function CarCardScreen({ navigation }: CarCardScreenProps) {
+ useStatusBar();
const { getAccessToken } = useAuth();
const queryClient = useQueryClient();
const theme = useTheme();
+ const { showAlert, showError, showSuccess } = useAppAlert();
const [carImage, setCarImage] = useState(null);
const [brand, setBrand] = useState(undefined);
const [model, setModel] = useState(undefined);
@@ -97,12 +130,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 +152,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 +168,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 +183,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 +198,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 +206,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 +296,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 +341,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 +357,8 @@ export default function CarCardScreen({ navigation }: CarCardScreenProps) {
style: "destructive",
onPress: () => navigation.navigate("Home"),
},
- ]
- );
+ ],
+ });
} else {
navigation.navigate("Home");
}
@@ -309,71 +366,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 +444,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 +475,7 @@ export default function CarCardScreen({ navigation }: CarCardScreenProps) {
queryClient.invalidateQueries({ queryKey: queryKeys.vehicles });
- Alert.alert("Успішно!", "Автомобіль додано в базу", [
+ showSuccess("Автомобіль додано в базу", "Успішно!", [
{
text: "OK",
onPress: () => navigation.navigate("Home"),
@@ -419,20 +483,152 @@ 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 (
@@ -444,268 +640,394 @@ export default function CarCardScreen({ navigation }: CarCardScreenProps) {
return (
-
-
- Додай свій автомобіль
-
-
- Створи картку твого авто
-
-
-
-
- {/* VIN Decode Field */}
- {/* */}
-
- {/* Car Image */}
-
-
- {carImage ? (
-
- ) : (
-
- 🚗
- Фото авто
-
- )}
-
-
+
+ Додай свій автомобіль
+ Створи картку твого авто
+
- {/* Brand Field */}
-
-
);
}
diff --git a/apps/Mobile/src/screens/CarEditScreen/CarEditScreen.tsx b/apps/Mobile/src/screens/CarEditScreen/CarEditScreen.tsx
index 9d16e9a29..292ff7f25 100644
--- a/apps/Mobile/src/screens/CarEditScreen/CarEditScreen.tsx
+++ b/apps/Mobile/src/screens/CarEditScreen/CarEditScreen.tsx
@@ -5,7 +5,6 @@ import {
TextInput,
TouchableOpacity,
Image,
- Alert,
Platform,
ActivityIndicator,
} from "react-native";
@@ -22,6 +21,7 @@ import { CarEditScreenProps } from "../../navigation/types";
import { Select, SelectOption } from "../../components/Select";
import { useVehicle, useUpdateVehicle, useDeleteVehicle } from "../../queries";
import { formatVinShort } from "../HomeScreen/homeScreenUtils";
+import { useAppAlert } from "../../components/AppAlert";
const COLOR_OPTIONS: SelectOption[] = [
{ label: "Білий", value: "white" },
@@ -44,6 +44,7 @@ export default function CarEditScreen({
}: CarEditScreenProps) {
useStatusBar();
const theme = useTheme();
+ const { showAlert, showError, showSuccess } = useAppAlert();
const { carId } = route.params;
const {
data: vehicle,
@@ -75,7 +76,7 @@ export default function CarEditScreen({
const permissionResult =
await ImagePicker.requestMediaLibraryPermissionsAsync();
if (permissionResult.granted === false) {
- Alert.alert("Дозвіл потрібен", "Потрібен дозвіл для доступу до галереї!");
+ showError("Потрібен дозвіл для доступу до галереї!", "Дозвіл потрібен");
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
@@ -114,18 +115,18 @@ export default function CarEditScreen({
const handleCancel = () => {
if (hasChanges()) {
- Alert.alert(
- "Скасувати зміни?",
- "Ви внесли зміни. Ви впевнені, що хочете скасувати?",
- [
+ showAlert({
+ title: "Скасувати зміни?",
+ message: "Ви внесли зміни. Ви впевнені, що хочете скасувати?",
+ buttons: [
{ text: "Продовжити редагування", style: "cancel" },
{
text: "Скасувати",
style: "destructive",
onPress: () => navigation.goBack(),
},
- ]
- );
+ ],
+ });
} else {
navigation.goBack();
}
@@ -133,16 +134,16 @@ export default function CarEditScreen({
const handleSave = async () => {
if (!licensePlate.trim()) {
- Alert.alert("Помилка", "Введіть номерний знак");
+ showError("Введіть номерний знак");
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;
}
@@ -160,22 +161,22 @@ export default function CarEditScreen({
try {
await updateVehicle.mutateAsync({ vehicleId: carId, data: dto });
- Alert.alert("Успішно!", "Дані автомобіля оновлені", [
+ showSuccess("Дані автомобіля оновлені", "Успішно!", [
{ text: "OK", onPress: () => navigation.goBack() },
]);
} catch (e) {
- Alert.alert(
- "Помилка",
+ showError(
e instanceof Error ? e.message : "Не вдалося зберегти зміни"
);
}
};
const handleDelete = () => {
- Alert.alert(
- "Видалити автомобіль?",
- "Ви впевнені, що хочете видалити цей автомобіль? Цю дію неможливо скасувати.",
- [
+ showAlert({
+ title: "Видалити автомобіль?",
+ message:
+ "Ви впевнені, що хочете видалити цей автомобіль? Цю дію неможливо скасувати.",
+ buttons: [
{ text: "Скасувати", style: "cancel" },
{
text: "Видалити",
@@ -185,15 +186,14 @@ export default function CarEditScreen({
await deleteVehicle.mutateAsync(carId);
navigation.navigate("Home");
} catch (e) {
- Alert.alert(
- "Помилка",
+ showError(
e instanceof Error ? e.message : "Не вдалося видалити"
);
}
},
},
- ]
- );
+ ],
+ });
};
if (isLoadingVehicle || !vehicle) {
@@ -377,10 +377,7 @@ export default function CarEditScreen({
-
+
Скасувати
diff --git a/apps/Mobile/src/screens/HomeScreen/GarageContent.tsx b/apps/Mobile/src/screens/HomeScreen/GarageContent.tsx
index 07061cc25..25efd5d25 100644
--- a/apps/Mobile/src/screens/HomeScreen/GarageContent.tsx
+++ b/apps/Mobile/src/screens/HomeScreen/GarageContent.tsx
@@ -1,7 +1,6 @@
import React from "react";
import {
ActivityIndicator,
- Alert,
Image,
RefreshControl,
ScrollView,
@@ -34,7 +33,9 @@ import {
TRANSMISSION_LABELS,
WHEEL_DRIVE_LABELS,
} from "./homeScreenUtils";
+import { formatEngineDisplay } from "../../utils/vehicleFormUtils";
import { styles } from "./GarageContent.styles";
+import { useAppAlert } from "../../components/AppAlert";
const CAR_PLACEHOLDER_SVG = `
- Оновлення в реальному часі
+
+ Оновлення в реальному часі
+
Прийнято о {formatAcceptedAt(session.acceptedAt)}
@@ -340,7 +342,7 @@ export function ServiceStatusContent({
color={theme.colors.text.inverse}
/>
- Переглянути та затвердити перелік робіт
+ Затвердити перелік робіт
diff --git a/apps/Mobile/src/screens/ServiceStatusScreen/serviceStatusConfig.ts b/apps/Mobile/src/screens/ServiceStatusScreen/serviceStatusConfig.ts
index e8857ca82..595ef23fd 100644
--- a/apps/Mobile/src/screens/ServiceStatusScreen/serviceStatusConfig.ts
+++ b/apps/Mobile/src/screens/ServiceStatusScreen/serviceStatusConfig.ts
@@ -26,6 +26,12 @@ export const SERVICE_STATUS_STEPS: ServiceStatusStepConfig[] = [
title: "Погодження",
shortTitle: "Кошторис",
},
+ {
+ id: "waiting_parts",
+ emoji: "📦",
+ title: "Очікуємо деталей",
+ shortTitle: "Деталі",
+ },
{
id: "in_progress",
emoji: "🔧",
@@ -67,6 +73,8 @@ export function getStatusContextMessage(
return "Майстер проводить огляд. Очікуйте на результати та кошторис.";
case "approval":
return "Діагностику завершено. Перегляньте перелік робіт і затвердіть кошторис.";
+ case "waiting_parts":
+ return "Очікуємо постачання деталей. Ремонт розпочнеться одразу після їх отримання.";
case "in_progress":
return currentWork
? `Зараз виконується: ${currentWork}`
diff --git a/apps/Mobile/src/screens/UserCheckScreen/UserCheckScreen.tsx b/apps/Mobile/src/screens/UserCheckScreen/UserCheckScreen.tsx
index d2173799f..45267b91f 100644
--- a/apps/Mobile/src/screens/UserCheckScreen/UserCheckScreen.tsx
+++ b/apps/Mobile/src/screens/UserCheckScreen/UserCheckScreen.tsx
@@ -3,7 +3,6 @@ import {
View,
Text,
ActivityIndicator,
- Alert,
TouchableOpacity,
} from "react-native";
import { useMe } from "../../queries/userQueries";
@@ -11,10 +10,12 @@ import { useAuth } from "../../context/AuthContext";
import { theme } from "../../styles/theme";
import { styles } from "./UserCheckScreen.styles";
import { UserCheckScreenProps } from "../../navigation/types";
+import { useAppAlert } from "../../components/AppAlert";
export default function UserCheckScreen({ navigation }: UserCheckScreenProps) {
const { data: meData, isLoading, error } = useMe();
const { logout } = useAuth();
+ const { showAlert } = useAppAlert();
const [showSkipButton, setShowSkipButton] = useState(false);
useEffect(() => {
@@ -93,34 +94,39 @@ export default function UserCheckScreen({ navigation }: UserCheckScreenProps) {
}
// Показуємо повідомлення користувачу
- Alert.alert("Помилка", errorMessage, [
- {
- text: "Скасувати",
- style: "cancel",
- onPress: async () => {
- await logout();
- },
- },
- {
- text: buttonText,
- onPress: async () => {
- if (buttonText === "Увійти знову") {
+ showAlert({
+ title: "Помилка",
+ message: errorMessage,
+ buttons: [
+ {
+ text: "Скасувати",
+ style: "cancel",
+ onPress: async () => {
await logout();
- } else {
- // Спробувати знову - просто закриваємо Alert, query автоматично повториться
- // React Query автоматично повторить запит через retry логіку
- }
+ },
},
- },
- ]);
+ {
+ text: buttonText,
+ onPress: async () => {
+ if (buttonText === "Увійти знову") {
+ await logout();
+ } else {
+ // Спробувати знову - просто закриваємо Alert, query автоматично повториться
+ // React Query автоматично повторить запит через retry логіку
+ }
+ },
+ },
+ ],
+ });
}
- }, [error, isLoading, logout, navigation]);
+ }, [error, isLoading, logout, navigation, showAlert]);
const handleSkip = () => {
- Alert.alert(
- "Пропустити перевірку",
- "Ви впевнені, що хочете пропустити перевірку профілю? Це може призвести до проблем з функціональністю додатку.",
- [
+ showAlert({
+ title: "Пропустити перевірку",
+ message:
+ "Ви впевнені, що хочете пропустити перевірку профілю? Це може призвести до проблем з функціональністю додатку.",
+ buttons: [
{
text: "Скасувати",
style: "cancel",
@@ -130,8 +136,8 @@ export default function UserCheckScreen({ navigation }: UserCheckScreenProps) {
style: "destructive",
onPress: () => navigation.replace("ProfileSetup"),
},
- ]
- );
+ ],
+ });
};
return (
diff --git a/apps/Mobile/src/types/serviceStatus.ts b/apps/Mobile/src/types/serviceStatus.ts
index 3ee0e41ba..c4c204ebf 100644
--- a/apps/Mobile/src/types/serviceStatus.ts
+++ b/apps/Mobile/src/types/serviceStatus.ts
@@ -2,6 +2,7 @@ export type ServiceStatusId =
| "received"
| "diagnostics"
| "approval"
+ | "waiting_parts"
| "in_progress"
| "quality_check"
| "ready";
diff --git a/apps/Mobile/src/utils/apiErrorUtils.ts b/apps/Mobile/src/utils/apiErrorUtils.ts
new file mode 100644
index 000000000..824ae9775
--- /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 000000000..8cc08fd74
--- /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 000000000..1c494516b
--- /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) та цифри";
|