mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 09:42:53 +00:00
refactor(bookings): replace RFQ/quotation flow with submit, staff review, approval routing, and payment stubs
This commit is contained in:
@@ -23,6 +23,10 @@ import { OtpModule } from './modules/otp/otp.module';
|
||||
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
|
||||
import { BackofficeModule } from "./modules/backoffice/backoffice.module";
|
||||
import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module";
|
||||
import {
|
||||
EDR_FREIGHT_APPLICATION,
|
||||
EDR_FREIGHT_PERMISSIONS,
|
||||
} from "./seed/edr-freight.seed";
|
||||
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
|
||||
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
|
||||
|
||||
@@ -46,7 +50,10 @@ import { DemoUsersSeeder } from "./seed/demo-users.seeder";
|
||||
},
|
||||
}),
|
||||
SharedAuthModule,
|
||||
IamModule.forRoot(),
|
||||
IamModule.forRoot({
|
||||
applications: [EDR_FREIGHT_APPLICATION],
|
||||
permissions: EDR_FREIGHT_PERMISSIONS,
|
||||
}),
|
||||
BookingsModule,
|
||||
FilesModule,
|
||||
ConsignmentsModule,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
Put,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
@@ -27,6 +28,19 @@ export class BackofficeController {
|
||||
return this.backofficeService.createOrganizationUser(organizationId, dto);
|
||||
}
|
||||
|
||||
@Get("organizations/:orgId/employees")
|
||||
@ApiOperation({ summary: "Get deduplicated organization employees for backoffice" })
|
||||
getOrganizationEmployees(
|
||||
@Param("orgId", ParseUUIDPipe) organizationId: string,
|
||||
@Query("skip") skip?: string,
|
||||
@Query("take") take?: string,
|
||||
) {
|
||||
return this.backofficeService.getOrganizationEmployees(organizationId, {
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
}
|
||||
|
||||
@Get("organizations/:orgId/employee-users/:userId/roles")
|
||||
@ApiOperation({ summary: "Get org-scoped roles assigned to an employee user" })
|
||||
getEmployeeUserRoles(
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
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, EntityManager, 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";
|
||||
@@ -21,10 +21,14 @@ const RESERVED_ROLE_KEYS = new Set([
|
||||
"unit_admin",
|
||||
]);
|
||||
const DEFAULT_USER_PASSWORD = "12345678";
|
||||
const ORGANIZATION_ADMIN_ROLE_KEY = "organization_admin";
|
||||
const EDR_ORG_MANAGER_ROLE_KEY = "edr_org_manager";
|
||||
|
||||
@Injectable()
|
||||
export class BackofficeService {
|
||||
constructor(
|
||||
@InjectRepository(Employee)
|
||||
private readonly employeeRepository: Repository<Employee>,
|
||||
@InjectRepository(Organization)
|
||||
private readonly organizationRepository: Repository<Organization>,
|
||||
@InjectRepository(Role)
|
||||
@@ -51,6 +55,7 @@ export class BackofficeService {
|
||||
const email = dto.email.trim().toLowerCase();
|
||||
const username = dto.username.trim().toLowerCase();
|
||||
const phoneNumber = dto.phoneNumber?.trim() || undefined;
|
||||
const assignOrganizationAdmin = dto.assignOrganizationAdmin === true;
|
||||
const name = {
|
||||
en: dto.name.en.trim(),
|
||||
...(dto.name.am?.trim() ? { am: dto.name.am.trim() } : {}),
|
||||
@@ -168,6 +173,16 @@ export class BackofficeService {
|
||||
throw new NotFoundException("employee_create_failed");
|
||||
}
|
||||
|
||||
const userId = user.id;
|
||||
|
||||
if (!userId) {
|
||||
throw new NotFoundException("user_create_failed");
|
||||
}
|
||||
|
||||
if (assignOrganizationAdmin) {
|
||||
await this.ensureOrganizationAdminAccess(manager, organizationId, userId);
|
||||
}
|
||||
|
||||
return employee;
|
||||
});
|
||||
}
|
||||
@@ -201,6 +216,45 @@ export class BackofficeService {
|
||||
}));
|
||||
}
|
||||
|
||||
async getOrganizationEmployees(
|
||||
organizationId: string,
|
||||
query: { skip?: string; take?: string },
|
||||
) {
|
||||
const organizationExists = await this.organizationRepository.exists({
|
||||
where: { id: organizationId },
|
||||
});
|
||||
|
||||
if (!organizationExists) {
|
||||
throw new NotFoundException("organization_not_found");
|
||||
}
|
||||
|
||||
const take = Number.parseInt(query.take ?? "1000", 10);
|
||||
const skip = Number.parseInt(query.skip ?? "0", 10);
|
||||
|
||||
const employees = await this.employeeRepository.find({
|
||||
where: {
|
||||
organizationId,
|
||||
isCurrent: true,
|
||||
},
|
||||
relations: {
|
||||
user: true,
|
||||
employeePositions: {
|
||||
position: true,
|
||||
},
|
||||
},
|
||||
order: {
|
||||
createdAt: "DESC",
|
||||
},
|
||||
});
|
||||
|
||||
const deduplicated = this.mergeEmployeesByUser(employees);
|
||||
|
||||
return {
|
||||
count: deduplicated.length,
|
||||
items: deduplicated.slice(skip, skip + take),
|
||||
};
|
||||
}
|
||||
|
||||
async replaceEmployeeUserRoles(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
@@ -268,4 +322,104 @@ export class BackofficeService {
|
||||
throw new NotFoundException("user_not_found_in_organization");
|
||||
}
|
||||
}
|
||||
|
||||
private mergeEmployeesByUser(employees: Employee[]) {
|
||||
const employeesByUserId = new Map<string, Employee>();
|
||||
|
||||
for (const employee of employees) {
|
||||
const userId = employee.userId;
|
||||
const employeeId = employee.id;
|
||||
|
||||
if (!userId) {
|
||||
if (employeeId) {
|
||||
employeesByUserId.set(employeeId, employee);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = employeesByUserId.get(userId);
|
||||
|
||||
if (!existing) {
|
||||
employeesByUserId.set(userId, employee);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingPositions = existing.employeePositions ?? [];
|
||||
const nextPositions = employee.employeePositions ?? [];
|
||||
const mergedEmployeePositions = Array.from(
|
||||
new Map(
|
||||
[...existingPositions, ...nextPositions].map((employeePosition) => [
|
||||
employeePosition.id,
|
||||
employeePosition,
|
||||
]),
|
||||
).values(),
|
||||
);
|
||||
|
||||
employeesByUserId.set(userId, {
|
||||
...existing,
|
||||
...employee,
|
||||
id: existing.id,
|
||||
user: existing.user ?? employee.user,
|
||||
userId,
|
||||
name: existing.name ?? employee.name,
|
||||
status: existing.status ?? employee.status,
|
||||
employeePositions: mergedEmployeePositions,
|
||||
});
|
||||
}
|
||||
|
||||
return [...employeesByUserId.values()];
|
||||
}
|
||||
|
||||
private async ensureOrganizationAdminAccess(
|
||||
manager: EntityManager,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
) {
|
||||
const roles = await manager.getRepository(Role).find({
|
||||
where: [
|
||||
{ key: ORGANIZATION_ADMIN_ROLE_KEY },
|
||||
{ key: EDR_ORG_MANAGER_ROLE_KEY },
|
||||
],
|
||||
select: { id: true, key: true },
|
||||
});
|
||||
|
||||
const requiredRoles = [ORGANIZATION_ADMIN_ROLE_KEY, EDR_ORG_MANAGER_ROLE_KEY].map((key) => {
|
||||
const role = roles.find((item) => item.key === key);
|
||||
|
||||
if (!role?.id) {
|
||||
throw new NotFoundException(`required_role_not_seeded:${key}`);
|
||||
}
|
||||
|
||||
return {
|
||||
id: role.id,
|
||||
key: role.key,
|
||||
};
|
||||
});
|
||||
|
||||
const existingRoleIds = new Set(
|
||||
(
|
||||
await manager.getRepository(UserRole).find({
|
||||
where: {
|
||||
userId,
|
||||
organizationId,
|
||||
},
|
||||
select: { roleId: true },
|
||||
})
|
||||
).map((userRole) => userRole.roleId),
|
||||
);
|
||||
|
||||
const rolesToInsert = requiredRoles
|
||||
.filter((role) => !existingRoleIds.has(role.id))
|
||||
.map((role) => ({
|
||||
userId,
|
||||
roleId: role.id,
|
||||
organizationId,
|
||||
}));
|
||||
|
||||
if (!rolesToInsert.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await manager.getRepository(UserRole).insert(rolesToInsert);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsEmail, IsObject, IsOptional, IsString, MinLength } from "class-validator";
|
||||
import { IsBoolean, IsEmail, IsObject, IsOptional, IsString, MinLength } from "class-validator";
|
||||
|
||||
class CreateOrganizationUserNameDto {
|
||||
@ApiProperty()
|
||||
@@ -31,4 +31,9 @@ export class CreateOrganizationUserDto {
|
||||
@ApiProperty({ type: CreateOrganizationUserNameDto })
|
||||
@IsObject()
|
||||
name!: CreateOrganizationUserNameDto;
|
||||
|
||||
@ApiProperty({ required: false, default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
assignOrganizationAdmin?: boolean;
|
||||
}
|
||||
|
||||
229
apps/edr-freight-api/src/seed/edr-freight.seed.ts
Normal file
229
apps/edr-freight-api/src/seed/edr-freight.seed.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
export type FreightSeedRole = {
|
||||
key: string;
|
||||
name: { en: string };
|
||||
permissionKeys: string[];
|
||||
};
|
||||
|
||||
const IAM_PERMISSION_KEYS = {
|
||||
activateEmployee: "can:activateEmployee",
|
||||
activateUser: "can:activateUser",
|
||||
createEmployee: "can:createEmployee",
|
||||
createPositionPermission: "can:create:position_permission",
|
||||
createUnit: "can:create:unit",
|
||||
createUserRole: "can:create:user_role",
|
||||
deactivateEmployee: "can:deactivateEmployee",
|
||||
deletePositionPermission: "can:delete:position_permission",
|
||||
deleteUnit: "can:delete:unit",
|
||||
deleteUserRole: "can:delete:user_role",
|
||||
findAllOrganization: "can:find_all:organization",
|
||||
manageOrganizationAdmin: "manage:organizationAdmin",
|
||||
manageUnitAdmin: "manage:unitAdmin",
|
||||
updateUnit: "can:update:unit",
|
||||
viewPositionPermission: "can:view:position_permission",
|
||||
viewUserRole: "can:view:user_role",
|
||||
} as const;
|
||||
|
||||
export const EDR_FREIGHT_APPLICATION = {
|
||||
id: "7f5a2175-c270-495b-bec9-d59ddbdab5d1",
|
||||
key: "edr_freight_app",
|
||||
name: {
|
||||
am: "EDR Freight App",
|
||||
en: "EDR Freight App",
|
||||
},
|
||||
} as const;
|
||||
|
||||
const EMPLOYEE_REGISTRATION_PERMISSIONS = [
|
||||
{
|
||||
id: "62b5aa2d-4ef6-474d-913a-994568dce1c8",
|
||||
key: "edr_freight_app:employee_registration:view",
|
||||
name: { am: "View employee registration", en: "View employee registration" },
|
||||
applicationKey: EDR_FREIGHT_APPLICATION.key,
|
||||
},
|
||||
{
|
||||
id: "8072204d-26de-4e62-88aa-74afd916a0cb",
|
||||
key: "edr_freight_app:employee_registration:create",
|
||||
name: { am: "Create employee registration", en: "Create employee registration" },
|
||||
applicationKey: EDR_FREIGHT_APPLICATION.key,
|
||||
},
|
||||
{
|
||||
id: "b7dc55a6-ae7c-4558-8c4e-7d8ce5c7fa08",
|
||||
key: "edr_freight_app:employee_registration:update",
|
||||
name: { am: "Update employee registration", en: "Update employee registration" },
|
||||
applicationKey: EDR_FREIGHT_APPLICATION.key,
|
||||
},
|
||||
{
|
||||
id: "7ef06121-bd31-4c0d-b36d-5401b4bfd05c",
|
||||
key: "edr_freight_app:employee_registration:activate",
|
||||
name: { am: "Activate employee registration", en: "Activate employee registration" },
|
||||
applicationKey: EDR_FREIGHT_APPLICATION.key,
|
||||
},
|
||||
{
|
||||
id: "2688e144-7f0c-4704-8d59-e92b0c08117a",
|
||||
key: "edr_freight_app:employee_registration:deactivate",
|
||||
name: { am: "Deactivate employee registration", en: "Deactivate employee registration" },
|
||||
applicationKey: EDR_FREIGHT_APPLICATION.key,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const ROLE_ASSIGNMENT_PERMISSIONS = [
|
||||
{
|
||||
id: "4de87873-e00d-4330-9b4f-f4fb065f49e0",
|
||||
key: "edr_freight_app:role_assignment:view",
|
||||
name: { am: "View role assignment", en: "View role assignment" },
|
||||
applicationKey: EDR_FREIGHT_APPLICATION.key,
|
||||
},
|
||||
{
|
||||
id: "36f022b4-4b94-4220-a46c-df7bd1a1b184",
|
||||
key: "edr_freight_app:role_assignment:assign",
|
||||
name: { am: "Assign role", en: "Assign role" },
|
||||
applicationKey: EDR_FREIGHT_APPLICATION.key,
|
||||
},
|
||||
{
|
||||
id: "c1f34177-a0ae-4a46-a24a-3281b9137bab",
|
||||
key: "edr_freight_app:role_assignment:replace",
|
||||
name: { am: "Replace role assignment", en: "Replace role assignment" },
|
||||
applicationKey: EDR_FREIGHT_APPLICATION.key,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const HIERARCHY_UNIT_PERMISSIONS = [
|
||||
{
|
||||
id: "2bfa2428-ec40-4588-9b01-dfacce6a2b82",
|
||||
key: "edr_freight_app:hierarchy_units:view",
|
||||
name: { am: "View hierarchy units", en: "View hierarchy units" },
|
||||
applicationKey: EDR_FREIGHT_APPLICATION.key,
|
||||
},
|
||||
{
|
||||
id: "1e92daff-9cc7-4a67-9994-879f34bfda16",
|
||||
key: "edr_freight_app:hierarchy_units:create",
|
||||
name: { am: "Create hierarchy unit", en: "Create hierarchy unit" },
|
||||
applicationKey: EDR_FREIGHT_APPLICATION.key,
|
||||
},
|
||||
{
|
||||
id: "4ef2d8ad-c627-4448-b4b6-dd6b8b602dc1",
|
||||
key: "edr_freight_app:hierarchy_units:update",
|
||||
name: { am: "Update hierarchy unit", en: "Update hierarchy unit" },
|
||||
applicationKey: EDR_FREIGHT_APPLICATION.key,
|
||||
},
|
||||
{
|
||||
id: "15353ac5-246b-42e6-9ac3-eb61c4f1cd22",
|
||||
key: "edr_freight_app:hierarchy_units:delete",
|
||||
name: { am: "Delete hierarchy unit", en: "Delete hierarchy unit" },
|
||||
applicationKey: EDR_FREIGHT_APPLICATION.key,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const HIERARCHY_POSITION_PERMISSIONS = [
|
||||
{
|
||||
id: "37ff6f5b-9fb0-4139-af99-22fe54703029",
|
||||
key: "edr_freight_app:hierarchy_positions:view",
|
||||
name: { am: "View hierarchy positions", en: "View hierarchy positions" },
|
||||
applicationKey: EDR_FREIGHT_APPLICATION.key,
|
||||
},
|
||||
{
|
||||
id: "af6c091a-6448-4459-a635-c2181efd1de0",
|
||||
key: "edr_freight_app:hierarchy_positions:create",
|
||||
name: { am: "Create hierarchy position", en: "Create hierarchy position" },
|
||||
applicationKey: EDR_FREIGHT_APPLICATION.key,
|
||||
},
|
||||
{
|
||||
id: "e78f624d-b570-4cd6-8f16-12090a4a9d31",
|
||||
key: "edr_freight_app:hierarchy_positions:update",
|
||||
name: { am: "Update hierarchy position", en: "Update hierarchy position" },
|
||||
applicationKey: EDR_FREIGHT_APPLICATION.key,
|
||||
},
|
||||
{
|
||||
id: "7fba7887-a365-4281-96ea-fb14582b047e",
|
||||
key: "edr_freight_app:hierarchy_positions:delete",
|
||||
name: { am: "Delete hierarchy position", en: "Delete hierarchy position" },
|
||||
applicationKey: EDR_FREIGHT_APPLICATION.key,
|
||||
},
|
||||
{
|
||||
id: "a33905ff-f2b8-40b9-a8cf-e2968f6f46fb",
|
||||
key: "edr_freight_app:hierarchy_positions:change_parent",
|
||||
name: { am: "Change hierarchy position parent", en: "Change hierarchy position parent" },
|
||||
applicationKey: EDR_FREIGHT_APPLICATION.key,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const HIERARCHY_EMPLOYEE_ASSIGNMENT_PERMISSIONS = [
|
||||
{
|
||||
id: "b6ca90ff-3e95-4af2-bac8-fb298ca62080",
|
||||
key: "edr_freight_app:hierarchy_employee_assignment:view",
|
||||
name: { am: "View hierarchy employee assignment", en: "View hierarchy employee assignment" },
|
||||
applicationKey: EDR_FREIGHT_APPLICATION.key,
|
||||
},
|
||||
{
|
||||
id: "0637472f-d6b7-4332-85bb-eaa6a02205c1",
|
||||
key: "edr_freight_app:hierarchy_employee_assignment:invite",
|
||||
name: { am: "Invite hierarchy employee assignment", en: "Invite hierarchy employee assignment" },
|
||||
applicationKey: EDR_FREIGHT_APPLICATION.key,
|
||||
},
|
||||
{
|
||||
id: "de366c81-b6d1-4cf9-a5f1-a5c8a6fb5e7b",
|
||||
key: "edr_freight_app:hierarchy_employee_assignment:assign",
|
||||
name: { am: "Assign hierarchy employee assignment", en: "Assign hierarchy employee assignment" },
|
||||
applicationKey: EDR_FREIGHT_APPLICATION.key,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const POSITION_TYPE_PERMISSIONS = [
|
||||
{
|
||||
id: "f258fb51-2890-4c93-b024-271b09d705d0",
|
||||
key: "edr_freight_app:position_types:view",
|
||||
name: { am: "View position types", en: "View position types" },
|
||||
applicationKey: EDR_FREIGHT_APPLICATION.key,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const EDR_FREIGHT_PERMISSIONS = [
|
||||
...EMPLOYEE_REGISTRATION_PERMISSIONS,
|
||||
...ROLE_ASSIGNMENT_PERMISSIONS,
|
||||
...HIERARCHY_UNIT_PERMISSIONS,
|
||||
...HIERARCHY_POSITION_PERMISSIONS,
|
||||
...HIERARCHY_EMPLOYEE_ASSIGNMENT_PERMISSIONS,
|
||||
...POSITION_TYPE_PERMISSIONS,
|
||||
];
|
||||
|
||||
export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [
|
||||
{
|
||||
key: "edr_employee",
|
||||
name: { en: "EDR Employee" },
|
||||
permissionKeys: [
|
||||
"edr_freight_app:employee_registration:view",
|
||||
"edr_freight_app:role_assignment:view",
|
||||
"edr_freight_app:hierarchy_units:view",
|
||||
"edr_freight_app:hierarchy_positions:view",
|
||||
"edr_freight_app:hierarchy_employee_assignment:view",
|
||||
"edr_freight_app:position_types:view",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "edr_org_manager",
|
||||
name: { en: "EDR Org Manager" },
|
||||
permissionKeys: [
|
||||
...EDR_FREIGHT_PERMISSIONS.map((permission) => permission.key),
|
||||
IAM_PERMISSION_KEYS.createEmployee,
|
||||
IAM_PERMISSION_KEYS.deactivateEmployee,
|
||||
IAM_PERMISSION_KEYS.activateEmployee,
|
||||
IAM_PERMISSION_KEYS.activateUser,
|
||||
IAM_PERMISSION_KEYS.createUserRole,
|
||||
IAM_PERMISSION_KEYS.deleteUserRole,
|
||||
IAM_PERMISSION_KEYS.viewUserRole,
|
||||
IAM_PERMISSION_KEYS.manageOrganizationAdmin,
|
||||
IAM_PERMISSION_KEYS.manageUnitAdmin,
|
||||
IAM_PERMISSION_KEYS.createUnit,
|
||||
IAM_PERMISSION_KEYS.updateUnit,
|
||||
IAM_PERMISSION_KEYS.deleteUnit,
|
||||
IAM_PERMISSION_KEYS.createPositionPermission,
|
||||
IAM_PERMISSION_KEYS.deletePositionPermission,
|
||||
IAM_PERMISSION_KEYS.viewPositionPermission,
|
||||
IAM_PERMISSION_KEYS.findAllOrganization,
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "edr_customer",
|
||||
name: { en: "EDR Customer" },
|
||||
permissionKeys: [],
|
||||
},
|
||||
];
|
||||
@@ -8,45 +8,17 @@ import {
|
||||
} from "@tria-plc/iamapi-common";
|
||||
import { DataSource, EntityManager, In } from "typeorm";
|
||||
|
||||
import { EDR_FREIGHT_ROLES, type FreightSeedRole } from "./edr-freight.seed";
|
||||
|
||||
const EDR_ORG_KEY = "edr_freight";
|
||||
const EDR_ORG_NAME = { en: "EDR Freight" };
|
||||
const SEED_FLAG = "SEED_EDR_ORG";
|
||||
|
||||
type SeedPermission = {
|
||||
key: string;
|
||||
name: { en: string };
|
||||
};
|
||||
|
||||
type SeedRole = {
|
||||
key: string;
|
||||
name: { en: string };
|
||||
permissions: SeedPermission[];
|
||||
};
|
||||
|
||||
type SeedOrganization = {
|
||||
id: string;
|
||||
key: string;
|
||||
};
|
||||
|
||||
const SEED_ROLES: SeedRole[] = [
|
||||
{
|
||||
key: "edr_employee",
|
||||
name: { en: "EDR Employee" },
|
||||
permissions: [
|
||||
// { key: "permission:key", name: { en: "Permission Name" } },
|
||||
{ key: "permission:key", name: { en: "Permission Name" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "edr_customer",
|
||||
name: { en: "EDR Customer" },
|
||||
permissions: [
|
||||
// { key: "permission:key", name: { en: "Permission Name" } },
|
||||
{ key: "permission:key", name: { en: "Permission Name" } },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class EdrOrgSeeder {
|
||||
private readonly logger = new Logger(EdrOrgSeeder.name);
|
||||
@@ -63,9 +35,8 @@ export class EdrOrgSeeder {
|
||||
const organization = await this.ensureOrganization(manager);
|
||||
|
||||
await this.ensureOrganizationConfiguration(manager, organization.id);
|
||||
await this.ensurePermissions(manager, SEED_ROLES);
|
||||
await this.ensureRoles(manager, SEED_ROLES);
|
||||
await this.ensureRolePermissions(manager, SEED_ROLES);
|
||||
await this.ensureRoles(manager, EDR_FREIGHT_ROLES);
|
||||
await this.ensureRolePermissions(manager, EDR_FREIGHT_ROLES);
|
||||
});
|
||||
|
||||
this.logger.log(`Ensured EDR organization seed for '${EDR_ORG_KEY}'`);
|
||||
@@ -127,34 +98,7 @@ export class EdrOrgSeeder {
|
||||
);
|
||||
}
|
||||
|
||||
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[]) {
|
||||
private async ensureRoles(manager: EntityManager, seedRoles: FreightSeedRole[]) {
|
||||
await manager.getRepository(Role).upsert(
|
||||
seedRoles.map(({ key, name }) => ({ key, name })),
|
||||
{
|
||||
@@ -169,24 +113,24 @@ export class EdrOrgSeeder {
|
||||
|
||||
private async ensureRolePermissions(
|
||||
manager: EntityManager,
|
||||
seedRoles: SeedRole[],
|
||||
seedRoles: FreightSeedRole[],
|
||||
) {
|
||||
const permissions = this.collectPermissions(seedRoles);
|
||||
const permissionKeys = [...new Set(seedRoles.flatMap((role) => role.permissionKeys))];
|
||||
|
||||
if (!permissions.length) {
|
||||
if (!permissionKeys.length) {
|
||||
this.logger.log("No EDR role permissions configured; skipping role-permission links");
|
||||
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)) },
|
||||
const seededPermissions = await manager.getRepository(Permission).find({
|
||||
where: { key: In(permissionKeys) },
|
||||
select: { id: true, key: true },
|
||||
});
|
||||
|
||||
@@ -202,11 +146,11 @@ export class EdrOrgSeeder {
|
||||
throw new Error(`missing_role:${role.key}`);
|
||||
}
|
||||
|
||||
return role.permissions.map((permission) => {
|
||||
const seededPermission = permissionByKey.get(permission.key);
|
||||
return role.permissionKeys.map((permissionKey) => {
|
||||
const seededPermission = permissionByKey.get(permissionKey);
|
||||
|
||||
if (!seededPermission) {
|
||||
throw new Error(`missing_permission:${permission.key}`);
|
||||
throw new Error(`missing_permission:${permissionKey}`);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -21,6 +21,7 @@ import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
|
||||
import OverviewPage from "./pages/dashboard/OverviewPage";
|
||||
import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
|
||||
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
|
||||
import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage";
|
||||
import RolesPage from "./pages/dashboard/user-management/RolesPage";
|
||||
import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage";
|
||||
import UsersPage from "./pages/dashboard/user-management/UsersPage";
|
||||
@@ -74,6 +75,10 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Employees",
|
||||
href: "/dashboard/user-management/employees",
|
||||
},
|
||||
{
|
||||
label: "Position Types",
|
||||
href: "/dashboard/user-management/position-types",
|
||||
},
|
||||
{
|
||||
label: "Permissions",
|
||||
href: "/dashboard/user-management/permissions",
|
||||
@@ -114,7 +119,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
];
|
||||
|
||||
const hasPermission = (
|
||||
@@ -207,7 +211,8 @@ const App = () => {
|
||||
|
||||
<Route path="user-management" element={<UserManagementPage />} />
|
||||
<Route path="user-management/users" element={<UsersPage />} />
|
||||
<Route path="user-management/employees" element={<EmployeesPage />} />
|
||||
<Route path="user-management/position-types" element={<PositionTypesPage />} />
|
||||
{/* <Route path="user-management/employees" element={<EmployeesPage />} /> */}
|
||||
<Route path="user-management/permissions" element={<PermissionsPage />} />
|
||||
<Route path="user-management/roles" element={<RolesPage />} />
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { bookingsService } from "../services/bookings.service";
|
||||
|
||||
export const useBookings = () =>
|
||||
useQuery({
|
||||
queryKey: ["bookings"],
|
||||
queryFn: bookingsService.list,
|
||||
});
|
||||
|
||||
export const useBooking = (id: string) =>
|
||||
useQuery({
|
||||
queryKey: ["bookings", id],
|
||||
queryFn: () => bookingsService.get(id),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { consignmentsService } from "../services/consignments.service";
|
||||
|
||||
export const useConsignments = () =>
|
||||
useQuery({
|
||||
queryKey: ["consignments"],
|
||||
queryFn: consignmentsService.list,
|
||||
});
|
||||
|
||||
export const useConsignment = (id: string) =>
|
||||
useQuery({
|
||||
queryKey: ["consignments", id],
|
||||
queryFn: () => consignmentsService.get(id),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { customersService } from "@/services/customers.service";
|
||||
import type {
|
||||
CreateCustomerDto,
|
||||
UpdateCustomerDto,
|
||||
} from "@/types/customers";
|
||||
|
||||
const KEY = ["customers"] as const;
|
||||
|
||||
export const useCustomers = () =>
|
||||
useQuery({
|
||||
queryKey: KEY,
|
||||
queryFn: customersService.list,
|
||||
});
|
||||
|
||||
export const useCustomer = (id: string | undefined) =>
|
||||
useQuery({
|
||||
queryKey: [...KEY, "id", id],
|
||||
queryFn: () => customersService.getById(id!),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
|
||||
export const useCreateCustomer = () => {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (dto: CreateCustomerDto) => customersService.create(dto),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateCustomer = () => {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, dto }: { id: string; dto: UpdateCustomerDto }) =>
|
||||
customersService.update(id, dto),
|
||||
onSuccess: (_data, { id }) => {
|
||||
qc.invalidateQueries({ queryKey: KEY });
|
||||
qc.invalidateQueries({ queryKey: [...KEY, "id", id] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteCustomer = () => {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => customersService.remove(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
||||
});
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { trackingService } from "../services/tracking.service";
|
||||
|
||||
export const useTracking = (consignmentId: string) =>
|
||||
useQuery({
|
||||
queryKey: ["tracking", consignmentId],
|
||||
queryFn: () => trackingService.forConsignment(consignmentId),
|
||||
enabled: Boolean(consignmentId),
|
||||
});
|
||||
@@ -1,11 +1,8 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { QUERY_KEYS } from "@/constants/TANSTACK_QUEY_KEY";
|
||||
import {
|
||||
ruleEngineService,
|
||||
type RuleEngineListParams,
|
||||
} from "@/services/ruleEngine/ruleEngine.service";
|
||||
import { api } from "@/services/api";
|
||||
import type { RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service";
|
||||
import { RULE_ENGINE_SELECT_NONE } from "@/pages/ruleEngine/config/resources";
|
||||
import type {
|
||||
ApproveRatePayload,
|
||||
@@ -16,26 +13,29 @@ import type {
|
||||
const CARGO_TYPE_PARENT_PAGE_SIZE = 500;
|
||||
const CONTAINER_TYPE_OPTIONS_PAGE_SIZE = 500;
|
||||
|
||||
const listKey = (resource: RuleEngineResourceSlug) =>
|
||||
["rule-engine", resource] as const;
|
||||
|
||||
export const useRuleEngineList = (
|
||||
resource: RuleEngineResourceSlug,
|
||||
params: RuleEngineListParams,
|
||||
) =>
|
||||
useQuery({
|
||||
queryKey: [...QUERY_KEYS.RULE_ENGINE.list(resource), params],
|
||||
queryFn: () => ruleEngineService.list<RuleEngineRecord>(resource, params),
|
||||
});
|
||||
useQuery(
|
||||
api.ruleEngine.list.queryOptions({
|
||||
input: { resource, params },
|
||||
}),
|
||||
);
|
||||
|
||||
export const useCargoTypeParentOptions = (excludeId?: string, enabled = true) =>
|
||||
useQuery({
|
||||
queryKey: [
|
||||
...QUERY_KEYS.RULE_ENGINE.list("cargo-types"),
|
||||
"parent-options",
|
||||
excludeId ?? "",
|
||||
],
|
||||
queryKey: api.ruleEngine.list.queryKey({
|
||||
resource: "cargo-types",
|
||||
params: { page: 1, pageSize: CARGO_TYPE_PARENT_PAGE_SIZE },
|
||||
}),
|
||||
queryFn: () =>
|
||||
ruleEngineService.list<RuleEngineRecord>("cargo-types", {
|
||||
page: 1,
|
||||
pageSize: CARGO_TYPE_PARENT_PAGE_SIZE,
|
||||
api.ruleEngine.list.call({
|
||||
resource: "cargo-types",
|
||||
params: { page: 1, pageSize: CARGO_TYPE_PARENT_PAGE_SIZE },
|
||||
}),
|
||||
enabled,
|
||||
select: (result) => {
|
||||
@@ -46,7 +46,9 @@ export const useCargoTypeParentOptions = (excludeId?: string, enabled = true) =>
|
||||
const name = String(row.cargoTypeName ?? "").trim();
|
||||
const code = String(row.code ?? "").trim();
|
||||
const label =
|
||||
name && code ? `${name} (${code})` : name || code || String(row.id);
|
||||
name && code
|
||||
? `${name} (${code})`
|
||||
: name || code || String(row.id);
|
||||
return { label, value: String(row.id) };
|
||||
});
|
||||
return [noneOption, ...parents];
|
||||
@@ -84,20 +86,20 @@ export const useContainerTypeOptions = (enabled = true) =>
|
||||
});
|
||||
|
||||
export const useApprovalChain = (enabled: boolean) =>
|
||||
useQuery({
|
||||
queryKey: QUERY_KEYS.RULE_ENGINE.chain,
|
||||
queryFn: () => ruleEngineService.getApprovalChain(),
|
||||
enabled,
|
||||
});
|
||||
useQuery(
|
||||
api.ruleEngine.getApprovalChain.queryOptions({
|
||||
enabled,
|
||||
}),
|
||||
);
|
||||
|
||||
export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => {
|
||||
const qc = useQueryClient();
|
||||
const invalidate = () =>
|
||||
qc.invalidateQueries({ queryKey: QUERY_KEYS.RULE_ENGINE.list(resource) });
|
||||
qc.invalidateQueries({ queryKey: listKey(resource) });
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (payload: Record<string, unknown>) =>
|
||||
ruleEngineService.create(resource, payload),
|
||||
api.ruleEngine.create.call({ resource, payload }),
|
||||
onSuccess: () => {
|
||||
toast.success("Created successfully");
|
||||
invalidate();
|
||||
@@ -112,7 +114,7 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => {
|
||||
}: {
|
||||
id: string;
|
||||
payload: Record<string, unknown>;
|
||||
}) => ruleEngineService.update(resource, id, payload),
|
||||
}) => api.ruleEngine.update.call({ resource, id, payload }),
|
||||
onSuccess: () => {
|
||||
toast.success("Updated successfully");
|
||||
invalidate();
|
||||
@@ -121,7 +123,8 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => {
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => ruleEngineService.remove(resource, id),
|
||||
mutationFn: (id: string) =>
|
||||
api.ruleEngine.remove.call({ resource, id }),
|
||||
onSuccess: () => {
|
||||
toast.success("Deleted successfully");
|
||||
invalidate();
|
||||
@@ -135,10 +138,10 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => {
|
||||
export const useRateWorkflow = () => {
|
||||
const qc = useQueryClient();
|
||||
const invalidate = () =>
|
||||
qc.invalidateQueries({ queryKey: QUERY_KEYS.RULE_ENGINE.list("rates") });
|
||||
qc.invalidateQueries({ queryKey: listKey("rates") });
|
||||
|
||||
const submit = useMutation({
|
||||
mutationFn: (id: string) => ruleEngineService.submitRate(id),
|
||||
mutationFn: (id: string) => api.ruleEngine.submitRate.call({ id }),
|
||||
onSuccess: () => {
|
||||
toast.success("Rate submitted for approval");
|
||||
invalidate();
|
||||
@@ -147,8 +150,13 @@ export const useRateWorkflow = () => {
|
||||
});
|
||||
|
||||
const approve = useMutation({
|
||||
mutationFn: ({ id, payload }: { id: string; payload: ApproveRatePayload }) =>
|
||||
ruleEngineService.approveRate(id, payload),
|
||||
mutationFn: ({
|
||||
id,
|
||||
payload,
|
||||
}: {
|
||||
id: string;
|
||||
payload: ApproveRatePayload;
|
||||
}) => api.ruleEngine.approveRate.call({ id, payload }),
|
||||
onSuccess: () => {
|
||||
toast.success("Rate approved");
|
||||
invalidate();
|
||||
|
||||
48
apps/edr-freight-web/backoffice/src/hooks/useBookings.ts
Normal file
48
apps/edr-freight-web/backoffice/src/hooks/useBookings.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { BookingListFilter } from "@/services/bookings.service";
|
||||
|
||||
export const useBookingList = (filter?: BookingListFilter) => {
|
||||
const input = { filter };
|
||||
return {
|
||||
queryKey: api.bookings.list.queryKey(input),
|
||||
queryFn: () => api.bookings.list.call(input),
|
||||
};
|
||||
};
|
||||
|
||||
export const useBooking = (id: string) => ({
|
||||
queryKey: api.bookings.getById.queryKey({ id }),
|
||||
queryFn: () => api.bookings.getById.call({ id }),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
|
||||
export const useUpdateBookingStatus = () => {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
action,
|
||||
reason,
|
||||
}: {
|
||||
id: string;
|
||||
action: string;
|
||||
reason?: string;
|
||||
}) => api.bookings.updateStatus.call({ id, action, reason }),
|
||||
onSuccess: (_data, { id }) => {
|
||||
qc.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
||||
qc.invalidateQueries({
|
||||
queryKey: api.bookings.getById.queryKey({ id }),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteBooking = () => {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.bookings.remove.call({ id }),
|
||||
onSuccess: () =>
|
||||
qc.invalidateQueries({ queryKey: api.bookings.list.queryKey() }),
|
||||
});
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { dropdownSettingsService } from "@/services/dropdownSettings.service";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
CreateDropdownOptionDto,
|
||||
CreateDropdownSettingDto,
|
||||
@@ -8,38 +8,15 @@ import type {
|
||||
UpdateDropdownSettingDto,
|
||||
} from "@/types/dropdownSettings";
|
||||
|
||||
const KEY = ["dropdown-settings"] as const;
|
||||
|
||||
/* ------------------------------ Queries ------------------------------ */
|
||||
|
||||
export const useDropdownSettings = () =>
|
||||
useQuery({
|
||||
queryKey: KEY,
|
||||
queryFn: dropdownSettingsService.list,
|
||||
});
|
||||
|
||||
export const useDropdownSetting = (id: string) =>
|
||||
useQuery({
|
||||
queryKey: [...KEY, "id", id],
|
||||
queryFn: () => dropdownSettingsService.getById(id),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
|
||||
export const useDropdownSettingByCode = (code: string) =>
|
||||
useQuery({
|
||||
queryKey: [...KEY, "code", code],
|
||||
queryFn: () => dropdownSettingsService.getByCode(code),
|
||||
enabled: Boolean(code),
|
||||
});
|
||||
|
||||
/* ----------------------------- Mutations ----------------------------- */
|
||||
|
||||
export const useCreateDropdownSetting = () => {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (dto: CreateDropdownSettingDto) =>
|
||||
dropdownSettingsService.create(dto),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
||||
api.dropdownSettings.create.call(dto),
|
||||
onSuccess: () =>
|
||||
qc.invalidateQueries({ queryKey: api.dropdownSettings.list.queryKey() }),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -52,10 +29,12 @@ export const useUpdateDropdownSetting = () => {
|
||||
}: {
|
||||
id: string;
|
||||
dto: UpdateDropdownSettingDto;
|
||||
}) => dropdownSettingsService.update(id, dto),
|
||||
}) => api.dropdownSettings.update.call({ id, dto }),
|
||||
onSuccess: (_data, { id }) => {
|
||||
qc.invalidateQueries({ queryKey: KEY });
|
||||
qc.invalidateQueries({ queryKey: [...KEY, "id", id] });
|
||||
qc.invalidateQueries({ queryKey: api.dropdownSettings.list.queryKey() });
|
||||
qc.invalidateQueries({
|
||||
queryKey: api.dropdownSettings.getById.queryKey({ id }),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -63,8 +42,9 @@ export const useUpdateDropdownSetting = () => {
|
||||
export const useDeleteDropdownSetting = () => {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => dropdownSettingsService.remove(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
||||
mutationFn: (id: string) => api.dropdownSettings.remove.call({ id }),
|
||||
onSuccess: () =>
|
||||
qc.invalidateQueries({ queryKey: api.dropdownSettings.list.queryKey() }),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -77,10 +57,12 @@ export const useReplaceDropdownOptions = () => {
|
||||
}: {
|
||||
settingId: string;
|
||||
options: CreateDropdownOptionDto[];
|
||||
}) => dropdownSettingsService.replaceOptions(settingId, options),
|
||||
}) => api.dropdownSettings.replaceOptions.call({ id: settingId, options }),
|
||||
onSuccess: (_data, { settingId }) => {
|
||||
qc.invalidateQueries({ queryKey: KEY });
|
||||
qc.invalidateQueries({ queryKey: [...KEY, "id", settingId] });
|
||||
qc.invalidateQueries({ queryKey: api.dropdownSettings.list.queryKey() });
|
||||
qc.invalidateQueries({
|
||||
queryKey: api.dropdownSettings.getById.queryKey({ id: settingId }),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -94,10 +76,12 @@ export const useAddDropdownOption = () => {
|
||||
}: {
|
||||
settingId: string;
|
||||
dto: CreateDropdownOptionDto;
|
||||
}) => dropdownSettingsService.addOption(settingId, dto),
|
||||
}) => api.dropdownSettings.addOption.call({ id: settingId, dto }),
|
||||
onSuccess: (_data, { settingId }) => {
|
||||
qc.invalidateQueries({ queryKey: KEY });
|
||||
qc.invalidateQueries({ queryKey: [...KEY, "id", settingId] });
|
||||
qc.invalidateQueries({ queryKey: api.dropdownSettings.list.queryKey() });
|
||||
qc.invalidateQueries({
|
||||
queryKey: api.dropdownSettings.getById.queryKey({ id: settingId }),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -111,8 +95,9 @@ export const useUpdateDropdownOption = () => {
|
||||
}: {
|
||||
optionId: string;
|
||||
dto: UpdateDropdownOptionDto;
|
||||
}) => dropdownSettingsService.updateOption(optionId, dto),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
||||
}) => api.dropdownSettings.updateOption.call({ optionId, dto }),
|
||||
onSuccess: () =>
|
||||
qc.invalidateQueries({ queryKey: api.dropdownSettings.list.queryKey() }),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -120,7 +105,8 @@ export const useRemoveDropdownOption = () => {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (optionId: string) =>
|
||||
dropdownSettingsService.removeOption(optionId),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
||||
api.dropdownSettings.removeOption.call({ optionId }),
|
||||
onSuccess: () =>
|
||||
qc.invalidateQueries({ queryKey: api.dropdownSettings.list.queryKey() }),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { fileUploadSettingsService } from "@/services/fileUploadSettings.service";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
CreateFileUploadFieldDto,
|
||||
CreateFileUploadSettingDto,
|
||||
@@ -8,38 +8,17 @@ import type {
|
||||
UpdateFileUploadSettingDto,
|
||||
} from "@/types/fileUploadSettings";
|
||||
|
||||
const KEY = ["file-upload-settings"] as const;
|
||||
|
||||
/* ------------------------------ Queries ------------------------------ */
|
||||
|
||||
export const useFileUploadSettings = () =>
|
||||
useQuery({
|
||||
queryKey: KEY,
|
||||
queryFn: fileUploadSettingsService.list,
|
||||
});
|
||||
|
||||
export const useFileUploadSetting = (id: string) =>
|
||||
useQuery({
|
||||
queryKey: [...KEY, "id", id],
|
||||
queryFn: () => fileUploadSettingsService.getById(id),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
|
||||
export const useFileUploadSettingByCode = (code: string) =>
|
||||
useQuery({
|
||||
queryKey: [...KEY, "code", code],
|
||||
queryFn: () => fileUploadSettingsService.getByCode(code),
|
||||
enabled: Boolean(code),
|
||||
});
|
||||
|
||||
/* ----------------------------- Mutations ----------------------------- */
|
||||
|
||||
export const useCreateFileUploadSetting = () => {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (dto: CreateFileUploadSettingDto) =>
|
||||
fileUploadSettingsService.create(dto),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
||||
api.fileUploadSettings.create.call(dto),
|
||||
onSuccess: () =>
|
||||
qc.invalidateQueries({
|
||||
queryKey: api.fileUploadSettings.list.queryKey(),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -52,10 +31,14 @@ export const useUpdateFileUploadSetting = () => {
|
||||
}: {
|
||||
id: string;
|
||||
dto: UpdateFileUploadSettingDto;
|
||||
}) => fileUploadSettingsService.update(id, dto),
|
||||
}) => api.fileUploadSettings.update.call({ id, dto }),
|
||||
onSuccess: (_data, { id }) => {
|
||||
qc.invalidateQueries({ queryKey: KEY });
|
||||
qc.invalidateQueries({ queryKey: [...KEY, "id", id] });
|
||||
qc.invalidateQueries({
|
||||
queryKey: api.fileUploadSettings.list.queryKey(),
|
||||
});
|
||||
qc.invalidateQueries({
|
||||
queryKey: api.fileUploadSettings.getById.queryKey({ id }),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -63,8 +46,11 @@ export const useUpdateFileUploadSetting = () => {
|
||||
export const useDeleteFileUploadSetting = () => {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => fileUploadSettingsService.remove(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
||||
mutationFn: (id: string) => api.fileUploadSettings.remove.call({ id }),
|
||||
onSuccess: () =>
|
||||
qc.invalidateQueries({
|
||||
queryKey: api.fileUploadSettings.list.queryKey(),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -77,10 +63,14 @@ export const useReplaceFileUploadFields = () => {
|
||||
}: {
|
||||
settingId: string;
|
||||
fields: CreateFileUploadFieldDto[];
|
||||
}) => fileUploadSettingsService.replaceFields(settingId, fields),
|
||||
}) => api.fileUploadSettings.replaceFields.call({ id: settingId, fields }),
|
||||
onSuccess: (_data, { settingId }) => {
|
||||
qc.invalidateQueries({ queryKey: KEY });
|
||||
qc.invalidateQueries({ queryKey: [...KEY, "id", settingId] });
|
||||
qc.invalidateQueries({
|
||||
queryKey: api.fileUploadSettings.list.queryKey(),
|
||||
});
|
||||
qc.invalidateQueries({
|
||||
queryKey: api.fileUploadSettings.getById.queryKey({ id: settingId }),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -94,10 +84,14 @@ export const useAddFileUploadField = () => {
|
||||
}: {
|
||||
settingId: string;
|
||||
dto: CreateFileUploadFieldDto;
|
||||
}) => fileUploadSettingsService.addField(settingId, dto),
|
||||
}) => api.fileUploadSettings.addField.call({ settingId, dto }),
|
||||
onSuccess: (_data, { settingId }) => {
|
||||
qc.invalidateQueries({ queryKey: KEY });
|
||||
qc.invalidateQueries({ queryKey: [...KEY, "id", settingId] });
|
||||
qc.invalidateQueries({
|
||||
queryKey: api.fileUploadSettings.list.queryKey(),
|
||||
});
|
||||
qc.invalidateQueries({
|
||||
queryKey: api.fileUploadSettings.getById.queryKey({ id: settingId }),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -111,8 +105,11 @@ export const useUpdateFileUploadField = () => {
|
||||
}: {
|
||||
fieldId: string;
|
||||
dto: UpdateFileUploadFieldDto;
|
||||
}) => fileUploadSettingsService.updateField(fieldId, dto),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
||||
}) => api.fileUploadSettings.updateField.call({ fieldId, dto }),
|
||||
onSuccess: () =>
|
||||
qc.invalidateQueries({
|
||||
queryKey: api.fileUploadSettings.list.queryKey(),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -120,7 +117,10 @@ export const useRemoveFileUploadField = () => {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (fieldId: string) =>
|
||||
fileUploadSettingsService.removeField(fieldId),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
||||
api.fileUploadSettings.removeField.call({ fieldId }),
|
||||
onSuccess: () =>
|
||||
qc.invalidateQueries({
|
||||
queryKey: api.fileUploadSettings.list.queryKey(),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
@@ -27,14 +27,9 @@ import {
|
||||
|
||||
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 { api } from "@/services/api";
|
||||
import { useUpdateBookingStatus } from "@/hooks/useBookings";
|
||||
import { mapBookingToRequest, BOOKING_STATUSES } from "./booking-requests.mock";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -233,9 +228,16 @@ const STATUS_CONFIG: Record<
|
||||
export default function BookingRequestDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [booking, setBooking] = useState<BookingRequest | undefined>(
|
||||
id ? getBookingRequestById(id) : undefined,
|
||||
|
||||
const { data: bookingData } = useQuery(
|
||||
api.bookings.getById.queryOptions({
|
||||
input: { id: id ?? "" },
|
||||
enabled: Boolean(id),
|
||||
}),
|
||||
);
|
||||
const updateStatus = useUpdateBookingStatus();
|
||||
|
||||
const booking = bookingData ? mapBookingToRequest(bookingData) : undefined;
|
||||
|
||||
if (!booking) {
|
||||
return (
|
||||
@@ -270,20 +272,20 @@ export default function BookingRequestDetailPage() {
|
||||
booking.status,
|
||||
);
|
||||
|
||||
const isPending = updateStatus.isPending;
|
||||
|
||||
function handleApprove() {
|
||||
if (!booking) return;
|
||||
const nextStatus =
|
||||
const action =
|
||||
booking.status === "RFQ_SUBMITTED"
|
||||
? ("QUOTATION_SENT" as const)
|
||||
: ("APPROVED" as const);
|
||||
updateBookingRequestStatus(booking.id, nextStatus);
|
||||
setBooking(getBookingRequestById(booking.id));
|
||||
? "SEND_QUOTATION"
|
||||
: "APPROVE";
|
||||
updateStatus.mutate({ id: booking.id, action });
|
||||
}
|
||||
|
||||
function handleReject() {
|
||||
if (!booking) return;
|
||||
updateBookingRequestStatus(booking.id, "CANCELLED");
|
||||
setBooking(getBookingRequestById(booking.id));
|
||||
updateStatus.mutate({ id: booking.id, action: "CANCEL", reason: "Cancelled by backoffice" });
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
@@ -18,10 +19,11 @@ import {
|
||||
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/services/api";
|
||||
import {
|
||||
getBookingRequests,
|
||||
BOOKING_STATUSES,
|
||||
type BookingRequest,
|
||||
mapBookingToRequest,
|
||||
} from "./booking-requests.mock";
|
||||
import {
|
||||
DataTable,
|
||||
@@ -153,7 +155,13 @@ export default function BookingRequestsPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
|
||||
const bookingRequests = useMemo(() => getBookingRequests(), []);
|
||||
const { data: bookingData } = useQuery(
|
||||
api.bookings.list.queryOptions({ input: { filter: { page: pagination.pageIndex + 1, pageSize: pagination.pageSize } } }),
|
||||
);
|
||||
const bookingRequests = useMemo(
|
||||
() => (bookingData?.items ?? []).map(mapBookingToRequest),
|
||||
[bookingData],
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
export interface BookingRequest {
|
||||
id: string;
|
||||
reference: string;
|
||||
@@ -131,6 +133,36 @@ export function updateBookingRequestStatus(id: string, newStatus: (typeof BOOKIN
|
||||
saveBookingRequestsToStorage(requests);
|
||||
}
|
||||
|
||||
export function mapBookingToRequest(booking: Freight.IBooking): BookingRequest {
|
||||
return {
|
||||
id: booking.id,
|
||||
reference: booking.reference,
|
||||
customer: booking.customerId,
|
||||
status: booking.status as BookingRequest["status"],
|
||||
scheduledDate: booking.scheduledDate,
|
||||
totalAmount: booking.totalAmount,
|
||||
paymentStatus: booking.paymentStatus,
|
||||
contractType: booking.contractType,
|
||||
serviceType:
|
||||
booking.serviceType === "RAIL_ONLY" ? "RAIL" : (booking.serviceType as string),
|
||||
tradeDirection: booking.tradeDirection as string,
|
||||
originYard: booking.originStation,
|
||||
destinationYard: booking.destinationStation,
|
||||
cargoType: booking.freightType ?? booking.freightSubtype ?? "",
|
||||
cargoTotalWeightVgm: booking.cargoTotalWeightVgm,
|
||||
isHazardous: booking.isHazardous,
|
||||
paymentCurrency: booking.paymentCurrency,
|
||||
priorityScore: booking.priorityScore,
|
||||
firstMilePickupAddress: booking.firstMilePickupAddress ?? null,
|
||||
lastMileDeliveryAddress: booking.lastMileDeliveryAddress ?? null,
|
||||
shippingLine: null,
|
||||
pnrCode: null,
|
||||
createdBy: booking.customerId,
|
||||
createdAt: booking.createdAt,
|
||||
updatedAt: booking.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function getBookingRequests(): BookingRequest[] {
|
||||
if (typeof window === "undefined" || !window.localStorage) {
|
||||
return INITIAL_REQUESTS;
|
||||
|
||||
@@ -100,7 +100,7 @@ const EmployeesPage = () => {
|
||||
const responses = await Promise.all(
|
||||
organizationIds.map((organizationId) =>
|
||||
api.get<ListResponse<EmployeeRecord>>(
|
||||
`/employees/${organizationId}/by-organization`,
|
||||
`/backoffice/organizations/${organizationId}/employees`,
|
||||
{
|
||||
params: {
|
||||
skip: 0,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -188,6 +188,46 @@ const getItems = <T,>(payload: ListResponse<T> | T[] | undefined | null) => {
|
||||
return payload.items ?? payload.data ?? [];
|
||||
};
|
||||
|
||||
const mergeEmployeesByUser = (employees: EmployeeRecord[]) => {
|
||||
const employeesByUserId = new Map<string, EmployeeRecord>();
|
||||
|
||||
for (const employee of employees) {
|
||||
const userId = employee.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
employeesByUserId.set(employee.id, employee);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = employeesByUserId.get(userId);
|
||||
|
||||
if (!existing) {
|
||||
employeesByUserId.set(userId, employee);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingPositions = existing.employeePositions ?? [];
|
||||
const nextPositions = employee.employeePositions ?? [];
|
||||
const mergedPositions = Array.from(
|
||||
new Map(
|
||||
[...existingPositions, ...nextPositions].map((position) => [position.id, position]),
|
||||
).values(),
|
||||
);
|
||||
|
||||
employeesByUserId.set(userId, {
|
||||
...existing,
|
||||
...employee,
|
||||
id: existing.id,
|
||||
name: existing.name ?? employee.name,
|
||||
status: existing.status ?? employee.status,
|
||||
user: existing.user ?? employee.user,
|
||||
employeePositions: mergedPositions,
|
||||
});
|
||||
}
|
||||
|
||||
return [...employeesByUserId.values()];
|
||||
};
|
||||
|
||||
const toInternalKey = (value: string) =>
|
||||
value
|
||||
.trim()
|
||||
@@ -611,9 +651,9 @@ const UserManagementPage = () => {
|
||||
|
||||
try {
|
||||
const response = await api.get<ListResponse<EmployeeRecord>>(
|
||||
`/employees/${organizationId}/by-organization`,
|
||||
`/backoffice/organizations/${organizationId}/employees`,
|
||||
);
|
||||
setOrgEmployees(getItems(response.data));
|
||||
setOrgEmployees(mergeEmployeesByUser(getItems(response.data)));
|
||||
} catch {
|
||||
setOrgEmployees([]);
|
||||
} finally {
|
||||
@@ -745,13 +785,19 @@ const UserManagementPage = () => {
|
||||
const refreshSelectedUnit = useCallback(async () => {
|
||||
if (!selectedUnitId) {
|
||||
setPositions([]);
|
||||
setPositionTypes([]);
|
||||
setPositionTypesError(null);
|
||||
setPositionMembers([]);
|
||||
setUnitAdminUserIds(new Set());
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([loadPositions(selectedUnitId), loadUnitAdmins(selectedUnitId)]);
|
||||
}, [loadPositions, loadUnitAdmins, selectedUnitId]);
|
||||
await Promise.all([
|
||||
loadPositions(selectedUnitId),
|
||||
loadPositionTypes(selectedUnitId),
|
||||
loadUnitAdmins(selectedUnitId),
|
||||
]);
|
||||
}, [loadPositionTypes, loadPositions, loadUnitAdmins, selectedUnitId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadOrganizations();
|
||||
@@ -772,7 +818,7 @@ const UserManagementPage = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedOrgId(null);
|
||||
setSelectedOrgId(visibleOrganizations[0]?.id ?? null);
|
||||
setSelectedOrgConfiguration(null);
|
||||
setUnits([]);
|
||||
setPositions([]);
|
||||
@@ -780,6 +826,14 @@ const UserManagementPage = () => {
|
||||
setOrgEmployees([]);
|
||||
}, [selectedOrgId, visibleOrganizations]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedOrgId) {
|
||||
return;
|
||||
}
|
||||
|
||||
void refreshSelectedOrg();
|
||||
}, [refreshSelectedOrg, selectedOrgId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedOrgId) {
|
||||
setUnits([]);
|
||||
@@ -802,9 +856,23 @@ const UserManagementPage = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!units.some((unit) => unit.id === selectedUnitId)) {
|
||||
setSelectedUnitId(null);
|
||||
if (units.some((unit) => unit.id === selectedUnitId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedUnitId(units[0]?.id ?? null);
|
||||
}, [selectedOrgId, selectedUnitId, units]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedOrgId || !units.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedUnitId && units.some((unit) => unit.id === selectedUnitId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedUnitId(units[0]?.id ?? null);
|
||||
}, [selectedOrgId, selectedUnitId, units]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -820,6 +888,14 @@ const UserManagementPage = () => {
|
||||
}
|
||||
}, [selectedUnitId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedUnitId) {
|
||||
return;
|
||||
}
|
||||
|
||||
void refreshSelectedUnit();
|
||||
}, [refreshSelectedUnit, selectedUnitId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedUnitId || !selectedPositionId) {
|
||||
return;
|
||||
@@ -882,15 +958,11 @@ const UserManagementPage = () => {
|
||||
setSelectedPositionId(null);
|
||||
setExpandedDepartmentIds(new Set());
|
||||
setPositions([]);
|
||||
setPositionTypes([]);
|
||||
setPositionTypesError(null);
|
||||
setPositionMembers([]);
|
||||
setUnitAdminUserIds(new Set());
|
||||
resetMessages();
|
||||
|
||||
try {
|
||||
await Promise.all([loadPositions(unitId), loadUnitAdmins(unitId)]);
|
||||
} catch {
|
||||
// Individual loaders already handle their own error state.
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleDepartment = (departmentId: string) => {
|
||||
|
||||
@@ -60,6 +60,7 @@ interface UserFormState {
|
||||
email: string;
|
||||
username: string;
|
||||
phoneNumber: string;
|
||||
assignOrganizationAdmin: boolean;
|
||||
}
|
||||
|
||||
interface ListResponse<T> {
|
||||
@@ -75,6 +76,7 @@ const emptyUserForm: UserFormState = {
|
||||
email: "",
|
||||
username: "",
|
||||
phoneNumber: "",
|
||||
assignOrganizationAdmin: false,
|
||||
};
|
||||
|
||||
const inputClassName =
|
||||
@@ -102,6 +104,46 @@ const getItems = <T,>(payload: ListResponse<T> | T[] | undefined | null) => {
|
||||
return payload.items ?? payload.data ?? [];
|
||||
};
|
||||
|
||||
const mergeEmployeesByUser = (employees: EmployeeRecord[]) => {
|
||||
const employeesByUserId = new Map<string, EmployeeRecord>();
|
||||
|
||||
for (const employee of employees) {
|
||||
const userId = employee.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
employeesByUserId.set(employee.id, employee);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = employeesByUserId.get(userId);
|
||||
|
||||
if (!existing) {
|
||||
employeesByUserId.set(userId, employee);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingPositions = existing.employeePositions ?? [];
|
||||
const nextPositions = employee.employeePositions ?? [];
|
||||
const mergedPositions = Array.from(
|
||||
new Map(
|
||||
[...existingPositions, ...nextPositions].map((position) => [position.id, position]),
|
||||
).values(),
|
||||
);
|
||||
|
||||
employeesByUserId.set(userId, {
|
||||
...existing,
|
||||
...employee,
|
||||
id: existing.id,
|
||||
name: existing.name ?? employee.name,
|
||||
status: existing.status ?? employee.status,
|
||||
user: existing.user ?? employee.user,
|
||||
employeePositions: mergedPositions,
|
||||
});
|
||||
}
|
||||
|
||||
return [...employeesByUserId.values()];
|
||||
};
|
||||
|
||||
const getErrorMessage = (error: unknown, fallback: string) => {
|
||||
if (isAxiosError(error)) {
|
||||
const message = error.response?.data?.message;
|
||||
@@ -229,9 +271,9 @@ const UsersPage = () => {
|
||||
|
||||
try {
|
||||
const response = await api.get<ListResponse<EmployeeRecord>>(
|
||||
`/employees/${organizationId}/by-organization`,
|
||||
`/backoffice/organizations/${organizationId}/employees`,
|
||||
);
|
||||
setOrgEmployees(getItems(response.data));
|
||||
setOrgEmployees(mergeEmployeesByUser(getItems(response.data)));
|
||||
} catch {
|
||||
setOrgEmployees([]);
|
||||
} finally {
|
||||
@@ -346,14 +388,23 @@ const UsersPage = () => {
|
||||
am: createUserForm.nameAm.trim(),
|
||||
en: createUserForm.nameEn.trim(),
|
||||
},
|
||||
assignOrganizationAdmin: createUserForm.assignOrganizationAdmin,
|
||||
},
|
||||
);
|
||||
|
||||
const shouldAssignOrganizationAdmin = createUserForm.assignOrganizationAdmin;
|
||||
setCreateUserForm(emptyUserForm);
|
||||
setIsCreateUserOpen(false);
|
||||
setActionSuccess("User created. Default password: 12345678.");
|
||||
setActionSuccess(
|
||||
shouldAssignOrganizationAdmin
|
||||
? "User created as organization admin. Default password: 12345678."
|
||||
: "User created. Default password: 12345678.",
|
||||
);
|
||||
await loadOrgEmployees(selectedOrgId);
|
||||
await openManageRolesDialog(response.data);
|
||||
|
||||
if (!shouldAssignOrganizationAdmin) {
|
||||
await openManageRolesDialog(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
setActionError(getErrorMessage(error, "Failed to create user."));
|
||||
} finally {
|
||||
@@ -669,6 +720,24 @@ const UsersPage = () => {
|
||||
onChange={(event) => setCreateUserForm((current) => ({ ...current, phoneNumber: event.target.value }))}
|
||||
/>
|
||||
</Field>
|
||||
<label className="flex items-start gap-3 rounded-2xl border border-border bg-background px-4 py-3 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={createUserForm.assignOrganizationAdmin}
|
||||
onChange={(event) =>
|
||||
setCreateUserForm((current) => ({
|
||||
...current,
|
||||
assignOrganizationAdmin: event.target.checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-foreground">Create as organization admin</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Also assigns built-in org admin access and the freight org manager role.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -20,12 +20,16 @@ import ManageFileUploadFieldsDialog from "./ManageFileUploadFieldsDialog";
|
||||
import DeleteFileUploadSettingDialog from "./DeleteFileUploadSettingDialog";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { getMinFiles } from "@/types/fileUploadSettings";
|
||||
import { useDeleteFileUploadSetting, useFileUploadSettings } from "@/hooks/useFileUploadSettings";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
import { useDeleteFileUploadSetting } from "@/hooks/useFileUploadSettings";
|
||||
|
||||
export default function FileUploadSettingsPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const { data, isLoading, isError, error } = useFileUploadSettings();
|
||||
const { data, isLoading, isError, error } = useQuery(
|
||||
api.fileUploadSettings.list.queryOptions(),
|
||||
);
|
||||
const deleteMutation = useDeleteFileUploadSetting();
|
||||
|
||||
const fileUploadSettings = useMemo(
|
||||
|
||||
@@ -21,10 +21,9 @@ import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import EditDropdownSettingDialog from "./EditDropdownSettingDialog";
|
||||
import ManageDropdownOptionsDialog from "./ManageDropdownOptionsDialog";
|
||||
import DeleteDropdownSettingDialog from "./DeleteDropdownSettingDialog";
|
||||
import {
|
||||
useDeleteDropdownSetting,
|
||||
useDropdownSettings,
|
||||
} from "@/hooks/useDropdownSettings";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
import { useDeleteDropdownSetting } from "@/hooks/useDropdownSettings";
|
||||
import type { DropdownSetting } from "@/types/dropdownSettings";
|
||||
import {
|
||||
DataTable,
|
||||
@@ -86,7 +85,9 @@ export default function DropdownSettingsPage() {
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [activeDialog]);
|
||||
|
||||
const { data, isLoading, isError, error } = useDropdownSettings();
|
||||
const { data, isLoading, isError, error } = useQuery(
|
||||
api.dropdownSettings.list.queryOptions(),
|
||||
);
|
||||
const deleteMutation = useDeleteDropdownSetting();
|
||||
|
||||
const dropdownSettings = useMemo<DropdownSetting[]>(
|
||||
|
||||
251
apps/edr-freight-web/backoffice/src/services/api.ts
Normal file
251
apps/edr-freight-web/backoffice/src/services/api.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { endpoint } from "@/utils/endpoint";
|
||||
import type {
|
||||
CreateFileUploadFieldDto,
|
||||
CreateFileUploadSettingDto,
|
||||
FileUploadField,
|
||||
FileUploadSetting,
|
||||
UpdateFileUploadFieldDto,
|
||||
UpdateFileUploadSettingDto,
|
||||
} from "@/types/fileUploadSettings";
|
||||
import {
|
||||
CreateDropdownOptionDto,
|
||||
CreateDropdownSettingDto,
|
||||
DropdownOption,
|
||||
DropdownSetting,
|
||||
UpdateDropdownOptionDto,
|
||||
UpdateDropdownSettingDto,
|
||||
} from "@/types/dropdownSettings";
|
||||
import {
|
||||
ApproveRatePayload,
|
||||
RuleEngineListResult,
|
||||
RuleEngineRecord,
|
||||
RuleEngineResourceSlug,
|
||||
} from "@/types/rule-engine";
|
||||
import { fileUploadSettingsService } from "./fileUploadSettings.service";
|
||||
import { dropdownSettingsService } from "./dropdownSettings.service";
|
||||
import {
|
||||
ruleEngineService,
|
||||
RuleEngineListParams,
|
||||
} from "./ruleEngine/ruleEngine.service";
|
||||
import { bookingsService, BookingListFilter } from "./bookings.service";
|
||||
import type { Freight, PaginatedResponse } from "@edr/types";
|
||||
|
||||
export const api = {
|
||||
fileUploadSettings: {
|
||||
list: endpoint<void, FileUploadSetting[]>(
|
||||
"file-upload-settings",
|
||||
"list",
|
||||
fileUploadSettingsService.list,
|
||||
),
|
||||
|
||||
getById: endpoint<{ id: string }, FileUploadSetting>(
|
||||
"file-upload-settings",
|
||||
"getById",
|
||||
({ id }) => fileUploadSettingsService.getById(id),
|
||||
),
|
||||
|
||||
getByCode: endpoint<{ code: string }, FileUploadSetting>(
|
||||
"file-upload-settings",
|
||||
"getByCode",
|
||||
({ code }) => fileUploadSettingsService.getByCode(code),
|
||||
),
|
||||
|
||||
create: endpoint<CreateFileUploadSettingDto, FileUploadSetting>(
|
||||
"file-upload-settings",
|
||||
"create",
|
||||
(payload) => fileUploadSettingsService.create(payload),
|
||||
),
|
||||
|
||||
update: endpoint<
|
||||
{ id: string; dto: UpdateFileUploadSettingDto },
|
||||
FileUploadSetting
|
||||
>("file-upload-settings", "update", ({ id, dto }) =>
|
||||
fileUploadSettingsService.update(id, dto),
|
||||
),
|
||||
|
||||
remove: endpoint<{ id: string }, void>(
|
||||
"file-upload-settings",
|
||||
"remove",
|
||||
({ id }) => fileUploadSettingsService.remove(id),
|
||||
),
|
||||
|
||||
replaceFields: endpoint<
|
||||
{ id: string; fields: CreateFileUploadFieldDto[] },
|
||||
FileUploadField[]
|
||||
>("file-upload-settings", "replaceFields", ({ id, fields }) =>
|
||||
fileUploadSettingsService.replaceFields(id, fields),
|
||||
),
|
||||
|
||||
addField: endpoint<
|
||||
{ settingId: string; dto: CreateFileUploadFieldDto },
|
||||
FileUploadField
|
||||
>("file-upload-settings", "addField", ({ settingId, dto }) =>
|
||||
fileUploadSettingsService.addField(settingId, dto),
|
||||
),
|
||||
|
||||
updateField: endpoint<
|
||||
{ fieldId: string; dto: UpdateFileUploadFieldDto },
|
||||
FileUploadField
|
||||
>("file-upload-settings", "updateField", ({ fieldId, dto }) =>
|
||||
fileUploadSettingsService.updateField(fieldId, dto),
|
||||
),
|
||||
|
||||
removeField: endpoint<{ fieldId: string }, void>(
|
||||
"file-upload-settings",
|
||||
"removeField",
|
||||
({ fieldId }) => fileUploadSettingsService.removeField(fieldId),
|
||||
),
|
||||
},
|
||||
|
||||
dropdownSettings: {
|
||||
list: endpoint<void, DropdownSetting[]>(
|
||||
"dropdown-settings",
|
||||
"list",
|
||||
dropdownSettingsService.list,
|
||||
),
|
||||
|
||||
getById: endpoint<{ id: string }, DropdownSetting>(
|
||||
"dropdown-settings",
|
||||
"getById",
|
||||
({ id }) => dropdownSettingsService.getById(id),
|
||||
),
|
||||
|
||||
getByCode: endpoint<{ code: string }, DropdownSetting>(
|
||||
"dropdown-settings",
|
||||
"getByCode",
|
||||
({ code }) => dropdownSettingsService.getByCode(code),
|
||||
),
|
||||
|
||||
create: endpoint<CreateDropdownSettingDto, DropdownSetting>(
|
||||
"dropdown-settings",
|
||||
"create",
|
||||
(payload) => dropdownSettingsService.create(payload),
|
||||
),
|
||||
|
||||
update: endpoint<
|
||||
{ id: string; dto: UpdateDropdownSettingDto },
|
||||
DropdownSetting
|
||||
>("dropdown-settings", "update", ({ id, dto }) =>
|
||||
dropdownSettingsService.update(id, dto),
|
||||
),
|
||||
|
||||
remove: endpoint<{ id: string }, void>(
|
||||
"dropdown-settings",
|
||||
"remove",
|
||||
({ id }) => dropdownSettingsService.remove(id),
|
||||
),
|
||||
|
||||
replaceOptions: endpoint<
|
||||
{ id: string; options: CreateDropdownOptionDto[] },
|
||||
DropdownOption[]
|
||||
>("dropdown-settings", "replaceOptions", ({ id, options }) =>
|
||||
dropdownSettingsService.replaceOptions(id, options),
|
||||
),
|
||||
|
||||
addOption: endpoint<
|
||||
{ id: string; dto: CreateDropdownOptionDto },
|
||||
DropdownOption
|
||||
>("dropdown-settings", "addOption", ({ id, dto }) =>
|
||||
dropdownSettingsService.addOption(id, dto),
|
||||
),
|
||||
|
||||
updateOption: endpoint<
|
||||
{ optionId: string; dto: UpdateDropdownOptionDto },
|
||||
DropdownOption
|
||||
>("dropdown-settings", "updateOption", ({ optionId, dto }) =>
|
||||
dropdownSettingsService.updateOption(optionId, dto),
|
||||
),
|
||||
|
||||
removeOption: endpoint<{ optionId: string }, void>(
|
||||
"dropdown-settings",
|
||||
"removeOption",
|
||||
({ optionId }) => dropdownSettingsService.removeOption(optionId),
|
||||
),
|
||||
},
|
||||
|
||||
ruleEngine: {
|
||||
list: endpoint<
|
||||
{ resource: RuleEngineResourceSlug; params?: RuleEngineListParams },
|
||||
RuleEngineListResult<RuleEngineRecord>
|
||||
>("rule-engine", "list", ({ resource, params }) =>
|
||||
ruleEngineService.list(resource, params),
|
||||
),
|
||||
|
||||
getById: endpoint<
|
||||
{ resource: RuleEngineResourceSlug; id: string },
|
||||
RuleEngineRecord
|
||||
>("rule-engine", "getById", ({ resource, id }) =>
|
||||
ruleEngineService.getById(resource, id),
|
||||
),
|
||||
|
||||
create: endpoint<
|
||||
{ resource: RuleEngineResourceSlug; payload: Record<string, unknown> },
|
||||
RuleEngineRecord
|
||||
>("rule-engine", "create", ({ resource, payload }) =>
|
||||
ruleEngineService.create(resource, payload),
|
||||
),
|
||||
|
||||
update: endpoint<
|
||||
{
|
||||
resource: RuleEngineResourceSlug;
|
||||
id: string;
|
||||
payload: Record<string, unknown>;
|
||||
},
|
||||
RuleEngineRecord
|
||||
>("rule-engine", "update", ({ resource, id, payload }) =>
|
||||
ruleEngineService.update(resource, id, payload),
|
||||
),
|
||||
|
||||
remove: endpoint<
|
||||
{ resource: RuleEngineResourceSlug; id: string },
|
||||
void
|
||||
>("rule-engine", "remove", ({ resource, id }) =>
|
||||
ruleEngineService.remove(resource, id),
|
||||
),
|
||||
|
||||
submitRate: endpoint<{ id: string }, RuleEngineRecord>(
|
||||
"rule-engine",
|
||||
"submitRate",
|
||||
({ id }) => ruleEngineService.submitRate(id),
|
||||
),
|
||||
|
||||
approveRate: endpoint<
|
||||
{ id: string; payload: ApproveRatePayload },
|
||||
RuleEngineRecord
|
||||
>("rule-engine", "approveRate", ({ id, payload }) =>
|
||||
ruleEngineService.approveRate(id, payload),
|
||||
),
|
||||
|
||||
getApprovalChain: endpoint<void, RuleEngineRecord[]>(
|
||||
"rule-engine",
|
||||
"getApprovalChain",
|
||||
() => ruleEngineService.getApprovalChain(),
|
||||
),
|
||||
},
|
||||
|
||||
bookings: {
|
||||
list: endpoint<
|
||||
{ filter?: BookingListFilter },
|
||||
PaginatedResponse<Freight.IBooking>
|
||||
>("bookings", "list", ({ filter }) => bookingsService.list(filter)),
|
||||
|
||||
getById: endpoint<{ id: string }, Freight.IBooking>(
|
||||
"bookings",
|
||||
"getById",
|
||||
({ id }) => bookingsService.getById(id),
|
||||
),
|
||||
|
||||
updateStatus: endpoint<
|
||||
{ id: string; action: string; reason?: string },
|
||||
Freight.IBooking
|
||||
>("bookings", "updateStatus", ({ id, action, reason }) =>
|
||||
bookingsService.updateStatus(id, { action, reason }),
|
||||
),
|
||||
|
||||
remove: endpoint<{ id: string }, void>(
|
||||
"bookings",
|
||||
"remove",
|
||||
({ id }) => bookingsService.remove(id),
|
||||
),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Freight, PaginatedResponse } from "@edr/types";
|
||||
|
||||
import { api as client } from "../auth/http";
|
||||
import { unwrap } from "@/utils/endpoint";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
|
||||
const BASE = URL_CONSTANTS.BOOKINGS.BASE;
|
||||
|
||||
export interface BookingListFilter {
|
||||
status?: string;
|
||||
customerId?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
}
|
||||
|
||||
export const bookingsService = {
|
||||
list: async (
|
||||
filter?: BookingListFilter,
|
||||
): Promise<PaginatedResponse<Freight.IBooking>> => {
|
||||
const response = await client.get<PaginatedResponse<Freight.IBooking>>(
|
||||
BASE,
|
||||
{ params: filter },
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<Freight.IBooking> => {
|
||||
const response = await client.get<Freight.IBooking>(
|
||||
URL_CONSTANTS.BOOKINGS.BY_ID(id),
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
updateStatus: async (
|
||||
id: string,
|
||||
payload: { action: string; reason?: string },
|
||||
): Promise<Freight.IBooking> => {
|
||||
const response = await client.patch<Freight.IBooking>(
|
||||
`${URL_CONSTANTS.BOOKINGS.BY_ID(id)}/status`,
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
remove: async (id: string): Promise<void> => {
|
||||
await client.delete(URL_CONSTANTS.BOOKINGS.BY_ID(id));
|
||||
},
|
||||
};
|
||||
@@ -8,10 +8,8 @@ import type {
|
||||
UpdateFileUploadFieldDto,
|
||||
UpdateFileUploadSettingDto,
|
||||
} from "@/types/fileUploadSettings";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import { QUERY_KEYS } from "@/constants/TANSTACK_QUEY_KEY";
|
||||
import { ApiResponse } from "@/types/apiResponse";
|
||||
import { endpoint, unwrap } from "@/utils/endpoint";
|
||||
import { unwrap } from "@/utils/endpoint";
|
||||
|
||||
const BASE = "/file-upload-settings";
|
||||
|
||||
@@ -124,14 +122,3 @@ export const fileUploadSettingsService = {
|
||||
await client.delete(`${BASE}/fields/${fieldId}`);
|
||||
},
|
||||
};
|
||||
|
||||
export const getFileUploadSettingByCode = endpoint<string, FileUploadSetting>(
|
||||
QUERY_KEYS.FILES.FILE_UPLOAD_SETTINGS,
|
||||
QUERY_KEYS.FILES.BY_CODE,
|
||||
(code: any) =>
|
||||
client
|
||||
.get<
|
||||
ApiResponse<FileUploadSetting>
|
||||
>(`${URL_CONSTANTS.FILES.FILE_UPLOAD_SETTINGS_BY_CODE}/${code}`)
|
||||
.then((res: any) => res.data.data),
|
||||
);
|
||||
|
||||
29
apps/edr-freight-web/backoffice/src/utils/result.ts
Normal file
29
apps/edr-freight-web/backoffice/src/utils/result.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export type Result<T, E = { code: string; message: string; statusCode?: number }> =
|
||||
| { success: true; data: T }
|
||||
| { success: false; error: E };
|
||||
|
||||
export type ApiError = {
|
||||
code: string;
|
||||
message: string;
|
||||
statusCode?: number;
|
||||
};
|
||||
|
||||
export function extractApiError(err: unknown): ApiError {
|
||||
if (err && typeof err === "object") {
|
||||
const obj = err as Record<string, unknown>;
|
||||
const response = obj.response as Record<string, unknown> | undefined;
|
||||
if (response) {
|
||||
const statusCode = response.status as number | undefined;
|
||||
const data = response.data as Record<string, unknown> | undefined;
|
||||
return {
|
||||
code: (data?.error as string) || (data?.message as string) || "api_error",
|
||||
message: (data?.message as string) || (data?.error as string) || "An unexpected error occurred",
|
||||
statusCode,
|
||||
};
|
||||
}
|
||||
if (obj.message && typeof obj.message === "string") {
|
||||
return { code: "client_error", message: obj.message };
|
||||
}
|
||||
}
|
||||
return { code: "unknown_error", message: "An unexpected error occurred" };
|
||||
}
|
||||
@@ -32,8 +32,6 @@ import NewBookingPage from "./pages/bookings/NewBookingPage";
|
||||
import TrackingPage from "./pages/tracking/TrackingPage";
|
||||
import BillingPage from "./pages/billing/BillingPage";
|
||||
import { useEffect } from "react";
|
||||
import CustomerOnBoarding from "./pages/customers/on_boarding/TransportrOnBoarding";
|
||||
import CustomerOnboardingPage from "./pages/customers/on_boarding/CustomerOnboardingPage";
|
||||
|
||||
const sidebarItems: SidebarItem[] = [
|
||||
{ label: "Home", href: "/portal", icon: <Home /> },
|
||||
@@ -46,12 +44,13 @@ const sidebarItems: SidebarItem[] = [
|
||||
const App = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user, isPending, logout, customer } = useAuth();
|
||||
const { user, isPending, logout, customer, customerQuery } = useAuth();
|
||||
useEffect(() => {
|
||||
if (isPending) return;
|
||||
const isInProtectedRoutes = sidebarItems.find((item) =>
|
||||
location.pathname.startsWith(item.href),
|
||||
);
|
||||
console.log({ isInProtectedRoutes, location });
|
||||
if (!user) {
|
||||
if (isInProtectedRoutes) return navigate("/login");
|
||||
return;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Calendar,
|
||||
MapPin,
|
||||
@@ -22,10 +23,12 @@ import {
|
||||
CreditCard,
|
||||
FileSignature,
|
||||
PackageCheck,
|
||||
LoaderCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||
import { getBookingById } from "./bookings.mock";
|
||||
import { api } from "@/services/api";
|
||||
import type { Freight } from "@edr/types";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
@@ -37,45 +40,67 @@ import {
|
||||
} from "@edr/ui-common";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Grouping the 15 granular statuses into 6 logical progress stages for the UI tracker
|
||||
const PROGRESS_STAGES = [
|
||||
{ label: "Request", icon: FileText, statuses: ["DRAFT", "RFQ_SUBMITTED"] },
|
||||
{ label: "Quotation", icon: ClipboardCheck, statuses: ["QUOTATION_SENT", "QUOTATION_APPROVED", "QUOTATION_REJECTED"] },
|
||||
{ label: "Approval", icon: ShieldCheck, statuses: ["PENDING_APPROVAL", "APPROVED"] },
|
||||
{ label: "Execution", icon: FileSignature, statuses: ["SIGNED_CUSTOMER", "FULLY_EXECUTED", "PAID"] },
|
||||
{ label: "In Transit", icon: Train, statuses: ["IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"] },
|
||||
{ label: "Complete", icon: PackageCheck, statuses: ["COMPLETED"] },
|
||||
{ label: "Request", icon: FileText, statuses: ["DRAFT"] },
|
||||
{ label: "Approval", icon: ClipboardCheck, statuses: ["CONFIRMED"] },
|
||||
{ label: "In Transit", icon: Train, statuses: ["IN_TRANSIT"] },
|
||||
{ label: "Complete", icon: PackageCheck, statuses: ["DELIVERED"] },
|
||||
];
|
||||
|
||||
const STATUS_MAP: Record<string, { title: string; description: string; color: string; stage: number }> = {
|
||||
DRAFT: { title: "Drafting Request", description: "Booking is being prepared and has not been submitted.", color: "text-slate-500", stage: 0 },
|
||||
RFQ_SUBMITTED: { title: "RFQ Submitted", description: "Request for Quotation has been sent to the operations team.", color: "text-amber-600", stage: 0 },
|
||||
QUOTATION_SENT: { title: "Quotation Received", description: "EDR has sent a formal quotation for your review.", color: "text-sky-600", stage: 1 },
|
||||
QUOTATION_APPROVED: { title: "Quotation Approved", description: "You have accepted the quotation terms.", color: "text-emerald-600", stage: 1 },
|
||||
QUOTATION_REJECTED: { title: "Quotation Rejected", description: "The quotation was not accepted.", color: "text-red-600", stage: 1 },
|
||||
PENDING_APPROVAL: { title: "Internal Approval", description: "Booking is undergoing final administrative review.", color: "text-amber-600", stage: 2 },
|
||||
APPROVED: { title: "Booking Approved", description: "Request is fully approved and ready for execution.", color: "text-emerald-600", stage: 2 },
|
||||
SIGNED_CUSTOMER: { title: "Customer Signed", description: "Contract has been signed by the customer.", color: "text-sky-600", stage: 3 },
|
||||
FULLY_EXECUTED: { title: "Contract Executed", description: "All parties have signed. Operational setup in progress.", color: "text-indigo-600", stage: 3 },
|
||||
PAID: { title: "Payment Received", description: "Initial payments confirmed. Cargo ready for dispatch.", color: "text-emerald-600", stage: 3 },
|
||||
IN_TRANSIT: { title: "Cargo Moving", description: "Shipment is currently moving through the rail network.", color: "text-sky-600", stage: 4 },
|
||||
PENDING_CONSOLIDATION: { title: "Consolidation Node", description: "Cargo is waiting to be consolidated with other shipments.", color: "text-amber-500", stage: 4 },
|
||||
CONSOLIDATED: { title: "Load Consolidated", description: "Cargo has been successfully merged into a larger shipment.", color: "text-indigo-500", stage: 4 },
|
||||
COMPLETED: { title: "Service Complete", description: "Cargo delivered and service successfully terminated.", color: "text-emerald-600", stage: 5 },
|
||||
CONFIRMED: { title: "Booking Confirmed", description: "Booking has been confirmed and approved.", color: "text-emerald-600", stage: 1 },
|
||||
IN_TRANSIT: { title: "Cargo Moving", description: "Shipment is currently moving through the rail network.", color: "text-sky-600", stage: 2 },
|
||||
DELIVERED: { title: "Service Complete", description: "Cargo delivered and service successfully terminated.", color: "text-emerald-600", stage: 3 },
|
||||
CANCELLED: { title: "Cancelled", description: "This booking process has been terminated.", color: "text-red-600", stage: -1 },
|
||||
};
|
||||
|
||||
export default function BookingDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const booking = id ? getBookingById(id) : undefined;
|
||||
|
||||
const { data: booking, isLoading, isError, error } = useQuery(
|
||||
api.bookings.get.queryOptions({
|
||||
input: { id: id! },
|
||||
enabled: !!id,
|
||||
}),
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container mx-auto flex items-center justify-center p-12">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<LoaderCircle className="size-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">Loading booking details…</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="container mx-auto 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-red-50 text-red-400">
|
||||
<AlertTriangle className="size-8" />
|
||||
</div>
|
||||
<h1 className="mt-4 text-2xl font-bold text-slate-900">
|
||||
Failed to load booking
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{error instanceof Error ? error.message : "An unexpected error occurred."}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!booking) {
|
||||
return (
|
||||
<div className="container mx-auto 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" />
|
||||
<Package className="size-8" />
|
||||
</div>
|
||||
<h1 className="mt-4 text-2xl font-bold text-slate-900">
|
||||
Booking not found
|
||||
@@ -85,16 +110,17 @@ export default function BookingDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Normalize status to upper case for mapping
|
||||
const normalizedStatus = (booking.status === "In Transit" ? "IN_TRANSIT" : booking.status === "Pending" ? "RFQ_SUBMITTED" : booking.status.toUpperCase()) as keyof typeof STATUS_MAP;
|
||||
const normalizedStatus = booking.status as keyof typeof STATUS_MAP;
|
||||
const statusConfig = STATUS_MAP[normalizedStatus] || STATUS_MAP.DRAFT;
|
||||
const currentStageIndex = statusConfig.stage;
|
||||
|
||||
const containerCount = booking.containers?.reduce((sum, c) => sum + c.qty, 0) ?? 0;
|
||||
const containerType = booking.containers?.[0]?.type ?? null;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-7xl px-4 py-8">
|
||||
<div className="flex flex-col gap-8">
|
||||
|
||||
{/* Breadcrumbs Restored */}
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: "Bookings", href: "/bookings" },
|
||||
@@ -102,7 +128,6 @@ export default function BookingDetailPage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Compact Header Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-6">
|
||||
@@ -117,11 +142,9 @@ export default function BookingDetailPage() {
|
||||
<StatusBadge status={normalizedStatus} />
|
||||
</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" />
|
||||
{booking.requestedDate}
|
||||
{booking.scheduledDate ?? booking.createdAt}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -129,7 +152,6 @@ export default function BookingDetailPage() {
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{/* Granular Status Lifecycle */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
@@ -140,7 +162,6 @@ export default function BookingDetailPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-8">
|
||||
<div className="relative flex w-full justify-between px-2">
|
||||
{/* Progress Line */}
|
||||
<div className="absolute top-4 left-0 h-0.5 w-full bg-muted">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-500"
|
||||
@@ -185,7 +206,7 @@ export default function BookingDetailPage() {
|
||||
{statusConfig.description}
|
||||
</p>
|
||||
</div>
|
||||
{normalizedStatus !== "CANCELLED" && normalizedStatus !== "COMPLETED" && (
|
||||
{normalizedStatus !== "CANCELLED" && normalizedStatus !== "DELIVERED" && (
|
||||
<div className="ml-auto flex items-center gap-4 border-l border-border pl-6">
|
||||
<div className="flex flex-col">
|
||||
<p className="text-[9px] font-bold uppercase text-muted-foreground">Est. Waiting</p>
|
||||
@@ -200,7 +221,6 @@ export default function BookingDetailPage() {
|
||||
|
||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
|
||||
<div className="flex flex-col gap-8 lg:col-span-2">
|
||||
{/* Route & Core Service Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
@@ -232,14 +252,13 @@ export default function BookingDetailPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<InfoItem icon={<Layers />} label="Service" value="Rail & Forwarding" />
|
||||
<InfoItem icon={<ShieldCheck />} label="Return" value="With Return" />
|
||||
<InfoItem icon={<FileText />} label="Customs" value="Enabled" />
|
||||
<InfoItem icon={<Layers />} label="Service" value={booking.serviceType === "RAIL_AND_FORWARDING" ? "Rail & Forwarding" : "Rail Only"} />
|
||||
<InfoItem icon={<ShieldCheck />} label="Return" value={booking.equipmentReturn === "WITH_RETURN" ? "With Return" : "Without Return"} />
|
||||
<InfoItem icon={<FileText />} label="Trade" value={booking.tradeDirection === "IMPORT" ? "Import" : "Export"} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Mile Services Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
@@ -252,18 +271,19 @@ export default function BookingDetailPage() {
|
||||
<h3 className="border-l-4 border-primary pl-3 text-xs font-bold uppercase tracking-wide text-foreground">
|
||||
First Mile
|
||||
</h3>
|
||||
<InfoItem label="Address" value="Inside Addis Ababa Yard, Gate 2" />
|
||||
<InfoItem label="Address" value={booking.firstMileEnabled && booking.firstMilePickupAddress ? booking.firstMilePickupAddress : "Not requested"} />
|
||||
</div>
|
||||
<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>
|
||||
<p className="pl-4 text-xs text-muted-foreground italic">Not requested</p>
|
||||
<p className="pl-4 text-xs text-muted-foreground italic">
|
||||
{booking.lastMileEnabled && booking.lastMileDeliveryAddress ? booking.lastMileDeliveryAddress : "Not requested"}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Cargo Specifications Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
@@ -273,40 +293,44 @@ export default function BookingDetailPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-6">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<InfoItem icon={<Package />} label="Category" value={booking.cargoType} />
|
||||
<InfoItem icon={<Weight />} label="Weight" value={`${booking.weightTons} Tons`} />
|
||||
<InfoItem icon={<Ship />} label="Shipping Line" value="MSC" />
|
||||
<InfoItem icon={<Package />} label="Freight Type" value={booking.freightType === "BULK" ? "Bulk" : "Break Bulk"} />
|
||||
<InfoItem icon={<Weight />} label="Weight (VGM)" value={`${booking.cargoTotalWeightVgm} Tons`} />
|
||||
<InfoItem icon={<Ship />} label="Currency" value={booking.paymentCurrency} />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wide text-foreground">Load Details</h3>
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-muted text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-semibold">Description</th>
|
||||
<th className="px-3 py-2 font-semibold text-center">Unit</th>
|
||||
<th className="px-3 py-2 font-semibold text-right">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
<tr>
|
||||
<td className="px-3 py-2 font-medium">Main Equipment</td>
|
||||
<td className="px-3 py-2 text-center">20FT Container</td>
|
||||
<td className="px-3 py-2 text-right">4 Units</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{booking.containers && booking.containers.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wide text-foreground">Load Details</h3>
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-muted text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-semibold">Type</th>
|
||||
<th className="px-3 py-2 font-semibold text-center">Quantity</th>
|
||||
<th className="px-3 py-2 font-semibold text-right">VGM (Tons)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{booking.containers.map((c, i) => (
|
||||
<tr key={i}>
|
||||
<td className="px-3 py-2 font-medium">{c.type}</td>
|
||||
<td className="px-3 py-2 text-center">{c.qty} Units</td>
|
||||
<td className="px-3 py-2 text-right">{c.vgm}t</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Contract Card */}
|
||||
<Card className="border-primary/20 bg-primary/[0.02]">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
@@ -315,40 +339,48 @@ export default function BookingDetailPage() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<InfoItem label="Type" value="Renewal" />
|
||||
<InfoItem label="Ref" value="EDR-2024-88123" />
|
||||
<InfoItem label="Type" value={booking.contractType === "RENEWAL" ? "Renewal" : "New"} />
|
||||
<InfoItem label="Customer ID" value={booking.customerId} />
|
||||
<Separator />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline" className="bg-background text-[9px]">
|
||||
Hazardous: No
|
||||
Hazardous: {booking.isHazardous ? "Yes" : "No"}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="bg-background text-[9px]">
|
||||
Refrigerated: No
|
||||
Refrigerated: {booking.isRefrigerated ? "Yes" : "No"}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notes Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Additional Info</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-[9px] font-bold uppercase tracking-tight text-muted-foreground">Description</p>
|
||||
<p className="text-xs text-foreground leading-relaxed italic">"{booking.cargoDescription}"</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-[9px] font-bold uppercase tracking-tight text-muted-foreground">Instructions</p>
|
||||
<div className="rounded-lg bg-amber-50/50 border border-amber-100 p-2">
|
||||
<p className="text-xs text-amber-900 flex gap-2">
|
||||
<StickyNote className="size-3 shrink-0 mt-0.5 text-amber-500" />
|
||||
{booking.specialInstructions}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{booking.freightSubtype && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-[9px] font-bold uppercase tracking-tight text-muted-foreground">Cargo Description</p>
|
||||
<p className="text-xs text-foreground leading-relaxed italic">"{booking.freightSubtype}"</p>
|
||||
</div>
|
||||
)}
|
||||
{booking.financialTerms && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-[9px] font-bold uppercase tracking-tight text-muted-foreground">Financial Terms</p>
|
||||
<div className="rounded-lg bg-amber-50/50 border border-amber-100 p-2">
|
||||
<p className="text-xs text-amber-900 flex gap-2">
|
||||
<StickyNote className="size-3 shrink-0 mt-0.5 text-amber-500" />
|
||||
{booking.financialTerms}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!booking.freightSubtype && !booking.financialTerms && (
|
||||
<p className="text-xs text-muted-foreground italic">No additional information provided.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -396,7 +428,7 @@ function InfoItem({
|
||||
{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>
|
||||
<p className="text-xs font-bold text-foreground">{value ?? "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -405,20 +437,10 @@ function InfoItem({
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const statusColors: Record<string, string> = {
|
||||
DRAFT: "bg-slate-50 text-slate-700 border-slate-200",
|
||||
RFQ_SUBMITTED: "bg-amber-50 text-amber-700 border-amber-200",
|
||||
QUOTATION_SENT: "bg-sky-50 text-sky-700 border-sky-200",
|
||||
QUOTATION_APPROVED: "bg-emerald-50 text-emerald-700 border-emerald-200",
|
||||
QUOTATION_REJECTED: "bg-red-50 text-red-700 border-red-200",
|
||||
PENDING_APPROVAL: "bg-amber-50 text-amber-700 border-amber-200",
|
||||
APPROVED: "bg-emerald-50 text-emerald-700 border-emerald-200",
|
||||
SIGNED_CUSTOMER: "bg-sky-50 text-sky-700 border-sky-200",
|
||||
FULLY_EXECUTED: "bg-indigo-50 text-indigo-700 border-indigo-200",
|
||||
PAID: "bg-emerald-50 text-emerald-700 border-emerald-200",
|
||||
CONFIRMED: "bg-emerald-50 text-emerald-700 border-emerald-200",
|
||||
IN_TRANSIT: "bg-sky-50 text-sky-700 border-sky-200",
|
||||
COMPLETED: "bg-indigo-50 text-indigo-700 border-indigo-200",
|
||||
DELIVERED: "bg-indigo-50 text-indigo-700 border-indigo-200",
|
||||
CANCELLED: "bg-red-50 text-red-700 border-red-200",
|
||||
PENDING_CONSOLIDATION: "bg-amber-50 text-amber-700 border-amber-200",
|
||||
CONSOLIDATED: "bg-indigo-50 text-indigo-700 border-indigo-200",
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowRight,
|
||||
Clock,
|
||||
@@ -9,13 +10,11 @@ import {
|
||||
Package,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
Truck,
|
||||
} from "lucide-react";
|
||||
|
||||
import DeleteBookingDialog from "./DeleteBookingDialog";
|
||||
import { getMyBookings } from "@/lib/currentCustomer";
|
||||
import { deleteBooking, type Booking, type BookingStatus } from "./bookings.mock";
|
||||
import { api } from "@/services/api";
|
||||
import type { Freight } from "@edr/types";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
@@ -32,32 +31,30 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
export default function MyBookings() {
|
||||
const navigate = useNavigate();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [myBookings, setMyBookings] = useState(() => getMyBookings());
|
||||
|
||||
const handleDeleteConfirm = (id: number) => {
|
||||
deleteBooking(id);
|
||||
setMyBookings(getMyBookings());
|
||||
};
|
||||
const { data, isLoading, isError } = useQuery(
|
||||
api.bookings.list.queryOptions(),
|
||||
);
|
||||
|
||||
const bookings = data?.items ?? [];
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
return myBookings.filter((b) => {
|
||||
return bookings.filter((b) => {
|
||||
const term = searchTerm.toLowerCase();
|
||||
return (
|
||||
b.reference.toLowerCase().includes(term) ||
|
||||
b.originStation.toLowerCase().includes(term) ||
|
||||
b.destinationStation.toLowerCase().includes(term) ||
|
||||
b.cargoDescription.toLowerCase().includes(term) ||
|
||||
b.status.toLowerCase().includes(term)
|
||||
);
|
||||
});
|
||||
}, [myBookings, searchTerm]);
|
||||
}, [bookings, searchTerm]);
|
||||
|
||||
const total = filteredData.length;
|
||||
const pageCount = Math.ceil(total / pagination.pageSize);
|
||||
@@ -67,16 +64,16 @@ export default function MyBookings() {
|
||||
const paginatedData = useMemo(() => filteredData.slice(start, end), [filteredData, start, end]);
|
||||
|
||||
const activeCount = useMemo(() => {
|
||||
return myBookings.filter(
|
||||
(b) => b.status === "Confirmed" || b.status === "In Transit",
|
||||
return bookings.filter(
|
||||
(b) => b.status === "CONFIRMED" || b.status === "IN_TRANSIT",
|
||||
).length;
|
||||
}, [myBookings]);
|
||||
}, [bookings]);
|
||||
|
||||
const pendingCount = useMemo(() => {
|
||||
return myBookings.filter((b) => b.status === "Pending").length;
|
||||
}, [myBookings]);
|
||||
return bookings.filter((b) => b.status === "DRAFT").length;
|
||||
}, [bookings]);
|
||||
|
||||
const columns: ColumnDef<Booking>[] = [
|
||||
const columns: ColumnDef<Freight.IBooking>[] = [
|
||||
{
|
||||
accessorKey: "reference",
|
||||
header: "Reference",
|
||||
@@ -89,7 +86,7 @@ export default function MyBookings() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{booking.reference}</p>
|
||||
<p className="text-sm text-slate-500">{booking.requestedDate}</p>
|
||||
<p className="text-sm text-slate-500">{booking.scheduledDate ?? booking.createdAt}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -111,22 +108,24 @@ export default function MyBookings() {
|
||||
header: "Cargo",
|
||||
cell: ({ row }) => {
|
||||
const b = row.original;
|
||||
const containerCount = b.containers?.reduce((sum, c) => sum + c.qty, 0) ?? 0;
|
||||
const containerType = b.containers?.[0]?.type ?? null;
|
||||
return (
|
||||
<div className="text-sm text-slate-700">
|
||||
<p>{b.cargoType}</p>
|
||||
<p>{b.freightType === "BULK" ? "Bulk" : "Break Bulk"}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{b.containerCount > 0 ? `${b.containerCount} × ${b.containerType} · ` : ""}{b.weightTons}t
|
||||
{containerType && containerCount > 0 ? `${containerCount} × ${containerType} · ` : ""}{b.cargoTotalWeightVgm}t
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "transportMode",
|
||||
id: "transportMode",
|
||||
header: "Transport",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-slate-700">
|
||||
{row.original.transportMode}
|
||||
{row.original.serviceType === "RAIL_AND_FORWARDING" ? "Rail & Forwarding" : "Rail"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
@@ -158,19 +157,6 @@ export default function MyBookings() {
|
||||
<Eye />
|
||||
View
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DeleteBookingDialog
|
||||
bookingReference={booking.reference}
|
||||
onConfirm={() => handleDeleteConfirm(booking.id)}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e: Event) => e.preventDefault()}
|
||||
variant="destructive"
|
||||
>
|
||||
<Trash2 />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DeleteBookingDialog>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
@@ -179,10 +165,11 @@ export default function MyBookings() {
|
||||
},
|
||||
];
|
||||
|
||||
const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-6">
|
||||
<div className="space-y-6">
|
||||
{/* Header Section Card */}
|
||||
<Card className="p-6 flex-row justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
|
||||
@@ -214,14 +201,13 @@ export default function MyBookings() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Stat Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Total Bookings</p>
|
||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
||||
{myBookings.length}
|
||||
{bookings.length}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
@@ -259,7 +245,6 @@ export default function MyBookings() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Data Table */}
|
||||
<Card className="gap-0">
|
||||
<CardHeader className="flex flex-row items-center justify-between border-b">
|
||||
<div>
|
||||
@@ -276,7 +261,7 @@ export default function MyBookings() {
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="px-0">
|
||||
{total === 0 ? (
|
||||
{total === 0 && dataTableStatus === "success" ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 px-6 text-center">
|
||||
<Package className="h-12 w-12 text-slate-300 mb-4" />
|
||||
<h3 className="text-sm font-semibold text-slate-900">No bookings found</h3>
|
||||
@@ -288,8 +273,8 @@ export default function MyBookings() {
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={paginatedData}
|
||||
status="success"
|
||||
onRowClick={(row) => navigate(`/bookings/${(row as Booking).id}`)}
|
||||
status={dataTableStatus}
|
||||
onRowClick={(row) => navigate(`/bookings/${(row as Freight.IBooking).id}`)}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
@@ -311,20 +296,20 @@ export default function MyBookings() {
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: BookingStatus }) {
|
||||
const styles: Record<BookingStatus, string> = {
|
||||
Pending: "bg-amber-100 text-amber-700",
|
||||
Confirmed: "bg-sky-100 text-sky-700",
|
||||
"In Transit": "bg-indigo-100 text-indigo-700",
|
||||
Delivered: "bg-emerald-100 text-emerald-700",
|
||||
Cancelled: "bg-red-100 text-red-700",
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const styles: Record<string, string> = {
|
||||
DRAFT: "bg-amber-100 text-amber-700",
|
||||
CONFIRMED: "bg-sky-100 text-sky-700",
|
||||
IN_TRANSIT: "bg-indigo-100 text-indigo-700",
|
||||
DELIVERED: "bg-emerald-100 text-emerald-700",
|
||||
CANCELLED: "bg-red-100 text-red-700",
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
|
||||
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status] ?? "bg-slate-100 text-slate-700"}`}
|
||||
>
|
||||
{status}
|
||||
{status.replace(/_/g, ' ')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ export function Step5CargoDetails({
|
||||
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Package className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<p className="font-semibold">Container</p>
|
||||
<p className="font-semibold">Containerized</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Pre-packed containerized cargo (20ft / 40ft).
|
||||
</p>
|
||||
@@ -145,7 +145,7 @@ export function Step5CargoDetails({
|
||||
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-amber-100">
|
||||
<Weight className="h-4 w-4 text-amber-600" />
|
||||
</div>
|
||||
<p className="font-semibold">Bulk</p>
|
||||
<p className="font-semibold">General Cargo</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Bulk commodities or break-bulk cargo.
|
||||
</p>
|
||||
|
||||
@@ -6,22 +6,22 @@ export type CreateBookingPayload = Freight.CreateBookingDto;
|
||||
|
||||
export const bookingsService = {
|
||||
list: async (): Promise<PaginatedResponse<Freight.IBooking>> => {
|
||||
const { data } = await client.get("/bookings");
|
||||
const { data } = await client.get("/api/bookings");
|
||||
return data.data;
|
||||
},
|
||||
get: async (id: string): Promise<Freight.IBooking> => {
|
||||
const { data } = await client.get(`/bookings/${id}`);
|
||||
const { data } = await client.get(`/api/bookings/${id}`);
|
||||
return data.data;
|
||||
},
|
||||
create: async (payload: CreateBookingPayload): Promise<Freight.IBooking> => {
|
||||
const { data } = await client.post("/api/bookings", payload);
|
||||
return data.data;
|
||||
return data.data.booking;
|
||||
},
|
||||
getReferenceData: async (): Promise<Freight.BookingReferenceData> => {
|
||||
const { data } = await client.get("/api/bookings/reference-data");
|
||||
return data.data;
|
||||
},
|
||||
remove: async (id: string): Promise<void> => {
|
||||
await client.delete(`/bookings/${id}`);
|
||||
await client.delete(`/api/bookings/${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user