From b06824e0478124b91a35534edd4b9db0d5f97623 Mon Sep 17 00:00:00 2001 From: mijinummi Date: Fri, 27 Mar 2026 13:55:59 +0100 Subject: [PATCH 1/4] feat(dashboard): add Profile and Settings links to sidebar footer with active styling --- frontend/app/(dashboard)/layout.tsx | 62 +++++++ .../app/(dashboard)/shipments/[id]/page.tsx | 155 ++++++++++++++++++ frontend/components/ShipmentCard.tsx | 51 +++++- frontend/components/ShipmentTimeline.tsx | 23 +++ frontend/services/shipmentApi.ts | 1 + 5 files changed, 289 insertions(+), 3 deletions(-) create mode 100644 frontend/app/(dashboard)/layout.tsx diff --git a/frontend/app/(dashboard)/layout.tsx b/frontend/app/(dashboard)/layout.tsx new file mode 100644 index 00000000..2775d64f --- /dev/null +++ b/frontend/app/(dashboard)/layout.tsx @@ -0,0 +1,62 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { cn } from "@/lib/utils"; +import { ReactNode } from "react"; + +interface DashboardLayoutProps { + children: ReactNode; +} + +export default function DashboardLayout({ children }: DashboardLayoutProps) { + const pathname = usePathname(); + + return ( +
+ {/* Sidebar */} + + + {/* Main content */} +
{children}
+
+ ); +} diff --git a/frontend/app/(dashboard)/shipments/[id]/page.tsx b/frontend/app/(dashboard)/shipments/[id]/page.tsx index e69de29b..39dfc5b4 100644 --- a/frontend/app/(dashboard)/shipments/[id]/page.tsx +++ b/frontend/app/(dashboard)/shipments/[id]/page.tsx @@ -0,0 +1,155 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import { useParams } from 'next/navigation'; +import { shipmentApi } from '@/services/shipmentApi'; +import ShipmentTimeline from '@/components/ShipmentTimeline'; +import { toast } from 'react-hot-toast'; + +type UserRole = 'shipper' | 'carrier' | 'admin'; +type ShipmentStatus = + | 'PENDING' + | 'ACCEPTED' + | 'IN_TRANSIT' + | 'DELIVERED' + | 'DISPUTED' + | 'COMPLETED' + | 'CANCELLED'; + +interface Shipment { + id: string; + description: string; + weight: number; + volume: number; + price: number; + origin: string; + destination: string; + shipperName: string; + carrierName?: string; + status: ShipmentStatus; +} + +interface HistoryEvent { + id: string; + status: ShipmentStatus; + timestamp: string; +} + +export default function ShipmentDetailPage() { + const params = useParams(); + const id = params?.id as string; + + const [shipment, setShipment] = useState(null); + const [history, setHistory] = useState([]); + const [loading, setLoading] = useState(true); + const [actionLoading, setActionLoading] = useState(false); + + const fetchData = async () => { + try { + setLoading(true); + const [shipmentRes, historyRes] = await Promise.all([ + shipmentApi.getById(id), + shipmentApi.getHistory(id), + ]); + setShipment(shipmentRes); + setHistory(historyRes); + } catch (err) { + toast.error('Failed to load shipment'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (id) fetchData(); + }, [id]); + + const handleAction = async (action: string) => { + try { + setActionLoading(true); + await shipmentApi.performAction(id, action); + toast.success(`Action "${action}" successful`); + await fetchData(); + } catch (err) { + toast.error(`Failed to perform action: ${action}`); + } finally { + setActionLoading(false); + } + }; + + const renderActions = () => { + const role: UserRole = 'carrier'; // TODO: derive from auth context + const { status, carrierName } = shipment!; + + const buttons: JSX.Element[] = []; + + if (status === 'PENDING' && role !== 'shipper') { + buttons.push(); + } + if (status === 'ACCEPTED' && role === 'carrier' && carrierName) { + buttons.push(); + } + if (status === 'IN_TRANSIT' && role === 'carrier' && carrierName) { + buttons.push(); + } + if (status === 'DELIVERED' && role === 'shipper') { + buttons.push(); + } + if (['PENDING', 'ACCEPTED'].includes(status) && ['shipper', 'carrier', 'admin'].includes(role)) { + buttons.push(); + } + if (['IN_TRANSIT', 'DELIVERED'].includes(status) && ['shipper', 'carrier'].includes(role)) { + buttons.push(); + } + if (status === 'DISPUTED' && role === 'admin') { + buttons.push( +
+ + +
+ ); + } + if (['COMPLETED', 'CANCELLED'].includes(status)) { + buttons.push(

No further actions

); + } + + return
{buttons}
; + }; + + // ...rest of your JSX + + return ( +
+ {/* Left: Details + Actions */} +
+
+

Cargo

+
    +
  • Description: {shipment.description}
  • +
  • Weight: {shipment.weight} kg
  • +
  • Volume: {shipment.volume} m³
  • +
  • Price: ${shipment.price}
  • +
+
+ +
+

Parties

+
    +
  • Shipper: {shipment.shipperName}
  • +
  • Carrier: {shipment.carrierName || '—'}
  • +
