From 50f54ad08c746e5aeb9af3555de6d581a0030280 Mon Sep 17 00:00:00 2001 From: Adekoye Adewale <94379177+Adekoye-Adewale@users.noreply.github.com> Date: Sat, 23 May 2026 12:13:04 +0100 Subject: [PATCH 01/19] Update layout.tsx --- app/layout.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/layout.tsx b/app/layout.tsx index 05dfda1..972b7cc 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -40,9 +40,9 @@ export default function RootLayout({ return ( - + {children} From 4740fcfe7b9e55df8251c0bb391ae6f757f69891 Mon Sep 17 00:00:00 2001 From: Adekoye Adewale <94379177+Adekoye-Adewale@users.noreply.github.com> Date: Sat, 23 May 2026 12:21:23 +0100 Subject: [PATCH 02/19] setup host login and register screen --- app/(host)/us/host/login/page.tsx | 8 + app/(host)/us/host/register/page.tsx | 7 + components/forms/hostLoginForm.tsx | 227 ++++++++++++++++++ components/forms/hostRegisterForm.tsx | 83 +++++++ components/hostPages/loginPageComponent.tsx | 76 ++++++ .../hostPages/registerPageComponents.tsx | 111 +++++++++ 6 files changed, 512 insertions(+) create mode 100644 app/(host)/us/host/login/page.tsx create mode 100644 app/(host)/us/host/register/page.tsx create mode 100644 components/forms/hostLoginForm.tsx create mode 100644 components/forms/hostRegisterForm.tsx create mode 100644 components/hostPages/loginPageComponent.tsx create mode 100644 components/hostPages/registerPageComponents.tsx diff --git a/app/(host)/us/host/login/page.tsx b/app/(host)/us/host/login/page.tsx new file mode 100644 index 0000000..27e7320 --- /dev/null +++ b/app/(host)/us/host/login/page.tsx @@ -0,0 +1,8 @@ +import LoginPageComponent from '@/components/hostPages/loginPageComponent' +import React from 'react' + +export default function HostLoginPage() { + return ( + + ) +} diff --git a/app/(host)/us/host/register/page.tsx b/app/(host)/us/host/register/page.tsx new file mode 100644 index 0000000..f50a59a --- /dev/null +++ b/app/(host)/us/host/register/page.tsx @@ -0,0 +1,7 @@ +import RegisterPageComponents from "@/components/hostPages/registerPageComponents"; + +export default function HostRegisterPage() { + return ( + + ) +} diff --git a/components/forms/hostLoginForm.tsx b/components/forms/hostLoginForm.tsx new file mode 100644 index 0000000..a080c19 --- /dev/null +++ b/components/forms/hostLoginForm.tsx @@ -0,0 +1,227 @@ +'use client' + +import { useState } from 'react' +import { useForm } from 'react-hook-form' +import { Mail, X } from 'lucide-react' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import MainForm from './MainForm' +import { validators } from './form.validators' +import { FormFieldConfig } from './types' +import { RegisterOptions } from 'react-hook-form' +import { cn } from '@/lib/utils' + +const fields: FormFieldConfig[] = [ + { + name: 'email', + type: 'email', + label: 'Email Address', + placeholder: 'john@company.com', + icon: , + validation: validators.email() as RegisterOptions, + }, + { + name: 'password', + type: 'password', + label: 'Password', + placeholder: 'Enter your password', + validation: validators.required('Password') as RegisterOptions, + }, +] + +export default function HostLoginForm() { + const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false) + + const onSubmit = (values: Record) => { + console.log(values) + } + + return ( + <> +
+ {/* The two main fields via MainForm */} + setForgotPasswordOpen(true)} + /> + } + /> +
+ + {/* Forgot password dialog */} + {forgotPasswordOpen && ( + setForgotPasswordOpen(false)} /> + )} + + ) +} + +// ─── Remember me + Forgot password row ─────────────────────────────────────── + +const RememberForgotRow = ({ onForgotPassword }: { onForgotPassword: () => void }) => { + const [remembered, setRemembered] = useState(false) + + return ( +
+
+ setRemembered(!!v)} + className='border-[#E5E7EB] data-[state=checked]:bg-[#1A56DB] data-[state=checked]:border-[#1A56DB]' + /> + +
+ +
+ ) +} + +// ─── Forgot password dialog ─────────────────────────────────────────────────── + +type ForgotPasswordFormValues = { + resetEmail: string +} + +const ForgotPasswordDialog = ({ onClose }: { onClose: () => void }) => { + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ mode: 'onTouched' }) + + const onSubmit = (values: ForgotPasswordFormValues) => { + console.log('reset password for:', values.resetEmail) + // TODO: call reset password API here + onClose() + } + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+

+ Reset your password +

