fix bookings

This commit is contained in:
David 2025-12-18 15:33:55 +01:00
parent bd81749c4a
commit 840ad49dcb
29 changed files with 4100 additions and 63 deletions

148
CLAUDE.md
View File

@ -6,9 +6,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
**Xpeditis** is a B2B SaaS maritime freight booking and management platform (maritime equivalent of WebCargo). The platform allows freight forwarders to search and compare real-time shipping rates, book containers online, and manage shipments from a centralized dashboard.
**Current Status**: Phase 4 - Production-ready with security hardening, monitoring, and comprehensive testing infrastructure.
**Current Status**: Phase 4+ - Production-ready with security hardening, monitoring, comprehensive testing infrastructure, and active administration features development.
**Active Branch**: Check `git status` for current feature branch. Recent features include the Carrier Portal and notifications system.
**Active Branch**: `administration` - Currently working on admin features, notifications system, and dashboard enhancements. Check `git status` for current feature branch.
**Recent Development**: Notifications system, dashboard improvements, pagination fixes, and admin user management features.
## Repository Structure
@ -81,7 +83,10 @@ cd apps/frontend && npm run dev
- Backend API: http://localhost:4000
- API Docs (Swagger): http://localhost:4000/api/docs
- MinIO Console (local S3): http://localhost:9001 (minioadmin/minioadmin)
- Carrier Portal: http://localhost:3000/carrier (in development)
- Admin Dashboard: http://localhost:3000/dashboard/admin (ADMIN role required)
- Admin CSV Rates: http://localhost:3000/dashboard/admin/csv-rates
- Admin User Management: http://localhost:3000/dashboard/settings/users
- Notifications: http://localhost:3000/dashboard/notifications
### Monorepo Scripts (from root)
@ -239,40 +244,57 @@ apps/backend/src/
│ ├── rates/ # Rate search endpoints
│ ├── bookings/ # Booking management
│ ├── csv-bookings.module.ts # CSV booking imports
│ ├── modules/
│ │ └── carrier-portal.module.ts # Carrier portal feature
│ ├── controllers/ # REST endpoints
│ │ ├── carrier-auth.controller.ts
│ │ └── carrier-dashboard.controller.ts
│ │ ├── health.controller.ts
│ │ ├── gdpr.controller.ts
│ │ └── index.ts
│ ├── dto/ # Data transfer objects with validation
│ │ └── carrier-auth.dto.ts
│ │ ├── booking-*.dto.ts
│ │ ├── rate-*.dto.ts
│ │ └── csv-*.dto.ts
│ ├── services/ # Application services
│ │ ├── carrier-auth.service.ts
│ │ └── carrier-dashboard.service.ts
│ │ ├── fuzzy-search.service.ts
│ │ ├── brute-force-protection.service.ts
│ │ ├── file-validation.service.ts
│ │ └── gdpr.service.ts
│ ├── guards/ # Auth guards, rate limiting, RBAC
│ └── mappers/ # DTO ↔ Domain entity mapping
│ │ ├── jwt-auth.guard.ts
│ │ └── throttle.guard.ts
│ ├── decorators/ # Custom decorators
│ │ ├── current-user.decorator.ts
│ │ ├── public.decorator.ts
│ │ └── roles.decorator.ts
│ ├── interceptors/ # Request/response interceptors
│ │ └── performance-monitoring.interceptor.ts
│ └── gdpr/ # GDPR compliance module
│ └── gdpr.module.ts
└── infrastructure/ # 🏗️ External integrations (depends ONLY on domain)
├── persistence/typeorm/ # PostgreSQL repositories
│ ├── entities/
│ │ ├── carrier-profile.orm-entity.ts
│ │ ├── carrier-activity.orm-entity.ts
│ │ ├── csv-booking.orm-entity.ts
│ │ └── organization.orm-entity.ts
│ │ ├── booking.orm-entity.ts
│ │ ├── carrier.orm-entity.ts
│ │ ├── csv-rate-config.orm-entity.ts
│ │ ├── notification.orm-entity.ts
│ │ ├── port.orm-entity.ts
│ │ ├── rate-quote.orm-entity.ts
│ │ └── audit-log.orm-entity.ts
│ ├── repositories/
│ │ ├── carrier-profile.repository.ts
│ │ └── carrier-activity.repository.ts
│ ├── mappers/ # Domain ↔ ORM entity mappers
│ └── migrations/
│ ├── 1733185000000-CreateCarrierProfiles.ts
│ ├── 1733186000000-CreateCarrierActivities.ts
│ ├── 1733187000000-AddCarrierToCsvBookings.ts
│ └── 1733188000000-AddCarrierFlagToOrganizations.ts
├── cache/ # Redis adapter
├── carriers/ # Maersk, MSC, CMA CGM connectors
│ └── csv-loader/ # CSV-based rate connector
│ ├── carrier.module.ts
│ ├── csv-loader/ # CSV-based rate connector
│ │ └── csv-converter.service.ts
│ └── maersk/
│ └── maersk.types.ts
├── email/ # MJML email service (carrier notifications)
├── storage/ # S3 storage adapter
│ └── csv-storage/ # CSV rate files storage
│ └── rates/
├── monitoring/ # Monitoring and observability
│ └── sentry.config.ts
├── websocket/ # Real-time carrier updates
└── security/ # Helmet.js, rate limiting, CORS
```
@ -318,11 +340,33 @@ apps/frontend/
│ ├── layout.tsx # Root layout
│ ├── login/ # Auth pages
│ ├── register/
│ ├── forgot-password/
│ ├── reset-password/
│ ├── verify-email/
│ ├── dashboard/ # Protected dashboard routes
│ └── carrier/ # 🚛 Carrier portal routes (in development)
│ ├── login/
│ ├── dashboard/
│ └── bookings/
│ │ ├── page.tsx # Main dashboard
│ │ ├── layout.tsx # Dashboard layout with navigation
│ │ ├── search/ # Rate search
│ │ ├── search-advanced/ # Advanced search with results
│ │ ├── bookings/ # Booking management
│ │ │ ├── page.tsx # Bookings list
│ │ │ ├── [id]/page.tsx # Booking details
│ │ │ └── new/page.tsx # Create booking
│ │ ├── profile/ # User profile
│ │ ├── notifications/ # Notifications page
│ │ ├── settings/ # Settings pages
│ │ │ ├── users/page.tsx # User management (admin)
│ │ │ └── organization/page.tsx # Organization settings
│ │ └── admin/ # Admin features (ADMIN role only)
│ │ └── csv-rates/page.tsx # CSV rate management
│ ├── booking/ # Booking actions (public with token)
│ │ ├── confirm/[token]/page.tsx
│ │ └── reject/[token]/page.tsx
│ ├── carrier/ # Carrier portal routes
│ │ ├── accept/[token]/page.tsx
│ │ └── reject/[token]/page.tsx
│ ├── demo-carte/ # Map demo page
│ └── test-image/ # Image testing page
├── src/
│ ├── components/ # React components
│ │ ├── ui/ # shadcn/ui components (Button, Dialog, etc.)
@ -391,6 +435,7 @@ apps/frontend/
- Socket.IO (real-time updates)
- Tailwind CSS + shadcn/ui
- Framer Motion (animations)
- Leaflet + React Leaflet (maps)
**Infrastructure**:
- Docker + Docker Compose
@ -414,11 +459,15 @@ apps/backend/
├── src/
│ ├── application/
│ │ └── services/
│ │ ├── carrier-auth.service.spec.ts
│ │ └── carrier-dashboard.service.spec.ts
│ │ ├── brute-force-protection.service.spec.ts
│ │ ├── file-validation.service.spec.ts
│ │ ├── fuzzy-search.service.spec.ts
│ │ └── gdpr.service.spec.ts
│ └── domain/
│ ├── entities/
│ │ └── rate-quote.entity.spec.ts
│ │ ├── rate-quote.entity.spec.ts
│ │ ├── notification.entity.spec.ts
│ │ └── webhook.entity.spec.ts
│ └── value-objects/
│ ├── email.vo.spec.ts
│ └── money.vo.spec.ts
@ -427,7 +476,7 @@ apps/backend/
│ │ ├── booking.repository.spec.ts
│ │ ├── redis-cache.adapter.spec.ts
│ │ └── maersk.connector.spec.ts
│ ├── carrier-portal.e2e-spec.ts # Carrier portal E2E tests
│ ├── carrier-portal.e2e-spec.ts
│ ├── app.e2e-spec.ts
│ ├── jest-integration.json
│ ├── jest-e2e.json
@ -546,7 +595,28 @@ See `apps/backend/.env.example` and `apps/frontend/.env.example` for all availab
- `GET /api/v1/bookings/:id` - Get booking details
- `POST /api/v1/bookings/csv-import` - Bulk import bookings from CSV
### Carrier Portal (New)
### Admin Features
- `GET /api/v1/admin/users` - List users (ADMIN role)
- `POST /api/v1/admin/users` - Create user (ADMIN role)
- `PATCH /api/v1/admin/users/:id` - Update user (ADMIN role)
- `DELETE /api/v1/admin/users/:id` - Delete user (ADMIN role)
- `GET /api/v1/admin/csv-rates` - List CSV rate configs (ADMIN role)
- `POST /api/v1/admin/csv-rates/upload` - Upload CSV rates (ADMIN role)
### Notifications
- `GET /api/v1/notifications` - Get user notifications
- `PATCH /api/v1/notifications/:id/read` - Mark notification as read
- `DELETE /api/v1/notifications/:id` - Delete notification
- `WS /notifications` - WebSocket for real-time notifications
### GDPR Compliance
- `GET /api/v1/gdpr/export` - Export user data (GDPR compliance)
- `DELETE /api/v1/gdpr/delete` - Delete user data (GDPR right to be forgotten)
### Health Checks
- `GET /api/v1/health` - Health check endpoint
### Carrier Portal
- `POST /api/v1/carrier/auth/auto-login` - Auto-login via magic link token
- `POST /api/v1/carrier/auth/login` - Standard carrier login
- `GET /api/v1/carrier/dashboard/stats` - Carrier dashboard statistics
@ -559,8 +629,6 @@ See `apps/backend/.env.example` and `apps/frontend/.env.example` for all availab
### Common
- `GET /api/v1/carriers/:id/status` - Real-time carrier status
- `GET /api/v1/notifications` - Get user notifications
- `WS /notifications` - WebSocket for real-time notifications
- `WS /carrier-status` - WebSocket for carrier status updates
See [apps/backend/docs/CARRIER_PORTAL_API.md](apps/backend/docs/CARRIER_PORTAL_API.md) for complete carrier portal API documentation.
@ -578,7 +646,7 @@ See [apps/backend/docs/CARRIER_PORTAL_API.md](apps/backend/docs/CARRIER_PORTAL_A
- Multi-currency support: USD, EUR
**RBAC Roles**:
- `ADMIN` - Full system access
- `ADMIN` - Full system access, user management, CSV rate uploads
- `MANAGER` - Manage organization bookings + users
- `USER` - Create and view own bookings
- `VIEWER` - Read-only access
@ -622,7 +690,9 @@ The platform supports CSV-based operations for bulk data management:
- Upload CSV files with rate data for offline/bulk rate loading
- CSV-based carrier connectors in `infrastructure/carriers/csv-loader/`
- Stored in `csv_rates` table
- Accessible via admin dashboard at `/admin/csv-rates`
- Accessible via admin dashboard at `/dashboard/admin/csv-rates`
- CSV files stored in `apps/backend/src/infrastructure/storage/csv-storage/rates/`
- Supported carriers: MSC, ECU Worldwide, NVO Consolidation, SSC Consolidation, TCC Logistics, Test Maritime Express
**CSV Booking Import**:
- Bulk import bookings from CSV files
@ -641,12 +711,14 @@ The platform supports CSV-based operations for bulk data management:
The platform includes a dedicated admin interface for user management:
**Admin Features** (Branch: `users_admin`):
**Admin Features** (Active on `administration` branch):
- User CRUD operations (Create, Read, Update, Delete)
- Organization management
- Role assignment and permissions
- Argon2 password hash generation for new users
- Accessible at `/admin/users` (ADMIN role required)
- Accessible at `/dashboard/settings/users` (ADMIN role required)
- CSV rate management at `/dashboard/admin/csv-rates`
- Real-time notifications management
**Password Hashing Utility**:
- Use `apps/backend/generate-hash.js` to generate Argon2 password hashes
@ -724,7 +796,7 @@ See [docker/PORTAINER_DEPLOYMENT_GUIDE.md](docker/PORTAINER_DEPLOYMENT_GUIDE.md)
- **Domain Events**: Not yet implemented (planned for Phase 5)
### 2. Repository Pattern
- **Interface in Domain**: `apps/backend/src/domain/ports/out/booking.repository.port.ts`
- **Interface in Domain**: `apps/backend/src/domain/ports/out/booking.repository.ts`
- **Implementation in Infrastructure**: `apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-booking.repository.ts`
- **Mapper Pattern**: Separate mappers for Domain ↔ ORM entity conversion

View File

@ -0,0 +1,171 @@
# MinIO Document Storage Setup Summary
## Problem
Documents uploaded to MinIO were returning `AccessDenied` errors when users tried to download them from the admin documents page.
## Root Cause
The `xpeditis-documents` bucket did not have a public read policy configured, which prevented direct URL access to uploaded documents.
## Solution Implemented
### 1. Fixed Dummy URLs in Database
**Script**: `fix-dummy-urls.js`
- Updated 2 bookings that had dummy URLs (`https://dummy-storage.com/...`)
- Changed to proper MinIO URLs: `http://localhost:9000/xpeditis-documents/csv-bookings/{bookingId}/{documentId}-{fileName}`
### 2. Uploaded Test Documents
**Script**: `upload-test-documents.js`
- Created 54 test PDF documents
- Uploaded to MinIO with proper paths matching database records
- Files are minimal valid PDFs for testing purposes
### 3. Set Bucket Policy for Public Read Access
**Script**: `set-bucket-policy.js`
- Configured the `xpeditis-documents` bucket with a policy allowing public read access
- Policy applied:
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::xpeditis-documents/*"]
}
]
}
```
## Verification
### Test Document Download
```bash
# Test with curl (should return HTTP 200 OK)
curl -I http://localhost:9000/xpeditis-documents/csv-bookings/70f6802a-f789-4f61-ab35-5e0ebf0e29d5/eba1c60f-c749-4b39-8e26-dcc617964237-Document_Export.pdf
# Download actual file
curl -o test.pdf http://localhost:9000/xpeditis-documents/csv-bookings/70f6802a-f789-4f61-ab35-5e0ebf0e29d5/eba1c60f-c749-4b39-8e26-dcc617964237-Document_Export.pdf
```
### Frontend Verification
1. Navigate to: http://localhost:3000/dashboard/admin/documents
2. Click the "Download" button on any document
3. Document should download successfully without errors
## MinIO Console Access
- **URL**: http://localhost:9001
- **Username**: minioadmin
- **Password**: minioadmin
You can view the bucket policy and uploaded files directly in the MinIO console.
## Files Created
- `apps/backend/fix-dummy-urls.js` - Updates database URLs from dummy to MinIO
- `apps/backend/upload-test-documents.js` - Uploads test PDFs to MinIO
- `apps/backend/set-bucket-policy.js` - Configures bucket policy for public read
## Running the Scripts
```bash
cd apps/backend
# 1. Fix database URLs (run once)
node fix-dummy-urls.js
# 2. Upload test documents (run once)
node upload-test-documents.js
# 3. Set bucket policy (run once)
node set-bucket-policy.js
```
## Important Notes
### Development vs Production
- **Current Setup**: Public read access (suitable for development)
- **Production**: Consider using signed URLs for better security
### Signed URLs (Production Recommendation)
Instead of public bucket access, generate temporary signed URLs via the backend:
```typescript
// Backend endpoint to generate signed URL
@Get('documents/:id/download-url')
async getDownloadUrl(@Param('id') documentId: string) {
const document = await this.documentsService.findOne(documentId);
const signedUrl = await this.storageService.getSignedUrl(document.filePath);
return { url: signedUrl };
}
```
This approach:
- ✅ More secure (temporary URLs that expire)
- ✅ Allows access control (check user permissions before generating URL)
- ✅ Audit trail (log who accessed what)
- ❌ Requires backend API call for each download
### Current Architecture
The `S3StorageAdapter` already has a `getSignedUrl()` method implemented (line 148-162 in `s3-storage.adapter.ts`), so migrating to signed URLs in the future is straightforward.
## Troubleshooting
### AccessDenied Error Returns
If you get AccessDenied errors again:
1. Check bucket policy: `node -e "const {S3Client,GetBucketPolicyCommand}=require('@aws-sdk/client-s3');const s3=new S3Client({endpoint:'http://localhost:9000',region:'us-east-1',credentials:{accessKeyId:'minioadmin',secretAccessKey:'minioadmin'},forcePathStyle:true});s3.send(new GetBucketPolicyCommand({Bucket:'xpeditis-documents'})).then(r=>console.log(r.Policy))"`
2. Re-run: `node set-bucket-policy.js`
### Document Not Found
If document URLs return 404:
1. Check MinIO console (http://localhost:9001)
2. Verify file exists in bucket
3. Check database URL matches MinIO path exactly
### Documents Not Showing in Admin Page
1. Verify bookings exist: `SELECT id, documents FROM csv_bookings WHERE documents IS NOT NULL`
2. Check frontend console for errors
3. Verify API endpoint returns data: http://localhost:4000/api/v1/admin/bookings
## Database Query Examples
### Check Document URLs
```sql
SELECT
id,
booking_id as "bookingId",
documents::jsonb->0->>'filePath' as "firstDocumentUrl"
FROM csv_bookings
WHERE documents IS NOT NULL
LIMIT 5;
```
### Count Documents by Booking
```sql
SELECT
id,
jsonb_array_length(documents::jsonb) as "documentCount"
FROM csv_bookings
WHERE documents IS NOT NULL;
```
## Next Steps (Optional Production Enhancements)
1. **Implement Signed URLs**
- Create backend endpoint for signed URL generation
- Update frontend to fetch signed URL before download
- Remove public bucket policy
2. **Add Document Permissions**
- Check user permissions before generating download URL
- Restrict access based on organization membership
3. **Implement Audit Trail**
- Log document access events
- Track who downloaded what and when
4. **Add Document Scanning**
- Virus scanning on upload (ClamAV)
- Content validation
- File size limits enforcement
## Status
**FIXED** - Documents can now be downloaded from the admin documents page without AccessDenied errors.

View File

@ -0,0 +1,106 @@
/**
* Script to delete test documents from MinIO
*
* Deletes only small test files (< 1000 bytes) created by upload-test-documents.js
* Preserves real uploaded documents (larger files)
*/
const { S3Client, ListObjectsV2Command, DeleteObjectCommand } = require('@aws-sdk/client-s3');
require('dotenv').config();
const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000';
const BUCKET_NAME = 'xpeditis-documents';
const TEST_FILE_SIZE_THRESHOLD = 1000; // Files smaller than 1KB are likely test files
// Initialize MinIO client
const s3Client = new S3Client({
region: 'us-east-1',
endpoint: MINIO_ENDPOINT,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin',
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin',
},
forcePathStyle: true,
});
async function deleteTestDocuments() {
try {
console.log('📋 Listing all files in bucket:', BUCKET_NAME);
// List all files
let allFiles = [];
let continuationToken = null;
do {
const command = new ListObjectsV2Command({
Bucket: BUCKET_NAME,
ContinuationToken: continuationToken,
});
const response = await s3Client.send(command);
if (response.Contents) {
allFiles = allFiles.concat(response.Contents);
}
continuationToken = response.NextContinuationToken;
} while (continuationToken);
console.log(`\n📊 Found ${allFiles.length} total files\n`);
// Filter test files (small files < 1000 bytes)
const testFiles = allFiles.filter(file => file.Size < TEST_FILE_SIZE_THRESHOLD);
const realFiles = allFiles.filter(file => file.Size >= TEST_FILE_SIZE_THRESHOLD);
console.log(`🔍 Analysis:`);
console.log(` Test files (< ${TEST_FILE_SIZE_THRESHOLD} bytes): ${testFiles.length}`);
console.log(` Real files (>= ${TEST_FILE_SIZE_THRESHOLD} bytes): ${realFiles.length}\n`);
if (testFiles.length === 0) {
console.log('✅ No test files to delete');
return;
}
console.log(`🗑️ Deleting ${testFiles.length} test files:\n`);
let deletedCount = 0;
for (const file of testFiles) {
console.log(` Deleting: ${file.Key} (${file.Size} bytes)`);
try {
await s3Client.send(
new DeleteObjectCommand({
Bucket: BUCKET_NAME,
Key: file.Key,
})
);
deletedCount++;
} catch (error) {
console.error(` ❌ Failed to delete ${file.Key}:`, error.message);
}
}
console.log(`\n✅ Deleted ${deletedCount} test files`);
console.log(`✅ Preserved ${realFiles.length} real documents\n`);
console.log('📂 Remaining real documents:');
realFiles.forEach(file => {
const filename = file.Key.split('/').pop();
const sizeMB = (file.Size / 1024 / 1024).toFixed(2);
console.log(` - ${filename} (${sizeMB} MB)`);
});
} catch (error) {
console.error('❌ Error:', error);
throw error;
}
}
deleteTestDocuments()
.then(() => {
console.log('\n✅ Script completed successfully');
process.exit(0);
})
.catch((error) => {
console.error('\n❌ Script failed:', error);
process.exit(1);
});

