mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #110 from Tria-plc/freight_feature/change_to_mantine
Freight feature/change to mantine
This commit is contained in:
@@ -18,6 +18,7 @@ async function bootstrap() {
|
|||||||
// freight portal (5173), passenger portal (5174), backoffices (5183/5184)
|
// freight portal (5173), passenger portal (5174), backoffices (5183/5184)
|
||||||
// and any other dev port can call the API with cookies + Authorization.
|
// and any other dev port can call the API with cookies + Authorization.
|
||||||
// For production, restrict `origin` to known FQDNs.
|
// For production, restrict `origin` to known FQDNs.
|
||||||
|
|
||||||
app.enableCors({
|
app.enableCors({
|
||||||
origin: true, // reflect request origin
|
origin: true, // reflect request origin
|
||||||
credentials: true,
|
credentials: true,
|
||||||
@@ -52,9 +53,12 @@ async function bootstrap() {
|
|||||||
SwaggerModule.setup("api/docs", app, document);
|
SwaggerModule.setup("api/docs", app, document);
|
||||||
|
|
||||||
const port = parseInt(process.env.PORT ?? "3001", 10);
|
const port = parseInt(process.env.PORT ?? "3001", 10);
|
||||||
await app.listen(port);
|
// await app.listen(port, "0.0.0.0");
|
||||||
|
await app.listen(
|
||||||
|
|
||||||
|
port)
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log(`[freight-api] listening on http://localhost:${port}`);
|
console.log(`[freight-api] listening on port ${port}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
@@ -25,10 +25,10 @@ export const BOOKING_LIST_TABS: ReadonlyArray<{
|
|||||||
key: 'approved_contract',
|
key: 'approved_contract',
|
||||||
statuses: ['APPROVED', 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED'],
|
statuses: ['APPROVED', 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED'],
|
||||||
},
|
},
|
||||||
{ key: 'payment', statuses: ['FULLY_EXECUTED', 'PAID'] },
|
{ key: 'payment', statuses: ['FULLY_EXECUTED'] },
|
||||||
{
|
{
|
||||||
key: 'operations',
|
key: 'operations',
|
||||||
statuses: ['IN_TRANSIT', 'PENDING_CONSOLIDATION', 'CONSOLIDATED'],
|
statuses: ['IN_TRANSIT', 'PENDING_CONSOLIDATION', 'CONSOLIDATED','PAID'],
|
||||||
},
|
},
|
||||||
{ key: 'completed', statuses: ['COMPLETED'] },
|
{ key: 'completed', statuses: ['COMPLETED'] },
|
||||||
{ key: 'closed', statuses: ['REJECTED', 'CANCELLED'] },
|
{ key: 'closed', statuses: ['REJECTED', 'CANCELLED'] },
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ export function computeNextStep(
|
|||||||
};
|
};
|
||||||
case 'APPROVED':
|
case 'APPROVED':
|
||||||
return {
|
return {
|
||||||
action: 'GENERATE_CONTRACT',
|
action: 'CUSTOMER_SIGN',
|
||||||
description: 'Generate the contract document',
|
description: 'Contract generated; customer must sign',
|
||||||
};
|
};
|
||||||
case 'CONTRACT_READY':
|
case 'CONTRACT_READY':
|
||||||
return {
|
return {
|
||||||
@@ -49,8 +49,8 @@ export function computeNextStep(
|
|||||||
};
|
};
|
||||||
case 'FULLY_EXECUTED':
|
case 'FULLY_EXECUTED':
|
||||||
return {
|
return {
|
||||||
action: 'PAY',
|
action: 'AWAIT_PAYMENT',
|
||||||
description: 'Complete in-app payment',
|
description: 'Awaiting customer payment',
|
||||||
};
|
};
|
||||||
case 'PAID':
|
case 'PAID':
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -185,6 +185,11 @@ export class BookingTransitionService {
|
|||||||
await this.bookingsRepository.update(bookingId, updates as never);
|
await this.bookingsRepository.update(bookingId, updates as never);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (allDone) {
|
||||||
|
const generated = await this.contractService.generateContract(bookingId);
|
||||||
|
return this.bookingsService.findById(generated.id);
|
||||||
|
}
|
||||||
|
|
||||||
return this.bookingsService.findById(bookingId);
|
return this.bookingsService.findById(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsInt,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
const parseLoadTypes = (value: unknown): string[] => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((item) => String(item).trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
export class CreateWagonTypeDto {
|
||||||
|
@ApiProperty({ description: 'Display name, e.g. "Flat Wagon"', maxLength: 100 })
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Maximum payload capacity in metric tons' })
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.001)
|
||||||
|
@Transform(({ value }) => Number(value))
|
||||||
|
capacityTons!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Wagon length in meters' })
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.001)
|
||||||
|
@Transform(({ value }) => Number(value))
|
||||||
|
lengthMeters!: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Maximum wagons of this type per train' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Transform(({ value }) => (value === '' || value === null || value === undefined ? undefined : Number(value)))
|
||||||
|
maxWagonsPerTrain?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Supported load types, e.g. CONTAINER,BULK',
|
||||||
|
type: [String],
|
||||||
|
default: [],
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
@Transform(({ value }) => parseLoadTypes(value))
|
||||||
|
supportedLoadTypes?: string[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
|
|
||||||
|
import { CreateWagonTypeDto } from './create-wagon-type.dto';
|
||||||
|
|
||||||
|
export class UpdateWagonTypeDto extends PartialType(CreateWagonTypeDto) {}
|
||||||
@@ -1,16 +1,67 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import {
|
||||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
Body,
|
||||||
import { WagonTypesService } from './wagon-types.service';
|
Controller,
|
||||||
import { WagonType } from './entities/wagon-type.entity';
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
@ApiTags('Wagon Types')
|
import { RuleEngineManage, RuleEngineView } from '../../common/rule-engine-guards';
|
||||||
|
|
||||||
|
import { CreateWagonTypeDto } from './dto/create-wagon-type.dto';
|
||||||
|
import { UpdateWagonTypeDto } from './dto/update-wagon-type.dto';
|
||||||
|
import { WagonTypesService } from './wagon-types.service';
|
||||||
|
|
||||||
|
@ApiTags('wagon-types')
|
||||||
@Controller('wagon-types')
|
@Controller('wagon-types')
|
||||||
|
@ApiBearerAuth()
|
||||||
export class WagonTypesController {
|
export class WagonTypesController {
|
||||||
constructor(private readonly wagonTypesService: WagonTypesService) {}
|
constructor(private readonly wagonTypesService: WagonTypesService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'Get all active wagon types' })
|
@RuleEngineView('wagon-types')
|
||||||
async findAll(): Promise<WagonType[]> {
|
@ApiOperation({ summary: 'List wagon types' })
|
||||||
return this.wagonTypesService.findAll();
|
findAll(@Query() query: Record<string, string>) {
|
||||||
|
return this.wagonTypesService.findAll({
|
||||||
|
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
|
||||||
|
page: query['page'] ? parseInt(query['page'], 10) : undefined,
|
||||||
|
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@Get(':id')
|
||||||
|
@RuleEngineView('wagon-types')
|
||||||
|
@ApiOperation({ summary: 'Get a wagon type by ID' })
|
||||||
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.wagonTypesService.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RuleEngineManage('wagon-types')
|
||||||
|
@ApiOperation({ summary: 'Create a wagon type' })
|
||||||
|
create(@Body() dto: CreateWagonTypeDto) {
|
||||||
|
return this.wagonTypesService.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@RuleEngineManage('wagon-types')
|
||||||
|
@ApiOperation({ summary: 'Update a wagon type' })
|
||||||
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonTypeDto) {
|
||||||
|
return this.wagonTypesService.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@RuleEngineManage('wagon-types')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@ApiOperation({ summary: 'Soft-delete a wagon type' })
|
||||||
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.wagonTypesService.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,4 +13,8 @@ export class WagonTypesRepository extends BaseRepository<WagonType> {
|
|||||||
) {
|
) {
|
||||||
super(repository);
|
super(repository);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
findByCode(code: string): Promise<WagonType | null> {
|
||||||
|
return this.repository.findOne({ where: { code } });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import {
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
|
||||||
|
import { generateCode } from '../../common/utils/generate-code.util';
|
||||||
|
|
||||||
|
import { CreateWagonTypeDto } from './dto/create-wagon-type.dto';
|
||||||
|
import { UpdateWagonTypeDto } from './dto/update-wagon-type.dto';
|
||||||
import { WagonType } from './entities/wagon-type.entity';
|
import { WagonType } from './entities/wagon-type.entity';
|
||||||
import { WagonTypesRepository } from './wagon-types.repository';
|
import { WagonTypesRepository } from './wagon-types.repository';
|
||||||
|
|
||||||
@@ -7,20 +15,86 @@ import { WagonTypesRepository } from './wagon-types.repository';
|
|||||||
export class WagonTypesService {
|
export class WagonTypesService {
|
||||||
constructor(private readonly wagonTypesRepository: WagonTypesRepository) {}
|
constructor(private readonly wagonTypesRepository: WagonTypesRepository) {}
|
||||||
|
|
||||||
async findAll(): Promise<WagonType[]> {
|
async findAll(filter: {
|
||||||
return this.wagonTypesRepository.findAll({
|
isActive?: boolean;
|
||||||
where: { isActive: true },
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
} = {}): Promise<{
|
||||||
|
data: WagonType[];
|
||||||
|
meta: { total: number; page: number; pageSize: number; totalPages: number };
|
||||||
|
}> {
|
||||||
|
const page = filter.page ?? 1;
|
||||||
|
const pageSize = filter.pageSize ?? 20;
|
||||||
|
const where: Record<string, unknown> = {};
|
||||||
|
if (filter.isActive !== undefined) {
|
||||||
|
where.isActive = filter.isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [data, total] = await this.wagonTypesRepository.findAndCount({
|
||||||
|
where,
|
||||||
order: { code: 'ASC' },
|
order: { code: 'ASC' },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
meta: {
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<WagonType> {
|
||||||
|
const wagonType = await this.wagonTypesRepository.findById(id);
|
||||||
|
if (!wagonType) {
|
||||||
|
throw new NotFoundException(`Wagon type ${id} not found`);
|
||||||
|
}
|
||||||
|
return wagonType;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByCode(code: string): Promise<WagonType> {
|
async findByCode(code: string): Promise<WagonType> {
|
||||||
const [wagonType] = await this.wagonTypesRepository.findAll({ where: { code } });
|
const wagonType = await this.wagonTypesRepository.findByCode(code);
|
||||||
|
|
||||||
if (!wagonType) {
|
if (!wagonType) {
|
||||||
throw new NotFoundException(`Wagon type ${code} not found`);
|
throw new NotFoundException(`Wagon type ${code} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return wagonType;
|
return wagonType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateWagonTypeDto): Promise<WagonType> {
|
||||||
|
const code = generateCode(dto.name);
|
||||||
|
const existing = await this.wagonTypesRepository.findByCode(code);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Wagon type with name "${dto.name}" conflicts with existing code "${code}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.wagonTypesRepository.create({
|
||||||
|
code,
|
||||||
|
name: dto.name,
|
||||||
|
capacityTons: dto.capacityTons,
|
||||||
|
lengthMeters: dto.lengthMeters,
|
||||||
|
maxWagonsPerTrain: dto.maxWagonsPerTrain ?? null,
|
||||||
|
supportedLoadTypes: dto.supportedLoadTypes ?? [],
|
||||||
|
isActive: dto.isActive ?? true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateWagonTypeDto): Promise<WagonType> {
|
||||||
|
await this.findById(id);
|
||||||
|
const updated = await this.wagonTypesRepository.update(id, dto);
|
||||||
|
if (!updated) {
|
||||||
|
throw new NotFoundException(`Wagon type ${id} not found`);
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string): Promise<void> {
|
||||||
|
await this.findById(id);
|
||||||
|
await this.wagonTypesRepository.softDelete(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type FreightPermissionSeed = {
|
|||||||
export const RULE_ENGINE_RESOURCE_SLUGS = [
|
export const RULE_ENGINE_RESOURCE_SLUGS = [
|
||||||
'cargo-types',
|
'cargo-types',
|
||||||
'container-types',
|
'container-types',
|
||||||
|
'wagon-types',
|
||||||
'service-types',
|
'service-types',
|
||||||
'yards',
|
'yards',
|
||||||
'shipping-lines',
|
'shipping-lines',
|
||||||
@@ -56,6 +57,7 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string; manage: string }> = {
|
const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string; manage: string }> = {
|
||||||
'cargo-types': { view: 'b2000001-0001-4000-8000-000000000001', manage: 'b2000001-0001-4000-8000-000000000002' },
|
'cargo-types': { view: 'b2000001-0001-4000-8000-000000000001', manage: 'b2000001-0001-4000-8000-000000000002' },
|
||||||
'container-types': { view: 'b2000001-0001-4000-8000-000000000003', manage: 'b2000001-0001-4000-8000-000000000004' },
|
'container-types': { view: 'b2000001-0001-4000-8000-000000000003', manage: 'b2000001-0001-4000-8000-000000000004' },
|
||||||
|
'wagon-types': { view: 'b2000001-0001-4000-8000-000000000015', manage: 'b2000001-0001-4000-8000-000000000016' },
|
||||||
'service-types': { view: 'b2000001-0001-4000-8000-000000000005', manage: 'b2000001-0001-4000-8000-000000000006' },
|
'service-types': { view: 'b2000001-0001-4000-8000-000000000005', manage: 'b2000001-0001-4000-8000-000000000006' },
|
||||||
yards: { view: 'b2000001-0001-4000-8000-000000000007', manage: 'b2000001-0001-4000-8000-000000000008' },
|
yards: { view: 'b2000001-0001-4000-8000-000000000007', manage: 'b2000001-0001-4000-8000-000000000008' },
|
||||||
'shipping-lines': { view: 'b2000001-0001-4000-8000-000000000009', manage: 'b2000001-0001-4000-8000-00000000000a' },
|
'shipping-lines': { view: 'b2000001-0001-4000-8000-000000000009', manage: 'b2000001-0001-4000-8000-00000000000a' },
|
||||||
|
|||||||
@@ -353,6 +353,8 @@ export class PricingDataSeeder {
|
|||||||
): Promise<Rate[]> {
|
): Promise<Rate[]> {
|
||||||
const effectiveFrom = new Date("2026-01-01");
|
const effectiveFrom = new Date("2026-01-01");
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
// await rRepo.createQueryBuilder().delete().execute();
|
||||||
|
|
||||||
const rateData = [
|
const rateData = [
|
||||||
{
|
{
|
||||||
rateType: "CONTAINER_IMPORT",
|
rateType: "CONTAINER_IMPORT",
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "@edr/ui-common/theme.css" layer(theme);
|
@import "@edr/ui-common/theme.css" layer(theme);
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--freight-brand: #15803d;
|
||||||
|
--freight-brand-dark: #166534;
|
||||||
|
--freight-brand-light: #22c55e;
|
||||||
|
--freight-brand-muted: #f0fdf4;
|
||||||
|
--freight-brand-border: #bbf7d0;
|
||||||
|
--freight-brand-ring: rgb(21 128 61 / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
#root {
|
#root {
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@edr/types": "workspace:*",
|
"@edr/types": "workspace:*",
|
||||||
"@edr/ui-common": "workspace:*",
|
"@edr/ui-common": "workspace:*",
|
||||||
|
"@mantine/core": "^9.3.0",
|
||||||
|
"@mantine/hooks": "^9.3.0",
|
||||||
|
"@tabler/icons-react": "^3.44.0",
|
||||||
"@hello-pangea/dnd": "^18.0.1",
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
"@tanstack/react-query": "^5.100.11",
|
"@tanstack/react-query": "^5.100.11",
|
||||||
"@tria-plc/iamui-common": "1.1.2",
|
"@tria-plc/iamui-common": "1.1.2",
|
||||||
|
|||||||
@@ -35,16 +35,16 @@ import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
|||||||
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
||||||
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
||||||
import TrainsPage from "./pages/trains/TrainsPage";
|
import TrainsPage from "./pages/trains/TrainsPage";
|
||||||
import {
|
import {
|
||||||
CargoesCrudPage,
|
CargoesCrudPage,
|
||||||
ContainersCrudPage,
|
ContainersCrudPage,
|
||||||
LocomotivesCrudPage,
|
LocomotivesCrudPage,
|
||||||
TrainMasterDataPage,
|
TrainMasterDataPage,
|
||||||
WagonsCrudPage,
|
WagonsCrudPage,
|
||||||
} from "./pages/fleet/FleetCrudPages";
|
} from "./pages/fleet/FleetCrudPages";
|
||||||
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
||||||
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
||||||
import RoutesPage from "./pages/fleet/RoutesPage";
|
import RoutesPage from "./pages/fleet/RoutesPage";
|
||||||
|
|
||||||
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||||
{
|
{
|
||||||
@@ -56,41 +56,41 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
href: "/dashboard/overview",
|
href: "/dashboard/overview",
|
||||||
icon: <LayoutDashboard />,
|
icon: <LayoutDashboard />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Booking requests",
|
label: "Booking requests",
|
||||||
href: "/dashboard/booking-requests",
|
href: "/dashboard/booking-requests",
|
||||||
icon: <FileText />,
|
icon: <FileText />,
|
||||||
},
|
},
|
||||||
...demoItems,
|
...demoItems,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Operations",
|
title: "Operations",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: "Train Schedules",
|
label: "Train Schedules",
|
||||||
href: "/dashboard/operations/train-scheduling",
|
href: "/dashboard/operations/train-scheduling",
|
||||||
icon: <Train />,
|
icon: <Train />,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Fleet Management",
|
title: "Fleet Management",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: "Routes",
|
label: "Routes",
|
||||||
href: "/dashboard/routes",
|
href: "/dashboard/routes",
|
||||||
icon: <Network />,
|
icon: <Network />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Locomotives",
|
label: "Locomotives",
|
||||||
href: "/dashboard/locomotives",
|
href: "/dashboard/locomotives",
|
||||||
icon: <Train />,
|
icon: <Train />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Trains",
|
label: "Trains",
|
||||||
href: "/dashboard/trains",
|
href: "/dashboard/trains",
|
||||||
icon: <Train />,
|
icon: <Train />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Wagons",
|
label: "Wagons",
|
||||||
@@ -240,10 +240,12 @@ const App = () => {
|
|||||||
path="booking-requests/:id/contract"
|
path="booking-requests/:id/contract"
|
||||||
element={<BookingContractPage />}
|
element={<BookingContractPage />}
|
||||||
/>
|
/>
|
||||||
<Route path="operations/train-scheduling" element={<TrainsPage />} />
|
<Route path="operations/train-scheduling" element={<TrainsPage />} />
|
||||||
<Route path="routes" element={<RoutesPage />} />
|
<Route path="trains" element={<TrainMasterDataPage />} />
|
||||||
<Route path="locomotives" element={<LocomotivesCrudPage />} />
|
<Route path="operations/train-scheduling" element={<TrainsPage />} />
|
||||||
<Route path="trains" element={<TrainMasterDataPage />} />
|
<Route path="routes" element={<RoutesPage />} />
|
||||||
|
<Route path="locomotives" element={<LocomotivesCrudPage />} />
|
||||||
|
<Route path="trains" element={<TrainMasterDataPage />} />
|
||||||
<Route path="trains/:id" element={<TrainDetailPage />} />
|
<Route path="trains/:id" element={<TrainDetailPage />} />
|
||||||
<Route path="wagons" element={<WagonsCrudPage />} />
|
<Route path="wagons" element={<WagonsCrudPage />} />
|
||||||
<Route path="containers" element={<ContainersCrudPage />} />
|
<Route path="containers" element={<ContainersCrudPage />} />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Check, ShieldCheck } from "lucide-react";
|
import { Check, ShieldCheck } from "lucide-react";
|
||||||
|
import { Stack, Group, Text, Badge, Button, Box } from "@mantine/core";
|
||||||
|
|
||||||
import { BookingConfirmDialog } from "./BookingConfirmDialog";
|
import { BookingConfirmDialog } from "./BookingConfirmDialog";
|
||||||
import { useAuth } from "@/auth/useAuth";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
@@ -11,9 +12,7 @@ import {
|
|||||||
} from "@/features/bookings/booking-actions.config";
|
} from "@/features/bookings/booking-actions.config";
|
||||||
import type { useBookingMutations } from "@/hooks/bookings/useBookings";
|
import type { useBookingMutations } from "@/hooks/bookings/useBookings";
|
||||||
import type { BookingApprovalStep, BookingDetail } from "@/types/booking";
|
import type { BookingApprovalStep, BookingDetail } from "@/types/booking";
|
||||||
import { bookingGlass, bookingSurface } from "./booking-ui.styles";
|
import { SectionCard } from "./detail/SectionCard";
|
||||||
import { Badge, Button } from "@edr/ui-common";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
type Mutations = ReturnType<typeof useBookingMutations>;
|
type Mutations = ReturnType<typeof useBookingMutations>;
|
||||||
|
|
||||||
@@ -26,23 +25,16 @@ interface ApprovalStepsCardProps {
|
|||||||
export function ApprovalStepsCard({ booking, mutations }: ApprovalStepsCardProps) {
|
export function ApprovalStepsCard({ booking, mutations }: ApprovalStepsCardProps) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const [pendingStep, setPendingStep] = useState<BookingApprovalStep | null>(
|
const [pendingStep, setPendingStep] = useState<BookingApprovalStep | null>(null);
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const steps = useMemo(
|
const steps = useMemo(
|
||||||
() =>
|
() => [...(booking.approvalSteps ?? [])].sort((a, b) => a.stepOrder - b.stepOrder),
|
||||||
[...(booking.approvalSteps ?? [])].sort(
|
|
||||||
(a, b) => a.stepOrder - b.stepOrder,
|
|
||||||
),
|
|
||||||
[booking.approvalSteps],
|
[booking.approvalSteps],
|
||||||
);
|
);
|
||||||
|
|
||||||
const nextPending = getNextPendingApprovalStep(steps);
|
const nextPending = getNextPendingApprovalStep(steps);
|
||||||
const summary = formatApprovalProgress(booking.status, steps);
|
const summary = formatApprovalProgress(booking.status, steps);
|
||||||
const pendingAction = pendingStep
|
const pendingAction = pendingStep ? buildApproveActionForStep(pendingStep) : null;
|
||||||
? buildApproveActionForStep(pendingStep)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const openApprove = (step: BookingApprovalStep) => {
|
const openApprove = (step: BookingApprovalStep) => {
|
||||||
setPendingStep(step);
|
setPendingStep(step);
|
||||||
@@ -62,54 +54,60 @@ export function ApprovalStepsCard({ booking, mutations }: ApprovalStepsCardProps
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const subtitle =
|
||||||
|
summary.detail ||
|
||||||
|
(nextPending
|
||||||
|
? `Next: ${nextPending.requiredRole} · step ${nextPending.stepOrder}`
|
||||||
|
: steps.length
|
||||||
|
? "All steps complete"
|
||||||
|
: "Accept submission to begin");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={cn(bookingSurface.sectionCard, bookingGlass.activeTab)}>
|
<SectionCard
|
||||||
<div className={bookingSurface.sectionHeader}>
|
icon={ShieldCheck}
|
||||||
<div className={bookingSurface.sectionIcon}>
|
title="Approval chain"
|
||||||
<ShieldCheck className="size-4" strokeWidth={1.75} />
|
extra={
|
||||||
</div>
|
<Badge color="green" variant="light" radius="sm">
|
||||||
<div>
|
{steps.filter((s) => s.status === "APPROVED").length}/{steps.length}
|
||||||
<h2 className="text-sm font-semibold text-foreground">
|
</Badge>
|
||||||
Approval chain
|
}
|
||||||
</h2>
|
>
|
||||||
<p className="text-xs text-muted-foreground">
|
<Text size="xs" c="dimmed" mb="sm">
|
||||||
{summary.detail ||
|
{subtitle}
|
||||||
(nextPending
|
</Text>
|
||||||
? `Next: ${nextPending.requiredRole} · step ${nextPending.stepOrder}`
|
|
||||||
: steps.length
|
|
||||||
? "All steps complete"
|
|
||||||
: "Accept submission to begin")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="px-5 py-5">
|
{steps.length === 0 ? (
|
||||||
{steps.length === 0 ? (
|
<Text
|
||||||
<p className="rounded-lg border border-dashed border-border/60 bg-muted/10 px-4 py-6 text-center text-sm text-muted-foreground backdrop-blur-sm">
|
size="sm"
|
||||||
Use{" "}
|
c="dimmed"
|
||||||
<strong className="font-semibold text-foreground">
|
ta="center"
|
||||||
Accept for approval
|
py="lg"
|
||||||
</strong>{" "}
|
px="md"
|
||||||
in staff actions to instantiate steps.
|
style={{
|
||||||
</p>
|
borderRadius: 8,
|
||||||
) : (
|
border: "1px dashed var(--mantine-color-gray-3)",
|
||||||
<ul className="space-y-2">
|
background: "var(--mantine-color-gray-0)",
|
||||||
{steps.map((step) => (
|
}}
|
||||||
<StepRow
|
>
|
||||||
key={step.id}
|
Use <strong>Accept for approval</strong> in staff actions to instantiate steps.
|
||||||
step={step}
|
</Text>
|
||||||
steps={steps}
|
) : (
|
||||||
user={user}
|
<Stack gap="xs">
|
||||||
isNext={nextPending?.id === step.id}
|
{steps.map((step) => (
|
||||||
isPending={mutations.approveStep.isPending}
|
<StepRow
|
||||||
onApprove={openApprove}
|
key={step.id}
|
||||||
/>
|
step={step}
|
||||||
))}
|
steps={steps}
|
||||||
</ul>
|
user={user}
|
||||||
)}
|
isNext={nextPending?.id === step.id}
|
||||||
</div>
|
isPending={mutations.approveStep.isPending}
|
||||||
</div>
|
onApprove={openApprove}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
<BookingConfirmDialog
|
<BookingConfirmDialog
|
||||||
open={confirmOpen}
|
open={confirmOpen}
|
||||||
@@ -144,64 +142,76 @@ function StepRow({
|
|||||||
onApprove: (step: BookingApprovalStep) => void;
|
onApprove: (step: BookingApprovalStep) => void;
|
||||||
}) {
|
}) {
|
||||||
const canApprove = canActOnApprovalStep(user, step, steps);
|
const canApprove = canActOnApprovalStep(user, step, steps);
|
||||||
const statusStyles =
|
const statusColor =
|
||||||
step.status === "APPROVED"
|
step.status === "APPROVED"
|
||||||
? "border-emerald-500/25 bg-emerald-500/10 text-black"
|
? "green"
|
||||||
: step.status === "REJECTED"
|
: step.status === "REJECTED"
|
||||||
? "bg-red-500/10 text-red-800 dark:text-red-300"
|
? "red"
|
||||||
: isNext
|
: isNext
|
||||||
? "border-emerald-500/25 bg-emerald-500/10 text-black"
|
? "green"
|
||||||
: "bg-muted/40 text-muted-foreground";
|
: "gray";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<Group
|
||||||
className={cn(
|
justify="space-between"
|
||||||
"flex items-center justify-between gap-3 rounded-lg border px-4 py-3 transition-colors backdrop-blur-sm",
|
wrap="nowrap"
|
||||||
isNext ? bookingGlass.activeTab : "border-border/50 bg-card/60",
|
gap="sm"
|
||||||
)}
|
px="sm"
|
||||||
|
py="xs"
|
||||||
|
style={{
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
borderLeft: isNext
|
||||||
|
? "3px solid var(--freight-brand)"
|
||||||
|
: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
background: isNext ? "var(--mantine-color-gray-0)" : "white",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
<span
|
<Box
|
||||||
className={cn(
|
style={{
|
||||||
"flex size-8 shrink-0 items-center justify-center rounded-lg text-xs font-bold",
|
display: "flex",
|
||||||
isNext
|
alignItems: "center",
|
||||||
? cn(bookingGlass.iconWellGreen, "text-black")
|
justifyContent: "center",
|
||||||
: "bg-muted/40 text-muted-foreground",
|
width: 28,
|
||||||
)}
|
height: 28,
|
||||||
|
borderRadius: 8,
|
||||||
|
flexShrink: 0,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
background: "var(--mantine-color-gray-1)",
|
||||||
|
color: isNext ? "var(--mantine-color-gray-7)" : "var(--mantine-color-gray-6)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{step.stepOrder}
|
{step.stepOrder}
|
||||||
</span>
|
</Box>
|
||||||
<div className="min-w-0">
|
<Box style={{ minWidth: 0 }}>
|
||||||
<p className="text-sm font-semibold text-foreground">
|
<Text size="sm" fw={600}>
|
||||||
{step.requiredRole}
|
{step.requiredRole}
|
||||||
</p>
|
</Text>
|
||||||
{step.remarks && (
|
{step.remarks && (
|
||||||
<p className="truncate text-xs text-muted-foreground">
|
<Text size="xs" c="dimmed" truncate>
|
||||||
{step.remarks}
|
{step.remarks}
|
||||||
</p>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Group>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||||
{canApprove && (
|
{canApprove && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
size="compact-sm"
|
||||||
size="sm"
|
color="green"
|
||||||
className="h-8 gap-1.5 shadow-sm"
|
leftSection={<Check size={14} />}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
onClick={() => onApprove(step)}
|
onClick={() => onApprove(step)}
|
||||||
>
|
>
|
||||||
<Check className="size-3.5" />
|
|
||||||
Approve
|
Approve
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Badge
|
<Badge variant="light" color={statusColor} size="sm" radius="sm" tt="uppercase">
|
||||||
variant="outline"
|
|
||||||
className={cn("shrink-0 border text-[9px] uppercase", statusStyles)}
|
|
||||||
>
|
|
||||||
{step.status}
|
{step.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</Group>
|
||||||
</li>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import { ChevronRight, ExternalLink, MoreHorizontal } from "lucide-react";
|
||||||
ChevronRight,
|
import { Button, Menu, ActionIcon, Group, Text } from "@mantine/core";
|
||||||
ExternalLink,
|
|
||||||
Loader2,
|
|
||||||
MoreHorizontal,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import { BookingConfirmDialog } from "./BookingConfirmDialog";
|
import { BookingConfirmDialog } from "./BookingConfirmDialog";
|
||||||
import { useBookingActionDialog } from "./useBookingActionDialog";
|
import { useBookingActionDialog } from "./useBookingActionDialog";
|
||||||
@@ -16,16 +12,6 @@ import {
|
|||||||
type BookingActionContext,
|
type BookingActionContext,
|
||||||
} from "@/features/bookings/booking-actions.config";
|
} from "@/features/bookings/booking-actions.config";
|
||||||
import type { BookingListRow } from "@/types/booking";
|
import type { BookingListRow } from "@/types/booking";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@edr/ui-common";
|
|
||||||
|
|
||||||
interface BookingActionsMenuProps {
|
interface BookingActionsMenuProps {
|
||||||
row: BookingListRow;
|
row: BookingListRow;
|
||||||
@@ -39,7 +25,6 @@ interface BookingActionsMenuProps {
|
|||||||
export function BookingActionsMenu({
|
export function BookingActionsMenu({
|
||||||
row,
|
row,
|
||||||
variant = "table",
|
variant = "table",
|
||||||
className,
|
|
||||||
onSuppressRowClick,
|
onSuppressRowClick,
|
||||||
}: BookingActionsMenuProps) {
|
}: BookingActionsMenuProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -57,184 +42,179 @@ export function BookingActionsMenu({
|
|||||||
const goToContract = () =>
|
const goToContract = () =>
|
||||||
navigate(`/dashboard/booking-requests/${row.id}/contract`);
|
navigate(`/dashboard/booking-requests/${row.id}/contract`);
|
||||||
|
|
||||||
const hasMenu = listRowHasActions(row, user);
|
const handleAction = (action: (typeof actions)[number]) => {
|
||||||
|
onSuppressRowClick?.();
|
||||||
|
if (isContractNavAction(action.id)) {
|
||||||
|
goToContract();
|
||||||
|
} else {
|
||||||
|
flow.openAction(action);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasMenu = listRowHasActions(row, user);
|
||||||
const primary = actions.find((a) => a.primary) ?? actions[0];
|
const primary = actions.find((a) => a.primary) ?? actions[0];
|
||||||
|
|
||||||
if (!hasMenu && variant === "table") {
|
if (!hasMenu && variant === "table") {
|
||||||
return (
|
return (
|
||||||
<Button
|
<ActionIcon
|
||||||
variant="ghost"
|
variant="subtle"
|
||||||
size="icon"
|
color="gray"
|
||||||
className="size-8 text-muted-foreground hover:text-primary"
|
|
||||||
onClick={() => navigate(`/dashboard/booking-requests/${row.id}`)}
|
onClick={() => navigate(`/dashboard/booking-requests/${row.id}`)}
|
||||||
aria-label="View booking"
|
aria-label="View booking"
|
||||||
>
|
>
|
||||||
<ChevronRight className="size-4" />
|
<ChevronRight size={16} />
|
||||||
</Button>
|
</ActionIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toolbar: lay every action out as a button row.
|
||||||
|
if (variant === "toolbar" && actions.length > 0) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Group gap="sm" w="100%">
|
||||||
|
{actions.map((action) => {
|
||||||
|
const Icon = action.icon;
|
||||||
|
const destructive = action.variant === "destructive";
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={action.id}
|
||||||
|
size="sm"
|
||||||
|
variant={action.primary && !destructive ? "filled" : "default"}
|
||||||
|
color={destructive ? "red" : action.primary ? "green" : "gray"}
|
||||||
|
leftSection={<Icon size={16} />}
|
||||||
|
disabled={mutations.isPending}
|
||||||
|
onClick={() => handleAction(action)}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Group>
|
||||||
|
<ActionDialog flow={flow} pendingAction={pendingAction} onSuppressRowClick={onSuppressRowClick} />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Group
|
||||||
<div
|
gap={4}
|
||||||
data-stop-row-click
|
justify="flex-end"
|
||||||
className={cn(
|
wrap="nowrap"
|
||||||
"flex w-full min-h-[2.5rem] items-center justify-end gap-1",
|
data-stop-row-click
|
||||||
variant === "table" && "opacity-80 transition-opacity group-hover/tr:opacity-100",
|
onClick={(e) => e.stopPropagation()}
|
||||||
className,
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
)}
|
>
|
||||||
onClick={(e) => e.stopPropagation()}
|
{variant === "table" && primary && (
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
<Button
|
||||||
>
|
size="compact-sm"
|
||||||
{variant === "table" && primary && (
|
color="green"
|
||||||
<Button
|
visibleFrom="lg"
|
||||||
size="sm"
|
leftSection={<primary.icon size={14} />}
|
||||||
className="hidden h-8 gap-1.5 px-2.5 shadow-sm lg:inline-flex"
|
disabled={mutations.isPending}
|
||||||
disabled={mutations.isPending}
|
onClick={() => handleAction(primary)}
|
||||||
onClick={() =>
|
>
|
||||||
isContractNavAction(primary.id)
|
{primary.shortLabel}
|
||||||
? goToContract()
|
</Button>
|
||||||
: flow.openAction(primary)
|
)}
|
||||||
}
|
|
||||||
|
<Menu position="bottom-end" width={220} withinPortal>
|
||||||
|
<Menu.Target>
|
||||||
|
<ActionIcon
|
||||||
|
variant={variant === "table" ? "subtle" : "default"}
|
||||||
|
color="gray"
|
||||||
|
loading={mutations.isPending}
|
||||||
|
aria-label="Booking actions"
|
||||||
>
|
>
|
||||||
<primary.icon className="size-3.5" />
|
<MoreHorizontal size={16} />
|
||||||
{primary.shortLabel}
|
</ActionIcon>
|
||||||
</Button>
|
</Menu.Target>
|
||||||
)}
|
<Menu.Dropdown>
|
||||||
|
<Menu.Label>
|
||||||
{variant === "toolbar" && actions.length > 0 ? (
|
<Text size="xs" ff="monospace" c="dimmed">
|
||||||
<div className="flex w-full flex-wrap gap-2">
|
|
||||||
{actions.map((action) => {
|
|
||||||
const Icon = action.icon;
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={action.id}
|
|
||||||
size="sm"
|
|
||||||
variant={
|
|
||||||
action.variant === "destructive"
|
|
||||||
? "outline"
|
|
||||||
: action.primary
|
|
||||||
? "default"
|
|
||||||
: "outline"
|
|
||||||
}
|
|
||||||
className={cn(
|
|
||||||
"gap-2 shadow-sm",
|
|
||||||
action.variant === "destructive" &&
|
|
||||||
"border-red-200 text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30",
|
|
||||||
)}
|
|
||||||
disabled={mutations.isPending}
|
|
||||||
onClick={() =>
|
|
||||||
isContractNavAction(action.id)
|
|
||||||
? goToContract()
|
|
||||||
: flow.openAction(action)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon className="size-4" />
|
|
||||||
{action.label}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant={variant === "table" ? "ghost" : "outline"}
|
|
||||||
size={variant === "table" ? "icon" : "sm"}
|
|
||||||
className={cn(
|
|
||||||
variant === "table" ? "size-8" : "gap-2",
|
|
||||||
"shrink-0",
|
|
||||||
)}
|
|
||||||
disabled={mutations.isPending}
|
|
||||||
aria-label="Booking actions"
|
|
||||||
>
|
|
||||||
{mutations.isPending ? (
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<MoreHorizontal className="size-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
|
||||||
<DropdownMenuLabel className="font-mono text-xs text-muted-foreground">
|
|
||||||
{row.reference}
|
{row.reference}
|
||||||
</DropdownMenuLabel>
|
</Text>
|
||||||
<DropdownMenuSeparator />
|
</Menu.Label>
|
||||||
{actions.map((action) => {
|
{actions.map((action) => {
|
||||||
const Icon = action.icon;
|
const Icon = action.icon;
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItem
|
<Menu.Item
|
||||||
key={action.id}
|
key={action.id}
|
||||||
className={cn(
|
color={action.variant === "destructive" ? "red" : undefined}
|
||||||
"gap-2 cursor-pointer",
|
leftSection={<Icon size={15} />}
|
||||||
action.variant === "destructive" && "text-red-700 focus:text-red-700",
|
onClick={() => handleAction(action)}
|
||||||
)}
|
>
|
||||||
onSelect={(event) => {
|
{action.label}
|
||||||
event.preventDefault();
|
</Menu.Item>
|
||||||
onSuppressRowClick?.();
|
);
|
||||||
if (isContractNavAction(action.id)) {
|
})}
|
||||||
goToContract();
|
{actions.length > 0 && <Menu.Divider />}
|
||||||
} else {
|
<Menu.Item
|
||||||
flow.openAction(action);
|
leftSection={<ExternalLink size={15} />}
|
||||||
}
|
onClick={() => {
|
||||||
}}
|
onSuppressRowClick?.();
|
||||||
>
|
navigate(`/dashboard/booking-requests/${row.id}`);
|
||||||
<Icon className="size-4 opacity-70" />
|
}}
|
||||||
<span>{action.label}</span>
|
>
|
||||||
</DropdownMenuItem>
|
Open full details
|
||||||
);
|
</Menu.Item>
|
||||||
})}
|
</Menu.Dropdown>
|
||||||
{actions.length > 0 && <DropdownMenuSeparator />}
|
</Menu>
|
||||||
<DropdownMenuItem
|
|
||||||
className="gap-2 cursor-pointer"
|
|
||||||
onSelect={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
onSuppressRowClick?.();
|
|
||||||
navigate(`/dashboard/booking-requests/${row.id}`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ExternalLink className="size-4 opacity-70" />
|
|
||||||
Open full details
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<BookingConfirmDialog
|
<ActionDialog flow={flow} pendingAction={pendingAction} onSuppressRowClick={onSuppressRowClick} />
|
||||||
open={flow.dialogOpen}
|
</Group>
|
||||||
onOpenChange={(open) => {
|
);
|
||||||
if (!open) onSuppressRowClick?.();
|
}
|
||||||
flow.setDialogOpen(open);
|
|
||||||
}}
|
function ActionDialog({
|
||||||
action={pendingAction}
|
flow,
|
||||||
reference={flow.mergedContext.reference}
|
pendingAction,
|
||||||
inputValue={flow.inputValue}
|
onSuppressRowClick,
|
||||||
onInputChange={flow.setInputValue}
|
}: {
|
||||||
selectedFile={flow.selectedFile}
|
flow: ReturnType<typeof useBookingActionDialog>;
|
||||||
onFileChange={flow.setSelectedFile}
|
pendingAction: ReturnType<typeof useBookingActionDialog>["pendingAction"];
|
||||||
onConfirm={() => {
|
onSuppressRowClick?: () => void;
|
||||||
onSuppressRowClick?.();
|
}) {
|
||||||
flow.runAction();
|
return (
|
||||||
}}
|
<BookingConfirmDialog
|
||||||
isPending={mutations.isPending || flow.detailLoading}
|
open={flow.dialogOpen}
|
||||||
confirmDisabled={flow.confirmDisabled}
|
onOpenChange={(open) => {
|
||||||
extra={
|
if (!open) onSuppressRowClick?.();
|
||||||
flow.detailLoading ? (
|
flow.setDialogOpen(open);
|
||||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
}}
|
||||||
<Loader2 className="size-4 animate-spin" />
|
action={pendingAction}
|
||||||
Loading approval steps…
|
reference={flow.mergedContext.reference}
|
||||||
</p>
|
inputValue={flow.inputValue}
|
||||||
) : pendingAction?.id === "approve" &&
|
onInputChange={flow.setInputValue}
|
||||||
!getNextPendingApprovalStep(flow.mergedContext.approvalSteps) ? (
|
selectedFile={flow.selectedFile}
|
||||||
<p className="rounded-lg border border-amber-200/80 bg-amber-50/50 px-3 py-2 text-sm text-amber-900 dark:bg-amber-950/30 dark:text-amber-200">
|
onFileChange={flow.setSelectedFile}
|
||||||
No pending approval step. Refresh the page after staff accept, or
|
onConfirm={() => {
|
||||||
reject the booking.
|
onSuppressRowClick?.();
|
||||||
</p>
|
flow.runAction();
|
||||||
) : null
|
}}
|
||||||
}
|
isPending={flow.mutations.isPending || flow.detailLoading}
|
||||||
/>
|
confirmDisabled={flow.confirmDisabled}
|
||||||
</>
|
extra={
|
||||||
|
flow.detailLoading ? (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Loading approval steps…
|
||||||
|
</Text>
|
||||||
|
) : pendingAction?.id === "approve" &&
|
||||||
|
!getNextPendingApprovalStep(flow.mergedContext.approvalSteps) ? (
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
c="orange.9"
|
||||||
|
p="xs"
|
||||||
|
style={{
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid var(--mantine-color-orange-2)",
|
||||||
|
background: "var(--mantine-color-orange-0)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No pending approval step. Refresh the page after staff accept, or reject the
|
||||||
|
booking.
|
||||||
|
</Text>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { Download, Zap } from "lucide-react";
|
import { Download, Zap, FileText, Clock } from "lucide-react";
|
||||||
|
import { Stack, Text, Button } from "@mantine/core";
|
||||||
|
|
||||||
import type { BookingDetail } from "@/types/booking";
|
import type { BookingDetail } from "@/types/booking";
|
||||||
import { BookingActionsMenu } from "./BookingActionsMenu";
|
import { BookingActionsMenu } from "./BookingActionsMenu";
|
||||||
import { bookingSurface } from "./booking-ui.styles";
|
import { SectionCard } from "./detail/SectionCard";
|
||||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||||
import type { useBookingMutations } from "@/hooks/bookings/useBookings";
|
import type { useBookingMutations } from "@/hooks/bookings/useBookings";
|
||||||
import { Button } from "@edr/ui-common";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
type Mutations = ReturnType<typeof useBookingMutations>;
|
type Mutations = ReturnType<typeof useBookingMutations>;
|
||||||
|
|
||||||
@@ -16,10 +15,7 @@ interface BookingActionsToolbarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Detail-page actions: primary toolbar + downloads. */
|
/** Detail-page actions: primary toolbar + downloads. */
|
||||||
export function BookingActionsToolbar({
|
export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) {
|
||||||
booking,
|
|
||||||
mutations,
|
|
||||||
}: BookingActionsToolbarProps) {
|
|
||||||
const row = toBookingListRow(booking);
|
const row = toBookingListRow(booking);
|
||||||
const { status } = booking;
|
const { status } = booking;
|
||||||
|
|
||||||
@@ -33,50 +29,84 @@ export function BookingActionsToolbar({
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (
|
if (status === "REJECTED" || status === "CANCELLED" || status === "COMPLETED") {
|
||||||
status === "REJECTED" ||
|
|
||||||
status === "CANCELLED" ||
|
|
||||||
status === "COMPLETED"
|
|
||||||
) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === "CHANGES_REQUESTED") {
|
if (status === "CHANGES_REQUESTED") {
|
||||||
return (
|
return (
|
||||||
<PanelShell title="Awaiting customer" description="No staff actions until resubmit.">
|
<SectionCard icon={Zap} title="Awaiting customer">
|
||||||
{booking.latestChangeRequestNote && (
|
<Stack gap="sm">
|
||||||
<p className="rounded-lg border border-border/50 bg-muted/15 p-3 text-sm leading-relaxed backdrop-blur-sm">
|
<Text size="sm" c="dimmed">
|
||||||
{booking.latestChangeRequestNote}
|
No staff actions until resubmit.
|
||||||
</p>
|
</Text>
|
||||||
)}
|
{booking.latestChangeRequestNote && (
|
||||||
</PanelShell>
|
<Text
|
||||||
|
size="sm"
|
||||||
|
p="sm"
|
||||||
|
style={{
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
background: "var(--mantine-color-gray-0)",
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{booking.latestChangeRequestNote}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</SectionCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (["DRAFT", "PENDING_CONSOLIDATION", "CONSOLIDATED"].includes(status)) {
|
if (["DRAFT", "PENDING_CONSOLIDATION", "CONSOLIDATED"].includes(status)) {
|
||||||
return (
|
return (
|
||||||
<PanelShell
|
<SectionCard icon={Zap} title="No staff actions">
|
||||||
title="No staff actions"
|
<Text size="sm" c="dimmed">
|
||||||
description="Monitor until the customer or system advances status."
|
Monitor until the customer or system advances status.
|
||||||
muted
|
</Text>
|
||||||
/>
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
["FULLY_EXECUTED", "PNR_GENERATED", "PAYMENT_VERIFICATION_IN_PROGRESS"].includes(
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<Stack gap="lg">
|
||||||
|
<SectionCard icon={Clock} title="Awaiting customer payment">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Payment is completed by the customer. The booking status updates
|
||||||
|
automatically once payment is confirmed, then moves to Operations.
|
||||||
|
</Text>
|
||||||
|
{status === "FULLY_EXECUTED" && (
|
||||||
|
<BookingActionsMenu row={row} variant="toolbar" />
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</SectionCard>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<Stack gap="lg">
|
||||||
<PanelShell
|
<SectionCard icon={Zap} title="Staff actions">
|
||||||
title="Staff actions"
|
<Stack gap="sm">
|
||||||
description="Confirm each step before it is applied."
|
<Text size="xs" c="dimmed">
|
||||||
>
|
Confirm each step before it is applied.
|
||||||
<BookingActionsMenu row={row} variant="toolbar" />
|
</Text>
|
||||||
</PanelShell>
|
<BookingActionsMenu row={row} variant="toolbar" />
|
||||||
|
</Stack>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
{status === "CONTRACT_READY" && (
|
{status === "CONTRACT_READY" && (
|
||||||
<PanelShell title="Documents" description="Download generated contract.">
|
<SectionCard icon={FileText} title="Documents">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="default"
|
||||||
className="gap-2 border-border/60 bg-background/60 backdrop-blur-sm hover:bg-background/80"
|
leftSection={<Download size={16} />}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
downloadBlob(
|
downloadBlob(
|
||||||
() => mutations.downloadContract(),
|
() => mutations.downloadContract(),
|
||||||
@@ -84,38 +114,10 @@ export function BookingActionsToolbar({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Download className="size-4" />
|
|
||||||
Download contract
|
Download contract
|
||||||
</Button>
|
</Button>
|
||||||
</PanelShell>
|
</SectionCard>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Stack>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PanelShell({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
children,
|
|
||||||
muted,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
muted?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className={cn(bookingSurface.sectionCard, !muted && "ring-0")}>
|
|
||||||
<div className={bookingSurface.sectionHeader}>
|
|
||||||
<div className={bookingSurface.sectionIcon}>
|
|
||||||
<Zap className="size-4" strokeWidth={1.75} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
|
|
||||||
<p className="text-xs text-muted-foreground">{description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-3 px-5 py-5">{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function BookingApprovalProgressCell({ row }: BookingApprovalProgressCell
|
|||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm font-semibold",
|
"text-sm font-semibold",
|
||||||
summary.complete ? "text-emerald-700 dark:text-emerald-400" : "text-foreground",
|
summary.complete ? "text-[color:var(--freight-brand)]" : "text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{summary.label}
|
{summary.label}
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
import { Loader2 } from "lucide-react";
|
import type { ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Group,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Textarea,
|
||||||
|
FileInput,
|
||||||
|
} from "@mantine/core";
|
||||||
|
|
||||||
import type { BookingActionDef } from "@/features/bookings/booking-actions.config";
|
import type { BookingActionDef } from "@/features/bookings/booking-actions.config";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
Textarea,
|
|
||||||
} from "@edr/ui-common";
|
|
||||||
|
|
||||||
interface BookingConfirmDialogProps {
|
interface BookingConfirmDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -25,7 +24,7 @@ interface BookingConfirmDialogProps {
|
|||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
confirmDisabled?: boolean;
|
confirmDisabled?: boolean;
|
||||||
extra?: React.ReactNode;
|
extra?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BookingConfirmDialog({
|
export function BookingConfirmDialog({
|
||||||
@@ -45,129 +44,119 @@ export function BookingConfirmDialog({
|
|||||||
if (!action || !action.confirmTitle) return null;
|
if (!action || !action.confirmTitle) return null;
|
||||||
|
|
||||||
const Icon = action.icon;
|
const Icon = action.icon;
|
||||||
const needsTextInput =
|
const needsTextInput = action.input === "note" || action.input === "reason";
|
||||||
action.input === "note" || action.input === "reason";
|
|
||||||
const needsFileInput = action.input === "file";
|
const needsFileInput = action.input === "file";
|
||||||
const inputMissing =
|
const inputMissing =
|
||||||
(needsTextInput && !inputValue.trim()) ||
|
(needsTextInput && !inputValue.trim()) || (needsFileInput && !selectedFile);
|
||||||
(needsFileInput && !selectedFile);
|
|
||||||
const isDestructive = action.variant === "destructive";
|
const isDestructive = action.variant === "destructive";
|
||||||
|
const accent = isDestructive ? "red" : "green";
|
||||||
const preventClickThrough = (event: React.MouseEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Modal
|
||||||
<DialogContent
|
opened={open}
|
||||||
className="gap-0 overflow-hidden p-0 sm:max-w-md"
|
onClose={() => onOpenChange(false)}
|
||||||
showCloseButton={false}
|
withCloseButton={false}
|
||||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
centered
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
padding={0}
|
||||||
|
title={null}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<Box
|
||||||
|
px="lg"
|
||||||
|
py="md"
|
||||||
|
style={{
|
||||||
|
background: `var(--mantine-color-${accent}-0)`,
|
||||||
|
borderBottom: `1px solid var(--mantine-color-${accent}-1)`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<Group align="flex-start" gap="sm" wrap="nowrap">
|
||||||
className={cn(
|
<Box
|
||||||
"border-b px-6 py-5",
|
style={{
|
||||||
isDestructive
|
display: "flex",
|
||||||
? "bg-gradient-to-br from-red-500/10 via-background to-background"
|
alignItems: "center",
|
||||||
: "bg-gradient-to-br from-primary/8 via-background to-background",
|
justifyContent: "center",
|
||||||
)}
|
width: 44,
|
||||||
>
|
height: 44,
|
||||||
<DialogHeader className="gap-3 text-left">
|
borderRadius: 12,
|
||||||
<div className="flex items-start gap-3">
|
flexShrink: 0,
|
||||||
<div
|
background: `var(--mantine-color-${accent}-1)`,
|
||||||
className={cn(
|
color: `var(--mantine-color-${accent}-7)`,
|
||||||
"flex size-11 shrink-0 items-center justify-center rounded-xl shadow-sm",
|
}}
|
||||||
isDestructive
|
|
||||||
? "bg-red-500/15 text-red-700 dark:text-red-300"
|
|
||||||
: "bg-primary/15 text-primary",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon className="size-5" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 space-y-1 pt-0.5">
|
|
||||||
<DialogTitle className="text-base leading-snug">
|
|
||||||
{action.confirmTitle}
|
|
||||||
</DialogTitle>
|
|
||||||
{reference && (
|
|
||||||
<p className="font-mono text-xs font-semibold text-muted-foreground">
|
|
||||||
{reference}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogDescription className="text-left text-sm leading-relaxed">
|
|
||||||
{action.confirmDescription}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4 px-6 py-5">
|
|
||||||
{needsTextInput && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
||||||
{action.inputLabel}
|
|
||||||
<span className="text-red-600"> *</span>
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
value={inputValue}
|
|
||||||
onChange={(e) => onInputChange(e.target.value)}
|
|
||||||
placeholder={action.inputPlaceholder}
|
|
||||||
rows={4}
|
|
||||||
className="min-h-[100px] resize-y"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{needsFileInput && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
||||||
{action.inputLabel ?? "Bank slip file"}
|
|
||||||
<span className="text-red-600"> *</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept=".pdf,.png,.jpg,.jpeg"
|
|
||||||
className="block w-full text-sm text-muted-foreground file:mr-3 file:rounded-md file:border-0 file:bg-primary file:px-3 file:py-2 file:text-xs file:font-semibold file:text-primary-foreground"
|
|
||||||
onChange={(e) =>
|
|
||||||
onFileChange?.(e.target.files?.[0] ?? null)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{selectedFile && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Selected: {selectedFile.name}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{extra}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="gap-2 border-t bg-muted/20 px-6 py-4 sm:justify-end">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
disabled={isPending}
|
|
||||||
onMouseDown={preventClickThrough}
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
>
|
>
|
||||||
Cancel
|
<Icon size={20} />
|
||||||
</Button>
|
</Box>
|
||||||
<Button
|
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||||
type="button"
|
<Text fw={700} size="md" style={{ lineHeight: 1.3 }}>
|
||||||
variant={isDestructive ? "destructive" : "default"}
|
{action.confirmTitle}
|
||||||
disabled={isPending || inputMissing || confirmDisabled}
|
</Text>
|
||||||
className="min-w-[7rem] gap-2"
|
{reference && (
|
||||||
onMouseDown={preventClickThrough}
|
<Text size="xs" c="dimmed" ff="monospace" fw={600}>
|
||||||
onClick={onConfirm}
|
{reference}
|
||||||
>
|
</Text>
|
||||||
{isPending ? (
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Icon className="size-4" />
|
|
||||||
)}
|
)}
|
||||||
{action.shortLabel}
|
</Stack>
|
||||||
</Button>
|
</Group>
|
||||||
</DialogFooter>
|
{action.confirmDescription && (
|
||||||
</DialogContent>
|
<Text size="sm" c="dimmed" mt="sm" style={{ lineHeight: 1.5 }}>
|
||||||
</Dialog>
|
{action.confirmDescription}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<Stack gap="md" px="lg" py="lg">
|
||||||
|
{needsTextInput && (
|
||||||
|
<Textarea
|
||||||
|
label={action.inputLabel}
|
||||||
|
withAsterisk
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => onInputChange(e.currentTarget.value)}
|
||||||
|
placeholder={action.inputPlaceholder}
|
||||||
|
minRows={4}
|
||||||
|
autosize
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{needsFileInput && (
|
||||||
|
<FileInput
|
||||||
|
label={action.inputLabel ?? "Bank slip file"}
|
||||||
|
withAsterisk
|
||||||
|
placeholder="Select a file"
|
||||||
|
accept=".pdf,.png,.jpg,.jpeg"
|
||||||
|
value={selectedFile}
|
||||||
|
onChange={(file) => onFileChange?.(file)}
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{extra}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<Group
|
||||||
|
justify="flex-end"
|
||||||
|
gap="sm"
|
||||||
|
px="lg"
|
||||||
|
py="md"
|
||||||
|
style={{
|
||||||
|
borderTop: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
background: "var(--mantine-color-gray-0)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button variant="default" disabled={isPending} onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color={accent}
|
||||||
|
loading={isPending}
|
||||||
|
disabled={inputMissing || confirmDisabled}
|
||||||
|
leftSection={<Icon size={16} />}
|
||||||
|
onClick={onConfirm}
|
||||||
|
miw={112}
|
||||||
|
>
|
||||||
|
{action.shortLabel}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,86 +1,93 @@
|
|||||||
import { Banknote, Receipt } from "lucide-react";
|
import { Banknote, Receipt } from "lucide-react";
|
||||||
|
import { Paper, Stack, Group, Text, Divider } from "@mantine/core";
|
||||||
|
|
||||||
import type { BookingDetail } from "@/types/booking";
|
import type { BookingDetail } from "@/types/booking";
|
||||||
import { Separator } from "@edr/ui-common";
|
|
||||||
import { bookingGlass, bookingSurface } from "./booking-ui.styles";
|
import { SectionCard } from "./detail/SectionCard";
|
||||||
|
import { detailStyles } from "./detail/booking-detail.styles";
|
||||||
|
|
||||||
export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
|
export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
|
||||||
const amount = Number(booking.totalAmount);
|
const amount = Number(booking.totalAmount);
|
||||||
const modifiers = booking.cargoModifiers ?? [];
|
const modifiers = booking.cargoModifiers ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={bookingSurface.sectionCard}>
|
<SectionCard icon={Banknote} title="Pricing & payment">
|
||||||
<div className={bookingSurface.sectionHeader}>
|
<Stack gap="md">
|
||||||
<div className={bookingSurface.sectionIcon}>
|
<Paper radius="md" withBorder p="md" style={detailStyles.highlightCard}>
|
||||||
<Banknote className="size-4" strokeWidth={1.75} />
|
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-semibold text-foreground">
|
|
||||||
Pricing & payment
|
|
||||||
</h2>
|
|
||||||
<p className="text-xs text-muted-foreground">Commercial terms</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-4 px-5 py-5">
|
|
||||||
<div className={bookingSurface.valueCard}>
|
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
||||||
Total amount
|
Total amount
|
||||||
</p>
|
</Text>
|
||||||
<p className="mt-1 font-mono text-2xl font-semibold tabular-nums tracking-tight text-foreground">
|
<Text
|
||||||
|
size="xl"
|
||||||
|
fw={700}
|
||||||
|
c="green.9"
|
||||||
|
mt={4}
|
||||||
|
style={{ fontVariantNumeric: "tabular-nums", letterSpacing: "-0.5px" }}
|
||||||
|
>
|
||||||
{booking.paymentCurrency}{" "}
|
{booking.paymentCurrency}{" "}
|
||||||
{amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
{amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
||||||
</p>
|
</Text>
|
||||||
</div>
|
</Paper>
|
||||||
|
|
||||||
<Row label="Payment status" value={booking.paymentStatus} />
|
<Row label="Payment status" value={booking.paymentStatus} />
|
||||||
{booking.pnrCode && <Row label="PNR code" value={booking.pnrCode} mono />}
|
{booking.pnrCode && <Row label="PNR code" value={booking.pnrCode} mono />}
|
||||||
|
|
||||||
{modifiers.length > 0 && (
|
{modifiers.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Separator className="opacity-50" />
|
<Divider color="var(--mantine-color-gray-2)" />
|
||||||
<p className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
<Group gap={6}>
|
||||||
<Receipt className="size-3" />
|
<Receipt size={13} color="var(--mantine-color-gray-5)" />
|
||||||
Surcharges applied
|
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
|
||||||
</p>
|
Surcharges applied
|
||||||
<ul className="space-y-2">
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Stack gap="xs">
|
||||||
{modifiers.map((m) => (
|
{modifiers.map((m) => (
|
||||||
<li
|
<Group
|
||||||
key={m.id}
|
key={m.id}
|
||||||
className="flex justify-between rounded-lg border border-border/50 bg-muted/15 px-3 py-2 text-sm backdrop-blur-sm"
|
justify="space-between"
|
||||||
|
px="sm"
|
||||||
|
py={6}
|
||||||
|
style={{
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
background: "var(--mantine-color-gray-0)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="text-muted-foreground">Modifier</span>
|
<Text size="sm" c="dimmed">
|
||||||
<span className="font-mono font-semibold tabular-nums">
|
Modifier
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={600} style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||||
{Number(m.calculatedAmount).toLocaleString()}
|
{Number(m.calculatedAmount).toLocaleString()}
|
||||||
</span>
|
</Text>
|
||||||
</li>
|
</Group>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Stack>
|
||||||
</div>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Row({
|
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||||
label,
|
|
||||||
value,
|
|
||||||
mono,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
mono?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-2 rounded-lg border border-border/40 bg-muted/10 px-3 py-2.5 text-sm backdrop-blur-sm">
|
<Group
|
||||||
<span className="text-muted-foreground">{label}</span>
|
justify="space-between"
|
||||||
<span
|
px="sm"
|
||||||
className={
|
py="xs"
|
||||||
mono
|
style={{
|
||||||
? "font-mono text-xs font-semibold text-foreground"
|
borderRadius: 8,
|
||||||
: "font-medium text-foreground"
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
}
|
background: "var(--mantine-color-gray-0)",
|
||||||
>
|
}}
|
||||||
|
>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={600} ff={mono ? "monospace" : undefined}>
|
||||||
{value}
|
{value}
|
||||||
</span>
|
</Text>
|
||||||
</div>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
|
import { Badge } from "@mantine/core";
|
||||||
|
|
||||||
export function BookingPriorityBadge({ score }: { score: number }) {
|
export function BookingPriorityBadge({ score }: { score: number }) {
|
||||||
if (score >= 1000) {
|
if (score >= 1000) {
|
||||||
return (
|
return (
|
||||||
<span className="rounded-full bg-red-50 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-red-700">
|
<Badge color="red" variant="filled" size="sm" radius="lg" tt="uppercase">
|
||||||
Urgent
|
Urgent
|
||||||
</span>
|
</Badge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (score >= 500) {
|
if (score >= 500) {
|
||||||
return (
|
return (
|
||||||
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-amber-700">
|
<Badge color="yellow" variant="filled" size="sm" radius="lg" tt="uppercase">
|
||||||
High
|
High
|
||||||
</span>
|
</Badge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-slate-600">
|
<Badge color="gray" variant="light" size="sm" radius="lg" tt="uppercase">
|
||||||
Normal
|
Normal
|
||||||
</span>
|
</Badge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { bookingGlass } from "./booking-ui.styles";
|
import { Card, Group, Stack, Text, Paper } from "@mantine/core";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export interface StatItem {
|
export interface StatItem {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -10,54 +9,100 @@ export interface StatItem {
|
|||||||
accent?: "default" | "amber" | "emerald" | "rose";
|
accent?: "default" | "amber" | "emerald" | "rose";
|
||||||
}
|
}
|
||||||
|
|
||||||
const iconAccentStyles = {
|
const accentColors = {
|
||||||
default: "text-foreground/70",
|
default: { bg: "var(--mantine-color-gray-1)", color: "var(--mantine-color-gray-6)" },
|
||||||
amber: "text-amber-600 dark:text-amber-400",
|
amber: { bg: "var(--mantine-color-yellow-1)", color: "var(--mantine-color-yellow-6)" },
|
||||||
emerald: "text-emerald-600 dark:text-emerald-400",
|
emerald: { bg: "var(--freight-brand-muted)", color: "var(--freight-brand)" },
|
||||||
rose: "text-rose-600 dark:text-rose-400",
|
rose: { bg: "var(--mantine-color-red-1)", color: "var(--mantine-color-red-6)" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BookingStatGrid({ items }: { items: StatItem[] }) {
|
export function BookingStatGrid({ items }: { items: StatItem[] }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
<Paper
|
||||||
{items.map((item) => {
|
p="md"
|
||||||
const Icon = item.icon;
|
radius="lg"
|
||||||
const accent = item.accent ?? "default";
|
withBorder
|
||||||
return (
|
style={{
|
||||||
<div
|
background: "white",
|
||||||
key={item.label}
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
className={cn(
|
overflowX: "auto",
|
||||||
"group relative overflow-hidden rounded-xl p-5 transition-all duration-200 hover:shadow-md",
|
overflowY: "hidden",
|
||||||
bookingGlass.card,
|
WebkitOverflowScrolling: "touch",
|
||||||
)}
|
scrollBehavior: "smooth",
|
||||||
>
|
}}
|
||||||
<div className="flex items-start justify-between gap-3">
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<Group
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
gap="lg"
|
||||||
{item.label}
|
style={{
|
||||||
</p>
|
minWidth: "min-content",
|
||||||
<p className="mt-2 text-3xl font-semibold tabular-nums tracking-tight text-foreground">
|
display: "flex",
|
||||||
{item.value}
|
flexWrap: "nowrap",
|
||||||
</p>
|
}}
|
||||||
{item.hint && (
|
>
|
||||||
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
{items.map((item) => {
|
||||||
{item.hint}
|
const Icon = item.icon;
|
||||||
</p>
|
const accent = item.accent ?? "default";
|
||||||
)}
|
const accentStyle = accentColors[accent];
|
||||||
</div>
|
|
||||||
<div
|
return (
|
||||||
className={cn(
|
<Card
|
||||||
"flex size-10 shrink-0 items-center justify-center rounded-xl transition-transform duration-200 group-hover:scale-[1.02]",
|
key={item.label}
|
||||||
bookingGlass.iconWellGreen,
|
p="lg"
|
||||||
iconAccentStyles[accent],
|
radius="lg"
|
||||||
)}
|
withBorder
|
||||||
>
|
style={{
|
||||||
<Icon className="size-[18px]" strokeWidth={1.75} />
|
background: "white",
|
||||||
</div>
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
</div>
|
transition: "all 0.2s ease",
|
||||||
</div>
|
cursor: "pointer",
|
||||||
);
|
minWidth: "280px",
|
||||||
})}
|
width: "280px",
|
||||||
</div>
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.boxShadow = "0 4px 12px rgba(34, 197, 94, 0.12)";
|
||||||
|
e.currentTarget.style.borderColor = "var(--freight-brand-border)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.boxShadow = "none";
|
||||||
|
e.currentTarget.style.borderColor = "var(--mantine-color-gray-2)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="flex-start">
|
||||||
|
<Stack gap="xs" style={{ flex: 1 }}>
|
||||||
|
<Text size="xs" fw={600} c="dimmed" tt="uppercase">
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
<Text size="32px" fw={700} style={{ lineHeight: 1, letterSpacing: "-0.02em" }}>
|
||||||
|
{item.value}
|
||||||
|
</Text>
|
||||||
|
{item.hint && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{item.hint}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: "44px",
|
||||||
|
height: "44px",
|
||||||
|
borderRadius: "10px",
|
||||||
|
background: accentStyle.bg,
|
||||||
|
color: accentStyle.color,
|
||||||
|
flexShrink: 0,
|
||||||
|
transition: "transform 0.2s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={22} strokeWidth={1.75} />
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,50 @@
|
|||||||
import { Badge } from "@edr/ui-common";
|
import { Badge } from "@mantine/core";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { BOOKING_STATUS_STYLES } from "@/features/bookings/booking-status.config";
|
import { BOOKING_STATUS_STYLES } from "@/features/bookings/booking-status.config";
|
||||||
|
|
||||||
|
const statusColorMap: Record<string, string> = {
|
||||||
|
DRAFT: "gray",
|
||||||
|
SUBMITTED: "yellow",
|
||||||
|
CHANGES_REQUESTED: "orange",
|
||||||
|
PENDING_APPROVAL: "yellow",
|
||||||
|
APPROVED_PENDING_SIGNATURE: "cyan",
|
||||||
|
APPROVED: "green",
|
||||||
|
CONTRACT_READY: "indigo",
|
||||||
|
SIGNED_CUSTOMER: "cyan",
|
||||||
|
FULLY_EXECUTED: "indigo",
|
||||||
|
PNR_GENERATED: "violet",
|
||||||
|
PAYMENT_VERIFICATION_IN_PROGRESS: "yellow",
|
||||||
|
PAID: "green",
|
||||||
|
IN_TRANSIT: "cyan",
|
||||||
|
COMPLETED: "indigo",
|
||||||
|
REJECTED: "red",
|
||||||
|
CANCELLED: "red",
|
||||||
|
PENDING_CONSOLIDATION: "yellow",
|
||||||
|
CONSOLIDATED: "indigo",
|
||||||
|
};
|
||||||
|
|
||||||
export function BookingStatusBadge({ status }: { status: string }) {
|
export function BookingStatusBadge({ status }: { status: string }) {
|
||||||
const style = BOOKING_STATUS_STYLES[status] ?? {
|
const style = BOOKING_STATUS_STYLES[status] ?? {
|
||||||
label: status,
|
label: status,
|
||||||
color: "bg-muted text-muted-foreground border-border",
|
color: "gray",
|
||||||
};
|
};
|
||||||
|
const color = statusColorMap[status] ?? "gray";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
color={color}
|
||||||
className={cn(
|
variant="light"
|
||||||
"px-2 py-0.5 text-[9px] font-bold uppercase tracking-wider",
|
size="sm"
|
||||||
style.color,
|
radius="md"
|
||||||
)}
|
tt="uppercase"
|
||||||
|
fw={600}
|
||||||
|
title={style.label}
|
||||||
|
style={{
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
display: "inline-flex",
|
||||||
|
maxWidth: "100%",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{style.label}
|
{style.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
@@ -8,27 +8,24 @@ import {
|
|||||||
Wallet,
|
Wallet,
|
||||||
XCircle,
|
XCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { Group, Badge, UnstyledButton, Text } from "@mantine/core";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BOOKING_LIST_TABS,
|
BOOKING_LIST_TABS,
|
||||||
type BookingStatusTabKey,
|
type BookingStatusTabKey,
|
||||||
} from "@/features/bookings/booking-status.config";
|
} from "@/features/bookings/booking-status.config";
|
||||||
import { bookingGlass } from "./booking-ui.styles";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const TAB_ICONS: Record<BookingStatusTabKey, React.ReactNode> = {
|
const TAB_ICONS: Record<BookingStatusTabKey, React.ReactNode> = {
|
||||||
all: <LayoutGrid className="size-3.5" strokeWidth={1.75} />,
|
all: <LayoutGrid size={18} strokeWidth={1.75} />,
|
||||||
intake: <Inbox className="size-3.5" strokeWidth={1.75} />,
|
intake: <Inbox size={18} strokeWidth={1.75} />,
|
||||||
in_approval: <ClipboardCheck className="size-3.5" strokeWidth={1.75} />,
|
in_approval: <ClipboardCheck size={18} strokeWidth={1.75} />,
|
||||||
approved_contract: <FileSignature className="size-3.5" strokeWidth={1.75} />,
|
approved_contract: <FileSignature size={18} strokeWidth={1.75} />,
|
||||||
payment: <Wallet className="size-3.5" strokeWidth={1.75} />,
|
payment: <Wallet size={18} strokeWidth={1.75} />,
|
||||||
operations: <Train className="size-3.5" strokeWidth={1.75} />,
|
operations: <Train size={18} strokeWidth={1.75} />,
|
||||||
completed: <CheckCircle className="size-3.5" strokeWidth={1.75} />,
|
completed: <CheckCircle size={18} strokeWidth={1.75} />,
|
||||||
closed: <XCircle className="size-3.5" strokeWidth={1.75} />,
|
closed: <XCircle size={18} strokeWidth={1.75} />,
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeTabText = "text-black";
|
|
||||||
|
|
||||||
interface BookingStatusTabsProps {
|
interface BookingStatusTabsProps {
|
||||||
active: BookingStatusTabKey;
|
active: BookingStatusTabKey;
|
||||||
onChange: (tab: BookingStatusTabKey) => void;
|
onChange: (tab: BookingStatusTabKey) => void;
|
||||||
@@ -41,66 +38,74 @@ export function BookingStatusTabs({
|
|||||||
counts,
|
counts,
|
||||||
}: BookingStatusTabsProps) {
|
}: BookingStatusTabsProps) {
|
||||||
return (
|
return (
|
||||||
<div className={bookingGlass.tabRail}>
|
<Group
|
||||||
<div
|
gap="sm"
|
||||||
className="flex flex-nowrap gap-1.5 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
|
wrap="nowrap"
|
||||||
role="tablist"
|
p="md"
|
||||||
aria-label="Booking status filters"
|
style={{
|
||||||
>
|
background: "var(--mantine-color-gray-0)",
|
||||||
{BOOKING_LIST_TABS.map((tab) => {
|
borderRadius: "12px",
|
||||||
const isActive = active === tab.key;
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
const count = counts?.[tab.key];
|
overflowX: "auto",
|
||||||
return (
|
overflowY: "hidden",
|
||||||
<button
|
WebkitOverflowScrolling: "touch",
|
||||||
key={tab.key}
|
scrollBehavior: "smooth",
|
||||||
type="button"
|
scrollbarWidth: "thin",
|
||||||
role="tab"
|
}}
|
||||||
aria-selected={isActive}
|
>
|
||||||
onClick={() => onChange(tab.key)}
|
{BOOKING_LIST_TABS.map((tab) => {
|
||||||
className={cn(
|
const isActive = active === tab.key;
|
||||||
"flex min-w-[8rem] shrink-0 flex-col items-start gap-0.5 rounded-lg px-3 py-2.5 text-left transition-all duration-200",
|
const count = counts?.[tab.key];
|
||||||
isActive
|
return (
|
||||||
? bookingGlass.activeTab
|
<UnstyledButton
|
||||||
: "text-muted-foreground hover:bg-emerald-500/5 hover:text-foreground",
|
key={tab.key}
|
||||||
)}
|
onClick={() => onChange(tab.key)}
|
||||||
>
|
style={{
|
||||||
<span className="flex w-full items-center justify-between gap-2">
|
flexShrink: 0,
|
||||||
<span
|
background: isActive ? "white" : "transparent",
|
||||||
className={cn(
|
border: isActive ? "1px solid var(--freight-brand-border)" : "1px solid var(--mantine-color-gray-2)",
|
||||||
"flex items-center gap-2 text-sm font-medium",
|
borderRadius: "10px",
|
||||||
isActive ? activeTabText : "text-muted-foreground",
|
padding: "10px 16px",
|
||||||
)}
|
transition: "all 0.2s ease",
|
||||||
|
cursor: "pointer",
|
||||||
|
boxShadow: isActive ? "0 2px 8px rgb(21 128 61 / 0.12)" : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap="sm" justify="space-between" wrap="nowrap">
|
||||||
|
<Group gap={8}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: "32px",
|
||||||
|
height: "32px",
|
||||||
|
borderRadius: "8px",
|
||||||
|
background: isActive ? "var(--freight-brand-muted)" : "var(--mantine-color-gray-1)",
|
||||||
|
color: isActive ? "var(--freight-brand-dark)" : "var(--mantine-color-gray-6)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span
|
{TAB_ICONS[tab.key]}
|
||||||
className={cn(
|
</div>
|
||||||
"flex size-7 shrink-0 items-center justify-center rounded-md",
|
<Text size="sm" fw={600}>
|
||||||
isActive
|
{tab.label}
|
||||||
? cn(bookingGlass.iconWellGreen, "text-black")
|
</Text>
|
||||||
: "border border-transparent bg-muted/30",
|
</Group>
|
||||||
)}
|
{count !== undefined && count > 0 && (
|
||||||
>
|
<Badge
|
||||||
{TAB_ICONS[tab.key]}
|
size="sm"
|
||||||
</span>
|
variant={isActive ? "filled" : "light"}
|
||||||
<span className="whitespace-nowrap">{tab.label}</span>
|
color={isActive ? "green" : "gray"}
|
||||||
</span>
|
radius="lg"
|
||||||
{count !== undefined && count > 0 && (
|
>
|
||||||
<span
|
{count}
|
||||||
className={cn(
|
</Badge>
|
||||||
"rounded-full px-2 py-0.5 text-[10px] font-semibold tabular-nums",
|
)}
|
||||||
isActive
|
</Group>
|
||||||
? cn("bg-emerald-500/15", activeTabText)
|
</UnstyledButton>
|
||||||
: "bg-muted/50 text-muted-foreground",
|
);
|
||||||
)}
|
})}
|
||||||
>
|
</Group>
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,28 @@
|
|||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
CheckCircle2,
|
|
||||||
FileSignature,
|
FileSignature,
|
||||||
FileText,
|
FileText,
|
||||||
Train,
|
Train,
|
||||||
Wallet,
|
Wallet,
|
||||||
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { Paper, Group, Stack, Text, Box } from "@mantine/core";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import {
|
import {
|
||||||
getWorkflowStageIndex,
|
getWorkflowStageIndex,
|
||||||
WORKFLOW_STAGES,
|
WORKFLOW_STAGES,
|
||||||
} from "@/features/bookings/booking-status.config";
|
} from "@/features/bookings/booking-status.config";
|
||||||
import { bookingGlass, bookingSurface } from "./booking-ui.styles";
|
import { SectionCard } from "./detail/SectionCard";
|
||||||
|
import { BRAND_GREEN, detailStyles } from "./detail/booking-detail.styles";
|
||||||
|
|
||||||
const STAGE_ICONS = [FileText, FileSignature, FileSignature, Wallet, Train, Check];
|
const STAGE_ICONS: LucideIcon[] = [
|
||||||
|
FileText,
|
||||||
|
FileSignature,
|
||||||
|
FileSignature,
|
||||||
|
Wallet,
|
||||||
|
Train,
|
||||||
|
Check,
|
||||||
|
];
|
||||||
|
|
||||||
interface BookingWorkflowStepperProps {
|
interface BookingWorkflowStepperProps {
|
||||||
status: string;
|
status: string;
|
||||||
@@ -27,104 +35,94 @@ export function BookingWorkflowStepper({
|
|||||||
status,
|
status,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
titleColor,
|
|
||||||
}: BookingWorkflowStepperProps) {
|
}: BookingWorkflowStepperProps) {
|
||||||
const currentStage = getWorkflowStageIndex(status);
|
const currentStage = getWorkflowStageIndex(status);
|
||||||
const isTerminal = currentStage < 0;
|
const isTerminal = currentStage < 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={bookingSurface.sectionCard}>
|
<SectionCard icon={Train} title="Workflow progress">
|
||||||
<div className={bookingSurface.sectionHeader}>
|
<Group gap={0} wrap="nowrap" align="flex-start" mb="lg">
|
||||||
<div className={bookingSurface.sectionIcon}>
|
{WORKFLOW_STAGES.map((stage, index) => {
|
||||||
<Train className="size-4" strokeWidth={1.75} />
|
const Icon = STAGE_ICONS[index] ?? FileText;
|
||||||
</div>
|
const isComplete = !isTerminal && index < currentStage;
|
||||||
<div>
|
const isActive = !isTerminal && index === currentStage;
|
||||||
<h2 className="text-sm font-semibold text-foreground">
|
const isLast = index === WORKFLOW_STAGES.length - 1;
|
||||||
Workflow progress
|
|
||||||
</h2>
|
return (
|
||||||
<p className="text-xs text-muted-foreground">
|
<Box key={stage.label} style={{ flex: isLast ? "0 0 auto" : 1, minWidth: 0 }}>
|
||||||
Customer submission through completion
|
<Group gap={0} wrap="nowrap" align="center">
|
||||||
</p>
|
<Stack gap={6} align="center" style={{ flexShrink: 0 }}>
|
||||||
</div>
|
<Box
|
||||||
</div>
|
style={{
|
||||||
<div className="space-y-8 px-5 py-6">
|
display: "flex",
|
||||||
<div className="relative px-2">
|
alignItems: "center",
|
||||||
<div className="absolute left-4 right-4 top-5 h-px bg-border/60" />
|
justifyContent: "center",
|
||||||
<div
|
width: 34,
|
||||||
className="absolute left-4 top-5 h-px bg-emerald-500/40 transition-all duration-700 ease-out"
|
height: 34,
|
||||||
style={{
|
borderRadius: "50%",
|
||||||
width:
|
background: isComplete
|
||||||
!isTerminal && currentStage >= 0
|
? BRAND_GREEN
|
||||||
? `calc(${(currentStage / (WORKFLOW_STAGES.length - 1)) * 100}% - 2rem)`
|
: isActive
|
||||||
: "0%",
|
? "white"
|
||||||
}}
|
: "var(--mantine-color-gray-1)",
|
||||||
/>
|
border: isActive
|
||||||
<div className="relative flex justify-between">
|
? `2px solid ${BRAND_GREEN}`
|
||||||
{WORKFLOW_STAGES.map((stage, idx) => {
|
: isComplete
|
||||||
const Icon = STAGE_ICONS[idx] ?? FileText;
|
? "2px solid transparent"
|
||||||
const isCompleted = !isTerminal && idx < currentStage;
|
: "2px solid var(--mantine-color-gray-2)",
|
||||||
const isActive = !isTerminal && idx === currentStage;
|
color: isComplete
|
||||||
return (
|
? "white"
|
||||||
<div
|
: isActive
|
||||||
key={stage.label}
|
? "var(--freight-brand-dark)"
|
||||||
className="flex max-w-[4.5rem] flex-col items-center gap-2.5 sm:max-w-none"
|
: "var(--mantine-color-gray-5)",
|
||||||
>
|
transition: "all 0.2s ease",
|
||||||
<div
|
}}
|
||||||
className={cn(
|
|
||||||
"flex size-10 items-center justify-center rounded-full border-2 bg-card/80 backdrop-blur-sm transition-all duration-300",
|
|
||||||
isCompleted &&
|
|
||||||
cn(bookingGlass.iconWellGreen, "border-emerald-500/30 text-black"),
|
|
||||||
isActive &&
|
|
||||||
cn(
|
|
||||||
bookingGlass.activeTab,
|
|
||||||
"scale-105 border-emerald-500/30 text-black shadow-sm",
|
|
||||||
),
|
|
||||||
!isCompleted &&
|
|
||||||
!isActive &&
|
|
||||||
"border-border/60 text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{isCompleted ? (
|
{isComplete ? <Check size={16} strokeWidth={3} /> : <Icon size={15} />}
|
||||||
<CheckCircle2 className="size-4" />
|
</Box>
|
||||||
) : (
|
<Text
|
||||||
<Icon className="size-4" />
|
size="xs"
|
||||||
)}
|
fw={isActive ? 600 : 500}
|
||||||
</div>
|
c={isActive ? "green.7" : isComplete ? "dark" : "dimmed"}
|
||||||
<span
|
ta="center"
|
||||||
className={cn(
|
style={{ whiteSpace: "nowrap" }}
|
||||||
"text-center text-[10px] font-semibold uppercase leading-tight tracking-wide",
|
|
||||||
isActive ? "text-black" : "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{stage.label}
|
{stage.label}
|
||||||
</span>
|
</Text>
|
||||||
</div>
|
</Stack>
|
||||||
);
|
{!isLast && (
|
||||||
})}
|
<Box
|
||||||
</div>
|
style={{
|
||||||
</div>
|
flex: 1,
|
||||||
|
height: 2,
|
||||||
|
marginInline: 8,
|
||||||
|
marginBottom: 20,
|
||||||
|
borderRadius: 2,
|
||||||
|
background: isComplete ? BRAND_GREEN : "var(--mantine-color-gray-2)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Group>
|
||||||
|
|
||||||
<div
|
<Paper
|
||||||
className={cn(
|
radius="md"
|
||||||
"rounded-xl border px-5 py-4 backdrop-blur-sm",
|
withBorder
|
||||||
isTerminal
|
p="md"
|
||||||
? "border-destructive/20 bg-destructive/5"
|
style={
|
||||||
: bookingGlass.activeTab,
|
isTerminal ? detailStyles.statusBannerTerminal : detailStyles.statusBanner
|
||||||
)}
|
}
|
||||||
>
|
>
|
||||||
<h4
|
<Text size="sm" fw={600} c={isTerminal ? "red.7" : "dark"}>
|
||||||
className={cn(
|
{title}
|
||||||
"text-sm font-semibold tracking-tight",
|
</Text>
|
||||||
isTerminal ? titleColor : "text-black",
|
<Text size="sm" c="dimmed" mt={4}>
|
||||||
)}
|
{description}
|
||||||
>
|
</Text>
|
||||||
{title}
|
</Paper>
|
||||||
</h4>
|
</SectionCard>
|
||||||
<p className="mt-1.5 text-sm leading-relaxed text-muted-foreground">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,29 @@
|
|||||||
import { ArrowRight } from "lucide-react";
|
import { ArrowRight } from "lucide-react";
|
||||||
|
import { Alert, Text } from "@mantine/core";
|
||||||
|
|
||||||
import type { BookingNextStep } from "@/types/booking";
|
import type { BookingNextStep } from "@/types/booking";
|
||||||
import { bookingGlass } from "./booking-ui.styles";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface NextStepBannerProps {
|
interface NextStepBannerProps {
|
||||||
nextStep: BookingNextStep;
|
nextStep: BookingNextStep;
|
||||||
className?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NextStepBanner({ nextStep, className }: NextStepBannerProps) {
|
export function NextStepBanner({ nextStep }: NextStepBannerProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<Alert
|
||||||
className={cn(
|
variant="light"
|
||||||
"flex items-start gap-3 rounded-xl px-4 py-3 text-sm",
|
color="gray"
|
||||||
bookingGlass.activeTab,
|
radius="md"
|
||||||
className,
|
icon={<ArrowRight size={16} />}
|
||||||
)}
|
title={
|
||||||
role="status"
|
<Text size="sm" fw={600}>
|
||||||
>
|
|
||||||
<ArrowRight className="mt-0.5 size-4 shrink-0 text-black" aria-hidden />
|
|
||||||
<div className="min-w-0 space-y-0.5">
|
|
||||||
<p className="font-semibold text-black">
|
|
||||||
Next: {nextStep.action.replace(/_/g, " ")}
|
Next: {nextStep.action.replace(/_/g, " ")}
|
||||||
{nextStep.requiredRole ? ` (${nextStep.requiredRole})` : ""}
|
{nextStep.requiredRole ? ` (${nextStep.requiredRole})` : ""}
|
||||||
</p>
|
</Text>
|
||||||
<p className="text-muted-foreground">{nextStep.description}</p>
|
}
|
||||||
</div>
|
>
|
||||||
</div>
|
<Text size="sm" c="dimmed">
|
||||||
|
{nextStep.description}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/** Shared surfaces for booking list & detail — frosted glass, neutral accents. */
|
/** Shared surfaces for booking list & detail — frosted glass, brand accents. */
|
||||||
|
|
||||||
export const bookingGlass = {
|
export const bookingGlass = {
|
||||||
card: "border border-border/50 bg-card/75 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-card/60",
|
card: "border border-border/50 bg-card/75 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-card/60",
|
||||||
@@ -10,9 +10,9 @@ export const bookingGlass = {
|
|||||||
iconWellHero:
|
iconWellHero:
|
||||||
"border border-border/50 bg-background/60 text-foreground shadow-sm ring-1 ring-border/30 backdrop-blur-md supports-[backdrop-filter]:bg-background/45",
|
"border border-border/50 bg-background/60 text-foreground shadow-sm ring-1 ring-border/30 backdrop-blur-md supports-[backdrop-filter]:bg-background/45",
|
||||||
activeTab:
|
activeTab:
|
||||||
"border border-emerald-500/20 bg-emerald-500/10 shadow-sm backdrop-blur-md ring-1 ring-emerald-500/10 supports-[backdrop-filter]:bg-emerald-500/[0.08]",
|
"border border-[color:var(--freight-brand-border)] bg-[color:var(--freight-brand-muted)] shadow-sm backdrop-blur-md ring-1 ring-[color:var(--freight-brand-ring)]",
|
||||||
iconWellGreen:
|
iconWellGreen:
|
||||||
"border border-emerald-500/20 bg-emerald-500/15 text-emerald-700 shadow-sm backdrop-blur-sm supports-[backdrop-filter]:bg-emerald-500/10 dark:text-emerald-400",
|
"border border-[color:var(--freight-brand-border)] bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] shadow-sm backdrop-blur-sm",
|
||||||
tabRail:
|
tabRail:
|
||||||
"rounded-xl border border-border/60 bg-muted/10 p-2 backdrop-blur-sm supports-[backdrop-filter]:bg-muted/5",
|
"rounded-xl border border-border/60 bg-muted/10 p-2 backdrop-blur-sm supports-[backdrop-filter]:bg-muted/5",
|
||||||
tableHeader:
|
tableHeader:
|
||||||
@@ -38,7 +38,7 @@ export const bookingSurface = {
|
|||||||
sectionIcon: `flex size-9 shrink-0 items-center justify-center rounded-lg ${bookingGlass.iconWellGreen}`,
|
sectionIcon: `flex size-9 shrink-0 items-center justify-center rounded-lg ${bookingGlass.iconWellGreen}`,
|
||||||
sectionIconLg: `flex size-11 shrink-0 items-center justify-center rounded-xl ${bookingGlass.iconWellGreen}`,
|
sectionIconLg: `flex size-11 shrink-0 items-center justify-center rounded-xl ${bookingGlass.iconWellGreen}`,
|
||||||
valueCard:
|
valueCard:
|
||||||
"rounded-xl border border-emerald-500/20 bg-emerald-500/10 p-4 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-emerald-500/[0.08]",
|
"rounded-xl border border-[color:var(--freight-brand-border)] bg-[color:var(--freight-brand-muted)] p-4 shadow-sm backdrop-blur-md",
|
||||||
stickySidebar: "lg:sticky lg:top-6 lg:self-start",
|
stickySidebar: "lg:sticky lg:top-6 lg:self-start",
|
||||||
metricTile:
|
metricTile:
|
||||||
"rounded-lg border border-border/50 bg-background/70 px-4 py-3 shadow-xs backdrop-blur-sm",
|
"rounded-lg border border-border/50 bg-background/70 px-4 py-3 shadow-xs backdrop-blur-sm",
|
||||||
@@ -48,7 +48,7 @@ export const bookingSurface = {
|
|||||||
|
|
||||||
export const bookingInput = {
|
export const bookingInput = {
|
||||||
search:
|
search:
|
||||||
"h-10 w-full rounded-lg border border-border/60 bg-background/80 pl-10 text-sm shadow-xs backdrop-blur-sm transition-[box-shadow,border-color] placeholder:text-muted-foreground focus-visible:border-ring/60 focus-visible:ring-[3px] focus-visible:ring-ring/20 sm:max-w-xs",
|
"h-10 w-full rounded-lg border border-border/60 bg-background/80 pl-10 text-sm shadow-xs backdrop-blur-sm transition-[box-shadow,border-color] placeholder:text-muted-foreground focus-visible:border-[color:var(--freight-brand)] focus-visible:ring-[3px] focus-visible:ring-[color:var(--freight-brand-ring)] sm:max-w-xs",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const bookingTable = {
|
export const bookingTable = {
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { CheckCircle, Clock, XCircle } from "lucide-react";
|
||||||
|
import { Group, Text, Badge, Timeline } from "@mantine/core";
|
||||||
|
|
||||||
|
import { SectionCard } from "./SectionCard";
|
||||||
|
import {
|
||||||
|
approvalStatusColor,
|
||||||
|
formatDateTime,
|
||||||
|
type BookingApprovalStepView,
|
||||||
|
} from "./booking-detail.styles";
|
||||||
|
|
||||||
|
export interface BookingApprovalCardProps {
|
||||||
|
steps: BookingApprovalStepView[];
|
||||||
|
approvedCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vertical timeline of the booking's approval chain. */
|
||||||
|
export function BookingApprovalCard({ steps, approvedCount }: BookingApprovalCardProps) {
|
||||||
|
return (
|
||||||
|
<SectionCard
|
||||||
|
icon={CheckCircle}
|
||||||
|
title="Approval Workflow"
|
||||||
|
extra={
|
||||||
|
<Badge color="green" variant="light" radius="sm">
|
||||||
|
{approvedCount} / {steps.length} approved
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Timeline active={approvedCount - 1} bulletSize={26} lineWidth={2} color="green">
|
||||||
|
{steps.map((step) => (
|
||||||
|
<Timeline.Item
|
||||||
|
key={step.id}
|
||||||
|
color={approvalStatusColor(step.status)}
|
||||||
|
bullet={
|
||||||
|
step.status === "APPROVED" ? (
|
||||||
|
<CheckCircle size={14} />
|
||||||
|
) : step.status === "REJECTED" ? (
|
||||||
|
<XCircle size={14} />
|
||||||
|
) : (
|
||||||
|
<Clock size={14} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
<Group gap="sm">
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
{step.requiredRole.replace(/_/g, " ")}
|
||||||
|
</Text>
|
||||||
|
<Badge
|
||||||
|
color={approvalStatusColor(step.status)}
|
||||||
|
size="xs"
|
||||||
|
radius="sm"
|
||||||
|
variant="light"
|
||||||
|
>
|
||||||
|
{step.status}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{step.actionedAt && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{formatDateTime(step.actionedAt)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Timeline.Item>
|
||||||
|
))}
|
||||||
|
</Timeline>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { Package } from "lucide-react";
|
||||||
|
import { SimpleGrid, Divider, Box, Table, Text } from "@mantine/core";
|
||||||
|
|
||||||
|
import type { BookingDetail } from "@/types/booking";
|
||||||
|
|
||||||
|
import { SectionCard } from "./SectionCard";
|
||||||
|
import { MetricTile } from "./MetricTile";
|
||||||
|
|
||||||
|
export interface BookingCargoCardProps {
|
||||||
|
booking: BookingDetail;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cargo specs + container manifest table. */
|
||||||
|
export function BookingCargoCard({ booking }: BookingCargoCardProps) {
|
||||||
|
const containers = booking.bookingContainers ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionCard icon={Package} title="Cargo specifications">
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm">
|
||||||
|
<MetricTile
|
||||||
|
label="Cargo type"
|
||||||
|
value={booking.cargoType?.label ?? booking.freightType}
|
||||||
|
/>
|
||||||
|
<MetricTile label="Total VGM" value={`${booking.cargoTotalWeightVgm} tons`} />
|
||||||
|
<MetricTile
|
||||||
|
label="Hazardous"
|
||||||
|
value={booking.isHazardous ? "Yes" : "No"}
|
||||||
|
highlight={booking.isHazardous}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{containers.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider my="lg" color="var(--mantine-color-gray-2)" />
|
||||||
|
<Box style={{ overflowX: "auto" }}>
|
||||||
|
<Table verticalSpacing="sm" horizontalSpacing="md" highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Container type</Table.Th>
|
||||||
|
<Table.Th>Qty</Table.Th>
|
||||||
|
<Table.Th>VGM / unit</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{containers.map((c) => (
|
||||||
|
<Table.Tr key={c.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
{c.containerType?.label ?? c.containerType?.code ?? c.containerTypeId}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{c.quantity}</Table.Td>
|
||||||
|
<Table.Td>{c.vgmPerUnitTons} t</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { Boxes } from "lucide-react";
|
||||||
|
import { Text, Badge, Box, Table } from "@mantine/core";
|
||||||
|
|
||||||
|
import { SectionCard } from "./SectionCard";
|
||||||
|
import type { BookingContainerView } from "./booking-detail.styles";
|
||||||
|
|
||||||
|
export interface BookingContainersCardProps {
|
||||||
|
containers: BookingContainerView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BookingContainersCard({ containers }: BookingContainersCardProps) {
|
||||||
|
return (
|
||||||
|
<SectionCard
|
||||||
|
icon={Boxes}
|
||||||
|
title="Containers & Cargo"
|
||||||
|
extra={
|
||||||
|
<Badge color="gray" variant="light" radius="sm">
|
||||||
|
{containers.length} line{containers.length === 1 ? "" : "s"}
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Box style={{ overflowX: "auto" }}>
|
||||||
|
<Table verticalSpacing="md" horizontalSpacing="md" highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Container Type</Table.Th>
|
||||||
|
<Table.Th>Qty</Table.Th>
|
||||||
|
<Table.Th>VGM / Unit</Table.Th>
|
||||||
|
<Table.Th>Total VGM</Table.Th>
|
||||||
|
<Table.Th>Size</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{containers.map((container) => (
|
||||||
|
<Table.Tr key={container.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
{container.containerType?.label}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{container.quantity}</Table.Td>
|
||||||
|
<Table.Td>{container.vgmPerUnitTons} t</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fw={600} c="green.7" size="sm">
|
||||||
|
{(container.quantity * container.vgmPerUnitTons).toFixed(2)} t
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge color="gray" variant="light" radius="sm">
|
||||||
|
{container.containerType?.sizeFt}FT
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Anchor } from "lucide-react";
|
||||||
|
import { Code } from "@mantine/core";
|
||||||
|
|
||||||
|
import { SectionCard } from "./SectionCard";
|
||||||
|
|
||||||
|
export interface BookingContractSummaryCardProps {
|
||||||
|
summary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generated contract terms, shown verbatim. */
|
||||||
|
export function BookingContractSummaryCard({ summary }: BookingContractSummaryCardProps) {
|
||||||
|
return (
|
||||||
|
<SectionCard icon={Anchor} title="Contract summary">
|
||||||
|
<Code
|
||||||
|
block
|
||||||
|
style={{
|
||||||
|
maxHeight: 256,
|
||||||
|
overflow: "auto",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
background: "var(--mantine-color-gray-0)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{summary}
|
||||||
|
</Code>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { Building2, Calendar, CheckCircle, Boxes, Truck } from "lucide-react";
|
||||||
|
import { Paper, Group, Stack, Title, Text, Divider, SimpleGrid } from "@mantine/core";
|
||||||
|
|
||||||
|
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||||
|
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||||
|
|
||||||
|
import { detailStyles, formatDate, type BookingDetailView } from "./booking-detail.styles";
|
||||||
|
|
||||||
|
export interface BookingDetailHeaderProps {
|
||||||
|
booking: BookingDetailView;
|
||||||
|
approvedCount: number;
|
||||||
|
totalSteps: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BookingDetailHeader({
|
||||||
|
booking,
|
||||||
|
approvedCount,
|
||||||
|
totalSteps,
|
||||||
|
}: BookingDetailHeaderProps) {
|
||||||
|
const kpis = [
|
||||||
|
{ icon: Truck, label: "Trade Direction", value: booking.tradeDirection },
|
||||||
|
{ icon: Calendar, label: "Scheduled", value: formatDate(booking.scheduledDate) },
|
||||||
|
{ icon: Boxes, label: "Freight Type", value: booking.freightType },
|
||||||
|
{
|
||||||
|
icon: CheckCircle,
|
||||||
|
label: "Approvals",
|
||||||
|
value: `${approvedCount} / ${totalSteps} complete`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper radius="md" withBorder mt="sm" mb="lg" p="xl" style={detailStyles.card}>
|
||||||
|
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||||
|
<Stack gap={6}>
|
||||||
|
<Group gap="sm" align="center">
|
||||||
|
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}>
|
||||||
|
{booking.reference}
|
||||||
|
</Title>
|
||||||
|
<BookingStatusBadge status={booking.status} />
|
||||||
|
</Group>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Building2 size={14} color="var(--mantine-color-gray-5)" />
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{booking.company?.companyName}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
•
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Created {formatDate(booking.createdAt)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
<BookingPriorityBadge score={booking.priorityScore} />
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Divider my="lg" color="var(--mantine-color-gray-2)" />
|
||||||
|
|
||||||
|
<SimpleGrid cols={{ base: 2, md: 4 }} spacing="xl">
|
||||||
|
{kpis.map((kpi) => (
|
||||||
|
<Group key={kpi.label} gap="sm" wrap="nowrap" align="center">
|
||||||
|
<kpi.icon size={18} color="var(--mantine-color-gray-5)" />
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
|
||||||
|
{kpi.label}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{kpi.value}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { ArrowLeft, Download, CheckCircle } from "lucide-react";
|
||||||
|
import { Group, Button } from "@mantine/core";
|
||||||
|
|
||||||
|
export interface BookingDetailToolbarProps {
|
||||||
|
onBack: () => void;
|
||||||
|
onExport?: () => void;
|
||||||
|
onAction?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Top action bar for the booking detail page. */
|
||||||
|
export function BookingDetailToolbar({
|
||||||
|
onBack,
|
||||||
|
onExport,
|
||||||
|
onAction,
|
||||||
|
}: BookingDetailToolbarProps) {
|
||||||
|
return (
|
||||||
|
<Group justify="space-between" mb="md">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
leftSection={<ArrowLeft size={18} />}
|
||||||
|
onClick={onBack}
|
||||||
|
fw={600}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Group gap="sm">
|
||||||
|
<Button variant="default" leftSection={<Download size={16} />} onClick={onExport}>
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
<Button color="green" leftSection={<CheckCircle size={16} />} onClick={onAction}>
|
||||||
|
Take Action
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { FileText, Download } from "lucide-react";
|
||||||
|
import { Group, Stack, Text, Badge, ThemeIcon, ActionIcon } from "@mantine/core";
|
||||||
|
|
||||||
|
import { SectionCard } from "./SectionCard";
|
||||||
|
import { detailStyles, type BookingFileView } from "./booking-detail.styles";
|
||||||
|
|
||||||
|
export interface BookingDocumentsCardProps {
|
||||||
|
files: BookingFileView[];
|
||||||
|
onDownload?: (file: BookingFileView) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List of attached documents with per-file download actions. */
|
||||||
|
export function BookingDocumentsCard({ files, onDownload }: BookingDocumentsCardProps) {
|
||||||
|
return (
|
||||||
|
<SectionCard
|
||||||
|
icon={FileText}
|
||||||
|
title="Documents"
|
||||||
|
extra={
|
||||||
|
<Badge color="gray" variant="light" radius="sm">
|
||||||
|
{files.length}
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{files.length === 0 ? (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
No documents attached.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Stack gap="xs">
|
||||||
|
{files.map((file) => (
|
||||||
|
<Group
|
||||||
|
key={file.id}
|
||||||
|
justify="space-between"
|
||||||
|
wrap="nowrap"
|
||||||
|
p="xs"
|
||||||
|
style={detailStyles.fileRow}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = "var(--mantine-color-gray-0)";
|
||||||
|
e.currentTarget.style.borderColor = "var(--freight-brand-border)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = "transparent";
|
||||||
|
e.currentTarget.style.borderColor = "var(--mantine-color-gray-2)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
|
<ThemeIcon size={32} radius="md" variant="light" color="red">
|
||||||
|
<FileText size={16} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="sm" fw={500} truncate>
|
||||||
|
{file.name}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
onClick={() => onDownload?.(file)}
|
||||||
|
aria-label={`Download ${file.name}`}
|
||||||
|
>
|
||||||
|
<Download size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Hash, Package, Ship, Weight, Clock } from "lucide-react";
|
||||||
|
import { Group, Stack, Text, Divider } from "@mantine/core";
|
||||||
|
|
||||||
|
import { SectionCard } from "./SectionCard";
|
||||||
|
import { formatDate, type BookingDetailView } from "./booking-detail.styles";
|
||||||
|
|
||||||
|
interface FactRowProps {
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
value: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FactRow({ icon: Icon, label, value }: FactRowProps) {
|
||||||
|
return (
|
||||||
|
<Group justify="space-between" wrap="nowrap" py={6}>
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<Icon size={15} color="var(--mantine-color-gray-5)" />
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text size="sm" fw={600} ta="right">
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookingFactsCardProps {
|
||||||
|
booking: BookingDetailView;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Key/value summary of the booking's reference data. */
|
||||||
|
export function BookingFactsCard({ booking }: BookingFactsCardProps) {
|
||||||
|
const facts: FactRowProps[] = [
|
||||||
|
{ icon: Hash, label: "PNR Code", value: booking.pnrCode || "—" },
|
||||||
|
{ icon: Package, label: "Cargo Type", value: booking.cargoType?.label ?? "—" },
|
||||||
|
{ icon: Ship, label: "Shipping Line", value: booking.shippingLine?.label ?? "—" },
|
||||||
|
{ icon: Weight, label: "VGM Weight", value: `${booking.cargoTotalWeightVgm} tons` },
|
||||||
|
{ icon: Clock, label: "Last Updated", value: formatDate(booking.updatedAt) },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionCard icon={Hash} title="Booking Details">
|
||||||
|
<Stack gap={0}>
|
||||||
|
{facts.map((fact, index) => (
|
||||||
|
<div key={fact.label}>
|
||||||
|
{index > 0 && <Divider />}
|
||||||
|
<FactRow {...fact} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { Check } from "lucide-react";
|
||||||
|
import { Paper, Group, Stack, Text, Box } from "@mantine/core";
|
||||||
|
|
||||||
|
import {
|
||||||
|
WORKFLOW_STAGES,
|
||||||
|
getWorkflowStageIndex,
|
||||||
|
} from "@/features/bookings/booking-status.config";
|
||||||
|
|
||||||
|
import { detailStyles, BRAND_GREEN } from "./booking-detail.styles";
|
||||||
|
|
||||||
|
export interface BookingLifecycleStepperProps {
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Horizontal lifecycle tracker showing how far the booking has progressed. */
|
||||||
|
export function BookingLifecycleStepper({ status }: BookingLifecycleStepperProps) {
|
||||||
|
const currentStage = getWorkflowStageIndex(status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper radius="md" withBorder p="xl" mb="lg" style={detailStyles.card}>
|
||||||
|
<Group gap={0} wrap="nowrap" align="flex-start">
|
||||||
|
{WORKFLOW_STAGES.map((stage, index) => {
|
||||||
|
const isComplete = currentStage >= 0 && index < currentStage;
|
||||||
|
const isActive = index === currentStage;
|
||||||
|
const isLast = index === WORKFLOW_STAGES.length - 1;
|
||||||
|
|
||||||
|
const circleBg = isComplete
|
||||||
|
? BRAND_GREEN
|
||||||
|
: isActive
|
||||||
|
? "white"
|
||||||
|
: "var(--mantine-color-gray-1)";
|
||||||
|
const circleBorder = isActive
|
||||||
|
? `2px solid ${BRAND_GREEN}`
|
||||||
|
: isComplete
|
||||||
|
? "2px solid transparent"
|
||||||
|
: "2px solid var(--mantine-color-gray-2)";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={stage.label} style={{ flex: isLast ? "0 0 auto" : 1, minWidth: 0 }}>
|
||||||
|
<Group gap={0} wrap="nowrap" align="center">
|
||||||
|
<Stack gap={6} align="center" style={{ flexShrink: 0 }}>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: "32px",
|
||||||
|
height: "32px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: circleBg,
|
||||||
|
border: circleBorder,
|
||||||
|
color: isComplete
|
||||||
|
? "white"
|
||||||
|
: isActive
|
||||||
|
? "var(--freight-brand-dark)"
|
||||||
|
: "var(--mantine-color-gray-5)",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isComplete ? (
|
||||||
|
<Check size={16} strokeWidth={3} />
|
||||||
|
) : (
|
||||||
|
<Text size="xs" fw={700}>
|
||||||
|
{index + 1}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
fw={isActive ? 600 : 500}
|
||||||
|
c={isActive ? "green.7" : isComplete ? "dark" : "dimmed"}
|
||||||
|
ta="center"
|
||||||
|
style={{ whiteSpace: "nowrap" }}
|
||||||
|
>
|
||||||
|
{stage.label}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{!isLast && (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
height: "2px",
|
||||||
|
marginInline: "8px",
|
||||||
|
marginBottom: "20px",
|
||||||
|
borderRadius: "2px",
|
||||||
|
background: isComplete
|
||||||
|
? BRAND_GREEN
|
||||||
|
: "var(--mantine-color-gray-2)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Truck } from "lucide-react";
|
||||||
|
import { SimpleGrid } from "@mantine/core";
|
||||||
|
|
||||||
|
import type { BookingDetail } from "@/types/booking";
|
||||||
|
|
||||||
|
import { SectionCard } from "./SectionCard";
|
||||||
|
import { MetricTile } from "./MetricTile";
|
||||||
|
|
||||||
|
export interface BookingMileServicesCardProps {
|
||||||
|
booking: BookingDetail;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First / last mile addresses. Renders nothing when neither is present. */
|
||||||
|
export function BookingMileServicesCard({ booking }: BookingMileServicesCardProps) {
|
||||||
|
if (!booking.firstMilePickupAddress && !booking.lastMileDeliveryAddress) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionCard icon={Truck} title="Mile services">
|
||||||
|
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm">
|
||||||
|
{booking.firstMilePickupAddress && (
|
||||||
|
<MetricTile label="First mile pickup" value={booking.firstMilePickupAddress} />
|
||||||
|
)}
|
||||||
|
{booking.lastMileDeliveryAddress && (
|
||||||
|
<MetricTile label="Last mile delivery" value={booking.lastMileDeliveryAddress} />
|
||||||
|
)}
|
||||||
|
</SimpleGrid>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Paper, Stack, Group, Text, Title, Badge } from "@mantine/core";
|
||||||
|
|
||||||
|
import { detailStyles } from "./booking-detail.styles";
|
||||||
|
|
||||||
|
export interface BookingPaymentCardProps {
|
||||||
|
totalAmount: number;
|
||||||
|
currency: string;
|
||||||
|
paymentStatus: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Key-figure card: total amount + payment status. Flat, lightly tinted. */
|
||||||
|
export function BookingPaymentCard({
|
||||||
|
totalAmount,
|
||||||
|
currency,
|
||||||
|
paymentStatus,
|
||||||
|
}: BookingPaymentCardProps) {
|
||||||
|
return (
|
||||||
|
<Paper radius="md" withBorder p="xl" style={detailStyles.highlightCard}>
|
||||||
|
<Stack gap={6}>
|
||||||
|
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
|
||||||
|
Total Amount
|
||||||
|
</Text>
|
||||||
|
<Group align="flex-end" gap="xs">
|
||||||
|
<Title order={1} fw={700} c="green.9" style={{ letterSpacing: "-1px" }}>
|
||||||
|
{totalAmount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
||||||
|
</Title>
|
||||||
|
<Text fw={600} c="green.7" mb={6}>
|
||||||
|
{currency}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Badge
|
||||||
|
color={paymentStatus === "PAID" ? "green" : "yellow"}
|
||||||
|
variant="light"
|
||||||
|
radius="sm"
|
||||||
|
mt="xs"
|
||||||
|
w="fit-content"
|
||||||
|
>
|
||||||
|
{paymentStatus}
|
||||||
|
</Badge>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { Building2, Calendar, Clock, RefreshCw, ArrowLeft } from "lucide-react";
|
||||||
|
import { Paper, Group, Stack, Title, Text, Button, Box } from "@mantine/core";
|
||||||
|
|
||||||
|
import type { BookingDetail } from "@/types/booking";
|
||||||
|
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||||
|
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||||
|
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
|
||||||
|
|
||||||
|
import { detailStyles, formatDate } from "./booking-detail.styles";
|
||||||
|
|
||||||
|
export interface BookingRequestHeroProps {
|
||||||
|
booking: BookingDetail;
|
||||||
|
customerLabel: string;
|
||||||
|
onBack: () => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
isFetching?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Top hero for the request detail page: identity, status, next step, total value. */
|
||||||
|
export function BookingRequestHero({
|
||||||
|
booking,
|
||||||
|
customerLabel,
|
||||||
|
onBack,
|
||||||
|
onRefresh,
|
||||||
|
isFetching,
|
||||||
|
}: BookingRequestHeroProps) {
|
||||||
|
const amount = Number(booking.totalAmount);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper radius="md" withBorder p="xl" style={detailStyles.card}>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size="compact-sm"
|
||||||
|
leftSection={<ArrowLeft size={16} />}
|
||||||
|
onClick={onBack}
|
||||||
|
mb="md"
|
||||||
|
ml={-8}
|
||||||
|
fw={600}
|
||||||
|
>
|
||||||
|
Back to list
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
|
||||||
|
<Stack gap="sm" style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lts="0.06em">
|
||||||
|
Booking reference
|
||||||
|
</Text>
|
||||||
|
<Group gap="sm" align="center" wrap="wrap">
|
||||||
|
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}>
|
||||||
|
{booking.reference}
|
||||||
|
</Title>
|
||||||
|
<BookingStatusBadge status={booking.status} />
|
||||||
|
<BookingPriorityBadge score={booking.priorityScore} />
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{booking.nextStep && (
|
||||||
|
<Box maw={520}>
|
||||||
|
<NextStepBanner nextStep={booking.nextStep} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group gap="lg" mt={4}>
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Building2 size={14} color="var(--mantine-color-gray-5)" />
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{customerLabel}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Calendar size={14} color="var(--mantine-color-gray-5)" />
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Scheduled {booking.scheduledDate}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Clock size={14} color="var(--mantine-color-gray-5)" />
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Created {formatDate(booking.createdAt)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack gap="sm" align="flex-end">
|
||||||
|
<Paper radius="md" withBorder p="md" miw={200} style={detailStyles.highlightCard}>
|
||||||
|
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em" ta="right">
|
||||||
|
Total value
|
||||||
|
</Text>
|
||||||
|
<Text size="xl" fw={700} c="green.9" ta="right" mt={4} style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||||
|
{booking.paymentCurrency}{" "}
|
||||||
|
{amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" ta="right" mt={2}>
|
||||||
|
{booking.paymentStatus}
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
leftSection={<RefreshCw size={15} />}
|
||||||
|
loading={isFetching}
|
||||||
|
onClick={onRefresh}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { FileText, MessageSquare } from "lucide-react";
|
||||||
|
import { Group, Stack, Text, Badge, ThemeIcon } from "@mantine/core";
|
||||||
|
|
||||||
|
import { SectionCard } from "./SectionCard";
|
||||||
|
import { formatDateTime, type BookingReviewNoteView } from "./booking-detail.styles";
|
||||||
|
|
||||||
|
export interface BookingReviewNotesCardProps {
|
||||||
|
notes: BookingReviewNoteView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chronological list of reviewer / compliance notes. */
|
||||||
|
export function BookingReviewNotesCard({ notes }: BookingReviewNotesCardProps) {
|
||||||
|
if (notes.length === 0) {
|
||||||
|
return (
|
||||||
|
<SectionCard icon={MessageSquare} title="Review Notes">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
No review notes have been added yet.
|
||||||
|
</Text>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionCard icon={MessageSquare} title="Review Notes">
|
||||||
|
<Stack gap="md">
|
||||||
|
{notes.map((note) => (
|
||||||
|
<Group key={note.id} align="flex-start" gap="md" wrap="nowrap">
|
||||||
|
<ThemeIcon size={34} radius="xl" variant="light" color="green">
|
||||||
|
<FileText size={16} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Stack gap={2} style={{ flex: 1 }}>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Badge color="green" variant="light" size="sm" radius="sm">
|
||||||
|
{note.type}
|
||||||
|
</Badge>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{formatDateTime(note.createdAt)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text size="sm" style={{ lineHeight: 1.5 }}>
|
||||||
|
{note.note}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { MapPin } from "lucide-react";
|
||||||
|
import { Group, Stack, Text, Box } from "@mantine/core";
|
||||||
|
|
||||||
|
import { SectionCard } from "./SectionCard";
|
||||||
|
import { detailStyles, type BookingDetailView } from "./booking-detail.styles";
|
||||||
|
|
||||||
|
export interface BookingRouteCardProps {
|
||||||
|
booking: BookingDetailView;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BookingRouteCard({ booking }: BookingRouteCardProps) {
|
||||||
|
return (
|
||||||
|
<SectionCard icon={MapPin} title="Shipment Route">
|
||||||
|
<Group justify="space-between" align="center" wrap="nowrap" gap="xl">
|
||||||
|
{/* Origin */}
|
||||||
|
<Stack gap={2} style={{ flex: 1 }}>
|
||||||
|
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
|
||||||
|
Origin
|
||||||
|
</Text>
|
||||||
|
<Text fw={600}>{booking.originYard?.label}</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{booking.originYard?.code}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Connector */}
|
||||||
|
<Box style={{ flex: 1.4 }}>
|
||||||
|
<Group gap={6} wrap="nowrap" align="center">
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "var(--freight-brand)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Box style={detailStyles.routeLine} />
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: "50%",
|
||||||
|
border: "2px solid var(--mantine-color-gray-4)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
<Text size="xs" c="dimmed" ta="center" mt={6}>
|
||||||
|
{booking.shippingLine?.label} · {booking.serviceType?.label}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Destination */}
|
||||||
|
<Stack gap={2} style={{ flex: 1 }} align="flex-end">
|
||||||
|
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
|
||||||
|
Destination
|
||||||
|
</Text>
|
||||||
|
<Text fw={600} ta="right">
|
||||||
|
{booking.destinationYard?.label}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{booking.destinationYard?.code}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { Train, MapPin, ArrowRight } from "lucide-react";
|
||||||
|
import { Group, Stack, Text, Badge, Box, SimpleGrid } from "@mantine/core";
|
||||||
|
|
||||||
|
import type { BookingDetail } from "@/types/booking";
|
||||||
|
|
||||||
|
import { SectionCard } from "./SectionCard";
|
||||||
|
import { MetricTile } from "./MetricTile";
|
||||||
|
|
||||||
|
export interface BookingRouteServiceCardProps {
|
||||||
|
booking: BookingDetail;
|
||||||
|
originLabel: string;
|
||||||
|
destinationLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Endpoint({
|
||||||
|
label,
|
||||||
|
station,
|
||||||
|
align = "left",
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
station: string;
|
||||||
|
align?: "left" | "right";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Stack gap={2} style={{ flex: 1, minWidth: 0 }} align={align === "right" ? "flex-end" : "flex-start"}>
|
||||||
|
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<MapPin size={15} color="var(--freight-brand)" />
|
||||||
|
<Text fw={600} truncate>
|
||||||
|
{station}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BookingRouteServiceCard({
|
||||||
|
booking,
|
||||||
|
originLabel,
|
||||||
|
destinationLabel,
|
||||||
|
}: BookingRouteServiceCardProps) {
|
||||||
|
const serviceLabel =
|
||||||
|
booking.serviceType?.label ?? booking.serviceType?.code ?? "Rail service";
|
||||||
|
|
||||||
|
const metrics = [
|
||||||
|
{ label: "Trade direction", value: booking.tradeDirection },
|
||||||
|
{ label: "Freight type", value: booking.freightType },
|
||||||
|
{ label: "Equipment return", value: booking.equipmentReturn ?? "—" },
|
||||||
|
...(booking.shippingLine
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: "Shipping line",
|
||||||
|
value:
|
||||||
|
booking.shippingLine.label ??
|
||||||
|
booking.shippingLine.name ??
|
||||||
|
booking.shippingLine.code ??
|
||||||
|
"—",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionCard icon={Train} title="Route & service">
|
||||||
|
<Group justify="space-between" align="center" wrap="nowrap" gap="md">
|
||||||
|
<Endpoint label="Origin" station={originLabel} />
|
||||||
|
<Stack gap={6} align="center" style={{ flexShrink: 0 }}>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "var(--mantine-color-gray-1)",
|
||||||
|
border: "1px solid var(--mantine-color-gray-3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Train size={18} color="var(--mantine-color-gray-7)" />
|
||||||
|
</Box>
|
||||||
|
<Badge variant="light" color="gray" size="sm" radius="sm" tt="uppercase">
|
||||||
|
{serviceLabel}
|
||||||
|
</Badge>
|
||||||
|
</Stack>
|
||||||
|
<Group gap={6} wrap="nowrap" style={{ flex: 1, justifyContent: "flex-end" }}>
|
||||||
|
<ArrowRight size={16} color="var(--mantine-color-gray-4)" style={{ flexShrink: 0 }} />
|
||||||
|
<Endpoint label="Destination" station={destinationLabel} align="right" />
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="sm" mt="lg">
|
||||||
|
{metrics.map((m) => (
|
||||||
|
<MetricTile key={m.label} label={m.label} value={m.value} />
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Paper, Text } from "@mantine/core";
|
||||||
|
|
||||||
|
export interface MetricTileProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
highlight?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Small flat label/value tile used across the detail sections. */
|
||||||
|
export function MetricTile({ label, value, highlight }: MetricTileProps) {
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
radius="md"
|
||||||
|
withBorder
|
||||||
|
px="md"
|
||||||
|
py="sm"
|
||||||
|
style={{
|
||||||
|
background: highlight ? "var(--mantine-color-yellow-0)" : "var(--mantine-color-gray-0)",
|
||||||
|
borderColor: highlight
|
||||||
|
? "var(--mantine-color-yellow-3)"
|
||||||
|
: "var(--mantine-color-gray-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={600} mt={4} style={{ lineHeight: 1.4 }}>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Paper, Group, Text, Box } from "@mantine/core";
|
||||||
|
|
||||||
|
import { detailStyles } from "./booking-detail.styles";
|
||||||
|
|
||||||
|
export interface SectionCardProps {
|
||||||
|
icon: LucideIcon;
|
||||||
|
title: string;
|
||||||
|
extra?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Consistent flat card with a minimal icon + title header used by every detail section. */
|
||||||
|
export function SectionCard({ icon: Icon, title, extra, children }: SectionCardProps) {
|
||||||
|
return (
|
||||||
|
<Paper radius="md" withBorder style={detailStyles.card}>
|
||||||
|
<Group justify="space-between" px="xl" py="md" style={detailStyles.cardHeader}>
|
||||||
|
<Group gap="sm">
|
||||||
|
<Icon size={16} color="var(--mantine-color-gray-6)" />
|
||||||
|
<Text fw={600} size="sm" c="dark">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
{extra}
|
||||||
|
</Group>
|
||||||
|
<Box px="xl" py="lg">
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
|
import { FREIGHT_BRAND } from "@/theme/freight-brand";
|
||||||
|
|
||||||
|
/** Single brand accent. Minimal design uses solid green sparingly, no gradients. */
|
||||||
|
export const BRAND_GREEN = FREIGHT_BRAND;
|
||||||
|
|
||||||
|
/** Centralised style tokens for the booking detail page + cards. */
|
||||||
|
export const detailStyles = {
|
||||||
|
page: {
|
||||||
|
background: "var(--mantine-color-gray-0)",
|
||||||
|
minHeight: "100vh",
|
||||||
|
} satisfies CSSProperties,
|
||||||
|
|
||||||
|
/** Flat white card — thin border, no shadow. */
|
||||||
|
card: {
|
||||||
|
background: "white",
|
||||||
|
borderColor: "var(--mantine-color-gray-2)",
|
||||||
|
} satisfies CSSProperties,
|
||||||
|
|
||||||
|
cardHeader: {
|
||||||
|
borderBottom: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
} satisfies CSSProperties,
|
||||||
|
|
||||||
|
/** Subtle key-figure card (e.g. payment) — neutral tint, still flat. */
|
||||||
|
highlightCard: {
|
||||||
|
background: "var(--mantine-color-gray-0)",
|
||||||
|
borderColor: "var(--mantine-color-gray-2)",
|
||||||
|
} satisfies CSSProperties,
|
||||||
|
|
||||||
|
/** Workflow status description banner — neutral default. */
|
||||||
|
statusBanner: {
|
||||||
|
background: "var(--mantine-color-gray-0)",
|
||||||
|
borderColor: "var(--mantine-color-gray-2)",
|
||||||
|
} satisfies CSSProperties,
|
||||||
|
|
||||||
|
/** Workflow status banner for terminal (rejected/cancelled) states. */
|
||||||
|
statusBannerTerminal: {
|
||||||
|
background: "var(--mantine-color-red-0)",
|
||||||
|
borderColor: "var(--mantine-color-red-2)",
|
||||||
|
} satisfies CSSProperties,
|
||||||
|
|
||||||
|
routeLine: {
|
||||||
|
flex: 1,
|
||||||
|
height: "1px",
|
||||||
|
background: "var(--mantine-color-gray-3)",
|
||||||
|
} satisfies CSSProperties,
|
||||||
|
|
||||||
|
fileRow: {
|
||||||
|
borderRadius: "8px",
|
||||||
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
transition: "background 0.12s ease, border-color 0.12s ease",
|
||||||
|
} satisfies CSSProperties,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Map an approval-step status to a Mantine colour. */
|
||||||
|
export function approvalStatusColor(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case "APPROVED":
|
||||||
|
return "green";
|
||||||
|
case "PENDING":
|
||||||
|
return "yellow";
|
||||||
|
case "REJECTED":
|
||||||
|
return "red";
|
||||||
|
default:
|
||||||
|
return "gray";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- View model -----------------------------------------------------------
|
||||||
|
|
||||||
|
export interface BookingNamedRefView {
|
||||||
|
id: string;
|
||||||
|
label?: string;
|
||||||
|
code?: string;
|
||||||
|
companyName?: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookingContainerView {
|
||||||
|
id: string;
|
||||||
|
quantity: number;
|
||||||
|
vgmPerUnitTons: number;
|
||||||
|
containerType?: {
|
||||||
|
label?: string;
|
||||||
|
sizeFt?: number;
|
||||||
|
isReefer?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookingApprovalStepView {
|
||||||
|
id: string;
|
||||||
|
stepOrder: number;
|
||||||
|
requiredRole: string;
|
||||||
|
status: string;
|
||||||
|
actionedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookingReviewNoteView {
|
||||||
|
id: string;
|
||||||
|
note: string;
|
||||||
|
type: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookingFileView {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
mimeType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookingDetailView {
|
||||||
|
id: string;
|
||||||
|
reference: string;
|
||||||
|
status: string;
|
||||||
|
scheduledDate: string;
|
||||||
|
totalAmount: number;
|
||||||
|
paymentCurrency: string;
|
||||||
|
paymentStatus: string;
|
||||||
|
tradeDirection: string;
|
||||||
|
freightType: string;
|
||||||
|
priorityScore: number;
|
||||||
|
cargoTotalWeightVgm: number;
|
||||||
|
pnrCode?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
company?: BookingNamedRefView;
|
||||||
|
originYard?: BookingNamedRefView;
|
||||||
|
destinationYard?: BookingNamedRefView;
|
||||||
|
serviceType?: BookingNamedRefView;
|
||||||
|
cargoType?: BookingNamedRefView;
|
||||||
|
shippingLine?: BookingNamedRefView;
|
||||||
|
bookingContainers?: BookingContainerView[];
|
||||||
|
approvalSteps?: BookingApprovalStepView[];
|
||||||
|
reviewNotes?: BookingReviewNoteView[];
|
||||||
|
files?: BookingFileView[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export * from "./booking-detail.styles";
|
||||||
|
export * from "./SectionCard";
|
||||||
|
export * from "./MetricTile";
|
||||||
|
export * from "./BookingDetailToolbar";
|
||||||
|
export * from "./BookingDetailHeader";
|
||||||
|
export * from "./BookingLifecycleStepper";
|
||||||
|
export * from "./BookingRouteCard";
|
||||||
|
export * from "./BookingContainersCard";
|
||||||
|
export * from "./BookingApprovalCard";
|
||||||
|
export * from "./BookingReviewNotesCard";
|
||||||
|
export * from "./BookingPaymentCard";
|
||||||
|
export * from "./BookingFactsCard";
|
||||||
|
export * from "./BookingDocumentsCard";
|
||||||
|
export * from "./BookingRequestHero";
|
||||||
|
export * from "./BookingRouteServiceCard";
|
||||||
|
export * from "./BookingMileServicesCard";
|
||||||
|
export * from "./BookingCargoCard";
|
||||||
|
export * from "./BookingContractSummaryCard";
|
||||||
@@ -86,14 +86,8 @@ export function useBookingActionDialog(
|
|||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "generateContract":
|
|
||||||
mutations.generateContract.mutate(undefined, { onSuccess });
|
|
||||||
break;
|
|
||||||
case "viewContract":
|
case "viewContract":
|
||||||
break;
|
break;
|
||||||
case "payBooking":
|
|
||||||
mutations.payBooking.mutate(undefined, { onSuccess });
|
|
||||||
break;
|
|
||||||
case "startTransit":
|
case "startTransit":
|
||||||
mutations.startTransit.mutate(undefined, { onSuccess });
|
mutations.startTransit.mutate(undefined, { onSuccess });
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -9,13 +9,10 @@ import {
|
|||||||
Sun,
|
Sun,
|
||||||
User,
|
User,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { Group, Stack, Text, Avatar, Menu, ActionIcon, Badge, Box } from "@mantine/core";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
import type { PageMeta } from "./types";
|
import type { PageMeta } from "./types";
|
||||||
|
import { freightBrand } from "@/theme/freight-brand";
|
||||||
const iconButtonClass =
|
|
||||||
"relative inline-flex h-10 w-10 items-center justify-center rounded-xl border border-gray-200 bg-white text-gray-600 shadow-sm transition hover:border-primary/30 hover:bg-gray-50 hover:text-gray-900";
|
|
||||||
|
|
||||||
export interface FreightDashboardHeaderProps {
|
export interface FreightDashboardHeaderProps {
|
||||||
pageMeta: PageMeta;
|
pageMeta: PageMeta;
|
||||||
@@ -77,120 +74,146 @@ const FreightDashboardHeader = ({
|
|||||||
}, [isUserMenuOpen]);
|
}, [isUserMenuOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex h-20 shrink-0 items-center justify-between gap-4 px-6">
|
<header
|
||||||
<div className="min-w-0">
|
style={{
|
||||||
<h1 className="truncate text-xl font-bold tracking-tight text-foreground">
|
display: "flex",
|
||||||
|
height: "80px",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: "16px",
|
||||||
|
padding: "0 24px",
|
||||||
|
// borderBottom: `3px solid ${freightBrand.primary}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text size="lg" fw={700} truncate style={{ color: freightBrand.primaryDark }}>
|
||||||
{pageMeta.title}
|
{pageMeta.title}
|
||||||
</h1>
|
</Text>
|
||||||
<p className="mt-0.5 truncate text-sm text-secondary-foreground">
|
<Text size="sm" c="dimmed" truncate>
|
||||||
{pageMeta.subtitle}
|
{pageMeta.subtitle}
|
||||||
</p>
|
</Text>
|
||||||
</div>
|
</Stack>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<Group gap="sm" wrap="nowrap">
|
||||||
{enableThemeToggle ? (
|
{enableThemeToggle && (
|
||||||
<button
|
<ActionIcon
|
||||||
type="button"
|
variant="default"
|
||||||
|
size={40}
|
||||||
|
radius="lg"
|
||||||
onClick={onToggleTheme}
|
onClick={onToggleTheme}
|
||||||
aria-label={
|
style={{
|
||||||
theme === "dark" ? "Switch to light mode" : "Switch to dark mode"
|
background: "var(--mantine-color-gray-1)",
|
||||||
}
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
className={iconButtonClass}
|
color: "var(--mantine-color-gray-7)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{theme === "dark" ? (
|
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
|
||||||
<Sun className="h-5 w-5" />
|
</ActionIcon>
|
||||||
) : (
|
)}
|
||||||
<Moon className="h-5 w-5" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<button
|
<ActionIcon
|
||||||
type="button"
|
variant="default"
|
||||||
aria-label="Change language"
|
size={40}
|
||||||
className={iconButtonClass}
|
radius="lg"
|
||||||
|
style={{
|
||||||
|
background: "var(--mantine-color-gray-1)",
|
||||||
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
color: "var(--mantine-color-gray-7)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Languages className="h-5 w-5" />
|
<Languages size={18} />
|
||||||
</button>
|
</ActionIcon>
|
||||||
|
|
||||||
<button type="button" aria-label="Messages" className={iconButtonClass}>
|
<ActionIcon
|
||||||
<MessageSquare className="h-5 w-5" />
|
variant="default"
|
||||||
<span className="absolute right-2 top-2 h-2 w-2 rounded-full bg-red-500 ring-2 ring-white" />
|
size={40}
|
||||||
</button>
|
radius="lg"
|
||||||
|
style={{
|
||||||
<button
|
background: "var(--mantine-color-gray-1)",
|
||||||
type="button"
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
aria-label="Notifications"
|
color: "var(--mantine-color-gray-7)",
|
||||||
className={iconButtonClass}
|
position: "relative",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Bell className="h-5 w-5" />
|
<MessageSquare size={18} />
|
||||||
<span className="absolute right-2 top-2 h-2 w-2 rounded-full bg-red-500 ring-2 ring-white" />
|
<Badge
|
||||||
</button>
|
size="xs"
|
||||||
|
color="red"
|
||||||
|
circle
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "-3px",
|
||||||
|
right: "-3px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ActionIcon>
|
||||||
|
|
||||||
<div ref={userMenuRef} className="relative ml-1">
|
<ActionIcon
|
||||||
<button
|
variant="default"
|
||||||
type="button"
|
size={40}
|
||||||
aria-haspopup="menu"
|
radius="lg"
|
||||||
aria-expanded={isUserMenuOpen}
|
style={{
|
||||||
onClick={() => setIsUserMenuOpen((open) => !open)}
|
background: "var(--mantine-color-gray-1)",
|
||||||
className={cn(
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
"flex items-center gap-2 rounded-xl border border-transparent px-2 py-1.5 transition",
|
color: "var(--mantine-color-gray-7)",
|
||||||
isUserMenuOpen
|
position: "relative",
|
||||||
? "border-primary/30 bg-primary/5"
|
}}
|
||||||
: "hover:border-primary/20 hover:bg-gray-50",
|
>
|
||||||
)}
|
<Bell size={18} />
|
||||||
>
|
<Badge
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground">
|
size="xs"
|
||||||
{initials}
|
color="red"
|
||||||
</div>
|
circle
|
||||||
<ChevronDown
|
style={{
|
||||||
className={cn(
|
position: "absolute",
|
||||||
"hidden h-4 w-4 text-gray-400 transition sm:block",
|
top: "-3px",
|
||||||
isUserMenuOpen && "rotate-180 text-primary",
|
right: "-3px",
|
||||||
)}
|
}}
|
||||||
/>
|
/>
|
||||||
</button>
|
</ActionIcon>
|
||||||
|
|
||||||
{isUserMenuOpen ? (
|
<Menu position="bottom-end" shadow="md" opened={isUserMenuOpen} onOpen={() => setIsUserMenuOpen(true)} onClose={() => setIsUserMenuOpen(false)}>
|
||||||
<div
|
<Menu.Target>
|
||||||
role="menu"
|
<Group gap="sm" p="xs" style={{ cursor: "pointer", borderRadius: "12px" }}>
|
||||||
className="absolute right-0 top-full z-50 mt-2 w-52 overflow-hidden rounded-xl border border-gray-200 bg-white py-1 shadow-lg"
|
<Avatar name={initials} color="green" size="md" styles={{ root: { background: freightBrand.primary } }} />
|
||||||
>
|
<ChevronDown size={16} style={{ transition: "transform 0.2s", transform: isUserMenuOpen ? "rotate(180deg)" : "rotate(0deg)" }} />
|
||||||
<div className="border-b border-gray-100 px-4 py-3">
|
</Group>
|
||||||
<p className="text-sm font-semibold text-gray-900">
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Item disabled>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
{userName}
|
{userName}
|
||||||
</p>
|
</Text>
|
||||||
{userEmail ? (
|
{userEmail && (
|
||||||
<p className="text-xs text-gray-500">{userEmail}</p>
|
<Text size="xs" c="dimmed">
|
||||||
) : null}
|
{userEmail}
|
||||||
</div>
|
</Text>
|
||||||
<a
|
)}
|
||||||
href="#profile"
|
</Stack>
|
||||||
role="menuitem"
|
</Menu.Item>
|
||||||
onClick={() => setIsUserMenuOpen(false)}
|
<Menu.Divider />
|
||||||
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 transition hover:bg-gray-50"
|
<Menu.Item
|
||||||
>
|
leftSection={<User size={14} />}
|
||||||
<User className="h-4 w-4" />
|
onClick={() => setIsUserMenuOpen(false)}
|
||||||
Profile
|
>
|
||||||
</a>
|
Profile
|
||||||
<button
|
</Menu.Item>
|
||||||
type="button"
|
<Menu.Item
|
||||||
role="menuitem"
|
leftSection={<LogOut size={14} />}
|
||||||
onClick={() => {
|
color="red"
|
||||||
setIsUserMenuOpen(false);
|
onClick={() => {
|
||||||
onLogout?.();
|
setIsUserMenuOpen(false);
|
||||||
}}
|
onLogout?.();
|
||||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-red-600 transition hover:bg-red-50"
|
}}
|
||||||
>
|
>
|
||||||
<LogOut className="h-4 w-4" />
|
Logout
|
||||||
Logout
|
</Menu.Item>
|
||||||
</button>
|
</Menu.Dropdown>
|
||||||
</div>
|
</Menu>
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{headerRight}
|
{headerRight}
|
||||||
</div>
|
</Group>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { type ReactNode, useEffect, useState } from "react";
|
import { type ReactNode, useEffect, useState } from "react";
|
||||||
|
import { Box, Paper, MantineProvider } from "@mantine/core";
|
||||||
|
|
||||||
import FreightDashboardHeader from "./FreightDashboardHeader";
|
import FreightDashboardHeader from "./FreightDashboardHeader";
|
||||||
import FreightSidebar from "./FreightSidebar";
|
import FreightSidebar from "./FreightSidebar";
|
||||||
import { getPageMeta } from "./route-meta";
|
import { getPageMeta } from "./route-meta";
|
||||||
import type { SidebarSection } from "./types";
|
import type { SidebarSection } from "./types";
|
||||||
|
import { freightMantineTheme } from "@/theme/freight-brand";
|
||||||
|
|
||||||
type Theme = "light" | "dark";
|
type Theme = "light" | "dark";
|
||||||
const THEME_STORAGE_KEY = "edr-theme";
|
const THEME_STORAGE_KEY = "edr-theme";
|
||||||
@@ -30,9 +32,6 @@ export interface FreightDashboardLayoutProps {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const panelClass =
|
|
||||||
"rounded-lg border border-gray-200/80 bg-white shadow-[0_1px_3px_rgba(15,23,42,0.06)]";
|
|
||||||
|
|
||||||
const FreightDashboardLayout = ({
|
const FreightDashboardLayout = ({
|
||||||
sidebarSections,
|
sidebarSections,
|
||||||
activeHref = "",
|
activeHref = "",
|
||||||
@@ -73,40 +72,79 @@ const FreightDashboardLayout = ({
|
|||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<MantineProvider theme={freightMantineTheme}>
|
||||||
className="flex h-[100dvh] overflow-hidden bg-[#eceef2] p-2 antialiased"
|
<Box
|
||||||
style={{ fontFamily: "'Outfit', var(--font-sans)" }}
|
style={{
|
||||||
>
|
display: "flex",
|
||||||
<div className="flex h-full min-h-0 w-full gap-2">
|
height: "100dvh",
|
||||||
<FreightSidebar
|
overflow: "hidden",
|
||||||
sections={sidebarSections}
|
background: "var(--mantine-color-gray-1)",
|
||||||
activeHref={activeHref}
|
padding: "8px",
|
||||||
onNavigate={onNavigate}
|
fontFamily: "'Outfit', var(--font-sans)",
|
||||||
/>
|
}}
|
||||||
|
>
|
||||||
|
<Box style={{ display: "flex", height: "100%", minHeight: 0, width: "100%", gap: "8px" }}>
|
||||||
|
<FreightSidebar
|
||||||
|
sections={sidebarSections}
|
||||||
|
activeHref={activeHref}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col gap-2">
|
<Box
|
||||||
<div className={`shrink-0 ${panelClass}`}>
|
style={{
|
||||||
<FreightDashboardHeader
|
display: "flex",
|
||||||
pageMeta={pageMeta}
|
height: "100%",
|
||||||
headerRight={headerRight}
|
minHeight: 0,
|
||||||
enableThemeToggle={enableThemeToggle}
|
minWidth: 0,
|
||||||
userName={userName}
|
flex: 1,
|
||||||
userEmail={userEmail}
|
flexDirection: "column",
|
||||||
userInitials={userInitials}
|
gap: "8px",
|
||||||
onLogout={onLogout}
|
}}
|
||||||
theme={theme}
|
|
||||||
onToggleTheme={toggleTheme}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<main
|
|
||||||
className={`min-h-0 flex-1 overflow-y-auto overscroll-contain ${panelClass} p-4 md:p-6`}
|
|
||||||
>
|
>
|
||||||
{children}
|
<Paper
|
||||||
</main>
|
p={0}
|
||||||
</div>
|
radius="lg"
|
||||||
</div>
|
withBorder
|
||||||
</div>
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
background: "white",
|
||||||
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.05)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FreightDashboardHeader
|
||||||
|
pageMeta={pageMeta}
|
||||||
|
headerRight={headerRight}
|
||||||
|
enableThemeToggle={enableThemeToggle}
|
||||||
|
userName={userName}
|
||||||
|
userEmail={userEmail}
|
||||||
|
userInitials={userInitials}
|
||||||
|
onLogout={onLogout}
|
||||||
|
theme={theme}
|
||||||
|
onToggleTheme={toggleTheme}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper
|
||||||
|
p={{ base: 16, md: 24 }}
|
||||||
|
radius="lg"
|
||||||
|
withBorder
|
||||||
|
style={{
|
||||||
|
minHeight: 0,
|
||||||
|
flex: 1,
|
||||||
|
overflowY: "auto",
|
||||||
|
overscrollBehavior: "contain",
|
||||||
|
background: "white",
|
||||||
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.05)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</MantineProvider>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,13 +5,11 @@ import {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
import { ChevronDown, ChevronRight, Train } from "lucide-react";
|
||||||
|
import { Stack, Group, Text, Box, UnstyledButton, NavLink } from "@mantine/core";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
import type { SidebarItem, SidebarSection } from "./types";
|
import type { SidebarItem, SidebarSection } from "./types";
|
||||||
|
import { freightBrand } from "@/theme/freight-brand";
|
||||||
const EDR_LOGO = "/assets/logo.svg";
|
|
||||||
|
|
||||||
export interface FreightSidebarProps {
|
export interface FreightSidebarProps {
|
||||||
sections: SidebarSection[];
|
sections: SidebarSection[];
|
||||||
@@ -103,25 +101,6 @@ const FreightSidebar = ({
|
|||||||
setExpanded((current) => ({ ...current, [key]: !current[key] }));
|
setExpanded((current) => ({ ...current, [key]: !current[key] }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const navLinkClass = (active: boolean, depth: number) =>
|
|
||||||
cn(
|
|
||||||
"flex items-center justify-between rounded-md px-3 py-2.5 text-base font-medium leading-snug transition-colors",
|
|
||||||
active
|
|
||||||
? "bg-primary text-primary-foreground shadow-sm"
|
|
||||||
: "text-gray-900 hover:bg-gray-100",
|
|
||||||
depth > 0 && "text-[15px]",
|
|
||||||
);
|
|
||||||
|
|
||||||
const iconClass = (active: boolean, sectionActive: boolean) =>
|
|
||||||
cn(
|
|
||||||
"flex h-5 w-5 shrink-0 items-center justify-center [&_svg]:h-5 [&_svg]:w-5",
|
|
||||||
active
|
|
||||||
? "text-primary-foreground"
|
|
||||||
: sectionActive
|
|
||||||
? "text-gray-900"
|
|
||||||
: "text-gray-900",
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderNavBranch = (
|
const renderNavBranch = (
|
||||||
children: SidebarItem[],
|
children: SidebarItem[],
|
||||||
depth: number,
|
depth: number,
|
||||||
@@ -136,37 +115,37 @@ const FreightSidebar = ({
|
|||||||
const groupActive = branchContainsActive(child.children!);
|
const groupActive = branchContainsActive(child.children!);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={key} className="flex flex-col gap-0.5">
|
<Stack key={key} gap={4}>
|
||||||
<button
|
<UnstyledButton
|
||||||
type="button"
|
|
||||||
aria-expanded={isOpen}
|
|
||||||
onClick={() => toggleExpanded(key)}
|
onClick={() => toggleExpanded(key)}
|
||||||
className={cn(
|
style={{
|
||||||
"flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide transition-colors",
|
background: groupActive ? freightBrand.mutedBg : "transparent",
|
||||||
groupActive
|
padding: "8px 12px",
|
||||||
? "bg-gray-100 text-gray-900"
|
borderRadius: "8px",
|
||||||
: "text-gray-900 hover:bg-gray-100",
|
width: "100%",
|
||||||
)}
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="truncate">{child.label}</span>
|
<Group justify="space-between">
|
||||||
<ChevronDown
|
<Text size="xs" fw={600} style={{ color: groupActive ? freightBrand.primary : undefined }} c={groupActive ? undefined : "dimmed"} tt="uppercase">
|
||||||
className={cn(
|
{child.label}
|
||||||
"h-4 w-4 shrink-0 text-gray-900 transition-transform",
|
</Text>
|
||||||
isOpen ? "rotate-0" : "-rotate-90",
|
<ChevronDown
|
||||||
)}
|
size={14}
|
||||||
/>
|
style={{
|
||||||
</button>
|
transform: isOpen ? "rotate(0deg)" : "rotate(-90deg)",
|
||||||
{isOpen ? (
|
transition: "transform 0.2s",
|
||||||
<div
|
color: groupActive ? freightBrand.primary : "var(--mantine-color-gray-5)",
|
||||||
className={cn(
|
}}
|
||||||
"flex flex-col gap-0.5 border-l border-gray-200",
|
/>
|
||||||
depth === 0 ? "ml-3 pl-2" : "ml-2 pl-2",
|
</Group>
|
||||||
)}
|
</UnstyledButton>
|
||||||
>
|
{isOpen && (
|
||||||
|
<Stack gap={2} style={{ paddingLeft: "12px", borderLeft: `2px solid ${freightBrand.mutedBorder}` }}>
|
||||||
{renderNavBranch(child.children!, depth + 1, key)}
|
{renderNavBranch(child.children!, depth + 1, key)}
|
||||||
</div>
|
</Stack>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,24 +155,47 @@ const FreightSidebar = ({
|
|||||||
const childActiveHref = isHrefActive(childHref);
|
const childActiveHref = isHrefActive(childHref);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<NavLink
|
||||||
key={key}
|
key={key}
|
||||||
|
component="a"
|
||||||
href={child.href}
|
href={child.href}
|
||||||
onClick={(event) => navigateTo(event, child.href!)}
|
onClick={(e) => navigateTo(e as any, child.href!)}
|
||||||
aria-current={childActiveHref ? "page" : undefined}
|
label={child.label}
|
||||||
className={navLinkClass(childActiveHref, depth)}
|
active={childActiveHref}
|
||||||
>
|
color="green"
|
||||||
<span className="truncate">{child.label}</span>
|
style={{
|
||||||
<ChevronRight
|
borderRadius: "8px",
|
||||||
className={cn(
|
cursor: "pointer",
|
||||||
"h-4 w-4 shrink-0",
|
fontSize: "14px",
|
||||||
childActiveHref ? "text-primary-foreground/80" : "text-gray-900",
|
}}
|
||||||
)}
|
rightSection={<ChevronRight size={16} />}
|
||||||
/>
|
/>
|
||||||
</a>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const renderIconWell = (icon: React.ReactNode, active: boolean) => {
|
||||||
|
if (!icon) return null;
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: "30px",
|
||||||
|
height: "30px",
|
||||||
|
borderRadius: "8px",
|
||||||
|
flexShrink: 0,
|
||||||
|
background: active ? freightBrand.gradient : "var(--mantine-color-gray-1)",
|
||||||
|
color: active ? "white" : "var(--mantine-color-gray-6)",
|
||||||
|
boxShadow: active ? freightBrand.shadowSm : "none",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const renderTopLevelItem = (item: SidebarItem) => {
|
const renderTopLevelItem = (item: SidebarItem) => {
|
||||||
if (!item.href) return null;
|
if (!item.href) return null;
|
||||||
|
|
||||||
@@ -211,99 +213,127 @@ const FreightSidebar = ({
|
|||||||
const leafActive = isCurrentItem && !hasChildren;
|
const leafActive = isCurrentItem && !hasChildren;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={item.href} className="flex flex-col gap-0.5">
|
<Stack key={item.href} gap={0}>
|
||||||
<div
|
<NavLink
|
||||||
className={cn(
|
component="a"
|
||||||
"group flex items-center rounded-md transition-colors",
|
href={item.href}
|
||||||
leafActive
|
onClick={(e) => navigateTo(e as any, item.href!)}
|
||||||
? "bg-primary text-primary-foreground shadow-sm"
|
label={item.label}
|
||||||
: isSectionActive || (hasChildren && isCurrentItem)
|
leftSection={renderIconWell(item.icon, isActive)}
|
||||||
? "bg-gray-100 text-gray-900"
|
active={leafActive}
|
||||||
: "text-gray-900 hover:bg-gray-100",
|
color="green"
|
||||||
)}
|
variant="light"
|
||||||
>
|
style={{
|
||||||
<a
|
borderRadius: "10px",
|
||||||
href={item.href}
|
cursor: "pointer",
|
||||||
onClick={(event) => navigateTo(event, item.href!)}
|
fontSize: "14px",
|
||||||
aria-current={isCurrentItem ? "page" : undefined}
|
fontWeight: 500,
|
||||||
className="flex min-w-0 flex-1 items-center gap-3 px-3 py-2.5 text-sm leading-snug"
|
padding: "8px 10px",
|
||||||
>
|
}}
|
||||||
{item.icon ? (
|
rightSection={
|
||||||
<span className={iconClass(leafActive, isActive)}>
|
hasChildren ? (
|
||||||
{item.icon}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
<span className="truncate">{item.label}</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
{hasChildren ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={`Toggle ${item.label}`}
|
|
||||||
aria-expanded={isOpen}
|
|
||||||
onClick={() => toggleExpanded(item.href!)}
|
|
||||||
className={cn(
|
|
||||||
"mr-2 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md transition-colors",
|
|
||||||
leafActive
|
|
||||||
? "text-primary-foreground hover:bg-white/10"
|
|
||||||
: "text-gray-900 hover:bg-gray-200/80",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={cn(
|
size={16}
|
||||||
"h-4 w-4 transition-transform",
|
style={{
|
||||||
isOpen ? "rotate-0" : "-rotate-90",
|
transform: isOpen ? "rotate(0deg)" : "rotate(-90deg)",
|
||||||
)}
|
transition: "transform 0.2s",
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleExpanded(item.href!);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</button>
|
) : (
|
||||||
) : (
|
<ChevronRight size={16} />
|
||||||
<span
|
)
|
||||||
className={cn(
|
}
|
||||||
"mr-3 flex h-4 w-4 shrink-0 items-center justify-center",
|
/>
|
||||||
leafActive ? "text-primary-foreground/80" : "text-gray-900",
|
|
||||||
)}
|
|
||||||
aria-hidden
|
|
||||||
>
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasChildren && isOpen ? (
|
{hasChildren && isOpen && (
|
||||||
<div className="ml-3 flex flex-col gap-1 border-l border-gray-200 pl-2">
|
<Stack gap={4} style={{ paddingLeft: "16px", borderLeft: `2px solid ${freightBrand.mutedBorder}` }}>
|
||||||
{renderNavBranch(item.children!, 0, item.href)}
|
{renderNavBranch(item.children!, 0, item.href)}
|
||||||
</div>
|
</Stack>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="flex h-full max-h-full w-[280px] shrink-0 flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm">
|
<Box
|
||||||
<div className="flex shrink-0 items-center gap-2.5 border-b border-gray-100 px-5 py-5">
|
component="aside"
|
||||||
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto" />
|
style={{
|
||||||
<span className="text-lg font-semibold tracking-tight text-gray-900">
|
height: "100%",
|
||||||
EDR Freight
|
maxHeight: "100%",
|
||||||
</span>
|
width: "280px",
|
||||||
</div>
|
flexShrink: 0,
|
||||||
|
borderRadius: "12px",
|
||||||
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
background: "white",
|
||||||
|
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.05)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group
|
||||||
|
gap={12}
|
||||||
|
px="lg"
|
||||||
|
py="md"
|
||||||
|
style={{
|
||||||
|
borderBottom: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
flexShrink: 0,
|
||||||
|
height: "80px",
|
||||||
|
}}
|
||||||
|
wrap="nowrap"
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: "44px",
|
||||||
|
height: "44px",
|
||||||
|
borderRadius: "12px",
|
||||||
|
background: freightBrand.gradient,
|
||||||
|
boxShadow: freightBrand.shadow,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Train size={24} color="white" strokeWidth={2} />
|
||||||
|
</Box>
|
||||||
|
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||||
|
<Text size="md" fw={700} style={{ letterSpacing: "-0.3px", lineHeight: 1.2 }}>
|
||||||
|
EDR Freight
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" fw={500} style={{ letterSpacing: "0.3px" }}>
|
||||||
|
Backoffice
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
|
||||||
<nav className="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto overscroll-contain px-3 py-4">
|
<Stack
|
||||||
|
component="nav"
|
||||||
|
gap="lg"
|
||||||
|
p="md"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
overflowY: "auto",
|
||||||
|
overscrollBehavior: "contain",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{sections.map((section) => (
|
{sections.map((section) => (
|
||||||
<div key={section.title} className="flex flex-col gap-1">
|
<Stack key={section.title} gap={8}>
|
||||||
<p
|
<Text size="xs" fw={600} c="dimmed" tt="uppercase" style={{ letterSpacing: "0.5px", paddingLeft: "8px" }}>
|
||||||
className={cn(
|
|
||||||
"px-3 pb-1 text-xs font-semibold uppercase tracking-wide",
|
|
||||||
// Use a very light gray for ALL section titles, not just when mutedTitle is specified
|
|
||||||
"text-sidebar-secondary-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{section.title}
|
{section.title}
|
||||||
</p>
|
</Text>
|
||||||
{section.items.map((item) => renderTopLevelItem(item))}
|
<Stack gap={2}>
|
||||||
</div>
|
{section.items.map((item) => renderTopLevelItem(item))}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</Stack>
|
||||||
</aside>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
|
|||||||
prefix: RULE_ENGINE_CATEGORY_BASE_PATH.configuration,
|
prefix: RULE_ENGINE_CATEGORY_BASE_PATH.configuration,
|
||||||
meta: {
|
meta: {
|
||||||
title: "Configuration",
|
title: "Configuration",
|
||||||
subtitle: "Master data: cargo, containers, services, surcharges, yards, and shipping lines",
|
subtitle: "Master data: cargo, containers, wagon types, services, surcharges, yards, and shipping lines",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...configurationRouteMeta,
|
...configurationRouteMeta,
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
|
import { Stack, Group, Text, Pagination, Card, SimpleGrid } from "@mantine/core";
|
||||||
|
|
||||||
import type { RuleEngineResourceConfig } from "@/pages/ruleEngine/config/resources";
|
import type { RuleEngineResourceConfig } from "@/pages/ruleEngine/config/resources";
|
||||||
import type { RuleEngineRecord } from "@/types/rule-engine";
|
import type { RuleEngineRecord } from "@/types/rule-engine";
|
||||||
import { DataTableFooter } from "@edr/ui-common";
|
|
||||||
import type { Table } from "@edr/ui-common";
|
|
||||||
|
|
||||||
import RuleEngineRecordActions from "./RuleEngineRecordActions";
|
import RuleEngineRecordActions from "./RuleEngineRecordActions";
|
||||||
import { cardInitials, resolveCardPresentation } from "./ruleEngineCardMeta";
|
import { cardInitials, resolveCardPresentation } from "./ruleEngineCardMeta";
|
||||||
import { formatCell } from "./ruleEngineFormat";
|
import { formatCell } from "./ruleEngineFormat";
|
||||||
import { ruleEngineCard } from "./ruleEngineStyles";
|
|
||||||
|
|
||||||
export interface RuleEngineCardGridProps {
|
export interface RuleEngineCardGridProps {
|
||||||
config: RuleEngineResourceConfig;
|
config: RuleEngineResourceConfig;
|
||||||
@@ -14,7 +13,6 @@ export interface RuleEngineCardGridProps {
|
|||||||
status: "loading" | "error" | "success";
|
status: "loading" | "error" | "success";
|
||||||
emptyMessage: string;
|
emptyMessage: string;
|
||||||
itemLabel: string;
|
itemLabel: string;
|
||||||
table: Table<RuleEngineRecord>;
|
|
||||||
pagination: {
|
pagination: {
|
||||||
pageIndex: number;
|
pageIndex: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
@@ -29,13 +27,49 @@ export interface RuleEngineCardGridProps {
|
|||||||
onApproveRate?: (record: RuleEngineRecord) => void;
|
onApproveRate?: (record: RuleEngineRecord) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const extractLabel = (value: unknown): string => {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
return String(value || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
(typeof obj.label === "string" ? obj.label : null) ||
|
||||||
|
(typeof obj.cargoTypeName === "string" ? obj.cargoTypeName : null) ||
|
||||||
|
(typeof obj.serviceName === "string" ? obj.serviceName : null) ||
|
||||||
|
(typeof obj.code === "string" ? obj.code : null) ||
|
||||||
|
(typeof obj.name === "string" ? obj.name : null) ||
|
||||||
|
(typeof obj.actionLabel === "string" ? obj.actionLabel : null) ||
|
||||||
|
String(value)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSmartValue = (record: RuleEngineRecord, key: string): unknown => {
|
||||||
|
const value = record[key as keyof RuleEngineRecord];
|
||||||
|
|
||||||
|
// If the value is already an object, use it directly
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the key ends with "Id" and there's a corresponding non-Id key, use that
|
||||||
|
if (typeof key === "string" && key.endsWith("Id")) {
|
||||||
|
const relatedKey = key.slice(0, -2); // Remove "Id" suffix
|
||||||
|
const relatedValue = record[relatedKey as keyof RuleEngineRecord];
|
||||||
|
if (relatedValue && typeof relatedValue === "object") {
|
||||||
|
return relatedValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
const RuleEngineCardGrid = ({
|
const RuleEngineCardGrid = ({
|
||||||
config,
|
config,
|
||||||
rows,
|
rows,
|
||||||
status,
|
status,
|
||||||
emptyMessage,
|
emptyMessage,
|
||||||
itemLabel,
|
itemLabel,
|
||||||
table,
|
|
||||||
pagination,
|
pagination,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
@@ -48,109 +82,156 @@ const RuleEngineCardGrid = ({
|
|||||||
|
|
||||||
if (status === "error") {
|
if (status === "error") {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
|
<Stack align="center" justify="center" p="xl" style={{ minHeight: "400px" }}>
|
||||||
<p className="text-sm font-medium text-foreground">Failed to load data</p>
|
<Text size="lg" fw={600} c="red">Failed to load data</Text>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<Text size="sm" c="dimmed">
|
||||||
Please refresh the page or try again later.
|
Please refresh the page or try again later.
|
||||||
</p>
|
</Text>
|
||||||
</div>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === "loading") {
|
if (status === "loading") {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 gap-3 p-4 sm:grid-cols-2 lg:grid-cols-3 xl:gap-4">
|
<Stack gap="md" p="md">
|
||||||
{Array.from({ length: 6 }).map((_, index) => (
|
<SimpleGrid cols={{ base: 1, sm: 2, md: 2, lg: 3 }} spacing="md">
|
||||||
<div key={index} className={ruleEngineCard.skeleton}>
|
{Array.from({ length: 6 }).map((_, index) => (
|
||||||
<div className="flex gap-3">
|
<Card key={index} p="md" radius="lg" withBorder style={{ height: "280px", background: "var(--mantine-color-gray-0)" }}>
|
||||||
<div className="h-10 w-10 rounded-md bg-muted" />
|
<div style={{ animation: "pulse 2s infinite", opacity: 0.5 }}>
|
||||||
<div className="flex-1 space-y-2">
|
<div style={{ height: "20px", background: "var(--mantine-color-gray-3)", borderRadius: "4px", marginBottom: "12px" }} />
|
||||||
<div className="h-4 w-2/3 rounded-sm bg-muted" />
|
<div style={{ height: "16px", background: "var(--mantine-color-gray-3)", borderRadius: "4px", marginBottom: "20px", width: "80%" }} />
|
||||||
<div className="h-3 w-1/3 rounded-sm bg-muted" />
|
<div style={{ height: "16px", background: "var(--mantine-color-gray-3)", borderRadius: "4px", marginBottom: "8px" }} />
|
||||||
|
<div style={{ height: "16px", background: "var(--mantine-color-gray-3)", borderRadius: "4px" }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
<div className="mt-4 space-y-2">
|
))}
|
||||||
<div className="h-3 w-full rounded-sm bg-muted" />
|
</SimpleGrid>
|
||||||
<div className="h-3 w-4/5 rounded-sm bg-muted" />
|
</Stack>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === "success" && rows.length === 0) {
|
if (status === "success" && rows.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
|
<Stack align="center" justify="center" p="xl" style={{ minHeight: "400px" }}>
|
||||||
<p className="text-sm font-medium text-foreground">{emptyMessage}</p>
|
<Text size="lg" fw={600}>{emptyMessage}</Text>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<Text size="sm" c="dimmed">
|
||||||
Try adjusting your search or add a new record.
|
Try adjusting your search or add a new record.
|
||||||
</p>
|
</Text>
|
||||||
</div>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const avatarBg = "#f1f5f9";
|
||||||
|
const avatarText = "#475569";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Stack gap="md" p="md">
|
||||||
<div className="grid grid-cols-1 gap-3 p-4 sm:grid-cols-2 lg:grid-cols-3 xl:gap-4">
|
<SimpleGrid cols={{ base: 1, sm: 2, md: 2, lg: 3 }} spacing="md">
|
||||||
{rows.map((record) => {
|
{rows.map((record) => {
|
||||||
const title = String(record[presentation.titleKey] ?? "Untitled");
|
const titleValue = getSmartValue(record, presentation.titleKey);
|
||||||
const subtitle = presentation.subtitleKey
|
const title = extractLabel(titleValue);
|
||||||
? String(record[presentation.subtitleKey] ?? "")
|
|
||||||
: "";
|
const subtitleValue = presentation.subtitleKey
|
||||||
const code = presentation.codeKey
|
? getSmartValue(record, presentation.subtitleKey)
|
||||||
? String(record[presentation.codeKey] ?? "")
|
: null;
|
||||||
: "";
|
const subtitle = subtitleValue ? extractLabel(subtitleValue) : "";
|
||||||
|
|
||||||
|
const codeValue = presentation.codeKey
|
||||||
|
? getSmartValue(record, presentation.codeKey)
|
||||||
|
: null;
|
||||||
|
const code = codeValue ? extractLabel(codeValue) : "";
|
||||||
|
|
||||||
const statusValue = presentation.statusKey
|
const statusValue = presentation.statusKey
|
||||||
? record[presentation.statusKey]
|
? record[presentation.statusKey]
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article key={record.id} className={ruleEngineCard.article}>
|
<Card
|
||||||
<div className={ruleEngineCard.header}>
|
key={record.id}
|
||||||
<div className="flex items-start gap-3">
|
p="lg"
|
||||||
<div className={ruleEngineCard.avatar} aria-hidden>
|
radius="lg"
|
||||||
|
withBorder
|
||||||
|
style={{
|
||||||
|
background: "white",
|
||||||
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.boxShadow = "0 4px 12px rgba(0, 0, 0, 0.08)";
|
||||||
|
e.currentTarget.style.borderColor = "var(--mantine-color-gray-3)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.boxShadow = "none";
|
||||||
|
e.currentTarget.style.borderColor = "var(--mantine-color-gray-2)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="flex-start" mb="md">
|
||||||
|
<Group gap="sm" style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: "44px",
|
||||||
|
height: "44px",
|
||||||
|
borderRadius: "8px",
|
||||||
|
background: avatarBg,
|
||||||
|
fontSize: "16px",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: avatarText,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{cardInitials(title)}
|
{cardInitials(title)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<Text size="sm" fw={700} truncate title={title}>
|
||||||
<h3 className={ruleEngineCard.title}>{title}</h3>
|
{title}
|
||||||
{presentation.statusKey
|
</Text>
|
||||||
? formatCell(statusValue, "activeBadge")
|
{code && (
|
||||||
: null}
|
<Text size="xs" c="dimmed" style={{ marginTop: "4px" }}>
|
||||||
</div>
|
{code}
|
||||||
{(code || subtitle) && (
|
</Text>
|
||||||
<div className="mt-1.5 flex flex-wrap items-center gap-2">
|
|
||||||
{code ? formatCell(code, "code") : null}
|
|
||||||
{subtitle ? (
|
|
||||||
<span className={ruleEngineCard.meta}>
|
|
||||||
{presentation.subtitleKey === "stepOrder"
|
|
||||||
? `Step ${subtitle}`
|
|
||||||
: subtitle}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Group>
|
||||||
</div>
|
{presentation.statusKey && (
|
||||||
|
<div>{formatCell(statusValue, "activeBadge")}</div>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
{presentation.detailColumns.length > 0 ? (
|
{(subtitle || presentation.detailColumns.length > 0) && (
|
||||||
<dl className="grid flex-1 gap-x-4 gap-y-3 px-4 py-3.5 sm:grid-cols-2">
|
<Stack gap="xs" style={{ flex: 1, marginBottom: "md" }}>
|
||||||
{presentation.detailColumns.map((col) => (
|
{subtitle && (
|
||||||
<div key={col.id} className="min-w-0">
|
<Group gap="xs">
|
||||||
<dt className={ruleEngineCard.detailLabel}>{col.header}</dt>
|
<Text size="xs" c="dimmed" fw={500}>
|
||||||
<dd className={ruleEngineCard.detailValue}>
|
{presentation.subtitleKey === "stepOrder" ? "Step" : "Type"}:
|
||||||
{formatCell(record[col.accessorKey], col.format)}
|
</Text>
|
||||||
</dd>
|
<Text size="xs" fw={500}>
|
||||||
</div>
|
{subtitle}
|
||||||
))}
|
</Text>
|
||||||
</dl>
|
</Group>
|
||||||
) : (
|
)}
|
||||||
<div className="flex-1 px-4 py-2" />
|
{presentation.detailColumns.map((col) => {
|
||||||
|
const displayValue = getSmartValue(record, col.accessorKey);
|
||||||
|
return (
|
||||||
|
<Group key={col.id} justify="space-between" gap="xs" align="flex-start">
|
||||||
|
<Text size="xs" c="dimmed" fw={500}>
|
||||||
|
{col.header}:
|
||||||
|
</Text>
|
||||||
|
<div style={{ textAlign: "right", flex: 1 }}>
|
||||||
|
{formatCell(displayValue, col.format)}
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={ruleEngineCard.footer}>
|
<Group justify="flex-end" gap="xs" style={{ borderTop: "1px solid var(--mantine-color-gray-1)", paddingTop: "md" }}>
|
||||||
<RuleEngineRecordActions
|
<RuleEngineRecordActions
|
||||||
record={record}
|
record={record}
|
||||||
config={config}
|
config={config}
|
||||||
@@ -162,26 +243,26 @@ const RuleEngineCardGrid = ({
|
|||||||
onSubmitRate={onSubmitRate}
|
onSubmitRate={onSubmitRate}
|
||||||
onApproveRate={onApproveRate}
|
onApproveRate={onApproveRate}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Group>
|
||||||
</article>
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</SimpleGrid>
|
||||||
|
|
||||||
<div className="border-t border-border bg-card">
|
{pagination.pageCount > 1 && (
|
||||||
<DataTableFooter
|
<Group justify="space-between" align="center" p="md" style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}>
|
||||||
table={table}
|
<Text size="sm" c="dimmed">
|
||||||
pagination={pagination}
|
Showing {Math.min(rows.length, pagination.pageSize)} of {pagination.totalCount} {itemLabel}
|
||||||
options={{
|
</Text>
|
||||||
labels: {
|
<Pagination
|
||||||
showing: "Showing",
|
value={pagination.pageIndex + 1}
|
||||||
ofLabel: "of",
|
total={pagination.pageCount}
|
||||||
items: itemLabel,
|
size="sm"
|
||||||
},
|
radius="md"
|
||||||
}}
|
/>
|
||||||
/>
|
</Group>
|
||||||
</div>
|
)}
|
||||||
</>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,19 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
Modal,
|
||||||
Button,
|
Button,
|
||||||
Dialog,
|
TextInput,
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
Field,
|
|
||||||
FieldContent,
|
|
||||||
FieldLabel,
|
|
||||||
Input,
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
Separator,
|
|
||||||
Switch,
|
|
||||||
Textarea,
|
Textarea,
|
||||||
} from "@edr/ui-common";
|
Select,
|
||||||
|
Switch,
|
||||||
|
Stack,
|
||||||
|
Group,
|
||||||
|
Text,
|
||||||
|
Box,
|
||||||
|
SimpleGrid,
|
||||||
|
Divider,
|
||||||
|
} from "@mantine/core";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
RULE_ENGINE_SELECT_NONE,
|
RULE_ENGINE_SELECT_NONE,
|
||||||
@@ -29,8 +21,6 @@ import {
|
|||||||
} from "@/pages/ruleEngine/config/resources";
|
} from "@/pages/ruleEngine/config/resources";
|
||||||
import type { RuleEngineRecord } from "@/types/rule-engine";
|
import type { RuleEngineRecord } from "@/types/rule-engine";
|
||||||
|
|
||||||
import { ruleEngineField, ruleEngineSurface } from "./ruleEngineStyles";
|
|
||||||
|
|
||||||
export interface RuleEngineFormDialogProps {
|
export interface RuleEngineFormDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
@@ -43,6 +33,43 @@ export interface RuleEngineFormDialogProps {
|
|||||||
onSubmit: (values: Record<string, unknown>) => void;
|
onSubmit: (values: Record<string, unknown>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FormRow =
|
||||||
|
| { kind: "pair"; fields: [FormFieldDef, FormFieldDef] }
|
||||||
|
| { kind: "single"; field: FormFieldDef };
|
||||||
|
|
||||||
|
const isShortField = (field: FormFieldDef) =>
|
||||||
|
field.type === "text" ||
|
||||||
|
field.type === "number" ||
|
||||||
|
field.type === "select" ||
|
||||||
|
field.type === "date";
|
||||||
|
|
||||||
|
const buildFormRows = (fields: FormFieldDef[]): FormRow[] => {
|
||||||
|
const rows: FormRow[] = [];
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
while (index < fields.length) {
|
||||||
|
const field = fields[index];
|
||||||
|
|
||||||
|
if (field.type === "textarea" || field.type === "boolean") {
|
||||||
|
rows.push({ kind: "single", field });
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = fields[index + 1];
|
||||||
|
if (next && isShortField(next)) {
|
||||||
|
rows.push({ kind: "pair", fields: [field, next] });
|
||||||
|
index += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push({ kind: "single", field });
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
const buildInitialValues = (
|
const buildInitialValues = (
|
||||||
fields: FormFieldDef[],
|
fields: FormFieldDef[],
|
||||||
record?: RuleEngineRecord | null,
|
record?: RuleEngineRecord | null,
|
||||||
@@ -53,6 +80,8 @@ const buildInitialValues = (
|
|||||||
if (raw !== undefined && raw !== null) {
|
if (raw !== undefined && raw !== null) {
|
||||||
if (field.type === "date" && typeof raw === "string") {
|
if (field.type === "date" && typeof raw === "string") {
|
||||||
values[field.name] = raw.slice(0, 10);
|
values[field.name] = raw.slice(0, 10);
|
||||||
|
} else if (Array.isArray(raw)) {
|
||||||
|
values[field.name] = raw.join(", ");
|
||||||
} else {
|
} else {
|
||||||
values[field.name] = raw;
|
values[field.name] = raw;
|
||||||
}
|
}
|
||||||
@@ -85,11 +114,34 @@ const resolveSelectValue = (
|
|||||||
return String(raw);
|
return String(raw);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const inputStyles = {
|
||||||
|
label: { fontWeight: 600, marginBottom: 6, color: "var(--mantine-color-gray-8)" },
|
||||||
|
input: {
|
||||||
|
borderColor: "#e2e8f0",
|
||||||
|
background: "white",
|
||||||
|
transition: "border-color 0.15s ease, box-shadow 0.15s ease",
|
||||||
|
"&:focus": {
|
||||||
|
borderColor: "var(--freight-brand)",
|
||||||
|
boxShadow: "0 0 0 3px var(--freight-brand-ring)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const FieldLabel = ({ label, required }: { label: string; required?: boolean }) => (
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
<span>{label}</span>
|
||||||
|
{required ? (
|
||||||
|
<Text component="span" c="red" size="sm">
|
||||||
|
*
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
|
||||||
const RuleEngineFormDialog = ({
|
const RuleEngineFormDialog = ({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
title,
|
title,
|
||||||
description,
|
|
||||||
fields,
|
fields,
|
||||||
initialRecord,
|
initialRecord,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
@@ -106,6 +158,8 @@ const RuleEngineFormDialog = ({
|
|||||||
}
|
}
|
||||||
}, [open, fields, initialRecord]);
|
}, [open, fields, initialRecord]);
|
||||||
|
|
||||||
|
const formRows = useMemo(() => buildFormRows(fields), [fields]);
|
||||||
|
|
||||||
const setField = (name: string, value: unknown) => {
|
const setField = (name: string, value: unknown) => {
|
||||||
setValues((current) => ({ ...current, [name]: value }));
|
setValues((current) => ({ ...current, [name]: value }));
|
||||||
};
|
};
|
||||||
@@ -141,134 +195,171 @@ const RuleEngineFormDialog = ({
|
|||||||
onSubmit(payload);
|
onSubmit(payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderField = (field: FormFieldDef) => {
|
||||||
|
if (field.type === "boolean") {
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
key={field.name}
|
||||||
|
justify="space-between"
|
||||||
|
align="center"
|
||||||
|
wrap="nowrap"
|
||||||
|
gap="md"
|
||||||
|
px="md"
|
||||||
|
style={{
|
||||||
|
minHeight: 42,
|
||||||
|
background: "#f8fafc",
|
||||||
|
border: "1px solid #e2e8f0",
|
||||||
|
borderRadius: "var(--mantine-radius-md)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{field.label}
|
||||||
|
</Text>
|
||||||
|
<Switch
|
||||||
|
checked={Boolean(values[field.name])}
|
||||||
|
onChange={(e) => setField(field.name, e.currentTarget.checked)}
|
||||||
|
size="md"
|
||||||
|
color="green"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = <FieldLabel label={field.label} required={field.required} />;
|
||||||
|
|
||||||
|
if (field.type === "select") {
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
key={field.name}
|
||||||
|
label={label}
|
||||||
|
placeholder={
|
||||||
|
selectOptionsLoading ? "Loading options..." : (field.placeholder ?? "Select an option")
|
||||||
|
}
|
||||||
|
value={resolveSelectValue(field, values)}
|
||||||
|
onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)}
|
||||||
|
disabled={selectOptionsLoading}
|
||||||
|
data={(field.options ?? [])
|
||||||
|
.filter((opt) => opt.value !== "")
|
||||||
|
.map((opt) => ({
|
||||||
|
label: opt.label,
|
||||||
|
value: opt.value,
|
||||||
|
}))}
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
size="md"
|
||||||
|
radius="md"
|
||||||
|
styles={inputStyles}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === "textarea") {
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
key={field.name}
|
||||||
|
label={label}
|
||||||
|
value={String(values[field.name] ?? "")}
|
||||||
|
onChange={(e) => setField(field.name, e.currentTarget.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
required={field.required}
|
||||||
|
minRows={4}
|
||||||
|
autosize
|
||||||
|
maxRows={8}
|
||||||
|
size="md"
|
||||||
|
radius="md"
|
||||||
|
styles={inputStyles}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
key={field.name}
|
||||||
|
label={label}
|
||||||
|
type={field.type === "number" ? "number" : field.type === "date" ? "date" : "text"}
|
||||||
|
value={String(values[field.name] ?? "")}
|
||||||
|
onChange={(e) => setField(field.name, e.currentTarget.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
required={field.required}
|
||||||
|
size="md"
|
||||||
|
radius="md"
|
||||||
|
styles={inputStyles}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Modal
|
||||||
<DialogContent className={ruleEngineSurface.dialog}>
|
opened={open}
|
||||||
<DialogHeader className="space-y-1">
|
onClose={() => onOpenChange(false)}
|
||||||
<DialogTitle className="text-lg font-semibold">{title}</DialogTitle>
|
title={
|
||||||
<DialogDescription>{description}</DialogDescription>
|
<Text size="lg" fw={700} lh={1.2}>
|
||||||
</DialogHeader>
|
{title}
|
||||||
|
</Text>
|
||||||
<form onSubmit={handleSubmit} className="space-y-1">
|
}
|
||||||
<div className="max-h-[min(60vh,28rem)] space-y-4 overflow-y-auto pr-1">
|
centered
|
||||||
{fields.map((field) => (
|
size={720}
|
||||||
<Field key={field.name} orientation="vertical" className="gap-1.5">
|
radius="lg"
|
||||||
{field.type === "boolean" ? (
|
padding="xl"
|
||||||
<div className={ruleEngineField.switchRow}>
|
overlayProps={{ backgroundOpacity: 0.45, blur: 3 }}
|
||||||
<div className="min-w-0">
|
styles={{
|
||||||
<FieldLabel htmlFor={field.name} className={ruleEngineField.label}>
|
content: {
|
||||||
{field.label}
|
maxWidth: "min(720px, 95vw)",
|
||||||
</FieldLabel>
|
},
|
||||||
<p className={ruleEngineField.switchHint}>
|
body: {
|
||||||
{Boolean(values[field.name]) ? "Enabled" : "Disabled"}
|
paddingTop: 20,
|
||||||
</p>
|
},
|
||||||
</div>
|
}}
|
||||||
<Switch
|
>
|
||||||
id={field.name}
|
<form onSubmit={handleSubmit}>
|
||||||
checked={Boolean(values[field.name])}
|
<Stack gap="lg">
|
||||||
onCheckedChange={(checked) => setField(field.name, checked)}
|
<Box style={{ maxHeight: "calc(65vh - 120px)", overflowY: "auto", paddingRight: 4 }}>
|
||||||
/>
|
<Stack gap="md">
|
||||||
</div>
|
{formRows.map((row) =>
|
||||||
|
row.kind === "pair" ? (
|
||||||
|
<SimpleGrid key={`${row.fields[0].name}-${row.fields[1].name}`} cols={2} spacing="md">
|
||||||
|
<Box style={{ minWidth: 0 }}>{renderField(row.fields[0])}</Box>
|
||||||
|
<Box style={{ minWidth: 0 }}>{renderField(row.fields[1])}</Box>
|
||||||
|
</SimpleGrid>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<Box key={row.field.name}>{renderField(row.field)}</Box>
|
||||||
<FieldLabel htmlFor={field.name} className={ruleEngineField.label}>
|
),
|
||||||
{field.label}
|
)}
|
||||||
{field.required ? (
|
</Stack>
|
||||||
<span className={ruleEngineField.requiredMark}> *</span>
|
</Box>
|
||||||
) : null}
|
|
||||||
</FieldLabel>
|
|
||||||
<FieldContent>
|
|
||||||
{field.type === "select" ? (
|
|
||||||
<Select
|
|
||||||
value={resolveSelectValue(field, values)}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
setField(
|
|
||||||
field.name,
|
|
||||||
v === RULE_ENGINE_SELECT_NONE ? "" : v,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
disabled={selectOptionsLoading}
|
|
||||||
>
|
|
||||||
<SelectTrigger
|
|
||||||
id={field.name}
|
|
||||||
className={ruleEngineField.selectTrigger}
|
|
||||||
>
|
|
||||||
<SelectValue
|
|
||||||
placeholder={
|
|
||||||
selectOptionsLoading
|
|
||||||
? "Loading options..."
|
|
||||||
: (field.placeholder ?? "Select an option")
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className={ruleEngineField.selectContent}>
|
|
||||||
{(field.options ?? [])
|
|
||||||
.filter((opt) => opt.value !== "")
|
|
||||||
.map((opt) => (
|
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
) : field.type === "textarea" ? (
|
|
||||||
<Textarea
|
|
||||||
id={field.name}
|
|
||||||
value={String(values[field.name] ?? "")}
|
|
||||||
onChange={(e) => setField(field.name, e.target.value)}
|
|
||||||
placeholder={field.placeholder}
|
|
||||||
className={ruleEngineField.textarea}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Input
|
|
||||||
id={field.name}
|
|
||||||
type={
|
|
||||||
field.type === "number"
|
|
||||||
? "number"
|
|
||||||
: field.type === "date"
|
|
||||||
? "date"
|
|
||||||
: "text"
|
|
||||||
}
|
|
||||||
value={String(values[field.name] ?? "")}
|
|
||||||
onChange={(e) => setField(field.name, e.target.value)}
|
|
||||||
placeholder={field.placeholder}
|
|
||||||
className={ruleEngineField.input}
|
|
||||||
required={field.required}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</FieldContent>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator className="my-4" />
|
<Divider />
|
||||||
|
|
||||||
<DialogFooter className="gap-2 sm:gap-2">
|
<Group justify="flex-end" gap="sm">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
variant="default"
|
||||||
variant="outline"
|
|
||||||
className="rounded-md"
|
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" className="rounded-md" disabled={isSubmitting}>
|
<Button
|
||||||
{isSubmitting ? (
|
type="submit"
|
||||||
<>
|
disabled={isSubmitting}
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
leftSection={
|
||||||
Saving...
|
isSubmitting ? (
|
||||||
</>
|
<Loader2 size={18} style={{ animation: "spin 1s linear infinite" }} />
|
||||||
) : (
|
) : undefined
|
||||||
"Save"
|
}
|
||||||
)}
|
radius="md"
|
||||||
|
color="green"
|
||||||
|
variant="filled"
|
||||||
|
fw={600}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Saving..." : "Save"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</Group>
|
||||||
</form>
|
</Stack>
|
||||||
</DialogContent>
|
</form>
|
||||||
</Dialog>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,16 +6,10 @@ import {
|
|||||||
Send,
|
Send,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { ActionIcon, Button, Group, Menu, Tooltip } from "@mantine/core";
|
||||||
|
|
||||||
import type { RuleEngineResourceConfig } from "@/pages/ruleEngine/config/resources";
|
import type { RuleEngineResourceConfig } from "@/pages/ruleEngine/config/resources";
|
||||||
import type { RuleEngineRecord } from "@/types/rule-engine";
|
import type { RuleEngineRecord } from "@/types/rule-engine";
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@edr/ui-common";
|
|
||||||
|
|
||||||
export interface RuleEngineRecordActionsProps {
|
export interface RuleEngineRecordActionsProps {
|
||||||
record: RuleEngineRecord;
|
record: RuleEngineRecord;
|
||||||
@@ -29,6 +23,16 @@ export interface RuleEngineRecordActionsProps {
|
|||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const actionGroupStyle = {
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
borderRadius: 10,
|
||||||
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
background: "var(--mantine-color-gray-0)",
|
||||||
|
padding: 3,
|
||||||
|
} as const;
|
||||||
|
|
||||||
const RuleEngineRecordActions = ({
|
const RuleEngineRecordActions = ({
|
||||||
record,
|
record,
|
||||||
config,
|
config,
|
||||||
@@ -43,95 +47,209 @@ const RuleEngineRecordActions = ({
|
|||||||
const status = String(record.status ?? "");
|
const status = String(record.status ?? "");
|
||||||
const hasRateActions =
|
const hasRateActions =
|
||||||
config.slug === "rates" && (status === "DRAFT" || status === "PENDING_APPROVAL");
|
config.slug === "rates" && (status === "DRAFT" || status === "PENDING_APPROVAL");
|
||||||
|
const showViewChain = config.slug === "approval-rules" && onViewChain;
|
||||||
const iconBtnClass =
|
|
||||||
layout === "compact"
|
|
||||||
? "h-8 w-8 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
||||||
: "h-8 w-8 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground";
|
|
||||||
|
|
||||||
if (readOnly) {
|
if (readOnly) {
|
||||||
return onViewChain ? (
|
return showViewChain ? (
|
||||||
<Button
|
<Tooltip label="View approval chain">
|
||||||
type="button"
|
<ActionIcon
|
||||||
variant="ghost"
|
variant="subtle"
|
||||||
size="icon"
|
color="gray"
|
||||||
className={iconBtnClass}
|
size="md"
|
||||||
onClick={onViewChain}
|
radius="md"
|
||||||
aria-label="View chain"
|
|
||||||
>
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-end gap-0.5">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className={iconBtnClass}
|
|
||||||
onClick={() => onEdit(record)}
|
|
||||||
aria-label="Edit"
|
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{config.slug === "approval-rules" && onViewChain ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className={iconBtnClass}
|
|
||||||
onClick={onViewChain}
|
onClick={onViewChain}
|
||||||
aria-label="View approval chain"
|
aria-label="View approval chain"
|
||||||
>
|
>
|
||||||
<Eye className="h-4 w-4" />
|
<Eye size={16} />
|
||||||
</Button>
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layout === "compact") {
|
||||||
|
return (
|
||||||
|
<Group gap={6} wrap="nowrap" justify="flex-end">
|
||||||
|
{showViewChain ? (
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="gray"
|
||||||
|
size="compact-sm"
|
||||||
|
radius="md"
|
||||||
|
onClick={onViewChain}
|
||||||
|
leftSection={<Eye size={14} />}
|
||||||
|
>
|
||||||
|
Chain
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{hasRateActions ? (
|
||||||
|
<RateActionsMenu
|
||||||
|
status={status}
|
||||||
|
onSubmitRate={onSubmitRate}
|
||||||
|
onApproveRate={onApproveRate}
|
||||||
|
record={record}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div style={actionGroupStyle}>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size="compact-sm"
|
||||||
|
radius="md"
|
||||||
|
onClick={() => onEdit(record)}
|
||||||
|
leftSection={<Pencil size={14} />}
|
||||||
|
styles={{ root: { fontWeight: 600 } }}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
size="compact-sm"
|
||||||
|
radius="md"
|
||||||
|
onClick={() => onDelete(record)}
|
||||||
|
leftSection={<Trash2 size={14} />}
|
||||||
|
styles={{ root: { fontWeight: 600 } }}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group gap={6} wrap="nowrap" justify="flex-end">
|
||||||
|
{showViewChain ? (
|
||||||
|
<Tooltip label="View approval chain">
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
color="gray"
|
||||||
|
size="md"
|
||||||
|
radius="md"
|
||||||
|
onClick={onViewChain}
|
||||||
|
aria-label="View approval chain"
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
background: "white",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Eye size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{hasRateActions ? (
|
{hasRateActions ? (
|
||||||
<DropdownMenu modal={false}>
|
<RateActionsMenu
|
||||||
<DropdownMenuTrigger asChild>
|
status={status}
|
||||||
<Button
|
onSubmitRate={onSubmitRate}
|
||||||
type="button"
|
onApproveRate={onApproveRate}
|
||||||
variant="ghost"
|
record={record}
|
||||||
size="icon"
|
/>
|
||||||
className={iconBtnClass}
|
|
||||||
aria-label="More actions"
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
{status === "DRAFT" && onSubmitRate ? (
|
|
||||||
<DropdownMenuItem onSelect={() => onSubmitRate(record.id)}>
|
|
||||||
<Send />
|
|
||||||
Submit for approval
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
{status === "PENDING_APPROVAL" && onApproveRate ? (
|
|
||||||
<DropdownMenuItem onSelect={() => onApproveRate(record)}>
|
|
||||||
<CheckCircle2 />
|
|
||||||
Approve
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Button
|
<div style={actionGroupStyle}>
|
||||||
type="button"
|
<Tooltip label="Edit">
|
||||||
variant="ghost"
|
<ActionIcon
|
||||||
size="icon"
|
variant="subtle"
|
||||||
className={`${iconBtnClass} hover:bg-red-50 hover:text-red-600`}
|
color="gray"
|
||||||
onClick={() => onDelete(record)}
|
size="md"
|
||||||
aria-label="Delete"
|
radius="md"
|
||||||
>
|
onClick={() => onEdit(record)}
|
||||||
<Trash2 className="h-4 w-4" />
|
aria-label="Edit record"
|
||||||
</Button>
|
style={{
|
||||||
</div>
|
background: "white",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label="Delete">
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
size="md"
|
||||||
|
radius="md"
|
||||||
|
onClick={() => onDelete(record)}
|
||||||
|
aria-label="Delete record"
|
||||||
|
style={{
|
||||||
|
background: "var(--mantine-color-red-0)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function RateActionsMenu({
|
||||||
|
status,
|
||||||
|
onSubmitRate,
|
||||||
|
onApproveRate,
|
||||||
|
record,
|
||||||
|
compact = false,
|
||||||
|
}: {
|
||||||
|
status: string;
|
||||||
|
onSubmitRate?: (id: string) => void;
|
||||||
|
onApproveRate?: (record: RuleEngineRecord) => void;
|
||||||
|
record: RuleEngineRecord;
|
||||||
|
compact?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Menu position="bottom-end" shadow="md" withinPortal>
|
||||||
|
<Menu.Target>
|
||||||
|
{compact ? (
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="gray"
|
||||||
|
size="compact-sm"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<MoreHorizontal size={14} />}
|
||||||
|
>
|
||||||
|
More
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Tooltip label="More actions">
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
color="gray"
|
||||||
|
size="md"
|
||||||
|
radius="md"
|
||||||
|
aria-label="More actions"
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
background: "white",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MoreHorizontal size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
{status === "DRAFT" && onSubmitRate ? (
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<Send size={14} />}
|
||||||
|
onClick={() => onSubmitRate(record.id)}
|
||||||
|
>
|
||||||
|
Submit for approval
|
||||||
|
</Menu.Item>
|
||||||
|
) : null}
|
||||||
|
{status === "PENDING_APPROVAL" && onApproveRate ? (
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<CheckCircle2 size={14} />}
|
||||||
|
onClick={() => onApproveRate(record)}
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</Menu.Item>
|
||||||
|
) : null}
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default RuleEngineRecordActions;
|
export default RuleEngineRecordActions;
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { Filter, LayoutGrid, Plus, Search, Table2 } from "lucide-react";
|
import { LayoutGrid, Plus, Search, Table2 } from "lucide-react";
|
||||||
|
import { Button, TextInput, Group, SegmentedControl } from "@mantine/core";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Button, Input } from "@edr/ui-common";
|
|
||||||
|
|
||||||
import type { RuleEngineViewMode } from "./useRuleEngineViewMode";
|
import type { RuleEngineViewMode } from "./useRuleEngineViewMode";
|
||||||
import { ruleEngineToolbar } from "./ruleEngineStyles";
|
|
||||||
|
|
||||||
export interface RuleEngineToolbarProps {
|
export interface RuleEngineToolbarProps {
|
||||||
search: string;
|
search: string;
|
||||||
@@ -25,70 +22,72 @@ const RuleEngineToolbar = ({
|
|||||||
viewMode,
|
viewMode,
|
||||||
onViewModeChange,
|
onViewModeChange,
|
||||||
}: RuleEngineToolbarProps) => (
|
}: RuleEngineToolbarProps) => (
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
<Group gap="md" justify="space-between" align="center" wrap="nowrap">
|
||||||
<div className="relative min-w-0 flex-1 lg:max-w-md">
|
<TextInput
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
placeholder={searchPlaceholder}
|
||||||
<Input
|
value={search}
|
||||||
type="search"
|
onChange={(e) => onSearchChange(e.currentTarget.value)}
|
||||||
value={search}
|
leftSection={<Search size={18} />}
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
size="md"
|
||||||
placeholder={searchPlaceholder}
|
radius="lg"
|
||||||
className={ruleEngineToolbar.search}
|
style={{ flex: 1, minWidth: 0 }}
|
||||||
|
styles={{
|
||||||
|
input: {
|
||||||
|
borderColor: "var(--mantine-color-gray-3)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group gap="md" align="center" justify="flex-end" wrap="nowrap">
|
||||||
|
<SegmentedControl
|
||||||
|
value={viewMode}
|
||||||
|
onChange={(value) => onViewModeChange(value as RuleEngineViewMode)}
|
||||||
|
size="sm"
|
||||||
|
radius="lg"
|
||||||
|
color="green"
|
||||||
|
data={[
|
||||||
|
{
|
||||||
|
value: "table",
|
||||||
|
label: (
|
||||||
|
<Group gap={6} justify="center" wrap="nowrap">
|
||||||
|
<Table2 size={16} />
|
||||||
|
<span>Table</span>
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "cards",
|
||||||
|
label: (
|
||||||
|
<Group gap={6} justify="center" wrap="nowrap">
|
||||||
|
<LayoutGrid size={16} />
|
||||||
|
<span>Cards</span>
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
styles={{
|
||||||
|
root: {
|
||||||
|
background: "var(--mantine-color-gray-1)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
|
||||||
<div
|
|
||||||
className={ruleEngineToolbar.viewToggleGroup}
|
|
||||||
role="group"
|
|
||||||
aria-label="View mode"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onViewModeChange("table")}
|
|
||||||
className={cn(
|
|
||||||
ruleEngineToolbar.viewToggleBtn,
|
|
||||||
viewMode === "table"
|
|
||||||
? ruleEngineToolbar.viewToggleActive
|
|
||||||
: ruleEngineToolbar.viewToggleIdle,
|
|
||||||
)}
|
|
||||||
aria-pressed={viewMode === "table"}
|
|
||||||
>
|
|
||||||
<Table2 className="h-4 w-4" />
|
|
||||||
Table
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onViewModeChange("cards")}
|
|
||||||
className={cn(
|
|
||||||
ruleEngineToolbar.viewToggleBtn,
|
|
||||||
viewMode === "cards"
|
|
||||||
? ruleEngineToolbar.viewToggleActive
|
|
||||||
: ruleEngineToolbar.viewToggleIdle,
|
|
||||||
)}
|
|
||||||
aria-pressed={viewMode === "cards"}
|
|
||||||
>
|
|
||||||
<LayoutGrid className="h-4 w-4" />
|
|
||||||
Cards
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className={cn(ruleEngineToolbar.actionBtn, "gap-2 px-3")}
|
|
||||||
>
|
|
||||||
<Filter className="h-4 w-4" />
|
|
||||||
Filter
|
|
||||||
</Button>
|
|
||||||
{onAdd ? (
|
{onAdd ? (
|
||||||
<Button type="button" className={ruleEngineToolbar.primaryBtn} onClick={onAdd}>
|
<Button
|
||||||
<Plus className="h-4 w-4" />
|
onClick={onAdd}
|
||||||
|
leftSection={<Plus size={18} />}
|
||||||
|
size="sm"
|
||||||
|
radius="lg"
|
||||||
|
color="green"
|
||||||
|
variant="filled"
|
||||||
|
fw={600}
|
||||||
|
style={{ whiteSpace: "nowrap" }}
|
||||||
|
>
|
||||||
{addLabel}
|
{addLabel}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</Group>
|
||||||
</div>
|
</Group>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default RuleEngineToolbar;
|
export default RuleEngineToolbar;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
const TITLE_KEY_PRIORITY = [
|
const TITLE_KEY_PRIORITY = [
|
||||||
"cargoTypeName",
|
"cargoTypeName",
|
||||||
"serviceName",
|
"serviceName",
|
||||||
|
"name",
|
||||||
"label",
|
"label",
|
||||||
"actionLabel",
|
"actionLabel",
|
||||||
"rateType",
|
"rateType",
|
||||||
|
|||||||
@@ -1,30 +1,49 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { Badge, Text } from "@mantine/core";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import type { ColumnFormat } from "@/pages/ruleEngine/config/resources";
|
import type { ColumnFormat } from "@/pages/ruleEngine/config/resources";
|
||||||
import { Badge } from "@edr/ui-common";
|
|
||||||
|
|
||||||
const statusBadgeClass = (active: boolean) =>
|
const extractLabel = (value: unknown): string | null => {
|
||||||
cn(
|
if (!value || typeof value !== "object") return null;
|
||||||
"rounded-sm px-2 py-0.5 text-xs font-medium",
|
|
||||||
active
|
const obj = value as Record<string, unknown>;
|
||||||
? "border-emerald-200 bg-emerald-50 text-emerald-800"
|
return (
|
||||||
: "border-border bg-muted text-muted-foreground",
|
(typeof obj.label === "string" ? obj.label : null) ||
|
||||||
|
(typeof obj.cargoTypeName === "string" ? obj.cargoTypeName : null) ||
|
||||||
|
(typeof obj.code === "string" ? obj.code : null) ||
|
||||||
|
(typeof obj.name === "string" ? obj.name : null) ||
|
||||||
|
(typeof obj.actionLabel === "string" ? obj.actionLabel : null) ||
|
||||||
|
null
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode => {
|
export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode => {
|
||||||
if (value === null || value === undefined || value === "") {
|
if (value === null || value === undefined || value === "") {
|
||||||
return <span className="text-muted-foreground">—</span>;
|
return <Text size="sm" c="dimmed">—</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle stringified objects (e.g., "[object Object]")
|
||||||
|
if (typeof value === "string" && value.trim() === "[object Object]") {
|
||||||
|
return <Text size="sm" c="dimmed">—</Text>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (format === "boolean") {
|
if (format === "boolean") {
|
||||||
return value ? "Yes" : "No";
|
if (typeof value === "string") {
|
||||||
|
const boolVal = value.toLowerCase() === "true" || value === "1";
|
||||||
|
return <Text size="sm">{boolVal ? "✓ Yes" : "✗ No"}</Text>;
|
||||||
|
}
|
||||||
|
return <Text size="sm">{value ? "✓ Yes" : "✗ No"}</Text>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (format === "activeBadge") {
|
if (format === "activeBadge") {
|
||||||
const active = Boolean(value);
|
const active = Boolean(value);
|
||||||
return (
|
return (
|
||||||
<Badge variant="outline" className={statusBadgeClass(active)}>
|
<Badge
|
||||||
|
color={active ? "green" : "gray"}
|
||||||
|
variant={active ? "filled" : "light"}
|
||||||
|
size="sm"
|
||||||
|
radius="md"
|
||||||
|
>
|
||||||
{active ? "Active" : "Inactive"}
|
{active ? "Active" : "Inactive"}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
@@ -32,14 +51,16 @@ export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode =>
|
|||||||
|
|
||||||
if (format === "rateStatus") {
|
if (format === "rateStatus") {
|
||||||
const status = String(value);
|
const status = String(value);
|
||||||
const tone =
|
const color =
|
||||||
status === "LIVE"
|
status === "LIVE"
|
||||||
? "border-emerald-200 bg-emerald-50 text-emerald-800"
|
? "green"
|
||||||
: status === "DRAFT"
|
: status === "DRAFT"
|
||||||
? "border-amber-200 bg-amber-50 text-amber-800"
|
? "yellow"
|
||||||
: "border-sky-200 bg-sky-50 text-sky-800";
|
: status === "PENDING_APPROVAL"
|
||||||
|
? "orange"
|
||||||
|
: "blue";
|
||||||
return (
|
return (
|
||||||
<Badge variant="outline" className={cn("rounded-sm font-medium", tone)}>
|
<Badge color={color} variant="filled" size="sm" radius="md">
|
||||||
{status}
|
{status}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
@@ -48,38 +69,50 @@ export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode =>
|
|||||||
if (format === "code") {
|
if (format === "code") {
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="light"
|
||||||
className="rounded-sm border border-border bg-muted/80 font-mono text-[11px] font-medium text-foreground"
|
color="blue"
|
||||||
|
size="sm"
|
||||||
|
radius="md"
|
||||||
|
style={{
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{String(value)}
|
{String(value).toUpperCase()}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (format === "date") {
|
if (format === "date") {
|
||||||
const d = new Date(String(value));
|
const d = new Date(String(value));
|
||||||
return Number.isNaN(d.getTime()) ? String(value) : d.toLocaleDateString();
|
if (Number.isNaN(d.getTime())) return <Text size="sm">{String(value)}</Text>;
|
||||||
|
return <Text size="sm">{d.toLocaleDateString()}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return (
|
||||||
|
<Text size="sm">{value.length > 0 ? value.join(", ") : "—"}</Text>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (format === "entityLabel" && value && typeof value === "object") {
|
if (format === "entityLabel" && value && typeof value === "object") {
|
||||||
const entity = value as { label?: string; code?: string; cargoTypeName?: string };
|
const label = extractLabel(value);
|
||||||
const label =
|
if (label) {
|
||||||
entity.label?.trim() ||
|
return <Text size="sm">{label}</Text>;
|
||||||
entity.cargoTypeName?.trim() ||
|
}
|
||||||
entity.code?.trim();
|
return <Text size="sm" c="dimmed">—</Text>;
|
||||||
return label ? (
|
|
||||||
<span>{label}</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground">—</span>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (format === "rateLabel") {
|
if (format === "rateLabel") {
|
||||||
if (!value || typeof value !== "object") {
|
if (!value || typeof value !== "object") {
|
||||||
return value ? (
|
return value ? (
|
||||||
<span className="font-mono text-xs text-muted-foreground">{String(value)}</span>
|
<Text size="sm" c="dimmed" style={{ fontFamily: "monospace" }}>
|
||||||
|
{String(value)}
|
||||||
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">—</span>
|
<Text size="sm" c="dimmed">—</Text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const rate = value as {
|
const rate = value as {
|
||||||
@@ -95,11 +128,17 @@ export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode =>
|
|||||||
rate.rateUnit?.replace(/_/g, " "),
|
rate.rateUnit?.replace(/_/g, " "),
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
return parts.length > 0 ? (
|
return parts.length > 0 ? (
|
||||||
<span>{parts.join(" · ")}</span>
|
<Text size="sm">{parts.join(" · ")}</Text>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">—</span>
|
<Text size="sm" c="dimmed">—</Text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return String(value);
|
if (typeof value === "object") {
|
||||||
|
const label = extractLabel(value);
|
||||||
|
if (label) return <Text size="sm">{label}</Text>;
|
||||||
|
return <Text size="sm" c="dimmed">—</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Text size="sm">{String(value)}</Text>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
export const ruleEngineSurface = {
|
export const ruleEngineSurface = {
|
||||||
pageCard:
|
pageCard:
|
||||||
"overflow-hidden rounded-lg border border-border bg-card shadow-sm",
|
"overflow-hidden rounded-lg border border-border bg-card shadow-sm",
|
||||||
pageCardToolbar: "border-b border-border bg-muted/30 px-4 py-3 sm:px-5 sm:py-3.5",
|
pageCardToolbar: "border-b border-border bg-muted/30 px-3 py-2.5 sm:px-4 sm:py-3",
|
||||||
dialog: "max-h-[90vh] overflow-y-auto rounded-lg border-border sm:max-w-lg",
|
dialog: "max-h-[90vh] overflow-y-auto rounded-lg border-border sm:max-w-lg",
|
||||||
dialogSm: "rounded-lg border-border sm:max-w-md",
|
dialogSm: "rounded-lg border-border sm:max-w-md",
|
||||||
} as const;
|
} as const;
|
||||||
@@ -24,30 +24,30 @@ export const ruleEngineField = {
|
|||||||
|
|
||||||
export const ruleEngineToolbar = {
|
export const ruleEngineToolbar = {
|
||||||
search:
|
search:
|
||||||
"h-10 rounded-md border border-input bg-background pl-10 text-sm shadow-xs placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/30",
|
"h-9 rounded-md border border-input bg-background pl-9 text-sm shadow-xs placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/30 sm:h-10 sm:pl-10",
|
||||||
viewToggleGroup:
|
viewToggleGroup:
|
||||||
"flex h-10 items-center rounded-md border border-border bg-muted/40 p-0.5",
|
"flex h-9 items-center rounded-md border border-border bg-muted/40 p-0.5 sm:h-10",
|
||||||
viewToggleBtn:
|
viewToggleBtn:
|
||||||
"inline-flex h-8 items-center gap-1.5 rounded-sm px-3 text-sm font-medium transition-colors",
|
"inline-flex h-7 items-center gap-1 rounded-sm px-2 text-xs font-medium transition-colors sm:h-8 sm:gap-1.5 sm:px-3 sm:text-sm",
|
||||||
viewToggleActive: "bg-background text-foreground shadow-sm",
|
viewToggleActive: "bg-background text-foreground shadow-sm",
|
||||||
viewToggleIdle: "text-muted-foreground hover:bg-background/60 hover:text-foreground",
|
viewToggleIdle: "text-muted-foreground hover:bg-background/60 hover:text-foreground",
|
||||||
actionBtn: "h-10 rounded-md shadow-xs",
|
actionBtn: "h-9 rounded-md shadow-xs sm:h-10",
|
||||||
primaryBtn: "h-10 gap-2 rounded-md px-4 text-sm font-medium shadow-xs",
|
primaryBtn: "h-9 gap-1.5 rounded-md px-3 text-xs font-medium shadow-xs sm:h-10 sm:gap-2 sm:px-4 sm:text-sm",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const ruleEngineCard = {
|
export const ruleEngineCard = {
|
||||||
article:
|
article:
|
||||||
"group flex flex-col overflow-hidden rounded-lg border border-border bg-card shadow-sm transition-shadow duration-200 hover:shadow-md",
|
"group flex flex-col overflow-hidden rounded-lg border border-border bg-card shadow-sm transition-shadow duration-200 hover:shadow-md",
|
||||||
header: "border-b border-border bg-muted/25 px-4 py-3.5",
|
header: "border-b border-border bg-muted/25 px-3 py-2.5 sm:px-4 sm:py-3.5",
|
||||||
avatar:
|
avatar:
|
||||||
"flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-primary/12 text-sm font-semibold text-primary",
|
"flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-[#f1f5f9] text-xs font-semibold text-slate-600 sm:h-10 sm:w-10 sm:text-sm",
|
||||||
title: "truncate text-[15px] font-semibold text-foreground",
|
title: "truncate text-sm font-semibold text-foreground sm:text-[15px]",
|
||||||
meta: "text-xs text-muted-foreground",
|
meta: "text-xs text-muted-foreground",
|
||||||
detailLabel:
|
detailLabel:
|
||||||
"text-[11px] font-medium uppercase tracking-wide text-muted-foreground",
|
"text-[10px] font-medium uppercase tracking-wide text-muted-foreground sm:text-[11px]",
|
||||||
detailValue: "mt-0.5 text-sm text-foreground",
|
detailValue: "mt-0.5 text-xs text-foreground sm:text-sm",
|
||||||
footer: "mt-auto border-t border-border bg-muted/20 px-3 py-2.5",
|
footer: "mt-auto border-t border-border bg-muted/20 px-2 py-2 sm:px-3 sm:py-2.5",
|
||||||
skeleton: "animate-pulse rounded-lg border border-border bg-muted/30 p-4",
|
skeleton: "animate-pulse rounded-lg border border-border bg-muted/30 p-3 sm:p-4",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const ruleEngineTable = {
|
export const ruleEngineTable = {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default function Breadcrumbs({ items }: BreadcrumbsProps) {
|
|||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
aria-label="Home"
|
aria-label="Home"
|
||||||
className="flex items-center transition hover:text-[#10B981]"
|
className="flex items-center transition hover:text-[var(--freight-brand)]"
|
||||||
>
|
>
|
||||||
{/* <Home className="h-4 w-4" /> */}
|
{/* <Home className="h-4 w-4" /> */}
|
||||||
Dashboard
|
Dashboard
|
||||||
@@ -36,7 +36,7 @@ export default function Breadcrumbs({ items }: BreadcrumbsProps) {
|
|||||||
{item.href && !isLast ? (
|
{item.href && !isLast ? (
|
||||||
<Link
|
<Link
|
||||||
to={item.href}
|
to={item.href}
|
||||||
className="transition hover:text-[#10B981]"
|
className="transition hover:text-[var(--freight-brand)]"
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -136,6 +136,9 @@ export const URL_CONSTANTS = {
|
|||||||
CONTAINER_TYPES: "/container-types",
|
CONTAINER_TYPES: "/container-types",
|
||||||
CONTAINER_TYPE_BY_ID: (id: string) => `/container-types/${id}`,
|
CONTAINER_TYPE_BY_ID: (id: string) => `/container-types/${id}`,
|
||||||
|
|
||||||
|
WAGON_TYPES: "/wagon-types",
|
||||||
|
WAGON_TYPE_BY_ID: (id: string) => `/wagon-types/${id}`,
|
||||||
|
|
||||||
PRIORITY_RULES: "/priority-rules",
|
PRIORITY_RULES: "/priority-rules",
|
||||||
PRIORITY_RULE_BY_ID: (id: string) => `/priority-rules/${id}`,
|
PRIORITY_RULE_BY_ID: (id: string) => `/priority-rules/${id}`,
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,10 @@ import {
|
|||||||
Ban,
|
Ban,
|
||||||
Check,
|
Check,
|
||||||
FileSignature,
|
FileSignature,
|
||||||
FileText,
|
|
||||||
MessageSquareWarning,
|
MessageSquareWarning,
|
||||||
Play,
|
Play,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Truck,
|
Truck,
|
||||||
Wallet,
|
|
||||||
XCircle,
|
XCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@@ -30,10 +28,8 @@ export type BookingActionId =
|
|||||||
| "reject"
|
| "reject"
|
||||||
| "approve"
|
| "approve"
|
||||||
| "rejectApproval"
|
| "rejectApproval"
|
||||||
| "generateContract"
|
|
||||||
| "viewContract"
|
| "viewContract"
|
||||||
| "signContractStaff"
|
| "signContractStaff"
|
||||||
| "payBooking"
|
|
||||||
| "startTransit"
|
| "startTransit"
|
||||||
| "complete"
|
| "complete"
|
||||||
| "cancel";
|
| "cancel";
|
||||||
@@ -174,19 +170,6 @@ const SIGN_CONTRACT_STAFF_ACTION: BookingActionDef = {
|
|||||||
primary: true,
|
primary: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const PAY_BOOKING_ACTION: BookingActionDef = {
|
|
||||||
id: "payBooking",
|
|
||||||
label: "Pay",
|
|
||||||
shortLabel: "Pay",
|
|
||||||
description: "Complete in-app payment",
|
|
||||||
confirmTitle: "Complete payment?",
|
|
||||||
confirmDescription:
|
|
||||||
"This simulates an in-app payment (Telebirr for ETB, card for USD) and marks the booking as paid.",
|
|
||||||
variant: "default",
|
|
||||||
icon: Wallet,
|
|
||||||
primary: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
function withCancel(actions: BookingActionDef[]): BookingActionDef[] {
|
function withCancel(actions: BookingActionDef[]): BookingActionDef[] {
|
||||||
return [...actions, CANCEL_ACTION];
|
return [...actions, CANCEL_ACTION];
|
||||||
}
|
}
|
||||||
@@ -196,10 +179,8 @@ const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
|
|||||||
requestChanges: FREIGHT_PERMS.bookings.requestChanges,
|
requestChanges: FREIGHT_PERMS.bookings.requestChanges,
|
||||||
reject: FREIGHT_PERMS.bookings.reject,
|
reject: FREIGHT_PERMS.bookings.reject,
|
||||||
rejectApproval: FREIGHT_PERMS.bookings.rejectApproval,
|
rejectApproval: FREIGHT_PERMS.bookings.rejectApproval,
|
||||||
generateContract: FREIGHT_PERMS.bookings.generateContract,
|
|
||||||
viewContract: FREIGHT_PERMS.bookings.view,
|
viewContract: FREIGHT_PERMS.bookings.view,
|
||||||
signContractStaff: FREIGHT_PERMS.bookings.signStaff,
|
signContractStaff: FREIGHT_PERMS.bookings.signStaff,
|
||||||
payBooking: FREIGHT_PERMS.bookings.view,
|
|
||||||
startTransit: FREIGHT_PERMS.bookings.operations,
|
startTransit: FREIGHT_PERMS.bookings.operations,
|
||||||
complete: FREIGHT_PERMS.bookings.operations,
|
complete: FREIGHT_PERMS.bookings.operations,
|
||||||
cancel: FREIGHT_PERMS.bookings.cancel,
|
cancel: FREIGHT_PERMS.bookings.cancel,
|
||||||
@@ -277,21 +258,7 @@ export function getBookingActions(
|
|||||||
actions = withCancel(approvalActions(approvalSteps));
|
actions = withCancel(approvalActions(approvalSteps));
|
||||||
break;
|
break;
|
||||||
case "APPROVED":
|
case "APPROVED":
|
||||||
actions = [
|
actions = [{ ...VIEW_CONTRACT_ACTION, primary: true }, CANCEL_ACTION];
|
||||||
{
|
|
||||||
id: "generateContract",
|
|
||||||
label: "Generate contract",
|
|
||||||
shortLabel: "Contract",
|
|
||||||
description: "Create contract document",
|
|
||||||
confirmTitle: "Generate contract?",
|
|
||||||
confirmDescription:
|
|
||||||
"A contract will be generated and the booking moves to contract ready.",
|
|
||||||
variant: "default",
|
|
||||||
icon: FileText,
|
|
||||||
primary: true,
|
|
||||||
},
|
|
||||||
CANCEL_ACTION,
|
|
||||||
];
|
|
||||||
break;
|
break;
|
||||||
case "CONTRACT_READY":
|
case "CONTRACT_READY":
|
||||||
actions = [{ ...VIEW_CONTRACT_ACTION, primary: true }];
|
actions = [{ ...VIEW_CONTRACT_ACTION, primary: true }];
|
||||||
@@ -300,10 +267,7 @@ export function getBookingActions(
|
|||||||
actions = [SIGN_CONTRACT_STAFF_ACTION, VIEW_CONTRACT_ACTION];
|
actions = [SIGN_CONTRACT_STAFF_ACTION, VIEW_CONTRACT_ACTION];
|
||||||
break;
|
break;
|
||||||
case "FULLY_EXECUTED":
|
case "FULLY_EXECUTED":
|
||||||
actions = [
|
actions = [{ ...VIEW_CONTRACT_ACTION, label: "View executed contract", primary: true }];
|
||||||
PAY_BOOKING_ACTION,
|
|
||||||
{ ...VIEW_CONTRACT_ACTION, label: "View executed contract" },
|
|
||||||
];
|
|
||||||
break;
|
break;
|
||||||
case "PAID":
|
case "PAID":
|
||||||
actions = [
|
actions = [
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export const BOOKING_STATUS_STYLES: Record<string, StatusStyle> = {
|
|||||||
},
|
},
|
||||||
APPROVED: {
|
APPROVED: {
|
||||||
label: "Approved",
|
label: "Approved",
|
||||||
color: "bg-emerald-50 text-emerald-700 border-emerald-200",
|
color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]",
|
||||||
},
|
},
|
||||||
CONTRACT_READY: {
|
CONTRACT_READY: {
|
||||||
label: "Contract Ready",
|
label: "Contract Ready",
|
||||||
@@ -52,7 +52,7 @@ export const BOOKING_STATUS_STYLES: Record<string, StatusStyle> = {
|
|||||||
},
|
},
|
||||||
PAID: {
|
PAID: {
|
||||||
label: "Paid",
|
label: "Paid",
|
||||||
color: "bg-emerald-50 text-emerald-700 border-emerald-200",
|
color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]",
|
||||||
},
|
},
|
||||||
IN_TRANSIT: {
|
IN_TRANSIT: {
|
||||||
label: "In Transit",
|
label: "In Transit",
|
||||||
@@ -120,8 +120,8 @@ export const BOOKING_STATUS_META: Record<string, StatusMeta> = {
|
|||||||
},
|
},
|
||||||
APPROVED: {
|
APPROVED: {
|
||||||
title: "Approved",
|
title: "Approved",
|
||||||
description: "Ready to generate contract.",
|
description: "Contract generated automatically; awaiting customer signature.",
|
||||||
color: "text-emerald-600",
|
color: "text-[color:var(--freight-brand)]",
|
||||||
stage: 2,
|
stage: 2,
|
||||||
},
|
},
|
||||||
CONTRACT_READY: {
|
CONTRACT_READY: {
|
||||||
@@ -138,7 +138,7 @@ export const BOOKING_STATUS_META: Record<string, StatusMeta> = {
|
|||||||
},
|
},
|
||||||
FULLY_EXECUTED: {
|
FULLY_EXECUTED: {
|
||||||
title: "Fully Executed",
|
title: "Fully Executed",
|
||||||
description: "Contract locked; proceed to payment.",
|
description: "Contract locked; awaiting customer payment.",
|
||||||
color: "text-indigo-600",
|
color: "text-indigo-600",
|
||||||
stage: 3,
|
stage: 3,
|
||||||
},
|
},
|
||||||
@@ -157,7 +157,7 @@ export const BOOKING_STATUS_META: Record<string, StatusMeta> = {
|
|||||||
PAID: {
|
PAID: {
|
||||||
title: "Paid",
|
title: "Paid",
|
||||||
description: "Payment confirmed; ready for operations.",
|
description: "Payment confirmed; ready for operations.",
|
||||||
color: "text-emerald-600",
|
color: "text-[color:var(--freight-brand)]",
|
||||||
stage: 4,
|
stage: 4,
|
||||||
},
|
},
|
||||||
IN_TRANSIT: {
|
IN_TRANSIT: {
|
||||||
@@ -219,9 +219,17 @@ export const BOOKING_LIST_TABS = [
|
|||||||
{
|
{
|
||||||
key: "payment",
|
key: "payment",
|
||||||
label: "Payment",
|
label: "Payment",
|
||||||
statuses: ["FULLY_EXECUTED", "PAID"],
|
statuses: [
|
||||||
|
"FULLY_EXECUTED",
|
||||||
|
"PNR_GENERATED",
|
||||||
|
"PAYMENT_VERIFICATION_IN_PROGRESS",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "operations",
|
||||||
|
label: "Operations",
|
||||||
|
statuses: ["PAID", "IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"],
|
||||||
},
|
},
|
||||||
{ key: "operations", label: "Operations", statuses: ["IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"] },
|
|
||||||
{ key: "completed", label: "Completed", statuses: ["COMPLETED"] },
|
{ key: "completed", label: "Completed", statuses: ["COMPLETED"] },
|
||||||
{ key: "closed", label: "Closed", statuses: ["REJECTED", "CANCELLED"] },
|
{ key: "closed", label: "Closed", statuses: ["REJECTED", "CANCELLED"] },
|
||||||
] as const;
|
] as const;
|
||||||
@@ -240,11 +248,15 @@ export const WORKFLOW_STAGES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Payment",
|
label: "Payment",
|
||||||
statuses: ["FULLY_EXECUTED", "PAID"],
|
statuses: [
|
||||||
|
"FULLY_EXECUTED",
|
||||||
|
"PNR_GENERATED",
|
||||||
|
"PAYMENT_VERIFICATION_IN_PROGRESS",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Operations",
|
label: "Operations",
|
||||||
statuses: ["IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"],
|
statuses: ["PAID", "IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"],
|
||||||
},
|
},
|
||||||
{ label: "Done", statuses: ["COMPLETED"] },
|
{ label: "Done", statuses: ["COMPLETED"] },
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import "@mantine/core/styles.css";
|
||||||
import "@edr/ui-common/styles.css";
|
import "@edr/ui-common/styles.css";
|
||||||
import "../index.css";
|
import "../index.css";
|
||||||
import "@edr/ui-common/theme.css";
|
import "@edr/ui-common/theme.css";
|
||||||
@@ -9,8 +11,9 @@ import { Toaster } from "react-hot-toast";
|
|||||||
|
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import { AuthProvider } from "./auth/AuthProvider";
|
import { AuthProvider } from "./auth/AuthProvider";
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
import { queryClient } from "./lib/queryClient";
|
import { queryClient } from "./lib/queryClient";
|
||||||
|
import { freightMantineTheme } from "./theme/freight-brand";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
const THEME_STORAGE_KEY = "edr-theme";
|
const THEME_STORAGE_KEY = "edr-theme";
|
||||||
|
|
||||||
@@ -41,14 +44,15 @@ if (!rootElement) {
|
|||||||
|
|
||||||
createRoot(rootElement).render(
|
createRoot(rootElement).render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MantineProvider theme={freightMantineTheme}>
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<App />
|
<App />
|
||||||
<Toaster position="top-right" />
|
<Toaster position="top-right" />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>
|
||||||
|
</MantineProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,155 @@
|
|||||||
import FeaturePlaceholder from "@/components/FeaturePlaceholder";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
import { Container, Stack, Grid } from "@mantine/core";
|
||||||
|
|
||||||
|
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||||
|
import {
|
||||||
|
detailStyles,
|
||||||
|
type BookingDetailView,
|
||||||
|
BookingDetailToolbar,
|
||||||
|
BookingDetailHeader,
|
||||||
|
BookingLifecycleStepper,
|
||||||
|
BookingRouteCard,
|
||||||
|
BookingContainersCard,
|
||||||
|
BookingApprovalCard,
|
||||||
|
BookingReviewNotesCard,
|
||||||
|
BookingPaymentCard,
|
||||||
|
BookingFactsCard,
|
||||||
|
BookingDocumentsCard,
|
||||||
|
} from "@/components/bookings/detail";
|
||||||
|
|
||||||
const BookingDetailPage = () => {
|
const BookingDetailPage = () => {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Mock data - replace with actual API call
|
||||||
|
const booking: BookingDetailView = {
|
||||||
|
id: id || "a61955b7-af21-4664-84d3-7e4e66293b6f",
|
||||||
|
reference: "BKG-2026-001456",
|
||||||
|
status: "IN_TRANSIT",
|
||||||
|
scheduledDate: "2026-06-15",
|
||||||
|
totalAmount: 15750.5,
|
||||||
|
paymentCurrency: "USD",
|
||||||
|
paymentStatus: "PAID",
|
||||||
|
tradeDirection: "IMPORT",
|
||||||
|
freightType: "CONTAINER",
|
||||||
|
priorityScore: 650,
|
||||||
|
company: {
|
||||||
|
id: "1",
|
||||||
|
companyName: "Global Logistics Inc.",
|
||||||
|
name: "Global Logistics Inc.",
|
||||||
|
},
|
||||||
|
originYard: { id: "1", label: "Port of Shanghai", code: "PVG" },
|
||||||
|
destinationYard: { id: "2", label: "Port of Addis Ababa", code: "AAA" },
|
||||||
|
serviceType: { id: "1", label: "Container Import Service", code: "CIS" },
|
||||||
|
cargoType: { id: "1", label: "Electronics", code: "ELEC" },
|
||||||
|
shippingLine: { id: "1", label: "Maersk Line", code: "MAE" },
|
||||||
|
cargoTotalWeightVgm: 22.5,
|
||||||
|
pnrCode: "PNR-2026-001456",
|
||||||
|
createdAt: "2026-06-05T10:30:00Z",
|
||||||
|
updatedAt: "2026-06-06T14:20:00Z",
|
||||||
|
bookingContainers: [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
quantity: 2,
|
||||||
|
vgmPerUnitTons: 11.25,
|
||||||
|
containerType: { label: "20FT Standard", sizeFt: 20, isReefer: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
approvalSteps: [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
stepOrder: 1,
|
||||||
|
requiredRole: "LINE_STAFF",
|
||||||
|
status: "APPROVED",
|
||||||
|
actionedAt: "2026-06-05T11:00:00Z",
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// id: "2",
|
||||||
|
// stepOrder: 2,
|
||||||
|
// requiredRole: "DIRECTOR",
|
||||||
|
// status: "APPROVED",
|
||||||
|
// actionedAt: "2026-06-05T13:30:00Z",
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
stepOrder: 3,
|
||||||
|
requiredRole: "CEO",
|
||||||
|
status: "APPROVED",
|
||||||
|
actionedAt: "2026-06-05T15:45:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
reviewNotes: [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
note: "Cargo declaration verified against shipping documents.",
|
||||||
|
type: "VERIFICATION",
|
||||||
|
createdAt: "2026-06-05T11:15:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
note: "VGM documentation received and processed.",
|
||||||
|
type: "COMPLIANCE",
|
||||||
|
createdAt: "2026-06-05T12:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
files: [
|
||||||
|
{ id: "1", name: "Bill_of_Lading.pdf", mimeType: "application/pdf" },
|
||||||
|
{ id: "2", name: "VGM_Certificate.pdf", mimeType: "application/pdf" },
|
||||||
|
{ id: "3", name: "Commercial_Invoice.pdf", mimeType: "application/pdf" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const approvalSteps = booking.approvalSteps ?? [];
|
||||||
|
const approvedCount = approvalSteps.filter((s) => s.status === "APPROVED").length;
|
||||||
|
const totalSteps = approvalSteps.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FeaturePlaceholder
|
<div style={detailStyles.page}>
|
||||||
title="Booking Detail"
|
<Container size="xxl" py="lg">
|
||||||
description="Inspect booking metadata, operational notes, and fulfillment progress for internal teams."
|
<BookingDetailToolbar onBack={() => navigate(-1)} />
|
||||||
/>
|
|
||||||
|
<Breadcrumbs
|
||||||
|
items={[
|
||||||
|
{ label: "Operations" },
|
||||||
|
{ label: "Booking requests", href: "/dashboard/booking-requests" },
|
||||||
|
{ label: booking.reference },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{/*
|
||||||
|
<BookingDetailHeader
|
||||||
|
booking={booking}
|
||||||
|
approvedCount={approvedCount}
|
||||||
|
totalSteps={totalSteps}
|
||||||
|
/> */}
|
||||||
|
|
||||||
|
<BookingLifecycleStepper status={booking.status} />
|
||||||
|
|
||||||
|
<Grid gutter="lg">
|
||||||
|
{/* LEFT — primary content */}
|
||||||
|
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||||
|
<Stack gap="lg">
|
||||||
|
<BookingRouteCard booking={booking} />
|
||||||
|
<BookingContainersCard containers={booking.bookingContainers ?? []} />
|
||||||
|
<BookingApprovalCard steps={approvalSteps} approvedCount={approvedCount} />
|
||||||
|
<BookingReviewNotesCard notes={booking.reviewNotes ?? []} />
|
||||||
|
</Stack>
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
{/* RIGHT — summary sidebar */}
|
||||||
|
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||||
|
<Stack gap="lg">
|
||||||
|
<BookingPaymentCard
|
||||||
|
totalAmount={booking.totalAmount}
|
||||||
|
currency={booking.paymentCurrency}
|
||||||
|
paymentStatus={booking.paymentStatus}
|
||||||
|
/>
|
||||||
|
<BookingFactsCard booking={booking} />
|
||||||
|
<BookingDocumentsCard files={booking.files ?? []} />
|
||||||
|
</Stack>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,111 +1,110 @@
|
|||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { ArrowLeft, FileSignature, Package } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Anchor,
|
Container,
|
||||||
ArrowLeft,
|
Stack,
|
||||||
ArrowRight,
|
Grid,
|
||||||
Building2,
|
Center,
|
||||||
Calendar,
|
Loader,
|
||||||
Clock,
|
Text,
|
||||||
Loader2,
|
Paper,
|
||||||
MapPin,
|
Button,
|
||||||
Package,
|
Box,
|
||||||
FileSignature,
|
} from "@mantine/core";
|
||||||
RefreshCw,
|
|
||||||
Train,
|
|
||||||
Truck,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||||
import { ApprovalStepsCard } from "@/components/bookings/ApprovalStepsCard";
|
import { ApprovalStepsCard } from "@/components/bookings/ApprovalStepsCard";
|
||||||
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
|
|
||||||
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
|
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
|
||||||
import { BookingPricingSummary } from "@/components/bookings/BookingPricingSummary";
|
import { BookingPricingSummary } from "@/components/bookings/BookingPricingSummary";
|
||||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
|
||||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
|
||||||
import { BookingWorkflowStepper } from "@/components/bookings/BookingWorkflowStepper";
|
import { BookingWorkflowStepper } from "@/components/bookings/BookingWorkflowStepper";
|
||||||
import {
|
import {
|
||||||
bookingGlass,
|
detailStyles,
|
||||||
bookingSurface,
|
BookingRequestHero,
|
||||||
} from "@/components/bookings/booking-ui.styles";
|
BookingRouteServiceCard,
|
||||||
|
BookingMileServicesCard,
|
||||||
|
BookingCargoCard,
|
||||||
|
BookingContractSummaryCard,
|
||||||
|
} from "@/components/bookings/detail";
|
||||||
import { getStatusMeta } from "@/features/bookings/booking-status.config";
|
import { getStatusMeta } from "@/features/bookings/booking-status.config";
|
||||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||||
import {
|
import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings";
|
||||||
useBookingDetail,
|
|
||||||
useBookingMutations,
|
|
||||||
} from "@/hooks/bookings/useBookings";
|
|
||||||
import type { BookingDetail } from "@/types/booking";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import {
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Separator,
|
|
||||||
} from "@edr/ui-common";
|
|
||||||
|
|
||||||
export default function BookingRequestDetailPage() {
|
export default function BookingRequestDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: booking, isLoading, isError, refetch, isFetching } =
|
const { data: booking, isLoading, isError, refetch, isFetching } = useBookingDetail(id);
|
||||||
useBookingDetail(id);
|
|
||||||
const mutations = useBookingMutations(id ?? "");
|
const mutations = useBookingMutations(id ?? "");
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className={bookingSurface.page}>
|
<Box style={detailStyles.page}>
|
||||||
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 p-8">
|
<Center mih="60vh">
|
||||||
<Loader2 className="size-10 animate-spin text-muted-foreground" />
|
<Stack align="center" gap="md">
|
||||||
<p className="text-sm font-medium text-muted-foreground">
|
<Loader color="gray" />
|
||||||
Loading booking…
|
<Text size="sm" c="dimmed" fw={500}>
|
||||||
</p>
|
Loading booking…
|
||||||
</div>
|
</Text>
|
||||||
</div>
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isError || !booking) {
|
if (isError || !booking) {
|
||||||
return (
|
return (
|
||||||
<div className={bookingSurface.page}>
|
<Box style={detailStyles.page}>
|
||||||
<div className={bookingSurface.pageInner}>
|
<Container size="sm" py="xl">
|
||||||
<div
|
<Paper radius="md" withBorder p="xl" ta="center" style={detailStyles.card}>
|
||||||
className={cn(
|
<Center>
|
||||||
bookingSurface.sectionCard,
|
<Box
|
||||||
"mx-auto max-w-md p-12 text-center",
|
style={{
|
||||||
)}
|
display: "flex",
|
||||||
>
|
alignItems: "center",
|
||||||
<div
|
justifyContent: "center",
|
||||||
className={cn(
|
width: 64,
|
||||||
"mx-auto flex size-16 items-center justify-center rounded-2xl",
|
height: 64,
|
||||||
bookingGlass.iconWellGreen,
|
borderRadius: 16,
|
||||||
)}
|
background: "var(--mantine-color-gray-1)",
|
||||||
>
|
color: "var(--mantine-color-gray-6)",
|
||||||
<Package className="size-8" />
|
}}
|
||||||
</div>
|
>
|
||||||
<h1 className="mt-6 text-xl font-bold text-foreground">
|
<Package size={32} />
|
||||||
|
</Box>
|
||||||
|
</Center>
|
||||||
|
<Text fw={700} size="lg" mt="lg">
|
||||||
Booking not found
|
Booking not found
|
||||||
</h1>
|
</Text>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<Text size="sm" c="dimmed" mt={4}>
|
||||||
This request may have been removed or the link is invalid.
|
This request may have been removed or the link is invalid.
|
||||||
</p>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
className="mt-6 gap-2"
|
variant="default"
|
||||||
variant="outline"
|
mt="lg"
|
||||||
|
leftSection={<ArrowLeft size={16} />}
|
||||||
onClick={() => navigate("/dashboard/booking-requests")}
|
onClick={() => navigate("/dashboard/booking-requests")}
|
||||||
>
|
>
|
||||||
<ArrowLeft className="size-4" />
|
|
||||||
Back to booking requests
|
Back to booking requests
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</Paper>
|
||||||
</div>
|
</Container>
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const row = toBookingListRow(booking);
|
const row = toBookingListRow(booking);
|
||||||
const statusMeta = getStatusMeta(booking.status);
|
const statusMeta = getStatusMeta(booking.status);
|
||||||
const amount = Number(booking.totalAmount);
|
const showContractButton = [
|
||||||
|
"CONTRACT_READY",
|
||||||
|
"SIGNED_CUSTOMER",
|
||||||
|
"FULLY_EXECUTED",
|
||||||
|
].includes(booking.status);
|
||||||
|
const showApprovalCard =
|
||||||
|
booking.status === "PENDING_APPROVAL" ||
|
||||||
|
booking.status === "APPROVED_PENDING_SIGNATURE";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={bookingSurface.page}>
|
<Box style={detailStyles.page}>
|
||||||
<div className={bookingSurface.pageInner}>
|
<Container size="xxl" py="lg">
|
||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
items={[
|
items={[
|
||||||
{ label: "Booking requests", href: "/dashboard/booking-requests" },
|
{ label: "Booking requests", href: "/dashboard/booking-requests" },
|
||||||
@@ -113,381 +112,66 @@ export default function BookingRequestDetailPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={bookingSurface.detailHero}>
|
<Stack gap="lg" mt="sm">
|
||||||
<div className={bookingSurface.heroGlow} />
|
<BookingRequestHero
|
||||||
<div className={bookingSurface.heroSheen} />
|
booking={booking}
|
||||||
<div className="relative p-6 sm:p-8">
|
customerLabel={row.customerLabel}
|
||||||
<div className="mb-4">
|
onBack={() => navigate("/dashboard/booking-requests")}
|
||||||
<Button
|
onRefresh={() => refetch()}
|
||||||
variant="ghost"
|
isFetching={isFetching}
|
||||||
size="sm"
|
/>
|
||||||
className="-ml-2 gap-2 text-muted-foreground hover:text-foreground"
|
|
||||||
onClick={() => navigate("/dashboard/booking-requests")}
|
<BookingWorkflowStepper
|
||||||
>
|
status={booking.status}
|
||||||
<ArrowLeft className="size-4" />
|
title={statusMeta.title}
|
||||||
Back to list
|
description={statusMeta.description}
|
||||||
</Button>
|
titleColor={statusMeta.color}
|
||||||
</div>
|
/>
|
||||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
|
|
||||||
<div className="flex gap-4">
|
<Grid gutter="lg">
|
||||||
<div
|
{/* LEFT — primary content */}
|
||||||
className={cn(
|
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||||
"flex size-16 shrink-0 items-center justify-center rounded-2xl",
|
<Stack gap="lg">
|
||||||
bookingGlass.iconWellGreen,
|
<BookingRouteServiceCard
|
||||||
|
booking={booking}
|
||||||
|
originLabel={row.originLabel}
|
||||||
|
destinationLabel={row.destinationLabel}
|
||||||
|
/>
|
||||||
|
<BookingMileServicesCard booking={booking} />
|
||||||
|
<BookingCargoCard booking={booking} />
|
||||||
|
{booking.contractSummary && (
|
||||||
|
<BookingContractSummaryCard summary={booking.contractSummary} />
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
{/* RIGHT — sticky action / summary rail */}
|
||||||
|
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||||
|
<Box style={{ position: "sticky", top: 24 }}>
|
||||||
|
<Stack gap="lg">
|
||||||
|
<BookingPricingSummary booking={booking} />
|
||||||
|
<BookingActionsToolbar booking={booking} mutations={mutations} />
|
||||||
|
{showContractButton && (
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
color="green"
|
||||||
|
leftSection={<FileSignature size={16} />}
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/dashboard/booking-requests/${booking.id}/contract`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
View & sign contract
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
>
|
{showApprovalCard && (
|
||||||
<Package className="size-7" strokeWidth={1.75} />
|
<ApprovalStepsCard booking={booking} mutations={mutations} />
|
||||||
</div>
|
|
||||||
<div className="min-w-0 space-y-3">
|
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
||||||
Booking reference
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<h1 className="text-2xl font-semibold tracking-tight text-foreground sm:text-[1.75rem]">
|
|
||||||
{booking.reference}
|
|
||||||
</h1>
|
|
||||||
<BookingStatusBadge status={booking.status} />
|
|
||||||
<BookingPriorityBadge score={booking.priorityScore} />
|
|
||||||
</div>
|
|
||||||
{booking.nextStep && (
|
|
||||||
<NextStepBanner nextStep={booking.nextStep} className="max-w-xl" />
|
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-muted-foreground">
|
</Stack>
|
||||||
<span className="inline-flex items-center gap-1.5 font-medium text-foreground">
|
</Box>
|
||||||
<Building2 className="size-4 opacity-70" />
|
</Grid.Col>
|
||||||
{row.customerLabel}
|
</Grid>
|
||||||
</span>
|
</Stack>
|
||||||
<span className="inline-flex items-center gap-1.5">
|
</Container>
|
||||||
<Calendar className="size-4 opacity-70" />
|
</Box>
|
||||||
Scheduled {booking.scheduledDate}
|
|
||||||
</span>
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
<Clock className="size-4 opacity-70" />
|
|
||||||
Created{" "}
|
|
||||||
{new Date(booking.createdAt).toLocaleDateString(undefined, {
|
|
||||||
dateStyle: "medium",
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-stretch gap-3 sm:items-end">
|
|
||||||
<div className={cn(bookingSurface.valueCard, "min-w-[12rem] text-right")}>
|
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
||||||
Total value
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 font-mono text-2xl font-semibold tabular-nums text-foreground">
|
|
||||||
{booking.paymentCurrency}{" "}
|
|
||||||
{amount.toLocaleString(undefined, {
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{booking.paymentStatus}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="gap-2 self-end border-border/60 bg-background/60 backdrop-blur-sm hover:bg-background/80"
|
|
||||||
disabled={isFetching}
|
|
||||||
onClick={() => refetch()}
|
|
||||||
>
|
|
||||||
<RefreshCw
|
|
||||||
className={cn("size-4", isFetching && "animate-spin")}
|
|
||||||
/>
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<BookingWorkflowStepper
|
|
||||||
status={booking.status}
|
|
||||||
title={statusMeta.title}
|
|
||||||
description={statusMeta.description}
|
|
||||||
titleColor={statusMeta.color}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-12 xl:gap-8">
|
|
||||||
<div className="flex flex-col gap-6 xl:col-span-8">
|
|
||||||
<RouteCard booking={booking} row={row} />
|
|
||||||
<MileCard booking={booking} />
|
|
||||||
<CargoCard booking={booking} />
|
|
||||||
{booking.contractSummary && (
|
|
||||||
<SectionShell
|
|
||||||
icon={<Anchor className="size-4" />}
|
|
||||||
title="Contract summary"
|
|
||||||
subtitle="Generated terms"
|
|
||||||
>
|
|
||||||
<pre className="max-h-64 overflow-auto whitespace-pre-wrap rounded-lg border border-border/50 bg-muted/10 p-4 font-mono text-xs leading-relaxed text-muted-foreground backdrop-blur-sm">
|
|
||||||
{booking.contractSummary}
|
|
||||||
</pre>
|
|
||||||
</SectionShell>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col gap-6",
|
|
||||||
bookingSurface.stickySidebar,
|
|
||||||
"xl:col-span-4",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<BookingPricingSummary booking={booking} />
|
|
||||||
<BookingActionsToolbar booking={booking} mutations={mutations} />
|
|
||||||
{["CONTRACT_READY", "SIGNED_CUSTOMER", "FULLY_EXECUTED"].includes(
|
|
||||||
booking.status,
|
|
||||||
) && (
|
|
||||||
<div className={bookingSurface.sectionCard}>
|
|
||||||
<div className="px-5 py-4">
|
|
||||||
<Button
|
|
||||||
className="w-full gap-2 shadow-sm"
|
|
||||||
variant="default"
|
|
||||||
onClick={() =>
|
|
||||||
navigate(`/dashboard/booking-requests/${booking.id}/contract`)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<FileSignature className="size-4" />
|
|
||||||
View & sign contract
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(booking.status === "PENDING_APPROVAL" ||
|
|
||||||
booking.status === "APPROVED_PENDING_SIGNATURE") && (
|
|
||||||
<ApprovalStepsCard booking={booking} mutations={mutations} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SectionShell({
|
|
||||||
icon,
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
icon: React.ReactNode;
|
|
||||||
title: string;
|
|
||||||
subtitle?: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className={bookingSurface.sectionCard}>
|
|
||||||
<div className={bookingSurface.sectionHeader}>
|
|
||||||
<div className={bookingSurface.sectionIcon}>{icon}</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
|
|
||||||
{subtitle && (
|
|
||||||
<p className="text-xs text-muted-foreground">{subtitle}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={bookingSurface.sectionBody}>{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RouteCard({
|
|
||||||
booking,
|
|
||||||
row,
|
|
||||||
}: {
|
|
||||||
booking: BookingDetail;
|
|
||||||
row: ReturnType<typeof toBookingListRow>;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<SectionShell
|
|
||||||
icon={<Train className="size-4" />}
|
|
||||||
title="Route & service"
|
|
||||||
subtitle="Corridor and service level"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col items-stretch gap-6 rounded-xl border border-dashed border-emerald-500/20 p-5 backdrop-blur-sm md:flex-row md:items-center md:justify-between",
|
|
||||||
bookingGlass.activeTab,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<RouteEndpoint label="Origin" station={row.originLabel} />
|
|
||||||
<div className="flex flex-col items-center gap-2 px-4">
|
|
||||||
<div className={cn("flex size-10 items-center justify-center rounded-full", bookingGlass.iconWellGreen)}>
|
|
||||||
<Train className="size-5 text-black" strokeWidth={1.75} />
|
|
||||||
</div>
|
|
||||||
<ArrowRight className="size-5 rotate-90 text-muted-foreground md:rotate-0" />
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="border-border/50 bg-background/50 text-[10px] font-medium uppercase backdrop-blur-sm"
|
|
||||||
>
|
|
||||||
{booking.serviceType?.label ??
|
|
||||||
booking.serviceType?.code ??
|
|
||||||
"Rail service"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<RouteEndpoint label="Destination" station={row.destinationLabel} />
|
|
||||||
</div>
|
|
||||||
<div className="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
<MetricTile label="Trade direction" value={booking.tradeDirection} />
|
|
||||||
<MetricTile label="Freight type" value={booking.freightType} />
|
|
||||||
<MetricTile
|
|
||||||
label="Equipment return"
|
|
||||||
value={booking.equipmentReturn ?? "—"}
|
|
||||||
/>
|
|
||||||
{booking.shippingLine && (
|
|
||||||
<MetricTile
|
|
||||||
label="Shipping line"
|
|
||||||
value={
|
|
||||||
booking.shippingLine.label ??
|
|
||||||
booking.shippingLine.name ??
|
|
||||||
booking.shippingLine.code ??
|
|
||||||
"—"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SectionShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MileCard({ booking }: { booking: BookingDetail }) {
|
|
||||||
if (!booking.firstMilePickupAddress && !booking.lastMileDeliveryAddress) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<SectionShell
|
|
||||||
icon={<Truck className="size-4" />}
|
|
||||||
title="Mile services"
|
|
||||||
subtitle="First and last mile"
|
|
||||||
>
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
{booking.firstMilePickupAddress && (
|
|
||||||
<MetricTile
|
|
||||||
label="First mile pickup"
|
|
||||||
value={booking.firstMilePickupAddress}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{booking.lastMileDeliveryAddress && (
|
|
||||||
<MetricTile
|
|
||||||
label="Last mile delivery"
|
|
||||||
value={booking.lastMileDeliveryAddress}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</SectionShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CargoCard({ booking }: { booking: BookingDetail }) {
|
|
||||||
const containers = booking.bookingContainers ?? [];
|
|
||||||
return (
|
|
||||||
<SectionShell
|
|
||||||
icon={<Package className="size-4" />}
|
|
||||||
title="Cargo specifications"
|
|
||||||
subtitle="Freight and containers"
|
|
||||||
>
|
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
|
||||||
<MetricTile
|
|
||||||
label="Cargo type"
|
|
||||||
value={booking.cargoType?.label ?? booking.freightType}
|
|
||||||
/>
|
|
||||||
<MetricTile
|
|
||||||
label="Total VGM"
|
|
||||||
value={`${booking.cargoTotalWeightVgm} tons`}
|
|
||||||
/>
|
|
||||||
<MetricTile
|
|
||||||
label="Hazardous"
|
|
||||||
value={booking.isHazardous ? "Yes" : "No"}
|
|
||||||
highlight={booking.isHazardous}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{containers.length > 0 && (
|
|
||||||
<>
|
|
||||||
<Separator className="my-5" />
|
|
||||||
<div className="overflow-hidden rounded-lg border border-border/50 backdrop-blur-sm">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-border/50 bg-muted/20 text-left text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
||||||
<th className="px-4 py-3">Container type</th>
|
|
||||||
<th className="px-4 py-3">Qty</th>
|
|
||||||
<th className="px-4 py-3">VGM / unit</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{containers.map((c) => (
|
|
||||||
<tr
|
|
||||||
key={c.id}
|
|
||||||
className="border-b border-border/60 last:border-0"
|
|
||||||
>
|
|
||||||
<td className="px-4 py-3 font-medium text-foreground">
|
|
||||||
{c.containerType?.label ??
|
|
||||||
c.containerType?.code ??
|
|
||||||
c.containerTypeId}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 tabular-nums text-muted-foreground">
|
|
||||||
{c.quantity}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 tabular-nums text-muted-foreground">
|
|
||||||
{c.vgmPerUnitTons} t
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</SectionShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RouteEndpoint({
|
|
||||||
label,
|
|
||||||
station,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
station: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-w-0 items-center gap-3 md:max-w-[14rem]">
|
|
||||||
<div className={bookingSurface.sectionIconLg}>
|
|
||||||
<MapPin className="size-5" strokeWidth={1.75} />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
||||||
{label}
|
|
||||||
</p>
|
|
||||||
<p className="truncate text-sm font-semibold text-foreground">{station}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MetricTile({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
highlight,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
highlight?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
bookingSurface.metricTile,
|
|
||||||
highlight && "border-amber-300/50 bg-amber-50/50 dark:bg-amber-950/20",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
||||||
{label}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1.5 text-sm font-medium leading-snug text-foreground">
|
|
||||||
{value}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,20 @@ import {
|
|||||||
User,
|
User,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Stack,
|
||||||
|
Group,
|
||||||
|
Title,
|
||||||
|
Text,
|
||||||
|
Card,
|
||||||
|
TextInput,
|
||||||
|
ActionIcon,
|
||||||
|
Badge as MantineBadge,
|
||||||
|
Button as MantineButton,
|
||||||
|
ThemeIcon,
|
||||||
|
Paper,
|
||||||
|
} from "@mantine/core";
|
||||||
|
|
||||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||||
@@ -26,12 +40,7 @@ import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
|||||||
import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell";
|
import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell";
|
||||||
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
|
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
|
||||||
import { BookingTableEmpty } from "@/components/bookings/BookingTableEmpty";
|
import { BookingTableEmpty } from "@/components/bookings/BookingTableEmpty";
|
||||||
import {
|
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||||
bookingGlass,
|
|
||||||
bookingInput,
|
|
||||||
bookingSurface,
|
|
||||||
bookingTable,
|
|
||||||
} from "@/components/bookings/booking-ui.styles";
|
|
||||||
import { BOOKING_LIST_TABS } from "@/features/bookings/booking-status.config";
|
import { BOOKING_LIST_TABS } from "@/features/bookings/booking-status.config";
|
||||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||||
import { useBookingList, useBookingListSummary } from "@/hooks/bookings/useBookings";
|
import { useBookingList, useBookingListSummary } from "@/hooks/bookings/useBookings";
|
||||||
@@ -176,8 +185,18 @@ export default function BookingRequestsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "status",
|
id: "status",
|
||||||
|
size: 200,
|
||||||
|
minSize: 180,
|
||||||
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||||
cell: ({ row }) => <BookingStatusBadge status={row.original.status} />,
|
cell: ({ row }) => (
|
||||||
|
<div className="py-1">
|
||||||
|
<BookingStatusBadge status={row.original.status} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
headerClassName: "min-w-[11rem]",
|
||||||
|
cellClassName: "min-w-[11rem]",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "approval",
|
id: "approval",
|
||||||
@@ -237,168 +256,189 @@ export default function BookingRequestsPage() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={bookingSurface.page}>
|
<div style={{ background: "var(--mantine-color-gray-0)", minHeight: "100vh" }}>
|
||||||
<div className={bookingSurface.pageInner}>
|
<Container size="xxl" py="xl">
|
||||||
<Breadcrumbs items={[{ label: "Operations" }, { label: "Booking requests" }]} />
|
<Breadcrumbs items={[{ label: "Operations" }, { label: "Booking requests" }]} />
|
||||||
|
{/*
|
||||||
<div className={bookingSurface.hero}>
|
<Card
|
||||||
<div className={bookingSurface.heroGlow} />
|
p="lg"
|
||||||
<div className={bookingSurface.heroSheen} />
|
radius="lg"
|
||||||
<div className="relative flex flex-col gap-6 p-6 sm:flex-row sm:items-center sm:justify-between sm:p-8">
|
withBorder
|
||||||
<div className="flex items-start gap-4">
|
mb="xl"
|
||||||
<div
|
style={{
|
||||||
className={cn(
|
background: "white",
|
||||||
"flex size-14 shrink-0 items-center justify-center rounded-2xl",
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
bookingGlass.iconWellGreen,
|
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.05)",
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Inbox className="size-6" strokeWidth={1.75} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
||||||
Operations
|
|
||||||
</p>
|
|
||||||
<h1 className="mt-1 text-2xl font-semibold tracking-tight text-foreground sm:text-[1.75rem]">
|
|
||||||
Booking requests
|
|
||||||
</h1>
|
|
||||||
<p className="mt-1.5 max-w-xl text-sm leading-relaxed text-muted-foreground">
|
|
||||||
Track bookings from submission through payment and operations.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="gap-2 border-border/60 bg-background/60 backdrop-blur-sm hover:bg-background/80"
|
|
||||||
disabled={isFetching}
|
|
||||||
onClick={handleRefresh}
|
|
||||||
>
|
|
||||||
<RefreshCw
|
|
||||||
className={cn("size-4", isFetching && "animate-spin")}
|
|
||||||
/>
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<BookingStatGrid
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
label: "In queue",
|
|
||||||
value: statValue(metrics?.inQueue),
|
|
||||||
hint: "Total matching filter",
|
|
||||||
icon: LayoutList,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "On this page",
|
|
||||||
value: statValue(metrics?.onThisPage),
|
|
||||||
hint: "Current page",
|
|
||||||
icon: FileText,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Needs action",
|
|
||||||
value: statValue(metrics?.needsAction),
|
|
||||||
hint: "Submitted or pending approval",
|
|
||||||
icon: Clock,
|
|
||||||
accent:
|
|
||||||
!summaryLoading && (metrics?.needsAction ?? 0) > 0
|
|
||||||
? "amber"
|
|
||||||
: "default",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Urgent",
|
|
||||||
value: statValue(metrics?.urgent),
|
|
||||||
hint: "High priority score",
|
|
||||||
icon: AlertCircle,
|
|
||||||
accent:
|
|
||||||
!summaryLoading && (metrics?.urgent ?? 0) > 0 ? "rose" : "default",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<BookingStatusTabs
|
|
||||||
active={activeTab}
|
|
||||||
onChange={(tab) => {
|
|
||||||
setActiveTab(tab);
|
|
||||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
|
||||||
}}
|
}}
|
||||||
counts={tabCounts}
|
>
|
||||||
/>
|
<Group justify="space-between" align="flex-start">
|
||||||
|
<Group gap="md" align="flex-start">
|
||||||
<div className={bookingSurface.panel}>
|
<ThemeIcon
|
||||||
<div className={bookingSurface.panelToolbar}>
|
size="lg"
|
||||||
<div className="relative min-w-[12rem] flex-1 sm:max-w-sm">
|
radius="lg"
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
color="green"
|
||||||
<Input
|
variant="light"
|
||||||
type="search"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
placeholder="Search reference or customer…"
|
|
||||||
className={bookingInput.search}
|
|
||||||
/>
|
|
||||||
{query && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
||||||
onClick={() => setQuery("")}
|
|
||||||
aria-label="Clear search"
|
|
||||||
>
|
|
||||||
<X className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"hidden rounded-md border border-border/50 bg-background/50 px-2.5 py-1 text-xs text-muted-foreground backdrop-blur-sm sm:inline",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{total} record{total !== 1 ? "s" : ""}
|
<Inbox size={28} />
|
||||||
</span>
|
</ThemeIcon>
|
||||||
</div>
|
<Stack gap={8}>
|
||||||
</div>
|
<Text size="xs" fw={600} c="dimmed" tt="uppercase">
|
||||||
|
Operations
|
||||||
|
</Text>
|
||||||
|
<Title order={1} size="h2">
|
||||||
|
Booking Requests
|
||||||
|
</Title>
|
||||||
|
<Text size="sm" c="dimmed" maw="500px">
|
||||||
|
Track bookings from submission through payment and operations. Monitor status, prioritize urgent bookings, and manage approvals.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
<MantineButton
|
||||||
|
variant="light"
|
||||||
|
color="green"
|
||||||
|
leftSection={<RefreshCw size={18} />}
|
||||||
|
disabled={isFetching}
|
||||||
|
onClick={handleRefresh}
|
||||||
|
loading={isFetching}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</MantineButton>
|
||||||
|
</Group>
|
||||||
|
</Card> */}
|
||||||
|
|
||||||
{showEmpty ? (
|
<div className="mt-6"></div>
|
||||||
<BookingTableEmpty
|
<Stack gap="lg">
|
||||||
isError={isError}
|
<BookingStatGrid
|
||||||
hasSearch={hasSearch}
|
items={[
|
||||||
onRetry={handleRefresh}
|
{
|
||||||
|
label: "In queue",
|
||||||
|
value: statValue(metrics?.inQueue),
|
||||||
|
hint: "Total matching filter",
|
||||||
|
icon: LayoutList,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "On this page",
|
||||||
|
value: statValue(metrics?.onThisPage),
|
||||||
|
hint: "Current page",
|
||||||
|
icon: FileText,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Needs action",
|
||||||
|
value: statValue(metrics?.needsAction),
|
||||||
|
hint: "Submitted or pending approval",
|
||||||
|
icon: Clock,
|
||||||
|
accent:
|
||||||
|
!summaryLoading && (metrics?.needsAction ?? 0) > 0
|
||||||
|
? "amber"
|
||||||
|
: "default",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Urgent",
|
||||||
|
value: statValue(metrics?.urgent),
|
||||||
|
hint: "High priority score",
|
||||||
|
icon: AlertCircle,
|
||||||
|
accent:
|
||||||
|
!summaryLoading && (metrics?.urgent ?? 0) > 0 ? "rose" : "default",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Paper
|
||||||
|
p="md"
|
||||||
|
radius="lg"
|
||||||
|
withBorder
|
||||||
|
style={{
|
||||||
|
background: "white",
|
||||||
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BookingStatusTabs
|
||||||
|
active={activeTab}
|
||||||
|
onChange={(tab) => {
|
||||||
|
setActiveTab(tab);
|
||||||
|
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||||
|
}}
|
||||||
|
counts={tabCounts}
|
||||||
/>
|
/>
|
||||||
) : (
|
</Paper>
|
||||||
<div className={bookingSurface.tableWrap}>
|
|
||||||
<DataTable
|
<Card
|
||||||
columns={columns}
|
p="md"
|
||||||
data={rows}
|
radius="lg"
|
||||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
withBorder
|
||||||
onRowClick={handleRowClick}
|
style={{
|
||||||
pagination={{
|
background: "white",
|
||||||
pageIndex: pagination.pageIndex,
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
pageSize: pagination.pageSize,
|
}}
|
||||||
pageCount,
|
>
|
||||||
totalCount: total,
|
<Stack gap="md">
|
||||||
}}
|
<Group justify="space-between" gap="md" wrap="wrap">
|
||||||
tableOptions={{
|
<TextInput
|
||||||
state: { pagination },
|
placeholder="Search reference or customer…"
|
||||||
onPaginationChange: setPagination,
|
leftSection={<Search size={18} />}
|
||||||
manualPagination: true,
|
value={query}
|
||||||
pageCount,
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
}}
|
rightSection={
|
||||||
containerClassName={cn(
|
query && (
|
||||||
"border-0 shadow-none",
|
<ActionIcon
|
||||||
"[&_thead_tr]:border-b [&_thead_tr]:border-border/50",
|
size="sm"
|
||||||
"[&_thead_th]:bg-muted/20 [&_thead_th]:backdrop-blur-sm",
|
color="gray"
|
||||||
"[&_tbody_tr]:group/tr [&_tbody_tr]:cursor-pointer [&_tbody_tr]:border-b [&_tbody_tr]:border-border/30",
|
radius="md"
|
||||||
"[&_tbody_tr]:transition-colors [&_tbody_tr:hover]:bg-muted/20",
|
variant="transparent"
|
||||||
)}
|
onClick={() => setQuery("")}
|
||||||
footer={DataTableFooter}
|
>
|
||||||
/>
|
<X size={16} />
|
||||||
</div>
|
</ActionIcon>
|
||||||
)}
|
)
|
||||||
</div>
|
}
|
||||||
</div>
|
style={{ flex: 1, minWidth: "200px" }}
|
||||||
|
radius="lg"
|
||||||
|
/>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{total} record{total !== 1 ? "s" : ""}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{showEmpty ? (
|
||||||
|
<BookingTableEmpty
|
||||||
|
isError={isError}
|
||||||
|
hasSearch={hasSearch}
|
||||||
|
onRetry={handleRefresh}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ overflowX: "auto" }}>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={rows}
|
||||||
|
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||||
|
onRowClick={handleRowClick}
|
||||||
|
pagination={{
|
||||||
|
pageIndex: pagination.pageIndex,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
pageCount,
|
||||||
|
totalCount: total,
|
||||||
|
}}
|
||||||
|
tableOptions={{
|
||||||
|
state: { pagination },
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
manualPagination: true,
|
||||||
|
pageCount,
|
||||||
|
}}
|
||||||
|
containerClassName={cn(
|
||||||
|
"border-0 shadow-none",
|
||||||
|
"[&_thead_tr]:border-b [&_thead_tr]:border-border/50",
|
||||||
|
"[&_thead_th]:bg-muted/20 [&_thead_th]:backdrop-blur-sm",
|
||||||
|
"[&_tbody_tr]:group/tr [&_tbody_tr]:cursor-pointer [&_tbody_tr]:border-b [&_tbody_tr]:border-border/30",
|
||||||
|
"[&_tbody_tr]:transition-colors [&_tbody_tr:hover]:bg-muted/20",
|
||||||
|
)}
|
||||||
|
footer={DataTableFooter}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,16 +4,14 @@ import { useAuth } from "@/auth/useAuth";
|
|||||||
import { canAccessRuleEngineResource } from "@/lib/permissions";
|
import { canAccessRuleEngineResource } from "@/lib/permissions";
|
||||||
import type { ColumnDef } from "@tanstack/react-table";
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { Card, Button, Modal, Stack, Group, Text, List } from "@mantine/core";
|
||||||
|
|
||||||
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
|
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
|
||||||
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
|
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
|
||||||
import RuleEngineRecordActions from "@/components/ruleEngine/RuleEngineRecordActions";
|
import RuleEngineRecordActions from "@/components/ruleEngine/RuleEngineRecordActions";
|
||||||
import RuleEngineToolbar from "@/components/ruleEngine/RuleEngineToolbar";
|
import RuleEngineToolbar from "@/components/ruleEngine/RuleEngineToolbar";
|
||||||
import { formatCell } from "@/components/ruleEngine/ruleEngineFormat";
|
import { formatCell } from "@/components/ruleEngine/ruleEngineFormat";
|
||||||
import {
|
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||||
ruleEngineSurface,
|
|
||||||
ruleEngineTable,
|
|
||||||
} from "@/components/ruleEngine/ruleEngineStyles";
|
|
||||||
import { useRuleEngineViewMode } from "@/components/ruleEngine/useRuleEngineViewMode";
|
import { useRuleEngineViewMode } from "@/components/ruleEngine/useRuleEngineViewMode";
|
||||||
import {
|
import {
|
||||||
DEFAULT_CONFIGURATION_SLUG,
|
DEFAULT_CONFIGURATION_SLUG,
|
||||||
@@ -34,15 +32,8 @@ import {
|
|||||||
} from "@/hooks/rule-engine/useRuleEngine";
|
} from "@/hooks/rule-engine/useRuleEngine";
|
||||||
import type { RuleEngineRecord } from "@/types/rule-engine";
|
import type { RuleEngineRecord } from "@/types/rule-engine";
|
||||||
import {
|
import {
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
DataTable,
|
DataTable,
|
||||||
DataTableFooter,
|
DataTableFooter,
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
usePagination,
|
usePagination,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
@@ -177,15 +168,6 @@ const RuleEngineResourcePage = () => {
|
|||||||
[filteredRows.length, meta?.total, pageCount, pagination.pageIndex, pagination.pageSize],
|
[filteredRows.length, meta?.total, pageCount, pagination.pageIndex, pagination.pageSize],
|
||||||
);
|
);
|
||||||
|
|
||||||
const cardTable = useReactTable({
|
|
||||||
data: filteredRows,
|
|
||||||
columns: [] as ColumnDef<RuleEngineRecord>[],
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
manualPagination: true,
|
|
||||||
pageCount,
|
|
||||||
state: { pagination },
|
|
||||||
onPaginationChange: setPagination,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleApproveRate = useCallback(
|
const handleApproveRate = useCallback(
|
||||||
(record: RuleEngineRecord) => {
|
(record: RuleEngineRecord) => {
|
||||||
@@ -209,14 +191,19 @@ const RuleEngineResourcePage = () => {
|
|||||||
|
|
||||||
base.push({
|
base.push({
|
||||||
id: "actions",
|
id: "actions",
|
||||||
header: "Details",
|
header: "Actions",
|
||||||
size: 120,
|
size: 140,
|
||||||
meta: { headerClassName, cellClassName },
|
minSize: 120,
|
||||||
|
meta: {
|
||||||
|
headerClassName,
|
||||||
|
cellClassName: `${cellClassName} whitespace-nowrap`,
|
||||||
|
},
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
<div onClick={(e) => e.stopPropagation()} data-stop-row-click>
|
||||||
<RuleEngineRecordActions
|
<RuleEngineRecordActions
|
||||||
record={row.original}
|
record={row.original}
|
||||||
config={config}
|
config={config}
|
||||||
|
layout="row"
|
||||||
readOnly={!canManage}
|
readOnly={!canManage}
|
||||||
onEdit={(record) => {
|
onEdit={(record) => {
|
||||||
setEditing(record);
|
setEditing(record);
|
||||||
@@ -284,9 +271,9 @@ const RuleEngineResourcePage = () => {
|
|||||||
const itemLabel = config.label.toLowerCase();
|
const itemLabel = config.label.toLowerCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<Stack gap="lg">
|
||||||
<Card className={ruleEngineSurface.pageCard}>
|
<Card p="lg" radius="lg" withBorder style={{ background: "white", boxShadow: "0 1px 3px rgba(0, 0, 0, 0.05)" }}>
|
||||||
<div className={ruleEngineSurface.pageCardToolbar}>
|
<Stack gap="md">
|
||||||
<RuleEngineToolbar
|
<RuleEngineToolbar
|
||||||
search={search}
|
search={search}
|
||||||
onSearchChange={(v) => {
|
onSearchChange={(v) => {
|
||||||
@@ -299,65 +286,64 @@ const RuleEngineResourcePage = () => {
|
|||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
onViewModeChange={setViewMode}
|
onViewModeChange={setViewMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
{viewMode === "table" ? (
|
{viewMode === "table" ? (
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={filteredRows}
|
data={filteredRows}
|
||||||
status={tableStatus}
|
status={tableStatus}
|
||||||
error={
|
error={
|
||||||
isError
|
isError
|
||||||
? {
|
? {
|
||||||
message: "Failed to load data",
|
message: "Failed to load data",
|
||||||
description:
|
description:
|
||||||
error instanceof Error ? error.message : "Unknown error",
|
error instanceof Error ? error.message : "Unknown error",
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
emptyMessage={`No ${itemLabel} found.`}
|
emptyMessage={`No ${itemLabel} found.`}
|
||||||
pagination={paginationState}
|
pagination={paginationState}
|
||||||
tableOptions={{
|
tableOptions={{
|
||||||
manualPagination: true,
|
manualPagination: true,
|
||||||
pageCount,
|
pageCount,
|
||||||
state: { pagination },
|
state: { pagination },
|
||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
}}
|
}}
|
||||||
containerClassName="border-0 shadow-none [&_[data-slot=table-row]]:border-border"
|
containerClassName="border-0 shadow-none [&_[data-slot=table-row]]:border-border"
|
||||||
footerClassName="border-t border-border bg-card"
|
footerClassName="border-t border-border bg-card"
|
||||||
footer={({ table, pagination: footerPagination }) => (
|
footer={({ table, pagination: footerPagination }) => (
|
||||||
<DataTableFooter
|
<DataTableFooter
|
||||||
table={table}
|
table={table}
|
||||||
pagination={footerPagination}
|
pagination={footerPagination}
|
||||||
options={{
|
options={{
|
||||||
labels: {
|
labels: {
|
||||||
showing: "Showing",
|
showing: "Showing",
|
||||||
ofLabel: "of",
|
ofLabel: "of",
|
||||||
items: itemLabel,
|
items: itemLabel,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<RuleEngineCardGrid
|
<RuleEngineCardGrid
|
||||||
config={config}
|
config={config}
|
||||||
rows={filteredRows}
|
rows={filteredRows}
|
||||||
status={tableStatus}
|
status={tableStatus}
|
||||||
emptyMessage={`No ${itemLabel} found.`}
|
emptyMessage={`No ${itemLabel} found.`}
|
||||||
itemLabel={itemLabel}
|
itemLabel={itemLabel}
|
||||||
table={cardTable}
|
pagination={paginationState}
|
||||||
pagination={paginationState}
|
readOnly={!canManage}
|
||||||
readOnly={!canManage}
|
onEdit={canManage ? openEdit : undefined}
|
||||||
onEdit={canManage ? openEdit : undefined}
|
onDelete={canManage ? setDeleteTarget : undefined}
|
||||||
onDelete={canManage ? setDeleteTarget : undefined}
|
onViewChain={
|
||||||
onViewChain={
|
config.slug === "approval-rules" ? () => setChainOpen(true) : undefined
|
||||||
config.slug === "approval-rules" ? () => setChainOpen(true) : undefined
|
}
|
||||||
}
|
onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined}
|
||||||
onSubmitRate={canManage ? (id) => submit.mutate(id) : undefined}
|
onApproveRate={canManage ? handleApproveRate : undefined}
|
||||||
onApproveRate={canManage ? handleApproveRate : undefined}
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<RuleEngineFormDialog
|
<RuleEngineFormDialog
|
||||||
@@ -380,20 +366,23 @@ const RuleEngineResourcePage = () => {
|
|||||||
onSubmit={handleFormSubmit}
|
onSubmit={handleFormSubmit}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Dialog open={Boolean(deleteTarget)} onOpenChange={(o) => !o && setDeleteTarget(null)}>
|
<Modal
|
||||||
<DialogContent className={ruleEngineSurface.dialogSm}>
|
opened={Boolean(deleteTarget)}
|
||||||
<DialogHeader>
|
onClose={() => setDeleteTarget(null)}
|
||||||
<DialogTitle>Delete record?</DialogTitle>
|
title="Delete record?"
|
||||||
<DialogDescription>
|
centered
|
||||||
This will soft-delete the selected {config.label.toLowerCase()} record.
|
size="sm"
|
||||||
</DialogDescription>
|
>
|
||||||
</DialogHeader>
|
<Stack gap="md">
|
||||||
<div className="flex justify-end gap-2">
|
<Text size="sm">
|
||||||
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
|
This will soft-delete the selected {config.label.toLowerCase()} record.
|
||||||
|
</Text>
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="default" onClick={() => setDeleteTarget(null)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
color="red"
|
||||||
disabled={remove.isPending}
|
disabled={remove.isPending}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!deleteTarget) return;
|
if (!deleteTarget) return;
|
||||||
@@ -401,47 +390,51 @@ const RuleEngineResourcePage = () => {
|
|||||||
onSuccess: () => setDeleteTarget(null),
|
onSuccess: () => setDeleteTarget(null),
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
leftSection={remove.isPending && <Loader2 size={16} />}
|
||||||
>
|
>
|
||||||
{remove.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : "Delete"}
|
{remove.isPending ? "Deleting..." : "Delete"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</Group>
|
||||||
</DialogContent>
|
</Stack>
|
||||||
</Dialog>
|
</Modal>
|
||||||
|
|
||||||
<Dialog open={chainOpen} onOpenChange={setChainOpen}>
|
<Modal
|
||||||
<DialogContent className={ruleEngineSurface.dialog}>
|
opened={chainOpen}
|
||||||
<DialogHeader>
|
onClose={() => setChainOpen(false)}
|
||||||
<DialogTitle>Approval chain</DialogTitle>
|
title="Approval chain"
|
||||||
<DialogDescription>Configured approval steps from the API.</DialogDescription>
|
centered
|
||||||
</DialogHeader>
|
size="md"
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
{chainLoading ? (
|
{chainLoading ? (
|
||||||
<div className="flex justify-center py-8">
|
<Group justify="center" p="xl">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
<Loader2 size={32} style={{ animation: "spin 1s linear infinite" }} />
|
||||||
</div>
|
</Group>
|
||||||
) : (
|
) : (
|
||||||
<ol className="space-y-3">
|
<>
|
||||||
{(chainData ?? []).length === 0 ? (
|
{(chainData ?? []).length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">No approval rules configured.</p>
|
<Text size="sm" c="dimmed">No approval rules configured.</Text>
|
||||||
) : (
|
) : (
|
||||||
(chainData ?? []).map((step, index) => (
|
<List spacing="md">
|
||||||
<li
|
{(chainData ?? []).map((step, index) => (
|
||||||
key={String(step.id ?? index)}
|
<List.Item key={String(step.id ?? index)}>
|
||||||
className="rounded-md border border-border bg-muted/30 px-4 py-3 text-sm"
|
<Stack gap="xs">
|
||||||
>
|
<Text size="sm" fw={500}>
|
||||||
<p className="font-medium text-foreground">
|
Step {String(step.stepOrder ?? index + 1)}: {String(step.actionLabel ?? "")}
|
||||||
Step {String(step.stepOrder ?? index + 1)}: {String(step.actionLabel ?? "")}
|
</Text>
|
||||||
</p>
|
<Text size="sm" c="dimmed">
|
||||||
<p className="text-muted-foreground">
|
Role: {String(step.requiredRole ?? "—")}
|
||||||
Role: {String(step.requiredRole ?? "—")}
|
</Text>
|
||||||
</p>
|
</Stack>
|
||||||
</li>
|
</List.Item>
|
||||||
))
|
))}
|
||||||
|
</List>
|
||||||
)}
|
)}
|
||||||
</ol>
|
</>
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</Stack>
|
||||||
</Dialog>
|
</Modal>
|
||||||
</div>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -180,6 +180,46 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
{ name: "displayOrder", label: "Display order", type: "number" },
|
{ name: "displayOrder", label: "Display order", type: "number" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
slug: "wagon-types",
|
||||||
|
label: "Wagon Types",
|
||||||
|
category: "configuration",
|
||||||
|
subtitle: "Configure wagon classes used for capacity and train planning",
|
||||||
|
searchPlaceholder: "Search wagon types by name or code...",
|
||||||
|
cardTitleKey: "name",
|
||||||
|
columns: [
|
||||||
|
codeColumn("code"),
|
||||||
|
{ id: "name", header: "Name", accessorKey: "name" },
|
||||||
|
{ id: "capacityTons", header: "Capacity (t)", accessorKey: "capacityTons", format: "number" },
|
||||||
|
{ id: "lengthMeters", header: "Length (m)", accessorKey: "lengthMeters", format: "number" },
|
||||||
|
{ id: "maxWagonsPerTrain", header: "Max / train", accessorKey: "maxWagonsPerTrain", format: "number" },
|
||||||
|
{
|
||||||
|
id: "supportedLoadTypes",
|
||||||
|
header: "Load types",
|
||||||
|
accessorKey: "supportedLoadTypes",
|
||||||
|
},
|
||||||
|
activeColumn,
|
||||||
|
],
|
||||||
|
formFields: [
|
||||||
|
{ name: "name", label: "Name", type: "text", required: true },
|
||||||
|
{ name: "capacityTons", label: "Capacity (tons)", type: "number", required: true },
|
||||||
|
{ name: "lengthMeters", label: "Length (meters)", type: "number", required: true },
|
||||||
|
{
|
||||||
|
name: "maxWagonsPerTrain",
|
||||||
|
label: "Max wagons per train",
|
||||||
|
type: "number",
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "supportedLoadTypes",
|
||||||
|
label: "Supported load types",
|
||||||
|
type: "textarea",
|
||||||
|
optional: true,
|
||||||
|
placeholder: "CONTAINER, BULK",
|
||||||
|
},
|
||||||
|
{ name: "isActive", label: "Active", type: "boolean" },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
slug: "priority-rules",
|
slug: "priority-rules",
|
||||||
label: "Priority Rules",
|
label: "Priority Rules",
|
||||||
@@ -270,6 +310,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
category: "rules",
|
category: "rules",
|
||||||
subtitle: "VGM limits by container and trade direction",
|
subtitle: "VGM limits by container and trade direction",
|
||||||
searchPlaceholder: "Search weight limit rules...",
|
searchPlaceholder: "Search weight limit rules...",
|
||||||
|
cardTitleKey: "containerType",
|
||||||
|
cardSubtitleKey: "tradeDirection",
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
id: "containerType",
|
id: "containerType",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface RuleEngineListParams {
|
|||||||
const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
|
const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
|
||||||
"cargo-types": URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES,
|
"cargo-types": URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES,
|
||||||
"container-types": URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPES,
|
"container-types": URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPES,
|
||||||
|
"wagon-types": URL_CONSTANTS.RULE_ENGINE.WAGON_TYPES,
|
||||||
"priority-rules": URL_CONSTANTS.RULE_ENGINE.PRIORITY_RULES,
|
"priority-rules": URL_CONSTANTS.RULE_ENGINE.PRIORITY_RULES,
|
||||||
"service-types": URL_CONSTANTS.RULE_ENGINE.SERVICE_TYPES,
|
"service-types": URL_CONSTANTS.RULE_ENGINE.SERVICE_TYPES,
|
||||||
"surcharge-types": URL_CONSTANTS.RULE_ENGINE.SURCHARGE_TYPES,
|
"surcharge-types": URL_CONSTANTS.RULE_ENGINE.SURCHARGE_TYPES,
|
||||||
@@ -35,6 +36,8 @@ const byIdPath = (resource: RuleEngineResourceSlug, id: string): string => {
|
|||||||
return URL_CONSTANTS.RULE_ENGINE.CARGO_TYPE_BY_ID(id);
|
return URL_CONSTANTS.RULE_ENGINE.CARGO_TYPE_BY_ID(id);
|
||||||
case "container-types":
|
case "container-types":
|
||||||
return URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPE_BY_ID(id);
|
return URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPE_BY_ID(id);
|
||||||
|
case "wagon-types":
|
||||||
|
return URL_CONSTANTS.RULE_ENGINE.WAGON_TYPE_BY_ID(id);
|
||||||
case "priority-rules":
|
case "priority-rules":
|
||||||
return URL_CONSTANTS.RULE_ENGINE.PRIORITY_RULE_BY_ID(id);
|
return URL_CONSTANTS.RULE_ENGINE.PRIORITY_RULE_BY_ID(id);
|
||||||
case "service-types":
|
case "service-types":
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ const asList = <T>(payload: ListResponse<T>): T[] =>
|
|||||||
|
|
||||||
export const wagonTypesService = {
|
export const wagonTypesService = {
|
||||||
async getWagonTypes() {
|
async getWagonTypes() {
|
||||||
const response = await api.get<ListResponse<unknown>>('/wagon-types');
|
const response = await api.get<ListResponse<unknown>>('/wagon-types', {
|
||||||
|
params: { isActive: true, pageSize: 500 },
|
||||||
|
});
|
||||||
return asList(response.data);
|
return asList(response.data);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
40
apps/edr-freight-web/backoffice/src/theme/freight-brand.ts
Normal file
40
apps/edr-freight-web/backoffice/src/theme/freight-brand.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { createTheme, type MantineColorsTuple } from "@mantine/core";
|
||||||
|
|
||||||
|
/** EDR Freight primary brand green */
|
||||||
|
export const FREIGHT_BRAND = "#15803d";
|
||||||
|
export const FREIGHT_BRAND_DARK = "#166534";
|
||||||
|
export const FREIGHT_BRAND_LIGHT = "#22c55e";
|
||||||
|
|
||||||
|
export const freightBrand = {
|
||||||
|
primary: FREIGHT_BRAND,
|
||||||
|
primaryDark: FREIGHT_BRAND_DARK,
|
||||||
|
primaryLight: FREIGHT_BRAND_LIGHT,
|
||||||
|
gradient: `linear-gradient(135deg, ${FREIGHT_BRAND} 0%, ${FREIGHT_BRAND_DARK} 100%)`,
|
||||||
|
shadow: "0 4px 12px rgba(21, 128, 61, 0.28)",
|
||||||
|
shadowSm: "0 2px 6px rgba(21, 128, 61, 0.22)",
|
||||||
|
ring: "rgba(21, 128, 61, 0.2)",
|
||||||
|
mutedBg: "#f0fdf4",
|
||||||
|
mutedBorder: "#bbf7d0",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Mantine green scale with #15803d at index 6 (filled buttons, nav active). */
|
||||||
|
const freightGreen: MantineColorsTuple = [
|
||||||
|
"#f0fdf4",
|
||||||
|
"#dcfce7",
|
||||||
|
"#bbf7d0",
|
||||||
|
"#86efac",
|
||||||
|
"#4ade80",
|
||||||
|
"#22c55e",
|
||||||
|
FREIGHT_BRAND,
|
||||||
|
FREIGHT_BRAND_DARK,
|
||||||
|
"#14532d",
|
||||||
|
"#052e16",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const freightMantineTheme = createTheme({
|
||||||
|
primaryColor: "green",
|
||||||
|
colors: {
|
||||||
|
green: freightGreen,
|
||||||
|
},
|
||||||
|
fontFamily: "'Outfit', var(--font-sans)",
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
export type RuleEngineResourceSlug =
|
export type RuleEngineResourceSlug =
|
||||||
| "cargo-types"
|
| "cargo-types"
|
||||||
| "container-types"
|
| "container-types"
|
||||||
|
| "wagon-types"
|
||||||
| "priority-rules"
|
| "priority-rules"
|
||||||
| "service-types"
|
| "service-types"
|
||||||
| "surcharge-types"
|
| "surcharge-types"
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ async function bootstrap() {
|
|||||||
SwaggerModule.setup("api/docs", app, document);
|
SwaggerModule.setup("api/docs", app, document);
|
||||||
|
|
||||||
const port = parseInt(process.env.PORT ?? "3002", 10);
|
const port = parseInt(process.env.PORT ?? "3002", 10);
|
||||||
await app.listen(port);
|
await app.listen(port, "0.0.0.0");
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log(`[passenger-api] listening on http://localhost:${port}`);
|
console.log(`[passenger-api] listening on port ${port}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
74
pnpm-lock.yaml
generated
74
pnpm-lock.yaml
generated
@@ -190,6 +190,15 @@ importers:
|
|||||||
'@hello-pangea/dnd':
|
'@hello-pangea/dnd':
|
||||||
specifier: ^18.0.1
|
specifier: ^18.0.1
|
||||||
version: 18.0.1(@types/react@18.3.29)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
version: 18.0.1(@types/react@18.3.29)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
|
'@mantine/core':
|
||||||
|
specifier: ^9.3.0
|
||||||
|
version: 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.29)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
|
'@mantine/hooks':
|
||||||
|
specifier: ^9.3.0
|
||||||
|
version: 9.3.0(react@19.2.6)
|
||||||
|
'@tabler/icons-react':
|
||||||
|
specifier: ^3.44.0
|
||||||
|
version: 3.44.0(react@19.2.6)
|
||||||
'@tanstack/react-query':
|
'@tanstack/react-query':
|
||||||
specifier: ^5.100.11
|
specifier: ^5.100.11
|
||||||
version: 5.100.11(react@19.2.6)
|
version: 5.100.11(react@19.2.6)
|
||||||
@@ -1735,6 +1744,13 @@ packages:
|
|||||||
react: 19.2.6
|
react: 19.2.6
|
||||||
react-dom: 19.2.6
|
react-dom: 19.2.6
|
||||||
|
|
||||||
|
'@mantine/core@9.3.0':
|
||||||
|
resolution: {integrity: sha512-mHVCm61YVW9ipy9eHiKMqsRUm3TkOErbdw7zHs0HRw5g403nf7tSTqNGvaYE+aX1Py874qMkrUzeQfj4bjiiBA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@mantine/hooks': 9.3.0
|
||||||
|
react: 19.2.6
|
||||||
|
react-dom: 19.2.6
|
||||||
|
|
||||||
'@mantine/dates@8.3.18':
|
'@mantine/dates@8.3.18':
|
||||||
resolution: {integrity: sha512-FHx5teJOhupI0gO2o5evtVYQEdqOjayOkLRhEQfB5Nc5DvcysfPfmNILGkc1Nrp9ZQeQWKLT9qr+CkcCXwHOaw==}
|
resolution: {integrity: sha512-FHx5teJOhupI0gO2o5evtVYQEdqOjayOkLRhEQfB5Nc5DvcysfPfmNILGkc1Nrp9ZQeQWKLT9qr+CkcCXwHOaw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1749,6 +1765,11 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: 19.2.6
|
react: 19.2.6
|
||||||
|
|
||||||
|
'@mantine/hooks@9.3.0':
|
||||||
|
resolution: {integrity: sha512-QoSr9WI4WsKWrM3qFYYizHUn3+n+CVcFMYe4sdlnmFPStvs6BacPODKJSbFlYl73Z20t82JIy0eKqt4noHQI2g==}
|
||||||
|
peerDependencies:
|
||||||
|
react: 19.2.6
|
||||||
|
|
||||||
'@mapbox/node-pre-gyp@1.0.11':
|
'@mapbox/node-pre-gyp@1.0.11':
|
||||||
resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==}
|
resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -12892,7 +12913,7 @@ snapshots:
|
|||||||
'@jest/console@29.7.0':
|
'@jest/console@29.7.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jest/types': 29.6.3
|
'@jest/types': 29.6.3
|
||||||
'@types/node': 20.19.41
|
'@types/node': 24.13.0
|
||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
jest-message-util: 29.7.0
|
jest-message-util: 29.7.0
|
||||||
jest-util: 29.7.0
|
jest-util: 29.7.0
|
||||||
@@ -12937,7 +12958,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@jest/fake-timers': 29.7.0
|
'@jest/fake-timers': 29.7.0
|
||||||
'@jest/types': 29.6.3
|
'@jest/types': 29.6.3
|
||||||
'@types/node': 20.19.41
|
'@types/node': 24.13.0
|
||||||
jest-mock: 29.7.0
|
jest-mock: 29.7.0
|
||||||
|
|
||||||
'@jest/expect-utils@29.7.0':
|
'@jest/expect-utils@29.7.0':
|
||||||
@@ -12955,7 +12976,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@jest/types': 29.6.3
|
'@jest/types': 29.6.3
|
||||||
'@sinonjs/fake-timers': 10.3.0
|
'@sinonjs/fake-timers': 10.3.0
|
||||||
'@types/node': 20.19.41
|
'@types/node': 24.13.0
|
||||||
jest-message-util: 29.7.0
|
jest-message-util: 29.7.0
|
||||||
jest-mock: 29.7.0
|
jest-mock: 29.7.0
|
||||||
jest-util: 29.7.0
|
jest-util: 29.7.0
|
||||||
@@ -12983,7 +13004,7 @@ snapshots:
|
|||||||
'@jest/transform': 29.7.0
|
'@jest/transform': 29.7.0
|
||||||
'@jest/types': 29.6.3
|
'@jest/types': 29.6.3
|
||||||
'@jridgewell/trace-mapping': 0.3.31
|
'@jridgewell/trace-mapping': 0.3.31
|
||||||
'@types/node': 20.19.41
|
'@types/node': 24.13.0
|
||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
collect-v8-coverage: 1.0.3
|
collect-v8-coverage: 1.0.3
|
||||||
exit: 0.1.2
|
exit: 0.1.2
|
||||||
@@ -13145,6 +13166,19 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@types/react'
|
- '@types/react'
|
||||||
|
|
||||||
|
'@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.29)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||||
|
dependencies:
|
||||||
|
'@floating-ui/react': 0.27.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
|
'@mantine/hooks': 9.3.0(react@19.2.6)
|
||||||
|
clsx: 2.1.1
|
||||||
|
react: 19.2.6
|
||||||
|
react-dom: 19.2.6(react@19.2.6)
|
||||||
|
react-number-format: 5.4.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
|
react-remove-scroll: 2.7.2(@types/react@18.3.29)(react@19.2.6)
|
||||||
|
type-fest: 5.6.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/react'
|
||||||
|
|
||||||
'@mantine/dates@8.3.18(@mantine/core@8.3.18(@mantine/hooks@8.3.18(react@19.2.6))(@types/react@18.3.29)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@8.3.18(react@19.2.6))(dayjs@1.11.20)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
'@mantine/dates@8.3.18(@mantine/core@8.3.18(@mantine/hooks@8.3.18(react@19.2.6))(@types/react@18.3.29)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@8.3.18(react@19.2.6))(dayjs@1.11.20)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@mantine/core': 8.3.18(@mantine/hooks@8.3.18(react@19.2.6))(@types/react@18.3.29)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
'@mantine/core': 8.3.18(@mantine/hooks@8.3.18(react@19.2.6))(@types/react@18.3.29)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
@@ -13158,6 +13192,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.6
|
react: 19.2.6
|
||||||
|
|
||||||
|
'@mantine/hooks@9.3.0(react@19.2.6)':
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.6
|
||||||
|
|
||||||
'@mapbox/node-pre-gyp@1.0.11':
|
'@mapbox/node-pre-gyp@1.0.11':
|
||||||
dependencies:
|
dependencies:
|
||||||
detect-libc: 2.1.2
|
detect-libc: 2.1.2
|
||||||
@@ -15442,11 +15480,11 @@ snapshots:
|
|||||||
|
|
||||||
'@types/connect@3.4.38':
|
'@types/connect@3.4.38':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 20.19.41
|
'@types/node': 24.13.0
|
||||||
|
|
||||||
'@types/conventional-commits-parser@5.0.2':
|
'@types/conventional-commits-parser@5.0.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 20.19.41
|
'@types/node': 24.13.0
|
||||||
|
|
||||||
'@types/cookiejar@2.1.5': {}
|
'@types/cookiejar@2.1.5': {}
|
||||||
|
|
||||||
@@ -15590,7 +15628,7 @@ snapshots:
|
|||||||
|
|
||||||
'@types/send@1.2.1':
|
'@types/send@1.2.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 20.19.41
|
'@types/node': 24.13.0
|
||||||
|
|
||||||
'@types/serve-static@2.2.0':
|
'@types/serve-static@2.2.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -15599,7 +15637,7 @@ snapshots:
|
|||||||
|
|
||||||
'@types/set-cookie-parser@2.4.10':
|
'@types/set-cookie-parser@2.4.10':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 20.19.41
|
'@types/node': 24.13.0
|
||||||
|
|
||||||
'@types/signature_pad@2.3.6': {}
|
'@types/signature_pad@2.3.6': {}
|
||||||
|
|
||||||
@@ -20391,7 +20429,7 @@ snapshots:
|
|||||||
'@jest/expect': 29.7.0
|
'@jest/expect': 29.7.0
|
||||||
'@jest/test-result': 29.7.0
|
'@jest/test-result': 29.7.0
|
||||||
'@jest/types': 29.6.3
|
'@jest/types': 29.6.3
|
||||||
'@types/node': 20.19.41
|
'@types/node': 24.13.0
|
||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
co: 4.6.0
|
co: 4.6.0
|
||||||
dedent: 1.7.2(babel-plugin-macros@3.1.0)
|
dedent: 1.7.2(babel-plugin-macros@3.1.0)
|
||||||
@@ -20516,7 +20554,7 @@ snapshots:
|
|||||||
'@jest/environment': 29.7.0
|
'@jest/environment': 29.7.0
|
||||||
'@jest/fake-timers': 29.7.0
|
'@jest/fake-timers': 29.7.0
|
||||||
'@jest/types': 29.6.3
|
'@jest/types': 29.6.3
|
||||||
'@types/node': 20.19.41
|
'@types/node': 24.13.0
|
||||||
jest-mock: 29.7.0
|
jest-mock: 29.7.0
|
||||||
jest-util: 29.7.0
|
jest-util: 29.7.0
|
||||||
|
|
||||||
@@ -20526,7 +20564,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@jest/types': 29.6.3
|
'@jest/types': 29.6.3
|
||||||
'@types/graceful-fs': 4.1.9
|
'@types/graceful-fs': 4.1.9
|
||||||
'@types/node': 20.19.41
|
'@types/node': 24.13.0
|
||||||
anymatch: 3.1.3
|
anymatch: 3.1.3
|
||||||
fb-watchman: 2.0.2
|
fb-watchman: 2.0.2
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
@@ -20581,7 +20619,7 @@ snapshots:
|
|||||||
jest-mock@29.7.0:
|
jest-mock@29.7.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jest/types': 29.6.3
|
'@jest/types': 29.6.3
|
||||||
'@types/node': 20.19.41
|
'@types/node': 24.13.0
|
||||||
jest-util: 29.7.0
|
jest-util: 29.7.0
|
||||||
|
|
||||||
jest-pnp-resolver@1.2.3(jest-resolve@29.7.0):
|
jest-pnp-resolver@1.2.3(jest-resolve@29.7.0):
|
||||||
@@ -20619,7 +20657,7 @@ snapshots:
|
|||||||
'@jest/test-result': 29.7.0
|
'@jest/test-result': 29.7.0
|
||||||
'@jest/transform': 29.7.0
|
'@jest/transform': 29.7.0
|
||||||
'@jest/types': 29.6.3
|
'@jest/types': 29.6.3
|
||||||
'@types/node': 20.19.41
|
'@types/node': 24.13.0
|
||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
emittery: 0.13.1
|
emittery: 0.13.1
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
@@ -20647,7 +20685,7 @@ snapshots:
|
|||||||
'@jest/test-result': 29.7.0
|
'@jest/test-result': 29.7.0
|
||||||
'@jest/transform': 29.7.0
|
'@jest/transform': 29.7.0
|
||||||
'@jest/types': 29.6.3
|
'@jest/types': 29.6.3
|
||||||
'@types/node': 20.19.41
|
'@types/node': 24.13.0
|
||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
cjs-module-lexer: 1.4.3
|
cjs-module-lexer: 1.4.3
|
||||||
collect-v8-coverage: 1.0.3
|
collect-v8-coverage: 1.0.3
|
||||||
@@ -20693,7 +20731,7 @@ snapshots:
|
|||||||
jest-util@29.7.0:
|
jest-util@29.7.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jest/types': 29.6.3
|
'@jest/types': 29.6.3
|
||||||
'@types/node': 20.19.41
|
'@types/node': 24.13.0
|
||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
ci-info: 3.9.0
|
ci-info: 3.9.0
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
@@ -20722,7 +20760,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@jest/test-result': 29.7.0
|
'@jest/test-result': 29.7.0
|
||||||
'@jest/types': 29.6.3
|
'@jest/types': 29.6.3
|
||||||
'@types/node': 20.19.41
|
'@types/node': 24.13.0
|
||||||
ansi-escapes: 4.3.2
|
ansi-escapes: 4.3.2
|
||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
emittery: 0.13.1
|
emittery: 0.13.1
|
||||||
@@ -20731,13 +20769,13 @@ snapshots:
|
|||||||
|
|
||||||
jest-worker@27.5.1:
|
jest-worker@27.5.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 20.19.41
|
'@types/node': 24.13.0
|
||||||
merge-stream: 2.0.0
|
merge-stream: 2.0.0
|
||||||
supports-color: 8.1.1
|
supports-color: 8.1.1
|
||||||
|
|
||||||
jest-worker@29.7.0:
|
jest-worker@29.7.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 20.19.41
|
'@types/node': 24.13.0
|
||||||
jest-util: 29.7.0
|
jest-util: 29.7.0
|
||||||
merge-stream: 2.0.0
|
merge-stream: 2.0.0
|
||||||
supports-color: 8.1.1
|
supports-color: 8.1.1
|
||||||
|
|||||||
Reference in New Issue
Block a user