diff --git a/.gitignore b/.gitignore index 132db3f2a..13865633d 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,9 @@ coverage/ # OS/editor .DS_Store .idea/ -.vscode/ \ No newline at end of file +.vscode/ + +# emacs cache files +*~ +\#*\# +.\#* diff --git a/apps/edr-freight-api/src/migrations/1749000000000-NormalizeWeightLimitTradeDirectionBoth.ts b/apps/edr-freight-api/src/migrations/1749000000000-NormalizeWeightLimitTradeDirectionBoth.ts index 4bded0fab..e2587c35c 100644 --- a/apps/edr-freight-api/src/migrations/1749000000000-NormalizeWeightLimitTradeDirectionBoth.ts +++ b/apps/edr-freight-api/src/migrations/1749000000000-NormalizeWeightLimitTradeDirectionBoth.ts @@ -15,7 +15,7 @@ export class NormalizeWeightLimitTradeDirectionBoth1749000000000 SET trade_direction = 'BOTH' WHERE trade_direction::text = 'ANY'; EXCEPTION WHEN undefined_table OR undefined_column THEN NULL; - END $$; + END $$; `); } diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts index 3459de0f9..41e303985 100644 --- a/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts @@ -4,11 +4,13 @@ import { Get, Param, ParseUUIDPipe, + Post, Put, } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { BackofficeService } from "./backoffice.service"; +import { CreateOrganizationUserDto } from "./dto/create-organization-user.dto"; import { UpdateEmployeeUserRolesDto } from "./dto/update-employee-user-roles.dto"; @ApiTags("backoffice") @@ -16,6 +18,15 @@ import { UpdateEmployeeUserRolesDto } from "./dto/update-employee-user-roles.dto export class BackofficeController { constructor(private readonly backofficeService: BackofficeService) {} + @Post("organizations/:orgId/users") + @ApiOperation({ summary: "Create an organization user without assigning positions" }) + createOrganizationUser( + @Param("orgId", ParseUUIDPipe) organizationId: string, + @Body() dto: CreateOrganizationUserDto, + ) { + return this.backofficeService.createOrganizationUser(organizationId, dto); + } + @Get("organizations/:orgId/employee-users/:userId/roles") @ApiOperation({ summary: "Get org-scoped roles assigned to an employee user" }) getEmployeeUserRoles( diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.module.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.module.ts index 18b4e6947..90c1a7c79 100644 --- a/apps/edr-freight-api/src/modules/backoffice/backoffice.module.ts +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.module.ts @@ -1,5 +1,10 @@ import { Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; +import { + Employee, + Organization, + UserCredential, +} from "@tria-plc/iamapi-common"; import { Role } from "@tria-plc/iamapi-common/entities/iam/user/role.entity"; import { UserRole } from "@tria-plc/iamapi-common/entities/iam/user/user-role.entity"; @@ -9,7 +14,16 @@ import { BackofficeController } from "./backoffice.controller"; import { BackofficeService } from "./backoffice.service"; @Module({ - imports: [TypeOrmModule.forFeature([Role, UserRole, User])], + imports: [ + TypeOrmModule.forFeature([ + Employee, + Organization, + Role, + User, + UserCredential, + UserRole, + ]), + ], controllers: [BackofficeController], providers: [BackofficeService], exports: [BackofficeService], diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts index b5206be7d..d6b68982f 100644 --- a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts @@ -4,21 +4,29 @@ import { NotFoundException, } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; +import { hashPassword } from "@tria-plc/api-common/utils/argon"; +import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum"; import { DataSource, In, IsNull, Repository } from "typeorm"; +import { Employee, Organization, UserCredential } from "@tria-plc/iamapi-common"; import { Role } from "@tria-plc/iamapi-common/entities/iam/user/role.entity"; import { UserRole } from "@tria-plc/iamapi-common/entities/iam/user/user-role.entity"; import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; +import { CreateOrganizationUserDto } from "./dto/create-organization-user.dto"; + const RESERVED_ROLE_KEYS = new Set([ "super_admin", "organization_admin", "unit_admin", ]); +const DEFAULT_USER_PASSWORD = "12345678"; @Injectable() export class BackofficeService { constructor( + @InjectRepository(Organization) + private readonly organizationRepository: Repository, @InjectRepository(Role) private readonly roleRepository: Repository, @InjectRepository(UserRole) @@ -28,6 +36,142 @@ export class BackofficeService { private readonly dataSource: DataSource, ) {} + async createOrganizationUser( + organizationId: string, + dto: CreateOrganizationUserDto, + ) { + const organizationExists = await this.organizationRepository.exists({ + where: { id: organizationId }, + }); + + if (!organizationExists) { + throw new NotFoundException("organization_not_found"); + } + + const email = dto.email.trim().toLowerCase(); + const username = dto.username.trim().toLowerCase(); + const phoneNumber = dto.phoneNumber?.trim() || undefined; + const name = { + en: dto.name.en.trim(), + ...(dto.name.am?.trim() ? { am: dto.name.am.trim() } : {}), + }; + + const existingUsers = await this.userRepository.find({ + where: [{ email }, { username }], + select: { id: true, email: true, username: true }, + }); + + const emailUser = existingUsers.find((user) => user.email === email); + const usernameUser = existingUsers.find((user) => user.username === username); + + if (emailUser && usernameUser && emailUser.id !== usernameUser.id) { + throw new BadRequestException("email_or_username_already_in_use"); + } + + const existingUser = emailUser ?? usernameUser; + const hashedPassword = await hashPassword(DEFAULT_USER_PASSWORD); + + return this.dataSource.transaction(async (manager) => { + let user = existingUser; + + if (!user) { + user = await manager.getRepository(User).save( + manager.getRepository(User).create({ + email, + username, + phoneNumber, + name, + isActive: true, + hasSetPassword: true, + status: EUserStatus.ACCEPTED, + }), + ); + } else { + await manager.getRepository(User).update( + { id: user.id }, + { + email, + username, + phoneNumber, + name, + isActive: true, + hasSetPassword: true, + status: EUserStatus.ACCEPTED, + }, + ); + } + + const activeCredentialExists = await manager.getRepository(UserCredential).exists({ + where: { + userId: user.id, + isActive: true, + }, + }); + + if (!activeCredentialExists) { + await manager.getRepository(UserCredential).insert({ + userId: user.id, + password: hashedPassword, + isActive: true, + }); + } + + let employee = await manager.getRepository(Employee).findOne({ + where: { + userId: user.id, + organizationId, + isCurrent: true, + }, + relations: { + user: true, + employeePositions: { + position: true, + }, + }, + }); + + if (!employee) { + const insertResult = await manager.getRepository(Employee).insert({ + userId: user.id, + organizationId, + isCurrent: true, + name, + }); + + employee = await manager.getRepository(Employee).findOne({ + where: { id: insertResult.identifiers[0]?.id as string }, + relations: { + user: true, + employeePositions: { + position: true, + }, + }, + }); + } else { + await manager.getRepository(Employee).update( + { id: employee.id }, + { name }, + ); + + employee = await manager.getRepository(Employee).findOne({ + where: { id: employee.id }, + relations: { + user: true, + employeePositions: { + position: true, + }, + }, + }); + } + + if (!employee) { + throw new NotFoundException("employee_create_failed"); + } + + return employee; + }); + } + async getEmployeeUserRoles(organizationId: string, userId: string) { await this.assertUserBelongsToOrganization(organizationId, userId); diff --git a/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts b/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts new file mode 100644 index 000000000..1623ac075 --- /dev/null +++ b/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts @@ -0,0 +1,34 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsEmail, IsObject, IsOptional, IsString, MinLength } from "class-validator"; + +class CreateOrganizationUserNameDto { + @ApiProperty() + @IsString() + @MinLength(1) + en!: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + am?: string; +} + +export class CreateOrganizationUserDto { + @ApiProperty() + @IsEmail() + email!: string; + + @ApiProperty() + @IsString() + @MinLength(1) + username!: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + phoneNumber?: string; + + @ApiProperty({ type: CreateOrganizationUserNameDto }) + @IsObject() + name!: CreateOrganizationUserNameDto; +} diff --git a/apps/edr-freight-api/src/seed/edr-org.seeder.ts b/apps/edr-freight-api/src/seed/edr-org.seeder.ts index 63b464090..d03b9ef7f 100644 --- a/apps/edr-freight-api/src/seed/edr-org.seeder.ts +++ b/apps/edr-freight-api/src/seed/edr-org.seeder.ts @@ -2,21 +2,48 @@ import { Injectable, Logger } from "@nestjs/common"; import { Organization, OrganizationConfiguration, + Permission, Role, + RolePermission, } from "@tria-plc/iamapi-common"; -import { DataSource } from "typeorm"; +import { DataSource, EntityManager, In } from "typeorm"; const EDR_ORG_KEY = "edr_freight"; const EDR_ORG_NAME = { en: "EDR Freight" }; const SEED_FLAG = "SEED_EDR_ORG"; -const EDR_ROLES = [ + +type SeedPermission = { + key: string; + name: { en: string }; +}; + +type SeedRole = { + key: string; + name: { en: string }; + permissions: SeedPermission[]; +}; + +type SeedOrganization = { + id: string; + key: string; +}; + +const SEED_ROLES: SeedRole[] = [ { key: "edr_employee", name: { en: "EDR Employee" }, + permissions: [ + // { key: "permission:key", name: { en: "Permission Name" } }, + { key: "permission:key", name: { en: "Permission Name" } }, + ], }, { key: "edr_customer", name: { en: "EDR Customer" }, + permissions: [ + // { key: "permission:key", name: { en: "Permission Name" } }, + { key: "permission:key", name: { en: "Permission Name" } }, + ], }, ]; @@ -27,24 +54,31 @@ export class EdrOrgSeeder { constructor(private readonly dataSource: DataSource) {} async run() { - const shouldSeed = process.env[SEED_FLAG]?.trim().toLowerCase() === "true"; - - if (!shouldSeed) { + if (!this.shouldSeed()) { this.logger.log(`Skipping EDR org seed because ${SEED_FLAG} is not enabled`); return; } - const roleRepository = this.dataSource.getRepository(Role); - const organizationRepository = this.dataSource.getRepository(Organization); - const organizationConfigurationRepository = - this.dataSource.getRepository(OrganizationConfiguration); + await this.dataSource.transaction(async (manager) => { + const organization = await this.ensureOrganization(manager); - await roleRepository.upsert(EDR_ROLES, { - conflictPaths: { key: true }, + await this.ensureOrganizationConfiguration(manager, organization.id); + await this.ensurePermissions(manager, SEED_ROLES); + await this.ensureRoles(manager, SEED_ROLES); + await this.ensureRolePermissions(manager, SEED_ROLES); }); - this.logger.log("Ensured EDR roles 'edr_employee' and 'edr_customer'"); + this.logger.log(`Ensured EDR organization seed for '${EDR_ORG_KEY}'`); + } + private shouldSeed() { + return process.env[SEED_FLAG]?.trim().toLowerCase() === "true"; + } + + private async ensureOrganization( + manager: EntityManager, + ): Promise { + const organizationRepository = manager.getRepository(Organization); let organization = await organizationRepository.findOne({ where: { key: EDR_ORG_KEY }, select: { id: true, key: true }, @@ -57,18 +91,31 @@ export class EdrOrgSeeder { isGovernmentOrganization: true, }); - organization = { + this.logger.log(`Seeded EDR organization '${EDR_ORG_KEY}'`); + + return { id: insertResult.identifiers[0]?.id as string, key: EDR_ORG_KEY, - } as Organization; - - this.logger.log(`Seeded EDR organization '${EDR_ORG_KEY}'`); - } else { - this.logger.log(`Ensured EDR organization '${EDR_ORG_KEY}'`); + }; } + this.logger.log(`Ensured EDR organization '${EDR_ORG_KEY}'`); + + return { + id: organization.id as string, + key: EDR_ORG_KEY, + }; + } + + private async ensureOrganizationConfiguration( + manager: EntityManager, + organizationId: string, + ) { + const organizationConfigurationRepository = + manager.getRepository(OrganizationConfiguration); + await organizationConfigurationRepository.upsert({ - organizationId: organization.id, + organizationId, canCreateBranchByItself: true, canStartReceivingRecord: true, }, { @@ -79,4 +126,100 @@ export class EdrOrgSeeder { `Ensured organization configuration for '${EDR_ORG_KEY}'`, ); } + + private collectPermissions(seedRoles: SeedRole[]) { + const permissionByKey = new Map(); + + for (const role of seedRoles) { + for (const permission of role.permissions) { + permissionByKey.set(permission.key, permission); + } + } + + return [...permissionByKey.values()]; + } + + private async ensurePermissions(manager: EntityManager, seedRoles: SeedRole[]) { + const permissions = this.collectPermissions(seedRoles); + + if (!permissions.length) { + this.logger.log("No EDR role permissions configured; skipping permission seed"); + return; + } + + await manager.getRepository(Permission).upsert(permissions, { + conflictPaths: { key: true }, + }); + + this.logger.log(`Ensured ${permissions.length} EDR permissions`); + } + + private async ensureRoles(manager: EntityManager, seedRoles: SeedRole[]) { + await manager.getRepository(Role).upsert( + seedRoles.map(({ key, name }) => ({ key, name })), + { + conflictPaths: { key: true }, + }, + ); + + this.logger.log( + `Ensured EDR roles '${seedRoles.map((role) => role.key).join("', '")}'`, + ); + } + + private async ensureRolePermissions( + manager: EntityManager, + seedRoles: SeedRole[], + ) { + const permissions = this.collectPermissions(seedRoles); + + if (!permissions.length) { + return; + } + + const roleRepository = manager.getRepository(Role); + const permissionRepository = manager.getRepository(Permission); + const rolePermissionRepository = manager.getRepository(RolePermission); + + const roles = await roleRepository.find({ + where: { key: In(seedRoles.map((role) => role.key)) }, + select: { id: true, key: true }, + }); + const seededPermissions = await permissionRepository.find({ + where: { key: In(permissions.map((permission) => permission.key)) }, + select: { id: true, key: true }, + }); + + const roleByKey = new Map(roles.map((role) => [role.key, role])); + const permissionByKey = new Map( + seededPermissions.map((permission) => [permission.key, permission]), + ); + + const rolePermissions = seedRoles.flatMap((role) => { + const seededRole = roleByKey.get(role.key); + + if (!seededRole) { + throw new Error(`missing_role:${role.key}`); + } + + return role.permissions.map((permission) => { + const seededPermission = permissionByKey.get(permission.key); + + if (!seededPermission) { + throw new Error(`missing_permission:${permission.key}`); + } + + return { + roleId: seededRole.id, + permissionId: seededPermission.id, + }; + }); + }); + + await rolePermissionRepository.upsert(rolePermissions, { + conflictPaths: { roleId: true, permissionId: true }, + }); + + this.logger.log(`Ensured ${rolePermissions.length} EDR role-permission links`); + } } diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 99e51457c..c2b3a1a1a 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -1,67 +1,46 @@ import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom"; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { FreightDashboardLayout, type SidebarSection } from "@/components/layout"; -import { Boxes, LayoutDashboard, Network, Paperclip, Settings, SlidersHorizontal } from "lucide-react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + Boxes, + FileText, + LayoutDashboard, + Network, + Paperclip, + Settings, + SlidersHorizontal, +} from "lucide-react"; + +import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout"; +import LoadingScreen from "./components/LoadingScreen"; import { useAuth } from "./auth/useAuth"; import LoginPage from "./pages/auth/LoginPage"; +import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage"; +import BookingRequestsPage from "./pages/bookings/BookingRequestsPage"; +import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; +import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; import OverviewPage from "./pages/dashboard/OverviewPage"; import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage"; import RolesPage from "./pages/dashboard/user-management/RolesPage"; import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage"; -import LoadingScreen from "./components/LoadingScreen"; -import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; +import UsersPage from "./pages/dashboard/user-management/UsersPage"; import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; -import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; -import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; +import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; -// Create a QueryClient instance const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false, - staleTime: 5 * 60 * 1000, // 5 minutes + staleTime: 5 * 60 * 1000, }, }, }); -// const sidebarItems: SidebarItem[] = [ -// { -// label: "Overview", -// href: "/dashboard/overview", -// icon: , -// }, -// { -// label: "User management", -// href: "/dashboard/user-management", -// icon: , -// children: [ -// { -// label: "Employees", -// href: "/dashboard/user-management/employees", -// }, -// { -// label: "Permissions", -// href: "/dashboard/user-management/permissions", -// }, -// { -// label: "Roles", -// href: "/dashboard/user-management/roles", -// }, -// ], -// }, -// { -// label: "Rule Engine", -// href: "/dashboard/rule-engine", -// icon: , -// }, -// ]; - -const sidebarSections: SidebarSection[] = [ +const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ { title: "Main menu", mutedTitle: true, @@ -71,6 +50,12 @@ const sidebarSections: SidebarSection[] = [ href: "/dashboard/overview", icon: , }, + { + label: "Booking requests", + href: "/dashboard/booking-requests", + icon: , + }, + ...demoItems, ], }, { @@ -81,6 +66,10 @@ const sidebarSections: SidebarSection[] = [ href: "/dashboard/user-management", icon: , children: [ + { + label: "Users", + href: "/dashboard/user-management/users", + }, { label: "Employees", href: "/dashboard/user-management/employees", @@ -117,11 +106,6 @@ const sidebarSections: SidebarSection[] = [ icon: , children: getCategorySidebarChildren("configuration"), }, - ], - }, - { - title: "Rules & pricing", - items: [ { label: "Rules", href: "/dashboard/rules", @@ -130,6 +114,7 @@ const sidebarSections: SidebarSection[] = [ }, ], }, + ]; const hasPermission = ( @@ -138,16 +123,41 @@ const hasPermission = ( ) => { if (!user) return false; if (user.permissions?.some((p) => p.key === key)) return true; + return (user.employee ?? []).some((emp) => (emp.positions ?? []).some((pos) => (pos.permissions ?? []).some((p) => p.key === key), ), ); }; + const DashboardShell = () => { const navigate = useNavigate(); const location = useLocation(); const { user, logout } = useAuth(); + + const demoItems: SidebarItem[] = [ + ...(hasPermission(user, "can:demo:user1") + ? [ + { + label: "User1", + href: "/dashboard/user1", + icon: , + }, + ] + : []), + ...(hasPermission(user, "can:demo:user2") + ? [ + { + label: "User2", + href: "/dashboard/user2", + icon: , + }, + ] + : []), + ]; + + const sidebarSections = buildSidebarSections(demoItems); const displayName = user?.name?.en || user?.username || user?.email || "User"; return ( @@ -188,38 +198,57 @@ const App = () => { } /> } /> + }> } /> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + } /> } /> + } /> } /> + } /> } /> - } /> - } /> - } /> + } /> } /> - } /> - } /> - } /> - } /> + + } + /> + } + /> + } /> ); }; -export default App; \ No newline at end of file +export default App; diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx index 9439983f2..399f61778 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightSidebar.tsx @@ -269,7 +269,8 @@ const FreightSidebar = ({ sections, activeHref, onNavigate }: FreightSidebarProp