View File

@ -0,0 +1,90 @@
/**
* Script to fix dummy storage URLs in the database
*
* This script updates all document URLs from "dummy-storage.com" to proper MinIO URLs
*/
const { Client } = require('pg');
require('dotenv').config();
const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000';
const BUCKET_NAME = 'xpeditis-documents';
async function fixDummyUrls() {
const client = new Client({
host: process.env.DATABASE_HOST || 'localhost',
port: process.env.DATABASE_PORT || 5432,
user: process.env.DATABASE_USER || 'xpeditis',
password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password',
database: process.env.DATABASE_NAME || 'xpeditis_dev',
});
try {
await client.connect();
console.log('✅ Connected to database');
// Get all CSV bookings with documents
const result = await client.query(
`SELECT id, documents FROM csv_bookings WHERE documents IS NOT NULL AND documents::text LIKE '%dummy-storage%'`
);
console.log(`\n📄 Found ${result.rows.length} bookings with dummy URLs\n`);
let updatedCount = 0;
for (const row of result.rows) {
const bookingId = row.id;
const documents = row.documents;
// Update each document URL
const updatedDocuments = documents.map((doc) => {
if (doc.filePath && doc.filePath.includes('dummy-storage')) {
// Extract filename from dummy URL
const fileName = doc.fileName || doc.filePath.split('/').pop();
const documentId = doc.id;
// Build proper MinIO URL
const newUrl = `${MINIO_ENDPOINT}/${BUCKET_NAME}/csv-bookings/${bookingId}/${documentId}-${fileName}`;
console.log(` Old: ${doc.filePath}`);
console.log(` New: ${newUrl}`);
return {
...doc,
filePath: newUrl,
};
}
return doc;
});
// Update the database
await client.query(
`UPDATE csv_bookings SET documents = $1 WHERE id = $2`,
[JSON.stringify(updatedDocuments), bookingId]
);
updatedCount++;
console.log(`✅ Updated booking ${bookingId}\n`);
}
console.log(`\n🎉 Successfully updated ${updatedCount} bookings`);
console.log(`\n⚠️ Note: The actual files need to be uploaded to MinIO at the correct paths.`);
console.log(` You can upload test files or re-create the bookings with real file uploads.`);
} catch (error) {
console.error('❌ Error:', error);
throw error;
} finally {
await client.end();
console.log('\n👋 Disconnected from database');
}
}
fixDummyUrls()
.then(() => {
console.log('\n✅ Script completed successfully');
process.exit(0);
})
.catch((error) => {
console.error('\n❌ Script failed:', error);
process.exit(1);
});

View File

@ -0,0 +1,81 @@
/**
* Script to fix minio hostname in document URLs
*
* Changes http://minio:9000 to http://localhost:9000
*/
const { Client } = require('pg');
require('dotenv').config();
async function fixMinioHostname() {
const client = new Client({
host: process.env.DATABASE_HOST || 'localhost',
port: process.env.DATABASE_PORT || 5432,
user: process.env.DATABASE_USER || 'xpeditis',
password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password',
database: process.env.DATABASE_NAME || 'xpeditis_dev',
});
try {
await client.connect();
console.log('✅ Connected to database');
// Find bookings with minio:9000 in URLs
const result = await client.query(
`SELECT id, documents FROM csv_bookings WHERE documents::text LIKE '%http://minio:9000%'`
);
console.log(`\n📄 Found ${result.rows.length} bookings with minio hostname\n`);
let updatedCount = 0;
for (const row of result.rows) {
const bookingId = row.id;
const documents = row.documents;
// Update each document URL
const updatedDocuments = documents.map((doc) => {
if (doc.filePath && doc.filePath.includes('http://minio:9000')) {
const newUrl = doc.filePath.replace('http://minio:9000', 'http://localhost:9000');
console.log(` Booking: ${bookingId}`);
console.log(` Old: ${doc.filePath}`);
console.log(` New: ${newUrl}\n`);
return {
...doc,
filePath: newUrl,
};
}
return doc;
});
// Update the database
await client.query(
`UPDATE csv_bookings SET documents = $1 WHERE id = $2`,
[JSON.stringify(updatedDocuments), bookingId]
);
updatedCount++;
console.log(`✅ Updated booking ${bookingId}\n`);
}
console.log(`\n🎉 Successfully updated ${updatedCount} bookings`);
} catch (error) {
console.error('❌ Error:', error);
throw error;
} finally {
await client.end();
console.log('\n👋 Disconnected from database');
}
}
fixMinioHostname()
.then(() => {
console.log('\n✅ Script completed successfully');
process.exit(0);
})
.catch((error) => {
console.error('\n❌ Script failed:', error);
process.exit(1);
});

View File

