diff --git a/apps/edr-freight-web/backoffice/.env.example b/apps/edr-freight-web/backoffice/.env.example index e0099a3fc..cbbdd289f 100644 --- a/apps/edr-freight-web/backoffice/.env.example +++ b/apps/edr-freight-web/backoffice/.env.example @@ -1 +1,2 @@ VITE_API_URL=http://localhost:3001 +VITE_BASE_API_URL=http://localhost:3001 diff --git a/apps/edr-freight-web/backoffice/index.css b/apps/edr-freight-web/backoffice/index.css new file mode 100644 index 000000000..4909e33bd --- /dev/null +++ b/apps/edr-freight-web/backoffice/index.css @@ -0,0 +1,2 @@ +@import "tailwindcss"; +@import "@edr/ui-common/theme.css" layer(theme); diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index f0ad134f1..7ae00f78b 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -12,27 +12,34 @@ "type-check": "tsc --noEmit" }, "dependencies": { - "@tria-plc/iamui-common": "1.0.3", "@edr/types": "workspace:*", "@edr/ui-common": "workspace:*", "@tanstack/react-query": "^5.59.0", + "@tria-plc/iamui-common": "1.1.1", "axios": "^1.7.7", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "react": "^18.3.1", - "react-dom": "^18.3.1", + "libphonenumber-js": "^1.12.24", + "lucide-react": "^1.14.0", + "radix-ui": "^1.4.3", + "react": "19.2.6", + "react-dom": "19.2.6", "react-router-dom": "^6.27.0", + "recharts": "^3.8.1", + "tailwind-merge": "^3.6.0", "zustand": "^5.0.0" }, "devDependencies": { "@edr/eslint-config": "workspace:*", "@edr/tsconfig": "workspace:*", + "@tailwindcss/vite": "^4.3.0", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.2", "autoprefixer": "^10.4.20", "jsdom": "^25.0.1", "postcss": "^8.4.47", - "tailwindcss": "^3.4.13", + "tailwindcss": "^4.3.0", "typescript": "^5.5.4", "vite": "^5.4.8", "vitest": "^2.1.2" diff --git a/apps/edr-freight-web/backoffice/public/assets/logo.svg b/apps/edr-freight-web/backoffice/public/assets/logo.svg new file mode 100644 index 000000000..377b70438 --- /dev/null +++ b/apps/edr-freight-web/backoffice/public/assets/logo.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/edr-freight-web/backoffice/public/assets/smart-office-logo.svg b/apps/edr-freight-web/backoffice/public/assets/smart-office-logo.svg new file mode 100644 index 000000000..97e5bf786 --- /dev/null +++ b/apps/edr-freight-web/backoffice/public/assets/smart-office-logo.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index f3e631ec2..ce4d05489 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -1,19 +1,50 @@ -import { - useNavigate, - useLocation, - Routes, - Route, - Navigate, -} from "react-router-dom"; +import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom"; import { DashboardLayout, type SidebarItem } from "@edr/ui-common"; +import { LayoutDashboard, ShieldCheck, Users, Building2 } from "lucide-react"; -import DashboardPage from "./pages/dashboard/DashboardPage"; +import { useAuth } from "./auth/useAuth"; +import LoginPage from "./pages/auth/LoginPage"; +import OverviewPage from "./pages/dashboard/OverviewPage"; +import UsersPage from "./pages/dashboard/user-management/UsersPage"; +import RolesPage from "./pages/dashboard/user-management/RolesPage"; +import DepartmentsPage from "./pages/dashboard/user-management/DepartmentsPage"; +import LoadingScreen from "./components/LoadingScreen"; -const sidebarItems: SidebarItem[] = [{ label: "Dashboard", href: "/" }]; +const sidebarItems: SidebarItem[] = [ + { + label: "Overview", + href: "/dashboard/overview", + icon: , + }, + { + label: "User management", + href: "/dashboard/user-management", + icon: , + children: [ + { + label: "Users", + href: "/dashboard/user-management/users", + icon: , + }, + { + label: "Roles", + href: "/dashboard/user-management/roles", + }, + { + label: "Departments", + href: "/dashboard/user-management/departments", + icon: , + }, + ], + }, +]; -const App = () => { +const DashboardShell = () => { const navigate = useNavigate(); const location = useLocation(); + const { user, logout } = useAuth(); + + const displayName = user?.name?.en || user?.username || user?.email || "User"; return ( { sidebarItems={sidebarItems} activeHref={location.pathname} onNavigate={navigate} + enableThemeToggle + userName={displayName} + userEmail={user?.email} + onLogout={logout} > - - } /> - } /> - + ); }; +const App = () => { + const { user, loading } = useAuth(); + + if (loading) { + return ; + } + + if (!user) { + return ( + + } /> + } /> + + ); + } + + return ( + + } /> + } /> + }> + } /> + } + /> + } /> + } /> + } + /> + + } /> + + ); +}; + export default App; diff --git a/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx b/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx new file mode 100644 index 000000000..d49a0e9d9 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/auth/AuthProvider.tsx @@ -0,0 +1,138 @@ +import { + createContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; + +import { getMeRequest, loginRequest, verifyMfaRequest } from "./api"; +import { + AUTH_TOKEN_COOKIE, + AUTH_USER_COOKIE, + clearSessionCookies, + getCookie, + setCookie, +} from "./cookies"; +import { applyTokens } from "./http"; +import type { AuthTokens, AuthUser } from "./types"; + +interface LoginPayload { + email: string; + password: string; +} + +interface VerifyMfaPayload { + email: string; + otp: string; +} + +export interface AuthContextValue { + user: AuthUser | null; + loading: boolean; + login: (payload: LoginPayload) => Promise<{ mfaRequired: boolean }>; + verifyMfa: (payload: VerifyMfaPayload) => Promise; + logout: () => void; +} + +export const AuthContext = createContext(null); + +const persistUser = (user: AuthUser | null) => { + if (user) { + setCookie(AUTH_USER_COOKIE, JSON.stringify(user)); + return; + } + + clearSessionCookies(); +}; + +const bootstrapCachedUser = (): AuthUser | null => { + const serialized = getCookie(AUTH_USER_COOKIE); + if (!serialized) { + return null; + } + + try { + return JSON.parse(serialized) as AuthUser; + } catch { + return null; + } +}; + +export const AuthProvider = ({ children }: { children: ReactNode }) => { + const [user, setUser] = useState(() => bootstrapCachedUser()); + const [loading, setLoading] = useState(true); + const mfaEmailRef = useRef(null); + + const loadCurrentUser = async () => { + const currentUser = await getMeRequest(); + setUser(currentUser); + persistUser(currentUser); + }; + + useEffect(() => { + ["auth-token", "refresh-token"].forEach((name) => { + const value = getCookie(name); + if (value === "undefined" || value === "null" || value === "") { + clearSessionCookies(); + } + }); + + const bootstrap = async () => { + const token = getCookie(AUTH_TOKEN_COOKIE); + if (!token) { + setLoading(false); + return; + } + + try { + await loadCurrentUser(); + } catch { + clearSessionCookies(); + setUser(null); + } finally { + setLoading(false); + } + }; + + void bootstrap(); + }, []); + + const value = useMemo( + () => ({ + user, + loading, + login: async ({ email, password }) => { + const result = await loginRequest({ email, password }); + + if (result.mfaRequired) { + mfaEmailRef.current = email; + return { mfaRequired: true }; + } + + const tokens = result as AuthTokens; + applyTokens(tokens); + await loadCurrentUser(); + mfaEmailRef.current = null; + return { mfaRequired: false }; + }, + verifyMfa: async ({ email, otp }) => { + const identifier = mfaEmailRef.current ?? email; + const tokens = await verifyMfaRequest({ email: identifier, otp }); + applyTokens(tokens); + await loadCurrentUser(); + mfaEmailRef.current = null; + }, + logout: () => { + clearSessionCookies(); + localStorage.clear(); + setUser(null); + window.location.replace("/auth"); + }, + }), + [loading, user], + ); + + return {children}; +}; diff --git a/apps/edr-freight-web/backoffice/src/auth/api.ts b/apps/edr-freight-web/backoffice/src/auth/api.ts new file mode 100644 index 000000000..8c5b11b54 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/auth/api.ts @@ -0,0 +1,23 @@ +import { api } from "./http"; +import type { AuthTokens, AuthUser, LoginResponse } from "./types"; + +export const loginRequest = async (payload: { + email: string; + password: string; +}) => { + const response = await api.post("/auth/login", payload); + return response.data; +}; + +export const verifyMfaRequest = async (payload: { + email: string; + otp: string; +}) => { + const response = await api.post("/auth/mfa-verify", payload); + return response.data; +}; + +export const getMeRequest = async () => { + const response = await api.get("/auth/me"); + return response.data; +}; diff --git a/apps/edr-freight-web/backoffice/src/auth/cookies.ts b/apps/edr-freight-web/backoffice/src/auth/cookies.ts new file mode 100644 index 000000000..0292f7f38 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/auth/cookies.ts @@ -0,0 +1,38 @@ +const DEFAULT_PATH = "/"; +const SEVEN_DAYS_IN_SECONDS = 60 * 60 * 24 * 7; + +export const AUTH_TOKEN_COOKIE = "auth-token"; +export const REFRESH_TOKEN_COOKIE = "refresh-token"; +export const AUTH_USER_COOKIE = "auth-user"; + +export const AUTH_COOKIE_MAX_AGE = SEVEN_DAYS_IN_SECONDS; + +export const getCookie = (name: string) => { + const match = document.cookie + .split("; ") + .find((entry) => entry.startsWith(`${name}=`)); + + return match ? decodeURIComponent(match.split("=").slice(1).join("=")) : null; +}; + +export const setCookie = ( + name: string, + value: string, + maxAge = AUTH_COOKIE_MAX_AGE, +) => { + document.cookie = `${name}=${encodeURIComponent(value)}; Max-Age=${maxAge}; path=${DEFAULT_PATH}; SameSite=Lax`; +}; + +export const clearCookie = (name: string) => { + document.cookie = `${name}=; Max-Age=0; path=${DEFAULT_PATH}`; +}; + +export const clearSessionCookies = () => { + [ + AUTH_TOKEN_COOKIE, + REFRESH_TOKEN_COOKIE, + AUTH_USER_COOKIE, + "current-position-id", + "selected-position-id", + ].forEach(clearCookie); +}; diff --git a/apps/edr-freight-web/backoffice/src/auth/http.ts b/apps/edr-freight-web/backoffice/src/auth/http.ts new file mode 100644 index 000000000..115a0d9d1 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/auth/http.ts @@ -0,0 +1,99 @@ +import axios from "axios"; + +import { + AUTH_TOKEN_COOKIE, + REFRESH_TOKEN_COOKIE, + clearSessionCookies, + getCookie, + setCookie, +} from "./cookies"; +import type { AuthTokens } from "./types"; + +type RetriableRequest = { + _retry?: boolean; + headers?: Record; + url?: string; +}; + +const api = axios.create({ + baseURL: `${import.meta.env.VITE_BASE_API_URL}/api`, + withCredentials: true, +}); + +let refreshPromise: Promise | null = null; + +const applyTokens = ({ token, refreshToken }: AuthTokens) => { + setCookie(AUTH_TOKEN_COOKIE, token); + setCookie(REFRESH_TOKEN_COOKIE, refreshToken); +}; + +api.interceptors.request.use((config) => { + const token = getCookie(AUTH_TOKEN_COOKIE); + + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + + return config; +}); + +api.interceptors.response.use( + (response) => { + if ( + response.data && + typeof response.data === "object" && + "success" in response.data && + "data" in response.data + ) { + response.data = response.data.data; + } + + return response; + }, + async (error) => { + const originalRequest = error.config as RetriableRequest | undefined; + + if ( + error.response?.status !== 401 || + !originalRequest || + originalRequest._retry || + originalRequest.url?.includes("/auth/login") || + originalRequest.url?.includes("/auth/mfa-verify") || + originalRequest.url?.includes("/auth/refresh-token") + ) { + return Promise.reject(error); + } + + const refreshToken = getCookie(REFRESH_TOKEN_COOKIE); + if (!refreshToken) { + clearSessionCookies(); + return Promise.reject(error); + } + + originalRequest._retry = true; + + try { + refreshPromise ??= api + .post("/auth/refresh-token", { refreshToken }) + .then((response) => response.data) + .finally(() => { + refreshPromise = null; + }); + + const tokens = await refreshPromise; + applyTokens(tokens); + originalRequest.headers = { + ...originalRequest.headers, + Authorization: `Bearer ${tokens.token}`, + }; + + return api(originalRequest); + } catch (refreshError) { + clearSessionCookies(); + window.location.replace("/auth"); + return Promise.reject(refreshError); + } + }, +); + +export { api, applyTokens }; diff --git a/apps/edr-freight-web/backoffice/src/auth/types.ts b/apps/edr-freight-web/backoffice/src/auth/types.ts new file mode 100644 index 000000000..736fd9a36 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/auth/types.ts @@ -0,0 +1,18 @@ +export interface AuthUser { + id?: string; + email?: string; + username?: string; + name?: { + en?: string; + am?: string; + }; +} + +export interface AuthTokens { + token: string; + refreshToken: string; +} + +export interface LoginResponse extends Partial { + mfaRequired?: boolean; +} diff --git a/apps/edr-freight-web/backoffice/src/auth/useAuth.ts b/apps/edr-freight-web/backoffice/src/auth/useAuth.ts new file mode 100644 index 000000000..e454bc509 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/auth/useAuth.ts @@ -0,0 +1,13 @@ +import { useContext } from "react"; + +import { AuthContext } from "./AuthProvider"; + +export const useAuth = () => { + const context = useContext(AuthContext); + + if (!context) { + throw new Error("useAuth must be used within AuthProvider"); + } + + return context; +}; diff --git a/apps/edr-freight-web/backoffice/src/components/FeaturePlaceholder.tsx b/apps/edr-freight-web/backoffice/src/components/FeaturePlaceholder.tsx new file mode 100644 index 000000000..56807deba --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/FeaturePlaceholder.tsx @@ -0,0 +1,25 @@ +interface FeaturePlaceholderProps { + title: string; + description: string; +} + +const FeaturePlaceholder = ({ + title, + description, +}: FeaturePlaceholderProps) => { + return ( +
+
+

