From b4cac92d806a4b661ac76517e1b44b4cb171e773 Mon Sep 17 00:00:00 2001 From: iamasx Date: Sat, 25 Apr 2026 01:14:00 +0530 Subject: [PATCH] feat: add reserve-bay route with reservation cards, availability snapshots, and allocation summary Closes #208 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/layout.tsx | 1 + .../_components/allocation-summary.tsx | 83 ++++++++ .../_components/availability-snapshot.tsx | 85 ++++++++ .../_components/reservation-card.tsx | 66 ++++++ src/app/reserve-bay/_data/reserve-bay-data.ts | 195 ++++++++++++++++++ src/app/reserve-bay/page.test.tsx | 80 +++++++ src/app/reserve-bay/page.tsx | 192 +++++++++++++++++ src/app/reserve-bay/reserve-bay.module.css | 112 ++++++++++ 8 files changed, 814 insertions(+) create mode 100644 src/app/reserve-bay/_components/allocation-summary.tsx create mode 100644 src/app/reserve-bay/_components/availability-snapshot.tsx create mode 100644 src/app/reserve-bay/_components/reservation-card.tsx create mode 100644 src/app/reserve-bay/_data/reserve-bay-data.ts create mode 100644 src/app/reserve-bay/page.test.tsx create mode 100644 src/app/reserve-bay/page.tsx create mode 100644 src/app/reserve-bay/reserve-bay.module.css diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 4611bce..cb706ad 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -33,6 +33,7 @@ const navLinks = [ { href: "/queue-monitor", label: "Queue Monitor" }, { href: "/parcel-hub", label: "Parcel Hub" }, { href: "/status-board", label: "Status Board" }, + { href: "/reserve-bay", label: "Reserve Bay" }, ] as const; export default function RootLayout({ diff --git a/src/app/reserve-bay/_components/allocation-summary.tsx b/src/app/reserve-bay/_components/allocation-summary.tsx new file mode 100644 index 0000000..5f66016 --- /dev/null +++ b/src/app/reserve-bay/_components/allocation-summary.tsx @@ -0,0 +1,83 @@ +import type { + AllocationCategory, + AllocationEntry, +} from "../_data/reserve-bay-data"; +import styles from "../reserve-bay.module.css"; + +const categoryToneClasses: Record = { + inbound: "border-sky-200 bg-sky-50/80 text-sky-800", + outbound: "border-emerald-200 bg-emerald-50/80 text-emerald-800", + overflow: "border-amber-200 bg-amber-50/90 text-amber-800", + maintenance: "border-slate-200 bg-slate-100/90 text-slate-700", +}; + +const categoryFillClasses: Record = { + inbound: styles.fillInbound, + outbound: styles.fillHigh, + overflow: styles.fillModerate, + maintenance: styles.fillMaintenance, +}; + +export function AllocationSummary({ + allocations, +}: { + allocations: AllocationEntry[]; +}) { + const totalAllocated = allocations.reduce( + (sum, a) => sum + a.baysAllocated, + 0, + ); + const totalUsed = allocations.reduce((sum, a) => sum + a.baysUsed, 0); + + return ( +
+
+

+ Facility summary +

+
+

+ {totalUsed}/{totalAllocated} +

+

bays in use

