mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
solf merge conflict
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -22,3 +22,8 @@ coverage/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
# emacs cache files
|
||||||
|
*~
|
||||||
|
\#*\#
|
||||||
|
.\#*
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
Put,
|
Put,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
import { BackofficeService } from "./backoffice.service";
|
import { BackofficeService } from "./backoffice.service";
|
||||||
|
import { CreateOrganizationUserDto } from "./dto/create-organization-user.dto";
|
||||||
import { UpdateEmployeeUserRolesDto } from "./dto/update-employee-user-roles.dto";
|
import { UpdateEmployeeUserRolesDto } from "./dto/update-employee-user-roles.dto";
|
||||||
|
|
||||||
@ApiTags("backoffice")
|
@ApiTags("backoffice")
|
||||||
@@ -16,6 +18,15 @@ import { UpdateEmployeeUserRolesDto } from "./dto/update-employee-user-roles.dto
|
|||||||
export class BackofficeController {
|
export class BackofficeController {
|
||||||
constructor(private readonly backofficeService: BackofficeService) {}
|
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")
|
@Get("organizations/:orgId/employee-users/:userId/roles")
|
||||||
@ApiOperation({ summary: "Get org-scoped roles assigned to an employee user" })
|
@ApiOperation({ summary: "Get org-scoped roles assigned to an employee user" })
|
||||||
getEmployeeUserRoles(
|
getEmployeeUserRoles(
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
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 { 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 { 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";
|
import { BackofficeService } from "./backoffice.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Role, UserRole, User])],
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([
|
||||||
|
Employee,
|
||||||
|
Organization,
|
||||||
|
Role,
|
||||||
|
User,
|
||||||
|
UserCredential,
|
||||||
|
UserRole,
|
||||||
|
]),
|
||||||
|
],
|
||||||
controllers: [BackofficeController],
|
controllers: [BackofficeController],
|
||||||
providers: [BackofficeService],
|
providers: [BackofficeService],
|
||||||
exports: [BackofficeService],
|
exports: [BackofficeService],
|
||||||
|
|||||||
@@ -4,21 +4,29 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { InjectRepository } from "@nestjs/typeorm";
|
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 { 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 { 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 { 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 { 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([
|
const RESERVED_ROLE_KEYS = new Set([
|
||||||
"super_admin",
|
"super_admin",
|
||||||
"organization_admin",
|
"organization_admin",
|
||||||
"unit_admin",
|
"unit_admin",
|
||||||
]);
|
]);
|
||||||
|
const DEFAULT_USER_PASSWORD = "12345678";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BackofficeService {
|
export class BackofficeService {
|
||||||
constructor(
|
constructor(
|
||||||
|
@InjectRepository(Organization)
|
||||||
|
private readonly organizationRepository: Repository<Organization>,
|
||||||
@InjectRepository(Role)
|
@InjectRepository(Role)
|
||||||
private readonly roleRepository: Repository<Role>,
|
private readonly roleRepository: Repository<Role>,
|
||||||
@InjectRepository(UserRole)
|
@InjectRepository(UserRole)
|
||||||
@@ -28,6 +36,142 @@ export class BackofficeService {
|
|||||||
private readonly dataSource: DataSource,
|
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) {
|
async getEmployeeUserRoles(organizationId: string, userId: string) {
|
||||||
await this.assertUserBelongsToOrganization(organizationId, userId);
|
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 {
|
import {
|
||||||
Organization,
|
Organization,
|
||||||
OrganizationConfiguration,
|
OrganizationConfiguration,
|
||||||
|
Permission,
|
||||||
Role,
|
Role,
|
||||||
|
RolePermission,
|
||||||
} from "@tria-plc/iamapi-common";
|
} from "@tria-plc/iamapi-common";
|
||||||
import { DataSource } from "typeorm";
|
import { DataSource, EntityManager, In } from "typeorm";
|
||||||
|
|
||||||
const EDR_ORG_KEY = "edr_freight";
|
const EDR_ORG_KEY = "edr_freight";
|
||||||
const EDR_ORG_NAME = { en: "EDR Freight" };
|
const EDR_ORG_NAME = { en: "EDR Freight" };
|
||||||
const SEED_FLAG = "SEED_EDR_ORG";
|
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",
|
key: "edr_employee",
|
||||||
name: { en: "EDR Employee" },
|
name: { en: "EDR Employee" },
|
||||||
|
permissions: [
|
||||||
|
// { key: "permission:key", name: { en: "Permission Name" } },
|
||||||
|
{ key: "permission:key", name: { en: "Permission Name" } },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "edr_customer",
|
key: "edr_customer",
|
||||||
name: { en: "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) {}
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
async run() {
|
async run() {
|
||||||
const shouldSeed = process.env[SEED_FLAG]?.trim().toLowerCase() === "true";
|
if (!this.shouldSeed()) {
|
||||||
|
|
||||||
if (!shouldSeed) {
|
|
||||||
this.logger.log(`Skipping EDR org seed because ${SEED_FLAG} is not enabled`);
|
this.logger.log(`Skipping EDR org seed because ${SEED_FLAG} is not enabled`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const roleRepository = this.dataSource.getRepository(Role);
|
await this.dataSource.transaction(async (manager) => {
|
||||||
const organizationRepository = this.dataSource.getRepository(Organization);
|
const organization = await this.ensureOrganization(manager);
|
||||||
const organizationConfigurationRepository =
|
|
||||||
this.dataSource.getRepository(OrganizationConfiguration);
|
|
||||||
|
|
||||||
await roleRepository.upsert(EDR_ROLES, {
|
await this.ensureOrganizationConfiguration(manager, organization.id);
|
||||||
conflictPaths: { key: true },
|
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({
|
let organization = await organizationRepository.findOne({
|
||||||
where: { key: EDR_ORG_KEY },
|
where: { key: EDR_ORG_KEY },
|
||||||
select: { id: true, key: true },
|
select: { id: true, key: true },
|
||||||
@@ -57,18 +91,31 @@ export class EdrOrgSeeder {
|
|||||||
isGovernmentOrganization: true,
|
isGovernmentOrganization: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
organization = {
|
this.logger.log(`Seeded EDR organization '${EDR_ORG_KEY}'`);
|
||||||
|
|
||||||
|
return {
|
||||||
id: insertResult.identifiers[0]?.id as string,
|
id: insertResult.identifiers[0]?.id as string,
|
||||||
key: EDR_ORG_KEY,
|
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({
|
await organizationConfigurationRepository.upsert({
|
||||||
organizationId: organization.id,
|
organizationId,
|
||||||
canCreateBranchByItself: true,
|
canCreateBranchByItself: true,
|
||||||
canStartReceivingRecord: true,
|
canStartReceivingRecord: true,
|
||||||
}, {
|
}, {
|
||||||
@@ -79,4 +126,100 @@ export class EdrOrgSeeder {
|
|||||||
`Ensured organization configuration for '${EDR_ORG_KEY}'`,
|
`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 { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { FreightDashboardLayout, type SidebarSection } from "@/components/layout";
|
import {
|
||||||
import { Boxes, LayoutDashboard, Network, Paperclip, Settings, SlidersHorizontal } from "lucide-react";
|
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 { useAuth } from "./auth/useAuth";
|
||||||
import LoginPage from "./pages/auth/LoginPage";
|
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 OverviewPage from "./pages/dashboard/OverviewPage";
|
||||||
import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
|
import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
|
||||||
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
|
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
|
||||||
import RolesPage from "./pages/dashboard/user-management/RolesPage";
|
import RolesPage from "./pages/dashboard/user-management/RolesPage";
|
||||||
import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage";
|
import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage";
|
||||||
import LoadingScreen from "./components/LoadingScreen";
|
import UsersPage from "./pages/dashboard/user-management/UsersPage";
|
||||||
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
|
||||||
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
|
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
|
||||||
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
||||||
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
|
|
||||||
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
||||||
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
||||||
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
||||||
|
|
||||||
// Create a QueryClient instance
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
retry: 1,
|
retry: 1,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
staleTime: 5 * 60 * 1000,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// const sidebarItems: SidebarItem[] = [
|
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||||
// {
|
|
||||||
// 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[] = [
|
|
||||||
{
|
{
|
||||||
title: "Main menu",
|
title: "Main menu",
|
||||||
mutedTitle: true,
|
mutedTitle: true,
|
||||||
@@ -71,6 +50,12 @@ const sidebarSections: SidebarSection[] = [
|
|||||||
href: "/dashboard/overview",
|
href: "/dashboard/overview",
|
||||||
icon: <LayoutDashboard />,
|
icon: <LayoutDashboard />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Booking requests",
|
||||||
|
href: "/dashboard/booking-requests",
|
||||||
|
icon: <FileText />,
|
||||||
|
},
|
||||||
|
...demoItems,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -81,6 +66,10 @@ const sidebarSections: SidebarSection[] = [
|
|||||||
href: "/dashboard/user-management",
|
href: "/dashboard/user-management",
|
||||||
icon: <Network />,
|
icon: <Network />,
|
||||||
children: [
|
children: [
|
||||||
|
{
|
||||||
|
label: "Users",
|
||||||
|
href: "/dashboard/user-management/users",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Employees",
|
label: "Employees",
|
||||||
href: "/dashboard/user-management/employees",
|
href: "/dashboard/user-management/employees",
|
||||||
@@ -117,11 +106,6 @@ const sidebarSections: SidebarSection[] = [
|
|||||||
icon: <Boxes />,
|
icon: <Boxes />,
|
||||||
children: getCategorySidebarChildren("configuration"),
|
children: getCategorySidebarChildren("configuration"),
|
||||||
},
|
},
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Rules & pricing",
|
|
||||||
items: [
|
|
||||||
{
|
{
|
||||||
label: "Rules",
|
label: "Rules",
|
||||||
href: "/dashboard/rules",
|
href: "/dashboard/rules",
|
||||||
@@ -130,6 +114,7 @@ const sidebarSections: SidebarSection[] = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const hasPermission = (
|
const hasPermission = (
|
||||||
@@ -138,16 +123,41 @@ const hasPermission = (
|
|||||||
) => {
|
) => {
|
||||||
if (!user) return false;
|
if (!user) return false;
|
||||||
if (user.permissions?.some((p) => p.key === key)) return true;
|
if (user.permissions?.some((p) => p.key === key)) return true;
|
||||||
|
|
||||||
return (user.employee ?? []).some((emp) =>
|
return (user.employee ?? []).some((emp) =>
|
||||||
(emp.positions ?? []).some((pos) =>
|
(emp.positions ?? []).some((pos) =>
|
||||||
(pos.permissions ?? []).some((p) => p.key === key),
|
(pos.permissions ?? []).some((p) => p.key === key),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const DashboardShell = () => {
|
const DashboardShell = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { user, logout } = useAuth();
|
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";
|
const displayName = user?.name?.en || user?.username || user?.email || "User";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -188,34 +198,53 @@ const App = () => {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
|
||||||
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
|
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
|
||||||
|
|
||||||
<Route path="/dashboard" element={<DashboardShell />}>
|
<Route path="/dashboard" element={<DashboardShell />}>
|
||||||
<Route path="overview" element={<OverviewPage />} />
|
<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" 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
|
<Route
|
||||||
path="configuration"
|
path="configuration"
|
||||||
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
|
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
|
||||||
/>
|
/>
|
||||||
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
|
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="rules"
|
path="rules"
|
||||||
element={<Navigate to="/dashboard/rules/priority-rules" replace />}
|
element={<Navigate to="/dashboard/rules/priority-rules" replace />}
|
||||||
/>
|
/>
|
||||||
<Route path="rules/:resource" element={<RuleEngineResourcePage />} />
|
<Route path="rules/:resource" element={<RuleEngineResourcePage />} />
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="rule-engine"
|
path="rule-engine"
|
||||||
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
|
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
|
||||||
/>
|
/>
|
||||||
<Route path="rule-engine/:resource" element={<RuleEngineLegacyRedirect />} />
|
<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="user1" element={<DemoUser1Page />} />
|
||||||
<Route path="user2" element={<DemoUser2Page />} />
|
<Route path="user2" element={<DemoUser2Page />} />
|
||||||
<Route path="file-settings" element={<FileUploadSettingsPage />} />
|
|
||||||
<Route path="dropdown-settings" element={<DropdownSettingsPage />} />
|
<Route
|
||||||
<Route path="org-structure" element={<Navigate to="/dashboard/user-management" replace />} />
|
path="org-structure"
|
||||||
<Route path="org-structure/*" element={<Navigate to="/dashboard/user-management" replace />} />
|
element={<Navigate to="/dashboard/user-management" replace />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="org-structure/*"
|
||||||
|
element={<Navigate to="/dashboard/user-management" replace />}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
|
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
@@ -269,7 +269,8 @@ const FreightSidebar = ({ sections, activeHref, onNavigate }: FreightSidebarProp
|
|||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 pb-1 text-xs font-semibold uppercase tracking-wide",
|
"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}
|
{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.
|
No organizations available.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<aside className="rounded-3xl border border-border bg-card p-5 shadow-sm">
|
<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 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 (
|
return (
|
||||||
<FeaturePlaceholder
|
<section className="space-y-6 bg-background p-6 text-foreground">
|
||||||
title="Users"
|
<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">
|
||||||
description="Manage backoffice user accounts, activation state, and directory records for internal freight teams."
|
<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 { 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 { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { useNavigate } from "react-router-dom";
|
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 { Button } from "@edr/ui-common";
|
||||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type { CreateBookingPayload } from "@/services/bookings.service";
|
import type { CreateBookingPayload } from "@/services/bookings.service";
|
||||||
import {
|
import {
|
||||||
|
BookingFormInputValues,
|
||||||
STEPS,
|
STEPS,
|
||||||
bookingFormSchema,
|
bookingFormSchema,
|
||||||
calcWagons,
|
|
||||||
getRouteDirection,
|
getRouteDirection,
|
||||||
initialBookingFormValues,
|
initialBookingFormValues,
|
||||||
stepFields,
|
stepFields,
|
||||||
@@ -32,6 +38,10 @@ export default function NewBookingPage() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [step, setStep] = useState(1);
|
const [step, setStep] = useState(1);
|
||||||
const { customer } = useAuth();
|
const { customer } = useAuth();
|
||||||
|
const { data: referenceData, isLoading: refDataLoading } = useQuery(
|
||||||
|
api.bookings.referenceData.queryOptions(),
|
||||||
|
);
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (payload: CreateBookingPayload) =>
|
mutationFn: (payload: CreateBookingPayload) =>
|
||||||
api.bookings.create.call(payload),
|
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,
|
defaultValues: initialBookingFormValues,
|
||||||
resolver: zodResolver(bookingFormSchema),
|
resolver: zodResolver(bookingFormSchema),
|
||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
@@ -49,18 +59,12 @@ export default function NewBookingPage() {
|
|||||||
|
|
||||||
const originYard = form.watch("originYard");
|
const originYard = form.watch("originYard");
|
||||||
const destinationYard = form.watch("destinationYard");
|
const destinationYard = form.watch("destinationYard");
|
||||||
const containers = form.watch("containers");
|
|
||||||
|
|
||||||
const direction = useMemo(
|
const direction = useMemo(
|
||||||
() => getRouteDirection(originYard, destinationYard),
|
() => getRouteDirection(originYard, destinationYard),
|
||||||
[originYard, destinationYard],
|
[originYard, destinationYard],
|
||||||
);
|
);
|
||||||
|
|
||||||
const wagons = useMemo(() => {
|
|
||||||
if (!containers || containers.length === 0) return null;
|
|
||||||
return calcWagons(containers);
|
|
||||||
}, [containers]);
|
|
||||||
|
|
||||||
async function handleContinue() {
|
async function handleContinue() {
|
||||||
const valid = await form.trigger(stepFields[step], { shouldFocus: true });
|
const valid = await form.trigger(stepFields[step], { shouldFocus: true });
|
||||||
if (!valid) return;
|
if (!valid) return;
|
||||||
@@ -78,8 +82,6 @@ export default function NewBookingPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reference = data.previousContractRef;
|
|
||||||
|
|
||||||
const totalWeight =
|
const totalWeight =
|
||||||
data.cargoType === "container"
|
data.cargoType === "container"
|
||||||
? data.containers.reduce(
|
? data.containers.reduce(
|
||||||
@@ -88,59 +90,115 @@ export default function NewBookingPage() {
|
|||||||
)
|
)
|
||||||
: Number(data.cargoWeight || 0);
|
: Number(data.cargoWeight || 0);
|
||||||
|
|
||||||
const apiPayload = {
|
// ── Reference data lookups ──────────────────────────────────────────
|
||||||
reference,
|
const yards = referenceData?.yard ?? [];
|
||||||
customerId: customer!.id,
|
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),
|
scheduledDate: new Date().toISOString().slice(0, 10),
|
||||||
totalAmount: 0,
|
|
||||||
contractType:
|
contractType:
|
||||||
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
|
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
|
||||||
previousContractId: data.previousContractRef || undefined,
|
serviceTypeId: findServiceTypeId(),
|
||||||
serviceType:
|
equipmentReturn:
|
||||||
data.service.serviceType === "rail"
|
data.equipmentReturn === "with_return"
|
||||||
? "RAIL_ONLY"
|
? "WITH_RETURN"
|
||||||
: "RAIL_AND_FORWARDING",
|
: "WITHOUT_RETURN",
|
||||||
...(data.service.serviceType === "rail"
|
originYardId: findYardId(data.originYard),
|
||||||
? {}
|
destinationYardId: findYardId(data.destinationYard),
|
||||||
: {
|
|
||||||
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,
|
|
||||||
tradeDirection:
|
tradeDirection:
|
||||||
getRouteDirection(data.originYard, data.destinationYard) === "export"
|
direction === "export"
|
||||||
? "EXPORT"
|
? "EXPORT"
|
||||||
: "IMPORT",
|
: direction === "domestic"
|
||||||
|
? "DOMESTIC"
|
||||||
|
: "IMPORT",
|
||||||
|
cargoTypeId,
|
||||||
|
cargoTotalWeightVgm: totalWeight,
|
||||||
|
isHazardous: data.isHazardous,
|
||||||
paymentCurrency: "USD",
|
paymentCurrency: "USD",
|
||||||
allowConsolidation: data.consolidationEnabled,
|
allowConsolidation: data.consolidationEnabled,
|
||||||
...(data.cargoType === "container" && data.containers.length > 0
|
containers:
|
||||||
? {
|
data.cargoType === "container"
|
||||||
containers: data.containers.map((c) => ({
|
? data.containers.map((c) => ({
|
||||||
type: c.type === "40ft" ? ("40FT" as const) : ("20FT" as const),
|
containerTypeId: findContainerTypeId(c.containerType),
|
||||||
qty: Number(c.qty || 1),
|
quantity: Number(c.qty || 1),
|
||||||
vgm: Number(c.vgm || 0),
|
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);
|
createMutation.mutate(apiPayload);
|
||||||
});
|
});
|
||||||
@@ -179,11 +237,35 @@ export default function NewBookingPage() {
|
|||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="mx-auto max-w-4xl px-6 py-8">
|
<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 === 1 && <Step1ContractType form={form} />}
|
||||||
{step === 2 && <Step2ServiceType form={form} />}
|
{step === 2 && <Step2ServiceType form={form} />}
|
||||||
{step === 3 && <Step4Route form={form} />}
|
{step === 3 && (
|
||||||
|
<Step4Route
|
||||||
|
form={form}
|
||||||
|
referenceData={referenceData}
|
||||||
|
isLoading={refDataLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{step === 4 && (
|
{step === 4 && (
|
||||||
<Step5CargoDetails form={form} direction={direction} />
|
<Step5CargoDetails
|
||||||
|
form={form}
|
||||||
|
direction={direction}
|
||||||
|
referenceData={referenceData}
|
||||||
|
isLoading={refDataLoading}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{step === 5 && (
|
{step === 5 && (
|
||||||
<Step8Review form={form} setStep={setStep} direction={direction} />
|
<Step8Review form={form} setStep={setStep} direction={direction} />
|
||||||
@@ -210,9 +292,19 @@ export default function NewBookingPage() {
|
|||||||
<ChevronRight />
|
<ChevronRight />
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button type="submit" form="new-booking-form">
|
<Button
|
||||||
<Check />
|
type="submit"
|
||||||
Submit Contract Request
|
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>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -249,6 +249,7 @@ export const bookingFormSchema = z
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type BookingFormValues = z.infer<typeof bookingFormSchema>;
|
export type BookingFormValues = z.infer<typeof bookingFormSchema>;
|
||||||
|
export type BookingFormInputValues = z.input<typeof bookingFormSchema>;
|
||||||
|
|
||||||
export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
|
export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
|
||||||
previousContractRef: "",
|
previousContractRef: "",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@edr/ui-common";
|
} from "@edr/ui-common";
|
||||||
import type { BookingFormValues } from "./schema";
|
import type { BookingFormInputValues, BookingFormValues } from "./schema";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function OptionFieldError({ error }: { error?: { message?: string } }) {
|
export function OptionFieldError({ error }: { error?: { message?: string } }) {
|
||||||
@@ -122,7 +122,7 @@ export function SelectField({
|
|||||||
disabled,
|
disabled,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
field: ControllerRenderProps<BookingFormValues>;
|
field: ControllerRenderProps<BookingFormInputValues>;
|
||||||
error?: RhfFieldError;
|
error?: RhfFieldError;
|
||||||
label: string;
|
label: string;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||||
import { FileText, RefreshCw } from "lucide-react";
|
import { FileText, RefreshCw } from "lucide-react";
|
||||||
import { Field } from "@edr/ui-common";
|
import { Field } from "@edr/ui-common";
|
||||||
import { MOCK_VALID_CONTRACTS, type BookingFormValues } from "./schema";
|
import {
|
||||||
|
BookingFormInputValues,
|
||||||
|
MOCK_VALID_CONTRACTS,
|
||||||
|
type BookingFormValues,
|
||||||
|
} from "./schema";
|
||||||
import {
|
import {
|
||||||
AlertBox,
|
AlertBox,
|
||||||
OptionCard,
|
OptionCard,
|
||||||
@@ -11,7 +15,11 @@ import {
|
|||||||
StepHeader,
|
StepHeader,
|
||||||
} from "./shared";
|
} from "./shared";
|
||||||
|
|
||||||
type BookingForm = UseFormReturn<BookingFormValues>;
|
type BookingForm = UseFormReturn<
|
||||||
|
BookingFormInputValues,
|
||||||
|
any,
|
||||||
|
BookingFormValues
|
||||||
|
>;
|
||||||
|
|
||||||
export function Step1ContractType({ form }: { form: BookingForm }) {
|
export function Step1ContractType({ form }: { form: BookingForm }) {
|
||||||
const contractType = form.watch("contractType");
|
const contractType = form.watch("contractType");
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ import { useEffect, useRef } from "react";
|
|||||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||||
import { FileText, Package, Train, Truck } from "lucide-react";
|
import { FileText, Package, Train, Truck } from "lucide-react";
|
||||||
import { Badge, Field, FieldError, Input, Switch } from "@edr/ui-common";
|
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";
|
import { OptionCard, OptionFieldError, StepHeader } from "./shared";
|
||||||
|
|
||||||
type BookingForm = UseFormReturn<BookingFormValues>;
|
type BookingForm = UseFormReturn<
|
||||||
|
BookingFormInputValues,
|
||||||
|
any,
|
||||||
|
BookingFormValues
|
||||||
|
>;
|
||||||
|
|
||||||
export function Step2ServiceType({ form }: { form: BookingForm }) {
|
export function Step2ServiceType({ form }: { form: BookingForm }) {
|
||||||
const serviceType = form.watch("serviceType");
|
const serviceType = form.watch("serviceType");
|
||||||
|
|||||||
@@ -1,37 +1,50 @@
|
|||||||
|
import { useEffect, useMemo } from "react";
|
||||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||||
import { Flame, MapPin, Snowflake } from "lucide-react";
|
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 {
|
import {
|
||||||
SHIPPING_LINES,
|
BookingFormInputValues,
|
||||||
type BookingFormValues,
|
type BookingFormValues,
|
||||||
getRouteDirection,
|
getRouteDirection,
|
||||||
STATIONS,
|
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
import {
|
import { SelectField, StepHeader, StepLabel } from "./shared";
|
||||||
AlertBox,
|
|
||||||
SelectField,
|
|
||||||
SelectOptions,
|
|
||||||
StepHeader,
|
|
||||||
StepLabel,
|
|
||||||
} from "./shared";
|
|
||||||
import { useDropdownSettingByCode } from "@/hooks/useDropdownSettings";
|
|
||||||
import { DropdownOption } from "@/types/dropdownSettings";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
type BookingForm = UseFormReturn<BookingFormValues>;
|
type BookingForm = UseFormReturn<
|
||||||
|
BookingFormInputValues,
|
||||||
|
any,
|
||||||
|
BookingFormValues
|
||||||
|
>;
|
||||||
|
|
||||||
const STATION_DROPDOWN_CODE = "stations_ter";
|
export function Step4Route({
|
||||||
|
form,
|
||||||
export function Step4Route({ form }: { form: BookingForm }) {
|
referenceData,
|
||||||
|
isLoading,
|
||||||
|
}: {
|
||||||
|
form: BookingForm;
|
||||||
|
referenceData?: Freight.BookingReferenceData;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}) {
|
||||||
const originYard = form.watch("originYard");
|
const originYard = form.watch("originYard");
|
||||||
const destinationYard = form.watch("destinationYard");
|
const destinationYard = form.watch("destinationYard");
|
||||||
const {
|
|
||||||
data: stationSetting,
|
const yardOptions = useMemo(() => {
|
||||||
isLoading: stationsLoading,
|
if (!referenceData?.yard) return [];
|
||||||
isError: stationsError,
|
return referenceData.yard.map((y) => ({
|
||||||
error: stationsFetchError,
|
value: y.name,
|
||||||
} = useDropdownSettingByCode(STATION_DROPDOWN_CODE);
|
label: y.name,
|
||||||
const stationOptions = getStationOptions(stationSetting?.children);
|
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 direction = getRouteDirection(originYard, destinationYard);
|
||||||
const directionStyle: Record<string, string> = {
|
const directionStyle: Record<string, string> = {
|
||||||
export: "bg-sky-50 text-sky-800 border-sky-200",
|
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)",
|
import: "Import workflow (outside country to inside country)",
|
||||||
domestic: "Domestic corridor",
|
domestic: "Domestic corridor",
|
||||||
};
|
};
|
||||||
const stationSelectDisabled = stationsLoading || stationOptions.length === 0;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (direction === "domestic") {
|
if (direction === "domestic") {
|
||||||
@@ -51,6 +63,8 @@ export function Step4Route({ form }: { form: BookingForm }) {
|
|||||||
}
|
}
|
||||||
}, [direction]);
|
}, [direction]);
|
||||||
|
|
||||||
|
const stationSelectDisabled = yardOptions.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<StepHeader
|
<StepHeader
|
||||||
@@ -58,65 +72,59 @@ export function Step4Route({ form }: { form: BookingForm }) {
|
|||||||
description="Select the origin and destination yards."
|
description="Select the origin and destination yards."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-3">
|
{isLoading ? (
|
||||||
<StepLabel>Route</StepLabel>
|
<LoadingSkeleton />
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
) : (
|
||||||
<Controller
|
<div className="space-y-3">
|
||||||
name="originYard"
|
<StepLabel>Route</StepLabel>
|
||||||
control={form.control}
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
render={({ field, fieldState }) => (
|
<Controller
|
||||||
<SelectField
|
name="originYard"
|
||||||
field={field}
|
control={form.control}
|
||||||
error={fieldState.error}
|
render={({ field, fieldState }) => (
|
||||||
label="Origin Yard*"
|
<SelectField
|
||||||
placeholder="Select origin..."
|
field={field}
|
||||||
disabled={stationSelectDisabled}
|
error={fieldState.error}
|
||||||
>
|
label="Origin Yard*"
|
||||||
<StationSelectOptions
|
placeholder="Select origin..."
|
||||||
options={stationOptions}
|
disabled={stationSelectDisabled}
|
||||||
excludeValue={destinationYard}
|
>
|
||||||
isLoading={stationsLoading}
|
<YardSelectOptions
|
||||||
/>
|
options={yardOptions}
|
||||||
</SelectField>
|
excludeValue={destinationYard}
|
||||||
)}
|
/>
|
||||||
/>
|
</SelectField>
|
||||||
<Controller
|
)}
|
||||||
name="destinationYard"
|
/>
|
||||||
control={form.control}
|
<Controller
|
||||||
render={({ field, fieldState }) => (
|
name="destinationYard"
|
||||||
<SelectField
|
control={form.control}
|
||||||
field={field}
|
render={({ field, fieldState }) => (
|
||||||
error={fieldState.error}
|
<SelectField
|
||||||
label="Destination Yard *"
|
field={field}
|
||||||
placeholder="Select destination..."
|
error={fieldState.error}
|
||||||
disabled={stationSelectDisabled}
|
label="Destination Yard *"
|
||||||
>
|
placeholder="Select destination..."
|
||||||
<StationSelectOptions
|
disabled={stationSelectDisabled}
|
||||||
options={stationOptions}
|
>
|
||||||
excludeValue={originYard}
|
<YardSelectOptions
|
||||||
isLoading={stationsLoading}
|
options={yardOptions}
|
||||||
/>
|
excludeValue={originYard}
|
||||||
</SelectField>
|
/>
|
||||||
)}
|
</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]}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{direction && (
|
||||||
</div>
|
<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" && (
|
{direction && direction != "domestic" && (
|
||||||
<Controller
|
<Controller
|
||||||
@@ -129,7 +137,11 @@ export function Step4Route({ form }: { form: BookingForm }) {
|
|||||||
label="Shipping Line"
|
label="Shipping Line"
|
||||||
placeholder="Select shipping line..."
|
placeholder="Select shipping line..."
|
||||||
>
|
>
|
||||||
<SelectOptions options={SHIPPING_LINES} />
|
{shippingLineOptions.map((sl) => (
|
||||||
|
<SelectItem key={sl.value} value={sl.value}>
|
||||||
|
{sl.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectField>
|
</SelectField>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -179,23 +191,35 @@ export function Step4Route({ form }: { form: BookingForm }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStationOptions(options?: DropdownOption[]): DropdownOption[] {
|
function LoadingSkeleton() {
|
||||||
return [...(options ?? [])].sort((a, b) => a.order - b.order);
|
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,
|
options,
|
||||||
excludeValue,
|
excludeValue,
|
||||||
isLoading,
|
|
||||||
}: {
|
}: {
|
||||||
options: DropdownOption[];
|
options: Array<{ value: string; label: string; country: string }>;
|
||||||
excludeValue: string;
|
excludeValue: string;
|
||||||
isLoading: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
if (isLoading) {
|
if (options.length === 0) {
|
||||||
return (
|
return (
|
||||||
<SelectItem value="__stations_loading" disabled>
|
<SelectItem value="__yards_empty" disabled>
|
||||||
Loading stations...
|
No yards available
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -204,22 +228,10 @@ function StationSelectOptions({
|
|||||||
(option) => option.value !== excludeValue,
|
(option) => option.value !== excludeValue,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (availableOptions.length === 0) {
|
|
||||||
return (
|
|
||||||
<SelectItem value="__stations_empty" disabled>
|
|
||||||
No stations available
|
|
||||||
</SelectItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{availableOptions.map((option) => (
|
{availableOptions.map((option) => (
|
||||||
<SelectItem
|
<SelectItem key={option.value} value={option.value}>
|
||||||
key={option.id}
|
|
||||||
value={option.value}
|
|
||||||
disabled={option.disabled}
|
|
||||||
>
|
|
||||||
{option.label}
|
{option.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form";
|
import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form";
|
||||||
import { MapPin, Package, Plus, Trash2, Weight } from "lucide-react";
|
import { MapPin, Package, Plus, Trash2, Weight } from "lucide-react";
|
||||||
import { Button, Field, FieldError, FieldLabel, Input } from "@edr/ui-common";
|
|
||||||
import {
|
import {
|
||||||
BREAK_BULK_TYPES,
|
Button,
|
||||||
BULK_COMMODITIES,
|
Field,
|
||||||
CONTAINER_TYPES,
|
FieldError,
|
||||||
|
FieldLabel,
|
||||||
|
Input,
|
||||||
|
Skeleton,
|
||||||
|
} from "@edr/ui-common";
|
||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
import {
|
||||||
|
BookingFormInputValues,
|
||||||
calcWagons,
|
calcWagons,
|
||||||
type BookingFormValues,
|
type BookingFormValues,
|
||||||
type RouteDirection,
|
type RouteDirection,
|
||||||
@@ -13,19 +20,27 @@ import {
|
|||||||
AlertBox,
|
AlertBox,
|
||||||
OptionCard,
|
OptionCard,
|
||||||
SelectField,
|
SelectField,
|
||||||
SelectOptions,
|
SelectItem,
|
||||||
StepHeader,
|
StepHeader,
|
||||||
StepLabel,
|
StepLabel,
|
||||||
} from "./shared";
|
} from "./shared";
|
||||||
|
|
||||||
type BookingForm = UseFormReturn<BookingFormValues>;
|
type BookingForm = UseFormReturn<
|
||||||
|
BookingFormInputValues,
|
||||||
|
any,
|
||||||
|
BookingFormValues
|
||||||
|
>;
|
||||||
|
|
||||||
export function Step5CargoDetails({
|
export function Step5CargoDetails({
|
||||||
form,
|
form,
|
||||||
direction,
|
direction,
|
||||||
|
referenceData,
|
||||||
|
isLoading,
|
||||||
}: {
|
}: {
|
||||||
form: BookingForm;
|
form: BookingForm;
|
||||||
direction: RouteDirection;
|
direction: RouteDirection;
|
||||||
|
referenceData?: Freight.BookingReferenceData;
|
||||||
|
isLoading?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const cargoType = form.watch("cargoType");
|
const cargoType = form.watch("cargoType");
|
||||||
const freightType = form.watch("freightType");
|
const freightType = form.watch("freightType");
|
||||||
@@ -38,6 +53,20 @@ export function Step5CargoDetails({
|
|||||||
name: "containers",
|
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(
|
function getOverweightAlert(
|
||||||
type: "20ft" | "40ft",
|
type: "20ft" | "40ft",
|
||||||
vgm: number,
|
vgm: number,
|
||||||
@@ -54,6 +83,26 @@ export function Step5CargoDetails({
|
|||||||
return null;
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<StepHeader
|
<StepHeader
|
||||||
@@ -181,7 +230,11 @@ export function Step5CargoDetails({
|
|||||||
label="Commodity *"
|
label="Commodity *"
|
||||||
placeholder="Select commodity *"
|
placeholder="Select commodity *"
|
||||||
>
|
>
|
||||||
<SelectOptions options={BULK_COMMODITIES} />
|
{bulkCommodityOptions.map((option) => (
|
||||||
|
<SelectItem key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectField>
|
</SelectField>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -216,7 +269,11 @@ export function Step5CargoDetails({
|
|||||||
label="Break-bulk type *"
|
label="Break-bulk type *"
|
||||||
placeholder="Select type *"
|
placeholder="Select type *"
|
||||||
>
|
>
|
||||||
<SelectOptions options={BREAK_BULK_TYPES} />
|
{bulkCommodityOptions.map((option) => (
|
||||||
|
<SelectItem key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectField>
|
</SelectField>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -408,7 +465,11 @@ export function Step5CargoDetails({
|
|||||||
label="Container Type *"
|
label="Container Type *"
|
||||||
placeholder="Select type..."
|
placeholder="Select type..."
|
||||||
>
|
>
|
||||||
<SelectOptions options={CONTAINER_TYPES} />
|
{containerTypeOptions.map((option) => (
|
||||||
|
<SelectItem key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectField>
|
</SelectField>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -11,13 +11,17 @@ import {
|
|||||||
Textarea,
|
Textarea,
|
||||||
} from "@edr/ui-common";
|
} from "@edr/ui-common";
|
||||||
import {
|
import {
|
||||||
|
BookingFormInputValues,
|
||||||
type BookingFormValues,
|
type BookingFormValues,
|
||||||
type RouteDirection,
|
type RouteDirection,
|
||||||
type WagonCalcResult,
|
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
import { StepHeader } from "./shared";
|
import { StepHeader } from "./shared";
|
||||||
|
|
||||||
type BookingForm = UseFormReturn<BookingFormValues>;
|
type BookingForm = UseFormReturn<
|
||||||
|
BookingFormInputValues,
|
||||||
|
any,
|
||||||
|
BookingFormValues
|
||||||
|
>;
|
||||||
|
|
||||||
export function Step8Review({
|
export function Step8Review({
|
||||||
form,
|
form,
|
||||||
|
|||||||
@@ -137,6 +137,12 @@ export const api = {
|
|||||||
bookingsService.create,
|
bookingsService.create,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
referenceData: endpoint<void, Freight.BookingReferenceData>(
|
||||||
|
"bookings",
|
||||||
|
"referenceData",
|
||||||
|
bookingsService.getReferenceData,
|
||||||
|
),
|
||||||
|
|
||||||
remove: endpoint<{ id: string }, void>("bookings", "remove", ({ id }) =>
|
remove: endpoint<{ id: string }, void>("bookings", "remove", ({ id }) =>
|
||||||
bookingsService.remove(id),
|
bookingsService.remove(id),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ export const bookingsService = {
|
|||||||
const { data } = await client.post("/api/bookings", payload);
|
const { data } = await client.post("/api/bookings", payload);
|
||||||
return data.data;
|
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> => {
|
remove: async (id: string): Promise<void> => {
|
||||||
await client.delete(`/bookings/${id}`);
|
await client.delete(`/bookings/${id}`);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -216,45 +216,94 @@ export interface IInvoice extends BaseEntity {
|
|||||||
dueAt: string;
|
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 {
|
export interface CreateBookingDto {
|
||||||
reference: string;
|
reference?: string;
|
||||||
customerId: string;
|
customerId?: string;
|
||||||
trainId?: string;
|
trainId?: string;
|
||||||
scheduledDate: string;
|
scheduledDate: string;
|
||||||
totalAmount: number;
|
|
||||||
paymentStatus?: string;
|
|
||||||
contractType: "NEW" | "RENEWAL";
|
contractType: "NEW" | "RENEWAL";
|
||||||
previousContractId?: string;
|
previousContractId?: string;
|
||||||
serviceType: "RAIL_ONLY" | "RAIL_AND_FORWARDING";
|
serviceTypeId: string;
|
||||||
|
|
||||||
firstMileEnabled?: boolean;
|
|
||||||
firstMilePickupAddress?: string;
|
firstMilePickupAddress?: string;
|
||||||
lastMileEnabled?: boolean;
|
|
||||||
lastMileDeliveryAddress?: string;
|
lastMileDeliveryAddress?: string;
|
||||||
|
equipmentReturn: "WITH_RETURN" | "WITHOUT_RETURN" | "NA";
|
||||||
equipmentReturn: "WITH_RETURN" | "WITHOUT_RETURN";
|
originYardId: string;
|
||||||
customsClearingEnabled?: boolean;
|
destinationYardId: string;
|
||||||
originStation: string;
|
tradeDirection: "IMPORT" | "EXPORT" | "DOMESTIC";
|
||||||
destinationStation: string;
|
cargoTypeId: string;
|
||||||
|
cargoFreeText?: string;
|
||||||
|
shippingLineId?: string;
|
||||||
cargoTotalWeightVgm: number;
|
cargoTotalWeightVgm: number;
|
||||||
|
|
||||||
freightType: "BULK" | "BREAK_BULK";
|
|
||||||
freightSubtype?: string;
|
|
||||||
|
|
||||||
isHazardous?: boolean;
|
isHazardous?: boolean;
|
||||||
isRefrigerated?: boolean;
|
paymentCurrency: "ETB" | "USD";
|
||||||
|
pnrCode?: string;
|
||||||
tradeDirection: "IMPORT" | "EXPORT";
|
|
||||||
paymentCurrency: string;
|
|
||||||
allowConsolidation?: boolean;
|
|
||||||
|
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
endDate?: string;
|
endDate?: string;
|
||||||
financialTerms?: string;
|
financialTerms?: string;
|
||||||
|
containers: CreateBookingContainerDto[];
|
||||||
containers?: Array<{
|
allowConsolidation?: boolean;
|
||||||
type: "20FT" | "40FT";
|
|
||||||
qty: number;
|
|
||||||
vgm: number;
|
|
||||||
}>;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export type {
|
|||||||
|
|
||||||
export * from "./components/button";
|
export * from "./components/button";
|
||||||
export * from "./components/input";
|
export * from "./components/input";
|
||||||
|
export * from "./components/skeleton";
|
||||||
export * from "./components/textarea";
|
export * from "./components/textarea";
|
||||||
export * from "./components/label";
|
export * from "./components/label";
|
||||||
export * from "./components/card";
|
export * from "./components/card";
|
||||||
|
|||||||
Reference in New Issue
Block a user