diff --git a/Services/Main/MainHub.Api/Services/ServiceHistoryService.cs b/Services/Main/MainHub.Api/Services/ServiceHistoryService.cs index 4efc7c816..5b3d317a2 100644 --- a/Services/Main/MainHub.Api/Services/ServiceHistoryService.cs +++ b/Services/Main/MainHub.Api/Services/ServiceHistoryService.cs @@ -255,4 +255,4 @@ Guid serviceHistoryId Price = r.Price }).ToList(); } -} +} \ No newline at end of file diff --git a/Services/Main/MainHub.Api/Validators/CreateVehicleDtoValidator.cs b/Services/Main/MainHub.Api/Validators/CreateVehicleDtoValidator.cs index 55217e6fc..679e161b6 100644 --- a/Services/Main/MainHub.Api/Validators/CreateVehicleDtoValidator.cs +++ b/Services/Main/MainHub.Api/Validators/CreateVehicleDtoValidator.cs @@ -1,4 +1,3 @@ -using System.Data; using System.Text.RegularExpressions; using FluentValidation; using MainHub.Api.DTOs; @@ -61,12 +60,12 @@ public CreateVehicleDtoValidator() .When(x => x.BoughtAt.HasValue); RuleFor(x => x.EngineCapacity) - .NotEmpty() - .WithMessage("Engine capacity is required.") - .GreaterThan(0) - .WithMessage("Engine capacity must be greater than 0.") - .LessThanOrEqualTo(10000) - .WithMessage("Engine capacity must be less than or equal to 10000."); + .NotEmpty().WithMessage("Engine capacity is required.") + .LessThanOrEqualTo(10000).WithMessage("Engine capacity must be less than or equal to 10000.") + .GreaterThan(0).WithMessage("Engine capacity must be greater than 0.") + .When(x => x.FuelType is not FuelType.Electric and not FuelType.Hydrogen) + .Equal(0).WithMessage("Engine capacity must be 0 for Electric/Hydrogen vehicles.") + .When(x => x.FuelType is FuelType.Electric or FuelType.Hydrogen); RuleFor(x => x.Mileage) .NotEmpty() diff --git a/apps/AdminWebUi/src/App.tsx b/apps/AdminWebUi/src/App.tsx index 9630cc9cf..57d38541c 100644 --- a/apps/AdminWebUi/src/App.tsx +++ b/apps/AdminWebUi/src/App.tsx @@ -6,6 +6,7 @@ import { BrowserRouter } from "react-router-dom"; import { AuthProvider } from "./auth/AuthContext"; import { StoProvider } from "./context/StoContext"; import { StoVehicleProvider } from "./context/StoVehicleContext"; +import { BookingRequestProvider } from "./context/BookingRequestContext"; import { queryClient } from "./lib/queryClient"; import { AppRouter } from "./routes/AppRouter"; import { persistor, store } from "./store"; @@ -19,7 +20,9 @@ function App() { - + + + diff --git a/apps/AdminWebUi/src/components/ActivityLog/ActivityLog.module.css b/apps/AdminWebUi/src/components/ActivityLog/ActivityLog.module.css new file mode 100644 index 000000000..4cd79dee1 --- /dev/null +++ b/apps/AdminWebUi/src/components/ActivityLog/ActivityLog.module.css @@ -0,0 +1,233 @@ +.section { + margin: 20px 24px 24px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: var(--radius-lg); + background-color: #1a1c1e; + overflow: hidden; +} + +.toggle { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; + padding: 16px 20px; + border: none; + background: linear-gradient( + 180deg, + rgba(74, 222, 158, 0.04) 0%, + transparent 100% + ); + color: inherit; + cursor: pointer; + text-align: left; +} + +.toggle:hover { + background-color: rgba(255, 255, 255, 0.02); +} + +.toggleMain { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.toggleTitle { + margin: 0; + font-size: 13px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-secondary); +} + +.countBadge { + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + color: var(--accent-primary); + background-color: rgba(74, 222, 158, 0.12); + border: 1px solid rgba(74, 222, 158, 0.24); +} + +.toggleHint { + margin: 4px 0 0; + font-size: 12px; + color: var(--text-tertiary); + line-height: 1.4; +} + +.chevron { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + flex-shrink: 0; + border-radius: var(--radius-sm); + border: 1px solid rgba(255, 255, 255, 0.08); + color: var(--text-secondary); + transition: + transform 0.3s ease, + color 0.2s ease, + border-color 0.2s ease; +} + +.chevronExpanded { + transform: rotate(180deg); + color: var(--accent-primary); + border-color: rgba(74, 222, 158, 0.24); +} + +.bodyWrapper { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.32s ease; +} + +.bodyWrapperExpanded { + grid-template-rows: 1fr; +} + +.bodyInner { + overflow: hidden; + min-height: 0; + opacity: 0; + transition: opacity 0.24s ease; +} + +.bodyWrapperExpanded .bodyInner { + opacity: 1; +} + +.body { + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.scrollArea { + max-height: 360px; + overflow-y: auto; + padding: 12px 16px 16px; +} + +.scrollArea::-webkit-scrollbar { + width: 6px; +} + +.scrollArea::-webkit-scrollbar-thumb { + background-color: rgba(255, 255, 255, 0.12); + border-radius: 3px; +} + +.list { + display: flex; + flex-direction: column; + gap: 0; + margin: 0; + padding: 0; + list-style: none; +} + +.entry { + display: grid; + grid-template-columns: 20px 1fr; + gap: 12px; + padding: 12px 4px; + position: relative; +} + +.entry:not(:last-child)::after { + content: ""; + position: absolute; + left: 9px; + top: 32px; + bottom: -4px; + width: 2px; + background-color: rgba(255, 255, 255, 0.08); + border-radius: 1px; +} + +.marker { + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 4px; +} + +.dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; + box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.04); +} + +.entryMain { + min-width: 0; +} + +.entryHeader { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 8px; + margin-bottom: 4px; +} + +.entryTitle { + margin: 0; + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + line-height: 1.35; +} + +.entryTime { + font-size: 11px; + color: var(--text-tertiary); + white-space: nowrap; +} + +.entryMeta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin-bottom: 4px; +} + +.actorName { + font-size: 12px; + color: var(--text-secondary); +} + +.actorRole { + padding: 1px 6px; + border-radius: 4px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; + color: var(--text-tertiary); + background-color: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.entryDescription { + margin: 0; + font-size: 12px; + color: var(--text-secondary); + line-height: 1.45; +} + +.empty { + padding: 20px 16px; + font-size: 13px; + color: var(--text-tertiary); + text-align: center; +} diff --git a/apps/AdminWebUi/src/components/ActivityLog/ActivityLog.tsx b/apps/AdminWebUi/src/components/ActivityLog/ActivityLog.tsx new file mode 100644 index 000000000..baf778282 --- /dev/null +++ b/apps/AdminWebUi/src/components/ActivityLog/ActivityLog.tsx @@ -0,0 +1,120 @@ +import { useState } from "react"; +import type { StoVehicleActivityEntry, StoVehicleActivityType } from "../../types/stoVehicle"; +import { ACTIVITY_ACTOR_ROLE_LABELS } from "../../utils/stoVehicleActivityLog"; +import { formatEventDateTime } from "../../utils/stoVehicleLabels"; +import styles from "./ActivityLog.module.css"; + +const ACTIVITY_DOT_COLORS: Record = { + vehicle_created: "#6e7681", + appointment_scheduled: "#58a6ff", + vehicle_arrived: "#4ade9e", + status_changed: "#a371f7", + mechanic_assigned: "#d29922", + approval_requested: "#ffa657", + approval_sent_to_client: "#58a6ff", + approval_approved: "#4ade9e", + approval_rejected: "#f85149", +}; + +interface ActivityLogProps { + entries: StoVehicleActivityEntry[]; + hint?: string; +} + +function ChevronIcon({ expanded }: { expanded: boolean }) { + return ( + + ); +} + +export function ActivityLog({ + entries, + hint = "Повний життєвий цикл: статуси, погодження, призначення механіка", +}: ActivityLogProps) { + const [isExpanded, setIsExpanded] = useState(false); + + return ( +
+ + +
+
+
+ {entries.length === 0 ? ( +

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

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

    {entry.title}

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

    {entry.description}

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

{title}

+ {request.licensePlate} +
+

{request.clientName}

+
+
+ +

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

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

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

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

Автомобіль

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

Водій

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

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

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

{request.problemDescription}

+
+
+ +
+

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

+ + {request.attachmentsCount ? ( +

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

+ ) : null} + +
+
+

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

+

{preferredSlot}

+

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

+
+ +
+

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

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

{appointmentError}

+ ) : null} +