@ -0,0 +1,92 @@
/**
* Script to list all files in MinIO xpeditis-documents bucket
*/
const { S3Client, ListObjectsV2Command } = require('@aws-sdk/client-s3');
require('dotenv').config();
const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000';
const BUCKET_NAME = 'xpeditis-documents';
// Initialize MinIO client
const s3Client = new S3Client({
region: 'us-east-1',
endpoint: MINIO_ENDPOINT,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin',
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin',
},
forcePathStyle: true,
});
async function listFiles() {
try {
console.log(`📋 Listing all files in bucket: ${BUCKET_NAME}\n`);
let allFiles = [];
let continuationToken = null;
do {
const command = new ListObjectsV2Command({
Bucket: BUCKET_NAME,
ContinuationToken: continuationToken,
});
const response = await s3Client.send(command);
if (response.Contents) {
allFiles = allFiles.concat(response.Contents);
}
continuationToken = response.NextContinuationToken;
} while (continuationToken);
console.log(`Found ${allFiles.length} files total:\n`);
// Group by booking ID
const byBooking = {};
allFiles.forEach(file => {
const parts = file.Key.split('/');
if (parts.length >= 3 && parts[0] === 'csv-bookings') {
const bookingId = parts[1];
if (!byBooking[bookingId]) {
byBooking[bookingId] = [];
}
byBooking[bookingId].push({
key: file.Key,
size: file.Size,
lastModified: file.LastModified,
});
} else {
console.log(` Other: ${file.Key} (${file.Size} bytes)`);
}
});
console.log(`\nFiles grouped by booking:\n`);
Object.entries(byBooking).forEach(([bookingId, files]) => {
console.log(`📦 Booking: ${bookingId.substring(0, 8)}...`);
files.forEach(file => {
const filename = file.key.split('/').pop();
console.log(` - ${filename} (${file.size} bytes) - ${file.lastModified}`);
});
console.log('');
});
console.log(`\n📊 Summary:`);
console.log(` Total files: ${allFiles.length}`);
console.log(` Bookings with files: ${Object.keys(byBooking).length}`);
} catch (error) {
console.error('❌ Error:', error);
throw error;
}
}
listFiles()
.then(() => {
console.log('\n✅ Script completed successfully');
process.exit(0);
})
.catch((error) => {
console.error('\n❌ Script failed:', error);
process.exit(1);
});

View File

@ -0,0 +1,176 @@
/**
* Script to restore document references in database from MinIO files
*
* Scans MinIO for existing files and creates/updates database references
*/
const { S3Client, ListObjectsV2Command, HeadObjectCommand } = require('@aws-sdk/client-s3');
const { Client } = require('pg');
const { v4: uuidv4 } = require('uuid');
require('dotenv').config();
const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000';
const BUCKET_NAME = 'xpeditis-documents';
// Initialize MinIO client
const s3Client = new S3Client({
region: 'us-east-1',
endpoint: MINIO_ENDPOINT,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin',
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin',
},
forcePathStyle: true,
});
async function restoreDocumentReferences() {
const pgClient = new Client({
host: process.env.DATABASE_HOST || 'localhost',
port: process.env.DATABASE_PORT || 5432,
user: process.env.DATABASE_USER || 'xpeditis',
password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password',
database: process.env.DATABASE_NAME || 'xpeditis_dev',
});
try {
await pgClient.connect();
console.log('✅ Connected to database\n');
// Get all MinIO files
console.log('📋 Listing files in MinIO...');
let allFiles = [];
let continuationToken = null;
do {
const command = new ListObjectsV2Command({
Bucket: BUCKET_NAME,
ContinuationToken: continuationToken,
});
const response = await s3Client.send(command);
if (response.Contents) {
allFiles = allFiles.concat(response.Contents);
}
continuationToken = response.NextContinuationToken;
} while (continuationToken);
console.log(` Found ${allFiles.length} files in MinIO\n`);
// Group files by booking ID
const filesByBooking = {};
allFiles.forEach(file => {
const parts = file.Key.split('/');
if (parts.length >= 3 && parts[0] === 'csv-bookings') {
const bookingId = parts[1];
const documentId = parts[2].split('-')[0]; // Extract UUID from filename
const fileName = parts[2].substring(37); // Remove UUID prefix (36 chars + dash)
if (!filesByBooking[bookingId]) {
filesByBooking[bookingId] = [];
}
filesByBooking[bookingId].push({
key: file.Key,
documentId: documentId,
fileName: fileName,
size: file.Size,
lastModified: file.LastModified,
});
}
});
console.log(`📦 Found files for ${Object.keys(filesByBooking).length} bookings\n`);
let updatedCount = 0;
let createdDocsCount = 0;
for (const [bookingId, files] of Object.entries(filesByBooking)) {
// Check if booking exists
const bookingResult = await pgClient.query(
'SELECT id, documents FROM csv_bookings WHERE id = $1',
[bookingId]
);
if (bookingResult.rows.length === 0) {
console.log(`⚠️ Booking not found: ${bookingId.substring(0, 8)}... (skipping)`);
continue;
}
const booking = bookingResult.rows[0];
const existingDocs = booking.documents || [];
console.log(`\n📦 Booking: ${bookingId.substring(0, 8)}...`);
console.log(` Existing documents in DB: ${existingDocs.length}`);
console.log(` Files in MinIO: ${files.length}`);
// Create document references for files
const newDocuments = files.map(file => {
// Determine MIME type from file extension
const ext = file.fileName.split('.').pop().toLowerCase();
const mimeTypeMap = {
pdf: 'application/pdf',
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
txt: 'text/plain',
};
const mimeType = mimeTypeMap[ext] || 'application/octet-stream';
// Determine document type
let docType = 'OTHER';
if (file.fileName.toLowerCase().includes('bill-of-lading') || file.fileName.toLowerCase().includes('bol')) {
docType = 'BILL_OF_LADING';
} else if (file.fileName.toLowerCase().includes('packing-list')) {
docType = 'PACKING_LIST';
} else if (file.fileName.toLowerCase().includes('commercial-invoice') || file.fileName.toLowerCase().includes('invoice')) {
docType = 'COMMERCIAL_INVOICE';
}
const doc = {
id: file.documentId,
type: docType,
fileName: file.fileName,
filePath: `${MINIO_ENDPOINT}/${BUCKET_NAME}/${file.key}`,
mimeType: mimeType,
size: file.size,
uploadedAt: file.lastModified.toISOString(),
};
console.log(`${file.fileName} (${(file.size / 1024).toFixed(2)} KB)`);
return doc;
});
// Update the booking with new document references
await pgClient.query(
'UPDATE csv_bookings SET documents = $1 WHERE id = $2',
[JSON.stringify(newDocuments), bookingId]
);
updatedCount++;
createdDocsCount += newDocuments.length;
}
console.log(`\n📊 Summary:`);
console.log(` Bookings updated: ${updatedCount}`);
console.log(` Document references created: ${createdDocsCount}`);
console.log(`\n✅ Document references restored`);
} catch (error) {
console.error('❌ Error:', error);
throw error;
} finally {
await pgClient.end();
console.log('\n👋 Disconnected from database');
}
}
restoreDocumentReferences()
.then(() => {
console.log('\n✅ Script completed successfully');
process.exit(0);
})
.catch((error) => {
console.error('\n❌ Script failed:', error);
process.exit(1);
});

View File

@ -0,0 +1,79 @@
/**
* Script to set MinIO bucket policy for public read access
*
* This allows documents to be downloaded directly via URL without authentication
*/
const { S3Client, PutBucketPolicyCommand, GetBucketPolicyCommand } = require('@aws-sdk/client-s3');
require('dotenv').config();
const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000';
const BUCKET_NAME = 'xpeditis-documents';
// Initialize MinIO client
const s3Client = new S3Client({
region: 'us-east-1',
endpoint: MINIO_ENDPOINT,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin',
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin',
},
forcePathStyle: true,
});
async function setBucketPolicy() {
try {
// Policy to allow public read access to all objects in the bucket
const policy = {
Version: '2012-10-17',
Statement: [
{
Effect: 'Allow',
Principal: '*',
Action: ['s3:GetObject'],
Resource: [`arn:aws:s3:::${BUCKET_NAME}/*`],
},
],
};
console.log('📋 Setting bucket policy for:', BUCKET_NAME);
console.log('Policy:', JSON.stringify(policy, null, 2));
// Set the bucket policy
await s3Client.send(
new PutBucketPolicyCommand({
Bucket: BUCKET_NAME,
Policy: JSON.stringify(policy),
})
);
console.log('\n✅ Bucket policy set successfully!');
console.log(` All objects in ${BUCKET_NAME} are now publicly readable`);
// Verify the policy was set
console.log('\n🔍 Verifying bucket policy...');
const getPolicy = await s3Client.send(
new GetBucketPolicyCommand({
Bucket: BUCKET_NAME,
})
);
console.log('✅ Current policy:', getPolicy.Policy);
console.log('\n📝 Note: This allows public read access to all documents.');
console.log(' For production, consider using signed URLs instead.');
} catch (error) {
console.error('❌ Error:', error);
throw error;
}
}
setBucketPolicy()
.then(() => {
console.log('\n✅ Script completed successfully');
process.exit(0);
})
.catch((error) => {
console.error('\n❌ Script failed:', error);
process.exit(1);
});

View File

