solf merge conflict

This commit is contained in:
marshal
2026-06-01 23:50:49 +03:00
26 changed files with 3027 additions and 298 deletions

7
.gitignore vendored
View File

@@ -21,4 +21,9 @@ coverage/
# OS/editor
.DS_Store
.idea/
.vscode/
.vscode/
# emacs cache files
*~
\#*\#
.\#*

View File

@@ -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 $$;
`);
}

View File

@@ -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(

View File

@@ -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],

View File

@@ -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<Organization>,
@InjectRepository(Role)
private readonly roleRepository: Repository<Role>,
@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);

View File

@@ -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;
}

View File

@@ -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<SeedOrganization> {
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<string, SeedPermission>();
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`);
}
}

View File

@@ -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: <LayoutDashboard />,
// },
// {
// label: "User management",
// href: "/dashboard/user-management",
// icon: <Network />,
// 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: <Settings />,
// },
// ];
const sidebarSections: SidebarSection[] = [
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
title: "Main menu",
mutedTitle: true,
@@ -71,6 +50,12 @@ const sidebarSections: SidebarSection[] = [
href: "/dashboard/overview",
icon: <LayoutDashboard />,
},
{
label: "Booking requests",
href: "/dashboard/booking-requests",
icon: <FileText />,
},
...demoItems,
],
},
{
@@ -81,6 +66,10 @@ const sidebarSections: SidebarSection[] = [
href: "/dashboard/user-management",
icon: <Network />,
children: [
{
label: "Users",
href: "/dashboard/user-management/users",
},
{
label: "Employees",
href: "/dashboard/user-management/employees",
@@ -117,11 +106,6 @@ const sidebarSections: SidebarSection[] = [
icon: <Boxes />,
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: <Settings />,
},
]
: []),
...(hasPermission(user, "can:demo:user2")
? [
{
label: "User2",
href: "/dashboard/user2",
icon: <Settings />,
},
]
: []),
];
const sidebarSections = buildSidebarSections(demoItems);
const displayName = user?.name?.en || user?.username || user?.email || "User";
return (
@@ -188,38 +198,57 @@ const App = () => {
<Routes>
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<DashboardShell />}>
<Route path="overview" element={<OverviewPage />} />
<Route path="booking-requests" element={<BookingRequestsPage />} />
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
<Route path="user-management" element={<UserManagementPage />} />
<Route path="user-management/users" element={<UsersPage />} />
<Route path="user-management/employees" element={<EmployeesPage />} />
<Route path="user-management/permissions" element={<PermissionsPage />} />
<Route path="user-management/roles" element={<RolesPage />} />
<Route path="file-settings" element={<FileUploadSettingsPage />} />
<Route path="dropdown-settings" element={<DropdownSettingsPage />} />
<Route
path="configuration"
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
/>
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
<Route
path="rules"
element={<Navigate to="/dashboard/rules/priority-rules" replace />}
/>
<Route path="rules/:resource" element={<RuleEngineResourcePage />} />
<Route
path="rule-engine"
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
/>
<Route path="rule-engine/:resource" element={<RuleEngineLegacyRedirect />} />
<Route path="user-management/employees" element={<EmployeesPage />} />
<Route path="user-management/permissions" element={<PermissionsPage />} />
<Route path="user-management/roles" element={<RolesPage />} />
<Route path="user1" element={<DemoUser1Page />} />
<Route path="user2" element={<DemoUser2Page />} />
<Route path="file-settings" element={<FileUploadSettingsPage />} />
<Route path="dropdown-settings" element={<DropdownSettingsPage />} />
<Route path="org-structure" element={<Navigate to="/dashboard/user-management" replace />} />
<Route path="org-structure/*" element={<Navigate to="/dashboard/user-management" replace />} />
<Route
path="org-structure"
element={<Navigate to="/dashboard/user-management" replace />}
/>
<Route
path="org-structure/*"
element={<Navigate to="/dashboard/user-management" replace />}
/>
</Route>
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
</Routes>
</QueryClientProvider>
);
};
export default App;
export default App;

View File

