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}

+
+
+ ); +} diff --git a/frontend/app/(dashboard)/layout.tsx b/frontend/app/(dashboard)/layout.tsx index 19f6b497..2775d64f 100644 --- a/frontend/app/(dashboard)/layout.tsx +++ b/frontend/app/(dashboard)/layout.tsx @@ -1,101 +1,62 @@ -'use client'; +"use client"; -import Link from 'next/link'; -import { usePathname } from 'next/navigation'; -import { cn } from '../../lib/utils'; -import { useAuthStore } from '../../stores/auth.store'; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { cn } from "@/lib/utils"; +import { ReactNode } from "react"; -const SHIPPER_NAV = [ - { href: '/dashboard', label: 'Dashboard' }, - { href: '/shipments', label: 'My Shipments' }, - { href: '/shipments/new', label: 'Create Shipment' }, -]; - -const CARRIER_NAV = [ - { href: '/dashboard', label: 'Dashboard' }, - { href: '/shipments', label: 'My Jobs' }, - { href: '/marketplace', label: 'Marketplace' }, -]; - -const ADMIN_NAV = [ - { href: '/dashboard', label: 'Dashboard' }, - { href: '/shipments', label: 'All Shipments' }, - { href: '/marketplace', label: 'Marketplace' }, -]; +interface DashboardLayoutProps { + children: ReactNode; +} -export default function DashboardLayout({ children }: { children: React.ReactNode }) { +export default function DashboardLayout({ children }: DashboardLayoutProps) { const pathname = usePathname(); - const { user, logout } = useAuthStore(); - - const navItems = - user?.role === 'carrier' - ? CARRIER_NAV - : user?.role === 'admin' - ? ADMIN_NAV - : SHIPPER_NAV; return ( -
+
{/* Sidebar */} -
); } 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. +

+
+
+
+ ); +} diff --git a/frontend/app/(dashboard)/shipments/[id]/page.tsx b/frontend/app/(dashboard)/shipments/[id]/page.tsx index ab601c1f..90f97631 100644 --- a/frontend/app/(dashboard)/shipments/[id]/page.tsx +++ b/frontend/app/(dashboard)/shipments/[id]/page.tsx @@ -1,5 +1,79 @@ 'use client'; +<<<<<<< HEAD +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}`); +======= import { useEffect, useState } from 'react'; import { useParams, useRouter } from 'next/navigation'; import { toast } from 'sonner'; @@ -45,11 +119,86 @@ export default function ShipmentDetailPage() { await reload(); } catch { toast.error('Action failed. Please try again.'); +>>>>>>> main } finally { setActionLoading(false); } }; +<<<<<<< HEAD + 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 */} +
+ +======= if (loading) { return (
@@ -299,6 +448,7 @@ export default function ShipmentDetailPage() {
+>>>>>>> main
); diff --git a/frontend/components/ShipmentCard.tsx b/frontend/components/ShipmentCard.tsx new file mode 100644 index 00000000..9c035c1b --- /dev/null +++ b/frontend/components/ShipmentCard.tsx @@ -0,0 +1,70 @@ +import React from 'react'; + +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, 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()}
  • + )} +
+ + {/* 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 new file mode 100644 index 00000000..6e187dff --- /dev/null +++ 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/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}`); + }, +}; diff --git a/frontend/services/shipmentApi.ts b/frontend/services/shipmentApi.ts new file mode 100644 index 00000000..b16cd44c --- /dev/null +++ b/frontend/services/shipmentApi.ts @@ -0,0 +1,26 @@ +import axios from 'axios'; + + +interface MarketplaceResponse { + data: Shipment[]; + totalPages: number; + totalCount: number; +} + +interface Shipment { + id: string; + origin: string; + destination: string; + weight?: number; + price?: number; + pickupDate?: string; +} + +export const shipmentApi = { + async marketplace(params?: { origin?: string; destination?: string; page?: number }): Promise { + const response = await axios.get('/api/shipments/marketplace', { + params, + }); + return response.data; + }, +};