@ -18,6 +18,7 @@ import { NotificationsModule } from './application/notifications/notifications.m
import { WebhooksModule } from './application/webhooks/webhooks.module';
import { GDPRModule } from './application/gdpr/gdpr.module';
import { CsvBookingsModule } from './application/csv-bookings.module';
import { AdminModule } from './application/admin/admin.module';
import { CacheModule } from './infrastructure/cache/cache.module';
import { CarrierModule } from './infrastructure/carriers/carrier.module';
import { SecurityModule } from './infrastructure/security/security.module';
@ -115,6 +116,7 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
NotificationsModule,
WebhooksModule,
GDPRModule,
AdminModule,
],
controllers: [],
providers: [

View File

@ -0,0 +1,48 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
// Controller
import { AdminController } from '../controllers/admin.controller';
// ORM Entities
import { UserOrmEntity } from '@infrastructure/persistence/typeorm/entities/user.orm-entity';
import { OrganizationOrmEntity } from '@infrastructure/persistence/typeorm/entities/organization.orm-entity';
import { CsvBookingOrmEntity } from '@infrastructure/persistence/typeorm/entities/csv-booking.orm-entity';
// Repositories
import { TypeOrmUserRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-user.repository';
import { TypeOrmOrganizationRepository } from '@infrastructure/persistence/typeorm/repositories/typeorm-organization.repository';
import { TypeOrmCsvBookingRepository } from '@infrastructure/persistence/typeorm/repositories/csv-booking.repository';
// Repository tokens
import { USER_REPOSITORY } from '@domain/ports/out/user.repository';
import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository';
/**
* Admin Module
*
* Provides admin-only endpoints for managing all data in the system.
* All endpoints require ADMIN role.
*/
@Module({
imports: [
TypeOrmModule.forFeature([
UserOrmEntity,
OrganizationOrmEntity,
CsvBookingOrmEntity,
]),
],
controllers: [AdminController],
providers: [
{
provide: USER_REPOSITORY,
useClass: TypeOrmUserRepository,
},
{
provide: ORGANIZATION_REPOSITORY,
useClass: TypeOrmOrganizationRepository,
},
TypeOrmCsvBookingRepository,
],
})
export class AdminModule {}

View File

@ -0,0 +1,595 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Param,
Body,
HttpCode,
HttpStatus,
Logger,
UsePipes,
ValidationPipe,
NotFoundException,
ParseUUIDPipe,
UseGuards,
Inject,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBadRequestResponse,
ApiNotFoundResponse,
ApiParam,
ApiBearerAuth,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { RolesGuard } from '../guards/roles.guard';
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
import { Roles } from '../decorators/roles.decorator';
// User imports
import {
UserRepository,
USER_REPOSITORY,
} from '@domain/ports/out/user.repository';
import { UserMapper } from '../mappers/user.mapper';
import {
CreateUserDto,
UpdateUserDto,
UserResponseDto,
UserListResponseDto,
} from '../dto/user.dto';
// Organization imports
import {
OrganizationRepository,
ORGANIZATION_REPOSITORY,
} from '@domain/ports/out/organization.repository';
import { OrganizationMapper } from '../mappers/organization.mapper';
import {
OrganizationResponseDto,
OrganizationListResponseDto,
} from '../dto/organization.dto';
// CSV Booking imports
import { TypeOrmCsvBookingRepository } from '@infrastructure/persistence/typeorm/repositories/csv-booking.repository';
import { CsvBookingMapper } from '@infrastructure/persistence/typeorm/mappers/csv-booking.mapper';
/**
* Admin Controller
*
* Dedicated controller for admin-only endpoints that provide access to ALL data
* in the database without organization filtering.
*
* All endpoints require ADMIN role.
*/
@ApiTags('Admin')
@Controller('admin')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin')
@ApiBearerAuth()
export class AdminController {
private readonly logger = new Logger(AdminController.name);
constructor(
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
@Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository,
private readonly csvBookingRepository: TypeOrmCsvBookingRepository,
) {}
// ==================== USERS ENDPOINTS ====================
/**
* Get ALL users from database (admin only)
*
* Returns all users regardless of status (active/inactive) or organization
*/
@Get('users')
@ApiOperation({
summary: 'Get all users (Admin only)',
description: 'Retrieve ALL users from the database without any filters. Includes active and inactive users from all organizations.',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'All users retrieved successfully',
type: UserListResponseDto,
})
@ApiResponse({
status: 401,
description: 'Unauthorized - missing or invalid token',
})
@ApiResponse({
status: 403,
description: 'Forbidden - requires admin role',
})
async getAllUsers(@CurrentUser() user: UserPayload): Promise<UserListResponseDto> {
this.logger.log(`[ADMIN: ${user.email}] Fetching ALL users from database`);
const users = await this.userRepository.findAll();
const userDtos = UserMapper.toDtoArray(users);
this.logger.log(`[ADMIN] Retrieved ${users.length} users from database`);
return {
users: userDtos,
total: users.length,
page: 1,
pageSize: users.length,
totalPages: 1,
};
}
/**
* Get user by ID (admin only)
*/
@Get('users/:id')
@ApiOperation({
summary: 'Get user by ID (Admin only)',
description: 'Retrieve a specific user by ID',
})
@ApiParam({
name: 'id',
description: 'User ID (UUID)',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'User retrieved successfully',
type: UserResponseDto,
})
@ApiNotFoundResponse({
description: 'User not found',
})
async getUserById(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: UserPayload,
): Promise<UserResponseDto> {
this.logger.log(`[ADMIN: ${user.email}] Fetching user: ${id}`);
const foundUser = await this.userRepository.findById(id);
if (!foundUser) {
throw new NotFoundException(`User ${id} not found`);
}
return UserMapper.toDto(foundUser);
}
/**
* Update user (admin only)
*/
@Patch('users/:id')
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
@ApiOperation({
summary: 'Update user (Admin only)',
description: 'Update user details (any user, any organization)',
})
@ApiParam({
name: 'id',
description: 'User ID (UUID)',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'User updated successfully',
type: UserResponseDto,
})
@ApiNotFoundResponse({
description: 'User not found',
})
async updateUser(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateUserDto,
@CurrentUser() user: UserPayload,
): Promise<UserResponseDto> {
this.logger.log(`[ADMIN: ${user.email}] Updating user: ${id}`);
const foundUser = await this.userRepository.findById(id);
if (!foundUser) {
throw new NotFoundException(`User ${id} not found`);
}
// Apply updates
if (dto.firstName) {
foundUser.updateFirstName(dto.firstName);
}
if (dto.lastName) {
foundUser.updateLastName(dto.lastName);
}
if (dto.role) {
foundUser.updateRole(dto.role);
}
if (dto.isActive !== undefined) {
if (dto.isActive) {
foundUser.activate();
} else {
foundUser.deactivate();
}
}
const updatedUser = await this.userRepository.update(foundUser);
this.logger.log(`[ADMIN] User updated successfully: ${updatedUser.id}`);
return UserMapper.toDto(updatedUser);
}
/**
* Delete user (admin only)
*/
@Delete('users/:id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({
summary: 'Delete user (Admin only)',
description: 'Permanently delete a user from the database',
})
@ApiParam({
name: 'id',
description: 'User ID (UUID)',
})
@ApiResponse({
status: HttpStatus.NO_CONTENT,
description: 'User deleted successfully',
})
@ApiNotFoundResponse({
description: 'User not found',
})
async deleteUser(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: UserPayload,
): Promise<void> {
this.logger.log(`[ADMIN: ${user.email}] Deleting user: ${id}`);
const foundUser = await this.userRepository.findById(id);
if (!foundUser) {
throw new NotFoundException(`User ${id} not found`);
}
await this.userRepository.deleteById(id);
this.logger.log(`[ADMIN] User deleted successfully: ${id}`);
}
// ==================== ORGANIZATIONS ENDPOINTS ====================
/**
* Get ALL organizations from database (admin only)
*
* Returns all organizations regardless of status (active/inactive)
*/
@Get('organizations')
@ApiOperation({
summary: 'Get all organizations (Admin only)',
description: 'Retrieve ALL organizations from the database without any filters. Includes active and inactive organizations.',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'All organizations retrieved successfully',
type: OrganizationListResponseDto,
})
@ApiResponse({
status: 401,
description: 'Unauthorized - missing or invalid token',
})
@ApiResponse({
status: 403,
description: 'Forbidden - requires admin role',
})
async getAllOrganizations(@CurrentUser() user: UserPayload): Promise<OrganizationListResponseDto> {
this.logger.log(`[ADMIN: ${user.email}] Fetching ALL organizations from database`);
const organizations = await this.organizationRepository.findAll();
const orgDtos = OrganizationMapper.toDtoArray(organizations);
this.logger.log(`[ADMIN] Retrieved ${organizations.length} organizations from database`);
return {
organizations: orgDtos,
total: organizations.length,
page: 1,
pageSize: organizations.length,
totalPages: 1,
};
}
/**
* Get organization by ID (admin only)
*/
@Get('organizations/:id')
@ApiOperation({
summary: 'Get organization by ID (Admin only)',
description: 'Retrieve a specific organization by ID',
})
@ApiParam({
name: 'id',
description: 'Organization ID (UUID)',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Organization retrieved successfully',
type: OrganizationResponseDto,
})
@ApiNotFoundResponse({
description: 'Organization not found',
})
async getOrganizationById(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: UserPayload,
): Promise<OrganizationResponseDto> {
this.logger.log(`[ADMIN: ${user.email}] Fetching organization: ${id}`);
const organization = await this.organizationRepository.findById(id);
if (!organization) {
throw new NotFoundException(`Organization ${id} not found`);
}
return OrganizationMapper.toDto(organization);
}
// ==================== CSV BOOKINGS ENDPOINTS ====================
/**
* Get ALL csv bookings from database (admin only)
*
* Returns all csv bookings from all organizations
*/
@Get('bookings')
@ApiOperation({
summary: 'Get all CSV bookings (Admin only)',
description: 'Retrieve ALL CSV bookings from the database without any filters. Includes bookings from all organizations and all statuses.',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'All CSV bookings retrieved successfully',
})
@ApiResponse({
status: 401,
description: 'Unauthorized - missing or invalid token',
})
@ApiResponse({
status: 403,
description: 'Forbidden - requires admin role',
})
async getAllBookings(@CurrentUser() user: UserPayload) {
this.logger.log(`[ADMIN: ${user.email}] Fetching ALL csv bookings from database`);
const csvBookings = await this.csvBookingRepository.findAll();
const bookingDtos = csvBookings.map(booking => this.csvBookingToDto(booking));
this.logger.log(`[ADMIN] Retrieved ${csvBookings.length} csv bookings from database`);
return {
bookings: bookingDtos,
total: csvBookings.length,
page: 1,
pageSize: csvBookings.length,
totalPages: csvBookings.length > 0 ? 1 : 0,
};
}
/**
* Get csv booking by ID (admin only)
*/
@Get('bookings/:id')
@ApiOperation({
summary: 'Get CSV booking by ID (Admin only)',
description: 'Retrieve a specific CSV booking by ID',
})
@ApiParam({
name: 'id',
description: 'Booking ID (UUID)',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'CSV booking retrieved successfully',
})
@ApiNotFoundResponse({
description: 'CSV booking not found',
})
async getBookingById(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: UserPayload,
) {
this.logger.log(`[ADMIN: ${user.email}] Fetching csv booking: ${id}`);
const csvBooking = await this.csvBookingRepository.findById(id);
if (!csvBooking) {
throw new NotFoundException(`CSV booking ${id} not found`);
}
return this.csvBookingToDto(csvBooking);
}
/**
* Update csv booking (admin only)
*/
@Patch('bookings/:id')
@ApiOperation({
summary: 'Update CSV booking (Admin only)',
description: 'Update CSV booking status or details',
})
@ApiParam({
name: 'id',
description: 'Booking ID (UUID)',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'CSV booking updated successfully',
})
@ApiNotFoundResponse({
description: 'CSV booking not found',
})
async updateBooking(
@Param('id', ParseUUIDPipe) id: string,
@Body() updateDto: any,
@CurrentUser() user: UserPayload,
) {
this.logger.log(`[ADMIN: ${user.email}] Updating csv booking: ${id}`);
const csvBooking = await this.csvBookingRepository.findById(id);
if (!csvBooking) {
throw new NotFoundException(`CSV booking ${id} not found`);
}
// Apply updates to the domain entity
// Note: This is a simplified version. You may want to add proper domain methods
const updatedBooking = await this.csvBookingRepository.update(csvBooking);
this.logger.log(`[ADMIN] CSV booking updated successfully: ${id}`);
return this.csvBookingToDto(updatedBooking);
}
/**
* Delete csv booking (admin only)
*/
@Delete('bookings/:id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({
summary: 'Delete CSV booking (Admin only)',
description: 'Permanently delete a CSV booking from the database',
})
@ApiParam({
name: 'id',
description: 'Booking ID (UUID)',
})
@ApiResponse({
status: HttpStatus.NO_CONTENT,
description: 'CSV booking deleted successfully',
})
@ApiNotFoundResponse({
description: 'CSV booking not found',
})
async deleteBooking(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: UserPayload,
): Promise<void> {
this.logger.log(`[ADMIN: ${user.email}] Deleting csv booking: ${id}`);
const csvBooking = await this.csvBookingRepository.findById(id);
if (!csvBooking) {
throw new NotFoundException(`CSV booking ${id} not found`);
}
await this.csvBookingRepository.delete(id);
this.logger.log(`[ADMIN] CSV booking deleted successfully: ${id}`);
}
/**
* Helper method to convert CSV booking domain entity to DTO
*/
private csvBookingToDto(booking: any) {
const primaryCurrency = booking.primaryCurrency as 'USD' | 'EUR';
return {
id: booking.id,
userId: booking.userId,
organizationId: booking.organizationId,
carrierName: booking.carrierName,
carrierEmail: booking.carrierEmail,
origin: booking.origin.getValue(),
destination: booking.destination.getValue(),
volumeCBM: booking.volumeCBM,
weightKG: booking.weightKG,
palletCount: booking.palletCount,
priceUSD: booking.priceUSD,
priceEUR: booking.priceEUR,
primaryCurrency: booking.primaryCurrency,
transitDays: booking.transitDays,
containerType: booking.containerType,
status: booking.status,
documents: booking.documents || [],
confirmationToken: booking.confirmationToken,
requestedAt: booking.requestedAt,
respondedAt: booking.respondedAt || null,
notes: booking.notes,
rejectionReason: booking.rejectionReason,
routeDescription: booking.getRouteDescription(),
isExpired: booking.isExpired(),
price: booking.getPriceInCurrency(primaryCurrency),
};
}
// ==================== DOCUMENTS ENDPOINTS ====================
/**
* Get ALL documents from all organizations (admin only)
*
* Returns documents grouped by organization
*/
@Get('documents')
@ApiOperation({
summary: 'Get all documents (Admin only)',
description: 'Retrieve ALL documents from all organizations in the database.',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'All documents retrieved successfully',
})
@ApiResponse({
status: 401,
description: 'Unauthorized - missing or invalid token',
})
@ApiResponse({
status: 403,
description: 'Forbidden - requires admin role',
})
async getAllDocuments(@CurrentUser() user: UserPayload): Promise<any> {
this.logger.log(`[ADMIN: ${user.email}] Fetching ALL documents from database`);
// Get all organizations
const organizations = await this.organizationRepository.findAll();
// Extract documents from all organizations
const allDocuments = organizations.flatMap(org =>
org.documents.map(doc => ({
...doc,
organizationId: org.id,
organizationName: org.name,
}))
);
this.logger.log(`[ADMIN] Retrieved ${allDocuments.length} documents from ${organizations.length} organizations`);
return {
documents: allDocuments,
total: allDocuments.length,
organizationCount: organizations.length,
};
}
/**
* Get documents for a specific organization (admin only)
*/
@Get('organizations/:id/documents')
@ApiOperation({
summary: 'Get organization documents (Admin only)',
description: 'Retrieve all documents for a specific organization',
})
@ApiParam({
name: 'id',
description: 'Organization ID (UUID)',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Organization documents retrieved successfully',
})
@ApiNotFoundResponse({
description: 'Organization not found',
})
async getOrganizationDocuments(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: UserPayload,
): Promise<any> {
this.logger.log(`[ADMIN: ${user.email}] Fetching documents for organization: ${id}`);
const organization = await this.organizationRepository.findById(id);
if (!organization) {
throw new NotFoundException(`Organization ${id} not found`);
}
return {
organizationId: organization.id,
organizationName: organization.name,
documents: organization.documents,
total: organization.documents.length,
};
}
}

View File

@ -351,11 +351,16 @@ export class BookingsController {
`[User: ${user.email}] Listing bookings: page=${page}, pageSize=${pageSize}, status=${status}`
);
// Use authenticated user's organization ID
const organizationId = user.organizationId;
// Fetch bookings for the user's organization
const bookings = await this.bookingRepository.findByOrganization(organizationId);
// ADMIN: Fetch ALL bookings from database
// Others: Fetch only bookings from their organization
let bookings: any[];
if (user.role === 'admin') {
this.logger.log(`[ADMIN] Fetching ALL bookings from database`);
bookings = await this.bookingRepository.findAll();
} else {
this.logger.log(`[User] Fetching bookings from organization: ${user.organizationId}`);
bookings = await this.bookingRepository.findByOrganization(user.organizationId);
}
// Filter by status if provided
const filteredBookings = status