+ EDR Freight Backoffice +

+

{title}

+

+ {description} +

+
+
+ ); +}; + +export default FeaturePlaceholder; diff --git a/apps/edr-freight-web/backoffice/src/components/LoadingScreen.tsx b/apps/edr-freight-web/backoffice/src/components/LoadingScreen.tsx new file mode 100644 index 000000000..aedae2f7c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/LoadingScreen.tsx @@ -0,0 +1,14 @@ +const LoadingScreen = () => { + return ( +
+
+

+ EDR Freight Backoffice +

+

Loading...

+
+
+ ); +}; + +export default LoadingScreen; diff --git a/apps/edr-freight-web/backoffice/src/main.tsx b/apps/edr-freight-web/backoffice/src/main.tsx index 67557625f..819cbfcec 100644 --- a/apps/edr-freight-web/backoffice/src/main.tsx +++ b/apps/edr-freight-web/backoffice/src/main.tsx @@ -1,18 +1,46 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import "@edr/ui-common/styles.css"; +import "../index.css"; +import "@edr/ui-common/theme.css"; import App from "./App"; +import { AuthProvider } from "./auth/AuthProvider"; -const queryClient = new QueryClient(); +const THEME_STORAGE_KEY = "edr-theme"; -createRoot(document.getElementById("root")!).render( +const applyStoredTheme = () => { + const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY); + const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches; + const theme = storedTheme === "dark" || storedTheme === "light" + ? storedTheme + : prefersDark + ? "dark" + : "light"; + + if (theme === "dark") { + document.documentElement.classList.add("dark"); + return; + } + + document.documentElement.classList.remove("dark"); +}; + +applyStoredTheme(); + +const rootElement = document.getElementById("root"); + +if (!rootElement) { + throw new Error("Root element not found"); +} + +createRoot(rootElement).render( - - + + - - + + , ); diff --git a/apps/edr-freight-web/backoffice/src/pages/admin/DropdownSettingsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/admin/DropdownSettingsPage.tsx new file mode 100644 index 000000000..a78728c3c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/admin/DropdownSettingsPage.tsx @@ -0,0 +1,12 @@ +import FeaturePlaceholder from "@/components/FeaturePlaceholder"; + +const DropdownSettingsPage = () => { + return ( + + ); +}; + +export default DropdownSettingsPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/admin/FileUploadSettingsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/admin/FileUploadSettingsPage.tsx new file mode 100644 index 000000000..6c14156b8 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/admin/FileUploadSettingsPage.tsx @@ -0,0 +1,12 @@ +import FeaturePlaceholder from "@/components/FeaturePlaceholder"; + +const FileUploadSettingsPage = () => { + return ( + + ); +}; + +export default FileUploadSettingsPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx b/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx new file mode 100644 index 000000000..82e5757a8 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx @@ -0,0 +1,281 @@ +import { type FormEvent, useState } from "react"; +import { parsePhoneNumberFromString } from "libphonenumber-js"; +import { Eye, EyeOff, Mail, Smartphone, UserRound } from "lucide-react"; +import { useNavigate } from "react-router-dom"; + +import { useAuth } from "@/auth/useAuth"; + +type LoginMode = "email" | "phone" | "username"; + +const loginModes: Array<{ + value: LoginMode; + label: string; + icon: typeof Mail; + placeholder: string; +}> = [ + { value: "email", label: "Email", icon: Mail, placeholder: "name@company.com" }, + { value: "phone", label: "Phone", icon: Smartphone, placeholder: "09XXXXXXXX" }, + { value: "username", label: "Username", icon: UserRound, placeholder: "username" }, +]; + +const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const usernamePattern = /^[a-zA-Z0-9._-]{3,32}$/; + +const normalizeIdentifier = (mode: LoginMode, value: string) => { + const trimmed = value.trim(); + + if (mode === "email") { + if (!emailPattern.test(trimmed.toLowerCase())) { + throw new Error("Enter a valid email address."); + } + + return trimmed.toLowerCase(); + } + + if (mode === "phone") { + const parsed = parsePhoneNumberFromString(trimmed, "ET"); + + if (!parsed?.isValid()) { + throw new Error("Enter a valid Ethiopian phone number."); + } + + return parsed.number; + } + + if (!usernamePattern.test(trimmed)) { + throw new Error("Username must be 3-32 characters and use letters, numbers, ., _, or -."); + } + + return trimmed; +}; + +const LoginPage = () => { + const navigate = useNavigate(); + const { login, verifyMfa } = useAuth(); + const [mode, setMode] = useState("email"); + const [identifier, setIdentifier] = useState(""); + const [password, setPassword] = useState(""); + const [otp, setOtp] = useState(""); + const [needsMfa, setNeedsMfa] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const [normalizedIdentifier, setNormalizedIdentifier] = useState(""); + const [error, setError] = useState(null); + + const currentMode = loginModes.find((item) => item.value === mode)!; + const ModeIcon = currentMode.icon; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + setSubmitting(true); + setError(null); + + try { + const normalized = normalizeIdentifier(mode, identifier); + setNormalizedIdentifier(normalized); + + const result = await login({ email: normalized, password }); + if (result.mfaRequired) { + setNeedsMfa(true); + return; + } + + navigate("/dashboard/overview", { replace: true }); + } catch { + setError("Unable to sign in with those credentials."); + } finally { + setSubmitting(false); + } + }; + + const handleVerifyMfa = async (event: FormEvent) => { + event.preventDefault(); + setSubmitting(true); + setError(null); + + try { + await verifyMfa({ email: normalizedIdentifier, otp: otp.trim() }); + navigate("/dashboard/overview", { replace: true }); + } catch { + setError("Unable to verify the one-time code."); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+
+
+
+ EDR +
+

+ Freight operations +

+

+ Backoffice access for internal freight administration. +

+

+ Review operational activity, manage access, and coordinate internal railway workflows from a single dashboard. +

+
+ +
+
+

Region

+

Ethiopia default phone normalization

+
+
+

Session

+

7-day persistent token cookie

+
+
+

Access

+

Overview and user management

+
+
+
+ +
+
+ {!needsMfa ? ( +
+
+

+ Sign in +

+

+ EDR Backoffice +

+

+ Use your email, phone number, or username to access the internal freight dashboard. +

+
+ +
+ + + + + +
+ + {error ? ( +
+ {error} +
+ ) : null} + + +
+ ) : ( +
+
+

+ Multi-factor verification +

+

+ Confirm one-time code +

+

+ We sent a verification code for {normalizedIdentifier}. Enter it below to complete sign in. +

+
+ + + + {error ? ( +
+ {error} +
+ ) : null} + +
+ + +
+
+ )} +
+
+
+
+ ); +}; + +export default LoginPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/billing/BillingPage.tsx b/apps/edr-freight-web/backoffice/src/pages/billing/BillingPage.tsx new file mode 100644 index 000000000..3b61afa37 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/billing/BillingPage.tsx @@ -0,0 +1,12 @@ +import FeaturePlaceholder from "@/components/FeaturePlaceholder"; + +const BillingPage = () => { + return ( + + ); +}; + +export default BillingPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx new file mode 100644 index 000000000..da9bf695c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingDetailPage.tsx @@ -0,0 +1,12 @@ +import FeaturePlaceholder from "@/components/FeaturePlaceholder"; + +const BookingDetailPage = () => { + return ( + + ); +}; + +export default BookingDetailPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingsPage.tsx new file mode 100644 index 000000000..7a1736863 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingsPage.tsx @@ -0,0 +1,12 @@ +import FeaturePlaceholder from "@/components/FeaturePlaceholder"; + +const BookingsPage = () => { + return ( + + ); +}; + +export default BookingsPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx new file mode 100644 index 000000000..fdaea259f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx @@ -0,0 +1,12 @@ +import FeaturePlaceholder from "@/components/FeaturePlaceholder"; + +const NewBookingPage = () => { + return ( + + ); +}; + +export default NewBookingPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/consignments/ConsignmentDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/consignments/ConsignmentDetailPage.tsx new file mode 100644 index 000000000..028ce9c86 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/consignments/ConsignmentDetailPage.tsx @@ -0,0 +1,12 @@ +import FeaturePlaceholder from "@/components/FeaturePlaceholder"; + +const ConsignmentDetailPage = () => { + return ( + + ); +}; + +export default ConsignmentDetailPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/consignments/ConsignmentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/consignments/ConsignmentsPage.tsx new file mode 100644 index 000000000..6003ae0fd --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/consignments/ConsignmentsPage.tsx @@ -0,0 +1,12 @@ +import FeaturePlaceholder from "@/components/FeaturePlaceholder"; + +const ConsignmentsPage = () => { + return ( + + ); +}; + +export default ConsignmentsPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx new file mode 100644 index 000000000..1c1571966 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -0,0 +1,12 @@ +import FeaturePlaceholder from "@/components/FeaturePlaceholder"; + +const CustomerDetailPage = () => { + return ( + + ); +}; + +export default CustomerDetailPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx new file mode 100644 index 000000000..8d9158381 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx @@ -0,0 +1,12 @@ +import FeaturePlaceholder from "@/components/FeaturePlaceholder"; + +const CustomersPage = () => { + return ( + + ); +}; + +export default CustomersPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/NewCustomerPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/NewCustomerPage.tsx new file mode 100644 index 000000000..7aae5df33 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/customers/NewCustomerPage.tsx @@ -0,0 +1,12 @@ +import FeaturePlaceholder from "@/components/FeaturePlaceholder"; + +const NewCustomerPage = () => { + return ( + + ); +}; + +export default NewCustomerPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/DashboardPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/DashboardPage.tsx deleted file mode 100644 index 88609d715..000000000 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/DashboardPage.tsx +++ /dev/null @@ -1,10 +0,0 @@ -const DashboardPage = () => { - return ( -
-

EDR Freight Backoffice

-

Backoffice — coming soon.

-
- ); -}; - -export default DashboardPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx new file mode 100644 index 000000000..da8146f8d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx @@ -0,0 +1,12 @@ +import FeaturePlaceholder from "@/components/FeaturePlaceholder"; + +const OverviewPage = () => { + return ( + + ); +}; + +export default OverviewPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/DepartmentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/DepartmentsPage.tsx new file mode 100644 index 000000000..4d2e50920 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/DepartmentsPage.tsx @@ -0,0 +1,12 @@ +import FeaturePlaceholder from "@/components/FeaturePlaceholder"; + +const DepartmentsPage = () => { + return ( + + ); +}; + +export default DepartmentsPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/RolesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/RolesPage.tsx new file mode 100644 index 000000000..fd02787cf --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/RolesPage.tsx @@ -0,0 +1,12 @@ +import FeaturePlaceholder from "@/components/FeaturePlaceholder"; + +const RolesPage = () => { + return ( + + ); +}; + +export default RolesPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UsersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UsersPage.tsx new file mode 100644 index 000000000..0fcfc8d65 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UsersPage.tsx @@ -0,0 +1,12 @@ +import FeaturePlaceholder from "@/components/FeaturePlaceholder"; + +const UsersPage = () => { + return ( + + ); +}; + +export default UsersPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/documents/DocumentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/documents/DocumentsPage.tsx new file mode 100644 index 000000000..356c890c2 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/documents/DocumentsPage.tsx @@ -0,0 +1,12 @@ +import FeaturePlaceholder from "@/components/FeaturePlaceholder"; + +const DocumentsPage = () => { + return ( + + ); +}; + +export default DocumentsPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/tracking/TrackingPage.tsx b/apps/edr-freight-web/backoffice/src/pages/tracking/TrackingPage.tsx new file mode 100644 index 000000000..14fc66eea --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/tracking/TrackingPage.tsx @@ -0,0 +1,12 @@ +import FeaturePlaceholder from "@/components/FeaturePlaceholder"; + +const TrackingPage = () => { + return ( + + ); +}; + +export default TrackingPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/trains/TrainsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trains/TrainsPage.tsx new file mode 100644 index 000000000..a9d12eb6a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/trains/TrainsPage.tsx @@ -0,0 +1,12 @@ +import FeaturePlaceholder from "@/components/FeaturePlaceholder"; + +const TrainsPage = () => { + return ( + + ); +}; + +export default TrainsPage; diff --git a/apps/edr-freight-web/backoffice/src/vite-env.d.ts b/apps/edr-freight-web/backoffice/src/vite-env.d.ts index 11f02fe2a..f24da9f58 100644 --- a/apps/edr-freight-web/backoffice/src/vite-env.d.ts +++ b/apps/edr-freight-web/backoffice/src/vite-env.d.ts @@ -1 +1,24 @@ /// + +interface Window { + __IAM_CONFIG__?: { + apiUrl: string; + postLoginPath?: string; + }; + __USER_MANAGEMENT_BRANDING__?: { + organizationName: string; + appName: string; + moduleBasePath: string; + backToAppPath?: string; + backToAppLabel?: string; + }; +} + +interface ImportMetaEnv { + readonly VITE_API_URL: string; + readonly VITE_BASE_API_URL: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/apps/edr-freight-web/backoffice/tsconfig.app.json b/apps/edr-freight-web/backoffice/tsconfig.app.json index 73df43221..ff909c216 100644 --- a/apps/edr-freight-web/backoffice/tsconfig.app.json +++ b/apps/edr-freight-web/backoffice/tsconfig.app.json @@ -3,7 +3,11 @@ "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "useDefineForClassFields": true, - "skipLibCheck": true + "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } }, "include": ["src"] } diff --git a/apps/edr-freight-web/backoffice/vite.config.ts b/apps/edr-freight-web/backoffice/vite.config.ts index d1e6b8a86..cf7083258 100644 --- a/apps/edr-freight-web/backoffice/vite.config.ts +++ b/apps/edr-freight-web/backoffice/vite.config.ts @@ -1,8 +1,19 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); export default defineConfig({ - plugins: [react()], + plugins: [react(), tailwindcss()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, server: { port: 5183, host: "0.0.0.0", diff --git a/apps/edr-freight-web/portal/package.json b/apps/edr-freight-web/portal/package.json index 81a34f736..9afc896f7 100644 --- a/apps/edr-freight-web/portal/package.json +++ b/apps/edr-freight-web/portal/package.json @@ -14,6 +14,7 @@ "dependencies": { "@edr/types": "workspace:*", "@edr/ui-common": "workspace:*", + "@hookform/resolvers": "^5.4.0", "@tanstack/react-query": "^5.59.0", "@tria-plc/iamui-common": "1.1.1", "axios": "^1.7.7", @@ -23,9 +24,11 @@ "radix-ui": "^1.4.3", "react": "19.2.6", "react-dom": "19.2.6", + "react-hook-form": "^7.76.0", "react-router-dom": "^6.27.0", "recharts": "^3.8.1", "tailwind-merge": "^3.6.0", + "zod": "^4.4.3", "zustand": "^5.0.0" }, "devDependencies": { diff --git a/apps/edr-freight-web/portal/src/components/dynamic-select/DynamicSelect.tsx b/apps/edr-freight-web/portal/src/components/dynamic-select/DynamicSelect.tsx new file mode 100644 index 000000000..2003457f0 --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/dynamic-select/DynamicSelect.tsx @@ -0,0 +1,99 @@ +import { useQuery } from "@tanstack/react-query"; +import { cn } from "@/lib/utils"; +import { api } from "@/services/api"; +import { Loader2, AlertCircle } from "lucide-react"; +import * as React from "react"; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@edr/ui-common"; + +export interface DynamicSelectProps { + code: string; + placeholder?: string; + value?: string; + onValueChange?: (value: string) => void; + disabled?: boolean; + className?: string; +} + +export function DynamicSelect({ + code, + placeholder, + value, + onValueChange, + disabled, + className, +}: DynamicSelectProps) { + const { data, isLoading, isError } = useQuery( + api.dropdownSettings.getByCode.queryOptions({ input: { code } }), + ); + + if (isLoading) { + return ( +
+ + Loading... +
+ ); + } + + if (isError || !data) { + return ( +
+ + Failed to load options +
+ ); + } + + const options = [...data.children].sort((a, b) => a.order - b.order); + + return ( + + ); +} diff --git a/apps/edr-freight-web/portal/src/components/dynamic-select/index.ts b/apps/edr-freight-web/portal/src/components/dynamic-select/index.ts new file mode 100644 index 000000000..4e6c58b62 --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/dynamic-select/index.ts @@ -0,0 +1,2 @@ +export { DynamicSelect } from "./DynamicSelect"; +export type { DynamicSelectProps } from "./DynamicSelect"; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx index cd1bd1e99..7eb038196 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage.tsx @@ -14,14 +14,9 @@ import { } from "lucide-react"; import Breadcrumbs from "@/components/Breadcrumbs"; -import NewBookingPage from "./NewBookingPage"; import DeleteBookingDialog from "./DeleteBookingDialog"; import { getBookingById, type BookingStatus } from "./bookings.mock"; -import { - Button, - Card, - CardContent, -} from "@edr/ui-common"; +import { Button, Card } from "@edr/ui-common"; export default function BookingDetailPage() { const { id } = useParams<{ id: string }>(); @@ -89,26 +84,7 @@ export default function BookingDetailPage() {
- - - + - {booking.transportMode === "Multimodal" && booking.legs && booking.legs.length > 0 ? ( + {booking.transportMode === "Multimodal" && + booking.legs && + booking.legs.length > 0 ? (
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingsPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingsPage.tsx index e755c174b..ad7a6cf92 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingsPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingsPage.tsx @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { useNavigate } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import { ArrowRight, Clock, @@ -15,7 +15,6 @@ import { } from "lucide-react"; import Breadcrumbs from "@/components/Breadcrumbs"; -import NewBookingPage from "./NewBookingPage"; import DeleteBookingDialog from "./DeleteBookingDialog"; import { bookings, type BookingStatus } from "./bookings.mock"; import { @@ -125,29 +124,6 @@ export default function BookingsPage() { View - - e.preventDefault()}> - - Edit - -
- + - +
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index 6d127eb70..e23b44cb2 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -1,1301 +1,124 @@ -import { Fragment, useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; import { useNavigate } from "react-router-dom"; -import { - AlertTriangle, - Check, - CheckCircle2, - ChevronLeft, - ChevronRight, - FileText, - Flame, - Info, - Loader2, - MapPin, - Package, - RefreshCw, - Snowflake, - Train, - Weight, - XCircle, -} from "lucide-react"; -import { - Badge, - Button, - Card, - CardContent, - CardHeader, - CardTitle, - Input, - Label, - SmartFileInput, - Textarea, -} from "@edr/ui-common"; +import { CheckCircle2, ChevronLeft, ChevronRight } from "lucide-react"; +import { Button } from "@edr/ui-common"; import Breadcrumbs from "@/components/Breadcrumbs"; - -// ── Mock Constants ───────────────────────────────────────────────────────────── - -const STATIONS = [ - "Addis Ababa", "Adama", "Mojo", "Awash", "Mieso", - "Dire Dawa", "Aysha", "Ali Sabieh", "Holhol", "Djibouti City", -]; - -const ETHIOPIA_STATIONS = new Set([ - "Addis Ababa", "Adama", "Mojo", "Awash", "Mieso", "Dire Dawa", -]); - -const BULK_COMMODITIES = [ - "Coffee", "Beans", "Fertilizer", "Sugar", "Oil", "Livestock", "Steel", "Others", -]; - -const BREAK_BULK_TYPES = ["Machinery", "Ro-Ro", "Others"]; - -const MOCK_VALID_CONTRACTS = [ - "EDR-2024-10001", "EDR-2024-10002", "EDR-2023-88123", "EDR-2022-55442", -]; - -// US-04 compliance documents (TIN, Business License, Registration, National ID, optional PoA) -const BOOKING_DOCS_SETTING = { - id: "booking-compliance", - createdAt: "", - updatedAt: "", - deletedAt: null, - code: "booking_compliance_docs", - label: "Compliance Documents", - description: - "Upload your company's legal credentials. All mandatory documents must be submitted before the contract request can be reviewed by EDR Line Staff.", - entity: "booking" as const, - fields: [ - { - id: "f1", createdAt: "", updatedAt: "", deletedAt: null, - settingId: "booking-compliance", - fileKey: "tin_certificate", - fileLabel: "TIN Certificate", - helpText: "Tax Identification Number certificate issued by ERCA (10-digit TIN).", - isRequired: true, - isMultiple: false, - maxFiles: 1, - allowedExtensions: ["pdf", "jpg", "jpeg", "png"], - maxSizeMb: 5, - order: 1, - }, - { - id: "f2", createdAt: "", updatedAt: "", deletedAt: null, - settingId: "booking-compliance", - fileKey: "business_license", - fileLabel: "Business / Investment License", - helpText: "Current business or investment license issued by the relevant government authority.", - isRequired: true, - isMultiple: false, - maxFiles: 1, - allowedExtensions: ["pdf", "jpg", "jpeg", "png"], - maxSizeMb: 5, - order: 2, - }, - { - id: "f3", createdAt: "", updatedAt: "", deletedAt: null, - settingId: "booking-compliance", - fileKey: "business_registration", - fileLabel: "Business Registration Certificate", - helpText: "Certificate of registration from the relevant authority.", - isRequired: true, - isMultiple: false, - maxFiles: 1, - allowedExtensions: ["pdf", "jpg", "jpeg", "png"], - maxSizeMb: 5, - order: 3, - }, - { - id: "f4", createdAt: "", updatedAt: "", deletedAt: null, - settingId: "booking-compliance", - fileKey: "national_id", - fileLabel: "National ID / Passport", - helpText: "Valid government-issued ID or passport of the authorized signatory.", - isRequired: true, - isMultiple: false, - maxFiles: 1, - allowedExtensions: ["pdf", "jpg", "jpeg", "png"], - maxSizeMb: 5, - order: 4, - }, - { - id: "f5", createdAt: "", updatedAt: "", deletedAt: null, - settingId: "booking-compliance", - fileKey: "power_of_attorney", - fileLabel: "Power of Attorney (PoA)", - helpText: "Required only if a representative is signing on behalf of the company.", - isRequired: false, - isMultiple: false, - maxFiles: 1, - allowedExtensions: ["pdf", "jpg", "jpeg", "png"], - maxSizeMb: 5, - order: 5, - }, - ], -}; - -const REQUIRED_DOC_KEYS = [ - "tin_certificate", - "business_license", - "business_registration", - "national_id", -]; - -const STEPS = [ - { id: 1, label: "Contract Type", short: "Contract" }, - { id: 2, label: "Service Type", short: "Service" }, - { id: 3, label: "First & Last Mile", short: "Mile" }, - { id: 4, label: "Route & Cargo", short: "Cargo" }, - { id: 5, label: "Container Config", short: "Container" }, - { id: 6, label: "Wagon Allocation", short: "Wagons" }, - { id: 7, label: "Documents", short: "Docs" }, - { id: 8, label: "Review & Submit", short: "Submit" }, -] as const; - -// ── Types ────────────────────────────────────────────────────────────────────── - -type ContractTypeVal = "new" | "renewal" | ""; -type ServiceTypeVal = "rail" | "rail_forwarding" | ""; -type FreightTypeVal = "bulk" | "break_bulk" | ""; -type ContainerTypeVal = "20ft" | "40ft" | ""; -type EquipmentReturn = "with_return" | "without_return"; - -interface FormData { - contractType: ContractTypeVal; - previousContractRef: string; - draftContractId: string; - serviceType: ServiceTypeVal; - firstMileEnabled: boolean; - pickUpAddress: string; - lastMileEnabled: boolean; - deliveryAddress: string; - equipmentReturn: EquipmentReturn; - originYard: string; - destinationYard: string; - cargoWeight: string; - freightType: FreightTypeVal; - bulkCommodity: string; - bulkCommodityOther: string; - breakBulkType: string; - breakBulkTypeOther: string; - isHazardous: boolean; - isRefrigerated: boolean; - containerType: ContainerTypeVal; - quantity: string; - vgm: string; - consolidationEnabled: boolean; - documents: Record; - notes: string; - termsAccepted: boolean; -} - -const INITIAL_DATA: FormData = { - contractType: "", - previousContractRef: "", - draftContractId: "", - serviceType: "", - firstMileEnabled: false, - pickUpAddress: "", - lastMileEnabled: false, - deliveryAddress: "", - equipmentReturn: "with_return", - originYard: "", - destinationYard: "", - cargoWeight: "", - freightType: "", - bulkCommodity: "", - bulkCommodityOther: "", - breakBulkType: "", - breakBulkTypeOther: "", - isHazardous: false, - isRefrigerated: false, - containerType: "", - quantity: "1", - vgm: "", - consolidationEnabled: false, - documents: {}, - notes: "", - termsAccepted: false, -}; - -// ── Helpers ──────────────────────────────────────────────────────────────────── - -function genContractId(): string { - const yr = new Date().getFullYear(); - const n = Math.floor(10000 + Math.random() * 90000); - return `EDR-DRAFT-${yr}-${n}`; -} - -type RouteDirection = "import" | "export" | "domestic" | null; - -function getRouteDirection(origin: string, dest: string): RouteDirection { - if (!origin || !dest) return null; - const oEth = ETHIOPIA_STATIONS.has(origin); - const dEth = ETHIOPIA_STATIONS.has(dest); - if (oEth && !dEth) return "export"; - if (!oEth && dEth) return "import"; - if (oEth && dEth) return "domestic"; - return null; -} - -interface WagonCalcResult { - totalWagons: number; - hasOddUnit: boolean; - sharedWagons: number; -} - -function calcWagons(type: ContainerTypeVal, qty: number): WagonCalcResult { - if (type === "40ft") return { totalWagons: qty, hasOddUnit: false, sharedWagons: qty }; - const pairs = Math.floor(qty / 2); - const odd = qty % 2; - return { totalWagons: pairs + odd, hasOddUnit: odd > 0, sharedWagons: pairs }; -} - -// ── Shared primitives ────────────────────────────────────────────────────────── - -const selectCls = - "w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground shadow-xs outline-none transition hover:border-ring/50 focus:border-primary/50 focus:ring-2 focus:ring-primary/20"; - -function NativeSelect({ - value, onChange, options, placeholder, -}: { - value: string; - onChange: (v: string) => void; - options: { value: string; label: string }[]; - placeholder?: string; -}) { - return ( - - ); -} - -function OptionCard({ - selected, onClick, disabled, children, -}: { - selected: boolean; - onClick?: () => void; - disabled?: boolean; - children: React.ReactNode; -}) { - return ( - - ); -} - -function Toggle({ enabled, onChange }: { enabled: boolean; onChange: (v: boolean) => void }) { - return ( - - ); -} - -function AlertBox({ tone, children }: { - tone: "warning" | "error" | "success" | "info"; - children: React.ReactNode; -}) { - const s = { - warning: "bg-amber-50 border-amber-200 text-amber-800", - error: "bg-red-50 border-red-200 text-red-800", - success: "bg-emerald-50 border-emerald-200 text-emerald-800", - info: "bg-sky-50 border-sky-200 text-sky-800", - }; - const icons = { - warning: , - error: , - success: , - info: , - }; - return ( -
- {icons[tone]} -
{children}
-
- ); -} - -function Field({ label, children }: { label: string; children: React.ReactNode }) { - return ( -
- - {children} -
- ); -} - -function Divider() { - return
; -} - -function StepLabel({ children }: { children: React.ReactNode }) { - return ( -

- {children} -

- ); -} - -// ── Step 1: Contract Type ────────────────────────────────────────────────────── - -function Step1({ - d, set, renewalValid, renewalValidating, onValidate, -}: { - d: FormData; - set: (k: K, v: FormData[K]) => void; - renewalValid: boolean | null; - renewalValidating: boolean; - onValidate: () => void; -}) { - return ( -
-
-

Contract Type

-

- New contract or renewal of an existing one. -

-
- -
- { - set("contractType", "new"); - if (!d.draftContractId) set("draftContractId", genContractId()); - }} - > -
- -
-

New Contract

-

- Blank contract form. A draft ID is auto-generated. -

- {d.contractType === "new" && d.draftContractId && ( -

- {d.draftContractId} -

- )} -
- - set("contractType", "renewal")} - > -
- -
-

Contract Renewal

-

- Enter a previous reference to auto-populate historical parameters. -

-
-
- - {d.contractType === "renewal" && ( -
- -
- set("previousContractRef", e.target.value)} - className="font-mono" - /> - -
-
- {renewalValid === true && ( - - Contract found. Company details, route, and wagon preferences will - be pre-filled. - - )} - {renewalValid === false && ( - - Contract Reference Number not found or unauthorized. Try{" "} - EDR-2024-10001. - - )} -
- )} -
- ); -} - -// ── Step 2: Service Type ─────────────────────────────────────────────────────── - -function Step2({ - d, set, -}: { - d: FormData; - set: (k: K, v: FormData[K]) => void; -}) { - return ( -
-
-

Service Type

-

- Select the service combination you require. -

-
- -
- set("serviceType", "rail")}> -
- -
-

Rail Transport Only

-

- Rail transport along the EDR corridor, with optional first/last mile trucking. -

- - Option A - -
- - set("serviceType", "rail_forwarding")} - > -
- -
-