{section.title} diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx new file mode 100644 index 000000000..86b4f483e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -0,0 +1,734 @@ +import { useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { + AlertCircle, + AlertTriangle, + Anchor, + ArrowLeft, + ArrowRight, + Calendar, + Check, + CheckCircle2, + Clock, + FileSignature, + FileText, + History, + Info, + MapPin, + Package, + ShieldCheck, + Ship, + StickyNote, + Train, + Truck, + Weight, + X, +} from "lucide-react"; + +import Breadcrumbs from "@/components/ui/Breadcrumbs"; +import { cn } from "@/lib/utils"; +import { + getBookingRequestById, + getBookingRequests, + saveBookingRequestsToStorage, + updateBookingRequestStatus, + BOOKING_STATUSES, + type BookingRequest, +} from "./booking-requests.mock"; +import { + Badge, + Button, + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + Separator, +} from "@edr/ui-common"; + +const STATUS_STYLES: Record = { + DRAFT: { + label: "Draft", + color: "bg-slate-100 text-slate-700 border-slate-300", + }, + RFQ_SUBMITTED: { + label: "RFQ Submitted", + color: "bg-amber-50 text-amber-700 border-amber-200", + }, + QUOTATION_SENT: { + label: "Quotation Sent", + color: "bg-sky-50 text-sky-700 border-sky-200", + }, + QUOTATION_APPROVED: { + label: "Quotation Approved", + color: "bg-emerald-50 text-emerald-700 border-emerald-200", + }, + QUOTATION_REJECTED: { + label: "Quotation Rejected", + color: "bg-red-50 text-red-700 border-red-200", + }, + PENDING_APPROVAL: { + label: "Pending Approval", + color: "bg-amber-50 text-amber-700 border-amber-200", + }, + APPROVED: { + label: "Approved", + color: "bg-emerald-50 text-emerald-700 border-emerald-200", + }, + SIGNED_CUSTOMER: { + label: "Customer Signed", + color: "bg-sky-50 text-sky-700 border-sky-200", + }, + FULLY_EXECUTED: { + label: "Fully Executed", + color: "bg-indigo-50 text-indigo-700 border-indigo-200", + }, + PAID: { + label: "Paid", + color: "bg-emerald-50 text-emerald-700 border-emerald-200", + }, + IN_TRANSIT: { + label: "In Transit", + color: "bg-sky-50 text-sky-700 border-sky-200", + }, + COMPLETED: { + label: "Completed", + color: "bg-indigo-50 text-indigo-700 border-indigo-200", + }, + CANCELLED: { + label: "Cancelled", + color: "bg-red-50 text-red-700 border-red-200", + }, + PENDING_CONSOLIDATION: { + label: "Pending Consolidation", + color: "bg-amber-50 text-amber-700 border-amber-200", + }, + CONSOLIDATED: { + label: "Consolidated", + color: "bg-indigo-50 text-indigo-700 border-indigo-200", + }, +}; + +const PROGRESS_STAGES = [ + { label: "Request", icon: FileText, statuses: ["DRAFT", "RFQ_SUBMITTED"] }, + { + label: "Quotation", + icon: ShieldCheck, + statuses: ["QUOTATION_SENT", "QUOTATION_APPROVED", "QUOTATION_REJECTED"], + }, + { + label: "Approval", + icon: FileSignature, + statuses: ["PENDING_APPROVAL", "APPROVED"], + }, + { + label: "Execution", + icon: CheckCircle2, + statuses: ["SIGNED_CUSTOMER", "FULLY_EXECUTED", "PAID"], + }, + { + label: "In Transit", + icon: Train, + statuses: ["IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"], + }, + { label: "Complete", icon: Check, statuses: ["COMPLETED"] }, +]; + +const STATUS_CONFIG: Record< + string, + { title: string; description: string; color: string; stage: number } +> = { + DRAFT: { + title: "Draft", + description: "Booking is being prepared.", + color: "text-slate-500", + stage: 0, + }, + RFQ_SUBMITTED: { + title: "RFQ Submitted", + description: "Customer has submitted a request for quotation.", + color: "text-amber-600", + stage: 0, + }, + QUOTATION_SENT: { + title: "Quotation Sent", + description: "A formal quotation has been sent to the customer.", + color: "text-sky-600", + stage: 1, + }, + QUOTATION_APPROVED: { + title: "Quotation Approved", + description: "Customer approved the quotation.", + color: "text-emerald-600", + stage: 1, + }, + QUOTATION_REJECTED: { + title: "Quotation Rejected", + description: "Customer rejected the quotation.", + color: "text-red-600", + stage: 1, + }, + PENDING_APPROVAL: { + title: "Pending Approval", + description: "Booking requires your approval to proceed.", + color: "text-amber-600", + stage: 2, + }, + APPROVED: { + title: "Approved", + description: "Booking has been approved by all parties.", + color: "text-emerald-600", + stage: 2, + }, + SIGNED_CUSTOMER: { + title: "Customer Signed", + description: "Customer has signed the contract.", + color: "text-sky-600", + stage: 3, + }, + FULLY_EXECUTED: { + title: "Fully Executed", + description: "All parties have signed.", + color: "text-indigo-600", + stage: 3, + }, + PAID: { + title: "Paid", + description: "Payment received.", + color: "text-emerald-600", + stage: 3, + }, + IN_TRANSIT: { + title: "In Transit", + description: "Cargo is moving through the rail network.", + color: "text-sky-600", + stage: 4, + }, + PENDING_CONSOLIDATION: { + title: "Pending Consolidation", + description: "Cargo awaiting consolidation.", + color: "text-amber-500", + stage: 4, + }, + CONSOLIDATED: { + title: "Consolidated", + description: "Cargo merged into larger shipment.", + color: "text-indigo-500", + stage: 4, + }, + COMPLETED: { + title: "Completed", + description: "Service completed successfully.", + color: "text-emerald-600", + stage: 5, + }, + CANCELLED: { + title: "Cancelled", + description: "Booking terminated.", + color: "text-red-600", + stage: -1, + }, +}; + +export default function BookingRequestDetailPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [booking, setBooking] = useState( + id ? getBookingRequestById(id) : undefined, + ); + + if (!booking) { + return ( +

+ +
+ +
+

+ Booking not found +

+ +
+
+ ); + } + + const statusConfig = STATUS_CONFIG[booking.status] ?? STATUS_CONFIG.DRAFT; + const currentStage = statusConfig.stage; + + const canApprove = ["PENDING_APPROVAL", "RFQ_SUBMITTED"].includes( + booking.status, + ); + const canReject = !["COMPLETED", "CANCELLED", "QUOTATION_REJECTED"].includes( + booking.status, + ); + + function handleApprove() { + if (!booking) return; + const nextStatus = + booking.status === "RFQ_SUBMITTED" + ? ("QUOTATION_SENT" as const) + : ("APPROVED" as const); + updateBookingRequestStatus(booking.id, nextStatus); + setBooking(getBookingRequestById(booking.id)); + } + + function handleReject() { + if (!booking) return; + updateBookingRequestStatus(booking.id, "CANCELLED"); + setBooking(getBookingRequestById(booking.id)); + } + + return ( +
+
+ + +
+ + +
+
+ +
+
+
+

+ {booking.reference} +

+ + +
+
+ {booking.customer} + + + + Requested {booking.scheduledDate} + + + + + {new Date(booking.createdAt).toLocaleDateString()} + +
+
+
+
+
+ +
+ {canReject && ( + + )} + {canApprove && ( + + )} +
+
+ + + + + + Status Lifecycle + + + Track the booking from request to completion + + + +
+
+
= 0 + ? `${(currentStage / (PROGRESS_STAGES.length - 1)) * 100}%` + : "0%", + }} + /> +
+ {PROGRESS_STAGES.map((stage, idx) => { + const isCompleted = idx < currentStage; + const isActive = idx === currentStage; + return ( +
+
+ {isCompleted ? ( + + ) : ( + + )} +
+ + {stage.label} + +
+ ); + })} +
+ +
+
+ {booking.status === "CANCELLED" ? ( + + ) : ( + + )} +
+
+