@@ -269,7 +269,8 @@ const FreightSidebar = ({ sections, activeHref, onNavigate }: FreightSidebarProp
<p
className={cn(
"px-3 pb-1 text-xs font-semibold uppercase tracking-wide",
section.mutedTitle ? "text-gray-500" : "text-gray-900",
// Use a very light gray for ALL section titles, not just when mutedTitle is specified
"text-gray-400"
)}
>
{section.title}

View File

@@ -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<string, { label: string; color: string }> = {
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<BookingRequest | undefined>(
id ? getBookingRequestById(id) : undefined,
);
if (!booking) {
return (
<div className="p-6">
<Card className="flex flex-col items-center p-12 text-center">
<div className="flex size-16 items-center justify-center rounded-full bg-slate-100 text-slate-400">
<Package className="size-8" />
</div>
<h1 className="mt-4 text-2xl font-bold text-slate-900">
Booking not found
</h1>
<Button
className="mt-4"
variant="outline"
onClick={() => navigate("/dashboard/booking-requests")}
>
<ArrowLeft />
Back to Booking Requests
</Button>
</Card>
</div>
);
}
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 (
<div className="p-6">
<div className="space-y-6">
<Breadcrumbs
items={[
{ label: "Booking Requests", href: "/dashboard/booking-requests" },
{ label: booking.reference },
]}
/>
<div className="flex items-start justify-between gap-4">
<Card className="flex-1">
<CardHeader>
<div className="flex items-center gap-6">
<div className="flex size-14 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
<Package className="size-6" />
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-black tracking-tight text-foreground">
{booking.reference}
</h1>
<StatusBadge status={booking.status} />
<PriorityBadge score={booking.priorityScore} />
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="font-semibold">{booking.customer}</span>
<Separator orientation="vertical" className="h-3" />
<span className="flex items-center gap-1">
<Calendar className="size-3" />
Requested {booking.scheduledDate}
</span>
<Separator orientation="vertical" className="h-3" />
<span className="flex items-center gap-1">
<Clock className="size-3" />
{new Date(booking.createdAt).toLocaleDateString()}
</span>
</div>
</div>
</div>
</CardHeader>
</Card>
<div className="flex shrink-0 items-start gap-2">
{canReject && (
<Button
variant="outline"
className="border-red-200 text-red-700 hover:bg-red-50"
onClick={handleReject}
>
<X />
Reject
</Button>
)}
{canApprove && (
<Button onClick={handleApprove}>
<ShieldCheck />
{booking.status === "RFQ_SUBMITTED"
? "Send Quotation"
: "Approve"}
</Button>
)}
</div>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<History className="size-4 text-primary" />
Status Lifecycle
</CardTitle>
<CardDescription>
Track the booking from request to completion
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-8">
<div className="relative flex w-full justify-between px-2">
<div className="absolute left-0 top-4 h-0.5 w-full bg-muted">
<div
className="h-full bg-primary transition-all duration-500"
style={{
width:
currentStage >= 0
? `${(currentStage / (PROGRESS_STAGES.length - 1)) * 100}%`
: "0%",
}}
/>
</div>
{PROGRESS_STAGES.map((stage, idx) => {
const isCompleted = idx < currentStage;
const isActive = idx === currentStage;
return (
<div
key={stage.label}
className="relative z-10 flex flex-col items-center gap-2"
>
<div
className={cn(
"flex size-8 items-center justify-center rounded-full border-2 bg-background transition-all duration-300",
isCompleted
? "border-primary text-primary"
: isActive
? "scale-110 border-primary text-primary shadow-[0_0_10px_rgba(16,185,129,0.3)]"
: "border-muted text-muted-foreground",
)}
>
{isCompleted ? (
<CheckCircle2 className="size-4" />
) : (
<stage.icon className="size-4" />
)}
</div>
<span
className={cn(
"text-[9px] font-bold uppercase tracking-widest",
isActive ? "text-primary" : "text-muted-foreground",
)}
>
{stage.label}
</span>
</div>
);
})}
</div>
<div className="flex flex-col gap-4 rounded-xl border border-border/50 bg-muted/20 p-5 md:flex-row md:items-center">
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-background shadow-sm">
{booking.status === "CANCELLED" ? (
<AlertTriangle className="size-5 text-red-500" />
) : (
<Info className="size-5 text-primary" />
)}
</div>
<div className="flex flex-col gap-0.5">
<h4
className={cn(
"text-sm font-black uppercase tracking-tight",
statusConfig.color,
)}
>
{statusConfig.title}
</h4>
<p className="text-xs font-medium text-muted-foreground">
{statusConfig.description}
</p>
</div>
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
<div className="flex flex-col gap-8 lg:col-span-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Anchor className="size-4 text-primary" />
Route & Service
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-6">
<div className="flex flex-col items-center justify-between gap-4 rounded-xl border bg-muted/30 p-4 md:flex-row">
<RouteEndpoint
label="Origin Yard"
station={booking.originYard}
icon={<MapPin />}
/>
<div className="flex flex-col items-center gap-1 text-primary">
<div className="flex items-center gap-2">
<Train className="size-5" />
<ArrowRight className="size-4" />
</div>
<Badge
variant="outline"
className="border-primary/20 bg-primary/5 text-[9px] font-bold uppercase"
>
{booking.serviceType.replace(/_/g, " ")}
</Badge>
</div>
<RouteEndpoint
label="Destination Yard"
station={booking.destinationYard}
icon={<MapPin />}
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<InfoItem
icon={<Ship />}
label="Trade Direction"
value={booking.tradeDirection}
/>
<InfoItem
icon={<ShieldCheck />}
label="Return"
value={
booking.serviceType === "RAIL_AND_FORWARDING"
? "With Return"
: "Without Return"
}
/>
{booking.shippingLine && (
<InfoItem
icon={<Ship />}
label="Shipping Line"
value={booking.shippingLine}
/>
)}
</div>
</CardContent>
</Card>
{(booking.firstMilePickupAddress ||
booking.lastMileDeliveryAddress) && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Truck className="size-4 text-primary" />
Mile Services
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-6 md:grid-cols-2">
{booking.firstMilePickupAddress && (
<div className="flex flex-col gap-3">
<h3 className="border-l-4 border-primary pl-3 text-xs font-bold uppercase tracking-wide text-foreground">
First Mile
</h3>
<InfoItem
label="Pickup"
value={booking.firstMilePickupAddress}
/>
</div>
)}
{booking.lastMileDeliveryAddress && (
<div className="flex flex-col gap-3">
<h3 className="border-l-4 border-primary pl-3 text-xs font-bold uppercase tracking-wide text-foreground">
Last Mile
</h3>
<InfoItem
label="Delivery"
value={booking.lastMileDeliveryAddress}
/>
</div>
)}
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Package className="size-4 text-primary" />
Cargo Specifications
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<InfoItem
icon={<Package />}
label="Type"
value={booking.cargoType}
/>
<InfoItem
icon={<Weight />}
label="Total Weight"
value={`${booking.cargoTotalWeightVgm} Tons`}
/>
{booking.shippingLine && (
<InfoItem
icon={<Ship />}
label="Shipping Line"
value={booking.shippingLine}
/>
)}
</div>
<Separator />
<div className="flex flex-wrap gap-2">
<Badge variant="outline" className="bg-background text-[9px]">
Hazardous: {booking.isHazardous ? "Yes" : "No"}
</Badge>
{booking.pnrCode && (
<Badge
variant="outline"
className="bg-background text-[9px]"
>
PNR: {booking.pnrCode}
</Badge>
)}
</div>
</CardContent>
</Card>
</div>
<div className="flex flex-col gap-8">
<Card className="border-primary/20 bg-primary/[0.02]">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<FileText className="size-4 text-primary" />
Contract Info
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<InfoItem label="Type" value={booking.contractType} />
<InfoItem label="Currency" value={booking.paymentCurrency} />
<InfoItem
label="Amount"
value={`${booking.paymentCurrency} ${booking.totalAmount.toLocaleString()}`}
/>
<InfoItem label="Payment" value={booking.paymentStatus} />
<Separator />
<InfoItem label="Created By" value={booking.createdBy} />
<InfoItem
label="Created"
value={new Date(booking.createdAt).toLocaleDateString()}
/>
<InfoItem
label="Last Updated"
value={new Date(booking.updatedAt).toLocaleDateString()}
/>
</CardContent>
</Card>
{canApprove && (
<Card className="border-amber-200 bg-amber-50/30">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base text-amber-800">
<AlertCircle className="size-4" />
Approval Required
</CardTitle>
<CardDescription className="text-amber-700">
This booking is waiting for your review.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<Button onClick={handleApprove}>
<ShieldCheck />
{booking.status === "RFQ_SUBMITTED"
? "Send Quotation"
: "Approve Booking"}
</Button>
<Button
variant="outline"
className="border-red-200 text-red-700 hover:bg-red-50"
onClick={handleReject}
>
<X />
Reject
</Button>
</CardContent>
</Card>
)}
</div>
</div>
</div>
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const style = STATUS_STYLES[status] ?? {
label: status,
color: "bg-muted text-muted-foreground border-border",
};
return (
<Badge
variant="outline"
className={cn(
"px-2 py-0.5 font-bold uppercase tracking-wider text-[9px]",
style.color,
)}
>
{style.label}
</Badge>
);
}
function PriorityBadge({ score }: { score: number }) {
if (score >= 3) {
return (
<span className="rounded-full bg-red-50 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-red-700">
Urgent
</span>
);
}
if (score === 2) {
return (
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-amber-700">
High
</span>
);
}
return (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-slate-600">
Normal
</span>
);
}
function RouteEndpoint({
label,
station,
icon,
}: {
label: string;
station: string;
icon: React.ReactNode;
}) {
return (
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-sm">
<div className="[&_svg]:size-5">{icon}</div>
</div>
<div className="flex flex-col">
<p className="text-[9px] font-bold uppercase tracking-wide text-muted-foreground">
{label}
</p>
<p className="text-sm font-black text-foreground">{station}</p>
</div>
</div>
);
}
function InfoItem({
icon,
label,
value,
}: {
icon?: React.ReactNode;
label: string;
value?: string | number | null;
}) {
return (
<div className="flex items-start gap-2">
{icon && (
<div className="mt-0.5 text-muted-foreground [&_svg]:size-3.5">
{icon}
</div>
)}
<div className="flex flex-col">
<p className="text-[9px] font-bold uppercase tracking-tight text-muted-foreground">
{label}
</p>
<p className="text-xs font-bold text-foreground">{value ?? "—"}</p>
</div>
</div>
);
}

View File

@@ -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<string, { label: string; color: string }> = {
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 (
<Badge
variant="outline"
className={cn(
"px-2 py-0.5 font-bold uppercase tracking-wider text-[9px]",
style.color,
)}
>
{style.label}
</Badge>
);
}
function PriorityBadge({ score }: { score: number }) {
if (score >= 3) {
return (
<span className="rounded-full bg-red-50 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-red-700">
Urgent
</span>
);
}
if (score === 2) {
return (
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-amber-700">
High
</span>
);
}
return (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-slate-600">
Normal
</span>
);
}
export default function BookingRequestsPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<string | null>(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<BookingRequest>[] = [
{
id: "booking",
header: "Booking",
cell: ({ row }) => {
const b = row.original;
return (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Package className="h-5 w-5" />
</div>
<div>
<p className="font-medium text-slate-900">{b.reference}</p>
<p className="flex items-center gap-1 text-xs text-slate-500">
<User className="h-3 w-3" />
{b.customer}
</p>
</div>
</div>
);
},
},
{
id: "route",
header: "Route",
cell: ({ row }) => {
const b = row.original;
return (
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-1 text-xs font-medium text-slate-700">
<span>{b.originYard}</span>
<ArrowRight className="h-3 w-3 text-slate-400" />
<span>{b.destinationYard}</span>
</div>
<span className="text-[10px] uppercase tracking-wide text-slate-500">
{b.tradeDirection}
</span>
</div>
);
},
},
{
id: "status",
header: "Status",
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: "service",
header: "Service",
cell: ({ row }) => {
const b = row.original;
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium text-slate-700">
{b.serviceType.replace(/_/g, " ")}
</span>
<span className="flex items-center gap-1 text-[10px] text-slate-500">
<Calendar className="h-3 w-3" />
{b.scheduledDate}
</span>
</div>
);
},
},
{
id: "cargo",
header: "Cargo",
cell: ({ row }) => {
const b = row.original;
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium text-slate-700">
{b.cargoType}
</span>
<span className="text-[10px] text-slate-500">
{b.cargoTotalWeightVgm}T
</span>
</div>
);
},
},
{
id: "priority",
header: "Priority",
cell: ({ row }) => <PriorityBadge score={row.original.priorityScore} />,
},
{
id: "amount",
header: "Amount",
cell: ({ row }) => {
const b = row.original;
return (
<span className="font-mono text-xs font-semibold text-slate-900">
{b.paymentCurrency} {b.totalAmount.toLocaleString()}
</span>
);
},
},
{
id: "actions",
size: 40,
cell: ({ row }) => {
const b = row.original;
return (
<div
className="flex justify-end"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() =>
navigate(`/dashboard/booking-requests/${b.id}`)
}
>
<Eye />
View Details
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onSelect={() =>
navigate(`/dashboard/booking-requests/${b.id}`)
}
>
<AlertCircle />
Review
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
<Breadcrumbs items={[{ label: "Booking Requests" }]} />
<Card className="flex-row justify-between p-6">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
Booking Requests
</h1>
<p className="mt-1 text-sm text-secondary-foreground">
Review, approve, or reject customer booking requests across the
freight network.
</p>
</div>
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
<div className="relative w-full sm:w-72">
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
type="search"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}}
placeholder="Search reference or customer..."
className="pl-8!"
/>
</div>
</div>
</Card>
<div className="grid gap-4 md:grid-cols-4">
<StatCard
label="Total Requests"
value={bookingRequests.length}
icon={<FileText />}
/>
<StatCard
label="Pending Action"
value={pendingCount}
icon={<Clock />}
/>
<StatCard label="Active" value={activeCount} icon={<Train />} />
<StatCard label="Urgent" value={urgentCount} icon={<AlertCircle />} />
</div>
<Card className="gap-0">
<CardHeader className="flex flex-row items-center justify-between border-b">
<div>
<CardTitle>All Booking Requests</CardTitle>
<CardDescription>
{total} request{total !== 1 ? "s" : ""} found
</CardDescription>
</div>
<div className="flex items-center gap-2">
{statusFilter && (
<Button
variant="ghost"
size="sm"
onClick={() => setStatusFilter(null)}
>
Clear filter
</Button>
)}
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="sm">
<Filter />
{statusFilter
? (STATUS_STYLES[statusFilter]?.label ?? "Filter")
: "Filter"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{BOOKING_STATUSES.map((s) => (
<DropdownMenuItem
key={s}
onSelect={() => setStatusFilter(s)}
>
{STATUS_STYLES[s]?.label ?? s}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent className="px-0">
<DataTable
columns={columns}
data={paginatedData}
status="success"
onRowClick={(row) =>
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}
/>
</CardContent>
</Card>
</div>
</div>
);
}
function StatCard({
label,
value,
icon,
}: {
label: string;
value: number;
icon: React.ReactNode;
}) {
return (
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">{label}</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
{icon}
</div>
</CardContent>
</Card>
);
}

View File

@@ -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<T>(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;
}
}

View File

@@ -1433,6 +1433,7 @@ const UserManagementPage = () => {
No organizations available.
</div>
)}
</aside>
<aside className="rounded-3xl border border-border bg-card p-5 shadow-sm">

View File

@@ -1,11 +1,755 @@
import FeaturePlaceholder from "@/components/FeaturePlaceholder";
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { isAxiosError } from "axios";
import {
Badge,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@edr/ui-common";
import { Network, RefreshCw, Search, UserCheck, UserMinus, Users } from "lucide-react";
import { api } from "@/auth/http";
import { useAuth } from "@/auth/useAuth";
interface LocaleText {
en?: string;
am?: string;
}
interface OrganizationRecord {
id: string;
key: string;
name: LocaleText;
}
interface EmployeeUserRecord {
id: string;
name?: LocaleText;
email?: string;
phoneNumber?: string;
username?: string;
}
interface EmployeePositionSummary {
id: string;
position?: {
id: string;
name?: LocaleText;
};
}
interface EmployeeRecord {
id: string;
name?: LocaleText;
user?: EmployeeUserRecord;
status?: string;
employeePositions?: EmployeePositionSummary[];
}
interface RoleRecord {
id: string;
key: string;
name: LocaleText;
}
interface UserFormState {
nameEn: string;
nameAm: string;
email: string;
username: string;
phoneNumber: string;
}
interface ListResponse<T> {
items?: T[];
data?: T[];
}
const RESERVED_ROLE_KEYS = new Set(["super_admin", "organization_admin", "unit_admin"]);
const emptyUserForm: UserFormState = {
nameEn: "",
nameAm: "",
email: "",
username: "",
phoneNumber: "",
};
const inputClassName =
"w-full rounded-xl border border-border bg-background px-3 py-2.5 text-sm text-foreground outline-none transition focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100 dark:focus:ring-emerald-950";
const buttonClassName =
"inline-flex items-center justify-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-60";
const getLocaleLabel = (value?: LocaleText | null, fallback = "Unnamed") => {
if (!value) {
return fallback;
}
return value.en ?? value.am ?? fallback;
};
const getItems = <T,>(payload: ListResponse<T> | T[] | undefined | null) => {
if (!payload) {
return [] as T[];
}
if (Array.isArray(payload)) {
return payload;
}
return payload.items ?? payload.data ?? [];
};
const getErrorMessage = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
const message = error.response?.data?.message;
if (typeof message === "string") {
return message;
}
if (Array.isArray(message) && typeof message[0] === "string") {
return message[0];
}
}
return error instanceof Error ? error.message : fallback;
};
const Field = ({ label, children }: { label: string; children: ReactNode }) => (
<label className="flex flex-col gap-2 text-sm">
<span className="font-medium text-foreground">{label}</span>
{children}
</label>
);
const ManagementDialog = ({
open,
title,
description,
onOpenChange,
children,
}: {
open: boolean;
title: string;
description?: string;
onOpenChange: (open: boolean) => void;
children: ReactNode;
}) => (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description ? <DialogDescription>{description}</DialogDescription> : null}
</DialogHeader>
{children}
</DialogContent>
</Dialog>
);
const UsersPage = () => {
const { user } = useAuth();
const [organizations, setOrganizations] = useState<OrganizationRecord[]>([]);
const [orgEmployees, setOrgEmployees] = useState<EmployeeRecord[]>([]);
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(null);
const [orgUserSearch, setOrgUserSearch] = useState("");
const [createUserForm, setCreateUserForm] = useState<UserFormState>(emptyUserForm);
const [availableRoles, setAvailableRoles] = useState<RoleRecord[]>([]);
const [roleIds, setRoleIds] = useState<string[]>([]);
const [selectedRoleUser, setSelectedRoleUser] = useState<EmployeeRecord | null>(null);
const [loading, setLoading] = useState(true);
const [orgEmployeesLoading, setOrgEmployeesLoading] = useState(false);
const [rolesLoading, setRolesLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [actionSuccess, setActionSuccess] = useState<string | null>(null);
const [isCreateUserOpen, setIsCreateUserOpen] = useState(false);
const [isManageRolesOpen, setIsManageRolesOpen] = useState(false);
const isSuperAdmin = Boolean(user?.roles?.some((role) => role.key === "super_admin"));
const allowedOrgIds = useMemo(
() => new Set((user?.employee ?? []).map((employee) => employee.organizationId).filter(Boolean)),
[user?.employee],
);
const visibleOrganizations = useMemo(() => {
if (isSuperAdmin) {
return organizations;
}
return organizations.filter((organization) => allowedOrgIds.has(organization.id));
}, [allowedOrgIds, isSuperAdmin, organizations]);
const selectedOrganization = useMemo(
() => visibleOrganizations.find((item) => item.id === selectedOrgId) ?? null,
[selectedOrgId, visibleOrganizations],
);
const filteredOrgEmployees = useMemo(() => {
const query = orgUserSearch.trim().toLowerCase();
return orgEmployees.filter((employee) => {
const label = getLocaleLabel(
employee.name ?? employee.user?.name,
employee.user?.email ?? employee.id,
).toLowerCase();
const email = employee.user?.email?.toLowerCase() ?? "";
const username = employee.user?.username?.toLowerCase() ?? "";
if (!query) {
return true;
}
return label.includes(query) || email.includes(query) || username.includes(query);
});
}, [orgEmployees, orgUserSearch]);
const resetMessages = () => {
setActionError(null);
setActionSuccess(null);
};
const loadOrganizations = useCallback(async () => {
setLoading(true);
setLoadError(null);
try {
const response = await api.get<ListResponse<OrganizationRecord>>("/organizations");
setOrganizations(getItems(response.data));
} catch (error) {
setLoadError(getErrorMessage(error, "Failed to load organizations."));
} finally {
setLoading(false);
}
}, []);
const loadOrgEmployees = useCallback(async (organizationId: string) => {
setOrgEmployeesLoading(true);
try {
const response = await api.get<ListResponse<EmployeeRecord>>(
`/employees/${organizationId}/by-organization`,
);
setOrgEmployees(getItems(response.data));
} catch {
setOrgEmployees([]);
} finally {
setOrgEmployeesLoading(false);
}
}, []);
useEffect(() => {
void loadOrganizations();
}, [loadOrganizations]);
useEffect(() => {
if (!visibleOrganizations.length) {
setSelectedOrgId(null);
setOrgEmployees([]);
return;
}
if (selectedOrgId && visibleOrganizations.some((organization) => organization.id === selectedOrgId)) {
return;
}
setSelectedOrgId(visibleOrganizations[0]?.id ?? null);
}, [selectedOrgId, visibleOrganizations]);
useEffect(() => {
if (!selectedOrgId) {
setOrgEmployees([]);
return;
}
void loadOrgEmployees(selectedOrgId);
}, [loadOrgEmployees, selectedOrgId]);
const handleRefresh = async () => {
resetMessages();
await Promise.all([
loadOrganizations(),
selectedOrgId ? loadOrgEmployees(selectedOrgId) : Promise.resolve(),
]);
};
const handleSelectOrganization = async (organizationId: string) => {
setSelectedOrgId(organizationId);
setOrgEmployees([]);
resetMessages();
try {
await loadOrgEmployees(organizationId);
} catch {
// Loader already handles fallback state.
}
};
const openCreateUserDialog = () => {
if (!selectedOrgId) {
setActionError("Select an organization before adding a user.");
return;
}
setCreateUserForm(emptyUserForm);
resetMessages();
setIsCreateUserOpen(true);
};
const openManageRolesDialog = async (employee: EmployeeRecord) => {
if (!selectedOrgId || !employee.user?.id) {
return;
}
setRolesLoading(true);
resetMessages();
setSelectedRoleUser(employee);
setIsManageRolesOpen(true);
try {
const [rolesResponse, assignedResponse] = await Promise.all([
api.get<ListResponse<RoleRecord>>("/roles"),
api.get<RoleRecord[]>(`/backoffice/organizations/${selectedOrgId}/employee-users/${employee.user.id}/roles`),
]);
const roles = getItems(rolesResponse.data).filter((role) => !RESERVED_ROLE_KEYS.has(role.key));
setAvailableRoles(roles);
setRoleIds(getItems(assignedResponse.data).map((role) => role.id));
} catch (error) {
setActionError(getErrorMessage(error, "Failed to load user roles."));
setAvailableRoles([]);
setRoleIds([]);
} finally {
setRolesLoading(false);
}
};
const handleCreateUser = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!selectedOrgId) {
setActionError("Select an organization before adding a user.");
return;
}
setSubmitting(true);
resetMessages();
try {
const response = await api.post<EmployeeRecord>(
`/backoffice/organizations/${selectedOrgId}/users`,
{
username: createUserForm.username.trim(),
phoneNumber: createUserForm.phoneNumber.trim(),
email: createUserForm.email.trim(),
name: {
am: createUserForm.nameAm.trim(),
en: createUserForm.nameEn.trim(),
},
},
);
setCreateUserForm(emptyUserForm);
setIsCreateUserOpen(false);
setActionSuccess("User created. Default password: 12345678.");
await loadOrgEmployees(selectedOrgId);
await openManageRolesDialog(response.data);
} catch (error) {
setActionError(getErrorMessage(error, "Failed to create user."));
} finally {
setSubmitting(false);
}
};
const handleSaveRoles = async () => {
if (!selectedOrgId || !selectedRoleUser?.user?.id) {
return;
}
setSubmitting(true);
resetMessages();
try {
await api.put(
`/backoffice/organizations/${selectedOrgId}/employee-users/${selectedRoleUser.user.id}/roles`,
{ roleIds },
);
setActionSuccess("User roles updated.");
setIsManageRolesOpen(false);
} catch (error) {
setActionError(getErrorMessage(error, "Failed to update user roles."));
} finally {
setSubmitting(false);
}
};
const handleToggleUserActivation = async (employee: EmployeeRecord) => {
if (!employee.user?.id) {
return;
}
const isInactive = employee.status?.toLowerCase() === "inactive";
setSubmitting(true);
resetMessages();
try {
await api.patch(`/users/${isInactive ? "activate-user" : "deactivate-user"}/${employee.user.id}`);
setActionSuccess(isInactive ? "User activated." : "User deactivated.");
if (selectedOrgId) {
await loadOrgEmployees(selectedOrgId);
}
} catch (error) {
setActionError(getErrorMessage(error, "Failed to update user status."));
} finally {
setSubmitting(false);
}
};
return (
<FeaturePlaceholder
title="Users"
description="Manage backoffice user accounts, activation state, and directory records for internal freight teams."
/>
<section className="space-y-6 bg-background p-6 text-foreground">
<div className="rounded-3xl border border-border bg-linear-to-br from-emerald-100 via-card to-background p-6 shadow-sm dark:from-emerald-950/30 dark:via-card dark:to-background">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-100 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300">
<Network className="h-6 w-6" />
</div>
<div className="space-y-2">
<p className="text-sm font-medium uppercase tracking-[0.2em] text-emerald-700 dark:text-emerald-300">
User management
</p>
<h1 className="text-2xl font-semibold text-foreground">Users</h1>
<p className="max-w-3xl text-sm text-muted-foreground">
Create organization users, activate or deactivate access, and assign organization-scoped roles.
</p>
</div>
</div>
<button
type="button"
onClick={() => void handleRefresh()}
className={`${buttonClassName} border border-border bg-card text-card-foreground hover:bg-accent hover:text-accent-foreground`}
>
<RefreshCw className="h-4 w-4" />
Refresh
</button>
</div>
</div>
{actionSuccess ? (
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800 dark:border-emerald-900/60 dark:bg-emerald-950/30 dark:text-emerald-200">
{actionSuccess}
</div>
) : null}
{actionError ? (
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/40 dark:text-rose-200">
{actionError}
</div>
) : null}
{loadError ? (
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/40 dark:text-rose-200">
{loadError}
</div>
) : null}
{loading ? (
<div className="rounded-3xl border border-border bg-card p-6 text-sm text-muted-foreground shadow-sm">
Loading users workspace...
</div>
) : (
<div className="grid gap-6 xl:grid-cols-4">
<aside className="rounded-3xl border border-border bg-card p-5 shadow-sm">
<div className="mb-5">
<h2 className="text-lg font-semibold text-card-foreground">Organization</h2>
<p className="text-sm text-muted-foreground">
{isSuperAdmin ? "All organizations" : "Assigned organizations"}
</p>
</div>
{visibleOrganizations.length ? (
<ul className="space-y-2">
{visibleOrganizations.map((organization) => {
const isActive = selectedOrgId === organization.id;
const isDisabled = !isSuperAdmin;
return (
<li key={organization.id}>
<button
type="button"
disabled={isDisabled}
onClick={() => void handleSelectOrganization(organization.id)}
className={`flex w-full items-center justify-between gap-2 rounded-2xl border px-4 py-3 text-left transition ${
isActive
? "border-emerald-300 bg-emerald-50 text-emerald-900 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-100"
: "border-border bg-card text-card-foreground hover:border-emerald-200 hover:bg-emerald-50/80 dark:hover:bg-slate-900"
} ${isDisabled ? "cursor-default" : ""}`}
>
<div className="min-w-0">
<div className="truncate font-medium">
{getLocaleLabel(organization.name, organization.key)}
</div>
<div className="truncate text-xs text-muted-foreground">{organization.key}</div>
</div>
{!isSuperAdmin ? <Badge className="bg-sky-100 text-sky-700">Assigned</Badge> : null}
</button>
</li>
);
})}
</ul>
) : (
<div className="rounded-2xl border border-dashed border-border bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
No organizations available.
</div>
)}
<button
type="button"
disabled={!selectedOrgId || submitting}
onClick={openCreateUserDialog}
className={`${buttonClassName} mt-4 w-full bg-emerald-600 text-white hover:bg-emerald-700`}
>
Add user to organization
</button>
</aside>
<section className="rounded-3xl border border-border bg-card p-5 shadow-sm xl:col-span-3">
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-card-foreground">Users</h2>
<p className="text-sm text-muted-foreground">
{selectedOrganization
? `Manage users in ${getLocaleLabel(selectedOrganization.name, selectedOrganization.key)}`
: "Select an organization"}
</p>
</div>
<div className="rounded-full border border-border bg-muted px-3 py-1 text-sm font-medium text-muted-foreground">
{orgEmployees.length} users
</div>
</div>
<div className="relative mb-4">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
className={`${inputClassName} pl-9`}
value={orgUserSearch}
onChange={(event) => setOrgUserSearch(event.target.value)}
placeholder="Search users"
/>
</div>
{orgEmployeesLoading ? (
<div className="rounded-2xl border border-dashed border-border bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
Loading users...
</div>
) : filteredOrgEmployees.length ? (
<div className="space-y-3">
{filteredOrgEmployees.map((employee) => {
const userId = employee.user?.id;
const displayName = getLocaleLabel(
employee.name ?? employee.user?.name,
employee.user?.email ?? employee.id,
);
const assignedPositions = employee.employeePositions
?.map((position) => getLocaleLabel(position.position?.name, position.position?.id ?? ""))
.filter(Boolean)
.join(", ");
return (
<article
key={employee.id}
className="rounded-2xl border border-border bg-background p-4 shadow-sm"
>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-base font-semibold text-foreground">{displayName}</h3>
{employee.status ? (
<Badge className="bg-gray-100 text-gray-700">{employee.status}</Badge>
) : null}
</div>
<div className="text-sm text-muted-foreground">
{employee.user?.email || employee.user?.username || "No contact info"}
</div>
<div className="text-sm text-muted-foreground">
{assignedPositions ? `Current positions: ${assignedPositions}` : "No positions assigned."}
</div>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={!userId || submitting}
onClick={() => void handleToggleUserActivation(employee)}
className={`${buttonClassName} border border-border bg-card text-card-foreground hover:bg-accent hover:text-accent-foreground`}
>
{employee.status?.toLowerCase() === "inactive" ? (
<>
<UserCheck className="h-4 w-4" />
Activate
</>
) : (
<>
<UserMinus className="h-4 w-4" />
Deactivate
</>
)}
</button>
<button
type="button"
disabled={!userId || !selectedOrgId || submitting}
onClick={() => void openManageRolesDialog(employee)}
className={`${buttonClassName} bg-emerald-600 text-white hover:bg-emerald-700`}
>
<Users className="h-4 w-4" />
Manage roles
</button>
</div>
</div>
</article>
);
})}
</div>
) : (
<div className="rounded-2xl border border-dashed border-border bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
{selectedOrganization
? "No users found for this organization."
: "Select an organization to load users."}
</div>
)}
</section>
</div>
)}
<ManagementDialog
open={isCreateUserOpen}
onOpenChange={setIsCreateUserOpen}
title="Add user"
description={
selectedOrganization
? `Create a loginable user in ${getLocaleLabel(selectedOrganization.name, selectedOrganization.key)}. Default password: 12345678.`
: "Create a loginable user in the selected organization."
}
>
<form className="space-y-4" onSubmit={handleCreateUser}>
<Field label="English name">
<input
className={inputClassName}
value={createUserForm.nameEn}
onChange={(event) => setCreateUserForm((current) => ({ ...current, nameEn: event.target.value }))}
/>
</Field>
<Field label="Amharic name">
<input
className={inputClassName}
value={createUserForm.nameAm}
onChange={(event) => setCreateUserForm((current) => ({ ...current, nameAm: event.target.value }))}
/>
</Field>
<Field label="Email">
<input
className={inputClassName}
type="email"
value={createUserForm.email}
onChange={(event) => setCreateUserForm((current) => ({ ...current, email: event.target.value }))}
/>
</Field>
<Field label="Username">
<input
className={inputClassName}
value={createUserForm.username}
onChange={(event) => setCreateUserForm((current) => ({ ...current, username: event.target.value }))}
/>
</Field>
<Field label="Phone number">
<input
className={inputClassName}
value={createUserForm.phoneNumber}
onChange={(event) => setCreateUserForm((current) => ({ ...current, phoneNumber: event.target.value }))}
/>
</Field>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setIsCreateUserOpen(false)}
className={`${buttonClassName} border border-border bg-card text-card-foreground hover:bg-accent hover:text-accent-foreground`}
>
Cancel
</button>
<button type="submit" disabled={submitting} className={`${buttonClassName} bg-emerald-600 text-white hover:bg-emerald-700`}>
Create user
</button>
</div>
</form>
</ManagementDialog>
<ManagementDialog
open={isManageRolesOpen}
onOpenChange={setIsManageRolesOpen}
title="Manage roles"
description={
selectedRoleUser
? `Assign organization-scoped roles for ${getLocaleLabel(selectedRoleUser.name ?? selectedRoleUser.user?.name, selectedRoleUser.user?.email ?? selectedRoleUser.id)}.`
: undefined
}
>
<div className="space-y-4">
{rolesLoading ? (
<div className="rounded-2xl border border-dashed border-border bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
Loading roles...
</div>
) : (
<div className="max-h-[420px] space-y-2 overflow-y-auto pr-1">
{availableRoles.map((role) => (
<label
key={role.id}
className="flex items-center gap-3 rounded-2xl border border-border bg-background px-4 py-3 text-sm"
>
<input
type="checkbox"
checked={roleIds.includes(role.id)}
onChange={(event) => {
setRoleIds((current) =>
event.target.checked
? [...current, role.id]
: current.filter((currentRoleId) => currentRoleId !== role.id),
);
}}
/>
<div>
<div className="font-medium text-foreground">{getLocaleLabel(role.name, role.key)}</div>
<div className="text-xs text-muted-foreground">{role.key}</div>
</div>
</label>
))}
{!availableRoles.length ? (
<div className="rounded-2xl border border-dashed border-border bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
No assignable roles available.
</div>
) : null}
</div>
)}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setIsManageRolesOpen(false)}
className={`${buttonClassName} border border-border bg-card text-card-foreground hover:bg-accent hover:text-accent-foreground`}
>
Cancel
</button>
<button
type="button"
disabled={submitting || rolesLoading}
onClick={() => void handleSaveRoles()}
className={`${buttonClassName} bg-emerald-600 text-white hover:bg-emerald-700`}
>
Save roles
</button>
</div>
</div>
</ManagementDialog>
</section>
);
};