Rail Transport & Freight Forwarding

-

- Rail transport plus documentation, customs liaison, and a dedicated coordinator. -

- - Option B - -
-
- -

- Customs and Clearance Service cannot be selected independently — it must be bundled with - a Rail Transport service. -

-
- ); -} - -// ── Step 3: First & Last Mile ────────────────────────────────────────────────── - -function Step3({ - d, set, -}: { - d: FormData; - set: (k: K, v: FormData[K]) => void; -}) { - return ( -
-
-

First & Last Mile

-

- Configure trucking and container return options. -

-
- -
- {/* First Mile */} -
-
-
-

First Mile – Pick-up

-

- Truck pick-up from your premises to the origin rail yard. -

-
- set("firstMileEnabled", v)} /> -
- {d.firstMileEnabled && ( -
- set("pickUpAddress", e.target.value)} - /> -
- )} -
- - {/* Last Mile */} -
-
-
-

Last Mile – Delivery

-

- Truck delivery from the destination rail yard to the final address. -

-
- set("lastMileEnabled", v)} /> -
- {d.lastMileEnabled && ( -
- set("deliveryAddress", e.target.value)} - /> -
- )} -
- - {/* Equipment Return */} -
-

Equipment Return

-

- Declare whether the container asset will be returned after unloading. -

