mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 12:41:04 +00:00
Project Initialization
This commit is contained in:
87
CLAUDE.md
Normal file
87
CLAUDE.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# EDR Platform — Developer Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Freight Management and Passenger Management applications, plus shared types, NestJS utilities, and React component libraries.
|
||||||
|
|
||||||
|
## Apps
|
||||||
|
| App | Package name | Purpose | Port |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `edr-freight-api` | `@edr/freight-api` | NestJS API for freight management | 3001 |
|
||||||
|
| `edr-freight-web/portal` | `@edr/freight-portal` | React frontend for freight customer/portal users | 5173 |
|
||||||
|
| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React frontend for freight backoffice employees | 5183 |
|
||||||
|
| `edr-passenger-api` | `@edr/passenger-api` | NestJS API for passenger management | 3002 |
|
||||||
|
| `edr-passenger-web/portal` | `@edr/passenger-portal` | React frontend for passenger customer/portal users | 5174 |
|
||||||
|
| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | React frontend for passenger backoffice employees | 5184 |
|
||||||
|
|
||||||
|
`edr-freight-web` and `edr-passenger-web` are grouping folders, not workspace packages. Each holds a `portal/` and `backoffice/` sub-app, both of which are independent pnpm workspace packages (declared in `pnpm-workspace.yaml`). The existing `pnpm dev:freight` / `pnpm dev:passenger` turbo filters (`@edr/freight-*` / `@edr/passenger-*`) cover all four web apps + their APIs.
|
||||||
|
|
||||||
|
## Packages
|
||||||
|
| Package | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `@edr/types` | Shared TypeScript interfaces and enums |
|
||||||
|
| `@edr/api-common` | Shared NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository |
|
||||||
|
| `@edr/ui-common` | Shared React components and theme |
|
||||||
|
| `@edr/eslint-config` | Shared ESLint configurations (base/nestjs/react) |
|
||||||
|
| `@edr/tsconfig` | Shared TypeScript configurations |
|
||||||
|
| `@edr/prettier-config` | Shared Prettier configuration |
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
| Command | Description |
|
||||||
|
|---|---|
|
||||||
|
| `pnpm install` | Install all workspace dependencies |
|
||||||
|
| `pnpm dev` | Run every app in dev mode |
|
||||||
|
| `pnpm dev:freight` | Run only freight API + web |
|
||||||
|
| `pnpm dev:passenger` | Run only passenger API + web |
|
||||||
|
| `pnpm build` | Build every package and app |
|
||||||
|
| `pnpm test` | Run all tests |
|
||||||
|
| `pnpm lint` | Lint everything |
|
||||||
|
| `pnpm type-check` | Type-check every package |
|
||||||
|
| `pnpm format` | Format all files with Prettier |
|
||||||
|
|
||||||
|
## Standards
|
||||||
|
- **TypeScript strict mode** is enabled in every package and app.
|
||||||
|
- **pnpm** is the only supported package manager — never run `npm install` or `yarn`.
|
||||||
|
- **Conventional commits** are enforced via commitlint on every commit.
|
||||||
|
- **NestJS modules** follow the 4-layer pattern: `module → controller → service → repository` (entities and DTOs live alongside).
|
||||||
|
- **All entities** use UUID primary keys (`@PrimaryGeneratedColumn('uuid')`).
|
||||||
|
- **All entities** have `createdAt`, `updatedAt`, `deletedAt` (soft delete) via `@edr/api-common`'s `BaseEntity`.
|
||||||
|
- **All columns** use `snake_case` in the database (`@Column({ name: 'snake_case' })`); TypeScript properties use `camelCase`.
|
||||||
|
- **Never use `synchronize: true`** in production database config. All schema changes go through TypeORM migrations.
|
||||||
|
- **ESLint + Prettier** run on pre-commit via Husky + lint-staged.
|
||||||
|
- **Services** never inject TypeORM `Repository<T>` directly — they inject the custom repository class.
|
||||||
|
- **Controllers** never contain business logic.
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
Authentication is handled by an external package (`@edr/iamui-common` or equivalent) that will be integrated later. **Do not** implement any auth, login, logout, JWT verification, password hashing, or user management code in this repo.
|
||||||
|
|
||||||
|
When auth integration is needed, use placeholder TODO comments:
|
||||||
|
- `// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth`
|
||||||
|
- `// TODO: integrate @edr/auth — replace stub @CurrentUser with real one`
|
||||||
|
|
||||||
|
The `@CurrentUser`, `@Roles`, and `@Public` decorators in `@edr/api-common` are bare metadata setters with no guard wiring — they exist so controllers can be annotated correctly without depending on auth infrastructure yet.
|
||||||
|
|
||||||
|
## Port Assignments
|
||||||
|
- `edr-freight-api`: 3001
|
||||||
|
- `edr-freight-web/portal`: 5173
|
||||||
|
- `edr-freight-web/backoffice`: 5183
|
||||||
|
- `edr-passenger-api`: 3002
|
||||||
|
- `edr-passenger-web/portal`: 5174
|
||||||
|
- `edr-passenger-web/backoffice`: 5184
|
||||||
|
|
||||||
|
## Database Layout
|
||||||
|
- `postgres-freight` (port 5433): database `edr_freight` — freight API only.
|
||||||
|
- `postgres-passenger` (port 5434): database `edr_passenger` — passenger API only.
|
||||||
|
- Each app owns its own DB. No cross-database joins; cross-domain data flows through API calls or message queues.
|
||||||
|
|
||||||
|
## Adding a new module to a NestJS app
|
||||||
|
1. Create `modules/<feature>/` with `entities/`, `dto/`, and the four `<feature>.{module,controller,service,repository}.ts` files.
|
||||||
|
2. The entity extends `BaseEntity` from `@edr/api-common`.
|
||||||
|
3. The repository extends `BaseRepository<Entity>` from `@edr/api-common`.
|
||||||
|
4. The service injects the repository class (not `Repository<T>` directly).
|
||||||
|
5. The controller uses `@ApiTags()` + `@ApiOperation()` for Swagger.
|
||||||
|
6. Register the module in the app's `app.module.ts`.
|
||||||
|
|
||||||
|
## Adding a new shared component to `@edr/ui-common`
|
||||||
|
1. Create `src/components/<Name>/<Name>.tsx` and `src/components/<Name>/index.ts`.
|
||||||
|
2. Export from `src/index.ts`.
|
||||||
|
3. Component is a functional component with a `ComponentNameProps` interface (named-exported alongside the default).
|
||||||
17
apps/edr-freight-api/.env.example
Normal file
17
apps/edr-freight-api/.env.example
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# App
|
||||||
|
NODE_ENV=development
|
||||||
|
PORT=3001
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5433
|
||||||
|
DB_NAME=edr_freight
|
||||||
|
DB_USER=postgres
|
||||||
|
DB_PASSWORD=
|
||||||
|
|
||||||
|
# JWT (provided by external auth package — placeholder only)
|
||||||
|
JWT_SECRET=
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
1
apps/edr-freight-api/.tsbuildinfo
Normal file
1
apps/edr-freight-api/.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
26
apps/edr-freight-api/Dockerfile
Normal file
26
apps/edr-freight-api/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
FROM node:20-alpine AS base
|
||||||
|
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
FROM base AS deps
|
||||||
|
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||||
|
COPY apps/edr-freight-api/package.json ./apps/edr-freight-api/
|
||||||
|
COPY packages ./packages
|
||||||
|
RUN pnpm install --frozen-lockfile --filter @edr/freight-api...
|
||||||
|
|
||||||
|
FROM deps AS build
|
||||||
|
COPY apps/edr-freight-api ./apps/edr-freight-api
|
||||||
|
RUN pnpm --filter @edr/freight-api build
|
||||||
|
|
||||||
|
FROM node:20-alpine AS runtime
|
||||||
|
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
||||||
|
WORKDIR /app/apps/edr-freight-api
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
COPY --from=deps /app/node_modules ./../../node_modules
|
||||||
|
COPY --from=deps /app/apps/edr-freight-api/node_modules ./node_modules
|
||||||
|
COPY --from=build /app/apps/edr-freight-api/dist ./dist
|
||||||
|
COPY --from=build /app/apps/edr-freight-api/package.json ./package.json
|
||||||
|
|
||||||
|
EXPOSE 3001
|
||||||
|
CMD ["node", "dist/main.js"]
|
||||||
8
apps/edr-freight-api/nest-cli.json
Normal file
8
apps/edr-freight-api/nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
58
apps/edr-freight-api/package.json
Normal file
58
apps/edr-freight-api/package.json
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"name": "@edr/freight-api",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "EDR Freight Management API",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "nest start --watch",
|
||||||
|
"build": "nest build",
|
||||||
|
"start": "node dist/main.js",
|
||||||
|
"lint": "eslint src",
|
||||||
|
"test": "jest",
|
||||||
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
|
"type-check": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@edr/api-common": "workspace:*",
|
||||||
|
"@edr/types": "workspace:*",
|
||||||
|
"@nestjs/common": "^10.4.6",
|
||||||
|
"@nestjs/config": "^3.3.0",
|
||||||
|
"@nestjs/core": "^10.4.6",
|
||||||
|
"@nestjs/platform-express": "^10.4.6",
|
||||||
|
"@nestjs/swagger": "^7.4.2",
|
||||||
|
"@nestjs/typeorm": "^10.0.2",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.1",
|
||||||
|
"pg": "^8.13.0",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"typeorm": "^0.3.20"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@edr/eslint-config": "workspace:*",
|
||||||
|
"@edr/tsconfig": "workspace:*",
|
||||||
|
"@nestjs/cli": "^10.4.5",
|
||||||
|
"@nestjs/schematics": "^10.2.2",
|
||||||
|
"@nestjs/testing": "^10.4.6",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jest": "^29.5.13",
|
||||||
|
"@types/node": "^20.14.0",
|
||||||
|
"@types/supertest": "^6.0.2",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"supertest": "^7.0.0",
|
||||||
|
"ts-jest": "^29.2.5",
|
||||||
|
"ts-loader": "^9.5.1",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "^5.5.4"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"moduleFileExtensions": ["js", "json", "ts"],
|
||||||
|
"rootDir": "src",
|
||||||
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
|
"transform": { "^.+\\.(t|j)s$": "ts-jest" },
|
||||||
|
"collectCoverageFrom": ["**/*.(t|j)s"],
|
||||||
|
"coverageDirectory": "../coverage",
|
||||||
|
"testEnvironment": "node"
|
||||||
|
}
|
||||||
|
}
|
||||||
36
apps/edr-freight-api/src/app.module.ts
Normal file
36
apps/edr-freight-api/src/app.module.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import appConfig from './config/app.config';
|
||||||
|
import databaseConfig from './config/database.config';
|
||||||
|
|
||||||
|
import { BookingsModule } from './modules/bookings/bookings.module';
|
||||||
|
import { ConsignmentsModule } from './modules/consignments/consignments.module';
|
||||||
|
import { TrainsModule } from './modules/trains/trains.module';
|
||||||
|
import { CustomersModule } from './modules/customers/customers.module';
|
||||||
|
import { TrackingModule } from './modules/tracking/tracking.module';
|
||||||
|
import { BillingModule } from './modules/billing/billing.module';
|
||||||
|
import { NotificationsModule } from './modules/notifications/notifications.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
load: [appConfig, databaseConfig],
|
||||||
|
}),
|
||||||
|
TypeOrmModule.forRootAsync({
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
|
||||||
|
config.get<TypeOrmModuleOptions>('database')!,
|
||||||
|
}),
|
||||||
|
BookingsModule,
|
||||||
|
ConsignmentsModule,
|
||||||
|
TrainsModule,
|
||||||
|
CustomersModule,
|
||||||
|
TrackingModule,
|
||||||
|
BillingModule,
|
||||||
|
NotificationsModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { HttpExceptionFilter } from '@edr/api-common';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ResponseTransformInterceptor } from '@edr/api-common';
|
||||||
1
apps/edr-freight-api/src/common/pipes/validation.pipe.ts
Normal file
1
apps/edr-freight-api/src/common/pipes/validation.pipe.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { createValidationPipe } from '@edr/api-common';
|
||||||
7
apps/edr-freight-api/src/config/app.config.ts
Normal file
7
apps/edr-freight-api/src/config/app.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { registerAs } from '@nestjs/config';
|
||||||
|
|
||||||
|
export default registerAs('app', () => ({
|
||||||
|
env: process.env.NODE_ENV ?? 'development',
|
||||||
|
port: parseInt(process.env.PORT ?? '3001', 10),
|
||||||
|
apiPrefix: 'api',
|
||||||
|
}));
|
||||||
16
apps/edr-freight-api/src/config/database.config.ts
Normal file
16
apps/edr-freight-api/src/config/database.config.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { registerAs } from '@nestjs/config';
|
||||||
|
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
export default registerAs('database', (): TypeOrmModuleOptions => ({
|
||||||
|
type: 'postgres',
|
||||||
|
host: process.env.DB_HOST ?? 'localhost',
|
||||||
|
port: parseInt(process.env.DB_PORT ?? '5433', 10),
|
||||||
|
username: process.env.DB_USER ?? 'postgres',
|
||||||
|
password: process.env.DB_PASSWORD ?? '',
|
||||||
|
database: process.env.DB_NAME ?? 'edr_freight',
|
||||||
|
entities: [__dirname + '/../**/*.entity.{ts,js}'],
|
||||||
|
migrations: [__dirname + '/../../migrations/*.{ts,js}'],
|
||||||
|
// Never enable synchronize in production. Use migrations.
|
||||||
|
synchronize: process.env.NODE_ENV === 'development',
|
||||||
|
logging: process.env.NODE_ENV === 'development',
|
||||||
|
}));
|
||||||
31
apps/edr-freight-api/src/main.ts
Normal file
31
apps/edr-freight-api/src/main.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
|
import { HttpExceptionFilter, ResponseTransformInterceptor, createValidationPipe } from '@edr/api-common';
|
||||||
|
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create(AppModule, { cors: true });
|
||||||
|
|
||||||
|
app.setGlobalPrefix('api');
|
||||||
|
app.useGlobalPipes(createValidationPipe());
|
||||||
|
app.useGlobalFilters(new HttpExceptionFilter());
|
||||||
|
app.useGlobalInterceptors(new ResponseTransformInterceptor());
|
||||||
|
|
||||||
|
const config = new DocumentBuilder()
|
||||||
|
.setTitle('EDR Freight API')
|
||||||
|
.setDescription('API for the EDR Freight Management application')
|
||||||
|
.setVersion('0.1.0')
|
||||||
|
.addBearerAuth()
|
||||||
|
.build();
|
||||||
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
|
SwaggerModule.setup('api/docs', app, document);
|
||||||
|
|
||||||
|
const port = parseInt(process.env.PORT ?? '3001', 10);
|
||||||
|
await app.listen(port);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[freight-api] listening on http://localhost:${port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Controller, Get, Param, ParseUUIDPipe } from '@nestjs/common';
|
||||||
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { BillingService } from './billing.service';
|
||||||
|
|
||||||
|
@ApiTags('billing')
|
||||||
|
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
|
||||||
|
@Controller('billing')
|
||||||
|
export class BillingController {
|
||||||
|
constructor(private readonly billingService: BillingService) {}
|
||||||
|
|
||||||
|
@Get('invoices')
|
||||||
|
@ApiOperation({ summary: 'List all invoices' })
|
||||||
|
findAll() {
|
||||||
|
return this.billingService.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('invoices/booking/:bookingId')
|
||||||
|
@ApiOperation({ summary: 'List invoices for a booking' })
|
||||||
|
findByBooking(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||||
|
return this.billingService.findByBooking(bookingId);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/edr-freight-api/src/modules/billing/billing.module.ts
Normal file
14
apps/edr-freight-api/src/modules/billing/billing.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import { BillingController } from './billing.controller';
|
||||||
|
import { BillingService } from './billing.service';
|
||||||
|
import { Invoice } from './entities/invoice.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Invoice])],
|
||||||
|
controllers: [BillingController],
|
||||||
|
providers: [BillingService],
|
||||||
|
exports: [BillingService],
|
||||||
|
})
|
||||||
|
export class BillingModule {}
|
||||||
23
apps/edr-freight-api/src/modules/billing/billing.service.ts
Normal file
23
apps/edr-freight-api/src/modules/billing/billing.service.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { Invoice } from './entities/invoice.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BillingService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Invoice)
|
||||||
|
private readonly invoicesRepository: Repository<Invoice>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** List every invoice (most recent first). */
|
||||||
|
findAll(): Promise<Invoice[]> {
|
||||||
|
return this.invoicesRepository.find({ order: { issuedAt: 'DESC' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List invoices for a given booking. */
|
||||||
|
findByBooking(bookingId: string): Promise<Invoice[]> {
|
||||||
|
return this.invoicesRepository.find({ where: { bookingId }, order: { issuedAt: 'DESC' } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Freight } from '@edr/types';
|
||||||
|
import { Column, Entity } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity({ name: 'invoices' })
|
||||||
|
export class Invoice extends BaseEntity {
|
||||||
|
@Column({ name: 'booking_id', type: 'uuid' })
|
||||||
|
bookingId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'invoice_number', type: 'varchar', length: 64, unique: true })
|
||||||
|
invoiceNumber!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'amount', type: 'numeric', precision: 14, scale: 2 })
|
||||||
|
amount!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'currency', type: 'varchar', length: 8, default: 'ETB' })
|
||||||
|
currency!: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'status',
|
||||||
|
type: 'enum',
|
||||||
|
enum: Freight.PaymentStatus,
|
||||||
|
default: Freight.PaymentStatus.Pending,
|
||||||
|
})
|
||||||
|
status!: Freight.PaymentStatus;
|
||||||
|
|
||||||
|
@Column({ name: 'issued_at', type: 'timestamptz' })
|
||||||
|
issuedAt!: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'due_at', type: 'timestamptz' })
|
||||||
|
dueAt!: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Body, Controller, Delete, Get, HttpCode, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common';
|
||||||
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { BookingsService } from './bookings.service';
|
||||||
|
import { CreateBookingDto } from './dto/create-booking.dto';
|
||||||
|
import { FilterBookingDto } from './dto/filter-booking.dto';
|
||||||
|
|
||||||
|
@ApiTags('bookings')
|
||||||
|
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
|
||||||
|
@Controller('bookings')
|
||||||
|
export class BookingsController {
|
||||||
|
constructor(private readonly bookingsService: BookingsService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: 'Create a new freight booking' })
|
||||||
|
create(@Body() dto: CreateBookingDto) {
|
||||||
|
return this.bookingsService.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'List freight bookings (paginated)' })
|
||||||
|
findAll(@Query() filter: FilterBookingDto) {
|
||||||
|
return this.bookingsService.findAll(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Get a freight booking by ID' })
|
||||||
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.bookingsService.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@HttpCode(204)
|
||||||
|
@ApiOperation({ summary: 'Soft-delete a freight booking' })
|
||||||
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.bookingsService.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
apps/edr-freight-api/src/modules/bookings/bookings.module.ts
Normal file
15
apps/edr-freight-api/src/modules/bookings/bookings.module.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import { BookingsController } from './bookings.controller';
|
||||||
|
import { BookingsRepository } from './bookings.repository';
|
||||||
|
import { BookingsService } from './bookings.service';
|
||||||
|
import { Booking } from './entities/booking.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Booking])],
|
||||||
|
controllers: [BookingsController],
|
||||||
|
providers: [BookingsService, BookingsRepository],
|
||||||
|
exports: [BookingsService],
|
||||||
|
})
|
||||||
|
export class BookingsModule {}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { BaseRepository } from '@edr/api-common';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { Booking } from './entities/booking.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BookingsRepository extends BaseRepository<Booking> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Booking)
|
||||||
|
repository: Repository<Booking>,
|
||||||
|
) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find a booking by its human-readable reference number. */
|
||||||
|
findByReference(reference: string): Promise<Booking | null> {
|
||||||
|
return this.repository.findOne({ where: { reference } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { BookingsRepository } from './bookings.repository';
|
||||||
|
import { CreateBookingDto } from './dto/create-booking.dto';
|
||||||
|
import { FilterBookingDto } from './dto/filter-booking.dto';
|
||||||
|
import { Booking } from './entities/booking.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BookingsService {
|
||||||
|
constructor(private readonly bookingsRepository: BookingsRepository) {}
|
||||||
|
|
||||||
|
/** Create a new freight booking. */
|
||||||
|
async create(dto: CreateBookingDto): Promise<Booking> {
|
||||||
|
return this.bookingsRepository.create({
|
||||||
|
...dto,
|
||||||
|
scheduledDate: new Date(dto.scheduledDate),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return a paginated list of bookings matching the filter. */
|
||||||
|
async findAll(filter: FilterBookingDto): Promise<{ items: Booking[]; total: number }> {
|
||||||
|
const page = filter.page ?? 1;
|
||||||
|
const pageSize = filter.pageSize ?? 20;
|
||||||
|
const [items, total] = await this.bookingsRepository.findAndCount({
|
||||||
|
where: {
|
||||||
|
...(filter.status ? { status: filter.status } : {}),
|
||||||
|
...(filter.customerId ? { customerId: filter.customerId } : {}),
|
||||||
|
},
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
return { items, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get a single booking by ID, throwing if not found. */
|
||||||
|
async findById(id: string): Promise<Booking> {
|
||||||
|
const booking = await this.bookingsRepository.findById(id);
|
||||||
|
if (!booking) {
|
||||||
|
throw new NotFoundException(`Booking ${id} not found`);
|
||||||
|
}
|
||||||
|
return booking;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Soft-delete a booking. */
|
||||||
|
async remove(id: string): Promise<void> {
|
||||||
|
await this.findById(id);
|
||||||
|
await this.bookingsRepository.softDelete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Freight } from '@edr/types';
|
||||||
|
import {
|
||||||
|
IsDateString,
|
||||||
|
IsEnum,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateBookingDto {
|
||||||
|
@IsString()
|
||||||
|
reference!: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
customerId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
trainId?: string;
|
||||||
|
|
||||||
|
@IsDateString()
|
||||||
|
scheduledDate!: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
totalAmount!: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(Freight.BookingStatus)
|
||||||
|
status?: Freight.BookingStatus;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Freight } from '@edr/types';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsEnum, IsInt, IsOptional, IsUUID, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class FilterBookingDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(Freight.BookingStatus)
|
||||||
|
status?: Freight.BookingStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
customerId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
pageSize?: number = 20;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Freight } from '@edr/types';
|
||||||
|
import { Column, Entity } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity({ name: 'bookings' })
|
||||||
|
export class Booking extends BaseEntity {
|
||||||
|
@Column({ name: 'reference', type: 'varchar', length: 64, unique: true })
|
||||||
|
reference!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'customer_id', type: 'uuid' })
|
||||||
|
customerId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'train_id', type: 'uuid', nullable: true })
|
||||||
|
trainId?: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'status',
|
||||||
|
type: 'enum',
|
||||||
|
enum: Freight.BookingStatus,
|
||||||
|
default: Freight.BookingStatus.Draft,
|
||||||
|
})
|
||||||
|
status!: Freight.BookingStatus;
|
||||||
|
|
||||||
|
@Column({ name: 'scheduled_date', type: 'timestamptz' })
|
||||||
|
scheduledDate!: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||||
|
totalAmount!: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'payment_status',
|
||||||
|
type: 'enum',
|
||||||
|
enum: Freight.PaymentStatus,
|
||||||
|
default: Freight.PaymentStatus.Pending,
|
||||||
|
})
|
||||||
|
paymentStatus!: Freight.PaymentStatus;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common';
|
||||||
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { ConsignmentsService } from './consignments.service';
|
||||||
|
import { CreateConsignmentDto } from './dto/create-consignment.dto';
|
||||||
|
import { FilterConsignmentDto } from './dto/filter-consignment.dto';
|
||||||
|
|
||||||
|
@ApiTags('consignments')
|
||||||
|
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
|
||||||
|
@Controller('consignments')
|
||||||
|
export class ConsignmentsController {
|
||||||
|
constructor(private readonly consignmentsService: ConsignmentsService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: 'Create a new consignment' })
|
||||||
|
create(@Body() dto: CreateConsignmentDto) {
|
||||||
|
return this.consignmentsService.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'List consignments (paginated)' })
|
||||||
|
findAll(@Query() filter: FilterConsignmentDto) {
|
||||||
|
return this.consignmentsService.findAll(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Get a consignment by ID' })
|
||||||
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.consignmentsService.findById(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import { ConsignmentsController } from './consignments.controller';
|
||||||
|
import { ConsignmentsRepository } from './consignments.repository';
|
||||||
|
import { ConsignmentsService } from './consignments.service';
|
||||||
|
import { Consignment } from './entities/consignment.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Consignment])],
|
||||||
|
controllers: [ConsignmentsController],
|
||||||
|
providers: [ConsignmentsService, ConsignmentsRepository],
|
||||||
|
exports: [ConsignmentsService],
|
||||||
|
})
|
||||||
|
export class ConsignmentsModule {}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { BaseRepository } from '@edr/api-common';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { Consignment } from './entities/consignment.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ConsignmentsRepository extends BaseRepository<Consignment> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Consignment)
|
||||||
|
repository: Repository<Consignment>,
|
||||||
|
) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Look up a consignment by its tracking number. */
|
||||||
|
findByTrackingNumber(trackingNumber: string): Promise<Consignment | null> {
|
||||||
|
return this.repository.findOne({ where: { trackingNumber } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { ConsignmentsRepository } from './consignments.repository';
|
||||||
|
import { CreateConsignmentDto } from './dto/create-consignment.dto';
|
||||||
|
import { FilterConsignmentDto } from './dto/filter-consignment.dto';
|
||||||
|
import { Consignment } from './entities/consignment.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ConsignmentsService {
|
||||||
|
constructor(private readonly consignmentsRepository: ConsignmentsRepository) {}
|
||||||
|
|
||||||
|
/** Create a new consignment for a freight booking. */
|
||||||
|
create(dto: CreateConsignmentDto): Promise<Consignment> {
|
||||||
|
return this.consignmentsRepository.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return a paginated list of consignments. */
|
||||||
|
async findAll(filter: FilterConsignmentDto): Promise<{ items: Consignment[]; total: number }> {
|
||||||
|
const page = filter.page ?? 1;
|
||||||
|
const pageSize = filter.pageSize ?? 20;
|
||||||
|
const [items, total] = await this.consignmentsRepository.findAndCount({
|
||||||
|
where: {
|
||||||
|
...(filter.status ? { status: filter.status } : {}),
|
||||||
|
...(filter.bookingId ? { bookingId: filter.bookingId } : {}),
|
||||||
|
},
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
return { items, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get a single consignment by ID. */
|
||||||
|
async findById(id: string): Promise<Consignment> {
|
||||||
|
const consignment = await this.consignmentsRepository.findById(id);
|
||||||
|
if (!consignment) {
|
||||||
|
throw new NotFoundException(`Consignment ${id} not found`);
|
||||||
|
}
|
||||||
|
return consignment;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Freight } from '@edr/types';
|
||||||
|
import { IsEnum, IsNumber, IsString, IsUUID, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateConsignmentDto {
|
||||||
|
@IsUUID()
|
||||||
|
bookingId!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
trackingNumber!: string;
|
||||||
|
|
||||||
|
@IsEnum(Freight.CargoType)
|
||||||
|
cargoType!: Freight.CargoType;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
weightKg!: number;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
originStation!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
destinationStation!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Freight } from '@edr/types';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsEnum, IsInt, IsOptional, IsUUID, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class FilterConsignmentDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(Freight.ConsignmentStatus)
|
||||||
|
status?: Freight.ConsignmentStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
bookingId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
pageSize?: number = 20;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Freight } from '@edr/types';
|
||||||
|
import { Column, Entity } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity({ name: 'consignments' })
|
||||||
|
export class Consignment extends BaseEntity {
|
||||||
|
@Column({ name: 'booking_id', type: 'uuid' })
|
||||||
|
bookingId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'tracking_number', type: 'varchar', length: 64, unique: true })
|
||||||
|
trackingNumber!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'cargo_type', type: 'enum', enum: Freight.CargoType })
|
||||||
|
cargoType!: Freight.CargoType;
|
||||||
|
|
||||||
|
@Column({ name: 'weight_kg', type: 'numeric', precision: 12, scale: 2 })
|
||||||
|
weightKg!: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'status',
|
||||||
|
type: 'enum',
|
||||||
|
enum: Freight.ConsignmentStatus,
|
||||||
|
default: Freight.ConsignmentStatus.Pending,
|
||||||
|
})
|
||||||
|
status!: Freight.ConsignmentStatus;
|
||||||
|
|
||||||
|
@Column({ name: 'origin_station', type: 'varchar', length: 128 })
|
||||||
|
originStation!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'destination_station', type: 'varchar', length: 128 })
|
||||||
|
destinationStation!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Body, Controller, Get, Param, ParseUUIDPipe, Post } from '@nestjs/common';
|
||||||
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { CustomersService } from './customers.service';
|
||||||
|
import { CreateCustomerDto } from './dto/create-customer.dto';
|
||||||
|
|
||||||
|
@ApiTags('customers')
|
||||||
|
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
|
||||||
|
@Controller('customers')
|
||||||
|
export class CustomersController {
|
||||||
|
constructor(private readonly customersService: CustomersService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: 'Create a new customer' })
|
||||||
|
create(@Body() dto: CreateCustomerDto) {
|
||||||
|
return this.customersService.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'List all customers' })
|
||||||
|
findAll() {
|
||||||
|
return this.customersService.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Get a customer by ID' })
|
||||||
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.customersService.findById(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import { CustomersController } from './customers.controller';
|
||||||
|
import { CustomersRepository } from './customers.repository';
|
||||||
|
import { CustomersService } from './customers.service';
|
||||||
|
import { Customer } from './entities/customer.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Customer])],
|
||||||
|
controllers: [CustomersController],
|
||||||
|
providers: [CustomersService, CustomersRepository],
|
||||||
|
exports: [CustomersService],
|
||||||
|
})
|
||||||
|
export class CustomersModule {}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { BaseRepository } from '@edr/api-common';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { Customer } from './entities/customer.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CustomersRepository extends BaseRepository<Customer> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Customer)
|
||||||
|
repository: Repository<Customer>,
|
||||||
|
) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find a customer by their unique email. */
|
||||||
|
findByEmail(email: string): Promise<Customer | null> {
|
||||||
|
return this.repository.findOne({ where: { email } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { CustomersRepository } from './customers.repository';
|
||||||
|
import { CreateCustomerDto } from './dto/create-customer.dto';
|
||||||
|
import { Customer } from './entities/customer.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CustomersService {
|
||||||
|
constructor(private readonly customersRepository: CustomersRepository) {}
|
||||||
|
|
||||||
|
/** Create a new freight customer. */
|
||||||
|
create(dto: CreateCustomerDto): Promise<Customer> {
|
||||||
|
return this.customersRepository.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List every customer (alphabetical). */
|
||||||
|
findAll(): Promise<Customer[]> {
|
||||||
|
return this.customersRepository.findAll({ order: { name: 'ASC' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get a single customer by ID. */
|
||||||
|
async findById(id: string): Promise<Customer> {
|
||||||
|
const customer = await this.customersRepository.findById(id);
|
||||||
|
if (!customer) {
|
||||||
|
throw new NotFoundException(`Customer ${id} not found`);
|
||||||
|
}
|
||||||
|
return customer;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { IsEmail, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateCustomerDto {
|
||||||
|
@IsString()
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
phone!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
address?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
taxId?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity({ name: 'customers' })
|
||||||
|
export class Customer extends BaseEntity {
|
||||||
|
@Column({ name: 'name', type: 'varchar', length: 256 })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'email', type: 'varchar', length: 256, unique: true })
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'phone', type: 'varchar', length: 32 })
|
||||||
|
phone!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'address', type: 'text', nullable: true })
|
||||||
|
address?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'tax_id', type: 'varchar', length: 64, nullable: true })
|
||||||
|
taxId?: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { NotificationsService } from './notifications.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [NotificationsService],
|
||||||
|
exports: [NotificationsService],
|
||||||
|
})
|
||||||
|
export class NotificationsModule {}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NotificationsService {
|
||||||
|
private readonly logger = new Logger(NotificationsService.name);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch a notification to an operator or customer.
|
||||||
|
* TODO: wire to email/SMS provider (SendGrid, SMS API, etc.) via a mailer service.
|
||||||
|
*/
|
||||||
|
async send(recipient: string, subject: string, body: string): Promise<void> {
|
||||||
|
this.logger.log(`[notify] ${recipient} :: ${subject} :: ${body}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Freight } from '@edr/types';
|
||||||
|
import { Column, Entity } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity({ name: 'tracking_events' })
|
||||||
|
export class TrackingEvent extends BaseEntity {
|
||||||
|
@Column({ name: 'consignment_id', type: 'uuid' })
|
||||||
|
consignmentId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'location', type: 'varchar', length: 256 })
|
||||||
|
location!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'status', type: 'enum', enum: Freight.ConsignmentStatus })
|
||||||
|
status!: Freight.ConsignmentStatus;
|
||||||
|
|
||||||
|
@Column({ name: 'occurred_at', type: 'timestamptz' })
|
||||||
|
occurredAt!: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'description', type: 'text', nullable: true })
|
||||||
|
description?: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Controller, Get, Param, ParseUUIDPipe } from '@nestjs/common';
|
||||||
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { TrackingService } from './tracking.service';
|
||||||
|
|
||||||
|
@ApiTags('tracking')
|
||||||
|
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
|
||||||
|
@Controller('tracking')
|
||||||
|
export class TrackingController {
|
||||||
|
constructor(private readonly trackingService: TrackingService) {}
|
||||||
|
|
||||||
|
@Get(':consignmentId')
|
||||||
|
@ApiOperation({ summary: 'Get the tracking timeline for a consignment' })
|
||||||
|
findByConsignment(@Param('consignmentId', ParseUUIDPipe) consignmentId: string) {
|
||||||
|
return this.trackingService.findByConsignment(consignmentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/edr-freight-api/src/modules/tracking/tracking.module.ts
Normal file
14
apps/edr-freight-api/src/modules/tracking/tracking.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import { TrackingEvent } from './entities/tracking-event.entity';
|
||||||
|
import { TrackingController } from './tracking.controller';
|
||||||
|
import { TrackingService } from './tracking.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([TrackingEvent])],
|
||||||
|
controllers: [TrackingController],
|
||||||
|
providers: [TrackingService],
|
||||||
|
exports: [TrackingService],
|
||||||
|
})
|
||||||
|
export class TrackingModule {}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { TrackingEvent } from './entities/tracking-event.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TrackingService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(TrackingEvent)
|
||||||
|
private readonly trackingRepository: Repository<TrackingEvent>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Get the full timeline of tracking events for a consignment. */
|
||||||
|
findByConsignment(consignmentId: string): Promise<TrackingEvent[]> {
|
||||||
|
return this.trackingRepository.find({
|
||||||
|
where: { consignmentId },
|
||||||
|
order: { occurredAt: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record a new tracking event for a consignment. */
|
||||||
|
record(event: Partial<TrackingEvent>): Promise<TrackingEvent> {
|
||||||
|
const entity = this.trackingRepository.create(event);
|
||||||
|
return this.trackingRepository.save(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Freight } from '@edr/types';
|
||||||
|
import { IsEnum, IsNumber, IsOptional, IsString, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateTrainDto {
|
||||||
|
@IsString()
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
capacityTons!: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(Freight.TrainStatus)
|
||||||
|
status?: Freight.TrainStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Freight } from '@edr/types';
|
||||||
|
import { Column, Entity } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity({ name: 'trains' })
|
||||||
|
export class Train extends BaseEntity {
|
||||||
|
@Column({ name: 'code', type: 'varchar', length: 32, unique: true })
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'capacity_tons', type: 'numeric', precision: 10, scale: 2 })
|
||||||
|
capacityTons!: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'status',
|
||||||
|
type: 'enum',
|
||||||
|
enum: Freight.TrainStatus,
|
||||||
|
default: Freight.TrainStatus.Available,
|
||||||
|
})
|
||||||
|
status!: Freight.TrainStatus;
|
||||||
|
|
||||||
|
@Column({ name: 'notes', type: 'text', nullable: true })
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
30
apps/edr-freight-api/src/modules/trains/trains.controller.ts
Normal file
30
apps/edr-freight-api/src/modules/trains/trains.controller.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Body, Controller, Get, Param, ParseUUIDPipe, Post } from '@nestjs/common';
|
||||||
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { CreateTrainDto } from './dto/create-train.dto';
|
||||||
|
import { TrainsService } from './trains.service';
|
||||||
|
|
||||||
|
@ApiTags('trains')
|
||||||
|
// @UseGuards(JwtAuthGuard) — TODO: integrate @edr/auth
|
||||||
|
@Controller('trains')
|
||||||
|
export class TrainsController {
|
||||||
|
constructor(private readonly trainsService: TrainsService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: 'Register a new train' })
|
||||||
|
create(@Body() dto: CreateTrainDto) {
|
||||||
|
return this.trainsService.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'List all trains' })
|
||||||
|
findAll() {
|
||||||
|
return this.trainsService.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Get a train by ID' })
|
||||||
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.trainsService.findById(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
apps/edr-freight-api/src/modules/trains/trains.module.ts
Normal file
15
apps/edr-freight-api/src/modules/trains/trains.module.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import { Train } from './entities/train.entity';
|
||||||
|
import { TrainsController } from './trains.controller';
|
||||||
|
import { TrainsRepository } from './trains.repository';
|
||||||
|
import { TrainsService } from './trains.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Train])],
|
||||||
|
controllers: [TrainsController],
|
||||||
|
providers: [TrainsService, TrainsRepository],
|
||||||
|
exports: [TrainsService],
|
||||||
|
})
|
||||||
|
export class TrainsModule {}
|
||||||
16
apps/edr-freight-api/src/modules/trains/trains.repository.ts
Normal file
16
apps/edr-freight-api/src/modules/trains/trains.repository.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { BaseRepository } from '@edr/api-common';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { Train } from './entities/train.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TrainsRepository extends BaseRepository<Train> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Train)
|
||||||
|
repository: Repository<Train>,
|
||||||
|
) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
apps/edr-freight-api/src/modules/trains/trains.service.ts
Normal file
29
apps/edr-freight-api/src/modules/trains/trains.service.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { CreateTrainDto } from './dto/create-train.dto';
|
||||||
|
import { Train } from './entities/train.entity';
|
||||||
|
import { TrainsRepository } from './trains.repository';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TrainsService {
|
||||||
|
constructor(private readonly trainsRepository: TrainsRepository) {}
|
||||||
|
|
||||||
|
/** Register a new train in the fleet. */
|
||||||
|
create(dto: CreateTrainDto): Promise<Train> {
|
||||||
|
return this.trainsRepository.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List every active train. */
|
||||||
|
findAll(): Promise<Train[]> {
|
||||||
|
return this.trainsRepository.findAll({ order: { code: 'ASC' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get a single train by ID. */
|
||||||
|
async findById(id: string): Promise<Train> {
|
||||||
|
const train = await this.trainsRepository.findById(id);
|
||||||
|
if (!train) {
|
||||||
|
throw new NotFoundException(`Train ${id} not found`);
|
||||||
|
}
|
||||||
|
return train;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
apps/edr-freight-api/test/app.e2e-spec.ts
Normal file
26
apps/edr-freight-api/test/app.e2e-spec.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import request from 'supertest';
|
||||||
|
|
||||||
|
import { AppModule } from '../src/app.module';
|
||||||
|
|
||||||
|
describe('Freight API (e2e)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = moduleFixture.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/bookings returns a 200 with a list', () => {
|
||||||
|
return request(app.getHttpServer()).get('/api/bookings').expect(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
4
apps/edr-freight-api/tsconfig.build.json
Normal file
4
apps/edr-freight-api/tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "test", "dist", "**/*.spec.ts"]
|
||||||
|
}
|
||||||
12
apps/edr-freight-api/tsconfig.json
Normal file
12
apps/edr-freight-api/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "@edr/tsconfig/nestjs.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": "./",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"noEmit": false,
|
||||||
|
"incremental": true,
|
||||||
|
"tsBuildInfoFile": "./.tsbuildinfo"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
18
apps/edr-freight-web/Dockerfile.backoffice
Normal file
18
apps/edr-freight-web/Dockerfile.backoffice
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
FROM node:20-alpine AS base
|
||||||
|
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
FROM base AS deps
|
||||||
|
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||||
|
COPY apps/edr-freight-web/backoffice/package.json ./apps/edr-freight-web/backoffice/
|
||||||
|
COPY packages ./packages
|
||||||
|
RUN pnpm install --frozen-lockfile --filter @edr/freight-backoffice...
|
||||||
|
|
||||||
|
FROM deps AS build
|
||||||
|
COPY apps/edr-freight-web/backoffice ./apps/edr-freight-web/backoffice
|
||||||
|
RUN pnpm --filter @edr/freight-backoffice build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine AS runtime
|
||||||
|
COPY --from=build /app/apps/edr-freight-web/backoffice/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 5183
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
18
apps/edr-freight-web/Dockerfile.portal
Normal file
18
apps/edr-freight-web/Dockerfile.portal
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
FROM node:20-alpine AS base
|
||||||
|
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
FROM base AS deps
|
||||||
|
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||||
|
COPY apps/edr-freight-web/portal/package.json ./apps/edr-freight-web/portal/
|
||||||
|
COPY packages ./packages
|
||||||
|
RUN pnpm install --frozen-lockfile --filter @edr/freight-portal...
|
||||||
|
|
||||||
|
FROM deps AS build
|
||||||
|
COPY apps/edr-freight-web/portal ./apps/edr-freight-web/portal
|
||||||
|
RUN pnpm --filter @edr/freight-portal build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine AS runtime
|
||||||
|
COPY --from=build /app/apps/edr-freight-web/portal/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 5173
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
1
apps/edr-freight-web/backoffice/.env.example
Normal file
1
apps/edr-freight-web/backoffice/.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_URL=http://localhost:3001
|
||||||
12
apps/edr-freight-web/backoffice/index.html
Normal file
12
apps/edr-freight-web/backoffice/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>EDR Freight Backoffice</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
39
apps/edr-freight-web/backoffice/package.json
Normal file
39
apps/edr-freight-web/backoffice/package.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "@edr/freight-backoffice",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --port 5183",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview --port 5183",
|
||||||
|
"lint": "eslint src",
|
||||||
|
"test": "vitest run",
|
||||||
|
"type-check": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@edr/types": "workspace:*",
|
||||||
|
"@edr/ui-common": "workspace:*",
|
||||||
|
"@tanstack/react-query": "^5.59.0",
|
||||||
|
"axios": "^1.7.7",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.27.0",
|
||||||
|
"zustand": "^5.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@edr/eslint-config": "workspace:*",
|
||||||
|
"@edr/tsconfig": "workspace:*",
|
||||||
|
"@types/react": "^18.3.11",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
|
"postcss": "^8.4.47",
|
||||||
|
"tailwindcss": "^3.4.13",
|
||||||
|
"typescript": "^5.5.4",
|
||||||
|
"vite": "^5.4.8",
|
||||||
|
"vitest": "^2.1.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
29
apps/edr-freight-web/backoffice/src/App.tsx
Normal file
29
apps/edr-freight-web/backoffice/src/App.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { useNavigate, useLocation, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { DashboardLayout, type SidebarItem } from '@edr/ui-common';
|
||||||
|
|
||||||
|
import DashboardPage from './pages/dashboard/DashboardPage';
|
||||||
|
|
||||||
|
const sidebarItems: SidebarItem[] = [
|
||||||
|
{ label: 'Dashboard', href: '/' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const App = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout
|
||||||
|
title="EDR Freight Backoffice"
|
||||||
|
sidebarItems={sidebarItems}
|
||||||
|
activeHref={location.pathname}
|
||||||
|
onNavigate={navigate}
|
||||||
|
>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<DashboardPage />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
||||||
0
apps/edr-freight-web/backoffice/src/hooks/.gitkeep
Normal file
0
apps/edr-freight-web/backoffice/src/hooks/.gitkeep
Normal file
18
apps/edr-freight-web/backoffice/src/main.tsx
Normal file
18
apps/edr-freight-web/backoffice/src/main.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
const DashboardPage = () => {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<h1 className="text-2xl font-semibold">EDR Freight Backoffice</h1>
|
||||||
|
<p className="mt-2 text-gray-600">Backoffice — coming soon.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DashboardPage;
|
||||||
0
apps/edr-freight-web/backoffice/src/store/.gitkeep
Normal file
0
apps/edr-freight-web/backoffice/src/store/.gitkeep
Normal file
0
apps/edr-freight-web/backoffice/src/types/.gitkeep
Normal file
0
apps/edr-freight-web/backoffice/src/types/.gitkeep
Normal file
0
apps/edr-freight-web/backoffice/src/utils/.gitkeep
Normal file
0
apps/edr-freight-web/backoffice/src/utils/.gitkeep
Normal file
1
apps/edr-freight-web/backoffice/src/vite-env.d.ts
vendored
Normal file
1
apps/edr-freight-web/backoffice/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
9
apps/edr-freight-web/backoffice/tsconfig.app.json
Normal file
9
apps/edr-freight-web/backoffice/tsconfig.app.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "@edr/tsconfig/react.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
apps/edr-freight-web/backoffice/tsconfig.json
Normal file
7
apps/edr-freight-web/backoffice/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
14
apps/edr-freight-web/backoffice/tsconfig.node.json
Normal file
14
apps/edr-freight-web/backoffice/tsconfig.node.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "@edr/tsconfig/base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
14
apps/edr-freight-web/backoffice/vite.config.ts
Normal file
14
apps/edr-freight-web/backoffice/vite.config.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5183,
|
||||||
|
host: '0.0.0.0',
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
1
apps/edr-freight-web/portal/.env.example
Normal file
1
apps/edr-freight-web/portal/.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_URL=http://localhost:3001
|
||||||
12
apps/edr-freight-web/portal/index.html
Normal file
12
apps/edr-freight-web/portal/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>EDR Freight Portal</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
39
apps/edr-freight-web/portal/package.json
Normal file
39
apps/edr-freight-web/portal/package.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "@edr/freight-portal",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --port 5173",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview --port 5173",
|
||||||
|
"lint": "eslint src",
|
||||||
|
"test": "vitest run",
|
||||||
|
"type-check": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@edr/types": "workspace:*",
|
||||||
|
"@edr/ui-common": "workspace:*",
|
||||||
|
"@tanstack/react-query": "^5.59.0",
|
||||||
|
"axios": "^1.7.7",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.27.0",
|
||||||
|
"zustand": "^5.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@edr/eslint-config": "workspace:*",
|
||||||
|
"@edr/tsconfig": "workspace:*",
|
||||||
|
"@types/react": "^18.3.11",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
|
"postcss": "^8.4.47",
|
||||||
|
"tailwindcss": "^3.4.13",
|
||||||
|
"typescript": "^5.5.4",
|
||||||
|
"vite": "^5.4.8",
|
||||||
|
"vitest": "^2.1.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
50
apps/edr-freight-web/portal/src/App.tsx
Normal file
50
apps/edr-freight-web/portal/src/App.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { useNavigate, useLocation, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { DashboardLayout, type SidebarItem } from '@edr/ui-common';
|
||||||
|
|
||||||
|
import BookingsPage from './pages/bookings/BookingsPage';
|
||||||
|
import BookingDetailPage from './pages/bookings/BookingDetailPage';
|
||||||
|
import CreateBookingPage from './pages/bookings/CreateBookingPage';
|
||||||
|
import ConsignmentsPage from './pages/consignments/ConsignmentsPage';
|
||||||
|
import ConsignmentDetailPage from './pages/consignments/ConsignmentDetailPage';
|
||||||
|
import TrackingPage from './pages/tracking/TrackingPage';
|
||||||
|
import BillingPage from './pages/billing/BillingPage';
|
||||||
|
import TrainsPage from './pages/trains/TrainsPage';
|
||||||
|
import DashboardPage from './pages/dashboard/DashboardPage';
|
||||||
|
|
||||||
|
const sidebarItems: SidebarItem[] = [
|
||||||
|
{ label: 'Dashboard', href: '/' },
|
||||||
|
{ label: 'Bookings', href: '/bookings' },
|
||||||
|
{ label: 'Consignments', href: '/consignments' },
|
||||||
|
{ label: 'Tracking', href: '/tracking' },
|
||||||
|
{ label: 'Trains', href: '/trains' },
|
||||||
|
{ label: 'Billing', href: '/billing' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const App = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardLayout
|
||||||
|
title="EDR Freight"
|
||||||
|
sidebarItems={sidebarItems}
|
||||||
|
activeHref={location.pathname}
|
||||||
|
onNavigate={navigate}
|
||||||
|
>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<DashboardPage />} />
|
||||||
|
<Route path="/bookings" element={<BookingsPage />} />
|
||||||
|
<Route path="/bookings/new" element={<CreateBookingPage />} />
|
||||||
|
<Route path="/bookings/:id" element={<BookingDetailPage />} />
|
||||||
|
<Route path="/consignments" element={<ConsignmentsPage />} />
|
||||||
|
<Route path="/consignments/:id" element={<ConsignmentDetailPage />} />
|
||||||
|
<Route path="/tracking" element={<TrackingPage />} />
|
||||||
|
<Route path="/trains" element={<TrainsPage />} />
|
||||||
|
<Route path="/billing" element={<BillingPage />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { FormEvent, useState } from 'react';
|
||||||
|
import { Button, FormField } from '@edr/ui-common';
|
||||||
|
|
||||||
|
import type { CreateBookingPayload } from '../../services/bookings.service';
|
||||||
|
|
||||||
|
export interface BookingFormProps {
|
||||||
|
onSubmit: (payload: CreateBookingPayload) => void;
|
||||||
|
isSubmitting?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BookingForm = ({ onSubmit, isSubmitting }: BookingFormProps) => {
|
||||||
|
const [reference, setReference] = useState('');
|
||||||
|
const [customerId, setCustomerId] = useState('');
|
||||||
|
const [scheduledDate, setScheduledDate] = useState('');
|
||||||
|
const [totalAmount, setTotalAmount] = useState('0');
|
||||||
|
|
||||||
|
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onSubmit({
|
||||||
|
reference,
|
||||||
|
customerId,
|
||||||
|
scheduledDate,
|
||||||
|
totalAmount: Number(totalAmount),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||||
|
<FormField label="Reference" value={reference} onChange={(e) => setReference(e.target.value)} required />
|
||||||
|
<FormField label="Customer ID" value={customerId} onChange={(e) => setCustomerId(e.target.value)} required />
|
||||||
|
<FormField
|
||||||
|
label="Scheduled date"
|
||||||
|
type="date"
|
||||||
|
value={scheduledDate}
|
||||||
|
onChange={(e) => setScheduledDate(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="Total amount"
|
||||||
|
type="number"
|
||||||
|
value={totalAmount}
|
||||||
|
onChange={(e) => setTotalAmount(e.target.value)}
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
<Button type="submit" isLoading={isSubmitting}>
|
||||||
|
Create booking
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BookingForm;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { Freight } from '@edr/types';
|
||||||
|
import { Table, type TableColumn } from '@edr/ui-common';
|
||||||
|
|
||||||
|
export interface BookingTableProps {
|
||||||
|
bookings: Freight.IBooking[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: TableColumn<Freight.IBooking>[] = [
|
||||||
|
{ key: 'reference', header: 'Reference' },
|
||||||
|
{ key: 'customerId', header: 'Customer' },
|
||||||
|
{ key: 'status', header: 'Status' },
|
||||||
|
{
|
||||||
|
key: 'scheduledDate',
|
||||||
|
header: 'Scheduled',
|
||||||
|
render: (row) => new Date(row.scheduledDate).toLocaleDateString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'totalAmount',
|
||||||
|
header: 'Total',
|
||||||
|
render: (row) => row.totalAmount.toFixed(2),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const BookingTable = ({ bookings }: BookingTableProps) => (
|
||||||
|
<Table columns={columns} data={bookings} rowKey={(row) => row.id} emptyMessage="No bookings yet" />
|
||||||
|
);
|
||||||
|
|
||||||
|
export default BookingTable;
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { FormEvent, useState } from 'react';
|
||||||
|
import { Button, FormField } from '@edr/ui-common';
|
||||||
|
|
||||||
|
export interface ConsignmentFormProps {
|
||||||
|
onSubmit: (payload: {
|
||||||
|
bookingId: string;
|
||||||
|
trackingNumber: string;
|
||||||
|
cargoType: string;
|
||||||
|
weightKg: number;
|
||||||
|
originStation: string;
|
||||||
|
destinationStation: string;
|
||||||
|
}) => void;
|
||||||
|
isSubmitting?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConsignmentForm = ({ onSubmit, isSubmitting }: ConsignmentFormProps) => {
|
||||||
|
const [bookingId, setBookingId] = useState('');
|
||||||
|
const [trackingNumber, setTrackingNumber] = useState('');
|
||||||
|
const [cargoType, setCargoType] = useState('GENERAL');
|
||||||
|
const [weightKg, setWeightKg] = useState('0');
|
||||||
|
const [originStation, setOriginStation] = useState('');
|
||||||
|
const [destinationStation, setDestinationStation] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onSubmit({
|
||||||
|
bookingId,
|
||||||
|
trackingNumber,
|
||||||
|
cargoType,
|
||||||
|
weightKg: Number(weightKg),
|
||||||
|
originStation,
|
||||||
|
destinationStation,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||||
|
<FormField label="Booking ID" value={bookingId} onChange={(e) => setBookingId(e.target.value)} required />
|
||||||
|
<FormField
|
||||||
|
label="Tracking #"
|
||||||
|
value={trackingNumber}
|
||||||
|
onChange={(e) => setTrackingNumber(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<FormField label="Cargo type" value={cargoType} onChange={(e) => setCargoType(e.target.value)} />
|
||||||
|
<FormField
|
||||||
|
label="Weight (kg)"
|
||||||
|
type="number"
|
||||||
|
value={weightKg}
|
||||||
|
onChange={(e) => setWeightKg(e.target.value)}
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="Origin station"
|
||||||
|
value={originStation}
|
||||||
|
onChange={(e) => setOriginStation(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="Destination station"
|
||||||
|
value={destinationStation}
|
||||||
|
onChange={(e) => setDestinationStation(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button type="submit" isLoading={isSubmitting}>
|
||||||
|
Create consignment
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConsignmentForm;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Freight } from '@edr/types';
|
||||||
|
import { Table, type TableColumn } from '@edr/ui-common';
|
||||||
|
|
||||||
|
export interface ConsignmentTableProps {
|
||||||
|
consignments: Freight.IConsignment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: TableColumn<Freight.IConsignment>[] = [
|
||||||
|
{ key: 'trackingNumber', header: 'Tracking #' },
|
||||||
|
{ key: 'cargoType', header: 'Cargo' },
|
||||||
|
{ key: 'status', header: 'Status' },
|
||||||
|
{ key: 'originStation', header: 'Origin' },
|
||||||
|
{ key: 'destinationStation', header: 'Destination' },
|
||||||
|
{ key: 'weightKg', header: 'Weight (kg)', render: (row) => row.weightKg.toFixed(2) },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ConsignmentTable = ({ consignments }: ConsignmentTableProps) => (
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
data={consignments}
|
||||||
|
rowKey={(row) => row.id}
|
||||||
|
emptyMessage="No consignments yet"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ConsignmentTable;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { Freight } from '@edr/types';
|
||||||
|
import { Badge } from '@edr/ui-common';
|
||||||
|
|
||||||
|
export interface TrackingTimelineProps {
|
||||||
|
events: Freight.ITrackingEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const TrackingTimeline = ({ events }: TrackingTimelineProps) => {
|
||||||
|
if (events.length === 0) {
|
||||||
|
return <div className="text-sm text-gray-500">No tracking events yet.</div>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ol className="relative ml-3 border-l border-gray-200">
|
||||||
|
{events.map((event) => (
|
||||||
|
<li key={event.id} className="mb-4 ml-4">
|
||||||
|
<div className="absolute -left-1.5 mt-1.5 h-3 w-3 rounded-full bg-blue-500" />
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-gray-900">
|
||||||
|
<span>{event.location}</span>
|
||||||
|
<Badge tone="info">{event.status}</Badge>
|
||||||
|
</div>
|
||||||
|
<time className="text-xs text-gray-500">
|
||||||
|
{new Date(event.occurredAt).toLocaleString()}
|
||||||
|
</time>
|
||||||
|
{event.description ? (
|
||||||
|
<p className="text-sm text-gray-700">{event.description}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TrackingTimeline;
|
||||||
16
apps/edr-freight-web/portal/src/hooks/useBookings.ts
Normal file
16
apps/edr-freight-web/portal/src/hooks/useBookings.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { bookingsService } from '../services/bookings.service';
|
||||||
|
|
||||||
|
export const useBookings = () =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['bookings'],
|
||||||
|
queryFn: bookingsService.list,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useBooking = (id: string) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['bookings', id],
|
||||||
|
queryFn: () => bookingsService.get(id),
|
||||||
|
enabled: Boolean(id),
|
||||||
|
});
|
||||||
16
apps/edr-freight-web/portal/src/hooks/useConsignments.ts
Normal file
16
apps/edr-freight-web/portal/src/hooks/useConsignments.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { consignmentsService } from '../services/consignments.service';
|
||||||
|
|
||||||
|
export const useConsignments = () =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['consignments'],
|
||||||
|
queryFn: consignmentsService.list,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useConsignment = (id: string) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['consignments', id],
|
||||||
|
queryFn: () => consignmentsService.get(id),
|
||||||
|
enabled: Boolean(id),
|
||||||
|
});
|
||||||
10
apps/edr-freight-web/portal/src/hooks/useTracking.ts
Normal file
10
apps/edr-freight-web/portal/src/hooks/useTracking.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { trackingService } from '../services/tracking.service';
|
||||||
|
|
||||||
|
export const useTracking = (consignmentId: string) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['tracking', consignmentId],
|
||||||
|
queryFn: () => trackingService.forConsignment(consignmentId),
|
||||||
|
enabled: Boolean(consignmentId),
|
||||||
|
});
|
||||||
18
apps/edr-freight-web/portal/src/main.tsx
Normal file
18
apps/edr-freight-web/portal/src/main.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
const BillingPage = () => (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900">Billing</h1>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Invoice list and payment status will live here. Wire up @tanstack/react-query to{' '}
|
||||||
|
<code>/billing/invoices</code> when the feature is built out.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default BillingPage;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { useBooking } from '../../hooks/useBookings';
|
||||||
|
|
||||||
|
const BookingDetailPage = () => {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const { data: booking, isLoading } = useBooking(id ?? '');
|
||||||
|
|
||||||
|
if (isLoading) return <div className="text-sm text-gray-500">Loading…</div>;
|
||||||
|
if (!booking) return <div className="text-sm text-red-600">Booking not found.</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900">Booking {booking.reference}</h1>
|
||||||
|
<dl className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<dt className="text-gray-500">Status</dt>
|
||||||
|
<dd className="text-gray-900">{booking.status}</dd>
|
||||||
|
<dt className="text-gray-500">Customer ID</dt>
|
||||||
|
<dd className="text-gray-900">{booking.customerId}</dd>
|
||||||
|
<dt className="text-gray-500">Scheduled</dt>
|
||||||
|
<dd className="text-gray-900">{new Date(booking.scheduledDate).toLocaleString()}</dd>
|
||||||
|
<dt className="text-gray-500">Total amount</dt>
|
||||||
|
<dd className="text-gray-900">{booking.totalAmount.toFixed(2)}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BookingDetailPage;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Button } from '@edr/ui-common';
|
||||||
|
|
||||||
|
import BookingTable from '../../components/bookings/BookingTable';
|
||||||
|
import { useBookings } from '../../hooks/useBookings';
|
||||||
|
|
||||||
|
const BookingsPage = () => {
|
||||||
|
const { data, isLoading } = useBookings();
|
||||||
|
const items = data?.items ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900">Bookings</h1>
|
||||||
|
<Link to="/bookings/new">
|
||||||
|
<Button>New booking</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{isLoading ? <div className="text-sm text-gray-500">Loading…</div> : <BookingTable bookings={items} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BookingsPage;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import BookingForm from '../../components/bookings/BookingForm';
|
||||||
|
import { bookingsService } from '../../services/bookings.service';
|
||||||
|
|
||||||
|
const CreateBookingPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: bookingsService.create,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['bookings'] });
|
||||||
|
navigate('/bookings');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-lg">
|
||||||
|
<h1 className="mb-4 text-2xl font-semibold text-gray-900">New booking</h1>
|
||||||
|
<BookingForm onSubmit={mutation.mutate} isSubmitting={mutation.isPending} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateBookingPage;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { useConsignment } from '../../hooks/useConsignments';
|
||||||
|
|
||||||
|
const ConsignmentDetailPage = () => {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const { data, isLoading } = useConsignment(id ?? '');
|
||||||
|
|
||||||
|
if (isLoading) return <div className="text-sm text-gray-500">Loading…</div>;
|
||||||
|
if (!data) return <div className="text-sm text-red-600">Consignment not found.</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900">Consignment {data.trackingNumber}</h1>
|
||||||
|
<dl className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<dt className="text-gray-500">Status</dt>
|
||||||
|
<dd className="text-gray-900">{data.status}</dd>
|
||||||
|
<dt className="text-gray-500">Cargo</dt>
|
||||||
|
<dd className="text-gray-900">{data.cargoType}</dd>
|
||||||
|
<dt className="text-gray-500">Origin</dt>
|
||||||
|
<dd className="text-gray-900">{data.originStation}</dd>
|
||||||
|
<dt className="text-gray-500">Destination</dt>
|
||||||
|
<dd className="text-gray-900">{data.destinationStation}</dd>
|
||||||
|
<dt className="text-gray-500">Weight</dt>
|
||||||
|
<dd className="text-gray-900">{data.weightKg.toFixed(2)} kg</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConsignmentDetailPage;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import ConsignmentTable from '../../components/consignments/ConsignmentTable';
|
||||||
|
import { useConsignments } from '../../hooks/useConsignments';
|
||||||
|
|
||||||
|
const ConsignmentsPage = () => {
|
||||||
|
const { data, isLoading } = useConsignments();
|
||||||
|
const items = data?.items ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900">Consignments</h1>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-sm text-gray-500">Loading…</div>
|
||||||
|
) : (
|
||||||
|
<ConsignmentTable consignments={items} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConsignmentsPage;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
const DashboardPage = () => (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900">Freight Dashboard</h1>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Operational KPIs (bookings, consignments, on-time rate, revenue) go here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default DashboardPage;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Button, FormField } from '@edr/ui-common';
|
||||||
|
|
||||||
|
import TrackingTimeline from '../../components/tracking/TrackingTimeline';
|
||||||
|
import { useTracking } from '../../hooks/useTracking';
|
||||||
|
|
||||||
|
const TrackingPage = () => {
|
||||||
|
const [consignmentId, setConsignmentId] = useState('');
|
||||||
|
const [activeId, setActiveId] = useState('');
|
||||||
|
const { data, isFetching } = useTracking(activeId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex max-w-2xl flex-col gap-4">
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900">Tracking</h1>
|
||||||
|
<div className="flex items-end gap-3">
|
||||||
|
<FormField
|
||||||
|
label="Consignment ID"
|
||||||
|
value={consignmentId}
|
||||||
|
onChange={(event) => setConsignmentId(event.target.value)}
|
||||||
|
placeholder="UUID"
|
||||||
|
/>
|
||||||
|
<Button onClick={() => setActiveId(consignmentId)}>Look up</Button>
|
||||||
|
</div>
|
||||||
|
{isFetching ? (
|
||||||
|
<div className="text-sm text-gray-500">Loading…</div>
|
||||||
|
) : (
|
||||||
|
<TrackingTimeline events={data ?? []} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TrackingPage;
|
||||||
10
apps/edr-freight-web/portal/src/pages/trains/TrainsPage.tsx
Normal file
10
apps/edr-freight-web/portal/src/pages/trains/TrainsPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
const TrainsPage = () => (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900">Trains</h1>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Fleet roster, capacity, and maintenance status. Connect to <code>/trains</code> when ready.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default TrainsPage;
|
||||||
29
apps/edr-freight-web/portal/src/services/bookings.service.ts
Normal file
29
apps/edr-freight-web/portal/src/services/bookings.service.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type { Freight, PaginatedResponse } from '@edr/types';
|
||||||
|
|
||||||
|
import { api } from '../utils/api';
|
||||||
|
|
||||||
|
export interface CreateBookingPayload {
|
||||||
|
reference: string;
|
||||||
|
customerId: string;
|
||||||
|
scheduledDate: string;
|
||||||
|
totalAmount: number;
|
||||||
|
trainId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bookingsService = {
|
||||||
|
list: async (): Promise<PaginatedResponse<Freight.IBooking>> => {
|
||||||
|
const { data } = await api.get('/bookings');
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
get: async (id: string): Promise<Freight.IBooking> => {
|
||||||
|
const { data } = await api.get(`/bookings/${id}`);
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
create: async (payload: CreateBookingPayload): Promise<Freight.IBooking> => {
|
||||||
|
const { data } = await api.post('/bookings', payload);
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
remove: async (id: string): Promise<void> => {
|
||||||
|
await api.delete(`/bookings/${id}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { Freight, PaginatedResponse } from '@edr/types';
|
||||||
|
|
||||||
|
import { api } from '../utils/api';
|
||||||
|
|
||||||
|
export const consignmentsService = {
|
||||||
|
list: async (): Promise<PaginatedResponse<Freight.IConsignment>> => {
|
||||||
|
const { data } = await api.get('/consignments');
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
get: async (id: string): Promise<Freight.IConsignment> => {
|
||||||
|
const { data } = await api.get(`/consignments/${id}`);
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
10
apps/edr-freight-web/portal/src/services/tracking.service.ts
Normal file
10
apps/edr-freight-web/portal/src/services/tracking.service.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Freight } from '@edr/types';
|
||||||
|
|
||||||
|
import { api } from '../utils/api';
|
||||||
|
|
||||||
|
export const trackingService = {
|
||||||
|
forConsignment: async (consignmentId: string): Promise<Freight.ITrackingEvent[]> => {
|
||||||
|
const { data } = await api.get(`/tracking/${consignmentId}`);
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
12
apps/edr-freight-web/portal/src/store/index.ts
Normal file
12
apps/edr-freight-web/portal/src/store/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
interface AppState {
|
||||||
|
// TODO: integrate @edr/auth — currentUser will be sourced from the auth package
|
||||||
|
isSidebarOpen: boolean;
|
||||||
|
toggleSidebar: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAppStore = create<AppState>((set) => ({
|
||||||
|
isSidebarOpen: true,
|
||||||
|
toggleSidebar: () => set((state) => ({ isSidebarOpen: !state.isSidebarOpen })),
|
||||||
|
}));
|
||||||
6
apps/edr-freight-web/portal/src/types/index.ts
Normal file
6
apps/edr-freight-web/portal/src/types/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export type { Freight } from '@edr/types';
|
||||||
|
|
||||||
|
export interface NavItem {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
9
apps/edr-freight-web/portal/src/utils/api.ts
Normal file
9
apps/edr-freight-web/portal/src/utils/api.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
export const api = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_URL,
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: integrate @edr/auth — add a request interceptor here that attaches
|
||||||
|
// the bearer token from the auth package and a response interceptor that
|
||||||
|
// triggers a refresh on 401.
|
||||||
9
apps/edr-freight-web/portal/src/vite-env.d.ts
vendored
Normal file
9
apps/edr-freight-web/portal/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_URL: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user