+ + Enter your email and we'll send you a reset link. + +
+ +
+ + {/* Form */} +
+
+ +
+ + + + )} + /> +
+ {errors.resetEmail && ( + + + {errors.resetEmail.message as string} + + )} +
+ + {/* Actions */} +
+ + +
+
+
+
+ ) +} + +// ─── Icons ──────────────────────────────────────────────────────────────────── + +const ErrorIcon = () => ( + + + + + +) + +const LoadingSpinner = () => ( + + + + +) \ No newline at end of file diff --git a/components/forms/hostRegisterForm.tsx b/components/forms/hostRegisterForm.tsx new file mode 100644 index 0000000..e2a15f1 --- /dev/null +++ b/components/forms/hostRegisterForm.tsx @@ -0,0 +1,83 @@ +'use client' + +import { Mail, User, Building2, Phone } from 'lucide-react' +import MainForm from './MainForm' +import { validators } from './form.validators' +import { FormFieldConfig } from './types' +import { RegisterOptions } from 'react-hook-form' + +const fields: FormFieldConfig[] = [ + { + name: 'firstName', + type: 'text', + label: 'First Name', + placeholder: 'John', + icon: , + validation: validators.name('First name') as RegisterOptions, + className: 'w-full', + }, + { + name: 'lastName', + type: 'text', + label: 'Last Name', + placeholder: 'Smith', + icon: , + validation: validators.name('Last name') as RegisterOptions, + className: 'w-full', + }, + { + name: 'email', + type: 'email', + label: 'Email Address', + placeholder: 'john@company.com', + icon: , + validation: validators.email() as RegisterOptions, + }, + { + name: 'companyName', + type: 'text', + label: 'Company Name', + placeholder: 'Acme Rentals Ltd.', + icon: , + validation: validators.required('Company name') as RegisterOptions, + }, + { + name: 'phoneNumber', + type: 'tel', + label: 'Phone Number', + placeholder: '+1 555-123-4567', + icon: , + autoComplete: 'tel', + description: 'Include country code for international numbers.', + validation: validators.phone() as RegisterOptions, + }, + { + name: 'password', + type: 'password', + label: 'Password', + placeholder: 'Create a strong password', + validation: validators.password() as RegisterOptions, + }, + { + name: 'terms', + type: 'checkbox', + label: 'I agree to the Terms of Service and Privacy Policy', + validation: validators.checkbox('Terms of Service and Privacy Policy') as RegisterOptions, + }, +] + +export default function HostRegisterForm() { + const onSubmit = (values: Record) => { + console.log(values) + } + + return ( + + ) +} \ No newline at end of file diff --git a/components/hostPages/loginPageComponent.tsx b/components/hostPages/loginPageComponent.tsx new file mode 100644 index 0000000..ad6d581 --- /dev/null +++ b/components/hostPages/loginPageComponent.tsx @@ -0,0 +1,76 @@ +import Logo from "../headerNav/logo" +import Link from "next/link" +import { GoogleIcon, HostRegisterPageRightContent } from "./registerPageComponents" +import HostLoginForm from "../forms/hostLoginForm" + +export default function LoginPageComponent() { + return ( +
+ + +
+ ) +} + +export const HostLoginPageLeftContent = () => { + return ( +
+
+
+ + + HOST PORTAL + +
+ +
+
+

+ Welcome Back +

+ + Sign in to your host account. + +
+ +
+
+ +
+
+
+ or +
+
+
+ +
+
+ + Don't have an account? + + + Register now → + +
+
+ +
+ +
+ + © 2026 SwingRides. All rights reserved. + +
+
+
+ ) +} diff --git a/components/hostPages/registerPageComponents.tsx b/components/hostPages/registerPageComponents.tsx new file mode 100644 index 0000000..83617fd --- /dev/null +++ b/components/hostPages/registerPageComponents.tsx @@ -0,0 +1,111 @@ +import Image from "next/image" +import Logo from "../headerNav/logo" +import Link from "next/link" +import HostRegisterForm from "../forms/hostRegisterForm" + +export default function RegisterPageComponents() { + return ( +
+ + +
+ ) +} + +export const HostRegisterPageLeftContent = () => { + return ( +
+
+
+ + + HOST PORTAL + +
+ +
+
+

+ Create your account +

+ + List your vehicles and start earning with SwingRides. + +
+ +
+
+ +
+
+
+ or +
+
+
+ +
+
+ + Already have an account? + + + Sign in → + +
+
+ +
+ +
+ + © 2026 SwingRides. All rights reserved. + +
+
+
+ ) +} + +export const HostRegisterPageRightContent = () => { + return ( +
+ Swing Rides host dashboard sample image +
+ ) +} + +export const GoogleIcon = () => { + return ( + + + + + + + + + + + + + + + ) +} \ No newline at end of file From 0ab13e74f4fd035a31550f481509e6cf65d39ffb Mon Sep 17 00:00:00 2001 From: Adekoye Adewale <94379177+Adekoye-Adewale@users.noreply.github.com> Date: Sat, 23 May 2026 12:22:03 +0100 Subject: [PATCH 03/19] updated the mainForm component to receive a footer content --- components/forms/MainForm.tsx | 3 +++ components/forms/types.ts | 1 + 2 files changed, 4 insertions(+) diff --git a/components/forms/MainForm.tsx b/components/forms/MainForm.tsx index 35df3df..df135f8 100644 --- a/components/forms/MainForm.tsx +++ b/components/forms/MainForm.tsx @@ -36,6 +36,7 @@ export default function MainForm({ isLoading = false, className, rowPairs = [], + footerSlot, }: MainFormProps) { const { register, @@ -113,6 +114,8 @@ export default function MainForm({ })}
+ {footerSlot} + {/* Search */}
diff --git a/components/superAdminPages/dashboard/sidebar.tsx b/components/superAdminPages/dashboard/sidebar.tsx index 7ed5d98..afabaa1 100644 --- a/components/superAdminPages/dashboard/sidebar.tsx +++ b/components/superAdminPages/dashboard/sidebar.tsx @@ -83,7 +83,7 @@ export function SuperAdminSidebar() { function NavItem({ item }: { item: MenuItem }) { const pathname = usePathname() - const { state } = useSidebar() + const { state, setOpenMobile } = useSidebar() const isCollapsed = state === "collapsed" const isActive = pathname === item.url @@ -94,11 +94,14 @@ function NavItem({ item }: { item: MenuItem }) { const itemPadding = isCollapsed ? "px-0 justify-center" : "px-3" + const handleNavClick = () => setOpenMobile(false) + if (!hasSubMenu) { return ( {item.icon} @@ -160,6 +163,7 @@ function NavItem({ item }: { item: MenuItem }) { >
-
+
- + <> +
{["Time", "Event Type", "Entity", "Details"].map(h => ( @@ -116,6 +116,6 @@ export default function RecentActivityTable({ isLoading, rows }: RecentActivityT - + ) } \ No newline at end of file diff --git a/components/superAdminPages/pages/settingsPageComponent/emailActionSettingsPageComponent.tsx b/components/superAdminPages/pages/settingsPageComponent/emailActionSettingsPageComponent.tsx index 064fe78..fd7e386 100644 --- a/components/superAdminPages/pages/settingsPageComponent/emailActionSettingsPageComponent.tsx +++ b/components/superAdminPages/pages/settingsPageComponent/emailActionSettingsPageComponent.tsx @@ -200,7 +200,7 @@ const DataList = ({ title, label, type, emailType, handleSendEmail }: DataListPr
-
+
{title} From 0cc54e538a95306a3451b8d20992a0f2f714f11a Mon Sep 17 00:00:00 2001 From: Adekoye Adewale <94379177+Adekoye-Adewale@users.noreply.github.com> Date: Sun, 31 May 2026 18:41:41 +0100 Subject: [PATCH 05/19] Add BookingsDonutChart component Introduce a new client-side BookingsDonutChart component at components/hostComponents/charts/bookingDonutChart.tsx. Implements a reusable donut (pie) chart using @chakra-ui/charts and recharts with types BookingDonutDataItem and BookingsDonutChartProps, computes a centred total, shows a tooltip, custom sector coloring, and renders a legend with counts and rounded percentage shares. --- .../charts/bookingDonutChart.tsx | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 components/hostComponents/charts/bookingDonutChart.tsx diff --git a/components/hostComponents/charts/bookingDonutChart.tsx b/components/hostComponents/charts/bookingDonutChart.tsx new file mode 100644 index 0000000..499da4f --- /dev/null +++ b/components/hostComponents/charts/bookingDonutChart.tsx @@ -0,0 +1,100 @@ +"use client" + +import { Chart, useChart } from "@chakra-ui/charts" +import { Pie, PieChart, Sector, Tooltip } from "recharts" + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type BookingDonutDataItem = { + bookingStatus: string; + bookingCount: number; + color: string; +} + +type BookingsDonutChartProps = { + chartData: BookingDonutDataItem[] +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export default function BookingsDonutChart({ chartData }: BookingsDonutChartProps) { + const chart = useChart({ data: chartData }) + + const total = chartData.reduce((sum, item) => sum + item.bookingCount, 0) + + return ( +
+
+ + Booking Status + +
+ + {/* Donut with centred total */} +
+ + + } + /> + ( + + )} + /> + + + + {/* Centred total label */} +
+ + {total} + + + Total + +
+
+ + {/* Legend */} +
+ {chartData.map((item) => { + const pct = total > 0 ? Math.round((item.bookingCount / total) * 100) : 0 + return ( +
+
+
+ + {item.bookingStatus} + +
+
+ + {item.bookingCount} + + + {pct}% + +
+
+ ) + })} +
+
+ ) +} \ No newline at end of file From 1d39022612f8a1f1c69adc8a38ed883998784a23 Mon Sep 17 00:00:00 2001 From: Adekoye Adewale <94379177+Adekoye-Adewale@users.noreply.github.com> Date: Sun, 31 May 2026 18:42:03 +0100 Subject: [PATCH 06/19] Add RevenueChart component Introduce a new client-side RevenueChart component (components/hostComponents/charts/revenueChart.tsx). Implements a responsive Recharts area chart with 7/30/90 day filters, sample data generator (defaultGraphData), and typed props (graphData, onFilterChange, isLoading). Includes helpers for formatting, Y-axis ticks, tick thinning, trend calculation, a custom tooltip, and a trend badge; UI uses Tailwind classes and exports relevant types. --- .../hostComponents/charts/revenueChart.tsx | 306 ++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 components/hostComponents/charts/revenueChart.tsx diff --git a/components/hostComponents/charts/revenueChart.tsx b/components/hostComponents/charts/revenueChart.tsx new file mode 100644 index 0000000..237c8e4 --- /dev/null +++ b/components/hostComponents/charts/revenueChart.tsx @@ -0,0 +1,306 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { + AreaChart, + Area, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + CartesianGrid, +} from "recharts"; +import { TrendingUp, TrendingDown } from "lucide-react"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type FilterType = "7D" | "30D" | "90D"; + +export type GraphDataPoint = { + sales: number; + /** ISO date string e.g. "2025-05-01" or short label "May 1" */ + label: string; +}; + +export type GraphDataType = { + "7D": GraphDataPoint[]; + "30D": GraphDataPoint[]; + "90D": GraphDataPoint[]; + series: { + name: string; + color: string; + }[]; +}; + +// ─── Default / sample data ──────────────────────────────────────────────────── + +function buildDays(count: number, seed = 12_000): GraphDataPoint[] { + const now = new Date(); + return Array.from({ length: count }, (_, i) => { + const d = new Date(now); + d.setDate(d.getDate() - (count - 1 - i)); + const label = + count <= 7 + ? d.toLocaleDateString("en-US", { weekday: "short" }) // Mon, Tue… + : count <= 30 + ? d.toLocaleDateString("en-US", { month: "short", day: "numeric" }) // May 1 + : d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + const sales = Math.max( + 0, + seed + + Math.round(Math.sin(i * 0.7) * seed * 0.4) + + Math.round(Math.random() * seed * 0.3), + ); + return { label, sales }; + }); +} + +export const defaultGraphData: GraphDataType = { + "7D": buildDays(7, 14_000), + "30D": buildDays(30, 22_000), + "90D": buildDays(90, 18_000), + series: [{ name: "Revenue", color: "#1A56DB" }], +}; + +// ─── Props exposed to parent (for data fetching) ────────────────────────────── + +export type RevenueChartProps = { + /** Structured data keyed by filter window. Supply from your server/API fetch. */ + graphData?: GraphDataType; + /** Called whenever the user changes the time filter so the parent can re-fetch. */ + onFilterChange?: (filter: FilterType) => void; + /** Whether new data is loading (shows a subtle pulse on the chart). */ + isLoading?: boolean; +}; + +// ─── Filter options ─────────────────────────────────────────────────────────── + +const FILTER_OPTIONS: { label: string; value: FilterType }[] = [ + { label: "7 days", value: "7D" }, + { label: "30 days", value: "30D" }, + { label: "90 days", value: "90D" }, +]; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function getYTicks(maxVal: number): number[] { + const safeMax = maxVal === 0 ? 100 : maxVal; + const step = safeMax / 4; + return [0, 1, 2, 3, 4].map((i) => Math.round(step * i)); +} + +function formatSales(value: number): string { + if (value === 0) return "$0"; + if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(1)}M`; + if (value >= 1_000) return `$${Math.round(value / 1_000)}K`; + return `$${value}`; +} + +/** Percentage change from first → last point */ +function getTrend(data: GraphDataPoint[]): number | null { + if (data.length < 2) return null; + const first = data[0].sales; + const last = data[data.length - 1].sales; + if (first === 0) return null; + return ((last - first) / first) * 100; +} + +// ─── X-axis tick thinning ───────────────────────────────────────────────────── + +function getTickInterval(dataLength: number): number { + if (dataLength <= 7) return 0; // show every tick + if (dataLength <= 30) return 4; // ~every 5th + return 9; // ~every 10th +} + +// ─── Custom Tooltip ─────────────────────────────────────────────────────────── + +function CustomTooltip({ + active, + payload, + label, +}: { + active?: boolean; + payload?: { value: number }[]; + label?: string; +}) { + if (!active || !payload?.length) return null; + return ( +
+

+ {label} +

+

+ {formatSales(payload[0].value)} +

+
+ ); +} + +// ─── Trend badge ────────────────────────────────────────────────────────────── + +function TrendBadge({ trend }: { trend: number | null }) { + if (trend === null) return null; + const up = trend >= 0; + return ( +
+ {up ? : } + {up ? "+" : ""} + {trend.toFixed(1)}% +
+ ); +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +export default function RevenueChart({ + graphData = defaultGraphData, + onFilterChange, + isLoading = false, +}: RevenueChartProps) { + const [activeFilter, setActiveFilter] = useState("30D"); + + const handleFilterChange = (f: FilterType) => { + setActiveFilter(f); + onFilterChange?.(f); + }; + + const filteredData = useMemo( + () => graphData[activeFilter] ?? [], + [activeFilter, graphData], + ); + + const maxVal = useMemo( + () => Math.max(...filteredData.map((d) => d.sales), 0), + [filteredData], + ); + + const yTicks = useMemo(() => getYTicks(maxVal), [maxVal]); + const trend = useMemo(() => getTrend(filteredData), [filteredData]); + const tickInterval = getTickInterval(filteredData.length); + + // Summary stats + const totalRevenue = useMemo( + () => filteredData.reduce((sum, d) => sum + d.sales, 0), + [filteredData], + ); + const avgDaily = filteredData.length + ? Math.round(totalRevenue / filteredData.length) + : 0; + + return ( +
+ {/* ── Header ── */} +
+
+
+ + Revenue Overview + +
+ + {formatSales(totalRevenue)} + + +
+
+ + Avg {formatSales(avgDaily)} / day + +
+ + {/* Filter pill group */} +
+ {FILTER_OPTIONS.map(({ label, value }) => ( + + ))} +
+
+ + {/* ── Chart ── */} + + + + + + + + + + + + + + + + } + cursor={{ stroke: "#D1D5DB", strokeWidth: 1, strokeDasharray: "4 3" }} + /> + + + + +
+ ); +} \ No newline at end of file From fbf1f7eeb8ec167a6c93b7905199bb00534776aa Mon Sep 17 00:00:00 2001 From: Adekoye Adewale <94379177+Adekoye-Adewale@users.noreply.github.com> Date: Sun, 31 May 2026 18:43:18 +0100 Subject: [PATCH 07/19] Add host dashboard header and sidebar components Introduce host dashboard UI pieces: a responsive Header (search, notifications with filters, mark-all-read, avatar/logout) and a Collapsible Sidebar (menu groups, submenu animations, user card, collapse trigger). Add notification types/constants and helper icons, TypeScript types for navbar notifications, and sidebar/content constants (menu items and user stub). Includes sample notification data and uses existing UI primitives (Popover, Select, Sidebar) and motion for animations. --- .../hostComponents/dashboard/header.tsx | 492 ++++++++++++++++++ .../hostComponents/dashboard/sidebar.tsx | 267 ++++++++++ .../hostComponents/types/navbar.type.ts | 23 + components/hostComponents/utils/helper.tsx | 36 ++ constants/constant.ts | 5 + constants/hostSidebar.tsx | 81 +++ 6 files changed, 904 insertions(+) create mode 100644 components/hostComponents/dashboard/header.tsx create mode 100644 components/hostComponents/dashboard/sidebar.tsx create mode 100644 components/hostComponents/types/navbar.type.ts create mode 100644 components/hostComponents/utils/helper.tsx create mode 100644 constants/constant.ts create mode 100644 constants/hostSidebar.tsx diff --git a/components/hostComponents/dashboard/header.tsx b/components/hostComponents/dashboard/header.tsx new file mode 100644 index 0000000..84a30e6 --- /dev/null +++ b/components/hostComponents/dashboard/header.tsx @@ -0,0 +1,492 @@ +"use client" + +import { getInitials } from "@/components/pages/profilePages/utils" +import { Bell, ChevronDown, LogOut, Search, X, Menu } from "lucide-react" +import Image from "next/image" +import { userContent } from "@/constants/hostSidebar" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { Close as PopoverClose } from "@radix-ui/react-popover" +import { useSidebar } from "@/components/ui/sidebar" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import Link from "next/link" +import { HeaderAvatarProps, HostNotificationType, NotificationCardProps, NotificationGroupsType } from "../types/navbar.type" +import { HOST_NOTIFICATION_TYPE_CONST } from "../utils/helper" +import { Fragment, useState } from "react" + +// --------------------------------------------------------------------------- +// Sample data +// --------------------------------------------------------------------------- + +const initialTodayNotifications: NotificationCardProps[] = [ + { + id: "1", + title: "New Booking Request", + unread: true, + description: "John Doe booked Toyota Camry for 3 days.", + time: "2 minutes ago", + notificationType: "newBooking", + }, + { + id: "2", + title: "Payment Received", + unread: true, + description: "Payment of $450 received for booking #B-1021.", + time: "15 minutes ago", + notificationType: "paymentReceived", + }, + { + id: "3", + title: "Check-In Alert", + unread: true, + description: "Sarah Williams checked in for booking #B-1018.", + time: "1 hour ago", + notificationType: "checkInOut", + }, + { + id: "4", + title: "Maintenance Alert", + unread: false, + description: "Honda Civic (LKJ-234) is due for an oil change.", + time: "2 hours ago", + notificationType: "maintenanceAlert", + }, + { + id: "5", + title: "New Message", + unread: true, + description: "Customer Mike asked about insurance coverage.", + time: "3 hours ago", + notificationType: "communication", + }, + { + id: "6", + title: "New Booking Request", + unread: false, + description: "Emma Clarke booked Tesla Model 3 for 2 days.", + time: "4 hours ago", + notificationType: "newBooking", + }, + { + id: "7", + title: "Check-Out Alert", + unread: false, + description: "David Obi completed check-out for booking #B-1017.", + time: "5 hours ago", + notificationType: "checkInOut", + }, +] + +const initialEarlierNotifications: NotificationCardProps[] = [ + { + id: "8", + title: "New Message", + unread: false, + description: "You have a new message from a customer.", + time: "Yesterday, 4:30 PM", + notificationType: "communication", + }, + { + id: "9", + title: "Payment Received", + unread: false, + description: "Payment of $320 received for booking #B-1015.", + time: "Yesterday, 11:00 AM", + notificationType: "paymentReceived", + }, + { + id: "10", + title: "New Booking Request", + unread: false, + description: "Alex Johnson booked Ford Explorer for 5 days.", + time: "2 days ago", + notificationType: "newBooking", + }, + { + id: "11", + title: "Maintenance Alert", + unread: false, + description: "BMW X5 (ABJ-501) brake pads need replacement.", + time: "2 days ago", + notificationType: "maintenanceAlert", + }, + { + id: "12", + title: "Payment Received", + unread: false, + description: "Payment of $780 received for booking #B-1012.", + time: "3 days ago", + notificationType: "paymentReceived", + }, + { + id: "13", + title: "New Message", + unread: false, + description: "Grace Nwosu asked about extended rental options.", + time: "3 days ago", + notificationType: "communication", + }, + { + id: "14", + title: "Check-In Alert", + unread: false, + description: "Tunde Bello checked in for booking #B-1010.", + time: "4 days ago", + notificationType: "checkInOut", + }, + { + id: "15", + title: "New Booking Request", + unread: false, + description: "Fatima Yusuf booked Hyundai Tucson for 7 days.", + time: "5 days ago", + notificationType: "newBooking", + }, +] + +// --------------------------------------------------------------------------- +// Type map: select value → notification types that belong to that filter +// --------------------------------------------------------------------------- + +type FilterValue = NotificationGroupsType["value"] | "communication" | "all" + +const TAB_TYPE_MAP: Record, HostNotificationType[]> = { + bookings: ["newBooking"], + payments: ["paymentReceived"], + maintenance: ["maintenanceAlert"], + "check-in-out": ["checkInOut"], + communication: ["communication"], +} + +// --------------------------------------------------------------------------- +// DashboardHeader +// --------------------------------------------------------------------------- + +export function DashboardHeader() { + + const { toggleSidebar } = useSidebar() + + const [todayNotifications, setTodayNotifications] = useState(initialTodayNotifications) + const [earlierNotifications, setEarlierNotifications] = useState(initialEarlierNotifications) + + const unreadNotifications = [...todayNotifications, ...earlierNotifications].filter((n) => n.unread) + + const handleMarkAllAsRead = () => { + setTodayNotifications((prev) => prev.map((n) => ({ ...n, unread: false }))) + setEarlierNotifications((prev) => prev.map((n) => ({ ...n, unread: false }))) + } + + return ( +
+ + {/* Mobile-only hamburger */} + + {/* Search */} +
+ + +
+ +
+ + +
+
+ ) +} + +// --------------------------------------------------------------------------- +// Notification +// --------------------------------------------------------------------------- + +type NotificationProps = { + unread: NotificationCardProps[] + today: NotificationCardProps[] + earlier: NotificationCardProps[] + onMarkAllAsRead: () => void +} + +const Notification = ({ unread, today, earlier, onMarkAllAsRead }: NotificationProps) => { + return ( + + + + + +
+ {/* Header row */} +
+
+ + Notifications + + {unread.length > 0 && ( +
+ + {unread.length} + +
+ )} +
+ +
+ + + + +
+
+ + {/* Filter + scrollable content */} + +
+
+
+ ) +} + +// --------------------------------------------------------------------------- +// Notification filter groups +// --------------------------------------------------------------------------- + +const notificationGroups: { label: string; value: FilterValue }[] = [ + { label: "All", value: "all" }, + { label: "Bookings", value: "bookings" }, + { label: "Payments", value: "payments" }, + { label: "Maintenance", value: "maintenance" }, + { label: "Check-in/out", value: "check-in-out" }, + { label: "Communication", value: "communication" }, +] + +type NotificationPanelProps = { + today: NotificationCardProps[] + earlier: NotificationCardProps[] +} + +const NotificationPanel = ({ today, earlier }: NotificationPanelProps) => { + const [filter, setFilter] = useState("all") + + const applyFilter = (list: NotificationCardProps[]) => { + if (filter === "all") return list + const allowedTypes = TAB_TYPE_MAP[filter] + return list.filter((n) => allowedTypes.includes(n.notificationType)) + } + + const filteredToday = applyFilter(today) + const filteredEarlier = applyFilter(earlier) + + return ( +
+ {/* Select filter */} +
+ +
+ + {/* Scrollable notification list — 700px desktop, 450px mobile */} +
+ +
+
+ ) +} + +// --------------------------------------------------------------------------- +// Notification list content +// --------------------------------------------------------------------------- + +type NotificationTabContentProps = { + today: NotificationCardProps[] + earlier: NotificationCardProps[] +} + +const NotificationTabContent = ({ today, earlier }: NotificationTabContentProps) => { + const hasToday = today.length > 0 + const hasEarlier = earlier.length > 0 + + if (!hasToday && !hasEarlier) { + return ( +