-
- set("equipmentReturn", "with_return")} - > -

With Return

-

- Container returned to EDR after unloading. -

-
- set("equipmentReturn", "without_return")} - > -

Without Return

-

- Container retained by the customer after delivery. -

-
-
-
-
-
- ); -} - -// ── Step 4: Route & Cargo ────────────────────────────────────────────────────── - -function Step4({ - d, set, -}: { - d: FormData; - set: (k: K, v: FormData[K]) => void; -}) { - const direction = getRouteDirection(d.originYard, d.destinationYard); - const directionStyle: Record = { - export: "bg-sky-50 text-sky-800 border-sky-200", - import: "bg-amber-50 text-amber-800 border-amber-200", - domestic: "bg-muted text-muted-foreground border-border", - }; - const directionLabel: Record = { - export: "Export workflow (Ethiopia → Djibouti)", - import: "Import workflow (Djibouti → Ethiopia)", - domestic: "Domestic corridor", - }; - - return ( -
-
-

Route & Cargo

-

- Define the route, weight, and cargo classification. -

-
- - {/* Route */} -
- Route -
- - set("originYard", v)} - options={STATIONS.filter((s) => s !== d.destinationYard).map((s) => ({ - value: s, label: s, - }))} - placeholder="Select origin..." - /> - - - set("destinationYard", v)} - options={STATIONS.filter((s) => s !== d.originYard).map((s) => ({ - value: s, label: s, - }))} - placeholder="Select destination..." - /> - -
- {direction && ( -
- - {directionLabel[direction]} -
- )} -
- - - - {/* Weight */} -
- Weight - -
- - set("cargoWeight", e.target.value)} - className="pl-9" - min="0" - step="0.01" - /> -
-
-
- - - - {/* Freight Classification */} -
- Freight Type * -
- set("freightType", "bulk")} - > -