+ {statusConfig.title} +

+

+ {statusConfig.description} +

+
+
+ + + +
+
+ + + + + Route & Service + + + +
+ } + /> +
+
+ + +
+ + {booking.serviceType.replace(/_/g, " ")} + +
+ } + /> +
+ +
+ } + label="Trade Direction" + value={booking.tradeDirection} + /> + } + label="Return" + value={ + booking.serviceType === "RAIL_AND_FORWARDING" + ? "With Return" + : "Without Return" + } + /> + {booking.shippingLine && ( + } + label="Shipping Line" + value={booking.shippingLine} + /> + )} +
+
+
+ + {(booking.firstMilePickupAddress || + booking.lastMileDeliveryAddress) && ( + + + + + Mile Services + + + + {booking.firstMilePickupAddress && ( +
+

+ First Mile +

+ +
+ )} + {booking.lastMileDeliveryAddress && ( +
+

+ Last Mile +

+ +
+ )} +
+
+ )} + + + + + + Cargo Specifications + + + +
+ } + label="Type" + value={booking.cargoType} + /> + } + label="Total Weight" + value={`${booking.cargoTotalWeightVgm} Tons`} + /> + {booking.shippingLine && ( + } + label="Shipping Line" + value={booking.shippingLine} + /> + )} +
+ +
+ + Hazardous: {booking.isHazardous ? "Yes" : "No"} + + {booking.pnrCode && ( + + PNR: {booking.pnrCode} + + )} +
+
+
+
+ +
+ + + + + Contract Info + + + + + + + + + + + + + + + {canApprove && ( + + + + + Approval Required + + + This booking is waiting for your review. + + + + + + + + )} +
+
+
+
+ ); +} + +function StatusBadge({ status }: { status: string }) { + const style = STATUS_STYLES[status] ?? { + label: status, + color: "bg-muted text-muted-foreground border-border", + }; + return ( + + {style.label} + + ); +} + +function PriorityBadge({ score }: { score: number }) { + if (score >= 3) { + return ( + + Urgent + + ); + } + if (score === 2) { + return ( + + High + + ); + } + return ( + + Normal + + ); +} + +function RouteEndpoint({ + label, + station, + icon, +}: { + label: string; + station: string; + icon: React.ReactNode; +}) { + return ( +
+
+
{icon}
+
+
+

+ {label} +

+

{station}

+
+
+ ); +} + +function InfoItem({ + icon, + label, + value, +}: { + icon?: React.ReactNode; + label: string; + value?: string | number | null; +}) { + return ( +
+ {icon && ( +
+ {icon} +
+ )} +
+

+ {label} +

+

{value ?? "—"}

+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx new file mode 100644 index 000000000..d59658131 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -0,0 +1,479 @@ +import { useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { + AlertCircle, + ArrowRight, + Calendar, + Clock, + Eye, + FileText, + Filter, + MoreHorizontal, + Package, + Search, + ShieldCheck, + Train, + User, +} from "lucide-react"; + +import Breadcrumbs from "@/components/ui/Breadcrumbs"; +import { cn } from "@/lib/utils"; +import { + getBookingRequests, + BOOKING_STATUSES, + type BookingRequest, +} from "./booking-requests.mock"; +import { + DataTable, + DataTableFooter, + type ColumnDef, + usePagination, + Badge, + Button, + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + Input, + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + Separator, +} from "@edr/ui-common"; + +const STATUS_STYLES: Record = { + DRAFT: { + label: "Draft", + color: "bg-slate-100 text-slate-700 border-slate-300", + }, + RFQ_SUBMITTED: { + label: "RFQ Submitted", + color: "bg-amber-50 text-amber-700 border-amber-200", + }, + QUOTATION_SENT: { + label: "Quotation Sent", + color: "bg-sky-50 text-sky-700 border-sky-200", + }, + QUOTATION_APPROVED: { + label: "Quotation Approved", + color: "bg-emerald-50 text-emerald-700 border-emerald-200", + }, + QUOTATION_REJECTED: { + label: "Quotation Rejected", + color: "bg-red-50 text-red-700 border-red-200", + }, + PENDING_APPROVAL: { + label: "Pending Approval", + color: "bg-amber-50 text-amber-700 border-amber-200", + }, + APPROVED: { + label: "Approved", + color: "bg-emerald-50 text-emerald-700 border-emerald-200", + }, + SIGNED_CUSTOMER: { + label: "Customer Signed", + color: "bg-sky-50 text-sky-700 border-sky-200", + }, + FULLY_EXECUTED: { + label: "Fully Executed", + color: "bg-indigo-50 text-indigo-700 border-indigo-200", + }, + PAID: { + label: "Paid", + color: "bg-emerald-50 text-emerald-700 border-emerald-200", + }, + IN_TRANSIT: { + label: "In Transit", + color: "bg-sky-50 text-sky-700 border-sky-200", + }, + COMPLETED: { + label: "Completed", + color: "bg-indigo-50 text-indigo-700 border-indigo-200", + }, + CANCELLED: { + label: "Cancelled", + color: "bg-red-50 text-red-700 border-red-200", + }, + PENDING_CONSOLIDATION: { + label: "Pending Consolidation", + color: "bg-amber-50 text-amber-700 border-amber-200", + }, + CONSOLIDATED: { + label: "Consolidated", + color: "bg-indigo-50 text-indigo-700 border-indigo-200", + }, +}; + +function StatusBadge({ status }: { status: string }) { + const style = STATUS_STYLES[status] ?? { + label: status, + color: "bg-muted text-muted-foreground border-border", + }; + return ( + + {style.label} + + ); +} + +function PriorityBadge({ score }: { score: number }) { + if (score >= 3) { + return ( + + Urgent + + ); + } + if (score === 2) { + return ( + + High + + ); + } + return ( + + Normal + + ); +} + +export default function BookingRequestsPage() { + const navigate = useNavigate(); + const { pagination, setPagination } = usePagination({ pageSize: 10 }); + const [query, setQuery] = useState(""); + const [statusFilter, setStatusFilter] = useState(null); + + const bookingRequests = useMemo(() => getBookingRequests(), []); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + return bookingRequests.filter((b) => { + if ( + q && + !b.reference.toLowerCase().includes(q) && + !b.customer.toLowerCase().includes(q) + ) { + return false; + } + if (statusFilter && b.status !== statusFilter) { + return false; + } + return true; + }); + }, [bookingRequests, query, statusFilter]); + + const total = filtered.length; + const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); + const start = pagination.pageIndex * pagination.pageSize; + const end = Math.min(start + pagination.pageSize, total); + + const paginatedData = useMemo( + () => filtered.slice(start, end), + [start, end, filtered], + ); + + const pendingCount = bookingRequests.filter( + (b) => b.status === "PENDING_APPROVAL" || b.status === "RFQ_SUBMITTED", + ).length; + const activeCount = bookingRequests.filter( + (b) => !["COMPLETED", "CANCELLED"].includes(b.status), + ).length; + const urgentCount = bookingRequests.filter( + (b) => b.priorityScore >= 3, + ).length; + + const columns: ColumnDef[] = [ + { + id: "booking", + header: "Booking", + cell: ({ row }) => { + const b = row.original; + return ( +
+
+ +
+
+

{b.reference}

+

+ + {b.customer} +

+
+
+ ); + }, + }, + { + id: "route", + header: "Route", + cell: ({ row }) => { + const b = row.original; + return ( +
+
+ {b.originYard} + + {b.destinationYard} +
+ + {b.tradeDirection} + +
+ ); + }, + }, + { + id: "status", + header: "Status", + cell: ({ row }) => , + }, + { + id: "service", + header: "Service", + cell: ({ row }) => { + const b = row.original; + return ( +
+ + {b.serviceType.replace(/_/g, " ")} + + + + {b.scheduledDate} + +
+ ); + }, + }, + { + id: "cargo", + header: "Cargo", + cell: ({ row }) => { + const b = row.original; + return ( +
+ + {b.cargoType} + + + {b.cargoTotalWeightVgm}T + +
+ ); + }, + }, + { + id: "priority", + header: "Priority", + cell: ({ row }) => , + }, + { + id: "amount", + header: "Amount", + cell: ({ row }) => { + const b = row.original; + return ( + + {b.paymentCurrency} {b.totalAmount.toLocaleString()} + + ); + }, + }, + { + id: "actions", + size: 40, + cell: ({ row }) => { + const b = row.original; + return ( +
e.stopPropagation()} + > + + + + + + + navigate(`/dashboard/booking-requests/${b.id}`) + } + > + + View Details + + + + navigate(`/dashboard/booking-requests/${b.id}`) + } + > + + Review + + + +
+ ); + }, + }, + ]; + + return ( +
+
+ + + +
+

+ Booking Requests +

+

+ Review, approve, or reject customer booking requests across the + freight network. +

+
+ +
+
+ + { + setQuery(e.target.value); + setPagination({ + pageIndex: 0, + pageSize: pagination.pageSize, + }); + }} + placeholder="Search reference or customer..." + className="pl-8!" + /> +
+
+
+ +
+ } + /> + } + /> + } /> + } /> +
+ + + +
+ All Booking Requests + + {total} request{total !== 1 ? "s" : ""} found + +
+ +
+ {statusFilter && ( + + )} + + + + + + {BOOKING_STATUSES.map((s) => ( + setStatusFilter(s)} + > + {STATUS_STYLES[s]?.label ?? s} + + ))} + + +
+
+ + + + navigate(`/dashboard/booking-requests/${row.id}`) + } + pagination={{ + pageIndex: pagination.pageIndex, + pageSize: pagination.pageSize, + pageCount, + totalCount: total, + }} + tableOptions={{ + state: { pagination }, + onPaginationChange: setPagination, + }} + containerClassName="border-b shadow-none" + footer={DataTableFooter} + /> + +
+
+
+ ); +} + +function StatCard({ + label, + value, + icon, +}: { + label: string; + value: number; + icon: React.ReactNode; +}) { + return ( + + +
+

{label}

+

{value}

+
+
+ {icon} +
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/booking-requests.mock.ts b/apps/edr-freight-web/backoffice/src/pages/bookings/booking-requests.mock.ts new file mode 100644 index 000000000..a5d13cbc0 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/booking-requests.mock.ts @@ -0,0 +1,148 @@ +export interface BookingRequest { + id: string; + reference: string; + customer: string; + status: (typeof BOOKING_STATUSES)[number]; + scheduledDate: string; + totalAmount: number; + paymentStatus: string; + contractType: string; + serviceType: string; + tradeDirection: string; + originYard: string; + destinationYard: string; + cargoType: string; + cargoTotalWeightVgm: number; + isHazardous: boolean; + paymentCurrency: string; + priorityScore: number; + firstMilePickupAddress: string | null; + lastMileDeliveryAddress: string | null; + shippingLine: string | null; + pnrCode: string | null; + createdBy: string; + createdAt: string; + updatedAt: string; +} + +export const BOOKING_STATUSES = [ + "DRAFT", + "RFQ_SUBMITTED", + "QUOTATION_SENT", + "QUOTATION_APPROVED", + "QUOTATION_REJECTED", + "PENDING_APPROVAL", + "APPROVED", + "SIGNED_CUSTOMER", + "FULLY_EXECUTED", + "PAID", + "IN_TRANSIT", + "COMPLETED", + "CANCELLED", + "PENDING_CONSOLIDATION", + "CONSOLIDATED", +] as const; + +const customers = [ + "Ethio Cargo Logistics", + "Djibouti Shipping PLC", + "Horn of Africa Traders", + "Addis Freight Forwarders", + "Red Sea Maritime Services", + "Dire Dawa Imports Ltd", + "Awash Agro Industry", + "Mieso Mineral Exports", +]; + +const yards = [ + "Addis Ababa Dry Port", + "Mojo Inland Container Depot", + "Dire Dawa Freight Station", + "Djibouti Port Terminal", + "Adama Logistics Hub", + "Awash Cargo Center", +]; + +const serviceTypes = ["RAIL", "RAIL_AND_FORWARDING"]; +const tradeDirections = ["EXPORT", "IMPORT", "DOMESTIC"]; +const cargoTypes = ["Containerized", "Bulk", "Liquid", "Refrigerated", "Hazardous"]; +const shippingLines = ["MSC", "CMA CGM", "Maersk", "COSCO", "Hapag-Lloyd", null]; + +function pick(arr: T[], index: number): T { + return arr[index % arr.length]; +} + +function randDate(daysAgo: number): string { + const d = new Date(2026, 4, 28 - daysAgo); + return d.toISOString(); +} + +const now = Date.now(); + +const INITIAL_REQUESTS: BookingRequest[] = Array.from({ length: 25 }, (_, i) => { + const statusIndex = i % BOOKING_STATUSES.length; + const status = BOOKING_STATUSES[statusIndex]; + const customer = pick(customers, i); + + return { + id: String(i + 1), + reference: `EDR-BK-${String(2026001 + i).slice(-6)}`, + customer, + status, + scheduledDate: new Date(2026, 5, 1 + (i % 28)).toISOString().slice(0, 10), + totalAmount: 1500 + i * 320 + (i % 7) * 100, + paymentStatus: status === "PAID" || status === "COMPLETED" ? "PAID" : status === "CANCELLED" ? "REFUNDED" : "PENDING", + contractType: i % 5 === 0 ? "RENEWAL" : "NEW", + serviceType: pick(serviceTypes, i), + tradeDirection: pick(tradeDirections, i), + originYard: pick(yards, i), + destinationYard: pick(yards, i + 3), + cargoType: pick(cargoTypes, i), + cargoTotalWeightVgm: 10 + ((i * 7) % 90), + isHazardous: i % 7 === 0, + paymentCurrency: "USD", + priorityScore: i % 4 === 0 ? 3 : i % 3 === 0 ? 2 : 1, + firstMilePickupAddress: i % 3 === 0 ? "Bole Industrial Zone, Addis Ababa" : null, + lastMileDeliveryAddress: i % 4 === 0 ? "Port Boulevard, Djibouti City" : null, + shippingLine: pick(shippingLines, i), + pnrCode: i % 6 === 0 ? `PNR-${202600 + i}` : null, + createdBy: customer, + createdAt: randDate(30 - i), + updatedAt: randDate(2), + }; +}); + +export function saveBookingRequestsToStorage(data: BookingRequest[]) { + if (typeof window !== "undefined" && window.localStorage) { + localStorage.setItem("edr_backoffice_booking_requests", JSON.stringify(data)); + } +} + +export function getBookingRequestById(id: string): BookingRequest | undefined { + const requests = getBookingRequests(); + return requests.find((r) => r.id === id); +} + +export function updateBookingRequestStatus(id: string, newStatus: (typeof BOOKING_STATUSES)[number]) { + const requests = getBookingRequests(); + const idx = requests.findIndex((r) => r.id === id); + if (idx === -1) return; + requests[idx] = { ...requests[idx], status: newStatus, updatedAt: new Date().toISOString() }; + saveBookingRequestsToStorage(requests); +} + +export function getBookingRequests(): BookingRequest[] { + if (typeof window === "undefined" || !window.localStorage) { + return INITIAL_REQUESTS; + } + const data = localStorage.getItem("edr_backoffice_booking_requests"); + if (!data) { + localStorage.setItem("edr_backoffice_booking_requests", JSON.stringify(INITIAL_REQUESTS)); + return INITIAL_REQUESTS; + } + try { + return JSON.parse(data); + } catch { + return INITIAL_REQUESTS; + } +} diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementPage.tsx index 3d490fcf4..88fa34647 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementPage.tsx @@ -1433,6 +1433,7 @@ const UserManagementPage = () => { No organizations available.
)} +