+ No notifications +

+ ) + } + + return ( +
+ {hasToday && ( +
+
+

+ Today +

+
+
+ {today.map((item) => ( + + + + ))} +
+
+ )} + + {hasEarlier && ( +
+
+

+ Earlier +

+
+
+ {earlier.map((item) => ( + + + + ))} +
+
+ )} +
+ ) +} + +// --------------------------------------------------------------------------- +// Notification card +// --------------------------------------------------------------------------- + +const NotificationCard = ({ id, title, unread, description, time, notificationType }: NotificationCardProps) => { + const notificationObject = HOST_NOTIFICATION_TYPE_CONST[notificationType] + + const icon = notificationObject?.icon ?? ( +
+ +
+ ) + const slug = notificationObject?.slug ?? "#" + + return ( + +
+
{icon}
+
+
+

+ {title} +

+ {unread && ( +
+ )} +
+ + {description} + + + {time} + +
+
+ + ) +} + +// --------------------------------------------------------------------------- +// Header avatar +// --------------------------------------------------------------------------- + +const HeaderAvatar = ({ user }: HeaderAvatarProps) => { + const userInitials = getInitials(user.fullname) + + const handleLogout = () => { + console.log("user logout") + } + + return ( +
+
+ {user.avatar ? ( + + ) : ( + userInitials + )} +
+ + + + + + + + +
+ ) +} \ No newline at end of file diff --git a/components/hostComponents/dashboard/sidebar.tsx b/components/hostComponents/dashboard/sidebar.tsx new file mode 100644 index 0000000..6ef9721 --- /dev/null +++ b/components/hostComponents/dashboard/sidebar.tsx @@ -0,0 +1,267 @@ +"use client" + +import Logo from "@/components/headerNav/logo" +import { getInitials } from "@/components/pages/profilePages/utils" +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarHeader, + SidebarGroupLabel, + SidebarGroupContent, + SidebarMenu, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubItem, + useSidebar, +} from "@/components/ui/sidebar" +import Image from "next/image" +import Link from "next/link" +import { usePathname } from "next/navigation" +import { ChevronRight, PanelLeftClose, PanelLeftOpen } from "lucide-react" +import { useState } from "react" +import { sidebarContent, userContent } from "@/constants/hostSidebar" +import { AnimatePresence, motion } from "motion/react" + +const linkBase = [ + "flex items-center gap-3 py-2 px-3 w-full rounded-[10px]", + "border-l-4 transition-all duration-200", + "text-sm font-medium leading-5", + "[&_svg]:transition-colors [&_svg]:duration-200 [&_svg]:size-5 [&_svg]:shrink-0", +].join(" ") + +const linkInactive = [ + "border-l-transparent text-gray-500 [&_svg]:text-gray-400", + "hover:border-l-blue-700 hover:bg-indigo-50 hover:text-gray-900 hover:[&_svg]:text-blue-700", +].join(" ") + +const linkActive = [ + "border-l-blue-700 bg-indigo-50 text-blue-700 [&_svg]:text-blue-700", +].join(" ") + + +type MenuItem = { + icon: React.ReactNode + label: string + url: string + subMenu?: Omit[] +} + +export function HostSidebar() { + return ( + + + + + + + {sidebarContent.map((section) => ( + + + {section.title} + + + + {section.menu.map((item) => ( + + ))} + + + + ))} + + + + + + + + ) +} + +function NavItem({ item }: { item: MenuItem }) { + const pathname = usePathname() + const { state } = useSidebar() + const isCollapsed = state === "collapsed" + + const isActive = pathname === `/us/host${item.url}` + const isDashboardPage = pathname === "/us/host" + const isCurrentItemActive = isActive || (isDashboardPage && item.url === "/") + + const hasSubMenu = Boolean(item.subMenu?.length) + const isSubMenuActive = hasSubMenu && item.subMenu!.some((sub) => pathname === sub.url) + + const [isOpen, setIsOpen] = useState(isSubMenuActive) + + const itemPadding = isCollapsed ? "px-0 justify-center" : "px-3" + + if (!hasSubMenu) { + return ( + + + {item.icon} + {!isCollapsed && {item.label}} + + + ) + } + + return ( + + + + + {isOpen && !isCollapsed && ( + + + {item.subMenu!.map((subItem, i) => { + const subIsActive = pathname === subItem.url + return ( + + + + {subItem.icon} + {subItem.label} + + + + ) + })} + + + )} + + + ) +} + +const LogoCard = () => { + const { state } = useSidebar() + return ( +
+ +
+ ) +} + +type UserCardProps = { + user: { + fullname: string + avatar?: string + role: string + } +} + +const UserCard = ({ user }: UserCardProps) => { + const { state } = useSidebar() + const isCollapsed = state === "collapsed" + const userInitials = getInitials(user.fullname) + + return ( +
+
+ {user.avatar ? ( + + ) : ( + userInitials + )} +
+ + {!isCollapsed && ( +
+ + {user.fullname} + + + {user.role} + +
+ )} +
+ ) +} + +const SidebarTriggerButton = () => { + const { toggleSidebar, state } = useSidebar() + const isCollapsed = state === "collapsed" + + return ( + + ) +} \ No newline at end of file diff --git a/components/hostComponents/types/navbar.type.ts b/components/hostComponents/types/navbar.type.ts new file mode 100644 index 0000000..689e8f5 --- /dev/null +++ b/components/hostComponents/types/navbar.type.ts @@ -0,0 +1,23 @@ + +export type HostNotificationType = "newBooking" | "paymentReceived" | "maintenanceAlert" | "checkInOut" | "communication"; + +export type NotificationGroupsType = { + value: "bookings" | "payments" | "maintenance" | "check-in-out"; + label: string; +} + +export type NotificationCardProps = { + id: string; + title: string; + unread: boolean; + description: string; + time: string; + notificationType: HostNotificationType; +} + +export type HeaderAvatarProps = { + user: { + fullname: string + avatar?: string + } +} diff --git a/components/hostComponents/utils/helper.tsx b/components/hostComponents/utils/helper.tsx new file mode 100644 index 0000000..4f0b135 --- /dev/null +++ b/components/hostComponents/utils/helper.tsx @@ -0,0 +1,36 @@ +import { ReactNode } from "react"; +import { HostNotificationType } from "../types/navbar.type"; +import { Calendar, CheckCircle2, LogIn, Send, Wrench } from "lucide-react"; + +export const HOST_NOTIFICATION_TYPE_CONST: Record = { + "newBooking": { + icon:
+ +
, + slug: "/us/host/bookings", + }, + "paymentReceived": { + icon:
+ +
, + slug: "/us/host/bookings", + }, + "maintenanceAlert": { + icon:
+ +
, + slug: "/us/host/maintenance", + }, + "checkInOut": { + icon:
+ +
, + slug: "/us/host/bookings", + }, + "communication": { + icon:
+ +
, + slug: "/us/host/settings/?settingsTab=communicate", + }, +} diff --git a/constants/constant.ts b/constants/constant.ts new file mode 100644 index 0000000..ec88b63 --- /dev/null +++ b/constants/constant.ts @@ -0,0 +1,5 @@ +export const SITE_URL = "https://swingrides.com" + +export const SUPER_ADMIN_DASHBOARD_PATH = `/admin/` + +export const HOST_DASHBOARD_PATH = `/us/host/` \ No newline at end of file diff --git a/constants/hostSidebar.tsx b/constants/hostSidebar.tsx new file mode 100644 index 0000000..87abad0 --- /dev/null +++ b/constants/hostSidebar.tsx @@ -0,0 +1,81 @@ +import { BarChart3, Calendar, Car, FileExclamationPoint, Receipt, Settings, Star, Wrench } from "lucide-react" + +export const userContent = { + fullname: 'Metro Rentals', + role: 'Host Account', +} + +export const sidebarContent = [ + { + title: 'Main', + menu: [ + { + icon: ( + + + + + + + ), + label: 'Dashboard', + url: '/' + }, + { + icon: (), + label: 'Fleet', + url: '/fleet' + }, + { + icon: (), + label: 'Bookings', + url: '/bookings' + }, + ] + }, + { + title: 'OPERATIONS', + menu: [ + { + icon: (), + label: 'Maintenance', + url: '/maintenance' + }, + { + icon: (), + label: 'Expenses', + url: '/expenses' + } + ] + }, + { + title: 'MANAGEMENT', + menu: [ + { + icon: ( ), + label: 'Reports', + url: '/reports', + }, + { + icon: (), + label: 'Reviews', + url: '/reviews' + } + ] + }, + { + title: 'SYSTEM', + menu: [ + { + icon: ( ), + label: 'Settings', + url: '/settings', + }, + { + icon: (), + label: 'Report an Issue', + url: '/report-an-issue' + } + ] + }, +] \ No newline at end of file From 7ebb06a3aa222291203e6d6a01df3390ff6786a4 Mon Sep 17 00:00:00 2001 From: Adekoye Adewale <94379177+Adekoye-Adewale@users.noreply.github.com> Date: Sun, 31 May 2026 18:43:57 +0100 Subject: [PATCH 08/19] Add dashboard dynamic import and sample chart data Introduce a client-only dynamic import for BookingsDonutChart (with a Skeleton loading fallback and ssr disabled) and add sample chart data constants. constants/saleschartdata.ts provides helper functions for date labels and exports sampleRevenueGraphData (7D/30D/90D series) and sampleBookingDonutData for use by dashboard charts. --- .../dashboard/dynamicImport.tsx | 12 +++ constants/saleschartdata.ts | 83 +++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 components/hostComponents/dashboard/dynamicImport.tsx create mode 100644 constants/saleschartdata.ts diff --git a/components/hostComponents/dashboard/dynamicImport.tsx b/components/hostComponents/dashboard/dynamicImport.tsx new file mode 100644 index 0000000..3323a41 --- /dev/null +++ b/components/hostComponents/dashboard/dynamicImport.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { Skeleton } from "@/components/ui/skeleton"; +import dynamic from "next/dynamic"; + +export const BookingsDonutChart = dynamic( + () => import("@/components/hostComponents/charts/bookingDonutChart"), + { + ssr: false, + loading: () => , + }, +); \ No newline at end of file diff --git a/constants/saleschartdata.ts b/constants/saleschartdata.ts new file mode 100644 index 0000000..0db344e --- /dev/null +++ b/constants/saleschartdata.ts @@ -0,0 +1,83 @@ +import { BookingDonutDataItem } from "@/components/hostComponents/charts/bookingDonutChart"; +import { GraphDataType, GraphDataPoint } from "@/components/hostComponents/charts/revenueChart"; +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function daysAgo(n: number): string { + const d = new Date(); + d.setDate(d.getDate() - n); + return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); +} + +function weekdayAgo(n: number): string { + const d = new Date(); + d.setDate(d.getDate() - n); + return d.toLocaleDateString("en-US", { weekday: "short" }); +} + +// ─── 7-day window (last 7 days, labelled Mon–Sun) ──────────────────────────── + +const sevenDayData: GraphDataPoint[] = [ + { label: weekdayAgo(6), sales: 18_200 }, + { label: weekdayAgo(5), sales: 11_800 }, + { label: weekdayAgo(4), sales: 16_100 }, + { label: weekdayAgo(3), sales: 23_450 }, + { label: weekdayAgo(2), sales: 15_900 }, + { label: weekdayAgo(1), sales: 21_300 }, + { label: weekdayAgo(0), sales: 27_750 }, +]; + +// ─── 30-day window (sampled every ~3 days for readability) ─────────────────── + +const thirtyDayData: GraphDataPoint[] = [ + { label: daysAgo(29), sales: 19_800 }, + { label: daysAgo(27), sales: 22_400 }, + { label: daysAgo(25), sales: 28_100 }, + { label: daysAgo(23), sales: 36_600 }, + { label: daysAgo(21), sales: 33_200 }, + { label: daysAgo(19), sales: 24_800 }, + { label: daysAgo(17), sales: 19_400 }, + { label: daysAgo(15), sales: 14_300 }, + { label: daysAgo(13), sales: 17_100 }, + { label: daysAgo(11), sales: 18_500 }, + { label: daysAgo(9), sales: 19_200 }, + { label: daysAgo(7), sales: 22_800 }, + { label: daysAgo(5), sales: 11_800 }, + { label: daysAgo(3), sales: 23_450 }, + { label: daysAgo(1), sales: 21_300 }, + { label: daysAgo(0), sales: 27_750 }, +]; + +// ─── 90-day window (sampled weekly) ────────────────────────────────────────── + +const ninetyDayData: GraphDataPoint[] = [ + { label: daysAgo(90), sales: 12_200 }, + { label: daysAgo(83), sales: 18_900 }, + { label: daysAgo(76), sales: 28_100 }, + { label: daysAgo(69), sales: 21_300 }, + { label: daysAgo(62), sales: 17_600 }, + { label: daysAgo(55), sales: 13_800 }, + { label: daysAgo(48), sales: 9_500 }, + { label: daysAgo(41), sales: 15_200 }, + { label: daysAgo(34), sales: 19_700 }, + { label: daysAgo(27), sales: 22_400 }, + { label: daysAgo(20), sales: 30_800 }, + { label: daysAgo(13), sales: 17_100 }, + { label: daysAgo(6), sales: 18_200 }, + { label: daysAgo(0), sales: 27_750 }, +]; + +// ─── Exported sample data ───────────────────────────────────────────────────── + +export const sampleRevenueGraphData: GraphDataType = { + "7D": sevenDayData, + "30D": thirtyDayData, + "90D": ninetyDayData, + series: [{ name: "Revenue", color: "#1A56DB" }], +}; + +export const sampleBookingDonutData: BookingDonutDataItem[] = [ + { bookingStatus: "Active", bookingCount: 42, color: "#1A56DB" }, + { bookingStatus: "Pending", bookingCount: 18, color: "#F59E0B" }, + { bookingStatus: "Completed", bookingCount: 31, color: "#10B981" }, + { bookingStatus: "Cancelled", bookingCount: 8, color: "#EF4444" }, +] \ No newline at end of file From 14fa7d57cef7b3f0916369233277a2e36cb78f60 Mon Sep 17 00:00:00 2001 From: Adekoye Adewale <94379177+Adekoye-Adewale@users.noreply.github.com> Date: Sun, 31 May 2026 18:45:01 +0100 Subject: [PATCH 09/19] Add host dashboard layout and components Introduce host dashboard layout and page components. Adds app/(host)/us/host/layout.tsx which provides SidebarProvider, ChakraUIProvider and TooltipProvider, renders HostSidebar and DashboardHeader, reads the sidebar_state cookie, and exports page metadata. Adds PageWrapper (components/hostComponents/dashboard/pageWrapper.tsx) for consistent page headings and layout. Adds a client DashboardPageComponent (components/hostComponents/pages/dashboardPageComponents/index.tsx) containing overview cards, revenue chart, bookings donut, recent bookings table and fleet status UI (uses sample data and dynamic imports). Update app/(host)/us/host/page.tsx to render the new DashboardPageComponent. --- app/(host)/us/host/layout.tsx | 36 ++++ app/(host)/us/host/page.tsx | 5 +- .../hostComponents/dashboard/pageWrapper.tsx | 29 +++ .../pages/dashboardPageComponents/index.tsx | 189 ++++++++++++++++++ 4 files changed, 256 insertions(+), 3 deletions(-) create mode 100644 app/(host)/us/host/layout.tsx create mode 100644 components/hostComponents/dashboard/pageWrapper.tsx create mode 100644 components/hostComponents/pages/dashboardPageComponents/index.tsx diff --git a/app/(host)/us/host/layout.tsx b/app/(host)/us/host/layout.tsx new file mode 100644 index 0000000..7729411 --- /dev/null +++ b/app/(host)/us/host/layout.tsx @@ -0,0 +1,36 @@ +import { cookies } from "next/headers"; +import { SidebarProvider } from "@/components/ui/sidebar"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import type { Metadata } from "next"; +import "@/app/globals.css"; +import { HostSidebar } from "@/components/hostComponents/dashboard/sidebar"; +import { DashboardHeader } from "@/components/hostComponents/dashboard/header"; +import { ChakraUIProvider } from "@/components/providers/chakraProvider"; + +export const metadata: Metadata = { + title: "Swing Rides Host Dashboard", + description: "Drive your fleet. Own your data.", +}; + +export default async function HostDashboardLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + const cookieStore = await cookies(); + const sidebarOpen = cookieStore.get("sidebar_state")?.value !== "false"; + + return ( + + + + +
+ + {children} +
+
+
+
+ ); +} diff --git a/app/(host)/us/host/page.tsx b/app/(host)/us/host/page.tsx index d0b8865..52bb490 100644 --- a/app/(host)/us/host/page.tsx +++ b/app/(host)/us/host/page.tsx @@ -1,9 +1,8 @@ +import DashboardPageComponent from '@/components/hostComponents/pages/dashboardPageComponents' import React from 'react' export default function HostDashboard() { return ( -
- HOST DASHBOARD -
+ ) } diff --git a/components/hostComponents/dashboard/pageWrapper.tsx b/components/hostComponents/dashboard/pageWrapper.tsx new file mode 100644 index 0000000..fbb5c52 --- /dev/null +++ b/components/hostComponents/dashboard/pageWrapper.tsx @@ -0,0 +1,29 @@ +import { ReactNode } from "react"; +import PageIntro from "@/components/superAdminPages/dashboard/pageIntro"; + +type PageWrapperProps = { + pageTitle: string; + pageDescription: string; + pageButton?: ReactNode + children: ReactNode +} + +export default function PageWrapper({ pageTitle, pageDescription, pageButton, children }: PageWrapperProps) { + return ( +
+
+
+ + {pageButton &&
+ {pageButton} +
} +
+ + {children} +
+
+ ) +} diff --git a/components/hostComponents/pages/dashboardPageComponents/index.tsx b/components/hostComponents/pages/dashboardPageComponents/index.tsx new file mode 100644 index 0000000..3fb18b0 --- /dev/null +++ b/components/hostComponents/pages/dashboardPageComponents/index.tsx @@ -0,0 +1,189 @@ +"use client" + +import { Calendar, Car, Clock, DollarSign, TrendingDown, TrendingUp, Wrench } from "lucide-react"; +import PageWrapper from "../../dashboard/pageWrapper"; +import { ReactNode } from "react"; +import RevenueChart, { FilterType } from "../../charts/revenueChart"; +import { sampleBookingDonutData, sampleRevenueGraphData } from "@/constants/saleschartdata"; +import { BookingsDonutChart } from "../../dashboard/dynamicImport"; +import { Separator } from "@/components/ui/separator"; +import TestingTable from "../../dashboard/testingTable"; +import RecentBookingsTable from "./recentBookingsTable"; + +export default function DashboardPageComponent() { + + const handleFilterChange = (filter: FilterType) => { + // swap sampleMrrData for a real fetch/SWR call keyed by filter + console.log("Fetch data for window:", filter); + }; + + return ( + +
+
+ } + iconBgColor="bg-indigo-50" + title="Total Vehicles" + number={59} + trend={true} + trendText="+12% from last month" + /> + } + iconBgColor="bg-indigo-50" + title="Active Rentals" + number={42} + trend={false} + trendText="71% utilization rate" + /> + } + iconBgColor="bg-emerald-100" + title="Monthly Revenue" + number={'$24,500'} + trend={true} + trendText="+24% from last month" + /> + } + iconBgColor="bg-amber-100" + title="Pending Bookings" + number={18} + trend={false} + trendText="Requires approval" + /> +
+
+
+ +
+
+ +
+
+
+
+ +
+
+
+