Bulk

-

- Coffee, fertilizer, grain, ore, etc. -

-
- set("freightType", "break_bulk")} - > -

Break-Bulk

-

- Machinery, vehicles, project cargo, etc. -

-
-
- - {d.freightType === "bulk" && ( -
- { set("bulkCommodity", v); if (v !== "Others") set("bulkCommodityOther", ""); }} - options={BULK_COMMODITIES.map((c) => ({ value: c, label: c }))} - placeholder="Select commodity *" - /> - {d.bulkCommodity === "Others" && ( - set("bulkCommodityOther", e.target.value)} - /> - )} -
- )} - - {d.freightType === "break_bulk" && ( -
- { set("breakBulkType", v); if (v !== "Others") set("breakBulkTypeOther", ""); }} - options={BREAK_BULK_TYPES.map((c) => ({ value: c, label: c }))} - placeholder="Select type *" - /> - {d.breakBulkType === "Others" && ( - set("breakBulkTypeOther", e.target.value)} - /> - )} -
- )} -
- - - - {/* Modifiers */} -
-
-
- -
-

Hazardous Material

-

- Applies a Hazard Surcharge to the final bill. -

-
-
- set("isHazardous", v)} /> -
-
-
- -
-

Refrigerated Cargo

-

- Temperature-controlled transport — applies a Refrigerator Surcharge. -