+
+ +
+

Actions

+ {renderActions()} +
+
+ + {/* Right: Timeline */} +
+ +
+
+ ); +} diff --git a/frontend/components/ShipmentCard.tsx b/frontend/components/ShipmentCard.tsx index 4061cc95..9c035c1b 100644 --- a/frontend/components/ShipmentCard.tsx +++ b/frontend/components/ShipmentCard.tsx @@ -4,22 +4,67 @@ interface Shipment { id: string; origin: string; destination: string; + description?: string; weight?: number; + volume?: number; price?: number; pickupDate?: string; + shipperName?: string; + carrierName?: string; + status?: string; // optional for detail page context } interface Props { shipment: Shipment; + onClick?: (id: string) => void; // optional handler for navigation } -const ShipmentCard: React.FC = ({ shipment }) => { +const ShipmentCard: React.FC = ({ shipment, onClick }) => { return ( -
+
onClick?.(shipment.id)} + > + {/* Header */}

{shipment.origin} → {shipment.destination}

+ + {/* Cargo Details */}
    + {shipment.description &&
  • Description: {shipment.description}
  • } {shipment.weight &&
  • Weight: {shipment.weight} kg
  • } + {shipment.volume &&
  • Volume: {shipment.volume} m³
  • } {shipment.price &&
  • Price: ${shipment.price}
  • } - {shipment.pickupDate &&
  • Pickup: {new Date(shipment.pickupDate).toLocaleDateString()}
  • } + {shipment.pickupDate && ( +
  • Pickup: {new Date(shipment.pickupDate).toLocaleDateString()}
  • + )} +