+ Fleet Status +

+
+ } + iconBg={'bg-green-100'} + label={"Available"} + number={12} + /> + } + iconBg={'bg-indigo-50'} + label={"Available"} + number={42} + /> + } + iconBg={'bg-amber-100'} + label={"Available"} + number={3} + /> + } + iconBg={'bg-gray-100'} + label={"Inactive"} + number={2} + /> +
+ +
+
+ + Total Fleet + + + {'59'} vehicles + +
+
+
+
+
+
+
+ ) +} + +type DashboardOverviewCardProps = { + icon: ReactNode; + iconBgColor?: string + title: string; + number: string | number; + trend: boolean; + trendText: string; +} + +const DashboardOverviewCard = ({ icon, iconBgColor, title, number, trend, trendText }: DashboardOverviewCardProps ) => { + return ( +
+
+ {icon} +
+
+ + {title} + +
+
+ + {number} + +
+
+
+ {trend ? : } + + {trendText} + +
+
+
+ ) +} + +type FleetDataListProps = { + icon: ReactNode; + iconBg: string; + label: string; + number:number; +} + +const FleetDataList = ({ icon, iconBg="bg-green-100", label, number }: FleetDataListProps) => { + return ( +
+
+
+ {icon} +
+ + {label} + +
+
+ + {number} + +
+
+ ) +} \ No newline at end of file From fb680d57fe5695838facee8cc5698684f4f14ac9 Mon Sep 17 00:00:00 2001 From: Adekoye Adewale <94379177+Adekoye-Adewale@users.noreply.github.com> Date: Sun, 31 May 2026 18:45:08 +0100 Subject: [PATCH 10/19] Update subscriberDetailPage.tsx --- components/superAdminPages/pages/subscriberDetailPage.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/components/superAdminPages/pages/subscriberDetailPage.tsx b/components/superAdminPages/pages/subscriberDetailPage.tsx index 153aada..339c948 100644 --- a/components/superAdminPages/pages/subscriberDetailPage.tsx +++ b/components/superAdminPages/pages/subscriberDetailPage.tsx @@ -354,14 +354,6 @@ function DataTable(props: DataTableVariant & SlugType) { const pathname = usePathname(); const searchParams = useSearchParams(); - // // ── Fake loading ────────────────────────────────────────────────────── - // const [isLoading, setIsLoading] = useState(true); - - // useEffect(() => { - // const timer = setTimeout(() => setIsLoading(false), FAKE_LOAD_MS); - // return () => clearTimeout(timer); - // }, []); - // ── URL param helper ────────────────────────────────────────────────── // Each tab uses its own page param key to avoid collisions const pageKey = `${dataType.replace(" ", "_").toLowerCase()}_page`; From 91493944f0e6205db53262cf13aa996333c7b5e9 Mon Sep 17 00:00:00 2001 From: Adekoye Adewale <94379177+Adekoye-Adewale@users.noreply.github.com> Date: Sun, 31 May 2026 19:53:03 +0100 Subject: [PATCH 11/19] Add reusable DataTable and dashboard components Introduce a reusable client-side DataTable with toolbar, search, select/date filters, pagination, CSV export, and built-in edit/delete dialogs (components/hostComponents/dashboard/customTable.tsx). Add a small CardIntro component for dashboard headings (components/hostComponents/dashboard/cardIntro.tsx). Add RecentBookingsTable as an example consumer using useTableRows with mock data and the new DataTable (components/hostComponents/pages/dashboardPageComponents/recentBookingsTable.tsx). The table uses namespaced URL params for state (search, filters, sort, page) to enable bookmarkable/filterable tables. --- .../hostComponents/dashboard/cardIntro.tsx | 18 + .../hostComponents/dashboard/customTable.tsx | 885 ++++++++++++++++++ .../recentBookingsTable.tsx | 126 +++ 3 files changed, 1029 insertions(+) create mode 100644 components/hostComponents/dashboard/cardIntro.tsx create mode 100644 components/hostComponents/dashboard/customTable.tsx create mode 100644 components/hostComponents/pages/dashboardPageComponents/recentBookingsTable.tsx diff --git a/components/hostComponents/dashboard/cardIntro.tsx b/components/hostComponents/dashboard/cardIntro.tsx new file mode 100644 index 0000000..097ab25 --- /dev/null +++ b/components/hostComponents/dashboard/cardIntro.tsx @@ -0,0 +1,18 @@ + +type CardIntroProps = { + title: string; + desc: string; +} + +export const CardIntro = ({ title, desc }: CardIntroProps) => { + return ( +
+

+ {title} +

+ + {desc} + +
+ ) +} \ No newline at end of file diff --git a/components/hostComponents/dashboard/customTable.tsx b/components/hostComponents/dashboard/customTable.tsx new file mode 100644 index 0000000..9ec3d0f --- /dev/null +++ b/components/hostComponents/dashboard/customTable.tsx @@ -0,0 +1,885 @@ +"use client"; + +import { + useState, + useCallback, + useEffect, + useRef, + useContext, + createContext, + type ReactNode, +} from "react"; +import { useRouter, usePathname, useSearchParams } from "next/navigation"; +import { + ChevronLeft, + ChevronRight, + Search, + ChevronDown, + Eye, + Pencil, + Trash2, + X, + AlertTriangle, +} from "lucide-react"; +import Link from "next/link"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── + +export interface ColumnDef { + key: string; + header?: ReactNode; + className?: string; + cell?: (row: TRow) => ReactNode; +} + +export interface PaginationMeta { + total: number; + totalPages: number; + rowsPerPage: number; +} + +export interface SelectFilterItem { + label: string; + value: string; +} + +export interface FilterDef { + paramKey: string; + field: keyof TRow; +} + +/** + * Describes one filterable field for useTableRows. + * `paramKey` must match the paramKey used in the for that field. + * `field` is the key on TRow to compare against. + */ +export interface FilterDef { + paramKey: string; + field: keyof TRow; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Row action types +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Eye icon. + * - "link" → renders a Next.js ; parent provides the href per row. + * - "popup" → renders a + + {open && ( +
+ + {items.map((item) => ( + + ))} +
+ )} +
+ ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// TableToolbar +// ───────────────────────────────────────────────────────────────────────────── + +export interface TableToolbarProps { + search?: SearchInputProps; + /** Up to 3 select filters */ + filters?: SelectFilterProps[]; + /** + * Enable the "Sort by date" toggle. + * Only meaningful when `sortField` is also declared on useTableRows. + */ + dateSort?: boolean; + actions?: ReactNode; +} + +export function TableToolbar({ search, filters = [], dateSort = false, actions }: TableToolbarProps) { + return ( +
+
+ {search && } + {filters.slice(0, 3).map((f) => ( + + ))} + {dateSort && } + {actions &&
{actions}
} +
+
+ ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// DateSortFilter +// +// A toolbar toggle that writes `${tableId}_sort` = "newest" | "oldest" to the +// URL. It is intentionally a separate component from SelectFilter so TypeScript +// makes it impossible to add to a table without also declaring `sortField` on +// useTableRows — keeping the contract explicit. +// +// Usage in TableToolbar: dateSort={true} +// ───────────────────────────────────────────────────────────────────────────── + +function DateSortFilter() { + const tableId = useContext(TableIdContext); + const { getParam, setParam } = useTableParam(tableId); + const active = getParam("sort") ?? ""; + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, []); + + const options = [ + { label: "Newest first", value: "newest" }, + { label: "Oldest first", value: "oldest" }, + ]; + + const activeLabel = options.find((o) => o.value === active)?.label ?? "Sort by date"; + + return ( +
+ + + {open && ( +
+ {/* Reset option */} + + {options.map((opt) => ( + + ))} +
+ )} +
+ ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// TableDialog — internal shared dialog shell +// Used for both Edit and Delete. Renders a backdrop + card with: +// • Fixed header (title from DataTable, X close button) +// • Scrollable body (ReactNode from parent via action config) +// • Fixed footer (Cancel + Confirm; styles differ by variant) +// ───────────────────────────────────────────────────────────────────────────── + +interface TableDialogProps { + open: boolean; + onClose: () => void; + onConfirm: () => void; + title: string; + confirmLabel: string; + /** "edit" = blue confirm, "delete" = red confirm */ + variant: "edit" | "delete"; + children: ReactNode; +} + +function TableDialog({ + open, + onClose, + onConfirm, + title, + confirmLabel, + variant, + children, +}: TableDialogProps) { + // Close on Escape + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [open, onClose]); + + if (!open) return null; + + const confirmClass = + variant === "delete" + ? "bg-red-600 hover:bg-red-700 text-white" + : "bg-blue-600 hover:bg-blue-700 text-white"; + + return ( + /* Backdrop */ +
{ if (e.target === e.currentTarget) onClose(); }} + > + {/* Card */} +
+ + {/* Header */} +
+
+ {variant === "delete" && ( + + + + )} +