-
-
- set("isRefrigerated", v)} /> -
-
-
- ); -} - -// ── Step 5: Container Configuration ─────────────────────────────────────────── - -function Step5({ - d, set, direction, -}: { - d: FormData; - set: (k: K, v: FormData[K]) => void; - direction: RouteDirection; -}) { - const qty = parseInt(d.quantity) || 1; - const vgm = parseFloat(d.vgm) || 0; - - let overweightAlert: string | null = null; - if (d.containerType === "20ft" && vgm > 0) { - const limit = direction === "export" ? 25 : 20; - if (vgm > limit) - overweightAlert = `VGM ${vgm}t exceeds the ${limit}t ${direction ?? "standard"} limit for a 20ft container. An Overweight Surcharge will apply.`; - } - if (d.containerType === "40ft" && vgm > 32.5) - overweightAlert = `VGM ${vgm}t exceeds the 32.5t global limit for a 40ft container. An Overweight Surcharge will apply.`; - - return ( -
-
-

Container Configuration

-

- Select container type, quantity, and Verified Gross Mass per container. -

-
- -
- {([ - { - val: "20ft" as const, - label: "20ft Container (TEU)", - limit: direction === "export" ? "Max 25t per container" : "Max 20t per container", - }, - { - val: "40ft" as const, - label: "40ft Container (FEU)", - limit: "Max 32.5t per container", - }, - ]).map((ct) => ( - set("containerType", ct.val)}> -
- -

{ct.label}

-
-

{ct.limit}

-
- ))} -
- - {direction && ( -

- - Route detected as {direction} workflow -

- )} - - - -
- -
- - set("quantity", e.target.value)} - className="text-center" - min="1" - /> - -
-
- - set("vgm", e.target.value)} - min="0" - step="0.1" - /> - -
- - {overweightAlert && Overweight Alert: {overweightAlert}} -
- ); -} - -// ── Step 6: Wagon Allocation ─────────────────────────────────────────────────── - -function Step6({ - d, set, wagons, -}: { - d: FormData; - set: (k: K, v: FormData[K]) => void; - wagons: WagonCalcResult | null; -}) { - const qty = parseInt(d.quantity) || 1; - - return ( -
-
-