View File

@@ -1,17 +1,23 @@
import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { Check, CheckCircle2, ChevronLeft, ChevronRight } from "lucide-react";
import {
AlertCircle,
Check,
CheckCircle2,
ChevronLeft,
ChevronRight,
LoaderCircle,
} from "lucide-react";
import { Button } from "@edr/ui-common";
import Breadcrumbs from "@/components/Breadcrumbs";
import { api } from "@/services/api";
import type { CreateBookingPayload } from "@/services/bookings.service";
import {
BookingFormInputValues,
STEPS,
bookingFormSchema,
calcWagons,
getRouteDirection,
initialBookingFormValues,
stepFields,
@@ -32,6 +38,10 @@ export default function NewBookingPage() {
const queryClient = useQueryClient();
const [step, setStep] = useState(1);
const { customer } = useAuth();
const { data: referenceData, isLoading: refDataLoading } = useQuery(
api.bookings.referenceData.queryOptions(),
);
const createMutation = useMutation({
mutationFn: (payload: CreateBookingPayload) =>
api.bookings.create.call(payload),
@@ -41,7 +51,7 @@ export default function NewBookingPage() {
},
});
const form = useForm<BookingFormValues>({
const form = useForm<BookingFormInputValues, any, BookingFormValues>({
defaultValues: initialBookingFormValues,
resolver: zodResolver(bookingFormSchema),
mode: "onChange",
@@ -49,18 +59,12 @@ export default function NewBookingPage() {
const originYard = form.watch("originYard");
const destinationYard = form.watch("destinationYard");
const containers = form.watch("containers");
const direction = useMemo(
() => getRouteDirection(originYard, destinationYard),
[originYard, destinationYard],
);
const wagons = useMemo(() => {
if (!containers || containers.length === 0) return null;
return calcWagons(containers);
}, [containers]);
async function handleContinue() {
const valid = await form.trigger(stepFields[step], { shouldFocus: true });
if (!valid) return;
@@ -78,8 +82,6 @@ export default function NewBookingPage() {
return;
}
const reference = data.previousContractRef;
const totalWeight =
data.cargoType === "container"
? data.containers.reduce(
@@ -88,59 +90,115 @@ export default function NewBookingPage() {
)
: Number(data.cargoWeight || 0);
const apiPayload = {
reference,
customerId: customer!.id,
// ── Reference data lookups ──────────────────────────────────────────
const yards = referenceData?.yard ?? [];
const services = referenceData?.service ?? [];
const shippingLines = referenceData?.shipping_line ?? [];
const cargoTree = referenceData?.cargo_type ?? [];
const containerGroups = referenceData?.containers ?? [];
const findYardId = (name: string): string =>
yards.find((y) => y.name === name)?.id ?? "";
const findServiceTypeId = (): string => {
const code = data.serviceType === "rail" ? "RAIL" : "RAIL_AND_FORWARDING";
return services.find((s) => s.code === code)?.id ?? services[0]?.id ?? "";
};
const findShippingLineId = (name: string): string | undefined =>
shippingLines.find((l) => l.name === name)?.id;
const findCargoTypeId = (name: string): string | undefined => {
for (const group of cargoTree) {
const child = group.children?.find((c) => c.name === name);
if (child) return child.id;
}
return undefined;
};
const findContainerCargoTypeId = (): string => {
const group = cargoTree.find(
(g) => g.code === "CONTAINER" || /container/i.test(g.name),
);
console.log(group, cargoTree);
return group?.id ?? "";
};
const findContainerTypeId = (name: string): string => {
for (const group of containerGroups) {
const ct = group.types.find((t) => t.name === name);
if (ct) return ct.id;
}
return "";
};
const cargoTypeId = cargoTree[0].id;
// data.cargoType === "container"
// ? findContainerCargoTypeId()
// : (findCargoTypeId(
// data.freightType === "bulk"
// ? data.bulkCommodity
// : data.breakBulkType,
// ) ?? "");
const cargoFreeText =
data.cargoType === "container"
? undefined
: data.freightType === "bulk" && data.bulkCommodity === "Others"
? data.bulkCommodityOther
: data.freightType === "break_bulk" && data.breakBulkType === "Others"
? data.breakBulkTypeOther
: undefined;
// ── Build API payload ───────────────────────────────────────────────
const apiPayload: CreateBookingPayload = {
scheduledDate: new Date().toISOString().slice(0, 10),
totalAmount: 0,
contractType:
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
previousContractId: data.previousContractRef || undefined,
serviceType:
data.service.serviceType === "rail"
? "RAIL_ONLY"
: "RAIL_AND_FORWARDING",
...(data.service.serviceType === "rail"
? {}
: {
firstMileEnabled: data.firstMile.enabled,
firstMilePickupAddress: data.firstMile.pickUpAddress ?? undefined,
lastMileEnabled: data.lastMile.enabled,
lastMileDeliveryAddress: data.lastMile.deliveryAddress ?? undefined,
equipmentReturn:
data.equipmentReturn === "with_return"
? ("WITH_RETURN" as const)
: ("WITHOUT_RETURN" as const),
customsClearingEnabled: data.customsClearingEnabled,
}),
originStation: data.originYard,
destinationStation: data.destinationYard,
cargoTotalWeightVgm: totalWeight,
freightType: data.cargoType === "container" ? "BREAK_BULK" : "BULK",
freightSubtype:
data.cargoType === "container"
? undefined
: data.freightType === "bulk"
? data.bulkCommodity
: data.breakBulkType,
isHazardous: data.isHazardous,
isRefrigerated: data.isRefrigerated,
serviceTypeId: findServiceTypeId(),
equipmentReturn:
data.equipmentReturn === "with_return"
? "WITH_RETURN"
: "WITHOUT_RETURN",
originYardId: findYardId(data.originYard),
destinationYardId: findYardId(data.destinationYard),
tradeDirection:
getRouteDirection(data.originYard, data.destinationYard) === "export"
direction === "export"
? "EXPORT"
: "IMPORT",
: direction === "domestic"
? "DOMESTIC"
: "IMPORT",
cargoTypeId,
cargoTotalWeightVgm: totalWeight,
isHazardous: data.isHazardous,
paymentCurrency: "USD",
allowConsolidation: data.consolidationEnabled,
...(data.cargoType === "container" && data.containers.length > 0
? {
containers: data.containers.map((c) => ({
type: c.type === "40ft" ? ("40FT" as const) : ("20FT" as const),
qty: Number(c.qty || 1),
vgm: Number(c.vgm || 0),
})),
}
containers:
data.cargoType === "container"
? data.containers.map((c) => ({
containerTypeId: findContainerTypeId(c.containerType),
quantity: Number(c.qty || 1),
vgmPerUnitTons: Number(c.vgm || 0),
}))
: [],
...(customer ? { customerId: customer.id } : {}),
...(data.previousContractRef
? { previousContractId: data.previousContractRef }
: {}),
} satisfies CreateBookingPayload;
...(data.contractType === "renewal" && data.previousContractRef
? { pnrCode: data.previousContractRef }
: {}),
...(data.serviceType === "rail_forwarding" && data.firstMile.enabled
? { firstMilePickupAddress: data.firstMile.pickUpAddress }
: {}),
...(data.serviceType === "rail_forwarding" && data.lastMile.enabled
? { lastMileDeliveryAddress: data.lastMile.deliveryAddress }
: {}),
...(data.shippingLine
? { shippingLineId: findShippingLineId(data.shippingLine) }
: {}),
...(cargoFreeText ? { cargoFreeText } : {}),
};
createMutation.mutate(apiPayload);
});
@@ -179,11 +237,35 @@ export default function NewBookingPage() {
<div className="flex-1">
<div className="mx-auto max-w-4xl px-6 py-8">
{createMutation.isError && (
<div className="mb-6 flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-500" />
<div>
<p className="font-semibold">Submission failed</p>
<p className="mt-1 text-red-600">
{createMutation.error instanceof Error
? createMutation.error.message
: "An unexpected error occurred. Please try again."}
</p>
</div>
</div>
)}
{step === 1 && <Step1ContractType form={form} />}
{step === 2 && <Step2ServiceType form={form} />}
{step === 3 && <Step4Route form={form} />}
{step === 3 && (
<Step4Route
form={form}
referenceData={referenceData}
isLoading={refDataLoading}
/>
)}
{step === 4 && (
<Step5CargoDetails form={form} direction={direction} />
<Step5CargoDetails
form={form}
direction={direction}
referenceData={referenceData}
isLoading={refDataLoading}
/>
)}
{step === 5 && (
<Step8Review form={form} setStep={setStep} direction={direction} />
@@ -210,9 +292,19 @@ export default function NewBookingPage() {
<ChevronRight />
</Button>
) : (
<Button type="submit" form="new-booking-form">
<Check />
Submit Contract Request
<Button
type="submit"
form="new-booking-form"
disabled={createMutation.isPending}
>
{createMutation.isPending ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<Check />
)}
{createMutation.isPending
? "Submitting..."
: "Submit Contract Request"}
</Button>
)}
</div>

View File

@@ -249,6 +249,7 @@ export const bookingFormSchema = z
});
export type BookingFormValues = z.infer<typeof bookingFormSchema>;
export type BookingFormInputValues = z.input<typeof bookingFormSchema>;
export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
previousContractRef: "",

View File

@@ -21,7 +21,7 @@ import {
SelectTrigger,
SelectValue,
} from "@edr/ui-common";
import type { BookingFormValues } from "./schema";
import type { BookingFormInputValues, BookingFormValues } from "./schema";
import { cn } from "@/lib/utils";
export function OptionFieldError({ error }: { error?: { message?: string } }) {
@@ -122,7 +122,7 @@ export function SelectField({
disabled,
children,
}: {
field: ControllerRenderProps<BookingFormValues>;
field: ControllerRenderProps<BookingFormInputValues>;
error?: RhfFieldError;
label: string;
placeholder: string;

View File

@@ -1,7 +1,11 @@
import { Controller, type UseFormReturn } from "react-hook-form";
import { FileText, RefreshCw } from "lucide-react";
import { Field } from "@edr/ui-common";
import { MOCK_VALID_CONTRACTS, type BookingFormValues } from "./schema";
import {
BookingFormInputValues,
MOCK_VALID_CONTRACTS,
type BookingFormValues,
} from "./schema";
import {
AlertBox,
OptionCard,
@@ -11,7 +15,11 @@ import {
StepHeader,
} from "./shared";
type BookingForm = UseFormReturn<BookingFormValues>;
type BookingForm = UseFormReturn<
BookingFormInputValues,
any,
BookingFormValues
>;
export function Step1ContractType({ form }: { form: BookingForm }) {
const contractType = form.watch("contractType");

View File

@@ -2,10 +2,14 @@ import { useEffect, useRef } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import { FileText, Package, Train, Truck } from "lucide-react";
import { Badge, Field, FieldError, Input, Switch } from "@edr/ui-common";
import { type BookingFormValues } from "./schema";
import { BookingFormInputValues, type BookingFormValues } from "./schema";
import { OptionCard, OptionFieldError, StepHeader } from "./shared";
type BookingForm = UseFormReturn<BookingFormValues>;
type BookingForm = UseFormReturn<
BookingFormInputValues,
any,
BookingFormValues
>;
export function Step2ServiceType({ form }: { form: BookingForm }) {
const serviceType = form.watch("serviceType");

View File

@@ -1,37 +1,50 @@
import { useEffect, useMemo } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import { Flame, MapPin, Snowflake } from "lucide-react";
import { Field, SelectItem, Separator, Switch } from "@edr/ui-common";
import { Field, SelectItem, Separator, Skeleton, Switch } from "@edr/ui-common";
import type { Freight } from "@edr/types";
import {
SHIPPING_LINES,
BookingFormInputValues,
type BookingFormValues,
getRouteDirection,
STATIONS,
} from "./schema";
import {
AlertBox,
SelectField,
SelectOptions,
StepHeader,
StepLabel,
} from "./shared";
import { useDropdownSettingByCode } from "@/hooks/useDropdownSettings";
import { DropdownOption } from "@/types/dropdownSettings";
import { useEffect } from "react";
import { SelectField, StepHeader, StepLabel } from "./shared";
type BookingForm = UseFormReturn<BookingFormValues>;
type BookingForm = UseFormReturn<
BookingFormInputValues,
any,
BookingFormValues
>;
const STATION_DROPDOWN_CODE = "stations_ter";
export function Step4Route({ form }: { form: BookingForm }) {
export function Step4Route({
form,
referenceData,
isLoading,
}: {
form: BookingForm;
referenceData?: Freight.BookingReferenceData;
isLoading?: boolean;
}) {
const originYard = form.watch("originYard");
const destinationYard = form.watch("destinationYard");
const {
data: stationSetting,
isLoading: stationsLoading,
isError: stationsError,
error: stationsFetchError,
} = useDropdownSettingByCode(STATION_DROPDOWN_CODE);
const stationOptions = getStationOptions(stationSetting?.children);
const yardOptions = useMemo(() => {
if (!referenceData?.yard) return [];
return referenceData.yard.map((y) => ({
value: y.name,
label: y.name,
country: y.country,
}));
}, [referenceData]);
const shippingLineOptions = useMemo(() => {
if (!referenceData?.shipping_line) return [];
return referenceData.shipping_line.map((sl) => ({
value: sl.name,
label: sl.name,
}));
}, [referenceData]);
const direction = getRouteDirection(originYard, destinationYard);
const directionStyle: Record<string, string> = {
export: "bg-sky-50 text-sky-800 border-sky-200",
@@ -43,7 +56,6 @@ export function Step4Route({ form }: { form: BookingForm }) {
import: "Import workflow (outside country to inside country)",
domestic: "Domestic corridor",
};
const stationSelectDisabled = stationsLoading || stationOptions.length === 0;
useEffect(() => {
if (direction === "domestic") {
@@ -51,6 +63,8 @@ export function Step4Route({ form }: { form: BookingForm }) {
}
}, [direction]);
const stationSelectDisabled = yardOptions.length === 0;
return (
<div className="space-y-6">
<StepHeader
@@ -58,65 +72,59 @@ export function Step4Route({ form }: { form: BookingForm }) {
description="Select the origin and destination yards."
/>
<div className="space-y-3">
<StepLabel>Route</StepLabel>
<div className="grid gap-3 sm:grid-cols-2">
<Controller
name="originYard"
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Origin Yard*"
placeholder="Select origin..."
disabled={stationSelectDisabled}
>
<StationSelectOptions
options={stationOptions}
excludeValue={destinationYard}
isLoading={stationsLoading}
/>
</SelectField>
)}
/>
<Controller
name="destinationYard"
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Destination Yard *"
placeholder="Select destination..."
disabled={stationSelectDisabled}
>
<StationSelectOptions
options={stationOptions}
excludeValue={originYard}
isLoading={stationsLoading}
/>
</SelectField>
)}
/>
</div>
{stationsError && (
<AlertBox tone="error">
Failed to load stations from the API.{" "}
{stationsFetchError instanceof Error
? stationsFetchError.message
: "Try again later."}
</AlertBox>
)}
{direction && (
<div
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-xs font-medium ${directionStyle[direction]}`}
>
<MapPin className="h-3.5 w-3.5 shrink-0" />
{directionLabel[direction]}
{isLoading ? (
<LoadingSkeleton />
) : (
<div className="space-y-3">
<StepLabel>Route</StepLabel>
<div className="grid gap-3 sm:grid-cols-2">
<Controller
name="originYard"
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Origin Yard*"
placeholder="Select origin..."
disabled={stationSelectDisabled}
>
<YardSelectOptions
options={yardOptions}
excludeValue={destinationYard}
/>
</SelectField>
)}
/>
<Controller
name="destinationYard"
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Destination Yard *"
placeholder="Select destination..."
disabled={stationSelectDisabled}
>
<YardSelectOptions
options={yardOptions}
excludeValue={originYard}
/>
</SelectField>
)}
/>
</div>
)}
</div>
{direction && (
<div
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-xs font-medium ${directionStyle[direction]}`}
>
<MapPin className="h-3.5 w-3.5 shrink-0" />
{directionLabel[direction]}
</div>
)}
</div>
)}
{direction && direction != "domestic" && (
<Controller
@@ -129,7 +137,11 @@ export function Step4Route({ form }: { form: BookingForm }) {
label="Shipping Line"
placeholder="Select shipping line..."
>
<SelectOptions options={SHIPPING_LINES} />
{shippingLineOptions.map((sl) => (
<SelectItem key={sl.value} value={sl.value}>
{sl.label}
</SelectItem>
))}
</SelectField>
)}
/>
@@ -179,23 +191,35 @@ export function Step4Route({ form }: { form: BookingForm }) {
);
}
function getStationOptions(options?: DropdownOption[]): DropdownOption[] {
return [...(options ?? [])].sort((a, b) => a.order - b.order);
function LoadingSkeleton() {
return (
<div className="space-y-4 rounded-xl border border-border p-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-10 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-10 w-full" />
</div>
</div>
<Skeleton className="h-8 w-full" />
</div>
);
}
function StationSelectOptions({
function YardSelectOptions({
options,
excludeValue,
isLoading,
}: {
options: DropdownOption[];
options: Array<{ value: string; label: string; country: string }>;
excludeValue: string;
isLoading: boolean;
}) {
if (isLoading) {
if (options.length === 0) {
return (
<SelectItem value="__stations_loading" disabled>
Loading stations...
<SelectItem value="__yards_empty" disabled>
No yards available
</SelectItem>
);
}
@@ -204,22 +228,10 @@ function StationSelectOptions({
(option) => option.value !== excludeValue,
);
if (availableOptions.length === 0) {
return (
<SelectItem value="__stations_empty" disabled>
No stations available
</SelectItem>
);
}
return (
<>
{availableOptions.map((option) => (
<SelectItem
key={option.id}
value={option.value}
disabled={option.disabled}
>
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}

View File

@@ -1,10 +1,17 @@
import { useMemo } from "react";
import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form";
import { MapPin, Package, Plus, Trash2, Weight } from "lucide-react";
import { Button, Field, FieldError, FieldLabel, Input } from "@edr/ui-common";
import {
BREAK_BULK_TYPES,
BULK_COMMODITIES,
CONTAINER_TYPES,
Button,
Field,
FieldError,
FieldLabel,
Input,
Skeleton,
} from "@edr/ui-common";
import type { Freight } from "@edr/types";
import {
BookingFormInputValues,
calcWagons,
type BookingFormValues,
type RouteDirection,
@@ -13,19 +20,27 @@ import {
AlertBox,
OptionCard,
SelectField,
SelectOptions,
SelectItem,
StepHeader,
StepLabel,
} from "./shared";
type BookingForm = UseFormReturn<BookingFormValues>;
type BookingForm = UseFormReturn<
BookingFormInputValues,
any,
BookingFormValues
>;
export function Step5CargoDetails({
form,
direction,
referenceData,
isLoading,
}: {
form: BookingForm;
direction: RouteDirection;
referenceData?: Freight.BookingReferenceData;
isLoading?: boolean;
}) {
const cargoType = form.watch("cargoType");
const freightType = form.watch("freightType");
@@ -38,6 +53,20 @@ export function Step5CargoDetails({
name: "containers",
});
const containerTypeOptions = useMemo(() => {
if (!referenceData?.containers) return [];
return referenceData.containers.flatMap((group) =>
group.types.map((t) => t.name),
);
}, [referenceData]);
const bulkCommodityOptions = useMemo(() => {
if (!referenceData?.cargo_type) return [];
return referenceData.cargo_type.flatMap(
(group) => group.children?.map((c) => c.name) ?? [],
);
}, [referenceData]);
function getOverweightAlert(
type: "20ft" | "40ft",
vgm: number,
@@ -54,6 +83,26 @@ export function Step5CargoDetails({
return null;
}
if (isLoading) {
return (
<div className="space-y-6">
<StepHeader
title="Cargo Details"
description="Define your cargo type, weight, and container configuration."
/>
<div className="space-y-4 rounded-xl border border-border p-4">
<Skeleton className="h-4 w-24" />
<div className="grid gap-3 sm:grid-cols-2">
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</div>
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-1/3" />
</div>
</div>
);
}
return (
<div className="space-y-6">
<StepHeader
@@ -181,7 +230,11 @@ export function Step5CargoDetails({
label="Commodity *"
placeholder="Select commodity *"
>
<SelectOptions options={BULK_COMMODITIES} />
{bulkCommodityOptions.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectField>
)}
/>
@@ -216,7 +269,11 @@ export function Step5CargoDetails({
label="Break-bulk type *"
placeholder="Select type *"
>
<SelectOptions options={BREAK_BULK_TYPES} />
{bulkCommodityOptions.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectField>
)}
/>
@@ -408,7 +465,11 @@ export function Step5CargoDetails({
label="Container Type *"
placeholder="Select type..."
>
<SelectOptions options={CONTAINER_TYPES} />
{containerTypeOptions.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectField>
)}
/>

View File

@@ -11,13 +11,17 @@ import {
Textarea,
} from "@edr/ui-common";
import {
BookingFormInputValues,
type BookingFormValues,
type RouteDirection,
type WagonCalcResult,
} from "./schema";
import { StepHeader } from "./shared";
type BookingForm = UseFormReturn<BookingFormValues>;
type BookingForm = UseFormReturn<
BookingFormInputValues,
any,
BookingFormValues
>;
export function Step8Review({
form,

View File

@@ -137,6 +137,12 @@ export const api = {
bookingsService.create,
),
referenceData: endpoint<void, Freight.BookingReferenceData>(
"bookings",
"referenceData",
bookingsService.getReferenceData,
),
remove: endpoint<{ id: string }, void>("bookings", "remove", ({ id }) =>
bookingsService.remove(id),
),

View File

@@ -17,6 +17,10 @@ export const bookingsService = {
const { data } = await client.post("/api/bookings", payload);
return data.data;
},
getReferenceData: async (): Promise<Freight.BookingReferenceData> => {
const { data } = await client.get("/api/bookings/reference-data");
return data.data;
},
remove: async (id: string): Promise<void> => {
await client.delete(`/bookings/${id}`);
},

View File

@@ -216,45 +216,94 @@ export interface IInvoice extends BaseEntity {
dueAt: string;
}
// ── Reference Data (booking form catalog) ──────────────────────────────────────
export interface BookingReferenceYard {
id: string;
name: string;
code: string;
country: string;
}
export interface BookingReferenceContainerType {
id: string;
name: string;
code: string;
is_reefer: boolean;
wagons_per_unit: number;
}
export interface BookingReferenceContainerSizeGroup {
size: string;
types: BookingReferenceContainerType[];
}
export interface BookingReferenceService {
id: string;
name: string;
code: string;
}
export interface BookingReferenceShippingLine {
id: string;
name: string;
code: string;
}
export interface BookingReferenceCargoTypeChild {
id: string;
name: string;
code: string;
show_free_text_box: boolean;
}
export interface BookingReferenceCargoTypeGroup {
id: string;
name: string;
code: string;
children?: BookingReferenceCargoTypeChild[];
}
export interface BookingReferenceData {
yard: BookingReferenceYard[];
containers: BookingReferenceContainerSizeGroup[];
service: BookingReferenceService[];
shipping_line: BookingReferenceShippingLine[];
cargo_type: BookingReferenceCargoTypeGroup[];
}
// ── DTOs ───────────────────────────────────────────────────────────────────────
export interface CreateBookingContainerDto {
containerTypeId: string;
quantity: number;
vgmPerUnitTons: number;
}
export interface CreateBookingDto {
reference: string;
customerId: string;
reference?: string;
customerId?: string;
trainId?: string;
scheduledDate: string;
totalAmount: number;
paymentStatus?: string;
contractType: "NEW" | "RENEWAL";
previousContractId?: string;
serviceType: "RAIL_ONLY" | "RAIL_AND_FORWARDING";
firstMileEnabled?: boolean;
serviceTypeId: string;
firstMilePickupAddress?: string;
lastMileEnabled?: boolean;
lastMileDeliveryAddress?: string;
equipmentReturn: "WITH_RETURN" | "WITHOUT_RETURN";
customsClearingEnabled?: boolean;
originStation: string;
destinationStation: string;
equipmentReturn: "WITH_RETURN" | "WITHOUT_RETURN" | "NA";
originYardId: string;
destinationYardId: string;
tradeDirection: "IMPORT" | "EXPORT" | "DOMESTIC";
cargoTypeId: string;
cargoFreeText?: string;
shippingLineId?: string;
cargoTotalWeightVgm: number;
freightType: "BULK" | "BREAK_BULK";
freightSubtype?: string;
isHazardous?: boolean;
isRefrigerated?: boolean;
tradeDirection: "IMPORT" | "EXPORT";
paymentCurrency: string;
allowConsolidation?: boolean;
paymentCurrency: "ETB" | "USD";
pnrCode?: string;
startDate?: string;
endDate?: string;
financialTerms?: string;
containers?: Array<{
type: "20FT" | "40FT";
qty: number;
vgm: number;
}>;
containers: CreateBookingContainerDto[];
allowConsolidation?: boolean;
}

View File

@@ -23,6 +23,7 @@ export type {
export * from "./components/button";
export * from "./components/input";
export * from "./components/skeleton";
export * from "./components/textarea";
export * from "./components/label";
export * from "./components/card";