+ + {/* Parties */} + {(shipment.shipperName || shipment.carrierName) && ( +
+

Shipper: {shipment.shipperName || '—'}

+

Carrier: {shipment.carrierName || '—'}

+
+ )} + + {/* Status */} + {shipment.status && ( +

+ Status: {shipment.status} +

+ )} + + {/* Action */} + +
+ ); +}; + +export default ShipmentCard; diff --git a/frontend/components/ShipmentTimeline.tsx b/frontend/components/ShipmentTimeline.tsx index e69de29b..6e187dff 100644 --- a/frontend/components/ShipmentTimeline.tsx +++ b/frontend/components/ShipmentTimeline.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +interface HistoryEvent { + id: string; + status: string; + timestamp: string; +} + +export default function ShipmentTimeline({ history }: { history: HistoryEvent[] }) { + return ( +
+

Status Timeline

+
    + {history.map((event) => ( +
  • + {event.status} + {new Date(event.timestamp).toLocaleString()} +
  • + ))} +
+
+ ); +} diff --git a/frontend/services/shipmentApi.ts b/frontend/services/shipmentApi.ts index 82582441..b16cd44c 100644 --- a/frontend/services/shipmentApi.ts +++ b/frontend/services/shipmentApi.ts @@ -1,5 +1,6 @@ import axios from 'axios'; + interface MarketplaceResponse { data: Shipment[]; totalPages: number; From 72b3146e77f38c209ba8af6384c422c53078c9e6 Mon Sep 17 00:00:00 2001 From: mijinummi Date: Fri, 27 Mar 2026 14:14:17 +0100 Subject: [PATCH 2/4] feat(admin): add stats overview page with user, shipment, and revenue cards --- frontend/app/(dashboard)/admin/page.tsx | 117 ++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 frontend/app/(dashboard)/admin/page.tsx diff --git a/frontend/app/(dashboard)/admin/page.tsx b/frontend/app/(dashboard)/admin/page.tsx new file mode 100644 index 00000000..191b51d1 --- /dev/null +++ b/frontend/app/(dashboard)/admin/page.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { adminApi } from "@/lib/api/admin.api"; +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; +import { useUser } from "@/lib/hooks/useUser"; // assumes you have a user hook + +export default function AdminStatsPage() { + const router = useRouter(); + const { user, loading: userLoading } = useUser(); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + + // Redirect non-admins + useEffect(() => { + if (!userLoading && user?.role !== "admin") { + router.push("/dashboard"); + } + }, [user, userLoading, router]); + + // Fetch stats + useEffect(() => { + async function fetchStats() { + try { + const data = await adminApi.getStats(); + setStats(data); + } catch (err) { + console.error("Failed to load stats", err); + } finally { + setLoading(false); + } + } + fetchStats(); + }, []); + + if (loading) { + return ( +
+ {Array.from({ length: 10 }).map((_, i) => ( + + ))} +
+ ); + } + + return ( +
+

Admin Overview

+ + {/* User stats */} +
+ + + + + + +
+ + {/* Shipment stats */} +
+ + + + + 0} + /> + +
+ + {/* Revenue */} +
+ +
+ + {/* Quick navigation */} +
+ + +
+
+ ); +} + +function StatCard({ + title, + value, + destructive = false, +}: { + title: string; + value: any; + destructive?: boolean; +}) { + return ( + + + {title} + + +

{value}

+
+
+ ); +} From 56714e55ee78e0a9490e196bd4efeea201c868e8 Mon Sep 17 00:00:00 2001 From: mijinummi Date: Fri, 27 Mar 2026 14:19:50 +0100 Subject: [PATCH 3/4] feat(settings): add change password form with validation and danger zone section --- frontend/app/(dashboard)/settings/page.tsx | 138 +++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 frontend/app/(dashboard)/settings/page.tsx diff --git a/frontend/app/(dashboard)/settings/page.tsx b/frontend/app/(dashboard)/settings/page.tsx new file mode 100644 index 00000000..69dd690f --- /dev/null +++ b/frontend/app/(dashboard)/settings/page.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { toast } from "sonner"; +import { authApi } from "@/lib/api/auth.api"; +import { logout } from "@/stores/auth.store"; +import { + Card, + CardHeader, + CardTitle, + CardContent, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +const schema = z + .object({ + currentPassword: z.string().min(1, "Current password is required"), + newPassword: z.string().min(8, "New password must be at least 8 characters"), + confirmPassword: z.string().min(1, "Please confirm your new password"), + }) + .refine((data) => data.newPassword === data.confirmPassword, { + message: "Passwords do not match", + path: ["confirmPassword"], + }); + +type FormValues = z.infer; + +export default function SettingsPage() { + const { + register, + handleSubmit, + reset, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(schema), + }); + + const onSubmit = async (values: FormValues) => { + try { + await authApi.changePassword({ + currentPassword: values.currentPassword, + newPassword: values.newPassword, + }); + + toast.success("Password changed successfully. You will be signed out."); + reset(); + + setTimeout(() => { + logout(); + }, 1500); + } catch (err: any) { + toast.error(err.message || "Failed to change password"); + } + }; + + return ( +
+

Settings

+ + {/* Change Password Form */} + + + Change Password + + +
+
+ + + {errors.currentPassword && ( +

+ {errors.currentPassword.message} +

+ )} +
+ +
+ + + {errors.newPassword && ( +

+ {errors.newPassword.message} +

+ )} +
+ +
+ + + {errors.confirmPassword && ( +

+ {errors.confirmPassword.message} +

+ )} +
+ + +
+
+
+ + {/* Danger Zone */} + + + Danger Zone + + +

+ Account deletion requires contacting support. Please reach out to + our support team if you wish to permanently delete your account. +

+
+
+
+ ); +} From 80c0963e10df4cdebcebbe3c552ce4f5642b7f4c Mon Sep 17 00:00:00 2001 From: mijinummi Date: Fri, 27 Mar 2026 14:26:31 +0100 Subject: [PATCH 4/4] feat(api): add admin API client with stats, users, and shipments endpoints --- frontend/lib/api/admin.api.ts | 100 ++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 frontend/lib/api/admin.api.ts diff --git a/frontend/lib/api/admin.api.ts b/frontend/lib/api/admin.api.ts new file mode 100644 index 00000000..b18a7147 --- /dev/null +++ b/frontend/lib/api/admin.api.ts @@ -0,0 +1,100 @@ +import { apiClient } from "./client"; +import { User, UserRole } from "@/types/auth.types"; +import { Shipment, ShipmentStatus } from "@/types/shipment.types"; + +// Stats overview interface +export interface PlatformStats { + users: { + total: number; + active: number; + inactive: number; + shippers: number; + carriers: number; + admins: number; + }; + shipments: { + total: number; + pending: number; + inTransit: number; + completed: number; + disputed: number; + cancelled: number; + }; + revenue: { + completed: number; + }; +} + +// Paginated users +export interface PaginatedUsers { + data: User[]; + total: number; + page: number; + pageSize: number; +} + +// Paginated shipments +export interface PaginatedAdminShipments { + data: Shipment[]; + total: number; + page: number; + pageSize: number; +} + +// Query params for users +export interface QueryUsersParams { + page?: number; + pageSize?: number; + role?: UserRole; + status?: "active" | "inactive"; +} + +// Query params for shipments +export interface QueryAdminShipmentsParams { + page?: number; + pageSize?: number; + status?: ShipmentStatus; + disputed?: boolean; +} + +// Helper to serialize query params +function toQueryString(params: Record = {}): string { + const query = Object.entries(params) + .filter(([_, v]) => v !== undefined && v !== null) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) + .join("&"); + return query ? `?${query}` : ""; +} + +// Admin API client +export const adminApi = { + async getStats(): Promise { + return apiClient.get("/admin/stats"); + }, + + async listUsers(params?: QueryUsersParams): Promise { + const qs = toQueryString(params || {}); + return apiClient.get(`/admin/users${qs}`); + }, + + async getUser(id: string): Promise { + return apiClient.get(`/admin/users/${id}`); + }, + + async deactivateUser(id: string): Promise { + return apiClient.post(`/admin/users/${id}/deactivate`); + }, + + async activateUser(id: string): Promise { + return apiClient.post(`/admin/users/${id}/activate`); + }, + + async changeUserRole(id: string, role: UserRole): Promise { + return apiClient.post(`/admin/users/${id}/role`, { role }); + }, + + async listShipments(params?: QueryAdminShipmentsParams): Promise { + const qs = toQueryString(params || {}); + return apiClient.get(`/admin/shipments${qs}`); + }, +};