Wagon Allocation

-

- System-calculated wagon requirements based on your container profile. -

-
- - {!wagons ? ( - - Complete the container configuration in the previous step to see wagon allocation. - - ) : ( - <> -
-
-

{wagons.totalWagons}

-

Wagons Required

-
-
-

{qty}

-

{d.containerType} Containers

-
-
-

{wagons.sharedWagons}

-

Shared Slots

-
-
- -
- Wagon Layout -
- {Array.from({ length: wagons.totalWagons }, (_, i) => { - const isOdd = wagons.hasOddUnit && i === wagons.totalWagons - 1; - return ( -
- {isOdd - ? `1 × ${d.containerType} (½)` - : d.containerType === "20ft" ? "2 × 20ft" : "1 × 40ft"} -
- ); - })} -
-
- -

- Formula:{" "} - {d.containerType === "40ft" - ? "1 × 40ft = 1 Rail Wagon" - : `CEILING(${qty} ÷ 2) = ${wagons.totalWagons} Rail Wagon${wagons.totalWagons > 1 ? "s" : ""} — (2 × 20ft = 1 Wagon)`} -

- - {wagons.hasOddUnit && ( - <> - -
-
- -

Consolidation Option (US-07)

-
-

- You have 1 unpaired 20ft container. Opt in to share a wagon slot with another - shipper to optimise costs (2 × 20ft = 1 Wagon), or request a dedicated wagon. -

-
- set("consolidationEnabled", true)} - > -

Allow Consolidation

-

- Share a wagon slot — billing split with co-loader. -

-
- set("consolidationEnabled", false)} - > -

Dedicated Wagon

-

- Exclusive slot — standard single-party billing. -

-
-
-
- - )} - - )} -
- ); -} - -// ── Step 7: Compliance Documents ─────────────────────────────────────────────── - -function Step7Docs({ - d, set, -}: { - d: FormData; - set: (k: K, v: FormData[K]) => void; -}) { - const uploadedRequired = REQUIRED_DOC_KEYS.filter((k) => { - const f = d.documents[k]; - if (!f) return false; - return Array.isArray(f) ? f.length > 0 : true; - }).length; - - return ( -
-
-

