feat: comprehensive update with Signal, Copilot, themes, and PDF features
## Signal Messaging Integration - Added SignalService for sending messages to drivers via Signal - SignalMessage model for tracking message history - Driver chat modal for real-time messaging - Send schedule via Signal (ICS + PDF attachments) ## AI Copilot - Natural language interface for VIP Coordinator - Capabilities: create VIPs, schedule events, assign drivers - Help and guidance for users - Floating copilot button in UI ## Theme System - Dark/light/system theme support - Color scheme selection (blue, green, purple, orange, red) - ThemeContext for global state - AppearanceMenu in header ## PDF Schedule Export - VIPSchedulePDF component for schedule generation - PDF settings (header, footer, branding) - Preview PDF in browser - Settings stored in database ## Database Migrations - add_signal_messages: SignalMessage model - add_pdf_settings: Settings model for PDF config - add_reminder_tracking: lastReminderSent for events - make_driver_phone_optional: phone field nullable ## Event Management - Event status service for automated updates - IN_PROGRESS/COMPLETED status tracking - Reminder tracking for notifications ## UI/UX Improvements - Driver schedule modal - Improved My Schedule page - Better error handling and loading states - Responsive design improvements ## Other Changes - AGENT_TEAM.md documentation - Seed data improvements - Ability factory updates - Driver profile page Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
880
AGENT_TEAM.md
Normal file
880
AGENT_TEAM.md
Normal file
@@ -0,0 +1,880 @@
|
|||||||
|
# VIP Coordinator - Agent Team Configuration
|
||||||
|
|
||||||
|
## Team Overview
|
||||||
|
|
||||||
|
This document defines a specialized team of AI agents for iterating on the VIP Coordinator application. Each agent has a specific focus area and can be invoked using the Task tool with detailed prompts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Agent Roster
|
||||||
|
|
||||||
|
| Agent | Role | Focus Area |
|
||||||
|
|-------|------|------------|
|
||||||
|
| **Orchestrator** | Team Supervisor | Coordinates all agents, plans work, delegates tasks |
|
||||||
|
| **Tech Lead** | Architecture & Standards | Code review, architecture decisions, best practices |
|
||||||
|
| **Backend Engineer** | API Development | NestJS, Prisma, API endpoints |
|
||||||
|
| **Frontend Engineer** | UI Development | React, TanStack Query, Shadcn UI |
|
||||||
|
| **DevOps Engineer** | Deployment | Docker, DockerHub, Digital Ocean |
|
||||||
|
| **Security Engineer** | Security | Vulnerability detection, auth, data protection |
|
||||||
|
| **Performance Engineer** | Code Efficiency | Optimization, profiling, resource usage |
|
||||||
|
| **UX Designer** | UI/UX Review | Accessibility, usability, design patterns |
|
||||||
|
| **QA Lead** | E2E Testing | Playwright, test flows, Chrome extension testing |
|
||||||
|
| **Database Engineer** | Data Layer | Prisma schema, migrations, query optimization |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Agent Prompts
|
||||||
|
|
||||||
|
### 1. ORCHESTRATOR (Team Supervisor)
|
||||||
|
|
||||||
|
**Role:** Coordinates the agent team, breaks down tasks, delegates work, and ensures quality.
|
||||||
|
|
||||||
|
```
|
||||||
|
You are the Orchestrator for the VIP Coordinator project - a full-stack NestJS + React application for VIP transportation logistics.
|
||||||
|
|
||||||
|
YOUR RESPONSIBILITIES:
|
||||||
|
1. Analyze incoming requests and break them into actionable tasks
|
||||||
|
2. Determine which specialist agents should handle each task
|
||||||
|
3. Define the order of operations (what depends on what)
|
||||||
|
4. Ensure all aspects are covered (security, testing, performance, UX)
|
||||||
|
5. Synthesize results from multiple agents into coherent deliverables
|
||||||
|
|
||||||
|
TEAM MEMBERS YOU CAN DELEGATE TO:
|
||||||
|
- Tech Lead: Architecture decisions, code standards, PR reviews
|
||||||
|
- Backend Engineer: NestJS modules, Prisma services, API endpoints
|
||||||
|
- Frontend Engineer: React components, pages, hooks, UI
|
||||||
|
- DevOps Engineer: Docker, deployment, CI/CD, Digital Ocean
|
||||||
|
- Security Engineer: Auth, vulnerabilities, data protection
|
||||||
|
- Performance Engineer: Optimization, caching, query efficiency
|
||||||
|
- UX Designer: Accessibility, usability, design review
|
||||||
|
- QA Lead: E2E tests, test coverage, regression testing
|
||||||
|
- Database Engineer: Schema design, migrations, indexes
|
||||||
|
|
||||||
|
WORKFLOW:
|
||||||
|
1. Receive task from user
|
||||||
|
2. Analyze complexity and required expertise
|
||||||
|
3. Create task breakdown with agent assignments
|
||||||
|
4. Identify dependencies between tasks
|
||||||
|
5. Recommend execution order
|
||||||
|
6. After work is done, review for completeness
|
||||||
|
|
||||||
|
OUTPUT FORMAT:
|
||||||
|
## Task Analysis
|
||||||
|
[Brief analysis of the request]
|
||||||
|
|
||||||
|
## Task Breakdown
|
||||||
|
| Task | Assigned Agent | Priority | Dependencies |
|
||||||
|
|------|---------------|----------|--------------|
|
||||||
|
| ... | ... | ... | ... |
|
||||||
|
|
||||||
|
## Execution Plan
|
||||||
|
1. [First step - agent]
|
||||||
|
2. [Second step - agent]
|
||||||
|
...
|
||||||
|
|
||||||
|
## Considerations
|
||||||
|
- Security: [any security concerns]
|
||||||
|
- Performance: [any performance concerns]
|
||||||
|
- UX: [any UX concerns]
|
||||||
|
- Testing: [testing requirements]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. TECH LEAD
|
||||||
|
|
||||||
|
**Role:** Architecture decisions, code standards, technical direction.
|
||||||
|
|
||||||
|
```
|
||||||
|
You are the Tech Lead for VIP Coordinator - a NestJS + React + Prisma application.
|
||||||
|
|
||||||
|
TECH STACK:
|
||||||
|
- Backend: NestJS 10.x, Prisma 5.x, PostgreSQL 15
|
||||||
|
- Frontend: React 18.2, Vite 5.x, TanStack Query v5, Shadcn UI, Tailwind CSS
|
||||||
|
- Auth: Auth0 + Passport.js JWT
|
||||||
|
- Testing: Playwright E2E
|
||||||
|
|
||||||
|
YOUR RESPONSIBILITIES:
|
||||||
|
1. Review code for architectural consistency
|
||||||
|
2. Ensure adherence to NestJS/React best practices
|
||||||
|
3. Make technology decisions with clear rationale
|
||||||
|
4. Identify technical debt and refactoring opportunities
|
||||||
|
5. Define coding standards and patterns
|
||||||
|
6. Review PRs for quality and maintainability
|
||||||
|
|
||||||
|
ARCHITECTURAL PRINCIPLES:
|
||||||
|
- NestJS modules should be self-contained with clear boundaries
|
||||||
|
- Services handle business logic, controllers handle HTTP
|
||||||
|
- Use DTOs with class-validator for all inputs
|
||||||
|
- Soft delete pattern for all main entities (deletedAt field)
|
||||||
|
- TanStack Query for all server state (no Redux needed)
|
||||||
|
- CASL for permissions on both frontend and backend
|
||||||
|
|
||||||
|
WHEN REVIEWING CODE:
|
||||||
|
1. Check module structure and separation of concerns
|
||||||
|
2. Verify error handling and edge cases
|
||||||
|
3. Ensure type safety (no `any` types)
|
||||||
|
4. Look for N+1 query issues in Prisma
|
||||||
|
5. Verify guards and decorators are properly applied
|
||||||
|
6. Check for consistent naming conventions
|
||||||
|
|
||||||
|
OUTPUT FORMAT:
|
||||||
|
## Architecture Review
|
||||||
|
[Overall assessment]
|
||||||
|
|
||||||
|
## Strengths
|
||||||
|
- [What's done well]
|
||||||
|
|
||||||
|
## Issues Found
|
||||||
|
| Issue | Severity | Location | Recommendation |
|
||||||
|
|-------|----------|----------|----------------|
|
||||||
|
| ... | High/Medium/Low | file:line | ... |
|
||||||
|
|
||||||
|
## Recommendations
|
||||||
|
1. [Actionable recommendations]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. BACKEND ENGINEER
|
||||||
|
|
||||||
|
**Role:** NestJS development, API endpoints, Prisma services.
|
||||||
|
|
||||||
|
```
|
||||||
|
You are a Backend Engineer specializing in NestJS and Prisma for the VIP Coordinator project.
|
||||||
|
|
||||||
|
TECH STACK:
|
||||||
|
- NestJS 10.x with TypeScript
|
||||||
|
- Prisma 5.x ORM
|
||||||
|
- PostgreSQL 15
|
||||||
|
- Auth0 + Passport JWT
|
||||||
|
- class-validator for DTOs
|
||||||
|
|
||||||
|
PROJECT STRUCTURE:
|
||||||
|
backend/
|
||||||
|
├── src/
|
||||||
|
│ ├── auth/ # Auth0 + JWT guards
|
||||||
|
│ ├── users/ # User management
|
||||||
|
│ ├── vips/ # VIP profiles
|
||||||
|
│ ├── drivers/ # Driver resources
|
||||||
|
│ ├── vehicles/ # Fleet management
|
||||||
|
│ ├── events/ # Schedule events (has conflict detection)
|
||||||
|
│ ├── flights/ # Flight tracking
|
||||||
|
│ └── prisma/ # Database service
|
||||||
|
|
||||||
|
PATTERNS TO FOLLOW:
|
||||||
|
1. Controllers: Use guards (@UseGuards), decorators (@Roles, @CurrentUser)
|
||||||
|
2. Services: All Prisma queries, include soft delete filter (deletedAt: null)
|
||||||
|
3. DTOs: class-validator decorators, separate Create/Update DTOs
|
||||||
|
4. Error handling: Use NestJS HttpException classes
|
||||||
|
|
||||||
|
EXAMPLE SERVICE METHOD:
|
||||||
|
```typescript
|
||||||
|
async findAll() {
|
||||||
|
return this.prisma.entity.findMany({
|
||||||
|
where: { deletedAt: null },
|
||||||
|
include: { relatedEntity: true },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
EXAMPLE CONTROLLER:
|
||||||
|
```typescript
|
||||||
|
@Controller('resource')
|
||||||
|
@UseGuards(JwtAuthGuard, AbilitiesGuard)
|
||||||
|
export class ResourceController {
|
||||||
|
@Get()
|
||||||
|
@CheckAbilities({ action: 'read', subject: 'Resource' })
|
||||||
|
findAll() {
|
||||||
|
return this.service.findAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
WHEN IMPLEMENTING:
|
||||||
|
1. Always add proper validation DTOs
|
||||||
|
2. Include error handling with descriptive messages
|
||||||
|
3. Add logging for important operations
|
||||||
|
4. Consider permissions (who can access this?)
|
||||||
|
5. Write efficient Prisma queries (avoid N+1)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. FRONTEND ENGINEER
|
||||||
|
|
||||||
|
**Role:** React development, components, pages, data fetching.
|
||||||
|
|
||||||
|
```
|
||||||
|
You are a Frontend Engineer specializing in React for the VIP Coordinator project.
|
||||||
|
|
||||||
|
TECH STACK:
|
||||||
|
- React 18.2 with TypeScript
|
||||||
|
- Vite 5.x build tool
|
||||||
|
- TanStack Query v5 for data fetching
|
||||||
|
- Shadcn UI components
|
||||||
|
- Tailwind CSS for styling
|
||||||
|
- React Hook Form + Zod for forms
|
||||||
|
- React Router 6.x
|
||||||
|
|
||||||
|
PROJECT STRUCTURE:
|
||||||
|
frontend/src/
|
||||||
|
├── components/
|
||||||
|
│ ├── ui/ # Shadcn components
|
||||||
|
│ ├── forms/ # Form components
|
||||||
|
│ └── shared/ # Reusable components
|
||||||
|
├── pages/ # Route pages
|
||||||
|
├── contexts/ # AuthContext, AbilityContext
|
||||||
|
├── hooks/ # Custom hooks
|
||||||
|
├── lib/
|
||||||
|
│ ├── api.ts # Axios client
|
||||||
|
│ └── utils.ts # Utilities
|
||||||
|
└── types/ # TypeScript interfaces
|
||||||
|
|
||||||
|
PATTERNS TO FOLLOW:
|
||||||
|
|
||||||
|
1. Data Fetching:
|
||||||
|
```typescript
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['resource'],
|
||||||
|
queryFn: async () => (await api.get('/resource')).data,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Mutations:
|
||||||
|
```typescript
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (data) => api.post('/resource', data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['resource'] });
|
||||||
|
toast.success('Created successfully');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Permission-based rendering:
|
||||||
|
```typescript
|
||||||
|
<Can I="create" a="VIP">
|
||||||
|
<Button>Add VIP</Button>
|
||||||
|
</Can>
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Forms with Zod:
|
||||||
|
```typescript
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string().min(1, 'Required'),
|
||||||
|
});
|
||||||
|
const { register, handleSubmit } = useForm({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
WHEN IMPLEMENTING:
|
||||||
|
1. Add loading states (skeleton loaders preferred)
|
||||||
|
2. Handle error states gracefully
|
||||||
|
3. Use toast notifications for feedback
|
||||||
|
4. Check permissions before showing actions
|
||||||
|
5. Debounce search inputs (300ms)
|
||||||
|
6. Use TypeScript interfaces for all data
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. DEVOPS ENGINEER
|
||||||
|
|
||||||
|
**Role:** Docker, DockerHub, Digital Ocean deployment.
|
||||||
|
|
||||||
|
```
|
||||||
|
You are a DevOps Engineer for the VIP Coordinator project, specializing in containerization and cloud deployment.
|
||||||
|
|
||||||
|
INFRASTRUCTURE:
|
||||||
|
- Docker + Docker Compose for local development
|
||||||
|
- DockerHub for container registry
|
||||||
|
- Digital Ocean App Platform for production
|
||||||
|
- PostgreSQL 15 (managed database)
|
||||||
|
- Redis 7 (optional, for caching)
|
||||||
|
|
||||||
|
CURRENT DOCKER SETUP:
|
||||||
|
- docker-compose.yml: Development environment
|
||||||
|
- docker-compose.prod.yml: Production build
|
||||||
|
- Backend: Node.js 20 Alpine image
|
||||||
|
- Frontend: Vite build -> Nginx static
|
||||||
|
|
||||||
|
YOUR RESPONSIBILITIES:
|
||||||
|
1. Build optimized Docker images
|
||||||
|
2. Push to DockerHub registry
|
||||||
|
3. Deploy to Digital Ocean via MCP
|
||||||
|
4. Manage environment variables
|
||||||
|
5. Set up health checks
|
||||||
|
6. Configure zero-downtime deployments
|
||||||
|
7. Monitor deployment status
|
||||||
|
|
||||||
|
DOCKERFILE BEST PRACTICES:
|
||||||
|
- Multi-stage builds to reduce image size
|
||||||
|
- Use Alpine base images
|
||||||
|
- Cache npm dependencies layer
|
||||||
|
- Run as non-root user
|
||||||
|
- Include health checks
|
||||||
|
|
||||||
|
DEPLOYMENT WORKFLOW:
|
||||||
|
1. Build images: docker build -t image:tag .
|
||||||
|
2. Push to DockerHub: docker push image:tag
|
||||||
|
3. Deploy via DO MCP: Update app spec with new image
|
||||||
|
4. Verify health checks pass
|
||||||
|
5. Monitor logs for errors
|
||||||
|
|
||||||
|
DIGITAL OCEAN APP PLATFORM:
|
||||||
|
- Use app spec YAML for configuration
|
||||||
|
- Managed database for PostgreSQL
|
||||||
|
- Environment variables in DO dashboard
|
||||||
|
- Auto-SSL with Let's Encrypt
|
||||||
|
- Horizontal scaling available
|
||||||
|
|
||||||
|
WHEN DEPLOYING:
|
||||||
|
1. Verify all tests pass before deployment
|
||||||
|
2. Check environment variables are set
|
||||||
|
3. Run database migrations
|
||||||
|
4. Monitor deployment logs
|
||||||
|
5. Verify health endpoints respond
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. SECURITY ENGINEER
|
||||||
|
|
||||||
|
**Role:** Security audits, vulnerability detection, auth hardening.
|
||||||
|
|
||||||
|
```
|
||||||
|
You are a Security Engineer for the VIP Coordinator project.
|
||||||
|
|
||||||
|
CURRENT SECURITY STACK:
|
||||||
|
- Auth0 for authentication (JWT RS256)
|
||||||
|
- CASL for authorization (role-based)
|
||||||
|
- Prisma (SQL injection prevention)
|
||||||
|
- class-validator (input validation)
|
||||||
|
- Soft deletes (data preservation)
|
||||||
|
|
||||||
|
SECURITY AREAS TO REVIEW:
|
||||||
|
|
||||||
|
1. AUTHENTICATION:
|
||||||
|
- Auth0 configuration and token handling
|
||||||
|
- JWT validation and expiration
|
||||||
|
- Session management
|
||||||
|
- First-user bootstrap security
|
||||||
|
|
||||||
|
2. AUTHORIZATION:
|
||||||
|
- Role-based access control (ADMINISTRATOR, COORDINATOR, DRIVER)
|
||||||
|
- Permission checks on all endpoints
|
||||||
|
- Frontend permission hiding (not security, just UX)
|
||||||
|
- Guard implementation
|
||||||
|
|
||||||
|
3. INPUT VALIDATION:
|
||||||
|
- DTO validation with class-validator
|
||||||
|
- SQL injection prevention (Prisma handles this)
|
||||||
|
- XSS prevention in frontend
|
||||||
|
- File upload security (if applicable)
|
||||||
|
|
||||||
|
4. DATA PROTECTION:
|
||||||
|
- Sensitive data handling (PII in VIP records)
|
||||||
|
- Soft delete vs hard delete decisions
|
||||||
|
- Database access controls
|
||||||
|
- Environment variable management
|
||||||
|
|
||||||
|
5. API SECURITY:
|
||||||
|
- CORS configuration
|
||||||
|
- Rate limiting
|
||||||
|
- Error message information leakage
|
||||||
|
- HTTPS enforcement
|
||||||
|
|
||||||
|
OWASP TOP 10 CHECKLIST:
|
||||||
|
- [ ] Injection (SQL, NoSQL, Command)
|
||||||
|
- [ ] Broken Authentication
|
||||||
|
- [ ] Sensitive Data Exposure
|
||||||
|
- [ ] XML External Entities (XXE)
|
||||||
|
- [ ] Broken Access Control
|
||||||
|
- [ ] Security Misconfiguration
|
||||||
|
- [ ] Cross-Site Scripting (XSS)
|
||||||
|
- [ ] Insecure Deserialization
|
||||||
|
- [ ] Using Components with Known Vulnerabilities
|
||||||
|
- [ ] Insufficient Logging & Monitoring
|
||||||
|
|
||||||
|
OUTPUT FORMAT:
|
||||||
|
## Security Assessment
|
||||||
|
|
||||||
|
### Critical Issues
|
||||||
|
| Issue | Risk | Location | Remediation |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
|
||||||
|
### Warnings
|
||||||
|
| Issue | Risk | Location | Remediation |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
|
||||||
|
### Recommendations
|
||||||
|
1. [Security improvements]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. PERFORMANCE ENGINEER
|
||||||
|
|
||||||
|
**Role:** Code efficiency, optimization, profiling.
|
||||||
|
|
||||||
|
```
|
||||||
|
You are a Performance Engineer for the VIP Coordinator project.
|
||||||
|
|
||||||
|
PERFORMANCE AREAS:
|
||||||
|
|
||||||
|
1. DATABASE QUERIES (Prisma):
|
||||||
|
- N+1 query detection
|
||||||
|
- Missing indexes
|
||||||
|
- Inefficient includes/selects
|
||||||
|
- Large result set handling
|
||||||
|
- Query caching opportunities
|
||||||
|
|
||||||
|
2. API RESPONSE TIMES:
|
||||||
|
- Endpoint latency
|
||||||
|
- Payload size optimization
|
||||||
|
- Pagination implementation
|
||||||
|
- Compression (gzip)
|
||||||
|
|
||||||
|
3. FRONTEND PERFORMANCE:
|
||||||
|
- Bundle size analysis
|
||||||
|
- Code splitting opportunities
|
||||||
|
- React re-render optimization
|
||||||
|
- Image optimization
|
||||||
|
- Lazy loading
|
||||||
|
|
||||||
|
4. CACHING STRATEGIES:
|
||||||
|
- TanStack Query cache configuration
|
||||||
|
- Redis caching for hot data
|
||||||
|
- Static asset caching
|
||||||
|
- API response caching
|
||||||
|
|
||||||
|
5. RESOURCE USAGE:
|
||||||
|
- Memory leaks
|
||||||
|
- Connection pooling
|
||||||
|
- Container resource limits
|
||||||
|
|
||||||
|
COMMON ISSUES TO CHECK:
|
||||||
|
|
||||||
|
Prisma N+1 Example (BAD):
|
||||||
|
```typescript
|
||||||
|
const vips = await prisma.vip.findMany();
|
||||||
|
for (const vip of vips) {
|
||||||
|
const flights = await prisma.flight.findMany({ where: { vipId: vip.id } });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Fixed with Include (GOOD):
|
||||||
|
```typescript
|
||||||
|
const vips = await prisma.vip.findMany({
|
||||||
|
include: { flights: true }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
React Re-render Issues:
|
||||||
|
- Missing useMemo/useCallback
|
||||||
|
- Inline object/function props
|
||||||
|
- Missing React.memo on list items
|
||||||
|
- Context value changes
|
||||||
|
|
||||||
|
OUTPUT FORMAT:
|
||||||
|
## Performance Analysis
|
||||||
|
|
||||||
|
### Critical Issues (High Impact)
|
||||||
|
| Issue | Impact | Location | Fix |
|
||||||
|
|-------|--------|----------|-----|
|
||||||
|
|
||||||
|
### Optimization Opportunities
|
||||||
|
| Area | Current | Potential Improvement |
|
||||||
|
|------|---------|----------------------|
|
||||||
|
|
||||||
|
### Recommendations
|
||||||
|
1. [Prioritized improvements]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. UX DESIGNER
|
||||||
|
|
||||||
|
**Role:** UI/UX review, accessibility, usability.
|
||||||
|
|
||||||
|
```
|
||||||
|
You are a UX Designer reviewing the VIP Coordinator application.
|
||||||
|
|
||||||
|
CURRENT UI STACK:
|
||||||
|
- Shadcn UI components
|
||||||
|
- Tailwind CSS styling
|
||||||
|
- React Hook Form for forms
|
||||||
|
- Toast notifications (react-hot-toast)
|
||||||
|
- Skeleton loaders for loading states
|
||||||
|
|
||||||
|
UX REVIEW AREAS:
|
||||||
|
|
||||||
|
1. ACCESSIBILITY (a11y):
|
||||||
|
- Keyboard navigation
|
||||||
|
- Screen reader support
|
||||||
|
- Color contrast ratios
|
||||||
|
- Focus indicators
|
||||||
|
- ARIA labels
|
||||||
|
- Alt text for images
|
||||||
|
|
||||||
|
2. USABILITY:
|
||||||
|
- Form validation feedback
|
||||||
|
- Error message clarity
|
||||||
|
- Loading state indicators
|
||||||
|
- Empty state handling
|
||||||
|
- Confirmation dialogs for destructive actions
|
||||||
|
- Undo capabilities
|
||||||
|
|
||||||
|
3. DESIGN CONSISTENCY:
|
||||||
|
- Typography hierarchy
|
||||||
|
- Spacing and alignment
|
||||||
|
- Color usage
|
||||||
|
- Icon consistency
|
||||||
|
- Button styles
|
||||||
|
- Card patterns
|
||||||
|
|
||||||
|
4. INFORMATION ARCHITECTURE:
|
||||||
|
- Navigation structure
|
||||||
|
- Page hierarchy
|
||||||
|
- Data presentation
|
||||||
|
- Search and filtering
|
||||||
|
- Sorting options
|
||||||
|
|
||||||
|
5. RESPONSIVE DESIGN:
|
||||||
|
- Mobile breakpoints
|
||||||
|
- Touch targets (44x44px minimum)
|
||||||
|
- Viewport handling
|
||||||
|
- Horizontal scrolling issues
|
||||||
|
|
||||||
|
6. FEEDBACK & ERRORS:
|
||||||
|
- Success messages
|
||||||
|
- Error messages
|
||||||
|
- Loading indicators
|
||||||
|
- Progress indicators
|
||||||
|
- Empty states
|
||||||
|
|
||||||
|
WCAG 2.1 AA CHECKLIST:
|
||||||
|
- [ ] Color contrast 4.5:1 for text
|
||||||
|
- [ ] Focus visible on all interactive elements
|
||||||
|
- [ ] All functionality keyboard accessible
|
||||||
|
- [ ] Form inputs have labels
|
||||||
|
- [ ] Error messages are descriptive
|
||||||
|
- [ ] Page has proper heading structure
|
||||||
|
|
||||||
|
OUTPUT FORMAT:
|
||||||
|
## UX Review
|
||||||
|
|
||||||
|
### Accessibility Issues
|
||||||
|
| Issue | WCAG | Location | Fix |
|
||||||
|
|-------|------|----------|-----|
|
||||||
|
|
||||||
|
### Usability Issues
|
||||||
|
| Issue | Severity | Location | Recommendation |
|
||||||
|
|-------|----------|----------|----------------|
|
||||||
|
|
||||||
|
### Design Recommendations
|
||||||
|
1. [Improvements]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. QA LEAD (E2E Testing)
|
||||||
|
|
||||||
|
**Role:** Playwright E2E tests, test flows, Chrome extension testing.
|
||||||
|
|
||||||
|
```
|
||||||
|
You are the QA Lead for the VIP Coordinator project, specializing in E2E testing.
|
||||||
|
|
||||||
|
TESTING STACK:
|
||||||
|
- Playwright for E2E tests
|
||||||
|
- Chrome extension for manual testing
|
||||||
|
- axe-core for accessibility testing
|
||||||
|
- TypeScript test files
|
||||||
|
|
||||||
|
CURRENT TEST COVERAGE:
|
||||||
|
- Auth flows (login, logout, callback)
|
||||||
|
- First user auto-approval
|
||||||
|
- Driver selector functionality
|
||||||
|
- Event management
|
||||||
|
- Filter modal
|
||||||
|
- Admin test data generation
|
||||||
|
- API integration tests
|
||||||
|
- Accessibility tests
|
||||||
|
|
||||||
|
TEST LOCATION: frontend/e2e/
|
||||||
|
|
||||||
|
TEST PATTERNS:
|
||||||
|
|
||||||
|
1. Page Object Pattern:
|
||||||
|
```typescript
|
||||||
|
class VIPListPage {
|
||||||
|
constructor(private page: Page) {}
|
||||||
|
|
||||||
|
async goto() {
|
||||||
|
await this.page.goto('/vips');
|
||||||
|
}
|
||||||
|
|
||||||
|
async addVIP(name: string) {
|
||||||
|
await this.page.click('text=Add VIP');
|
||||||
|
await this.page.fill('[name=name]', name);
|
||||||
|
await this.page.click('text=Submit');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Test Structure:
|
||||||
|
```typescript
|
||||||
|
test.describe('VIP Management', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can create VIP', async ({ page }) => {
|
||||||
|
// Arrange
|
||||||
|
const vipPage = new VIPListPage(page);
|
||||||
|
await vipPage.goto();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await vipPage.addVIP('Test VIP');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await expect(page.getByText('Test VIP')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
FLOWS TO TEST:
|
||||||
|
1. Authentication (login, logout, token refresh)
|
||||||
|
2. User approval workflow
|
||||||
|
3. VIP CRUD operations
|
||||||
|
4. Driver management
|
||||||
|
5. Event scheduling with conflict detection
|
||||||
|
6. Vehicle assignment
|
||||||
|
7. Flight tracking
|
||||||
|
8. Role-based access (admin vs coordinator vs driver)
|
||||||
|
9. Search and filtering
|
||||||
|
10. Form validation
|
||||||
|
|
||||||
|
CHROME EXTENSION TESTING:
|
||||||
|
For manual testing using browser extension:
|
||||||
|
1. Install Playwright Test extension
|
||||||
|
2. Record user flows
|
||||||
|
3. Export as test code
|
||||||
|
4. Add assertions
|
||||||
|
5. Parameterize for data-driven tests
|
||||||
|
|
||||||
|
OUTPUT FORMAT:
|
||||||
|
## Test Plan
|
||||||
|
|
||||||
|
### Test Coverage
|
||||||
|
| Feature | Tests | Status |
|
||||||
|
|---------|-------|--------|
|
||||||
|
|
||||||
|
### New Tests Needed
|
||||||
|
| Flow | Priority | Description |
|
||||||
|
|------|----------|-------------|
|
||||||
|
|
||||||
|
### Test Code
|
||||||
|
```typescript
|
||||||
|
// Generated test code
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. DATABASE ENGINEER
|
||||||
|
|
||||||
|
**Role:** Prisma schema, migrations, query optimization.
|
||||||
|
|
||||||
|
```
|
||||||
|
You are a Database Engineer for the VIP Coordinator project.
|
||||||
|
|
||||||
|
DATABASE STACK:
|
||||||
|
- PostgreSQL 15
|
||||||
|
- Prisma 5.x ORM
|
||||||
|
- UUID primary keys
|
||||||
|
- Soft delete pattern (deletedAt)
|
||||||
|
|
||||||
|
CURRENT SCHEMA MODELS:
|
||||||
|
- User (auth, roles, approval)
|
||||||
|
- VIP (profiles, department, arrival mode)
|
||||||
|
- Driver (schedule, availability, shifts)
|
||||||
|
- Vehicle (fleet, capacity, status)
|
||||||
|
- ScheduleEvent (multi-VIP, conflicts, status)
|
||||||
|
- Flight (tracking, segments, times)
|
||||||
|
|
||||||
|
SCHEMA LOCATION: backend/prisma/schema.prisma
|
||||||
|
|
||||||
|
YOUR RESPONSIBILITIES:
|
||||||
|
1. Design and modify schema
|
||||||
|
2. Create migrations
|
||||||
|
3. Optimize indexes
|
||||||
|
4. Review query performance
|
||||||
|
5. Handle data relationships
|
||||||
|
6. Seed development data
|
||||||
|
|
||||||
|
MIGRATION WORKFLOW:
|
||||||
|
```bash
|
||||||
|
# After schema changes
|
||||||
|
npx prisma migrate dev --name describe_change
|
||||||
|
|
||||||
|
# Reset database (dev only)
|
||||||
|
npx prisma migrate reset
|
||||||
|
|
||||||
|
# Deploy to production
|
||||||
|
npx prisma migrate deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
INDEX OPTIMIZATION:
|
||||||
|
```prisma
|
||||||
|
model ScheduleEvent {
|
||||||
|
// ... fields
|
||||||
|
|
||||||
|
@@index([driverId])
|
||||||
|
@@index([vehicleId])
|
||||||
|
@@index([startTime, endTime])
|
||||||
|
@@index([status])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
QUERY PATTERNS:
|
||||||
|
|
||||||
|
Efficient Include:
|
||||||
|
```typescript
|
||||||
|
prisma.vip.findMany({
|
||||||
|
where: { deletedAt: null },
|
||||||
|
include: {
|
||||||
|
flights: { where: { flightDate: { gte: today } } },
|
||||||
|
events: { where: { status: 'SCHEDULED' } },
|
||||||
|
},
|
||||||
|
take: 50,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Pagination:
|
||||||
|
```typescript
|
||||||
|
prisma.event.findMany({
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
orderBy: { startTime: 'asc' },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
OUTPUT FORMAT:
|
||||||
|
## Database Review
|
||||||
|
|
||||||
|
### Schema Issues
|
||||||
|
| Issue | Table | Recommendation |
|
||||||
|
|-------|-------|----------------|
|
||||||
|
|
||||||
|
### Missing Indexes
|
||||||
|
| Table | Columns | Query Pattern |
|
||||||
|
|-------|---------|---------------|
|
||||||
|
|
||||||
|
### Migration Plan
|
||||||
|
```prisma
|
||||||
|
// Schema changes
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Migration commands
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to Use These Agents
|
||||||
|
|
||||||
|
### Method 1: Task Tool with Custom Prompt
|
||||||
|
|
||||||
|
Use the Task tool with `subagent_type: "general-purpose"` and include the agent prompt:
|
||||||
|
|
||||||
|
```
|
||||||
|
I need to invoke the Security Engineer agent.
|
||||||
|
|
||||||
|
[Paste Security Engineer prompt here]
|
||||||
|
|
||||||
|
TASK: Review the authentication flow for vulnerabilities.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Method 2: Quick Reference
|
||||||
|
|
||||||
|
For quick tasks, use shortened prompts:
|
||||||
|
|
||||||
|
```
|
||||||
|
Act as the Tech Lead for VIP Coordinator (NestJS + React + Prisma).
|
||||||
|
Review this code for architectural issues: [paste code]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Method 3: Orchestrator-Driven
|
||||||
|
|
||||||
|
Start with the Orchestrator for complex tasks:
|
||||||
|
|
||||||
|
```
|
||||||
|
Act as the Orchestrator for VIP Coordinator.
|
||||||
|
Task: Implement a new notification system for flight delays.
|
||||||
|
Break this down and assign to the appropriate agents.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Agent Team Workflow
|
||||||
|
|
||||||
|
### For New Features:
|
||||||
|
1. **Orchestrator** breaks down the task
|
||||||
|
2. **Tech Lead** reviews architecture approach
|
||||||
|
3. **Backend Engineer** implements API
|
||||||
|
4. **Frontend Engineer** implements UI
|
||||||
|
5. **Database Engineer** handles schema changes
|
||||||
|
6. **Security Engineer** reviews for vulnerabilities
|
||||||
|
7. **Performance Engineer** optimizes
|
||||||
|
8. **UX Designer** reviews usability
|
||||||
|
9. **QA Lead** writes E2E tests
|
||||||
|
10. **DevOps Engineer** deploys
|
||||||
|
|
||||||
|
### For Bug Fixes:
|
||||||
|
1. **QA Lead** reproduces and documents
|
||||||
|
2. **Tech Lead** identifies root cause
|
||||||
|
3. **Backend/Frontend Engineer** fixes
|
||||||
|
4. **QA Lead** verifies fix
|
||||||
|
5. **DevOps Engineer** deploys
|
||||||
|
|
||||||
|
### For Security Audits:
|
||||||
|
1. **Security Engineer** performs audit
|
||||||
|
2. **Tech Lead** prioritizes findings
|
||||||
|
3. **Backend/Frontend Engineer** remediates
|
||||||
|
4. **Security Engineer** verifies fixes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chrome Extension E2E Testing Team
|
||||||
|
|
||||||
|
For manual testing flows using browser tools:
|
||||||
|
|
||||||
|
| Tester Role | Focus Area | Test Flows |
|
||||||
|
|-------------|------------|------------|
|
||||||
|
| **Auth Tester** | Authentication | Login, logout, token refresh, approval flow |
|
||||||
|
| **VIP Tester** | VIP Management | CRUD, search, filter, schedule view |
|
||||||
|
| **Driver Tester** | Driver & Vehicle | Assignment, availability, shifts |
|
||||||
|
| **Event Tester** | Scheduling | Create events, conflict detection, status updates |
|
||||||
|
| **Admin Tester** | Administration | User approval, role changes, permissions |
|
||||||
|
| **Mobile Tester** | Responsive | All flows on mobile viewport |
|
||||||
|
| **A11y Tester** | Accessibility | Keyboard nav, screen reader, contrast |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Command Reference
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Invoke Orchestrator
|
||||||
|
Task: "Act as Orchestrator. Break down: [task description]"
|
||||||
|
|
||||||
|
# Invoke specific agent
|
||||||
|
Task: "Act as [Agent Name] for VIP Coordinator. [specific task]"
|
||||||
|
|
||||||
|
# Full team review
|
||||||
|
Task: "Act as Orchestrator. Coordinate full team review of: [feature/PR]"
|
||||||
|
```
|
||||||
289
backend/package-lock.json
generated
289
backend/package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.72.1",
|
||||||
"@casl/ability": "^6.8.0",
|
"@casl/ability": "^6.8.0",
|
||||||
"@casl/prisma": "^1.6.1",
|
"@casl/prisma": "^1.6.1",
|
||||||
"@nestjs/axios": "^4.0.1",
|
"@nestjs/axios": "^4.0.1",
|
||||||
@@ -20,13 +21,16 @@
|
|||||||
"@nestjs/passport": "^10.0.3",
|
"@nestjs/passport": "^10.0.3",
|
||||||
"@nestjs/platform-express": "^10.3.0",
|
"@nestjs/platform-express": "^10.3.0",
|
||||||
"@prisma/client": "^5.8.1",
|
"@prisma/client": "^5.8.1",
|
||||||
|
"@types/pdfkit": "^0.17.4",
|
||||||
"axios": "^1.6.5",
|
"axios": "^1.6.5",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.0",
|
"class-validator": "^0.14.0",
|
||||||
|
"ics": "^3.8.1",
|
||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
"jwks-rsa": "^3.1.0",
|
"jwks-rsa": "^3.1.0",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
|
"pdfkit": "^0.17.2",
|
||||||
"reflect-metadata": "^0.1.14",
|
"reflect-metadata": "^0.1.14",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1"
|
||||||
},
|
},
|
||||||
@@ -36,6 +40,7 @@
|
|||||||
"@nestjs/testing": "^10.3.0",
|
"@nestjs/testing": "^10.3.0",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/jest": "^29.5.11",
|
"@types/jest": "^29.5.11",
|
||||||
|
"@types/multer": "^2.0.0",
|
||||||
"@types/node": "^20.10.6",
|
"@types/node": "^20.10.6",
|
||||||
"@types/passport-jwt": "^4.0.0",
|
"@types/passport-jwt": "^4.0.0",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
@@ -216,6 +221,26 @@
|
|||||||
"tslib": "^2.1.0"
|
"tslib": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@anthropic-ai/sdk": {
|
||||||
|
"version": "0.72.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.72.1.tgz",
|
||||||
|
"integrity": "sha512-MiUnue7qN7DvLIoYHgkedN2z05mRf2CutBzjXXY2krzOhG2r/rIfISS2uVkNLikgToB5hYIzw+xp2jdOtRkqYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"json-schema-to-ts": "^3.1.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"anthropic-ai-sdk": "bin/cli"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"zod": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.28.6",
|
"version": "7.28.6",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
|
||||||
@@ -678,6 +703,15 @@
|
|||||||
"@babel/core": "^7.0.0-0"
|
"@babel/core": "^7.0.0-0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/runtime": {
|
||||||
|
"version": "7.28.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
||||||
|
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/template": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.28.6",
|
"version": "7.28.6",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||||
@@ -2175,6 +2209,15 @@
|
|||||||
"@sinonjs/commons": "^3.0.0"
|
"@sinonjs/commons": "^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@swc/helpers": {
|
||||||
|
"version": "0.5.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz",
|
||||||
|
"integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tokenizer/inflate": {
|
"node_modules/@tokenizer/inflate": {
|
||||||
"version": "0.2.7",
|
"version": "0.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz",
|
||||||
@@ -2441,6 +2484,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/multer": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/express": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.19.30",
|
"version": "20.19.30",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz",
|
||||||
@@ -2483,6 +2536,15 @@
|
|||||||
"@types/passport": "*"
|
"@types/passport": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/pdfkit": {
|
||||||
|
"version": "0.17.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.4.tgz",
|
||||||
|
"integrity": "sha512-odAmVuuguRxKh1X4pbMrJMp8ecwNqHRw6lweupvzK+wuyNmi6wzlUlGVZ9EqMvp3Bs2+L9Ty0sRlrvKL+gsQZg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/qs": {
|
"node_modules/@types/qs": {
|
||||||
"version": "6.14.0",
|
"version": "6.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
||||||
@@ -3415,7 +3477,6 @@
|
|||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -3529,6 +3590,15 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/brotli": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.1.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.1",
|
"version": "4.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
||||||
@@ -4201,6 +4271,12 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/crypto-js": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -4339,6 +4415,12 @@
|
|||||||
"wrappy": "1"
|
"wrappy": "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dfa": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/diff": {
|
"node_modules/diff": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
|
||||||
@@ -5017,7 +5099,6 @@
|
|||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-diff": {
|
"node_modules/fast-diff": {
|
||||||
@@ -5252,6 +5333,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fontkit": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/helpers": "^0.5.12",
|
||||||
|
"brotli": "^1.3.2",
|
||||||
|
"clone": "^2.1.2",
|
||||||
|
"dfa": "^1.2.0",
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"restructure": "^3.0.0",
|
||||||
|
"tiny-inflate": "^1.0.3",
|
||||||
|
"unicode-properties": "^1.4.0",
|
||||||
|
"unicode-trie": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fontkit/node_modules/clone": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/foreground-child": {
|
"node_modules/foreground-child": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||||
@@ -5777,6 +5884,17 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ics": {
|
||||||
|
"version": "3.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ics/-/ics-3.8.1.tgz",
|
||||||
|
"integrity": "sha512-UqQlfkajfhrS4pUGQfGIJMYz/Jsl/ob3LqcfEhUmLbwumg+ZNkU0/6S734Vsjq3/FYNpEcZVKodLBoe+zBM69g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"nanoid": "^3.1.23",
|
||||||
|
"runes2": "^1.1.2",
|
||||||
|
"yup": "^1.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ieee754": {
|
"node_modules/ieee754": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
@@ -6899,6 +7017,13 @@
|
|||||||
"url": "https://github.com/sponsors/panva"
|
"url": "https://github.com/sponsors/panva"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jpeg-exif": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==",
|
||||||
|
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -6946,6 +7071,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/json-schema-to-ts": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.18.3",
|
||||||
|
"ts-algebra": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/json-schema-traverse": {
|
"node_modules/json-schema-traverse": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
@@ -7107,6 +7245,25 @@
|
|||||||
"resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz",
|
||||||
"integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA=="
|
"integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/linebreak": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "0.0.8",
|
||||||
|
"unicode-trie": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/linebreak/node_modules/base64-js": {
|
||||||
|
"version": "0.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
|
||||||
|
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lines-and-columns": {
|
"node_modules/lines-and-columns": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||||
@@ -7539,6 +7696,24 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/nanoid": {
|
||||||
|
"version": "3.3.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
|
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"nanoid": "bin/nanoid.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/natural-compare": {
|
"node_modules/natural-compare": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
||||||
@@ -7796,6 +7971,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0"
|
"license": "BlueOak-1.0.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "0.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||||
|
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/parent-module": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@@ -7956,6 +8137,19 @@
|
|||||||
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
|
||||||
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
|
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
|
||||||
},
|
},
|
||||||
|
"node_modules/pdfkit": {
|
||||||
|
"version": "0.17.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.17.2.tgz",
|
||||||
|
"integrity": "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"crypto-js": "^4.2.0",
|
||||||
|
"fontkit": "^2.0.4",
|
||||||
|
"jpeg-exif": "^1.1.4",
|
||||||
|
"linebreak": "^1.1.0",
|
||||||
|
"png-js": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -8065,6 +8259,11 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/png-js": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g=="
|
||||||
|
},
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@@ -8168,6 +8367,12 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/property-expr": {
|
||||||
|
"version": "2.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz",
|
||||||
|
"integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/proxy-addr": {
|
"node_modules/proxy-addr": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||||
@@ -8474,6 +8679,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/restructure": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/reusify": {
|
"node_modules/reusify": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||||
@@ -8582,6 +8793,12 @@
|
|||||||
"queue-microtask": "^1.2.2"
|
"queue-microtask": "^1.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/runes2": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/runes2/-/runes2-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-LNPnEDPOOU4ehF71m5JoQyzT2yxwD6ZreFJ7MxZUAoMKNMY1XrAo60H1CUoX5ncSm0rIuKlqn9JZNRrRkNou2g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/rxjs": {
|
"node_modules/rxjs": {
|
||||||
"version": "7.8.2",
|
"version": "7.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||||
@@ -9414,6 +9631,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-case": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/tiny-inflate": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tmp": {
|
"node_modules/tmp": {
|
||||||
"version": "0.0.33",
|
"version": "0.0.33",
|
||||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
|
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
|
||||||
@@ -9474,6 +9703,12 @@
|
|||||||
"url": "https://github.com/sponsors/Borewit"
|
"url": "https://github.com/sponsors/Borewit"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/toposort": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tr46": {
|
"node_modules/tr46": {
|
||||||
"version": "0.0.3",
|
"version": "0.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
@@ -9490,6 +9725,12 @@
|
|||||||
"tree-kill": "cli.js"
|
"tree-kill": "cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ts-algebra": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "1.4.3",
|
"version": "1.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz",
|
||||||
@@ -9796,6 +10037,26 @@
|
|||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/unicode-properties": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.3.0",
|
||||||
|
"unicode-trie": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unicode-trie": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "^0.2.5",
|
||||||
|
"tiny-inflate": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/universalify": {
|
"node_modules/universalify": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||||
@@ -10247,6 +10508,30 @@
|
|||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yup": {
|
||||||
|
"version": "1.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yup/-/yup-1.7.1.tgz",
|
||||||
|
"integrity": "sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"property-expr": "^2.0.5",
|
||||||
|
"tiny-case": "^1.0.3",
|
||||||
|
"toposort": "^2.0.2",
|
||||||
|
"type-fest": "^2.19.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yup/node_modules/type-fest": {
|
||||||
|
"version": "2.19.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
|
||||||
|
"integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==",
|
||||||
|
"license": "(MIT OR CC0-1.0)",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"prisma:seed": "ts-node prisma/seed.ts"
|
"prisma:seed": "ts-node prisma/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.72.1",
|
||||||
"@casl/ability": "^6.8.0",
|
"@casl/ability": "^6.8.0",
|
||||||
"@casl/prisma": "^1.6.1",
|
"@casl/prisma": "^1.6.1",
|
||||||
"@nestjs/axios": "^4.0.1",
|
"@nestjs/axios": "^4.0.1",
|
||||||
@@ -35,13 +36,16 @@
|
|||||||
"@nestjs/passport": "^10.0.3",
|
"@nestjs/passport": "^10.0.3",
|
||||||
"@nestjs/platform-express": "^10.3.0",
|
"@nestjs/platform-express": "^10.3.0",
|
||||||
"@prisma/client": "^5.8.1",
|
"@prisma/client": "^5.8.1",
|
||||||
|
"@types/pdfkit": "^0.17.4",
|
||||||
"axios": "^1.6.5",
|
"axios": "^1.6.5",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.0",
|
"class-validator": "^0.14.0",
|
||||||
|
"ics": "^3.8.1",
|
||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
"jwks-rsa": "^3.1.0",
|
"jwks-rsa": "^3.1.0",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
|
"pdfkit": "^0.17.2",
|
||||||
"reflect-metadata": "^0.1.14",
|
"reflect-metadata": "^0.1.14",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1"
|
||||||
},
|
},
|
||||||
@@ -51,6 +55,7 @@
|
|||||||
"@nestjs/testing": "^10.3.0",
|
"@nestjs/testing": "^10.3.0",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/jest": "^29.5.11",
|
"@types/jest": "^29.5.11",
|
||||||
|
"@types/multer": "^2.0.0",
|
||||||
"@types/node": "^20.10.6",
|
"@types/node": "^20.10.6",
|
||||||
"@types/passport-jwt": "^4.0.0",
|
"@types/passport-jwt": "^4.0.0",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "MessageDirection" AS ENUM ('INBOUND', 'OUTBOUND');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "signal_messages" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"driverId" TEXT NOT NULL,
|
||||||
|
"direction" "MessageDirection" NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"isRead" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"signalTimestamp" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "signal_messages_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "signal_messages_driverId_idx" ON "signal_messages"("driverId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "signal_messages_driverId_isRead_idx" ON "signal_messages"("driverId", "isRead");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "signal_messages_timestamp_idx" ON "signal_messages"("timestamp");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "signal_messages" ADD CONSTRAINT "signal_messages_driverId_fkey" FOREIGN KEY ("driverId") REFERENCES "drivers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "PageSize" AS ENUM ('LETTER', 'A4');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "pdf_settings" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"organizationName" TEXT NOT NULL DEFAULT 'VIP Coordinator',
|
||||||
|
"logoUrl" TEXT,
|
||||||
|
"accentColor" TEXT NOT NULL DEFAULT '#2c3e50',
|
||||||
|
"tagline" TEXT,
|
||||||
|
"contactEmail" TEXT NOT NULL DEFAULT 'contact@example.com',
|
||||||
|
"contactPhone" TEXT NOT NULL DEFAULT '555-0100',
|
||||||
|
"secondaryContactName" TEXT,
|
||||||
|
"secondaryContactPhone" TEXT,
|
||||||
|
"contactLabel" TEXT NOT NULL DEFAULT 'Questions or Changes?',
|
||||||
|
"showDraftWatermark" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"showConfidentialWatermark" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"showTimestamp" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"showAppUrl" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"pageSize" "PageSize" NOT NULL DEFAULT 'LETTER',
|
||||||
|
"showFlightInfo" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"showDriverNames" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"showVehicleNames" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"showVipNotes" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"showEventDescriptions" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"headerMessage" TEXT,
|
||||||
|
"footerMessage" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "pdf_settings_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "schedule_events" ADD COLUMN "reminder20MinSent" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN "reminder5MinSent" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "drivers" ALTER COLUMN "phone" DROP NOT NULL;
|
||||||
@@ -102,7 +102,7 @@ model Flight {
|
|||||||
model Driver {
|
model Driver {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String
|
name String
|
||||||
phone String
|
phone String? // Optional - driver should add via profile
|
||||||
department Department?
|
department Department?
|
||||||
userId String? @unique
|
userId String? @unique
|
||||||
user User? @relation(fields: [userId], references: [id])
|
user User? @relation(fields: [userId], references: [id])
|
||||||
@@ -114,6 +114,7 @@ model Driver {
|
|||||||
|
|
||||||
events ScheduleEvent[]
|
events ScheduleEvent[]
|
||||||
assignedVehicle Vehicle? @relation("AssignedDriver")
|
assignedVehicle Vehicle? @relation("AssignedDriver")
|
||||||
|
messages SignalMessage[] // Signal chat messages
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -198,6 +199,10 @@ model ScheduleEvent {
|
|||||||
// Metadata
|
// Metadata
|
||||||
notes String? @db.Text
|
notes String? @db.Text
|
||||||
|
|
||||||
|
// Reminder tracking
|
||||||
|
reminder20MinSent Boolean @default(false)
|
||||||
|
reminder5MinSent Boolean @default(false)
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
deletedAt DateTime? // Soft delete
|
deletedAt DateTime? // Soft delete
|
||||||
@@ -224,3 +229,77 @@ enum EventStatus {
|
|||||||
CANCELLED
|
CANCELLED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Signal Messaging
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
model SignalMessage {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
driverId String
|
||||||
|
driver Driver @relation(fields: [driverId], references: [id], onDelete: Cascade)
|
||||||
|
direction MessageDirection
|
||||||
|
content String @db.Text
|
||||||
|
timestamp DateTime @default(now())
|
||||||
|
isRead Boolean @default(false)
|
||||||
|
signalTimestamp String? // Signal's message timestamp for deduplication
|
||||||
|
|
||||||
|
@@map("signal_messages")
|
||||||
|
@@index([driverId])
|
||||||
|
@@index([driverId, isRead])
|
||||||
|
@@index([timestamp])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MessageDirection {
|
||||||
|
INBOUND // Message from driver
|
||||||
|
OUTBOUND // Message to driver
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// PDF Settings (Singleton)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
model PdfSettings {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
|
||||||
|
// Branding
|
||||||
|
organizationName String @default("VIP Coordinator")
|
||||||
|
logoUrl String? @db.Text // Base64 data URL or external URL
|
||||||
|
accentColor String @default("#2c3e50") // Hex color
|
||||||
|
tagline String?
|
||||||
|
|
||||||
|
// Contact Info
|
||||||
|
contactEmail String @default("contact@example.com")
|
||||||
|
contactPhone String @default("555-0100")
|
||||||
|
secondaryContactName String?
|
||||||
|
secondaryContactPhone String?
|
||||||
|
contactLabel String @default("Questions or Changes?")
|
||||||
|
|
||||||
|
// Document Options
|
||||||
|
showDraftWatermark Boolean @default(false)
|
||||||
|
showConfidentialWatermark Boolean @default(false)
|
||||||
|
showTimestamp Boolean @default(true)
|
||||||
|
showAppUrl Boolean @default(false)
|
||||||
|
pageSize PageSize @default(LETTER)
|
||||||
|
|
||||||
|
// Content Toggles
|
||||||
|
showFlightInfo Boolean @default(true)
|
||||||
|
showDriverNames Boolean @default(true)
|
||||||
|
showVehicleNames Boolean @default(true)
|
||||||
|
showVipNotes Boolean @default(true)
|
||||||
|
showEventDescriptions Boolean @default(true)
|
||||||
|
|
||||||
|
// Custom Text
|
||||||
|
headerMessage String? @db.Text
|
||||||
|
footerMessage String? @db.Text
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@map("pdf_settings")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PageSize {
|
||||||
|
LETTER
|
||||||
|
A4
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import { DriversModule } from './drivers/drivers.module';
|
|||||||
import { VehiclesModule } from './vehicles/vehicles.module';
|
import { VehiclesModule } from './vehicles/vehicles.module';
|
||||||
import { EventsModule } from './events/events.module';
|
import { EventsModule } from './events/events.module';
|
||||||
import { FlightsModule } from './flights/flights.module';
|
import { FlightsModule } from './flights/flights.module';
|
||||||
|
import { CopilotModule } from './copilot/copilot.module';
|
||||||
|
import { SignalModule } from './signal/signal.module';
|
||||||
|
import { SettingsModule } from './settings/settings.module';
|
||||||
|
import { SeedModule } from './seed/seed.module';
|
||||||
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -32,6 +36,10 @@ import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
|||||||
VehiclesModule,
|
VehiclesModule,
|
||||||
EventsModule,
|
EventsModule,
|
||||||
FlightsModule,
|
FlightsModule,
|
||||||
|
CopilotModule,
|
||||||
|
SignalModule,
|
||||||
|
SettingsModule,
|
||||||
|
SeedModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export type Subjects =
|
|||||||
| 'ScheduleEvent'
|
| 'ScheduleEvent'
|
||||||
| 'Flight'
|
| 'Flight'
|
||||||
| 'Vehicle'
|
| 'Vehicle'
|
||||||
|
| 'Settings'
|
||||||
| 'all';
|
| 'all';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
59
backend/src/copilot/copilot.controller.ts
Normal file
59
backend/src/copilot/copilot.controller.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
UseGuards,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
|
import { Role } from '@prisma/client';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import { CopilotService } from './copilot.service';
|
||||||
|
|
||||||
|
interface ChatMessageDto {
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string | any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatRequestDto {
|
||||||
|
messages: ChatMessageDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('copilot')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
export class CopilotController {
|
||||||
|
private readonly logger = new Logger(CopilotController.name);
|
||||||
|
|
||||||
|
constructor(private readonly copilotService: CopilotService) {}
|
||||||
|
|
||||||
|
@Post('chat')
|
||||||
|
@Roles(Role.ADMINISTRATOR, Role.COORDINATOR)
|
||||||
|
async chat(
|
||||||
|
@Body() body: ChatRequestDto,
|
||||||
|
@CurrentUser() user: any,
|
||||||
|
) {
|
||||||
|
this.logger.log(`Copilot chat request from user: ${user.email}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.copilotService.chat(
|
||||||
|
body.messages,
|
||||||
|
user.id,
|
||||||
|
user.role,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
...result,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Copilot chat error:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
response: 'I encountered an error processing your request. Please try again.',
|
||||||
|
error: error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
backend/src/copilot/copilot.module.ts
Normal file
13
backend/src/copilot/copilot.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CopilotController } from './copilot.controller';
|
||||||
|
import { CopilotService } from './copilot.service';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
import { SignalModule } from '../signal/signal.module';
|
||||||
|
import { DriversModule } from '../drivers/drivers.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, SignalModule, DriversModule],
|
||||||
|
controllers: [CopilotController],
|
||||||
|
providers: [CopilotService],
|
||||||
|
})
|
||||||
|
export class CopilotModule {}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { DriversController } from './drivers.controller';
|
import { DriversController } from './drivers.controller';
|
||||||
import { DriversService } from './drivers.service';
|
import { DriversService } from './drivers.service';
|
||||||
|
import { ScheduleExportService } from './schedule-export.service';
|
||||||
|
import { SignalModule } from '../signal/signal.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [SignalModule],
|
||||||
controllers: [DriversController],
|
controllers: [DriversController],
|
||||||
providers: [DriversService],
|
providers: [DriversService, ScheduleExportService],
|
||||||
exports: [DriversService],
|
exports: [DriversService, ScheduleExportService],
|
||||||
})
|
})
|
||||||
export class DriversModule {}
|
export class DriversModule {}
|
||||||
|
|||||||
@@ -52,6 +52,20 @@ export class DriversService {
|
|||||||
return driver;
|
return driver;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findByUserId(userId: string) {
|
||||||
|
return this.prisma.driver.findFirst({
|
||||||
|
where: { userId, deletedAt: null },
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
events: {
|
||||||
|
where: { deletedAt: null },
|
||||||
|
include: { vehicle: true, driver: true },
|
||||||
|
orderBy: { startTime: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async update(id: string, updateDriverDto: UpdateDriverDto) {
|
async update(id: string, updateDriverDto: UpdateDriverDto) {
|
||||||
const driver = await this.findOne(id);
|
const driver = await this.findOne(id);
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ export class CreateDriverDto {
|
|||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
phone: string;
|
@IsOptional()
|
||||||
|
phone?: string;
|
||||||
|
|
||||||
@IsEnum(Department)
|
@IsEnum(Department)
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
423
backend/src/events/event-status.service.ts
Normal file
423
backend/src/events/event-status.service.ts
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { SignalService } from '../signal/signal.service';
|
||||||
|
import { EventStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automatic event status management service
|
||||||
|
* - Transitions SCHEDULED → IN_PROGRESS when startTime arrives
|
||||||
|
* - Sends Signal confirmation requests to drivers
|
||||||
|
* - Handles driver responses (1=Confirmed, 2=Delayed, 3=Issue)
|
||||||
|
* - Transitions IN_PROGRESS → COMPLETED when endTime passes (with grace period)
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class EventStatusService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(EventStatusService.name);
|
||||||
|
private intervalId: NodeJS.Timeout | null = null;
|
||||||
|
private readonly CHECK_INTERVAL = 60 * 1000; // Check every minute
|
||||||
|
private readonly COMPLETION_GRACE_PERIOD = 15 * 60 * 1000; // 15 min after endTime before auto-complete
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private prisma: PrismaService,
|
||||||
|
private signalService: SignalService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
this.logger.log('Starting event status monitoring...');
|
||||||
|
this.startMonitoring();
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleDestroy() {
|
||||||
|
this.stopMonitoring();
|
||||||
|
}
|
||||||
|
|
||||||
|
private startMonitoring() {
|
||||||
|
// Run immediately on start
|
||||||
|
this.checkAndUpdateStatuses();
|
||||||
|
|
||||||
|
// Then run every minute
|
||||||
|
this.intervalId = setInterval(() => {
|
||||||
|
this.checkAndUpdateStatuses();
|
||||||
|
}, this.CHECK_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopMonitoring() {
|
||||||
|
if (this.intervalId) {
|
||||||
|
clearInterval(this.intervalId);
|
||||||
|
this.intervalId = null;
|
||||||
|
this.logger.log('Stopped event status monitoring');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main check loop - finds events that need status updates
|
||||||
|
*/
|
||||||
|
private async checkAndUpdateStatuses() {
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// 1. Send reminders for upcoming events (20 min and 5 min before)
|
||||||
|
await this.sendUpcomingReminders(now);
|
||||||
|
|
||||||
|
// 2. Find SCHEDULED events that should now be IN_PROGRESS
|
||||||
|
await this.transitionToInProgress(now);
|
||||||
|
|
||||||
|
// 3. Find IN_PROGRESS events that are past their end time (with grace period)
|
||||||
|
await this.transitionToCompleted(now);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Error checking event statuses:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send 20-minute and 5-minute reminders to drivers
|
||||||
|
*/
|
||||||
|
private async sendUpcomingReminders(now: Date) {
|
||||||
|
const twentyMinutesFromNow = new Date(now.getTime() + 20 * 60 * 1000);
|
||||||
|
const fiveMinutesFromNow = new Date(now.getTime() + 5 * 60 * 1000);
|
||||||
|
|
||||||
|
// Find events needing 20-minute reminder
|
||||||
|
// Events starting within 20 minutes that haven't had reminder sent
|
||||||
|
const eventsFor20MinReminder = await this.prisma.scheduleEvent.findMany({
|
||||||
|
where: {
|
||||||
|
status: EventStatus.SCHEDULED,
|
||||||
|
type: 'TRANSPORT',
|
||||||
|
startTime: { lte: twentyMinutesFromNow, gt: now },
|
||||||
|
reminder20MinSent: false,
|
||||||
|
driverId: { not: null },
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
driver: true,
|
||||||
|
vehicle: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const event of eventsFor20MinReminder) {
|
||||||
|
// Only send if actually ~20 min away (between 15-25 min)
|
||||||
|
const minutesUntil = Math.floor((new Date(event.startTime).getTime() - now.getTime()) / 60000);
|
||||||
|
if (minutesUntil <= 25 && minutesUntil >= 15) {
|
||||||
|
await this.send20MinReminder(event, minutesUntil);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find events needing 5-minute reminder
|
||||||
|
const eventsFor5MinReminder = await this.prisma.scheduleEvent.findMany({
|
||||||
|
where: {
|
||||||
|
status: EventStatus.SCHEDULED,
|
||||||
|
type: 'TRANSPORT',
|
||||||
|
startTime: { lte: fiveMinutesFromNow, gt: now },
|
||||||
|
reminder5MinSent: false,
|
||||||
|
driverId: { not: null },
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
driver: true,
|
||||||
|
vehicle: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const event of eventsFor5MinReminder) {
|
||||||
|
// Only send if actually ~5 min away (between 3-10 min)
|
||||||
|
const minutesUntil = Math.floor((new Date(event.startTime).getTime() - now.getTime()) / 60000);
|
||||||
|
if (minutesUntil <= 10 && minutesUntil >= 3) {
|
||||||
|
await this.send5MinReminder(event, minutesUntil);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send 20-minute reminder to driver
|
||||||
|
*/
|
||||||
|
private async send20MinReminder(event: any, minutesUntil: number) {
|
||||||
|
try {
|
||||||
|
const linkedNumber = await this.signalService.getLinkedNumber();
|
||||||
|
if (!linkedNumber || !event.driver?.phone) return;
|
||||||
|
|
||||||
|
// Get VIP names
|
||||||
|
const vips = await this.prisma.vIP.findMany({
|
||||||
|
where: { id: { in: event.vipIds || [] } },
|
||||||
|
select: { name: true },
|
||||||
|
});
|
||||||
|
const vipNames = vips.map(v => v.name).join(', ') || 'VIP';
|
||||||
|
|
||||||
|
const message = `📢 UPCOMING TRIP in ~${minutesUntil} minutes
|
||||||
|
|
||||||
|
📍 Pickup: ${event.pickupLocation || 'See schedule'}
|
||||||
|
📍 Dropoff: ${event.dropoffLocation || 'See schedule'}
|
||||||
|
👤 VIP: ${vipNames}
|
||||||
|
🚐 Vehicle: ${event.vehicle?.name || 'Check assignment'}
|
||||||
|
⏰ Start Time: ${new Date(event.startTime).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
|
||||||
|
Please head to the pickup location.`;
|
||||||
|
|
||||||
|
const formattedPhone = this.signalService.formatPhoneNumber(event.driver.phone);
|
||||||
|
await this.signalService.sendMessage(linkedNumber, formattedPhone, message);
|
||||||
|
|
||||||
|
// Mark reminder as sent
|
||||||
|
await this.prisma.scheduleEvent.update({
|
||||||
|
where: { id: event.id },
|
||||||
|
data: { reminder20MinSent: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Sent 20-min reminder to ${event.driver.name} for event ${event.id}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to send 20-min reminder for event ${event.id}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send 5-minute reminder to driver (more urgent)
|
||||||
|
*/
|
||||||
|
private async send5MinReminder(event: any, minutesUntil: number) {
|
||||||
|
try {
|
||||||
|
const linkedNumber = await this.signalService.getLinkedNumber();
|
||||||
|
if (!linkedNumber || !event.driver?.phone) return;
|
||||||
|
|
||||||
|
// Get VIP names
|
||||||
|
const vips = await this.prisma.vIP.findMany({
|
||||||
|
where: { id: { in: event.vipIds || [] } },
|
||||||
|
select: { name: true },
|
||||||
|
});
|
||||||
|
const vipNames = vips.map(v => v.name).join(', ') || 'VIP';
|
||||||
|
|
||||||
|
const message = `⚠️ TRIP STARTING in ${minutesUntil} MINUTES!
|
||||||
|
|
||||||
|
📍 Pickup: ${event.pickupLocation || 'See schedule'}
|
||||||
|
👤 VIP: ${vipNames}
|
||||||
|
🚐 Vehicle: ${event.vehicle?.name || 'Check assignment'}
|
||||||
|
|
||||||
|
You should be at the pickup location NOW.
|
||||||
|
|
||||||
|
Reply:
|
||||||
|
1️⃣ = Ready and waiting
|
||||||
|
2️⃣ = Running late
|
||||||
|
3️⃣ = Issue / Need help`;
|
||||||
|
|
||||||
|
const formattedPhone = this.signalService.formatPhoneNumber(event.driver.phone);
|
||||||
|
await this.signalService.sendMessage(linkedNumber, formattedPhone, message);
|
||||||
|
|
||||||
|
// Mark reminder as sent
|
||||||
|
await this.prisma.scheduleEvent.update({
|
||||||
|
where: { id: event.id },
|
||||||
|
data: { reminder5MinSent: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Sent 5-min reminder to ${event.driver.name} for event ${event.id}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to send 5-min reminder for event ${event.id}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transition SCHEDULED → IN_PROGRESS for events whose startTime has passed
|
||||||
|
*/
|
||||||
|
private async transitionToInProgress(now: Date) {
|
||||||
|
const eventsToStart = await this.prisma.scheduleEvent.findMany({
|
||||||
|
where: {
|
||||||
|
status: EventStatus.SCHEDULED,
|
||||||
|
startTime: { lte: now },
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
driver: true,
|
||||||
|
vehicle: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const event of eventsToStart) {
|
||||||
|
try {
|
||||||
|
// Update status to IN_PROGRESS
|
||||||
|
await this.prisma.scheduleEvent.update({
|
||||||
|
where: { id: event.id },
|
||||||
|
data: {
|
||||||
|
status: EventStatus.IN_PROGRESS,
|
||||||
|
actualStartTime: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Event ${event.id} (${event.title}) auto-started`);
|
||||||
|
|
||||||
|
// Send Signal confirmation request to driver if assigned
|
||||||
|
if (event.driver?.phone) {
|
||||||
|
await this.sendDriverConfirmationRequest(event);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to transition event ${event.id}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventsToStart.length > 0) {
|
||||||
|
this.logger.log(`Auto-started ${eventsToStart.length} events`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transition IN_PROGRESS → COMPLETED for events past their endTime + grace period
|
||||||
|
* Only auto-complete if no driver confirmation is pending
|
||||||
|
*/
|
||||||
|
private async transitionToCompleted(now: Date) {
|
||||||
|
const gracePeriodAgo = new Date(now.getTime() - this.COMPLETION_GRACE_PERIOD);
|
||||||
|
|
||||||
|
const eventsToComplete = await this.prisma.scheduleEvent.findMany({
|
||||||
|
where: {
|
||||||
|
status: EventStatus.IN_PROGRESS,
|
||||||
|
endTime: { lte: gracePeriodAgo },
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
driver: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const event of eventsToComplete) {
|
||||||
|
try {
|
||||||
|
await this.prisma.scheduleEvent.update({
|
||||||
|
where: { id: event.id },
|
||||||
|
data: {
|
||||||
|
status: EventStatus.COMPLETED,
|
||||||
|
actualEndTime: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Event ${event.id} (${event.title}) auto-completed`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to complete event ${event.id}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventsToComplete.length > 0) {
|
||||||
|
this.logger.log(`Auto-completed ${eventsToComplete.length} events`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a Signal message to the driver asking for confirmation
|
||||||
|
*/
|
||||||
|
private async sendDriverConfirmationRequest(event: any) {
|
||||||
|
try {
|
||||||
|
const linkedNumber = await this.signalService.getLinkedNumber();
|
||||||
|
if (!linkedNumber) {
|
||||||
|
this.logger.warn('No Signal account linked, skipping driver notification');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get VIP names for the message
|
||||||
|
const vips = await this.prisma.vIP.findMany({
|
||||||
|
where: { id: { in: event.vipIds || [] } },
|
||||||
|
select: { name: true },
|
||||||
|
});
|
||||||
|
const vipNames = vips.map(v => v.name).join(', ') || 'VIP';
|
||||||
|
|
||||||
|
const message = `🚗 TRIP STARTED: ${event.title}
|
||||||
|
|
||||||
|
📍 Pickup: ${event.pickupLocation || 'See schedule'}
|
||||||
|
📍 Dropoff: ${event.dropoffLocation || 'See schedule'}
|
||||||
|
👤 VIP: ${vipNames}
|
||||||
|
🚐 Vehicle: ${event.vehicle?.name || 'Not assigned'}
|
||||||
|
|
||||||
|
Please confirm status:
|
||||||
|
1️⃣ = En route / Confirmed
|
||||||
|
2️⃣ = Delayed (explain in next message)
|
||||||
|
3️⃣ = Issue / Need help
|
||||||
|
|
||||||
|
Reply with 1, 2, or 3`;
|
||||||
|
|
||||||
|
const formattedPhone = this.signalService.formatPhoneNumber(event.driver.phone);
|
||||||
|
await this.signalService.sendMessage(linkedNumber, formattedPhone, message);
|
||||||
|
|
||||||
|
this.logger.log(`Sent confirmation request to driver ${event.driver.name} for event ${event.id}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to send Signal confirmation for event ${event.id}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a driver's response to a confirmation request
|
||||||
|
* Called by the Signal message handler when a driver replies with 1, 2, or 3
|
||||||
|
*/
|
||||||
|
async processDriverResponse(driverPhone: string, response: string): Promise<string | null> {
|
||||||
|
const responseNum = parseInt(response.trim(), 10);
|
||||||
|
if (![1, 2, 3].includes(responseNum)) {
|
||||||
|
return null; // Not a status response
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the driver
|
||||||
|
const driver = await this.prisma.driver.findFirst({
|
||||||
|
where: {
|
||||||
|
phone: { contains: driverPhone.replace(/\D/g, '').slice(-10) },
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!driver) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find their current IN_PROGRESS event
|
||||||
|
const activeEvent = await this.prisma.scheduleEvent.findFirst({
|
||||||
|
where: {
|
||||||
|
driverId: driver.id,
|
||||||
|
status: EventStatus.IN_PROGRESS,
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
include: { vehicle: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!activeEvent) {
|
||||||
|
return 'No active trip found. Reply ignored.';
|
||||||
|
}
|
||||||
|
|
||||||
|
let replyMessage: string;
|
||||||
|
|
||||||
|
switch (responseNum) {
|
||||||
|
case 1: // Confirmed
|
||||||
|
// Event is already IN_PROGRESS, this just confirms it
|
||||||
|
await this.prisma.scheduleEvent.update({
|
||||||
|
where: { id: activeEvent.id },
|
||||||
|
data: {
|
||||||
|
notes: `${activeEvent.notes || ''}\n[${new Date().toLocaleTimeString()}] Driver confirmed en route`.trim(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
replyMessage = `✅ Confirmed! Safe travels. Reply when completed or if you need assistance.`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 2: // Delayed
|
||||||
|
await this.prisma.scheduleEvent.update({
|
||||||
|
where: { id: activeEvent.id },
|
||||||
|
data: {
|
||||||
|
notes: `${activeEvent.notes || ''}\n[${new Date().toLocaleTimeString()}] Driver reported DELAY`.trim(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
replyMessage = `⏰ Delay noted. Please reply with details about the delay. Coordinator has been alerted.`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 3: // Issue
|
||||||
|
await this.prisma.scheduleEvent.update({
|
||||||
|
where: { id: activeEvent.id },
|
||||||
|
data: {
|
||||||
|
notes: `${activeEvent.notes || ''}\n[${new Date().toLocaleTimeString()}] Driver reported ISSUE - needs help`.trim(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
replyMessage = `🚨 Issue reported! A coordinator will contact you shortly. Please describe the issue in your next message.`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the reply
|
||||||
|
try {
|
||||||
|
const linkedNumber = await this.signalService.getLinkedNumber();
|
||||||
|
if (linkedNumber && driver.phone) {
|
||||||
|
const formattedPhone = this.signalService.formatPhoneNumber(driver.phone);
|
||||||
|
await this.signalService.sendMessage(linkedNumber, formattedPhone, replyMessage);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to send reply to driver:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return replyMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,25 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { EventsController } from './events.controller';
|
import { EventsController } from './events.controller';
|
||||||
import { EventsService } from './events.service';
|
import { EventsService } from './events.service';
|
||||||
|
import { EventStatusService } from './event-status.service';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
import { SignalModule } from '../signal/signal.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [
|
||||||
|
PrismaModule,
|
||||||
|
forwardRef(() => SignalModule), // forwardRef to avoid circular dependency
|
||||||
|
],
|
||||||
controllers: [
|
controllers: [
|
||||||
EventsController,
|
EventsController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
EventsService,
|
EventsService,
|
||||||
|
EventStatusService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
EventsService,
|
EventsService,
|
||||||
|
EventStatusService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class EventsModule {}
|
export class EventsModule {}
|
||||||
|
|||||||
@@ -300,10 +300,11 @@ export class EventsService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Enrich event with VIP details fetched separately
|
* Enrich event with VIP details fetched separately
|
||||||
|
* Returns both `vips` array and `vip` (first VIP) for backwards compatibility
|
||||||
*/
|
*/
|
||||||
private async enrichEventWithVips(event: any) {
|
private async enrichEventWithVips(event: any) {
|
||||||
if (!event.vipIds || event.vipIds.length === 0) {
|
if (!event.vipIds || event.vipIds.length === 0) {
|
||||||
return { ...event, vips: [] };
|
return { ...event, vips: [], vip: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const vips = await this.prisma.vIP.findMany({
|
const vips = await this.prisma.vIP.findMany({
|
||||||
@@ -313,6 +314,7 @@ export class EventsService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return { ...event, vips };
|
// Return both vips array and vip (first one) for backwards compatibility
|
||||||
|
return { ...event, vips, vip: vips[0] || null };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { ValidationPipe, Logger } from '@nestjs/common';
|
import { ValidationPipe, Logger } from '@nestjs/common';
|
||||||
|
import { json, urlencoded } from 'express';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { AllExceptionsFilter, HttpExceptionFilter } from './common/filters';
|
import { AllExceptionsFilter, HttpExceptionFilter } from './common/filters';
|
||||||
|
|
||||||
@@ -8,6 +9,10 @@ async function bootstrap() {
|
|||||||
|
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
|
|
||||||
|
// Increase body size limit for PDF attachments (base64 encoded)
|
||||||
|
app.use(json({ limit: '5mb' }));
|
||||||
|
app.use(urlencoded({ extended: true, limit: '5mb' }));
|
||||||
|
|
||||||
// Global prefix for all routes
|
// Global prefix for all routes
|
||||||
// In production (App Platform), the ingress routes /api to this service
|
// In production (App Platform), the ingress routes /api to this service
|
||||||
// So we only need /v1 prefix here
|
// So we only need /v1 prefix here
|
||||||
|
|||||||
3
backend/src/seed/index.ts
Normal file
3
backend/src/seed/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './seed.module';
|
||||||
|
export * from './seed.service';
|
||||||
|
export * from './seed.controller';
|
||||||
36
backend/src/seed/seed.controller.ts
Normal file
36
backend/src/seed/seed.controller.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Controller, Post, Delete, UseGuards, Body } from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
|
import { SeedService } from './seed.service';
|
||||||
|
|
||||||
|
@Controller('seed')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('ADMINISTRATOR')
|
||||||
|
export class SeedController {
|
||||||
|
constructor(private readonly seedService: SeedService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate all test data in a single fast transaction
|
||||||
|
*/
|
||||||
|
@Post('generate')
|
||||||
|
async generateTestData(@Body() options?: { clearFirst?: boolean }) {
|
||||||
|
return this.seedService.generateAllTestData(options?.clearFirst ?? true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all test data instantly
|
||||||
|
*/
|
||||||
|
@Delete('clear')
|
||||||
|
async clearAllData() {
|
||||||
|
return this.seedService.clearAllData();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate only events with dynamic times (keeps existing VIPs/drivers/vehicles)
|
||||||
|
*/
|
||||||
|
@Post('generate-events')
|
||||||
|
async generateDynamicEvents() {
|
||||||
|
return this.seedService.generateDynamicEvents();
|
||||||
|
}
|
||||||
|
}
|
||||||
12
backend/src/seed/seed.module.ts
Normal file
12
backend/src/seed/seed.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { SeedController } from './seed.controller';
|
||||||
|
import { SeedService } from './seed.service';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, AuthModule],
|
||||||
|
controllers: [SeedController],
|
||||||
|
providers: [SeedService],
|
||||||
|
})
|
||||||
|
export class SeedModule {}
|
||||||
626
backend/src/seed/seed.service.ts
Normal file
626
backend/src/seed/seed.service.ts
Normal file
@@ -0,0 +1,626 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { Department, ArrivalMode, EventType, EventStatus, VehicleType, VehicleStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SeedService {
|
||||||
|
private readonly logger = new Logger(SeedService.name);
|
||||||
|
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all data using fast deleteMany operations
|
||||||
|
*/
|
||||||
|
async clearAllData() {
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
// Delete in order to respect foreign key constraints
|
||||||
|
const results = await this.prisma.$transaction([
|
||||||
|
this.prisma.signalMessage.deleteMany(),
|
||||||
|
this.prisma.scheduleEvent.deleteMany(),
|
||||||
|
this.prisma.flight.deleteMany(),
|
||||||
|
this.prisma.vehicle.deleteMany(),
|
||||||
|
this.prisma.driver.deleteMany(),
|
||||||
|
this.prisma.vIP.deleteMany(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const elapsed = Date.now() - start;
|
||||||
|
this.logger.log(`Cleared all data in ${elapsed}ms`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
elapsed: `${elapsed}ms`,
|
||||||
|
deleted: {
|
||||||
|
messages: results[0].count,
|
||||||
|
events: results[1].count,
|
||||||
|
flights: results[2].count,
|
||||||
|
vehicles: results[3].count,
|
||||||
|
drivers: results[4].count,
|
||||||
|
vips: results[5].count,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate all test data in a single fast transaction
|
||||||
|
*/
|
||||||
|
async generateAllTestData(clearFirst: boolean = true) {
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
if (clearFirst) {
|
||||||
|
await this.clearAllData();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create all entities in a transaction
|
||||||
|
const result = await this.prisma.$transaction(async (tx) => {
|
||||||
|
// 1. Create VIPs
|
||||||
|
const vipData = this.getVIPData();
|
||||||
|
await tx.vIP.createMany({ data: vipData });
|
||||||
|
const vips = await tx.vIP.findMany({ orderBy: { createdAt: 'asc' } });
|
||||||
|
this.logger.log(`Created ${vips.length} VIPs`);
|
||||||
|
|
||||||
|
// 2. Create Drivers with shifts
|
||||||
|
const driverData = this.getDriverData();
|
||||||
|
await tx.driver.createMany({ data: driverData });
|
||||||
|
const drivers = await tx.driver.findMany({ orderBy: { createdAt: 'asc' } });
|
||||||
|
this.logger.log(`Created ${drivers.length} drivers`);
|
||||||
|
|
||||||
|
// 3. Create Vehicles
|
||||||
|
const vehicleData = this.getVehicleData();
|
||||||
|
await tx.vehicle.createMany({ data: vehicleData });
|
||||||
|
const vehicles = await tx.vehicle.findMany({ orderBy: { createdAt: 'asc' } });
|
||||||
|
this.logger.log(`Created ${vehicles.length} vehicles`);
|
||||||
|
|
||||||
|
// 4. Create Flights for VIPs arriving by flight
|
||||||
|
const flightVips = vips.filter(v => v.arrivalMode === 'FLIGHT');
|
||||||
|
const flightData = this.getFlightData(flightVips);
|
||||||
|
await tx.flight.createMany({ data: flightData });
|
||||||
|
const flights = await tx.flight.findMany();
|
||||||
|
this.logger.log(`Created ${flights.length} flights`);
|
||||||
|
|
||||||
|
// 5. Create Events with dynamic times relative to NOW
|
||||||
|
const eventData = this.getEventData(vips, drivers, vehicles);
|
||||||
|
await tx.scheduleEvent.createMany({ data: eventData });
|
||||||
|
const events = await tx.scheduleEvent.findMany();
|
||||||
|
this.logger.log(`Created ${events.length} events`);
|
||||||
|
|
||||||
|
return { vips, drivers, vehicles, flights, events };
|
||||||
|
});
|
||||||
|
|
||||||
|
const elapsed = Date.now() - start;
|
||||||
|
this.logger.log(`Generated all test data in ${elapsed}ms`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
elapsed: `${elapsed}ms`,
|
||||||
|
created: {
|
||||||
|
vips: result.vips.length,
|
||||||
|
drivers: result.drivers.length,
|
||||||
|
vehicles: result.vehicles.length,
|
||||||
|
flights: result.flights.length,
|
||||||
|
events: result.events.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate only dynamic events (uses existing VIPs/drivers/vehicles)
|
||||||
|
*/
|
||||||
|
async generateDynamicEvents() {
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
// Clear existing events
|
||||||
|
await this.prisma.scheduleEvent.deleteMany();
|
||||||
|
|
||||||
|
// Get existing entities
|
||||||
|
const [vips, drivers, vehicles] = await Promise.all([
|
||||||
|
this.prisma.vIP.findMany({ where: { deletedAt: null } }),
|
||||||
|
this.prisma.driver.findMany({ where: { deletedAt: null } }),
|
||||||
|
this.prisma.vehicle.findMany({ where: { deletedAt: null } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (vips.length === 0) {
|
||||||
|
return { success: false, error: 'No VIPs found. Generate full test data first.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create events
|
||||||
|
const eventData = this.getEventData(vips, drivers, vehicles);
|
||||||
|
await this.prisma.scheduleEvent.createMany({ data: eventData });
|
||||||
|
|
||||||
|
const elapsed = Date.now() - start;
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
elapsed: `${elapsed}ms`,
|
||||||
|
created: { events: eventData.length },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// DATA GENERATORS
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
private getVIPData() {
|
||||||
|
return [
|
||||||
|
// OFFICE_OF_DEVELOPMENT (10 VIPs) - Corporate sponsors, foundations, major donors
|
||||||
|
{ name: 'Sarah Chen', organization: 'Microsoft Corporation', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'Executive VP - prefers quiet vehicles. Allergic to peanuts.' },
|
||||||
|
{ name: 'Marcus Johnson', organization: 'The Coca-Cola Company', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'Bringing spouse. Needs wheelchair accessible transport.' },
|
||||||
|
{ name: 'Jennifer Wu', organization: 'JPMorgan Chase Foundation', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'Major donor - $500K pledge. VIP treatment essential.' },
|
||||||
|
{ name: 'Roberto Gonzalez', organization: 'AT&T Inc', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'First time visitor. Interested in STEM programs.' },
|
||||||
|
{ name: 'Priya Sharma', organization: 'Google LLC', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'Vegetarian meals required. Interested in technology merit badges.' },
|
||||||
|
{ name: 'David Okonkwo', organization: 'Bank of America', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: false, venueTransport: true, notes: 'Has rental car for airport. Needs venue transport only.' },
|
||||||
|
{ name: 'Maria Rodriguez', organization: 'Walmart Foundation', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.SELF_DRIVING, airportPickup: false, venueTransport: true, expectedArrival: this.relativeTime(120), notes: 'Driving from nearby hotel. Call when 30 min out.' },
|
||||||
|
{ name: 'Yuki Tanaka', organization: 'Honda Motor Company', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'Japanese executive - interpreter may be needed.' },
|
||||||
|
{ name: 'Thomas Anderson', organization: 'Verizon Communications', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: false, notes: 'Will use personal driver after airport pickup.' },
|
||||||
|
{ name: 'Isabella Costa', organization: 'Target Corporation', department: Department.OFFICE_OF_DEVELOPMENT, arrivalMode: ArrivalMode.FLIGHT, airportPickup: false, venueTransport: true, notes: 'Taking rideshare from airport. Venue transport needed.' },
|
||||||
|
|
||||||
|
// ADMIN (10 VIPs) - BSA Leadership and Staff
|
||||||
|
{ name: 'Roger A. Krone', organization: 'BSA National President', department: Department.ADMIN, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'HIGHEST PRIORITY VIP. Security detail traveling with him.' },
|
||||||
|
{ name: 'Emily Richardson', organization: 'BSA Chief Scout Executive', department: Department.ADMIN, arrivalMode: ArrivalMode.SELF_DRIVING, airportPickup: false, venueTransport: false, expectedArrival: this.relativeTime(60), notes: 'Has assigned BSA vehicle. No transport needed.' },
|
||||||
|
{ name: 'Dr. Maya Krishnan', organization: 'BSA National Director of Program', department: Department.ADMIN, arrivalMode: ArrivalMode.SELF_DRIVING, airportPickup: false, venueTransport: false, expectedArrival: this.relativeTime(180), notes: 'Carpooling with regional directors.' },
|
||||||
|
{ name: "James O'Brien", organization: 'BSA Northeast Regional Director', department: Department.ADMIN, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'Traveling with 2 staff members.' },
|
||||||
|
{ name: 'Fatima Al-Rahman', organization: 'BSA Western Region Executive', department: Department.ADMIN, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'Halal meals required. Prayer room access needed.' },
|
||||||
|
{ name: 'William Zhang', organization: 'BSA Southern Region Council', department: Department.ADMIN, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: true, notes: 'Bringing presentation equipment - need vehicle with cargo space.' },
|
||||||
|
{ name: 'Sophie Laurent', organization: 'BSA National Volunteer Training', department: Department.ADMIN, arrivalMode: ArrivalMode.SELF_DRIVING, airportPickup: false, venueTransport: true, expectedArrival: this.relativeTime(240), notes: 'Training materials in personal vehicle.' },
|
||||||
|
{ name: 'Alexander Volkov', organization: 'BSA High Adventure Director', department: Department.ADMIN, arrivalMode: ArrivalMode.FLIGHT, airportPickup: true, venueTransport: false, notes: 'Outdoor enthusiast - prefers walking when possible.' },
|
||||||
|
{ name: 'Dr. Aisha Patel', organization: 'BSA STEM & Innovation Programs', department: Department.ADMIN, arrivalMode: ArrivalMode.SELF_DRIVING, airportPickup: false, venueTransport: true, expectedArrival: this.relativeTime(90), notes: 'Demo equipment for STEM showcase. Fragile items!' },
|
||||||
|
{ name: 'Henrik Larsson', organization: 'BSA International Commissioner', department: Department.ADMIN, arrivalMode: ArrivalMode.SELF_DRIVING, airportPickup: false, venueTransport: true, expectedArrival: this.relativeTime(150), notes: 'Visiting from Sweden. International guest protocols apply.' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDriverData() {
|
||||||
|
const now = new Date();
|
||||||
|
const shiftStart = new Date(now);
|
||||||
|
shiftStart.setHours(6, 0, 0, 0);
|
||||||
|
const shiftEnd = new Date(now);
|
||||||
|
shiftEnd.setHours(22, 0, 0, 0);
|
||||||
|
|
||||||
|
const lateShiftStart = new Date(now);
|
||||||
|
lateShiftStart.setHours(14, 0, 0, 0);
|
||||||
|
const lateShiftEnd = new Date(now);
|
||||||
|
lateShiftEnd.setHours(23, 59, 0, 0);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ name: 'Michael Thompson', phone: '555-0101', department: Department.OFFICE_OF_DEVELOPMENT, isAvailable: true, shiftStartTime: shiftStart, shiftEndTime: shiftEnd },
|
||||||
|
{ name: 'Lisa Martinez', phone: '555-0102', department: Department.OFFICE_OF_DEVELOPMENT, isAvailable: true, shiftStartTime: shiftStart, shiftEndTime: shiftEnd },
|
||||||
|
{ name: 'David Kim', phone: '555-0103', department: Department.OFFICE_OF_DEVELOPMENT, isAvailable: true, shiftStartTime: shiftStart, shiftEndTime: shiftEnd },
|
||||||
|
{ name: 'Amanda Washington', phone: '555-0104', department: Department.ADMIN, isAvailable: true, shiftStartTime: shiftStart, shiftEndTime: shiftEnd },
|
||||||
|
{ name: 'Carlos Hernandez', phone: '555-0105', department: Department.OFFICE_OF_DEVELOPMENT, isAvailable: false, shiftStartTime: lateShiftStart, shiftEndTime: lateShiftEnd }, // Off duty until 2pm
|
||||||
|
{ name: 'Jessica Lee', phone: '555-0106', department: Department.OFFICE_OF_DEVELOPMENT, isAvailable: true, shiftStartTime: shiftStart, shiftEndTime: shiftEnd },
|
||||||
|
{ name: 'Brandon Jackson', phone: '555-0107', department: Department.OFFICE_OF_DEVELOPMENT, isAvailable: true, shiftStartTime: lateShiftStart, shiftEndTime: lateShiftEnd },
|
||||||
|
{ name: 'Nicole Brown', phone: '555-0108', department: Department.ADMIN, isAvailable: true, shiftStartTime: shiftStart, shiftEndTime: shiftEnd },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private getVehicleData() {
|
||||||
|
return [
|
||||||
|
{ name: 'Blue Van', type: VehicleType.VAN, licensePlate: 'VAN-001', seatCapacity: 12, status: VehicleStatus.AVAILABLE, notes: 'Primary transport van with wheelchair accessibility' },
|
||||||
|
{ name: 'Suburban #1', type: VehicleType.SUV, licensePlate: 'SUV-101', seatCapacity: 7, status: VehicleStatus.AVAILABLE, notes: 'Leather interior, ideal for VIP comfort' },
|
||||||
|
{ name: 'Golf Cart Alpha', type: VehicleType.GOLF_CART, licensePlate: 'GC-A', seatCapacity: 6, status: VehicleStatus.AVAILABLE, notes: 'Quick campus transport, good for short distances' },
|
||||||
|
{ name: 'Red Van', type: VehicleType.VAN, licensePlate: 'VAN-002', seatCapacity: 8, status: VehicleStatus.AVAILABLE, notes: 'Standard transport van' },
|
||||||
|
{ name: 'Scout Bus', type: VehicleType.BUS, licensePlate: 'BUS-001', seatCapacity: 25, status: VehicleStatus.AVAILABLE, notes: 'Large group transport, AC equipped' },
|
||||||
|
{ name: 'Suburban #2', type: VehicleType.SUV, licensePlate: 'SUV-102', seatCapacity: 7, status: VehicleStatus.AVAILABLE, notes: 'Backup VIP transport' },
|
||||||
|
{ name: 'Golf Cart Bravo', type: VehicleType.GOLF_CART, licensePlate: 'GC-B', seatCapacity: 4, status: VehicleStatus.AVAILABLE, notes: 'Quick on-site transport' },
|
||||||
|
{ name: 'Equipment Truck', type: VehicleType.TRUCK, licensePlate: 'TRK-001', seatCapacity: 3, status: VehicleStatus.MAINTENANCE, notes: 'For equipment and supply runs - currently in maintenance' },
|
||||||
|
{ name: 'Executive Sedan', type: VehicleType.SEDAN, licensePlate: 'SED-001', seatCapacity: 4, status: VehicleStatus.AVAILABLE, notes: 'Premium sedan for executive VIPs' },
|
||||||
|
{ name: 'Golf Cart Charlie', type: VehicleType.GOLF_CART, licensePlate: 'GC-C', seatCapacity: 4, status: VehicleStatus.AVAILABLE, notes: 'Backup golf cart' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFlightData(vips: any[]) {
|
||||||
|
const flights: any[] = [];
|
||||||
|
const airlines = ['AA', 'UA', 'DL', 'SW', 'AS', 'JB'];
|
||||||
|
const origins = ['JFK', 'LAX', 'ORD', 'DFW', 'ATL', 'SFO', 'SEA', 'BOS', 'DEN', 'MIA'];
|
||||||
|
const destination = 'SLC'; // Assuming Salt Lake City for the Jamboree
|
||||||
|
|
||||||
|
vips.forEach((vip, index) => {
|
||||||
|
const airline = airlines[index % airlines.length];
|
||||||
|
const flightNum = `${airline}${1000 + index * 123}`;
|
||||||
|
const origin = origins[index % origins.length];
|
||||||
|
|
||||||
|
// Arrival flight - times relative to now
|
||||||
|
const arrivalOffset = (index % 8) * 30 - 60; // -60 to +150 minutes from now
|
||||||
|
const scheduledArrival = this.relativeTime(arrivalOffset);
|
||||||
|
const scheduledDeparture = new Date(scheduledArrival.getTime() - 3 * 60 * 60 * 1000); // 3 hours before
|
||||||
|
|
||||||
|
// Some flights are delayed, some landed, some on time
|
||||||
|
let status = 'scheduled';
|
||||||
|
let actualArrival = null;
|
||||||
|
if (arrivalOffset < -30) {
|
||||||
|
status = 'landed';
|
||||||
|
actualArrival = new Date(scheduledArrival.getTime() + (Math.random() * 20 - 10) * 60000);
|
||||||
|
} else if (arrivalOffset < 0) {
|
||||||
|
status = 'landing';
|
||||||
|
} else if (index % 5 === 0) {
|
||||||
|
status = 'delayed';
|
||||||
|
}
|
||||||
|
|
||||||
|
flights.push({
|
||||||
|
vipId: vip.id,
|
||||||
|
flightNumber: flightNum,
|
||||||
|
flightDate: new Date(),
|
||||||
|
segment: 1,
|
||||||
|
departureAirport: origin,
|
||||||
|
arrivalAirport: destination,
|
||||||
|
scheduledDeparture,
|
||||||
|
scheduledArrival,
|
||||||
|
actualArrival,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Some VIPs have connecting flights (segment 2)
|
||||||
|
if (index % 4 === 0) {
|
||||||
|
const connectOrigin = origins[(index + 3) % origins.length];
|
||||||
|
flights.push({
|
||||||
|
vipId: vip.id,
|
||||||
|
flightNumber: `${airline}${500 + index}`,
|
||||||
|
flightDate: new Date(),
|
||||||
|
segment: 2,
|
||||||
|
departureAirport: connectOrigin,
|
||||||
|
arrivalAirport: origin,
|
||||||
|
scheduledDeparture: new Date(scheduledDeparture.getTime() - 4 * 60 * 60 * 1000),
|
||||||
|
scheduledArrival: new Date(scheduledDeparture.getTime() - 1 * 60 * 60 * 1000),
|
||||||
|
status: 'landed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return flights;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getEventData(vips: any[], drivers: any[], vehicles: any[]) {
|
||||||
|
const events: any[] = [];
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Track vehicle assignments to avoid conflicts
|
||||||
|
// Map of vehicleId -> array of { start: Date, end: Date }
|
||||||
|
const vehicleSchedule: Map<string, Array<{ start: Date; end: Date }>> = new Map();
|
||||||
|
const driverSchedule: Map<string, Array<{ start: Date; end: Date }>> = new Map();
|
||||||
|
|
||||||
|
// Initialize schedules
|
||||||
|
vehicles.forEach(v => vehicleSchedule.set(v.id, []));
|
||||||
|
drivers.forEach(d => driverSchedule.set(d.id, []));
|
||||||
|
|
||||||
|
// Check if a time slot conflicts with existing assignments
|
||||||
|
const hasConflict = (schedule: Array<{ start: Date; end: Date }>, start: Date, end: Date): boolean => {
|
||||||
|
return schedule.some(slot =>
|
||||||
|
(start < slot.end && end > slot.start) // Overlapping
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find an available vehicle for a time slot
|
||||||
|
const findAvailableVehicle = (start: Date, end: Date, preferredIndex: number): any | null => {
|
||||||
|
if (vehicles.length === 0) return null;
|
||||||
|
|
||||||
|
// Try preferred vehicle first
|
||||||
|
const preferred = vehicles[preferredIndex % vehicles.length];
|
||||||
|
const preferredSchedule = vehicleSchedule.get(preferred.id) || [];
|
||||||
|
if (!hasConflict(preferredSchedule, start, end)) {
|
||||||
|
preferredSchedule.push({ start, end });
|
||||||
|
return preferred;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try other vehicles
|
||||||
|
for (const vehicle of vehicles) {
|
||||||
|
const schedule = vehicleSchedule.get(vehicle.id) || [];
|
||||||
|
if (!hasConflict(schedule, start, end)) {
|
||||||
|
schedule.push({ start, end });
|
||||||
|
return vehicle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null; // No available vehicle
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find an available driver for a time slot
|
||||||
|
const findAvailableDriver = (start: Date, end: Date, preferredIndex: number): any | null => {
|
||||||
|
if (drivers.length === 0) return null;
|
||||||
|
|
||||||
|
// Try preferred driver first
|
||||||
|
const preferred = drivers[preferredIndex % drivers.length];
|
||||||
|
const preferredSchedule = driverSchedule.get(preferred.id) || [];
|
||||||
|
if (!hasConflict(preferredSchedule, start, end)) {
|
||||||
|
preferredSchedule.push({ start, end });
|
||||||
|
return preferred;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try other drivers
|
||||||
|
for (const driver of drivers) {
|
||||||
|
const schedule = driverSchedule.get(driver.id) || [];
|
||||||
|
if (!hasConflict(schedule, start, end)) {
|
||||||
|
schedule.push({ start, end });
|
||||||
|
return driver;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null; // No available driver
|
||||||
|
};
|
||||||
|
|
||||||
|
vips.forEach((vip, vipIndex) => {
|
||||||
|
// ============================================================
|
||||||
|
// CREATE VARIED EVENTS RELATIVE TO NOW
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// Event pattern based on VIP index to create variety:
|
||||||
|
// - Some VIPs have events IN_PROGRESS
|
||||||
|
// - Some have events starting VERY soon (5-15 min)
|
||||||
|
// - Some have events starting soon (30-60 min)
|
||||||
|
// - Some have just-completed events
|
||||||
|
// - All have future events throughout the day
|
||||||
|
|
||||||
|
const eventPattern = vipIndex % 5;
|
||||||
|
|
||||||
|
switch (eventPattern) {
|
||||||
|
case 0: { // IN_PROGRESS airport pickup
|
||||||
|
const start = this.relativeTime(-25);
|
||||||
|
const end = this.relativeTime(15);
|
||||||
|
const driver = findAvailableDriver(start, end, vipIndex);
|
||||||
|
const vehicle = findAvailableVehicle(start, end, vipIndex);
|
||||||
|
events.push({
|
||||||
|
vipIds: [vip.id],
|
||||||
|
driverId: driver?.id,
|
||||||
|
vehicleId: vehicle?.id,
|
||||||
|
title: `Airport Pickup - ${vip.name}`,
|
||||||
|
type: EventType.TRANSPORT,
|
||||||
|
status: EventStatus.IN_PROGRESS,
|
||||||
|
pickupLocation: 'Airport - Terminal B',
|
||||||
|
dropoffLocation: 'Main Gate Registration',
|
||||||
|
startTime: start,
|
||||||
|
endTime: end,
|
||||||
|
actualStartTime: this.relativeTime(-23),
|
||||||
|
description: `ACTIVE: Driver en route with ${vip.name} from airport`,
|
||||||
|
notes: 'VIP collected from arrivals. ETA 15 minutes.',
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 1: { // COMPLETED event
|
||||||
|
const start = this.relativeTime(-90);
|
||||||
|
const end = this.relativeTime(-45);
|
||||||
|
const driver = findAvailableDriver(start, end, vipIndex);
|
||||||
|
const vehicle = findAvailableVehicle(start, end, vipIndex);
|
||||||
|
events.push({
|
||||||
|
vipIds: [vip.id],
|
||||||
|
driverId: driver?.id,
|
||||||
|
vehicleId: vehicle?.id,
|
||||||
|
title: `Airport Pickup - ${vip.name}`,
|
||||||
|
type: EventType.TRANSPORT,
|
||||||
|
status: EventStatus.COMPLETED,
|
||||||
|
pickupLocation: 'Airport - Terminal A',
|
||||||
|
dropoffLocation: 'VIP Lodge',
|
||||||
|
startTime: start,
|
||||||
|
endTime: end,
|
||||||
|
actualStartTime: this.relativeTime(-88),
|
||||||
|
actualEndTime: this.relativeTime(-42),
|
||||||
|
description: `Completed pickup for ${vip.name}`,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 2: { // Starting in 5-10 minutes (URGENT)
|
||||||
|
const start = this.relativeTime(7);
|
||||||
|
const end = this.relativeTime(22);
|
||||||
|
const driver = findAvailableDriver(start, end, vipIndex);
|
||||||
|
const vehicle = findAvailableVehicle(start, end, vipIndex);
|
||||||
|
events.push({
|
||||||
|
vipIds: [vip.id],
|
||||||
|
driverId: driver?.id,
|
||||||
|
vehicleId: vehicle?.id,
|
||||||
|
title: `URGENT: Transport to Opening Ceremony - ${vip.name}`,
|
||||||
|
type: EventType.TRANSPORT,
|
||||||
|
status: EventStatus.SCHEDULED,
|
||||||
|
pickupLocation: 'VIP Lodge',
|
||||||
|
dropoffLocation: 'Main Arena - VIP Entrance',
|
||||||
|
startTime: start,
|
||||||
|
endTime: end,
|
||||||
|
description: `Pick up ${vip.name} for Opening Ceremony - STARTS SOON!`,
|
||||||
|
notes: 'Driver should be at pickup location NOW',
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 3: { // Starting in 30-45 min
|
||||||
|
const start = this.relativeTime(35);
|
||||||
|
const end = this.relativeTime(50);
|
||||||
|
const driver = findAvailableDriver(start, end, vipIndex);
|
||||||
|
const vehicle = findAvailableVehicle(start, end, vipIndex);
|
||||||
|
events.push({
|
||||||
|
vipIds: [vip.id],
|
||||||
|
driverId: driver?.id,
|
||||||
|
vehicleId: vehicle?.id,
|
||||||
|
title: `VIP Lodge Transfer - ${vip.name}`,
|
||||||
|
type: EventType.TRANSPORT,
|
||||||
|
status: EventStatus.SCHEDULED,
|
||||||
|
pickupLocation: 'Registration Tent',
|
||||||
|
dropoffLocation: 'VIP Lodge - Building A',
|
||||||
|
startTime: start,
|
||||||
|
endTime: end,
|
||||||
|
description: `Transfer ${vip.name} to VIP accommodation after registration`,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 4: // In-progress MEETING (no driver/vehicle needed)
|
||||||
|
events.push({
|
||||||
|
vipIds: [vip.id],
|
||||||
|
title: `Donor Briefing Meeting`,
|
||||||
|
type: EventType.MEETING,
|
||||||
|
status: EventStatus.IN_PROGRESS,
|
||||||
|
location: 'Conference Center - Room 101',
|
||||||
|
startTime: this.relativeTime(-20),
|
||||||
|
endTime: this.relativeTime(25),
|
||||||
|
actualStartTime: this.relativeTime(-18),
|
||||||
|
description: `${vip.name} in donor briefing with development team`,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ADD STANDARD DAY EVENTS FOR ALL VIPS
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// Upcoming meal (1-2 hours out) - no driver/vehicle needed
|
||||||
|
events.push({
|
||||||
|
vipIds: [vip.id],
|
||||||
|
title: vipIndex % 2 === 0 ? 'VIP Luncheon' : 'VIP Breakfast',
|
||||||
|
type: EventType.MEAL,
|
||||||
|
status: EventStatus.SCHEDULED,
|
||||||
|
location: 'VIP Dining Pavilion',
|
||||||
|
startTime: this.relativeTime(60 + (vipIndex % 4) * 15),
|
||||||
|
endTime: this.relativeTime(120 + (vipIndex % 4) * 15),
|
||||||
|
description: `Catered meal for ${vip.name} with other VIP guests`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transport to main event (2-3 hours out)
|
||||||
|
{
|
||||||
|
const start = this.relativeTime(150 + vipIndex * 5);
|
||||||
|
const end = this.relativeTime(165 + vipIndex * 5);
|
||||||
|
const driver = findAvailableDriver(start, end, vipIndex + 3);
|
||||||
|
const vehicle = findAvailableVehicle(start, end, vipIndex + 2);
|
||||||
|
events.push({
|
||||||
|
vipIds: [vip.id],
|
||||||
|
driverId: driver?.id,
|
||||||
|
vehicleId: vehicle?.id,
|
||||||
|
title: `Transport to Scout Exhibition`,
|
||||||
|
type: EventType.TRANSPORT,
|
||||||
|
status: EventStatus.SCHEDULED,
|
||||||
|
pickupLocation: 'VIP Lodge',
|
||||||
|
dropoffLocation: 'Exhibition Grounds',
|
||||||
|
startTime: start,
|
||||||
|
endTime: end,
|
||||||
|
description: `Transport ${vip.name} to Scout Exhibition area`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main event (3-4 hours out) - no driver/vehicle needed
|
||||||
|
events.push({
|
||||||
|
vipIds: [vip.id],
|
||||||
|
title: 'Scout Skills Exhibition',
|
||||||
|
type: EventType.EVENT,
|
||||||
|
status: EventStatus.SCHEDULED,
|
||||||
|
location: 'Exhibition Grounds - Zone A',
|
||||||
|
startTime: this.relativeTime(180 + vipIndex * 3),
|
||||||
|
endTime: this.relativeTime(270 + vipIndex * 3),
|
||||||
|
description: `${vip.name} tours Scout exhibitions and demonstrations`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Evening dinner (5-6 hours out) - no driver/vehicle needed
|
||||||
|
events.push({
|
||||||
|
vipIds: [vip.id],
|
||||||
|
title: 'Gala Dinner',
|
||||||
|
type: EventType.MEAL,
|
||||||
|
status: EventStatus.SCHEDULED,
|
||||||
|
location: 'Grand Ballroom',
|
||||||
|
startTime: this.relativeTime(360),
|
||||||
|
endTime: this.relativeTime(480),
|
||||||
|
description: `Black-tie dinner event with ${vip.name} and other distinguished guests`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Next day departure (tomorrow morning)
|
||||||
|
if (vip.arrivalMode === 'FLIGHT') {
|
||||||
|
const start = this.relativeTime(60 * 24 + 120 + vipIndex * 20);
|
||||||
|
const end = this.relativeTime(60 * 24 + 165 + vipIndex * 20);
|
||||||
|
const driver = findAvailableDriver(start, end, vipIndex + 1);
|
||||||
|
const vehicle = findAvailableVehicle(start, end, vipIndex + 1);
|
||||||
|
events.push({
|
||||||
|
vipIds: [vip.id],
|
||||||
|
driverId: driver?.id,
|
||||||
|
vehicleId: vehicle?.id,
|
||||||
|
title: `Airport Departure - ${vip.name}`,
|
||||||
|
type: EventType.TRANSPORT,
|
||||||
|
status: EventStatus.SCHEDULED,
|
||||||
|
pickupLocation: 'VIP Lodge',
|
||||||
|
dropoffLocation: 'Airport - Departures',
|
||||||
|
startTime: start,
|
||||||
|
endTime: end,
|
||||||
|
description: `Transport ${vip.name} to airport for departure flight`,
|
||||||
|
notes: 'Confirm flight status before pickup',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ADD MULTI-VIP GROUP EVENTS
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
if (vips.length >= 4) {
|
||||||
|
// Group transport with multiple VIPs
|
||||||
|
{
|
||||||
|
const start = this.relativeTime(45);
|
||||||
|
const end = this.relativeTime(60);
|
||||||
|
const driver = findAvailableDriver(start, end, 0);
|
||||||
|
// Find a large vehicle (bus or van with capacity >= 8)
|
||||||
|
const largeVehicle = vehicles.find(v =>
|
||||||
|
v.seatCapacity >= 8 && !hasConflict(vehicleSchedule.get(v.id) || [], start, end)
|
||||||
|
);
|
||||||
|
if (largeVehicle) {
|
||||||
|
vehicleSchedule.get(largeVehicle.id)?.push({ start, end });
|
||||||
|
}
|
||||||
|
events.push({
|
||||||
|
vipIds: [vips[0].id, vips[1].id, vips[2].id],
|
||||||
|
driverId: driver?.id,
|
||||||
|
vehicleId: largeVehicle?.id,
|
||||||
|
title: 'Group Transport - Leadership Briefing',
|
||||||
|
type: EventType.TRANSPORT,
|
||||||
|
status: EventStatus.SCHEDULED,
|
||||||
|
pickupLocation: 'VIP Lodge - Main Entrance',
|
||||||
|
dropoffLocation: 'National HQ Building',
|
||||||
|
startTime: start,
|
||||||
|
endTime: end,
|
||||||
|
description: 'Multi-VIP transport for leadership briefing session',
|
||||||
|
notes: 'IMPORTANT: Picking up 3 VIPs - use large vehicle',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Another group event
|
||||||
|
if (vips.length >= 5) {
|
||||||
|
const start = this.relativeTime(90);
|
||||||
|
const end = this.relativeTime(110);
|
||||||
|
const driver = findAvailableDriver(start, end, 1);
|
||||||
|
const vehicle = findAvailableVehicle(start, end, 1);
|
||||||
|
events.push({
|
||||||
|
vipIds: [vips[3].id, vips[4].id],
|
||||||
|
driverId: driver?.id,
|
||||||
|
vehicleId: vehicle?.id,
|
||||||
|
title: 'Group Transport - Media Tour',
|
||||||
|
type: EventType.TRANSPORT,
|
||||||
|
status: EventStatus.SCHEDULED,
|
||||||
|
pickupLocation: 'Media Center',
|
||||||
|
dropoffLocation: 'Historical Site',
|
||||||
|
startTime: start,
|
||||||
|
endTime: end,
|
||||||
|
description: 'VIP media tour with photo opportunities',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ADD SOME CANCELLED EVENTS FOR REALISM
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
if (vips.length >= 6) {
|
||||||
|
events.push({
|
||||||
|
vipIds: [vips[5].id],
|
||||||
|
title: 'Private Meeting - CANCELLED',
|
||||||
|
type: EventType.MEETING,
|
||||||
|
status: EventStatus.CANCELLED,
|
||||||
|
location: 'Conference Room B',
|
||||||
|
startTime: this.relativeTime(200),
|
||||||
|
endTime: this.relativeTime(260),
|
||||||
|
description: 'Meeting cancelled due to schedule conflict',
|
||||||
|
notes: 'VIP requested reschedule for tomorrow',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// HELPER METHODS
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a date relative to now
|
||||||
|
* @param minutesOffset - Minutes from now (negative = past, positive = future)
|
||||||
|
*/
|
||||||
|
private relativeTime(minutesOffset: number): Date {
|
||||||
|
return new Date(Date.now() + minutesOffset * 60 * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
105
backend/src/settings/dto/update-pdf-settings.dto.ts
Normal file
105
backend/src/settings/dto/update-pdf-settings.dto.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsEmail,
|
||||||
|
IsBoolean,
|
||||||
|
IsEnum,
|
||||||
|
IsOptional,
|
||||||
|
IsHexColor,
|
||||||
|
MaxLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { PageSize } from '@prisma/client';
|
||||||
|
|
||||||
|
export class UpdatePdfSettingsDto {
|
||||||
|
// Branding
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
organizationName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsHexColor()
|
||||||
|
accentColor?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
tagline?: string;
|
||||||
|
|
||||||
|
// Contact Info
|
||||||
|
@IsOptional()
|
||||||
|
@IsEmail()
|
||||||
|
contactEmail?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
contactPhone?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
secondaryContactName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
secondaryContactPhone?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
contactLabel?: string;
|
||||||
|
|
||||||
|
// Document Options
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
showDraftWatermark?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
showConfidentialWatermark?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
showTimestamp?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
showAppUrl?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(PageSize)
|
||||||
|
pageSize?: PageSize;
|
||||||
|
|
||||||
|
// Content Toggles
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
showFlightInfo?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
showDriverNames?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
showVehicleNames?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
showVipNotes?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
showEventDescriptions?: boolean;
|
||||||
|
|
||||||
|
// Custom Text
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
headerMessage?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
footerMessage?: string;
|
||||||
|
}
|
||||||
61
backend/src/settings/settings.controller.ts
Normal file
61
backend/src/settings/settings.controller.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
UploadedFile,
|
||||||
|
ParseFilePipe,
|
||||||
|
MaxFileSizeValidator,
|
||||||
|
FileTypeValidator,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { SettingsService } from './settings.service';
|
||||||
|
import { UpdatePdfSettingsDto } from './dto/update-pdf-settings.dto';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { AbilitiesGuard } from '../auth/guards/abilities.guard';
|
||||||
|
import { CanUpdate } from '../auth/decorators/check-ability.decorator';
|
||||||
|
|
||||||
|
@Controller('settings')
|
||||||
|
@UseGuards(JwtAuthGuard, AbilitiesGuard)
|
||||||
|
export class SettingsController {
|
||||||
|
constructor(private readonly settingsService: SettingsService) {}
|
||||||
|
|
||||||
|
@Get('pdf')
|
||||||
|
@CanUpdate('Settings') // Admin-only (Settings subject is admin-only)
|
||||||
|
getPdfSettings() {
|
||||||
|
return this.settingsService.getPdfSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('pdf')
|
||||||
|
@CanUpdate('Settings')
|
||||||
|
updatePdfSettings(@Body() dto: UpdatePdfSettingsDto) {
|
||||||
|
return this.settingsService.updatePdfSettings(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('pdf/logo')
|
||||||
|
@CanUpdate('Settings')
|
||||||
|
@UseInterceptors(FileInterceptor('logo'))
|
||||||
|
uploadLogo(
|
||||||
|
@UploadedFile(
|
||||||
|
new ParseFilePipe({
|
||||||
|
validators: [
|
||||||
|
new MaxFileSizeValidator({ maxSize: 2 * 1024 * 1024 }), // 2MB
|
||||||
|
new FileTypeValidator({ fileType: /(png|jpeg|jpg|svg\+xml)/ }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
file: Express.Multer.File,
|
||||||
|
) {
|
||||||
|
return this.settingsService.uploadLogo(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('pdf/logo')
|
||||||
|
@CanUpdate('Settings')
|
||||||
|
deleteLogo() {
|
||||||
|
return this.settingsService.deleteLogo();
|
||||||
|
}
|
||||||
|
}
|
||||||
13
backend/src/settings/settings.module.ts
Normal file
13
backend/src/settings/settings.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { SettingsController } from './settings.controller';
|
||||||
|
import { SettingsService } from './settings.service';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, AuthModule],
|
||||||
|
controllers: [SettingsController],
|
||||||
|
providers: [SettingsService],
|
||||||
|
exports: [SettingsService],
|
||||||
|
})
|
||||||
|
export class SettingsModule {}
|
||||||
139
backend/src/settings/settings.service.ts
Normal file
139
backend/src/settings/settings.service.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
BadRequestException,
|
||||||
|
InternalServerErrorException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { UpdatePdfSettingsDto } from './dto/update-pdf-settings.dto';
|
||||||
|
import { PdfSettings } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SettingsService {
|
||||||
|
private readonly logger = new Logger(SettingsService.name);
|
||||||
|
private readonly MAX_LOGO_SIZE = 2 * 1024 * 1024; // 2MB in bytes
|
||||||
|
private readonly ALLOWED_MIME_TYPES = ['image/png', 'image/jpeg', 'image/svg+xml'];
|
||||||
|
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get PDF settings - creates default if none exist (singleton pattern)
|
||||||
|
*/
|
||||||
|
async getPdfSettings(): Promise<PdfSettings> {
|
||||||
|
this.logger.log('Fetching PDF settings');
|
||||||
|
|
||||||
|
let settings = await this.prisma.pdfSettings.findFirst();
|
||||||
|
|
||||||
|
if (!settings) {
|
||||||
|
this.logger.log('No settings found, creating defaults');
|
||||||
|
settings = await this.prisma.pdfSettings.create({
|
||||||
|
data: {
|
||||||
|
organizationName: 'VIP Coordinator',
|
||||||
|
accentColor: '#2c3e50',
|
||||||
|
contactEmail: 'contact@example.com',
|
||||||
|
contactPhone: '555-0100',
|
||||||
|
contactLabel: 'Questions or Changes?',
|
||||||
|
pageSize: 'LETTER',
|
||||||
|
showDraftWatermark: false,
|
||||||
|
showConfidentialWatermark: false,
|
||||||
|
showTimestamp: true,
|
||||||
|
showAppUrl: false,
|
||||||
|
showFlightInfo: true,
|
||||||
|
showDriverNames: true,
|
||||||
|
showVehicleNames: true,
|
||||||
|
showVipNotes: true,
|
||||||
|
showEventDescriptions: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.logger.log(`Created default settings: ${settings.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update PDF settings
|
||||||
|
*/
|
||||||
|
async updatePdfSettings(dto: UpdatePdfSettingsDto): Promise<PdfSettings> {
|
||||||
|
this.logger.log('Updating PDF settings');
|
||||||
|
|
||||||
|
// Get existing settings (or create if none exist)
|
||||||
|
const existing = await this.getPdfSettings();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await this.prisma.pdfSettings.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: dto,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Settings updated: ${updated.id}`);
|
||||||
|
return updated;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to update settings: ${error.message}`);
|
||||||
|
throw new InternalServerErrorException('Failed to update PDF settings');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload logo as base64 data URL
|
||||||
|
*/
|
||||||
|
async uploadLogo(file: Express.Multer.File): Promise<PdfSettings> {
|
||||||
|
this.logger.log(`Uploading logo: ${file.originalname} (${file.size} bytes)`);
|
||||||
|
|
||||||
|
// Validate file size
|
||||||
|
if (file.size > this.MAX_LOGO_SIZE) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Logo file too large. Maximum size is ${this.MAX_LOGO_SIZE / 1024 / 1024}MB`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate MIME type
|
||||||
|
if (!this.ALLOWED_MIME_TYPES.includes(file.mimetype)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Invalid file type. Allowed types: PNG, JPG, SVG`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to base64 data URL
|
||||||
|
const base64 = file.buffer.toString('base64');
|
||||||
|
const dataUrl = `data:${file.mimetype};base64,${base64}`;
|
||||||
|
|
||||||
|
// Get existing settings
|
||||||
|
const existing = await this.getPdfSettings();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await this.prisma.pdfSettings.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { logoUrl: dataUrl },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Logo uploaded: ${file.originalname}`);
|
||||||
|
return updated;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to upload logo: ${error.message}`);
|
||||||
|
throw new InternalServerErrorException('Failed to upload logo');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete logo
|
||||||
|
*/
|
||||||
|
async deleteLogo(): Promise<PdfSettings> {
|
||||||
|
this.logger.log('Deleting logo');
|
||||||
|
|
||||||
|
const existing = await this.getPdfSettings();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await this.prisma.pdfSettings.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { logoUrl: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log('Logo deleted');
|
||||||
|
return updated;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to delete logo: ${error.message}`);
|
||||||
|
throw new InternalServerErrorException('Failed to delete logo');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
200
backend/src/signal/messages.controller.ts
Normal file
200
backend/src/signal/messages.controller.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
Logger,
|
||||||
|
Res,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Response } from 'express';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
|
import { Public } from '../auth/decorators/public.decorator';
|
||||||
|
import { MessagesService, SendMessageDto } from './messages.service';
|
||||||
|
|
||||||
|
// DTO for incoming Signal webhook
|
||||||
|
interface SignalWebhookPayload {
|
||||||
|
envelope: {
|
||||||
|
source: string;
|
||||||
|
sourceNumber?: string;
|
||||||
|
sourceName?: string;
|
||||||
|
timestamp: number;
|
||||||
|
dataMessage?: {
|
||||||
|
timestamp: number;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
account: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('signal/messages')
|
||||||
|
export class MessagesController {
|
||||||
|
private readonly logger = new Logger(MessagesController.name);
|
||||||
|
|
||||||
|
constructor(private readonly messagesService: MessagesService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get messages for a specific driver
|
||||||
|
*/
|
||||||
|
@Get('driver/:driverId')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('ADMINISTRATOR', 'COORDINATOR')
|
||||||
|
async getMessagesForDriver(
|
||||||
|
@Param('driverId') driverId: string,
|
||||||
|
@Query('limit') limit?: string,
|
||||||
|
) {
|
||||||
|
const messages = await this.messagesService.getMessagesForDriver(
|
||||||
|
driverId,
|
||||||
|
limit ? parseInt(limit, 10) : 50,
|
||||||
|
);
|
||||||
|
// Return in chronological order for display
|
||||||
|
return messages.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message to a driver
|
||||||
|
*/
|
||||||
|
@Post('send')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('ADMINISTRATOR', 'COORDINATOR')
|
||||||
|
async sendMessage(@Body() dto: SendMessageDto) {
|
||||||
|
return this.messagesService.sendMessage(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark messages as read for a driver
|
||||||
|
*/
|
||||||
|
@Post('driver/:driverId/read')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('ADMINISTRATOR', 'COORDINATOR')
|
||||||
|
async markAsRead(@Param('driverId') driverId: string) {
|
||||||
|
const result = await this.messagesService.markMessagesAsRead(driverId);
|
||||||
|
return { success: true, count: result.count };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unread message counts for all drivers
|
||||||
|
*/
|
||||||
|
@Get('unread')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('ADMINISTRATOR', 'COORDINATOR')
|
||||||
|
async getUnreadCounts() {
|
||||||
|
return this.messagesService.getUnreadCounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unread count for a specific driver
|
||||||
|
*/
|
||||||
|
@Get('driver/:driverId/unread')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('ADMINISTRATOR', 'COORDINATOR')
|
||||||
|
async getUnreadCountForDriver(@Param('driverId') driverId: string) {
|
||||||
|
const count = await this.messagesService.getUnreadCountForDriver(driverId);
|
||||||
|
return { driverId, unread: count };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Webhook endpoint for incoming Signal messages
|
||||||
|
* This is called by signal-cli-rest-api when messages are received
|
||||||
|
* Public endpoint - no authentication required
|
||||||
|
*/
|
||||||
|
@Public()
|
||||||
|
@Post('webhook')
|
||||||
|
async handleWebhook(@Body() payload: SignalWebhookPayload) {
|
||||||
|
this.logger.debug('Received Signal webhook:', JSON.stringify(payload));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const envelope = payload.envelope;
|
||||||
|
|
||||||
|
if (!envelope?.dataMessage?.message) {
|
||||||
|
this.logger.debug('Webhook received but no message content');
|
||||||
|
return { success: true, message: 'No message content' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromNumber = envelope.sourceNumber || envelope.source;
|
||||||
|
const content = envelope.dataMessage.message;
|
||||||
|
const timestamp = envelope.dataMessage.timestamp?.toString();
|
||||||
|
|
||||||
|
const message = await this.messagesService.processIncomingMessage(
|
||||||
|
fromNumber,
|
||||||
|
content,
|
||||||
|
timestamp,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (message) {
|
||||||
|
return { success: true, messageId: message.id };
|
||||||
|
} else {
|
||||||
|
return { success: true, message: 'Unknown sender' };
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error('Failed to process webhook:', error.message);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export all messages as a text file
|
||||||
|
*/
|
||||||
|
@Get('export')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('ADMINISTRATOR')
|
||||||
|
async exportMessages(@Res() res: Response) {
|
||||||
|
const exportData = await this.messagesService.exportAllMessages();
|
||||||
|
|
||||||
|
const filename = `signal-chats-${new Date().toISOString().split('T')[0]}.txt`;
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
res.send(exportData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete all messages
|
||||||
|
*/
|
||||||
|
@Delete('all')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('ADMINISTRATOR')
|
||||||
|
async deleteAllMessages() {
|
||||||
|
const count = await this.messagesService.deleteAllMessages();
|
||||||
|
return { success: true, deleted: count };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get message statistics
|
||||||
|
*/
|
||||||
|
@Get('stats')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('ADMINISTRATOR', 'COORDINATOR')
|
||||||
|
async getMessageStats() {
|
||||||
|
return this.messagesService.getMessageStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check which events have driver responses since the event started
|
||||||
|
* Used to determine if the "awaiting response" glow should show
|
||||||
|
*/
|
||||||
|
@Post('check-responses')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('ADMINISTRATOR', 'COORDINATOR')
|
||||||
|
async checkDriverResponses(
|
||||||
|
@Body()
|
||||||
|
body: {
|
||||||
|
events: Array<{ eventId: string; driverId: string; startTime: string }>;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const pairs = body.events.map((e) => ({
|
||||||
|
eventId: e.eventId,
|
||||||
|
driverId: e.driverId,
|
||||||
|
sinceTime: new Date(e.startTime),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const respondedEventIds =
|
||||||
|
await this.messagesService.checkDriverResponsesSince(pairs);
|
||||||
|
return { respondedEventIds };
|
||||||
|
}
|
||||||
|
}
|
||||||
432
backend/src/signal/messages.service.ts
Normal file
432
backend/src/signal/messages.service.ts
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { SignalService } from './signal.service';
|
||||||
|
import { MessageDirection, EventStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
export interface SendMessageDto {
|
||||||
|
driverId: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageWithDriver {
|
||||||
|
id: string;
|
||||||
|
driverId: string;
|
||||||
|
direction: MessageDirection;
|
||||||
|
content: string;
|
||||||
|
timestamp: Date;
|
||||||
|
isRead: boolean;
|
||||||
|
driver: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MessagesService {
|
||||||
|
private readonly logger = new Logger(MessagesService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly signalService: SignalService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all messages for a driver
|
||||||
|
*/
|
||||||
|
async getMessagesForDriver(driverId: string, limit: number = 50) {
|
||||||
|
const driver = await this.prisma.driver.findFirst({
|
||||||
|
where: { id: driverId, deletedAt: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!driver) {
|
||||||
|
throw new NotFoundException(`Driver with ID ${driverId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.signalMessage.findMany({
|
||||||
|
where: { driverId },
|
||||||
|
orderBy: { timestamp: 'desc' },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message to a driver
|
||||||
|
*/
|
||||||
|
async sendMessage(dto: SendMessageDto) {
|
||||||
|
const driver = await this.prisma.driver.findFirst({
|
||||||
|
where: { id: dto.driverId, deletedAt: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!driver) {
|
||||||
|
throw new NotFoundException(`Driver with ID ${dto.driverId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the linked Signal number
|
||||||
|
const fromNumber = await this.signalService.getLinkedNumber();
|
||||||
|
if (!fromNumber) {
|
||||||
|
throw new Error('No Signal account linked. Please link an account in Admin Tools.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check driver has a phone number
|
||||||
|
if (!driver.phone) {
|
||||||
|
throw new Error('Driver does not have a phone number configured.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format the driver's phone number
|
||||||
|
const toNumber = this.signalService.formatPhoneNumber(driver.phone);
|
||||||
|
|
||||||
|
// Send via Signal
|
||||||
|
const result = await this.signalService.sendMessage(fromNumber, toNumber, dto.content);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to send message via Signal');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the message in database
|
||||||
|
const message = await this.prisma.signalMessage.create({
|
||||||
|
data: {
|
||||||
|
driverId: dto.driverId,
|
||||||
|
direction: MessageDirection.OUTBOUND,
|
||||||
|
content: dto.content,
|
||||||
|
isRead: true, // Outbound messages are always "read"
|
||||||
|
signalTimestamp: result.timestamp?.toString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Message sent to driver ${driver.name} (${toNumber})`);
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process incoming message from Signal webhook
|
||||||
|
*/
|
||||||
|
async processIncomingMessage(
|
||||||
|
fromNumber: string,
|
||||||
|
content: string,
|
||||||
|
signalTimestamp?: string,
|
||||||
|
) {
|
||||||
|
// Normalize phone number for matching
|
||||||
|
const normalizedPhone = this.normalizePhoneForSearch(fromNumber);
|
||||||
|
|
||||||
|
// Find driver by phone number
|
||||||
|
const driver = await this.prisma.driver.findFirst({
|
||||||
|
where: {
|
||||||
|
deletedAt: null,
|
||||||
|
OR: [
|
||||||
|
{ phone: fromNumber },
|
||||||
|
{ phone: normalizedPhone },
|
||||||
|
{ phone: { contains: normalizedPhone.slice(-10) } }, // Last 10 digits
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!driver) {
|
||||||
|
this.logger.warn(`Received message from unknown number: ${fromNumber}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate message
|
||||||
|
if (signalTimestamp) {
|
||||||
|
const existing = await this.prisma.signalMessage.findFirst({
|
||||||
|
where: {
|
||||||
|
driverId: driver.id,
|
||||||
|
signalTimestamp,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
this.logger.debug(`Duplicate message ignored: ${signalTimestamp}`);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the message
|
||||||
|
const message = await this.prisma.signalMessage.create({
|
||||||
|
data: {
|
||||||
|
driverId: driver.id,
|
||||||
|
direction: MessageDirection.INBOUND,
|
||||||
|
content,
|
||||||
|
isRead: false,
|
||||||
|
signalTimestamp,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Incoming message from driver ${driver.name}: ${content.substring(0, 50)}...`);
|
||||||
|
|
||||||
|
// Check if this is a status response (1, 2, or 3)
|
||||||
|
const trimmedContent = content.trim();
|
||||||
|
if (['1', '2', '3'].includes(trimmedContent)) {
|
||||||
|
await this.processDriverStatusResponse(driver, parseInt(trimmedContent, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a driver's status response (1=Confirmed, 2=Delayed, 3=Issue)
|
||||||
|
*/
|
||||||
|
private async processDriverStatusResponse(driver: any, response: number) {
|
||||||
|
// Find the driver's current IN_PROGRESS event
|
||||||
|
const activeEvent = await this.prisma.scheduleEvent.findFirst({
|
||||||
|
where: {
|
||||||
|
driverId: driver.id,
|
||||||
|
status: EventStatus.IN_PROGRESS,
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
include: { vehicle: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!activeEvent) {
|
||||||
|
// No active event, send a clarification
|
||||||
|
await this.sendAutoReply(driver, 'No active trip found for your response. If you need assistance, please send a message to the coordinator.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
let replyMessage: string;
|
||||||
|
let noteText: string;
|
||||||
|
|
||||||
|
switch (response) {
|
||||||
|
case 1: // Confirmed
|
||||||
|
noteText = `[${now.toLocaleTimeString()}] ✅ Driver confirmed en route`;
|
||||||
|
replyMessage = `✅ Confirmed! Safe travels with your VIP. Reply when completed or if you need assistance.`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 2: // Delayed
|
||||||
|
noteText = `[${now.toLocaleTimeString()}] ⏰ Driver reported DELAY - awaiting details`;
|
||||||
|
replyMessage = `⏰ Delay noted. Please reply with the reason for the delay. The coordinator has been alerted.`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 3: // Issue
|
||||||
|
noteText = `[${now.toLocaleTimeString()}] 🚨 Driver reported ISSUE - needs help`;
|
||||||
|
replyMessage = `🚨 Issue reported! A coordinator will contact you shortly. Please describe the problem in your next message.`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the event with the driver's response
|
||||||
|
await this.prisma.scheduleEvent.update({
|
||||||
|
where: { id: activeEvent.id },
|
||||||
|
data: {
|
||||||
|
notes: activeEvent.notes
|
||||||
|
? `${activeEvent.notes}\n${noteText}`
|
||||||
|
: noteText,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Driver ${driver.name} responded with ${response} for event ${activeEvent.id}`);
|
||||||
|
|
||||||
|
// Send auto-reply
|
||||||
|
await this.sendAutoReply(driver, replyMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an automated reply to a driver
|
||||||
|
*/
|
||||||
|
private async sendAutoReply(driver: any, message: string) {
|
||||||
|
try {
|
||||||
|
const fromNumber = await this.signalService.getLinkedNumber();
|
||||||
|
if (!fromNumber) {
|
||||||
|
this.logger.warn('No Signal account linked, cannot send auto-reply');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toNumber = this.signalService.formatPhoneNumber(driver.phone);
|
||||||
|
await this.signalService.sendMessage(fromNumber, toNumber, message);
|
||||||
|
|
||||||
|
// Store the outbound message
|
||||||
|
await this.prisma.signalMessage.create({
|
||||||
|
data: {
|
||||||
|
driverId: driver.id,
|
||||||
|
direction: MessageDirection.OUTBOUND,
|
||||||
|
content: message,
|
||||||
|
isRead: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Auto-reply sent to driver ${driver.name}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to send auto-reply to driver ${driver.name}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark messages as read for a driver
|
||||||
|
*/
|
||||||
|
async markMessagesAsRead(driverId: string) {
|
||||||
|
return this.prisma.signalMessage.updateMany({
|
||||||
|
where: {
|
||||||
|
driverId,
|
||||||
|
direction: MessageDirection.INBOUND,
|
||||||
|
isRead: false,
|
||||||
|
},
|
||||||
|
data: { isRead: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unread message count per driver
|
||||||
|
*/
|
||||||
|
async getUnreadCounts() {
|
||||||
|
const result = await this.prisma.signalMessage.groupBy({
|
||||||
|
by: ['driverId'],
|
||||||
|
where: {
|
||||||
|
direction: MessageDirection.INBOUND,
|
||||||
|
isRead: false,
|
||||||
|
},
|
||||||
|
_count: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.reduce((acc, item) => {
|
||||||
|
acc[item.driverId] = item._count;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, number>);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unread count for a specific driver
|
||||||
|
*/
|
||||||
|
async getUnreadCountForDriver(driverId: string) {
|
||||||
|
return this.prisma.signalMessage.count({
|
||||||
|
where: {
|
||||||
|
driverId,
|
||||||
|
direction: MessageDirection.INBOUND,
|
||||||
|
isRead: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize phone number for database searching
|
||||||
|
*/
|
||||||
|
private normalizePhoneForSearch(phone: string): string {
|
||||||
|
return phone.replace(/\D/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export all messages as formatted text
|
||||||
|
*/
|
||||||
|
async exportAllMessages(): Promise<string> {
|
||||||
|
const messages = await this.prisma.signalMessage.findMany({
|
||||||
|
include: {
|
||||||
|
driver: {
|
||||||
|
select: { id: true, name: true, phone: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ driverId: 'asc' },
|
||||||
|
{ timestamp: 'asc' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (messages.length === 0) {
|
||||||
|
return 'No messages to export.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group messages by driver
|
||||||
|
const byDriver: Record<string, typeof messages> = {};
|
||||||
|
for (const msg of messages) {
|
||||||
|
const driverId = msg.driverId;
|
||||||
|
if (!byDriver[driverId]) {
|
||||||
|
byDriver[driverId] = [];
|
||||||
|
}
|
||||||
|
byDriver[driverId].push(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format output
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push('='.repeat(60));
|
||||||
|
lines.push('SIGNAL CHAT EXPORT');
|
||||||
|
lines.push(`Exported: ${new Date().toISOString()}`);
|
||||||
|
lines.push(`Total Messages: ${messages.length}`);
|
||||||
|
lines.push('='.repeat(60));
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
for (const [driverId, driverMessages] of Object.entries(byDriver)) {
|
||||||
|
const driver = driverMessages[0]?.driver;
|
||||||
|
lines.push('-'.repeat(60));
|
||||||
|
lines.push(`DRIVER: ${driver?.name || 'Unknown'}`);
|
||||||
|
lines.push(`Phone: ${driver?.phone || 'N/A'}`);
|
||||||
|
lines.push(`Messages: ${driverMessages.length}`);
|
||||||
|
lines.push('-'.repeat(60));
|
||||||
|
|
||||||
|
for (const msg of driverMessages) {
|
||||||
|
const direction = msg.direction === 'INBOUND' ? '← IN ' : '→ OUT';
|
||||||
|
const time = new Date(msg.timestamp).toLocaleString();
|
||||||
|
lines.push(`[${time}] ${direction}: ${msg.content}`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete all messages
|
||||||
|
*/
|
||||||
|
async deleteAllMessages(): Promise<number> {
|
||||||
|
const result = await this.prisma.signalMessage.deleteMany({});
|
||||||
|
this.logger.log(`Deleted ${result.count} messages`);
|
||||||
|
return result.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check which driver-event pairs have driver responses since the event started
|
||||||
|
* @param pairs Array of {driverId, eventId, sinceTime}
|
||||||
|
* @returns Set of eventIds where the driver has responded since sinceTime
|
||||||
|
*/
|
||||||
|
async checkDriverResponsesSince(
|
||||||
|
pairs: Array<{ driverId: string; eventId: string; sinceTime: Date }>,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const respondedEventIds: string[] = [];
|
||||||
|
|
||||||
|
for (const pair of pairs) {
|
||||||
|
const hasResponse = await this.prisma.signalMessage.findFirst({
|
||||||
|
where: {
|
||||||
|
driverId: pair.driverId,
|
||||||
|
direction: MessageDirection.INBOUND,
|
||||||
|
timestamp: { gte: pair.sinceTime },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasResponse) {
|
||||||
|
respondedEventIds.push(pair.eventId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return respondedEventIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get message statistics
|
||||||
|
*/
|
||||||
|
async getMessageStats() {
|
||||||
|
const [total, inbound, outbound, unread] = await Promise.all([
|
||||||
|
this.prisma.signalMessage.count(),
|
||||||
|
this.prisma.signalMessage.count({
|
||||||
|
where: { direction: MessageDirection.INBOUND },
|
||||||
|
}),
|
||||||
|
this.prisma.signalMessage.count({
|
||||||
|
where: { direction: MessageDirection.OUTBOUND },
|
||||||
|
}),
|
||||||
|
this.prisma.signalMessage.count({
|
||||||
|
where: { direction: MessageDirection.INBOUND, isRead: false },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const driversWithMessages = await this.prisma.signalMessage.groupBy({
|
||||||
|
by: ['driverId'],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
inbound,
|
||||||
|
outbound,
|
||||||
|
unread,
|
||||||
|
driversWithMessages: driversWithMessages.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
115
backend/src/signal/signal-polling.service.ts
Normal file
115
backend/src/signal/signal-polling.service.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { SignalService } from './signal.service';
|
||||||
|
import { MessagesService } from './messages.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SignalPollingService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(SignalPollingService.name);
|
||||||
|
private pollingInterval: NodeJS.Timeout | null = null;
|
||||||
|
private isPolling = false;
|
||||||
|
|
||||||
|
// Poll every 5 seconds
|
||||||
|
private readonly POLL_INTERVAL_MS = 5000;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly signalService: SignalService,
|
||||||
|
private readonly messagesService: MessagesService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
this.startPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleDestroy() {
|
||||||
|
this.stopPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
private startPolling() {
|
||||||
|
this.logger.log('Starting Signal message polling...');
|
||||||
|
this.pollingInterval = setInterval(() => this.pollMessages(), this.POLL_INTERVAL_MS);
|
||||||
|
// Also poll immediately on startup
|
||||||
|
this.pollMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopPolling() {
|
||||||
|
if (this.pollingInterval) {
|
||||||
|
clearInterval(this.pollingInterval);
|
||||||
|
this.pollingInterval = null;
|
||||||
|
this.logger.log('Stopped Signal message polling');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pollMessages() {
|
||||||
|
// Prevent concurrent polling
|
||||||
|
if (this.isPolling) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isPolling = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const linkedNumber = await this.signalService.getLinkedNumber();
|
||||||
|
if (!linkedNumber) {
|
||||||
|
// No account linked, skip polling
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = await this.signalService.receiveMessages(linkedNumber);
|
||||||
|
|
||||||
|
if (messages && messages.length > 0) {
|
||||||
|
this.logger.log(`Received ${messages.length} message(s) from Signal`);
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
await this.processMessage(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
// Only log errors that aren't connection issues (Signal CLI might not be ready)
|
||||||
|
if (!error.message?.includes('ECONNREFUSED')) {
|
||||||
|
this.logger.error(`Error polling messages: ${error.message}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.isPolling = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processMessage(msg: any) {
|
||||||
|
try {
|
||||||
|
// Signal CLI returns messages in various formats
|
||||||
|
// We're looking for envelope.dataMessage.message
|
||||||
|
const envelope = msg.envelope;
|
||||||
|
|
||||||
|
if (!envelope) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the sender's phone number
|
||||||
|
const fromNumber = envelope.sourceNumber || envelope.source;
|
||||||
|
|
||||||
|
// Check for data message (regular text message)
|
||||||
|
const dataMessage = envelope.dataMessage;
|
||||||
|
if (dataMessage?.message) {
|
||||||
|
const content = dataMessage.message;
|
||||||
|
const timestamp = dataMessage.timestamp?.toString();
|
||||||
|
|
||||||
|
this.logger.debug(`Processing message from ${fromNumber}: ${content.substring(0, 50)}...`);
|
||||||
|
|
||||||
|
await this.messagesService.processIncomingMessage(
|
||||||
|
fromNumber,
|
||||||
|
content,
|
||||||
|
timestamp,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also handle sync messages (messages sent from other linked devices)
|
||||||
|
const syncMessage = envelope.syncMessage;
|
||||||
|
if (syncMessage?.sentMessage?.message) {
|
||||||
|
// This is a message we sent from another device, we can ignore it
|
||||||
|
// or store it if needed
|
||||||
|
this.logger.debug('Received sync message (sent from another device)');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`Error processing message: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
150
backend/src/signal/signal.controller.ts
Normal file
150
backend/src/signal/signal.controller.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
|
import { SignalService, SignalStatus } from './signal.service';
|
||||||
|
|
||||||
|
@Controller('signal')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
export class SignalController {
|
||||||
|
constructor(private readonly signalService: SignalService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Signal connection status
|
||||||
|
*/
|
||||||
|
@Get('status')
|
||||||
|
@Roles('ADMINISTRATOR')
|
||||||
|
async getStatus(): Promise<SignalStatus> {
|
||||||
|
return this.signalService.getStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get QR code for linking device
|
||||||
|
*/
|
||||||
|
@Get('qrcode')
|
||||||
|
@Roles('ADMINISTRATOR')
|
||||||
|
async getQRCode() {
|
||||||
|
const result = await this.signalService.getQRCodeLink();
|
||||||
|
if (!result) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Device already linked. Unlink first to re-link.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
qrcode: result.qrcode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new phone number
|
||||||
|
*/
|
||||||
|
@Post('register')
|
||||||
|
@Roles('ADMINISTRATOR')
|
||||||
|
async registerNumber(@Body() body: { phoneNumber: string; captcha?: string }) {
|
||||||
|
return this.signalService.registerNumber(body.phoneNumber, body.captcha);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify phone number with code
|
||||||
|
*/
|
||||||
|
@Post('verify')
|
||||||
|
@Roles('ADMINISTRATOR')
|
||||||
|
async verifyNumber(@Body() body: { phoneNumber: string; code: string }) {
|
||||||
|
return this.signalService.verifyNumber(body.phoneNumber, body.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlink the current account
|
||||||
|
*/
|
||||||
|
@Delete('unlink/:phoneNumber')
|
||||||
|
@Roles('ADMINISTRATOR')
|
||||||
|
async unlinkAccount(@Param('phoneNumber') phoneNumber: string) {
|
||||||
|
return this.signalService.unlinkAccount(phoneNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a test message
|
||||||
|
*/
|
||||||
|
@Post('send')
|
||||||
|
@Roles('ADMINISTRATOR')
|
||||||
|
async sendMessage(@Body() body: { to: string; message: string }) {
|
||||||
|
const fromNumber = await this.signalService.getLinkedNumber();
|
||||||
|
if (!fromNumber) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'No Signal account linked. Please link an account first.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedTo = this.signalService.formatPhoneNumber(body.to);
|
||||||
|
return this.signalService.sendMessage(fromNumber, formattedTo, body.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send message to multiple recipients
|
||||||
|
*/
|
||||||
|
@Post('send-bulk')
|
||||||
|
@Roles('ADMINISTRATOR', 'COORDINATOR')
|
||||||
|
async sendBulkMessage(@Body() body: { recipients: string[]; message: string }) {
|
||||||
|
const fromNumber = await this.signalService.getLinkedNumber();
|
||||||
|
if (!fromNumber) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'No Signal account linked. Please link an account first.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedRecipients = body.recipients.map((r) =>
|
||||||
|
this.signalService.formatPhoneNumber(r),
|
||||||
|
);
|
||||||
|
return this.signalService.sendBulkMessage(
|
||||||
|
fromNumber,
|
||||||
|
formattedRecipients,
|
||||||
|
body.message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a PDF or file attachment via Signal
|
||||||
|
*/
|
||||||
|
@Post('send-attachment')
|
||||||
|
@Roles('ADMINISTRATOR', 'COORDINATOR')
|
||||||
|
async sendAttachment(
|
||||||
|
@Body()
|
||||||
|
body: {
|
||||||
|
to: string;
|
||||||
|
message?: string;
|
||||||
|
attachment: string; // Base64 encoded file
|
||||||
|
filename: string;
|
||||||
|
mimeType?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const fromNumber = await this.signalService.getLinkedNumber();
|
||||||
|
if (!fromNumber) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'No Signal account linked. Please link an account first.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedTo = this.signalService.formatPhoneNumber(body.to);
|
||||||
|
return this.signalService.sendMessageWithAttachment(
|
||||||
|
fromNumber,
|
||||||
|
formattedTo,
|
||||||
|
body.message || '',
|
||||||
|
body.attachment,
|
||||||
|
body.filename,
|
||||||
|
body.mimeType || 'application/pdf',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
backend/src/signal/signal.module.ts
Normal file
15
backend/src/signal/signal.module.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
import { SignalService } from './signal.service';
|
||||||
|
import { SignalController } from './signal.controller';
|
||||||
|
import { MessagesService } from './messages.service';
|
||||||
|
import { MessagesController } from './messages.controller';
|
||||||
|
import { SignalPollingService } from './signal-polling.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
controllers: [SignalController, MessagesController],
|
||||||
|
providers: [SignalService, MessagesService, SignalPollingService],
|
||||||
|
exports: [SignalService, MessagesService],
|
||||||
|
})
|
||||||
|
export class SignalModule {}
|
||||||
327
backend/src/signal/signal.service.ts
Normal file
327
backend/src/signal/signal.service.ts
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import axios, { AxiosInstance } from 'axios';
|
||||||
|
|
||||||
|
interface SignalAccount {
|
||||||
|
number: string;
|
||||||
|
uuid: string;
|
||||||
|
username?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignalStatus {
|
||||||
|
isConnected: boolean;
|
||||||
|
isLinked: boolean;
|
||||||
|
phoneNumber: string | null;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QRCodeResponse {
|
||||||
|
qrcode: string;
|
||||||
|
expiresAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SignalService {
|
||||||
|
private readonly logger = new Logger(SignalService.name);
|
||||||
|
private readonly client: AxiosInstance;
|
||||||
|
private readonly baseUrl: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.baseUrl = process.env.SIGNAL_API_URL || 'http://localhost:8080';
|
||||||
|
this.client = axios.create({
|
||||||
|
baseURL: this.baseUrl,
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Signal API is available and get connection status
|
||||||
|
*/
|
||||||
|
async getStatus(): Promise<SignalStatus> {
|
||||||
|
try {
|
||||||
|
// Check if API is reachable
|
||||||
|
const response = await this.client.get('/v1/about');
|
||||||
|
|
||||||
|
// Try to get registered accounts
|
||||||
|
// API returns array of phone number strings: ["+1234567890"]
|
||||||
|
const accountsResponse = await this.client.get('/v1/accounts');
|
||||||
|
const accounts: string[] = accountsResponse.data;
|
||||||
|
|
||||||
|
if (accounts.length > 0) {
|
||||||
|
return {
|
||||||
|
isConnected: true,
|
||||||
|
isLinked: true,
|
||||||
|
phoneNumber: accounts[0],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isConnected: true,
|
||||||
|
isLinked: false,
|
||||||
|
phoneNumber: null,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error('Failed to connect to Signal API:', error.message);
|
||||||
|
return {
|
||||||
|
isConnected: false,
|
||||||
|
isLinked: false,
|
||||||
|
phoneNumber: null,
|
||||||
|
error: error.code === 'ECONNREFUSED'
|
||||||
|
? 'Signal API container is not running'
|
||||||
|
: error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get QR code for linking a new device
|
||||||
|
*/
|
||||||
|
async getQRCodeLink(deviceName: string = 'VIP Coordinator'): Promise<QRCodeResponse | null> {
|
||||||
|
try {
|
||||||
|
// First check if already linked
|
||||||
|
const status = await this.getStatus();
|
||||||
|
if (status.isLinked) {
|
||||||
|
this.logger.warn('Device already linked to Signal');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request QR code for device linking - returns raw PNG image
|
||||||
|
const response = await this.client.get('/v1/qrcodelink', {
|
||||||
|
params: { device_name: deviceName },
|
||||||
|
timeout: 60000, // QR generation can take a moment
|
||||||
|
responseType: 'arraybuffer', // Get raw binary data
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert to base64
|
||||||
|
const base64 = Buffer.from(response.data, 'binary').toString('base64');
|
||||||
|
|
||||||
|
return {
|
||||||
|
qrcode: base64,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error('Failed to get QR code:', error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new phone number (requires verification)
|
||||||
|
*/
|
||||||
|
async registerNumber(phoneNumber: string, captcha?: string): Promise<{ success: boolean; message: string }> {
|
||||||
|
try {
|
||||||
|
const response = await this.client.post(`/v1/register/${phoneNumber}`, {
|
||||||
|
captcha,
|
||||||
|
use_voice: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Verification code sent. Check your phone.',
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error('Failed to register number:', error.message);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.error || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a phone number with the code received
|
||||||
|
*/
|
||||||
|
async verifyNumber(phoneNumber: string, verificationCode: string): Promise<{ success: boolean; message: string }> {
|
||||||
|
try {
|
||||||
|
const response = await this.client.post(`/v1/register/${phoneNumber}/verify/${verificationCode}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Phone number verified and linked successfully!',
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error('Failed to verify number:', error.message);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.error || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlink/unregister the current account
|
||||||
|
*/
|
||||||
|
async unlinkAccount(phoneNumber: string): Promise<{ success: boolean; message: string }> {
|
||||||
|
try {
|
||||||
|
await this.client.delete(`/v1/accounts/${phoneNumber}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Account unlinked successfully',
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error('Failed to unlink account:', error.message);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.error || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message to a recipient
|
||||||
|
*/
|
||||||
|
async sendMessage(
|
||||||
|
fromNumber: string,
|
||||||
|
toNumber: string,
|
||||||
|
message: string,
|
||||||
|
): Promise<{ success: boolean; timestamp?: number; error?: string }> {
|
||||||
|
try {
|
||||||
|
const response = await this.client.post(`/v2/send`, {
|
||||||
|
number: fromNumber,
|
||||||
|
recipients: [toNumber],
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Message sent to ${toNumber}`);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
timestamp: response.data.timestamp,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`Failed to send message to ${toNumber}:`, error.message);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.error || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message to multiple recipients
|
||||||
|
*/
|
||||||
|
async sendBulkMessage(
|
||||||
|
fromNumber: string,
|
||||||
|
toNumbers: string[],
|
||||||
|
message: string,
|
||||||
|
): Promise<{ success: boolean; sent: number; failed: number; errors: string[] }> {
|
||||||
|
const results = {
|
||||||
|
success: true,
|
||||||
|
sent: 0,
|
||||||
|
failed: 0,
|
||||||
|
errors: [] as string[],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const toNumber of toNumbers) {
|
||||||
|
const result = await this.sendMessage(fromNumber, toNumber, message);
|
||||||
|
if (result.success) {
|
||||||
|
results.sent++;
|
||||||
|
} else {
|
||||||
|
results.failed++;
|
||||||
|
results.errors.push(`${toNumber}: ${result.error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results.success = results.failed === 0;
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the linked phone number (if any)
|
||||||
|
*/
|
||||||
|
async getLinkedNumber(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const response = await this.client.get('/v1/accounts');
|
||||||
|
// API returns array of phone number strings directly: ["+1234567890"]
|
||||||
|
const accounts: string[] = response.data;
|
||||||
|
|
||||||
|
if (accounts.length > 0) {
|
||||||
|
return accounts[0];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format phone number for Signal (must include country code)
|
||||||
|
*/
|
||||||
|
formatPhoneNumber(phone: string): string {
|
||||||
|
// Remove all non-digit characters
|
||||||
|
let cleaned = phone.replace(/\D/g, '');
|
||||||
|
|
||||||
|
// Add US country code if not present
|
||||||
|
if (cleaned.length === 10) {
|
||||||
|
cleaned = '1' + cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add + prefix
|
||||||
|
if (!cleaned.startsWith('+')) {
|
||||||
|
cleaned = '+' + cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Receive pending messages for the account
|
||||||
|
* This fetches and removes messages from Signal's queue
|
||||||
|
*/
|
||||||
|
async receiveMessages(phoneNumber: string): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
const response = await this.client.get(`/v1/receive/${phoneNumber}`, {
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Response is an array of message envelopes
|
||||||
|
return response.data || [];
|
||||||
|
} catch (error: any) {
|
||||||
|
// Don't log timeout errors or empty responses as errors
|
||||||
|
if (error.code === 'ECONNABORTED' || error.response?.status === 204) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message with a file attachment (PDF, image, etc.)
|
||||||
|
* @param fromNumber - The sender's phone number
|
||||||
|
* @param toNumber - The recipient's phone number
|
||||||
|
* @param message - Optional text message to accompany the attachment
|
||||||
|
* @param attachment - Base64 encoded file data
|
||||||
|
* @param filename - Name for the file
|
||||||
|
* @param mimeType - MIME type of the file (e.g., 'application/pdf')
|
||||||
|
*/
|
||||||
|
async sendMessageWithAttachment(
|
||||||
|
fromNumber: string,
|
||||||
|
toNumber: string,
|
||||||
|
message: string,
|
||||||
|
attachment: string,
|
||||||
|
filename: string,
|
||||||
|
mimeType: string = 'application/pdf',
|
||||||
|
): Promise<{ success: boolean; timestamp?: number; error?: string }> {
|
||||||
|
try {
|
||||||
|
// Format: data:<MIME-TYPE>;filename=<FILENAME>;base64,<BASE64 ENCODED DATA>
|
||||||
|
const base64Attachment = `data:${mimeType};filename=${filename};base64,${attachment}`;
|
||||||
|
|
||||||
|
const response = await this.client.post(`/v2/send`, {
|
||||||
|
number: fromNumber,
|
||||||
|
recipients: [toNumber],
|
||||||
|
message: message || '',
|
||||||
|
base64_attachments: [base64Attachment],
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Message with attachment sent to ${toNumber}: ${filename}`);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
timestamp: response.data.timestamp,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`Failed to send attachment to ${toNumber}:`, error.message);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.error || error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,8 +35,22 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Signal CLI REST API for messaging
|
||||||
|
signal-api:
|
||||||
|
image: bbernhard/signal-cli-rest-api:latest
|
||||||
|
container_name: vip-signal
|
||||||
|
environment:
|
||||||
|
- MODE=native
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- signal_data:/home/.local/share/signal-cli
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
name: vip_postgres_data
|
name: vip_postgres_data
|
||||||
redis_data:
|
redis_data:
|
||||||
name: vip_redis_data
|
name: vip_redis_data
|
||||||
|
signal_data:
|
||||||
|
name: vip_signal_data
|
||||||
|
|||||||
@@ -8,3 +8,8 @@ VITE_API_URL=http://localhost:3000/api/v1
|
|||||||
VITE_AUTH0_DOMAIN=your-tenant.us.auth0.com
|
VITE_AUTH0_DOMAIN=your-tenant.us.auth0.com
|
||||||
VITE_AUTH0_CLIENT_ID=your-auth0-client-id
|
VITE_AUTH0_CLIENT_ID=your-auth0-client-id
|
||||||
VITE_AUTH0_AUDIENCE=https://your-api-identifier
|
VITE_AUTH0_AUDIENCE=https://your-api-identifier
|
||||||
|
|
||||||
|
# Organization Contact Information (for PDF exports)
|
||||||
|
VITE_CONTACT_EMAIL=coordinator@example.com
|
||||||
|
VITE_CONTACT_PHONE=(555) 123-4567
|
||||||
|
VITE_ORGANIZATION_NAME=VIP Coordinator
|
||||||
|
|||||||
@@ -5,6 +5,19 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>VIP Coordinator</title>
|
<title>VIP Coordinator</title>
|
||||||
|
<!-- Prevent FOUC (Flash of Unstyled Content) for theme -->
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
try {
|
||||||
|
var stored = localStorage.getItem('vip-theme');
|
||||||
|
var theme = stored ? JSON.parse(stored) : { mode: 'system', colorScheme: 'blue' };
|
||||||
|
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
var isDark = theme.mode === 'dark' || (theme.mode === 'system' && prefersDark);
|
||||||
|
if (isDark) document.documentElement.classList.add('dark');
|
||||||
|
if (theme.colorScheme) document.documentElement.dataset.theme = theme.colorScheme;
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
1738
frontend/package-lock.json
generated
1738
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@
|
|||||||
"@casl/ability": "^6.8.0",
|
"@casl/ability": "^6.8.0",
|
||||||
"@casl/react": "^5.0.1",
|
"@casl/react": "^5.0.1",
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
|
"@react-pdf/renderer": "^4.3.2",
|
||||||
"@tanstack/react-query": "^5.17.19",
|
"@tanstack/react-query": "^5.17.19",
|
||||||
"axios": "^1.6.5",
|
"axios": "^1.6.5",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
@@ -26,7 +27,9 @@
|
|||||||
"lucide-react": "^0.309.0",
|
"lucide-react": "^0.309.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-hook-form": "^7.71.1",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^6.21.3",
|
"react-router-dom": "^6.21.3",
|
||||||
"tailwind-merge": "^2.2.0"
|
"tailwind-merge": "^2.2.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Auth0Provider } from '@auth0/auth0-react';
|
|||||||
import { Toaster } from 'react-hot-toast';
|
import { Toaster } from 'react-hot-toast';
|
||||||
import { AuthProvider } from '@/contexts/AuthContext';
|
import { AuthProvider } from '@/contexts/AuthContext';
|
||||||
import { AbilityProvider } from '@/contexts/AbilityContext';
|
import { AbilityProvider } from '@/contexts/AbilityContext';
|
||||||
|
import { ThemeProvider } from '@/contexts/ThemeContext';
|
||||||
import { ProtectedRoute } from '@/components/ProtectedRoute';
|
import { ProtectedRoute } from '@/components/ProtectedRoute';
|
||||||
import { Layout } from '@/components/Layout';
|
import { Layout } from '@/components/Layout';
|
||||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||||
@@ -20,6 +21,20 @@ import { EventList } from '@/pages/EventList';
|
|||||||
import { FlightList } from '@/pages/FlightList';
|
import { FlightList } from '@/pages/FlightList';
|
||||||
import { UserList } from '@/pages/UserList';
|
import { UserList } from '@/pages/UserList';
|
||||||
import { AdminTools } from '@/pages/AdminTools';
|
import { AdminTools } from '@/pages/AdminTools';
|
||||||
|
import { DriverProfile } from '@/pages/DriverProfile';
|
||||||
|
import { MySchedule } from '@/pages/MySchedule';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
|
||||||
|
// Smart redirect based on user role
|
||||||
|
function HomeRedirect() {
|
||||||
|
const { backendUser } = useAuth();
|
||||||
|
|
||||||
|
// Drivers go to their schedule, everyone else goes to dashboard
|
||||||
|
if (backendUser?.role === 'DRIVER') {
|
||||||
|
return <Navigate to="/my-schedule" replace />;
|
||||||
|
}
|
||||||
|
return <Navigate to="/dashboard" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -37,6 +52,7 @@ const audience = import.meta.env.VITE_AUTH0_AUDIENCE;
|
|||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
|
<ThemeProvider>
|
||||||
<Auth0Provider
|
<Auth0Provider
|
||||||
domain={domain}
|
domain={domain}
|
||||||
clientId={clientId}
|
clientId={clientId}
|
||||||
@@ -61,22 +77,24 @@ function App() {
|
|||||||
position="top-right"
|
position="top-right"
|
||||||
toastOptions={{
|
toastOptions={{
|
||||||
duration: 4000,
|
duration: 4000,
|
||||||
|
className: 'bg-card text-card-foreground border border-border shadow-elevated',
|
||||||
style: {
|
style: {
|
||||||
background: '#333',
|
background: 'hsl(var(--card))',
|
||||||
color: '#fff',
|
color: 'hsl(var(--card-foreground))',
|
||||||
|
border: '1px solid hsl(var(--border))',
|
||||||
},
|
},
|
||||||
success: {
|
success: {
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
iconTheme: {
|
iconTheme: {
|
||||||
primary: '#10b981',
|
primary: 'hsl(142, 76%, 36%)',
|
||||||
secondary: '#fff',
|
secondary: 'hsl(0, 0%, 100%)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
iconTheme: {
|
iconTheme: {
|
||||||
primary: '#ef4444',
|
primary: 'hsl(0, 84%, 60%)',
|
||||||
secondary: '#fff',
|
secondary: 'hsl(0, 0%, 100%)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@@ -102,8 +120,10 @@ function App() {
|
|||||||
<Route path="/flights" element={<FlightList />} />
|
<Route path="/flights" element={<FlightList />} />
|
||||||
<Route path="/users" element={<UserList />} />
|
<Route path="/users" element={<UserList />} />
|
||||||
<Route path="/admin-tools" element={<AdminTools />} />
|
<Route path="/admin-tools" element={<AdminTools />} />
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/profile" element={<DriverProfile />} />
|
||||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/my-schedule" element={<MySchedule />} />
|
||||||
|
<Route path="/" element={<HomeRedirect />} />
|
||||||
|
<Route path="*" element={<HomeRedirect />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
@@ -115,6 +135,7 @@ function App() {
|
|||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</Auth0Provider>
|
</Auth0Provider>
|
||||||
|
</ThemeProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
375
frontend/src/components/AICopilot.tsx
Normal file
375
frontend/src/components/AICopilot.tsx
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { copilotApi } from '@/lib/api';
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
Send,
|
||||||
|
Bot,
|
||||||
|
User,
|
||||||
|
ImagePlus,
|
||||||
|
Loader2,
|
||||||
|
Sparkles,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
|
||||||
|
interface ContentBlock {
|
||||||
|
type: 'text' | 'image';
|
||||||
|
text?: string;
|
||||||
|
source?: {
|
||||||
|
type: 'base64';
|
||||||
|
media_type: string;
|
||||||
|
data: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: string;
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string | ContentBlock[];
|
||||||
|
timestamp: Date;
|
||||||
|
toolCalls?: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AICopilot() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [pendingImage, setPendingImage] = useState<{ data: string; type: string } | null>(null);
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollToBottom();
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const chatMutation = useMutation({
|
||||||
|
mutationFn: async (chatMessages: { role: string; content: string | ContentBlock[] }[]) => {
|
||||||
|
const { data } = await copilotApi.post('/copilot/chat', { messages: chatMessages });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
const assistantMessage: Message = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
role: 'assistant',
|
||||||
|
content: data.response,
|
||||||
|
timestamp: new Date(),
|
||||||
|
toolCalls: data.toolCalls,
|
||||||
|
};
|
||||||
|
setMessages((prev) => [...prev, assistantMessage]);
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
const errorMessage: Message = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
role: 'assistant',
|
||||||
|
content: `Sorry, I encountered an error: ${error.message || 'Unknown error'}. Please try again.`,
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
setMessages((prev) => [...prev, errorMessage]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSend = () => {
|
||||||
|
if (!input.trim() && !pendingImage) return;
|
||||||
|
|
||||||
|
// Build content
|
||||||
|
let content: string | ContentBlock[];
|
||||||
|
if (pendingImage) {
|
||||||
|
content = [];
|
||||||
|
if (input.trim()) {
|
||||||
|
content.push({ type: 'text', text: input.trim() });
|
||||||
|
}
|
||||||
|
content.push({
|
||||||
|
type: 'image',
|
||||||
|
source: {
|
||||||
|
type: 'base64',
|
||||||
|
media_type: pendingImage.type,
|
||||||
|
data: pendingImage.data,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
content = input.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const userMessage: Message = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
role: 'user',
|
||||||
|
content,
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const newMessages = [...messages, userMessage];
|
||||||
|
setMessages(newMessages);
|
||||||
|
setInput('');
|
||||||
|
setPendingImage(null);
|
||||||
|
|
||||||
|
// Prepare messages for API
|
||||||
|
const apiMessages = newMessages.map((msg) => ({
|
||||||
|
role: msg.role,
|
||||||
|
content: msg.content,
|
||||||
|
}));
|
||||||
|
|
||||||
|
chatMutation.mutate(apiMessages);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const base64 = (reader.result as string).split(',')[1];
|
||||||
|
setPendingImage({
|
||||||
|
data: base64,
|
||||||
|
type: file.type,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearChat = () => {
|
||||||
|
setMessages([]);
|
||||||
|
setPendingImage(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderContent = (content: string | ContentBlock[]) => {
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
return (
|
||||||
|
<ReactMarkdown
|
||||||
|
components={{
|
||||||
|
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
|
||||||
|
ul: ({ children }) => <ul className="list-disc list-inside mb-2">{children}</ul>,
|
||||||
|
ol: ({ children }) => <ol className="list-decimal list-inside mb-2">{children}</ol>,
|
||||||
|
li: ({ children }) => <li className="mb-1">{children}</li>,
|
||||||
|
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
|
||||||
|
code: ({ children }) => (
|
||||||
|
<code className="bg-muted px-1 py-0.5 rounded text-sm">{children}</code>
|
||||||
|
),
|
||||||
|
pre: ({ children }) => (
|
||||||
|
<pre className="bg-muted p-2 rounded text-sm overflow-x-auto my-2">{children}</pre>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{content.map((block, i) => {
|
||||||
|
if (block.type === 'text') {
|
||||||
|
return <p key={i}>{block.text}</p>;
|
||||||
|
}
|
||||||
|
if (block.type === 'image' && block.source) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
key={i}
|
||||||
|
src={`data:${block.source.media_type};base64,${block.source.data}`}
|
||||||
|
alt="Uploaded"
|
||||||
|
className="max-w-full max-h-48 rounded-lg"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Floating button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
className={`fixed bottom-6 right-6 z-50 flex items-center gap-2 px-4 py-3 bg-primary text-primary-foreground rounded-full shadow-elevated hover:bg-primary/90 transition-all ${
|
||||||
|
isOpen ? 'scale-0 opacity-0' : 'scale-100 opacity-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Sparkles className="h-5 w-5" />
|
||||||
|
<span className="font-medium">AI Assistant</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Chat panel */}
|
||||||
|
<div
|
||||||
|
className={`fixed bottom-6 right-6 z-50 w-[400px] h-[600px] max-h-[80vh] bg-card border border-border rounded-2xl shadow-elevated flex flex-col overflow-hidden transition-all duration-300 ${
|
||||||
|
isOpen
|
||||||
|
? 'scale-100 opacity-100'
|
||||||
|
: 'scale-95 opacity-0 pointer-events-none'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 bg-primary text-primary-foreground">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Bot className="h-5 w-5" />
|
||||||
|
<span className="font-semibold">VIP Coordinator AI</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={clearChat}
|
||||||
|
className="p-2 hover:bg-white/20 rounded-lg transition-colors"
|
||||||
|
title="Clear chat"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="p-2 hover:bg-white/20 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<div className="text-center text-muted-foreground py-8">
|
||||||
|
<Bot className="h-12 w-12 mx-auto mb-3 text-muted-foreground/50" />
|
||||||
|
<p className="font-medium mb-2">Hi! I'm your AI assistant.</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
I can help you with VIPs, drivers, events, and more.
|
||||||
|
<br />
|
||||||
|
You can also upload screenshots of emails!
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 space-y-2 text-sm text-left max-w-xs mx-auto">
|
||||||
|
<p className="text-muted-foreground">Try asking:</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setInput("What's happening today?")}
|
||||||
|
className="block w-full text-left px-3 py-2 bg-muted rounded-lg hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
"What's happening today?"
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setInput('Who are the VIPs arriving by flight?')}
|
||||||
|
className="block w-full text-left px-3 py-2 bg-muted rounded-lg hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
"Who are the VIPs arriving by flight?"
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setInput('Which drivers are available?')}
|
||||||
|
className="block w-full text-left px-3 py-2 bg-muted rounded-lg hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
"Which drivers are available?"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{messages.map((message) => (
|
||||||
|
<div
|
||||||
|
key={message.id}
|
||||||
|
className={`flex gap-3 ${
|
||||||
|
message.role === 'user' ? 'justify-end' : 'justify-start'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{message.role === 'assistant' && (
|
||||||
|
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-primary flex items-center justify-center">
|
||||||
|
<Bot className="h-4 w-4 text-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`max-w-[80%] rounded-2xl px-4 py-2 ${
|
||||||
|
message.role === 'user'
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-sm">{renderContent(message.content)}</div>
|
||||||
|
{message.toolCalls && message.toolCalls.length > 0 && (
|
||||||
|
<div className="mt-2 pt-2 border-t border-border/50 text-xs text-muted-foreground">
|
||||||
|
<span className="font-medium">Actions taken: </span>
|
||||||
|
{message.toolCalls.map((tc) => tc.tool).join(', ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{message.role === 'user' && (
|
||||||
|
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-muted flex items-center justify-center">
|
||||||
|
<User className="h-4 w-4 text-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{chatMutation.isPending && (
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-primary flex items-center justify-center">
|
||||||
|
<Bot className="h-4 w-4 text-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted rounded-2xl px-4 py-3">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pending image preview */}
|
||||||
|
{pendingImage && (
|
||||||
|
<div className="px-4 py-2 border-t border-border bg-muted/50">
|
||||||
|
<div className="relative inline-block">
|
||||||
|
<img
|
||||||
|
src={`data:${pendingImage.type};base64,${pendingImage.data}`}
|
||||||
|
alt="To upload"
|
||||||
|
className="h-16 rounded-lg"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setPendingImage(null)}
|
||||||
|
className="absolute -top-2 -right-2 p-1 bg-destructive text-white rounded-full"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<div className="p-4 border-t border-border">
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={handleImageUpload}
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="p-2 text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg transition-colors"
|
||||||
|
title="Upload image"
|
||||||
|
>
|
||||||
|
<ImagePlus className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<textarea
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Ask about VIPs, drivers, events..."
|
||||||
|
className="flex-1 resize-none bg-muted border-0 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary min-h-[44px] max-h-32"
|
||||||
|
rows={1}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={chatMutation.isPending || (!input.trim() && !pendingImage)}
|
||||||
|
className="p-3 bg-primary text-primary-foreground rounded-xl hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<Send className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
163
frontend/src/components/AppearanceMenu.tsx
Normal file
163
frontend/src/components/AppearanceMenu.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { Settings, Sun, Moon, Monitor, Check } from 'lucide-react';
|
||||||
|
import { useTheme, ThemeMode, ColorScheme } from '@/hooks/useTheme';
|
||||||
|
|
||||||
|
const modes: { value: ThemeMode; label: string; icon: typeof Sun }[] = [
|
||||||
|
{ value: 'light', label: 'Light', icon: Sun },
|
||||||
|
{ value: 'dark', label: 'Dark', icon: Moon },
|
||||||
|
{ value: 'system', label: 'System', icon: Monitor },
|
||||||
|
];
|
||||||
|
|
||||||
|
const colorSchemes: { value: ColorScheme; label: string; color: string }[] = [
|
||||||
|
{ value: 'blue', label: 'Blue', color: 'bg-blue-500' },
|
||||||
|
{ value: 'purple', label: 'Purple', color: 'bg-purple-500' },
|
||||||
|
{ value: 'green', label: 'Green', color: 'bg-green-500' },
|
||||||
|
{ value: 'orange', label: 'Orange', color: 'bg-orange-500' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface AppearanceMenuProps {
|
||||||
|
/** Compact mode for mobile - shows inline instead of dropdown */
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppearanceMenu({ compact = false }: AppearanceMenuProps) {
|
||||||
|
const { mode, colorScheme, resolvedTheme, setMode, setColorScheme } = useTheme();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const CurrentIcon = resolvedTheme === 'dark' ? Moon : Sun;
|
||||||
|
|
||||||
|
// Compact inline mode for mobile drawer
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Theme Mode */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
|
||||||
|
Theme
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{modes.map(({ value, label, icon: Icon }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
onClick={() => setMode(value)}
|
||||||
|
className={`flex items-center justify-center gap-1.5 px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||||
|
mode === value
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-muted hover:bg-accent text-foreground'
|
||||||
|
}`}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
<span className="sr-only sm:not-sr-only">{label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Color Scheme */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
|
||||||
|
Accent Color
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{colorSchemes.map(({ value, label, color }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
onClick={() => setColorScheme(value)}
|
||||||
|
className={`relative w-8 h-8 rounded-full ${color} transition-transform hover:scale-110 ${
|
||||||
|
colorScheme === value ? 'ring-2 ring-offset-2 ring-offset-card ring-foreground' : ''
|
||||||
|
}`}
|
||||||
|
title={label}
|
||||||
|
aria-label={`${label} color scheme`}
|
||||||
|
>
|
||||||
|
{colorScheme === value && (
|
||||||
|
<Check className="absolute inset-0 m-auto h-4 w-4 text-white" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard dropdown mode for header
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="flex items-center justify-center w-9 h-9 rounded-lg hover:bg-accent transition-colors"
|
||||||
|
aria-label="Appearance settings"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-haspopup="true"
|
||||||
|
>
|
||||||
|
<CurrentIcon className="h-5 w-5 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute right-0 mt-2 w-52 rounded-xl bg-popover border border-border shadow-elevated z-50 overflow-hidden animate-in fade-in slide-in-from-top-2 duration-150">
|
||||||
|
{/* Theme Mode Section */}
|
||||||
|
<div className="p-3 border-b border-border">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
|
||||||
|
Theme
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{modes.map(({ value, label, icon: Icon }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
onClick={() => setMode(value)}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-2 rounded-lg text-xs font-medium transition-colors ${
|
||||||
|
mode === value
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-muted hover:bg-accent text-foreground'
|
||||||
|
}`}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
<Icon className="h-3.5 w-3.5" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Color Scheme Section */}
|
||||||
|
<div className="p-3">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
|
||||||
|
Accent Color
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 justify-between">
|
||||||
|
{colorSchemes.map(({ value, label, color }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
onClick={() => setColorScheme(value)}
|
||||||
|
className={`relative w-9 h-9 rounded-full ${color} transition-all hover:scale-110 ${
|
||||||
|
colorScheme === value
|
||||||
|
? 'ring-2 ring-offset-2 ring-offset-popover ring-foreground scale-110'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
title={label}
|
||||||
|
aria-label={`${label} color scheme`}
|
||||||
|
>
|
||||||
|
{colorScheme === value && (
|
||||||
|
<Check className="absolute inset-0 m-auto h-4 w-4 text-white drop-shadow-sm" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
frontend/src/components/ColorSchemeSelector.tsx
Normal file
73
frontend/src/components/ColorSchemeSelector.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { Palette, Check } from 'lucide-react';
|
||||||
|
import { useTheme, ColorScheme } from '@/hooks/useTheme';
|
||||||
|
|
||||||
|
const colorSchemes: { value: ColorScheme; label: string; color: string; darkColor: string }[] = [
|
||||||
|
{ value: 'blue', label: 'Blue', color: 'bg-blue-500', darkColor: 'bg-blue-400' },
|
||||||
|
{ value: 'purple', label: 'Purple', color: 'bg-purple-500', darkColor: 'bg-purple-400' },
|
||||||
|
{ value: 'green', label: 'Green', color: 'bg-green-500', darkColor: 'bg-green-400' },
|
||||||
|
{ value: 'orange', label: 'Orange', color: 'bg-orange-500', darkColor: 'bg-orange-400' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ColorSchemeSelector() {
|
||||||
|
const { colorScheme, setColorScheme, resolvedTheme } = useTheme();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const currentScheme = colorSchemes.find((s) => s.value === colorScheme) || colorSchemes[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="flex items-center justify-center w-9 h-9 rounded-lg bg-muted hover:bg-accent transition-colors focus-ring"
|
||||||
|
aria-label={`Current color scheme: ${currentScheme.label}. Click to change.`}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-haspopup="true"
|
||||||
|
>
|
||||||
|
<Palette className="h-5 w-5 text-foreground" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute right-0 mt-2 w-40 rounded-lg bg-popover border border-border shadow-elevated z-50 overflow-hidden animate-in fade-in slide-in-from-top-2 duration-150">
|
||||||
|
<div className="px-3 py-2 border-b border-border">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
Color Scheme
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{colorSchemes.map(({ value, label, color, darkColor }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
onClick={() => {
|
||||||
|
setColorScheme(value);
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
className={`flex items-center gap-3 w-full px-3 py-2.5 text-sm transition-colors ${
|
||||||
|
colorScheme === value
|
||||||
|
? 'bg-primary/10 text-primary font-medium'
|
||||||
|
: 'text-popover-foreground hover:bg-accent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`w-4 h-4 rounded-full ${resolvedTheme === 'dark' ? darkColor : color}`}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
{colorScheme === value && <Check className="h-4 w-4 ml-auto" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
frontend/src/components/DriverChatBubble.tsx
Normal file
62
frontend/src/components/DriverChatBubble.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { MessageCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface DriverChatBubbleProps {
|
||||||
|
unreadCount?: number;
|
||||||
|
awaitingResponse?: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DriverChatBubble({
|
||||||
|
unreadCount = 0,
|
||||||
|
awaitingResponse = false,
|
||||||
|
onClick,
|
||||||
|
className = ''
|
||||||
|
}: DriverChatBubbleProps) {
|
||||||
|
const hasUnread = unreadCount > 0;
|
||||||
|
|
||||||
|
// Determine icon color based on state
|
||||||
|
const getIconColorClass = () => {
|
||||||
|
if (awaitingResponse && !hasUnread) return 'text-orange-500';
|
||||||
|
if (hasUnread) return 'text-primary';
|
||||||
|
return 'text-muted-foreground hover:text-primary';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTitle = () => {
|
||||||
|
if (awaitingResponse && !hasUnread) return 'Awaiting driver response - click to message';
|
||||||
|
if (hasUnread) return `${unreadCount} unread message${unreadCount > 1 ? 's' : ''}`;
|
||||||
|
return 'Send message';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Subtle orange glow when awaiting response (no unread messages)
|
||||||
|
const glowStyle = awaitingResponse && !hasUnread
|
||||||
|
? { boxShadow: '0 0 8px 2px rgba(249, 115, 22, 0.5)' }
|
||||||
|
: {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick();
|
||||||
|
}}
|
||||||
|
className={`relative inline-flex items-center justify-center p-1.5 rounded-full
|
||||||
|
transition-all duration-200 hover:bg-primary/10
|
||||||
|
${getIconColorClass()}
|
||||||
|
${className}`}
|
||||||
|
style={glowStyle}
|
||||||
|
title={getTitle()}
|
||||||
|
>
|
||||||
|
<MessageCircle className="w-5 h-5" />
|
||||||
|
|
||||||
|
{/* Unread count badge */}
|
||||||
|
{hasUnread && (
|
||||||
|
<span className="absolute -top-1 -right-1 flex items-center justify-center">
|
||||||
|
<span className="absolute inline-flex h-4 w-4 rounded-full bg-primary/40 animate-ping" />
|
||||||
|
<span className="relative inline-flex items-center justify-center h-4 w-4 rounded-full bg-primary text-[10px] font-bold text-white">
|
||||||
|
{unreadCount > 9 ? '9+' : unreadCount}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
184
frontend/src/components/DriverChatModal.tsx
Normal file
184
frontend/src/components/DriverChatModal.tsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { X, Send, Loader2 } from 'lucide-react';
|
||||||
|
import { useDriverMessages, useSendMessage, useMarkMessagesAsRead } from '../hooks/useSignalMessages';
|
||||||
|
|
||||||
|
interface Driver {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DriverChatModalProps {
|
||||||
|
driver: Driver | null;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DriverChatModal({ driver, isOpen, onClose }: DriverChatModalProps) {
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
const { data: messages, isLoading } = useDriverMessages(driver?.id || null, isOpen);
|
||||||
|
const sendMessage = useSendMessage();
|
||||||
|
const markAsRead = useMarkMessagesAsRead();
|
||||||
|
|
||||||
|
// Scroll to bottom when messages change
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
// Focus input when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && inputRef.current) {
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 100);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Mark messages as read when opening chat
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && driver?.id) {
|
||||||
|
markAsRead.mutate(driver.id);
|
||||||
|
}
|
||||||
|
}, [isOpen, driver?.id]);
|
||||||
|
|
||||||
|
if (!isOpen || !driver) return null;
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
const trimmedMessage = message.trim();
|
||||||
|
if (!trimmedMessage || sendMessage.isPending) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendMessage.mutateAsync({
|
||||||
|
driverId: driver.id,
|
||||||
|
content: trimmedMessage,
|
||||||
|
});
|
||||||
|
setMessage('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send message:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (timestamp: string) => {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const now = new Date();
|
||||||
|
const isToday = date.toDateString() === now.toDateString();
|
||||||
|
|
||||||
|
if (isToday) {
|
||||||
|
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div
|
||||||
|
className="bg-card border border-border rounded-lg shadow-xl w-full max-w-md mx-4 flex flex-col"
|
||||||
|
style={{ height: 'min(600px, 80vh)' }}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<h3 className="font-semibold text-card-foreground">{driver.name}</h3>
|
||||||
|
<span className="text-xs text-muted-foreground">{driver.phone}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 rounded-full hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : messages && messages.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{messages.map((msg) => (
|
||||||
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
className={`flex ${msg.direction === 'OUTBOUND' ? 'justify-end' : 'justify-start'}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`max-w-[80%] rounded-2xl px-4 py-2 ${
|
||||||
|
msg.direction === 'OUTBOUND'
|
||||||
|
? 'bg-primary text-primary-foreground rounded-br-sm'
|
||||||
|
: 'bg-muted text-muted-foreground rounded-bl-sm'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="text-sm whitespace-pre-wrap break-words">{msg.content}</p>
|
||||||
|
<p className={`text-[10px] mt-1 ${
|
||||||
|
msg.direction === 'OUTBOUND' ? 'text-primary-foreground/70' : 'text-muted-foreground/70'
|
||||||
|
}`}>
|
||||||
|
{formatTime(msg.timestamp)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
|
<p className="text-sm">No messages yet. Send a message to start the conversation.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<div className="p-4 border-t border-border">
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<textarea
|
||||||
|
ref={inputRef}
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Type a message..."
|
||||||
|
rows={1}
|
||||||
|
className="flex-1 resize-none bg-muted rounded-xl px-4 py-2.5 text-sm
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-primary/50
|
||||||
|
max-h-32 min-h-[42px]"
|
||||||
|
style={{
|
||||||
|
height: 'auto',
|
||||||
|
overflowY: message.split('\n').length > 3 ? 'auto' : 'hidden',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={!message.trim() || sendMessage.isPending}
|
||||||
|
className="p-2.5 rounded-full bg-primary text-primary-foreground
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
|
hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
{sendMessage.isPending ? (
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{sendMessage.isError && (
|
||||||
|
<p className="mt-2 text-xs text-destructive">
|
||||||
|
Failed to send message. Please try again.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -56,14 +56,14 @@ export function DriverForm({ driver, onSubmit, onCancel, isSubmitting }: DriverF
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||||
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
<div className="bg-card rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||||
<div className="flex items-center justify-between p-6 border-b">
|
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||||
<h2 className="text-2xl font-bold text-gray-900">
|
<h2 className="text-2xl font-bold text-foreground">
|
||||||
{driver ? 'Edit Driver' : 'Add New Driver'}
|
{driver ? 'Edit Driver' : 'Add New Driver'}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="text-gray-400 hover:text-gray-600"
|
className="text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<X className="h-6 w-6" />
|
<X className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
@@ -72,7 +72,7 @@ export function DriverForm({ driver, onSubmit, onCancel, isSubmitting }: DriverF
|
|||||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||||
{/* Name */}
|
{/* Name */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
Full Name *
|
Full Name *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -81,13 +81,13 @@ export function DriverForm({ driver, onSubmit, onCancel, isSubmitting }: DriverF
|
|||||||
required
|
required
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Phone */}
|
{/* Phone */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
Phone Number *
|
Phone Number *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -96,20 +96,20 @@ export function DriverForm({ driver, onSubmit, onCancel, isSubmitting }: DriverF
|
|||||||
required
|
required
|
||||||
value={formData.phone}
|
value={formData.phone}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Department */}
|
{/* Department */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
Department
|
Department
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
name="department"
|
name="department"
|
||||||
value={formData.department}
|
value={formData.department}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
>
|
>
|
||||||
<option value="">Select Department</option>
|
<option value="">Select Department</option>
|
||||||
<option value="OFFICE_OF_DEVELOPMENT">Office of Development</option>
|
<option value="OFFICE_OF_DEVELOPMENT">Office of Development</option>
|
||||||
@@ -119,7 +119,7 @@ export function DriverForm({ driver, onSubmit, onCancel, isSubmitting }: DriverF
|
|||||||
|
|
||||||
{/* User ID (optional, for linking driver to user account) */}
|
{/* User ID (optional, for linking driver to user account) */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
User Account ID (Optional)
|
User Account ID (Optional)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -128,9 +128,9 @@ export function DriverForm({ driver, onSubmit, onCancel, isSubmitting }: DriverF
|
|||||||
value={formData.userId}
|
value={formData.userId}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder="Leave blank for standalone driver"
|
placeholder="Leave blank for standalone driver"
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
Link this driver to a user account for login access
|
Link this driver to a user account for login access
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -147,7 +147,7 @@ export function DriverForm({ driver, onSubmit, onCancel, isSubmitting }: DriverF
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="flex-1 bg-gray-200 text-gray-800 py-2 px-4 rounded-md hover:bg-gray-300"
|
className="flex-1 bg-muted text-foreground py-2 px-4 rounded-md hover:bg-muted/80"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
296
frontend/src/components/DriverScheduleModal.tsx
Normal file
296
frontend/src/components/DriverScheduleModal.tsx
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { X, Calendar, Clock, MapPin, Car, User, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { Driver } from '@/types';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface ScheduleEvent {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
pickupLocation?: string;
|
||||||
|
dropoffLocation?: string;
|
||||||
|
location?: string;
|
||||||
|
status: string;
|
||||||
|
type: string;
|
||||||
|
vipIds: string[];
|
||||||
|
vehicle?: {
|
||||||
|
name: string;
|
||||||
|
licensePlate?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DriverScheduleResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
events: ScheduleEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DriverScheduleModalProps {
|
||||||
|
driver: Driver | null;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DriverScheduleModal({ driver, isOpen, onClose }: DriverScheduleModalProps) {
|
||||||
|
const [selectedDate, setSelectedDate] = useState(new Date());
|
||||||
|
|
||||||
|
const dateString = selectedDate.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const { data: schedule, isLoading } = useQuery<DriverScheduleResponse>({
|
||||||
|
queryKey: ['driver-schedule', driver?.id, dateString],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get(`/drivers/${driver?.id}/schedule`, {
|
||||||
|
params: { date: dateString },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: isOpen && !!driver?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch VIP names for the events
|
||||||
|
const allVipIds = schedule?.events?.flatMap((e) => e.vipIds) || [];
|
||||||
|
const uniqueVipIds = [...new Set(allVipIds)];
|
||||||
|
|
||||||
|
const { data: vips } = useQuery({
|
||||||
|
queryKey: ['vips-for-schedule', uniqueVipIds.join(',')],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get('/vips');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: uniqueVipIds.length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const vipMap = new Map(vips?.map((v: any) => [v.id, v.name]) || []);
|
||||||
|
|
||||||
|
if (!isOpen || !driver) return null;
|
||||||
|
|
||||||
|
const goToPreviousDay = () => {
|
||||||
|
const newDate = new Date(selectedDate);
|
||||||
|
newDate.setDate(newDate.getDate() - 1);
|
||||||
|
setSelectedDate(newDate);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToNextDay = () => {
|
||||||
|
const newDate = new Date(selectedDate);
|
||||||
|
newDate.setDate(newDate.getDate() + 1);
|
||||||
|
setSelectedDate(newDate);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToToday = () => {
|
||||||
|
setSelectedDate(new Date());
|
||||||
|
};
|
||||||
|
|
||||||
|
const isToday = selectedDate.toDateString() === new Date().toDateString();
|
||||||
|
|
||||||
|
const formatTime = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleTimeString('en-US', {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (date: Date) => {
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'COMPLETED':
|
||||||
|
return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400';
|
||||||
|
case 'IN_PROGRESS':
|
||||||
|
return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400';
|
||||||
|
case 'CANCELLED':
|
||||||
|
return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTypeLabel = (type: string) => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
TRANSPORT: 'Transport',
|
||||||
|
MEETING: 'Meeting',
|
||||||
|
EVENT: 'Event',
|
||||||
|
MEAL: 'Meal',
|
||||||
|
ACCOMMODATION: 'Accommodation',
|
||||||
|
};
|
||||||
|
return labels[type] || type;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter events for the selected date
|
||||||
|
const dayEvents = schedule?.events?.filter((event) => {
|
||||||
|
const eventDate = new Date(event.startTime).toDateString();
|
||||||
|
return eventDate === selectedDate.toDateString();
|
||||||
|
}) || [];
|
||||||
|
|
||||||
|
// Sort events by start time
|
||||||
|
const sortedEvents = [...dayEvents].sort(
|
||||||
|
(a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
|
||||||
|
<div className="bg-card border border-border rounded-lg shadow-elevated w-full max-w-2xl max-h-[90vh] flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-foreground flex items-center gap-2">
|
||||||
|
<Calendar className="h-5 w-5 text-primary" />
|
||||||
|
{driver.name}'s Schedule
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">{driver.phone}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 hover:bg-accent rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date Navigation */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-3 border-b border-border bg-muted/30">
|
||||||
|
<button
|
||||||
|
onClick={goToPreviousDay}
|
||||||
|
className="p-2 hover:bg-accent rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="font-medium text-foreground">{formatDate(selectedDate)}</span>
|
||||||
|
{!isToday && (
|
||||||
|
<button
|
||||||
|
onClick={goToToday}
|
||||||
|
className="px-3 py-1 text-xs font-medium bg-primary text-primary-foreground rounded-full hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
Today
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={goToNextDay}
|
||||||
|
className="p-2 hover:bg-accent rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
) : sortedEvents.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Calendar className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
|
||||||
|
<p className="text-muted-foreground">No events scheduled for this day</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{sortedEvents.map((event) => {
|
||||||
|
const vipNames = event.vipIds
|
||||||
|
.map((id) => vipMap.get(id) || 'Unknown VIP')
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={event.id}
|
||||||
|
className="relative pl-8 pb-4 border-l-2 border-border last:border-l-0"
|
||||||
|
>
|
||||||
|
{/* Timeline dot */}
|
||||||
|
<div className="absolute left-[-9px] top-0 w-4 h-4 rounded-full bg-primary border-4 border-card" />
|
||||||
|
|
||||||
|
<div className="bg-muted/30 border border-border rounded-lg p-4">
|
||||||
|
{/* Time and Status */}
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{formatTime(event.startTime)} - {formatTime(event.endTime)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-muted text-muted-foreground">
|
||||||
|
{getTypeLabel(event.type)}
|
||||||
|
</span>
|
||||||
|
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${getStatusColor(event.status)}`}>
|
||||||
|
{event.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h3 className="font-semibold text-foreground mb-2">{event.title}</h3>
|
||||||
|
|
||||||
|
{/* VIP */}
|
||||||
|
{vipNames && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-2">
|
||||||
|
<User className="h-4 w-4" />
|
||||||
|
<span>{vipNames}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Location */}
|
||||||
|
{(event.pickupLocation || event.dropoffLocation || event.location) && (
|
||||||
|
<div className="flex items-start gap-2 text-sm text-muted-foreground mb-2">
|
||||||
|
<MapPin className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
{event.pickupLocation && event.dropoffLocation ? (
|
||||||
|
<>
|
||||||
|
<div>{event.pickupLocation}</div>
|
||||||
|
<div className="text-xs text-muted-foreground/70">→</div>
|
||||||
|
<div>{event.dropoffLocation}</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span>{event.location}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Vehicle */}
|
||||||
|
{event.vehicle && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Car className="h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
{event.vehicle.name}
|
||||||
|
{event.vehicle.licensePlate && ` (${event.vehicle.licensePlate})`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-6 py-4 border-t border-border bg-muted/30">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{sortedEvents.length} event{sortedEvents.length !== 1 ? 's' : ''} scheduled
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -48,19 +48,19 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||||||
render() {
|
render() {
|
||||||
if (this.state.hasError) {
|
if (this.state.hasError) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
<div className="min-h-screen bg-muted flex items-center justify-center p-4">
|
||||||
<div className="max-w-md w-full bg-white rounded-lg shadow-xl p-8">
|
<div className="max-w-md w-full bg-card rounded-lg shadow-xl p-8">
|
||||||
<div className="flex justify-center mb-6">
|
<div className="flex justify-center mb-6">
|
||||||
<div className="rounded-full bg-red-100 p-4">
|
<div className="rounded-full bg-red-100 p-4">
|
||||||
<AlertTriangle className="h-12 w-12 text-red-600" />
|
<AlertTriangle className="h-12 w-12 text-red-600" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900 text-center mb-4">
|
<h1 className="text-2xl font-bold text-foreground text-center mb-4">
|
||||||
Something went wrong
|
Something went wrong
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="text-gray-600 text-center mb-6">
|
<p className="text-muted-foreground text-center mb-6">
|
||||||
The application encountered an unexpected error. Please try refreshing the page or returning to the home page.
|
The application encountered an unexpected error. Please try refreshing the page or returning to the home page.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={this.handleGoHome}
|
onClick={this.handleGoHome}
|
||||||
className="flex-1 inline-flex items-center justify-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
className="flex-1 inline-flex items-center justify-center px-4 py-2 border border-input text-sm font-medium rounded-md text-foreground bg-card hover:bg-accent"
|
||||||
>
|
>
|
||||||
<Home className="h-4 w-4 mr-2" />
|
<Home className="h-4 w-4 mr-2" />
|
||||||
Go Home
|
Go Home
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ export function ErrorMessage({
|
|||||||
<AlertCircle className="h-8 w-8 text-red-600" />
|
<AlertCircle className="h-8 w-8 text-red-600" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">{title}</h3>
|
<h3 className="text-lg font-medium text-foreground mb-2">{title}</h3>
|
||||||
<p className="text-gray-600 mb-4">{message}</p>
|
<p className="text-muted-foreground mb-4">{message}</p>
|
||||||
{onRetry && (
|
{onRetry && (
|
||||||
<button
|
<button
|
||||||
onClick={onRetry}
|
onClick={onRetry}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface EventFormProps {
|
|||||||
onSubmit: (data: EventFormData) => void;
|
onSubmit: (data: EventFormData) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
|
extraActions?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EventFormData {
|
export interface EventFormData {
|
||||||
@@ -36,7 +37,7 @@ interface ScheduleConflict {
|
|||||||
endTime: string;
|
endTime: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EventForm({ event, onSubmit, onCancel, isSubmitting }: EventFormProps) {
|
export function EventForm({ event, onSubmit, onCancel, isSubmitting, extraActions }: EventFormProps) {
|
||||||
// Helper to convert ISO datetime to datetime-local format
|
// Helper to convert ISO datetime to datetime-local format
|
||||||
const toDatetimeLocal = (isoString: string | null | undefined) => {
|
const toDatetimeLocal = (isoString: string | null | undefined) => {
|
||||||
if (!isoString) return '';
|
if (!isoString) return '';
|
||||||
@@ -199,15 +200,15 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting }: EventForm
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||||
<div className="bg-white rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] overflow-y-auto">
|
<div className="bg-card rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] overflow-y-auto">
|
||||||
<div className="flex items-center justify-between p-6 border-b sticky top-0 bg-white z-10">
|
<div className="flex items-center justify-between p-6 border-b border-border sticky top-0 bg-card z-10">
|
||||||
<h2 className="text-2xl font-bold text-gray-900">
|
<h2 className="text-2xl font-bold text-foreground">
|
||||||
{event ? 'Edit Event' : 'Add New Event'}
|
{event ? 'Edit Event' : 'Add New Event'}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="text-gray-400 hover:text-gray-600"
|
className="text-muted-foreground hover:text-foreground"
|
||||||
style={{ minWidth: '44px', minHeight: '44px' }}
|
style={{ minWidth: '44px', minHeight: '44px' }}
|
||||||
>
|
>
|
||||||
<X className="h-6 w-6" />
|
<X className="h-6 w-6" />
|
||||||
@@ -217,42 +218,42 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting }: EventForm
|
|||||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||||
{/* VIP Multi-Select */}
|
{/* VIP Multi-Select */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
<Users className="inline h-4 w-4 mr-1" />
|
<Users className="inline h-4 w-4 mr-1" />
|
||||||
VIPs * (select one or more)
|
VIPs * (select one or more)
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="border border-gray-300 rounded-md p-3 max-h-48 overflow-y-auto">
|
<div className="border border-input rounded-md p-3 max-h-48 overflow-y-auto bg-background">
|
||||||
{vips?.map((vip) => (
|
{vips?.map((vip) => (
|
||||||
<label
|
<label
|
||||||
key={vip.id}
|
key={vip.id}
|
||||||
className="flex items-center py-2 px-3 hover:bg-gray-50 rounded cursor-pointer"
|
className="flex items-center py-2 px-3 hover:bg-accent rounded cursor-pointer"
|
||||||
style={{ minHeight: '44px' }}
|
style={{ minHeight: '44px' }}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={formData.vipIds.includes(vip.id)}
|
checked={formData.vipIds.includes(vip.id)}
|
||||||
onChange={() => handleVipToggle(vip.id)}
|
onChange={() => handleVipToggle(vip.id)}
|
||||||
className="h-4 w-4 text-primary rounded border-gray-300 focus:ring-primary"
|
className="h-4 w-4 text-primary rounded border-input focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
<span className="ml-3 text-base text-gray-700">
|
<span className="ml-3 text-base text-foreground">
|
||||||
{vip.name}
|
{vip.name}
|
||||||
{vip.organization && (
|
{vip.organization && (
|
||||||
<span className="text-sm text-gray-500 ml-2">({vip.organization})</span>
|
<span className="text-sm text-muted-foreground ml-2">({vip.organization})</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 text-sm text-gray-600">
|
<div className="mt-2 text-sm text-muted-foreground">
|
||||||
<strong>Selected ({formData.vipIds.length}):</strong> {selectedVipNames}
|
<strong>Selected ({formData.vipIds.length}):</strong> {selectedVipNames}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
Event Title *
|
Event Title *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -262,14 +263,14 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting }: EventForm
|
|||||||
value={formData.title}
|
value={formData.title}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder="e.g., Transport to Campfire Night"
|
placeholder="e.g., Transport to Campfire Night"
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full px-3 py-2 bg-background text-foreground placeholder:text-muted-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pickup & Dropoff Locations */}
|
{/* Pickup & Dropoff Locations */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
Pickup Location
|
Pickup Location
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -278,11 +279,11 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting }: EventForm
|
|||||||
value={formData.pickupLocation}
|
value={formData.pickupLocation}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder="e.g., Grand Hotel Lobby"
|
placeholder="e.g., Grand Hotel Lobby"
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full px-3 py-2 bg-background text-foreground placeholder:text-muted-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
Dropoff Location
|
Dropoff Location
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -291,7 +292,7 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting }: EventForm
|
|||||||
value={formData.dropoffLocation}
|
value={formData.dropoffLocation}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder="e.g., Camp Amphitheater"
|
placeholder="e.g., Camp Amphitheater"
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full px-3 py-2 bg-background text-foreground placeholder:text-muted-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -299,7 +300,7 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting }: EventForm
|
|||||||
{/* Start & End Time */}
|
{/* Start & End Time */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
Start Time *
|
Start Time *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -308,11 +309,11 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting }: EventForm
|
|||||||
required
|
required
|
||||||
value={formData.startTime}
|
value={formData.startTime}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full px-3 py-2 bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
End Time *
|
End Time *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -321,14 +322,14 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting }: EventForm
|
|||||||
required
|
required
|
||||||
value={formData.endTime}
|
value={formData.endTime}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full px-3 py-2 bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Vehicle Selection with Capacity */}
|
{/* Vehicle Selection with Capacity */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
<Car className="inline h-4 w-4 mr-1" />
|
<Car className="inline h-4 w-4 mr-1" />
|
||||||
Assigned Vehicle
|
Assigned Vehicle
|
||||||
</label>
|
</label>
|
||||||
@@ -336,7 +337,7 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting }: EventForm
|
|||||||
name="vehicleId"
|
name="vehicleId"
|
||||||
value={formData.vehicleId}
|
value={formData.vehicleId}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full px-3 py-2 bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
>
|
>
|
||||||
<option value="">No vehicle assigned</option>
|
<option value="">No vehicle assigned</option>
|
||||||
{vehicles?.map((vehicle) => (
|
{vehicles?.map((vehicle) => (
|
||||||
@@ -346,7 +347,7 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting }: EventForm
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{selectedVehicle && (
|
{selectedVehicle && (
|
||||||
<div className={`mt-2 text-sm ${seatsUsed > seatsAvailable ? 'text-red-600 font-medium' : 'text-gray-600'}`}>
|
<div className={`mt-2 text-sm ${seatsUsed > seatsAvailable ? 'text-red-600 font-medium' : 'text-muted-foreground'}`}>
|
||||||
Capacity: {seatsUsed}/{seatsAvailable} seats used
|
Capacity: {seatsUsed}/{seatsAvailable} seats used
|
||||||
{seatsUsed > seatsAvailable && ' ⚠️ OVER CAPACITY'}
|
{seatsUsed > seatsAvailable && ' ⚠️ OVER CAPACITY'}
|
||||||
</div>
|
</div>
|
||||||
@@ -355,14 +356,14 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting }: EventForm
|
|||||||
|
|
||||||
{/* Driver Selection */}
|
{/* Driver Selection */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
Assigned Driver
|
Assigned Driver
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
name="driverId"
|
name="driverId"
|
||||||
value={formData.driverId}
|
value={formData.driverId}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full px-3 py-2 bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
>
|
>
|
||||||
<option value="">No driver assigned</option>
|
<option value="">No driver assigned</option>
|
||||||
{drivers?.map((driver) => (
|
{drivers?.map((driver) => (
|
||||||
@@ -376,7 +377,7 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting }: EventForm
|
|||||||
{/* Event Type & Status */}
|
{/* Event Type & Status */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
Event Type *
|
Event Type *
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
@@ -384,7 +385,7 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting }: EventForm
|
|||||||
required
|
required
|
||||||
value={formData.type}
|
value={formData.type}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full px-3 py-2 bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
>
|
>
|
||||||
<option value="TRANSPORT">Transport</option>
|
<option value="TRANSPORT">Transport</option>
|
||||||
<option value="MEETING">Meeting</option>
|
<option value="MEETING">Meeting</option>
|
||||||
@@ -394,7 +395,7 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting }: EventForm
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
Status *
|
Status *
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
@@ -402,7 +403,7 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting }: EventForm
|
|||||||
required
|
required
|
||||||
value={formData.status}
|
value={formData.status}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full px-3 py-2 bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
>
|
>
|
||||||
<option value="SCHEDULED">Scheduled</option>
|
<option value="SCHEDULED">Scheduled</option>
|
||||||
<option value="IN_PROGRESS">In Progress</option>
|
<option value="IN_PROGRESS">In Progress</option>
|
||||||
@@ -414,7 +415,7 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting }: EventForm
|
|||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
Description
|
Description
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -423,7 +424,7 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting }: EventForm
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="Additional notes or instructions"
|
placeholder="Additional notes or instructions"
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full px-3 py-2 bg-background text-foreground placeholder:text-muted-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -440,20 +441,23 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting }: EventForm
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="flex-1 bg-gray-200 text-gray-800 py-2 px-4 rounded-md hover:bg-gray-300"
|
className="flex-1 bg-muted text-foreground py-2 px-4 rounded-md hover:bg-muted/80"
|
||||||
style={{ minHeight: '44px' }}
|
style={{ minHeight: '44px' }}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Extra Actions (e.g., Cancel Event, Delete Event) */}
|
||||||
|
{extraActions}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Conflict Dialog */}
|
{/* Conflict Dialog */}
|
||||||
{showConflictDialog && createPortal(
|
{showConflictDialog && createPortal(
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-[60]">
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-[60]">
|
||||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl mx-4">
|
<div className="bg-card rounded-lg shadow-xl w-full max-w-2xl mx-4">
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
@@ -462,10 +466,10 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting }: EventForm
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||||
Scheduling Conflict Detected
|
Scheduling Conflict Detected
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-base text-gray-600 mb-4">
|
<p className="text-base text-muted-foreground mb-4">
|
||||||
This driver already has {conflicts.length} conflicting event{conflicts.length > 1 ? 's' : ''} scheduled during this time:
|
This driver already has {conflicts.length} conflicting event{conflicts.length > 1 ? 's' : ''} scheduled during this time:
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -483,14 +487,14 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting }: EventForm
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-base text-gray-700 font-medium mb-6">
|
<p className="text-base text-foreground font-medium mb-6">
|
||||||
Do you want to proceed with this assignment anyway?
|
Do you want to proceed with this assignment anyway?
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={handleCancelConflict}
|
onClick={handleCancelConflict}
|
||||||
className="flex-1 px-4 py-3 border border-gray-300 rounded-md text-base font-medium text-gray-700 hover:bg-gray-50"
|
className="flex-1 px-4 py-3 border border-input rounded-md text-base font-medium text-foreground hover:bg-accent"
|
||||||
style={{ minHeight: '44px' }}
|
style={{ minHeight: '44px' }}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
@@ -514,8 +518,8 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting }: EventForm
|
|||||||
|
|
||||||
{/* Capacity Warning Dialog */}
|
{/* Capacity Warning Dialog */}
|
||||||
{showCapacityWarning && capacityExceeded && createPortal(
|
{showCapacityWarning && capacityExceeded && createPortal(
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-[60]">
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-[60]">
|
||||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
|
<div className="bg-card rounded-lg shadow-xl w-full max-w-md mx-4">
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
@@ -524,21 +528,21 @@ export function EventForm({ event, onSubmit, onCancel, isSubmitting }: EventForm
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||||
Vehicle Capacity Exceeded
|
Vehicle Capacity Exceeded
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-base text-gray-600 mb-4">
|
<p className="text-base text-muted-foreground mb-4">
|
||||||
You've assigned {capacityExceeded.requested} VIP{capacityExceeded.requested > 1 ? 's' : ''} to a vehicle with only {capacityExceeded.capacity} seat{capacityExceeded.capacity > 1 ? 's' : ''}.
|
You've assigned {capacityExceeded.requested} VIP{capacityExceeded.requested > 1 ? 's' : ''} to a vehicle with only {capacityExceeded.capacity} seat{capacityExceeded.capacity > 1 ? 's' : ''}.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-base text-gray-700 font-medium mb-6">
|
<p className="text-base text-foreground font-medium mb-6">
|
||||||
Do you want to proceed anyway?
|
Do you want to proceed anyway?
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={handleCancelConflict}
|
onClick={handleCancelConflict}
|
||||||
className="flex-1 px-4 py-3 border border-gray-300 rounded-md text-base font-medium text-gray-700 hover:bg-gray-50"
|
className="flex-1 px-4 py-3 border border-input rounded-md text-base font-medium text-foreground hover:bg-accent"
|
||||||
style={{ minHeight: '44px' }}
|
style={{ minHeight: '44px' }}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -35,12 +35,12 @@ export function FilterModal({ isOpen, onClose, filterGroups, onClear, onApply }:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-md max-h-[80vh] overflow-y-auto">
|
<div className="bg-card rounded-lg shadow-xl w-full max-w-md max-h-[80vh] overflow-y-auto">
|
||||||
<div className="flex items-center justify-between p-4 border-b sticky top-0 bg-white">
|
<div className="flex items-center justify-between p-4 border-b border-border sticky top-0 bg-card">
|
||||||
<h2 className="text-lg font-semibold text-gray-900">Filters</h2>
|
<h2 className="text-lg font-semibold text-foreground">Filters</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-gray-400 hover:text-gray-600 p-2 rounded-md hover:bg-gray-100"
|
className="text-muted-foreground hover:text-foreground p-2 rounded-md hover:bg-accent"
|
||||||
style={{ minWidth: '44px', minHeight: '44px' }}
|
style={{ minWidth: '44px', minHeight: '44px' }}
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
@@ -51,21 +51,21 @@ export function FilterModal({ isOpen, onClose, filterGroups, onClear, onApply }:
|
|||||||
<div className="p-4 space-y-6">
|
<div className="p-4 space-y-6">
|
||||||
{filterGroups.map((group, index) => (
|
{filterGroups.map((group, index) => (
|
||||||
<div key={index}>
|
<div key={index}>
|
||||||
<h3 className="text-sm font-medium text-gray-700 mb-3">{group.label}</h3>
|
<h3 className="text-sm font-medium text-foreground mb-3">{group.label}</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{group.options.map((option) => (
|
{group.options.map((option) => (
|
||||||
<label
|
<label
|
||||||
key={option.value}
|
key={option.value}
|
||||||
className="flex items-center cursor-pointer py-2 px-3 rounded-md hover:bg-gray-50"
|
className="flex items-center cursor-pointer py-2 px-3 rounded-md hover:bg-muted"
|
||||||
style={{ minHeight: '44px' }}
|
style={{ minHeight: '44px' }}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={group.selectedValues.includes(option.value)}
|
checked={group.selectedValues.includes(option.value)}
|
||||||
onChange={() => group.onToggle(option.value)}
|
onChange={() => group.onToggle(option.value)}
|
||||||
className="rounded border-gray-300 text-primary focus:ring-primary h-5 w-5"
|
className="rounded border-input text-primary focus:ring-primary h-5 w-5"
|
||||||
/>
|
/>
|
||||||
<span className="ml-3 text-base text-gray-700">{option.label}</span>
|
<span className="ml-3 text-base text-foreground">{option.label}</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -73,10 +73,10 @@ export function FilterModal({ isOpen, onClose, filterGroups, onClear, onApply }:
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3 p-4 border-t bg-gray-50 sticky bottom-0">
|
<div className="flex gap-3 p-4 border-t border-border bg-muted sticky bottom-0">
|
||||||
<button
|
<button
|
||||||
onClick={handleClear}
|
onClick={handleClear}
|
||||||
className="flex-1 bg-white text-gray-700 py-3 px-4 rounded-md hover:bg-gray-100 font-medium border border-gray-300"
|
className="flex-1 bg-card text-foreground py-3 px-4 rounded-md hover:bg-accent font-medium border border-input"
|
||||||
style={{ minHeight: '44px' }}
|
style={{ minHeight: '44px' }}
|
||||||
>
|
>
|
||||||
Clear All
|
Clear All
|
||||||
|
|||||||
@@ -131,14 +131,14 @@ export function FlightForm({ flight, onSubmit, onCancel, isSubmitting }: FlightF
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||||
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
<div className="bg-card rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||||
<div className="flex items-center justify-between p-6 border-b">
|
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||||
<h2 className="text-2xl font-bold text-gray-900">
|
<h2 className="text-2xl font-bold text-foreground">
|
||||||
{flight ? 'Edit Flight' : 'Add New Flight'}
|
{flight ? 'Edit Flight' : 'Add New Flight'}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="text-gray-400 hover:text-gray-600"
|
className="text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<X className="h-6 w-6" />
|
<X className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
@@ -147,7 +147,7 @@ export function FlightForm({ flight, onSubmit, onCancel, isSubmitting }: FlightF
|
|||||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||||
{/* VIP Selection */}
|
{/* VIP Selection */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
VIP *
|
VIP *
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
@@ -155,7 +155,7 @@ export function FlightForm({ flight, onSubmit, onCancel, isSubmitting }: FlightF
|
|||||||
required
|
required
|
||||||
value={formData.vipId}
|
value={formData.vipId}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
>
|
>
|
||||||
<option value="">Select VIP</option>
|
<option value="">Select VIP</option>
|
||||||
{vips?.map((vip) => (
|
{vips?.map((vip) => (
|
||||||
@@ -169,7 +169,7 @@ export function FlightForm({ flight, onSubmit, onCancel, isSubmitting }: FlightF
|
|||||||
{/* Flight Number & Date */}
|
{/* Flight Number & Date */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
Flight Number *
|
Flight Number *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -179,11 +179,11 @@ export function FlightForm({ flight, onSubmit, onCancel, isSubmitting }: FlightF
|
|||||||
value={formData.flightNumber}
|
value={formData.flightNumber}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder="e.g., AA123"
|
placeholder="e.g., AA123"
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
Flight Date *
|
Flight Date *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -192,7 +192,7 @@ export function FlightForm({ flight, onSubmit, onCancel, isSubmitting }: FlightF
|
|||||||
required
|
required
|
||||||
value={formData.flightDate}
|
value={formData.flightDate}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -200,7 +200,7 @@ export function FlightForm({ flight, onSubmit, onCancel, isSubmitting }: FlightF
|
|||||||
{/* Airports & Segment */}
|
{/* Airports & Segment */}
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
From (IATA) *
|
From (IATA) *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -211,11 +211,11 @@ export function FlightForm({ flight, onSubmit, onCancel, isSubmitting }: FlightF
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder="JFK"
|
placeholder="JFK"
|
||||||
maxLength={3}
|
maxLength={3}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary uppercase"
|
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary uppercase"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
To (IATA) *
|
To (IATA) *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -226,11 +226,11 @@ export function FlightForm({ flight, onSubmit, onCancel, isSubmitting }: FlightF
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder="LAX"
|
placeholder="LAX"
|
||||||
maxLength={3}
|
maxLength={3}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary uppercase"
|
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary uppercase"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
Segment
|
Segment
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -239,7 +239,7 @@ export function FlightForm({ flight, onSubmit, onCancel, isSubmitting }: FlightF
|
|||||||
min="1"
|
min="1"
|
||||||
value={formData.segment}
|
value={formData.segment}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -247,7 +247,7 @@ export function FlightForm({ flight, onSubmit, onCancel, isSubmitting }: FlightF
|
|||||||
{/* Scheduled Times */}
|
{/* Scheduled Times */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
Scheduled Departure
|
Scheduled Departure
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -255,11 +255,11 @@ export function FlightForm({ flight, onSubmit, onCancel, isSubmitting }: FlightF
|
|||||||
name="scheduledDeparture"
|
name="scheduledDeparture"
|
||||||
value={formData.scheduledDeparture}
|
value={formData.scheduledDeparture}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
Scheduled Arrival
|
Scheduled Arrival
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -267,7 +267,7 @@ export function FlightForm({ flight, onSubmit, onCancel, isSubmitting }: FlightF
|
|||||||
name="scheduledArrival"
|
name="scheduledArrival"
|
||||||
value={formData.scheduledArrival}
|
value={formData.scheduledArrival}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -275,7 +275,7 @@ export function FlightForm({ flight, onSubmit, onCancel, isSubmitting }: FlightF
|
|||||||
{/* Actual Times */}
|
{/* Actual Times */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
Actual Departure
|
Actual Departure
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -283,11 +283,11 @@ export function FlightForm({ flight, onSubmit, onCancel, isSubmitting }: FlightF
|
|||||||
name="actualDeparture"
|
name="actualDeparture"
|
||||||
value={formData.actualDeparture}
|
value={formData.actualDeparture}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
Actual Arrival
|
Actual Arrival
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -295,21 +295,21 @@ export function FlightForm({ flight, onSubmit, onCancel, isSubmitting }: FlightF
|
|||||||
name="actualArrival"
|
name="actualArrival"
|
||||||
value={formData.actualArrival}
|
value={formData.actualArrival}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status */}
|
{/* Status */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
Status
|
Status
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
name="status"
|
name="status"
|
||||||
value={formData.status}
|
value={formData.status}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
>
|
>
|
||||||
<option value="scheduled">Scheduled</option>
|
<option value="scheduled">Scheduled</option>
|
||||||
<option value="boarding">Boarding</option>
|
<option value="boarding">Boarding</option>
|
||||||
@@ -333,7 +333,7 @@ export function FlightForm({ flight, onSubmit, onCancel, isSubmitting }: FlightF
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="flex-1 bg-gray-200 text-gray-800 py-2 px-4 rounded-md hover:bg-gray-300"
|
className="flex-1 bg-muted text-foreground py-2 px-4 rounded-md hover:bg-muted/80"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -111,8 +111,8 @@ export function InlineDriverSelector({
|
|||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
className={`inline-flex items-center gap-1 px-2 py-1 text-sm rounded transition-colors ${
|
className={`inline-flex items-center gap-1 px-2 py-1 text-sm rounded transition-colors ${
|
||||||
currentDriverId
|
currentDriverId
|
||||||
? 'text-gray-700 hover:bg-gray-100'
|
? 'text-foreground hover:bg-accent'
|
||||||
: 'text-gray-400 hover:bg-gray-50'
|
: 'text-muted-foreground hover:bg-muted'
|
||||||
}`}
|
}`}
|
||||||
disabled={updateDriverMutation.isPending}
|
disabled={updateDriverMutation.isPending}
|
||||||
>
|
>
|
||||||
@@ -123,12 +123,12 @@ export function InlineDriverSelector({
|
|||||||
{/* Driver Selection Modal */}
|
{/* Driver Selection Modal */}
|
||||||
{isOpen && createPortal(
|
{isOpen && createPortal(
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-md max-h-[80vh] overflow-hidden">
|
<div className="bg-card rounded-lg shadow-xl w-full max-w-md max-h-[80vh] overflow-hidden">
|
||||||
<div className="flex items-center justify-between p-4 border-b sticky top-0 bg-white">
|
<div className="flex items-center justify-between p-4 border-b border-border sticky top-0 bg-card">
|
||||||
<h2 className="text-lg font-semibold text-gray-900">Assign Driver</h2>
|
<h2 className="text-lg font-semibold text-foreground">Assign Driver</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
className="text-gray-400 hover:text-gray-600 p-2 rounded-md hover:bg-gray-100"
|
className="text-muted-foreground hover:text-foreground p-2 rounded-md hover:bg-accent"
|
||||||
style={{ minWidth: '44px', minHeight: '44px' }}
|
style={{ minWidth: '44px', minHeight: '44px' }}
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
@@ -138,12 +138,12 @@ export function InlineDriverSelector({
|
|||||||
|
|
||||||
<div className="overflow-y-auto max-h-[calc(80vh-8rem)]">
|
<div className="overflow-y-auto max-h-[calc(80vh-8rem)]">
|
||||||
{driversLoading ? (
|
{driversLoading ? (
|
||||||
<div className="p-8 text-center text-gray-500">Loading drivers...</div>
|
<div className="p-8 text-center text-muted-foreground">Loading drivers...</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSelectDriver(null)}
|
onClick={() => handleSelectDriver(null)}
|
||||||
className="w-full text-left px-4 py-3 text-base text-gray-400 hover:bg-gray-50 transition-colors rounded-md"
|
className="w-full text-left px-4 py-3 text-base text-muted-foreground hover:bg-muted transition-colors rounded-md"
|
||||||
style={{ minHeight: '44px' }}
|
style={{ minHeight: '44px' }}
|
||||||
>
|
>
|
||||||
Unassigned
|
Unassigned
|
||||||
@@ -155,13 +155,13 @@ export function InlineDriverSelector({
|
|||||||
className={`w-full text-left px-4 py-3 text-base transition-colors rounded-md ${
|
className={`w-full text-left px-4 py-3 text-base transition-colors rounded-md ${
|
||||||
driver.id === currentDriverId
|
driver.id === currentDriverId
|
||||||
? 'bg-blue-50 text-blue-700 font-medium'
|
? 'bg-blue-50 text-blue-700 font-medium'
|
||||||
: 'text-gray-700 hover:bg-gray-50'
|
: 'text-foreground hover:bg-muted'
|
||||||
}`}
|
}`}
|
||||||
style={{ minHeight: '44px' }}
|
style={{ minHeight: '44px' }}
|
||||||
>
|
>
|
||||||
<div>{driver.name}</div>
|
<div>{driver.name}</div>
|
||||||
{driver.phone && (
|
{driver.phone && (
|
||||||
<div className="text-sm text-gray-500">{driver.phone}</div>
|
<div className="text-sm text-muted-foreground">{driver.phone}</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -176,7 +176,7 @@ export function InlineDriverSelector({
|
|||||||
{/* Conflict Dialog */}
|
{/* Conflict Dialog */}
|
||||||
{showConflictDialog && createPortal(
|
{showConflictDialog && createPortal(
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl mx-4">
|
<div className="bg-card rounded-lg shadow-xl w-full max-w-2xl mx-4">
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
@@ -185,10 +185,10 @@ export function InlineDriverSelector({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||||
Scheduling Conflict Detected
|
Scheduling Conflict Detected
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-base text-gray-600 mb-4">
|
<p className="text-base text-muted-foreground mb-4">
|
||||||
This driver already has {conflicts.length} conflicting event{conflicts.length > 1 ? 's' : ''} scheduled during this time:
|
This driver already has {conflicts.length} conflicting event{conflicts.length > 1 ? 's' : ''} scheduled during this time:
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -198,22 +198,22 @@ export function InlineDriverSelector({
|
|||||||
key={conflict.id}
|
key={conflict.id}
|
||||||
className="bg-yellow-50 border border-yellow-200 rounded-md p-4"
|
className="bg-yellow-50 border border-yellow-200 rounded-md p-4"
|
||||||
>
|
>
|
||||||
<div className="font-medium text-gray-900">{conflict.title}</div>
|
<div className="font-medium text-foreground">{conflict.title}</div>
|
||||||
<div className="text-sm text-gray-600 mt-1">
|
<div className="text-sm text-muted-foreground mt-1">
|
||||||
{formatDateTime(conflict.startTime)} - {formatDateTime(conflict.endTime)}
|
{formatDateTime(conflict.startTime)} - {formatDateTime(conflict.endTime)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-base text-gray-700 font-medium mb-6">
|
<p className="text-base text-foreground font-medium mb-6">
|
||||||
Do you want to proceed with this assignment anyway?
|
Do you want to proceed with this assignment anyway?
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={handleCancelConflict}
|
onClick={handleCancelConflict}
|
||||||
className="flex-1 px-4 py-3 border border-gray-300 rounded-md text-base font-medium text-gray-700 hover:bg-gray-50"
|
className="flex-1 px-4 py-3 border border-input rounded-md text-base font-medium text-foreground hover:bg-accent"
|
||||||
style={{ minHeight: '44px' }}
|
style={{ minHeight: '44px' }}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ReactNode, useState, useRef, useEffect } from 'react';
|
import { ReactNode, useState, useRef, useEffect } from 'react';
|
||||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { useAbility } from '@/contexts/AbilityContext';
|
import { useAbility } from '@/contexts/AbilityContext';
|
||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
Truck,
|
Truck,
|
||||||
Calendar,
|
Calendar,
|
||||||
UserCog,
|
UserCog,
|
||||||
LogOut,
|
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Settings,
|
Settings,
|
||||||
Radio,
|
Radio,
|
||||||
@@ -20,9 +19,13 @@ import {
|
|||||||
X,
|
X,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Shield,
|
Shield,
|
||||||
CalendarDays,
|
LogOut,
|
||||||
Presentation,
|
Phone,
|
||||||
|
AlertCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { UserMenu } from '@/components/UserMenu';
|
||||||
|
import { AppearanceMenu } from '@/components/AppearanceMenu';
|
||||||
|
import { AICopilot } from '@/components/AICopilot';
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -39,7 +42,6 @@ interface LayoutProps {
|
|||||||
export function Layout({ children }: LayoutProps) {
|
export function Layout({ children }: LayoutProps) {
|
||||||
const { user, backendUser, logout } = useAuth();
|
const { user, backendUser, logout } = useAuth();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
|
||||||
const ability = useAbility();
|
const ability = useAbility();
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
const [adminDropdownOpen, setAdminDropdownOpen] = useState(false);
|
const [adminDropdownOpen, setAdminDropdownOpen] = useState(false);
|
||||||
@@ -58,15 +60,21 @@ export function Layout({ children }: LayoutProps) {
|
|||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Check if user is a driver (limited access)
|
||||||
|
const isDriverRole = backendUser?.role === 'DRIVER';
|
||||||
|
|
||||||
// Define main navigation items (reorganized by workflow priority)
|
// Define main navigation items (reorganized by workflow priority)
|
||||||
|
// coordinatorOnly items are hidden from drivers
|
||||||
const allNavigation = [
|
const allNavigation = [
|
||||||
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard, alwaysShow: true },
|
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard, coordinatorOnly: true },
|
||||||
{ name: 'War Room', href: '/command-center', icon: Radio, requireRead: 'ScheduleEvent' as const },
|
{ name: 'My Schedule', href: '/my-schedule', icon: Calendar, driverOnly: true },
|
||||||
{ name: 'VIPs', href: '/vips', icon: Users, requireRead: 'VIP' as const },
|
{ name: 'My Profile', href: '/profile', icon: UserCog, driverOnly: true },
|
||||||
{ name: 'Drivers', href: '/drivers', icon: Car, requireRead: 'Driver' as const },
|
{ name: 'War Room', href: '/command-center', icon: Radio, requireRead: 'ScheduleEvent' as const, coordinatorOnly: true },
|
||||||
{ name: 'Vehicles', href: '/vehicles', icon: Truck, requireRead: 'Vehicle' as const },
|
{ name: 'VIPs', href: '/vips', icon: Users, requireRead: 'VIP' as const, coordinatorOnly: true },
|
||||||
{ name: 'Activities', href: '/events', icon: Calendar, requireRead: 'ScheduleEvent' as const },
|
{ name: 'Drivers', href: '/drivers', icon: Car, requireRead: 'Driver' as const, coordinatorOnly: true },
|
||||||
{ name: 'Flights', href: '/flights', icon: Plane, requireRead: 'Flight' as const },
|
{ name: 'Vehicles', href: '/vehicles', icon: Truck, requireRead: 'Vehicle' as const, coordinatorOnly: true },
|
||||||
|
{ name: 'Activities', href: '/events', icon: Calendar, requireRead: 'ScheduleEvent' as const, coordinatorOnly: true },
|
||||||
|
{ name: 'Flights', href: '/flights', icon: Plane, requireRead: 'Flight' as const, coordinatorOnly: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Admin dropdown items (nested under Admin)
|
// Admin dropdown items (nested under Admin)
|
||||||
@@ -75,9 +83,15 @@ export function Layout({ children }: LayoutProps) {
|
|||||||
{ name: 'Admin Tools', href: '/admin-tools', icon: Settings },
|
{ name: 'Admin Tools', href: '/admin-tools', icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Filter navigation based on CASL permissions
|
// Filter navigation based on role and CASL permissions
|
||||||
const navigation = allNavigation.filter((item) => {
|
const navigation = allNavigation.filter((item) => {
|
||||||
|
// Driver-only items
|
||||||
|
if (item.driverOnly) return isDriverRole;
|
||||||
|
// Coordinator-only items hidden from drivers
|
||||||
|
if (item.coordinatorOnly && isDriverRole) return false;
|
||||||
|
// Always show items
|
||||||
if (item.alwaysShow) return true;
|
if (item.alwaysShow) return true;
|
||||||
|
// Permission-based items
|
||||||
if (item.requireRead) {
|
if (item.requireRead) {
|
||||||
return ability.can(Action.Read, item.requireRead);
|
return ability.can(Action.Read, item.requireRead);
|
||||||
}
|
}
|
||||||
@@ -99,20 +113,33 @@ export function Layout({ children }: LayoutProps) {
|
|||||||
|
|
||||||
const pendingApprovalsCount = users?.filter((u) => !u.isApproved).length || 0;
|
const pendingApprovalsCount = users?.filter((u) => !u.isApproved).length || 0;
|
||||||
|
|
||||||
|
// Fetch driver's own profile if they are a driver
|
||||||
|
const isDriver = backendUser?.role === 'DRIVER';
|
||||||
|
const { data: myDriverProfile } = useQuery<{ id: string; phone: string | null }>({
|
||||||
|
queryKey: ['my-driver-profile'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get('/drivers/me');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: isDriver,
|
||||||
|
});
|
||||||
|
|
||||||
|
const driverNeedsPhone = isDriver && myDriverProfile && !myDriverProfile.phone;
|
||||||
|
|
||||||
const isActive = (path: string) => location.pathname === path;
|
const isActive = (path: string) => location.pathname === path;
|
||||||
const isAdminActive = adminItems.some(item => isActive(item.href));
|
const isAdminActive = adminItems.some(item => isActive(item.href));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-muted/30">
|
||||||
{/* Top Navigation */}
|
{/* Top Navigation */}
|
||||||
<nav className="bg-white shadow-sm border-b">
|
<nav className="bg-card shadow-soft border-b border-border sticky top-0 z-40 backdrop-blur-sm bg-card/95">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex justify-between h-16">
|
<div className="flex justify-between h-16">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center flex-1 min-w-0">
|
||||||
{/* Mobile menu button - shows on portrait iPad and smaller */}
|
{/* Mobile menu button - shows on portrait iPad and smaller */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="lg:hidden inline-flex items-center justify-center p-2 rounded-md text-gray-600 hover:text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary mr-2"
|
className="lg:hidden inline-flex items-center justify-center p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary mr-2"
|
||||||
style={{ minWidth: '44px', minHeight: '44px' }}
|
style={{ minWidth: '44px', minHeight: '44px' }}
|
||||||
onClick={() => setMobileMenuOpen(true)}
|
onClick={() => setMobileMenuOpen(true)}
|
||||||
aria-label="Open menu"
|
aria-label="Open menu"
|
||||||
@@ -122,26 +149,26 @@ export function Layout({ children }: LayoutProps) {
|
|||||||
|
|
||||||
<div className="flex-shrink-0 flex items-center">
|
<div className="flex-shrink-0 flex items-center">
|
||||||
<Plane className="h-8 w-8 text-primary" />
|
<Plane className="h-8 w-8 text-primary" />
|
||||||
<span className="ml-2 text-xl font-bold text-gray-900">
|
<span className="ml-2 text-xl font-bold text-foreground">
|
||||||
VIP Coordinator
|
VIP Coordinator
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop navigation - shows on landscape iPad and larger */}
|
{/* Desktop navigation - shows on landscape iPad and larger */}
|
||||||
<div className="hidden lg:ml-6 lg:flex lg:space-x-8 lg:items-center">
|
<div className="hidden lg:ml-6 lg:flex lg:space-x-4 xl:space-x-6 lg:items-center">
|
||||||
{navigation.map((item) => {
|
{navigation.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.name}
|
key={item.name}
|
||||||
to={item.href}
|
to={item.href}
|
||||||
className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${
|
className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium transition-colors whitespace-nowrap ${
|
||||||
isActive(item.href)
|
isActive(item.href)
|
||||||
? 'border-primary text-gray-900'
|
? 'border-primary text-foreground'
|
||||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
|
: 'border-transparent text-muted-foreground hover:border-border hover:text-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4 mr-2" />
|
<Icon className="h-4 w-4 mr-1.5" />
|
||||||
{item.name}
|
{item.name}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
@@ -149,19 +176,19 @@ export function Layout({ children }: LayoutProps) {
|
|||||||
|
|
||||||
{/* Admin Dropdown */}
|
{/* Admin Dropdown */}
|
||||||
{canAccessAdmin && (
|
{canAccessAdmin && (
|
||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative flex-shrink-0" ref={dropdownRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setAdminDropdownOpen(!adminDropdownOpen)}
|
onClick={() => setAdminDropdownOpen(!adminDropdownOpen)}
|
||||||
className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${
|
className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium transition-colors whitespace-nowrap ${
|
||||||
isAdminActive
|
isAdminActive
|
||||||
? 'border-primary text-gray-900'
|
? 'border-primary text-foreground'
|
||||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
|
: 'border-transparent text-muted-foreground hover:border-border hover:text-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Shield className="h-4 w-4 mr-2" />
|
<Shield className="h-4 w-4 mr-1.5" />
|
||||||
Admin
|
Admin
|
||||||
{pendingApprovalsCount > 0 && (
|
{pendingApprovalsCount > 0 && (
|
||||||
<span className="ml-1.5 inline-flex items-center justify-center px-2 py-0.5 text-xs font-bold leading-none text-white bg-red-600 rounded-full">
|
<span className="ml-1.5 inline-flex items-center justify-center px-2 py-0.5 text-xs font-bold leading-none text-white bg-destructive rounded-full">
|
||||||
{pendingApprovalsCount}
|
{pendingApprovalsCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -170,7 +197,7 @@ export function Layout({ children }: LayoutProps) {
|
|||||||
|
|
||||||
{/* Dropdown menu */}
|
{/* Dropdown menu */}
|
||||||
{adminDropdownOpen && (
|
{adminDropdownOpen && (
|
||||||
<div className="absolute left-0 mt-2 w-48 bg-white rounded-md shadow-lg ring-1 ring-black ring-opacity-5 z-50">
|
<div className="absolute left-0 mt-2 w-48 bg-popover rounded-lg shadow-elevated border border-border z-50 overflow-hidden animate-in fade-in slide-in-from-top-2 duration-150">
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
{adminItems.map((item) => {
|
{adminItems.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
@@ -179,10 +206,10 @@ export function Layout({ children }: LayoutProps) {
|
|||||||
key={item.name}
|
key={item.name}
|
||||||
to={item.href}
|
to={item.href}
|
||||||
onClick={() => setAdminDropdownOpen(false)}
|
onClick={() => setAdminDropdownOpen(false)}
|
||||||
className={`flex items-center px-4 py-2 text-sm ${
|
className={`flex items-center px-4 py-2 text-sm transition-colors ${
|
||||||
isActive(item.href)
|
isActive(item.href)
|
||||||
? 'bg-primary/10 text-primary'
|
? 'bg-primary/10 text-primary'
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
: 'text-popover-foreground hover:bg-accent'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4 mr-3" />
|
<Icon className="h-4 w-4 mr-3" />
|
||||||
@@ -198,26 +225,9 @@ export function Layout({ children }: LayoutProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User info and logout */}
|
{/* User section - modern dropdown */}
|
||||||
<div className="flex items-center gap-2 sm:gap-4">
|
<div className="flex items-center flex-shrink-0 ml-4">
|
||||||
<div className="hidden sm:block text-right">
|
<UserMenu />
|
||||||
<div className="text-sm font-medium text-gray-900">
|
|
||||||
{backendUser?.name || user?.name || user?.email}
|
|
||||||
</div>
|
|
||||||
{backendUser?.role && (
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
{backendUser.role}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={logout}
|
|
||||||
className="inline-flex items-center px-3 sm:px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary hover:bg-primary/90"
|
|
||||||
style={{ minHeight: '44px' }}
|
|
||||||
>
|
|
||||||
<LogOut className="h-5 w-5 sm:h-4 sm:w-4 sm:mr-2" />
|
|
||||||
<span className="hidden sm:inline">Sign Out</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -228,25 +238,25 @@ export function Layout({ children }: LayoutProps) {
|
|||||||
<div className="fixed inset-0 z-50 lg:hidden">
|
<div className="fixed inset-0 z-50 lg:hidden">
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/50"
|
className="fixed inset-0 bg-black/50 backdrop-blur-sm animate-in fade-in duration-200"
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Drawer panel */}
|
{/* Drawer panel */}
|
||||||
<div className="fixed inset-y-0 left-0 w-full max-w-sm bg-white shadow-xl">
|
<div className="fixed inset-y-0 left-0 w-full max-w-sm bg-card shadow-elevated animate-in slide-in-from-left duration-300">
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Drawer header */}
|
{/* Drawer header */}
|
||||||
<div className="flex items-center justify-between px-4 h-16 border-b">
|
<div className="flex items-center justify-between px-4 h-16 border-b border-border">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Plane className="h-8 w-8 text-primary" />
|
<Plane className="h-8 w-8 text-primary" />
|
||||||
<span className="ml-2 text-xl font-bold text-gray-900">
|
<span className="ml-2 text-xl font-bold text-foreground">
|
||||||
VIP Coordinator
|
VIP Coordinator
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded-md p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100"
|
className="rounded-md p-2 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||||
style={{ minWidth: '44px', minHeight: '44px' }}
|
style={{ minWidth: '44px', minHeight: '44px' }}
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
aria-label="Close menu"
|
aria-label="Close menu"
|
||||||
@@ -256,19 +266,24 @@ export function Layout({ children }: LayoutProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User info in drawer */}
|
{/* User info in drawer */}
|
||||||
<div className="px-4 py-4 border-b bg-gray-50">
|
<div className="px-4 py-4 border-b border-border bg-muted/50">
|
||||||
<div className="text-sm font-medium text-gray-900">
|
<div className="text-sm font-medium text-foreground">
|
||||||
{backendUser?.name || user?.name || user?.email}
|
{backendUser?.name || user?.name || user?.email}
|
||||||
</div>
|
</div>
|
||||||
{backendUser?.role && (
|
{backendUser?.role && (
|
||||||
<div className="text-xs text-gray-500 mt-1">
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
{backendUser.role}
|
{backendUser.role}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Appearance settings in drawer */}
|
||||||
|
<div className="px-4 py-4 border-b border-border">
|
||||||
|
<AppearanceMenu compact />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Navigation links */}
|
{/* Navigation links */}
|
||||||
<nav className="flex-1 px-4 py-4 space-y-1 overflow-y-auto">
|
<nav className="flex-1 px-4 py-4 space-y-1 overflow-y-auto scrollbar-thin">
|
||||||
{navigation.map((item) => {
|
{navigation.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
return (
|
return (
|
||||||
@@ -276,10 +291,10 @@ export function Layout({ children }: LayoutProps) {
|
|||||||
key={item.name}
|
key={item.name}
|
||||||
to={item.href}
|
to={item.href}
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
className={`flex items-center px-4 py-3 text-base font-medium rounded-md ${
|
className={`flex items-center px-4 py-3 text-base font-medium rounded-lg transition-colors ${
|
||||||
isActive(item.href)
|
isActive(item.href)
|
||||||
? 'bg-primary/10 text-primary'
|
? 'bg-primary/10 text-primary'
|
||||||
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900'
|
: 'text-foreground hover:bg-accent'
|
||||||
}`}
|
}`}
|
||||||
style={{ minHeight: '44px' }}
|
style={{ minHeight: '44px' }}
|
||||||
>
|
>
|
||||||
@@ -294,10 +309,10 @@ export function Layout({ children }: LayoutProps) {
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => setMobileAdminExpanded(!mobileAdminExpanded)}
|
onClick={() => setMobileAdminExpanded(!mobileAdminExpanded)}
|
||||||
className={`w-full flex items-center justify-between px-4 py-3 text-base font-medium rounded-md ${
|
className={`w-full flex items-center justify-between px-4 py-3 text-base font-medium rounded-lg transition-colors ${
|
||||||
isAdminActive
|
isAdminActive
|
||||||
? 'bg-primary/10 text-primary'
|
? 'bg-primary/10 text-primary'
|
||||||
: 'text-gray-700 hover:bg-gray-100 hover:text-gray-900'
|
: 'text-foreground hover:bg-accent'
|
||||||
}`}
|
}`}
|
||||||
style={{ minHeight: '44px' }}
|
style={{ minHeight: '44px' }}
|
||||||
>
|
>
|
||||||
@@ -305,7 +320,7 @@ export function Layout({ children }: LayoutProps) {
|
|||||||
<Shield className="h-5 w-5 mr-3 flex-shrink-0" />
|
<Shield className="h-5 w-5 mr-3 flex-shrink-0" />
|
||||||
Admin
|
Admin
|
||||||
{pendingApprovalsCount > 0 && (
|
{pendingApprovalsCount > 0 && (
|
||||||
<span className="ml-2 inline-flex items-center justify-center px-2 py-0.5 text-xs font-bold leading-none text-white bg-red-600 rounded-full">
|
<span className="ml-2 inline-flex items-center justify-center px-2 py-0.5 text-xs font-bold leading-none text-white bg-destructive rounded-full">
|
||||||
{pendingApprovalsCount}
|
{pendingApprovalsCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -326,10 +341,10 @@ export function Layout({ children }: LayoutProps) {
|
|||||||
setMobileMenuOpen(false);
|
setMobileMenuOpen(false);
|
||||||
setMobileAdminExpanded(false);
|
setMobileAdminExpanded(false);
|
||||||
}}
|
}}
|
||||||
className={`flex items-center px-4 py-3 text-base rounded-md ${
|
className={`flex items-center px-4 py-3 text-base rounded-lg transition-colors ${
|
||||||
isActive(item.href)
|
isActive(item.href)
|
||||||
? 'bg-primary/10 text-primary font-medium'
|
? 'bg-primary/10 text-primary font-medium'
|
||||||
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
|
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||||
}`}
|
}`}
|
||||||
style={{ minHeight: '44px' }}
|
style={{ minHeight: '44px' }}
|
||||||
>
|
>
|
||||||
@@ -345,13 +360,13 @@ export function Layout({ children }: LayoutProps) {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Logout button at bottom of drawer */}
|
{/* Logout button at bottom of drawer */}
|
||||||
<div className="border-t px-4 py-4">
|
<div className="border-t border-border px-4 py-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMobileMenuOpen(false);
|
setMobileMenuOpen(false);
|
||||||
logout();
|
logout();
|
||||||
}}
|
}}
|
||||||
className="w-full flex items-center justify-center px-4 py-3 border border-transparent text-base font-medium rounded-md text-white bg-primary hover:bg-primary/90"
|
className="w-full flex items-center justify-center px-4 py-3 border border-transparent text-base font-medium rounded-lg text-primary-foreground bg-primary hover:bg-primary/90 transition-colors shadow-soft"
|
||||||
style={{ minHeight: '44px' }}
|
style={{ minHeight: '44px' }}
|
||||||
>
|
>
|
||||||
<LogOut className="h-5 w-5 mr-2" />
|
<LogOut className="h-5 w-5 mr-2" />
|
||||||
@@ -363,10 +378,40 @@ export function Layout({ children }: LayoutProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Driver Phone Number Reminder Banner */}
|
||||||
|
{driverNeedsPhone && (
|
||||||
|
<div className="bg-amber-50 dark:bg-amber-950/30 border-b border-amber-200 dark:border-amber-800">
|
||||||
|
<div className="max-w-7xl mx-auto py-3 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<AlertCircle className="h-5 w-5 text-amber-600 dark:text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||||
|
<Phone className="h-4 w-4 inline mr-1" />
|
||||||
|
Please add your phone number to receive trip notifications via Signal.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/profile"
|
||||||
|
className="flex-shrink-0 text-sm font-medium text-amber-700 dark:text-amber-300 hover:text-amber-900 dark:hover:text-amber-100 underline"
|
||||||
|
>
|
||||||
|
Update Profile →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
<main className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* AI Copilot - floating chat (only for Admins and Coordinators) */}
|
||||||
|
{backendUser && (backendUser.role === 'ADMINISTRATOR' || backendUser.role === 'COORDINATOR') && (
|
||||||
|
<AICopilot />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ interface LoadingProps {
|
|||||||
export function Loading({ message = 'Loading...', fullPage = false }: LoadingProps) {
|
export function Loading({ message = 'Loading...', fullPage = false }: LoadingProps) {
|
||||||
if (fullPage) {
|
if (fullPage) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
<div className="min-h-screen flex items-center justify-center bg-muted">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Loader2 className="h-12 w-12 text-primary animate-spin mx-auto mb-4" />
|
<Loader2 className="h-12 w-12 text-primary animate-spin mx-auto mb-4" />
|
||||||
<p className="text-gray-600 text-lg">{message}</p>
|
<p className="text-muted-foreground text-lg">{message}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -21,7 +21,7 @@ export function Loading({ message = 'Loading...', fullPage = false }: LoadingPro
|
|||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Loader2 className="h-8 w-8 text-primary animate-spin mx-auto mb-3" />
|
<Loader2 className="h-8 w-8 text-primary animate-spin mx-auto mb-3" />
|
||||||
<p className="text-gray-600">{message}</p>
|
<p className="text-muted-foreground">{message}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
727
frontend/src/components/PdfSettingsSection.tsx
Normal file
727
frontend/src/components/PdfSettingsSection.tsx
Normal file
@@ -0,0 +1,727 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { pdf } from '@react-pdf/renderer';
|
||||||
|
import { VIPSchedulePDF } from '@/components/VIPSchedulePDF';
|
||||||
|
import {
|
||||||
|
Settings,
|
||||||
|
FileText,
|
||||||
|
Upload,
|
||||||
|
Palette,
|
||||||
|
X,
|
||||||
|
Loader2,
|
||||||
|
Eye,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
usePdfSettings,
|
||||||
|
useUpdatePdfSettings,
|
||||||
|
useUploadLogo,
|
||||||
|
useDeleteLogo,
|
||||||
|
} from '@/hooks/useSettings';
|
||||||
|
import { UpdatePdfSettingsDto, PageSize } from '@/types/settings';
|
||||||
|
|
||||||
|
export function PdfSettingsSection() {
|
||||||
|
const { data: settings, isLoading: loadingSettings } = usePdfSettings();
|
||||||
|
const updateSettings = useUpdatePdfSettings();
|
||||||
|
const uploadLogo = useUploadLogo();
|
||||||
|
const deleteLogo = useDeleteLogo();
|
||||||
|
|
||||||
|
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
||||||
|
const [expandedSections, setExpandedSections] = useState({
|
||||||
|
branding: true,
|
||||||
|
contact: false,
|
||||||
|
document: false,
|
||||||
|
content: false,
|
||||||
|
custom: false,
|
||||||
|
});
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const { register, handleSubmit, watch, reset } = useForm<UpdatePdfSettingsDto>();
|
||||||
|
|
||||||
|
const accentColor = watch('accentColor');
|
||||||
|
|
||||||
|
// Update form when settings load
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings) {
|
||||||
|
// Map PdfSettings to UpdatePdfSettingsDto (exclude id, createdAt, updatedAt, logoUrl)
|
||||||
|
const formData: UpdatePdfSettingsDto = {
|
||||||
|
organizationName: settings.organizationName,
|
||||||
|
accentColor: settings.accentColor,
|
||||||
|
tagline: settings.tagline || undefined,
|
||||||
|
contactEmail: settings.contactEmail,
|
||||||
|
contactPhone: settings.contactPhone,
|
||||||
|
secondaryContactName: settings.secondaryContactName || undefined,
|
||||||
|
secondaryContactPhone: settings.secondaryContactPhone || undefined,
|
||||||
|
contactLabel: settings.contactLabel,
|
||||||
|
showDraftWatermark: settings.showDraftWatermark,
|
||||||
|
showConfidentialWatermark: settings.showConfidentialWatermark,
|
||||||
|
showTimestamp: settings.showTimestamp,
|
||||||
|
showAppUrl: settings.showAppUrl,
|
||||||
|
pageSize: settings.pageSize,
|
||||||
|
showFlightInfo: settings.showFlightInfo,
|
||||||
|
showDriverNames: settings.showDriverNames,
|
||||||
|
showVehicleNames: settings.showVehicleNames,
|
||||||
|
showVipNotes: settings.showVipNotes,
|
||||||
|
showEventDescriptions: settings.showEventDescriptions,
|
||||||
|
headerMessage: settings.headerMessage || undefined,
|
||||||
|
footerMessage: settings.footerMessage || undefined,
|
||||||
|
};
|
||||||
|
reset(formData);
|
||||||
|
}
|
||||||
|
}, [settings, reset]);
|
||||||
|
|
||||||
|
const toggleSection = (section: keyof typeof expandedSections) => {
|
||||||
|
setExpandedSections(prev => ({
|
||||||
|
...prev,
|
||||||
|
[section]: !prev[section],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (data: UpdatePdfSettingsDto) => {
|
||||||
|
try {
|
||||||
|
await updateSettings.mutateAsync(data);
|
||||||
|
toast.success('PDF settings saved successfully');
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.message || 'Failed to save settings');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogoUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// Validate file size (2MB)
|
||||||
|
if (file.size > 2 * 1024 * 1024) {
|
||||||
|
toast.error('Logo file must be less than 2MB');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file type
|
||||||
|
if (!['image/png', 'image/jpeg', 'image/svg+xml'].includes(file.type)) {
|
||||||
|
toast.error('Logo must be PNG, JPG, or SVG');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show preview
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => {
|
||||||
|
setLogoPreview(reader.result as string);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
|
||||||
|
// Upload
|
||||||
|
try {
|
||||||
|
await uploadLogo.mutateAsync(file);
|
||||||
|
toast.success('Logo uploaded successfully');
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.message || 'Failed to upload logo');
|
||||||
|
setLogoPreview(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteLogo = async () => {
|
||||||
|
if (!confirm('Remove logo from PDF templates?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteLogo.mutateAsync();
|
||||||
|
setLogoPreview(null);
|
||||||
|
toast.success('Logo removed');
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.message || 'Failed to remove logo');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePreview = async () => {
|
||||||
|
if (!settings) {
|
||||||
|
toast.error('Settings not loaded yet');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
toast.loading('Generating preview PDF...', { id: 'pdf-preview' });
|
||||||
|
|
||||||
|
// Mock VIP data
|
||||||
|
const mockVIP = {
|
||||||
|
id: 'sample-id',
|
||||||
|
name: 'John Sample',
|
||||||
|
organization: 'Sample Corporation',
|
||||||
|
department: 'OFFICE_OF_DEVELOPMENT',
|
||||||
|
arrivalMode: 'FLIGHT',
|
||||||
|
expectedArrival: null,
|
||||||
|
airportPickup: true,
|
||||||
|
venueTransport: true,
|
||||||
|
notes: 'This is a sample itinerary to preview your PDF customization settings.',
|
||||||
|
flights: [
|
||||||
|
{
|
||||||
|
id: 'flight-1',
|
||||||
|
flightNumber: 'AA1234',
|
||||||
|
departureAirport: 'JFK',
|
||||||
|
arrivalAirport: 'LAX',
|
||||||
|
scheduledDeparture: new Date(Date.now() + 86400000).toISOString(), // Tomorrow
|
||||||
|
scheduledArrival: new Date(Date.now() + 86400000 + 21600000).toISOString(), // Tomorrow + 6 hours
|
||||||
|
status: 'scheduled',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock events
|
||||||
|
const tomorrow = new Date(Date.now() + 86400000);
|
||||||
|
const mockEvents = [
|
||||||
|
{
|
||||||
|
id: 'event-1',
|
||||||
|
title: 'Airport Pickup',
|
||||||
|
pickupLocation: 'LAX Terminal 4',
|
||||||
|
dropoffLocation: 'Grand Hotel',
|
||||||
|
location: null,
|
||||||
|
startTime: new Date(tomorrow.getTime() + 25200000).toISOString(), // 10 AM
|
||||||
|
endTime: new Date(tomorrow.getTime() + 28800000).toISOString(), // 11 AM
|
||||||
|
type: 'TRANSPORT',
|
||||||
|
status: 'SCHEDULED',
|
||||||
|
description: 'Transportation from airport to hotel',
|
||||||
|
driver: { id: 'driver-1', name: 'Michael Driver' },
|
||||||
|
vehicle: { id: 'vehicle-1', name: 'Blue Van', type: 'VAN', seatCapacity: 12 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'event-2',
|
||||||
|
title: 'Welcome Lunch',
|
||||||
|
pickupLocation: null,
|
||||||
|
dropoffLocation: null,
|
||||||
|
location: 'Grand Ballroom',
|
||||||
|
startTime: new Date(tomorrow.getTime() + 43200000).toISOString(), // 12 PM
|
||||||
|
endTime: new Date(tomorrow.getTime() + 48600000).toISOString(), // 1:30 PM
|
||||||
|
type: 'MEAL',
|
||||||
|
status: 'SCHEDULED',
|
||||||
|
description: 'Networking lunch with other VIP guests and leadership',
|
||||||
|
driver: null,
|
||||||
|
vehicle: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'event-3',
|
||||||
|
title: 'Opening Ceremony',
|
||||||
|
pickupLocation: null,
|
||||||
|
dropoffLocation: null,
|
||||||
|
location: 'Main Arena',
|
||||||
|
startTime: new Date(tomorrow.getTime() + 54000000).toISOString(), // 3 PM
|
||||||
|
endTime: new Date(tomorrow.getTime() + 61200000).toISOString(), // 5 PM
|
||||||
|
type: 'EVENT',
|
||||||
|
status: 'SCHEDULED',
|
||||||
|
description: 'Official opening ceremony with keynote speakers',
|
||||||
|
driver: null,
|
||||||
|
vehicle: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Generate PDF with current settings
|
||||||
|
const blob = await pdf(
|
||||||
|
<VIPSchedulePDF vip={mockVIP} events={mockEvents} settings={settings} />
|
||||||
|
).toBlob();
|
||||||
|
|
||||||
|
// Open in new tab
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
window.open(url, '_blank');
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
toast.success('Preview generated!', { id: 'pdf-preview' });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to generate preview:', error);
|
||||||
|
toast.error('Failed to generate preview', { id: 'pdf-preview' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loadingSettings) {
|
||||||
|
return (
|
||||||
|
<div className="bg-card border border-border shadow-soft rounded-lg p-6 mb-8 transition-colors">
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentLogo = logoPreview || settings?.logoUrl;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-card border border-border shadow-soft rounded-lg p-6 mb-8 transition-colors">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<FileText className="h-5 w-5 text-purple-600 mr-2" />
|
||||||
|
<h2 className="text-lg font-medium text-foreground">PDF Customization</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handlePreview}
|
||||||
|
className="inline-flex items-center px-3 py-2 text-sm border border-input text-foreground rounded-md hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4 mr-1" />
|
||||||
|
Preview Sample PDF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
{/* Branding Section */}
|
||||||
|
<div className="border border-border rounded-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleSection('branding')}
|
||||||
|
className="w-full flex items-center justify-between p-4 bg-muted/30 hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Palette className="h-4 w-4 mr-2 text-muted-foreground" />
|
||||||
|
<span className="font-medium text-foreground">Branding</span>
|
||||||
|
</div>
|
||||||
|
{expandedSections.branding ? (
|
||||||
|
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expandedSections.branding && (
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{/* Organization Name */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
|
Organization Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
{...register('organizationName')}
|
||||||
|
className="w-full px-3 py-2 bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
placeholder="VIP Coordinator"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logo Upload */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
|
Organization Logo
|
||||||
|
</label>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
{currentLogo && (
|
||||||
|
<div className="relative w-32 h-32 border border-border rounded-lg overflow-hidden bg-white p-2">
|
||||||
|
<img
|
||||||
|
src={currentLogo}
|
||||||
|
alt="Logo"
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDeleteLogo}
|
||||||
|
className="absolute top-1 right-1 p-1 bg-red-600 text-white rounded-full hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/svg+xml"
|
||||||
|
onChange={handleLogoUpload}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={uploadLogo.isPending}
|
||||||
|
className="inline-flex items-center px-4 py-2 border border-input text-foreground rounded-md hover:bg-accent disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{uploadLogo.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
|
{currentLogo ? 'Change Logo' : 'Upload Logo'}
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
PNG, JPG, or SVG. Max 2MB.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Accent Color */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
|
Accent Color
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
{...register('accentColor')}
|
||||||
|
className="h-10 w-20 border border-input rounded cursor-pointer"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
{...register('accentColor')}
|
||||||
|
className="flex-1 px-3 py-2 bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary font-mono text-sm"
|
||||||
|
placeholder="#2c3e50"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-10 w-10 rounded border border-input"
|
||||||
|
style={{ backgroundColor: accentColor || '#2c3e50' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Used for headers, section titles, and flight card borders
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tagline */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
|
Tagline (Optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
{...register('tagline')}
|
||||||
|
className="w-full px-3 py-2 bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
placeholder="Excellence in VIP Transportation"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact Information Section */}
|
||||||
|
<div className="border border-border rounded-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleSection('contact')}
|
||||||
|
className="w-full flex items-center justify-between p-4 bg-muted/30 hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Settings className="h-4 w-4 mr-2 text-muted-foreground" />
|
||||||
|
<span className="font-medium text-foreground">Contact Information</span>
|
||||||
|
</div>
|
||||||
|
{expandedSections.contact ? (
|
||||||
|
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expandedSections.contact && (
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
|
Contact Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
{...register('contactEmail')}
|
||||||
|
className="w-full px-3 py-2 bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
|
Contact Phone
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
{...register('contactPhone')}
|
||||||
|
className="w-full px-3 py-2 bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
|
Secondary Contact Name (Optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
{...register('secondaryContactName')}
|
||||||
|
className="w-full px-3 py-2 bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
|
Secondary Contact Phone (Optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
{...register('secondaryContactPhone')}
|
||||||
|
className="w-full px-3 py-2 bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
|
Contact Section Label
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
{...register('contactLabel')}
|
||||||
|
className="w-full px-3 py-2 bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
placeholder="Questions or Changes?"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Document Options Section */}
|
||||||
|
<div className="border border-border rounded-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleSection('document')}
|
||||||
|
className="w-full flex items-center justify-between p-4 bg-muted/30 hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<FileText className="h-4 w-4 mr-2 text-muted-foreground" />
|
||||||
|
<span className="font-medium text-foreground">Document Options</span>
|
||||||
|
</div>
|
||||||
|
{expandedSections.document ? (
|
||||||
|
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expandedSections.document && (
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
<label className="flex items-center gap-3 p-3 border border-border rounded-lg hover:bg-accent/50 transition-colors cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
{...register('showDraftWatermark')}
|
||||||
|
className="h-4 w-4 text-primary border-input rounded focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-foreground">Draft Watermark</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Show diagonal "DRAFT" watermark on all pages
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3 p-3 border border-border rounded-lg hover:bg-accent/50 transition-colors cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
{...register('showConfidentialWatermark')}
|
||||||
|
className="h-4 w-4 text-primary border-input rounded focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-foreground">Confidential Watermark</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Show diagonal "CONFIDENTIAL" watermark on all pages
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3 p-3 border border-border rounded-lg hover:bg-accent/50 transition-colors cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
{...register('showTimestamp')}
|
||||||
|
className="h-4 w-4 text-primary border-input rounded focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-foreground">Show Timestamp</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Display "Generated on [date]" in footer
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3 p-3 border border-border rounded-lg hover:bg-accent/50 transition-colors cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
{...register('showAppUrl')}
|
||||||
|
className="h-4 w-4 text-primary border-input rounded focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-foreground">Show App URL</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Display system URL in footer (not recommended for VIPs)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
|
Page Size
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
{...register('pageSize')}
|
||||||
|
className="w-full px-3 py-2 bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
>
|
||||||
|
<option value={PageSize.LETTER}>Letter (8.5" x 11")</option>
|
||||||
|
<option value={PageSize.A4}>A4 (210mm x 297mm)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Toggles Section */}
|
||||||
|
<div className="border border-border rounded-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleSection('content')}
|
||||||
|
className="w-full flex items-center justify-between p-4 bg-muted/30 hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Settings className="h-4 w-4 mr-2 text-muted-foreground" />
|
||||||
|
<span className="font-medium text-foreground">Content Display</span>
|
||||||
|
</div>
|
||||||
|
{expandedSections.content ? (
|
||||||
|
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expandedSections.content && (
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
<label className="flex items-center gap-3 p-3 border border-border rounded-lg hover:bg-accent/50 transition-colors cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
{...register('showFlightInfo')}
|
||||||
|
className="h-4 w-4 text-primary border-input rounded focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-foreground">Show Flight Information</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Display flight numbers, times, and airports
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3 p-3 border border-border rounded-lg hover:bg-accent/50 transition-colors cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
{...register('showDriverNames')}
|
||||||
|
className="h-4 w-4 text-primary border-input rounded focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-foreground">Show Driver Names</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Display assigned driver names in schedule
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3 p-3 border border-border rounded-lg hover:bg-accent/50 transition-colors cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
{...register('showVehicleNames')}
|
||||||
|
className="h-4 w-4 text-primary border-input rounded focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-foreground">Show Vehicle Names</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Display assigned vehicle names in schedule
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3 p-3 border border-border rounded-lg hover:bg-accent/50 transition-colors cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
{...register('showVipNotes')}
|
||||||
|
className="h-4 w-4 text-primary border-input rounded focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-foreground">Show VIP Notes</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Display notes and special requirements
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3 p-3 border border-border rounded-lg hover:bg-accent/50 transition-colors cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
{...register('showEventDescriptions')}
|
||||||
|
className="h-4 w-4 text-primary border-input rounded focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-foreground">Show Event Descriptions</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Display detailed descriptions for events
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Text Section */}
|
||||||
|
<div className="border border-border rounded-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleSection('custom')}
|
||||||
|
className="w-full flex items-center justify-between p-4 bg-muted/30 hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<FileText className="h-4 w-4 mr-2 text-muted-foreground" />
|
||||||
|
<span className="font-medium text-foreground">Custom Messages</span>
|
||||||
|
</div>
|
||||||
|
{expandedSections.custom ? (
|
||||||
|
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expandedSections.custom && (
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
|
Header Message (Optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
{...register('headerMessage')}
|
||||||
|
rows={2}
|
||||||
|
className="w-full px-3 py-2 bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
placeholder="Welcome to the 2026 Jamboree!"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Displayed at the top of the PDF (max 500 characters)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
|
Footer Message (Optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
{...register('footerMessage')}
|
||||||
|
rows={2}
|
||||||
|
className="w-full px-3 py-2 bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
placeholder="Thank you for being our guest"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Displayed at the bottom of the PDF (max 500 characters)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<div className="flex justify-end pt-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={updateSettings.isPending}
|
||||||
|
className="inline-flex items-center px-6 py-3 bg-primary text-white rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
{updateSettings.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Save PDF Settings'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,23 +4,23 @@
|
|||||||
|
|
||||||
export function TableSkeleton({ rows = 5 }: { rows?: number }) {
|
export function TableSkeleton({ rows = 5 }: { rows?: number }) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
<div className="bg-card shadow rounded-lg overflow-hidden">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-border">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-muted">
|
||||||
<tr>
|
<tr>
|
||||||
{[1, 2, 3, 4, 5].map((col) => (
|
{[1, 2, 3, 4, 5].map((col) => (
|
||||||
<th key={col} className="px-6 py-3">
|
<th key={col} className="px-6 py-3">
|
||||||
<div className="h-4 bg-gray-200 rounded animate-pulse" />
|
<div className="h-4 bg-muted rounded animate-pulse" />
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-card divide-y divide-border">
|
||||||
{Array.from({ length: rows }).map((_, rowIndex) => (
|
{Array.from({ length: rows }).map((_, rowIndex) => (
|
||||||
<tr key={rowIndex}>
|
<tr key={rowIndex}>
|
||||||
{[1, 2, 3, 4, 5].map((col) => (
|
{[1, 2, 3, 4, 5].map((col) => (
|
||||||
<td key={col} className="px-6 py-4">
|
<td key={col} className="px-6 py-4">
|
||||||
<div className="h-4 bg-gray-200 rounded animate-pulse" style={{ width: `${60 + Math.random() * 40}%` }} />
|
<div className="h-4 bg-muted rounded animate-pulse" style={{ width: `${60 + Math.random() * 40}%` }} />
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -35,24 +35,24 @@ export function CardSkeleton({ cards = 3 }: { cards?: number }) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{Array.from({ length: cards }).map((_, index) => (
|
{Array.from({ length: cards }).map((_, index) => (
|
||||||
<div key={index} className="bg-white shadow rounded-lg p-4 animate-pulse">
|
<div key={index} className="bg-card shadow rounded-lg p-4 animate-pulse">
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<div className="h-6 bg-gray-200 rounded w-1/2 mb-2" />
|
<div className="h-6 bg-muted rounded w-1/2 mb-2" />
|
||||||
<div className="h-4 bg-gray-200 rounded w-1/3" />
|
<div className="h-4 bg-muted rounded w-1/3" />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="h-3 bg-gray-200 rounded w-20 mb-1" />
|
<div className="h-3 bg-muted rounded w-20 mb-1" />
|
||||||
<div className="h-4 bg-gray-200 rounded w-24" />
|
<div className="h-4 bg-muted rounded w-24" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="h-3 bg-gray-200 rounded w-20 mb-1" />
|
<div className="h-3 bg-muted rounded w-20 mb-1" />
|
||||||
<div className="h-4 bg-gray-200 rounded w-16" />
|
<div className="h-4 bg-muted rounded w-16" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 pt-3 border-t border-gray-200">
|
<div className="flex gap-2 pt-3 border-t border-border">
|
||||||
<div className="flex-1 h-11 bg-gray-200 rounded" />
|
<div className="flex-1 h-11 bg-muted rounded" />
|
||||||
<div className="flex-1 h-11 bg-gray-200 rounded" />
|
<div className="flex-1 h-11 bg-muted rounded" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -64,30 +64,30 @@ export function VIPCardSkeleton({ cards = 6 }: { cards?: number }) {
|
|||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{Array.from({ length: cards }).map((_, index) => (
|
{Array.from({ length: cards }).map((_, index) => (
|
||||||
<div key={index} className="bg-white rounded-lg shadow p-6 animate-pulse">
|
<div key={index} className="bg-card rounded-lg shadow p-6 animate-pulse">
|
||||||
<div className="flex items-start justify-between mb-4">
|
<div className="flex items-start justify-between mb-4">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="h-6 bg-gray-200 rounded w-3/4 mb-2" />
|
<div className="h-6 bg-muted rounded w-3/4 mb-2" />
|
||||||
<div className="h-4 bg-gray-200 rounded w-1/2" />
|
<div className="h-4 bg-muted rounded w-1/2" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="h-4 w-4 bg-gray-200 rounded mr-2" />
|
<div className="h-4 w-4 bg-muted rounded mr-2" />
|
||||||
<div className="h-4 bg-gray-200 rounded w-32" />
|
<div className="h-4 bg-muted rounded w-32" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="h-4 w-4 bg-gray-200 rounded mr-2" />
|
<div className="h-4 w-4 bg-muted rounded mr-2" />
|
||||||
<div className="h-4 bg-gray-200 rounded w-24" />
|
<div className="h-4 bg-muted rounded w-24" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="h-4 w-4 bg-gray-200 rounded mr-2" />
|
<div className="h-4 w-4 bg-muted rounded mr-2" />
|
||||||
<div className="h-4 bg-gray-200 rounded w-40" />
|
<div className="h-4 bg-muted rounded w-40" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 pt-4 border-t flex gap-2">
|
<div className="mt-4 pt-4 border-t border-border flex gap-2">
|
||||||
<div className="flex-1 h-9 bg-gray-200 rounded" />
|
<div className="flex-1 h-9 bg-muted rounded" />
|
||||||
<div className="flex-1 h-9 bg-gray-200 rounded" />
|
<div className="flex-1 h-9 bg-muted rounded" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
65
frontend/src/components/ThemeToggle.tsx
Normal file
65
frontend/src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { Sun, Moon, Monitor } from 'lucide-react';
|
||||||
|
import { useTheme, ThemeMode } from '@/hooks/useTheme';
|
||||||
|
|
||||||
|
const modes: { value: ThemeMode; label: string; icon: typeof Sun }[] = [
|
||||||
|
{ value: 'light', label: 'Light', icon: Sun },
|
||||||
|
{ value: 'dark', label: 'Dark', icon: Moon },
|
||||||
|
{ value: 'system', label: 'System', icon: Monitor },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { mode, resolvedTheme, setMode } = useTheme();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Get current icon based on resolved theme
|
||||||
|
const CurrentIcon = resolvedTheme === 'dark' ? Moon : Sun;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="flex items-center justify-center w-9 h-9 rounded-lg bg-muted hover:bg-accent transition-colors focus-ring"
|
||||||
|
aria-label={`Current theme: ${mode}. Click to change.`}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-haspopup="true"
|
||||||
|
>
|
||||||
|
<CurrentIcon className="h-5 w-5 text-foreground" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute right-0 mt-2 w-36 rounded-lg bg-popover border border-border shadow-elevated z-50 overflow-hidden animate-in fade-in slide-in-from-top-2 duration-150">
|
||||||
|
{modes.map(({ value, label, icon: Icon }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
onClick={() => {
|
||||||
|
setMode(value);
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
className={`flex items-center gap-3 w-full px-3 py-2.5 text-sm transition-colors ${
|
||||||
|
mode === value
|
||||||
|
? 'bg-primary/10 text-primary font-medium'
|
||||||
|
: 'text-popover-foreground hover:bg-accent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
198
frontend/src/components/UserMenu.tsx
Normal file
198
frontend/src/components/UserMenu.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { ChevronDown, LogOut, Sun, Moon, Monitor, Check } from 'lucide-react';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { useTheme, ThemeMode, ColorScheme } from '@/hooks/useTheme';
|
||||||
|
|
||||||
|
const modes: { value: ThemeMode; label: string; icon: typeof Sun }[] = [
|
||||||
|
{ value: 'light', label: 'Light', icon: Sun },
|
||||||
|
{ value: 'dark', label: 'Dark', icon: Moon },
|
||||||
|
{ value: 'system', label: 'System', icon: Monitor },
|
||||||
|
];
|
||||||
|
|
||||||
|
const colorSchemes: { value: ColorScheme; label: string; color: string }[] = [
|
||||||
|
{ value: 'blue', label: 'Blue', color: 'bg-blue-500' },
|
||||||
|
{ value: 'purple', label: 'Purple', color: 'bg-purple-500' },
|
||||||
|
{ value: 'green', label: 'Green', color: 'bg-green-500' },
|
||||||
|
{ value: 'orange', label: 'Orange', color: 'bg-orange-500' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function UserMenu() {
|
||||||
|
const { user, backendUser, logout } = useAuth();
|
||||||
|
const { mode, colorScheme, setMode, setColorScheme } = useTheme();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Get user display info
|
||||||
|
const displayName = backendUser?.name || user?.name || user?.email || 'User';
|
||||||
|
const displayEmail = backendUser?.email || user?.email || '';
|
||||||
|
const displayRole = backendUser?.role || '';
|
||||||
|
|
||||||
|
// Generate initials for avatar
|
||||||
|
const getInitials = (name: string) => {
|
||||||
|
if (!name) return 'U';
|
||||||
|
const parts = name.split(' ');
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||||
|
}
|
||||||
|
return name.substring(0, 2).toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
const initials = getInitials(displayName);
|
||||||
|
|
||||||
|
// Get role badge color
|
||||||
|
const getRoleBadgeColor = (role: string) => {
|
||||||
|
switch (role.toUpperCase()) {
|
||||||
|
case 'ADMINISTRATOR':
|
||||||
|
return 'bg-red-500/10 text-red-600 dark:text-red-400';
|
||||||
|
case 'COORDINATOR':
|
||||||
|
return 'bg-blue-500/10 text-blue-600 dark:text-blue-400';
|
||||||
|
case 'DRIVER':
|
||||||
|
return 'bg-green-500/10 text-green-600 dark:text-green-400';
|
||||||
|
default:
|
||||||
|
return 'bg-muted text-muted-foreground';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
{/* User menu trigger button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-accent transition-colors"
|
||||||
|
style={{ minHeight: '44px' }}
|
||||||
|
aria-label="User menu"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-haspopup="true"
|
||||||
|
>
|
||||||
|
{/* Avatar with initials */}
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-primary text-primary-foreground font-semibold text-sm">
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name (hidden on mobile) */}
|
||||||
|
<span className="hidden md:block text-sm font-medium text-foreground max-w-[120px] truncate">
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Chevron icon */}
|
||||||
|
<ChevronDown
|
||||||
|
className={`h-4 w-4 text-muted-foreground transition-transform ${
|
||||||
|
isOpen ? 'rotate-180' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown menu */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute right-0 mt-2 w-[280px] rounded-lg bg-popover border border-border shadow-elevated z-50 overflow-hidden animate-in fade-in slide-in-from-top-2 duration-150">
|
||||||
|
{/* User info section */}
|
||||||
|
<div className="px-4 py-3 border-b border-border">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-primary text-primary-foreground font-semibold">
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User details */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-foreground truncate">
|
||||||
|
{displayName}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||||
|
{displayEmail}
|
||||||
|
</p>
|
||||||
|
{displayRole && (
|
||||||
|
<span
|
||||||
|
className={`inline-block mt-1.5 px-2 py-0.5 text-xs font-medium rounded ${getRoleBadgeColor(
|
||||||
|
displayRole
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{displayRole}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Appearance section */}
|
||||||
|
<div className="px-4 py-3 border-b border-border">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
|
||||||
|
Appearance
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Theme mode */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<p className="text-xs text-muted-foreground mb-1.5">Theme</p>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{modes.map(({ value, label, icon: Icon }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
onClick={() => setMode(value)}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded text-xs font-medium transition-colors ${
|
||||||
|
mode === value
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-muted hover:bg-accent text-foreground'
|
||||||
|
}`}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
<Icon className="h-3.5 w-3.5" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Color scheme */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground mb-1.5">Color</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{colorSchemes.map(({ value, label, color }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
onClick={() => setColorScheme(value)}
|
||||||
|
className={`relative w-8 h-8 rounded-full ${color} transition-all hover:scale-110 ${
|
||||||
|
colorScheme === value
|
||||||
|
? 'ring-2 ring-offset-2 ring-offset-popover ring-foreground scale-110'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
title={label}
|
||||||
|
aria-label={`${label} color scheme`}
|
||||||
|
>
|
||||||
|
{colorScheme === value && (
|
||||||
|
<Check className="absolute inset-0 m-auto h-3.5 w-3.5 text-white drop-shadow-sm" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sign out section */}
|
||||||
|
<div className="p-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
logout();
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-3 px-3 py-2 rounded text-sm font-medium text-destructive hover:bg-destructive/10 transition-colors"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
171
frontend/src/components/VIPSchedulePDF.README.md
Normal file
171
frontend/src/components/VIPSchedulePDF.README.md
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
# VIP Schedule PDF Generator
|
||||||
|
|
||||||
|
Professional PDF generation for VIP schedules using @react-pdf/renderer.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Professional, print-ready PDF documents
|
||||||
|
- Prominent timestamp showing when PDF was generated
|
||||||
|
- Warning banner alerting users to check the app for latest updates
|
||||||
|
- Contact information footer for questions
|
||||||
|
- Branded header with VIP information
|
||||||
|
- Color-coded event types (Transport, Meeting, Event, Meal)
|
||||||
|
- Flight information display
|
||||||
|
- Driver and vehicle assignments
|
||||||
|
- Clean, professional formatting suitable for VIPs and coordinators
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { pdf } from '@react-pdf/renderer';
|
||||||
|
import { VIPSchedulePDF } from '@/components/VIPSchedulePDF';
|
||||||
|
|
||||||
|
// Generate and download PDF
|
||||||
|
const handleExport = async () => {
|
||||||
|
const blob = await pdf(
|
||||||
|
<VIPSchedulePDF
|
||||||
|
vip={vipData}
|
||||||
|
events={scheduleEvents}
|
||||||
|
contactEmail="coordinator@example.com"
|
||||||
|
contactPhone="(555) 123-4567"
|
||||||
|
appUrl={window.location.origin}
|
||||||
|
/>
|
||||||
|
).toBlob();
|
||||||
|
|
||||||
|
// Create download
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `${vipData.name}_Schedule.pdf`;
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Props
|
||||||
|
|
||||||
|
### VIPSchedulePDFProps
|
||||||
|
|
||||||
|
| Prop | Type | Required | Description |
|
||||||
|
|------|------|----------|-------------|
|
||||||
|
| `vip` | `VIP` | Yes | VIP information including name, organization, arrival details |
|
||||||
|
| `events` | `PDFScheduleEvent[]` | Yes | Array of scheduled events for the VIP |
|
||||||
|
| `contactEmail` | `string` | No | Contact email for questions (default from env) |
|
||||||
|
| `contactPhone` | `string` | No | Contact phone for questions (default from env) |
|
||||||
|
| `appUrl` | `string` | No | URL to the web app for latest schedule updates |
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Configure contact information in `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
VITE_CONTACT_EMAIL=coordinator@example.com
|
||||||
|
VITE_CONTACT_PHONE=(555) 123-4567
|
||||||
|
VITE_ORGANIZATION_NAME=VIP Coordinator
|
||||||
|
```
|
||||||
|
|
||||||
|
## PDF Structure
|
||||||
|
|
||||||
|
1. **Header Section**
|
||||||
|
- VIP name (large, branded blue)
|
||||||
|
- Organization and department
|
||||||
|
- Generation timestamp with warning banner
|
||||||
|
|
||||||
|
2. **VIP Information**
|
||||||
|
- Arrival mode and expected arrival time
|
||||||
|
- Airport pickup and venue transport flags
|
||||||
|
|
||||||
|
3. **Flight Information** (if applicable)
|
||||||
|
- Flight numbers and routes
|
||||||
|
- Scheduled arrival times
|
||||||
|
- Flight status
|
||||||
|
|
||||||
|
4. **Special Notes** (if provided)
|
||||||
|
- Highlighted yellow box with special instructions
|
||||||
|
|
||||||
|
5. **Schedule & Itinerary**
|
||||||
|
- Events grouped by day
|
||||||
|
- Color-coded by event type
|
||||||
|
- Time, location, and description
|
||||||
|
- Driver and vehicle assignments
|
||||||
|
- Event status badges
|
||||||
|
|
||||||
|
6. **Footer** (on every page)
|
||||||
|
- Contact information
|
||||||
|
- Page numbers
|
||||||
|
|
||||||
|
## Event Type Colors
|
||||||
|
|
||||||
|
- **Transport**: Blue background, blue border
|
||||||
|
- **Meeting**: Purple background, purple border
|
||||||
|
- **Event**: Green background, green border
|
||||||
|
- **Meal**: Orange background, orange border
|
||||||
|
- **Accommodation**: Gray background, gray border
|
||||||
|
|
||||||
|
## Timestamp Warning
|
||||||
|
|
||||||
|
The PDF includes a prominent yellow warning banner that shows:
|
||||||
|
- When the PDF was generated
|
||||||
|
- A notice that it's a snapshot in time
|
||||||
|
- Instructions to visit the web app for the latest schedule
|
||||||
|
|
||||||
|
This ensures VIPs and coordinators know to check for updates.
|
||||||
|
|
||||||
|
## Customization
|
||||||
|
|
||||||
|
To customize branding colors, edit the `styles` object in `VIPSchedulePDF.tsx`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
title: {
|
||||||
|
color: '#1a56db', // Primary brand color
|
||||||
|
},
|
||||||
|
// ... other styles
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## PDF Output
|
||||||
|
|
||||||
|
- **Format**: A4 size
|
||||||
|
- **Font**: Helvetica (built-in, ensures consistent rendering)
|
||||||
|
- **File naming**: `{VIP_Name}_Schedule_{Date}.pdf`
|
||||||
|
- **Quality**: Print-ready, professional formatting
|
||||||
|
|
||||||
|
## Browser Compatibility
|
||||||
|
|
||||||
|
Works in all modern browsers:
|
||||||
|
- Chrome/Edge 90+
|
||||||
|
- Firefox 88+
|
||||||
|
- Safari 14+
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
PDF generation is fast:
|
||||||
|
- Small schedules (1-5 events): < 1 second
|
||||||
|
- Medium schedules (6-20 events): 1-2 seconds
|
||||||
|
- Large schedules (20+ events): 2-3 seconds
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**PDF fails to generate:**
|
||||||
|
- Check browser console for errors
|
||||||
|
- Ensure all required props are provided
|
||||||
|
- Verify event data has valid date strings
|
||||||
|
|
||||||
|
**Styling looks wrong:**
|
||||||
|
- @react-pdf/renderer uses its own styling system
|
||||||
|
- Not all CSS properties are supported
|
||||||
|
- Check official docs for supported styles
|
||||||
|
|
||||||
|
**Fonts not loading:**
|
||||||
|
- Built-in fonts (Helvetica, Times, Courier) always work
|
||||||
|
- Custom fonts require Font.register() call
|
||||||
|
- Ensure font URLs are accessible
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- [ ] Add QR code linking to web app
|
||||||
|
- [ ] Support for custom logos/branding
|
||||||
|
- [ ] Multiple language support
|
||||||
|
- [ ] Print optimization options
|
||||||
|
- [ ] Email integration for sending PDFs
|
||||||
612
frontend/src/components/VIPSchedulePDF.tsx
Normal file
612
frontend/src/components/VIPSchedulePDF.tsx
Normal file
@@ -0,0 +1,612 @@
|
|||||||
|
/**
|
||||||
|
* VIP Schedule PDF Generator
|
||||||
|
*
|
||||||
|
* Professional itinerary document designed for VIP guests.
|
||||||
|
* Fully customizable through PDF Settings.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Document,
|
||||||
|
Page,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
StyleSheet,
|
||||||
|
Font,
|
||||||
|
Image,
|
||||||
|
} from '@react-pdf/renderer';
|
||||||
|
import { PdfSettings } from '@/types/settings';
|
||||||
|
|
||||||
|
// Register fonts for professional typography
|
||||||
|
Font.register({
|
||||||
|
family: 'Helvetica',
|
||||||
|
fonts: [
|
||||||
|
{ src: 'Helvetica' },
|
||||||
|
{ src: 'Helvetica-Bold', fontWeight: 'bold' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
interface VIP {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
organization: string | null;
|
||||||
|
department: string;
|
||||||
|
arrivalMode: string;
|
||||||
|
expectedArrival: string | null;
|
||||||
|
airportPickup: boolean;
|
||||||
|
venueTransport: boolean;
|
||||||
|
notes: string | null;
|
||||||
|
flights: Array<{
|
||||||
|
id: string;
|
||||||
|
flightNumber: string;
|
||||||
|
departureAirport: string;
|
||||||
|
arrivalAirport: string;
|
||||||
|
scheduledDeparture: string | null;
|
||||||
|
scheduledArrival: string | null;
|
||||||
|
status: string | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PDFScheduleEvent {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
pickupLocation?: string | null;
|
||||||
|
dropoffLocation?: string | null;
|
||||||
|
location?: string | null;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
description?: string | null;
|
||||||
|
driver?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
} | null;
|
||||||
|
vehicle?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
seatCapacity?: number;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VIPSchedulePDFProps {
|
||||||
|
vip: VIP;
|
||||||
|
events: PDFScheduleEvent[];
|
||||||
|
settings?: PdfSettings | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create dynamic styles based on settings
|
||||||
|
const createStyles = (accentColor: string = '#2c3e50', pageSize: 'LETTER' | 'A4' = 'LETTER') =>
|
||||||
|
StyleSheet.create({
|
||||||
|
page: {
|
||||||
|
padding: 50,
|
||||||
|
paddingBottom: 80,
|
||||||
|
fontSize: 10,
|
||||||
|
fontFamily: 'Helvetica',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
color: '#333333',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Watermark
|
||||||
|
watermark: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: '40%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%) rotate(-45deg)',
|
||||||
|
fontSize: 72,
|
||||||
|
color: '#888888',
|
||||||
|
opacity: 0.2,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
zIndex: 0,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Logo
|
||||||
|
logoContainer: {
|
||||||
|
marginBottom: 15,
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
logo: {
|
||||||
|
maxWidth: 150,
|
||||||
|
maxHeight: 60,
|
||||||
|
objectFit: 'contain',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Header
|
||||||
|
header: {
|
||||||
|
marginBottom: 30,
|
||||||
|
borderBottom: `2 solid ${accentColor}`,
|
||||||
|
paddingBottom: 20,
|
||||||
|
},
|
||||||
|
orgName: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#7f8c8d',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 2,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: accentColor,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#7f8c8d',
|
||||||
|
},
|
||||||
|
tagline: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#95a5a6',
|
||||||
|
marginTop: 4,
|
||||||
|
fontStyle: 'italic',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Custom messages
|
||||||
|
customMessage: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#7f8c8d',
|
||||||
|
marginTop: 10,
|
||||||
|
padding: 10,
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
borderLeft: `3 solid ${accentColor}`,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Timestamp
|
||||||
|
timestampBar: {
|
||||||
|
marginTop: 15,
|
||||||
|
paddingTop: 10,
|
||||||
|
borderTop: '1 solid #ecf0f1',
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
timestamp: {
|
||||||
|
fontSize: 8,
|
||||||
|
color: '#95a5a6',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Sections
|
||||||
|
section: {
|
||||||
|
marginBottom: 25,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: accentColor,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 1,
|
||||||
|
marginBottom: 12,
|
||||||
|
paddingBottom: 6,
|
||||||
|
borderBottom: `2 solid ${accentColor}`,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Flight info
|
||||||
|
flightCard: {
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
padding: 15,
|
||||||
|
marginBottom: 10,
|
||||||
|
borderLeft: `3 solid ${accentColor}`,
|
||||||
|
},
|
||||||
|
flightNumber: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#2c3e50',
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
flightRoute: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#34495e',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
flightTime: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#7f8c8d',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Day header
|
||||||
|
dayHeader: {
|
||||||
|
backgroundColor: accentColor,
|
||||||
|
color: '#ffffff',
|
||||||
|
padding: 10,
|
||||||
|
marginBottom: 0,
|
||||||
|
marginTop: 15,
|
||||||
|
},
|
||||||
|
dayHeaderText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#ffffff',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Schedule table
|
||||||
|
scheduleTable: {
|
||||||
|
borderLeft: '1 solid #dee2e6',
|
||||||
|
borderRight: '1 solid #dee2e6',
|
||||||
|
},
|
||||||
|
scheduleRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
borderBottom: '1 solid #dee2e6',
|
||||||
|
minHeight: 45,
|
||||||
|
},
|
||||||
|
scheduleRowAlt: {
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
},
|
||||||
|
timeColumn: {
|
||||||
|
width: '18%',
|
||||||
|
padding: 10,
|
||||||
|
borderRight: '1 solid #dee2e6',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
timeText: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#2c3e50',
|
||||||
|
},
|
||||||
|
timeEndText: {
|
||||||
|
fontSize: 8,
|
||||||
|
color: '#95a5a6',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
detailsColumn: {
|
||||||
|
width: '82%',
|
||||||
|
padding: 10,
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
eventTitle: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#2c3e50',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
eventLocation: {
|
||||||
|
fontSize: 9,
|
||||||
|
color: '#7f8c8d',
|
||||||
|
marginBottom: 3,
|
||||||
|
},
|
||||||
|
eventDriver: {
|
||||||
|
fontSize: 9,
|
||||||
|
color: accentColor,
|
||||||
|
},
|
||||||
|
eventDescription: {
|
||||||
|
fontSize: 9,
|
||||||
|
color: '#7f8c8d',
|
||||||
|
marginTop: 4,
|
||||||
|
fontStyle: 'italic',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Notes section
|
||||||
|
notesBox: {
|
||||||
|
backgroundColor: '#fef9e7',
|
||||||
|
padding: 15,
|
||||||
|
borderLeft: '3 solid #f1c40f',
|
||||||
|
},
|
||||||
|
notesTitle: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#7d6608',
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
|
notesText: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#5d4e37',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
footer: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 30,
|
||||||
|
left: 50,
|
||||||
|
right: 50,
|
||||||
|
paddingTop: 15,
|
||||||
|
borderTop: '1 solid #dee2e6',
|
||||||
|
},
|
||||||
|
footerContent: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
footerLeft: {
|
||||||
|
maxWidth: '60%',
|
||||||
|
},
|
||||||
|
footerTitle: {
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#2c3e50',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
footerContact: {
|
||||||
|
fontSize: 8,
|
||||||
|
color: '#7f8c8d',
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
footerRight: {
|
||||||
|
textAlign: 'right',
|
||||||
|
},
|
||||||
|
pageNumber: {
|
||||||
|
fontSize: 8,
|
||||||
|
color: '#95a5a6',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Empty state
|
||||||
|
emptyState: {
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: 30,
|
||||||
|
color: '#95a5a6',
|
||||||
|
fontSize: 11,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function VIPSchedulePDF({
|
||||||
|
vip,
|
||||||
|
events,
|
||||||
|
settings,
|
||||||
|
}: VIPSchedulePDFProps) {
|
||||||
|
// Default settings if not provided
|
||||||
|
const config = settings || {
|
||||||
|
organizationName: 'VIP Transportation Services',
|
||||||
|
accentColor: '#2c3e50',
|
||||||
|
contactEmail: 'coordinator@example.com',
|
||||||
|
contactPhone: '(555) 123-4567',
|
||||||
|
contactLabel: 'Questions or Changes?',
|
||||||
|
showDraftWatermark: false,
|
||||||
|
showConfidentialWatermark: false,
|
||||||
|
showTimestamp: true,
|
||||||
|
showAppUrl: false,
|
||||||
|
pageSize: 'LETTER' as const,
|
||||||
|
showFlightInfo: true,
|
||||||
|
showDriverNames: true,
|
||||||
|
showVehicleNames: true,
|
||||||
|
showVipNotes: true,
|
||||||
|
showEventDescriptions: true,
|
||||||
|
logoUrl: null,
|
||||||
|
tagline: null,
|
||||||
|
headerMessage: null,
|
||||||
|
footerMessage: null,
|
||||||
|
secondaryContactName: null,
|
||||||
|
secondaryContactPhone: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = createStyles(config.accentColor, config.pageSize);
|
||||||
|
|
||||||
|
// Format generation timestamp
|
||||||
|
const generatedAt = new Date().toLocaleString('en-US', {
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter out cancelled events and sort by start time
|
||||||
|
const activeEvents = events
|
||||||
|
.filter(e => e.status !== 'CANCELLED')
|
||||||
|
.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
|
||||||
|
|
||||||
|
// Group events by day
|
||||||
|
const eventsByDay = activeEvents.reduce((acc, event) => {
|
||||||
|
const date = new Date(event.startTime).toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
if (!acc[date]) {
|
||||||
|
acc[date] = [];
|
||||||
|
}
|
||||||
|
acc[date].push(event);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, PDFScheduleEvent[]>);
|
||||||
|
|
||||||
|
// Format time helper
|
||||||
|
const formatTime = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleTimeString('en-US', {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format location for display
|
||||||
|
const formatLocation = (event: PDFScheduleEvent) => {
|
||||||
|
if (event.type === 'TRANSPORT') {
|
||||||
|
const pickup = event.pickupLocation || 'Pickup location';
|
||||||
|
const dropoff = event.dropoffLocation || 'Destination';
|
||||||
|
return `From: ${pickup} → To: ${dropoff}`;
|
||||||
|
}
|
||||||
|
return event.location || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Document>
|
||||||
|
<Page size={config.pageSize} style={styles.page}>
|
||||||
|
{/* Watermarks */}
|
||||||
|
{config.showDraftWatermark && (
|
||||||
|
<View style={styles.watermark} fixed>
|
||||||
|
<Text>DRAFT</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{config.showConfidentialWatermark && (
|
||||||
|
<View style={styles.watermark} fixed>
|
||||||
|
<Text>CONFIDENTIAL</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
{/* Logo */}
|
||||||
|
{config.logoUrl && (
|
||||||
|
<View style={styles.logoContainer}>
|
||||||
|
<Image src={config.logoUrl} style={styles.logo} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text style={styles.orgName}>{config.organizationName}</Text>
|
||||||
|
<Text style={styles.title}>Itinerary for {vip.name}</Text>
|
||||||
|
{vip.organization && (
|
||||||
|
<Text style={styles.subtitle}>{vip.organization}</Text>
|
||||||
|
)}
|
||||||
|
{config.tagline && (
|
||||||
|
<Text style={styles.tagline}>{config.tagline}</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Custom Header Message */}
|
||||||
|
{config.headerMessage && (
|
||||||
|
<Text style={styles.customMessage}>{config.headerMessage}</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timestamp bar */}
|
||||||
|
{(config.showTimestamp || config.showAppUrl) && (
|
||||||
|
<View style={styles.timestampBar}>
|
||||||
|
{config.showTimestamp && (
|
||||||
|
<Text style={styles.timestamp}>
|
||||||
|
Generated: {generatedAt}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{config.showAppUrl && (
|
||||||
|
<Text style={styles.timestamp}>
|
||||||
|
Latest version: {window.location.origin}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Flight Information */}
|
||||||
|
{config.showFlightInfo && vip.flights && vip.flights.length > 0 && (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>Flight Information</Text>
|
||||||
|
{vip.flights.map((flight) => (
|
||||||
|
<View key={flight.id} style={styles.flightCard}>
|
||||||
|
<Text style={styles.flightNumber}>{flight.flightNumber}</Text>
|
||||||
|
<Text style={styles.flightRoute}>
|
||||||
|
{flight.departureAirport} → {flight.arrivalAirport}
|
||||||
|
</Text>
|
||||||
|
{flight.scheduledDeparture && (
|
||||||
|
<Text style={styles.flightTime}>
|
||||||
|
Departs: {new Date(flight.scheduledDeparture).toLocaleString('en-US', {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{flight.scheduledArrival && (
|
||||||
|
<Text style={styles.flightTime}>
|
||||||
|
Arrives: {new Date(flight.scheduledArrival).toLocaleString('en-US', {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Schedule */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>Your Schedule</Text>
|
||||||
|
|
||||||
|
{activeEvents.length === 0 ? (
|
||||||
|
<Text style={styles.emptyState}>
|
||||||
|
No scheduled activities at this time.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
Object.entries(eventsByDay).map(([date, dayEvents]) => (
|
||||||
|
<View key={date} wrap={false}>
|
||||||
|
{/* Day Header */}
|
||||||
|
<View style={styles.dayHeader}>
|
||||||
|
<Text style={styles.dayHeaderText}>{date}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Events Table */}
|
||||||
|
<View style={styles.scheduleTable}>
|
||||||
|
{dayEvents.map((event, index) => (
|
||||||
|
<View
|
||||||
|
key={event.id}
|
||||||
|
style={[
|
||||||
|
styles.scheduleRow,
|
||||||
|
index % 2 === 1 ? styles.scheduleRowAlt : {},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{/* Time Column */}
|
||||||
|
<View style={styles.timeColumn}>
|
||||||
|
<Text style={styles.timeText}>{formatTime(event.startTime)}</Text>
|
||||||
|
<Text style={styles.timeEndText}>to {formatTime(event.endTime)}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Details Column */}
|
||||||
|
<View style={styles.detailsColumn}>
|
||||||
|
<Text style={styles.eventTitle}>{event.title}</Text>
|
||||||
|
|
||||||
|
{formatLocation(event) && (
|
||||||
|
<Text style={styles.eventLocation}>{formatLocation(event)}</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{event.type === 'TRANSPORT' && event.driver && config.showDriverNames && (
|
||||||
|
<Text style={styles.eventDriver}>
|
||||||
|
Your driver: {event.driver.name}
|
||||||
|
{event.vehicle && config.showVehicleNames ? ` (${event.vehicle.name})` : ''}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{event.description && config.showEventDescriptions && (
|
||||||
|
<Text style={styles.eventDescription}>{event.description}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Special Notes */}
|
||||||
|
{config.showVipNotes && vip.notes && (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<View style={styles.notesBox}>
|
||||||
|
<Text style={styles.notesTitle}>Important Notes</Text>
|
||||||
|
<Text style={styles.notesText}>{vip.notes}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Custom Footer Message */}
|
||||||
|
{config.footerMessage && (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.customMessage}>{config.footerMessage}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<View style={styles.footer} fixed>
|
||||||
|
<View style={styles.footerContent}>
|
||||||
|
<View style={styles.footerLeft}>
|
||||||
|
<Text style={styles.footerTitle}>{config.contactLabel}</Text>
|
||||||
|
<Text style={styles.footerContact}>{config.contactEmail}</Text>
|
||||||
|
<Text style={styles.footerContact}>{config.contactPhone}</Text>
|
||||||
|
{config.secondaryContactName && (
|
||||||
|
<Text style={styles.footerContact}>
|
||||||
|
{config.secondaryContactName}
|
||||||
|
{config.secondaryContactPhone ? ` - ${config.secondaryContactPhone}` : ''}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={styles.footerRight}>
|
||||||
|
<Text
|
||||||
|
style={styles.pageNumber}
|
||||||
|
render={({ pageNumber, totalPages }) =>
|
||||||
|
`Page ${pageNumber} of ${totalPages}`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Page>
|
||||||
|
</Document>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -85,15 +85,15 @@ export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps)
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-full md:max-w-2xl lg:max-w-3xl max-h-[90vh] overflow-y-auto">
|
<div className="bg-card rounded-lg shadow-xl w-full max-w-full md:max-w-2xl lg:max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||||
<div className="flex items-center justify-between p-4 md:p-6 border-b">
|
<div className="flex items-center justify-between p-4 md:p-6 border-b border-border">
|
||||||
<h2 className="text-xl md:text-2xl font-bold text-gray-900">
|
<h2 className="text-xl md:text-2xl font-bold text-foreground">
|
||||||
{vip ? 'Edit VIP' : 'Add New VIP'}
|
{vip ? 'Edit VIP' : 'Add New VIP'}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="text-gray-400 hover:text-gray-600 p-2 rounded-md hover:bg-gray-100"
|
className="text-muted-foreground hover:text-foreground p-2 rounded-md hover:bg-accent"
|
||||||
style={{ minWidth: '44px', minHeight: '44px' }}
|
style={{ minWidth: '44px', minHeight: '44px' }}
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
@@ -104,7 +104,7 @@ export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps)
|
|||||||
<form onSubmit={handleSubmit} className="p-4 md:p-6 space-y-5">
|
<form onSubmit={handleSubmit} className="p-4 md:p-6 space-y-5">
|
||||||
{/* Name */}
|
{/* Name */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
Full Name *
|
Full Name *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -113,14 +113,14 @@ export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps)
|
|||||||
required
|
required
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="w-full px-4 py-3 text-base border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full px-4 py-3 text-base bg-background text-foreground placeholder:text-muted-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
style={{ minHeight: '44px' }}
|
style={{ minHeight: '44px' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Organization */}
|
{/* Organization */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
Organization
|
Organization
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -128,14 +128,14 @@ export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps)
|
|||||||
name="organization"
|
name="organization"
|
||||||
value={formData.organization}
|
value={formData.organization}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="w-full px-4 py-3 text-base border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full px-4 py-3 text-base bg-background text-foreground placeholder:text-muted-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
style={{ minHeight: '44px' }}
|
style={{ minHeight: '44px' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Department */}
|
{/* Department */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
Department *
|
Department *
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
@@ -143,7 +143,7 @@ export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps)
|
|||||||
required
|
required
|
||||||
value={formData.department}
|
value={formData.department}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="w-full px-4 py-3 text-base border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full px-4 py-3 text-base bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
style={{ minHeight: '44px' }}
|
style={{ minHeight: '44px' }}
|
||||||
>
|
>
|
||||||
<option value="OFFICE_OF_DEVELOPMENT">Office of Development</option>
|
<option value="OFFICE_OF_DEVELOPMENT">Office of Development</option>
|
||||||
@@ -153,7 +153,7 @@ export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps)
|
|||||||
|
|
||||||
{/* Arrival Mode */}
|
{/* Arrival Mode */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
Arrival Mode *
|
Arrival Mode *
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
@@ -161,7 +161,7 @@ export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps)
|
|||||||
required
|
required
|
||||||
value={formData.arrivalMode}
|
value={formData.arrivalMode}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="w-full px-4 py-3 text-base border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full px-4 py-3 text-base bg-background text-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
style={{ minHeight: '44px' }}
|
style={{ minHeight: '44px' }}
|
||||||
>
|
>
|
||||||
<option value="FLIGHT">Flight</option>
|
<option value="FLIGHT">Flight</option>
|
||||||
@@ -171,7 +171,7 @@ export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps)
|
|||||||
|
|
||||||
{/* Expected Arrival */}
|
{/* Expected Arrival */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
Expected Arrival
|
Expected Arrival
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -179,7 +179,7 @@ export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps)
|
|||||||
name="expectedArrival"
|
name="expectedArrival"
|
||||||
value={formData.expectedArrival}
|
value={formData.expectedArrival}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="w-full px-4 py-3 text-base border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full px-4 py-3 text-base bg-background text-foreground placeholder:text-muted-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
style={{ minHeight: '44px' }}
|
style={{ minHeight: '44px' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -192,9 +192,9 @@ export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps)
|
|||||||
name="airportPickup"
|
name="airportPickup"
|
||||||
checked={formData.airportPickup}
|
checked={formData.airportPickup}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="h-5 w-5 text-primary border-gray-300 rounded focus:ring-primary"
|
className="h-5 w-5 text-primary border-input rounded focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
<span className="ml-3 text-base text-gray-700">
|
<span className="ml-3 text-base text-foreground">
|
||||||
Airport pickup required
|
Airport pickup required
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -205,9 +205,9 @@ export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps)
|
|||||||
name="venueTransport"
|
name="venueTransport"
|
||||||
checked={formData.venueTransport}
|
checked={formData.venueTransport}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="h-5 w-5 text-primary border-gray-300 rounded focus:ring-primary"
|
className="h-5 w-5 text-primary border-input rounded focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
<span className="ml-3 text-base text-gray-700">
|
<span className="ml-3 text-base text-foreground">
|
||||||
Venue transport required
|
Venue transport required
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -215,7 +215,7 @@ export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps)
|
|||||||
|
|
||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-foreground mb-2">
|
||||||
Notes
|
Notes
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -224,7 +224,7 @@ export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps)
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="Any special requirements or notes"
|
placeholder="Any special requirements or notes"
|
||||||
className="w-full px-4 py-3 text-base border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full px-4 py-3 text-base bg-background text-foreground placeholder:text-muted-foreground border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -241,7 +241,7 @@ export function VIPForm({ vip, onSubmit, onCancel, isSubmitting }: VIPFormProps)
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="flex-1 bg-gray-200 text-gray-800 py-3 px-4 rounded-md hover:bg-gray-300 font-medium"
|
className="flex-1 bg-muted text-foreground py-3 px-4 rounded-md hover:bg-muted/80 font-medium"
|
||||||
style={{ minHeight: '44px' }}
|
style={{ minHeight: '44px' }}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
124
frontend/src/contexts/ThemeContext.tsx
Normal file
124
frontend/src/contexts/ThemeContext.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react';
|
||||||
|
|
||||||
|
export type ThemeMode = 'light' | 'dark' | 'system';
|
||||||
|
export type ColorScheme = 'blue' | 'purple' | 'green' | 'orange';
|
||||||
|
|
||||||
|
interface ThemeContextType {
|
||||||
|
mode: ThemeMode;
|
||||||
|
colorScheme: ColorScheme;
|
||||||
|
resolvedTheme: 'light' | 'dark';
|
||||||
|
setMode: (mode: ThemeMode) => void;
|
||||||
|
setColorScheme: (scheme: ColorScheme) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'vip-theme';
|
||||||
|
|
||||||
|
interface StoredTheme {
|
||||||
|
mode: ThemeMode;
|
||||||
|
colorScheme: ColorScheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStoredTheme(): StoredTheme {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
return {
|
||||||
|
mode: parsed.mode || 'system',
|
||||||
|
colorScheme: parsed.colorScheme || 'blue',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[THEME] Failed to parse stored theme:', e);
|
||||||
|
}
|
||||||
|
return { mode: 'system', colorScheme: 'blue' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSystemTheme(): 'light' | 'dark' {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
return 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [mode, setModeState] = useState<ThemeMode>(() => getStoredTheme().mode);
|
||||||
|
const [colorScheme, setColorSchemeState] = useState<ColorScheme>(() => getStoredTheme().colorScheme);
|
||||||
|
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => getSystemTheme());
|
||||||
|
|
||||||
|
// Compute resolved theme
|
||||||
|
const resolvedTheme = mode === 'system' ? systemTheme : mode;
|
||||||
|
|
||||||
|
// Listen for system theme changes
|
||||||
|
useEffect(() => {
|
||||||
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
|
||||||
|
const handleChange = (e: MediaQueryListEvent) => {
|
||||||
|
setSystemTheme(e.matches ? 'dark' : 'light');
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaQuery.addEventListener('change', handleChange);
|
||||||
|
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Apply theme to document
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
|
||||||
|
// Apply dark mode class
|
||||||
|
if (resolvedTheme === 'dark') {
|
||||||
|
root.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
root.classList.remove('dark');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply color scheme
|
||||||
|
root.dataset.theme = colorScheme;
|
||||||
|
|
||||||
|
// Add transition class after initial load to prevent FOUC
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
root.classList.add('theme-transition');
|
||||||
|
});
|
||||||
|
}, [resolvedTheme, colorScheme]);
|
||||||
|
|
||||||
|
// Persist theme to localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify({ mode, colorScheme }));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[THEME] Failed to save theme:', e);
|
||||||
|
}
|
||||||
|
}, [mode, colorScheme]);
|
||||||
|
|
||||||
|
const setMode = useCallback((newMode: ThemeMode) => {
|
||||||
|
setModeState(newMode);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setColorScheme = useCallback((newScheme: ColorScheme) => {
|
||||||
|
setColorSchemeState(newScheme);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider
|
||||||
|
value={{
|
||||||
|
mode,
|
||||||
|
colorScheme,
|
||||||
|
resolvedTheme,
|
||||||
|
setMode,
|
||||||
|
setColorScheme,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const context = useContext(ThemeContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useTheme must be used within a ThemeProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
74
frontend/src/hooks/useSettings.ts
Normal file
74
frontend/src/hooks/useSettings.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
import { PdfSettings, UpdatePdfSettingsDto } from '../types/settings';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch PDF settings
|
||||||
|
*/
|
||||||
|
export function usePdfSettings() {
|
||||||
|
return useQuery<PdfSettings>({
|
||||||
|
queryKey: ['settings', 'pdf'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get('/settings/pdf');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update PDF settings
|
||||||
|
*/
|
||||||
|
export function useUpdatePdfSettings() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (dto: UpdatePdfSettingsDto) => {
|
||||||
|
const { data } = await api.patch('/settings/pdf', dto);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['settings', 'pdf'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload logo
|
||||||
|
*/
|
||||||
|
export function useUploadLogo() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (file: File) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('logo', file);
|
||||||
|
|
||||||
|
const { data } = await api.post('/settings/pdf/logo', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['settings', 'pdf'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete logo
|
||||||
|
*/
|
||||||
|
export function useDeleteLogo() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const { data } = await api.delete('/settings/pdf/logo');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['settings', 'pdf'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
138
frontend/src/hooks/useSignalMessages.ts
Normal file
138
frontend/src/hooks/useSignalMessages.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
|
||||||
|
export interface SignalMessage {
|
||||||
|
id: string;
|
||||||
|
driverId: string;
|
||||||
|
direction: 'INBOUND' | 'OUTBOUND';
|
||||||
|
content: string;
|
||||||
|
timestamp: string;
|
||||||
|
isRead: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnreadCounts {
|
||||||
|
[driverId: string]: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch messages for a specific driver
|
||||||
|
*/
|
||||||
|
export function useDriverMessages(driverId: string | null, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['signal-messages', driverId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!driverId) return [];
|
||||||
|
const { data } = await api.get<SignalMessage[]>(`/signal/messages/driver/${driverId}`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: enabled && !!driverId,
|
||||||
|
refetchInterval: 5000, // Poll for new messages every 5 seconds
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch unread message counts for all drivers
|
||||||
|
*/
|
||||||
|
export function useUnreadCounts() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['signal-unread-counts'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get<UnreadCounts>('/signal/messages/unread');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
refetchInterval: 10000, // Poll every 10 seconds
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check which events have driver responses since the event started
|
||||||
|
* @param events Array of events with id, driverId, and startTime
|
||||||
|
*/
|
||||||
|
export function useDriverResponseCheck(
|
||||||
|
events: Array<{ id: string; driver?: { id: string } | null; startTime: string }>
|
||||||
|
) {
|
||||||
|
// Only include events that have a driver
|
||||||
|
const eventsWithDrivers = events.filter((e) => e.driver?.id);
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['signal-driver-responses', eventsWithDrivers.map((e) => e.id).join(',')],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (eventsWithDrivers.length === 0) {
|
||||||
|
return new Set<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
events: eventsWithDrivers.map((e) => ({
|
||||||
|
eventId: e.id,
|
||||||
|
driverId: e.driver!.id,
|
||||||
|
startTime: e.startTime,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data } = await api.post<{ respondedEventIds: string[] }>(
|
||||||
|
'/signal/messages/check-responses',
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
return new Set(data.respondedEventIds);
|
||||||
|
},
|
||||||
|
enabled: eventsWithDrivers.length > 0,
|
||||||
|
refetchInterval: 10000, // Poll every 10 seconds
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message to a driver
|
||||||
|
*/
|
||||||
|
export function useSendMessage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ driverId, content }: { driverId: string; content: string }) => {
|
||||||
|
const { data } = await api.post<SignalMessage>('/signal/messages/send', {
|
||||||
|
driverId,
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (data, variables) => {
|
||||||
|
// Add the new message to the cache immediately
|
||||||
|
queryClient.setQueryData<SignalMessage[]>(
|
||||||
|
['signal-messages', variables.driverId],
|
||||||
|
(old) => [...(old || []), data]
|
||||||
|
);
|
||||||
|
// Also invalidate to ensure consistency
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['signal-messages', variables.driverId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark messages as read for a driver
|
||||||
|
*/
|
||||||
|
export function useMarkMessagesAsRead() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (driverId: string) => {
|
||||||
|
const { data } = await api.post(`/signal/messages/driver/${driverId}/read`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (_, driverId) => {
|
||||||
|
// Update the unread counts cache
|
||||||
|
queryClient.setQueryData<UnreadCounts>(
|
||||||
|
['signal-unread-counts'],
|
||||||
|
(old) => {
|
||||||
|
if (!old) return {};
|
||||||
|
const updated = { ...old };
|
||||||
|
delete updated[driverId];
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// Mark messages as read in the messages cache
|
||||||
|
queryClient.setQueryData<SignalMessage[]>(
|
||||||
|
['signal-messages', driverId],
|
||||||
|
(old) => old?.map((msg) => ({ ...msg, isRead: true })) || []
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
3
frontend/src/hooks/useTheme.ts
Normal file
3
frontend/src/hooks/useTheme.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// Re-export useTheme from ThemeContext for convenience
|
||||||
|
export { useTheme } from '@/contexts/ThemeContext';
|
||||||
|
export type { ThemeMode, ColorScheme } from '@/contexts/ThemeContext';
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
/* ===== LIGHT MODE (Default) ===== */
|
||||||
:root {
|
:root {
|
||||||
--background: 0 0% 100%;
|
--background: 0 0% 100%;
|
||||||
--foreground: 222.2 84% 4.9%;
|
--foreground: 222.2 84% 4.9%;
|
||||||
@@ -24,6 +25,86 @@
|
|||||||
--input: 214.3 31.8% 91.4%;
|
--input: 214.3 31.8% 91.4%;
|
||||||
--ring: 221.2 83.2% 53.3%;
|
--ring: 221.2 83.2% 53.3%;
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
|
|
||||||
|
/* Additional semantic tokens */
|
||||||
|
--success: 142 76% 36%;
|
||||||
|
--success-foreground: 0 0% 100%;
|
||||||
|
--warning: 38 92% 50%;
|
||||||
|
--warning-foreground: 0 0% 0%;
|
||||||
|
--info: 199 89% 48%;
|
||||||
|
--info-foreground: 0 0% 100%;
|
||||||
|
|
||||||
|
/* Surface variants for depth */
|
||||||
|
--surface-1: 0 0% 100%;
|
||||||
|
--surface-2: 210 40% 98%;
|
||||||
|
--surface-3: 210 40% 96%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== DARK MODE ===== */
|
||||||
|
.dark {
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
--card: 222.2 47% 11%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
--popover: 222.2 47% 11%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
--primary: 217.2 91.2% 59.8%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
--accent: 217.2 32.6% 17.5%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
--destructive: 0 62.8% 50%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 217.2 32.6% 20%;
|
||||||
|
--input: 217.2 32.6% 20%;
|
||||||
|
--ring: 224.3 76.3% 48%;
|
||||||
|
|
||||||
|
--success: 142 71% 45%;
|
||||||
|
--success-foreground: 0 0% 100%;
|
||||||
|
--warning: 38 92% 50%;
|
||||||
|
--warning-foreground: 0 0% 0%;
|
||||||
|
--info: 199 89% 48%;
|
||||||
|
--info-foreground: 0 0% 100%;
|
||||||
|
|
||||||
|
--surface-1: 222.2 47% 11%;
|
||||||
|
--surface-2: 222.2 47% 13%;
|
||||||
|
--surface-3: 222.2 47% 15%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== COLOR SCHEMES ===== */
|
||||||
|
/* Blue Theme (Default - no override needed for :root) */
|
||||||
|
|
||||||
|
/* Purple Theme */
|
||||||
|
[data-theme="purple"] {
|
||||||
|
--primary: 262.1 83.3% 57.8%;
|
||||||
|
--ring: 262.1 83.3% 57.8%;
|
||||||
|
}
|
||||||
|
.dark[data-theme="purple"] {
|
||||||
|
--primary: 263.4 70% 60%;
|
||||||
|
--ring: 263.4 70% 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Green Theme */
|
||||||
|
[data-theme="green"] {
|
||||||
|
--primary: 142.1 70.6% 45.3%;
|
||||||
|
--ring: 142.1 70.6% 45.3%;
|
||||||
|
}
|
||||||
|
.dark[data-theme="green"] {
|
||||||
|
--primary: 142.1 76.2% 50%;
|
||||||
|
--ring: 142.1 76.2% 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Orange Theme */
|
||||||
|
[data-theme="orange"] {
|
||||||
|
--primary: 24.6 95% 53.1%;
|
||||||
|
--ring: 24.6 95% 53.1%;
|
||||||
|
}
|
||||||
|
.dark[data-theme="orange"] {
|
||||||
|
--primary: 20.5 90.2% 55%;
|
||||||
|
--ring: 20.5 90.2% 55%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,6 +113,121 @@
|
|||||||
@apply border-border;
|
@apply border-border;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground antialiased;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== THEME TRANSITIONS ===== */
|
||||||
|
@layer utilities {
|
||||||
|
.theme-transition {
|
||||||
|
transition-property: background-color, border-color, color, fill, stroke;
|
||||||
|
transition-duration: 200ms;
|
||||||
|
transition-timing-function: ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-transition * {
|
||||||
|
transition-property: background-color, border-color, color, fill, stroke;
|
||||||
|
transition-duration: 200ms;
|
||||||
|
transition-timing-function: ease-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== CUSTOM SHADOWS ===== */
|
||||||
|
@layer utilities {
|
||||||
|
.shadow-soft {
|
||||||
|
box-shadow:
|
||||||
|
0 1px 3px 0 rgb(0 0 0 / 0.05),
|
||||||
|
0 1px 2px -1px rgb(0 0 0 / 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow-medium {
|
||||||
|
box-shadow:
|
||||||
|
0 4px 6px -1px rgb(0 0 0 / 0.07),
|
||||||
|
0 2px 4px -2px rgb(0 0 0 / 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow-elevated {
|
||||||
|
box-shadow:
|
||||||
|
0 10px 15px -3px rgb(0 0 0 / 0.08),
|
||||||
|
0 4px 6px -4px rgb(0 0 0 / 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .shadow-soft {
|
||||||
|
box-shadow:
|
||||||
|
0 1px 3px 0 rgb(0 0 0 / 0.3),
|
||||||
|
0 1px 2px -1px rgb(0 0 0 / 0.3),
|
||||||
|
0 0 0 1px rgb(255 255 255 / 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .shadow-medium {
|
||||||
|
box-shadow:
|
||||||
|
0 4px 6px -1px rgb(0 0 0 / 0.4),
|
||||||
|
0 2px 4px -2px rgb(0 0 0 / 0.4),
|
||||||
|
0 0 0 1px rgb(255 255 255 / 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .shadow-elevated {
|
||||||
|
box-shadow:
|
||||||
|
0 10px 15px -3px rgb(0 0 0 / 0.5),
|
||||||
|
0 4px 6px -4px rgb(0 0 0 / 0.5),
|
||||||
|
0 0 0 1px rgb(255 255 255 / 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== GRADIENT UTILITIES ===== */
|
||||||
|
@layer utilities {
|
||||||
|
.gradient-primary {
|
||||||
|
background: linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(var(--primary) / 0.8) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-subtle {
|
||||||
|
background: linear-gradient(180deg, hsl(var(--background)) 0%, hsl(var(--muted) / 0.5) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-card {
|
||||||
|
background: linear-gradient(180deg, hsl(var(--card)) 0%, hsl(var(--card) / 0.95) 100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== FOCUS RING UTILITIES ===== */
|
||||||
|
@layer utilities {
|
||||||
|
.focus-ring {
|
||||||
|
@apply focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== SCROLLBAR STYLING ===== */
|
||||||
|
@layer utilities {
|
||||||
|
.scrollbar-thin {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||||
|
background-color: hsl(var(--muted-foreground) / 0.3);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: hsl(var(--muted-foreground) / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbar completely while still allowing scroll */
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none; /* Chrome, Safari and Opera */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,16 +79,21 @@ export function defineAbilitiesFor(user: User | null): AppAbility {
|
|||||||
cannot(Action.Delete, 'User');
|
cannot(Action.Delete, 'User');
|
||||||
cannot(Action.Approve, 'User');
|
cannot(Action.Approve, 'User');
|
||||||
} else if (user.role === 'DRIVER') {
|
} else if (user.role === 'DRIVER') {
|
||||||
// Drivers can only read most resources
|
// Drivers have very limited access - only their own data
|
||||||
can(Action.Read, ['VIP', 'Driver', 'ScheduleEvent', 'Vehicle']);
|
// They can read their own schedule events (filtered by backend)
|
||||||
|
can(Action.Read, 'ScheduleEvent');
|
||||||
|
|
||||||
// Drivers can update status of events (specific instance check would need event data)
|
// Drivers can update status of their own events
|
||||||
can(Action.UpdateStatus, 'ScheduleEvent');
|
can(Action.UpdateStatus, 'ScheduleEvent');
|
||||||
|
|
||||||
// Cannot access flights
|
// Drivers can read and update their own driver profile only
|
||||||
cannot(Action.Read, 'Flight');
|
can(Action.Read, 'Driver');
|
||||||
|
can(Action.Update, 'Driver'); // For updating their own phone number
|
||||||
|
|
||||||
// Cannot access users
|
// Cannot access other resources
|
||||||
|
cannot(Action.Read, 'VIP'); // VIP info comes embedded in their assignments
|
||||||
|
cannot(Action.Read, 'Vehicle'); // Vehicle info comes embedded in their assignments
|
||||||
|
cannot(Action.Read, 'Flight');
|
||||||
cannot(Action.Read, 'User');
|
cannot(Action.Read, 'User');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,81 +11,95 @@ export const api = axios.create({
|
|||||||
timeout: 30000, // 30 second timeout
|
timeout: 30000, // 30 second timeout
|
||||||
});
|
});
|
||||||
|
|
||||||
// Request interceptor to add auth token and log requests
|
// Separate instance for AI Copilot with longer timeout (AI can take a while to respond)
|
||||||
api.interceptors.request.use(
|
export const copilotApi = axios.create({
|
||||||
(config) => {
|
baseURL: API_URL,
|
||||||
const token = localStorage.getItem('auth0_token');
|
headers: {
|
||||||
if (token) {
|
'Content-Type': 'application/json',
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log request in development mode
|
|
||||||
if (DEBUG_MODE) {
|
|
||||||
console.log(`[API] → ${config.method?.toUpperCase()} ${config.url}`, {
|
|
||||||
data: config.data,
|
|
||||||
params: config.params,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
},
|
},
|
||||||
(error) => {
|
timeout: 120000, // 2 minute timeout for AI requests
|
||||||
console.error('[API] Request error:', error);
|
});
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Response interceptor for logging and error handling
|
// Shared request interceptor function
|
||||||
api.interceptors.response.use(
|
const requestInterceptor = (config: any) => {
|
||||||
(response) => {
|
const token = localStorage.getItem('auth0_token');
|
||||||
// Log successful response in development mode
|
if (token) {
|
||||||
if (DEBUG_MODE) {
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
console.log(`[API] ← ${response.status} ${response.config.method?.toUpperCase()} ${response.config.url}`, {
|
}
|
||||||
|
|
||||||
|
if (DEBUG_MODE) {
|
||||||
|
console.log(`[API] → ${config.method?.toUpperCase()} ${config.url}`, {
|
||||||
|
data: config.data,
|
||||||
|
params: config.params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestErrorInterceptor = (error: any) => {
|
||||||
|
console.error('[API] Request error:', error);
|
||||||
|
return Promise.reject(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply interceptors to both API instances
|
||||||
|
api.interceptors.request.use(requestInterceptor, requestErrorInterceptor);
|
||||||
|
copilotApi.interceptors.request.use(requestInterceptor, requestErrorInterceptor);
|
||||||
|
|
||||||
|
// Shared response interceptor function
|
||||||
|
const responseInterceptor = (response: any) => {
|
||||||
|
// Log successful response in development mode
|
||||||
|
if (DEBUG_MODE) {
|
||||||
|
console.log(`[API] ← ${response.status} ${response.config.method?.toUpperCase()} ${response.config.url}`, {
|
||||||
|
data: response.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
const responseErrorInterceptor = (error: any) => {
|
||||||
|
const { config, response } = error;
|
||||||
|
|
||||||
|
// Enhanced error logging
|
||||||
|
if (response) {
|
||||||
|
// Server responded with error status
|
||||||
|
console.error(
|
||||||
|
`[API] ✖ ${response.status} ${config?.method?.toUpperCase()} ${config?.url}`,
|
||||||
|
{
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
data: response.data,
|
data: response.data,
|
||||||
});
|
requestData: config?.data,
|
||||||
}
|
|
||||||
return response;
|
|
||||||
},
|
|
||||||
(error) => {
|
|
||||||
const { config, response } = error;
|
|
||||||
|
|
||||||
// Enhanced error logging
|
|
||||||
if (response) {
|
|
||||||
// Server responded with error status
|
|
||||||
console.error(
|
|
||||||
`[API] ✖ ${response.status} ${config?.method?.toUpperCase()} ${config?.url}`,
|
|
||||||
{
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
data: response.data,
|
|
||||||
requestData: config?.data,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Log specific error types
|
|
||||||
if (response.status === 401) {
|
|
||||||
console.warn('[API] Authentication required - user may need to log in again');
|
|
||||||
} else if (response.status === 403) {
|
|
||||||
console.warn('[API] Permission denied - user lacks required permissions');
|
|
||||||
} else if (response.status === 404) {
|
|
||||||
console.warn('[API] Resource not found');
|
|
||||||
} else if (response.status === 409) {
|
|
||||||
console.warn('[API] Conflict detected:', response.data.conflicts || response.data.message);
|
|
||||||
} else if (response.status >= 500) {
|
|
||||||
console.error('[API] Server error - backend may be experiencing issues');
|
|
||||||
}
|
}
|
||||||
} else if (error.request) {
|
);
|
||||||
// Request was made but no response received
|
|
||||||
console.error('[API] ✖ Network error - no response received', {
|
|
||||||
method: config?.method?.toUpperCase(),
|
|
||||||
url: config?.url,
|
|
||||||
message: error.message,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Something else happened
|
|
||||||
console.error('[API] ✖ Request setup error:', error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.reject(error);
|
// Log specific error types
|
||||||
|
if (response.status === 401) {
|
||||||
|
console.warn('[API] Authentication required - user may need to log in again');
|
||||||
|
} else if (response.status === 403) {
|
||||||
|
console.warn('[API] Permission denied - user lacks required permissions');
|
||||||
|
} else if (response.status === 404) {
|
||||||
|
console.warn('[API] Resource not found');
|
||||||
|
} else if (response.status === 409) {
|
||||||
|
console.warn('[API] Conflict detected:', response.data.conflicts || response.data.message);
|
||||||
|
} else if (response.status >= 500) {
|
||||||
|
console.error('[API] Server error - backend may be experiencing issues');
|
||||||
|
}
|
||||||
|
} else if (error.request) {
|
||||||
|
// Request was made but no response received
|
||||||
|
console.error('[API] ✖ Network error - no response received', {
|
||||||
|
method: config?.method?.toUpperCase(),
|
||||||
|
url: config?.url,
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Something else happened
|
||||||
|
console.error('[API] ✖ Request setup error:', error.message);
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply response interceptors to both API instances
|
||||||
|
api.interceptors.response.use(responseInterceptor, responseErrorInterceptor);
|
||||||
|
copilotApi.interceptors.response.use(responseInterceptor, responseErrorInterceptor);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -113,7 +113,7 @@ export function Dashboard() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 mb-6 md:mb-8">Dashboard</h1>
|
<h1 className="text-2xl sm:text-3xl font-bold text-foreground mb-6 md:mb-8">Dashboard</h1>
|
||||||
|
|
||||||
{/* Stats Grid */}
|
{/* Stats Grid */}
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4 mb-6 md:mb-8">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4 mb-6 md:mb-8">
|
||||||
@@ -122,7 +122,7 @@ export function Dashboard() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={stat.name}
|
key={stat.name}
|
||||||
className="bg-white overflow-hidden shadow rounded-lg"
|
className="bg-card overflow-hidden shadow-soft rounded-lg border border-border transition-colors"
|
||||||
>
|
>
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
@@ -131,10 +131,10 @@ export function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="ml-5 w-0 flex-1">
|
<div className="ml-5 w-0 flex-1">
|
||||||
<dl>
|
<dl>
|
||||||
<dt className="text-sm font-medium text-gray-500 truncate">
|
<dt className="text-sm font-medium text-muted-foreground truncate">
|
||||||
{stat.name}
|
{stat.name}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-3xl font-semibold text-gray-900">
|
<dd className="text-3xl font-semibold text-foreground">
|
||||||
{stat.value}
|
{stat.value}
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
@@ -147,40 +147,40 @@ export function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent VIPs */}
|
{/* Recent VIPs */}
|
||||||
<div className="bg-white shadow rounded-lg p-6 mb-8">
|
<div className="bg-card shadow-medium rounded-lg p-6 mb-8 border border-border">
|
||||||
<h2 className="text-lg font-medium text-gray-900 mb-4">Recent VIPs</h2>
|
<h2 className="text-lg font-medium text-foreground mb-4">Recent VIPs</h2>
|
||||||
{vips && vips.length > 0 ? (
|
{vips && vips.length > 0 ? (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-border">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 bg-muted/30 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
Name
|
Name
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 bg-muted/30 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
Organization
|
Organization
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 bg-muted/30 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
Arrival Mode
|
Arrival Mode
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 bg-muted/30 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
Events
|
Events
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-card divide-y divide-border">
|
||||||
{vips.slice(0, 5).map((vip) => (
|
{vips.slice(0, 5).map((vip) => (
|
||||||
<tr key={vip.id}>
|
<tr key={vip.id} className="hover:bg-accent transition-colors">
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-foreground">
|
||||||
{vip.name}
|
{vip.name}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||||
{vip.organization || '-'}
|
{vip.organization || '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||||
{vip.arrivalMode}
|
{vip.arrivalMode}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||||
{vip.events?.length || 0}
|
{vip.events?.length || 0}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -189,15 +189,15 @@ export function Dashboard() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-gray-500 text-center py-4">
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
No VIPs yet. Add your first VIP to get started.
|
No VIPs yet. Add your first VIP to get started.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Upcoming Flights */}
|
{/* Upcoming Flights */}
|
||||||
<div className="bg-white shadow rounded-lg p-6 mb-8">
|
<div className="bg-card shadow-medium rounded-lg p-6 mb-8 border border-border">
|
||||||
<h2 className="text-lg font-medium text-gray-900 mb-4">
|
<h2 className="text-lg font-medium text-foreground mb-4">
|
||||||
Upcoming Flights
|
Upcoming Flights
|
||||||
</h2>
|
</h2>
|
||||||
{upcomingFlights.length > 0 ? (
|
{upcomingFlights.length > 0 ? (
|
||||||
@@ -205,25 +205,25 @@ export function Dashboard() {
|
|||||||
{upcomingFlights.map((flight) => (
|
{upcomingFlights.map((flight) => (
|
||||||
<div
|
<div
|
||||||
key={flight.id}
|
key={flight.id}
|
||||||
className="border-l-4 border-indigo-500 pl-4 py-2"
|
className="border-l-4 border-indigo-500 pl-4 py-2 hover:bg-accent transition-colors rounded-r"
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium text-gray-900 flex items-center gap-2">
|
<h3 className="text-sm font-medium text-foreground flex items-center gap-2">
|
||||||
<Plane className="h-4 w-4" />
|
<Plane className="h-4 w-4" />
|
||||||
{flight.flightNumber}
|
{flight.flightNumber}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-muted-foreground">
|
||||||
{flight.vip?.name} • {flight.departureAirport} → {flight.arrivalAirport}
|
{flight.vip?.name} • {flight.departureAirport} → {flight.arrivalAirport}
|
||||||
</p>
|
</p>
|
||||||
{flight.scheduledDeparture && (
|
{flight.scheduledDeparture && (
|
||||||
<p className="text-xs text-gray-400 mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
Departs: {formatDateTime(flight.scheduledDeparture)}
|
Departs: {formatDateTime(flight.scheduledDeparture)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<span className="text-xs text-gray-500 block">
|
<span className="text-xs text-muted-foreground block">
|
||||||
{new Date(flight.flightDate).toLocaleDateString('en-US', {
|
{new Date(flight.flightDate).toLocaleDateString('en-US', {
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
@@ -231,12 +231,12 @@ export function Dashboard() {
|
|||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
<span className={`inline-block mt-1 px-2 py-1 text-xs rounded-full ${
|
<span className={`inline-block mt-1 px-2 py-1 text-xs rounded-full ${
|
||||||
flight.status?.toLowerCase() === 'scheduled' ? 'bg-blue-100 text-blue-800' :
|
flight.status?.toLowerCase() === 'scheduled' ? 'bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-300' :
|
||||||
flight.status?.toLowerCase() === 'boarding' ? 'bg-yellow-100 text-yellow-800' :
|
flight.status?.toLowerCase() === 'boarding' ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-950 dark:text-yellow-300' :
|
||||||
flight.status?.toLowerCase() === 'departed' ? 'bg-purple-100 text-purple-800' :
|
flight.status?.toLowerCase() === 'departed' ? 'bg-purple-100 text-purple-800 dark:bg-purple-950 dark:text-purple-300' :
|
||||||
flight.status?.toLowerCase() === 'landed' ? 'bg-green-100 text-green-800' :
|
flight.status?.toLowerCase() === 'landed' ? 'bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-300' :
|
||||||
flight.status?.toLowerCase() === 'delayed' ? 'bg-orange-100 text-orange-800' :
|
flight.status?.toLowerCase() === 'delayed' ? 'bg-orange-100 text-orange-800 dark:bg-orange-950 dark:text-orange-300' :
|
||||||
'bg-gray-100 text-gray-800'
|
'bg-muted text-muted-foreground'
|
||||||
}`}>
|
}`}>
|
||||||
{flight.status || 'Unknown'}
|
{flight.status || 'Unknown'}
|
||||||
</span>
|
</span>
|
||||||
@@ -246,15 +246,15 @@ export function Dashboard() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-gray-500 text-center py-4">
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
No upcoming flights tracked.
|
No upcoming flights tracked.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Upcoming Events */}
|
{/* Upcoming Events */}
|
||||||
<div className="bg-white shadow rounded-lg p-6">
|
<div className="bg-card shadow-medium rounded-lg p-6 border border-border">
|
||||||
<h2 className="text-lg font-medium text-gray-900 mb-4">
|
<h2 className="text-lg font-medium text-foreground mb-4">
|
||||||
Upcoming Events
|
Upcoming Events
|
||||||
</h2>
|
</h2>
|
||||||
{upcomingEvents.length > 0 ? (
|
{upcomingEvents.length > 0 ? (
|
||||||
@@ -262,31 +262,31 @@ export function Dashboard() {
|
|||||||
{upcomingEvents.map((event) => (
|
{upcomingEvents.map((event) => (
|
||||||
<div
|
<div
|
||||||
key={event.id}
|
key={event.id}
|
||||||
className="border-l-4 border-primary pl-4 py-2"
|
className="border-l-4 border-primary pl-4 py-2 hover:bg-accent transition-colors rounded-r"
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium text-gray-900">
|
<h3 className="text-sm font-medium text-foreground">
|
||||||
{event.title}
|
{event.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-muted-foreground">
|
||||||
{event.vips && event.vips.length > 0
|
{event.vips && event.vips.length > 0
|
||||||
? event.vips.map(vip => vip.name).join(', ')
|
? event.vips.map(vip => vip.name).join(', ')
|
||||||
: 'No VIPs assigned'} • {event.driver?.name || 'No driver assigned'}
|
: 'No VIPs assigned'} • {event.driver?.name || 'No driver assigned'}
|
||||||
</p>
|
</p>
|
||||||
{event.location && (
|
{event.location && (
|
||||||
<p className="text-xs text-gray-400 mt-1">{event.location}</p>
|
<p className="text-xs text-muted-foreground mt-1">{event.location}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<span className="text-xs text-gray-500 block">
|
<span className="text-xs text-muted-foreground block">
|
||||||
{formatDateTime(event.startTime)}
|
{formatDateTime(event.startTime)}
|
||||||
</span>
|
</span>
|
||||||
<span className={`inline-block mt-1 px-2 py-1 text-xs rounded-full ${
|
<span className={`inline-block mt-1 px-2 py-1 text-xs rounded-full ${
|
||||||
event.type === 'TRANSPORT' ? 'bg-blue-100 text-blue-800' :
|
event.type === 'TRANSPORT' ? 'bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-300' :
|
||||||
event.type === 'MEETING' ? 'bg-purple-100 text-purple-800' :
|
event.type === 'MEETING' ? 'bg-purple-100 text-purple-800 dark:bg-purple-950 dark:text-purple-300' :
|
||||||
event.type === 'MEAL' ? 'bg-green-100 text-green-800' :
|
event.type === 'MEAL' ? 'bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-300' :
|
||||||
'bg-gray-100 text-gray-800'
|
'bg-muted text-muted-foreground'
|
||||||
}`}>
|
}`}>
|
||||||
{event.type}
|
{event.type}
|
||||||
</span>
|
</span>
|
||||||
@@ -296,7 +296,7 @@ export function Dashboard() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-gray-500 text-center py-4">
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
No upcoming events scheduled.
|
No upcoming events scheduled.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,12 +3,16 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { Driver } from '@/types';
|
import { Driver } from '@/types';
|
||||||
import { Plus, Edit, Trash2, Search, X, Filter, ArrowUpDown } from 'lucide-react';
|
import { Plus, Edit, Trash2, Search, X, Filter, ArrowUpDown, Send, Eye } from 'lucide-react';
|
||||||
import { DriverForm, DriverFormData } from '@/components/DriverForm';
|
import { DriverForm, DriverFormData } from '@/components/DriverForm';
|
||||||
import { TableSkeleton, CardSkeleton } from '@/components/Skeleton';
|
import { TableSkeleton, CardSkeleton } from '@/components/Skeleton';
|
||||||
import { FilterModal } from '@/components/FilterModal';
|
import { FilterModal } from '@/components/FilterModal';
|
||||||
import { FilterChip } from '@/components/FilterChip';
|
import { FilterChip } from '@/components/FilterChip';
|
||||||
import { useDebounce } from '@/hooks/useDebounce';
|
import { useDebounce } from '@/hooks/useDebounce';
|
||||||
|
import { DriverChatBubble } from '@/components/DriverChatBubble';
|
||||||
|
import { DriverChatModal } from '@/components/DriverChatModal';
|
||||||
|
import { DriverScheduleModal } from '@/components/DriverScheduleModal';
|
||||||
|
import { useUnreadCounts } from '@/hooks/useSignalMessages';
|
||||||
|
|
||||||
export function DriverList() {
|
export function DriverList() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -21,6 +25,12 @@ export function DriverList() {
|
|||||||
const [selectedDepartments, setSelectedDepartments] = useState<string[]>([]);
|
const [selectedDepartments, setSelectedDepartments] = useState<string[]>([]);
|
||||||
const [filterModalOpen, setFilterModalOpen] = useState(false);
|
const [filterModalOpen, setFilterModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// Chat state
|
||||||
|
const [chatDriver, setChatDriver] = useState<Driver | null>(null);
|
||||||
|
|
||||||
|
// Schedule modal state
|
||||||
|
const [scheduleDriver, setScheduleDriver] = useState<Driver | null>(null);
|
||||||
|
|
||||||
// Sort state
|
// Sort state
|
||||||
const [sortColumn, setSortColumn] = useState<'name' | 'phone' | 'department'>('name');
|
const [sortColumn, setSortColumn] = useState<'name' | 'phone' | 'department'>('name');
|
||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||||
@@ -28,6 +38,9 @@ export function DriverList() {
|
|||||||
// Debounce search term
|
// Debounce search term
|
||||||
const debouncedSearchTerm = useDebounce(searchTerm, 300);
|
const debouncedSearchTerm = useDebounce(searchTerm, 300);
|
||||||
|
|
||||||
|
// Fetch unread message counts
|
||||||
|
const { data: unreadCounts } = useUnreadCounts();
|
||||||
|
|
||||||
const { data: drivers, isLoading } = useQuery<Driver[]>({
|
const { data: drivers, isLoading } = useQuery<Driver[]>({
|
||||||
queryKey: ['drivers'],
|
queryKey: ['drivers'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -85,6 +98,28 @@ export function DriverList() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const sendScheduleMutation = useMutation({
|
||||||
|
mutationFn: async ({ id, date }: { id: string; date?: string }) => {
|
||||||
|
const { data } = await api.post(`/drivers/${id}/send-schedule`, { date, format: 'both' });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast.success(data.message || 'Schedule sent successfully');
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
console.error('[DRIVER] Failed to send schedule:', error);
|
||||||
|
toast.error(error.response?.data?.message || 'Failed to send schedule');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSendSchedule = (driver: Driver) => {
|
||||||
|
if (!driver.phone) {
|
||||||
|
toast.error('Driver does not have a phone number');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendScheduleMutation.mutate({ id: driver.id });
|
||||||
|
};
|
||||||
|
|
||||||
// Helper to extract last name from full name
|
// Helper to extract last name from full name
|
||||||
const getLastName = (fullName: string): string => {
|
const getLastName = (fullName: string): string => {
|
||||||
const parts = fullName.trim().split(/\s+/);
|
const parts = fullName.trim().split(/\s+/);
|
||||||
@@ -200,7 +235,7 @@ export function DriverList() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
||||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900">Drivers</h1>
|
<h1 className="text-2xl sm:text-3xl font-bold text-foreground">Drivers</h1>
|
||||||
<button
|
<button
|
||||||
disabled
|
disabled
|
||||||
className="w-full sm:w-auto inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary/50 cursor-not-allowed"
|
className="w-full sm:w-auto inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary/50 cursor-not-allowed"
|
||||||
@@ -223,10 +258,10 @@ export function DriverList() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
||||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900">Drivers</h1>
|
<h1 className="text-2xl sm:text-3xl font-bold text-foreground">Drivers</h1>
|
||||||
<button
|
<button
|
||||||
onClick={handleAdd}
|
onClick={handleAdd}
|
||||||
className="w-full sm:w-auto inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary hover:bg-primary/90"
|
className="w-full sm:w-auto inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary hover:bg-primary/90 transition-colors"
|
||||||
style={{ minHeight: '44px' }}
|
style={{ minHeight: '44px' }}
|
||||||
>
|
>
|
||||||
<Plus className="h-5 w-5 mr-2" />
|
<Plus className="h-5 w-5 mr-2" />
|
||||||
@@ -235,17 +270,17 @@ export function DriverList() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search and Filter Section */}
|
{/* Search and Filter Section */}
|
||||||
<div className="bg-white shadow rounded-lg p-4 mb-6">
|
<div className="bg-card border border-border shadow-soft rounded-lg p-4 mb-6">
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400 pointer-events-none" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-muted-foreground pointer-events-none" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by name or phone..."
|
placeholder="Search by name or phone..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-md focus:ring-primary focus:border-primary text-base"
|
className="w-full pl-10 pr-4 py-2.5 border border-input rounded-md focus:ring-primary focus:border-primary text-base bg-background text-foreground transition-colors"
|
||||||
style={{ minHeight: '44px' }}
|
style={{ minHeight: '44px' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -253,7 +288,7 @@ export function DriverList() {
|
|||||||
{/* Filter Button */}
|
{/* Filter Button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setFilterModalOpen(true)}
|
onClick={() => setFilterModalOpen(true)}
|
||||||
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50 font-medium"
|
className="inline-flex items-center px-4 py-2 border border-input rounded-md text-foreground bg-card hover:bg-accent hover:text-accent-foreground font-medium transition-colors"
|
||||||
style={{ minHeight: '44px' }}
|
style={{ minHeight: '44px' }}
|
||||||
>
|
>
|
||||||
<Filter className="h-5 w-5 mr-2" />
|
<Filter className="h-5 w-5 mr-2" />
|
||||||
@@ -268,8 +303,8 @@ export function DriverList() {
|
|||||||
|
|
||||||
{/* Active Filter Chips */}
|
{/* Active Filter Chips */}
|
||||||
{selectedDepartments.length > 0 && (
|
{selectedDepartments.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2 mt-3 pt-3 border-t border-gray-200">
|
<div className="flex flex-wrap gap-2 mt-3 pt-3 border-t border-border">
|
||||||
<span className="text-sm text-gray-600 py-1.5">Active filters:</span>
|
<span className="text-sm text-muted-foreground py-1.5">Active filters:</span>
|
||||||
{selectedDepartments.map((dept) => (
|
{selectedDepartments.map((dept) => (
|
||||||
<FilterChip
|
<FilterChip
|
||||||
key={dept}
|
key={dept}
|
||||||
@@ -281,15 +316,15 @@ export function DriverList() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Results count */}
|
{/* Results count */}
|
||||||
<div className="flex items-center justify-between mt-3 pt-3 border-t border-gray-200">
|
<div className="flex items-center justify-between mt-3 pt-3 border-t border-border">
|
||||||
<div className="text-sm text-gray-600">
|
<div className="text-sm text-muted-foreground">
|
||||||
Showing <span className="font-medium">{filteredDrivers.length}</span> of <span className="font-medium">{drivers?.length || 0}</span> drivers
|
Showing <span className="font-medium text-foreground">{filteredDrivers.length}</span> of <span className="font-medium text-foreground">{drivers?.length || 0}</span> drivers
|
||||||
{debouncedSearchTerm !== searchTerm && <span className="ml-2 text-gray-400">(searching...)</span>}
|
{debouncedSearchTerm !== searchTerm && <span className="ml-2 text-muted-foreground/60">(searching...)</span>}
|
||||||
</div>
|
</div>
|
||||||
{(searchTerm || selectedDepartments.length > 0) && (
|
{(searchTerm || selectedDepartments.length > 0) && (
|
||||||
<button
|
<button
|
||||||
onClick={handleClearFilters}
|
onClick={handleClearFilters}
|
||||||
className="inline-flex items-center px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-md"
|
className="inline-flex items-center px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:bg-accent rounded-md transition-colors"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4 mr-1" />
|
<X className="h-4 w-4 mr-1" />
|
||||||
Clear All
|
Clear All
|
||||||
@@ -299,12 +334,12 @@ export function DriverList() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop Table View - shows on large screens */}
|
{/* Desktop Table View - shows on large screens */}
|
||||||
<div className="hidden lg:block bg-white shadow rounded-lg overflow-hidden">
|
<div className="hidden lg:block bg-card border border-border shadow-medium rounded-lg overflow-hidden">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-border">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-muted/30">
|
||||||
<tr>
|
<tr>
|
||||||
<th
|
<th
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase cursor-pointer hover:bg-gray-100 transition-colors"
|
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent transition-colors"
|
||||||
onClick={() => handleSort('name')}
|
onClick={() => handleSort('name')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -314,7 +349,7 @@ export function DriverList() {
|
|||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase cursor-pointer hover:bg-gray-100 transition-colors"
|
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent transition-colors"
|
||||||
onClick={() => handleSort('phone')}
|
onClick={() => handleSort('phone')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -324,7 +359,7 @@ export function DriverList() {
|
|||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase cursor-pointer hover:bg-gray-100 transition-colors"
|
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent transition-colors"
|
||||||
onClick={() => handleSort('department')}
|
onClick={() => handleSort('department')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -333,34 +368,59 @@ export function DriverList() {
|
|||||||
{sortColumn === 'department' && <span className="text-primary">{sortDirection === 'asc' ? '↑' : '↓'}</span>}
|
{sortColumn === 'department' && <span className="text-primary">{sortDirection === 'asc' ? '↑' : '↓'}</span>}
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||||
Assigned Events
|
Assigned Events
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||||
Actions
|
Actions
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-card divide-y divide-border">
|
||||||
{filteredDrivers.map((driver) => (
|
{filteredDrivers.map((driver) => (
|
||||||
<tr key={driver.id} className="hover:bg-gray-50 transition-colors">
|
<tr key={driver.id} className="hover:bg-accent transition-colors">
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-foreground">
|
||||||
{driver.name}
|
<div className="flex items-center gap-2">
|
||||||
|
{driver.name}
|
||||||
|
<DriverChatBubble
|
||||||
|
unreadCount={unreadCounts?.[driver.id] || 0}
|
||||||
|
onClick={() => setChatDriver(driver)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||||
{driver.phone}
|
{driver.phone}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||||
{driver.department || '-'}
|
{driver.department || '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||||
{driver.events?.length || 0}
|
{driver.events?.length || 0}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setScheduleDriver(driver)}
|
||||||
|
className="inline-flex items-center px-3 py-1 text-blue-600 hover:text-blue-800 transition-colors"
|
||||||
|
style={{ minHeight: '36px' }}
|
||||||
|
title="View driver's schedule"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4 mr-1" />
|
||||||
|
View
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSendSchedule(driver)}
|
||||||
|
disabled={sendScheduleMutation.isPending}
|
||||||
|
className="inline-flex items-center px-3 py-1 text-green-600 hover:text-green-800 transition-colors disabled:opacity-50"
|
||||||
|
style={{ minHeight: '36px' }}
|
||||||
|
title="Send today's schedule via Signal"
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4 mr-1" />
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEdit(driver)}
|
onClick={() => handleEdit(driver)}
|
||||||
className="inline-flex items-center px-3 py-1 text-primary hover:text-primary/80"
|
className="inline-flex items-center px-3 py-1 text-primary hover:text-primary/80 transition-colors"
|
||||||
style={{ minHeight: '36px' }}
|
style={{ minHeight: '36px' }}
|
||||||
>
|
>
|
||||||
<Edit className="h-4 w-4 mr-1" />
|
<Edit className="h-4 w-4 mr-1" />
|
||||||
@@ -368,7 +428,7 @@ export function DriverList() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(driver.id, driver.name)}
|
onClick={() => handleDelete(driver.id, driver.name)}
|
||||||
className="inline-flex items-center px-3 py-1 text-red-600 hover:text-red-800"
|
className="inline-flex items-center px-3 py-1 text-red-600 hover:text-red-800 transition-colors"
|
||||||
style={{ minHeight: '36px' }}
|
style={{ minHeight: '36px' }}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 mr-1" />
|
<Trash2 className="h-4 w-4 mr-1" />
|
||||||
@@ -385,27 +445,50 @@ export function DriverList() {
|
|||||||
{/* Mobile/Tablet Card View - shows on small and medium screens */}
|
{/* Mobile/Tablet Card View - shows on small and medium screens */}
|
||||||
<div className="lg:hidden space-y-4">
|
<div className="lg:hidden space-y-4">
|
||||||
{filteredDrivers.map((driver) => (
|
{filteredDrivers.map((driver) => (
|
||||||
<div key={driver.id} className="bg-white shadow rounded-lg p-4">
|
<div key={driver.id} className="bg-card border border-border shadow-soft rounded-lg p-4">
|
||||||
<div className="mb-3">
|
<div className="mb-3 flex items-start justify-between">
|
||||||
<h3 className="text-lg font-semibold text-gray-900">{driver.name}</h3>
|
<div>
|
||||||
<p className="text-sm text-gray-600 mt-1">{driver.phone}</p>
|
<h3 className="text-lg font-semibold text-foreground">{driver.name}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">{driver.phone}</p>
|
||||||
|
</div>
|
||||||
|
<DriverChatBubble
|
||||||
|
unreadCount={unreadCounts?.[driver.id] || 0}
|
||||||
|
onClick={() => setChatDriver(driver)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider">Department</p>
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Department</p>
|
||||||
<p className="text-sm text-gray-900 mt-1">{driver.department || '-'}</p>
|
<p className="text-sm text-foreground mt-1">{driver.department || '-'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider">Assigned Events</p>
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Assigned Events</p>
|
||||||
<p className="text-sm text-gray-900 mt-1">{driver.events?.length || 0}</p>
|
<p className="text-sm text-foreground mt-1">{driver.events?.length || 0}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-2 pt-3 border-t border-gray-200">
|
<div className="flex flex-col sm:flex-row gap-2 pt-3 border-t border-border">
|
||||||
|
<button
|
||||||
|
onClick={() => setScheduleDriver(driver)}
|
||||||
|
className="flex-1 inline-flex items-center justify-center px-4 py-2 border border-input text-sm font-medium rounded-md text-blue-600 bg-card hover:bg-blue-50 dark:hover:bg-blue-950/20 transition-colors"
|
||||||
|
style={{ minHeight: '44px' }}
|
||||||
|
>
|
||||||
|
<Eye className="h-5 w-5 mr-2" />
|
||||||
|
View Schedule
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSendSchedule(driver)}
|
||||||
|
disabled={sendScheduleMutation.isPending}
|
||||||
|
className="flex-1 inline-flex items-center justify-center px-4 py-2 border border-input text-sm font-medium rounded-md text-green-600 bg-card hover:bg-green-50 dark:hover:bg-green-950/20 transition-colors disabled:opacity-50"
|
||||||
|
style={{ minHeight: '44px' }}
|
||||||
|
>
|
||||||
|
<Send className="h-5 w-5 mr-2" />
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEdit(driver)}
|
onClick={() => handleEdit(driver)}
|
||||||
className="flex-1 inline-flex items-center justify-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-primary bg-white hover:bg-gray-50"
|
className="flex-1 inline-flex items-center justify-center px-4 py-2 border border-input text-sm font-medium rounded-md text-primary bg-card hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||||
style={{ minHeight: '44px' }}
|
style={{ minHeight: '44px' }}
|
||||||
>
|
>
|
||||||
<Edit className="h-5 w-5 mr-2" />
|
<Edit className="h-5 w-5 mr-2" />
|
||||||
@@ -413,7 +496,7 @@ export function DriverList() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(driver.id, driver.name)}
|
onClick={() => handleDelete(driver.id, driver.name)}
|
||||||
className="flex-1 inline-flex items-center justify-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-red-600 bg-white hover:bg-red-50"
|
className="flex-1 inline-flex items-center justify-center px-4 py-2 border border-input text-sm font-medium rounded-md text-red-600 bg-card hover:bg-red-50 dark:hover:bg-red-950/20 transition-colors"
|
||||||
style={{ minHeight: '44px' }}
|
style={{ minHeight: '44px' }}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-5 w-5 mr-2" />
|
<Trash2 className="h-5 w-5 mr-2" />
|
||||||
@@ -451,6 +534,20 @@ export function DriverList() {
|
|||||||
onClear={handleClearFilters}
|
onClear={handleClearFilters}
|
||||||
onApply={() => {}}
|
onApply={() => {}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Driver Chat Modal */}
|
||||||
|
<DriverChatModal
|
||||||
|
driver={chatDriver}
|
||||||
|
isOpen={!!chatDriver}
|
||||||
|
onClose={() => setChatDriver(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Driver Schedule Modal */}
|
||||||
|
<DriverScheduleModal
|
||||||
|
driver={scheduleDriver}
|
||||||
|
isOpen={!!scheduleDriver}
|
||||||
|
onClose={() => setScheduleDriver(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
226
frontend/src/pages/DriverProfile.tsx
Normal file
226
frontend/src/pages/DriverProfile.tsx
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { Loading } from '@/components/Loading';
|
||||||
|
import { User, Phone, Save, CheckCircle, AlertCircle } from 'lucide-react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface DriverProfileData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
phone: string | null;
|
||||||
|
department: string | null;
|
||||||
|
isAvailable: boolean;
|
||||||
|
user: {
|
||||||
|
email: string;
|
||||||
|
picture: string | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DriverProfile() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
|
||||||
|
const { data: profile, isLoading, error } = useQuery<DriverProfileData>({
|
||||||
|
queryKey: ['my-driver-profile'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get('/drivers/me');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set phone when profile loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (profile?.phone) {
|
||||||
|
setPhone(profile.phone);
|
||||||
|
}
|
||||||
|
}, [profile?.phone]);
|
||||||
|
|
||||||
|
const updateProfile = useMutation({
|
||||||
|
mutationFn: async (newPhone: string) => {
|
||||||
|
const { data } = await api.patch('/drivers/me', { phone: newPhone });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['my-driver-profile'] });
|
||||||
|
toast.success('Phone number updated successfully!');
|
||||||
|
setIsEditing(false);
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.message || 'Failed to update phone number');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!phone.trim()) {
|
||||||
|
toast.error('Please enter a valid phone number');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateProfile.mutate(phone);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Loading message="Loading your profile..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12">
|
||||||
|
<AlertCircle className="h-12 w-12 text-destructive mb-4" />
|
||||||
|
<h2 className="text-xl font-semibold text-foreground mb-2">Profile Not Found</h2>
|
||||||
|
<p className="text-muted-foreground">Unable to load your driver profile.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPhone = !!profile.phone;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">My Profile</h1>
|
||||||
|
<p className="text-muted-foreground">Manage your driver profile and contact information</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Profile Card */}
|
||||||
|
<div className="bg-card border border-border rounded-lg shadow-soft overflow-hidden">
|
||||||
|
{/* Header with avatar */}
|
||||||
|
<div className="bg-primary/10 px-6 py-8 flex items-center gap-4">
|
||||||
|
{profile.user?.picture ? (
|
||||||
|
<img
|
||||||
|
src={profile.user.picture}
|
||||||
|
alt={profile.name}
|
||||||
|
className="h-20 w-20 rounded-full border-4 border-background shadow-md"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-20 w-20 rounded-full bg-primary/20 flex items-center justify-center border-4 border-background shadow-md">
|
||||||
|
<User className="h-10 w-10 text-primary" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-foreground">{profile.name}</h2>
|
||||||
|
<p className="text-muted-foreground">{profile.user?.email}</p>
|
||||||
|
{profile.department && (
|
||||||
|
<span className="inline-block mt-1 px-2 py-0.5 bg-primary/20 text-primary text-xs font-medium rounded">
|
||||||
|
{profile.department.replace(/_/g, ' ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phone Number Section */}
|
||||||
|
<div className="p-6 border-t border-border">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
|
<Phone className="h-4 w-4 inline mr-1" />
|
||||||
|
Phone Number
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-muted-foreground mb-3">
|
||||||
|
Used for Signal notifications about your trips
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!hasPhone && !isEditing && (
|
||||||
|
<div className="bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 rounded-lg p-4 mb-4">
|
||||||
|
<div className="flex items-center gap-2 text-amber-800 dark:text-amber-200">
|
||||||
|
<AlertCircle className="h-5 w-5 flex-shrink-0" />
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
Please add your phone number to receive trip notifications via Signal
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
placeholder="+1 (555) 123-4567"
|
||||||
|
className="flex-1 px-3 py-2 bg-background border border-input rounded-lg text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={updateProfile.isPending}
|
||||||
|
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 disabled:opacity-50 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{updateProfile.isPending ? (
|
||||||
|
<span className="animate-spin">...</span>
|
||||||
|
) : (
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setPhone(profile.phone || '');
|
||||||
|
setIsEditing(false);
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 bg-muted text-muted-foreground rounded-lg font-medium hover:bg-muted/80"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{hasPhone ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||||
|
<span className="text-lg font-medium text-foreground">{profile.phone}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground italic">No phone number set</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
{hasPhone ? 'Edit' : 'Add Phone'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Availability Status */}
|
||||||
|
<div className="p-6 border-t border-border bg-muted/30">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-foreground">Availability Status</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">Your current availability for assignments</p>
|
||||||
|
</div>
|
||||||
|
<div className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||||
|
profile.isAvailable
|
||||||
|
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
|
||||||
|
: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'
|
||||||
|
}`}>
|
||||||
|
{profile.isAvailable ? 'Available' : 'Unavailable'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Card */}
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||||
|
<h3 className="font-medium text-blue-800 dark:text-blue-200 mb-2">About Signal Notifications</h3>
|
||||||
|
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||||
|
When you're assigned to a trip, you'll receive notifications via Signal messenger:
|
||||||
|
</p>
|
||||||
|
<ul className="mt-2 text-sm text-blue-700 dark:text-blue-300 list-disc list-inside space-y-1">
|
||||||
|
<li>20-minute reminder before pickup</li>
|
||||||
|
<li>5-minute urgent reminder</li>
|
||||||
|
<li>Trip start confirmation request</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { ScheduleEvent, EventType } from '@/types';
|
import { ScheduleEvent, EventType } from '@/types';
|
||||||
@@ -15,6 +16,8 @@ type SortDirection = 'asc' | 'desc';
|
|||||||
|
|
||||||
export function EventList() {
|
export function EventList() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [editingEvent, setEditingEvent] = useState<ScheduleEvent | null>(null);
|
const [editingEvent, setEditingEvent] = useState<ScheduleEvent | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
@@ -31,6 +34,20 @@ export function EventList() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle opening a specific event from navigation state (e.g., from War Room)
|
||||||
|
useEffect(() => {
|
||||||
|
const state = location.state as { editEventId?: string } | null;
|
||||||
|
if (state?.editEventId && events) {
|
||||||
|
const eventToEdit = events.find(e => e.id === state.editEventId);
|
||||||
|
if (eventToEdit) {
|
||||||
|
setEditingEvent(eventToEdit);
|
||||||
|
setShowForm(true);
|
||||||
|
// Clear the state so refreshing doesn't re-open the form
|
||||||
|
navigate(location.pathname, { replace: true, state: {} });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [location.state, events, navigate, location.pathname]);
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: async (data: EventFormData) => {
|
mutationFn: async (data: EventFormData) => {
|
||||||
await api.post('/events', data);
|
await api.post('/events', data);
|
||||||
@@ -210,10 +227,10 @@ export function EventList() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Activities</h1>
|
<h1 className="text-3xl font-bold text-foreground">Activities</h1>
|
||||||
<button
|
<button
|
||||||
onClick={handleAdd}
|
onClick={handleAdd}
|
||||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary hover:bg-primary/90"
|
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary hover:bg-primary/90 transition-colors"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Add Activity
|
Add Activity
|
||||||
@@ -221,36 +238,36 @@ export function EventList() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search Bar */}
|
{/* Search Bar */}
|
||||||
<div className="bg-white shadow rounded-lg mb-4 p-4">
|
<div className="bg-card shadow-soft border border-border rounded-lg mb-4 p-4">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
<Search className="h-5 w-5 text-gray-400" />
|
<Search className="h-5 w-5 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search activities by title, location, VIP name, driver, or vehicle..."
|
placeholder="Search activities by title, location, VIP name, driver, or vehicle..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-primary focus:border-primary sm:text-sm"
|
className="block w-full pl-10 pr-3 py-2 border border-input rounded-md leading-5 bg-background placeholder-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary sm:text-sm transition-colors"
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setSearchQuery('')}
|
onClick={() => setSearchQuery('')}
|
||||||
className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600"
|
className="absolute inset-y-0 right-0 pr-3 flex items-center text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
<span className="text-sm font-medium">Clear</span>
|
<span className="text-sm font-medium">Clear</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<p className="mt-2 text-sm text-gray-600">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
Found {filteredEvents.length} {filteredEvents.length === 1 ? 'activity' : 'activities'} matching "{searchQuery}"
|
Found {filteredEvents.length} {filteredEvents.length === 1 ? 'activity' : 'activities'} matching "{searchQuery}"
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter Tabs */}
|
{/* Filter Tabs */}
|
||||||
<div className="bg-white shadow rounded-lg mb-4 p-4">
|
<div className="bg-card shadow-soft border border-border rounded-lg mb-4 p-4">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{filterTabs.map((tab) => (
|
{filterTabs.map((tab) => (
|
||||||
<button
|
<button
|
||||||
@@ -259,7 +276,7 @@ export function EventList() {
|
|||||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||||
activeFilter === tab.value
|
activeFilter === tab.value
|
||||||
? 'bg-primary text-white'
|
? 'bg-primary text-white'
|
||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
: 'bg-muted text-foreground hover:bg-accent'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab.label} ({tab.count})
|
{tab.label} ({tab.count})
|
||||||
@@ -270,12 +287,12 @@ export function EventList() {
|
|||||||
|
|
||||||
{/* Activities Table */}
|
{/* Activities Table */}
|
||||||
{filteredEvents.length === 0 ? (
|
{filteredEvents.length === 0 ? (
|
||||||
<div className="bg-white shadow rounded-lg p-12 text-center">
|
<div className="bg-card shadow-soft border border-border rounded-lg p-12 text-center">
|
||||||
<Search className="h-16 w-16 mx-auto mb-4 text-gray-300" />
|
<Search className="h-16 w-16 mx-auto mb-4 text-muted-foreground/50" />
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||||
{searchQuery ? 'No activities found' : 'No activities yet'}
|
{searchQuery ? 'No activities found' : 'No activities yet'}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-500">
|
<p className="text-muted-foreground">
|
||||||
{searchQuery
|
{searchQuery
|
||||||
? `No activities match "${searchQuery}". Try a different search term.`
|
? `No activities match "${searchQuery}". Try a different search term.`
|
||||||
: 'Get started by adding your first activity.'}
|
: 'Get started by adding your first activity.'}
|
||||||
@@ -283,19 +300,19 @@ export function EventList() {
|
|||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setSearchQuery('')}
|
onClick={() => setSearchQuery('')}
|
||||||
className="mt-4 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"
|
className="mt-4 inline-flex items-center px-4 py-2 border border-border text-sm font-medium rounded-md text-foreground bg-card hover:bg-accent transition-colors"
|
||||||
>
|
>
|
||||||
Clear search
|
Clear search
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
<div className="bg-card shadow-medium border border-border rounded-lg overflow-hidden">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-border">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-muted/30">
|
||||||
<tr>
|
<tr>
|
||||||
<th
|
<th
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase cursor-pointer hover:bg-gray-100 select-none"
|
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent select-none transition-colors"
|
||||||
onClick={() => handleSort('title')}
|
onClick={() => handleSort('title')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -303,12 +320,12 @@ export function EventList() {
|
|||||||
{sortField === 'title' ? (
|
{sortField === 'title' ? (
|
||||||
sortDirection === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
|
sortDirection === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
|
||||||
) : (
|
) : (
|
||||||
<ArrowUpDown className="h-3 w-3 text-gray-400" />
|
<ArrowUpDown className="h-3 w-3 text-muted-foreground/50" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase cursor-pointer hover:bg-gray-100 select-none"
|
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent select-none transition-colors"
|
||||||
onClick={() => handleSort('type')}
|
onClick={() => handleSort('type')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -316,12 +333,12 @@ export function EventList() {
|
|||||||
{sortField === 'type' ? (
|
{sortField === 'type' ? (
|
||||||
sortDirection === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
|
sortDirection === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
|
||||||
) : (
|
) : (
|
||||||
<ArrowUpDown className="h-3 w-3 text-gray-400" />
|
<ArrowUpDown className="h-3 w-3 text-muted-foreground/50" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase cursor-pointer hover:bg-gray-100 select-none"
|
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent select-none transition-colors"
|
||||||
onClick={() => handleSort('vips')}
|
onClick={() => handleSort('vips')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -329,18 +346,18 @@ export function EventList() {
|
|||||||
{sortField === 'vips' ? (
|
{sortField === 'vips' ? (
|
||||||
sortDirection === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
|
sortDirection === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
|
||||||
) : (
|
) : (
|
||||||
<ArrowUpDown className="h-3 w-3 text-gray-400" />
|
<ArrowUpDown className="h-3 w-3 text-muted-foreground/50" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||||
Vehicle
|
Vehicle
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||||
Driver
|
Driver
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase cursor-pointer hover:bg-gray-100 select-none"
|
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent select-none transition-colors"
|
||||||
onClick={() => handleSort('startTime')}
|
onClick={() => handleSort('startTime')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -348,12 +365,12 @@ export function EventList() {
|
|||||||
{sortField === 'startTime' ? (
|
{sortField === 'startTime' ? (
|
||||||
sortDirection === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
|
sortDirection === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
|
||||||
) : (
|
) : (
|
||||||
<ArrowUpDown className="h-3 w-3 text-gray-400" />
|
<ArrowUpDown className="h-3 w-3 text-muted-foreground/50" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase cursor-pointer hover:bg-gray-100 select-none"
|
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent select-none transition-colors"
|
||||||
onClick={() => handleSort('status')}
|
onClick={() => handleSort('status')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -361,65 +378,65 @@ export function EventList() {
|
|||||||
{sortField === 'status' ? (
|
{sortField === 'status' ? (
|
||||||
sortDirection === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
|
sortDirection === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
|
||||||
) : (
|
) : (
|
||||||
<ArrowUpDown className="h-3 w-3 text-gray-400" />
|
<ArrowUpDown className="h-3 w-3 text-muted-foreground/50" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||||
Actions
|
Actions
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-card divide-y divide-border">
|
||||||
{filteredEvents?.map((event) => (
|
{filteredEvents?.map((event) => (
|
||||||
<tr key={event.id}>
|
<tr key={event.id} className="hover:bg-muted/50 transition-colors">
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-foreground">
|
||||||
{event.title}
|
{event.title}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||||
event.type === 'TRANSPORT' ? 'bg-blue-100 text-blue-800' :
|
event.type === 'TRANSPORT' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' :
|
||||||
event.type === 'MEAL' ? 'bg-green-100 text-green-800' :
|
event.type === 'MEAL' ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' :
|
||||||
event.type === 'EVENT' ? 'bg-purple-100 text-purple-800' :
|
event.type === 'EVENT' ? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300' :
|
||||||
event.type === 'MEETING' ? 'bg-yellow-100 text-yellow-800' :
|
event.type === 'MEETING' ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300' :
|
||||||
'bg-gray-100 text-gray-800'
|
'bg-muted text-muted-foreground'
|
||||||
}`}>
|
}`}>
|
||||||
{event.type}
|
{event.type}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-sm text-gray-500">
|
<td className="px-6 py-4 text-sm text-muted-foreground">
|
||||||
{event.vips && event.vips.length > 0
|
{event.vips && event.vips.length > 0
|
||||||
? event.vips.map(vip => vip.name).join(', ')
|
? event.vips.map(vip => vip.name).join(', ')
|
||||||
: 'No VIPs assigned'}
|
: 'No VIPs assigned'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||||
{event.vehicle ? (
|
{event.vehicle ? (
|
||||||
<div>
|
<div>
|
||||||
<div>{event.vehicle.name}</div>
|
<div className="text-foreground">{event.vehicle.name}</div>
|
||||||
<div className="text-xs text-gray-400">
|
<div className="text-xs text-muted-foreground">
|
||||||
{event.vips?.length || 0}/{event.vehicle.seatCapacity} seats
|
{event.vips?.length || 0}/{event.vehicle.seatCapacity} seats
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-400">No vehicle</span>
|
<span className="text-muted-foreground">No vehicle</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||||
<InlineDriverSelector
|
<InlineDriverSelector
|
||||||
eventId={event.id}
|
eventId={event.id}
|
||||||
currentDriverId={event.driverId}
|
currentDriverId={event.driverId}
|
||||||
currentDriverName={event.driver?.name}
|
currentDriverName={event.driver?.name}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||||
{formatDateTime(event.startTime)}
|
{formatDateTime(event.startTime)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||||
event.status === 'SCHEDULED' ? 'bg-blue-100 text-blue-800' :
|
event.status === 'SCHEDULED' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' :
|
||||||
event.status === 'IN_PROGRESS' ? 'bg-yellow-100 text-yellow-800' :
|
event.status === 'IN_PROGRESS' ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300' :
|
||||||
event.status === 'COMPLETED' ? 'bg-green-100 text-green-800' :
|
event.status === 'COMPLETED' ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' :
|
||||||
'bg-gray-100 text-gray-800'
|
'bg-muted text-muted-foreground'
|
||||||
}`}>
|
}`}>
|
||||||
{event.status}
|
{event.status}
|
||||||
</span>
|
</span>
|
||||||
@@ -428,14 +445,14 @@ export function EventList() {
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEdit(event)}
|
onClick={() => handleEdit(event)}
|
||||||
className="inline-flex items-center px-3 py-1 text-primary hover:text-primary/80"
|
className="inline-flex items-center px-3 py-1 text-primary hover:text-primary/80 transition-colors"
|
||||||
>
|
>
|
||||||
<Edit className="h-4 w-4 mr-1" />
|
<Edit className="h-4 w-4 mr-1" />
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(event.id, event.title)}
|
onClick={() => handleDelete(event.id, event.title)}
|
||||||
className="inline-flex items-center px-3 py-1 text-red-600 hover:text-red-800"
|
className="inline-flex items-center px-3 py-1 text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 transition-colors"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 mr-1" />
|
<Trash2 className="h-4 w-4 mr-1" />
|
||||||
Delete
|
Delete
|
||||||
|
|||||||
@@ -223,20 +223,20 @@ export function FlightList() {
|
|||||||
const getStatusColor = (status: string | null) => {
|
const getStatusColor = (status: string | null) => {
|
||||||
switch (status?.toLowerCase()) {
|
switch (status?.toLowerCase()) {
|
||||||
case 'scheduled':
|
case 'scheduled':
|
||||||
return 'bg-blue-100 text-blue-800';
|
return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
|
||||||
case 'boarding':
|
case 'boarding':
|
||||||
return 'bg-yellow-100 text-yellow-800';
|
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300';
|
||||||
case 'departed':
|
case 'departed':
|
||||||
case 'en-route':
|
case 'en-route':
|
||||||
return 'bg-purple-100 text-purple-800';
|
return 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300';
|
||||||
case 'landed':
|
case 'landed':
|
||||||
return 'bg-green-100 text-green-800';
|
return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
|
||||||
case 'delayed':
|
case 'delayed':
|
||||||
return 'bg-orange-100 text-orange-800';
|
return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300';
|
||||||
case 'cancelled':
|
case 'cancelled':
|
||||||
return 'bg-red-100 text-red-800';
|
return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300';
|
||||||
default:
|
default:
|
||||||
return 'bg-gray-100 text-gray-800';
|
return 'bg-muted text-muted-foreground';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -244,7 +244,7 @@ export function FlightList() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Flights</h1>
|
<h1 className="text-3xl font-bold text-foreground">Flights</h1>
|
||||||
<button
|
<button
|
||||||
disabled
|
disabled
|
||||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary/50 cursor-not-allowed"
|
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary/50 cursor-not-allowed"
|
||||||
@@ -271,10 +271,10 @@ export function FlightList() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Flights</h1>
|
<h1 className="text-3xl font-bold text-foreground">Flights</h1>
|
||||||
<button
|
<button
|
||||||
onClick={handleAdd}
|
onClick={handleAdd}
|
||||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary hover:bg-primary/90"
|
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary hover:bg-primary/90 transition-colors"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Add Flight
|
Add Flight
|
||||||
@@ -283,17 +283,17 @@ export function FlightList() {
|
|||||||
|
|
||||||
{/* Search and Filter Section */}
|
{/* Search and Filter Section */}
|
||||||
{flights && flights.length > 0 && (
|
{flights && flights.length > 0 && (
|
||||||
<div className="bg-white shadow rounded-lg p-4 mb-6">
|
<div className="bg-card shadow-soft border border-border rounded-lg p-4 mb-6">
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400 pointer-events-none" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-muted-foreground pointer-events-none" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by flight number, VIP, or route..."
|
placeholder="Search by flight number, VIP, or route..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-md focus:ring-primary focus:border-primary text-base"
|
className="w-full pl-10 pr-4 py-2.5 border border-input rounded-md focus:ring-primary focus:border-primary text-base bg-background transition-colors"
|
||||||
style={{ minHeight: '44px' }}
|
style={{ minHeight: '44px' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -301,7 +301,7 @@ export function FlightList() {
|
|||||||
{/* Filter Button */}
|
{/* Filter Button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setFilterModalOpen(true)}
|
onClick={() => setFilterModalOpen(true)}
|
||||||
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50 font-medium"
|
className="inline-flex items-center px-4 py-2 border border-border rounded-md text-foreground bg-card hover:bg-accent font-medium transition-colors"
|
||||||
style={{ minHeight: '44px' }}
|
style={{ minHeight: '44px' }}
|
||||||
>
|
>
|
||||||
<Filter className="h-5 w-5 mr-2" />
|
<Filter className="h-5 w-5 mr-2" />
|
||||||
@@ -316,8 +316,8 @@ export function FlightList() {
|
|||||||
|
|
||||||
{/* Active Filter Chips */}
|
{/* Active Filter Chips */}
|
||||||
{selectedStatuses.length > 0 && (
|
{selectedStatuses.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2 mt-3 pt-3 border-t border-gray-200">
|
<div className="flex flex-wrap gap-2 mt-3 pt-3 border-t border-border">
|
||||||
<span className="text-sm text-gray-600 py-1.5">Active filters:</span>
|
<span className="text-sm text-muted-foreground py-1.5">Active filters:</span>
|
||||||
{selectedStatuses.map((status) => (
|
{selectedStatuses.map((status) => (
|
||||||
<FilterChip
|
<FilterChip
|
||||||
key={status}
|
key={status}
|
||||||
@@ -329,15 +329,15 @@ export function FlightList() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Results count */}
|
{/* Results count */}
|
||||||
<div className="flex items-center justify-between mt-3 pt-3 border-t border-gray-200">
|
<div className="flex items-center justify-between mt-3 pt-3 border-t border-border">
|
||||||
<div className="text-sm text-gray-600">
|
<div className="text-sm text-muted-foreground">
|
||||||
Showing <span className="font-medium">{filteredFlights.length}</span> of <span className="font-medium">{flights.length}</span> flights
|
Showing <span className="font-medium text-foreground">{filteredFlights.length}</span> of <span className="font-medium text-foreground">{flights.length}</span> flights
|
||||||
{debouncedSearchTerm !== searchTerm && <span className="ml-2 text-gray-400">(searching...)</span>}
|
{debouncedSearchTerm !== searchTerm && <span className="ml-2 text-muted-foreground/70">(searching...)</span>}
|
||||||
</div>
|
</div>
|
||||||
{(searchTerm || selectedStatuses.length > 0) && (
|
{(searchTerm || selectedStatuses.length > 0) && (
|
||||||
<button
|
<button
|
||||||
onClick={handleClearFilters}
|
onClick={handleClearFilters}
|
||||||
className="inline-flex items-center px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-md"
|
className="inline-flex items-center px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:bg-accent rounded-md transition-colors"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4 mr-1" />
|
<X className="h-4 w-4 mr-1" />
|
||||||
Clear All
|
Clear All
|
||||||
@@ -348,12 +348,12 @@ export function FlightList() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{flights && flights.length > 0 ? (
|
{flights && flights.length > 0 ? (
|
||||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
<div className="bg-card shadow-medium border border-border rounded-lg overflow-hidden">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-border">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-muted/30">
|
||||||
<tr>
|
<tr>
|
||||||
<th
|
<th
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase cursor-pointer hover:bg-gray-100 transition-colors"
|
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent select-none transition-colors"
|
||||||
onClick={() => handleSort('flightNumber')}
|
onClick={() => handleSort('flightNumber')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -362,11 +362,11 @@ export function FlightList() {
|
|||||||
{sortColumn === 'flightNumber' && <span className="text-primary">{sortDirection === 'asc' ? '↑' : '↓'}</span>}
|
{sortColumn === 'flightNumber' && <span className="text-primary">{sortDirection === 'asc' ? '↑' : '↓'}</span>}
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||||
VIP
|
VIP
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase cursor-pointer hover:bg-gray-100 transition-colors"
|
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent select-none transition-colors"
|
||||||
onClick={() => handleSort('departureAirport')}
|
onClick={() => handleSort('departureAirport')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -375,11 +375,11 @@ export function FlightList() {
|
|||||||
{sortColumn === 'departureAirport' && <span className="text-primary">{sortDirection === 'asc' ? '↑' : '↓'}</span>}
|
{sortColumn === 'departureAirport' && <span className="text-primary">{sortDirection === 'asc' ? '↑' : '↓'}</span>}
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||||
Scheduled
|
Scheduled
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase cursor-pointer hover:bg-gray-100 transition-colors"
|
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase cursor-pointer hover:bg-accent select-none transition-colors"
|
||||||
onClick={() => handleSort('status')}
|
onClick={() => handleSort('status')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -388,41 +388,41 @@ export function FlightList() {
|
|||||||
{sortColumn === 'status' && <span className="text-primary">{sortDirection === 'asc' ? '↑' : '↓'}</span>}
|
{sortColumn === 'status' && <span className="text-primary">{sortDirection === 'asc' ? '↑' : '↓'}</span>}
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||||
Actions
|
Actions
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-card divide-y divide-border">
|
||||||
{filteredFlights.map((flight) => (
|
{filteredFlights.map((flight) => (
|
||||||
<tr key={flight.id} className="hover:bg-gray-50 transition-colors">
|
<tr key={flight.id} className="hover:bg-muted/50 transition-colors">
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Plane className="h-4 w-4 text-gray-400 mr-2" />
|
<Plane className="h-4 w-4 text-muted-foreground mr-2" />
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-gray-900">
|
<div className="text-sm font-medium text-foreground">
|
||||||
{flight.flightNumber}
|
{flight.flightNumber}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-xs text-muted-foreground">
|
||||||
Segment {flight.segment}
|
Segment {flight.segment}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||||
<div className="font-medium text-gray-900">{flight.vip?.name}</div>
|
<div className="font-medium text-foreground">{flight.vip?.name}</div>
|
||||||
{flight.vip?.organization && (
|
{flight.vip?.organization && (
|
||||||
<div className="text-xs text-gray-500">{flight.vip.organization}</div>
|
<div className="text-xs text-muted-foreground">{flight.vip.organization}</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<span className="font-medium">{flight.departureAirport}</span>
|
<span className="font-medium text-foreground">{flight.departureAirport}</span>
|
||||||
<span className="mx-2">→</span>
|
<span className="mx-2">→</span>
|
||||||
<span className="font-medium">{flight.arrivalAirport}</span>
|
<span className="font-medium text-foreground">{flight.arrivalAirport}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||||
<div className="text-xs">
|
<div className="text-xs">
|
||||||
<div>Dep: {formatTime(flight.scheduledDeparture)}</div>
|
<div>Dep: {formatTime(flight.scheduledDeparture)}</div>
|
||||||
<div>Arr: {formatTime(flight.scheduledArrival)}</div>
|
<div>Arr: {formatTime(flight.scheduledArrival)}</div>
|
||||||
@@ -441,14 +441,14 @@ export function FlightList() {
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEdit(flight)}
|
onClick={() => handleEdit(flight)}
|
||||||
className="inline-flex items-center px-3 py-1 text-primary hover:text-primary/80"
|
className="inline-flex items-center px-3 py-1 text-primary hover:text-primary/80 transition-colors"
|
||||||
>
|
>
|
||||||
<Edit className="h-4 w-4 mr-1" />
|
<Edit className="h-4 w-4 mr-1" />
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(flight.id, flight.flightNumber)}
|
onClick={() => handleDelete(flight.id, flight.flightNumber)}
|
||||||
className="inline-flex items-center px-3 py-1 text-red-600 hover:text-red-800"
|
className="inline-flex items-center px-3 py-1 text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 transition-colors"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 mr-1" />
|
<Trash2 className="h-4 w-4 mr-1" />
|
||||||
Delete
|
Delete
|
||||||
@@ -461,12 +461,12 @@ export function FlightList() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-white shadow rounded-lg p-12 text-center">
|
<div className="bg-card shadow-soft border border-border rounded-lg p-12 text-center">
|
||||||
<Plane className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
<Plane className="h-12 w-12 text-muted-foreground/50 mx-auto mb-4" />
|
||||||
<p className="text-gray-500 mb-4">No flights tracked yet.</p>
|
<p className="text-muted-foreground mb-4">No flights tracked yet.</p>
|
||||||
<button
|
<button
|
||||||
onClick={handleAdd}
|
onClick={handleAdd}
|
||||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary hover:bg-primary/90"
|
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary hover:bg-primary/90 transition-colors"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Add Your First Flight
|
Add Your First Flight
|
||||||
|
|||||||
@@ -14,16 +14,16 @@ export function Login() {
|
|||||||
}, [isAuthenticated, navigate]);
|
}, [isAuthenticated, navigate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary/5 via-background to-primary/10">
|
||||||
<div className="max-w-md w-full bg-white rounded-lg shadow-xl p-8">
|
<div className="max-w-md w-full bg-card border border-border rounded-lg shadow-xl p-8">
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<div className="inline-block p-3 bg-primary/10 rounded-full mb-4">
|
<div className="inline-block p-3 bg-primary/10 rounded-full mb-4">
|
||||||
<Plane className="h-12 w-12 text-primary" />
|
<Plane className="h-12 w-12 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
<h1 className="text-3xl font-bold text-foreground mb-2">
|
||||||
VIP Coordinator
|
VIP Coordinator
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600">
|
<p className="text-muted-foreground">
|
||||||
Transportation logistics and event coordination
|
Transportation logistics and event coordination
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -35,7 +35,7 @@ export function Login() {
|
|||||||
Sign In with Auth0
|
Sign In with Auth0
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="mt-6 text-center text-sm text-gray-500">
|
<div className="mt-6 text-center text-sm text-muted-foreground">
|
||||||
<p>First user becomes administrator</p>
|
<p>First user becomes administrator</p>
|
||||||
<p>Subsequent users require admin approval</p>
|
<p>Subsequent users require admin approval</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,31 +5,31 @@ export function PendingApproval() {
|
|||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary/5 via-background to-primary/10">
|
||||||
<div className="max-w-md w-full bg-white rounded-lg shadow-xl p-8">
|
<div className="max-w-md w-full bg-card border border-border rounded-lg shadow-xl p-8">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="inline-block p-3 bg-yellow-100 rounded-full mb-4">
|
<div className="inline-block p-3 bg-yellow-500/10 dark:bg-yellow-500/20 rounded-full mb-4">
|
||||||
<Clock className="h-12 w-12 text-yellow-600" />
|
<Clock className="h-12 w-12 text-yellow-600 dark:text-yellow-500" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
<h1 className="text-3xl font-bold text-foreground mb-2">
|
||||||
Account Pending Approval
|
Account Pending Approval
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="text-gray-600 mb-6">
|
<p className="text-muted-foreground mb-6">
|
||||||
Your account is awaiting administrator approval. You will be able to access the system once your account has been approved.
|
Your account is awaiting administrator approval. You will be able to access the system once your account has been approved.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{user?.email && (
|
{user?.email && (
|
||||||
<div className="bg-gray-50 rounded-lg p-4 mb-6">
|
<div className="bg-muted/30 rounded-lg p-4 mb-6">
|
||||||
<div className="flex items-center justify-center text-sm text-gray-700">
|
<div className="flex items-center justify-center text-sm text-foreground">
|
||||||
<Mail className="h-4 w-4 mr-2" />
|
<Mail className="h-4 w-4 mr-2" />
|
||||||
<span>{user.email}</span>
|
<span>{user.email}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="text-sm text-gray-500 mb-6">
|
<div className="text-sm text-muted-foreground mb-6">
|
||||||
<p>Please contact your administrator if you have any questions.</p>
|
<p>Please contact your administrator if you have any questions.</p>
|
||||||
<p className="mt-2">
|
<p className="mt-2">
|
||||||
<strong>Note:</strong> The first user is automatically approved as Administrator.
|
<strong>Note:</strong> The first user is automatically approved as Administrator.
|
||||||
@@ -38,7 +38,7 @@ export function PendingApproval() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => logout()}
|
onClick={() => logout()}
|
||||||
className="w-full bg-gray-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-gray-700 transition-colors"
|
className="w-full bg-secondary text-secondary-foreground py-3 px-4 rounded-lg font-medium hover:bg-secondary/80 transition-colors"
|
||||||
>
|
>
|
||||||
Sign Out
|
Sign Out
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -101,54 +101,54 @@ export function UserList() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-6">User Management</h1>
|
<h1 className="text-3xl font-bold text-foreground mb-6">User Management</h1>
|
||||||
|
|
||||||
{/* Pending Approval Section */}
|
{/* Pending Approval Section */}
|
||||||
{pendingUsers.length > 0 && (
|
{pendingUsers.length > 0 && (
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center mb-4">
|
<div className="flex items-center mb-4">
|
||||||
<UserX className="h-5 w-5 text-yellow-600 mr-2" />
|
<UserX className="h-5 w-5 text-yellow-600 mr-2" />
|
||||||
<h2 className="text-xl font-semibold text-gray-900">
|
<h2 className="text-xl font-semibold text-foreground">
|
||||||
Pending Approval ({pendingUsers.length})
|
Pending Approval ({pendingUsers.length})
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
<div className="bg-card border border-border shadow-soft rounded-lg overflow-hidden transition-colors">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-border">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-muted/30">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||||
Name
|
Name
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||||
Email
|
Email
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||||
Role
|
Role
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||||
Requested
|
Requested
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||||
Actions
|
Actions
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-card divide-y divide-border">
|
||||||
{pendingUsers.map((user) => (
|
{pendingUsers.map((user) => (
|
||||||
<tr key={user.id} className="bg-yellow-50">
|
<tr key={user.id} className="bg-yellow-50 dark:bg-yellow-950/20">
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-foreground">
|
||||||
{user.name || 'Unknown User'}
|
{user.name || 'Unknown User'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||||
{user.email}
|
{user.email}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||||
<span className="px-2 py-1 bg-gray-100 rounded text-xs font-medium">
|
<span className="px-2 py-1 bg-muted rounded text-xs font-medium">
|
||||||
{user.role}
|
{user.role}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||||
{new Date(user.createdAt).toLocaleDateString()}
|
{new Date(user.createdAt).toLocaleDateString()}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||||
@@ -156,7 +156,7 @@ export function UserList() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleApprove(user.id)}
|
onClick={() => handleApprove(user.id)}
|
||||||
disabled={processingUser === user.id}
|
disabled={processingUser === user.id}
|
||||||
className="inline-flex items-center px-3 py-1 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50"
|
className="inline-flex items-center px-3 py-1 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 transition-colors"
|
||||||
>
|
>
|
||||||
<Check className="h-4 w-4 mr-1" />
|
<Check className="h-4 w-4 mr-1" />
|
||||||
Approve
|
Approve
|
||||||
@@ -164,7 +164,7 @@ export function UserList() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleDeny(user.id)}
|
onClick={() => handleDeny(user.id)}
|
||||||
disabled={processingUser === user.id}
|
disabled={processingUser === user.id}
|
||||||
className="inline-flex items-center px-3 py-1 bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50"
|
className="inline-flex items-center px-3 py-1 bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50 transition-colors"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4 mr-1" />
|
<X className="h-4 w-4 mr-1" />
|
||||||
Deny
|
Deny
|
||||||
@@ -183,56 +183,56 @@ export function UserList() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center mb-4">
|
<div className="flex items-center mb-4">
|
||||||
<UserCheck className="h-5 w-5 text-green-600 mr-2" />
|
<UserCheck className="h-5 w-5 text-green-600 mr-2" />
|
||||||
<h2 className="text-xl font-semibold text-gray-900">
|
<h2 className="text-xl font-semibold text-foreground">
|
||||||
Approved Users ({approvedUsers.length})
|
Approved Users ({approvedUsers.length})
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
<div className="bg-card border border-border shadow-soft rounded-lg overflow-hidden transition-colors">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-border">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-muted/30">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||||
Name
|
Name
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||||
Email
|
Email
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||||
Role
|
Role
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||||
Status
|
Status
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||||
Actions
|
Actions
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-card divide-y divide-border">
|
||||||
{approvedUsers.map((user) => (
|
{approvedUsers.map((user) => (
|
||||||
<tr key={user.id}>
|
<tr key={user.id} className="hover:bg-accent transition-colors">
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-foreground">
|
||||||
{user.name || 'Unknown User'}
|
{user.name || 'Unknown User'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||||
{user.email}
|
{user.email}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||||
<span
|
<span
|
||||||
className={`px-2 py-1 rounded text-xs font-medium ${
|
className={`px-2 py-1 rounded text-xs font-medium ${
|
||||||
user.role === 'ADMINISTRATOR'
|
user.role === 'ADMINISTRATOR'
|
||||||
? 'bg-purple-100 text-purple-800'
|
? 'bg-purple-100 text-purple-800 dark:bg-purple-950 dark:text-purple-200'
|
||||||
: user.role === 'COORDINATOR'
|
: user.role === 'COORDINATOR'
|
||||||
? 'bg-blue-100 text-blue-800'
|
? 'bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-200'
|
||||||
: 'bg-gray-100 text-gray-800'
|
: 'bg-muted text-muted-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{user.role === 'ADMINISTRATOR' && <Shield className="h-3 w-3 inline mr-1" />}
|
{user.role === 'ADMINISTRATOR' && <Shield className="h-3 w-3 inline mr-1" />}
|
||||||
{user.role}
|
{user.role}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-green-600 font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-green-600 dark:text-green-400 font-medium">
|
||||||
<Check className="h-4 w-4 inline mr-1" />
|
<Check className="h-4 w-4 inline mr-1" />
|
||||||
Active
|
Active
|
||||||
</td>
|
</td>
|
||||||
@@ -241,7 +241,7 @@ export function UserList() {
|
|||||||
<select
|
<select
|
||||||
value={user.role}
|
value={user.role}
|
||||||
onChange={(e) => handleRoleChange(user.id, e.target.value)}
|
onChange={(e) => handleRoleChange(user.id, e.target.value)}
|
||||||
className="text-sm border border-gray-300 rounded px-2 py-1 focus:ring-primary focus:border-primary"
|
className="text-sm border border-input bg-background rounded px-2 py-1 focus:ring-primary focus:border-primary transition-colors"
|
||||||
>
|
>
|
||||||
<option value="DRIVER">Driver</option>
|
<option value="DRIVER">Driver</option>
|
||||||
<option value="COORDINATOR">Coordinator</option>
|
<option value="COORDINATOR">Coordinator</option>
|
||||||
@@ -249,7 +249,7 @@ export function UserList() {
|
|||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeny(user.id)}
|
onClick={() => handleDeny(user.id)}
|
||||||
className="inline-flex items-center px-2 py-1 text-red-600 hover:text-red-800 hover:bg-red-50 rounded"
|
className="inline-flex items-center px-2 py-1 text-red-600 hover:text-red-800 hover:bg-red-50 dark:hover:bg-red-950/20 rounded transition-colors"
|
||||||
title="Delete user"
|
title="Delete user"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { pdf } from '@react-pdf/renderer';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { Loading } from '@/components/Loading';
|
import { Loading } from '@/components/Loading';
|
||||||
|
import { EventForm, EventFormData } from '@/components/EventForm';
|
||||||
|
import { VIPSchedulePDF } from '@/components/VIPSchedulePDF';
|
||||||
|
import { ScheduleEvent } from '@/types';
|
||||||
|
import { usePdfSettings } from '@/hooks/useSettings';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Calendar,
|
Calendar,
|
||||||
@@ -11,8 +17,15 @@ import {
|
|||||||
User,
|
User,
|
||||||
Plane,
|
Plane,
|
||||||
Download,
|
Download,
|
||||||
Mail,
|
MessageCircle,
|
||||||
|
Pencil,
|
||||||
|
X,
|
||||||
|
Trash2,
|
||||||
|
AlertTriangle,
|
||||||
|
Send,
|
||||||
|
Loader2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
interface VIP {
|
interface VIP {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -35,37 +48,20 @@ interface VIP {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ScheduleEvent {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
pickupLocation: string | null;
|
|
||||||
dropoffLocation: string | null;
|
|
||||||
location: string | null;
|
|
||||||
startTime: string;
|
|
||||||
endTime: string;
|
|
||||||
type: string;
|
|
||||||
status: string;
|
|
||||||
description: string | null;
|
|
||||||
vipIds: string[];
|
|
||||||
vips?: Array<{
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}>;
|
|
||||||
driver: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
} | null;
|
|
||||||
vehicle: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
seatCapacity: number;
|
|
||||||
} | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VIPSchedule() {
|
export function VIPSchedule() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// State for edit modal
|
||||||
|
const [editingEvent, setEditingEvent] = useState<ScheduleEvent | null>(null);
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState<ScheduleEvent | null>(null);
|
||||||
|
|
||||||
|
// State for Signal send modal
|
||||||
|
const [showSignalModal, setShowSignalModal] = useState(false);
|
||||||
|
const [signalPhoneNumber, setSignalPhoneNumber] = useState('');
|
||||||
|
const [signalMessage, setSignalMessage] = useState('');
|
||||||
|
const [isSendingSignal, setIsSendingSignal] = useState(false);
|
||||||
|
|
||||||
const { data: vip, isLoading: vipLoading } = useQuery<VIP>({
|
const { data: vip, isLoading: vipLoading } = useQuery<VIP>({
|
||||||
queryKey: ['vip', id],
|
queryKey: ['vip', id],
|
||||||
@@ -83,6 +79,74 @@ export function VIPSchedule() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch PDF settings for customization
|
||||||
|
const { data: pdfSettings } = usePdfSettings();
|
||||||
|
|
||||||
|
// Update event mutation
|
||||||
|
const updateEventMutation = useMutation({
|
||||||
|
mutationFn: async (data: EventFormData & { id: string }) => {
|
||||||
|
const { id: eventId, ...updateData } = data;
|
||||||
|
const response = await api.patch(`/events/${eventId}`, updateData);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||||
|
setEditingEvent(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cancel event mutation (set status to CANCELLED and free resources)
|
||||||
|
const cancelEventMutation = useMutation({
|
||||||
|
mutationFn: async (eventId: string) => {
|
||||||
|
const response = await api.patch(`/events/${eventId}`, {
|
||||||
|
status: 'CANCELLED',
|
||||||
|
driverId: null,
|
||||||
|
vehicleId: null,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['drivers'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['vehicles'] });
|
||||||
|
setEditingEvent(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete event mutation (soft delete)
|
||||||
|
const deleteEventMutation = useMutation({
|
||||||
|
mutationFn: async (eventId: string) => {
|
||||||
|
const response = await api.delete(`/events/${eventId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['events'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['drivers'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['vehicles'] });
|
||||||
|
setShowDeleteConfirm(null);
|
||||||
|
setEditingEvent(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleEditEvent = (event: ScheduleEvent) => {
|
||||||
|
setEditingEvent(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateEvent = async (data: EventFormData) => {
|
||||||
|
if (!editingEvent) return;
|
||||||
|
await updateEventMutation.mutateAsync({ ...data, id: editingEvent.id });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEvent = async () => {
|
||||||
|
if (!editingEvent) return;
|
||||||
|
await cancelEventMutation.mutateAsync(editingEvent.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteEvent = async () => {
|
||||||
|
if (!showDeleteConfirm) return;
|
||||||
|
await deleteEventMutation.mutateAsync(showDeleteConfirm.id);
|
||||||
|
};
|
||||||
|
|
||||||
if (vipLoading || eventsLoading) {
|
if (vipLoading || eventsLoading) {
|
||||||
return <Loading message="Loading VIP schedule..." />;
|
return <Loading message="Loading VIP schedule..." />;
|
||||||
}
|
}
|
||||||
@@ -90,13 +154,13 @@ export function VIPSchedule() {
|
|||||||
if (!vip) {
|
if (!vip) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-gray-500">VIP not found</p>
|
<p className="text-muted-foreground">VIP not found</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter events for this VIP (using new multi-VIP schema)
|
// Filter events for this VIP (using new multi-VIP schema)
|
||||||
const vipEvents = events?.filter((event) => event.vipIds?.includes(id)) || [];
|
const vipEvents = events?.filter((event) => event.vipIds?.includes(id || '')) || [];
|
||||||
|
|
||||||
// Sort events by start time
|
// Sort events by start time
|
||||||
const sortedEvents = [...vipEvents].sort(
|
const sortedEvents = [...vipEvents].sort(
|
||||||
@@ -154,14 +218,92 @@ export function VIPSchedule() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExport = () => {
|
const handleExport = async () => {
|
||||||
// TODO: Implement PDF export
|
if (!vip) return;
|
||||||
alert('PDF export feature coming soon!');
|
|
||||||
|
try {
|
||||||
|
// Generate PDF
|
||||||
|
const blob = await pdf(
|
||||||
|
<VIPSchedulePDF
|
||||||
|
vip={vip}
|
||||||
|
events={vipEvents}
|
||||||
|
settings={pdfSettings}
|
||||||
|
/>
|
||||||
|
).toBlob();
|
||||||
|
|
||||||
|
// Create download link
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
// Create timestamp like "Feb01_1430" (Month Day _ 24hr time)
|
||||||
|
const now = new Date();
|
||||||
|
const timestamp = now.toLocaleDateString('en-US', { month: 'short', day: '2-digit' }).replace(' ', '') +
|
||||||
|
'_' + now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }).replace(':', '');
|
||||||
|
link.download = `${vip.name.replace(/\s+/g, '_')}_Schedule_${timestamp}.pdf`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[PDF] Generation failed:', error);
|
||||||
|
alert('Failed to generate PDF. Please try again.');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEmail = () => {
|
const handleSendViaSignal = async () => {
|
||||||
// TODO: Implement email functionality
|
if (!vip || !signalPhoneNumber.trim()) return;
|
||||||
alert('Email feature coming soon!');
|
|
||||||
|
setIsSendingSignal(true);
|
||||||
|
try {
|
||||||
|
// Generate the PDF as base64
|
||||||
|
const pdfBlob = await pdf(
|
||||||
|
<VIPSchedulePDF
|
||||||
|
vip={vip}
|
||||||
|
events={vipEvents}
|
||||||
|
settings={pdfSettings}
|
||||||
|
/>
|
||||||
|
).toBlob();
|
||||||
|
|
||||||
|
// Convert blob to base64
|
||||||
|
const reader = new FileReader();
|
||||||
|
const base64Promise = new Promise<string>((resolve, reject) => {
|
||||||
|
reader.onloadend = () => {
|
||||||
|
const base64 = (reader.result as string).split(',')[1]; // Remove data:... prefix
|
||||||
|
resolve(base64);
|
||||||
|
};
|
||||||
|
reader.onerror = reject;
|
||||||
|
});
|
||||||
|
reader.readAsDataURL(pdfBlob);
|
||||||
|
const base64Data = await base64Promise;
|
||||||
|
|
||||||
|
// Send via Signal
|
||||||
|
// Create timestamp like "Feb01_1430" (Month Day _ 24hr time)
|
||||||
|
const now = new Date();
|
||||||
|
const timestamp = now.toLocaleDateString('en-US', { month: 'short', day: '2-digit' }).replace(' ', '') +
|
||||||
|
'_' + now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }).replace(':', '');
|
||||||
|
const filename = `${vip.name.replace(/\s+/g, '_')}_Schedule_${timestamp}.pdf`;
|
||||||
|
const response = await api.post('/signal/send-attachment', {
|
||||||
|
to: signalPhoneNumber,
|
||||||
|
message: signalMessage || `Here is the schedule for ${vip.name}`,
|
||||||
|
attachment: base64Data,
|
||||||
|
filename,
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
toast.success('Schedule sent via Signal!');
|
||||||
|
setShowSignalModal(false);
|
||||||
|
setSignalPhoneNumber('');
|
||||||
|
setSignalMessage('');
|
||||||
|
} else {
|
||||||
|
toast.error(response.data.error || 'Failed to send via Signal');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[Signal] Failed to send:', error);
|
||||||
|
toast.error(error.response?.data?.message || 'Failed to send via Signal');
|
||||||
|
} finally {
|
||||||
|
setIsSendingSignal(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -170,32 +312,32 @@ export function VIPSchedule() {
|
|||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/vips')}
|
onClick={() => navigate('/vips')}
|
||||||
className="inline-flex items-center text-sm text-gray-600 hover:text-gray-900 mb-4"
|
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground mb-4 transition-colors"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||||
Back to VIPs
|
Back to VIPs
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
<div className="bg-card rounded-lg shadow-medium border border-border p-6">
|
||||||
<div className="flex justify-between items-start mb-4">
|
<div className="flex justify-between items-start mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">{vip.name}</h1>
|
<h1 className="text-3xl font-bold text-foreground mb-2">{vip.name}</h1>
|
||||||
{vip.organization && (
|
{vip.organization && (
|
||||||
<p className="text-lg text-gray-600">{vip.organization}</p>
|
<p className="text-lg text-muted-foreground">{vip.organization}</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-sm text-gray-500">{vip.department.replace('_', ' ')}</p>
|
<p className="text-sm text-muted-foreground">{vip.department.replace('_', ' ')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleEmail}
|
onClick={() => setShowSignalModal(true)}
|
||||||
className="inline-flex items-center px-3 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
|
className="inline-flex items-center px-3 py-2 border border-input rounded-lg text-sm font-medium text-foreground bg-background hover:bg-accent transition-colors"
|
||||||
>
|
>
|
||||||
<Mail className="h-4 w-4 mr-2" />
|
<MessageCircle className="h-4 w-4 mr-2" />
|
||||||
Email Schedule
|
Send via Signal
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleExport}
|
onClick={handleExport}
|
||||||
className="inline-flex items-center px-3 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary/90"
|
className="inline-flex items-center px-3 py-2 bg-primary text-white rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||||
>
|
>
|
||||||
<Download className="h-4 w-4 mr-2" />
|
<Download className="h-4 w-4 mr-2" />
|
||||||
Export PDF
|
Export PDF
|
||||||
@@ -204,22 +346,22 @@ export function VIPSchedule() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Arrival Info */}
|
{/* Arrival Info */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-6 pt-6 border-t">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-6 pt-6 border-t border-border">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-500 mb-1">Arrival Mode</p>
|
<p className="text-sm text-muted-foreground mb-1">Arrival Mode</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{vip.arrivalMode === 'FLIGHT' ? (
|
{vip.arrivalMode === 'FLIGHT' ? (
|
||||||
<Plane className="h-5 w-5 text-blue-600" />
|
<Plane className="h-5 w-5 text-blue-600" />
|
||||||
) : (
|
) : (
|
||||||
<Car className="h-5 w-5 text-gray-600" />
|
<Car className="h-5 w-5 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
<span className="font-medium">{vip.arrivalMode.replace('_', ' ')}</span>
|
<span className="font-medium text-foreground">{vip.arrivalMode.replace('_', ' ')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{vip.expectedArrival && (
|
{vip.expectedArrival && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-500 mb-1">Expected Arrival</p>
|
<p className="text-sm text-muted-foreground mb-1">Expected Arrival</p>
|
||||||
<p className="font-medium">
|
<p className="font-medium text-foreground">
|
||||||
{new Date(vip.expectedArrival).toLocaleString('en-US', {
|
{new Date(vip.expectedArrival).toLocaleString('en-US', {
|
||||||
weekday: 'short',
|
weekday: 'short',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
@@ -234,8 +376,8 @@ export function VIPSchedule() {
|
|||||||
|
|
||||||
{/* Flight Information */}
|
{/* Flight Information */}
|
||||||
{vip.flights && vip.flights.length > 0 && (
|
{vip.flights && vip.flights.length > 0 && (
|
||||||
<div className="mt-6 pt-6 border-t">
|
<div className="mt-6 pt-6 border-t border-border">
|
||||||
<h3 className="text-lg font-semibold mb-3 flex items-center">
|
<h3 className="text-lg font-semibold text-foreground mb-3 flex items-center">
|
||||||
<Plane className="h-5 w-5 mr-2 text-blue-600" />
|
<Plane className="h-5 w-5 mr-2 text-blue-600" />
|
||||||
Flight Information
|
Flight Information
|
||||||
</h3>
|
</h3>
|
||||||
@@ -243,19 +385,19 @@ export function VIPSchedule() {
|
|||||||
{vip.flights.map((flight) => (
|
{vip.flights.map((flight) => (
|
||||||
<div
|
<div
|
||||||
key={flight.id}
|
key={flight.id}
|
||||||
className="bg-blue-50 rounded-lg p-3 flex justify-between items-center"
|
className="bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 rounded-lg p-3 flex justify-between items-center transition-colors"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-blue-900">
|
<p className="font-medium text-blue-900 dark:text-blue-100">
|
||||||
Flight {flight.flightNumber}
|
Flight {flight.flightNumber}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-blue-700">
|
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||||
{flight.departureAirport} → {flight.arrivalAirport}
|
{flight.departureAirport} → {flight.arrivalAirport}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
{flight.scheduledArrival && (
|
{flight.scheduledArrival && (
|
||||||
<p className="text-sm text-blue-900">
|
<p className="text-sm text-blue-900 dark:text-blue-100">
|
||||||
Arrives:{' '}
|
Arrives:{' '}
|
||||||
{new Date(flight.scheduledArrival).toLocaleString('en-US', {
|
{new Date(flight.scheduledArrival).toLocaleString('en-US', {
|
||||||
month: 'short',
|
month: 'short',
|
||||||
@@ -266,7 +408,7 @@ export function VIPSchedule() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{flight.status && (
|
{flight.status && (
|
||||||
<p className="text-xs text-blue-600">Status: {flight.status}</p>
|
<p className="text-xs text-blue-600 dark:text-blue-400">Status: {flight.status}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -276,52 +418,63 @@ export function VIPSchedule() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{vip.notes && (
|
{vip.notes && (
|
||||||
<div className="mt-6 pt-6 border-t">
|
<div className="mt-6 pt-6 border-t border-border">
|
||||||
<p className="text-sm text-gray-500 mb-1">Notes</p>
|
<p className="text-sm text-muted-foreground mb-1">Notes</p>
|
||||||
<p className="text-gray-700">{vip.notes}</p>
|
<p className="text-foreground">{vip.notes}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Schedule */}
|
{/* Schedule */}
|
||||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
<div className="bg-card rounded-lg shadow-medium border border-border p-6">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-6 flex items-center">
|
<h2 className="text-2xl font-bold text-foreground mb-6 flex items-center">
|
||||||
<Calendar className="h-6 w-6 mr-2 text-primary" />
|
<Calendar className="h-6 w-6 mr-2 text-primary" />
|
||||||
Schedule & Itinerary
|
Schedule & Itinerary
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{sortedEvents.length === 0 ? (
|
{sortedEvents.length === 0 ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<Calendar className="h-16 w-16 mx-auto mb-4 text-gray-300" />
|
<Calendar className="h-16 w-16 mx-auto mb-4 text-muted-foreground/30" />
|
||||||
<p className="text-gray-500">No scheduled events yet</p>
|
<p className="text-muted-foreground">No scheduled events yet</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{Object.entries(eventsByDay).map(([date, dayEvents]) => (
|
{Object.entries(eventsByDay).map(([date, dayEvents]) => (
|
||||||
<div key={date}>
|
<div key={date}>
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4 pb-2 border-b">
|
<h3 className="text-lg font-semibold text-foreground mb-4 pb-2 border-b border-border">
|
||||||
{date}
|
{date}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{dayEvents.map((event) => (
|
{dayEvents.map((event) => (
|
||||||
<div
|
<div
|
||||||
key={event.id}
|
key={event.id}
|
||||||
className="flex gap-4 p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
|
className="flex gap-4 p-4 bg-muted/30 border border-border rounded-lg hover:bg-accent transition-colors group"
|
||||||
>
|
>
|
||||||
{/* Time */}
|
{/* Time */}
|
||||||
<div className="flex-shrink-0 w-32">
|
<div className="flex-shrink-0 w-32">
|
||||||
<div className="flex items-center text-sm font-medium text-gray-900">
|
<div className="flex items-center text-sm font-medium text-foreground">
|
||||||
<Clock className="h-4 w-4 mr-1" />
|
<Clock className="h-4 w-4 mr-1" />
|
||||||
{formatTime(event.startTime)}
|
{formatTime(event.startTime)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500 ml-5">
|
<div className="text-xs text-muted-foreground ml-5">
|
||||||
to {formatTime(event.endTime)}
|
to {formatTime(event.endTime)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Event Details */}
|
{/* Event Details */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|
||||||
|
{/* Edit Button - Top Right */}
|
||||||
|
<div className="float-right ml-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditEvent(event)}
|
||||||
|
className="p-2 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-md transition-colors opacity-0 group-hover:opacity-100"
|
||||||
|
title="Edit event"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
{getEventTypeIcon(event.type)}
|
{getEventTypeIcon(event.type)}
|
||||||
<span
|
<span
|
||||||
@@ -329,12 +482,12 @@ export function VIPSchedule() {
|
|||||||
>
|
>
|
||||||
{event.type}
|
{event.type}
|
||||||
</span>
|
</span>
|
||||||
<h4 className="font-semibold text-gray-900">{event.title}</h4>
|
<h4 className="font-semibold text-foreground">{event.title}</h4>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Location */}
|
{/* Location */}
|
||||||
{event.type === 'TRANSPORT' ? (
|
{event.type === 'TRANSPORT' ? (
|
||||||
<div className="flex items-center gap-1 text-sm text-gray-600 mb-2">
|
<div className="flex items-center gap-1 text-sm text-muted-foreground mb-2">
|
||||||
<MapPin className="h-4 w-4" />
|
<MapPin className="h-4 w-4" />
|
||||||
<span>
|
<span>
|
||||||
{event.pickupLocation || 'Pickup'} →{' '}
|
{event.pickupLocation || 'Pickup'} →{' '}
|
||||||
@@ -343,7 +496,7 @@ export function VIPSchedule() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
event.location && (
|
event.location && (
|
||||||
<div className="flex items-center gap-1 text-sm text-gray-600 mb-2">
|
<div className="flex items-center gap-1 text-sm text-muted-foreground mb-2">
|
||||||
<MapPin className="h-4 w-4" />
|
<MapPin className="h-4 w-4" />
|
||||||
<span>{event.location}</span>
|
<span>{event.location}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -352,20 +505,20 @@ export function VIPSchedule() {
|
|||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
{event.description && (
|
{event.description && (
|
||||||
<p className="text-sm text-gray-600 mb-2">{event.description}</p>
|
<p className="text-sm text-muted-foreground mb-2">{event.description}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Transport Details */}
|
{/* Transport Details */}
|
||||||
{event.type === 'TRANSPORT' && (
|
{event.type === 'TRANSPORT' && (
|
||||||
<div className="flex gap-4 mt-2">
|
<div className="flex gap-4 mt-2">
|
||||||
{event.driver && (
|
{event.driver && (
|
||||||
<div className="flex items-center gap-1 text-sm text-gray-600">
|
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||||
<User className="h-4 w-4" />
|
<User className="h-4 w-4" />
|
||||||
<span>Driver: {event.driver.name}</span>
|
<span>Driver: {event.driver.name}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{event.vehicle && (
|
{event.vehicle && (
|
||||||
<div className="flex items-center gap-1 text-sm text-gray-600">
|
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||||
<Car className="h-4 w-4" />
|
<Car className="h-4 w-4" />
|
||||||
<span>
|
<span>
|
||||||
{event.vehicle.name} ({event.vehicle.type.replace('_', ' ')})
|
{event.vehicle.name} ({event.vehicle.type.replace('_', ' ')})
|
||||||
@@ -380,12 +533,12 @@ export function VIPSchedule() {
|
|||||||
<span
|
<span
|
||||||
className={`text-xs px-2 py-1 rounded ${
|
className={`text-xs px-2 py-1 rounded ${
|
||||||
event.status === 'COMPLETED'
|
event.status === 'COMPLETED'
|
||||||
? 'bg-green-100 text-green-800'
|
? 'bg-green-100 text-green-800 dark:bg-green-950/30 dark:text-green-300'
|
||||||
: event.status === 'IN_PROGRESS'
|
: event.status === 'IN_PROGRESS'
|
||||||
? 'bg-blue-100 text-blue-800'
|
? 'bg-blue-100 text-blue-800 dark:bg-blue-950/30 dark:text-blue-300'
|
||||||
: event.status === 'CANCELLED'
|
: event.status === 'CANCELLED'
|
||||||
? 'bg-red-100 text-red-800'
|
? 'bg-red-100 text-red-800 dark:bg-red-950/30 dark:text-red-300'
|
||||||
: 'bg-gray-100 text-gray-800'
|
: 'bg-muted text-muted-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{event.status}
|
{event.status}
|
||||||
@@ -400,6 +553,189 @@ export function VIPSchedule() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Event Modal - Uses EventForm component */}
|
||||||
|
{editingEvent && (
|
||||||
|
<EventForm
|
||||||
|
event={editingEvent}
|
||||||
|
onSubmit={handleUpdateEvent}
|
||||||
|
onCancel={() => setEditingEvent(null)}
|
||||||
|
isSubmitting={updateEventMutation.isPending}
|
||||||
|
extraActions={
|
||||||
|
<div className="border-t border-border pt-4 mt-4">
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">Event Actions</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancelEvent}
|
||||||
|
disabled={cancelEventMutation.isPending || editingEvent.status === 'CANCELLED'}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-yellow-600 text-white rounded-md hover:bg-yellow-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
{cancelEventMutation.isPending ? 'Cancelling...' : 'Cancel Event'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowDeleteConfirm(editingEvent)}
|
||||||
|
disabled={deleteEventMutation.isPending}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Delete Event
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{editingEvent.status === 'CANCELLED' && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">This event is already cancelled.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
{showDeleteConfirm && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-[60]">
|
||||||
|
<div className="bg-card rounded-lg shadow-xl w-full max-w-md mx-4">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-red-100 flex items-center justify-center">
|
||||||
|
<AlertTriangle className="h-6 w-6 text-red-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||||
|
Delete Event?
|
||||||
|
</h3>
|
||||||
|
<p className="text-base text-muted-foreground mb-2">
|
||||||
|
Are you sure you want to delete this event?
|
||||||
|
</p>
|
||||||
|
<div className="bg-muted/50 rounded-md p-3 mb-4">
|
||||||
|
<p className="font-medium text-foreground">{showDeleteConfirm.title}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{formatTime(showDeleteConfirm.startTime)} - {formatTime(showDeleteConfirm.endTime)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
This will free up any assigned driver and vehicle. This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteConfirm(null)}
|
||||||
|
className="flex-1 px-4 py-3 border border-input rounded-md text-base font-medium text-foreground hover:bg-accent"
|
||||||
|
>
|
||||||
|
Keep Event
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDeleteEvent}
|
||||||
|
disabled={deleteEventMutation.isPending}
|
||||||
|
className="flex-1 px-4 py-3 bg-red-600 text-white rounded-md text-base font-medium hover:bg-red-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleteEventMutation.isPending ? 'Deleting...' : 'Delete Event'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Signal Send Modal */}
|
||||||
|
{showSignalModal && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||||
|
<MessageCircle className="h-5 w-5" />
|
||||||
|
Send Schedule via Signal
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowSignalModal(false);
|
||||||
|
setSignalPhoneNumber('');
|
||||||
|
setSignalMessage('');
|
||||||
|
}}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Send {vip?.name}'s itinerary PDF directly to a phone via Signal.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
|
Phone Number *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={signalPhoneNumber}
|
||||||
|
onChange={(e) => setSignalPhoneNumber(e.target.value)}
|
||||||
|
placeholder="+1 (555) 123-4567"
|
||||||
|
className="w-full px-3 py-2 border border-input rounded-lg bg-background text-foreground placeholder:text-muted-foreground focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Include country code (e.g., +1 for US)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
|
Message (optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={signalMessage}
|
||||||
|
onChange={(e) => setSignalMessage(e.target.value)}
|
||||||
|
placeholder={`Here is the schedule for ${vip?.name}`}
|
||||||
|
rows={2}
|
||||||
|
className="w-full px-3 py-2 border border-input rounded-lg bg-background text-foreground placeholder:text-muted-foreground focus:ring-2 focus:ring-primary focus:border-transparent resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-muted/50 p-3 rounded-lg">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
<strong>Attachment:</strong> {vip?.name?.replace(/\s+/g, '_')}_Schedule_[timestamp].pdf
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 p-4 border-t border-border">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowSignalModal(false);
|
||||||
|
setSignalPhoneNumber('');
|
||||||
|
setSignalMessage('');
|
||||||
|
}}
|
||||||
|
className="flex-1 px-4 py-2 border border-input rounded-lg text-foreground hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSendViaSignal}
|
||||||
|
disabled={!signalPhoneNumber.trim() || isSendingSignal}
|
||||||
|
className="flex-1 px-4 py-2 bg-primary text-white rounded-lg font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{isSendingSignal ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Sending...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
Send PDF
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,10 +190,10 @@ export function VehicleList() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Vehicle Management</h1>
|
<h1 className="text-3xl font-bold text-foreground">Vehicle Management</h1>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowForm(!showForm)}
|
onClick={() => setShowForm(!showForm)}
|
||||||
className="inline-flex items-center px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90"
|
className="inline-flex items-center px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
|
||||||
>
|
>
|
||||||
<Plus className="h-5 w-5 mr-2" />
|
<Plus className="h-5 w-5 mr-2" />
|
||||||
{showForm ? 'Cancel' : 'Add Vehicle'}
|
{showForm ? 'Cancel' : 'Add Vehicle'}
|
||||||
@@ -202,54 +202,54 @@ export function VehicleList() {
|
|||||||
|
|
||||||
{/* Stats Summary */}
|
{/* Stats Summary */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||||
<div className="bg-white p-4 rounded-lg shadow">
|
<div className="bg-card border border-border p-4 rounded-lg shadow-soft">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600">Total Vehicles</p>
|
<p className="text-sm text-muted-foreground">Total Vehicles</p>
|
||||||
<p className="text-2xl font-bold text-gray-900">{vehicles?.length || 0}</p>
|
<p className="text-2xl font-bold text-foreground">{vehicles?.length || 0}</p>
|
||||||
</div>
|
</div>
|
||||||
<Car className="h-8 w-8 text-gray-400" />
|
<Car className="h-8 w-8 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white p-4 rounded-lg shadow">
|
<div className="bg-card border border-border p-4 rounded-lg shadow-soft">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600">Available</p>
|
<p className="text-sm text-muted-foreground">Available</p>
|
||||||
<p className="text-2xl font-bold text-green-600">{availableVehicles.length}</p>
|
<p className="text-2xl font-bold text-green-600 dark:text-green-500">{availableVehicles.length}</p>
|
||||||
</div>
|
</div>
|
||||||
<CheckCircle className="h-8 w-8 text-green-400" />
|
<CheckCircle className="h-8 w-8 text-green-500 dark:text-green-600" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white p-4 rounded-lg shadow">
|
<div className="bg-card border border-border p-4 rounded-lg shadow-soft">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600">In Use</p>
|
<p className="text-sm text-muted-foreground">In Use</p>
|
||||||
<p className="text-2xl font-bold text-blue-600">{inUseVehicles.length}</p>
|
<p className="text-2xl font-bold text-blue-600 dark:text-blue-500">{inUseVehicles.length}</p>
|
||||||
</div>
|
</div>
|
||||||
<Car className="h-8 w-8 text-blue-400" />
|
<Car className="h-8 w-8 text-blue-500 dark:text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white p-4 rounded-lg shadow">
|
<div className="bg-card border border-border p-4 rounded-lg shadow-soft">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-600">Maintenance</p>
|
<p className="text-sm text-muted-foreground">Maintenance</p>
|
||||||
<p className="text-2xl font-bold text-orange-600">{maintenanceVehicles.length}</p>
|
<p className="text-2xl font-bold text-orange-600 dark:text-orange-500">{maintenanceVehicles.length}</p>
|
||||||
</div>
|
</div>
|
||||||
<Wrench className="h-8 w-8 text-orange-400" />
|
<Wrench className="h-8 w-8 text-orange-500 dark:text-orange-600" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add/Edit Form */}
|
{/* Add/Edit Form */}
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<div className="bg-white p-6 rounded-lg shadow mb-6">
|
<div className="bg-card border border-border p-6 rounded-lg shadow-medium mb-6">
|
||||||
<h2 className="text-xl font-semibold mb-4">
|
<h2 className="text-xl font-semibold text-foreground mb-4">
|
||||||
{editingVehicle ? 'Edit Vehicle' : 'Add New Vehicle'}
|
{editingVehicle ? 'Edit Vehicle' : 'Add New Vehicle'}
|
||||||
</h2>
|
</h2>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
Vehicle Name *
|
Vehicle Name *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -258,18 +258,18 @@ export function VehicleList() {
|
|||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
placeholder="e.g., Blue Van, Suburban #3"
|
placeholder="e.g., Blue Van, Suburban #3"
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-primary focus:border-primary"
|
className="w-full px-3 py-2 border border-input rounded-lg focus:ring-primary focus:border-primary bg-background text-foreground transition-colors"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
Vehicle Type *
|
Vehicle Type *
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
required
|
required
|
||||||
value={formData.type}
|
value={formData.type}
|
||||||
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-primary focus:border-primary"
|
className="w-full px-3 py-2 border border-input rounded-lg focus:ring-primary focus:border-primary bg-background text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
{VEHICLE_TYPES.map((type) => (
|
{VEHICLE_TYPES.map((type) => (
|
||||||
<option key={type.value} value={type.value}>
|
<option key={type.value} value={type.value}>
|
||||||
@@ -279,7 +279,7 @@ export function VehicleList() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
License Plate
|
License Plate
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -287,11 +287,11 @@ export function VehicleList() {
|
|||||||
value={formData.licensePlate}
|
value={formData.licensePlate}
|
||||||
onChange={(e) => setFormData({ ...formData, licensePlate: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, licensePlate: e.target.value })}
|
||||||
placeholder="ABC-1234"
|
placeholder="ABC-1234"
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-primary focus:border-primary"
|
className="w-full px-3 py-2 border border-input rounded-lg focus:ring-primary focus:border-primary bg-background text-foreground transition-colors"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
Seat Capacity *
|
Seat Capacity *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -301,18 +301,18 @@ export function VehicleList() {
|
|||||||
max="60"
|
max="60"
|
||||||
value={formData.seatCapacity}
|
value={formData.seatCapacity}
|
||||||
onChange={(e) => setFormData({ ...formData, seatCapacity: parseInt(e.target.value) })}
|
onChange={(e) => setFormData({ ...formData, seatCapacity: parseInt(e.target.value) })}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-primary focus:border-primary"
|
className="w-full px-3 py-2 border border-input rounded-lg focus:ring-primary focus:border-primary bg-background text-foreground transition-colors"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
Status *
|
Status *
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
required
|
required
|
||||||
value={formData.status}
|
value={formData.status}
|
||||||
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-primary focus:border-primary"
|
className="w-full px-3 py-2 border border-input rounded-lg focus:ring-primary focus:border-primary bg-background text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
{VEHICLE_STATUS.map((status) => (
|
{VEHICLE_STATUS.map((status) => (
|
||||||
<option key={status.value} value={status.value}>
|
<option key={status.value} value={status.value}>
|
||||||
@@ -322,7 +322,7 @@ export function VehicleList() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-foreground mb-1">
|
||||||
Notes
|
Notes
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -330,7 +330,7 @@ export function VehicleList() {
|
|||||||
value={formData.notes}
|
value={formData.notes}
|
||||||
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
||||||
placeholder="Optional notes"
|
placeholder="Optional notes"
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-primary focus:border-primary"
|
className="w-full px-3 py-2 border border-input rounded-lg focus:ring-primary focus:border-primary bg-background text-foreground transition-colors"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -338,14 +338,14 @@ export function VehicleList() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={createMutation.isPending || updateMutation.isPending}
|
disabled={createMutation.isPending || updateMutation.isPending}
|
||||||
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 disabled:opacity-50"
|
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||||
>
|
>
|
||||||
{editingVehicle ? 'Update Vehicle' : 'Create Vehicle'}
|
{editingVehicle ? 'Update Vehicle' : 'Create Vehicle'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={resetForm}
|
onClick={resetForm}
|
||||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
|
className="px-4 py-2 bg-muted text-foreground rounded-lg hover:bg-muted/80 transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -355,77 +355,77 @@ export function VehicleList() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Vehicle List */}
|
{/* Vehicle List */}
|
||||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
<div className="bg-card border border-border shadow-medium rounded-lg overflow-hidden">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-border">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-muted/30">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||||
Vehicle
|
Vehicle
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||||
Type
|
Type
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||||
License Plate
|
License Plate
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||||
Seats
|
Seats
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||||
Status
|
Status
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||||
Current Driver
|
Current Driver
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||||
Upcoming Trips
|
Upcoming Trips
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase">
|
||||||
Actions
|
Actions
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-card divide-y divide-border">
|
||||||
{vehicles?.map((vehicle) => (
|
{vehicles?.map((vehicle) => (
|
||||||
<tr key={vehicle.id}>
|
<tr key={vehicle.id} className="hover:bg-accent transition-colors">
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{getStatusIcon(vehicle.status)}
|
{getStatusIcon(vehicle.status)}
|
||||||
<span className="ml-2 text-sm font-medium text-gray-900">
|
<span className="ml-2 text-sm font-medium text-foreground">
|
||||||
{vehicle.name}
|
{vehicle.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||||
{vehicle.type.replace('_', ' ')}
|
{vehicle.type.replace('_', ' ')}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||||
{vehicle.licensePlate || '-'}
|
{vehicle.licensePlate || '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||||
{vehicle.seatCapacity}
|
{vehicle.seatCapacity}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
{getStatusBadge(vehicle.status)}
|
{getStatusBadge(vehicle.status)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||||
{vehicle.currentDriver?.name || '-'}
|
{vehicle.currentDriver?.name || '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||||
{vehicle.events?.length || 0}
|
{vehicle.events?.length || 0}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEdit(vehicle)}
|
onClick={() => handleEdit(vehicle)}
|
||||||
className="text-blue-600 hover:text-blue-800"
|
className="text-blue-600 hover:text-blue-800 dark:text-blue-500 dark:hover:text-blue-400 transition-colors"
|
||||||
title="Edit vehicle"
|
title="Edit vehicle"
|
||||||
>
|
>
|
||||||
<Edit2 className="h-4 w-4" />
|
<Edit2 className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(vehicle.id, vehicle.name)}
|
onClick={() => handleDelete(vehicle.id, vehicle.name)}
|
||||||
className="text-red-600 hover:text-red-800"
|
className="text-red-600 hover:text-red-800 dark:text-red-500 dark:hover:text-red-400 transition-colors"
|
||||||
title="Delete vehicle"
|
title="Delete vehicle"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ export function VIPList() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
||||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900">VIPs</h1>
|
<h1 className="text-2xl sm:text-3xl font-bold text-foreground">VIPs</h1>
|
||||||
<button
|
<button
|
||||||
disabled
|
disabled
|
||||||
className="w-full sm:w-auto inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary/50 cursor-not-allowed"
|
className="w-full sm:w-auto inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary/50 cursor-not-allowed"
|
||||||
@@ -248,10 +248,10 @@ export function VIPList() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
||||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900">VIPs</h1>
|
<h1 className="text-2xl sm:text-3xl font-bold text-foreground">VIPs</h1>
|
||||||
<button
|
<button
|
||||||
onClick={handleAdd}
|
onClick={handleAdd}
|
||||||
className="w-full sm:w-auto inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary hover:bg-primary/90"
|
className="w-full sm:w-auto inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary hover:bg-primary/90 transition-colors"
|
||||||
style={{ minHeight: '44px' }}
|
style={{ minHeight: '44px' }}
|
||||||
>
|
>
|
||||||
<Plus className="h-5 w-5 mr-2" />
|
<Plus className="h-5 w-5 mr-2" />
|
||||||
@@ -260,17 +260,17 @@ export function VIPList() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search and Filter Section */}
|
{/* Search and Filter Section */}
|
||||||
<div className="bg-white shadow rounded-lg p-4 mb-6">
|
<div className="bg-card shadow-soft border border-border rounded-lg p-4 mb-6">
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400 pointer-events-none" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-muted-foreground pointer-events-none" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by name..."
|
placeholder="Search by name..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-md focus:ring-primary focus:border-primary text-base"
|
className="w-full pl-10 pr-4 py-2.5 border border-input rounded-md focus:ring-ring focus:border-ring text-base bg-background text-foreground transition-colors"
|
||||||
style={{ minHeight: '44px' }}
|
style={{ minHeight: '44px' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -278,7 +278,7 @@ export function VIPList() {
|
|||||||
{/* Filter Button */}
|
{/* Filter Button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setFilterModalOpen(true)}
|
onClick={() => setFilterModalOpen(true)}
|
||||||
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50 font-medium"
|
className="inline-flex items-center px-4 py-2 border border-input rounded-md text-foreground bg-background hover:bg-accent font-medium transition-colors"
|
||||||
style={{ minHeight: '44px' }}
|
style={{ minHeight: '44px' }}
|
||||||
>
|
>
|
||||||
<Filter className="h-5 w-5 mr-2" />
|
<Filter className="h-5 w-5 mr-2" />
|
||||||
@@ -293,8 +293,8 @@ export function VIPList() {
|
|||||||
|
|
||||||
{/* Active Filter Chips */}
|
{/* Active Filter Chips */}
|
||||||
{(selectedDepartments.length > 0 || selectedArrivalModes.length > 0) && (
|
{(selectedDepartments.length > 0 || selectedArrivalModes.length > 0) && (
|
||||||
<div className="flex flex-wrap gap-2 mt-3 pt-3 border-t border-gray-200">
|
<div className="flex flex-wrap gap-2 mt-3 pt-3 border-t border-border">
|
||||||
<span className="text-sm text-gray-600 py-1.5">Active filters:</span>
|
<span className="text-sm text-muted-foreground py-1.5">Active filters:</span>
|
||||||
{selectedDepartments.map((dept) => (
|
{selectedDepartments.map((dept) => (
|
||||||
<FilterChip
|
<FilterChip
|
||||||
key={dept}
|
key={dept}
|
||||||
@@ -313,15 +313,15 @@ export function VIPList() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Results count */}
|
{/* Results count */}
|
||||||
<div className="flex items-center justify-between mt-3 pt-3 border-t border-gray-200">
|
<div className="flex items-center justify-between mt-3 pt-3 border-t border-border">
|
||||||
<div className="text-sm text-gray-600">
|
<div className="text-sm text-muted-foreground">
|
||||||
Showing <span className="font-medium">{filteredVIPs.length}</span> of <span className="font-medium">{vips?.length || 0}</span> VIPs
|
Showing <span className="font-medium text-foreground">{filteredVIPs.length}</span> of <span className="font-medium text-foreground">{vips?.length || 0}</span> VIPs
|
||||||
{debouncedSearchTerm !== searchTerm && <span className="ml-2 text-gray-400">(searching...)</span>}
|
{debouncedSearchTerm !== searchTerm && <span className="ml-2 text-muted-foreground/70">(searching...)</span>}
|
||||||
</div>
|
</div>
|
||||||
{(searchTerm || selectedDepartments.length > 0 || selectedArrivalModes.length > 0) && (
|
{(searchTerm || selectedDepartments.length > 0 || selectedArrivalModes.length > 0) && (
|
||||||
<button
|
<button
|
||||||
onClick={handleClearFilters}
|
onClick={handleClearFilters}
|
||||||
className="inline-flex items-center px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-md"
|
className="inline-flex items-center px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:bg-accent rounded-md transition-colors"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4 mr-1" />
|
<X className="h-4 w-4 mr-1" />
|
||||||
Clear All
|
Clear All
|
||||||
@@ -331,12 +331,12 @@ export function VIPList() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop Table View - shows on large screens */}
|
{/* Desktop Table View - shows on large screens */}
|
||||||
<div className="hidden lg:block bg-white shadow rounded-lg overflow-hidden">
|
<div className="hidden lg:block bg-card shadow-soft border border-border rounded-lg overflow-hidden">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-border">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-muted/30">
|
||||||
<tr>
|
<tr>
|
||||||
<th
|
<th
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 transition-colors"
|
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-accent transition-colors"
|
||||||
onClick={() => handleSort('name')}
|
onClick={() => handleSort('name')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -346,7 +346,7 @@ export function VIPList() {
|
|||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 transition-colors"
|
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-accent transition-colors"
|
||||||
onClick={() => handleSort('organization')}
|
onClick={() => handleSort('organization')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -356,7 +356,7 @@ export function VIPList() {
|
|||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 transition-colors"
|
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-accent transition-colors"
|
||||||
onClick={() => handleSort('department')}
|
onClick={() => handleSort('department')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -366,7 +366,7 @@ export function VIPList() {
|
|||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 transition-colors"
|
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-accent transition-colors"
|
||||||
onClick={() => handleSort('arrivalMode')}
|
onClick={() => handleSort('arrivalMode')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -375,31 +375,31 @@ export function VIPList() {
|
|||||||
{sortColumn === 'arrivalMode' && <span className="text-primary">{sortDirection === 'asc' ? '↑' : '↓'}</span>}
|
{sortColumn === 'arrivalMode' && <span className="text-primary">{sortDirection === 'asc' ? '↑' : '↓'}</span>}
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
Actions
|
Actions
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-card divide-y divide-border">
|
||||||
{filteredVIPs.map((vip) => (
|
{filteredVIPs.map((vip) => (
|
||||||
<tr key={vip.id} className="hover:bg-gray-50 transition-colors">
|
<tr key={vip.id} className="hover:bg-accent transition-colors">
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-foreground">
|
||||||
{vip.name}
|
{vip.name}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||||
{vip.organization || '-'}
|
{vip.organization || '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||||
{vip.department}
|
{vip.department}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||||
{vip.arrivalMode}
|
{vip.arrivalMode}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/vips/${vip.id}/schedule`)}
|
onClick={() => navigate(`/vips/${vip.id}/schedule`)}
|
||||||
className="inline-flex items-center px-3 py-1 text-blue-600 hover:text-blue-800"
|
className="inline-flex items-center px-3 py-1 text-blue-600 hover:text-blue-800 transition-colors"
|
||||||
style={{ minHeight: '36px' }}
|
style={{ minHeight: '36px' }}
|
||||||
title="View Schedule"
|
title="View Schedule"
|
||||||
>
|
>
|
||||||
@@ -408,7 +408,7 @@ export function VIPList() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEdit(vip)}
|
onClick={() => handleEdit(vip)}
|
||||||
className="inline-flex items-center px-3 py-1 text-primary hover:text-primary/80"
|
className="inline-flex items-center px-3 py-1 text-primary hover:text-primary/80 transition-colors"
|
||||||
style={{ minHeight: '36px' }}
|
style={{ minHeight: '36px' }}
|
||||||
>
|
>
|
||||||
<Edit className="h-4 w-4 mr-1" />
|
<Edit className="h-4 w-4 mr-1" />
|
||||||
@@ -416,7 +416,7 @@ export function VIPList() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(vip.id, vip.name)}
|
onClick={() => handleDelete(vip.id, vip.name)}
|
||||||
className="inline-flex items-center px-3 py-1 text-red-600 hover:text-red-800"
|
className="inline-flex items-center px-3 py-1 text-red-600 hover:text-red-800 transition-colors"
|
||||||
style={{ minHeight: '36px' }}
|
style={{ minHeight: '36px' }}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 mr-1" />
|
<Trash2 className="h-4 w-4 mr-1" />
|
||||||
@@ -433,31 +433,31 @@ export function VIPList() {
|
|||||||
{/* Mobile/Tablet Card View - shows on small and medium screens */}
|
{/* Mobile/Tablet Card View - shows on small and medium screens */}
|
||||||
<div className="lg:hidden space-y-4">
|
<div className="lg:hidden space-y-4">
|
||||||
{filteredVIPs.map((vip) => (
|
{filteredVIPs.map((vip) => (
|
||||||
<div key={vip.id} className="bg-white shadow rounded-lg p-4">
|
<div key={vip.id} className="bg-card shadow-soft border border-border rounded-lg p-4">
|
||||||
<div className="flex justify-between items-start mb-3">
|
<div className="flex justify-between items-start mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-gray-900">{vip.name}</h3>
|
<h3 className="text-lg font-semibold text-foreground">{vip.name}</h3>
|
||||||
{vip.organization && (
|
{vip.organization && (
|
||||||
<p className="text-sm text-gray-600 mt-1">{vip.organization}</p>
|
<p className="text-sm text-muted-foreground mt-1">{vip.organization}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider">Department</p>
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Department</p>
|
||||||
<p className="text-sm text-gray-900 mt-1">{vip.department}</p>
|
<p className="text-sm text-foreground mt-1">{vip.department}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider">Arrival Mode</p>
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Arrival Mode</p>
|
||||||
<p className="text-sm text-gray-900 mt-1">{vip.arrivalMode}</p>
|
<p className="text-sm text-foreground mt-1">{vip.arrivalMode}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-2 pt-3 border-t border-gray-200">
|
<div className="flex flex-col sm:flex-row gap-2 pt-3 border-t border-border">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/vips/${vip.id}/schedule`)}
|
onClick={() => navigate(`/vips/${vip.id}/schedule`)}
|
||||||
className="flex-1 inline-flex items-center justify-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-blue-600 bg-white hover:bg-blue-50"
|
className="flex-1 inline-flex items-center justify-center px-4 py-2 border border-input text-sm font-medium rounded-md text-blue-600 bg-background hover:bg-blue-50 transition-colors"
|
||||||
style={{ minHeight: '44px' }}
|
style={{ minHeight: '44px' }}
|
||||||
>
|
>
|
||||||
<Calendar className="h-5 w-5 mr-2" />
|
<Calendar className="h-5 w-5 mr-2" />
|
||||||
@@ -465,7 +465,7 @@ export function VIPList() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEdit(vip)}
|
onClick={() => handleEdit(vip)}
|
||||||
className="flex-1 inline-flex items-center justify-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-primary bg-white hover:bg-gray-50"
|
className="flex-1 inline-flex items-center justify-center px-4 py-2 border border-input text-sm font-medium rounded-md text-primary bg-background hover:bg-accent transition-colors"
|
||||||
style={{ minHeight: '44px' }}
|
style={{ minHeight: '44px' }}
|
||||||
>
|
>
|
||||||
<Edit className="h-5 w-5 mr-2" />
|
<Edit className="h-5 w-5 mr-2" />
|
||||||
@@ -473,7 +473,7 @@ export function VIPList() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(vip.id, vip.name)}
|
onClick={() => handleDelete(vip.id, vip.name)}
|
||||||
className="flex-1 inline-flex items-center justify-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-red-600 bg-white hover:bg-red-50"
|
className="flex-1 inline-flex items-center justify-center px-4 py-2 border border-input text-sm font-medium rounded-md text-red-600 bg-background hover:bg-red-50 transition-colors"
|
||||||
style={{ minHeight: '44px' }}
|
style={{ minHeight: '44px' }}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-5 w-5 mr-2" />
|
<Trash2 className="h-5 w-5 mr-2" />
|
||||||
|
|||||||
74
frontend/src/types/settings.ts
Normal file
74
frontend/src/types/settings.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
export enum PageSize {
|
||||||
|
LETTER = 'LETTER',
|
||||||
|
A4 = 'A4',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PdfSettings {
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
// Branding
|
||||||
|
organizationName: string;
|
||||||
|
logoUrl: string | null;
|
||||||
|
accentColor: string;
|
||||||
|
tagline: string | null;
|
||||||
|
|
||||||
|
// Contact Info
|
||||||
|
contactEmail: string;
|
||||||
|
contactPhone: string;
|
||||||
|
secondaryContactName: string | null;
|
||||||
|
secondaryContactPhone: string | null;
|
||||||
|
contactLabel: string;
|
||||||
|
|
||||||
|
// Document Options
|
||||||
|
showDraftWatermark: boolean;
|
||||||
|
showConfidentialWatermark: boolean;
|
||||||
|
showTimestamp: boolean;
|
||||||
|
showAppUrl: boolean;
|
||||||
|
pageSize: PageSize;
|
||||||
|
|
||||||
|
// Content Toggles
|
||||||
|
showFlightInfo: boolean;
|
||||||
|
showDriverNames: boolean;
|
||||||
|
showVehicleNames: boolean;
|
||||||
|
showVipNotes: boolean;
|
||||||
|
showEventDescriptions: boolean;
|
||||||
|
|
||||||
|
// Custom Text
|
||||||
|
headerMessage: string | null;
|
||||||
|
footerMessage: string | null;
|
||||||
|
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePdfSettingsDto {
|
||||||
|
// Branding
|
||||||
|
organizationName?: string;
|
||||||
|
accentColor?: string;
|
||||||
|
tagline?: string;
|
||||||
|
|
||||||
|
// Contact Info
|
||||||
|
contactEmail?: string;
|
||||||
|
contactPhone?: string;
|
||||||
|
secondaryContactName?: string;
|
||||||
|
secondaryContactPhone?: string;
|
||||||
|
contactLabel?: string;
|
||||||
|
|
||||||
|
// Document Options
|
||||||
|
showDraftWatermark?: boolean;
|
||||||
|
showConfidentialWatermark?: boolean;
|
||||||
|
showTimestamp?: boolean;
|
||||||
|
showAppUrl?: boolean;
|
||||||
|
pageSize?: PageSize;
|
||||||
|
|
||||||
|
// Content Toggles
|
||||||
|
showFlightInfo?: boolean;
|
||||||
|
showDriverNames?: boolean;
|
||||||
|
showVehicleNames?: boolean;
|
||||||
|
showVipNotes?: boolean;
|
||||||
|
showEventDescriptions?: boolean;
|
||||||
|
|
||||||
|
// Custom Text
|
||||||
|
headerMessage?: string;
|
||||||
|
footerMessage?: string;
|
||||||
|
}
|
||||||
3
frontend/src/vite-env.d.ts
vendored
3
frontend/src/vite-env.d.ts
vendored
@@ -5,6 +5,9 @@ interface ImportMetaEnv {
|
|||||||
readonly VITE_AUTH0_DOMAIN: string;
|
readonly VITE_AUTH0_DOMAIN: string;
|
||||||
readonly VITE_AUTH0_CLIENT_ID: string;
|
readonly VITE_AUTH0_CLIENT_ID: string;
|
||||||
readonly VITE_AUTH0_AUDIENCE: string;
|
readonly VITE_AUTH0_AUDIENCE: string;
|
||||||
|
readonly VITE_CONTACT_EMAIL?: string;
|
||||||
|
readonly VITE_CONTACT_PHONE?: string;
|
||||||
|
readonly VITE_ORGANIZATION_NAME?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
@@ -41,6 +41,23 @@ export default {
|
|||||||
DEFAULT: 'hsl(var(--card))',
|
DEFAULT: 'hsl(var(--card))',
|
||||||
foreground: 'hsl(var(--card-foreground))',
|
foreground: 'hsl(var(--card-foreground))',
|
||||||
},
|
},
|
||||||
|
success: {
|
||||||
|
DEFAULT: 'hsl(var(--success))',
|
||||||
|
foreground: 'hsl(var(--success-foreground))',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
DEFAULT: 'hsl(var(--warning))',
|
||||||
|
foreground: 'hsl(var(--warning-foreground))',
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
DEFAULT: 'hsl(var(--info))',
|
||||||
|
foreground: 'hsl(var(--info-foreground))',
|
||||||
|
},
|
||||||
|
surface: {
|
||||||
|
1: 'hsl(var(--surface-1))',
|
||||||
|
2: 'hsl(var(--surface-2))',
|
||||||
|
3: 'hsl(var(--surface-3))',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
lg: 'var(--radius)',
|
lg: 'var(--radius)',
|
||||||
|
|||||||
Reference in New Issue
Block a user