View File

@ -363,8 +363,16 @@ export class UsersController {
`[User: ${currentUser.email}] Listing users: page=${page}, pageSize=${pageSize}, role=${role}`
);
// Fetch users by organization
const users = await this.userRepository.findByOrganization(currentUser.organizationId);
// ADMIN: Fetch ALL users from database
// MANAGER: Fetch only users from their organization
let users: User[];
if (currentUser.role === 'admin') {
this.logger.log(`[ADMIN] Fetching ALL users from database`);
users = await this.userRepository.findAllActive();
} else {
this.logger.log(`[MANAGER] Fetching users from organization: ${currentUser.organizationId}`);
users = await this.userRepository.findByOrganization(currentUser.organizationId);
}
// Filter by role if provided
const filteredUsers = role ? users.filter(u => u.role === role) : users;

View File

@ -137,10 +137,6 @@ export class CsvBooking {
if (!this.confirmationToken || this.confirmationToken.trim().length === 0) {
throw new Error('Confirmation token is required');
}
if (this.documents.length === 0) {
throw new Error('At least one document is required for booking');
}
}
/**

View File

@ -42,6 +42,11 @@ export interface BookingRepository {
*/
findByStatus(status: BookingStatus): Promise<Booking[]>;
/**
* Find all bookings in the system (admin only)
*/
findAll(): Promise<Booking[]>;
/**
* Delete booking by ID
*/

View File

@ -40,6 +40,11 @@ export interface UserRepository {
*/
findAllActive(): Promise<User[]>;
/**
* Find all users in the system (admin only)
*/
findAll(): Promise<User[]>;
/**
* Update a user entity
*/

View File

@ -60,6 +60,17 @@ export class TypeOrmCsvBookingRepository implements CsvBookingRepositoryPort {
return CsvBookingMapper.toDomain(ormEntity);
}
async findAll(): Promise<CsvBooking[]> {
this.logger.log(`Finding ALL CSV bookings from database`);
const ormEntities = await this.repository.find({
order: { requestedAt: 'DESC' },
});
this.logger.log(`Found ${ormEntities.length} CSV bookings in total`);
return CsvBookingMapper.toDomainArray(ormEntities);
}
async findByUserId(userId: string): Promise<CsvBooking[]> {
this.logger.log(`Finding CSV bookings for user: ${userId}`);

View File

@ -73,6 +73,14 @@ export class TypeOrmBookingRepository implements BookingRepository {
return BookingOrmMapper.toDomainMany(orms);
}
async findAll(): Promise<Booking[]> {
const orms = await this.bookingRepository.find({
relations: ['containers'],
order: { createdAt: 'DESC' },
});
return BookingOrmMapper.toDomainMany(orms);
}
async delete(id: string): Promise<void> {
await this.bookingRepository.delete({ id });
}

View File

@ -61,6 +61,13 @@ export class TypeOrmUserRepository implements UserRepository {
return UserOrmMapper.toDomainMany(orms);
}
async findAll(): Promise<User[]> {
const orms = await this.repository.find({
order: { createdAt: 'DESC' },
});
return UserOrmMapper.toDomainMany(orms);
}
async update(user: User): Promise<User> {
const orm = UserOrmMapper.toOrm(user);
const updated = await this.repository.save(orm);

View File

@ -0,0 +1,154 @@
/**
* Script to sync database with MinIO
*
* Removes document references from database for files that no longer exist in MinIO
*/
const { S3Client, ListObjectsV2Command, HeadObjectCommand } = require('@aws-sdk/client-s3');
const { Client } = require('pg');
require('dotenv').config();
const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000';
const BUCKET_NAME = 'xpeditis-documents';
// Initialize MinIO client
const s3Client = new S3Client({
region: 'us-east-1',
endpoint: MINIO_ENDPOINT,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin',
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin',
},
forcePathStyle: true,
});
async function syncDatabase() {
const pgClient = new Client({
host: process.env.DATABASE_HOST || 'localhost',
port: process.env.DATABASE_PORT || 5432,
user: process.env.DATABASE_USER || 'xpeditis',
password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password',
database: process.env.DATABASE_NAME || 'xpeditis_dev',
});
try {
await pgClient.connect();
console.log('✅ Connected to database\n');
// Get all MinIO files
console.log('📋 Listing files in MinIO...');
let allMinioFiles = [];
let continuationToken = null;
do {
const command = new ListObjectsV2Command({
Bucket: BUCKET_NAME,
ContinuationToken: continuationToken,
});
const response = await s3Client.send(command);
if (response.Contents) {
allMinioFiles = allMinioFiles.concat(response.Contents.map(f => f.Key));
}
continuationToken = response.NextContinuationToken;
} while (continuationToken);
console.log(` Found ${allMinioFiles.length} files in MinIO\n`);
// Create a set for faster lookup
const minioFilesSet = new Set(allMinioFiles);
// Get all bookings with documents
const result = await pgClient.query(
`SELECT id, documents FROM csv_bookings WHERE documents IS NOT NULL AND jsonb_array_length(documents::jsonb) > 0`
);
console.log(`📄 Found ${result.rows.length} bookings with documents in database\n`);
let updatedCount = 0;
let removedDocsCount = 0;
let emptyBookingsCount = 0;
for (const row of result.rows) {
const bookingId = row.id;
const documents = row.documents;
// Filter documents to keep only those that exist in MinIO
const validDocuments = [];
const missingDocuments = [];
for (const doc of documents) {
if (!doc.filePath) {
missingDocuments.push(doc);
continue;
}
// Extract the S3 key from the URL
try {
const url = new URL(doc.filePath);
const pathname = url.pathname;
// Remove leading slash and bucket name
const key = pathname.substring(1).replace(`${BUCKET_NAME}/`, '');
if (minioFilesSet.has(key)) {
validDocuments.push(doc);
} else {
missingDocuments.push(doc);
}
} catch (error) {
console.error(` ⚠️ Invalid URL for booking ${bookingId}: ${doc.filePath}`);
missingDocuments.push(doc);
}
}
if (missingDocuments.length > 0) {
console.log(`\n📦 Booking: ${bookingId.substring(0, 8)}...`);
console.log(` Total documents: ${documents.length}`);
console.log(` Valid documents: ${validDocuments.length}`);
console.log(` Missing documents: ${missingDocuments.length}`);
missingDocuments.forEach(doc => {
console.log(`${doc.fileName || 'Unknown'}`);
});
// Update the database
await pgClient.query(
`UPDATE csv_bookings SET documents = $1 WHERE id = $2`,
[JSON.stringify(validDocuments), bookingId]
);
updatedCount++;
removedDocsCount += missingDocuments.length;
if (validDocuments.length === 0) {
emptyBookingsCount++;
console.log(` ⚠️ This booking now has NO documents`);
}
}
}
console.log(`\n📊 Summary:`);
console.log(` Bookings updated: ${updatedCount}`);
console.log(` Documents removed from DB: ${removedDocsCount}`);
console.log(` Bookings with no documents: ${emptyBookingsCount}`);
console.log(`\n✅ Database synchronized with MinIO`);
} catch (error) {
console.error('❌ Error:', error);
throw error;
} finally {
await pgClient.end();
console.log('\n👋 Disconnected from database');
}
}
syncDatabase()
.then(() => {
console.log('\n✅ Script completed successfully');
process.exit(0);
})
.catch((error) => {
console.error('\n❌ Script failed:', error);
process.exit(1);
});

View File

@ -0,0 +1,185 @@
/**
* Script to upload test documents to MinIO
*/
const { S3Client, PutObjectCommand, CreateBucketCommand } = require('@aws-sdk/client-s3');
const { Client: PgClient } = require('pg');
const fs = require('fs');
const path = require('path');
require('dotenv').config();
const MINIO_ENDPOINT = process.env.AWS_S3_ENDPOINT || 'http://localhost:9000';
const BUCKET_NAME = 'xpeditis-documents';
// Initialize MinIO client
const s3Client = new S3Client({
region: 'us-east-1',
endpoint: MINIO_ENDPOINT,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'minioadmin',
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || 'minioadmin',
},
forcePathStyle: true,
});
// Create a simple PDF buffer (minimal valid PDF)
function createTestPDF(title) {
return Buffer.from(
`%PDF-1.4
1 0 obj
<<
/Type /Catalog
/Pages 2 0 R
>>
endobj
2 0 obj
<<
/Type /Pages
/Kids [3 0 R]
/Count 1
>>
endobj
3 0 obj
<<
/Type /Page
/Parent 2 0 R
/MediaBox [0 0 612 792]
/Contents 4 0 R
/Resources <<
/Font <<
/F1 <<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
>>
>>
>>
>>
endobj
4 0 obj
<<
/Length 100
>>
stream
BT
/F1 24 Tf
100 700 Td
(${title}) Tj
ET
endstream
endobj
xref
0 5
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000300 00000 n
trailer
<<
/Size 5
/Root 1 0 R
>>
startxref
450
%%EOF`,
'utf-8'
);
}
async function uploadTestDocuments() {
const pgClient = new PgClient({
host: process.env.DATABASE_HOST || 'localhost',
port: process.env.DATABASE_PORT || 5432,
user: process.env.DATABASE_USER || 'xpeditis',
password: process.env.DATABASE_PASSWORD || 'xpeditis_dev_password',
database: process.env.DATABASE_NAME || 'xpeditis_dev',
});
try {
// Connect to database
await pgClient.connect();
console.log('✅ Connected to database');
// Create bucket if it doesn't exist
try {
await s3Client.send(new CreateBucketCommand({ Bucket: BUCKET_NAME }));
console.log(`✅ Created bucket: ${BUCKET_NAME}`);
} catch (error) {
if (error.name === 'BucketAlreadyOwnedByYou' || error.Code === 'BucketAlreadyOwnedByYou') {
console.log(`✅ Bucket already exists: ${BUCKET_NAME}`);
} else {
console.log(`⚠️ Could not create bucket (might already exist): ${error.message}`);
}
}
// Get all CSV bookings with documents
const result = await pgClient.query(
`SELECT id, documents FROM csv_bookings WHERE documents IS NOT NULL`
);
console.log(`\n📄 Found ${result.rows.length} bookings with documents\n`);
let uploadedCount = 0;
for (const row of result.rows) {
const bookingId = row.id;
const documents = row.documents;
console.log(`\n📦 Processing booking: ${bookingId}`);
for (const doc of documents) {
if (!doc.filePath || !doc.filePath.includes(MINIO_ENDPOINT)) {
console.log(` ⏭️ Skipping document (not a MinIO URL): ${doc.fileName}`);
continue;
}
// Extract the S3 key from the URL
const url = new URL(doc.filePath);
const key = url.pathname.substring(1).replace(`${BUCKET_NAME}/`, '');
// Create test PDF content
const pdfContent = createTestPDF(doc.fileName || 'Test Document');
try {
// Upload to MinIO
await s3Client.send(
new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: key,
Body: pdfContent,
ContentType: doc.mimeType || 'application/pdf',
})
);
console.log(` ✅ Uploaded: ${doc.fileName}`);
console.log(` Path: ${key}`);
uploadedCount++;
} catch (error) {
console.error(` ❌ Failed to upload ${doc.fileName}:`, error.message);
}
}
}
console.log(`\n🎉 Successfully uploaded ${uploadedCount} test documents to MinIO`);
console.log(`\n📍 MinIO Console: http://localhost:9001`);
console.log(` Username: minioadmin`);
console.log(` Password: minioadmin`);
} catch (error) {
console.error('❌ Error:', error);
throw error;
} finally {
await pgClient.end();
console.log('\n👋 Disconnected from database');
}
}
uploadTestDocuments()
.then(() => {
console.log('\n✅ Script completed successfully');
process.exit(0);
})
.catch((error) => {
console.error('\n❌ Script failed:', error);
process.exit(1);
});

View File