Compliance Documents

-

- Upload your company's legal credentials for EDR contract eligibility verification (US-04). -

-
- -
-
- {uploadedRequired}/{REQUIRED_DOC_KEYS.length} -
-

- {uploadedRequired < REQUIRED_DOC_KEYS.length - ? `${REQUIRED_DOC_KEYS.length - uploadedRequired} mandatory document(s) still needed.` - : "All mandatory documents uploaded. Power of Attorney is optional."} -

-
- - set("documents", val)} - /> -
- ); -} - -// ── Step 8: Review & Submit ──────────────────────────────────────────────────── - -function Step8Review({ - d, set, setStep, wagons, direction, -}: { - d: FormData; - set: (k: K, v: FormData[K]) => void; - setStep: (n: number) => void; - wagons: WagonCalcResult | null; - direction: RouteDirection; -}) { - function Row({ label, value, target }: { label: string; value: string; target: number }) { - return ( -
-
-

{label}

-

{value || "—"}

-
- -
- ); - } - - const cargoValue = - d.freightType === "bulk" - ? `Bulk — ${d.bulkCommodity === "Others" ? d.bulkCommodityOther : d.bulkCommodity}` - : d.freightType === "break_bulk" - ? `Break-Bulk — ${d.breakBulkType === "Others" ? d.breakBulkTypeOther : d.breakBulkType}` - : ""; - - const uploadedCount = REQUIRED_DOC_KEYS.filter((k) => { - const f = d.documents[k]; - if (!f) return false; - return Array.isArray(f) ? f.length > 0 : true; - }).length; - - return ( -
-
-

Review & Submit

-

- Confirm your contract request before sending it for EDR staff review. -

-
- -
- - - - Contract & Service - - - - - - - - - - - - - First & Last Mile - - - - - - - - - - - - - Route & Cargo - - - - - - - - - - - - - - - Container & Wagons - - - - - - 1 ? "s" : ""}` : ""} - target={6} - /> - - - -
- -
- -