fix bookings
This commit is contained in:
parent
bd81749c4a
commit
840ad49dcb
148
CLAUDE.md
148
CLAUDE.md
@ -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
|
||||
|
||||
|
||||
171
apps/backend/MINIO_SETUP_SUMMARY.md
Normal file
171
apps/backend/MINIO_SETUP_SUMMARY.md
Normal 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.
|
||||
106
apps/backend/delete-test-documents.js
Normal file
106
apps/backend/delete-test-documents.js
Normal 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);
|
||||
});
|
||||
90
apps/backend/fix-dummy-urls.js
Normal file
90
apps/backend/fix-dummy-urls.js
Normal 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);
|
||||
});
|
||||
81
apps/backend/fix-minio-hostname.js
Normal file
81
apps/backend/fix-minio-hostname.js
Normal 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);
|
||||
});
|
||||
92
apps/backend/list-minio-files.js
Normal file
92
apps/backend/list-minio-files.js
Normal 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);
|
||||
});
|
||||
176
apps/backend/restore-document-references.js
Normal file
176
apps/backend/restore-document-references.js
Normal 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);
|
||||
});
|
||||
79
apps/backend/set-bucket-policy.js
Normal file
79
apps/backend/set-bucket-policy.js
Normal 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);
|
||||
});
|
||||
@ -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: [
|
||||
|
||||
48
apps/backend/src/application/admin/admin.module.ts
Normal file
48
apps/backend/src/application/admin/admin.module.ts
Normal 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 {}
|
||||
595
apps/backend/src/application/controllers/admin.controller.ts
Normal file
595
apps/backend/src/application/controllers/admin.controller.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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}`);
|
||||
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
154
apps/backend/sync-database-with-minio.js
Normal file
154
apps/backend/sync-database-with-minio.js
Normal 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);
|
||||
});
|
||||
185
apps/backend/upload-test-documents.js
Normal file
185
apps/backend/upload-test-documents.js
Normal 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);
|
||||
});
|
||||
413
apps/frontend/app/dashboard/admin/bookings/page.tsx
Normal file
413
apps/frontend/app/dashboard/admin/bookings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
589
apps/frontend/app/dashboard/admin/documents/page.tsx
Normal file
589
apps/frontend/app/dashboard/admin/documents/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
421
apps/frontend/app/dashboard/admin/organizations/page.tsx
Normal file
421
apps/frontend/app/dashboard/admin/organizations/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
455
apps/frontend/app/dashboard/admin/users/page.tsx
Normal file
455
apps/frontend/app/dashboard/admin/users/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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 */}
|
||||
|
||||
135
apps/frontend/src/components/admin/AdminPanelDropdown.tsx
Normal file
135
apps/frontend/src/components/admin/AdminPanelDropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
132
apps/frontend/src/lib/api/admin.ts
Normal file
132
apps/frontend/src/lib/api/admin.ts
Normal 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`);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user