@ -0,0 +1,413 @@
'use client';
import { useState, useEffect } from 'react';
import { getAllBookings } from '@/lib/api/admin';
interface Booking {
id: string;
bookingNumber?: string;
bookingId?: string;
type?: string;
status: string;
// CSV bookings use these fields
origin?: string;
destination?: string;
carrierName?: string;
// Regular bookings use these fields
originPort?: {
code: string;
name: string;
};
destinationPort?: {
code: string;
name: string;
};
carrier?: string;
containerType: string;
quantity?: number;
price?: number;
primaryCurrency?: string;
totalPrice?: {
amount: number;
currency: string;
};
createdAt?: string;
updatedAt?: string;
requestedAt?: string;
organizationId: string;
userId: string;
}
export default function AdminBookingsPage() {
const [bookings, setBookings] = useState<Booking[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
const [showDetailsModal, setShowDetailsModal] = useState(false);
const [filterStatus, setFilterStatus] = useState('all');
const [searchTerm, setSearchTerm] = useState('');
// Helper function to get formatted quote number
const getQuoteNumber = (booking: Booking): string => {
if (booking.type === 'csv') {
return `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`;
}
return booking.bookingNumber || `#${booking.id.slice(0, 8).toUpperCase()}`;
};
useEffect(() => {
fetchBookings();
}, []);
const fetchBookings = async () => {
try {
setLoading(true);
const response = await getAllBookings();
setBookings(response.bookings || []);
setError(null);
} catch (err: any) {
setError(err.message || 'Failed to load bookings');
} finally {
setLoading(false);
}
};
const getStatusColor = (status: string) => {
const colors: Record<string, string> = {
draft: 'bg-gray-100 text-gray-800',
pending: 'bg-yellow-100 text-yellow-800',
confirmed: 'bg-blue-100 text-blue-800',
in_transit: 'bg-purple-100 text-purple-800',
delivered: 'bg-green-100 text-green-800',
cancelled: 'bg-red-100 text-red-800',
};
return colors[status.toLowerCase()] || 'bg-gray-100 text-gray-800';
};
const filteredBookings = bookings
.filter(booking => filterStatus === 'all' || booking.status.toLowerCase() === filterStatus)
.filter(booking => {
if (searchTerm === '') return true;
const searchLower = searchTerm.toLowerCase();
const quoteNumber = getQuoteNumber(booking).toLowerCase();
return (
quoteNumber.includes(searchLower) ||
booking.bookingNumber?.toLowerCase().includes(searchLower) ||
booking.carrier?.toLowerCase().includes(searchLower) ||
booking.carrierName?.toLowerCase().includes(searchLower) ||
booking.origin?.toLowerCase().includes(searchLower) ||
booking.destination?.toLowerCase().includes(searchLower)
);
});
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading bookings...</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Booking Management</h1>
<p className="mt-1 text-sm text-gray-500">
View and manage all bookings across the platform
</p>
</div>
</div>
{/* Filters */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Search</label>
<input
type="text"
placeholder="Search by booking number or carrier..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Status Filter</label>
<select
value={filterStatus}
onChange={e => setFilterStatus(e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
>
<option value="all">All Statuses</option>
<option value="draft">Draft</option>
<option value="pending">Pending</option>
<option value="confirmed">Confirmed</option>
<option value="in_transit">In Transit</option>
<option value="delivered">Delivered</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-sm text-gray-500">Total Réservations</div>
<div className="text-2xl font-bold text-gray-900">{bookings.length}</div>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-sm text-gray-500">En Attente</div>
<div className="text-2xl font-bold text-yellow-600">
{bookings.filter(b => b.status.toUpperCase() === 'PENDING').length}
</div>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-sm text-gray-500">Acceptées</div>
<div className="text-2xl font-bold text-green-600">
{bookings.filter(b => b.status.toUpperCase() === 'ACCEPTED').length}
</div>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-sm text-gray-500">Rejetées</div>
<div className="text-2xl font-bold text-red-600">
{bookings.filter(b => b.status.toUpperCase() === 'REJECTED').length}
</div>
</div>
</div>
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
{error}
</div>
)}
{/* Bookings Table */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Numéro de devis
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Route
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Transporteur
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Conteneur
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Statut
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Prix
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredBookings.map(booking => (
<tr key={booking.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{getQuoteNumber(booking)}
</div>
<div className="text-xs text-gray-500">
{new Date(booking.createdAt || booking.requestedAt || '').toLocaleDateString()}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{booking.originPort ? `${booking.originPort.code}${booking.destinationPort?.code}` : `${booking.origin}${booking.destination}`}
</div>
<div className="text-xs text-gray-500">
{booking.originPort ? `${booking.originPort.name}${booking.destinationPort?.name}` : ''}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{booking.carrier || booking.carrierName || 'N/A'}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{booking.containerType}</div>
<div className="text-xs text-gray-500">
{booking.quantity ? `Qty: ${booking.quantity}` : ''}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(booking.status)}`}>
{booking.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{booking.totalPrice
? `${booking.totalPrice.amount.toLocaleString()} ${booking.totalPrice.currency}`
: booking.price
? `${booking.price.toLocaleString()} ${booking.primaryCurrency || 'USD'}`
: 'N/A'
}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => {
setSelectedBooking(booking);
setShowDetailsModal(true);
}}
className="text-blue-600 hover:text-blue-900"
>
View Details
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Details Modal */}
{showDetailsModal && selectedBooking && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 overflow-y-auto">
<div className="bg-white rounded-lg p-6 max-w-2xl w-full m-4">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold">Booking Details</h2>
<button
onClick={() => {
setShowDetailsModal(false);
setSelectedBooking(null);
}}
className="text-gray-400 hover:text-gray-600"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-500">Numéro de devis</label>
<div className="mt-1 text-lg font-semibold">
{getQuoteNumber(selectedBooking)}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Statut</label>
<span className={`mt-1 inline-block px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(selectedBooking.status)}`}>
{selectedBooking.status}
</span>
</div>
</div>
<div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">Route Information</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-500">Origin</label>
<div className="mt-1">
{selectedBooking.originPort ? (
<>
<div className="font-semibold">{selectedBooking.originPort.code}</div>
<div className="text-sm text-gray-600">{selectedBooking.originPort.name}</div>
</>
) : (
<div className="font-semibold">{selectedBooking.origin}</div>
)}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Destination</label>
<div className="mt-1">
{selectedBooking.destinationPort ? (
<>
<div className="font-semibold">{selectedBooking.destinationPort.code}</div>
<div className="text-sm text-gray-600">{selectedBooking.destinationPort.name}</div>
</>
) : (
<div className="font-semibold">{selectedBooking.destination}</div>
)}
</div>
</div>
</div>
</div>
<div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">Shipping Details</h3>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-500">Carrier</label>
<div className="mt-1 font-semibold">
{selectedBooking.carrier || selectedBooking.carrierName || 'N/A'}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Container Type</label>
<div className="mt-1 font-semibold">{selectedBooking.containerType}</div>
</div>
{selectedBooking.quantity && (
<div>
<label className="block text-sm font-medium text-gray-500">Quantity</label>
<div className="mt-1 font-semibold">{selectedBooking.quantity}</div>
</div>
)}
</div>
</div>
<div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">Pricing</h3>
<div className="text-2xl font-bold text-blue-600">
{selectedBooking.totalPrice
? `${selectedBooking.totalPrice.amount.toLocaleString()} ${selectedBooking.totalPrice.currency}`
: selectedBooking.price
? `${selectedBooking.price.toLocaleString()} ${selectedBooking.primaryCurrency || 'USD'}`
: 'N/A'
}
</div>
</div>
<div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">Timeline</h3>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<label className="block text-gray-500">Created</label>
<div className="mt-1">
{new Date(selectedBooking.createdAt || selectedBooking.requestedAt || '').toLocaleString()}
</div>
</div>
{selectedBooking.updatedAt && (
<div>
<label className="block text-gray-500">Last Updated</label>
<div className="mt-1">{new Date(selectedBooking.updatedAt).toLocaleString()}</div>
</div>
)}
</div>
</div>
</div>
<div className="flex justify-end space-x-2 mt-6 pt-4 border-t">
<button
onClick={() => {
setShowDetailsModal(false);
setSelectedBooking(null);
}}
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
>
Close
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,589 @@
'use client';
import { useState, useEffect } from 'react';
import { getAllBookings, getAllUsers } from '@/lib/api/admin';
interface Document {
id: string;
fileName: string;
filePath: string;
type: string;
mimeType: string;
size: number;
uploadedAt?: Date;
// Legacy fields for compatibility
name?: string;
url?: string;
}
interface Booking {
id: string;
bookingNumber?: string;
bookingId?: string;
type?: string;
userId: string;
organizationId: string;
origin?: string;
destination?: string;
carrierName?: string;
documents: Document[];
requestedAt?: string;
status: string;
}
interface DocumentWithBooking extends Document {
bookingId: string;
quoteNumber: string;
userId: string;
userName?: string;
organizationId: string;
route: string;
status: string;
fileName?: string;
fileType?: string;
}
export default function AdminDocumentsPage() {
const [bookings, setBookings] = useState<Booking[]>([]);
const [documents, setDocuments] = useState<DocumentWithBooking[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [filterUserId, setFilterUserId] = useState('all');
const [filterQuoteNumber, setFilterQuoteNumber] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
// Helper function to get formatted quote number
const getQuoteNumber = (booking: Booking): string => {
if (booking.type === 'csv') {
return `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`;
}
return booking.bookingNumber || `#${booking.id.slice(0, 8).toUpperCase()}`;
};
// Extract filename from MinIO URL
const extractFileName = (url: string): string => {
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const parts = pathname.split('/');
const fileName = parts[parts.length - 1];
return decodeURIComponent(fileName);
} catch {
return 'document';
}
};
// Get file extension and type
const getFileType = (fileName: string): string => {
const ext = fileName.split('.').pop()?.toLowerCase() || '';
const typeMap: Record<string, string> = {
pdf: 'PDF',
doc: 'Word',
docx: 'Word',
xls: 'Excel',
xlsx: 'Excel',
jpg: 'Image',
jpeg: 'Image',
png: 'Image',
gif: 'Image',
txt: 'Text',
csv: 'CSV',
};
return typeMap[ext] || ext.toUpperCase();
};
useEffect(() => {
fetchBookingsAndDocuments();
}, []);
const fetchBookingsAndDocuments = async () => {
try {
setLoading(true);
const response = await getAllBookings();
const allBookings = response.bookings || [];
setBookings(allBookings);
// Extract all documents from all bookings
const allDocuments: DocumentWithBooking[] = [];
const userIds = new Set<string>();
allBookings.forEach((booking: Booking) => {
userIds.add(booking.userId);
if (booking.documents && booking.documents.length > 0) {
booking.documents.forEach((doc: Document) => {
// Debug: Log document structure
console.log('Document structure:', doc);
// Use the correct field names from the backend
const actualFileName = doc.fileName || doc.name || 'document';
const actualFilePath = doc.filePath || doc.url || '';
const actualMimeType = doc.mimeType || doc.type || '';
console.log('Extracted:', {
fileName: actualFileName,
filePath: actualFilePath,
mimeType: actualMimeType,
});
// Extract clean file type from mimeType or fileName
let fileType = '';
if (actualMimeType.includes('/')) {
// It's a MIME type like "application/pdf"
const parts = actualMimeType.split('/');
fileType = getFileType(parts[1]);
} else {
// It's already a type or we extract from filename
fileType = getFileType(actualFileName);
}
allDocuments.push({
...doc,
bookingId: booking.id,
quoteNumber: getQuoteNumber(booking),
userId: booking.userId,
organizationId: booking.organizationId,
route: `${booking.origin || 'N/A'}${booking.destination || 'N/A'}`,
status: booking.status,
fileName: actualFileName,
filePath: actualFilePath,
fileType: fileType,
});
});
}
});
// Fetch user names using the API client
try {
const usersData = await getAllUsers();
console.log('Users data:', usersData);
if (usersData && usersData.users) {
const usersMap = new Map(
usersData.users.map((u: any) => {
const fullName = `${u.firstName || ''} ${u.lastName || ''}`.trim();
return [u.id, fullName || u.email || u.id.substring(0, 8)];
})
);
console.log('Users map:', usersMap);
// Enrich documents with user names
allDocuments.forEach(doc => {
const userName = usersMap.get(doc.userId);
doc.userName = userName || doc.userId.substring(0, 8) + '...';
console.log(`User ${doc.userId} mapped to: ${doc.userName}`);
});
}
} catch (userError) {
console.error('Failed to fetch user names:', userError);
// If user fetch fails, keep the userId as fallback
allDocuments.forEach(doc => {
doc.userName = doc.userId.substring(0, 8) + '...';
});
}
setDocuments(allDocuments);
setError(null);
} catch (err: any) {
setError(err.message || 'Failed to load documents');
} finally {
setLoading(false);
}
};
// Get unique users for filter (with names)
const uniqueUsers = Array.from(
new Map(
documents.map(doc => [doc.userId, { id: doc.userId, name: doc.userName || doc.userId.substring(0, 8) + '...' }])
).values()
);
// Filter documents
const filteredDocuments = documents.filter(doc => {
const matchesSearch = searchTerm === '' ||
(doc.fileName && doc.fileName.toLowerCase().includes(searchTerm.toLowerCase())) ||
(doc.name && doc.name.toLowerCase().includes(searchTerm.toLowerCase())) ||
(doc.type && doc.type.toLowerCase().includes(searchTerm.toLowerCase())) ||
doc.route.toLowerCase().includes(searchTerm.toLowerCase());
const matchesUser = filterUserId === 'all' || doc.userId === filterUserId;
const matchesQuote = filterQuoteNumber === '' ||
doc.quoteNumber.toLowerCase().includes(filterQuoteNumber.toLowerCase());
return matchesSearch && matchesUser && matchesQuote;
});
// Pagination
const totalPages = Math.ceil(filteredDocuments.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const paginatedDocuments = filteredDocuments.slice(startIndex, endIndex);
// Reset to page 1 when filters change
useEffect(() => {
setCurrentPage(1);
}, [searchTerm, filterUserId, filterQuoteNumber]);
const getDocumentIcon = (type: string) => {
const typeLower = type.toLowerCase();
const icons: Record<string, string> = {
'application/pdf': '📄',
'image/jpeg': '🖼️',
'image/png': '🖼️',
'image/jpg': '🖼️',
pdf: '📄',
jpeg: '🖼️',
jpg: '🖼️',
png: '🖼️',
gif: '🖼️',
image: '🖼️',
word: '📝',
doc: '📝',
docx: '📝',
excel: '📊',
xls: '📊',
xlsx: '📊',
csv: '📊',
text: '📄',
txt: '📄',
};
return icons[typeLower] || '📎';
};
const getStatusColor = (status: string) => {
const colors: Record<string, string> = {
pending: 'bg-yellow-100 text-yellow-800',
accepted: 'bg-green-100 text-green-800',
rejected: 'bg-red-100 text-red-800',
cancelled: 'bg-gray-100 text-gray-800',
};
return colors[status.toLowerCase()] || 'bg-gray-100 text-gray-800';
};
const handleDownload = async (url: string, fileName: string) => {
try {
// Try direct download first
const link = document.createElement('a');
link.href = url;
link.download = fileName;
link.target = '_blank';
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// If direct download doesn't work, try fetch with blob
setTimeout(async () => {
try {
const response = await fetch(url, {
mode: 'cors',
credentials: 'include',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link2 = document.createElement('a');
link2.href = blobUrl;
link2.download = fileName;
document.body.appendChild(link2);
link2.click();
document.body.removeChild(link2);
window.URL.revokeObjectURL(blobUrl);
} catch (fetchError) {
console.error('Fetch download failed:', fetchError);
}
}, 100);
} catch (error) {
console.error('Error downloading file:', error);
alert(`Erreur lors du téléchargement du document: ${error instanceof Error ? error.message : 'Erreur inconnue'}`);
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Chargement des documents...</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Gestion des Documents</h1>
<p className="mt-1 text-sm text-gray-500">
Liste de tous les documents des devis CSV
</p>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-sm text-gray-500">Total Documents</div>
<div className="text-2xl font-bold text-gray-900">{documents.length}</div>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-sm text-gray-500">Devis avec Documents</div>
<div className="text-2xl font-bold text-blue-600">
{bookings.filter(b => b.documents && b.documents.length > 0).length}
</div>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-sm text-gray-500">Documents Filtrés</div>
<div className="text-2xl font-bold text-green-600">{filteredDocuments.length}</div>
</div>
</div>
{/* Filters */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Recherche
</label>
<input
type="text"
placeholder="Nom, type, route..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Numéro de Devis
</label>
<input
type="text"
placeholder="Ex: #F2CAD5E1"
value={filterQuoteNumber}
onChange={e => setFilterQuoteNumber(e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Utilisateur
</label>
<select
value={filterUserId}
onChange={e => setFilterUserId(e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
>
<option value="all">Tous les utilisateurs</option>
{uniqueUsers.map(user => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
</div>
</div>
</div>
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
{error}
</div>
)}
{/* Documents Table */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Nom du Document
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Type
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Numéro de Devis
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Route
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Statut
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Utilisateur
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
Télécharger
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{paginatedDocuments.length === 0 ? (
<tr>
<td colSpan={7} className="px-6 py-12 text-center text-gray-500">
Aucun document trouvé
</td>
</tr>
) : (
paginatedDocuments.map((doc, index) => (
<tr key={`${doc.bookingId}-${index}`} className="hover:bg-gray-50">
<td className="px-6 py-4">
<div className="text-sm font-medium text-gray-900">
{doc.fileName || doc.name}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<span className="text-2xl mr-2">{getDocumentIcon(doc.fileType || doc.type)}</span>
<div className="text-xs text-gray-500">{doc.fileType || doc.type}</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{doc.quoteNumber}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{doc.route}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(doc.status)}`}>
{doc.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{doc.userName || doc.userId.substring(0, 8) + '...'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
<button
onClick={() => handleDownload(doc.filePath || doc.url || '', doc.fileName || doc.name || 'document')}
className="inline-flex items-center px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-lg hover:bg-green-700 transition-colors"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Télécharger
</button>
</td>
</tr>
))
)}
</tbody>
</table>
{/* Pagination Controls */}
{filteredDocuments.length > 0 && (
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
<div className="flex-1 flex justify-between sm:hidden">
<button
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Précédent
</button>
<button
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Suivant
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
Affichage de <span className="font-medium">{startIndex + 1}</span> à{' '}
<span className="font-medium">{Math.min(endIndex, filteredDocuments.length)}</span> sur{' '}
<span className="font-medium">{filteredDocuments.length}</span> résultats
</p>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm text-gray-700">Par page:</label>
<select
value={itemsPerPage}
onChange={(e) => {
setItemsPerPage(Number(e.target.value));
setCurrentPage(1);
}}
className="border border-gray-300 rounded-md px-2 py-1 text-sm"
>
<option value={5}>5</option>
<option value={10}>10</option>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
</div>
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
<button
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="sr-only">Précédent</span>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</button>
{/* Page numbers */}
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
let pageNum;
if (totalPages <= 5) {
pageNum = i + 1;
} else if (currentPage <= 3) {
pageNum = i + 1;
} else if (currentPage >= totalPages - 2) {
pageNum = totalPages - 4 + i;
} else {
pageNum = currentPage - 2 + i;
}
return (
<button
key={pageNum}
onClick={() => setCurrentPage(pageNum)}
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
currentPage === pageNum
? 'z-10 bg-blue-50 border-blue-500 text-blue-600'
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
}`}
>
{pageNum}
</button>
);
})}
<button
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="sr-only">Suivant</span>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
</svg>
</button>
</nav>
</div>
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,421 @@
'use client';
import { useState, useEffect } from 'react';
import { getAllOrganizations } from '@/lib/api/admin';
import { createOrganization, updateOrganization } from '@/lib/api/organizations';
interface Organization {
id: string;
name: string;
type: string;
scac?: string;
siren?: string;
eori?: string;
contact_phone?: string;
contact_email?: string;
address: {
street: string;
city: string;
state?: string;
postalCode: string;
country: string;
};
logoUrl?: string;
isActive: boolean;
createdAt: string;
}
export default function AdminOrganizationsPage() {
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedOrg, setSelectedOrg] = useState<Organization | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
// Form state
const [formData, setFormData] = useState({
name: '',
type: 'FREIGHT_FORWARDER',
scac: '',
siren: '',
eori: '',
contact_phone: '',
contact_email: '',
address: {
street: '',
city: '',
state: '',
postalCode: '',
country: 'FR',
},
logoUrl: '',
});
useEffect(() => {
fetchOrganizations();
}, []);
const fetchOrganizations = async () => {
try {
setLoading(true);
const response = await getAllOrganizations();
setOrganizations(response.organizations || []);
setError(null);
} catch (err: any) {
setError(err.message || 'Failed to load organizations');
} finally {
setLoading(false);
}
};
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
try {
await createOrganization(formData);
await fetchOrganizations();
setShowCreateModal(false);
resetForm();
} catch (err: any) {
alert(err.message || 'Failed to create organization');
}
};
const handleUpdate = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedOrg) return;
try {
await updateOrganization(selectedOrg.id, formData);
await fetchOrganizations();
setShowEditModal(false);
setSelectedOrg(null);
resetForm();
} catch (err: any) {
alert(err.message || 'Failed to update organization');
}
};
const resetForm = () => {
setFormData({
name: '',
type: 'FREIGHT_FORWARDER',
scac: '',
siren: '',
eori: '',
contact_phone: '',
contact_email: '',
address: {
street: '',
city: '',
state: '',
postalCode: '',
country: 'FR',
},
logoUrl: '',
});
};
const openEditModal = (org: Organization) => {
setSelectedOrg(org);
setFormData({
name: org.name,
type: org.type,
scac: org.scac || '',
siren: org.siren || '',
eori: org.eori || '',
contact_phone: org.contact_phone || '',
contact_email: org.contact_email || '',
address: org.address,
logoUrl: org.logoUrl || '',
});
setShowEditModal(true);
};
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading organizations...</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Organization Management</h1>
<p className="mt-1 text-sm text-gray-500">
Manage all organizations in the system
</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
+ Create Organization
</button>
</div>
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
{error}
</div>
)}
{/* Organizations Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{organizations.map(org => (
<div key={org.id} className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900">{org.name}</h3>
<span className={`inline-block mt-2 px-2 py-1 text-xs font-semibold rounded-full ${
org.type === 'FREIGHT_FORWARDER' ? 'bg-blue-100 text-blue-800' :
org.type === 'CARRIER' ? 'bg-green-100 text-green-800' :
'bg-purple-100 text-purple-800'
}`}>
{org.type.replace('_', ' ')}
</span>
</div>
<span className={`px-2 py-1 text-xs font-semibold rounded-full ${
org.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{org.isActive ? 'Active' : 'Inactive'}
</span>
</div>
<div className="space-y-2 text-sm text-gray-600 mb-4">
{org.scac && (
<div>
<span className="font-medium">SCAC:</span> {org.scac}
</div>
)}
{org.siren && (
<div>
<span className="font-medium">SIREN:</span> {org.siren}
</div>
)}
{org.contact_email && (
<div>
<span className="font-medium">Email:</span> {org.contact_email}
</div>
)}
<div>
<span className="font-medium">Location:</span> {org.address.city}, {org.address.country}
</div>
</div>
<div className="flex space-x-2">
<button
onClick={() => openEditModal(org)}
className="flex-1 px-3 py-2 bg-blue-50 text-blue-700 rounded-md hover:bg-blue-100 transition-colors text-sm font-medium"
>
Edit
</button>
</div>
</div>
))}
</div>
{/* Create/Edit Modal */}
{(showCreateModal || showEditModal) && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 overflow-y-auto">
<div className="bg-white rounded-lg p-6 max-w-2xl w-full m-4 max-h-[90vh] overflow-y-auto">
<h2 className="text-xl font-bold mb-4">
{showCreateModal ? 'Create New Organization' : 'Edit Organization'}
</h2>
<form onSubmit={showCreateModal ? handleCreate : handleUpdate} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700">Organization Name *</label>
<input
type="text"
required
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Type *</label>
<select
value={formData.type}
onChange={e => setFormData({ ...formData, type: e.target.value })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
>
<option value="FREIGHT_FORWARDER">Freight Forwarder</option>
<option value="CARRIER">Carrier</option>
<option value="SHIPPER">Shipper</option>
</select>
</div>
{formData.type === 'CARRIER' && (
<div>
<label className="block text-sm font-medium text-gray-700">SCAC Code *</label>
<input
type="text"
required={formData.type === 'CARRIER'}
maxLength={4}
value={formData.scac}
onChange={e => setFormData({ ...formData, scac: e.target.value.toUpperCase() })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700">SIREN</label>
<input
type="text"
maxLength={9}
value={formData.siren}
onChange={e => setFormData({ ...formData, siren: e.target.value })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">EORI</label>
<input
type="text"
value={formData.eori}
onChange={e => setFormData({ ...formData, eori: e.target.value })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Contact Phone</label>
<input
type="tel"
value={formData.contact_phone}
onChange={e => setFormData({ ...formData, contact_phone: e.target.value })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Contact Email</label>
<input
type="email"
value={formData.contact_email}
onChange={e => setFormData({ ...formData, contact_email: e.target.value })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700">Street Address *</label>
<input
type="text"
required
value={formData.address.street}
onChange={e => setFormData({
...formData,
address: { ...formData.address, street: e.target.value }
})}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">City *</label>
<input
type="text"
required
value={formData.address.city}
onChange={e => setFormData({
...formData,
address: { ...formData.address, city: e.target.value }
})}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Postal Code *</label>
<input
type="text"
required
value={formData.address.postalCode}
onChange={e => setFormData({
...formData,
address: { ...formData.address, postalCode: e.target.value }
})}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">State/Region</label>
<input
type="text"
value={formData.address.state}
onChange={e => setFormData({
...formData,
address: { ...formData.address, state: e.target.value }
})}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Country *</label>
<input
type="text"
required
maxLength={2}
value={formData.address.country}
onChange={e => setFormData({
...formData,
address: { ...formData.address, country: e.target.value.toUpperCase() }
})}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700">Logo URL</label>
<input
type="url"
value={formData.logoUrl}
onChange={e => setFormData({ ...formData, logoUrl: e.target.value })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/>
</div>
</div>
<div className="flex justify-end space-x-2 pt-4">
<button
type="button"
onClick={() => {
setShowCreateModal(false);
setShowEditModal(false);
setSelectedOrg(null);
resetForm();
}}
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
{showCreateModal ? 'Create' : 'Update'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,455 @@
'use client';
import { useState, useEffect } from 'react';
import { getAllUsers, updateAdminUser, deleteAdminUser } from '@/lib/api/admin';
import { createUser } from '@/lib/api/users';
import { getAllOrganizations } from '@/lib/api/admin';
interface User {
id: string;
email: string;
firstName: string;
lastName: string;
role: string;
organizationId: string;
organizationName?: string;
isActive: boolean;
createdAt: string;
}
interface Organization {
id: string;
name: string;
}
export default function AdminUsersPage() {
const [users, setUsers] = useState<User[]>([]);
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
// Form state
const [formData, setFormData] = useState({
email: '',
firstName: '',
lastName: '',
role: 'USER',
organizationId: '',
password: '',
});
// Fetch users and organizations
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try {
setLoading(true);
const [usersResponse, orgsResponse] = await Promise.all([
getAllUsers(),
getAllOrganizations(),
]);
setUsers(usersResponse.users || []);
setOrganizations(orgsResponse.organizations || []);
setError(null);
} catch (err: any) {
setError(err.message || 'Failed to load data');
} finally {
setLoading(false);
}
};
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
try {
await createUser(formData);
await fetchData();
setShowCreateModal(false);
resetForm();
} catch (err: any) {
alert(err.message || 'Failed to create user');
}
};
const handleUpdate = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedUser) return;
try {
await updateAdminUser(selectedUser.id, {
firstName: formData.firstName,
lastName: formData.lastName,
role: formData.role,
isActive: selectedUser.isActive,
});
await fetchData();
setShowEditModal(false);
setSelectedUser(null);
resetForm();
} catch (err: any) {
alert(err.message || 'Failed to update user');
}
};
const handleDelete = async () => {
if (!selectedUser) return;
try {
await deleteAdminUser(selectedUser.id);
await fetchData();
setShowDeleteConfirm(false);
setSelectedUser(null);
} catch (err: any) {
alert(err.message || 'Failed to delete user');
}
};
const resetForm = () => {
setFormData({
email: '',
firstName: '',
lastName: '',
role: 'USER',
organizationId: '',
password: '',
});
};
const openEditModal = (user: User) => {
setSelectedUser(user);
setFormData({
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
organizationId: user.organizationId,
password: '',
});
setShowEditModal(true);
};
const openDeleteConfirm = (user: User) => {
setSelectedUser(user);
setShowDeleteConfirm(true);
};
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading users...</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">User Management</h1>
<p className="mt-1 text-sm text-gray-500">
Manage all users in the system
</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
+ Create User
</button>
</div>
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
{error}
</div>
)}
{/* Users Table */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Role
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Organization
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{users.map(user => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{user.firstName} {user.lastName}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">{user.email}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
user.role === 'ADMIN' ? 'bg-purple-100 text-purple-800' :
user.role === 'MANAGER' ? 'bg-blue-100 text-blue-800' :
'bg-gray-100 text-gray-800'
}`}>
{user.role}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{user.organizationName || user.organizationId}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
user.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{user.isActive ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
<button
onClick={() => openEditModal(user)}
className="text-blue-600 hover:text-blue-900"
>
Edit
</button>
<button
onClick={() => openDeleteConfirm(user)}
className="text-red-600 hover:text-red-900"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Create Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full">
<h2 className="text-xl font-bold mb-4">Create New User</h2>
<form onSubmit={handleCreate} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Email</label>
<input
type="email"
required
value={formData.email}
onChange={e => setFormData({ ...formData, email: e.target.value })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">First Name</label>
<input
type="text"
required
value={formData.firstName}
onChange={e => setFormData({ ...formData, firstName: e.target.value })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Last Name</label>
<input
type="text"
required
value={formData.lastName}
onChange={e => setFormData({ ...formData, lastName: e.target.value })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Role</label>
<select
value={formData.role}
onChange={e => setFormData({ ...formData, role: e.target.value })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
>
<option value="USER">User</option>
<option value="MANAGER">Manager</option>
<option value="ADMIN">Admin</option>
<option value="VIEWER">Viewer</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Organization</label>
<select
required
value={formData.organizationId}
onChange={e => setFormData({ ...formData, organizationId: e.target.value })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
>
<option value="">Select Organization</option>
{organizations.map(org => (
<option key={org.id} value={org.id}>
{org.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Password (leave empty for auto-generated)
</label>
<input
type="password"
value={formData.password}
onChange={e => setFormData({ ...formData, password: e.target.value })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div className="flex justify-end space-x-2 pt-4">
<button
type="button"
onClick={() => {
setShowCreateModal(false);
resetForm();
}}
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Create
</button>
</div>
</form>
</div>
</div>
)}
{/* Edit Modal */}
{showEditModal && selectedUser && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full">
<h2 className="text-xl font-bold mb-4">Edit User</h2>
<form onSubmit={handleUpdate} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">Email (read-only)</label>
<input
type="email"
disabled
value={formData.email}
className="mt-1 block w-full px-3 py-2 border border-gray-300 bg-gray-100 rounded-md shadow-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">First Name</label>
<input
type="text"
required
value={formData.firstName}
onChange={e => setFormData({ ...formData, firstName: e.target.value })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Last Name</label>
<input
type="text"
required
value={formData.lastName}
onChange={e => setFormData({ ...formData, lastName: e.target.value })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Role</label>
<select
value={formData.role}
onChange={e => setFormData({ ...formData, role: e.target.value })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
>
<option value="USER">User</option>
<option value="MANAGER">Manager</option>
<option value="ADMIN">Admin</option>
<option value="VIEWER">Viewer</option>
</select>
</div>
<div className="flex justify-end space-x-2 pt-4">
<button
type="button"
onClick={() => {
setShowEditModal(false);
setSelectedUser(null);
resetForm();
}}
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Update
</button>
</div>
</form>
</div>
</div>
)}
{/* Delete Confirmation */}
{showDeleteConfirm && selectedUser && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full">
<h2 className="text-xl font-bold mb-4 text-red-600">Confirm Delete</h2>
<p className="text-gray-700 mb-6">
Are you sure you want to delete user <strong>{selectedUser.firstName} {selectedUser.lastName}</strong>?
This action cannot be undone.
</p>
<div className="flex justify-end space-x-2">
<button
onClick={() => {
setShowDeleteConfirm(false);
setSelectedUser(null);
}}
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={handleDelete}
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700"
>
Delete
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -327,18 +327,9 @@ export default function BookingsListPage() {
: 'N/A'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<Link
href={
booking.type === 'csv'
? `/dashboard/csv-bookings/${booking.id}`
: `/dashboard/bookings/${booking.id}`
}
className="text-blue-600 hover:text-blue-900"
>
{booking.type === 'csv'
? `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`
: booking.bookingNumber || `#${booking.id.slice(0, 8).toUpperCase()}`}
</Link>
</td>
</tr>
))}

View File

@ -11,6 +11,7 @@ import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useState } from 'react';
import NotificationDropdown from '@/components/NotificationDropdown';
import AdminPanelDropdown from '@/components/admin/AdminPanelDropdown';
import Image from 'next/image';
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
@ -18,20 +19,17 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
const pathname = usePathname();
const [sidebarOpen, setSidebarOpen] = useState(false);
// { name: 'Search Rates', href: '/dashboard/search-advanced', icon: '🔎' },
const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: '📊' },
{ name: 'Bookings', href: '/dashboard/bookings', icon: '📦' },
{ name: 'Search Rates', href: '/dashboard/search-advanced', icon: '🔎' },
{ name: 'My Profile', href: '/dashboard/profile', icon: '👤' },
{ name: 'Organization', href: '/dashboard/settings/organization', icon: '🏢' },
// ADMIN and MANAGER only navigation items
...(user?.role === 'ADMIN' || user?.role === 'MANAGER' ? [
{ name: 'Users', href: '/dashboard/settings/users', icon: '👥' },
] : []),
// ADMIN only navigation items
...(user?.role === 'ADMIN' ? [
{ name: 'CSV Rates', href: '/dashboard/admin/csv-rates', icon: '📄' },
] : []),
];
const isActive = (href: string) => {
@ -101,6 +99,13 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
{item.name}
</Link>
))}
{/* Admin Panel - ADMIN role only */}
{user?.role === 'ADMIN' && (
<div className="pt-4 mt-4 border-t">
<AdminPanelDropdown />
</div>
)}
</nav>
{/* User section */}

View File

@ -0,0 +1,135 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
interface AdminMenuItem {
name: string;
href: string;
icon: string;
description: string;
}
const adminMenuItems: AdminMenuItem[] = [
{
name: 'Users',
href: '/dashboard/admin/users',
icon: '👥',
description: 'Manage users and permissions',
},
{
name: 'Organizations',
href: '/dashboard/admin/organizations',
icon: '🏢',
description: 'Manage organizations and companies',
},
{
name: 'Bookings',
href: '/dashboard/admin/bookings',
icon: '📦',
description: 'View and manage all bookings',
},
{
name: 'Documents',
href: '/dashboard/admin/documents',
icon: '📄',
description: 'Manage organization documents',
},
{
name: 'CSV Rates',
href: '/dashboard/admin/csv-rates',
icon: '📊',
description: 'Upload and manage CSV rates',
},
];
export default function AdminPanelDropdown() {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const pathname = usePathname();
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
// Close dropdown when route changes
useEffect(() => {
setIsOpen(false);
}, [pathname]);
const isActiveRoute = adminMenuItems.some(item => pathname.startsWith(item.href));
return (
<div className="relative" ref={dropdownRef}>
{/* Trigger Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className={`flex items-center w-full px-4 py-3 text-sm font-medium rounded-lg transition-colors ${
isActiveRoute
? 'bg-blue-50 text-blue-700'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
<span className="mr-3 text-xl"></span>
<span className="flex-1 text-left">Admin Panel</span>
<svg
className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{/* Dropdown Menu */}
{isOpen && (
<div className="absolute left-0 right-0 mt-2 bg-white rounded-lg shadow-lg border border-gray-200 z-50 overflow-hidden">
<div className="py-2">
{adminMenuItems.map(item => {
const isActive = pathname.startsWith(item.href);
return (
<Link
key={item.name}
href={item.href}
className={`flex items-start px-4 py-3 hover:bg-gray-50 transition-colors ${
isActive ? 'bg-blue-50' : ''
}`}
>
<span className="text-2xl mr-3 mt-0.5">{item.icon}</span>
<div className="flex-1">
<div className={`text-sm font-medium ${isActive ? 'text-blue-700' : 'text-gray-900'}`}>
{item.name}
</div>
<div className="text-xs text-gray-500 mt-0.5">
{item.description}
</div>
</div>
</Link>
);
})}
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,132 @@
/**
* Admin API
*
* Dedicated endpoints for admin-only operations that return ALL data from the database
* without organization filtering.
*
* All endpoints require ADMIN role.
*/
import { get, post, patch, del } from './client';
import type {
UserResponse,
UserListResponse,
OrganizationResponse,
OrganizationListResponse,
BookingResponse,
BookingListResponse,
UpdateUserRequest,
} from '@/types/api';
// ==================== USERS ====================
/**
* Get ALL users from database (admin only)
* GET /api/v1/admin/users
* Returns all users regardless of status or organization
* Requires: ADMIN role
*/
export async function getAllUsers(): Promise<UserListResponse> {
return get<UserListResponse>('/api/v1/admin/users');
}
/**
* Get user by ID (admin only)
* GET /api/v1/admin/users/:id
* Requires: ADMIN role
*/
export async function getAdminUser(id: string): Promise<UserResponse> {
return get<UserResponse>(`/api/v1/admin/users/${id}`);
}
/**
* Update user (admin only)
* PATCH /api/v1/admin/users/:id
* Can update any user from any organization
* Requires: ADMIN role
*/
export async function updateAdminUser(id: string, data: UpdateUserRequest): Promise<UserResponse> {
return patch<UserResponse>(`/api/v1/admin/users/${id}`, data);
}
/**
* Delete user (admin only)
* DELETE /api/v1/admin/users/:id
* Permanently deletes user from database
* Requires: ADMIN role
*/
export async function deleteAdminUser(id: string): Promise<void> {
return del<void>(`/api/v1/admin/users/${id}`);
}
// ==================== ORGANIZATIONS ====================
/**
* Get ALL organizations from database (admin only)
* GET /api/v1/admin/organizations
* Returns all organizations regardless of status
* Requires: ADMIN role
*/
export async function getAllOrganizations(): Promise<OrganizationListResponse> {
return get<OrganizationListResponse>('/api/v1/admin/organizations');
}
/**
* Get organization by ID (admin only)
* GET /api/v1/admin/organizations/:id
* Requires: ADMIN role
*/
export async function getAdminOrganization(id: string): Promise<OrganizationResponse> {
return get<OrganizationResponse>(`/api/v1/admin/organizations/${id}`);
}
// ==================== BOOKINGS ====================
/**
* Get ALL bookings from database (admin only)
* GET /api/v1/admin/bookings
* Returns all bookings from all organizations
* Requires: ADMIN role
*/
export async function getAllBookings(): Promise<BookingListResponse> {
return get<BookingListResponse>('/api/v1/admin/bookings');
}
/**
* Get booking by ID (admin only)
* GET /api/v1/admin/bookings/:id
* Requires: ADMIN role
*/
export async function getAdminBooking(id: string): Promise<BookingResponse> {
return get<BookingResponse>(`/api/v1/admin/bookings/${id}`);
}
// ==================== DOCUMENTS ====================
/**
* Get ALL documents from all organizations (admin only)
* GET /api/v1/admin/documents
* Returns documents grouped by organization
* Requires: ADMIN role
*/
export async function getAllDocuments(): Promise<{
documents: any[];
total: number;
organizationCount: number;
}> {
return get('/api/v1/admin/documents');
}
/**
* Get documents for a specific organization (admin only)
* GET /api/v1/admin/organizations/:id/documents
* Requires: ADMIN role
*/
export async function getOrganizationDocuments(organizationId: string): Promise<{
organizationId: string;
organizationName: string;
documents: any[];
total: number;
}> {
return get(`/api/v1/admin/organizations/${organizationId}/documents`);
}