+ {title} +

+
+ +
+ + {/* Body — parent-supplied ReactNode */} +
+ {children} +
+ + {/* Footer — always Cancel + Confirm */} +
+ + +
+
+
+ ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// TableSkeletonRows +// ───────────────────────────────────────────────────────────────────────────── + +function TableSkeletonRows({ cols }: { cols: number }) { + return ( + <> + {Array.from({ length: 5 }).map((_, i) => ( + + {Array.from({ length: cols }).map((_, j) => ( + +
+ + ))} + + ))} + + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// DataTable +// ───────────────────────────────────────────────────────────────────────────── + +export interface DataTableProps { + tableId: string; + columns: ColumnDef[]; + rows: TRow[]; + pagination: PaginationMeta; + toolbar?: ReactNode; + loading?: boolean; + emptyMessage?: string; + /** Eye icon — "link" navigates, "popup" fires onClick */ + viewAction?: ViewAction; + /** Pencil icon — opens Edit dialog with parent-supplied body */ + editAction?: EditAction; + /** Trash icon — opens Delete confirmation dialog */ + deleteAction?: DeleteAction; + /** Plain text link in the actions cell — no icon */ + linkAction?: LinkAction; +} + +export function DataTable({ + tableId, + columns, + rows, + pagination, + toolbar, + viewAction, + editAction, + deleteAction, + linkAction, + loading = false, + emptyMessage = "No results match your filters.", +}: DataTableProps) { + const { getParam, setParam } = useTableParam(tableId); + + const page = Math.max(1, Number(getParam("page") ?? 1)); + const totalPages = Math.max(1, pagination.totalPages); + const { rowsPerPage, total } = pagination; + + const goToPage = (p: number) => setParam("page", String(p)); + + const paginationLabel = + total === 0 + ? "0 results" + : `${(page - 1) * rowsPerPage + 1}–${Math.min(page * rowsPerPage, total)} of ${total}`; + + // ── Dialog state ──────────────────────────────────────────────────────── + type DialogKind = "edit" | "delete" | null; + const [activeRow, setActiveRow] = useState(null); + const [dialogKind, setDialogKind] = useState(null); + + const openDialog = (kind: "edit" | "delete", row: TRow) => { + setActiveRow(row); + setDialogKind(kind); + }; + const closeDialog = () => { + setDialogKind(null); + setActiveRow(null); + }; + + const handleEditConfirm = () => { + if (activeRow && editAction) { editAction.onConfirm(activeRow); closeDialog(); } + }; + const handleDeleteConfirm = () => { + if (activeRow && deleteAction) { deleteAction.onConfirm(activeRow); closeDialog(); } + }; + + const hasActions = Boolean(viewAction || editAction || deleteAction || linkAction); + + const colCount = columns.length + (hasActions ? 1 : 0); + + return ( + + {toolbar} + +
+
+ + + {columns.map((col) => ( + + {col.header ?? col.key} + + ))} + {hasActions && Actions} + + + + + {loading ? ( + + ) : rows.length === 0 ? ( + + + {emptyMessage} + + + ) : ( + rows.map((row) => ( + + {columns.map((col) => ( + + {col.cell + ? col.cell(row) + : String((row as Record)[col.key] ?? "")} + + ))} + {/* ── Actions cell ──────────────────────────────────── */} + {hasActions && ( + +
+ + {/* Eye — link or popup, parent decides */} + {viewAction && ( + viewAction.type === "link" ? ( + + + + ) : ( + + ) + )} + + {/* Pencil — opens Edit dialog */} + {editAction && ( + + )} + + {/* Trash — opens Delete dialog */} + {deleteAction && ( + + )} + + {/* Text link — no icon, sits after the icon group */} + {linkAction && ( + + {linkAction.label} + + )} + +
+
+ )} +
+ )) + )} +
+
+ + {/* Pagination */} +
+
+ Rows per page: + + {rowsPerPage} + +
+
+ {paginationLabel} + + +
+
+
+ {/* Edit dialog — rendered outside the table so it sits above everything */} + {editAction && ( + + {activeRow ? editAction.dialogContent(activeRow) : null} + + )} + + {/* Delete dialog */} + {deleteAction && ( + + {activeRow ? deleteAction.dialogContent(activeRow) : null} + + )} + + ); +} \ No newline at end of file diff --git a/components/hostComponents/pages/dashboardPageComponents/recentBookingsTable.tsx b/components/hostComponents/pages/dashboardPageComponents/recentBookingsTable.tsx new file mode 100644 index 0000000..f2ae22e --- /dev/null +++ b/components/hostComponents/pages/dashboardPageComponents/recentBookingsTable.tsx @@ -0,0 +1,126 @@ +import Link from "next/link"; +import { DataTable, useTableRows, ColumnDef } from "@/components/hostComponents/dashboard/customTable"; + +interface BookingRow { + id: string; + vehicle: string; + customer: string; + status: string; + amount: string; + date: string; +} + +const bookingColumns: ColumnDef[] = [ + { + key: "id", + header: "Booking ID", + cell: (row) => ( + + {row.id} + + ), + }, + { + key: "vehicle", + header: "Vehicle", + cell: (row) => ( + + {row.vehicle} + + ), + }, + { + key: "customer", + header: "Customer", + cell: (row) => ( + + {row.customer} + + ), + }, + { + key: "status", + header: "Status", + cell: (row) => , + }, + { + key: "amount", + header: "Amount", + cell: (row) => ( + + {row.amount} + + ), + }, + { + key: "date", + header: "Date", + cell: (row) => ( + + {new Date(row.date).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" })} + + ), + }, +]; + +const MOCK_DATA: BookingRow[] = [ + { id: "BK-1001", vehicle: "Tesla Model 3", customer: "Alexander Pierce", status: "pending", amount: "$450.00", date: "2026-06-15" }, + { id: "BK-2001", vehicle: "Ford F-150", customer: "Sarah Jenkins", status: "cancelled", amount: "$320.00", date: "2026-05-20" }, + { id: "BK-3001", vehicle: "Porsche 911", customer: "Michael Chen", status: "pending", amount: "$1,200.00", date: "2026-06-01" }, + { id: "BK-3002", vehicle: "Tesla Model S", customer: "Michael Chen", status: "active", amount: "$900.00", date: "2026-05-11" }, + { id: "BK-5001", vehicle: "Toyota RAV4", customer: "David Smith", status: "pending", amount: "$240.00", date: "2026-05-12" }, + { id: "BK-6001", vehicle: "Honda Civic", customer: "Jordan Taylor", status: "active", amount: "$180.00", date: "2026-05-25" }, + { id: "BK-7001", vehicle: "Subaru Outback", customer: "Samantha Reed", status: "active", amount: "$310.00", date: "2026-05-11" }, + { id: "BK-9001", vehicle: "Mini Cooper", customer: "Olivia Martinez", status: "pending", amount: "$110.00", date: "2026-05-28" }, + { id: "BK-1301", vehicle: "Aston Martin DB5", customer: "James Bond", status: "completed", amount: "$5,000.00", date: "2026-05-07" }, + { id: "BK-1401", vehicle: "Audi A4", customer: "Isabella Garcia", status: "cancelled", amount: "$420.00", date: "2026-05-22" }, + { id: "BK-1501", vehicle: "BMW M4", customer: "Thomas Müller", status: "pending", amount: "$850.00", date: "2026-06-15" }, + { id: "BK-1601", vehicle: "Volvo XC90", customer: "Grace Hopper", status: "active", amount: "$360.00", date: "2026-05-11" }, + { id: "BK-1201", vehicle: "Volkswagen Golf", customer: "Sophia Lee", status: "completed", amount: "$220.00", date: "2026-05-01" } +]; + +export default function RecentBookingsTable() { + + const { rows, pagination } = useTableRows({ + tableId: "recentBookings", + data: MOCK_DATA, + rowsPerPage: 10, + }); + + return ( + <> +
+

+ Recent Bookings +

+ + View all + +
+ + + ) +} + +function StatusBadge({ status }: { status: string }) { + const styles: Record = { + pending: "bg-orange-50 text-amber-500", + active: "bg-sky-100 text-blue-500", + completed: "bg-green-100 text-emerald-500", + cancelled: "bg-red-100 text-red-500", + inactive: "bg-gray-100 text-gray-500", + }; + return ( + + {status} + + ); +} \ No newline at end of file From 29da03c04e3db7dda63f47151e251a2dbc38b937 Mon Sep 17 00:00:00 2001 From: Adekoye Adewale <94379177+Adekoye-Adewale@users.noreply.github.com> Date: Sun, 7 Jun 2026 14:32:05 +0100 Subject: [PATCH 12/19] Add maintenance page and logging components Introduce a new Maintenance UI: adds app/(host)/us/host/maintenance/page.tsx and components under components/hostComponents/pages/maintenance. Includes MaintenancePageComponents (overview cards, service alerts, service history table with mock data, table toolbar and CSV export, and a modal), and LogMaintenanceServiceForm (client-side React Hook Form with validation, date picker, tabs for next-service by mileage/date, and a placeholder submit handler). Wire up modal open/close and a simple PageButton to launch the log form. --- app/(host)/us/host/maintenance/page.tsx | 8 + .../maintenance/logMaintenanceServiceForm.tsx | 484 ++++++++++++++++ .../maintenance/maintenancePageComponents.tsx | 518 ++++++++++++++++++ 3 files changed, 1010 insertions(+) create mode 100644 app/(host)/us/host/maintenance/page.tsx create mode 100644 components/hostComponents/pages/maintenance/logMaintenanceServiceForm.tsx create mode 100644 components/hostComponents/pages/maintenance/maintenancePageComponents.tsx diff --git a/app/(host)/us/host/maintenance/page.tsx b/app/(host)/us/host/maintenance/page.tsx new file mode 100644 index 0000000..e10e4da --- /dev/null +++ b/app/(host)/us/host/maintenance/page.tsx @@ -0,0 +1,8 @@ +import MaintenancePageComponents from '@/components/hostComponents/pages/maintenance/maintenancePageComponents' +import React from 'react' + +export default function MaintenancePage() { + return ( + + ) +} diff --git a/components/hostComponents/pages/maintenance/logMaintenanceServiceForm.tsx b/components/hostComponents/pages/maintenance/logMaintenanceServiceForm.tsx new file mode 100644 index 0000000..bb772b7 --- /dev/null +++ b/components/hostComponents/pages/maintenance/logMaintenanceServiceForm.tsx @@ -0,0 +1,484 @@ +'use client' + +import { useState } from 'react' +import { useForm, Controller, useWatch, Control, FieldValues, Path, RegisterOptions } from 'react-hook-form' +import { X } from 'lucide-react' +import { format } from 'date-fns' +import { CalendarIcon } from 'lucide-react' +import { cn } from '@/lib/utils' + +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { Label } from '@/components/ui/label' +import { Button } from '@/components/ui/button' +import { FieldSeparator } from '@/components/ui/field' +import { Calendar } from '@/components/ui/calendar' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover' + +// ─── Types ──────────────────────────────────────────────────────────────────── + +type NextServiceTab = 'mileage' | 'date' + +type LogMaintenanceFormValues = { + vehicleName: string + serviceType: string + serviceDate: string + mileageAtService: string + cost: string + provider: string + nextServiceTab: NextServiceTab + nextServiceMileage: string + nextServiceDate: string + notes: string +} + +type LogMaintenanceServiceFormProps = { + onClose: () => void +} + +// ─── Placeholder submit handler ─────────────────────────────────────────────── + +const logServiceToDatabase = async (values: LogMaintenanceFormValues) => { + // TODO: replace with real API call + // await fetch('/api/maintenance/log', { + // method: 'POST', + // headers: { 'Content-Type': 'application/json' }, + // body: JSON.stringify(values), + // }) + console.log('logging maintenance service:', values) +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export default function LogMaintenanceServiceForm({ onClose }: LogMaintenanceServiceFormProps) { + const [activeTab, setActiveTab] = useState('mileage') + + const { + register, + handleSubmit, + control, + formState: { errors, isSubmitting }, + } = useForm({ + mode: 'onTouched', + defaultValues: { + vehicleName: '', + serviceType: '', + serviceDate: '', + mileageAtService: '', + cost: '', + provider: '', + nextServiceTab: 'mileage', + nextServiceMileage: '', + nextServiceDate: '', + notes: '', + }, + }) + + const mileageAtService = useWatch({ control, name: 'mileageAtService' }) + const serviceDate = useWatch({ control, name: 'serviceDate' }) + + const onSubmit = async (values: LogMaintenanceFormValues) => { + await logServiceToDatabase({ ...values, nextServiceTab: activeTab }) + onClose() + } + + return ( +
+ + {/* ── Header ──────────────────────────────────────── */} +
+ + Log Maintenance Service + + +
+ + + {/* ── Scrollable body ──────────────────────────────── */} +
+ {/* 1. Vehicle Name */} + + + + + {/* 2. Service Type */} + + + + + {/* 3. Service Date + Mileage at Service */} +
+ + + name='serviceDate' + control={control} + placeholder='Pick a date' + error={errors.serviceDate?.message} + rules={{ required: 'Service date is required' }} + /> + + + + + +
+ + {/* 4. Cost + Provider */} +
+ +
+ + $ + + +
+
+ + + + +
+ + {/* 5. Next Service Due */} +
+ + Next Service Due * + + + {/* Tab buttons */} +
+ {(['mileage', 'date'] as NextServiceTab[]).map(tab => ( + + ))} +
+ + {/* Tab content */} + {activeTab === 'mileage' ? ( + + { + if (activeTab !== 'mileage') return true + const next = parseInt(value.replace(/,/g, ''), 10) + const current = parseInt((mileageAtService ?? '').replace(/,/g, ''), 10) + if (isNaN(next)) return 'Enter a valid mileage' + if (!isNaN(current) && next <= current) { + return `Must be greater than current mileage (${mileageAtService} km)` + } + return true + }, + })} + /> + + ) : ( + + + name='nextServiceDate' + control={control} + placeholder='Pick a date' + error={errors.nextServiceDate?.message} + rules={{ + required: activeTab === 'date' ? 'Next service date is required' : false, + validate: (value: string) => { + if (activeTab !== 'date' || !value) return true + if (!serviceDate) return true + return new Date(value) > new Date(serviceDate) + ? true + : 'Must be after the service date' + }, + }} + minDate={serviceDate ? new Date(serviceDate) : undefined} + /> + + )} +
+ + {/* 6. Notes */} + +