+
+
+ +
+ {allocations.map((alloc) => ( +
+
+
+ + {alloc.label} + +
+ + {alloc.baysUsed}/{alloc.baysAllocated} + +
+ +
+
+ Utilization + {alloc.utilization}% +
+
+ +
+
+
+ ))} +
+
+ ); +} diff --git a/src/app/reserve-bay/_components/availability-snapshot.tsx b/src/app/reserve-bay/_components/availability-snapshot.tsx new file mode 100644 index 0000000..d092d3f --- /dev/null +++ b/src/app/reserve-bay/_components/availability-snapshot.tsx @@ -0,0 +1,85 @@ +import type { + AvailabilityLevel, + AvailabilitySnapshot, +} from "../_data/reserve-bay-data"; +import styles from "../reserve-bay.module.css"; + +const levelToneClasses: Record = { + high: "border-emerald-200 bg-emerald-50/80 text-emerald-800", + moderate: "border-amber-200 bg-amber-50/90 text-amber-800", + low: "border-rose-200 bg-rose-50 text-rose-800", +}; + +const levelFillClasses: Record = { + high: styles.fillHigh, + moderate: styles.fillModerate, + low: styles.fillLow, +}; + +export function AvailabilitySnapshotCard({ + snapshot, +}: { + snapshot: AvailabilitySnapshot; +}) { + const occupancyPct = Math.round( + (snapshot.occupiedBays / snapshot.totalBays) * 100, + ); + const freeBays = snapshot.totalBays - snapshot.occupiedBays; + + return ( +
+
+
+

+ {snapshot.zone} +

+

+ {freeBays} free +

+
+ + {snapshot.level} availability + +
+ +
+
+

+ Next free at +

+

+ {snapshot.nextFreeAt} +

+
+
+

+ Peak window +

+

+ {snapshot.peakWindow} +

+
+
+ +
+
+ Occupancy + + {snapshot.occupiedBays}/{snapshot.totalBays} bays · {occupancyPct}% + +
+
+ +
+
+
+ ); +} diff --git a/src/app/reserve-bay/_components/reservation-card.tsx b/src/app/reserve-bay/_components/reservation-card.tsx new file mode 100644 index 0000000..e24dd56 --- /dev/null +++ b/src/app/reserve-bay/_components/reservation-card.tsx @@ -0,0 +1,66 @@ +import type { Reservation, ReservationStatus } from "../_data/reserve-bay-data"; +import styles from "../reserve-bay.module.css"; + +const statusToneClasses: Record = { + confirmed: "border-emerald-200 bg-emerald-50 text-emerald-800", + pending: "border-amber-200 bg-amber-50 text-amber-800", + expired: "border-slate-200 bg-slate-100 text-slate-600", +}; + +const statusLabel: Record = { + confirmed: "Confirmed", + pending: "Pending", + expired: "Expired", +}; + +export function ReservationCard({ reservation }: { reservation: Reservation }) { + return ( +
+
+
+
+ + {statusLabel[reservation.status]} + +

+ Bay {reservation.bayCode} +

+
+ +
+

+ {reservation.carrier} +

+

+ {reservation.notes} +

+
+
+ +

+ {reservation.windowStart}–{reservation.windowEnd} +

+
+ +
+
+

+ Trailer +

+

{reservation.trailerId}

+
+
+

+ Load type +

+

{reservation.loadType}

+
+
+
+ ); +} diff --git a/src/app/reserve-bay/_data/reserve-bay-data.ts b/src/app/reserve-bay/_data/reserve-bay-data.ts new file mode 100644 index 0000000..a7edb50 --- /dev/null +++ b/src/app/reserve-bay/_data/reserve-bay-data.ts @@ -0,0 +1,195 @@ +export type ReservationStatus = "confirmed" | "pending" | "expired"; +export type AvailabilityLevel = "high" | "moderate" | "low"; +export type AllocationCategory = "inbound" | "outbound" | "overflow" | "maintenance"; + +export interface Reservation { + id: string; + bayCode: string; + carrier: string; + status: ReservationStatus; + windowStart: string; + windowEnd: string; + trailerId: string; + loadType: string; + notes: string; +} + +export interface AvailabilitySnapshot { + id: string; + zone: string; + totalBays: number; + occupiedBays: number; + level: AvailabilityLevel; + nextFreeAt: string; + peakWindow: string; +} + +export interface AllocationEntry { + id: string; + category: AllocationCategory; + baysAllocated: number; + baysUsed: number; + utilization: number; + label: string; +} + +export const reserveBaySummary = { + eyebrow: "Reserve Bay", + title: "Manage dock reservations and bay allocation across facilities.", + description: + "Track reservation status, monitor real-time availability by zone, and review how bays are allocated across inbound, outbound, overflow, and maintenance categories.", + stats: [ + { + label: "Total bays", + value: "48", + tone: "high" as AvailabilityLevel, + }, + { + label: "Currently occupied", + value: "31", + tone: "moderate" as AvailabilityLevel, + }, + { + label: "Reservations today", + value: "22", + tone: "high" as AvailabilityLevel, + }, + { + label: "Avg turnaround", + value: "38 min", + tone: "moderate" as AvailabilityLevel, + }, + ], +}; + +export const reserveBayReservations: Reservation[] = [ + { + id: "res-001", + bayCode: "B-12", + carrier: "Northline Freight", + status: "confirmed", + windowStart: "06:00", + windowEnd: "08:30", + trailerId: "NLF-4821", + loadType: "Dry goods", + notes: "Priority unload — perishable buffer required on adjacent bay.", + }, + { + id: "res-002", + bayCode: "B-07", + carrier: "Summit Logistics", + status: "confirmed", + windowStart: "07:15", + windowEnd: "09:00", + trailerId: "SML-1193", + loadType: "Palletized electronics", + notes: "Requires forklift team Alpha on standby for high-value scan.", + }, + { + id: "res-003", + bayCode: "B-22", + carrier: "Coastway Express", + status: "pending", + windowStart: "09:30", + windowEnd: "11:00", + trailerId: "CWE-3347", + loadType: "Refrigerated produce", + notes: "Awaiting cold-chain dock confirmation from facilities.", + }, + { + id: "res-004", + bayCode: "B-03", + carrier: "Midland Carriers", + status: "expired", + windowStart: "04:00", + windowEnd: "05:30", + trailerId: "MLC-0782", + loadType: "Bulk industrial", + notes: "Carrier no-show — bay released back to overflow pool.", + }, + { + id: "res-005", + bayCode: "B-18", + carrier: "Tristate Hauling", + status: "confirmed", + windowStart: "10:00", + windowEnd: "12:30", + trailerId: "TSH-5590", + loadType: "Mixed freight", + notes: "Double-door access needed for tandem unload sequence.", + }, +]; + +export const reserveBayAvailability: AvailabilitySnapshot[] = [ + { + id: "zone-a", + zone: "Zone A — Inbound dock", + totalBays: 12, + occupiedBays: 9, + level: "low", + nextFreeAt: "08:45", + peakWindow: "06:00–10:00", + }, + { + id: "zone-b", + zone: "Zone B — Outbound staging", + totalBays: 16, + occupiedBays: 10, + level: "moderate", + nextFreeAt: "07:30", + peakWindow: "14:00–18:00", + }, + { + id: "zone-c", + zone: "Zone C — Overflow / flex", + totalBays: 10, + occupiedBays: 5, + level: "high", + nextFreeAt: "Available now", + peakWindow: "11:00–15:00", + }, + { + id: "zone-d", + zone: "Zone D — Maintenance reserve", + totalBays: 10, + occupiedBays: 7, + level: "moderate", + nextFreeAt: "09:15", + peakWindow: "08:00–12:00", + }, +]; + +export const reserveBayAllocations: AllocationEntry[] = [ + { + id: "alloc-inbound", + category: "inbound", + baysAllocated: 14, + baysUsed: 11, + utilization: 79, + label: "Inbound receiving", + }, + { + id: "alloc-outbound", + category: "outbound", + baysAllocated: 16, + baysUsed: 12, + utilization: 75, + label: "Outbound dispatch", + }, + { + id: "alloc-overflow", + category: "overflow", + baysAllocated: 10, + baysUsed: 4, + utilization: 40, + label: "Overflow staging", + }, + { + id: "alloc-maintenance", + category: "maintenance", + baysAllocated: 8, + baysUsed: 4, + utilization: 50, + label: "Maintenance hold", + }, +]; diff --git a/src/app/reserve-bay/page.test.tsx b/src/app/reserve-bay/page.test.tsx new file mode 100644 index 0000000..94227a9 --- /dev/null +++ b/src/app/reserve-bay/page.test.tsx @@ -0,0 +1,80 @@ +import { cleanup, render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + reserveBayAllocations, + reserveBayAvailability, + reserveBayReservations, +} from "./_data/reserve-bay-data"; +import ReserveBayPage from "./page"; + +afterEach(() => { + cleanup(); +}); + +describe("ReserveBayPage", () => { + it("renders the reserve bay shell independently from the home page", () => { + render(); + + expect( + screen.getByRole("heading", { + name: /manage dock reservations and bay allocation across facilities\./i, + }), + ).toBeTruthy(); + expect( + screen.getByRole("heading", { name: /availability snapshot/i }), + ).toBeTruthy(); + expect( + screen.getByRole("heading", { name: /reservations/i }), + ).toBeTruthy(); + expect( + screen.getByRole("heading", { name: /allocation summary/i }), + ).toBeTruthy(); + expect( + screen.queryByText(/to get started, edit the page\.tsx file\./i), + ).toBeNull(); + }); + + it("renders each availability zone from mock content", () => { + render(); + + const zoneList = screen.getByRole("list", { + name: /availability zones/i, + }); + expect(within(zoneList).getAllByRole("listitem")).toHaveLength( + reserveBayAvailability.length, + ); + + for (const snapshot of reserveBayAvailability) { + expect(screen.getByText(snapshot.zone)).toBeTruthy(); + } + }); + + it("renders reservation cards and allocation panel with responsive layout hooks", () => { + render(); + + const reservationList = screen.getByRole("list", { + name: /reservations/i, + }); + const allocationList = screen.getByRole("list", { + name: /allocation breakdown/i, + }); + const panels = screen.getByTestId("reserve-bay-panels"); + + expect(within(reservationList).getAllByRole("listitem")).toHaveLength( + reserveBayReservations.length, + ); + expect(within(allocationList).getAllByRole("listitem")).toHaveLength( + reserveBayAllocations.length, + ); + expect( + screen.getByText(reserveBayReservations[0].carrier), + ).toBeTruthy(); + expect( + screen.getByText(reserveBayAllocations[0].label), + ).toBeTruthy(); + expect(panels.className).toContain( + "xl:grid-cols-[minmax(0,1.35fr)_minmax(320px,0.95fr)]", + ); + }); +}); diff --git a/src/app/reserve-bay/page.tsx b/src/app/reserve-bay/page.tsx new file mode 100644 index 0000000..b26b996 --- /dev/null +++ b/src/app/reserve-bay/page.tsx @@ -0,0 +1,192 @@ +import type { Metadata } from "next"; +import Link from "next/link"; + +import { AllocationSummary } from "./_components/allocation-summary"; +import { AvailabilitySnapshotCard } from "./_components/availability-snapshot"; +import { ReservationCard } from "./_components/reservation-card"; +import { + reserveBayAllocations, + reserveBayAvailability, + reserveBayReservations, + reserveBaySummary, + type AvailabilityLevel, +} from "./_data/reserve-bay-data"; +import styles from "./reserve-bay.module.css"; + +export const metadata: Metadata = { + title: "Reserve Bay", + description: + "Dock reservation cards, real-time availability snapshots, and a compact bay allocation summary.", +}; + +const levelToneClasses: Record = { + high: "border-emerald-300/30 bg-emerald-300/15 text-emerald-50", + moderate: "border-amber-300/30 bg-amber-300/15 text-amber-50", + low: "border-rose-300/30 bg-rose-300/15 text-rose-50", +}; + +export default function ReserveBayPage() { + return ( +
+
+
+ + Home + + Dock management +
+ +
+
+
+
+

+ {reserveBaySummary.eyebrow} +

+
+

+ {reserveBaySummary.title} +

+

+ {reserveBaySummary.description} +

+
+
+ +
+ {reserveBaySummary.stats.map((stat) => ( +
+ + {stat.label} + + + {stat.value} + +
+ ))} +
+
+ + +
+
+ +
+
+
+

+ Zone overview +

+

+ Availability snapshot +

+
+

+ Real-time bay occupancy across all facility zones with peak window indicators. +

+
+ +
+ {reserveBayAvailability.map((snapshot) => ( + + ))} +
+
+ +
+
+
+
+

+ Dock schedule +

+

+ Reservations +

+
+

+ Active and upcoming bay reservations ordered by time window. +

+
+
    + {reserveBayReservations.map((reservation) => ( +
  • + +
  • + ))} +
+
+ +
+
+

+ Capacity planning +

+

+ Allocation summary +

+
+ +
+
+
+
+ ); +} diff --git a/src/app/reserve-bay/reserve-bay.module.css b/src/app/reserve-bay/reserve-bay.module.css new file mode 100644 index 0000000..d9f39a4 --- /dev/null +++ b/src/app/reserve-bay/reserve-bay.module.css @@ -0,0 +1,112 @@ +.shell { + position: relative; + isolation: isolate; + overflow: hidden; + min-height: 100vh; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(240, 242, 248, 0.98)), + linear-gradient(135deg, rgba(99, 102, 241, 0.1), transparent 38%), + linear-gradient(215deg, rgba(14, 165, 233, 0.08), transparent 42%); +} + +.shell::before { + content: ""; + position: absolute; + inset: -10% -30% auto auto; + width: 34rem; + height: 34rem; + border-radius: 9999px; + background: + radial-gradient(circle, rgba(99, 102, 241, 0.15), transparent 58%); + filter: blur(12px); + z-index: -2; +} + +.shell::after { + content: ""; + position: absolute; + inset: 0; + background-image: + linear-gradient(rgba(148, 163, 184, 0.12) 1px, transparent 1px), + linear-gradient(90deg, rgba(148, 163, 184, 0.12) 1px, transparent 1px); + background-position: center; + background-size: 4.75rem 4.75rem; + mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.5), transparent 85%); + z-index: -1; +} + +.surfaceCard { + border: 1px solid rgba(255, 255, 255, 0.78); + background: rgba(255, 255, 255, 0.8); + box-shadow: + 0 20px 60px rgba(15, 23, 42, 0.08), + inset 0 1px 0 rgba(255, 255, 255, 0.65); + backdrop-filter: blur(14px); + border-radius: 1.75rem; +} + +.heroPanel { + background: + linear-gradient(135deg, rgba(15, 23, 42, 0.95), rgba(49, 46, 129, 0.88)), + radial-gradient(circle at top right, rgba(99, 102, 241, 0.3), transparent 40%); + color: #f8fafc; +} + +.heroPanel .surfaceCard { + background: rgba(255, 255, 255, 0.18); +} + +.reservationCard { + position: relative; +} + +.reservationCard::before { + content: ""; + position: absolute; + inset: 1.1rem auto 1.1rem 1.1rem; + width: 0.35rem; + border-radius: 9999px; + background: linear-gradient(180deg, #818cf8, #4f46e5); + opacity: 0.95; +} + +.progressRail { + height: 0.7rem; + overflow: hidden; + border-radius: 9999px; + background: rgba(148, 163, 184, 0.16); +} + +.progressFill { + display: block; + height: 100%; + border-radius: inherit; +} + +.fillHigh { + background: linear-gradient(90deg, #10b981, #0f766e); +} + +.fillModerate { + background: linear-gradient(90deg, #f59e0b, #ea580c); +} + +.fillLow { + background: linear-gradient(90deg, #fb7185, #be123c); +} + +.fillInbound { + background: linear-gradient(90deg, #38bdf8, #0284c7); +} + +.fillMaintenance { + background: linear-gradient(90deg, #64748b, #334155); +} + +@media (max-width: 640px) { + .shell::before { + inset: -15% -40% auto auto; + width: 24rem; + height: 24rem; + } +}