mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
solf merge conflict
This commit is contained in:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -21,4 +21,9 @@ coverage/
|
||||
# OS/editor
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
.vscode/
|
||||
|
||||
# emacs cache files
|
||||
*~
|
||||
\#*\#
|
||||
.\#*
|
||||
|
||||
@@ -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 $$;
|
||||
`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1433,6 +1433,7 @@ const UserManagementPage = () => {
|
||||
No organizations available.
|
||||
</div>
|
||||
)}
|
||||
|
||||
</aside>
|
||||
|
||||
<aside className="rounded-3xl border border-border bg-card p-5 shadow-sm">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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}`);
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user