public-ready-init
This commit is contained in:
commit
a5f846474c
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
*.env.local
|
||||||
|
|
||||||
|
# SSL certificates
|
||||||
|
certs/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Google service account keys
|
||||||
|
google-sa.json
|
||||||
|
*-sa.json
|
||||||
|
*.json.key
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker-compose.override.yml
|
||||||
294
README.md
Normal file
294
README.md
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
# Nexus 2
|
||||||
|
|
||||||
|
A field service management and CRM platform built with Django REST Framework and SvelteKit. Designed for managing customers, accounts, service schedules, projects, invoices, and team coordination with integrated Google Workspace services.
|
||||||
|
|
||||||
|
## Improvements Over Nexus 1
|
||||||
|
|
||||||
|
Nexus 2 is a complete rewrite that addresses limitations in the original Nexus platform:
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
- **Modern Frontend Stack**: Migrated from React + Webpack to SvelteKit + Vite for faster builds, better developer experience, and SSR capabilities
|
||||||
|
- **TypeScript Throughout**: Full TypeScript support in the frontend with typed API interfaces
|
||||||
|
- **Cleaner Project Structure**: Separated backend and frontend into distinct directories for better organization
|
||||||
|
- **Simplified Deployment**: Removed Redis dependency - no longer required for basic operation
|
||||||
|
|
||||||
|
### Data Model Enhancements
|
||||||
|
- **Customer Entity**: Added a Customer model to group related Accounts under a single business entity with centralized billing information
|
||||||
|
- **Revenue Tracking**: Track revenue per account with date ranges for historical billing data
|
||||||
|
- **Labor Costs**: Track labor costs on projects for profitability analysis
|
||||||
|
- **Flexible Scheduling**: New Schedule model defines weekly service patterns with exceptions, enabling automatic service generation
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
- **Invoice Management**: Full invoicing system with draft/sent/paid/overdue status tracking and payment recording
|
||||||
|
- **Service Auto-Generation**: Generate monthly service records automatically from defined schedules
|
||||||
|
- **Role-Based Profiles**: Team members have roles (Admin, Team Leader, Team Member) for access control
|
||||||
|
- **Dashboard**: Centralized overview of operations
|
||||||
|
|
||||||
|
### API Improvements
|
||||||
|
- **RESTful CRUD**: Consistent REST API patterns for all entities (vs. custom action-based endpoints in Nexus 1)
|
||||||
|
- **Pagination**: Built-in pagination with configurable page sizes
|
||||||
|
- **Better Filtering**: Query parameters for filtering lists by related entities
|
||||||
|
|
||||||
|
### Simplified Integrations
|
||||||
|
- **Focused Google Integration**: Calendar and Drive/Sheets only (removed Gmail dependency)
|
||||||
|
- **Optional Google Features**: Application works without Google integration configured
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Customer Management**: Track customers with contact and billing information
|
||||||
|
- **Account Management**: Manage service accounts with locations, contacts, and revenue tracking
|
||||||
|
- **Service Scheduling**: Define weekly service schedules with exceptions and auto-generate service records
|
||||||
|
- **Service Tracking**: Track service visits with status, team assignments, and notes
|
||||||
|
- **Project Management**: Plan and track projects with team assignments and labor costs
|
||||||
|
- **Invoice Management**: Generate and track invoices with payment status
|
||||||
|
- **Team Profiles**: Manage team members with roles (Admin, Team Leader, Team Member)
|
||||||
|
- **Reports**: Track team member activity across services and projects
|
||||||
|
- **Punchlist API**: Backend API for project punchlists with Google Sheets/PDF export (frontend UI requires customization)
|
||||||
|
- **Google Calendar Integration**: Create calendar events for services and projects
|
||||||
|
- **JWT Authentication**: Secure API access with token refresh
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- Python 3.12
|
||||||
|
- Django 5.x with Django REST Framework
|
||||||
|
- PostgreSQL
|
||||||
|
- Google APIs (Calendar, Drive, Sheets)
|
||||||
|
- JWT authentication via djangorestframework-simplejwt
|
||||||
|
- WhiteNoise for static file serving
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- SvelteKit with TypeScript
|
||||||
|
- Vite
|
||||||
|
- Axios for API calls
|
||||||
|
- SSR-ready
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Python 3.12+
|
||||||
|
- Node.js 22+
|
||||||
|
- PostgreSQL
|
||||||
|
- Google Workspace account with domain-wide delegation (for calendar/drive features)
|
||||||
|
|
||||||
|
## Google Workspace Setup
|
||||||
|
|
||||||
|
This application can integrate with Google Workspace for calendar events and punchlist exports:
|
||||||
|
|
||||||
|
1. Create a service account in Google Cloud Console
|
||||||
|
2. Enable the following APIs:
|
||||||
|
- Google Calendar API
|
||||||
|
- Google Drive API
|
||||||
|
- Google Sheets API
|
||||||
|
3. For domain-wide delegation (calendar events on behalf of users):
|
||||||
|
- Enable domain-wide delegation for the service account
|
||||||
|
- Grant the following scopes in Google Workspace Admin:
|
||||||
|
- `https://www.googleapis.com/auth/calendar`
|
||||||
|
- `https://www.googleapis.com/auth/drive`
|
||||||
|
- `https://www.googleapis.com/auth/spreadsheets`
|
||||||
|
4. Download the service account JSON key
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
1. Copy the environment example:
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Configure the required environment variables in `.env`:
|
||||||
|
- `SECRET_KEY`: Django secret key
|
||||||
|
- `DB_*`: PostgreSQL connection settings
|
||||||
|
- `GOOGLE_*`: Google API configuration (optional)
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
The frontend uses environment variables set in docker-compose or `.env`:
|
||||||
|
- `PUBLIC_API_URL`: Backend API URL (e.g., `http://localhost:8000/api`)
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# Create virtual environment
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate # or `venv\Scripts\activate` on Windows
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
python manage.py migrate
|
||||||
|
|
||||||
|
# Create superuser
|
||||||
|
python manage.py createsuperuser
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
python manage.py runserver
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Build for production
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Deployment
|
||||||
|
|
||||||
|
### SSL Certificates
|
||||||
|
|
||||||
|
Place your SSL certificates in the `certs/` directory:
|
||||||
|
- `frontend-cert.pem` and `frontend-key.pem` for the frontend
|
||||||
|
- `backend-cert.pem` and `backend-key.pem` for the backend (if using HTTPS)
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Create a `.env` file in the root directory for docker-compose:
|
||||||
|
```bash
|
||||||
|
BACKEND_PORT=8443
|
||||||
|
FRONTEND_PORT=443
|
||||||
|
FRONTEND_ORIGIN=https://your-domain.com
|
||||||
|
PUBLIC_API_URL=https://api.your-domain.com/api
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build and Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build and start containers
|
||||||
|
docker-compose up -d --build
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker-compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
nexus-2/
|
||||||
|
├── backend/ # Django backend
|
||||||
|
│ ├── api/ # Main Django app
|
||||||
|
│ │ ├── models.py # Database models
|
||||||
|
│ │ ├── views.py # API views
|
||||||
|
│ │ ├── serializers.py # DRF serializers
|
||||||
|
│ │ ├── urls.py # URL routing
|
||||||
|
│ │ ├── google.py # Google API integrations
|
||||||
|
│ │ └── migrations/ # Database migrations
|
||||||
|
│ ├── config/ # Django project settings
|
||||||
|
│ │ ├── settings.py # Django configuration
|
||||||
|
│ │ └── urls.py # Root URL routing
|
||||||
|
│ ├── manage.py
|
||||||
|
│ ├── requirements.txt
|
||||||
|
│ └── Dockerfile
|
||||||
|
├── frontend/ # SvelteKit frontend
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── lib/ # Shared utilities
|
||||||
|
│ │ │ ├── api.ts # API client with types
|
||||||
|
│ │ │ ├── auth.ts # Authentication helpers
|
||||||
|
│ │ │ └── components/ # Reusable components
|
||||||
|
│ │ └── routes/ # SvelteKit routes
|
||||||
|
│ │ ├── accounts/ # Account management
|
||||||
|
│ │ ├── customers/ # Customer management
|
||||||
|
│ │ ├── services/ # Service tracking
|
||||||
|
│ │ ├── projects/ # Project management
|
||||||
|
│ │ ├── invoices/ # Invoice management
|
||||||
|
│ │ ├── schedules/ # Schedule management
|
||||||
|
│ │ ├── dashboard/ # Dashboard
|
||||||
|
│ │ ├── profile/ # User profile
|
||||||
|
│ │ ├── admin/ # Admin features
|
||||||
|
│ │ ├── calendar/ # Calendar integration
|
||||||
|
│ │ └── login/ # Authentication
|
||||||
|
│ ├── package.json
|
||||||
|
│ └── Dockerfile
|
||||||
|
├── docker-compose.yml
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- `POST /api/token/` - Obtain JWT tokens
|
||||||
|
- `POST /api/token/refresh/` - Refresh access token
|
||||||
|
|
||||||
|
### Profiles
|
||||||
|
- `GET/POST /api/profiles/` - List/create profiles
|
||||||
|
- `GET/PUT/PATCH/DELETE /api/profiles/{id}/` - Profile detail
|
||||||
|
- `POST /api/auth/change-password/` - Change password
|
||||||
|
- `POST /api/auth/reset-password/` - Reset password (admin)
|
||||||
|
|
||||||
|
### Customers
|
||||||
|
- `GET/POST /api/customers/` - List/create customers
|
||||||
|
- `GET/PUT/PATCH/DELETE /api/customers/{id}/` - Customer detail
|
||||||
|
|
||||||
|
### Accounts
|
||||||
|
- `GET/POST /api/accounts/` - List/create accounts
|
||||||
|
- `GET/PUT/PATCH/DELETE /api/accounts/{id}/` - Account detail
|
||||||
|
|
||||||
|
### Revenues
|
||||||
|
- `GET/POST /api/revenues/` - List/create revenues
|
||||||
|
- `GET/PUT/DELETE /api/revenues/{id}/` - Revenue detail
|
||||||
|
|
||||||
|
### Schedules
|
||||||
|
- `GET/POST /api/schedules/` - List/create schedules
|
||||||
|
- `GET/PUT/PATCH/DELETE /api/schedules/{id}/` - Schedule detail
|
||||||
|
- `POST /api/generate-services/` - Generate services from schedules
|
||||||
|
|
||||||
|
### Services
|
||||||
|
- `GET/POST /api/services/` - List/create services
|
||||||
|
- `GET/PUT/PATCH/DELETE /api/services/{id}/` - Service detail
|
||||||
|
|
||||||
|
### Projects
|
||||||
|
- `GET/POST /api/projects/` - List/create projects
|
||||||
|
- `GET/PUT/PATCH/DELETE /api/projects/{id}/` - Project detail
|
||||||
|
|
||||||
|
### Invoices
|
||||||
|
- `GET/POST /api/invoices/` - List/create invoices
|
||||||
|
- `GET/PUT/PATCH/DELETE /api/invoices/{id}/` - Invoice detail
|
||||||
|
- `POST /api/invoices/{id}/mark_as_paid/` - Mark invoice as paid
|
||||||
|
|
||||||
|
### Reports
|
||||||
|
- `GET/POST /api/reports/` - List/create reports
|
||||||
|
- `GET/PUT/PATCH/DELETE /api/reports/{id}/` - Report detail
|
||||||
|
|
||||||
|
### Punchlists
|
||||||
|
- `GET/POST /api/punchlists/` - List/create punchlists
|
||||||
|
- `GET/PUT/PATCH/DELETE /api/punchlists/{id}/` - Punchlist detail
|
||||||
|
- `POST /api/export/punchlist/` - Export punchlist to Google Sheets/PDF
|
||||||
|
|
||||||
|
## Customizing the Punchlist Feature
|
||||||
|
|
||||||
|
The Punchlist model is designed as a customizable checklist for service projects. The backend includes a generic punchlist structure with sections for:
|
||||||
|
|
||||||
|
- **Front area**: ceiling, vents, fixtures, counter
|
||||||
|
- **Main work area**: equipment, walls, ceiling, vents, floors
|
||||||
|
- **Equipment stations**: 7 configurable stations plus sinks, dispensers, other
|
||||||
|
- **Back area**: ceiling, vents
|
||||||
|
- **End of visit**: trash, cleanup, security check
|
||||||
|
|
||||||
|
**To customize for your use case:**
|
||||||
|
|
||||||
|
1. **Modify the model** (`backend/api/models.py`): Update the `Punchlist` model fields to match your specific checklist requirements
|
||||||
|
|
||||||
|
2. **Update the field mapping** (`backend/api/google.py`): Modify `PUNCHLIST_FIELD_MAPPING` to map your model fields to cells in your Google Sheets template
|
||||||
|
|
||||||
|
3. **Create a Google Sheets template**: Design a template that matches your punchlist layout and configure `PUNCHLIST_TEMPLATE_ID` in your environment
|
||||||
|
|
||||||
|
4. **Build frontend UI**: The frontend punchlist routes were removed as they were specific to a previous implementation. Create new SvelteKit routes under `frontend/src/routes/projects/punchlist/` with forms matching your model fields
|
||||||
|
|
||||||
|
The punchlist API endpoints are fully functional - only the frontend UI needs to be built for your specific workflow.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
33
backend/.env.example
Normal file
33
backend/.env.example
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# Django settings
|
||||||
|
SECRET_KEY=your-secret-key-here
|
||||||
|
DEBUG=False
|
||||||
|
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||||
|
|
||||||
|
# Database configuration
|
||||||
|
DB_NAME=nexus
|
||||||
|
DB_USER=postgres
|
||||||
|
DB_PASSWORD=your-database-password
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
|
||||||
|
# Timezone
|
||||||
|
TIME_ZONE=America/Detroit
|
||||||
|
|
||||||
|
# JWT settings
|
||||||
|
JWT_ACCESS_LIFETIME_MINUTES=60
|
||||||
|
JWT_REFRESH_LIFETIME_DAYS=14
|
||||||
|
|
||||||
|
# CORS (comma-separated list of allowed origins)
|
||||||
|
CORS_ALLOWED_ORIGINS=http://localhost:5173,https://your-domain.com
|
||||||
|
|
||||||
|
# Google API configuration
|
||||||
|
# Path to your service account JSON key file (relative to app root)
|
||||||
|
GOOGLE_SERVICE_ACCOUNT_FILE=google-sa.json
|
||||||
|
|
||||||
|
# Calendar settings (for domain-wide delegation)
|
||||||
|
GOOGLE_CALENDAR_ID=primary
|
||||||
|
GOOGLE_IMPERSONATOR_EMAIL=your-email@your-domain.com
|
||||||
|
|
||||||
|
# Google Drive punchlist settings (optional)
|
||||||
|
PUNCHLIST_TEMPLATE_ID=your-google-sheets-template-id
|
||||||
|
PUNCHLIST_FOLDER_ID=your-google-drive-folder-id
|
||||||
4
backend/.gitignore
vendored
Normal file
4
backend/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
.venv
|
||||||
|
.env
|
||||||
|
api/data
|
||||||
|
google-sa.json
|
||||||
14
backend/Dockerfile
Normal file
14
backend/Dockerfile
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --upgrade pip
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
RUN pip install gunicorn uvicorn whitenoise
|
||||||
|
RUN mkdir -p /app/certs/
|
||||||
|
RUN mkdir -p /app/staticfiles/
|
||||||
|
COPY . .
|
||||||
|
RUN python manage.py collectstatic --noinput
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD gunicorn config.asgi:application -k uvicorn.workers.UvicornWorker --certfile=/app/certs/backend-cert.pem --keyfile=/app/certs/backend-key.pem --bind=0.0.0.0:8000
|
||||||
0
backend/api/__init__.py
Normal file
0
backend/api/__init__.py
Normal file
17
backend/api/admin.py
Normal file
17
backend/api/admin.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from .models import (
|
||||||
|
Profile, Customer, Account, Revenue, Labor,
|
||||||
|
Schedule, Service, Project, Invoice, Report, Punchlist
|
||||||
|
)
|
||||||
|
|
||||||
|
admin.site.register(Profile)
|
||||||
|
admin.site.register(Customer)
|
||||||
|
admin.site.register(Account)
|
||||||
|
admin.site.register(Revenue)
|
||||||
|
admin.site.register(Labor)
|
||||||
|
admin.site.register(Schedule)
|
||||||
|
admin.site.register(Service)
|
||||||
|
admin.site.register(Project)
|
||||||
|
admin.site.register(Invoice)
|
||||||
|
admin.site.register(Report)
|
||||||
|
admin.site.register(Punchlist)
|
||||||
6
backend/api/apps.py
Normal file
6
backend/api/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ApiConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'api'
|
||||||
388
backend/api/google.py
Normal file
388
backend/api/google.py
Normal file
@ -0,0 +1,388 @@
|
|||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from googleapiclient.discovery import build
|
||||||
|
from googleapiclient.http import MediaIoBaseDownload, MediaIoBaseUpload
|
||||||
|
from google.oauth2 import service_account
|
||||||
|
from google.auth.exceptions import GoogleAuthError
|
||||||
|
from googleapiclient.errors import HttpError
|
||||||
|
|
||||||
|
from .models import Punchlist
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Configuration from environment
|
||||||
|
CALENDAR_ID = os.getenv('GOOGLE_CALENDAR_ID', 'primary')
|
||||||
|
IMPERSONATOR_EMAIL = os.getenv('GOOGLE_IMPERSONATOR_EMAIL')
|
||||||
|
TIME_ZONE = os.getenv('TIME_ZONE', 'America/Detroit')
|
||||||
|
|
||||||
|
# Punchlist Google Drive configuration from environment
|
||||||
|
PUNCHLIST_TEMPLATE = os.getenv('PUNCHLIST_TEMPLATE_ID', '')
|
||||||
|
PUNCHLIST_FOLDER = os.getenv('PUNCHLIST_FOLDER_ID', '')
|
||||||
|
|
||||||
|
|
||||||
|
def create_event(key, data):
|
||||||
|
"""
|
||||||
|
Create an event on the project calendar using the Google API
|
||||||
|
Args:
|
||||||
|
key (dict): Service account credentials dictionary
|
||||||
|
data (dict): Event data with the following structure:
|
||||||
|
- summary (str): Event title
|
||||||
|
- description (str): Event description
|
||||||
|
- start (str): ISO format start datetime
|
||||||
|
- end (str): ISO format end datetime
|
||||||
|
- location (str): Event location
|
||||||
|
- attendees (list): List of dicts with 'email' keys
|
||||||
|
- reminders (list, optional): List of dicts with 'method' and 'minutes' keys
|
||||||
|
Returns:
|
||||||
|
dict: Created event data or error information
|
||||||
|
"""
|
||||||
|
service_account_key = key
|
||||||
|
# Set up calendar details from environment
|
||||||
|
calendar_id = CALENDAR_ID
|
||||||
|
impersonator = IMPERSONATOR_EMAIL
|
||||||
|
# Validate required fields
|
||||||
|
required_fields = ['summary', 'start', 'end']
|
||||||
|
missing_fields = [field for field in required_fields if field not in data]
|
||||||
|
if missing_fields:
|
||||||
|
error_msg = f"Missing required fields: {', '.join(missing_fields)}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return {"error": error_msg}
|
||||||
|
# Construct event data
|
||||||
|
event = {
|
||||||
|
'summary': data.get('summary'),
|
||||||
|
'description': data.get('description', ''),
|
||||||
|
'start': {
|
||||||
|
'dateTime': data.get('start'),
|
||||||
|
'timeZone': TIME_ZONE,
|
||||||
|
},
|
||||||
|
'end': {
|
||||||
|
'dateTime': data.get('end'),
|
||||||
|
'timeZone': TIME_ZONE,
|
||||||
|
},
|
||||||
|
'location': data.get('location', ''),
|
||||||
|
'attendees': data.get('attendees', []),
|
||||||
|
'reminders': {
|
||||||
|
'useDefault': False if data.get('reminders') else True,
|
||||||
|
'overrides': data.get('reminders', []) if data.get('reminders') else [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
# Set up authentication
|
||||||
|
scopes = ['https://www.googleapis.com/auth/calendar']
|
||||||
|
credentials = service_account.Credentials.from_service_account_info(
|
||||||
|
service_account_key, scopes=scopes, subject=impersonator
|
||||||
|
)
|
||||||
|
# Build and call the service
|
||||||
|
service = build('calendar', 'v3', credentials=credentials)
|
||||||
|
created_event = service.events().insert(calendarId=calendar_id, body=event).execute()
|
||||||
|
logger.info(f"Event created: {created_event.get('htmlLink')}")
|
||||||
|
return created_event
|
||||||
|
|
||||||
|
except GoogleAuthError as e:
|
||||||
|
logger.error(f"Authentication error: {e}")
|
||||||
|
return {"error": "Authentication failed", "details": str(e)}
|
||||||
|
except HttpError as e:
|
||||||
|
logger.error(f"Google API error: {e}")
|
||||||
|
return {"error": "Google Calendar API error", "details": str(e)}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error creating event: {e}")
|
||||||
|
return {"error": "Unexpected error creating event", "details": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
# Field mapping for punchlist export to Google Sheets
|
||||||
|
# Maps model field names to spreadsheet cell references
|
||||||
|
# Customize this mapping to match your Google Sheets template
|
||||||
|
PUNCHLIST_FIELD_MAPPING = {
|
||||||
|
'account_name': 'B4',
|
||||||
|
'date': 'D4',
|
||||||
|
# Front area
|
||||||
|
'front_ceiling': 'D8',
|
||||||
|
'front_vents': 'D9',
|
||||||
|
'front_fixtures': 'D10',
|
||||||
|
'front_counter': 'D11',
|
||||||
|
# Main work area
|
||||||
|
'main_equipment': 'D15',
|
||||||
|
'main_equipment_disassemble': 'D16',
|
||||||
|
'main_equipment_reassemble': 'D17',
|
||||||
|
'main_equipment_test': 'D18',
|
||||||
|
'main_equipment_exterior': 'D19',
|
||||||
|
'main_walls': 'D20',
|
||||||
|
'main_fixtures': 'D21',
|
||||||
|
'main_ceiling': 'D22',
|
||||||
|
'main_vents': 'D23',
|
||||||
|
'main_floors': 'D24',
|
||||||
|
# Equipment stations
|
||||||
|
'equip_station_1': 'D28',
|
||||||
|
'equip_station_2': 'D29',
|
||||||
|
'equip_station_3': 'D30',
|
||||||
|
'equip_station_4': 'D31',
|
||||||
|
'equip_station_5': 'D32',
|
||||||
|
'equip_station_6': 'D33',
|
||||||
|
'equip_station_7': 'D34',
|
||||||
|
'equip_sinks': 'D35',
|
||||||
|
'equip_dispensers': 'D36',
|
||||||
|
'equip_other': 'D37',
|
||||||
|
# Back area
|
||||||
|
'back_ceiling': 'D41',
|
||||||
|
'back_vents': 'D42',
|
||||||
|
# End of visit
|
||||||
|
'end_trash': 'D46',
|
||||||
|
'end_clean': 'D47',
|
||||||
|
'end_secure': 'D48',
|
||||||
|
# Notes
|
||||||
|
'notes': 'A52'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_google_service(key):
|
||||||
|
"""Create and return Google API service"""
|
||||||
|
credentials = service_account.Credentials.from_service_account_info(
|
||||||
|
key,
|
||||||
|
scopes=['https://www.googleapis.com/auth/drive', 'https://www.googleapis.com/auth/spreadsheets']
|
||||||
|
)
|
||||||
|
|
||||||
|
drive_service = build('drive', 'v3', credentials=credentials)
|
||||||
|
sheets_service = build('sheets', 'v4', credentials=credentials)
|
||||||
|
|
||||||
|
return drive_service, sheets_service
|
||||||
|
|
||||||
|
|
||||||
|
def export_punchlist_to_sheet(key, punchlist_id, template_id=None):
|
||||||
|
"""
|
||||||
|
Export a punchlist to Google Sheets and generate a PDF
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Service account credentials dictionary
|
||||||
|
punchlist_id: The ID of the punchlist to export
|
||||||
|
template_id: Optional template ID, uses default if not provided
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: URLs to the created sheet and PDF
|
||||||
|
"""
|
||||||
|
# Get the punchlist data
|
||||||
|
try:
|
||||||
|
punchlist = Punchlist.objects.get(id=punchlist_id)
|
||||||
|
|
||||||
|
if punchlist.sheet_url and punchlist.pdf_url:
|
||||||
|
# Try to verify files still exist in Google Drive
|
||||||
|
try:
|
||||||
|
drive_service, _ = get_google_service(key)
|
||||||
|
|
||||||
|
# Extract file IDs from URLs
|
||||||
|
sheet_id = extract_file_id_from_url(punchlist.sheet_url)
|
||||||
|
pdf_id = extract_file_id_from_url(punchlist.pdf_url)
|
||||||
|
|
||||||
|
# Check if files exist
|
||||||
|
sheet_exists = check_file_exists(drive_service, sheet_id)
|
||||||
|
pdf_exists = check_file_exists(drive_service, pdf_id)
|
||||||
|
|
||||||
|
if sheet_exists and pdf_exists:
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'already_exported': True,
|
||||||
|
'sheetUrl': punchlist.sheet_url,
|
||||||
|
'pdfUrl': punchlist.pdf_url,
|
||||||
|
'message': 'Files already exist'
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
# If there's an error checking, proceed with creating new files
|
||||||
|
pass
|
||||||
|
except Punchlist.DoesNotExist:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': f'Punchlist with ID {punchlist_id} not found'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get account and project info
|
||||||
|
account_name = punchlist.account.name if punchlist.account else 'Unknown Account'
|
||||||
|
service_date = punchlist.project.date if punchlist.project else punchlist.date
|
||||||
|
|
||||||
|
# Get Google services
|
||||||
|
drive_service, sheets_service = get_google_service(key)
|
||||||
|
|
||||||
|
# Use provided template ID or default
|
||||||
|
template_id = template_id or PUNCHLIST_TEMPLATE
|
||||||
|
|
||||||
|
if not template_id:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': 'No punchlist template ID configured'
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. Copy the template
|
||||||
|
copied_file = drive_service.files().copy(
|
||||||
|
fileId=template_id,
|
||||||
|
body={
|
||||||
|
'name': f'Punchlist - {account_name} - {service_date}',
|
||||||
|
'parents': [PUNCHLIST_FOLDER] if PUNCHLIST_FOLDER else []
|
||||||
|
}
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
new_sheet_id = copied_file['id']
|
||||||
|
|
||||||
|
# 2. Prepare data for the sheet
|
||||||
|
value_range_body = {
|
||||||
|
'valueInputOption': 'USER_ENTERED',
|
||||||
|
'data': []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Map punchlist data to sheet cells
|
||||||
|
data_mapping = {
|
||||||
|
'account_name': account_name,
|
||||||
|
'date': service_date.strftime('%m/%d/%Y') if service_date else '',
|
||||||
|
'front_ceiling': 'X' if punchlist.front_ceiling else '',
|
||||||
|
'front_vents': 'X' if punchlist.front_vents else '',
|
||||||
|
'front_fixtures': 'X' if punchlist.front_fixtures else '',
|
||||||
|
'front_counter': 'X' if punchlist.front_counter else '',
|
||||||
|
'main_equipment': punchlist.main_equipment.upper() if punchlist.main_equipment else '',
|
||||||
|
'main_equipment_disassemble': 'X' if punchlist.main_equipment_disassemble else '',
|
||||||
|
'main_equipment_reassemble': 'X' if punchlist.main_equipment_reassemble else '',
|
||||||
|
'main_equipment_test': 'X' if punchlist.main_equipment_test else '',
|
||||||
|
'main_equipment_exterior': 'X' if punchlist.main_equipment_exterior else '',
|
||||||
|
'main_walls': 'X' if punchlist.main_walls else '',
|
||||||
|
'main_fixtures': 'X' if punchlist.main_fixtures else '',
|
||||||
|
'main_ceiling': 'X' if punchlist.main_ceiling else '',
|
||||||
|
'main_vents': 'X' if punchlist.main_vents else '',
|
||||||
|
'main_floors': 'X' if punchlist.main_floors else '',
|
||||||
|
'equip_station_1': 'X' if punchlist.equip_station_1 else '',
|
||||||
|
'equip_station_2': 'X' if punchlist.equip_station_2 else '',
|
||||||
|
'equip_station_3': 'X' if punchlist.equip_station_3 else '',
|
||||||
|
'equip_station_4': 'X' if punchlist.equip_station_4 else '',
|
||||||
|
'equip_station_5': 'X' if punchlist.equip_station_5 else '',
|
||||||
|
'equip_station_6': 'X' if punchlist.equip_station_6 else '',
|
||||||
|
'equip_station_7': 'X' if punchlist.equip_station_7 else '',
|
||||||
|
'equip_sinks': 'X' if punchlist.equip_sinks else '',
|
||||||
|
'equip_dispensers': 'X' if punchlist.equip_dispensers else '',
|
||||||
|
'equip_other': 'X' if punchlist.equip_other else '',
|
||||||
|
'back_ceiling': 'X' if punchlist.back_ceiling else '',
|
||||||
|
'back_vents': 'X' if punchlist.back_vents else '',
|
||||||
|
'end_trash': 'X' if punchlist.end_trash else '',
|
||||||
|
'end_clean': 'X' if punchlist.end_clean else '',
|
||||||
|
'end_secure': 'X' if punchlist.end_secure else '',
|
||||||
|
'notes': punchlist.notes or ''
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create value range objects for each cell
|
||||||
|
for field, cell in PUNCHLIST_FIELD_MAPPING.items():
|
||||||
|
if field in data_mapping:
|
||||||
|
value = data_mapping[field]
|
||||||
|
|
||||||
|
# Extract sheet name and cell reference
|
||||||
|
if '!' in cell:
|
||||||
|
sheet_name, cell_ref = cell.split('!')
|
||||||
|
else:
|
||||||
|
sheet_name = 'Sheet1' # Default sheet name
|
||||||
|
cell_ref = cell
|
||||||
|
|
||||||
|
value_range_body['data'].append({
|
||||||
|
'range': f"'{sheet_name}'!{cell_ref}",
|
||||||
|
'values': [[value]]
|
||||||
|
})
|
||||||
|
|
||||||
|
# 3. Update the sheet with data
|
||||||
|
sheets_service.spreadsheets().values().batchUpdate(
|
||||||
|
spreadsheetId=new_sheet_id,
|
||||||
|
body=value_range_body
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
# 4. Export as PDF
|
||||||
|
request = drive_service.files().export_media(
|
||||||
|
fileId=new_sheet_id,
|
||||||
|
mimeType='application/pdf'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a BytesIO object to store the PDF
|
||||||
|
pdf_file = io.BytesIO()
|
||||||
|
downloader = MediaIoBaseDownload(pdf_file, request)
|
||||||
|
|
||||||
|
# Download the PDF
|
||||||
|
done = False
|
||||||
|
while not done:
|
||||||
|
status, done = downloader.next_chunk()
|
||||||
|
|
||||||
|
# 5. Upload the PDF to Drive
|
||||||
|
pdf_metadata = {
|
||||||
|
'name': f'Punchlist - {account_name} - {service_date}.pdf',
|
||||||
|
'mimeType': 'application/pdf',
|
||||||
|
}
|
||||||
|
if PUNCHLIST_FOLDER:
|
||||||
|
pdf_metadata['parents'] = [PUNCHLIST_FOLDER]
|
||||||
|
|
||||||
|
pdf_file.seek(0)
|
||||||
|
pdf_media = MediaIoBaseUpload(pdf_file, mimetype='application/pdf')
|
||||||
|
|
||||||
|
uploaded_pdf = drive_service.files().create(
|
||||||
|
body=pdf_metadata,
|
||||||
|
media_body=pdf_media,
|
||||||
|
fields='id,webViewLink'
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
# 6. Make both files accessible via link
|
||||||
|
for file_id in [new_sheet_id, uploaded_pdf['id']]:
|
||||||
|
drive_service.permissions().create(
|
||||||
|
fileId=file_id,
|
||||||
|
body={
|
||||||
|
'type': 'anyone',
|
||||||
|
'role': 'reader'
|
||||||
|
}
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
# Get the web view links
|
||||||
|
sheet_file = drive_service.files().get(
|
||||||
|
fileId=new_sheet_id,
|
||||||
|
fields='webViewLink'
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
punchlist.sheet_url = sheet_file['webViewLink']
|
||||||
|
punchlist.pdf_url = uploaded_pdf['webViewLink']
|
||||||
|
punchlist.exported_at = timezone.now()
|
||||||
|
punchlist.save()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'sheetUrl': sheet_file['webViewLink'],
|
||||||
|
'pdfUrl': uploaded_pdf['webViewLink']
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message': f'Error exporting punchlist: {str(e)}'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def extract_file_id_from_url(url):
|
||||||
|
"""Extract Google Drive file ID from a sharing URL"""
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Google Drive URLs typically have the format:
|
||||||
|
# https://drive.google.com/file/d/{FILE_ID}/view or
|
||||||
|
# https://docs.google.com/spreadsheets/d/{FILE_ID}/edit
|
||||||
|
match = re.search(r'\/d\/([a-zA-Z0-9_-]+)', url)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
# For spreadsheet URLs
|
||||||
|
match = re.search(r'\/spreadsheets\/d\/([a-zA-Z0-9_-]+)', url)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def check_file_exists(drive_service, file_id):
|
||||||
|
"""Check if a file exists in Google Drive"""
|
||||||
|
if not file_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
drive_service.files().get(fileId=file_id).execute()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
0
backend/api/migrations/__init__.py
Normal file
0
backend/api/migrations/__init__.py
Normal file
310
backend/api/models.py
Normal file
310
backend/api/models.py
Normal file
@ -0,0 +1,310 @@
|
|||||||
|
import uuid
|
||||||
|
from django.db import models
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class Profile(models.Model):
|
||||||
|
"""Extension of the Django User model"""
|
||||||
|
ROLE_CHOICES = (
|
||||||
|
('admin', 'Admin'),
|
||||||
|
('team_leader', 'Team Leader'),
|
||||||
|
('team_member', 'Team Member'),
|
||||||
|
)
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
|
||||||
|
first_name = models.CharField(max_length=100)
|
||||||
|
last_name = models.CharField(max_length=100)
|
||||||
|
primary_phone = models.CharField(max_length=20)
|
||||||
|
secondary_phone = models.CharField(max_length=20, blank=True, null=True)
|
||||||
|
email = models.EmailField()
|
||||||
|
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='team_member')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['last_name', 'first_name']
|
||||||
|
verbose_name_plural = "Profiles"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.first_name} {self.last_name}"
|
||||||
|
|
||||||
|
|
||||||
|
class Customer(models.Model):
|
||||||
|
"""Customer model with contact information"""
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
name = models.CharField(max_length=200)
|
||||||
|
primary_contact_first_name = models.CharField(max_length=100)
|
||||||
|
primary_contact_last_name = models.CharField(max_length=100)
|
||||||
|
primary_contact_phone = models.CharField(max_length=20)
|
||||||
|
primary_contact_email = models.EmailField()
|
||||||
|
secondary_contact_first_name = models.CharField(max_length=100, blank=True, null=True)
|
||||||
|
secondary_contact_last_name = models.CharField(max_length=100, blank=True, null=True)
|
||||||
|
secondary_contact_phone = models.CharField(max_length=20, blank=True, null=True)
|
||||||
|
secondary_contact_email = models.EmailField(blank=True, null=True)
|
||||||
|
billing_contact_first_name = models.CharField(max_length=100)
|
||||||
|
billing_contact_last_name = models.CharField(max_length=100)
|
||||||
|
billing_street_address = models.CharField(max_length=255)
|
||||||
|
billing_city = models.CharField(max_length=100)
|
||||||
|
billing_state = models.CharField(max_length=100)
|
||||||
|
billing_zip_code = models.CharField(max_length=20)
|
||||||
|
billing_email = models.EmailField()
|
||||||
|
billing_terms = models.TextField()
|
||||||
|
start_date = models.DateField()
|
||||||
|
end_date = models.DateField(blank=True, null=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['name']
|
||||||
|
verbose_name_plural = "Customers"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class Account(models.Model):
|
||||||
|
"""Account model belonging to a customer"""
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
customer = models.ForeignKey(Customer, on_delete=models.CASCADE, related_name='accounts')
|
||||||
|
name = models.CharField(max_length=200)
|
||||||
|
street_address = models.CharField(max_length=255)
|
||||||
|
city = models.CharField(max_length=100)
|
||||||
|
state = models.CharField(max_length=100)
|
||||||
|
zip_code = models.CharField(max_length=20)
|
||||||
|
contact_first_name = models.CharField(max_length=100)
|
||||||
|
contact_last_name = models.CharField(max_length=100)
|
||||||
|
contact_phone = models.CharField(max_length=20)
|
||||||
|
contact_email = models.EmailField()
|
||||||
|
start_date = models.DateField()
|
||||||
|
end_date = models.DateField(blank=True, null=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['name']
|
||||||
|
verbose_name_plural = "Accounts"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} ({self.customer.name})"
|
||||||
|
|
||||||
|
|
||||||
|
class Revenue(models.Model):
|
||||||
|
"""Revenue records for accounts"""
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
account = models.ForeignKey(Account, on_delete=models.CASCADE, related_name='revenues')
|
||||||
|
amount = models.DecimalField(max_digits=10, decimal_places=2)
|
||||||
|
start_date = models.DateField()
|
||||||
|
end_date = models.DateField(blank=True, null=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-start_date']
|
||||||
|
verbose_name_plural = "Revenues"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.account.name} - ${self.amount}"
|
||||||
|
|
||||||
|
|
||||||
|
class Labor(models.Model):
|
||||||
|
"""Labor records for accounts"""
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
account = models.ForeignKey(Account, on_delete=models.CASCADE, related_name='labors')
|
||||||
|
amount = models.DecimalField(max_digits=10, decimal_places=2)
|
||||||
|
start_date = models.DateField()
|
||||||
|
end_date = models.DateField(blank=True, null=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-start_date']
|
||||||
|
verbose_name_plural = "Labors"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.account.name} - ${self.amount}"
|
||||||
|
|
||||||
|
|
||||||
|
class Schedule(models.Model):
|
||||||
|
"""Service schedules for accounts"""
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
account = models.ForeignKey(Account, on_delete=models.CASCADE, related_name='schedules')
|
||||||
|
monday_service = models.BooleanField(default=False)
|
||||||
|
tuesday_service = models.BooleanField(default=False)
|
||||||
|
wednesday_service = models.BooleanField(default=False)
|
||||||
|
thursday_service = models.BooleanField(default=False)
|
||||||
|
friday_service = models.BooleanField(default=False)
|
||||||
|
saturday_service = models.BooleanField(default=False)
|
||||||
|
sunday_service = models.BooleanField(default=False)
|
||||||
|
weekend_service = models.BooleanField(default=False)
|
||||||
|
schedule_exception = models.TextField(blank=True, null=True)
|
||||||
|
start_date = models.DateField()
|
||||||
|
end_date = models.DateField(blank=True, null=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-start_date']
|
||||||
|
verbose_name_plural = "Schedules"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Schedule for {self.account.name}"
|
||||||
|
|
||||||
|
|
||||||
|
class Service(models.Model):
|
||||||
|
"""Service records for accounts"""
|
||||||
|
STATUS_CHOICES = (
|
||||||
|
('scheduled', 'Scheduled'),
|
||||||
|
('in_progress', 'In Progress'),
|
||||||
|
('completed', 'Completed'),
|
||||||
|
('cancelled', 'Cancelled'),
|
||||||
|
)
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
account = models.ForeignKey(Account, on_delete=models.CASCADE, related_name='services')
|
||||||
|
date = models.DateField()
|
||||||
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='scheduled')
|
||||||
|
team_members = models.ManyToManyField(Profile, related_name='services')
|
||||||
|
notes = models.TextField(blank=True, null=True)
|
||||||
|
deadline_start = models.DateTimeField()
|
||||||
|
deadline_end = models.DateTimeField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-date']
|
||||||
|
verbose_name_plural = "Services"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Service for {self.account.name} on {self.date}"
|
||||||
|
|
||||||
|
|
||||||
|
class Project(models.Model):
|
||||||
|
"""Project records for customers"""
|
||||||
|
STATUS_CHOICES = (
|
||||||
|
('planned', 'Planned'),
|
||||||
|
('in_progress', 'In Progress'),
|
||||||
|
('completed', 'Completed'),
|
||||||
|
('cancelled', 'Cancelled'),
|
||||||
|
)
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
customer = models.ForeignKey(Customer, on_delete=models.CASCADE, related_name='projects')
|
||||||
|
account = models.ForeignKey(Account, on_delete=models.CASCADE, related_name='projects', blank=True, null=True)
|
||||||
|
date = models.DateField()
|
||||||
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='planned')
|
||||||
|
team_members = models.ManyToManyField(Profile, related_name='projects')
|
||||||
|
notes = models.TextField(blank=True, null=True)
|
||||||
|
labor = models.DecimalField(max_digits=10, decimal_places=2)
|
||||||
|
amount = models.DecimalField(max_digits=10, decimal_places=2, default=0.00)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-date']
|
||||||
|
verbose_name_plural = "Projects"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Project for {self.customer.name} on {self.date}"
|
||||||
|
|
||||||
|
|
||||||
|
class Invoice(models.Model):
|
||||||
|
"""Invoice records"""
|
||||||
|
STATUS_CHOICES = (
|
||||||
|
('draft', 'Draft'),
|
||||||
|
('sent', 'Sent'),
|
||||||
|
('paid', 'Paid'),
|
||||||
|
('overdue', 'Overdue'),
|
||||||
|
('cancelled', 'Cancelled'),
|
||||||
|
)
|
||||||
|
PAYMENT_CHOICES = (
|
||||||
|
('check', 'Check'),
|
||||||
|
('credit_card', 'Credit Card'),
|
||||||
|
('bank_transfer', 'Bank Transfer'),
|
||||||
|
('cash', 'Cash'),
|
||||||
|
)
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
date = models.DateField()
|
||||||
|
customer = models.ForeignKey(Customer, on_delete=models.CASCADE, related_name='invoices')
|
||||||
|
accounts = models.ManyToManyField(Account, related_name='invoices', blank=True)
|
||||||
|
projects = models.ManyToManyField(Project, related_name='invoices', blank=True)
|
||||||
|
revenues = models.ManyToManyField(Revenue, related_name='invoices', blank=True)
|
||||||
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft')
|
||||||
|
date_paid = models.DateField(blank=True, null=True)
|
||||||
|
payment_type = models.CharField(max_length=20, choices=PAYMENT_CHOICES, blank=True, null=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-date']
|
||||||
|
verbose_name_plural = "Invoices"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Invoice for {self.customer.name} on {self.date}"
|
||||||
|
|
||||||
|
|
||||||
|
class Report(models.Model):
|
||||||
|
"""Report records"""
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
date = models.DateField()
|
||||||
|
team_member = models.ForeignKey(Profile, on_delete=models.CASCADE, related_name='reports')
|
||||||
|
services = models.ManyToManyField(Service, related_name='reports', blank=True)
|
||||||
|
projects = models.ManyToManyField(Project, related_name='reports', blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-date']
|
||||||
|
verbose_name_plural = "Reports"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Report by {self.team_member.first_name} {self.team_member.last_name} on {self.date}"
|
||||||
|
|
||||||
|
|
||||||
|
class Punchlist(models.Model):
|
||||||
|
"""
|
||||||
|
Punchlist records for projects.
|
||||||
|
This is a customizable checklist for service projects with sections for
|
||||||
|
different areas and equipment. Modify the fields to match your specific
|
||||||
|
service requirements.
|
||||||
|
"""
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
project = models.ForeignKey('Project', on_delete=models.CASCADE, related_name='punchlists')
|
||||||
|
account = models.ForeignKey('Account', on_delete=models.CASCADE, related_name='punchlists')
|
||||||
|
date = models.DateField()
|
||||||
|
second_visit = models.BooleanField(default=False)
|
||||||
|
second_date = models.DateTimeField(blank=True, null=True)
|
||||||
|
|
||||||
|
# Front area section
|
||||||
|
front_ceiling = models.BooleanField(default=False)
|
||||||
|
front_vents = models.BooleanField(default=False)
|
||||||
|
front_fixtures = models.BooleanField(default=False)
|
||||||
|
front_counter = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
# Main work area section
|
||||||
|
main_equipment = models.CharField(max_length=20, blank=True)
|
||||||
|
main_equipment_disassemble = models.BooleanField(default=False)
|
||||||
|
main_equipment_reassemble = models.BooleanField(default=False)
|
||||||
|
main_equipment_test = models.BooleanField(default=False)
|
||||||
|
main_equipment_exterior = models.BooleanField(default=False)
|
||||||
|
main_walls = models.BooleanField(default=False)
|
||||||
|
main_fixtures = models.BooleanField(default=False)
|
||||||
|
main_ceiling = models.BooleanField(default=False)
|
||||||
|
main_vents = models.BooleanField(default=False)
|
||||||
|
main_floors = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
# Equipment section
|
||||||
|
equip_station_1 = models.BooleanField(default=False)
|
||||||
|
equip_station_2 = models.BooleanField(default=False)
|
||||||
|
equip_station_3 = models.BooleanField(default=False)
|
||||||
|
equip_station_4 = models.BooleanField(default=False)
|
||||||
|
equip_station_5 = models.BooleanField(default=False)
|
||||||
|
equip_station_6 = models.BooleanField(default=False)
|
||||||
|
equip_station_7 = models.BooleanField(default=False)
|
||||||
|
equip_sinks = models.BooleanField(default=False)
|
||||||
|
equip_dispensers = models.BooleanField(default=False)
|
||||||
|
equip_other = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
# Back area section
|
||||||
|
back_ceiling = models.BooleanField(default=False)
|
||||||
|
back_vents = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
# End of visit section
|
||||||
|
end_trash = models.BooleanField(default=False)
|
||||||
|
end_clean = models.BooleanField(default=False)
|
||||||
|
end_secure = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
# Notes
|
||||||
|
notes = models.TextField(blank=True)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
sheet_url = models.URLField(blank=True, null=True)
|
||||||
|
pdf_url = models.URLField(blank=True, null=True)
|
||||||
|
exported_at = models.DateTimeField(blank=True, null=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-date']
|
||||||
|
verbose_name_plural = "Punchlists"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Punchlist for {self.account.name} on {self.date}"
|
||||||
125
backend/api/serializers.py
Normal file
125
backend/api/serializers.py
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from .models import (
|
||||||
|
Profile, Customer, Account, Revenue, Labor,
|
||||||
|
Schedule, Service, Project, Invoice, Report, Punchlist
|
||||||
|
)
|
||||||
|
|
||||||
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = ['id', 'username', 'email']
|
||||||
|
|
||||||
|
class ProfileSerializer(serializers.ModelSerializer):
|
||||||
|
user = UserSerializer(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Profile
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
class RevenueSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Revenue
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
class LaborSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Labor
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
class ScheduleSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Schedule
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
class ServiceSerializer(serializers.ModelSerializer):
|
||||||
|
team_members_detail = ProfileSerializer(source='team_members', many=True, read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Service
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
def to_representation(self, instance):
|
||||||
|
# This method customizes the output format
|
||||||
|
data = super().to_representation(instance)
|
||||||
|
# Replace team_members with profile information
|
||||||
|
if 'team_members_detail' in data:
|
||||||
|
data['team_members'] = data.pop('team_members_detail')
|
||||||
|
return data
|
||||||
|
|
||||||
|
def validate_team_members(self, value):
|
||||||
|
request = self.context.get('request')
|
||||||
|
if request and request.user and hasattr(request.user, 'profile'):
|
||||||
|
if request.user.profile.role in ['admin']:
|
||||||
|
return value
|
||||||
|
# Default validation
|
||||||
|
if not value:
|
||||||
|
raise serializers.ValidationError("At least one team member is required.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
class ProjectSerializer(serializers.ModelSerializer):
|
||||||
|
team_members_detail = ProfileSerializer(source='team_members', many=True, read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Project
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
def to_representation(self, instance):
|
||||||
|
# This method customizes the output format
|
||||||
|
data = super().to_representation(instance)
|
||||||
|
# Replace team_members with profile information
|
||||||
|
if 'team_members_detail' in data:
|
||||||
|
data['team_members'] = data.pop('team_members_detail')
|
||||||
|
return data
|
||||||
|
|
||||||
|
def validate_team_members(self, value):
|
||||||
|
request = self.context.get('request')
|
||||||
|
if request and request.user and hasattr(request.user, 'profile'):
|
||||||
|
if request.user.profile.role in ['admin', 'team_leader']:
|
||||||
|
return value
|
||||||
|
# Default validation
|
||||||
|
if not value:
|
||||||
|
raise serializers.ValidationError("At least one team member is required.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
class AccountSerializer(serializers.ModelSerializer):
|
||||||
|
revenues = RevenueSerializer(many=True, read_only=True)
|
||||||
|
labors = LaborSerializer(many=True, read_only=True)
|
||||||
|
schedules = ScheduleSerializer(many=True, read_only=True)
|
||||||
|
services = ServiceSerializer(many=True, read_only=True)
|
||||||
|
projects = ProjectSerializer(many=True, read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Account
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
class CustomerSerializer(serializers.ModelSerializer):
|
||||||
|
accounts = AccountSerializer(many=True, read_only=True)
|
||||||
|
projects = ProjectSerializer(many=True, read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Customer
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
class InvoiceSerializer(serializers.ModelSerializer):
|
||||||
|
customer = CustomerSerializer(read_only=True)
|
||||||
|
accounts = AccountSerializer(many=True, read_only=True)
|
||||||
|
projects = ProjectSerializer(many=True, read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Invoice
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
class ReportSerializer(serializers.ModelSerializer):
|
||||||
|
team_member = ProfileSerializer(read_only=True)
|
||||||
|
services = ServiceSerializer(many=True, read_only=True)
|
||||||
|
projects = ProjectSerializer(many=True, read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Report
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
class PunchlistSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Punchlist
|
||||||
|
fields = '__all__'
|
||||||
3
backend/api/tests.py
Normal file
3
backend/api/tests.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
37
backend/api/urls.py
Normal file
37
backend/api/urls.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
from django.urls import path, include
|
||||||
|
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
from .views import (
|
||||||
|
UserViewSet, ProfileViewSet, CustomerViewSet,
|
||||||
|
AccountViewSet, RevenueViewSet, LaborViewSet,
|
||||||
|
ScheduleViewSet, ServiceViewSet, ProjectViewSet,
|
||||||
|
InvoiceViewSet, ReportViewSet, current_user, change_password, reset_password, generate_services,
|
||||||
|
create_calendar_event, PunchlistViewSet, export_punchlist
|
||||||
|
)
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r'users', UserViewSet)
|
||||||
|
router.register(r'profiles', ProfileViewSet)
|
||||||
|
router.register(r'customers', CustomerViewSet)
|
||||||
|
router.register(r'accounts', AccountViewSet)
|
||||||
|
router.register(r'revenues', RevenueViewSet)
|
||||||
|
router.register(r'labors', LaborViewSet)
|
||||||
|
router.register(r'schedules', ScheduleViewSet)
|
||||||
|
router.register(r'services', ServiceViewSet)
|
||||||
|
router.register(r'projects', ProjectViewSet)
|
||||||
|
router.register(r'invoices', InvoiceViewSet)
|
||||||
|
router.register(r'reports', ReportViewSet)
|
||||||
|
router.register(r'punchlists', PunchlistViewSet)
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('', include(router.urls)),
|
||||||
|
path('api-auth/', include('rest_framework.urls')),
|
||||||
|
path('token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
|
||||||
|
path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
|
||||||
|
path('auth/user/', current_user, name='current_user'),
|
||||||
|
path('auth/change-password/', change_password, name='change_password'),
|
||||||
|
path('auth/reset-password/', reset_password, name='reset_password'),
|
||||||
|
path('generate-services/', generate_services, name='generate_services'),
|
||||||
|
path('calendar/create/', create_calendar_event, name='create_calendar_event'),
|
||||||
|
path('export/punchlist/', export_punchlist, name='export_punchlist')
|
||||||
|
]
|
||||||
603
backend/api/views.py
Normal file
603
backend/api/views.py
Normal file
@ -0,0 +1,603 @@
|
|||||||
|
import calendar
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from django.contrib.auth import update_session_auth_hash
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import transaction
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework import viewsets, permissions, status
|
||||||
|
from rest_framework.decorators import api_view, permission_classes, action
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from .google import create_event, export_punchlist_to_sheet
|
||||||
|
from .models import (
|
||||||
|
Profile, Customer, Account, Revenue, Labor,
|
||||||
|
Schedule, Service, Project, Invoice, Report, Punchlist
|
||||||
|
)
|
||||||
|
from .serializers import (
|
||||||
|
UserSerializer, ProfileSerializer, CustomerSerializer,
|
||||||
|
AccountSerializer, RevenueSerializer, LaborSerializer,
|
||||||
|
ScheduleSerializer, ServiceSerializer, ProjectSerializer,
|
||||||
|
InvoiceSerializer, ReportSerializer, PunchlistSerializer
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Users API
|
||||||
|
class UserViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = User.objects.all()
|
||||||
|
serializer_class = UserSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
|
||||||
|
# Profiles API
|
||||||
|
class ProfileViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Profile.objects.all()
|
||||||
|
serializer_class = ProfileSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
|
||||||
|
# Customers API
|
||||||
|
class CustomerViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Customer.objects.all()
|
||||||
|
serializer_class = CustomerSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
|
||||||
|
# Accounts API
|
||||||
|
class AccountViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Account.objects.all()
|
||||||
|
serializer_class = AccountSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = Account.objects.all()
|
||||||
|
customer_id = self.request.query_params.get('customer_id')
|
||||||
|
if customer_id is not None:
|
||||||
|
queryset = queryset.filter(customer_id=customer_id)
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
|
# Revenues API
|
||||||
|
class RevenueViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Revenue.objects.all()
|
||||||
|
serializer_class = RevenueSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = Revenue.objects.all()
|
||||||
|
account_id = self.request.query_params.get('account_id')
|
||||||
|
if account_id is not None:
|
||||||
|
queryset = queryset.filter(account_id=account_id)
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
|
# Labor API
|
||||||
|
class LaborViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Labor.objects.all()
|
||||||
|
serializer_class = LaborSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = Labor.objects.all()
|
||||||
|
account_id = self.request.query_params.get('account_id')
|
||||||
|
if account_id is not None:
|
||||||
|
queryset = queryset.filter(account_id=account_id)
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
|
# Schedules API
|
||||||
|
class ScheduleViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Schedule.objects.all()
|
||||||
|
serializer_class = ScheduleSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = Schedule.objects.all()
|
||||||
|
account_id = self.request.query_params.get('account_id')
|
||||||
|
if account_id is not None:
|
||||||
|
queryset = queryset.filter(account_id=account_id)
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
|
# Services API
|
||||||
|
class ServiceViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Service.objects.all()
|
||||||
|
serializer_class = ServiceSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = Service.objects.all()
|
||||||
|
|
||||||
|
# Filter by account
|
||||||
|
account_id = self.request.query_params.get('account_id')
|
||||||
|
if account_id is not None:
|
||||||
|
queryset = queryset.filter(account_id=account_id)
|
||||||
|
|
||||||
|
# Filter by team member
|
||||||
|
team_member_id = self.request.query_params.get('team_member_id')
|
||||||
|
if team_member_id is not None:
|
||||||
|
queryset = queryset.filter(team_members__id=team_member_id)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
|
# Projects API
|
||||||
|
class ProjectViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Project.objects.all()
|
||||||
|
serializer_class = ProjectSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = Project.objects.all()
|
||||||
|
customer_id = self.request.query_params.get('customer_id')
|
||||||
|
account_id = self.request.query_params.get('account_id')
|
||||||
|
|
||||||
|
if customer_id is not None:
|
||||||
|
queryset = queryset.filter(customer_id=customer_id)
|
||||||
|
if account_id is not None:
|
||||||
|
queryset = queryset.filter(account_id=account_id)
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
|
# Invoices API
|
||||||
|
class InvoiceViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Invoice.objects.all()
|
||||||
|
serializer_class = InvoiceSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = Invoice.objects.all()
|
||||||
|
customer_id = self.request.query_params.get('customer_id')
|
||||||
|
if customer_id is not None:
|
||||||
|
queryset = queryset.filter(customer_id=customer_id)
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def mark_as_paid(self, request, pk=None):
|
||||||
|
"""
|
||||||
|
Mark an invoice as paid with payment details
|
||||||
|
"""
|
||||||
|
invoice = self.get_object()
|
||||||
|
|
||||||
|
# Update invoice with payment information
|
||||||
|
invoice.status = 'paid'
|
||||||
|
|
||||||
|
# Set payment date if provided, otherwise use today
|
||||||
|
date_paid = request.data.get('date_paid')
|
||||||
|
if date_paid:
|
||||||
|
invoice.date_paid = date_paid
|
||||||
|
else:
|
||||||
|
invoice.date_paid = timezone.now().date()
|
||||||
|
|
||||||
|
# Set payment type if provided
|
||||||
|
payment_type = request.data.get('payment_type')
|
||||||
|
if payment_type:
|
||||||
|
invoice.payment_type = payment_type
|
||||||
|
|
||||||
|
invoice.save()
|
||||||
|
|
||||||
|
# Return the updated invoice
|
||||||
|
serializer = self.get_serializer(invoice)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
# Reports API
|
||||||
|
class ReportViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Report.objects.all()
|
||||||
|
serializer_class = ReportSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = Report.objects.all()
|
||||||
|
team_member_id = self.request.query_params.get('team_member_id')
|
||||||
|
if team_member_id is not None:
|
||||||
|
queryset = queryset.filter(team_member_id=team_member_id)
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
|
# User Profile API
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def current_user(request):
|
||||||
|
"""
|
||||||
|
Endpoint to get the current authenticated user and their profile
|
||||||
|
"""
|
||||||
|
user = request.user
|
||||||
|
user_serializer = UserSerializer(user)
|
||||||
|
# Get the profile associated with the user
|
||||||
|
try:
|
||||||
|
profile = Profile.objects.get(user=user)
|
||||||
|
profile_serializer = ProfileSerializer(profile)
|
||||||
|
# Return combined data
|
||||||
|
return Response({
|
||||||
|
'user': user_serializer.data,
|
||||||
|
'profile': profile_serializer.data
|
||||||
|
})
|
||||||
|
except ObjectDoesNotExist:
|
||||||
|
# Return just the user data if no profile exists
|
||||||
|
return Response({
|
||||||
|
'user': user_serializer.data,
|
||||||
|
'profile': None
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# Punchlist API
|
||||||
|
class PunchlistViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Punchlist.objects.all()
|
||||||
|
serializer_class = PunchlistSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = Punchlist.objects.all()
|
||||||
|
project_id = self.request.query_params.get('project_id')
|
||||||
|
account_id = self.request.query_params.get('account_id')
|
||||||
|
|
||||||
|
if project_id is not None:
|
||||||
|
queryset = queryset.filter(project_id=project_id)
|
||||||
|
if account_id is not None:
|
||||||
|
queryset = queryset.filter(account_id=account_id)
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
def create(self, request, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Create a new punchlist
|
||||||
|
"""
|
||||||
|
# Extract project_id from request data
|
||||||
|
project_id = request.data.get('project_id')
|
||||||
|
if not project_id:
|
||||||
|
return Response(
|
||||||
|
{"error": "project_id is required"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify project exists
|
||||||
|
try:
|
||||||
|
project = Project.objects.get(id=project_id)
|
||||||
|
except Project.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"error": f"Project with id {project_id} does not exist"},
|
||||||
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract account_id from request data
|
||||||
|
account_id = request.data.get('account_id')
|
||||||
|
if not account_id:
|
||||||
|
return Response(
|
||||||
|
{"error": "account_id is required"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify account exists
|
||||||
|
try:
|
||||||
|
account = Account.objects.get(id=account_id)
|
||||||
|
except Account.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"error": f"Account with id {account_id} does not exist"},
|
||||||
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a modified data dictionary with project and account fields
|
||||||
|
modified_data = request.data.copy()
|
||||||
|
modified_data['project'] = project_id # Map project_id to project
|
||||||
|
modified_data['account'] = account_id # Map account_id to account
|
||||||
|
|
||||||
|
# Create serializer with modified data
|
||||||
|
serializer = self.get_serializer(data=modified_data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
self.perform_create(serializer)
|
||||||
|
|
||||||
|
headers = self.get_success_headers(serializer.data)
|
||||||
|
return Response(
|
||||||
|
serializer.data,
|
||||||
|
status=status.HTTP_201_CREATED,
|
||||||
|
headers=headers
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Change Password API
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def change_password(request):
|
||||||
|
"""
|
||||||
|
Endpoint to change user password
|
||||||
|
"""
|
||||||
|
user = request.user
|
||||||
|
# Check if required fields are present
|
||||||
|
if not all(k in request.data for k in ('current_password', 'new_password')):
|
||||||
|
return Response(
|
||||||
|
{'error': 'Current password and new password are required'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
# Verify current password
|
||||||
|
if not user.check_password(request.data['current_password']):
|
||||||
|
return Response(
|
||||||
|
{'error': 'Current password is incorrect'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
# Set new password
|
||||||
|
user.set_password(request.data['new_password'])
|
||||||
|
user.save()
|
||||||
|
# Update session to prevent logout
|
||||||
|
update_session_auth_hash(request, user)
|
||||||
|
return Response({'message': 'Password changed successfully'}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
# Reset Password API
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def reset_password(request):
|
||||||
|
"""
|
||||||
|
Endpoint for admins to reset a user's password
|
||||||
|
"""
|
||||||
|
# Check if user is admin
|
||||||
|
try:
|
||||||
|
profile = Profile.objects.get(user=request.user)
|
||||||
|
if profile.role != 'admin':
|
||||||
|
return Response(
|
||||||
|
{"error": "Only administrators can reset passwords"},
|
||||||
|
status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
except ObjectDoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"error": "User profile not found"},
|
||||||
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
# Get parameters
|
||||||
|
user_id = request.data.get('user_id')
|
||||||
|
new_password = request.data.get('new_password')
|
||||||
|
if not user_id or not new_password:
|
||||||
|
return Response(
|
||||||
|
{"error": "Missing required parameters"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
# Find the target profile and user
|
||||||
|
try:
|
||||||
|
target_profile = Profile.objects.get(id=user_id)
|
||||||
|
target_user = target_profile.user
|
||||||
|
# Set new password
|
||||||
|
target_user.set_password(new_password)
|
||||||
|
target_user.save()
|
||||||
|
return Response({"message": "Password reset successful"}, status=status.HTTP_200_OK)
|
||||||
|
except ObjectDoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"error": "Target user profile not found"},
|
||||||
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return Response(
|
||||||
|
{"error": str(e)},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Generate Services API
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def generate_services(request):
|
||||||
|
"""
|
||||||
|
Batch generate services for a given month based on active schedules
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Expected data from request
|
||||||
|
month = int(request.data.get('month'))
|
||||||
|
year = int(request.data.get('year'))
|
||||||
|
account_ids = request.data.get('account_ids', None)
|
||||||
|
# Fixed holiday dates for 2025
|
||||||
|
holidays = [
|
||||||
|
datetime(2025, 1, 1).date(), # New Year's Day
|
||||||
|
datetime(2025, 1, 20).date(), # MLK Day
|
||||||
|
datetime(2025, 2, 17).date(), # Presidents' Day
|
||||||
|
datetime(2025, 5, 26).date(), # Memorial Day
|
||||||
|
datetime(2025, 7, 4).date(), # Independence Day
|
||||||
|
datetime(2025, 9, 1).date(), # Labor Day
|
||||||
|
datetime(2025, 11, 27).date(), # Thanksgiving
|
||||||
|
datetime(2025, 12, 25).date(), # Christmas
|
||||||
|
]
|
||||||
|
# Validate month
|
||||||
|
if month < 0 or month > 11:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Month must be between 0 (January) and 11 (December)'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return Response(
|
||||||
|
{'error': 'Invalid parameters. Month and year must be integers.'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
# Get calendar for specified month
|
||||||
|
cal = calendar.monthcalendar(year, (month + 1))
|
||||||
|
# Get first and last day of month (for filtering schedules)
|
||||||
|
first_day = datetime(year, (month + 1), 1).date()
|
||||||
|
last_day = datetime(year, (month + 1), calendar.monthrange(year, (month + 1))[1]).date()
|
||||||
|
# Get active schedules for the month
|
||||||
|
schedules_query = Schedule.objects.filter(
|
||||||
|
start_date__lte=last_day
|
||||||
|
).filter(
|
||||||
|
Q(end_date__isnull=True) |
|
||||||
|
Q(end_date__gte=first_day)
|
||||||
|
)
|
||||||
|
# Filter by account IDs if provided
|
||||||
|
if account_ids:
|
||||||
|
schedules_query = schedules_query.filter(account__id__in=account_ids)
|
||||||
|
# Initialize service count
|
||||||
|
services_created = 0
|
||||||
|
with transaction.atomic():
|
||||||
|
for schedule in schedules_query:
|
||||||
|
# Use the monthcalendar to iterate through days
|
||||||
|
for week in cal:
|
||||||
|
for weekday, day in enumerate(week):
|
||||||
|
if day == 0:
|
||||||
|
continue
|
||||||
|
# Iterated date object
|
||||||
|
iter_date = datetime(year, month + 1, day).date()
|
||||||
|
# Check if date is in schedule's range
|
||||||
|
if not (schedule.start_date <= iter_date and (
|
||||||
|
schedule.end_date is None or schedule.end_date >= iter_date)):
|
||||||
|
continue
|
||||||
|
# Check if date is a holiday
|
||||||
|
if iter_date in holidays:
|
||||||
|
continue
|
||||||
|
# Initial service requirement
|
||||||
|
service_needed = False
|
||||||
|
# Check regular weekday service requirement
|
||||||
|
if weekday == 0 and schedule.monday_service:
|
||||||
|
service_needed = True
|
||||||
|
elif weekday == 1 and schedule.tuesday_service:
|
||||||
|
service_needed = True
|
||||||
|
elif weekday == 2 and schedule.wednesday_service:
|
||||||
|
service_needed = True
|
||||||
|
elif weekday == 3 and schedule.thursday_service:
|
||||||
|
service_needed = True
|
||||||
|
elif weekday == 4 and schedule.friday_service:
|
||||||
|
service_needed = True
|
||||||
|
elif weekday == 5 and schedule.saturday_service:
|
||||||
|
service_needed = True
|
||||||
|
elif weekday == 6 and schedule.sunday_service:
|
||||||
|
service_needed = True
|
||||||
|
# Separate check for weekend service
|
||||||
|
if (schedule.weekend_service and weekday in [4]) and not schedule.friday_service:
|
||||||
|
service_needed = True
|
||||||
|
# Create service if needed and doesn't already exist
|
||||||
|
if service_needed:
|
||||||
|
# Check for existing service
|
||||||
|
existing_service = Service.objects.filter(
|
||||||
|
account=schedule.account,
|
||||||
|
date=iter_date
|
||||||
|
).exists()
|
||||||
|
# If service does not exist
|
||||||
|
if not existing_service:
|
||||||
|
# Service starts at 6:00PM
|
||||||
|
deadline_start = datetime.combine(
|
||||||
|
iter_date,
|
||||||
|
datetime.min.time()
|
||||||
|
).replace(hour=18)
|
||||||
|
# Service ends at 6:00AM
|
||||||
|
deadline_end = datetime.combine(
|
||||||
|
iter_date + timedelta(days=1),
|
||||||
|
datetime.min.time()
|
||||||
|
).replace(hour=6)
|
||||||
|
# Prepare notes
|
||||||
|
notes = ""
|
||||||
|
if schedule.schedule_exception:
|
||||||
|
notes += f"Schedule exception: {schedule.schedule_exception}"
|
||||||
|
if schedule.weekend_service and weekday in [4]:
|
||||||
|
notes += " Weekend Service"
|
||||||
|
# Create the service
|
||||||
|
Service.objects.create(
|
||||||
|
account=schedule.account,
|
||||||
|
date=iter_date,
|
||||||
|
status='scheduled',
|
||||||
|
notes=notes.strip(),
|
||||||
|
deadline_start=deadline_start,
|
||||||
|
deadline_end=deadline_end
|
||||||
|
)
|
||||||
|
# Add to the counter
|
||||||
|
services_created += 1
|
||||||
|
# Return a summary of the operation
|
||||||
|
return Response({
|
||||||
|
'message': f'Successfully generated {services_created} services for {calendar.month_name[month + 1]} {year}.',
|
||||||
|
'count': services_created,
|
||||||
|
'month': month,
|
||||||
|
'year': year,
|
||||||
|
'accountsProcessed': schedules_query.count()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
key_file_path = os.path.join(settings.BASE_DIR, 'google-sa.json')
|
||||||
|
|
||||||
|
|
||||||
|
# Google Calendar Event API
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def create_calendar_event(request):
|
||||||
|
"""
|
||||||
|
Create a Google Calendar event for a project or service by reading key from file
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
with open(key_file_path, 'r') as f:
|
||||||
|
google_service_account_key = json.load(f)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return Response(
|
||||||
|
{"error": "Google service account key file not found"},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
return Response(
|
||||||
|
{"error": "Invalid Google service account key file format", "details": str(e)},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
result = create_event(google_service_account_key, request.data)
|
||||||
|
if "error" in result:
|
||||||
|
return Response(result, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
return Response(result, status=status.HTTP_201_CREATED)
|
||||||
|
except Exception as e:
|
||||||
|
return Response(
|
||||||
|
{"error": "Failed to create calendar event", "details": str(e)},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def export_punchlist(request):
|
||||||
|
"""
|
||||||
|
Export a punchlist to Google Sheets and generate a PDF
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Validate required parameters
|
||||||
|
punchlist_id = request.data.get('punchlistId')
|
||||||
|
if not punchlist_id:
|
||||||
|
return Response(
|
||||||
|
{"error": "punchlistId is required"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get optional template ID
|
||||||
|
template_id = request.data.get('templateId')
|
||||||
|
|
||||||
|
# Load Google service account key
|
||||||
|
try:
|
||||||
|
with open(key_file_path, 'r') as f:
|
||||||
|
google_service_account_key = json.load(f)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return Response(
|
||||||
|
{"error": "Google service account key file not found"},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
return Response(
|
||||||
|
{"error": "Invalid Google service account key file format", "details": str(e)},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
# Call the export function with the service account key
|
||||||
|
from .google import export_punchlist_to_sheet
|
||||||
|
result = export_punchlist_to_sheet(google_service_account_key, punchlist_id, template_id)
|
||||||
|
|
||||||
|
# Handle errors from the export function
|
||||||
|
if not result.get('success', False):
|
||||||
|
return Response(
|
||||||
|
{"error": result.get('message', 'Unknown error during export')},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return success response with URLs
|
||||||
|
return Response({
|
||||||
|
"success": True,
|
||||||
|
"message": "Punchlist exported successfully",
|
||||||
|
"sheetUrl": result.get('sheetUrl'),
|
||||||
|
"pdfUrl": result.get('pdfUrl'),
|
||||||
|
"alreadyExported": result.get('already_exported', False)
|
||||||
|
}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Handle any unexpected errors
|
||||||
|
return Response(
|
||||||
|
{"error": "Failed to export punchlist", "details": str(e)},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
0
backend/config/__init__.py
Normal file
0
backend/config/__init__.py
Normal file
16
backend/config/asgi.py
Normal file
16
backend/config/asgi.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
ASGI config for config project.
|
||||||
|
|
||||||
|
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||||
|
|
||||||
|
application = get_asgi_application()
|
||||||
119
backend/config/settings.py
Normal file
119
backend/config/settings.py
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import os
|
||||||
|
from datetime import timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
SECRET_KEY = os.getenv("SECRET_KEY")
|
||||||
|
DEBUG = os.getenv("DEBUG", "False").lower() in ("true", "1", "yes")
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "*").split(",")
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
'django.contrib.admin',
|
||||||
|
'django.contrib.auth',
|
||||||
|
'django.contrib.contenttypes',
|
||||||
|
'django.contrib.sessions',
|
||||||
|
'django.contrib.messages',
|
||||||
|
'django.contrib.staticfiles',
|
||||||
|
'rest_framework',
|
||||||
|
'rest_framework_simplejwt',
|
||||||
|
'corsheaders',
|
||||||
|
'api.apps.ApiConfig',
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
'corsheaders.middleware.CorsMiddleware',
|
||||||
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||||
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
|
'django.middleware.common.CommonMiddleware',
|
||||||
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = 'config.urls'
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||||
|
'DIRS': [BASE_DIR / 'templates'],
|
||||||
|
'APP_DIRS': True,
|
||||||
|
'OPTIONS': {
|
||||||
|
'context_processors': [
|
||||||
|
'django.template.context_processors.request',
|
||||||
|
'django.contrib.auth.context_processors.auth',
|
||||||
|
'django.contrib.messages.context_processors.messages',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = 'config.wsgi.application'
|
||||||
|
|
||||||
|
# Database configuration from environment
|
||||||
|
DATABASES = {
|
||||||
|
'default': {
|
||||||
|
'ENGINE': 'django.db.backends.postgresql',
|
||||||
|
'NAME': os.getenv('DB_NAME', 'nexus'),
|
||||||
|
'USER': os.getenv('DB_USER', 'postgres'),
|
||||||
|
'PASSWORD': os.getenv('DB_PASSWORD'),
|
||||||
|
'HOST': os.getenv('DB_HOST', 'localhost'),
|
||||||
|
'PORT': os.getenv('DB_PORT', '5432'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
|
||||||
|
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
|
||||||
|
{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
|
||||||
|
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
|
||||||
|
]
|
||||||
|
|
||||||
|
LANGUAGE_CODE = 'en-us'
|
||||||
|
USE_I18N = True
|
||||||
|
USE_TZ = True
|
||||||
|
TIME_ZONE = os.getenv('TIME_ZONE', 'America/Detroit')
|
||||||
|
|
||||||
|
STATIC_URL = 'static/'
|
||||||
|
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
|
||||||
|
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
|
||||||
|
STATICFILES_DIRS = [
|
||||||
|
os.path.join(BASE_DIR, 'static'),
|
||||||
|
]
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
|
||||||
|
REST_FRAMEWORK = {
|
||||||
|
'DEFAULT_PERMISSION_CLASSES': [
|
||||||
|
'rest_framework.permissions.IsAuthenticated',
|
||||||
|
],
|
||||||
|
'DEFAULT_AUTHENTICATION_CLASSES': [
|
||||||
|
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||||
|
],
|
||||||
|
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
|
||||||
|
'PAGE_SIZE': 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
SIMPLE_JWT = {
|
||||||
|
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=int(os.getenv('JWT_ACCESS_LIFETIME_MINUTES', 60))),
|
||||||
|
'REFRESH_TOKEN_LIFETIME': timedelta(days=int(os.getenv('JWT_REFRESH_LIFETIME_DAYS', 14))),
|
||||||
|
'ROTATE_REFRESH_TOKENS': False,
|
||||||
|
'BLACKLIST_AFTER_ROTATION': False,
|
||||||
|
'ALGORITHM': 'HS256',
|
||||||
|
'SIGNING_KEY': SECRET_KEY,
|
||||||
|
'VERIFYING_KEY': None,
|
||||||
|
'AUTH_HEADER_TYPES': ('Bearer',),
|
||||||
|
'USER_ID_FIELD': 'id',
|
||||||
|
'USER_ID_CLAIM': 'user_id',
|
||||||
|
'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',),
|
||||||
|
'TOKEN_TYPE_CLAIM': 'token_type',
|
||||||
|
}
|
||||||
|
|
||||||
|
# CORS configuration
|
||||||
|
CORS_ALLOWED_ORIGINS = os.getenv('CORS_ALLOWED_ORIGINS', 'http://localhost:5173').split(',')
|
||||||
7
backend/config/urls.py
Normal file
7
backend/config/urls.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.urls import path, include
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('admin/', admin.site.urls),
|
||||||
|
path('api/', include('api.urls')),
|
||||||
|
]
|
||||||
16
backend/config/wsgi.py
Normal file
16
backend/config/wsgi.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
WSGI config for config project.
|
||||||
|
|
||||||
|
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||||
|
|
||||||
|
application = get_wsgi_application()
|
||||||
22
backend/manage.py
Executable file
22
backend/manage.py
Executable file
@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""Django's command-line utility for administrative tasks."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run administrative tasks."""
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||||
|
try:
|
||||||
|
from django.core.management import execute_from_command_line
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ImportError(
|
||||||
|
"Couldn't import Django. Are you sure it's installed and "
|
||||||
|
"available on your PYTHONPATH environment variable? Did you "
|
||||||
|
"forget to activate a virtual environment?"
|
||||||
|
) from exc
|
||||||
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
40
backend/requirements.txt
Normal file
40
backend/requirements.txt
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
asgiref==3.8.1
|
||||||
|
beautifulsoup4==4.13.4
|
||||||
|
cachetools==5.5.2
|
||||||
|
certifi==2025.4.26
|
||||||
|
charset-normalizer==3.4.1
|
||||||
|
dj-database-url==2.3.0
|
||||||
|
Django==5.2
|
||||||
|
django-cors-headers==4.7.0
|
||||||
|
djangorestframework==3.16.0
|
||||||
|
djangorestframework_simplejwt==5.5.0
|
||||||
|
dotenv==0.9.9
|
||||||
|
google==3.0.0
|
||||||
|
google-api-core==2.23.0
|
||||||
|
google-api-python-client==2.154.0
|
||||||
|
google-auth==2.36.0
|
||||||
|
google-auth-httplib2==0.2.0
|
||||||
|
google-auth-oauthlib==1.2.1
|
||||||
|
googleapis-common-protos==1.66.0
|
||||||
|
httplib2==0.22.0
|
||||||
|
hvac==2.3.0
|
||||||
|
idna==3.10
|
||||||
|
oauth2==1.9.0.post1
|
||||||
|
oauthlib==3.2.2
|
||||||
|
proto-plus==1.26.1
|
||||||
|
protobuf==5.29.4
|
||||||
|
psycopg2-binary==2.9.10
|
||||||
|
pyasn1==0.6.1
|
||||||
|
pyasn1_modules==0.4.2
|
||||||
|
PyJWT==2.9.0
|
||||||
|
pyparsing==3.2.3
|
||||||
|
python-dotenv==1.1.0
|
||||||
|
requests==2.32.3
|
||||||
|
requests-oauthlib==2.0.0
|
||||||
|
rsa==4.9.1
|
||||||
|
soupsieve==2.7
|
||||||
|
sqlparse==0.5.3
|
||||||
|
typing_extensions==4.13.2
|
||||||
|
uritemplate==4.1.1
|
||||||
|
urllib3==2.4.0
|
||||||
|
whitenoise==6.9.0
|
||||||
0
backend/static/.gitkeep
Normal file
0
backend/static/.gitkeep
Normal file
0
backend/templates/.gitkeep
Normal file
0
backend/templates/.gitkeep
Normal file
37
docker-compose.yml
Normal file
37
docker-compose.yml
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "${BACKEND_PORT:-8443}:8000"
|
||||||
|
volumes:
|
||||||
|
- ./backend/.env:/app/.env:ro
|
||||||
|
- ./backend/google-sa.json:/app/google-sa.json:ro
|
||||||
|
- ./certs:/app/certs:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- nexus-network
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "${FRONTEND_PORT:-443}:3000"
|
||||||
|
volumes:
|
||||||
|
- ./certs:/app/certs
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- PORT=3000
|
||||||
|
- ORIGIN=${FRONTEND_ORIGIN:-https://localhost}
|
||||||
|
- PUBLIC_API_URL=${PUBLIC_API_URL:-https://localhost:8443/api}
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
networks:
|
||||||
|
- nexus-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
nexus-network:
|
||||||
|
driver: bridge
|
||||||
23
frontend/.gitignore
vendored
Normal file
23
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
node_modules
|
||||||
|
|
||||||
|
# Output
|
||||||
|
.output
|
||||||
|
.vercel
|
||||||
|
.netlify
|
||||||
|
.wrangler
|
||||||
|
/.svelte-kit
|
||||||
|
/build
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.test
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
1
frontend/.npmrc
Normal file
1
frontend/.npmrc
Normal file
@ -0,0 +1 @@
|
|||||||
|
engine-strict=true
|
||||||
6
frontend/.prettierignore
Normal file
6
frontend/.prettierignore
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
# Package Managers
|
||||||
|
package-lock.json
|
||||||
|
pnpm-lock.yaml
|
||||||
|
yarn.lock
|
||||||
|
bun.lock
|
||||||
|
bun.lockb
|
||||||
15
frontend/.prettierrc
Normal file
15
frontend/.prettierrc
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"useTabs": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"printWidth": 100,
|
||||||
|
"plugins": ["prettier-plugin-svelte"],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.svelte",
|
||||||
|
"options": {
|
||||||
|
"parser": "svelte"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
18
frontend/Dockerfile
Normal file
18
frontend/Dockerfile
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
FROM node:18-alpine as builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:18-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /app/build /app/build
|
||||||
|
COPY --from=builder /app/package.json /app/package.json
|
||||||
|
RUN npm install --omit=dev
|
||||||
|
RUN mkdir -p /app/certs/
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV ORIGIN=https://localhost:3000
|
||||||
|
CMD ["node", "-e", "import('https').then(https => { import('fs').then(fs => { import('path').then(path => { import('./build/handler.js').then(mod => { const handler = mod.handler; const options = { key: fs.readFileSync(path.resolve('/app/certs/frontend-key.pem')), cert: fs.readFileSync(path.resolve('/app/certs/frontend-cert.pem')) }; const server = https.createServer(options, handler); server.listen(3000, () => { console.log('SvelteKit application running on https://localhost:3000'); }); }); }); }); });"]
|
||||||
38
frontend/README.md
Normal file
38
frontend/README.md
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
# sv
|
||||||
|
|
||||||
|
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||||
|
|
||||||
|
## Creating a project
|
||||||
|
|
||||||
|
If you're seeing this, you've probably already done this step. Congrats!
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# create a new project in the current directory
|
||||||
|
npx sv create
|
||||||
|
|
||||||
|
# create a new project in my-app
|
||||||
|
npx sv create my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Developing
|
||||||
|
|
||||||
|
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# or start the server and open the app in a new browser tab
|
||||||
|
npm run dev -- --open
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
To create a production version of your app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
You can preview the production build with `npm run preview`.
|
||||||
|
|
||||||
|
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||||
36
frontend/eslint.config.js
Normal file
36
frontend/eslint.config.js
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import prettier from 'eslint-config-prettier';
|
||||||
|
import js from '@eslint/js';
|
||||||
|
import { includeIgnoreFile } from '@eslint/compat';
|
||||||
|
import svelte from 'eslint-plugin-svelte';
|
||||||
|
import globals from 'globals';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import ts from 'typescript-eslint';
|
||||||
|
import svelteConfig from './svelte.config.js';
|
||||||
|
|
||||||
|
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
|
||||||
|
|
||||||
|
export default ts.config(
|
||||||
|
includeIgnoreFile(gitignorePath),
|
||||||
|
js.configs.recommended,
|
||||||
|
...ts.configs.recommended,
|
||||||
|
...svelte.configs.recommended,
|
||||||
|
prettier,
|
||||||
|
...svelte.configs.prettier,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: { ...globals.browser, ...globals.node }
|
||||||
|
},
|
||||||
|
rules: { 'no-undef': 'off' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
extraFileExtensions: ['.svelte'],
|
||||||
|
parser: ts.parser,
|
||||||
|
svelteConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
3858
frontend/package-lock.json
generated
Normal file
3858
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
frontend/package.json
Normal file
39
frontend/package.json
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"lint": "prettier --check . && eslint ."
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/compat": "^1.2.5",
|
||||||
|
"@eslint/js": "^9.18.0",
|
||||||
|
"@sveltejs/adapter-node": "^5.2.12",
|
||||||
|
"@sveltejs/kit": "^2.16.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
|
"eslint": "^9.18.0",
|
||||||
|
"eslint-config-prettier": "^10.0.1",
|
||||||
|
"eslint-plugin-svelte": "^3.0.0",
|
||||||
|
"globals": "^16.0.0",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"prettier-plugin-svelte": "^3.3.3",
|
||||||
|
"svelte": "^5.28.1",
|
||||||
|
"svelte-check": "^4.0.0",
|
||||||
|
"typescript": "^5.0.0",
|
||||||
|
"typescript-eslint": "^8.20.0",
|
||||||
|
"vite": "^6.2.5"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.8.4",
|
||||||
|
"bootstrap": "^5.3.5",
|
||||||
|
"date-fns": "^4.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
13
frontend/src/app.d.ts
vendored
Normal file
13
frontend/src/app.d.ts
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
|
// for information about these interfaces
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
// interface Error {}
|
||||||
|
// interface Locals {}
|
||||||
|
// interface PageData {}
|
||||||
|
// interface PageState {}
|
||||||
|
// interface Platform {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
15
frontend/src/app.html
Normal file
15
frontend/src/app.html
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"/>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.css"/>
|
||||||
|
<title>Nexus v2</title>
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body class="bg-dark-subtle p-3 m-0 border-0 bd-example m-0 border-0" data-bs-theme="dark" data-sveltekit-preload-data="hover">
|
||||||
|
<div class='bg-dark' style="display: contents">%sveltekit.body%</div>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.5/dist/js/bootstrap.bundle.min.js" integrity="sha384-k6d4wzSIapyDyv1kpU366/PK5hCdSbCRGRCMv+eplOQJWyd1fbcAu9OCUj5zNLiq" crossorigin="anonymous"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
645
frontend/src/lib/api.ts
Normal file
645
frontend/src/lib/api.ts
Normal file
@ -0,0 +1,645 @@
|
|||||||
|
import axios, {
|
||||||
|
type AxiosInstance, AxiosError,
|
||||||
|
type AxiosResponse,
|
||||||
|
type InternalAxiosRequestConfig
|
||||||
|
} from 'axios';
|
||||||
|
import {browser} from '$app/environment';
|
||||||
|
|
||||||
|
// TYPES
|
||||||
|
interface ExtendedAxiosRequestConfig extends InternalAxiosRequestConfig {
|
||||||
|
_retry?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Profile {
|
||||||
|
id: string;
|
||||||
|
user: User;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
primary_phone: string;
|
||||||
|
secondary_phone?: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileParams {
|
||||||
|
role?: string;
|
||||||
|
user_id?: string;
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
[key: string]: string | number | boolean | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Customer {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
primary_contact_first_name: string;
|
||||||
|
primary_contact_last_name: string;
|
||||||
|
primary_contact_phone: string;
|
||||||
|
primary_contact_email: string;
|
||||||
|
secondary_contact_first_name?: string;
|
||||||
|
secondary_contact_last_name?: string;
|
||||||
|
secondary_contact_phone?: string;
|
||||||
|
secondary_contact_email?: string;
|
||||||
|
billing_contact_first_name: string;
|
||||||
|
billing_contact_last_name: string;
|
||||||
|
billing_street_address: string;
|
||||||
|
billing_city: string;
|
||||||
|
billing_state: string;
|
||||||
|
billing_zip_code: string;
|
||||||
|
billing_email: string;
|
||||||
|
billing_terms: string;
|
||||||
|
start_date: string;
|
||||||
|
end_date?: string;
|
||||||
|
accounts?: Account[];
|
||||||
|
projects?: Project[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Account {
|
||||||
|
id: string;
|
||||||
|
customer: string | Customer;
|
||||||
|
name: string;
|
||||||
|
street_address: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
zip_code: string;
|
||||||
|
contact_first_name: string;
|
||||||
|
contact_last_name: string;
|
||||||
|
contact_phone: string;
|
||||||
|
contact_email: string;
|
||||||
|
start_date: string;
|
||||||
|
end_date?: string;
|
||||||
|
revenues?: Revenue[];
|
||||||
|
labors?: Labor[];
|
||||||
|
schedules?: Schedule[];
|
||||||
|
services?: Service[];
|
||||||
|
projects?: Project[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Schedule {
|
||||||
|
id: string;
|
||||||
|
account: string | Account;
|
||||||
|
monday_service: boolean;
|
||||||
|
tuesday_service: boolean;
|
||||||
|
wednesday_service: boolean;
|
||||||
|
thursday_service: boolean;
|
||||||
|
friday_service: boolean;
|
||||||
|
saturday_service: boolean;
|
||||||
|
sunday_service: boolean;
|
||||||
|
weekend_service: boolean;
|
||||||
|
schedule_exception?: string;
|
||||||
|
start_date: string;
|
||||||
|
end_date?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Revenue {
|
||||||
|
id: string;
|
||||||
|
account: string | Account;
|
||||||
|
amount: number;
|
||||||
|
start_date: string;
|
||||||
|
end_date?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Labor {
|
||||||
|
id: string;
|
||||||
|
account: string | Account;
|
||||||
|
amount: number;
|
||||||
|
start_date: string;
|
||||||
|
end_date?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Schedule {
|
||||||
|
id: string;
|
||||||
|
account: string | Account;
|
||||||
|
monday_service: boolean;
|
||||||
|
tuesday_service: boolean;
|
||||||
|
wednesday_service: boolean;
|
||||||
|
thursday_service: boolean;
|
||||||
|
friday_service: boolean;
|
||||||
|
saturday_service: boolean;
|
||||||
|
sunday_service: boolean;
|
||||||
|
weekend_service: boolean;
|
||||||
|
schedule_exception?: string;
|
||||||
|
start_date: string;
|
||||||
|
end_date?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Service {
|
||||||
|
id: string;
|
||||||
|
account: string | Account;
|
||||||
|
date: string;
|
||||||
|
status: 'scheduled' | 'in_progress' | 'completed' | 'cancelled';
|
||||||
|
team_members: string[] | Profile[];
|
||||||
|
notes?: string;
|
||||||
|
deadline_start: string;
|
||||||
|
deadline_end: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Project {
|
||||||
|
id: string;
|
||||||
|
customer: string | Customer;
|
||||||
|
account?: string | Account;
|
||||||
|
date: string;
|
||||||
|
status: 'planned' | 'in_progress' | 'completed' | 'cancelled';
|
||||||
|
team_members: string[] | Profile[]; // Change this to match Service
|
||||||
|
notes?: string;
|
||||||
|
labor: number;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Invoice {
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
customer: string | Customer;
|
||||||
|
accounts: Account[];
|
||||||
|
revenues: Revenue[];
|
||||||
|
projects: Project[];
|
||||||
|
status: 'draft' | 'sent' | 'paid' | 'overdue' | 'cancelled';
|
||||||
|
date_paid?: string;
|
||||||
|
payment_type?: 'check' | 'credit_card' | 'bank_transfer' | 'cash';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Report {
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
team_member: string | Profile;
|
||||||
|
services: Service[];
|
||||||
|
projects: Project[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerParams {
|
||||||
|
name?: string;
|
||||||
|
primary_contact_email?: string;
|
||||||
|
|
||||||
|
[key: string]: string | number | boolean | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountParams {
|
||||||
|
customer_id?: string;
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
[key: string]: string | number | boolean | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleParams {
|
||||||
|
account_id?: string;
|
||||||
|
active?: boolean;
|
||||||
|
start_date_from?: string;
|
||||||
|
start_date_to?: string;
|
||||||
|
end_date_from?: string;
|
||||||
|
end_date_to?: string;
|
||||||
|
|
||||||
|
[key: string]: string | number | boolean | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Holiday {
|
||||||
|
date: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceParams {
|
||||||
|
account_id?: string;
|
||||||
|
status?: string;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
team_member_id?: string;
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
[key: string]: string | number | boolean | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectParams {
|
||||||
|
customer_id?: string;
|
||||||
|
account_id?: string;
|
||||||
|
status?: string;
|
||||||
|
|
||||||
|
[key: string]: string | number | boolean | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvoiceParams {
|
||||||
|
customer_id?: string;
|
||||||
|
status?: string;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
|
||||||
|
[key: string]: string | number | boolean | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportParams {
|
||||||
|
team_member_id?: string;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
|
||||||
|
[key: string]: string | number | boolean | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TokenRefreshResponse {
|
||||||
|
access: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
count: number;
|
||||||
|
next: string | null;
|
||||||
|
previous: string | null;
|
||||||
|
results: T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceGenerationResponse {
|
||||||
|
message: string;
|
||||||
|
count: number;
|
||||||
|
month: number;
|
||||||
|
year: number;
|
||||||
|
accountsProcessed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PasswordChangeRequest extends Record<string, unknown> {
|
||||||
|
current_password: string;
|
||||||
|
new_password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PasswordResetRequest extends Record<string, unknown> {
|
||||||
|
user_id: string;
|
||||||
|
new_password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the base URL for API requests based on environment
|
||||||
|
* Uses PUBLIC_API_URL environment variable if set, otherwise defaults to localhost
|
||||||
|
* @returns The base URL for API requests
|
||||||
|
*/
|
||||||
|
const getBaseUrl = (): string => {
|
||||||
|
// Check for environment variable (set in .env or docker-compose)
|
||||||
|
const envApiUrl = import.meta.env.PUBLIC_API_URL;
|
||||||
|
if (envApiUrl) {
|
||||||
|
return envApiUrl;
|
||||||
|
}
|
||||||
|
// Default to localhost for development
|
||||||
|
return 'http://localhost:8000/api';
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Create an Axios instance with custom configuration
|
||||||
|
*/
|
||||||
|
const apiClient: AxiosInstance = axios.create({
|
||||||
|
baseURL: getBaseUrl(),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
timeout: 30000 // 30 seconds
|
||||||
|
});
|
||||||
|
/**
|
||||||
|
* Request interceptor for API calls
|
||||||
|
*/
|
||||||
|
apiClient.interceptors.request.use(
|
||||||
|
(config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => { // Change to InternalAxiosRequestConfig
|
||||||
|
const token = browser ? localStorage.getItem('access_token') : null;
|
||||||
|
if (token && config.headers) {
|
||||||
|
config.headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error: Error) => {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
/**
|
||||||
|
* Response interceptor for API calls
|
||||||
|
* Handles token refresh on 401 Unauthorized errors
|
||||||
|
*/
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response: AxiosResponse): AxiosResponse => response,
|
||||||
|
async (error: AxiosError) => {
|
||||||
|
const originalRequest = error.config as ExtendedAxiosRequestConfig;
|
||||||
|
if (!originalRequest) {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
if (error.response?.status === 401 && !originalRequest._retry && browser) {
|
||||||
|
originalRequest._retry = true;
|
||||||
|
const refreshToken = localStorage.getItem('refresh_token');
|
||||||
|
if (!refreshToken) {
|
||||||
|
console.error('No refresh token available. Redirecting to login...');
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('refresh_token');
|
||||||
|
window.location.href = '/login';
|
||||||
|
return Promise.reject(new Error('Authentication required'));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await axios.post<TokenRefreshResponse>(
|
||||||
|
`${getBaseUrl()}/token/refresh/`,
|
||||||
|
{refresh: refreshToken}
|
||||||
|
);
|
||||||
|
const {access} = response.data;
|
||||||
|
localStorage.setItem('access_token', access);
|
||||||
|
if (originalRequest.headers) {
|
||||||
|
originalRequest.headers['Authorization'] = `Bearer ${access}`;
|
||||||
|
}
|
||||||
|
return axios(originalRequest);
|
||||||
|
} catch (refreshError) {
|
||||||
|
console.error('Token refresh failed:', refreshError);
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('refresh_token');
|
||||||
|
window.location.href = '/login';
|
||||||
|
return Promise.reject(refreshError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
/**
|
||||||
|
* API service object with methods for common HTTP operations
|
||||||
|
*/
|
||||||
|
export const apiService = {
|
||||||
|
/**
|
||||||
|
* Make a GET request
|
||||||
|
* @param url - Endpoint URL (will be appended to base URL)
|
||||||
|
* @param params - Query parameters
|
||||||
|
* @returns Promise with response data
|
||||||
|
*/
|
||||||
|
get: async <T>(url: string, params?: Record<string, unknown>): Promise<T> => {
|
||||||
|
const response = await apiClient.get<T>(url, {params});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Make a POST request
|
||||||
|
* @param url - Endpoint URL (will be appended to base URL)
|
||||||
|
* @param data - Request body
|
||||||
|
* @returns Promise with response data
|
||||||
|
*/
|
||||||
|
post: async <T>(url: string, data?: Record<string, unknown>): Promise<T> => {
|
||||||
|
const response = await apiClient.post<T>(url, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Make a PUT request
|
||||||
|
* @param url - Endpoint URL (will be appended to base URL)
|
||||||
|
* @param data - Request body
|
||||||
|
* @returns Promise with response data
|
||||||
|
*/
|
||||||
|
put: async <T>(url: string, data?: Record<string, unknown>): Promise<T> => {
|
||||||
|
const response = await apiClient.put<T>(url, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Make a PATCH request
|
||||||
|
* @param url - Endpoint URL (will be appended to base URL)
|
||||||
|
* @param data - Request body
|
||||||
|
* @returns Promise with response data
|
||||||
|
*/
|
||||||
|
patch: async <T>(url: string, data?: Record<string, unknown>): Promise<T> => {
|
||||||
|
const response = await apiClient.patch<T>(url, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Make a DELETE request
|
||||||
|
* @param url - Endpoint URL (will be appended to base URL)
|
||||||
|
* @returns Promise with response data
|
||||||
|
*/
|
||||||
|
delete: async <T>(url: string): Promise<T> => {
|
||||||
|
const response = await apiClient.delete<T>(url);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
export const profileService = {
|
||||||
|
getAll: async (params?: ProfileParams) => {
|
||||||
|
// Initial request for the first page
|
||||||
|
const firstPageResponse = await apiService.get<PaginatedResponse<Profile>>('/profiles/', params);
|
||||||
|
let allProfiles = [...firstPageResponse.results];
|
||||||
|
// Get total number of pages based on count and page size (assuming default page size of 10)
|
||||||
|
const pageSize = 10; // Adjust if your API uses a different page size
|
||||||
|
const totalPages = Math.ceil(firstPageResponse.count / pageSize);
|
||||||
|
// Fetch subsequent pages if they exist
|
||||||
|
for (let page = 2; page <= totalPages; page++) {
|
||||||
|
const nextPageParams = {...params, page};
|
||||||
|
const nextPageResponse = await apiService.get<PaginatedResponse<Profile>>('/profiles/', nextPageParams);
|
||||||
|
allProfiles = [...allProfiles, ...nextPageResponse.results];
|
||||||
|
}
|
||||||
|
// Return a response that matches the PaginatedResponse structure but contains all results
|
||||||
|
return {
|
||||||
|
count: firstPageResponse.count,
|
||||||
|
next: null,
|
||||||
|
previous: null,
|
||||||
|
results: allProfiles
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getById: (id: string) => apiService.get<Profile>(`/profiles/${id}/`),
|
||||||
|
create: (data: Omit<Profile, 'id'>) => apiService.post<Profile>('/profiles/', data),
|
||||||
|
update: (id: string, data: Partial<Profile>) => apiService.put<Profile>(`/profiles/${id}/`, data),
|
||||||
|
patch: (id: string, data: Partial<Profile>) => apiService.patch<Profile>(`/profiles/${id}/`, data),
|
||||||
|
delete: (id: string) => apiService.delete<void>(`/profiles/${id}/`),
|
||||||
|
changePassword: (data: PasswordChangeRequest) => apiService.post<void>('/auth/change-password/', data),
|
||||||
|
resetPassword: (data: PasswordResetRequest) => apiService.post<void>('/auth/reset-password/', data)
|
||||||
|
};
|
||||||
|
export const customerService = {
|
||||||
|
getAll: async (params?: CustomerParams) => {
|
||||||
|
// Initial request for the first page
|
||||||
|
const firstPageResponse = await apiService.get<PaginatedResponse<Customer>>('/customers/', params);
|
||||||
|
let allCustomers = [...firstPageResponse.results];
|
||||||
|
// Get total number of pages based on count and page size (assuming default page size of 10)
|
||||||
|
const pageSize = 10; // Adjust if your API uses a different page size
|
||||||
|
const totalPages = Math.ceil(firstPageResponse.count / pageSize);
|
||||||
|
// Fetch subsequent pages if they exist
|
||||||
|
for (let page = 2; page <= totalPages; page++) {
|
||||||
|
const nextPageParams = {...params, page};
|
||||||
|
const nextPageResponse = await apiService.get<PaginatedResponse<Customer>>('/customers/', nextPageParams);
|
||||||
|
allCustomers = [...allCustomers, ...nextPageResponse.results];
|
||||||
|
}
|
||||||
|
// Return a response that matches the PaginatedResponse structure but contains all results
|
||||||
|
return {
|
||||||
|
count: firstPageResponse.count,
|
||||||
|
next: null,
|
||||||
|
previous: null,
|
||||||
|
results: allCustomers
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getById: (id: string) => apiService.get<Customer>(`/customers/${id}/`),
|
||||||
|
create: (data: Omit<Customer, 'id'>) => apiService.post<Customer>('/customers/', data),
|
||||||
|
update: (id: string, data: Partial<Customer>) => apiService.put<Customer>(`/customers/${id}/`, data),
|
||||||
|
patch: (id: string, data: Partial<Customer>) => apiService.patch<Customer>(`/customers/${id}/`, data),
|
||||||
|
delete: (id: string) => apiService.delete<void>(`/customers/${id}/`),
|
||||||
|
getAccounts: (id: string) => apiService.get<PaginatedResponse<Account>>(`/accounts/`, {customer_id: id})
|
||||||
|
};
|
||||||
|
export const accountService = {
|
||||||
|
getAll: async (params?: AccountParams) => {
|
||||||
|
// Initial request for the first page
|
||||||
|
const firstPageResponse = await apiService.get<PaginatedResponse<Account>>('/accounts/', params);
|
||||||
|
let allAccounts = [...firstPageResponse.results];
|
||||||
|
// Get total number of pages based on count and page size (assuming default page size of 10)
|
||||||
|
const pageSize = 10; // Adjust if your API uses a different page size
|
||||||
|
const totalPages = Math.ceil(firstPageResponse.count / pageSize);
|
||||||
|
// Fetch subsequent pages if they exist
|
||||||
|
for (let page = 2; page <= totalPages; page++) {
|
||||||
|
const nextPageParams = {...params, page};
|
||||||
|
const nextPageResponse = await apiService.get<PaginatedResponse<Account>>('/accounts/', nextPageParams);
|
||||||
|
allAccounts = [...allAccounts, ...nextPageResponse.results];
|
||||||
|
}
|
||||||
|
// Return a response that matches the PaginatedResponse structure but contains all results
|
||||||
|
return {
|
||||||
|
count: firstPageResponse.count,
|
||||||
|
next: null,
|
||||||
|
previous: null,
|
||||||
|
results: allAccounts
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getById: (id: string) => apiService.get<Account>(`/accounts/${id}/`),
|
||||||
|
create: (data: Omit<Account, 'id'>) => apiService.post<Account>('/accounts/', data),
|
||||||
|
update: (id: string, data: Partial<Account>) => apiService.put<Account>(`/accounts/${id}/`, data),
|
||||||
|
patch: (id: string, data: Partial<Account>) => apiService.patch<Account>(`/accounts/${id}/`, data),
|
||||||
|
delete: (id: string) => apiService.delete<void>(`/accounts/${id}/`),
|
||||||
|
getServices: (id: string) => apiService.get<PaginatedResponse<Service>>(`/services/`, {account_id: id}),
|
||||||
|
getRevenues: async (id: string) => {
|
||||||
|
// Initial request for the first page
|
||||||
|
const firstPageResponse = await apiService.get<PaginatedResponse<Revenue>>('/revenues/', {account_id: id});
|
||||||
|
let allRevenues = [...firstPageResponse.results];
|
||||||
|
|
||||||
|
// Get total number of pages based on count and page size
|
||||||
|
const pageSize = 10; // Adjust if your API uses a different page size
|
||||||
|
const totalPages = Math.ceil(firstPageResponse.count / pageSize);
|
||||||
|
|
||||||
|
// Fetch subsequent pages if they exist
|
||||||
|
for (let page = 2; page <= totalPages; page++) {
|
||||||
|
const nextPageParams = {account_id: id, page};
|
||||||
|
const nextPageResponse = await apiService.get<PaginatedResponse<Revenue>>('/revenues/', nextPageParams);
|
||||||
|
allRevenues = [...allRevenues, ...nextPageResponse.results];
|
||||||
|
}
|
||||||
|
|
||||||
|
return allRevenues;
|
||||||
|
}
|
||||||
|
,
|
||||||
|
createRevenue: (data: Omit<Revenue, 'id'>) => apiService.post<Revenue>('/revenues/', data),
|
||||||
|
updateRevenue: (id: string, data: Partial<Revenue>) => apiService.put<Revenue>(`/revenues/${id}/`, data),
|
||||||
|
deleteRevenue: (id: string) => apiService.delete<void>(`/revenues/${id}/`)
|
||||||
|
};
|
||||||
|
export const scheduleService = {
|
||||||
|
getAll: async (params?: ScheduleParams) => {
|
||||||
|
// Initial request for the first page
|
||||||
|
const firstPageResponse = await apiService.get<PaginatedResponse<Schedule>>('/schedules/', params);
|
||||||
|
let allSchedules = [...firstPageResponse.results];
|
||||||
|
// Get total number of pages based on count and page size (assuming default page size of 10)
|
||||||
|
const pageSize = 10; // Adjust if your API uses a different page size
|
||||||
|
const totalPages = Math.ceil(firstPageResponse.count / pageSize);
|
||||||
|
// Fetch subsequent pages if they exist
|
||||||
|
for (let page = 2; page <= totalPages; page++) {
|
||||||
|
const nextPageParams = {...params, page};
|
||||||
|
const nextPageResponse = await apiService.get<PaginatedResponse<Schedule>>('/schedules/', nextPageParams);
|
||||||
|
allSchedules = [...allSchedules, ...nextPageResponse.results];
|
||||||
|
}
|
||||||
|
// Return a response that matches the PaginatedResponse structure but contains all results
|
||||||
|
return {
|
||||||
|
count: firstPageResponse.count,
|
||||||
|
next: null,
|
||||||
|
previous: null,
|
||||||
|
results: allSchedules
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getById: (id: string) => apiService.get<Schedule>(`/schedules/${id}/`),
|
||||||
|
create: (data: Omit<Schedule, 'id'>) => apiService.post<Schedule>('/schedules/', data),
|
||||||
|
update: (id: string, data: Partial<Schedule>) => apiService.put<Schedule>(`/schedules/${id}/`, data),
|
||||||
|
patch: (id: string, data: Partial<Schedule>) => apiService.patch<Schedule>(`/schedules/${id}/`, data),
|
||||||
|
delete: (id: string) => apiService.delete<void>(`/schedules/${id}/`),
|
||||||
|
generateServices: (month: number, year: number, accountIds?: string[]) =>
|
||||||
|
apiService.post<ServiceGenerationResponse>('/generate-services/', {
|
||||||
|
month,
|
||||||
|
year,
|
||||||
|
account_ids: accountIds
|
||||||
|
})
|
||||||
|
};
|
||||||
|
export const serviceService = {
|
||||||
|
getAll: async (params?: ServiceParams) => {
|
||||||
|
// Initial request for the first page
|
||||||
|
const firstPageResponse = await apiService.get<PaginatedResponse<Service>>('/services/', params);
|
||||||
|
let allServices = [...firstPageResponse.results];
|
||||||
|
// Get total number of pages based on count and page size (assuming default page size of 10)
|
||||||
|
const pageSize = 10; // Adjust if your API uses a different page size
|
||||||
|
const totalPages = Math.ceil(firstPageResponse.count / pageSize);
|
||||||
|
// Fetch subsequent pages if they exist
|
||||||
|
for (let page = 2; page <= totalPages; page++) {
|
||||||
|
const nextPageParams = {...params, page};
|
||||||
|
const nextPageResponse = await apiService.get<PaginatedResponse<Service>>('/services/', nextPageParams);
|
||||||
|
allServices = [...allServices, ...nextPageResponse.results];
|
||||||
|
}
|
||||||
|
// Return a response that matches the PaginatedResponse structure but contains all results
|
||||||
|
return {
|
||||||
|
count: firstPageResponse.count,
|
||||||
|
next: null,
|
||||||
|
previous: null,
|
||||||
|
results: allServices
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getById: (id: string) => apiService.get<Service>(`/services/${id}/`),
|
||||||
|
create: (data: Omit<Service, 'id'>) => apiService.post<Service>('/services/', data),
|
||||||
|
update: (id: string, data: Partial<Service>) => apiService.put<Service>(`/services/${id}/`, data),
|
||||||
|
patch: (id: string, data: Partial<Service>) => apiService.patch<Service>(`/services/${id}/`, data),
|
||||||
|
delete: (id: string) => apiService.delete<void>(`/services/${id}/`)
|
||||||
|
};
|
||||||
|
export const projectService = {
|
||||||
|
getAll: async (params?: ProjectParams) => {
|
||||||
|
// Initial request for the first page
|
||||||
|
const firstPageResponse = await apiService.get<PaginatedResponse<Project>>('/projects/', params);
|
||||||
|
let allProjects = [...firstPageResponse.results];
|
||||||
|
// Get total number of pages based on count and page size (assuming default page size of 10)
|
||||||
|
const pageSize = 10; // Adjust if your API uses a different page size
|
||||||
|
const totalPages = Math.ceil(firstPageResponse.count / pageSize);
|
||||||
|
// Fetch subsequent pages if they exist
|
||||||
|
for (let page = 2; page <= totalPages; page++) {
|
||||||
|
const nextPageParams = {...params, page};
|
||||||
|
const nextPageResponse = await apiService.get<PaginatedResponse<Project>>('/projects/', nextPageParams);
|
||||||
|
allProjects = [...allProjects, ...nextPageResponse.results];
|
||||||
|
}
|
||||||
|
// Return a response that matches the PaginatedResponse structure but contains all results
|
||||||
|
return {
|
||||||
|
count: firstPageResponse.count,
|
||||||
|
next: null,
|
||||||
|
previous: null,
|
||||||
|
results: allProjects
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getById: (id: string) => apiService.get<Project>(`/projects/${id}/`),
|
||||||
|
create: (data: Omit<Project, 'id'>) => apiService.post<Project>('/projects/', data),
|
||||||
|
update: (id: string, data: Partial<Project>) => apiService.put<Project>(`/projects/${id}/`, data),
|
||||||
|
patch: (id: string, data: Partial<Project>) => apiService.patch<Project>(`/projects/${id}/`, data),
|
||||||
|
delete: (id: string) => apiService.delete<void>(`/projects/${id}/`)
|
||||||
|
};
|
||||||
|
export const invoiceService = {
|
||||||
|
getAll: async (params?: InvoiceParams) => {
|
||||||
|
// Initial request for the first page
|
||||||
|
const firstPageResponse = await apiService.get<PaginatedResponse<Invoice>>('/invoices/', params);
|
||||||
|
let allInvoices = [...firstPageResponse.results];
|
||||||
|
// Get total number of pages based on count and page size (assuming default page size of 10)
|
||||||
|
const pageSize = 10; // Adjust if your API uses a different page size
|
||||||
|
const totalPages = Math.ceil(firstPageResponse.count / pageSize);
|
||||||
|
// Fetch subsequent pages if they exist
|
||||||
|
for (let page = 2; page <= totalPages; page++) {
|
||||||
|
const nextPageParams = {...params, page};
|
||||||
|
const nextPageResponse = await apiService.get<PaginatedResponse<Invoice>>('/invoices/', nextPageParams);
|
||||||
|
allInvoices = [...allInvoices, ...nextPageResponse.results];
|
||||||
|
}
|
||||||
|
// Return a response that matches the PaginatedResponse structure but contains all results
|
||||||
|
return {
|
||||||
|
count: firstPageResponse.count,
|
||||||
|
next: null,
|
||||||
|
previous: null,
|
||||||
|
results: allInvoices
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getById: (id: string) => apiService.get<Invoice>(`/invoices/${id}/`),
|
||||||
|
create: (data: Omit<Invoice, 'id'>) => apiService.post<Invoice>('/invoices/', data),
|
||||||
|
update: (id: string, data: Partial<Invoice>) => apiService.put<Invoice>(`/invoices/${id}/`, data),
|
||||||
|
patch: (id: string, data: Partial<Invoice>) => apiService.patch<Invoice>(`/invoices/${id}/`, data),
|
||||||
|
delete: (id: string) => apiService.delete<void>(`/invoices/${id}/`),
|
||||||
|
markAsPaid: (id: string, data: Record<string, unknown>) =>
|
||||||
|
apiService.post<Invoice>(`/invoices/${id}/mark_as_paid/`, data)
|
||||||
|
};
|
||||||
|
export const reportService = {
|
||||||
|
getAll: (params?: ReportParams) => apiService.get<PaginatedResponse<Report>>('/reports/', params),
|
||||||
|
getById: (id: string) => apiService.get<Report>(`/reports/${id}/`),
|
||||||
|
create: (data: Omit<Report, 'id'>) => apiService.post<Report>('/reports/', data),
|
||||||
|
update: (id: string, data: Partial<Report>) => apiService.put<Report>(`/reports/${id}/`, data),
|
||||||
|
patch: (id: string, data: Partial<Report>) => apiService.patch<Report>(`/reports/${id}/`, data),
|
||||||
|
delete: (id: string) => apiService.delete<void>(`/reports/${id}/`)
|
||||||
|
};
|
||||||
|
export default apiClient;
|
||||||
218
frontend/src/lib/auth.ts
Normal file
218
frontend/src/lib/auth.ts
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
import {browser} from '$app/environment';
|
||||||
|
import {writable, derived} from 'svelte/store';
|
||||||
|
import axios from 'axios';
|
||||||
|
import apiClient, {type User, type Profile} from './api.js';
|
||||||
|
|
||||||
|
// TYPES
|
||||||
|
interface AuthState {
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
user: User | null;
|
||||||
|
profile: Profile | null;
|
||||||
|
accessToken: string | null;
|
||||||
|
refreshToken: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoginCredentials {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TokenResponse {
|
||||||
|
access: string;
|
||||||
|
refresh: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// INITIAL STATE
|
||||||
|
const initialState: AuthState = {
|
||||||
|
isAuthenticated: false,
|
||||||
|
user: null,
|
||||||
|
profile: null,
|
||||||
|
accessToken: browser ? localStorage.getItem('access_token') : null,
|
||||||
|
refreshToken: browser ? localStorage.getItem('refresh_token') : null,
|
||||||
|
loading: false,
|
||||||
|
error: null
|
||||||
|
};
|
||||||
|
|
||||||
|
// AUTH STORE FACTORY FUNCTION
|
||||||
|
const createAuthStore = () => {
|
||||||
|
const {subscribe, set, update} = writable<AuthState>(initialState);
|
||||||
|
if (browser && initialState.accessToken) {
|
||||||
|
update(state => ({
|
||||||
|
...state,
|
||||||
|
isAuthenticated: true,
|
||||||
|
loading: true
|
||||||
|
}));
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await loadUserProfile();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during initial profile loading:', error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
// LOAD USER PROFILE
|
||||||
|
async function loadUserProfile() {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get('/auth/user/');
|
||||||
|
const data = response.data;
|
||||||
|
const userData = data.user as User;
|
||||||
|
const profileData = data.profile as Profile | null;
|
||||||
|
update(state => ({
|
||||||
|
...state,
|
||||||
|
isAuthenticated: true,
|
||||||
|
user: userData,
|
||||||
|
profile: profileData,
|
||||||
|
loading: false,
|
||||||
|
error: null
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading user profile:', error);
|
||||||
|
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||||
|
logout();
|
||||||
|
} else {
|
||||||
|
update(state => ({
|
||||||
|
...state,
|
||||||
|
loading: false,
|
||||||
|
error: 'Failed to load user profile'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LOGIN FUNCTION - SETS ACCESS AND REFRESH TOKENS
|
||||||
|
async function login(credentials: LoginCredentials) {
|
||||||
|
update(state => ({...state, loading: true, error: null}));
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post<TokenResponse>(
|
||||||
|
`/token/`,
|
||||||
|
credentials
|
||||||
|
);
|
||||||
|
const {access, refresh} = response.data;
|
||||||
|
if (browser) {
|
||||||
|
localStorage.setItem('access_token', access);
|
||||||
|
localStorage.setItem('refresh_token', refresh);
|
||||||
|
}
|
||||||
|
update(state => ({
|
||||||
|
...state,
|
||||||
|
accessToken: access,
|
||||||
|
refreshToken: refresh,
|
||||||
|
isAuthenticated: true,
|
||||||
|
}));
|
||||||
|
await loadUserProfile();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login failed:', error);
|
||||||
|
let errorMessage = 'Authentication failed';
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
errorMessage = 'Invalid username or password';
|
||||||
|
} else if (error.response?.data?.detail) {
|
||||||
|
errorMessage = error.response.data.detail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
update(state => ({
|
||||||
|
...state,
|
||||||
|
loading: false,
|
||||||
|
error: errorMessage,
|
||||||
|
isAuthenticated: false,
|
||||||
|
user: null,
|
||||||
|
profile: null,
|
||||||
|
accessToken: null,
|
||||||
|
refreshToken: null
|
||||||
|
}));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LOGOUT FUNCTION - REMOVES TOKENS AND CLEARS STORES
|
||||||
|
function logout() {
|
||||||
|
if (browser) {
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('refresh_token');
|
||||||
|
}
|
||||||
|
set({
|
||||||
|
isAuthenticated: false,
|
||||||
|
user: null,
|
||||||
|
profile: null,
|
||||||
|
accessToken: null,
|
||||||
|
refreshToken: null,
|
||||||
|
loading: false,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
if (browser) {
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TOKEN REFRESH FUNCTION
|
||||||
|
async function refreshToken(): Promise<string | null> {
|
||||||
|
const currentState = getAuthState();
|
||||||
|
if (!currentState.refreshToken) {
|
||||||
|
logout();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post<{ access: string }>(
|
||||||
|
`/token/refresh/`,
|
||||||
|
{refresh: currentState.refreshToken}
|
||||||
|
);
|
||||||
|
const newAccessToken = response.data.access;
|
||||||
|
if (browser) {
|
||||||
|
localStorage.setItem('access_token', newAccessToken);
|
||||||
|
}
|
||||||
|
update(state => ({
|
||||||
|
...state,
|
||||||
|
accessToken: newAccessToken
|
||||||
|
}));
|
||||||
|
return newAccessToken;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Token refresh failed:', error);
|
||||||
|
logout();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HELPER FUNCTION FOR CURRENT STATE
|
||||||
|
function getAuthState(): AuthState {
|
||||||
|
let value: AuthState = initialState;
|
||||||
|
subscribe(state => {
|
||||||
|
value = state;
|
||||||
|
})();
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
refreshToken,
|
||||||
|
loadUserProfile
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// CREATE AND EXPORT AUTH STORE
|
||||||
|
export const auth = createAuthStore();
|
||||||
|
|
||||||
|
// DERIVED STORES FOR COMPONENTS
|
||||||
|
export const isAuthenticated = derived(
|
||||||
|
auth,
|
||||||
|
$auth => $auth.isAuthenticated
|
||||||
|
);
|
||||||
|
export const profile = derived(
|
||||||
|
auth,
|
||||||
|
$auth => $auth.profile
|
||||||
|
);
|
||||||
|
export const loading = derived(
|
||||||
|
auth,
|
||||||
|
$auth => $auth.loading
|
||||||
|
);
|
||||||
|
export const error = derived(
|
||||||
|
auth,
|
||||||
|
$auth => $auth.error
|
||||||
|
);
|
||||||
|
|
||||||
|
// EXPORT TYPES FOR COMPONENTS
|
||||||
|
export type {AuthState, LoginCredentials, TokenResponse};
|
||||||
218
frontend/src/lib/components/Navbar.svelte
Normal file
218
frontend/src/lib/components/Navbar.svelte
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
<script>
|
||||||
|
import {auth, isAuthenticated, profile} from '$lib/auth';
|
||||||
|
// STATES
|
||||||
|
let isAdmin = $state(false);
|
||||||
|
let isTeamLeader = $state(false);
|
||||||
|
let userName = $state('User');
|
||||||
|
// EFFECT UPDATES WITH PROFILE UPDATES
|
||||||
|
$effect(() => {
|
||||||
|
if ($profile) {
|
||||||
|
isAdmin = $profile.role === 'admin';
|
||||||
|
isTeamLeader = $profile.role === 'team_leader';
|
||||||
|
userName = `${$profile.first_name} ${$profile.last_name}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// LOGOUT FUNCTION
|
||||||
|
const handleLogout = () => {
|
||||||
|
auth.logout();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<!-- Bootstrap 5 Navbar -->
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand" href="/">Nexus 2</a>
|
||||||
|
<button
|
||||||
|
class="navbar-toggler"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="collapse"
|
||||||
|
data-bs-target="#navbarSupportedContent"
|
||||||
|
aria-controls="navbarSupportedContent"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-label="Toggle navigation"
|
||||||
|
>
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
||||||
|
<ul class="navbar-nav">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" href="/">Home</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{#if $isAuthenticated}
|
||||||
|
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/dashboard">Dashboard</a>
|
||||||
|
</li>
|
||||||
|
<!-- Customers Dropdown -->
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a
|
||||||
|
class="nav-link dropdown-toggle"
|
||||||
|
id="customersDropdown"
|
||||||
|
role="button"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
Customers
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu" aria-labelledby="customersDropdown">
|
||||||
|
<li><a class="dropdown-item" href="/customers">Customers List</a></li>
|
||||||
|
<li><a class="dropdown-item" href="/customers/new">Add Customer</a></li>
|
||||||
|
<li>
|
||||||
|
<hr class="dropdown-divider">
|
||||||
|
</li>
|
||||||
|
<li><a class="dropdown-item" href="/customers/">Customer Reports</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<!-- Accounts Dropdown -->
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a
|
||||||
|
class="nav-link dropdown-toggle"
|
||||||
|
id="accountsDropdown"
|
||||||
|
role="button"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
Accounts
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu" aria-labelledby="accountsDropdown">
|
||||||
|
<li><a class="dropdown-item" href="/accounts">Accounts List</a></li>
|
||||||
|
<li><a class="dropdown-item" href="/accounts/new">Add Account</a></li>
|
||||||
|
{#if isAdmin || isTeamLeader}
|
||||||
|
<li>
|
||||||
|
<hr class="dropdown-divider">
|
||||||
|
</li>
|
||||||
|
<li><a class="dropdown-item" href="/schedules">Schedules</a></li>
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<!-- Services Dropdown -->
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a
|
||||||
|
class="nav-link dropdown-toggle"
|
||||||
|
id="servicesDropdown"
|
||||||
|
role="button"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
Services
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu" aria-labelledby="servicesDropdown">
|
||||||
|
<li><a class="dropdown-item" href="/services">Services List</a></li>
|
||||||
|
<li><a class="dropdown-item" href="/services/new">Schedule Service</a></li>
|
||||||
|
<li>
|
||||||
|
<hr class="dropdown-divider">
|
||||||
|
</li>
|
||||||
|
<li><a class="dropdown-item" href="/services/generate">Bulk Scheduling</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<!-- Projects Dropdown -->
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a
|
||||||
|
class="nav-link dropdown-toggle"
|
||||||
|
id="projectsDropdown"
|
||||||
|
role="button"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
Projects
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu" aria-labelledby="projectsDropdown">
|
||||||
|
<li><a class="dropdown-item" href="/projects">Projects List</a></li>
|
||||||
|
<li><a class="dropdown-item" href="/projects/new">Schedule Project</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<!-- Invoices Dropdown -->
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a
|
||||||
|
class="nav-link dropdown-toggle"
|
||||||
|
id="invoicesDropdown"
|
||||||
|
role="button"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
Invoices
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu" aria-labelledby="invoicesDropdown">
|
||||||
|
<li><a class="dropdown-item" href="/invoices">All Invoices</a></li>
|
||||||
|
<li><a class="dropdown-item" href="/invoices/new">Create Invoice</a></li>
|
||||||
|
<li>
|
||||||
|
<hr class="dropdown-divider">
|
||||||
|
</li>
|
||||||
|
<li><a class="dropdown-item" href="/invoices/">Invoices Placeholder</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<!-- Calendar Dropdown -->
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a
|
||||||
|
class="nav-link dropdown-toggle"
|
||||||
|
id="calendarDropdown"
|
||||||
|
role="button"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
Calendar
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu" aria-labelledby="calendarDropdown">
|
||||||
|
{#if isAdmin || isTeamLeader}
|
||||||
|
<li><a class="dropdown-item" href="/calendar/create">Create Event (Google)</a></li>
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<!-- Admin Dropdown (only visible to admins) -->
|
||||||
|
{#if isAdmin}
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a
|
||||||
|
class="nav-link dropdown-toggle"
|
||||||
|
id="adminDropdown"
|
||||||
|
role="button"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
Admin
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu" aria-labelledby="adminDropdown">
|
||||||
|
<li><a class="dropdown-item" href="/admin/users">Users</a></li>
|
||||||
|
<li><a class="dropdown-item" href="/admin/reports">Reports</a></li>
|
||||||
|
<li>
|
||||||
|
<hr class="dropdown-divider">
|
||||||
|
</li>
|
||||||
|
<li><a class="dropdown-item" href="/admin/settings">Settings</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
<!-- User Menu -->
|
||||||
|
<ul class="navbar-nav">
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a
|
||||||
|
class="nav-link dropdown-toggle"
|
||||||
|
id="userDropdown"
|
||||||
|
role="button"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
{userName}
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="userDropdown">
|
||||||
|
<li><a class="dropdown-item" href="/profile">My Profile</a></li>
|
||||||
|
<li><a class="dropdown-item" href="/profile/schedule">My Schedule</a></li>
|
||||||
|
<li>
|
||||||
|
<hr class="dropdown-divider">
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button class="dropdown-item" onclick={handleLogout}>Logout</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{:else}
|
||||||
|
<!-- Not authenticated -->
|
||||||
|
<ul class="navbar-nav ms-auto">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/login">Login</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
201
frontend/src/lib/google.ts
Normal file
201
frontend/src/lib/google.ts
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
import apiClient, {type Service, type Project} from './api.js';
|
||||||
|
|
||||||
|
// Calendar Event type definition
|
||||||
|
export interface CalendarEvent {
|
||||||
|
summary: string;
|
||||||
|
description: string;
|
||||||
|
start: string; // ISO format datetime string
|
||||||
|
end: string; // ISO format datetime string
|
||||||
|
location: string;
|
||||||
|
attendees: { email: string }[];
|
||||||
|
reminders?: { method: string; minutes: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interface for the response we expect from the API
|
||||||
|
export interface CalendarEventResponse {
|
||||||
|
id?: string;
|
||||||
|
htmlLink?: string;
|
||||||
|
error?: string;
|
||||||
|
details?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type for API errors
|
||||||
|
interface ApiError {
|
||||||
|
response?: {
|
||||||
|
data?: {
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service for handling Google Calendar API
|
||||||
|
export const calendarService = {
|
||||||
|
/**
|
||||||
|
* Create a calendar event
|
||||||
|
* @param event - Calendar event data
|
||||||
|
* @returns Promise with the created event data
|
||||||
|
*/
|
||||||
|
createEvent: async (event: CalendarEvent): Promise<CalendarEventResponse> => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post<CalendarEventResponse>('/calendar/create/', event);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('Error creating calendar event:', error);
|
||||||
|
const apiError = error as ApiError;
|
||||||
|
return {
|
||||||
|
error: 'Failed to create calendar event',
|
||||||
|
details: apiError.response?.data?.error || apiError.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a calendar event from a service
|
||||||
|
* @param service - The service to create an event for
|
||||||
|
* @param additionalAttendees - Additional email addresses to invite
|
||||||
|
* @returns Promise with the created event data
|
||||||
|
*/
|
||||||
|
createEventFromService: async (service: Service, additionalAttendees: string[] = []): Promise<CalendarEventResponse> => {
|
||||||
|
|
||||||
|
// Extract account and location information
|
||||||
|
const account = typeof service.account === 'object' ? service.account : null;
|
||||||
|
if (!account) {
|
||||||
|
return {
|
||||||
|
error: 'Service account information missing',
|
||||||
|
details: 'Cannot create calendar event without account information'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const location = `${account.street_address}, ${account.city}, ${account.state} ${account.zip_code}`;
|
||||||
|
|
||||||
|
// Extract team members for attendees
|
||||||
|
const attendees = [
|
||||||
|
// Add team members as attendees
|
||||||
|
...(service.team_members || []).map(member => ({
|
||||||
|
email: typeof member === 'object' ? member.email : ''
|
||||||
|
})),
|
||||||
|
// Add additional attendees
|
||||||
|
...additionalAttendees.map(email => ({email}))
|
||||||
|
].filter(attendee => attendee.email); // Filter out empty emails
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create event data
|
||||||
|
const eventData: CalendarEvent = {
|
||||||
|
summary: `Service: ${account.name}`,
|
||||||
|
description: `Service scheduled for ${account.name}.\n\nNotes: ${service.notes || 'None'}`,
|
||||||
|
start: service.deadline_start,
|
||||||
|
end: service.deadline_end,
|
||||||
|
location,
|
||||||
|
attendees,
|
||||||
|
reminders: [
|
||||||
|
{method: 'email', minutes: 60}, // 2 hours before
|
||||||
|
{method: 'popup', minutes: 30} // 30 minutes before
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
return await calendarService.createEvent(eventData);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('Error creating event from service:', error);
|
||||||
|
const typedError = error as Error;
|
||||||
|
return {
|
||||||
|
error: 'Failed to create calendar event from service',
|
||||||
|
details: typedError.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a calendar event from a project
|
||||||
|
* @param project - The project to create an event for
|
||||||
|
* @param additionalAttendees - Additional email addresses to invite
|
||||||
|
* @param startTime - Start time in HH:MM format (optional)
|
||||||
|
* @param endTime - End time in HH:MM format (optional)
|
||||||
|
* @returns Promise with the created event data
|
||||||
|
*/
|
||||||
|
createEventFromProject: async (
|
||||||
|
project: Project,
|
||||||
|
additionalAttendees: string[] = [],
|
||||||
|
startTime: string = '09:00',
|
||||||
|
endTime: string = '17:00'
|
||||||
|
): Promise<CalendarEventResponse> => {
|
||||||
|
// Extract customer and account information
|
||||||
|
const customer = typeof project.customer === 'object' ? project.customer : null;
|
||||||
|
const account = typeof project.account === 'object' ? project.account : null;
|
||||||
|
|
||||||
|
if (!customer) {
|
||||||
|
return {
|
||||||
|
error: 'Project customer information missing',
|
||||||
|
details: 'Cannot create calendar event without customer information'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine location based on account or customer
|
||||||
|
const location = account
|
||||||
|
? `${account.street_address}, ${account.city}, ${account.state} ${account.zip_code}`
|
||||||
|
: `${customer.billing_street_address}, ${customer.billing_city}, ${customer.billing_state} ${customer.billing_zip_code}`;
|
||||||
|
|
||||||
|
// Create date strings with time
|
||||||
|
const projectDate = project.date.split('T')[0]; // Get just the date part
|
||||||
|
|
||||||
|
// Parse the time strings to determine if this is an overnight event
|
||||||
|
const startHour = parseInt(startTime.split(':')[0], 10);
|
||||||
|
const startMinute = parseInt(startTime.split(':')[1], 10);
|
||||||
|
const endHour = parseInt(endTime.split(':')[0], 10);
|
||||||
|
const endMinute = parseInt(endTime.split(':')[1], 10);
|
||||||
|
|
||||||
|
// Check if end time is earlier than start time (overnight event)
|
||||||
|
const isOvernightEvent = (endHour < startHour) ||
|
||||||
|
(endHour === startHour && endMinute < startMinute);
|
||||||
|
|
||||||
|
// Create start datetime
|
||||||
|
const startDateTime = `${projectDate}T${startTime}:00`;
|
||||||
|
|
||||||
|
// For end datetime, add a day if it's an overnight event
|
||||||
|
let endDateTime;
|
||||||
|
if (isOvernightEvent) {
|
||||||
|
// Create a Date object from the project date
|
||||||
|
const endDate = new Date(projectDate);
|
||||||
|
// Add one day
|
||||||
|
endDate.setDate(endDate.getDate() + 1);
|
||||||
|
// Format the date as YYYY-MM-DD
|
||||||
|
const nextDay = endDate.toISOString().split('T')[0];
|
||||||
|
// Create the end datetime with the next day
|
||||||
|
endDateTime = `${nextDay}T${endTime}:00`;
|
||||||
|
} else {
|
||||||
|
// Same day event
|
||||||
|
endDateTime = `${projectDate}T${endTime}:00`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rest of your function remains the same...
|
||||||
|
const attendees = [
|
||||||
|
...(project.team_members || []).map(member => ({
|
||||||
|
email: typeof member === 'object' ? member.email : ''
|
||||||
|
})),
|
||||||
|
...additionalAttendees.map(email => ({email}))
|
||||||
|
].filter(attendee => attendee.email);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const eventData = {
|
||||||
|
summary: `Project: ${customer.name}${account ? ` - ${account.name}` : ''}`,
|
||||||
|
description: project.notes || `Project for ${customer.name}`,
|
||||||
|
start: startDateTime,
|
||||||
|
end: endDateTime,
|
||||||
|
location,
|
||||||
|
attendees
|
||||||
|
};
|
||||||
|
|
||||||
|
return await calendarService.createEvent(eventData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating calendar event from project:', error);
|
||||||
|
return {
|
||||||
|
error: 'Failed to create calendar event',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
export default calendarService;
|
||||||
141
frontend/src/lib/punchlist.ts
Normal file
141
frontend/src/lib/punchlist.ts
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import {apiService, type Account, type Project, type PaginatedResponse} from "./api";
|
||||||
|
|
||||||
|
export interface Punchlist {
|
||||||
|
id: string;
|
||||||
|
project: string | Project;
|
||||||
|
account: string | Account;
|
||||||
|
date: string;
|
||||||
|
second_visit: boolean;
|
||||||
|
second_date: string | null;
|
||||||
|
// Front area
|
||||||
|
front_ceiling: boolean;
|
||||||
|
front_vents: boolean;
|
||||||
|
front_fixtures: boolean;
|
||||||
|
front_counter: boolean;
|
||||||
|
// Main work area
|
||||||
|
main_equipment: string;
|
||||||
|
main_equipment_disassemble: boolean;
|
||||||
|
main_equipment_reassemble: boolean;
|
||||||
|
main_equipment_test: boolean;
|
||||||
|
main_equipment_exterior: boolean;
|
||||||
|
main_walls: boolean;
|
||||||
|
main_fixtures: boolean;
|
||||||
|
main_ceiling: boolean;
|
||||||
|
main_vents: boolean;
|
||||||
|
main_floors: boolean;
|
||||||
|
// Equipment stations
|
||||||
|
equip_station_1: boolean;
|
||||||
|
equip_station_2: boolean;
|
||||||
|
equip_station_3: boolean;
|
||||||
|
equip_station_4: boolean;
|
||||||
|
equip_station_5: boolean;
|
||||||
|
equip_station_6: boolean;
|
||||||
|
equip_station_7: boolean;
|
||||||
|
equip_sinks: boolean;
|
||||||
|
equip_dispensers: boolean;
|
||||||
|
equip_other: boolean;
|
||||||
|
// Back area
|
||||||
|
back_ceiling: boolean;
|
||||||
|
back_vents: boolean;
|
||||||
|
// End of visit
|
||||||
|
end_trash: boolean;
|
||||||
|
end_clean: boolean;
|
||||||
|
end_secure: boolean;
|
||||||
|
// Notes and metadata
|
||||||
|
notes: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
sheet_url?: string;
|
||||||
|
pdf_url?: string;
|
||||||
|
exported_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PunchlistCreateRequest {
|
||||||
|
project_id: string;
|
||||||
|
account_id: string;
|
||||||
|
date: string;
|
||||||
|
second_visit: boolean;
|
||||||
|
second_date: string | null;
|
||||||
|
// Front area
|
||||||
|
front_ceiling: boolean;
|
||||||
|
front_vents: boolean;
|
||||||
|
front_fixtures: boolean;
|
||||||
|
front_counter: boolean;
|
||||||
|
// Main work area
|
||||||
|
main_equipment: string;
|
||||||
|
main_equipment_disassemble: boolean;
|
||||||
|
main_equipment_reassemble: boolean;
|
||||||
|
main_equipment_test: boolean;
|
||||||
|
main_equipment_exterior: boolean;
|
||||||
|
main_walls: boolean;
|
||||||
|
main_fixtures: boolean;
|
||||||
|
main_ceiling: boolean;
|
||||||
|
main_vents: boolean;
|
||||||
|
main_floors: boolean;
|
||||||
|
// Equipment stations
|
||||||
|
equip_station_1: boolean;
|
||||||
|
equip_station_2: boolean;
|
||||||
|
equip_station_3: boolean;
|
||||||
|
equip_station_4: boolean;
|
||||||
|
equip_station_5: boolean;
|
||||||
|
equip_station_6: boolean;
|
||||||
|
equip_station_7: boolean;
|
||||||
|
equip_sinks: boolean;
|
||||||
|
equip_dispensers: boolean;
|
||||||
|
equip_other: boolean;
|
||||||
|
// Back area
|
||||||
|
back_ceiling: boolean;
|
||||||
|
back_vents: boolean;
|
||||||
|
// End of visit
|
||||||
|
end_trash: boolean;
|
||||||
|
end_clean: boolean;
|
||||||
|
end_secure: boolean;
|
||||||
|
// Notes
|
||||||
|
notes: string;
|
||||||
|
|
||||||
|
// Index signature for dynamic access
|
||||||
|
[key: string]: string | boolean | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PunchlistParams {
|
||||||
|
project_id?: string;
|
||||||
|
account_id?: string;
|
||||||
|
|
||||||
|
[key: string]: string | number | boolean | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportPunchlistResponse {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
sheetUrl?: string;
|
||||||
|
pdfUrl?: string;
|
||||||
|
alreadyExported?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const punchlistService = {
|
||||||
|
getAll: async (params?: PunchlistParams) => {
|
||||||
|
const firstPageResponse = await apiService.get<PaginatedResponse<Punchlist>>('/punchlists/', params);
|
||||||
|
let allPunchlists = [...firstPageResponse.results];
|
||||||
|
|
||||||
|
const pageSize = 10;
|
||||||
|
const totalPages = Math.ceil(firstPageResponse.count / pageSize);
|
||||||
|
|
||||||
|
for (let page = 2; page <= totalPages; page++) {
|
||||||
|
const nextPageParams = {...params, page};
|
||||||
|
const nextPageResponse = await apiService.get<PaginatedResponse<Punchlist>>('/punchlists/', nextPageParams);
|
||||||
|
allPunchlists = [...allPunchlists, ...nextPageResponse.results];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
count: firstPageResponse.count,
|
||||||
|
next: null,
|
||||||
|
previous: null,
|
||||||
|
results: allPunchlists
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getById: (id: string) => apiService.get<Punchlist>(`/punchlists/${id}/`),
|
||||||
|
create: (data: PunchlistCreateRequest) => apiService.post<Punchlist>('/punchlists/', data),
|
||||||
|
update: (id: string, data: Partial<Punchlist>) => apiService.put<Punchlist>(`/punchlists/${id}/`, data),
|
||||||
|
patch: (id: string, data: Partial<Punchlist>) => apiService.patch<Punchlist>(`/punchlists/${id}/`, data),
|
||||||
|
delete: (id: string) => apiService.delete<void>(`/punchlists/${id}/`)
|
||||||
|
};
|
||||||
6
frontend/src/routes/+layout.svelte
Normal file
6
frontend/src/routes/+layout.svelte
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<script>
|
||||||
|
import Navbar from "$lib/components/Navbar.svelte";
|
||||||
|
let { children } = $props();
|
||||||
|
</script>
|
||||||
|
<Navbar/>
|
||||||
|
{@render children()}
|
||||||
109
frontend/src/routes/+page.svelte
Normal file
109
frontend/src/routes/+page.svelte
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { auth } from '$lib/auth';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
let loading = true;
|
||||||
|
let error = '';
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
// Check if user is already authenticated
|
||||||
|
if ($auth.isAuthenticated) {
|
||||||
|
await goto('/dashboard');
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
|
} catch (err) {
|
||||||
|
error = 'Failed to check authentication status';
|
||||||
|
console.error(err);
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Nexus Portal | Employee Login</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main class="container-fluid vh-100 d-flex flex-column">
|
||||||
|
<div class="row flex-grow-1">
|
||||||
|
<!-- Left side - Welcome content -->
|
||||||
|
<div class="col-md-6 bg-primary text-white p-4 p-md-5 d-flex align-items-center">
|
||||||
|
<div class="mx-auto" style="max-width: 500px;">
|
||||||
|
<h1 class="display-4 fw-bold mb-4">Nexus Employee Portal</h1>
|
||||||
|
<p class="fs-5 mb-4">Your centralized platform for managing customers, projects, invoices, and reports.</p>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="d-flex mb-3">
|
||||||
|
<div class="bg-primary me-3 p-2 rounded-circle">
|
||||||
|
<i class="bi bi-check-circle-fill"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="fw-semibold">Customer Management</h3>
|
||||||
|
<p>Track customer information and billing details</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex mb-3">
|
||||||
|
<div class="bg-primary me-3 p-2 rounded-circle">
|
||||||
|
<i class="bi bi-clipboard-check"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="fw-semibold">Project Tracking</h3>
|
||||||
|
<p>Manage projects and punchlists efficiently</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex mb-3">
|
||||||
|
<div class="bg-primary me-3 p-2 rounded-circle">
|
||||||
|
<i class="bi bi-receipt"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="fw-semibold">Invoice Management</h3>
|
||||||
|
<p>Create and track invoices and payments</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right side - Login prompt -->
|
||||||
|
<div class="col-md-6 bg-dark-gray p-4 p-md-5 d-flex align-items-center justify-content-center">
|
||||||
|
<div class="card shadow w-100" style="max-width: 400px;">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
{#if loading}
|
||||||
|
<div class="text-center my-4">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<h2 class="card-title text-center mb-4 fw-bold">Employee Access</h2>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-danger" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<p class="text-center mb-4 text-muted">
|
||||||
|
Please sign in to access the employee portal and its features.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<a href="/login" class="btn btn-primary w-100 py-2 mb-3">
|
||||||
|
Sign In
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="text-center text-muted small">
|
||||||
|
<p>Forgot your password? Contact your administrator.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="bg-dark text-white py-3 text-center">
|
||||||
|
<p class="small mb-0">© {new Date().getFullYear()} Nexus Portal. All rights reserved.</p>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
243
frontend/src/routes/accounts/+page.svelte
Normal file
243
frontend/src/routes/accounts/+page.svelte
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {onMount} from 'svelte';
|
||||||
|
import {accountService, customerService, type Account, type Customer} from '$lib/api.js';
|
||||||
|
import {profile, isAuthenticated} from '$lib/auth.js';
|
||||||
|
import {goto} from '$app/navigation';
|
||||||
|
// STATES
|
||||||
|
let accounts: Account[] = $state([]);
|
||||||
|
let filteredAccounts: Account[] = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let searchTerm = $state('');
|
||||||
|
let showInactive = $state(false);
|
||||||
|
let isAdmin = $state(false);
|
||||||
|
let customers: Customer[] = $state([]);
|
||||||
|
|
||||||
|
// AUTH CHECK
|
||||||
|
onMount(async () => {
|
||||||
|
if (!$isAuthenticated) {
|
||||||
|
await goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isAdmin = $profile?.role === 'admin';
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
// First load customers for name lookups
|
||||||
|
const customersResponse = await customerService.getAll();
|
||||||
|
if (customersResponse && Array.isArray(customersResponse.results)) {
|
||||||
|
customers = customersResponse.results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FETCH ACCOUNTS
|
||||||
|
const response = await accountService.getAll();
|
||||||
|
if (response && Array.isArray(response.results)) {
|
||||||
|
accounts = response.results;
|
||||||
|
// Call filterAccounts once after loading data
|
||||||
|
filterAccounts();
|
||||||
|
} else {
|
||||||
|
console.error('Error: accountService.getAll() did not return a paginated response with results array:', response);
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading accounts:', error);
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// LIVE SEARCH RESULT UPDATES
|
||||||
|
$effect(() => {
|
||||||
|
// Only call filterAccounts when these values change
|
||||||
|
if (accounts.length > 0) {
|
||||||
|
filterAccounts();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ACCOUNT FILTER
|
||||||
|
function filterAccounts() {
|
||||||
|
|
||||||
|
// Create a new array for the filtered accounts
|
||||||
|
const filtered = accounts.filter(account => {
|
||||||
|
// ACTIVE
|
||||||
|
const isActive = !account.end_date || new Date(account.end_date) > new Date();
|
||||||
|
if (!showInactive && !isActive) return false;
|
||||||
|
|
||||||
|
// SEARCH TERM
|
||||||
|
if (searchTerm) {
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
return (
|
||||||
|
account.name.toLowerCase().includes(term) ||
|
||||||
|
account.contact_first_name.toLowerCase().includes(term) ||
|
||||||
|
account.contact_last_name.toLowerCase().includes(term) ||
|
||||||
|
account.contact_email.toLowerCase().includes(term) ||
|
||||||
|
(typeof account.customer === 'object' && account.customer.name.toLowerCase().includes(term))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then sort the filtered accounts by customer name first, then by account name
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
// Get customer names for comparison
|
||||||
|
const customerNameA = typeof a.customer === 'object'
|
||||||
|
? a.customer.name.toLowerCase()
|
||||||
|
: getCustomerName(a).toLowerCase();
|
||||||
|
|
||||||
|
const customerNameB = typeof b.customer === 'object'
|
||||||
|
? b.customer.name.toLowerCase()
|
||||||
|
: getCustomerName(b).toLowerCase();
|
||||||
|
|
||||||
|
// First compare by customer name
|
||||||
|
if (customerNameA < customerNameB) return -1;
|
||||||
|
if (customerNameA > customerNameB) return 1;
|
||||||
|
|
||||||
|
// If customer names are the same, compare by account name
|
||||||
|
const accountNameA = a.name.toLowerCase();
|
||||||
|
const accountNameB = b.name.toLowerCase();
|
||||||
|
|
||||||
|
if (accountNameA < accountNameB) return -1;
|
||||||
|
if (accountNameA > accountNameB) return 1;
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update the state variable once at the end
|
||||||
|
filteredAccounts = [...filtered];
|
||||||
|
}
|
||||||
|
|
||||||
|
// CUSTOMER NAME LOOKUP
|
||||||
|
function getCustomerName(account: Account): string {
|
||||||
|
if (typeof account.customer === 'object') {
|
||||||
|
return account.customer.name;
|
||||||
|
} else {
|
||||||
|
const customer = customers.find(c => c.id === account.customer);
|
||||||
|
return customer ? customer.name : 'Unknown Customer';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK ACCOUNT AS INACTIVE
|
||||||
|
function markInactive(account: Account) {
|
||||||
|
if (confirm(`Are you sure you want to mark ${account.name} as inactive?`)) {
|
||||||
|
const today = new Date().toISOString().split('T')[0]; // Format: YYYY-MM-DD
|
||||||
|
|
||||||
|
accountService.patch(account.id, {end_date: today})
|
||||||
|
.then(() => {
|
||||||
|
// Update the account in the local state
|
||||||
|
const index = accounts.findIndex(a => a.id === account.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
accounts[index] = {...accounts[index], end_date: today};
|
||||||
|
// Re-filter accounts to update the UI
|
||||||
|
filterAccounts();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error marking account inactive:', error);
|
||||||
|
alert('Failed to update account status. Please try again.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container-fluid p-4">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="mb-0">Accounts</h1>
|
||||||
|
{#if isAdmin}
|
||||||
|
<a href="/accounts/new" class="btn btn-primary">Add Account</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<!-- Search and filters -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="input-group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Search accounts..."
|
||||||
|
bind:value={searchTerm}
|
||||||
|
>
|
||||||
|
<button class="btn btn-outline-secondary" type="button">
|
||||||
|
<i class="bi bi-search"></i>
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 d-flex justify-content-end align-items-center">
|
||||||
|
<div class="form-check">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
id="showInactive"
|
||||||
|
bind:checked={showInactive}
|
||||||
|
>
|
||||||
|
<label class="form-check-label" for="showInactive">
|
||||||
|
Show Inactive Accounts
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Account List -->
|
||||||
|
{#if loading}
|
||||||
|
<div class="text-center p-5">
|
||||||
|
<div class="spinner-border" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if filteredAccounts.length === 0}
|
||||||
|
<div class="alert alert-info" role="alert">
|
||||||
|
No accounts found. {searchTerm ? 'Try adjusting your search.' : ''}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Account Name</th>
|
||||||
|
<th>Customer</th>
|
||||||
|
<th>Contact</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Phone</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each filteredAccounts as account (account.id)}
|
||||||
|
{@const isActive = !account.end_date || new Date(account.end_date) > new Date()}
|
||||||
|
<tr class={isActive ? '' : 'table-secondary'}>
|
||||||
|
<td>{account.name}</td>
|
||||||
|
<td>{getCustomerName(account)}</td>
|
||||||
|
<td>{account.contact_first_name} {account.contact_last_name}</td>
|
||||||
|
<td>
|
||||||
|
<a href="mailto:{account.contact_email}">{account.contact_email}</a>
|
||||||
|
</td>
|
||||||
|
<td>{account.contact_phone}</td>
|
||||||
|
<td>
|
||||||
|
<span class={`badge ${isActive ? 'bg-success' : 'bg-secondary'}`}>
|
||||||
|
{isActive ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group">
|
||||||
|
<a
|
||||||
|
href={`/accounts/${account.id}`}
|
||||||
|
class="btn btn-sm btn-outline-primary"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</a>
|
||||||
|
{#if isAdmin && isActive}
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline-danger"
|
||||||
|
onclick={() => markInactive(account)}
|
||||||
|
>
|
||||||
|
Set Inactive
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
789
frontend/src/routes/accounts/[id]/+page.svelte
Normal file
789
frontend/src/routes/accounts/[id]/+page.svelte
Normal file
@ -0,0 +1,789 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {onMount} from 'svelte';
|
||||||
|
import {
|
||||||
|
accountService,
|
||||||
|
customerService,
|
||||||
|
serviceService,
|
||||||
|
scheduleService,
|
||||||
|
type Account,
|
||||||
|
type Customer,
|
||||||
|
type Service,
|
||||||
|
type Revenue,
|
||||||
|
type Schedule
|
||||||
|
} from '$lib/api.js';
|
||||||
|
import {profile, isAuthenticated} from '$lib/auth.js';
|
||||||
|
import {goto} from '$app/navigation';
|
||||||
|
import {page} from '$app/state';
|
||||||
|
import {parseISO, format} from 'date-fns';
|
||||||
|
// STATES
|
||||||
|
let account: Account | null = $state(null);
|
||||||
|
let customer: Customer | null = $state(null);
|
||||||
|
let services: Service[] = $state([]);
|
||||||
|
let revenues: Revenue[] = $state([]);
|
||||||
|
let schedules: Schedule[] = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let savingData = $state(false);
|
||||||
|
let savingRevenue = $state(false);
|
||||||
|
let accountId = $state('');
|
||||||
|
let isAdmin = $state(false);
|
||||||
|
let showEditForm = $state(false);
|
||||||
|
let showRevenueForm = $state(false);
|
||||||
|
let editingRevenueId = $state('');
|
||||||
|
let accountForm: Partial<Account> = $state({
|
||||||
|
customer: '',
|
||||||
|
name: '',
|
||||||
|
street_address: '',
|
||||||
|
city: '',
|
||||||
|
state: '',
|
||||||
|
zip_code: '',
|
||||||
|
contact_first_name: '',
|
||||||
|
contact_last_name: '',
|
||||||
|
contact_phone: '',
|
||||||
|
contact_email: '',
|
||||||
|
start_date: '',
|
||||||
|
end_date: ''
|
||||||
|
});
|
||||||
|
let revenueForm: Partial<Revenue> = $state({
|
||||||
|
account: '',
|
||||||
|
amount: 0,
|
||||||
|
start_date: new Date().toISOString().split('T')[0],
|
||||||
|
end_date: ''
|
||||||
|
});
|
||||||
|
// AUTH CHECK
|
||||||
|
onMount(async () => {
|
||||||
|
if (!$isAuthenticated) {
|
||||||
|
await goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isAdmin = $profile?.role === 'admin';
|
||||||
|
accountId = page.params.id;
|
||||||
|
if (!accountId) {
|
||||||
|
await goto('/accounts');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
// FETCH ACCOUNT DETAILS
|
||||||
|
account = await accountService.getById(accountId);
|
||||||
|
// FETCH CUSTOMER
|
||||||
|
if (account) {
|
||||||
|
const customerId = typeof account.customer === 'object'
|
||||||
|
? account.customer.id
|
||||||
|
: account.customer;
|
||||||
|
customer = await customerService.getById(customerId);
|
||||||
|
// FETCH SERVICES
|
||||||
|
const servicesResponse = await serviceService.getAll({account_id: accountId});
|
||||||
|
if (servicesResponse && Array.isArray(servicesResponse.results)) {
|
||||||
|
services = servicesResponse.results;
|
||||||
|
}
|
||||||
|
// FETCH REVENUES
|
||||||
|
await loadRevenues();
|
||||||
|
// FETCH SCHEDULES
|
||||||
|
await loadSchedules();
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading account:', error);
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// LOAD REVENUES
|
||||||
|
async function loadRevenues() {
|
||||||
|
try {
|
||||||
|
const revenuesResponse = await accountService.getRevenues(accountId);
|
||||||
|
if (revenuesResponse && Array.isArray(revenuesResponse)) {
|
||||||
|
revenues = revenuesResponse;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading revenues:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LOAD SCHEDULES
|
||||||
|
async function loadSchedules() {
|
||||||
|
try {
|
||||||
|
const schedulesResponse = await scheduleService.getAll({account_id: accountId});
|
||||||
|
if (schedulesResponse && Array.isArray(schedulesResponse.results)) {
|
||||||
|
schedules = schedulesResponse.results;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading schedules:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SETUP EDIT FORM
|
||||||
|
function toggleEditForm() {
|
||||||
|
if (!account) return;
|
||||||
|
if (!showEditForm) {
|
||||||
|
accountForm = {
|
||||||
|
customer: typeof account.customer === 'object' ? account.customer.id : account.customer,
|
||||||
|
name: account.name,
|
||||||
|
street_address: account.street_address,
|
||||||
|
city: account.city,
|
||||||
|
state: account.state,
|
||||||
|
zip_code: account.zip_code,
|
||||||
|
contact_first_name: account.contact_first_name,
|
||||||
|
contact_last_name: account.contact_last_name,
|
||||||
|
contact_phone: account.contact_phone,
|
||||||
|
contact_email: account.contact_email,
|
||||||
|
start_date: account.start_date,
|
||||||
|
end_date: account.end_date || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
showEditForm = !showEditForm;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TOGGLE REVENUE FORM
|
||||||
|
function toggleRevenueForm(revenueId?: string) {
|
||||||
|
if (showRevenueForm) {
|
||||||
|
showRevenueForm = false;
|
||||||
|
editingRevenueId = '';
|
||||||
|
resetRevenueForm();
|
||||||
|
} else {
|
||||||
|
showRevenueForm = true;
|
||||||
|
if (revenueId) {
|
||||||
|
editingRevenueId = revenueId;
|
||||||
|
const revenueToEdit = revenues.find(r => r.id === revenueId);
|
||||||
|
if (revenueToEdit) {
|
||||||
|
revenueForm = {
|
||||||
|
account: accountId,
|
||||||
|
amount: revenueToEdit.amount,
|
||||||
|
start_date: revenueToEdit.start_date,
|
||||||
|
end_date: revenueToEdit.end_date || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
editingRevenueId = '';
|
||||||
|
resetRevenueForm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RESET REVENUE FORM
|
||||||
|
function resetRevenueForm() {
|
||||||
|
revenueForm = {
|
||||||
|
account: accountId,
|
||||||
|
amount: 0,
|
||||||
|
start_date: new Date().toISOString().split('T')[0],
|
||||||
|
end_date: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// UPDATE ACCOUNT
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!account) return;
|
||||||
|
try {
|
||||||
|
savingData = true;
|
||||||
|
const formData = {...accountForm};
|
||||||
|
if (formData.end_date === '') {
|
||||||
|
delete formData.end_date;
|
||||||
|
}
|
||||||
|
await accountService.update(account.id, formData);
|
||||||
|
// REFRESH DATA
|
||||||
|
account = await accountService.getById(account.id);
|
||||||
|
showEditForm = false;
|
||||||
|
savingData = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating account:', error);
|
||||||
|
alert('Failed to update account. Please try again.');
|
||||||
|
savingData = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HANDLE REVENUE SAVE
|
||||||
|
async function handleRevenueSubmit() {
|
||||||
|
try {
|
||||||
|
savingRevenue = true;
|
||||||
|
// Define the formData with a proper type that includes all possible fields
|
||||||
|
const formData: {
|
||||||
|
account: string;
|
||||||
|
amount: number;
|
||||||
|
start_date: string;
|
||||||
|
end_date?: string; // Make end_date optional with the ? operator
|
||||||
|
} = {
|
||||||
|
account: accountId,
|
||||||
|
amount: Number(revenueForm.amount) || 0,
|
||||||
|
start_date: revenueForm.start_date || new Date().toISOString().split('T')[0]
|
||||||
|
};
|
||||||
|
// Only add end_date if it exists and is not empty
|
||||||
|
if (revenueForm.end_date && revenueForm.end_date.trim() !== '') {
|
||||||
|
formData.end_date = revenueForm.end_date;
|
||||||
|
}
|
||||||
|
if (editingRevenueId) {
|
||||||
|
// Update existing revenue
|
||||||
|
await accountService.updateRevenue(editingRevenueId, formData);
|
||||||
|
} else {
|
||||||
|
// Create new revenue
|
||||||
|
await accountService.createRevenue(formData);
|
||||||
|
}
|
||||||
|
// Refresh revenues
|
||||||
|
await loadRevenues();
|
||||||
|
showRevenueForm = false;
|
||||||
|
resetRevenueForm();
|
||||||
|
savingRevenue = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving revenue:', error);
|
||||||
|
alert('Failed to save revenue. Please try again.');
|
||||||
|
savingRevenue = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK INACTIVE
|
||||||
|
function markInactive() {
|
||||||
|
if (!account) return;
|
||||||
|
|
||||||
|
if (confirm(`Are you sure you want to mark ${account.name} as inactive?`)) {
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
accountService.patch(account.id, {end_date: today})
|
||||||
|
.then(async () => {
|
||||||
|
// Refresh account data
|
||||||
|
account = await accountService.getById(accountId);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error marking account inactive:', error);
|
||||||
|
alert('Failed to update account. Please try again.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FORMAT DATE USING DATE-FNS
|
||||||
|
function formatDate(dateStr: string | undefined): string {
|
||||||
|
if (!dateStr) return 'N/A';
|
||||||
|
try {
|
||||||
|
const date = parseISO(dateStr); // Treat the string as an ISO 8601 UTC date
|
||||||
|
return format(date, 'MMMM d, yyyy'); // Format for display in local timezone
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error formatting date:', error);
|
||||||
|
return 'Invalid Date';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FORMAT CURRENCY
|
||||||
|
function formatCurrency(amount: number): string {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
minimumFractionDigits: 2
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CHECK IF ACCOUNT IS ACTIVE
|
||||||
|
function isAccountActive(acc: Account): boolean {
|
||||||
|
return !acc.end_date || new Date(acc.end_date) > new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE REVENUE
|
||||||
|
async function deleteRevenue(revenueId: string) {
|
||||||
|
if (confirm('Are you sure you want to delete this revenue record?')) {
|
||||||
|
try {
|
||||||
|
await accountService.deleteRevenue(revenueId);
|
||||||
|
await loadRevenues();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting revenue:', error);
|
||||||
|
alert('Failed to delete revenue. Please try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container-fluid p-4">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<a href="/accounts" class="btn btn-outline-secondary mb-2">
|
||||||
|
<i class="bi bi-arrow-left"></i> Back to Accounts
|
||||||
|
</a>
|
||||||
|
<h1 class="mb-0">{account?.name || 'Account Details'}</h1>
|
||||||
|
{#if account && customer}
|
||||||
|
<p class="text-muted">Customer: <a href={`/customers/${customer.id}`}>{customer.name}</a></p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if isAdmin && account}
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-primary me-2" onclick={toggleEditForm}>
|
||||||
|
{showEditForm ? 'Cancel' : 'Edit Account'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if account && isAccountActive(account)}
|
||||||
|
<button class="btn btn-outline-danger" onclick={markInactive}>
|
||||||
|
Mark Inactive
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="text-center p-5">
|
||||||
|
<div class="spinner-border" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if !account}
|
||||||
|
<div class="alert alert-danger" role="alert">
|
||||||
|
Account not found
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Edit Form -->
|
||||||
|
{#if showEditForm}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h5 class="mb-0">Edit Account</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form onsubmit={handleSubmit}>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<label for="name" class="form-label">Account Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="name"
|
||||||
|
bind:value={accountForm.name}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="start_date" class="form-label">Start Date *</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
class="form-control"
|
||||||
|
id="start_date"
|
||||||
|
bind:value={accountForm.start_date}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h5 class="mt-4 mb-3">Address</h5>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<label for="street_address" class="form-label">Street Address *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="street_address"
|
||||||
|
bind:value={accountForm.street_address}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="city" class="form-label">City *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="city"
|
||||||
|
bind:value={accountForm.city}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="state" class="form-label">State *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="state"
|
||||||
|
bind:value={accountForm.state}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="zip_code" class="form-label">Zip Code *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="zip_code"
|
||||||
|
bind:value={accountForm.zip_code}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h5 class="mt-4 mb-3">Contact Information</h5>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="contact_first_name" class="form-label">First Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="contact_first_name"
|
||||||
|
bind:value={accountForm.contact_first_name}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="contact_last_name" class="form-label">Last Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="contact_last_name"
|
||||||
|
bind:value={accountForm.contact_last_name}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="contact_phone" class="form-label">Phone *</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
class="form-control"
|
||||||
|
id="contact_phone"
|
||||||
|
bind:value={accountForm.contact_phone}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="contact_email" class="form-label">Email *</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
class="form-control"
|
||||||
|
id="contact_email"
|
||||||
|
bind:value={accountForm.contact_email}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 d-flex justify-content-end">
|
||||||
|
<button type="button" class="btn btn-secondary me-2" onclick={toggleEditForm}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary" disabled={savingData}>
|
||||||
|
{savingData ? 'Updating...' : 'Update Account'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Account Details -->
|
||||||
|
<div class="row">
|
||||||
|
<!-- Main Info -->
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Account Information</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<p class="mb-1 text-muted">Status</p>
|
||||||
|
<span class={`badge ${isAccountActive(account) ? 'bg-success' : 'bg-secondary'}`}>
|
||||||
|
{isAccountActive(account) ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<p class="mb-1 text-muted">Start Date</p>
|
||||||
|
<p>{formatDate(account.start_date)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h6 class="mt-4 mb-2">Address</h6>
|
||||||
|
<p>
|
||||||
|
{account.street_address}<br>
|
||||||
|
{account.city}, {account.state} {account.zip_code}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h6 class="mt-4 mb-2">Contact Information</h6>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<p class="mb-1 text-muted">Contact Name</p>
|
||||||
|
<p>{account.contact_first_name} {account.contact_last_name}</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<p class="mb-1 text-muted">Contact Phone</p>
|
||||||
|
<p>
|
||||||
|
<a href={`tel:${account.contact_phone}`}>{account.contact_phone}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<p class="mb-1 text-muted">Contact Email</p>
|
||||||
|
<p>
|
||||||
|
<a href={`mailto:${account.contact_email}`}>{account.contact_email}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Services -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">Services</h5>
|
||||||
|
<a href={`/services/new?account_id=${accountId}`} class="btn btn-sm btn-primary">
|
||||||
|
Add Service
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{#if services.length === 0}
|
||||||
|
<div class="p-4 text-center">
|
||||||
|
<p class="text-muted mb-0">No services found for this account.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Team Members</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each services as service (service.id)}
|
||||||
|
<tr>
|
||||||
|
<td>{formatDate(service.date)}</td>
|
||||||
|
<td>
|
||||||
|
<span class={`badge ${service.status === 'completed' ? 'bg-success' :
|
||||||
|
service.status === 'cancelled' ? 'bg-danger' :
|
||||||
|
service.status === 'in_progress' ? 'bg-primary' : 'bg-secondary'}`}>
|
||||||
|
{service.status.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{#if service.team_members && service.team_members.length > 0}
|
||||||
|
{service.team_members.map(tm =>
|
||||||
|
typeof tm === 'object' && tm ?
|
||||||
|
`${tm.first_name || ''} ${tm.last_name || ''}` :
|
||||||
|
''
|
||||||
|
).join(', ')}
|
||||||
|
{:else}
|
||||||
|
<span class="text-muted">Not assigned</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href={`/services/${service.id}`}
|
||||||
|
class="btn btn-sm btn-outline-primary">
|
||||||
|
View
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="card-footer text-center">
|
||||||
|
<a href={`/services?account_id=${account.id}`} class="btn btn-outline-secondary">
|
||||||
|
View All Services
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<!-- Revenue Form -->
|
||||||
|
{#if showRevenueForm}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h5 class="mb-0">{editingRevenueId ? 'Edit Revenue' : 'Add Revenue'}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form onsubmit={handleRevenueSubmit}>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="amount" class="form-label">Amount *</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text">$</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="form-control"
|
||||||
|
id="amount"
|
||||||
|
bind:value={revenueForm.amount}
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="start_date" class="form-label">Start Date *</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
class="form-control"
|
||||||
|
id="rev_start_date"
|
||||||
|
bind:value={revenueForm.start_date}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="end_date" class="form-label">End Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
class="form-control"
|
||||||
|
id="rev_end_date"
|
||||||
|
bind:value={revenueForm.end_date}
|
||||||
|
min={revenueForm.start_date}
|
||||||
|
>
|
||||||
|
<div class="form-text">Leave blank if this is current revenue</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<button type="button" class="btn btn-secondary me-2"
|
||||||
|
onclick={() => toggleRevenueForm()}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary" disabled={savingRevenue}>
|
||||||
|
{savingRevenue ? 'Saving...' : (editingRevenueId ? 'Update Revenue' : 'Add Revenue')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Revenue Info -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">Revenue</h5>
|
||||||
|
{#if isAdmin && !showRevenueForm}
|
||||||
|
<button class="btn btn-sm btn-primary" onclick={() => toggleRevenueForm()}>
|
||||||
|
Add Revenue
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{#if revenues.length === 0}
|
||||||
|
<p class="text-muted text-center mb-0">No revenue records found.</p>
|
||||||
|
{:else}
|
||||||
|
{#each revenues as revenue (revenue.id)}
|
||||||
|
<div class="border-bottom pb-3 mb-3">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-0">{formatCurrency(revenue.amount)}</h6>
|
||||||
|
<small class="text-muted">
|
||||||
|
From: {formatDate(revenue.start_date)}
|
||||||
|
{#if revenue.end_date}
|
||||||
|
to {formatDate(revenue.end_date)}
|
||||||
|
{:else}
|
||||||
|
(Current)
|
||||||
|
{/if}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
{#if isAdmin}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline-secondary me-1"
|
||||||
|
title="Edit"
|
||||||
|
aria-label="Edit revenue"
|
||||||
|
onclick={() => toggleRevenueForm(revenue.id)}
|
||||||
|
>
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline-danger"
|
||||||
|
title="Delete"
|
||||||
|
aria-label="Delete revenue"
|
||||||
|
onclick={() => deleteRevenue(revenue.id)}
|
||||||
|
>
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Schedule Info -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">Schedule</h5>
|
||||||
|
{#if isAdmin}
|
||||||
|
<a href="/schedules" class="btn btn-sm btn-primary">
|
||||||
|
Manage Schedules
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{#if schedules.length === 0}
|
||||||
|
<p class="text-muted text-center mb-0">No schedule records found.</p>
|
||||||
|
{:else}
|
||||||
|
{#each schedules as schedule (schedule.id)}
|
||||||
|
<div class="border-bottom pb-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-0">Service Days</h6>
|
||||||
|
<ul class="list-unstyled mt-2">
|
||||||
|
{#if schedule.monday_service}
|
||||||
|
<li><span class="badge bg-primary">Monday</span></li>
|
||||||
|
{/if}
|
||||||
|
{#if schedule.tuesday_service}
|
||||||
|
<li><span class="badge bg-primary">Tuesday</span></li>
|
||||||
|
{/if}
|
||||||
|
{#if schedule.wednesday_service}
|
||||||
|
<li><span class="badge bg-primary">Wednesday</span></li>
|
||||||
|
{/if}
|
||||||
|
{#if schedule.thursday_service}
|
||||||
|
<li><span class="badge bg-primary">Thursday</span></li>
|
||||||
|
{/if}
|
||||||
|
{#if schedule.friday_service}
|
||||||
|
<li><span class="badge bg-primary">Friday</span></li>
|
||||||
|
{/if}
|
||||||
|
{#if schedule.saturday_service}
|
||||||
|
<li><span class="badge bg-primary">Saturday</span></li>
|
||||||
|
{/if}
|
||||||
|
{#if schedule.sunday_service}
|
||||||
|
<li><span class="badge bg-primary">Sunday</span></li>
|
||||||
|
{/if}
|
||||||
|
{#if schedule.weekend_service}
|
||||||
|
<li><span class="badge bg-info">Weekend Service</span></li>
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
{#if schedule.schedule_exception}
|
||||||
|
<div class="mt-2">
|
||||||
|
<h6 class="mb-1">Exceptions:</h6>
|
||||||
|
<p class="small">{schedule.schedule_exception}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<small class="text-muted d-block mt-2">
|
||||||
|
From: {formatDate(schedule.start_date)}
|
||||||
|
{#if schedule.end_date}
|
||||||
|
to {formatDate(schedule.end_date)}
|
||||||
|
{:else}
|
||||||
|
(Current)
|
||||||
|
{/if}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Quick Actions</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<a href={`/services/new?account_id=${accountId}`} class="btn btn-outline-primary">
|
||||||
|
<i class="bi bi-calendar-plus"></i> Schedule Service
|
||||||
|
</a>
|
||||||
|
<a href={`/projects/new?account_id=${accountId}`} class="btn btn-outline-primary">
|
||||||
|
<i class="bi bi-layout-text-window"></i> Create Project
|
||||||
|
</a>
|
||||||
|
<a href={`/invoices/new?account_id=${accountId}`} class="btn btn-outline-primary">
|
||||||
|
<i class="bi bi-receipt"></i> Generate Invoice
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
231
frontend/src/routes/accounts/new/+page.svelte
Normal file
231
frontend/src/routes/accounts/new/+page.svelte
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import {
|
||||||
|
accountService,
|
||||||
|
customerService,
|
||||||
|
type Account,
|
||||||
|
type Customer
|
||||||
|
} from '$lib/api.js';
|
||||||
|
import { profile, isAuthenticated } from '$lib/auth.js';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
// STATES
|
||||||
|
let loading = $state(false);
|
||||||
|
let customers: Customer[] = $state([]);
|
||||||
|
let isAdmin = $state(false);
|
||||||
|
let accountForm: Partial<Account> = $state({
|
||||||
|
customer: '',
|
||||||
|
name: '',
|
||||||
|
street_address: '',
|
||||||
|
city: '',
|
||||||
|
state: '',
|
||||||
|
zip_code: '',
|
||||||
|
contact_first_name: '',
|
||||||
|
contact_last_name: '',
|
||||||
|
contact_phone: '',
|
||||||
|
contact_email: '',
|
||||||
|
start_date: new Date().toISOString().split('T')[0],
|
||||||
|
end_date: ''
|
||||||
|
});
|
||||||
|
let customerIdFromUrl = $state('');
|
||||||
|
// AUTH CHECK
|
||||||
|
onMount(async () => {
|
||||||
|
if (!$isAuthenticated) {
|
||||||
|
await goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isAdmin = $profile?.role === 'admin';
|
||||||
|
if (!isAdmin) {
|
||||||
|
await goto('/accounts');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
customerIdFromUrl = urlParams.get('customer_id') || '';
|
||||||
|
if (customerIdFromUrl) {
|
||||||
|
accountForm.customer = customerIdFromUrl;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// FETCH CUSTOMERS FOR DROPDOWN
|
||||||
|
const response = await customerService.getAll();
|
||||||
|
if (response && Array.isArray(response.results)) {
|
||||||
|
customers = response.results;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading customers:', error);
|
||||||
|
alert('Failed to load customers. Please try again.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// CREATE ACCOUNT
|
||||||
|
async function handleSubmit() {
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
const formData = { ...accountForm };
|
||||||
|
if (formData.end_date === '') {
|
||||||
|
delete formData.end_date;
|
||||||
|
}
|
||||||
|
const newAccount = await accountService.create(formData as Omit<Account, 'id'>);
|
||||||
|
loading = false;
|
||||||
|
await goto(`/accounts/${newAccount.id}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating account:', error);
|
||||||
|
alert('Failed to create account. Please try again.');
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container-fluid p-4">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="mb-0">Add New Account</h1>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<a href="/accounts" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left"></i> Back to Accounts
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<!-- Account Form -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h5 class="mb-0">Account Information</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form onsubmit={handleSubmit}>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="customer" class="form-label">Customer *</label>
|
||||||
|
<select
|
||||||
|
class="form-select"
|
||||||
|
id="customer"
|
||||||
|
bind:value={accountForm.customer}
|
||||||
|
required
|
||||||
|
disabled={!!customerIdFromUrl}
|
||||||
|
>
|
||||||
|
<option value="">Select a customer</option>
|
||||||
|
{#each customers as customer (customer.id)}
|
||||||
|
<option value={customer.id}>{customer.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="name" class="form-label">Account Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="name"
|
||||||
|
bind:value={accountForm.name}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="start_date" class="form-label">Start Date *</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
class="form-control"
|
||||||
|
id="start_date"
|
||||||
|
bind:value={accountForm.start_date}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h5 class="mt-4 mb-3">Address</h5>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<label for="street_address" class="form-label">Street Address *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="street_address"
|
||||||
|
bind:value={accountForm.street_address}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="city" class="form-label">City *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="city"
|
||||||
|
bind:value={accountForm.city}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="state" class="form-label">State *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="state"
|
||||||
|
bind:value={accountForm.state}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="zip_code" class="form-label">Zip Code *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="zip_code"
|
||||||
|
bind:value={accountForm.zip_code}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h5 class="mt-4 mb-3">Contact Information</h5>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="contact_first_name" class="form-label">First Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="contact_first_name"
|
||||||
|
bind:value={accountForm.contact_first_name}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="contact_last_name" class="form-label">Last Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="contact_last_name"
|
||||||
|
bind:value={accountForm.contact_last_name}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="contact_phone" class="form-label">Phone *</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
class="form-control"
|
||||||
|
id="contact_phone"
|
||||||
|
bind:value={accountForm.contact_phone}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="contact_email" class="form-label">Email *</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
class="form-control"
|
||||||
|
id="contact_email"
|
||||||
|
bind:value={accountForm.contact_email}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 d-flex justify-content-end">
|
||||||
|
<a href="/accounts" class="btn btn-secondary me-2">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="btn btn-primary" disabled={loading}>
|
||||||
|
{loading ? 'Creating...' : 'Create Account'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
521
frontend/src/routes/admin/users/+page.svelte
Normal file
521
frontend/src/routes/admin/users/+page.svelte
Normal file
@ -0,0 +1,521 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { profileService, type Profile } from '$lib/api.js';
|
||||||
|
import { profile, isAuthenticated } from '$lib/auth.js';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
// STATES
|
||||||
|
let users: Profile[] = $state([]);
|
||||||
|
let filteredUsers: Profile[] = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let searchTerm = $state('');
|
||||||
|
let roleFilter = $state('all');
|
||||||
|
let isAdmin = $state(false);
|
||||||
|
let showCreateForm = $state(false);
|
||||||
|
let showResetPasswordForm = $state(false);
|
||||||
|
let editingUser: Profile | null = $state(null);
|
||||||
|
let resetUserId: string = $state('');
|
||||||
|
let resetUserName: string = $state('');
|
||||||
|
let newPassword: string = $state('');
|
||||||
|
let confirmPassword: string = $state('');
|
||||||
|
let passwordError: string = $state('');
|
||||||
|
let passwordSuccess: string = $state('');
|
||||||
|
|
||||||
|
// New user form
|
||||||
|
let userForm: Partial<Profile & {username: string}> = $state({
|
||||||
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
|
primary_phone: '',
|
||||||
|
secondary_phone: '',
|
||||||
|
email: '',
|
||||||
|
role: 'team_member',
|
||||||
|
username: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// ROLE OPTIONS
|
||||||
|
const roleOptions = [
|
||||||
|
{ value: 'all', label: 'All Roles' },
|
||||||
|
{ value: 'admin', label: 'Admin' },
|
||||||
|
{ value: 'team_leader', label: 'Team Leader' },
|
||||||
|
{ value: 'team_member', label: 'Team Member' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// AUTH CHECK
|
||||||
|
onMount(async () => {
|
||||||
|
if (!$isAuthenticated) {
|
||||||
|
await goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only admins can access this page
|
||||||
|
isAdmin = $profile?.role === 'admin';
|
||||||
|
if (!isAdmin) {
|
||||||
|
await goto('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
const response = await profileService.getAll();
|
||||||
|
if (response && Array.isArray(response.results)) {
|
||||||
|
users = response.results;
|
||||||
|
filterUsers();
|
||||||
|
} else {
|
||||||
|
console.error('Error: profileService.getAll() did not return a paginated response with results array:', response);
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading users:', error);
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// FILTER USERS
|
||||||
|
function filterUsers() {
|
||||||
|
filteredUsers = users.filter(user => {
|
||||||
|
// ROLE FILTER
|
||||||
|
if (roleFilter !== 'all' && user.role !== roleFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SEARCH TERM
|
||||||
|
if (searchTerm) {
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
const username = user.user?.username?.toLowerCase() || '';
|
||||||
|
return (
|
||||||
|
user.first_name.toLowerCase().includes(term) ||
|
||||||
|
user.last_name.toLowerCase().includes(term) ||
|
||||||
|
user.email.toLowerCase().includes(term) ||
|
||||||
|
user.primary_phone.toLowerCase().includes(term) ||
|
||||||
|
username.includes(term)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// CREATE USER
|
||||||
|
function toggleCreateForm() {
|
||||||
|
showCreateForm = !showCreateForm;
|
||||||
|
editingUser = null;
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
// EDIT USER
|
||||||
|
function editUser(user: Profile) {
|
||||||
|
editingUser = user;
|
||||||
|
showCreateForm = true;
|
||||||
|
userForm = {
|
||||||
|
first_name: user.first_name,
|
||||||
|
last_name: user.last_name,
|
||||||
|
primary_phone: user.primary_phone,
|
||||||
|
secondary_phone: user.secondary_phone,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
username: user.user?.username || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// RESET FORM
|
||||||
|
function resetForm() {
|
||||||
|
userForm = {
|
||||||
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
|
primary_phone: '',
|
||||||
|
secondary_phone: '',
|
||||||
|
email: '',
|
||||||
|
role: 'team_member',
|
||||||
|
username: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// HANDLE USER CREATE/UPDATE
|
||||||
|
async function handleSubmit() {
|
||||||
|
try {
|
||||||
|
if (editingUser?.id) {
|
||||||
|
await profileService.update(editingUser.id, userForm);
|
||||||
|
const index = users.findIndex(u => u.id === editingUser!.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
users[index] = {
|
||||||
|
...users[index],
|
||||||
|
...userForm,
|
||||||
|
user: {
|
||||||
|
...users[index].user,
|
||||||
|
username: userForm.username || '' // Add a default value
|
||||||
|
}
|
||||||
|
};
|
||||||
|
users = [...users];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const newUser = await profileService.create(userForm as Omit<Profile, 'id'>);
|
||||||
|
users = [...users, newUser];
|
||||||
|
}
|
||||||
|
resetForm();
|
||||||
|
showCreateForm = false;
|
||||||
|
editingUser = null;
|
||||||
|
filterUsers();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving user:', error);
|
||||||
|
alert('Failed to save user. Please try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE USER
|
||||||
|
async function deleteUser(userId: string) {
|
||||||
|
if (confirm('Are you sure you want to delete this user?')) {
|
||||||
|
try {
|
||||||
|
await profileService.delete(userId);
|
||||||
|
users = users.filter(user => user.id !== userId);
|
||||||
|
filterUsers();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting user:', error);
|
||||||
|
alert('Failed to delete user. Please try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OPEN RESET PASSWORD FORM
|
||||||
|
function openResetPasswordForm(user: Profile) {
|
||||||
|
resetUserId = user.id;
|
||||||
|
resetUserName = `${user.first_name} ${user.last_name}`;
|
||||||
|
newPassword = '';
|
||||||
|
confirmPassword = '';
|
||||||
|
passwordError = '';
|
||||||
|
passwordSuccess = '';
|
||||||
|
showResetPasswordForm = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RESET PASSWORD
|
||||||
|
async function resetPassword() {
|
||||||
|
if (!resetUserId) return;
|
||||||
|
|
||||||
|
passwordError = '';
|
||||||
|
passwordSuccess = '';
|
||||||
|
|
||||||
|
// Validate passwords
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
passwordError = 'Password must be at least 8 characters long';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
passwordError = 'Passwords do not match';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await profileService.resetPassword({
|
||||||
|
user_id: resetUserId,
|
||||||
|
new_password: newPassword
|
||||||
|
});
|
||||||
|
|
||||||
|
passwordSuccess = 'Password has been reset successfully';
|
||||||
|
|
||||||
|
// Clear form after 3 seconds and close
|
||||||
|
setTimeout(() => {
|
||||||
|
showResetPasswordForm = false;
|
||||||
|
resetUserId = '';
|
||||||
|
newPassword = '';
|
||||||
|
confirmPassword = '';
|
||||||
|
passwordSuccess = '';
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error resetting password:', error);
|
||||||
|
passwordError = 'Failed to reset password. Please try again.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LIVE SEARCH RESULT UPDATES
|
||||||
|
$effect(() => {
|
||||||
|
if (searchTerm !== undefined || roleFilter !== undefined) {
|
||||||
|
filterUsers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container-fluid p-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="mb-0">User Management</h1>
|
||||||
|
{#if isAdmin}
|
||||||
|
<button class="btn btn-primary" onclick={toggleCreateForm}>
|
||||||
|
{showCreateForm ? 'Cancel' : 'Add User'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search and filters -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="input-group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Search users..."
|
||||||
|
bind:value={searchTerm}
|
||||||
|
>
|
||||||
|
<button class="btn btn-outline-secondary" type="button">
|
||||||
|
<i class="bi bi-search"></i>
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<select
|
||||||
|
class="form-select"
|
||||||
|
bind:value={roleFilter}
|
||||||
|
>
|
||||||
|
{#each roleOptions as option (option.value)}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create/Edit Form -->
|
||||||
|
{#if showCreateForm}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
{editingUser ? 'Edit User' : 'Add New User'}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form onsubmit={handleSubmit}>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="first_name" class="form-label">First Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="first_name"
|
||||||
|
bind:value={userForm.first_name}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="last_name" class="form-label">Last Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="last_name"
|
||||||
|
bind:value={userForm.last_name}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="email" class="form-label">Email *</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
class="form-control"
|
||||||
|
id="email"
|
||||||
|
bind:value={userForm.email}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="username" class="form-label">Username *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="username"
|
||||||
|
bind:value={userForm.username}
|
||||||
|
required
|
||||||
|
disabled={!!editingUser}
|
||||||
|
>
|
||||||
|
{#if editingUser}
|
||||||
|
<small class="text-muted">Username cannot be changed after creation</small>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="role" class="form-label">Role *</label>
|
||||||
|
<select
|
||||||
|
class="form-select"
|
||||||
|
id="role"
|
||||||
|
bind:value={userForm.role}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
<option value="team_leader">Team Leader</option>
|
||||||
|
<option value="team_member">Team Member</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="primary_phone" class="form-label">Primary Phone *</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
class="form-control"
|
||||||
|
id="primary_phone"
|
||||||
|
bind:value={userForm.primary_phone}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="secondary_phone" class="form-label">Secondary Phone</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
class="form-control"
|
||||||
|
id="secondary_phone"
|
||||||
|
bind:value={userForm.secondary_phone}
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !editingUser}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="bi bi-info-circle me-2"></i>
|
||||||
|
A user account will be created with a temporary password that the user will need to change on first login.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-4 d-flex justify-content-end">
|
||||||
|
<button type="button" class="btn btn-secondary me-2" onclick={toggleCreateForm}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
{editingUser ? 'Update User' : 'Create User'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Reset Password Modal -->
|
||||||
|
{#if showResetPasswordForm}
|
||||||
|
<div class="modal fade show" style="display: block; background: rgba(0,0,0,0.5);" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header bg-warning">
|
||||||
|
<h5 class="modal-title">Reset Password for {resetUserName}</h5>
|
||||||
|
<button type="button" class="btn-close" onclick={() => showResetPasswordForm = false}>Test</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
{#if passwordSuccess}
|
||||||
|
<div class="alert alert-success">
|
||||||
|
{passwordSuccess}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<form onsubmit={resetPassword}>
|
||||||
|
{#if passwordError}
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
{passwordError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="new_password" class="form-label">New Password *</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
class="form-control"
|
||||||
|
id="new_password"
|
||||||
|
bind:value={newPassword}
|
||||||
|
required
|
||||||
|
minlength="8"
|
||||||
|
>
|
||||||
|
<small class="text-muted">Minimum 8 characters</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="confirm_password" class="form-label">Confirm Password *</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
class="form-control"
|
||||||
|
id="confirm_password"
|
||||||
|
bind:value={confirmPassword}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<button type="button" class="btn btn-secondary me-2" onclick={() => showResetPasswordForm = false}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-warning">
|
||||||
|
Reset Password
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Users List -->
|
||||||
|
{#if loading}
|
||||||
|
<div class="text-center p-5">
|
||||||
|
<div class="spinner-border" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if filteredUsers.length === 0}
|
||||||
|
<div class="alert alert-info" role="alert">
|
||||||
|
No users found. {searchTerm ? 'Try adjusting your search.' : ''}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Primary Phone</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each filteredUsers as user (user.id)}
|
||||||
|
<tr>
|
||||||
|
<td>{user.first_name} {user.last_name}</td>
|
||||||
|
<td>{user.user?.username || 'N/A'}</td>
|
||||||
|
<td>{user.email}</td>
|
||||||
|
<td>
|
||||||
|
<span class={`badge ${
|
||||||
|
user.role === 'admin' ? 'bg-danger' :
|
||||||
|
user.role === 'team_leader' ? 'bg-primary' : 'bg-success'
|
||||||
|
}`}>
|
||||||
|
{user.role.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{user.primary_phone}</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick={() => editUser(user)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline-warning"
|
||||||
|
onclick={() => openResetPasswordForm(user)}
|
||||||
|
>
|
||||||
|
Reset Password
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline-danger"
|
||||||
|
onclick={() => deleteUser(user.id)}
|
||||||
|
disabled={user.id === $profile?.id}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
377
frontend/src/routes/calendar/create/+page.svelte
Normal file
377
frontend/src/routes/calendar/create/+page.svelte
Normal file
@ -0,0 +1,377 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {onMount} from 'svelte';
|
||||||
|
import {goto} from '$app/navigation';
|
||||||
|
import {profile, isAuthenticated} from '$lib/auth.js';
|
||||||
|
import {
|
||||||
|
serviceService,
|
||||||
|
projectService,
|
||||||
|
accountService,
|
||||||
|
customerService,
|
||||||
|
type Service,
|
||||||
|
type Project,
|
||||||
|
} from '$lib/api.js';
|
||||||
|
import calendarService, {type CalendarEventResponse} from '$lib/google.js';
|
||||||
|
import {parseISO, format} from 'date-fns';
|
||||||
|
|
||||||
|
// STATES
|
||||||
|
let loading = $state(true);
|
||||||
|
let services: (Service & { accountName?: string })[] = $state([]); // Include accountName
|
||||||
|
let projects: (Project & { customerName?: string; accountName?: string })[] = $state([]); // Include customerName and accountName
|
||||||
|
let selectedType = $state('service'); // 'service' or 'project'
|
||||||
|
let selectedId = $state('');
|
||||||
|
let additionalAttendees = $state(''); // Comma-separated list of emails
|
||||||
|
let creating = $state(false);
|
||||||
|
let result: CalendarEventResponse | null = $state(null);
|
||||||
|
let showResult = $state(false);
|
||||||
|
let isAdmin = $state(false);
|
||||||
|
let isTeamLeader = $state(false);
|
||||||
|
let projectStartTime = $state('09:00'); // Default to 9:00 AM
|
||||||
|
let projectEndTime = $state('17:00'); // Default to 5:00 PM
|
||||||
|
|
||||||
|
// Handle item selection
|
||||||
|
function handleTypeChange() {
|
||||||
|
selectedId = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadServiceNames(loadedServices: Service[]) {
|
||||||
|
return Promise.all(
|
||||||
|
loadedServices.map(async (service) => {
|
||||||
|
if (typeof service.account === 'string' && service.account) {
|
||||||
|
try {
|
||||||
|
const account = await accountService.getById(service.account);
|
||||||
|
return {...service, account: account, accountName: account.name};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error loading account ${service.account}:`, error);
|
||||||
|
return {...service, accountName: 'Unknown Account'};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {...service, accountName: 'Unknown Account'};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProjectNames(loadedProjects: Project[]) {
|
||||||
|
return Promise.all(
|
||||||
|
loadedProjects.map(async (project) => {
|
||||||
|
let customerName = 'Unknown Customer';
|
||||||
|
let accountName = '';
|
||||||
|
let customer = null;
|
||||||
|
let account = null;
|
||||||
|
if (typeof project.customer === 'string' && project.customer) {
|
||||||
|
try {
|
||||||
|
customer = await customerService.getById(project.customer);
|
||||||
|
customerName = customer.name;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error loading customer ${project.customer}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (project.account && typeof project.account === 'string' && project.account) {
|
||||||
|
try {
|
||||||
|
account = await accountService.getById(project.account);
|
||||||
|
accountName = ` - ${account.name}`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error loading account ${project.account}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...project,
|
||||||
|
customerName: customerName + accountName,
|
||||||
|
customer: customer || project.customer,
|
||||||
|
account: account || project.account
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load active services and projects
|
||||||
|
onMount(async () => {
|
||||||
|
if (!$isAuthenticated) {
|
||||||
|
await goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isAdmin = $profile?.role === 'admin';
|
||||||
|
isTeamLeader = $profile?.role === 'team_leader';
|
||||||
|
// Only admins and team leaders can use this feature
|
||||||
|
if (!isAdmin && !isTeamLeader) {
|
||||||
|
await goto('/dashboard');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
// Load active services (scheduled or in progress)
|
||||||
|
const servicesResponse = await serviceService.getAll({
|
||||||
|
status: 'scheduled,in_progress'
|
||||||
|
});
|
||||||
|
if (servicesResponse && Array.isArray(servicesResponse.results)) {
|
||||||
|
services = await loadServiceNames(servicesResponse.results);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load active projects (planned or in progress)
|
||||||
|
const projectsResponse = await projectService.getAll({
|
||||||
|
status: 'planned,in_progress'
|
||||||
|
});
|
||||||
|
if (projectsResponse && Array.isArray(projectsResponse.results)) {
|
||||||
|
projects = await loadProjectNames(projectsResponse.results);
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading data:', error);
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create calendar event
|
||||||
|
async function createCalendarEvent() {
|
||||||
|
if (!selectedId) {
|
||||||
|
alert('Please select a service or project');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
creating = true;
|
||||||
|
result = null;
|
||||||
|
showResult = false;
|
||||||
|
|
||||||
|
// Parse additional attendees
|
||||||
|
const attendeesList = additionalAttendees
|
||||||
|
.split(',')
|
||||||
|
.map(email => email.trim())
|
||||||
|
.filter(email => email);
|
||||||
|
|
||||||
|
if (selectedType === 'service') {
|
||||||
|
const service = services.find(s => s.id === selectedId);
|
||||||
|
if (service) {
|
||||||
|
result = await calendarService.createEventFromService(service, attendeesList);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const project = projects.find(p => p.id === selectedId);
|
||||||
|
if (project) {
|
||||||
|
result = await calendarService.createEventFromProject(project, attendeesList, projectStartTime, projectEndTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showResult = true;
|
||||||
|
creating = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating calendar event:', error);
|
||||||
|
result = {
|
||||||
|
error: 'Failed to create calendar event',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
};
|
||||||
|
showResult = true;
|
||||||
|
creating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format date for display using date-fns
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
if (!dateStr) return 'N/A';
|
||||||
|
try {
|
||||||
|
const date = parseISO(dateStr); // Parse ISO string to date object
|
||||||
|
return format(date, 'MMMM d, yyyy'); // Format with date-fns
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error formatting date:', error);
|
||||||
|
return 'Invalid Date';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format date and time for display using date-fns
|
||||||
|
function formatDateTime(dateStr: string): string {
|
||||||
|
if (!dateStr) return 'N/A';
|
||||||
|
try {
|
||||||
|
const date = parseISO(dateStr); // Parse ISO string to date object
|
||||||
|
return format(date, 'MMMM d, yyyy h:mm a'); // Format with date-fns
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error formatting date time:', error);
|
||||||
|
return 'Invalid Date';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container-fluid p-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="mb-0">Create Calendar Event</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<a href="/dashboard" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left"></i> Back to Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="text-center p-5">
|
||||||
|
<div class="spinner-border" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h5 class="mb-0">Calendar Event Details</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form onsubmit={createCalendarEvent}>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label" for="eventType">Event Type</label>
|
||||||
|
<div class="btn-group w-100" role="group">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
class="btn-check"
|
||||||
|
name="eventType"
|
||||||
|
id="serviceType"
|
||||||
|
value="service"
|
||||||
|
bind:group={selectedType}
|
||||||
|
onchange={handleTypeChange}
|
||||||
|
checked
|
||||||
|
>
|
||||||
|
<label class="btn btn-outline-primary" for="serviceType">
|
||||||
|
Service
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
class="btn-check"
|
||||||
|
name="eventType"
|
||||||
|
id="projectType"
|
||||||
|
value="project"
|
||||||
|
bind:group={selectedType}
|
||||||
|
onchange={handleTypeChange}
|
||||||
|
>
|
||||||
|
<label class="btn btn-outline-primary" for="projectType">
|
||||||
|
Project
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if selectedType === 'service'}
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="serviceSelect" class="form-label">Select Service</label>
|
||||||
|
{#if services.length === 0}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
No active services found. Services must be in "Scheduled" or "In Progress" status.
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<select
|
||||||
|
class="form-select"
|
||||||
|
id="serviceSelect"
|
||||||
|
bind:value={selectedId}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">-- Select a Service --</option>
|
||||||
|
{#each services as service (service.id)}
|
||||||
|
<option value={service.id}>
|
||||||
|
{service.accountName} - {formatDateTime(service.deadline_start)}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if selectedType === 'project'}
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="projectSelect" class="form-label">Select Project</label>
|
||||||
|
{#if projects.length === 0}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
No active projects found. Projects must be in "Planned" or "In Progress" status.
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<select
|
||||||
|
class="form-select"
|
||||||
|
id="projectSelect"
|
||||||
|
bind:value={selectedId}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">-- Select a Project --</option>
|
||||||
|
{#each projects as project (project.id)}
|
||||||
|
<option value={project.id}>
|
||||||
|
{project.customerName} - {formatDate(project.date)}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Time inputs for projects -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="projectStartTime" class="form-label">Start Time</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
class="form-control"
|
||||||
|
id="projectStartTime"
|
||||||
|
bind:value={projectStartTime}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="projectEndTime" class="form-label">End Time</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
class="form-control"
|
||||||
|
id="projectEndTime"
|
||||||
|
bind:value={projectEndTime}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="attendees" class="form-label">Additional Attendees (comma-separated emails)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="attendees"
|
||||||
|
placeholder="email1@example.com, email2@example.com"
|
||||||
|
bind:value={additionalAttendees}
|
||||||
|
>
|
||||||
|
<div class="form-text">
|
||||||
|
Team members associated with the selected item will be added automatically.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary"
|
||||||
|
disabled={creating || !selectedId}
|
||||||
|
>
|
||||||
|
{creating ? 'Creating...' : 'Create Calendar Event'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showResult && result}
|
||||||
|
<div class="mt-4">
|
||||||
|
{#if result.error}
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
<h5 class="alert-heading">Error</h5>
|
||||||
|
<p>{result.error}</p>
|
||||||
|
{#if result.details}
|
||||||
|
<hr>
|
||||||
|
<p class="mb-0">{result.details}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<h5 class="alert-heading">Success!</h5>
|
||||||
|
<p>Calendar event created successfully.</p>
|
||||||
|
{#if result.htmlLink}
|
||||||
|
<hr>
|
||||||
|
<p class="mb-0">
|
||||||
|
<a href={result.htmlLink} target="_blank" rel="noopener noreferrer">
|
||||||
|
View Event in Google Calendar
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
183
frontend/src/routes/customers/+page.svelte
Normal file
183
frontend/src/routes/customers/+page.svelte
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { customerService, type Customer } from '$lib/api.js';
|
||||||
|
import { profile, isAuthenticated } from '$lib/auth.js';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
// STATES
|
||||||
|
let customers: Customer[] = $state([]);
|
||||||
|
let filteredCustomers: Customer[] = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let searchTerm = $state('');
|
||||||
|
let showInactive = $state(false);
|
||||||
|
let isAdmin = $state(false);
|
||||||
|
// AUTH CHECK
|
||||||
|
onMount(async () => {
|
||||||
|
if (!$isAuthenticated) {
|
||||||
|
await goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isAdmin = $profile?.role === 'admin';
|
||||||
|
// FETCH CUSTOMERS
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
const response = await customerService.getAll();
|
||||||
|
if (response && Array.isArray(response.results)) {
|
||||||
|
customers = response.results;
|
||||||
|
filterCustomers();
|
||||||
|
} else {
|
||||||
|
console.error('Error: customerService.getAll() did not return a paginated response with results array:', response);
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading customers:', error);
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// LIVE SEARCH RESULT UPDATES
|
||||||
|
$effect(() => {
|
||||||
|
if (searchTerm !== undefined || showInactive !== undefined) {
|
||||||
|
filterCustomers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// CUSTOMER FILTER
|
||||||
|
function filterCustomers() {
|
||||||
|
filteredCustomers = customers.filter(customer => {
|
||||||
|
// ACTIVE
|
||||||
|
const isActive = !customer.end_date || new Date(customer.end_date) > new Date();
|
||||||
|
if (!showInactive && !isActive) return false;
|
||||||
|
// SEARCH TERM
|
||||||
|
if (searchTerm) {
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
return (
|
||||||
|
customer.name.toLowerCase().includes(term) ||
|
||||||
|
customer.primary_contact_first_name.toLowerCase().includes(term) ||
|
||||||
|
customer.primary_contact_last_name.toLowerCase().includes(term) ||
|
||||||
|
customer.primary_contact_email.toLowerCase().includes(term)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// MARK INACTIVE
|
||||||
|
function markInactive(customer: Customer) {
|
||||||
|
if (confirm(`Are you sure you want to mark ${customer.name} as inactive?`)) {
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
customerService.patch(customer.id, { end_date: today })
|
||||||
|
.then(() => {
|
||||||
|
const index = customers.findIndex(c => c.id === customer.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
customers[index] = { ...customers[index], end_date: today };
|
||||||
|
customers = [...customers];
|
||||||
|
filterCustomers();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error marking customer inactive:', error);
|
||||||
|
alert('Failed to update customer. Please try again.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="container-fluid p-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="mb-0">Customers</h1>
|
||||||
|
{#if isAdmin}
|
||||||
|
<a href="/customers/new" class="btn btn-primary">Add Customer</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<!-- Search and filters -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="input-group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Search customers..."
|
||||||
|
bind:value={searchTerm}
|
||||||
|
>
|
||||||
|
<button class="btn btn-outline-secondary" type="button">
|
||||||
|
<i class="bi bi-search"></i>
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 d-flex justify-content-end align-items-center">
|
||||||
|
<div class="form-check">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
id="showInactive"
|
||||||
|
bind:checked={showInactive}
|
||||||
|
>
|
||||||
|
<label class="form-check-label" for="showInactive">
|
||||||
|
Show Inactive Customers
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Customer List -->
|
||||||
|
{#if loading}
|
||||||
|
<div class="text-center p-5">
|
||||||
|
<div class="spinner-border" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if filteredCustomers.length === 0}
|
||||||
|
<div class="alert alert-info" role="alert">
|
||||||
|
No customers found. {searchTerm ? 'Try adjusting your search.' : ''}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Company Name</th>
|
||||||
|
<th>Primary Contact</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Phone</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each filteredCustomers as customer (customer.id)}
|
||||||
|
{@const isActive = !customer.end_date || new Date(customer.end_date) > new Date()}
|
||||||
|
<tr class={isActive ? '' : 'table-secondary'}>
|
||||||
|
<td>{customer.name}</td>
|
||||||
|
<td>{customer.primary_contact_first_name} {customer.primary_contact_last_name}</td>
|
||||||
|
<td>
|
||||||
|
<a href="mailto:{customer.primary_contact_email}">{customer.primary_contact_email}</a>
|
||||||
|
</td>
|
||||||
|
<td>{customer.primary_contact_phone}</td>
|
||||||
|
<td>
|
||||||
|
<span class={`badge ${isActive ? 'bg-success' : 'bg-secondary'}`}>
|
||||||
|
{isActive ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group">
|
||||||
|
<a
|
||||||
|
href={`/customers/${customer.id}`}
|
||||||
|
class="btn btn-sm btn-outline-primary"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</a>
|
||||||
|
{#if isAdmin && isActive}
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline-danger"
|
||||||
|
onclick={() => markInactive(customer)}
|
||||||
|
>
|
||||||
|
Set Inactive
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
617
frontend/src/routes/customers/[id]/+page.svelte
Normal file
617
frontend/src/routes/customers/[id]/+page.svelte
Normal file
@ -0,0 +1,617 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import {
|
||||||
|
customerService,
|
||||||
|
accountService,
|
||||||
|
type Customer,
|
||||||
|
type Account
|
||||||
|
} from '$lib/api.js';
|
||||||
|
import { profile, isAuthenticated } from '$lib/auth.js';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { parseISO, format } from 'date-fns';
|
||||||
|
// STATES
|
||||||
|
let customer: Customer | null = $state(null);
|
||||||
|
let accounts: Account[] = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let savingData = $state(false);
|
||||||
|
let customerId = $state('');
|
||||||
|
let isAdmin = $state(false);
|
||||||
|
let showEditForm = $state(false);
|
||||||
|
let customerForm: Partial<Customer> = $state({
|
||||||
|
name: '',
|
||||||
|
primary_contact_first_name: '',
|
||||||
|
primary_contact_last_name: '',
|
||||||
|
primary_contact_phone: '',
|
||||||
|
primary_contact_email: '',
|
||||||
|
secondary_contact_first_name: '',
|
||||||
|
secondary_contact_last_name: '',
|
||||||
|
secondary_contact_phone: '',
|
||||||
|
secondary_contact_email: '',
|
||||||
|
billing_contact_first_name: '',
|
||||||
|
billing_contact_last_name: '',
|
||||||
|
billing_street_address: '',
|
||||||
|
billing_city: '',
|
||||||
|
billing_state: '',
|
||||||
|
billing_zip_code: '',
|
||||||
|
billing_email: '',
|
||||||
|
billing_terms: '',
|
||||||
|
start_date: '',
|
||||||
|
end_date: ''
|
||||||
|
});
|
||||||
|
// LOAD CUSTOMER DATA
|
||||||
|
onMount(async () => {
|
||||||
|
// AUTH CHECK
|
||||||
|
if (!$isAuthenticated) {
|
||||||
|
await goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isAdmin = $profile?.role === 'admin';
|
||||||
|
customerId = page.params.id;
|
||||||
|
if (!customerId) {
|
||||||
|
await goto('/customers');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
// FETCH CUSTOMERS
|
||||||
|
customer = await customerService.getById(customerId);
|
||||||
|
// FETCH ACCOUNTS
|
||||||
|
if (customer) {
|
||||||
|
const accountsResponse = await accountService.getAll({ customer_id: customerId });
|
||||||
|
if (accountsResponse && Array.isArray(accountsResponse.results)) {
|
||||||
|
accounts = accountsResponse.results;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading customer:', error);
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// SETUP EDIT FORM
|
||||||
|
function toggleEditForm() {
|
||||||
|
if (!customer) return;
|
||||||
|
if (!showEditForm) {
|
||||||
|
customerForm = {
|
||||||
|
name: customer.name,
|
||||||
|
primary_contact_first_name: customer.primary_contact_first_name,
|
||||||
|
primary_contact_last_name: customer.primary_contact_last_name,
|
||||||
|
primary_contact_phone: customer.primary_contact_phone,
|
||||||
|
primary_contact_email: customer.primary_contact_email,
|
||||||
|
secondary_contact_first_name: customer.secondary_contact_first_name || '',
|
||||||
|
secondary_contact_last_name: customer.secondary_contact_last_name || '',
|
||||||
|
secondary_contact_phone: customer.secondary_contact_phone || '',
|
||||||
|
secondary_contact_email: customer.secondary_contact_email || '',
|
||||||
|
billing_contact_first_name: customer.billing_contact_first_name,
|
||||||
|
billing_contact_last_name: customer.billing_contact_last_name,
|
||||||
|
billing_street_address: customer.billing_street_address,
|
||||||
|
billing_city: customer.billing_city,
|
||||||
|
billing_state: customer.billing_state,
|
||||||
|
billing_zip_code: customer.billing_zip_code,
|
||||||
|
billing_email: customer.billing_email,
|
||||||
|
billing_terms: customer.billing_terms,
|
||||||
|
start_date: customer.start_date,
|
||||||
|
end_date: customer.end_date || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
showEditForm = !showEditForm;
|
||||||
|
}
|
||||||
|
// UPDATE CUSTOMER
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!customer) return;
|
||||||
|
try {
|
||||||
|
savingData = true;
|
||||||
|
const formData = { ...customerForm };
|
||||||
|
if (formData.end_date === '') {
|
||||||
|
delete formData.end_date;
|
||||||
|
}
|
||||||
|
await customerService.update(customer.id, formData);
|
||||||
|
// REFRESH DATA
|
||||||
|
customer = await customerService.getById(customer.id);
|
||||||
|
showEditForm = false;
|
||||||
|
savingData = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating customer:', error);
|
||||||
|
alert('Failed to update customer. Please try again.');
|
||||||
|
savingData = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// MARK INACTIVE
|
||||||
|
function markInactive() {
|
||||||
|
if (!customer) return;
|
||||||
|
if (confirm(`Are you sure you want to mark ${customer.name} as inactive?`)) {
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
customerService.patch(customer.id, { end_date: today })
|
||||||
|
.then(async () => {
|
||||||
|
// Refresh customer data
|
||||||
|
customer = await customerService.getById(customerId);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error marking customer inactive:', error);
|
||||||
|
alert('Failed to update customer. Please try again.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// CREATE NEW ACCOUNT FROM CUSTOMER
|
||||||
|
function createNewAccount() {
|
||||||
|
goto(`/accounts/new?customer_id=${customerId}`);
|
||||||
|
}
|
||||||
|
// FORMAT DATE USING DATE-FNS
|
||||||
|
function formatDate(dateStr: string | undefined): string {
|
||||||
|
if (!dateStr) return 'N/A';
|
||||||
|
try {
|
||||||
|
const date = parseISO(dateStr); // Treat the string as an ISO 8601 UTC date
|
||||||
|
return format(date, 'MMMM d, yyyy'); // Format for display in local timezone
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error formatting date:', error);
|
||||||
|
return 'Invalid Date';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// CHECK IF CUSTOMER IS ACTIVE
|
||||||
|
function isCustomerActive(cust: Customer): boolean {
|
||||||
|
return !cust.end_date || new Date(cust.end_date) > new Date();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container-fluid p-4">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<a href="/customers" class="btn btn-outline-secondary mb-2">
|
||||||
|
<i class="bi bi-arrow-left"></i> Back to Customers
|
||||||
|
</a>
|
||||||
|
<h1 class="mb-0">{customer?.name || 'Customer Details'}</h1>
|
||||||
|
{#if customer}
|
||||||
|
<p class="text-muted">
|
||||||
|
<span class={`badge ${isCustomerActive(customer) ? 'bg-success' : 'bg-secondary'} me-2`}>
|
||||||
|
{isCustomerActive(customer) ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
Since {formatDate(customer.start_date)}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if isAdmin && customer}
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-primary me-2" onclick={toggleEditForm}>
|
||||||
|
{showEditForm ? 'Cancel' : 'Edit Customer'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if customer && isCustomerActive(customer)}
|
||||||
|
<button class="btn btn-outline-danger" onclick={markInactive}>
|
||||||
|
Mark Inactive
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if loading && !customer}
|
||||||
|
<div class="text-center p-5">
|
||||||
|
<div class="spinner-border" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if !customer}
|
||||||
|
<div class="alert alert-danger" role="alert">
|
||||||
|
Customer not found
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Edit Form -->
|
||||||
|
{#if showEditForm}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h5 class="mb-0">Edit Customer</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form onsubmit={handleSubmit}>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="name" class="form-label">Company Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="name"
|
||||||
|
bind:value={customerForm.name}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="start_date" class="form-label">Start Date *</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
class="form-control"
|
||||||
|
id="start_date"
|
||||||
|
bind:value={customerForm.start_date}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h5 class="mt-4 mb-3">Primary Contact</h5>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="primary_contact_first_name" class="form-label">First Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="primary_contact_first_name"
|
||||||
|
bind:value={customerForm.primary_contact_first_name}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="primary_contact_last_name" class="form-label">Last Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="primary_contact_last_name"
|
||||||
|
bind:value={customerForm.primary_contact_last_name}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="primary_contact_phone" class="form-label">Phone *</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
class="form-control"
|
||||||
|
id="primary_contact_phone"
|
||||||
|
bind:value={customerForm.primary_contact_phone}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="primary_contact_email" class="form-label">Email *</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
class="form-control"
|
||||||
|
id="primary_contact_email"
|
||||||
|
bind:value={customerForm.primary_contact_email}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h5 class="mt-4 mb-3">Secondary Contact</h5>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="secondary_contact_first_name" class="form-label">First Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="secondary_contact_first_name"
|
||||||
|
bind:value={customerForm.secondary_contact_first_name}
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="secondary_contact_last_name" class="form-label">Last Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="secondary_contact_last_name"
|
||||||
|
bind:value={customerForm.secondary_contact_last_name}
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="secondary_contact_phone" class="form-label">Phone</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
class="form-control"
|
||||||
|
id="secondary_contact_phone"
|
||||||
|
bind:value={customerForm.secondary_contact_phone}
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="secondary_contact_email" class="form-label">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
class="form-control"
|
||||||
|
id="secondary_contact_email"
|
||||||
|
bind:value={customerForm.secondary_contact_email}
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h5 class="mt-4 mb-3">Billing Information</h5>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="billing_contact_first_name" class="form-label">Contact First Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="billing_contact_first_name"
|
||||||
|
bind:value={customerForm.billing_contact_first_name}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="billing_contact_last_name" class="form-label">Contact Last Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="billing_contact_last_name"
|
||||||
|
bind:value={customerForm.billing_contact_last_name}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<label for="billing_street_address" class="form-label">Street Address *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="billing_street_address"
|
||||||
|
bind:value={customerForm.billing_street_address}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="billing_city" class="form-label">City *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="billing_city"
|
||||||
|
bind:value={customerForm.billing_city}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="billing_state" class="form-label">State *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="billing_state"
|
||||||
|
bind:value={customerForm.billing_state}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="billing_zip_code" class="form-label">Zip Code *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="billing_zip_code"
|
||||||
|
bind:value={customerForm.billing_zip_code}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="billing_email" class="form-label">Billing Email *</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
class="form-control"
|
||||||
|
id="billing_email"
|
||||||
|
bind:value={customerForm.billing_email}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="billing_terms" class="form-label">Billing Terms *</label>
|
||||||
|
<textarea
|
||||||
|
class="form-control"
|
||||||
|
id="billing_terms"
|
||||||
|
bind:value={customerForm.billing_terms}
|
||||||
|
rows="3"
|
||||||
|
required
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 d-flex justify-content-end">
|
||||||
|
<button type="button" class="btn btn-secondary me-2" onclick={toggleEditForm}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary" disabled={savingData}>
|
||||||
|
{savingData ? 'Updating...' : 'Update Customer'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<!-- Customer Details -->
|
||||||
|
<div class="row">
|
||||||
|
<!-- Main Info -->
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Customer Information</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="mt-2 mb-3">Primary Contact</h6>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<p class="mb-1 text-muted">Contact Name</p>
|
||||||
|
<p>{customer.primary_contact_first_name} {customer.primary_contact_last_name}</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<p class="mb-1 text-muted">Contact Phone</p>
|
||||||
|
<p>
|
||||||
|
<a href={`tel:${customer.primary_contact_phone}`}>{customer.primary_contact_phone}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<p class="mb-1 text-muted">Contact Email</p>
|
||||||
|
<p>
|
||||||
|
<a href={`mailto:${customer.primary_contact_email}`}>{customer.primary_contact_email}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if customer.secondary_contact_first_name || customer.secondary_contact_last_name}
|
||||||
|
<h6 class="mt-4 mb-3">Secondary Contact</h6>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<p class="mb-1 text-muted">Contact Name</p>
|
||||||
|
<p>{customer.secondary_contact_first_name} {customer.secondary_contact_last_name}</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<p class="mb-1 text-muted">Contact Phone</p>
|
||||||
|
<p>
|
||||||
|
{#if customer.secondary_contact_phone}
|
||||||
|
<a href={`tel:${customer.secondary_contact_phone}`}>{customer.secondary_contact_phone}</a>
|
||||||
|
{:else}
|
||||||
|
<span class="text-muted">Not provided</span>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<p class="mb-1 text-muted">Contact Email</p>
|
||||||
|
<p>
|
||||||
|
{#if customer.secondary_contact_email}
|
||||||
|
<a href={`mailto:${customer.secondary_contact_email}`}>{customer.secondary_contact_email}</a>
|
||||||
|
{:else}
|
||||||
|
<span class="text-muted">Not provided</span>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<h6 class="mt-4 mb-3">Billing Information</h6>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<p class="mb-1 text-muted">Billing Contact</p>
|
||||||
|
<p>{customer.billing_contact_first_name} {customer.billing_contact_last_name}</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<p class="mb-1 text-muted">Billing Email</p>
|
||||||
|
<p>
|
||||||
|
<a href={`mailto:${customer.billing_email}`}>{customer.billing_email}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<p class="mb-1 text-muted">Billing Address</p>
|
||||||
|
<p>
|
||||||
|
{customer.billing_street_address}<br>
|
||||||
|
{customer.billing_city}, {customer.billing_state} {customer.billing_zip_code}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<p class="mb-1 text-muted">Billing Terms</p>
|
||||||
|
<p>{customer.billing_terms}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Quick Actions</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<button class="btn btn-outline-primary" onclick={createNewAccount}>
|
||||||
|
<i class="bi bi-building-add"></i> Create New Account
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href={`/invoices/new?customer_id=${customer.id}`}
|
||||||
|
class="btn btn-outline-primary"
|
||||||
|
>
|
||||||
|
<i class="bi bi-receipt"></i> Generate Invoice
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={`/projects/new?customer_id=${customer.id}`}
|
||||||
|
class="btn btn-outline-primary"
|
||||||
|
>
|
||||||
|
<i class="bi bi-layout-text-window"></i> Create Project
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Customer Stats -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Customer Statistics</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between mb-3">
|
||||||
|
<span>Total Accounts:</span>
|
||||||
|
<span class="fw-bold">{accounts.length}</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between mb-3">
|
||||||
|
<span>Active Accounts:</span>
|
||||||
|
<span class="fw-bold">
|
||||||
|
{accounts.filter(acc => !acc.end_date || new Date(acc.end_date) > new Date()).length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- More stats could be added here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Accounts List -->
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">Customer Accounts</h5>
|
||||||
|
{#if isAdmin}
|
||||||
|
<button class="btn btn-sm btn-primary" onclick={createNewAccount}>
|
||||||
|
<i class="bi bi-plus"></i> Add Account
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{#if accounts.length === 0}
|
||||||
|
<div class="p-4 text-center">
|
||||||
|
<p class="text-muted mb-0">No accounts found for this customer.</p>
|
||||||
|
{#if isAdmin}
|
||||||
|
<button class="btn btn-outline-primary mt-3" onclick={createNewAccount}>
|
||||||
|
Create First Account
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Account Name</th>
|
||||||
|
<th>Location</th>
|
||||||
|
<th>Contact</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each accounts as account (account.id)}
|
||||||
|
{@const isActive = !account.end_date || new Date(account.end_date) > new Date()}
|
||||||
|
<tr class={isActive ? '' : 'table-secondary'}>
|
||||||
|
<td>{account.name}</td>
|
||||||
|
<td>{account.city}, {account.state}</td>
|
||||||
|
<td>{account.contact_first_name} {account.contact_last_name}</td>
|
||||||
|
<td>
|
||||||
|
<span class={`badge ${isActive ? 'bg-success' : 'bg-secondary'}`}>
|
||||||
|
{isActive ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a
|
||||||
|
href={`/accounts/${account.id}`}
|
||||||
|
class="btn btn-sm btn-outline-primary"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
286
frontend/src/routes/customers/new/+page.svelte
Normal file
286
frontend/src/routes/customers/new/+page.svelte
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { customerService, type Customer } from '$lib/api.js';
|
||||||
|
import { profile, isAuthenticated } from '$lib/auth.js';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
// STATES
|
||||||
|
let loading = $state(false);
|
||||||
|
let isAdmin = $state(false);
|
||||||
|
let customerForm: Partial<Customer> = $state({
|
||||||
|
name: '',
|
||||||
|
primary_contact_first_name: '',
|
||||||
|
primary_contact_last_name: '',
|
||||||
|
primary_contact_phone: '',
|
||||||
|
primary_contact_email: '',
|
||||||
|
secondary_contact_first_name: '',
|
||||||
|
secondary_contact_last_name: '',
|
||||||
|
secondary_contact_phone: '',
|
||||||
|
secondary_contact_email: '',
|
||||||
|
billing_contact_first_name: '',
|
||||||
|
billing_contact_last_name: '',
|
||||||
|
billing_street_address: '',
|
||||||
|
billing_city: '',
|
||||||
|
billing_state: '',
|
||||||
|
billing_zip_code: '',
|
||||||
|
billing_email: '',
|
||||||
|
billing_terms: '',
|
||||||
|
start_date: new Date().toISOString().split('T')[0],
|
||||||
|
end_date: ''
|
||||||
|
});
|
||||||
|
// AUTH CHECK
|
||||||
|
onMount(async () => {
|
||||||
|
if (!$isAuthenticated) {
|
||||||
|
await goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isAdmin = $profile?.role === 'admin';
|
||||||
|
if (!isAdmin) {
|
||||||
|
await goto('/customers');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// CREATE CUSTOMER
|
||||||
|
async function handleSubmit() {
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
const formData = { ...customerForm };
|
||||||
|
if (formData.end_date === '') {
|
||||||
|
delete formData.end_date;
|
||||||
|
}
|
||||||
|
const newCustomer = await customerService.create(formData as Omit<Customer, 'id'>);
|
||||||
|
loading = false;
|
||||||
|
await goto(`/customers/${newCustomer.id}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating customer:', error);
|
||||||
|
alert('Failed to create customer. Please try again.');
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container-fluid p-4">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="mb-0">Add New Customer</h1>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<a href="/customers" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left"></i> Back to Customers
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<!-- Customer Form -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h5 class="mb-0">Customer Information</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form onsubmit={handleSubmit}>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="name" class="form-label">Company Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="name"
|
||||||
|
bind:value={customerForm.name}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="start_date" class="form-label">Start Date *</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
class="form-control"
|
||||||
|
id="start_date"
|
||||||
|
bind:value={customerForm.start_date}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h5 class="mt-4 mb-3">Primary Contact</h5>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="primary_contact_first_name" class="form-label">First Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="primary_contact_first_name"
|
||||||
|
bind:value={customerForm.primary_contact_first_name}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="primary_contact_last_name" class="form-label">Last Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="primary_contact_last_name"
|
||||||
|
bind:value={customerForm.primary_contact_last_name}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="primary_contact_phone" class="form-label">Phone *</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
class="form-control"
|
||||||
|
id="primary_contact_phone"
|
||||||
|
bind:value={customerForm.primary_contact_phone}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="primary_contact_email" class="form-label">Email *</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
class="form-control"
|
||||||
|
id="primary_contact_email"
|
||||||
|
bind:value={customerForm.primary_contact_email}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h5 class="mt-4 mb-3">Secondary Contact</h5>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="secondary_contact_first_name" class="form-label">First Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="secondary_contact_first_name"
|
||||||
|
bind:value={customerForm.secondary_contact_first_name}
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="secondary_contact_last_name" class="form-label">Last Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="secondary_contact_last_name"
|
||||||
|
bind:value={customerForm.secondary_contact_last_name}
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="secondary_contact_phone" class="form-label">Phone</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
class="form-control"
|
||||||
|
id="secondary_contact_phone"
|
||||||
|
bind:value={customerForm.secondary_contact_phone}
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="secondary_contact_email" class="form-label">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
class="form-control"
|
||||||
|
id="secondary_contact_email"
|
||||||
|
bind:value={customerForm.secondary_contact_email}
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h5 class="mt-4 mb-3">Billing Information</h5>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="billing_contact_first_name" class="form-label">Contact First Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="billing_contact_first_name"
|
||||||
|
bind:value={customerForm.billing_contact_first_name}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="billing_contact_last_name" class="form-label">Contact Last Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="billing_contact_last_name"
|
||||||
|
bind:value={customerForm.billing_contact_last_name}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<label for="billing_street_address" class="form-label">Street Address *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="billing_street_address"
|
||||||
|
bind:value={customerForm.billing_street_address}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="billing_city" class="form-label">City *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="billing_city"
|
||||||
|
bind:value={customerForm.billing_city}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="billing_state" class="form-label">State *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="billing_state"
|
||||||
|
bind:value={customerForm.billing_state}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="billing_zip_code" class="form-label">Zip Code *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="billing_zip_code"
|
||||||
|
bind:value={customerForm.billing_zip_code}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="billing_email" class="form-label">Billing Email *</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
class="form-control"
|
||||||
|
id="billing_email"
|
||||||
|
bind:value={customerForm.billing_email}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="billing_terms" class="form-label">Billing Terms *</label>
|
||||||
|
<textarea
|
||||||
|
class="form-control"
|
||||||
|
id="billing_terms"
|
||||||
|
bind:value={customerForm.billing_terms}
|
||||||
|
rows="3"
|
||||||
|
required
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 d-flex justify-content-end">
|
||||||
|
<a href="/customers" class="btn btn-secondary me-2">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="btn btn-primary" disabled={loading}>
|
||||||
|
{loading ? 'Creating...' : 'Create Customer'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
399
frontend/src/routes/dashboard/+page.svelte
Normal file
399
frontend/src/routes/dashboard/+page.svelte
Normal file
@ -0,0 +1,399 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import {
|
||||||
|
customerService,
|
||||||
|
accountService,
|
||||||
|
serviceService,
|
||||||
|
projectService,
|
||||||
|
type Customer,
|
||||||
|
type Account,
|
||||||
|
type Service,
|
||||||
|
type Project,
|
||||||
|
type Profile
|
||||||
|
} from '$lib/api.js';
|
||||||
|
import { profile, isAuthenticated } from '$lib/auth.js';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import {format, parseISO} from "date-fns";
|
||||||
|
|
||||||
|
// STATES
|
||||||
|
let customers: Customer[] = $state([]);
|
||||||
|
let accounts: Account[] = $state([]);
|
||||||
|
let upcomingServices: Service[] = $state([]);
|
||||||
|
let recentProjects: Project[] = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state('');
|
||||||
|
let username = $state('');
|
||||||
|
|
||||||
|
// AUTH CHECK
|
||||||
|
onMount(async () => {
|
||||||
|
if (!$isAuthenticated) {
|
||||||
|
await goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
username = $profile?.first_name || '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
|
||||||
|
// Load data for the dashboard
|
||||||
|
await Promise.all([
|
||||||
|
loadCustomers(),
|
||||||
|
loadAccounts(),
|
||||||
|
loadUpcomingServices(),
|
||||||
|
loadRecentProjects()
|
||||||
|
]);
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading dashboard data:', err);
|
||||||
|
error = 'Failed to load dashboard data';
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// LOAD CUSTOMERS
|
||||||
|
async function loadCustomers() {
|
||||||
|
try {
|
||||||
|
const response = await customerService.getAll();
|
||||||
|
if (response && Array.isArray(response.results)) {
|
||||||
|
customers = response.results;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching customers:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LOAD ACCOUNTS
|
||||||
|
async function loadAccounts() {
|
||||||
|
try {
|
||||||
|
const response = await accountService.getAll();
|
||||||
|
if (response && Array.isArray(response.results)) {
|
||||||
|
accounts = response.results;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching accounts:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LOAD UPCOMING SERVICES
|
||||||
|
async function loadUpcomingServices() {
|
||||||
|
try {
|
||||||
|
// Get today's date in YYYY-MM-DD format
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// Get services scheduled from today
|
||||||
|
const response = await serviceService.getAll({
|
||||||
|
status: 'scheduled',
|
||||||
|
date_from: today
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response && Array.isArray(response.results)) {
|
||||||
|
// Sort by date (closest first) and take just 5
|
||||||
|
upcomingServices = response.results
|
||||||
|
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||||
|
.slice(0, 5);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching upcoming services:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LOAD RECENT PROJECTS
|
||||||
|
async function loadRecentProjects() {
|
||||||
|
try {
|
||||||
|
const response = await projectService.getAll();
|
||||||
|
if (response && Array.isArray(response.results)) {
|
||||||
|
// Sort by date (newest first) and take just 5
|
||||||
|
recentProjects = response.results
|
||||||
|
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
||||||
|
.slice(0, 5);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching recent projects:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FORMAT DATE USING DATE-FNS
|
||||||
|
function formatDate(dateStr: string | undefined): string {
|
||||||
|
if (!dateStr) return 'N/A';
|
||||||
|
try {
|
||||||
|
const date = parseISO(dateStr); // Treat the string as an ISO 8601 UTC date
|
||||||
|
return format(date, 'MMMM d, yyyy'); // Format for display in local timezone
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error formatting date:', error);
|
||||||
|
return 'Invalid Date';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET CUSTOMER NAME
|
||||||
|
function getCustomerName(customer: string | Customer): string {
|
||||||
|
if (typeof customer === 'object') {
|
||||||
|
return customer.name;
|
||||||
|
} else {
|
||||||
|
const foundCustomer = customers.find(c => c.id === customer);
|
||||||
|
return foundCustomer ? foundCustomer.name : 'Unknown Customer';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET ACCOUNT NAME
|
||||||
|
function getAccountName(account: string | Account | null | undefined): string {
|
||||||
|
if (!account) return 'N/A';
|
||||||
|
|
||||||
|
if (typeof account === 'object') {
|
||||||
|
return account.name;
|
||||||
|
} else {
|
||||||
|
const foundAccount = accounts.find(a => a.id === account);
|
||||||
|
return foundAccount ? foundAccount.name : 'Unknown Account';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// COUNT ACTIVE CUSTOMERS
|
||||||
|
function getActiveCustomersCount(): number {
|
||||||
|
return customers.filter(customer =>
|
||||||
|
!customer.end_date || new Date(customer.end_date) > new Date()
|
||||||
|
).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// COUNT ACTIVE ACCOUNTS
|
||||||
|
function getActiveAccountsCount(): number {
|
||||||
|
return accounts.filter(account =>
|
||||||
|
!account.end_date || new Date(account.end_date) > new Date()
|
||||||
|
).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET TODAY'S SERVICES
|
||||||
|
function getTodaysServicesCount(): number {
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
return upcomingServices.filter(service => service.date === today).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET THIS WEEK'S SERVICES
|
||||||
|
function getThisWeeksServicesCount(): number {
|
||||||
|
const today = new Date();
|
||||||
|
const oneWeekLater = new Date();
|
||||||
|
oneWeekLater.setDate(today.getDate() + 7);
|
||||||
|
|
||||||
|
return upcomingServices.filter(service => {
|
||||||
|
const serviceDate = new Date(service.date);
|
||||||
|
return serviceDate >= today && serviceDate <= oneWeekLater;
|
||||||
|
}).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTeamMemberNames(teamMembers: (Profile | string)[] | undefined): string {
|
||||||
|
if (!teamMembers || teamMembers.length === 0) {
|
||||||
|
return 'Not assigned';
|
||||||
|
}
|
||||||
|
|
||||||
|
return teamMembers
|
||||||
|
.map(member => typeof member === 'object' ?
|
||||||
|
`${member.first_name} ${member.last_name}` : 'Unknown')
|
||||||
|
.join(', ');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container-fluid p-4">
|
||||||
|
<!-- Header with Navigation -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<h1 class="mb-0">Dashboard</h1>
|
||||||
|
<p class="text-muted">Welcome back, {username}!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="text-center p-5">
|
||||||
|
<div class="spinner-border" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="alert alert-danger" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Stats Cards -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="card h-100 border-primary">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<h5 class="card-title text-primary">Active Customers</h5>
|
||||||
|
<h2 class="display-4">{getActiveCustomersCount()}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="text-primary fs-1">
|
||||||
|
<i class="bi bi-people"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/customers" class="btn btn-sm btn-outline-primary mt-3">View All</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="card h-100 border-success">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<h5 class="card-title text-success">Active Accounts</h5>
|
||||||
|
<h2 class="display-4">{getActiveAccountsCount()}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="text-success fs-1">
|
||||||
|
<i class="bi bi-building"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/accounts" class="btn btn-sm btn-outline-success mt-3">View All</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="card h-100 border-warning">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<h5 class="card-title text-warning">Today's Services</h5>
|
||||||
|
<h2 class="display-4">{getTodaysServicesCount()}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="text-warning fs-1">
|
||||||
|
<i class="bi bi-calendar-day"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/services" class="btn btn-sm btn-outline-warning mt-3">View All</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="card h-100 border-info">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<h5 class="card-title text-info">This Week's Services</h5>
|
||||||
|
<h2 class="display-4">{getThisWeeksServicesCount()}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="text-info fs-1">
|
||||||
|
<i class="bi bi-calendar-week"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/services" class="btn btn-sm btn-outline-info mt-3">View All</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-dark-gray-subtle">
|
||||||
|
<h5 class="mb-0">Quick Actions</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3 mb-2">
|
||||||
|
<a href="/customers/new" class="btn btn-primary d-block">
|
||||||
|
<i class="bi bi-person-plus me-2"></i> Add Customer
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-2">
|
||||||
|
<a href="/accounts/new" class="btn btn-success d-block">
|
||||||
|
<i class="bi bi-building-add me-2"></i> Add Account
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-2">
|
||||||
|
<a href="/services/new" class="btn btn-warning d-block text-dark">
|
||||||
|
<i class="bi bi-calendar-plus me-2"></i> Schedule Service
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-2">
|
||||||
|
<a href="/projects/new" class="btn btn-info d-block text-dark">
|
||||||
|
<i class="bi bi-briefcase me-2"></i> Create Project
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content Rows -->
|
||||||
|
<div class="row">
|
||||||
|
<!-- Upcoming Services -->
|
||||||
|
<div class="col-lg-6 mb-4">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center bg-dark-gray-subtle">
|
||||||
|
<h5 class="mb-0">Upcoming Services</h5>
|
||||||
|
<a href="/services" class="btn btn-sm btn-outline-primary">View All</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{#if upcomingServices.length === 0}
|
||||||
|
<div class="p-4 text-center">
|
||||||
|
<p class="text-muted mb-0">No upcoming services scheduled.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
{#each upcomingServices as service (service.id)}
|
||||||
|
<a href={`/services/${service.id}`} class="list-group-item list-group-item-action">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h6 class="mb-1">{getAccountName(service.account)}</h6>
|
||||||
|
<small class="text-muted">{formatDate(service.date)}</small>
|
||||||
|
</div>
|
||||||
|
<p class="mb-1 text-truncate">
|
||||||
|
<small>
|
||||||
|
<span class="badge bg-secondary me-2">
|
||||||
|
{formatDate(service.date)} {new Date(service.deadline_start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
<span class="text-muted">Team: {getTeamMemberNames(service.team_members)}</span>
|
||||||
|
</small>
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Projects -->
|
||||||
|
<div class="col-lg-6 mb-4">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center bg-dark-gray-subtle">
|
||||||
|
<h5 class="mb-0">Recent Projects</h5>
|
||||||
|
<a href="/projects" class="btn btn-sm btn-outline-primary">View All</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{#if recentProjects.length === 0}
|
||||||
|
<div class="p-4 text-center">
|
||||||
|
<p class="text-muted mb-0">No recent projects found.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
{#each recentProjects as project (project.id)}
|
||||||
|
<a href={`/projects/${project.id}`} class="list-group-item list-group-item-action">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h6 class="mb-1">{getCustomerName(project.customer)}</h6>
|
||||||
|
<small class="text-muted">{formatDate(project.date)}</small>
|
||||||
|
</div>
|
||||||
|
<p class="mb-1 text-truncate">
|
||||||
|
<small>
|
||||||
|
<span class={`badge ${
|
||||||
|
project.status === 'completed' ? 'bg-success' :
|
||||||
|
project.status === 'cancelled' ? 'bg-danger' :
|
||||||
|
project.status === 'in_progress' ? 'bg-primary' : 'bg-secondary'
|
||||||
|
} me-2`}>
|
||||||
|
{project.status.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
<span class="text-muted">
|
||||||
|
Account: {project.account ? getAccountName(project.account) : 'N/A'}
|
||||||
|
</span>
|
||||||
|
</small>
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
340
frontend/src/routes/invoices/+page.svelte
Normal file
340
frontend/src/routes/invoices/+page.svelte
Normal file
@ -0,0 +1,340 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import {invoiceService, customerService, type Invoice, type Customer, type InvoiceParams} from '$lib/api.js';
|
||||||
|
import { profile, isAuthenticated } from '$lib/auth.js';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import {format, parseISO} from "date-fns";
|
||||||
|
|
||||||
|
// STATES
|
||||||
|
let invoices: Invoice[] = $state([]);
|
||||||
|
let filteredInvoices: Invoice[] = $state([]);
|
||||||
|
let customers: Customer[] = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let searchTerm = $state('');
|
||||||
|
let statusFilter = $state('all');
|
||||||
|
let customerFilter = $state('');
|
||||||
|
let dateFromFilter = $state('');
|
||||||
|
let dateToFilter = $state('');
|
||||||
|
let isAdmin = $state(false);
|
||||||
|
|
||||||
|
// STATUS OPTIONS
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: 'all', label: 'All Statuses' },
|
||||||
|
{ value: 'draft', label: 'Draft' },
|
||||||
|
{ value: 'sent', label: 'Sent' },
|
||||||
|
{ value: 'paid', label: 'Paid' },
|
||||||
|
{ value: 'overdue', label: 'Overdue' },
|
||||||
|
{ value: 'cancelled', label: 'Cancelled' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// AUTH CHECK
|
||||||
|
onMount(async () => {
|
||||||
|
if (!$isAuthenticated) {
|
||||||
|
await goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isAdmin = $profile?.role === 'admin';
|
||||||
|
|
||||||
|
// Check if customer_id is in URL params
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const customerIdFromUrl = urlParams.get('customer_id') || '';
|
||||||
|
if (customerIdFromUrl) {
|
||||||
|
customerFilter = customerIdFromUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
|
||||||
|
// Load customers for the filter dropdown
|
||||||
|
const customersResponse = await customerService.getAll();
|
||||||
|
if (customersResponse && Array.isArray(customersResponse.results)) {
|
||||||
|
customers = customersResponse.results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load invoices with filters
|
||||||
|
await loadInvoices();
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading invoices:', error);
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// LOAD INVOICES
|
||||||
|
async function loadInvoices() {
|
||||||
|
const params: InvoiceParams = {}; // Change to InvoiceParams instead of Record<string, unknown>
|
||||||
|
|
||||||
|
if (customerFilter) {
|
||||||
|
params.customer_id = customerFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusFilter !== 'all') {
|
||||||
|
params.status = statusFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateFromFilter) {
|
||||||
|
params.date_from = dateFromFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateToFilter) {
|
||||||
|
params.date_to = dateToFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await invoiceService.getAll(params);
|
||||||
|
if (response && Array.isArray(response.results)) {
|
||||||
|
invoices = response.results;
|
||||||
|
filterInvoices();
|
||||||
|
} else {
|
||||||
|
console.error('Error: invoiceService.getAll() did not return a paginated response with results array:', response);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading invoices:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FILTER INVOICES
|
||||||
|
function filterInvoices() {
|
||||||
|
filteredInvoices = invoices.filter(invoice => {
|
||||||
|
// Search term
|
||||||
|
if (searchTerm) {
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
let customerName = '';
|
||||||
|
|
||||||
|
if (typeof invoice.customer === 'object') {
|
||||||
|
customerName = invoice.customer.name.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoiceId = invoice.id.toLowerCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
customerName.includes(term) ||
|
||||||
|
invoiceId.includes(term)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// APPLY FILTERS
|
||||||
|
async function applyFilters() {
|
||||||
|
loading = true;
|
||||||
|
await loadInvoices();
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RESET FILTERS
|
||||||
|
function resetFilters() {
|
||||||
|
searchTerm = '';
|
||||||
|
statusFilter = 'all';
|
||||||
|
customerFilter = '';
|
||||||
|
dateFromFilter = '';
|
||||||
|
dateToFilter = '';
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
// FORMAT DATE USING DATE-FNS
|
||||||
|
function formatDate(dateStr: string | undefined): string {
|
||||||
|
if (!dateStr) return 'N/A';
|
||||||
|
try {
|
||||||
|
const date = parseISO(dateStr); // Treat the string as an ISO 8601 UTC date
|
||||||
|
return format(date, 'MMMM d, yyyy'); // Format for display in local timezone
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error formatting date:', error);
|
||||||
|
return 'Invalid Date';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FORMAT CURRENCY
|
||||||
|
function formatCurrency(amount: number): string {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
minimumFractionDigits: 2
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET CUSTOMER NAME
|
||||||
|
function getCustomerName(invoice: Invoice): string {
|
||||||
|
if (typeof invoice.customer === 'object') {
|
||||||
|
return invoice.customer.name;
|
||||||
|
} else {
|
||||||
|
const customer = customers.find(c => c.id === invoice.customer);
|
||||||
|
return customer ? customer.name : 'Unknown Customer';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET TOTAL AMOUNT
|
||||||
|
function getInvoiceTotal(invoice: Invoice): number {
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
// Add revenues if present
|
||||||
|
if (invoice.revenues && invoice.revenues.length > 0) {
|
||||||
|
total += invoice.revenues.reduce((sum, revenue) => sum + revenue.amount, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add projects if present
|
||||||
|
if (invoice.projects && invoice.projects.length > 0) {
|
||||||
|
total += invoice.projects.reduce((sum, project) => sum + (project.amount || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
// LIVE SEARCH RESULT UPDATES
|
||||||
|
$effect(() => {
|
||||||
|
if (searchTerm !== undefined) {
|
||||||
|
filterInvoices();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container-fluid p-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="mb-0">Invoices</h1>
|
||||||
|
{#if isAdmin}
|
||||||
|
<a href="/invoices/new" class="btn btn-primary">Create New Invoice</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h5 class="mb-0">Filters</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="customerFilter" class="form-label">Customer</label>
|
||||||
|
<select
|
||||||
|
class="form-select"
|
||||||
|
id="customerFilter"
|
||||||
|
bind:value={customerFilter}
|
||||||
|
>
|
||||||
|
<option value="">All Customers</option>
|
||||||
|
{#each customers as customer (customer.id)}
|
||||||
|
<option value={customer.id}>{customer.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="statusFilter" class="form-label">Status</label>
|
||||||
|
<select
|
||||||
|
class="form-select"
|
||||||
|
id="statusFilter"
|
||||||
|
bind:value={statusFilter}
|
||||||
|
>
|
||||||
|
{#each statusOptions as option (option.value)}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="dateFromFilter" class="form-label">Date Range</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
class="form-control"
|
||||||
|
id="dateFromFilter"
|
||||||
|
bind:value={dateFromFilter}
|
||||||
|
placeholder="From"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
class="form-control"
|
||||||
|
id="dateToFilter"
|
||||||
|
bind:value={dateToFilter}
|
||||||
|
placeholder="To"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="input-group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Search invoices..."
|
||||||
|
bind:value={searchTerm}
|
||||||
|
>
|
||||||
|
<button class="btn btn-outline-secondary" type="button">
|
||||||
|
<i class="bi bi-search"></i>
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 d-flex justify-content-end">
|
||||||
|
<button class="btn btn-outline-secondary me-2" onclick={resetFilters}>
|
||||||
|
Reset Filters
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" onclick={applyFilters}>
|
||||||
|
Apply Filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Invoices List -->
|
||||||
|
{#if loading}
|
||||||
|
<div class="text-center p-5">
|
||||||
|
<div class="spinner-border" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if filteredInvoices.length === 0}
|
||||||
|
<div class="alert alert-info" role="alert">
|
||||||
|
No invoices found. {searchTerm || statusFilter !== 'all' || customerFilter || dateFromFilter || dateToFilter ? 'Try adjusting your filters.' : ''}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Invoice ID</th>
|
||||||
|
<th>Customer</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Amount</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Date Paid</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each filteredInvoices as invoice (invoice.id)}
|
||||||
|
<tr>
|
||||||
|
<td>{invoice.id}</td>
|
||||||
|
<td>{getCustomerName(invoice)}</td>
|
||||||
|
<td>{formatDate(invoice.date)}</td>
|
||||||
|
<td>{formatCurrency(getInvoiceTotal(invoice))}</td>
|
||||||
|
<td>
|
||||||
|
<span class={`badge ${
|
||||||
|
invoice.status === 'paid' ? 'bg-success' :
|
||||||
|
invoice.status === 'overdue' ? 'bg-danger' :
|
||||||
|
invoice.status === 'sent' ? 'bg-primary' :
|
||||||
|
invoice.status === 'cancelled' ? 'bg-secondary' : 'bg-info'
|
||||||
|
}`}>
|
||||||
|
{invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{invoice.date_paid ? formatDate(invoice.date_paid) : 'Not paid'}</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group">
|
||||||
|
<a
|
||||||
|
href={`/invoices/${invoice.id}`}
|
||||||
|
class="btn btn-sm btn-outline-primary"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
550
frontend/src/routes/invoices/[id]/+page.svelte
Normal file
550
frontend/src/routes/invoices/[id]/+page.svelte
Normal file
@ -0,0 +1,550 @@
|
|||||||
|
// frontend/src/routes/invoices/[id]/+page.svelte
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import {
|
||||||
|
invoiceService,
|
||||||
|
customerService,
|
||||||
|
type Invoice,
|
||||||
|
type Customer,
|
||||||
|
type Account,
|
||||||
|
type Project,
|
||||||
|
type Revenue
|
||||||
|
} from '$lib/api.js';
|
||||||
|
import { profile, isAuthenticated } from '$lib/auth.js';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import {format, parseISO} from "date-fns";
|
||||||
|
|
||||||
|
// Get invoice ID from URL
|
||||||
|
const invoiceId = page.params.id;
|
||||||
|
|
||||||
|
// STATES
|
||||||
|
let invoice: Invoice | null = $state(null);
|
||||||
|
let customer: Customer | null = $state(null);
|
||||||
|
let accounts: Account[] = $state([]);
|
||||||
|
let projects: Project[] = $state([]);
|
||||||
|
let revenues: Revenue[] = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state('');
|
||||||
|
let isEditing = $state(false);
|
||||||
|
let isAdmin = $state(false);
|
||||||
|
|
||||||
|
// STATUS OPTIONS
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: 'draft', label: 'Draft' },
|
||||||
|
{ value: 'sent', label: 'Sent' },
|
||||||
|
{ value: 'paid', label: 'Paid' },
|
||||||
|
{ value: 'overdue', label: 'Overdue' },
|
||||||
|
{ value: 'cancelled', label: 'Cancelled' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// PAYMENT OPTIONS
|
||||||
|
const paymentOptions = [
|
||||||
|
{ value: 'check', label: 'Check' },
|
||||||
|
{ value: 'credit_card', label: 'Credit Card' },
|
||||||
|
{ value: 'bank_transfer', label: 'Bank Transfer' },
|
||||||
|
{ value: 'cash', label: 'Cash' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// AUTH CHECK
|
||||||
|
onMount(async () => {
|
||||||
|
if (!$isAuthenticated) {
|
||||||
|
await goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isAdmin = $profile?.role === 'admin';
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
|
||||||
|
// Load invoice details
|
||||||
|
await loadInvoice();
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading invoice details:', err);
|
||||||
|
error = 'Failed to load invoice details';
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// LOAD INVOICE
|
||||||
|
async function loadInvoice() {
|
||||||
|
try {
|
||||||
|
invoice = await invoiceService.getById(invoiceId);
|
||||||
|
|
||||||
|
if (invoice) {
|
||||||
|
// Get customer details
|
||||||
|
if (typeof invoice.customer === 'object') {
|
||||||
|
customer = invoice.customer;
|
||||||
|
} else {
|
||||||
|
customer = await customerService.getById(invoice.customer as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract accounts, projects, and revenues
|
||||||
|
if (invoice.accounts && Array.isArray(invoice.accounts)) {
|
||||||
|
accounts = invoice.accounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoice.projects && Array.isArray(invoice.projects)) {
|
||||||
|
projects = invoice.projects;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoice.revenues && Array.isArray(invoice.revenues)) {
|
||||||
|
revenues = invoice.revenues;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching invoice:', err);
|
||||||
|
error = 'Failed to load invoice';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAVE INVOICE
|
||||||
|
async function saveInvoice() {
|
||||||
|
if (!invoice) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
await invoiceService.update(invoiceId, invoice);
|
||||||
|
isEditing = false;
|
||||||
|
await loadInvoice(); // Reload to get fresh data
|
||||||
|
loading = false;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error updating invoice:', err);
|
||||||
|
error = 'Failed to update invoice';
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK AS PAID
|
||||||
|
async function markAsPaid() {
|
||||||
|
if (!invoice) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// Prepare payment details
|
||||||
|
const paymentData = {
|
||||||
|
status: 'paid',
|
||||||
|
date_paid: today,
|
||||||
|
payment_type: invoice.payment_type || 'check' // Default to check if not specified
|
||||||
|
};
|
||||||
|
|
||||||
|
await invoiceService.markAsPaid(invoiceId, paymentData);
|
||||||
|
await loadInvoice(); // Reload to get fresh data
|
||||||
|
loading = false;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error marking invoice as paid:', err);
|
||||||
|
error = 'Failed to update invoice payment status';
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FORMAT DATE USING DATE-FNS
|
||||||
|
function formatDate(dateStr: string | undefined): string {
|
||||||
|
if (!dateStr) return 'N/A';
|
||||||
|
try {
|
||||||
|
const date = parseISO(dateStr); // Treat the string as an ISO 8601 UTC date
|
||||||
|
return format(date, 'MMMM d, yyyy'); // Format for display in local timezone
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error formatting date:', error);
|
||||||
|
return 'Invalid Date';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FORMAT CURRENCY
|
||||||
|
function formatCurrency(amount: number): string {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
minimumFractionDigits: 2
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET TOTAL AMOUNT
|
||||||
|
function getInvoiceTotal(): number {
|
||||||
|
if (!invoice) return 0;
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
// Add revenues if present
|
||||||
|
if (revenues && revenues.length > 0) {
|
||||||
|
total += revenues.reduce((sum, revenue) => sum + revenue.amount, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add projects if present
|
||||||
|
if (projects && projects.length > 0) {
|
||||||
|
total += projects.reduce((sum, project) => sum + (project.amount || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container-fluid p-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<a href="/invoices" class="btn btn-outline-secondary me-2">
|
||||||
|
<i class="bi bi-arrow-left"></i> Back to Invoices
|
||||||
|
</a>
|
||||||
|
<h1 class="d-inline-block mb-0">Invoice Details</h1>
|
||||||
|
</div>
|
||||||
|
{#if isAdmin && !isEditing && invoice && invoice.status !== 'paid' && invoice.status !== 'cancelled'}
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-primary me-2" onclick={() => isEditing = true}>
|
||||||
|
<i class="bi bi-pencil"></i> Edit Invoice
|
||||||
|
</button>
|
||||||
|
{#if invoice.status === 'sent'}
|
||||||
|
<button class="btn btn-success" onclick={markAsPaid}>
|
||||||
|
<i class="bi bi-check-circle"></i> Mark as Paid
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="text-center p-5">
|
||||||
|
<div class="spinner-border" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="alert alert-danger" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{:else if !invoice}
|
||||||
|
<div class="alert alert-warning" role="alert">
|
||||||
|
Invoice not found
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="row">
|
||||||
|
<!-- Invoice Details -->
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">Invoice Information</h5>
|
||||||
|
<span class={`badge ${
|
||||||
|
invoice.status === 'paid' ? 'bg-success' :
|
||||||
|
invoice.status === 'overdue' ? 'bg-danger' :
|
||||||
|
invoice.status === 'sent' ? 'bg-primary' :
|
||||||
|
invoice.status === 'cancelled' ? 'bg-secondary' : 'bg-info'
|
||||||
|
} fs-6`}>
|
||||||
|
{invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{#if isEditing}
|
||||||
|
<!-- Edit Mode -->
|
||||||
|
<form onsubmit={saveInvoice}>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="date" class="form-label">Invoice Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
class="form-control"
|
||||||
|
id="date"
|
||||||
|
bind:value={invoice.date}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="status" class="form-label">Status</label>
|
||||||
|
<select
|
||||||
|
class="form-select"
|
||||||
|
id="status"
|
||||||
|
bind:value={invoice.status}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
{#each statusOptions as option (option.value)}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if invoice.status === 'paid' || isEditing}
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="date_paid" class="form-label">Date Paid</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
class="form-control"
|
||||||
|
id="date_paid"
|
||||||
|
bind:value={invoice.date_paid}
|
||||||
|
disabled={invoice.status !== 'paid'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="payment_type" class="form-label">Payment Method</label>
|
||||||
|
<select
|
||||||
|
class="form-select"
|
||||||
|
id="payment_type"
|
||||||
|
bind:value={invoice.payment_type}
|
||||||
|
disabled={invoice.status !== 'paid'}
|
||||||
|
>
|
||||||
|
{#each paymentOptions as option (option.value)}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end mt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline-secondary me-2"
|
||||||
|
onclick={() => isEditing = false}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<!-- View Mode -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6 class="text-muted">Invoice Date</h6>
|
||||||
|
<p class="fs-5">{formatDate(invoice.date)}</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6 class="text-muted">Invoice ID</h6>
|
||||||
|
<p class="fs-5">{invoice.id}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if invoice.status === 'paid' && invoice.date_paid}
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6 class="text-muted">Date Paid</h6>
|
||||||
|
<p class="fs-5">{formatDate(invoice.date_paid)}</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6 class="text-muted">Payment Method</h6>
|
||||||
|
<p class="fs-5">
|
||||||
|
{invoice.payment_type ?
|
||||||
|
invoice.payment_type.charAt(0).toUpperCase() + invoice.payment_type.slice(1).replace('_', ' ') :
|
||||||
|
'Not specified'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<h6 class="text-muted">Customer</h6>
|
||||||
|
<p class="fs-5">
|
||||||
|
<a href={customer ? `/customers/${customer.id}` : '#'}>
|
||||||
|
{customer ? customer.name : 'Unknown Customer'}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if customer}
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6 class="text-muted">Billing Contact</h6>
|
||||||
|
<p>
|
||||||
|
{customer.billing_contact_first_name} {customer.billing_contact_last_name}<br>
|
||||||
|
<a href={`mailto:${customer.billing_email}`}>{customer.billing_email}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6 class="text-muted">Billing Address</h6>
|
||||||
|
<p>
|
||||||
|
{customer.billing_street_address}<br>
|
||||||
|
{customer.billing_city}, {customer.billing_state} {customer.billing_zip_code}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Items Section -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h5 class="mb-0">Invoice Items</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<!-- Accounts/Revenues Section -->
|
||||||
|
{#if revenues && revenues.length > 0}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Account</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each revenues as revenue (revenue.id)}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{typeof revenue.account === 'object' ?
|
||||||
|
revenue.account.name :
|
||||||
|
accounts.find(a => a.id === revenue.account)?.name || 'Unknown Account'}
|
||||||
|
</td>
|
||||||
|
<td>Monthly Service ({formatDate(revenue.start_date)} - {revenue.end_date ? formatDate(revenue.end_date) : 'Ongoing'})</td>
|
||||||
|
<td>{formatCurrency(revenue.amount)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Projects Section -->
|
||||||
|
{#if projects && projects.length > 0}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Project</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each projects as project (project.id)}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href={`/projects/${project.id}`}>
|
||||||
|
Project for {typeof project.customer === 'object' ?
|
||||||
|
project.customer.name :
|
||||||
|
customer?.name || 'Unknown Customer'}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>{formatDate(project.date)}</td>
|
||||||
|
<td>{formatCurrency(project.amount || 0)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if (!revenues || revenues.length === 0) && (!projects || projects.length === 0)}
|
||||||
|
<div class="p-4 text-center">
|
||||||
|
<p class="text-muted">No items attached to this invoice.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="card-footer bg-light">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<h5>Total</h5>
|
||||||
|
<h5>{formatCurrency(getInvoiceTotal())}</h5>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions Sidebar -->
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h5 class="mb-0">Actions</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<button class="btn btn-outline-primary">
|
||||||
|
<i class="bi bi-printer"></i> Print Invoice
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-primary">
|
||||||
|
<i class="bi bi-download"></i> Download PDF
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-primary">
|
||||||
|
<i class="bi bi-envelope"></i> Email to Customer
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if invoice.status === 'draft'}
|
||||||
|
<button class="btn btn-primary">
|
||||||
|
<i class="bi bi-send"></i> Send to Customer
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if invoice.status === 'sent'}
|
||||||
|
<button class="btn btn-success" onclick={markAsPaid}>
|
||||||
|
<i class="bi bi-check-circle"></i> Mark as Paid
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-danger">
|
||||||
|
<i class="bi bi-exclamation-triangle"></i> Mark as Overdue
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Payment History -->
|
||||||
|
{#if invoice.status === 'paid' && invoice.date_paid}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h5 class="mb-0">Payment History</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="bg-success rounded-circle p-2 me-3">
|
||||||
|
<i class="bi bi-check-lg text-white"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-0">Payment Received</h6>
|
||||||
|
<p class="text-muted mb-0">{formatDate(invoice.date_paid)}</p>
|
||||||
|
</div>
|
||||||
|
<div class="ms-auto">
|
||||||
|
<span class="fw-bold">{formatCurrency(getInvoiceTotal())}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<p class="mb-1">Payment Method:</p>
|
||||||
|
<p class="fw-bold">
|
||||||
|
{invoice.payment_type ?
|
||||||
|
invoice.payment_type.charAt(0).toUpperCase() + invoice.payment_type.slice(1).replace('_', ' ') :
|
||||||
|
'Not specified'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Customer Info -->
|
||||||
|
{#if customer}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h5 class="mb-0">Customer Information</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h6>{customer.name}</h6>
|
||||||
|
<p class="mb-2">
|
||||||
|
<strong>Primary Contact:</strong><br>
|
||||||
|
{customer.primary_contact_first_name} {customer.primary_contact_last_name}<br>
|
||||||
|
<a href={`tel:${customer.primary_contact_phone}`}>{customer.primary_contact_phone}</a><br>
|
||||||
|
<a href={`mailto:${customer.primary_contact_email}`}>{customer.primary_contact_email}</a>
|
||||||
|
</p>
|
||||||
|
<p class="mb-0">
|
||||||
|
<strong>Billing Contact:</strong><br>
|
||||||
|
{customer.billing_contact_first_name} {customer.billing_contact_last_name}<br>
|
||||||
|
<a href={`mailto:${customer.billing_email}`}>{customer.billing_email}</a>
|
||||||
|
</p>
|
||||||
|
<div class="mt-3">
|
||||||
|
<a href={`/customers/${customer.id}`} class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-person"></i> View Customer Profile
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
328
frontend/src/routes/invoices/new/+page.svelte
Normal file
328
frontend/src/routes/invoices/new/+page.svelte
Normal file
@ -0,0 +1,328 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import {
|
||||||
|
invoiceService,
|
||||||
|
customerService,
|
||||||
|
accountService,
|
||||||
|
projectService,
|
||||||
|
type Invoice,
|
||||||
|
type Customer,
|
||||||
|
type Account,
|
||||||
|
type Project,
|
||||||
|
} from '$lib/api.js';
|
||||||
|
import { profile, isAuthenticated } from '$lib/auth.js';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
// STATES
|
||||||
|
let customers: Customer[] = $state([]);
|
||||||
|
let accounts: Account[] = $state([]);
|
||||||
|
let projects: Project[] = $state([]);
|
||||||
|
let selectedCustomer: string = $state('');
|
||||||
|
let selectedAccounts: string[] = $state([]);
|
||||||
|
let selectedProjects: string[] = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let saving = $state(false);
|
||||||
|
let isAdmin = $state(false);
|
||||||
|
|
||||||
|
// Invoice form data
|
||||||
|
let invoiceForm = $state({
|
||||||
|
date: new Date().toISOString().split('T')[0],
|
||||||
|
customer: '',
|
||||||
|
status: 'draft'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check URL parameters
|
||||||
|
let customerIdFromUrl = $state('');
|
||||||
|
|
||||||
|
// AUTH CHECK
|
||||||
|
onMount(async () => {
|
||||||
|
if (!$isAuthenticated) {
|
||||||
|
await goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isAdmin = $profile?.role === 'admin';
|
||||||
|
|
||||||
|
// Only admin can create invoices
|
||||||
|
if (!isAdmin) {
|
||||||
|
await goto('/invoices');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if customer_id is in URL params
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
customerIdFromUrl = urlParams.get('customer_id') || '';
|
||||||
|
if (customerIdFromUrl) {
|
||||||
|
selectedCustomer = customerIdFromUrl;
|
||||||
|
invoiceForm.customer = customerIdFromUrl;
|
||||||
|
await loadCustomerData(customerIdFromUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
|
||||||
|
// Load customers for the dropdown
|
||||||
|
const customersResponse = await customerService.getAll();
|
||||||
|
if (customersResponse && Array.isArray(customersResponse.results)) {
|
||||||
|
customers = customersResponse.results;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading data:', error);
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// LOAD CUSTOMER DATA
|
||||||
|
async function loadCustomerData(customerId: string) {
|
||||||
|
if (!customerId) {
|
||||||
|
accounts = [];
|
||||||
|
projects = [];
|
||||||
|
selectedAccounts = [];
|
||||||
|
selectedProjects = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load accounts for the selected customer
|
||||||
|
const accountsResponse = await accountService.getAll({ customer_id: customerId });
|
||||||
|
if (accountsResponse && Array.isArray(accountsResponse.results)) {
|
||||||
|
accounts = accountsResponse.results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load projects for the selected customer
|
||||||
|
const projectsResponse = await projectService.getAll({ customer_id: customerId });
|
||||||
|
if (projectsResponse && Array.isArray(projectsResponse.results)) {
|
||||||
|
projects = projectsResponse.results.filter(p =>
|
||||||
|
p.status === 'completed'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading customer data:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HANDLE CUSTOMER SELECTION CHANGE
|
||||||
|
function handleCustomerChange() {
|
||||||
|
if (selectedCustomer) {
|
||||||
|
invoiceForm.customer = selectedCustomer;
|
||||||
|
loadCustomerData(selectedCustomer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HANDLE INVOICE CREATION
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (saving) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
saving = true;
|
||||||
|
|
||||||
|
// Prepare invoice data
|
||||||
|
const invoiceData = {
|
||||||
|
...invoiceForm,
|
||||||
|
accounts: selectedAccounts,
|
||||||
|
projects: selectedProjects,
|
||||||
|
revenues: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create invoice - cast to unknown first to bypass type checking
|
||||||
|
const newInvoice = await invoiceService.create(invoiceData as unknown as Omit<Invoice, 'id'>);
|
||||||
|
saving = false;
|
||||||
|
|
||||||
|
// Redirect to the new invoice's detail page
|
||||||
|
await goto(`/invoices/${newInvoice.id}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating invoice:', error);
|
||||||
|
alert('Failed to create invoice. Please try again.');
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// FORMAT DATE
|
||||||
|
function formatDate(dateStr: string | undefined): string {
|
||||||
|
if (!dateStr) return 'N/A';
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// FORMAT CURRENCY
|
||||||
|
function formatCurrency(amount: number): string {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
minimumFractionDigits: 2
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container-fluid p-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="mb-0">Create New Invoice</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<a href="/invoices" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left"></i> Back to Invoices
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="text-center p-5">
|
||||||
|
<div class="spinner-border" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Create Form -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h5 class="mb-0">Invoice Information</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form onsubmit={handleSubmit}>
|
||||||
|
<!-- Basic Invoice Details -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="customer" class="form-label">Customer *</label>
|
||||||
|
<select
|
||||||
|
class="form-select"
|
||||||
|
id="customer"
|
||||||
|
bind:value={selectedCustomer}
|
||||||
|
onchange={handleCustomerChange}
|
||||||
|
required
|
||||||
|
disabled={!!customerIdFromUrl}
|
||||||
|
>
|
||||||
|
<option value="">Select a customer</option>
|
||||||
|
{#each customers as customer (customer.id)}
|
||||||
|
<option value={customer.id}>{customer.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="date" class="form-label">Invoice Date *</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
class="form-control"
|
||||||
|
id="date"
|
||||||
|
bind:value={invoiceForm.date}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if selectedCustomer}
|
||||||
|
<!-- Accounts Selection -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label" for="accounts">Select Accounts to Invoice</label>
|
||||||
|
{#if accounts.length === 0}
|
||||||
|
<p class="text-muted">No active accounts found for this customer.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
<th>Account Name</th>
|
||||||
|
<th>Location</th>
|
||||||
|
<th>Monthly Revenue</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each accounts as account (account.id)}
|
||||||
|
{#if !account.end_date || new Date(account.end_date) > new Date()}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="form-check">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
id={`account-${account.id}`}
|
||||||
|
value={account.id}
|
||||||
|
bind:group={selectedAccounts}
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{account.name}</td>
|
||||||
|
<td>{account.city}, {account.state}</td>
|
||||||
|
<td>
|
||||||
|
{account.revenues && account.revenues.length > 0
|
||||||
|
? formatCurrency(account.revenues[0].amount)
|
||||||
|
: 'No revenue data'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Projects Selection -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label" for="projects">Select Projects to Invoice</label>
|
||||||
|
{#if projects.length === 0}
|
||||||
|
<p class="text-muted">No completed projects available for invoicing.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
<th>Project Date</th>
|
||||||
|
<th>Account</th>
|
||||||
|
<th>Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each projects as project (project.id)}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="form-check">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
id={`project-${project.id}`}
|
||||||
|
value={project.id}
|
||||||
|
bind:group={selectedProjects}
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{formatDate(project.date)}</td>
|
||||||
|
<td>
|
||||||
|
{project.account && typeof project.account === 'object'
|
||||||
|
? project.account.name
|
||||||
|
: project.account
|
||||||
|
? accounts.find(a => a.id === project.account)?.name || 'Unknown Account'
|
||||||
|
: 'No Account'}
|
||||||
|
</td>
|
||||||
|
<td>{formatCurrency(project.amount || 0)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Submit Buttons -->
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<a href="/invoices" class="btn btn-secondary me-2">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary"
|
||||||
|
disabled={saving || !selectedCustomer || (selectedAccounts.length === 0 && selectedProjects.length === 0)}
|
||||||
|
>
|
||||||
|
{saving ? 'Creating...' : 'Create Invoice'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
92
frontend/src/routes/login/+page.svelte
Normal file
92
frontend/src/routes/login/+page.svelte
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { auth, loading, error, isAuthenticated } from '$lib/auth.js';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
// STATES
|
||||||
|
let username = $state('');
|
||||||
|
let password = $state('');
|
||||||
|
// FORM SUBMISSION HANDLER
|
||||||
|
const handleSubmit = async (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const success = await auth.login({ username, password });
|
||||||
|
if (success) {
|
||||||
|
await goto('/dashboard');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Login error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// GO RIGHT TO DASHBOARD IF ALREADY AUTHENTICATED
|
||||||
|
onMount(() => {
|
||||||
|
if ($isAuthenticated) {
|
||||||
|
goto('/dashboard');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<div class="container min-vh-100 d-flex justify-content-center align-items-center py-5">
|
||||||
|
<div class="col-11 col-sm-8 col-md-6 col-lg-5 col-xl-4">
|
||||||
|
<div class="card border-0 shadow-lg bg-dark text-light rounded-4">
|
||||||
|
<!-- Card Header with Logo -->
|
||||||
|
<div class="card-header bg-dark text-center border-0 pt-4 pb-0">
|
||||||
|
<h1 class="h3 mb-2">Nexus 2 Login</h1>
|
||||||
|
<p class="text-muted">Login to your account</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-body px-4 px-md-5 py-4">
|
||||||
|
{#if $error}
|
||||||
|
<div class="alert alert-danger d-flex align-items-center" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||||
|
<div>{$error}</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if $loading}
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-muted">Authenticating...</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<form onsubmit={handleSubmit} class="needs-validation" novalidate>
|
||||||
|
<div class="form-floating mb-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control bg-dark text-light border-secondary {$error && !username.trim() ? 'is-invalid' : ''}"
|
||||||
|
id="username"
|
||||||
|
placeholder="Username"
|
||||||
|
bind:value={username}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<label for="username" class="text-secondary">Username</label>
|
||||||
|
{#if $error && !username.trim()}
|
||||||
|
<div class="invalid-feedback">Please enter your username</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="form-floating mb-4">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
class="form-control bg-dark text-light border-secondary {$error && !password.trim() ? 'is-invalid' : ''}"
|
||||||
|
id="password"
|
||||||
|
placeholder="Password"
|
||||||
|
bind:value={password}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<label for="password" class="text-secondary">Password</label>
|
||||||
|
{#if $error && !password.trim()}
|
||||||
|
<div class="invalid-feedback">Please enter your password</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary w-100 py-2 mb-4 rounded-pill"
|
||||||
|
disabled={$loading}
|
||||||
|
>
|
||||||
|
<i class="bi bi-box-arrow-in-right me-2"></i>
|
||||||
|
Sign In
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
433
frontend/src/routes/profile/+page.svelte
Normal file
433
frontend/src/routes/profile/+page.svelte
Normal file
@ -0,0 +1,433 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { profile, isAuthenticated, auth } from '$lib/auth.js';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { profileService, type Profile } from '$lib/api.js';
|
||||||
|
|
||||||
|
// STATES
|
||||||
|
let loading = $state(true);
|
||||||
|
let saving = $state(false);
|
||||||
|
let profileData: Profile | null = $state($profile);
|
||||||
|
let showEditForm = $state(false);
|
||||||
|
let showPasswordForm = $state(false);
|
||||||
|
let profileForm: Partial<Profile> = $state({
|
||||||
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
|
primary_phone: '',
|
||||||
|
secondary_phone: '',
|
||||||
|
email: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// PASSWORD FORM STATE
|
||||||
|
let currentPassword = $state('');
|
||||||
|
let newPassword = $state('');
|
||||||
|
let confirmPassword = $state('');
|
||||||
|
let passwordError = $state('');
|
||||||
|
let passwordSuccess = $state('');
|
||||||
|
|
||||||
|
// AUTH CHECK
|
||||||
|
onMount(async () => {
|
||||||
|
if (!$isAuthenticated) {
|
||||||
|
await goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
|
||||||
|
// Refresh profile data
|
||||||
|
await auth.loadUserProfile();
|
||||||
|
profileData = $profile;
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading profile:', error);
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// SETUP EDIT FORM
|
||||||
|
function toggleEditForm() {
|
||||||
|
if (!profileData) return;
|
||||||
|
|
||||||
|
if (!showEditForm) {
|
||||||
|
// Populate form for editing
|
||||||
|
profileForm = {
|
||||||
|
first_name: profileData.first_name,
|
||||||
|
last_name: profileData.last_name,
|
||||||
|
primary_phone: profileData.primary_phone,
|
||||||
|
secondary_phone: profileData.secondary_phone || '',
|
||||||
|
email: profileData.email
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
showEditForm = !showEditForm;
|
||||||
|
showPasswordForm = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TOGGLE PASSWORD RESET FORM
|
||||||
|
function togglePasswordForm() {
|
||||||
|
showPasswordForm = !showPasswordForm;
|
||||||
|
showEditForm = false;
|
||||||
|
|
||||||
|
// Reset form fields
|
||||||
|
if (showPasswordForm) {
|
||||||
|
currentPassword = '';
|
||||||
|
newPassword = '';
|
||||||
|
confirmPassword = '';
|
||||||
|
passwordError = '';
|
||||||
|
passwordSuccess = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UPDATE PROFILE
|
||||||
|
async function handleProfileUpdate() {
|
||||||
|
if (!profileData) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
saving = true;
|
||||||
|
|
||||||
|
const formData = { ...profileForm };
|
||||||
|
if (formData.secondary_phone === '') {
|
||||||
|
formData.secondary_phone = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
await profileService.update(profileData.id, formData);
|
||||||
|
|
||||||
|
// Refresh profile data
|
||||||
|
await auth.loadUserProfile();
|
||||||
|
profileData = $profile;
|
||||||
|
|
||||||
|
showEditForm = false;
|
||||||
|
saving = false;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating profile:', error);
|
||||||
|
alert('Failed to update profile. Please try again.');
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PASSWORD RESET
|
||||||
|
async function handlePasswordReset() {
|
||||||
|
try {
|
||||||
|
passwordError = '';
|
||||||
|
passwordSuccess = '';
|
||||||
|
|
||||||
|
// Validate password matching
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
passwordError = 'New passwords do not match';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate password complexity
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
passwordError = 'Password must be at least 8 characters long';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saving = true;
|
||||||
|
|
||||||
|
// Call API to change password
|
||||||
|
await profileService.changePassword({
|
||||||
|
current_password: currentPassword,
|
||||||
|
new_password: newPassword
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
currentPassword = '';
|
||||||
|
newPassword = '';
|
||||||
|
confirmPassword = '';
|
||||||
|
passwordSuccess = 'Password updated successfully';
|
||||||
|
saving = false;
|
||||||
|
|
||||||
|
// Close form after successful update
|
||||||
|
setTimeout(() => {
|
||||||
|
showPasswordForm = false;
|
||||||
|
passwordSuccess = '';
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error changing password:', error);
|
||||||
|
passwordError = 'Failed to update password. Please check your current password and try again.';
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container-fluid p-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="mb-0">Your Profile</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="text-center p-5">
|
||||||
|
<div class="spinner-border" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if !profileData}
|
||||||
|
<div class="alert alert-danger" role="alert">
|
||||||
|
Profile not found. Please try logging in again.
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Profile Card -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">Profile Information</h5>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-primary" onclick={toggleEditForm}>
|
||||||
|
{showEditForm ? 'Cancel' : 'Edit Profile'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{#if showEditForm}
|
||||||
|
<!-- Edit Form -->
|
||||||
|
<form onsubmit={handleProfileUpdate}>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="first_name" class="form-label">First Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="first_name"
|
||||||
|
bind:value={profileForm.first_name}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="last_name" class="form-label">Last Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="last_name"
|
||||||
|
bind:value={profileForm.last_name}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="email" class="form-label">Email *</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
class="form-control"
|
||||||
|
id="email"
|
||||||
|
bind:value={profileForm.email}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="primary_phone" class="form-label">Primary Phone *</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
class="form-control"
|
||||||
|
id="primary_phone"
|
||||||
|
bind:value={profileForm.primary_phone}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="secondary_phone" class="form-label">Secondary Phone</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
class="form-control"
|
||||||
|
id="secondary_phone"
|
||||||
|
bind:value={profileForm.secondary_phone}
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end mt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline-secondary me-2"
|
||||||
|
onclick={toggleEditForm}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary"
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<!-- View Profile -->
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<p class="mb-1 text-muted">Name</p>
|
||||||
|
<p class="fs-5">{profileData.first_name} {profileData.last_name}</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<p class="mb-1 text-muted">Email</p>
|
||||||
|
<p class="fs-5">
|
||||||
|
<a href={`mailto:${profileData.email}`}>{profileData.email}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<p class="mb-1 text-muted">Primary Phone</p>
|
||||||
|
<p class="fs-5">
|
||||||
|
<a href={`tel:${profileData.primary_phone}`}>{profileData.primary_phone}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<p class="mb-1 text-muted">Secondary Phone</p>
|
||||||
|
{#if profileData.secondary_phone}
|
||||||
|
<p class="fs-5">
|
||||||
|
<a href={`tel:${profileData.secondary_phone}`}>{profileData.secondary_phone}</a>
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-muted">Not provided</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<p class="mb-1 text-muted">Role</p>
|
||||||
|
<p class="fs-5">
|
||||||
|
<span class="badge bg-primary">{profileData.role.replace('_', ' ')}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Password Reset Card -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">Security</h5>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-outline-secondary" onclick={togglePasswordForm}>
|
||||||
|
{showPasswordForm ? 'Cancel' : 'Change Password'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{#if showPasswordForm}
|
||||||
|
<!-- Password Reset Form -->
|
||||||
|
<form onsubmit={handlePasswordReset}>
|
||||||
|
{#if passwordError}
|
||||||
|
<div class="alert alert-danger" role="alert">
|
||||||
|
{passwordError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if passwordSuccess}
|
||||||
|
<div class="alert alert-success" role="alert">
|
||||||
|
{passwordSuccess}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="current_password" class="form-label">Current Password *</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
class="form-control"
|
||||||
|
id="current_password"
|
||||||
|
bind:value={currentPassword}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="new_password" class="form-label">New Password *</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
class="form-control"
|
||||||
|
id="new_password"
|
||||||
|
bind:value={newPassword}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<div class="form-text">
|
||||||
|
Password must be at least 8 characters long.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="confirm_password" class="form-label">Confirm New Password *</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
class="form-control"
|
||||||
|
id="confirm_password"
|
||||||
|
bind:value={confirmPassword}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end mt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline-secondary me-2"
|
||||||
|
onclick={togglePasswordForm}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary"
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? 'Updating...' : 'Update Password'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<p class="mb-0">To change your password, click the "Change Password" button.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<!-- Account Status -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Account Status</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<span>Status:</span>
|
||||||
|
<span class="badge bg-success">Active</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<span>Last Login:</span>
|
||||||
|
<span>Today</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Quick Actions</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<a href="/dashboard" class="btn btn-outline-primary">
|
||||||
|
<i class="bi bi-speedometer2"></i> Dashboard
|
||||||
|
</a>
|
||||||
|
<a href="/services" class="btn btn-outline-primary">
|
||||||
|
<i class="bi bi-calendar-check"></i> View Services
|
||||||
|
</a>
|
||||||
|
<a href="/projects" class="btn btn-outline-primary">
|
||||||
|
<i class="bi bi-briefcase"></i> View Projects
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-outline-danger" onclick={() => auth.logout()}>
|
||||||
|
<i class="bi bi-box-arrow-right"></i> Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
357
frontend/src/routes/profile/schedule/+page.svelte
Normal file
357
frontend/src/routes/profile/schedule/+page.svelte
Normal file
@ -0,0 +1,357 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {onMount} from 'svelte';
|
||||||
|
import {type Account, accountService, type Service, type ServiceParams, serviceService} from '$lib/api.js';
|
||||||
|
import {isAuthenticated, profile} from '$lib/auth.js';
|
||||||
|
import {goto} from '$app/navigation';
|
||||||
|
import {format, parseISO} from "date-fns";
|
||||||
|
|
||||||
|
// STATES
|
||||||
|
let services: Service[] = $state([]);
|
||||||
|
let accounts: Record<string, Account> = $state({});
|
||||||
|
let loading = $state(true);
|
||||||
|
let accountsLoading = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let statusFilter = $state('all');
|
||||||
|
let dateFromFilter = $state('');
|
||||||
|
let dateToFilter = $state('');
|
||||||
|
|
||||||
|
// STATUS OPTIONS
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: 'all', label: 'All Statuses' },
|
||||||
|
{ value: 'scheduled', label: 'Scheduled' },
|
||||||
|
{ value: 'in_progress', label: 'In Progress' },
|
||||||
|
{ value: 'completed', label: 'Completed' },
|
||||||
|
{ value: 'cancelled', label: 'Cancelled' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// AUTH CHECK
|
||||||
|
onMount(async () => {
|
||||||
|
if (!$isAuthenticated) {
|
||||||
|
await goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial load of services
|
||||||
|
await loadServices();
|
||||||
|
});
|
||||||
|
|
||||||
|
// LOAD SERVICES
|
||||||
|
async function loadServices() {
|
||||||
|
if (!$profile) {
|
||||||
|
error = "User profile not loaded";
|
||||||
|
loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params: ServiceParams = {
|
||||||
|
team_member_id: $profile.id
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add optional filters if set
|
||||||
|
if (statusFilter !== 'all') {
|
||||||
|
params.status = statusFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateFromFilter) {
|
||||||
|
params.date_from = dateFromFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateToFilter) {
|
||||||
|
params.date_to = dateToFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await serviceService.getAll(params);
|
||||||
|
services = response.results;
|
||||||
|
|
||||||
|
// Sort services by date
|
||||||
|
services.sort((a, b) => {
|
||||||
|
// For upcoming services, sort by date ascending
|
||||||
|
return new Date(a.date).getTime() - new Date(b.date).getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load account data after services are loaded
|
||||||
|
await loadAccountData();
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading services:', err);
|
||||||
|
error = "Failed to load services. Please try again.";
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LOAD ACCOUNT DATA
|
||||||
|
async function loadAccountData() {
|
||||||
|
if (services.length === 0) return;
|
||||||
|
|
||||||
|
accountsLoading = true;
|
||||||
|
|
||||||
|
// Get all unique account IDs
|
||||||
|
const accountIds = new Set<string>();
|
||||||
|
services.forEach(service => {
|
||||||
|
const accountId = typeof service.account === 'string'
|
||||||
|
? service.account
|
||||||
|
: typeof service.account === 'object' && service.account ? service.account.id : null;
|
||||||
|
|
||||||
|
if (accountId) {
|
||||||
|
accountIds.add(accountId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load each account that isn't already in our cache
|
||||||
|
const loadPromises = Array.from(accountIds).map(async accountId => {
|
||||||
|
if (!accounts[accountId]) {
|
||||||
|
try {
|
||||||
|
accounts[accountId] = await accountService.getById(accountId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error loading account ${accountId}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(loadPromises);
|
||||||
|
accountsLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// APPLY FILTERS
|
||||||
|
function applyFilters() {
|
||||||
|
loadServices();
|
||||||
|
}
|
||||||
|
|
||||||
|
// RESET FILTERS
|
||||||
|
function resetFilters() {
|
||||||
|
statusFilter = 'all';
|
||||||
|
dateFromFilter = '';
|
||||||
|
dateToFilter = '';
|
||||||
|
loadServices();
|
||||||
|
}
|
||||||
|
|
||||||
|
// FORMAT DATE
|
||||||
|
function formatDate(dateStr: string | undefined): string {
|
||||||
|
if (!dateStr) return 'N/A';
|
||||||
|
try {
|
||||||
|
const date = parseISO(dateStr);
|
||||||
|
return format(date, 'MMMM d, yyyy');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error formatting date:', error);
|
||||||
|
return 'Invalid Date';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FORMAT TIME
|
||||||
|
function formatTime(dateTimeStr: string | undefined): string {
|
||||||
|
if (!dateTimeStr) return 'N/A';
|
||||||
|
try {
|
||||||
|
const date = parseISO(dateTimeStr);
|
||||||
|
return format(date, 'h:mm aa');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error formatting time:', error);
|
||||||
|
return 'Invalid Time';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET ACCOUNT NAME
|
||||||
|
function getAccountName(service: Service): string {
|
||||||
|
let accountId: string | null = null;
|
||||||
|
|
||||||
|
if (typeof service.account === 'string') {
|
||||||
|
accountId = service.account;
|
||||||
|
} else if (typeof service.account === 'object' && service.account && 'id' in service.account) {
|
||||||
|
accountId = service.account.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accountId && accounts[accountId]) {
|
||||||
|
return accounts[accountId].name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return accountsLoading ? 'Loading...' : 'Unknown Account';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container-fluid p-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="mb-0">My Services</h1>
|
||||||
|
<p class="text-muted">
|
||||||
|
Services assigned to {$profile?.first_name} {$profile?.last_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h5 class="mb-0">Filters</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="statusFilter" class="form-label">Status</label>
|
||||||
|
<select
|
||||||
|
class="form-select"
|
||||||
|
id="statusFilter"
|
||||||
|
bind:value={statusFilter}
|
||||||
|
>
|
||||||
|
{#each statusOptions as option (option.value)}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="dateFromFilter" class="form-label">From Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
class="form-control"
|
||||||
|
id="dateFromFilter"
|
||||||
|
bind:value={dateFromFilter}
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="dateToFilter" class="form-label">To Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
class="form-control"
|
||||||
|
id="dateToFilter"
|
||||||
|
bind:value={dateToFilter}
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row justify-content-end">
|
||||||
|
<div class="col-md-6 d-flex justify-content-end">
|
||||||
|
<button class="btn btn-outline-secondary me-2" onclick={resetFilters}>
|
||||||
|
Reset Filters
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" onclick={applyFilters}>
|
||||||
|
Apply Filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error display -->
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-danger" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Services Display -->
|
||||||
|
{#if loading}
|
||||||
|
<div class="text-center p-5">
|
||||||
|
<div class="spinner-border" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if services.length === 0}
|
||||||
|
<div class="alert alert-info" role="alert">
|
||||||
|
<p class="mb-0">No services assigned to you{statusFilter !== 'all' ? ` with status "${statusFilter}"` : ''}.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Upcoming Services (Today and Future) -->
|
||||||
|
<div class="mb-5">
|
||||||
|
<h3>Upcoming Services</h3>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Account</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Time Window</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each services.filter(s => {
|
||||||
|
const serviceDate = new Date(s.date);
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
return serviceDate >= today && (statusFilter === 'all' || s.status === statusFilter);
|
||||||
|
}) as service (service.id)}
|
||||||
|
<tr>
|
||||||
|
<td>{getAccountName(service)}</td>
|
||||||
|
<td>{formatDate(service.date)}</td>
|
||||||
|
<td>{formatTime(service.deadline_start)} - {formatTime(service.deadline_end)}</td>
|
||||||
|
<td>
|
||||||
|
<span class={`badge ${
|
||||||
|
service.status === 'completed' ? 'bg-success' :
|
||||||
|
service.status === 'cancelled' ? 'bg-danger' :
|
||||||
|
service.status === 'in_progress' ? 'bg-primary' : 'bg-secondary'
|
||||||
|
}`}>
|
||||||
|
{service.status.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href={`/services/${service.id}`} class="btn btn-sm btn-outline-primary">
|
||||||
|
View Details
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center">No upcoming services</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Past Services -->
|
||||||
|
<div>
|
||||||
|
<h3>Past Services</h3>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Account</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Time Window</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each services.filter(s => {
|
||||||
|
const serviceDate = new Date(s.date);
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
return serviceDate < today && (statusFilter === 'all' || s.status === statusFilter);
|
||||||
|
}).sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) as service (service.id)}
|
||||||
|
<tr>
|
||||||
|
<td>{getAccountName(service)}</td>
|
||||||
|
<td>{formatDate(service.date)}</td>
|
||||||
|
<td>{formatTime(service.deadline_start)} - {formatTime(service.deadline_end)}</td>
|
||||||
|
<td>
|
||||||
|
<span class={`badge ${
|
||||||
|
service.status === 'completed' ? 'bg-success' :
|
||||||
|
service.status === 'cancelled' ? 'bg-danger' :
|
||||||
|
service.status === 'in_progress' ? 'bg-primary' : 'bg-secondary'
|
||||||
|
}`}>
|
||||||
|
{service.status.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href={`/services/${service.id}`} class="btn btn-sm btn-outline-primary">
|
||||||
|
View Details
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center">No past services</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
363
frontend/src/routes/projects/+page.svelte
Normal file
363
frontend/src/routes/projects/+page.svelte
Normal file
@ -0,0 +1,363 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import {
|
||||||
|
projectService,
|
||||||
|
customerService,
|
||||||
|
accountService,
|
||||||
|
type Project,
|
||||||
|
type Customer,
|
||||||
|
type Account,
|
||||||
|
type ProjectParams
|
||||||
|
} from '$lib/api.js';
|
||||||
|
import { profile, isAuthenticated } from '$lib/auth.js';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import {format, parseISO} from "date-fns";
|
||||||
|
|
||||||
|
// STATES
|
||||||
|
let projects: Project[] = $state([]);
|
||||||
|
let filteredProjects: Project[] = $state([]);
|
||||||
|
let customers: Customer[] = $state([]);
|
||||||
|
let accounts: Account[] = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let searchTerm = $state('');
|
||||||
|
let statusFilter = $state('all');
|
||||||
|
let customerFilter = $state('');
|
||||||
|
let accountFilter = $state('');
|
||||||
|
let isAdmin = $state(false);
|
||||||
|
let isTeamLeader = $state(false);
|
||||||
|
|
||||||
|
// STATUS OPTIONS
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: 'all', label: 'All Statuses' },
|
||||||
|
{ value: 'planned', label: 'Planned' },
|
||||||
|
{ value: 'in_progress', label: 'In Progress' },
|
||||||
|
{ value: 'completed', label: 'Completed' },
|
||||||
|
{ value: 'cancelled', label: 'Cancelled' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// AUTH CHECK
|
||||||
|
onMount(async () => {
|
||||||
|
if (!$isAuthenticated) {
|
||||||
|
await goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isAdmin = $profile?.role === 'admin';
|
||||||
|
isTeamLeader = $profile?.role === 'team_leader';
|
||||||
|
|
||||||
|
// Check if customer_id or account_id is in URL params
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const customerIdFromUrl = urlParams.get('customer_id') || '';
|
||||||
|
const accountIdFromUrl = urlParams.get('account_id') || '';
|
||||||
|
|
||||||
|
if (customerIdFromUrl) {
|
||||||
|
customerFilter = customerIdFromUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accountIdFromUrl) {
|
||||||
|
accountFilter = accountIdFromUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
|
||||||
|
// Load customers for the filter dropdown
|
||||||
|
const customersResponse = await customerService.getAll();
|
||||||
|
if (customersResponse && Array.isArray(customersResponse.results)) {
|
||||||
|
customers = customersResponse.results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load accounts for the filter dropdown
|
||||||
|
const accountsResponse = await accountService.getAll();
|
||||||
|
if (accountsResponse && Array.isArray(accountsResponse.results)) {
|
||||||
|
accounts = accountsResponse.results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load projects with filters
|
||||||
|
await loadProjects();
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading projects:', error);
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// LOAD PROJECTS
|
||||||
|
async function loadProjects() {
|
||||||
|
const params: ProjectParams = {};
|
||||||
|
|
||||||
|
if (customerFilter) {
|
||||||
|
params.customer_id = customerFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accountFilter) {
|
||||||
|
params.account_id = accountFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusFilter !== 'all') {
|
||||||
|
params.status = statusFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await projectService.getAll(params);
|
||||||
|
if (response && Array.isArray(response.results)) {
|
||||||
|
projects = response.results;
|
||||||
|
filterProjects();
|
||||||
|
} else {
|
||||||
|
console.error('Error: projectService.getAll() did not return a paginated response with results array:', response);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading projects:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FILTER PROJECTS
|
||||||
|
function filterProjects() {
|
||||||
|
filteredProjects = projects.filter(project => {
|
||||||
|
// Search term
|
||||||
|
if (searchTerm) {
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
let customerName = '';
|
||||||
|
let accountName = '';
|
||||||
|
|
||||||
|
if (typeof project.customer === 'object') {
|
||||||
|
customerName = project.customer.name.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (project.account && typeof project.account === 'object') {
|
||||||
|
accountName = project.account.name.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamMembersString = project.team_members
|
||||||
|
? project.team_members.map(tm => `${tm.first_name} ${tm.last_name}`.toLowerCase()).join(' ')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const notes = project.notes ? project.notes.toLowerCase() : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
customerName.includes(term) ||
|
||||||
|
accountName.includes(term) ||
|
||||||
|
teamMembersString.includes(term) ||
|
||||||
|
notes.includes(term)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// APPLY FILTERS
|
||||||
|
async function applyFilters() {
|
||||||
|
loading = true;
|
||||||
|
await loadProjects();
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RESET FILTERS
|
||||||
|
function resetFilters() {
|
||||||
|
searchTerm = '';
|
||||||
|
statusFilter = 'all';
|
||||||
|
customerFilter = '';
|
||||||
|
accountFilter = '';
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
// FORMAT DATE USING DATE-FNS
|
||||||
|
function formatDate(dateStr: string | undefined): string {
|
||||||
|
if (!dateStr) return 'N/A';
|
||||||
|
try {
|
||||||
|
const date = parseISO(dateStr); // Treat the string as an ISO 8601 UTC date
|
||||||
|
return format(date, 'MMMM d, yyyy'); // Format for display in local timezone
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error formatting date:', error);
|
||||||
|
return 'Invalid Date';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FORMAT CURRENCY
|
||||||
|
function formatCurrency(amount: number): string {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
minimumFractionDigits: 2
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET CUSTOMER NAME
|
||||||
|
function getCustomerName(project: Project): string {
|
||||||
|
if (typeof project.customer === 'object') {
|
||||||
|
return project.customer.name;
|
||||||
|
} else {
|
||||||
|
const customer = customers.find(c => c.id === project.customer);
|
||||||
|
return customer ? customer.name : 'Unknown Customer';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET ACCOUNT NAME
|
||||||
|
function getAccountName(project: Project): string {
|
||||||
|
if (!project.account) return 'N/A';
|
||||||
|
|
||||||
|
if (typeof project.account === 'object') {
|
||||||
|
return project.account.name;
|
||||||
|
} else {
|
||||||
|
const account = accounts.find(a => a.id === project.account);
|
||||||
|
return account ? account.name : 'Unknown Account';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LIVE SEARCH RESULT UPDATES
|
||||||
|
$effect(() => {
|
||||||
|
if (searchTerm !== undefined) {
|
||||||
|
filterProjects();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container-fluid p-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="mb-0">Projects</h1>
|
||||||
|
{#if isAdmin || isTeamLeader}
|
||||||
|
<a href="/projects/new" class="btn btn-primary">Create New Project</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-dark-gray-subtle">
|
||||||
|
<h5 class="mb-0">Filters</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="customerFilter" class="form-label">Customer</label>
|
||||||
|
<select
|
||||||
|
class="form-select"
|
||||||
|
id="customerFilter"
|
||||||
|
bind:value={customerFilter}
|
||||||
|
>
|
||||||
|
<option value="">All Customers</option>
|
||||||
|
{#each customers as customer (customer.id)}
|
||||||
|
<option value={customer.id}>{customer.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="accountFilter" class="form-label">Account</label>
|
||||||
|
<select
|
||||||
|
class="form-select"
|
||||||
|
id="accountFilter"
|
||||||
|
bind:value={accountFilter}
|
||||||
|
>
|
||||||
|
<option value="">All Accounts</option>
|
||||||
|
{#each accounts as account (account.id)}
|
||||||
|
<option value={account.id}>{account.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="statusFilter" class="form-label">Status</label>
|
||||||
|
<select
|
||||||
|
class="form-select"
|
||||||
|
id="statusFilter"
|
||||||
|
bind:value={statusFilter}
|
||||||
|
>
|
||||||
|
{#each statusOptions as option (option.value)}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="input-group">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Search projects..."
|
||||||
|
bind:value={searchTerm}
|
||||||
|
>
|
||||||
|
<button class="btn btn-outline-secondary" type="button">
|
||||||
|
<i class="bi bi-search"></i>
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 d-flex justify-content-end">
|
||||||
|
<button class="btn btn-outline-secondary me-2" onclick={resetFilters}>
|
||||||
|
Reset Filters
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" onclick={applyFilters}>
|
||||||
|
Apply Filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Projects List -->
|
||||||
|
{#if loading}
|
||||||
|
<div class="text-center p-5">
|
||||||
|
<div class="spinner-border" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if filteredProjects.length === 0}
|
||||||
|
<div class="alert alert-info" role="alert">
|
||||||
|
No projects found. {searchTerm || statusFilter !== 'all' || customerFilter || accountFilter ? 'Try adjusting your filters.' : ''}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Customer</th>
|
||||||
|
<th>Account</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Labor Cost</th>
|
||||||
|
<th>Billing Amount</th>
|
||||||
|
<th>Team Members</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each filteredProjects as project (project.id)}
|
||||||
|
<tr>
|
||||||
|
<td>{getCustomerName(project)}</td>
|
||||||
|
<td>{getAccountName(project)}</td>
|
||||||
|
<td>{formatDate(project.date)}</td>
|
||||||
|
<td>
|
||||||
|
<span class={`badge ${
|
||||||
|
project.status === 'completed' ? 'bg-success' :
|
||||||
|
project.status === 'cancelled' ? 'bg-danger' :
|
||||||
|
project.status === 'in_progress' ? 'bg-primary' : 'bg-secondary'
|
||||||
|
}`}>
|
||||||
|
{project.status.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{formatCurrency(project.labor)}</td>
|
||||||
|
<td>{formatCurrency(project.amount)}</td>
|
||||||
|
<td>
|
||||||
|
{#if project.team_members && project.team_members.length > 0}
|
||||||
|
{project.team_members.map(tm => `${tm.first_name} ${tm.last_name}`).join(', ')}
|
||||||
|
{:else}
|
||||||
|
<span class="text-muted">Not assigned</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group">
|
||||||
|
<a
|
||||||
|
href={`/projects/${project.id}`}
|
||||||
|
class="btn btn-sm btn-outline-primary"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
374
frontend/src/routes/projects/[id]/+page.svelte
Normal file
374
frontend/src/routes/projects/[id]/+page.svelte
Normal file
@ -0,0 +1,374 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {onMount} from 'svelte';
|
||||||
|
import {
|
||||||
|
projectService,
|
||||||
|
customerService,
|
||||||
|
accountService,
|
||||||
|
type Project,
|
||||||
|
type Customer,
|
||||||
|
type Account
|
||||||
|
} from '$lib/api.js';
|
||||||
|
import {profile, isAuthenticated} from '$lib/auth.js';
|
||||||
|
import {goto} from '$app/navigation';
|
||||||
|
import {page} from '$app/state';
|
||||||
|
import {format, parseISO} from 'date-fns';
|
||||||
|
|
||||||
|
// STATES
|
||||||
|
let project: Project | null = $state(null);
|
||||||
|
let customer: Customer | null = $state(null);
|
||||||
|
let account: Account | null = $state(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
let projectId = $state('');
|
||||||
|
let isAdmin = $state(false);
|
||||||
|
let isTeamLeader = $state(false);
|
||||||
|
|
||||||
|
// LOAD PROJECT DATA
|
||||||
|
onMount(async () => {
|
||||||
|
if (!$isAuthenticated) {
|
||||||
|
await goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isAdmin = $profile?.role === 'admin';
|
||||||
|
isTeamLeader = $profile?.role === 'team_leader';
|
||||||
|
projectId = page.params.id;
|
||||||
|
|
||||||
|
if (!projectId) {
|
||||||
|
await goto('/projects');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
|
||||||
|
// Fetch project details
|
||||||
|
project = await projectService.getById(projectId);
|
||||||
|
|
||||||
|
// Fetch related customer and account
|
||||||
|
if (project) {
|
||||||
|
const customerId = typeof project.customer === 'object'
|
||||||
|
? project.customer.id
|
||||||
|
: project.customer;
|
||||||
|
|
||||||
|
customer = await customerService.getById(customerId);
|
||||||
|
|
||||||
|
if (project.account) {
|
||||||
|
const accountId = typeof project.account === 'object'
|
||||||
|
? project.account.id
|
||||||
|
: project.account;
|
||||||
|
|
||||||
|
account = await accountService.getById(accountId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading project:', error);
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// UPDATE PROJECT STATUS
|
||||||
|
async function updateStatus(newStatus: string) {
|
||||||
|
if (!project) return;
|
||||||
|
|
||||||
|
if (confirm(`Are you sure you want to mark this project as ${newStatus}?`)) {
|
||||||
|
try {
|
||||||
|
await projectService.patch(project.id, {
|
||||||
|
status: newStatus as 'planned' | 'in_progress' | 'completed' | 'cancelled'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh project data
|
||||||
|
project = await projectService.getById(project.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error updating project to ${newStatus}:`, error);
|
||||||
|
alert('Failed to update project. Please try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FORMAT DATE USING DATE-FNS
|
||||||
|
function formatDate(dateStr: string | undefined): string {
|
||||||
|
if (!dateStr) return 'N/A';
|
||||||
|
try {
|
||||||
|
const date = parseISO(dateStr);
|
||||||
|
return format(date, 'MMMM d, yyyy');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error formatting date:', error);
|
||||||
|
return 'Invalid Date';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FORMAT CURRENCY
|
||||||
|
function formatCurrency(amount: number): string {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
minimumFractionDigits: 2
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container-fluid p-4">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<a href="/projects" class="btn btn-outline-secondary mb-2">
|
||||||
|
<i class="bi bi-arrow-left"></i> Back to Projects
|
||||||
|
</a>
|
||||||
|
<h1 class="mb-0">Project Details</h1>
|
||||||
|
{#if project && customer}
|
||||||
|
<p class="text-muted">
|
||||||
|
Customer: <a href={`/customers/${customer.id}`}>{customer.name}</a>
|
||||||
|
{#if account}
|
||||||
|
| Account: <a href={`/accounts/${account.id}`}>{account.name}</a>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if (isAdmin || isTeamLeader) && project && project.status !== 'completed' && project.status !== 'cancelled'}
|
||||||
|
<div>
|
||||||
|
<a href={`/projects/${projectId}/edit`} class="btn btn-primary">
|
||||||
|
Edit Project
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="text-center p-5">
|
||||||
|
<div class="spinner-border" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if !project}
|
||||||
|
<div class="alert alert-danger" role="alert">
|
||||||
|
Project not found
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Project Details -->
|
||||||
|
<div class="row">
|
||||||
|
<!-- Main Info -->
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">Project Information</h5>
|
||||||
|
<span class={`badge ${
|
||||||
|
project.status === 'completed' ? 'bg-success' :
|
||||||
|
project.status === 'cancelled' ? 'bg-danger' :
|
||||||
|
project.status === 'in_progress' ? 'bg-primary' : 'bg-secondary'
|
||||||
|
}`}>
|
||||||
|
{project.status.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<p class="mb-1 text-muted">Project Date</p>
|
||||||
|
<p class="mb-3">{formatDate(project.date)}</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<p class="mb-1 text-muted">Financial Information</p>
|
||||||
|
<p class="mb-3">
|
||||||
|
<span class="d-block">Labor Cost: {formatCurrency(project.labor)}</span>
|
||||||
|
<span class="d-block">Billing Amount: {formatCurrency(project.amount)}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if account}
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<p class="mb-1 text-muted">Account Address</p>
|
||||||
|
<p class="mb-3">
|
||||||
|
{account.street_address}<br>
|
||||||
|
{account.city}, {account.state} {account.zip_code}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<p class="mb-1 text-muted">Team Members</p>
|
||||||
|
{#if project.team_members && project.team_members.length > 0}
|
||||||
|
<ul class="list-group">
|
||||||
|
{#each project.team_members as member (typeof member === 'object' ? member.id : member)}
|
||||||
|
<li class="list-group-item">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<strong>{typeof member === 'object' ? `${member.first_name} ${member.last_name}` : 'Unknown Member'}</strong>
|
||||||
|
<div class="text-muted small">{typeof member === 'object' ? member.role.replace('_', ' ') : ''}</div>
|
||||||
|
</div>
|
||||||
|
{#if typeof member === 'object'}
|
||||||
|
<div>
|
||||||
|
<a href={`tel:${member.primary_phone}`}
|
||||||
|
class="btn btn-sm btn-outline-secondary me-1">
|
||||||
|
<i class="bi bi-telephone"></i>
|
||||||
|
Call
|
||||||
|
</a>
|
||||||
|
<a href={`mailto:${member.email}`}
|
||||||
|
class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="bi bi-envelope"></i>
|
||||||
|
Email
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else}
|
||||||
|
<p class="text-muted">No team members assigned</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if project.notes}
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<p class="mb-1 text-muted">Notes</p>
|
||||||
|
<div class="p-3 bg-light rounded">
|
||||||
|
<p class="mb-0">{project.notes}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div class="col-lg-4">
|
||||||
|
{#if isAdmin || isTeamLeader}
|
||||||
|
<!-- Status Management -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Status Management</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{#if project.status === 'planned'}
|
||||||
|
<p>This project is currently planned.</p>
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
onclick={() => updateStatus('in_progress')}
|
||||||
|
>
|
||||||
|
Mark as In Progress
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-success"
|
||||||
|
onclick={() => updateStatus('completed')}
|
||||||
|
>
|
||||||
|
Mark as Completed
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-danger"
|
||||||
|
onclick={() => updateStatus('cancelled')}
|
||||||
|
>
|
||||||
|
Cancel Project
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else if project.status === 'in_progress'}
|
||||||
|
<p>This project is currently in progress.</p>
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<button
|
||||||
|
class="btn btn-success"
|
||||||
|
onclick={() => updateStatus('completed')}
|
||||||
|
>
|
||||||
|
Mark as Completed
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-danger"
|
||||||
|
onclick={() => updateStatus('cancelled')}
|
||||||
|
>
|
||||||
|
Cancel Project
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else if project.status === 'completed'}
|
||||||
|
<p class="text-success">
|
||||||
|
<i class="bi bi-check-circle"></i>
|
||||||
|
This project has been completed.
|
||||||
|
</p>
|
||||||
|
<div class="d-grid">
|
||||||
|
<button
|
||||||
|
class="btn btn-outline-secondary"
|
||||||
|
onclick={() => updateStatus('in_progress')}
|
||||||
|
>
|
||||||
|
Reopen Project
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else if project.status === 'cancelled'}
|
||||||
|
<p class="text-danger">
|
||||||
|
<i class="bi bi-x-circle"></i>
|
||||||
|
This project has been cancelled.
|
||||||
|
</p>
|
||||||
|
<div class="d-grid">
|
||||||
|
<button
|
||||||
|
class="btn btn-outline-secondary"
|
||||||
|
onclick={() => updateStatus('planned')}
|
||||||
|
>
|
||||||
|
Restart Project
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Contact Information -->
|
||||||
|
{#if customer}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Customer Contact</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="mb-1"><strong>{customer.primary_contact_first_name} {customer.primary_contact_last_name}</strong>
|
||||||
|
</p>
|
||||||
|
<p class="mb-3">
|
||||||
|
<a href={`tel:${customer.primary_contact_phone}`}>{customer.primary_contact_phone}</a><br>
|
||||||
|
<a href={`mailto:${customer.primary_contact_email}`}>{customer.primary_contact_email}</a>
|
||||||
|
</p>
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<a href={`tel:${customer.primary_contact_phone}`} class="btn btn-outline-primary">
|
||||||
|
<i class="bi bi-telephone"></i> Call Contact
|
||||||
|
</a>
|
||||||
|
<a href={`mailto:${customer.primary_contact_email}`} class="btn btn-outline-primary">
|
||||||
|
<i class="bi bi-envelope"></i> Email Contact
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Quick Actions</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
{#if customer}
|
||||||
|
<a href={`/projects/new?customer_id=${customer.id}`} class="btn btn-outline-primary">
|
||||||
|
<i class="bi bi-plus-circle"></i> Create New Project
|
||||||
|
</a>
|
||||||
|
<a href={`/customers/${customer.id}`} class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-people"></i> View Customer
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{#if account}
|
||||||
|
<a href={`/accounts/${account.id}`} class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-building"></i> View Account
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
<a href="/projects" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-list-check"></i> View All Projects
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
340
frontend/src/routes/projects/[id]/edit/+page.svelte
Normal file
340
frontend/src/routes/projects/[id]/edit/+page.svelte
Normal file
@ -0,0 +1,340 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {onMount} from 'svelte';
|
||||||
|
import {
|
||||||
|
projectService,
|
||||||
|
customerService,
|
||||||
|
accountService,
|
||||||
|
profileService,
|
||||||
|
type Project,
|
||||||
|
type Customer,
|
||||||
|
type Account,
|
||||||
|
type Profile
|
||||||
|
} from '$lib/api.js';
|
||||||
|
import {profile, isAuthenticated} from '$lib/auth.js';
|
||||||
|
import {goto} from '$app/navigation';
|
||||||
|
import {page} from '$app/state';
|
||||||
|
|
||||||
|
// STATES
|
||||||
|
let project: Project | null = $state(null);
|
||||||
|
let customer: Customer | null = $state(null);
|
||||||
|
let accounts: Account[] = $state([]);
|
||||||
|
let allProfiles: Profile[] = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let saving = $state(false);
|
||||||
|
let projectId = $state('');
|
||||||
|
let isAdmin = $state(false);
|
||||||
|
let isTeamLeader = $state(false);
|
||||||
|
let selectedTeamMembers: string[] = $state([]);
|
||||||
|
let apiError = $state('');
|
||||||
|
|
||||||
|
// PROJECT FORM
|
||||||
|
let projectForm: Partial<Project> & { team_members?: string[] } = $state({
|
||||||
|
customer: '',
|
||||||
|
account: '',
|
||||||
|
date: '',
|
||||||
|
status: 'planned',
|
||||||
|
notes: '',
|
||||||
|
labor: 0,
|
||||||
|
amount: 0,
|
||||||
|
team_members: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// STATUS OPTIONS
|
||||||
|
const statusOptions = [
|
||||||
|
{value: 'planned', label: 'Planned'},
|
||||||
|
{value: 'in_progress', label: 'In Progress'},
|
||||||
|
{value: 'completed', label: 'Completed'},
|
||||||
|
{value: 'cancelled', label: 'Cancelled'}
|
||||||
|
];
|
||||||
|
|
||||||
|
// LOAD PROJECT DATA
|
||||||
|
onMount(async () => {
|
||||||
|
if (!$isAuthenticated) {
|
||||||
|
await goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isAdmin = $profile?.role === 'admin';
|
||||||
|
isTeamLeader = $profile?.role === 'team_leader';
|
||||||
|
projectId = page.params.id;
|
||||||
|
|
||||||
|
if (!projectId) {
|
||||||
|
await goto('/projects');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only admin and team leader can edit projects
|
||||||
|
if (!isAdmin && !isTeamLeader) {
|
||||||
|
await goto(`/projects/${projectId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
|
||||||
|
// Load profiles for team assignment first so they're ready
|
||||||
|
const profilesResponse = await profileService.getAll();
|
||||||
|
if (profilesResponse && Array.isArray(profilesResponse.results)) {
|
||||||
|
allProfiles = profilesResponse.results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch project details
|
||||||
|
project = await projectService.getById(projectId);
|
||||||
|
|
||||||
|
// Check if project is completed or cancelled
|
||||||
|
if (project && (project.status === 'completed' || project.status === 'cancelled')) {
|
||||||
|
// Redirect to view page with a message
|
||||||
|
await goto(`/projects/${projectId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch related customer
|
||||||
|
if (project) {
|
||||||
|
const customerId = typeof project.customer === 'object'
|
||||||
|
? project.customer.id
|
||||||
|
: project.customer;
|
||||||
|
|
||||||
|
customer = await customerService.getById(customerId);
|
||||||
|
|
||||||
|
// Load accounts for this customer
|
||||||
|
const accountsResponse = await accountService.getAll({customer_id: customerId});
|
||||||
|
if (accountsResponse && Array.isArray(accountsResponse.results)) {
|
||||||
|
accounts = accountsResponse.results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-populate form values for editing
|
||||||
|
projectForm = {
|
||||||
|
customer: customerId,
|
||||||
|
account: project.account ? (typeof project.account === 'object' ? project.account.id : project.account) : '',
|
||||||
|
date: project.date,
|
||||||
|
status: project.status,
|
||||||
|
notes: project.notes || '',
|
||||||
|
labor: project.labor || 0,
|
||||||
|
amount: project.amount || 0,
|
||||||
|
team_members: [] // Will be set separately
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set selected team members - these should be IDs
|
||||||
|
if (project.team_members && project.team_members.length > 0) {
|
||||||
|
selectedTeamMembers = project.team_members.map(tm =>
|
||||||
|
typeof tm === 'object' && 'id' in tm ? tm.id : tm
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading project:', error);
|
||||||
|
loading = false;
|
||||||
|
apiError = 'Failed to load project data. Please try again.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// HANDLE PROJECT UPDATE
|
||||||
|
async function handleSubmit(event: Event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!project || saving) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
saving = true;
|
||||||
|
apiError = '';
|
||||||
|
|
||||||
|
// Instead of using type assertions, format the data to match what your API expects
|
||||||
|
const formData: Partial<Project> = {
|
||||||
|
customer: projectForm.customer,
|
||||||
|
account: projectForm.account || undefined,
|
||||||
|
date: projectForm.date,
|
||||||
|
status: projectForm.status,
|
||||||
|
notes: projectForm.notes,
|
||||||
|
labor: projectForm.labor,
|
||||||
|
amount: projectForm.amount,
|
||||||
|
team_members: selectedTeamMembers // Now this would be compatible with the updated interface
|
||||||
|
};
|
||||||
|
|
||||||
|
await projectService.update(project.id, formData);
|
||||||
|
|
||||||
|
// Navigate back to project details
|
||||||
|
await goto(`/projects/${project.id}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating project:', error);
|
||||||
|
apiError = 'Failed to update project. Please try again.';
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container-fluid p-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<a href={`/projects/${projectId}`} class="btn btn-outline-secondary mb-2">
|
||||||
|
<i class="bi bi-arrow-left"></i> Back to Project Details
|
||||||
|
</a>
|
||||||
|
<h1 class="mb-0">Edit Project</h1>
|
||||||
|
{#if project && customer}
|
||||||
|
<p class="text-muted">
|
||||||
|
Customer: <a href={`/customers/${customer.id}`}>{customer.name}</a>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="text-center p-5">
|
||||||
|
<div class="spinner-border" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if !project}
|
||||||
|
<div class="alert alert-danger" role="alert">
|
||||||
|
Project not found
|
||||||
|
</div>
|
||||||
|
{:else if !isAdmin && !isTeamLeader}
|
||||||
|
<div class="alert alert-warning" role="alert">
|
||||||
|
You don't have permission to edit projects
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#if apiError}
|
||||||
|
<div class="alert alert-danger" role="alert">
|
||||||
|
{apiError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h5 class="mb-0">Edit Project</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form onsubmit={handleSubmit}>
|
||||||
|
<!-- Account Selection -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="account" class="form-label">Account</label>
|
||||||
|
<select
|
||||||
|
class="form-select"
|
||||||
|
id="account"
|
||||||
|
bind:value={projectForm.account}
|
||||||
|
>
|
||||||
|
<option value="">No Account (Optional)</option>
|
||||||
|
{#each accounts as acc (acc.id)}
|
||||||
|
<option value={acc.id}>{acc.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Project Status -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="status" class="form-label">Status *</label>
|
||||||
|
<select
|
||||||
|
class="form-select"
|
||||||
|
id="status"
|
||||||
|
bind:value={projectForm.status}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
{#each statusOptions as option (option.value)}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Project Date & Financial Info -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="date" class="form-label">Project Date *</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
class="form-control"
|
||||||
|
id="date"
|
||||||
|
bind:value={projectForm.date}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="labor" class="form-label">Labor Cost *</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="form-control"
|
||||||
|
id="labor"
|
||||||
|
bind:value={projectForm.labor}
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="amount" class="form-label">Billing Amount *</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="form-control"
|
||||||
|
id="amount"
|
||||||
|
bind:value={projectForm.amount}
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Team Members -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label" for="team">Team Members</label>
|
||||||
|
<p class="small text-muted mb-3">Select team members to assign to this project.</p>
|
||||||
|
|
||||||
|
{#if allProfiles.length === 0}
|
||||||
|
<p class="text-muted">No team members available.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="row">
|
||||||
|
{#each allProfiles as profileObj (profileObj.id)}
|
||||||
|
<div class="col-md-4 col-lg-3 mb-2">
|
||||||
|
<div class="form-check">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
id={`profile-${profileObj.id}`}
|
||||||
|
value={profileObj.id}
|
||||||
|
bind:group={selectedTeamMembers}
|
||||||
|
>
|
||||||
|
<label class="form-check-label" for={`profile-${profileObj.id}`}>
|
||||||
|
{profileObj.first_name} {profileObj.last_name}
|
||||||
|
<small class="d-block text-muted">
|
||||||
|
{profileObj.role.replace('_', ' ')}
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="notes" class="form-label">Notes</label>
|
||||||
|
<textarea
|
||||||
|
class="form-control"
|
||||||
|
id="notes"
|
||||||
|
rows="4"
|
||||||
|
bind:value={projectForm.notes}
|
||||||
|
placeholder="Enter any details or instructions about this project..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit Buttons -->
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<a
|
||||||
|
href={`/projects/${projectId}`}
|
||||||
|
class="btn btn-secondary me-2"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary"
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? 'Updating...' : 'Update Project'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
337
frontend/src/routes/projects/new/+page.svelte
Normal file
337
frontend/src/routes/projects/new/+page.svelte
Normal file
@ -0,0 +1,337 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import {
|
||||||
|
projectService,
|
||||||
|
customerService,
|
||||||
|
accountService,
|
||||||
|
profileService,
|
||||||
|
type Project,
|
||||||
|
type Customer,
|
||||||
|
type Account,
|
||||||
|
type Profile
|
||||||
|
} from '$lib/api.js';
|
||||||
|
import { profile, isAuthenticated } from '$lib/auth.js';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
// STATES
|
||||||
|
let customers: Customer[] = $state([]);
|
||||||
|
let accounts: Account[] = $state([]);
|
||||||
|
let allProfiles: Profile[] = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let saving = $state(false);
|
||||||
|
let isAdmin = $state(false);
|
||||||
|
let isTeamLeader = $state(false);
|
||||||
|
let selectedTeamMembers: string[] = $state([]);
|
||||||
|
|
||||||
|
// Form data
|
||||||
|
let projectForm: Partial<Project> = $state({
|
||||||
|
customer: '',
|
||||||
|
account: '',
|
||||||
|
date: new Date().toISOString().split('T')[0],
|
||||||
|
status: 'planned',
|
||||||
|
notes: '',
|
||||||
|
labor: 0,
|
||||||
|
amount: 0,
|
||||||
|
team_members: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Type-safe form data for submission
|
||||||
|
interface ProjectSubmitData extends Omit<Partial<Project>, 'team_members'> {
|
||||||
|
team_members: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// STATUS OPTIONS
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: 'planned', label: 'Planned' },
|
||||||
|
{ value: 'in_progress', label: 'In Progress' },
|
||||||
|
{ value: 'completed', label: 'Completed' },
|
||||||
|
{ value: 'cancelled', label: 'Cancelled' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Check URL parameters
|
||||||
|
let customerIdFromUrl = $state('');
|
||||||
|
let accountIdFromUrl = $state('');
|
||||||
|
|
||||||
|
// AUTH CHECK
|
||||||
|
onMount(async () => {
|
||||||
|
if (!$isAuthenticated) {
|
||||||
|
await goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isAdmin = $profile?.role === 'admin';
|
||||||
|
isTeamLeader = $profile?.role === 'team_leader';
|
||||||
|
|
||||||
|
// Only admin and team leader can create projects
|
||||||
|
if (!isAdmin && !isTeamLeader) {
|
||||||
|
await goto('/projects');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if customer_id or account_id is in URL params
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
customerIdFromUrl = urlParams.get('customer_id') || '';
|
||||||
|
accountIdFromUrl = urlParams.get('account_id') || '';
|
||||||
|
|
||||||
|
if (customerIdFromUrl) {
|
||||||
|
projectForm.customer = customerIdFromUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accountIdFromUrl) {
|
||||||
|
projectForm.account = accountIdFromUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
|
||||||
|
// Load customers for the dropdown
|
||||||
|
const customersResponse = await customerService.getAll();
|
||||||
|
if (customersResponse && Array.isArray(customersResponse.results)) {
|
||||||
|
customers = customersResponse.results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If customer is pre-selected, load its accounts
|
||||||
|
if (customerIdFromUrl) {
|
||||||
|
await loadAccounts(customerIdFromUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load profiles for team assignment
|
||||||
|
const profilesResponse = await profileService.getAll();
|
||||||
|
if (profilesResponse && Array.isArray(profilesResponse.results)) {
|
||||||
|
allProfiles = profilesResponse.results;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading data:', error);
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// LOAD ACCOUNTS FOR SELECTED CUSTOMER
|
||||||
|
async function loadAccounts(customerId: string) {
|
||||||
|
if (!customerId) {
|
||||||
|
accounts = [];
|
||||||
|
projectForm.account = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const accountsResponse = await accountService.getAll({ customer_id: customerId });
|
||||||
|
if (accountsResponse && Array.isArray(accountsResponse.results)) {
|
||||||
|
accounts = accountsResponse.results;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading accounts:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HANDLE CUSTOMER SELECTION CHANGE
|
||||||
|
function handleCustomerChange() {
|
||||||
|
if (projectForm.customer) {
|
||||||
|
loadAccounts(projectForm.customer as string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HANDLE PROJECT CREATION
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (saving) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
saving = true;
|
||||||
|
|
||||||
|
// Create a submission-ready object with the correct types
|
||||||
|
const submitData: ProjectSubmitData = {
|
||||||
|
customer: projectForm.customer,
|
||||||
|
account: projectForm.account || undefined,
|
||||||
|
date: projectForm.date,
|
||||||
|
status: projectForm.status as 'planned' | 'in_progress' | 'completed' | 'cancelled',
|
||||||
|
notes: projectForm.notes,
|
||||||
|
labor: projectForm.labor || 0,
|
||||||
|
team_members: selectedTeamMembers
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create project
|
||||||
|
const newProject = await projectService.create(submitData as unknown as Omit<Project, 'id'>);
|
||||||
|
saving = false;
|
||||||
|
|
||||||
|
// Redirect to the new project's detail page
|
||||||
|
await goto(`/projects/${newProject.id}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating project:', error);
|
||||||
|
alert('Failed to create project. Please try again.');
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container-fluid p-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="mb-0">Create New Project</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<a href="/projects" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left"></i> Back to Projects
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="text-center p-5">
|
||||||
|
<div class="spinner-border" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Create Form -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h5 class="mb-0">Project Information</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form onsubmit={handleSubmit}>
|
||||||
|
<!-- Customer & Account Selection -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="customer" class="form-label">Customer *</label>
|
||||||
|
<select
|
||||||
|
class="form-select"
|
||||||
|
id="customer"
|
||||||
|
bind:value={projectForm.customer}
|
||||||
|
onchange={handleCustomerChange}
|
||||||
|
required
|
||||||
|
disabled={!!customerIdFromUrl}
|
||||||
|
>
|
||||||
|
<option value="">Select a customer</option>
|
||||||
|
{#each customers as customer (customer.id)}
|
||||||
|
<option value={customer.id}>{customer.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="account" class="form-label">Account</label>
|
||||||
|
<select
|
||||||
|
class="form-select"
|
||||||
|
id="account"
|
||||||
|
bind:value={projectForm.account}
|
||||||
|
disabled={!projectForm.customer || !!accountIdFromUrl}
|
||||||
|
>
|
||||||
|
<option value="">No Account (Optional)</option>
|
||||||
|
{#each accounts as account (account.id)}
|
||||||
|
<option value={account.id}>{account.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Project Date & Status -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="date" class="form-label">Project Date *</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
class="form-control"
|
||||||
|
id="date"
|
||||||
|
bind:value={projectForm.date}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="status" class="form-label">Status *</label>
|
||||||
|
<select
|
||||||
|
class="form-select"
|
||||||
|
id="status"
|
||||||
|
bind:value={projectForm.status}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
{#each statusOptions as option (option.value)}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="labor" class="form-label">Labor Cost *</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="form-control"
|
||||||
|
id="labor"
|
||||||
|
bind:value={projectForm.labor}
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="amount" class="form-label">Billing Amount *</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="form-control"
|
||||||
|
id="amount"
|
||||||
|
bind:value={projectForm.amount}
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Team Members -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label" for="teamMembers">Team Members</label>
|
||||||
|
{#if allProfiles.length === 0}
|
||||||
|
<p class="text-muted">No team members available.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="row" id="teamMembers">
|
||||||
|
{#each allProfiles as profileObj (profileObj.id)}
|
||||||
|
<div class="col-md-4 col-lg-3 mb-2">
|
||||||
|
<div class="form-check">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
id={`profile-${profileObj.id}`}
|
||||||
|
value={profileObj.id}
|
||||||
|
bind:group={selectedTeamMembers}
|
||||||
|
>
|
||||||
|
<label class="form-check-label" for={`profile-${profileObj.id}`}>
|
||||||
|
{profileObj.first_name} {profileObj.last_name}
|
||||||
|
<small class="d-block text-muted">
|
||||||
|
{profileObj.role.replace('_', ' ')}
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="notes" class="form-label">Notes</label>
|
||||||
|
<textarea
|
||||||
|
class="form-control"
|
||||||
|
id="notes"
|
||||||
|
rows="4"
|
||||||
|
bind:value={projectForm.notes}
|
||||||
|
placeholder="Enter any details or instructions about this project..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit Buttons -->
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<a href="/projects" class="btn btn-secondary me-2">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary"
|
||||||
|
disabled={saving || !projectForm.customer}
|
||||||
|
>
|
||||||
|
{saving ? 'Creating...' : 'Create Project'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
480
frontend/src/routes/schedules/+page.svelte
Normal file
480
frontend/src/routes/schedules/+page.svelte
Normal file
@ -0,0 +1,480 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import {
|
||||||
|
scheduleService,
|
||||||
|
accountService,
|
||||||
|
type Schedule,
|
||||||
|
type Account
|
||||||
|
} from '$lib/api.js';
|
||||||
|
import { profile, isAuthenticated } from '$lib/auth.js';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
// STATES
|
||||||
|
let schedules: Schedule[] = $state([]);
|
||||||
|
let filteredSchedules: Schedule[] = $state([]);
|
||||||
|
let accounts: Account[] = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let searchTerm = $state('');
|
||||||
|
let accountFilter = $state('');
|
||||||
|
let showInactive = $state(false);
|
||||||
|
let isAdmin = $state(false);
|
||||||
|
let isTeamLeader = $state(false);
|
||||||
|
let showCreateForm = $state(false);
|
||||||
|
let selectedDays: string[] = $state([]);
|
||||||
|
// Schedule form data
|
||||||
|
let scheduleForm: Partial<Schedule> = $state({
|
||||||
|
account: '',
|
||||||
|
monday_service: false,
|
||||||
|
tuesday_service: false,
|
||||||
|
wednesday_service: false,
|
||||||
|
thursday_service: false,
|
||||||
|
friday_service: false,
|
||||||
|
saturday_service: false,
|
||||||
|
sunday_service: false,
|
||||||
|
weekend_service: false,
|
||||||
|
schedule_exception: '',
|
||||||
|
start_date: new Date().toISOString().split('T')[0],
|
||||||
|
end_date: undefined
|
||||||
|
});
|
||||||
|
// WEEKDAY MANAGEMENT
|
||||||
|
const weekdayOptions = [
|
||||||
|
{ value: 'monday_service', label: 'Monday' },
|
||||||
|
{ value: 'tuesday_service', label: 'Tuesday' },
|
||||||
|
{ value: 'wednesday_service', label: 'Wednesday' },
|
||||||
|
{ value: 'thursday_service', label: 'Thursday' },
|
||||||
|
{ value: 'friday_service', label: 'Friday' },
|
||||||
|
{ value: 'saturday_service', label: 'Saturday' },
|
||||||
|
{ value: 'sunday_service', label: 'Sunday' },
|
||||||
|
{ value: 'weekend_service', label: 'Weekend' }
|
||||||
|
];
|
||||||
|
// AUTH CHECK
|
||||||
|
onMount(async () => {
|
||||||
|
if (!$isAuthenticated) {
|
||||||
|
await goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isAdmin = $profile?.role === 'admin';
|
||||||
|
isTeamLeader = $profile?.role === 'team_leader';
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const accountIdFromUrl = urlParams.get('account_id') || '';
|
||||||
|
if (accountIdFromUrl) {
|
||||||
|
accountFilter = accountIdFromUrl;
|
||||||
|
scheduleForm.account = accountIdFromUrl;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
// FETCH ACCOUNTS
|
||||||
|
const accountsResponse = await accountService.getAll();
|
||||||
|
if (accountsResponse && Array.isArray(accountsResponse.results)) {
|
||||||
|
accounts = accountsResponse.results;
|
||||||
|
}
|
||||||
|
// FETCH SCHEDULES
|
||||||
|
await loadSchedules();
|
||||||
|
loading = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading schedules:', error);
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// LOAD SCHEDULES
|
||||||
|
async function loadSchedules() {
|
||||||
|
const params: { account_id?: string } = {};
|
||||||
|
if (accountFilter) {
|
||||||
|
params.account_id = accountFilter;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await scheduleService.getAll(params);
|
||||||
|
if (response && Array.isArray(response.results)) {
|
||||||
|
schedules = response.results;
|
||||||
|
filterSchedules();
|
||||||
|
} else {
|
||||||
|
console.error('Error: scheduleService.getAll() did not return a paginated response with results array:', response);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading schedules:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// LIVE SEARCH RESULT UPDATES
|
||||||
|
$effect(() => {
|
||||||
|
if (searchTerm !== undefined || showInactive !== undefined) {
|
||||||
|
filterSchedules();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// FILTER SCHEDULES
|
||||||
|
function filterSchedules() {
|
||||||
|
filteredSchedules = schedules.filter(schedule => {
|
||||||
|
// ACTIVE
|
||||||
|
const isActive = !schedule.end_date || new Date(schedule.end_date) > new Date();
|
||||||
|
if (!showInactive && !isActive) return false;
|
||||||
|
// SEARCH TERM
|
||||||
|
if (searchTerm) {
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
let accountName = '';
|
||||||
|
if (typeof schedule.account === 'object') {
|
||||||
|
accountName = schedule.account.name.toLowerCase();
|
||||||
|
}
|
||||||
|
const exception = schedule.schedule_exception ? schedule.schedule_exception.toLowerCase() : '';
|
||||||
|
return (
|
||||||
|
accountName.includes(term) ||
|
||||||
|
exception.includes(term)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// APPLY FILTERS
|
||||||
|
async function applyFilters() {
|
||||||
|
loading = true;
|
||||||
|
await loadSchedules();
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
// RESET FILTERS
|
||||||
|
function resetFilters() {
|
||||||
|
searchTerm = '';
|
||||||
|
accountFilter = '';
|
||||||
|
showInactive = false;
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
// CREATE SCHEDULE
|
||||||
|
function toggleCreateForm() {
|
||||||
|
showCreateForm = !showCreateForm;
|
||||||
|
resetScheduleForm();
|
||||||
|
}
|
||||||
|
// RESET FORM
|
||||||
|
function resetScheduleForm() {
|
||||||
|
scheduleForm = {
|
||||||
|
account: accountFilter || '',
|
||||||
|
monday_service: false,
|
||||||
|
tuesday_service: false,
|
||||||
|
wednesday_service: false,
|
||||||
|
thursday_service: false,
|
||||||
|
friday_service: false,
|
||||||
|
saturday_service: false,
|
||||||
|
sunday_service: false,
|
||||||
|
weekend_service: false,
|
||||||
|
schedule_exception: '',
|
||||||
|
start_date: new Date().toISOString().split('T')[0],
|
||||||
|
end_date: undefined
|
||||||
|
};
|
||||||
|
selectedDays = [];
|
||||||
|
}
|
||||||
|
// EDIT SCHEDULE
|
||||||
|
function editSchedule(schedule: Schedule) {
|
||||||
|
scheduleForm = {
|
||||||
|
id: schedule.id,
|
||||||
|
account: typeof schedule.account === 'object' ? schedule.account.id : schedule.account,
|
||||||
|
monday_service: schedule.monday_service,
|
||||||
|
tuesday_service: schedule.tuesday_service,
|
||||||
|
wednesday_service: schedule.wednesday_service,
|
||||||
|
thursday_service: schedule.thursday_service,
|
||||||
|
friday_service: schedule.friday_service,
|
||||||
|
saturday_service: schedule.saturday_service,
|
||||||
|
sunday_service: schedule.sunday_service,
|
||||||
|
weekend_service: schedule.weekend_service,
|
||||||
|
schedule_exception: schedule.schedule_exception || undefined,
|
||||||
|
start_date: schedule.start_date,
|
||||||
|
end_date: schedule.end_date || undefined
|
||||||
|
};
|
||||||
|
selectedDays = weekdayOptions
|
||||||
|
.filter(day => schedule[day.value as keyof Schedule] === true)
|
||||||
|
.map(day => day.value);
|
||||||
|
showCreateForm = true;
|
||||||
|
}
|
||||||
|
// HANDLE SCHEDULE CREATION/UPDATE
|
||||||
|
async function handleSubmit() {
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
// Update form fields based on selectedDays
|
||||||
|
weekdayOptions.forEach(day => {
|
||||||
|
(scheduleForm[day.value as keyof typeof scheduleForm] as boolean) = selectedDays.includes(day.value);
|
||||||
|
});
|
||||||
|
// Create or update schedule
|
||||||
|
if (scheduleForm.id) {
|
||||||
|
await scheduleService.update(scheduleForm.id as string, scheduleForm);
|
||||||
|
} else {
|
||||||
|
await scheduleService.create(scheduleForm as Omit<Schedule, 'id'>);
|
||||||
|
}
|
||||||
|
// Reset form and refresh schedules
|
||||||
|
resetScheduleForm();
|
||||||
|
showCreateForm = false;
|
||||||
|
await loadSchedules();
|
||||||
|
loading = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving schedule:', error);
|
||||||
|
alert('Failed to save schedule. Please try again.');
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// MARK SCHEDULE INACTIVE
|
||||||
|
function markInactive(schedule: Schedule) {
|
||||||
|
if (confirm(`Are you sure you want to end this schedule?`)) {
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
scheduleService.patch(schedule.id, { end_date: today })
|
||||||
|
.then(async () => {
|
||||||
|
await loadSchedules();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error marking schedule inactive:', error);
|
||||||
|
alert('Failed to update schedule. Please try again.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// FORMAT DATE
|
||||||
|
function formatDate(dateStr: string | undefined): string {
|
||||||
|
if (!dateStr) return 'N/A';
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET ACCOUNT NAME
|
||||||
|
function getAccountName(schedule: Schedule): string {
|
||||||
|
if (typeof schedule.account === 'object') {
|
||||||
|
return schedule.account.name;
|
||||||
|
} else {
|
||||||
|
const account = accounts.find(a => a.id === schedule.account);
|
||||||
|
return account ? account.name : 'Unknown Account';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET SCHEDULE DAYS
|
||||||
|
function getScheduleDays(schedule: Schedule): string {
|
||||||
|
const days = [];
|
||||||
|
if (schedule.monday_service) days.push('Mon');
|
||||||
|
if (schedule.tuesday_service) days.push('Tue');
|
||||||
|
if (schedule.wednesday_service) days.push('Wed');
|
||||||
|
if (schedule.thursday_service) days.push('Thu');
|
||||||
|
if (schedule.friday_service) days.push('Fri');
|
||||||
|
if (schedule.saturday_service) days.push('Sat');
|
||||||
|
if (schedule.sunday_service) days.push('Sun');
|
||||||
|
if (schedule.weekend_service) days.push('Weekend');
|
||||||
|
return days.join(', ');
|
||||||
|
}
|
||||||
|
// HANDLE WEEKDAY SELECTION
|
||||||
|
function updateWeekdaySelection() {
|
||||||
|
// Update form based on selected days
|
||||||
|
weekdayOptions.forEach(day => {
|
||||||
|
(scheduleForm[day.value as keyof typeof scheduleForm] as boolean) = selectedDays.includes(day.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container-fluid p-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="mb-0">Schedules</h1>
|
||||||
|
<div>
|
||||||
|
{#if isAdmin || isTeamLeader}
|
||||||
|
<a href="/services/generate" class="btn btn-success me-2">
|
||||||
|
<i class="bi bi-calendar-plus"></i> Generate Services
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-primary" onclick={toggleCreateForm}>
|
||||||
|
{showCreateForm ? 'Cancel' : 'Add Schedule'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create/Edit Form -->
|
||||||
|
{#if showCreateForm}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h5 class="mb-0">{scheduleForm.id ? 'Edit Schedule' : 'Add New Schedule'}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form onsubmit={handleSubmit}>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="account" class="form-label">Account *</label>
|
||||||
|
<select
|
||||||
|
class="form-select"
|
||||||
|
id="account"
|
||||||
|
bind:value={scheduleForm.account}
|
||||||
|
required
|
||||||
|
disabled={!!accountFilter}
|
||||||
|
>
|
||||||
|
<option value="">Select an account</option>
|
||||||
|
{#each accounts as account (account.id)}
|
||||||
|
<option value={account.id}>{account.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="start_date" class="form-label">Start Date *</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
class="form-control"
|
||||||
|
id="start_date"
|
||||||
|
bind:value={scheduleForm.start_date}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label" for="serviceDays">Service Days *</label>
|
||||||
|
<div class="row" id="serviceDays">
|
||||||
|
{#each weekdayOptions as day (day.value)}
|
||||||
|
<div class="col-md-3 mb-2">
|
||||||
|
<div class="form-check">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
id={day.value}
|
||||||
|
value={day.value}
|
||||||
|
bind:group={selectedDays}
|
||||||
|
onchange={updateWeekdaySelection}
|
||||||
|
>
|
||||||
|
<label class="form-check-label" for={day.value}>
|
||||||
|
{day.label}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="schedule_exception" class="form-label">Schedule Exceptions</label>
|
||||||
|
<textarea
|
||||||
|
class="form-control"
|
||||||
|
id="schedule_exception"
|
||||||
|
rows="3"
|
||||||
|
bind:value={scheduleForm.schedule_exception}
|
||||||
|
placeholder="Note any exceptions to the regular schedule here..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<button type="button" class="btn btn-secondary me-2" onclick={toggleCreateForm}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
{scheduleForm.id ? 'Update Schedule' : 'Create Schedule'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Search and filters -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-dark-gray-subtle">
|
||||||
|
<h5 class="mb-0">Filters</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="accountFilter" class="form-label">Account</label>
|
||||||
|
<select
|
||||||
|
class="form-select"
|
||||||
|
id="accountFilter"
|
||||||
|
bind:value={accountFilter}
|
||||||
|
>
|
||||||
|
<option value="">All Accounts</option>
|
||||||
|
{#each accounts as account (account.id)}
|
||||||
|
<option value={account.id}>{account.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="searchTerm" class="form-label">Search</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="searchTerm"
|
||||||
|
placeholder="Search schedules..."
|
||||||
|
bind:value={searchTerm}
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 d-flex align-items-end">
|
||||||
|
<div class="form-check mb-2">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
id="showInactive"
|
||||||
|
bind:checked={showInactive}
|
||||||
|
>
|
||||||
|
<label class="form-check-label" for="showInactive">
|
||||||
|
Show Inactive Schedules
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 d-flex justify-content-end">
|
||||||
|
<button class="btn btn-outline-secondary me-2" onclick={resetFilters}>
|
||||||
|
Reset Filters
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" onclick={applyFilters}>
|
||||||
|
Apply Filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Schedules List -->
|
||||||
|
{#if loading}
|
||||||
|
<div class="text-center p-5">
|
||||||
|
<div class="spinner-border" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if filteredSchedules.length === 0}
|
||||||
|
<div class="alert alert-info" role="alert">
|
||||||
|
No schedules found. {searchTerm ? 'Try adjusting your search.' : ''}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Account</th>
|
||||||
|
<th>Service Days</th>
|
||||||
|
<th>Start Date</th>
|
||||||
|
<th>End Date</th>
|
||||||
|
<th>Exceptions</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each filteredSchedules as schedule (schedule.id)}
|
||||||
|
{@const isActive = !schedule.end_date || new Date(schedule.end_date) > new Date()}
|
||||||
|
<tr class={isActive ? '' : 'table-secondary'}>
|
||||||
|
<td>{getAccountName(schedule)}</td>
|
||||||
|
<td>{getScheduleDays(schedule)}</td>
|
||||||
|
<td>{formatDate(schedule.start_date)}</td>
|
||||||
|
<td>{schedule.end_date ? formatDate(schedule.end_date) : 'Active'}</td>
|
||||||
|
<td>{schedule.schedule_exception || 'None'}</td>
|
||||||
|
<td>
|
||||||
|
<span class={`badge ${isActive ? 'bg-success' : 'bg-secondary'}`}>
|
||||||
|
{isActive ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group">
|
||||||
|
{#if isAdmin || isTeamLeader}
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick={() => editSchedule(schedule)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if isActive}
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline-danger"
|
||||||
|
onclick={() => markInactive(schedule)}
|
||||||
|
>
|
||||||
|
End Schedule
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
318
frontend/src/routes/services/+page.svelte
Normal file
318
frontend/src/routes/services/+page.svelte
Normal file
@ -0,0 +1,318 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {onMount} from 'svelte';
|
||||||
|
import {serviceService, accountService, type Service, type Account, type ServiceParams} from '$lib/api.js';
|
||||||
|
import {profile, isAuthenticated} from '$lib/auth.js';
|
||||||
|
import {goto} from '$app/navigation';
|
||||||
|
import {format, parseISO} from "date-fns";
|
||||||
|
|
||||||
|
// STATES
|
||||||
|
let services: Service[] = $state([]);
|
||||||
|
let filteredServices: Service[] = $state([]);
|
||||||
|
let accounts: Account[] = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let searchTerm = $state('');
|
||||||
|
let statusFilter = $state('all');
|
||||||
|
let accountFilter = $state('');
|
||||||
|
let dateFromFilter = $state('');
|
||||||
|
let dateToFilter = $state('');
|
||||||
|
let isAdmin = $state(false);
|
||||||
|
let isTeamLeader = $state(false);
|
||||||
|
|
||||||
|
// STATUS OPTIONS
|
||||||
|
const statusOptions = [
|
||||||
|
{value: 'all', label: 'All Statuses'},
|
||||||
|
{value: 'scheduled', label: 'Scheduled'},
|
||||||
|
{value: 'in_progress', label: 'In Progress'},
|
||||||
|
{value: 'completed', label: 'Completed'},
|
||||||
|
{value: 'cancelled', label: 'Cancelled'}
|
||||||
|
];
|
||||||
|
// AUTH CHECK
|
||||||
|
onMount(async () => {
|
||||||
|
if (!$isAuthenticated) {
|
||||||
|
await goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isAdmin = $profile?.role === 'admin';
|
||||||
|
isTeamLeader = $profile?.role === 'team_leader';
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const accountIdFromUrl = urlParams.get('account_id') || '';
|
||||||
|
if (accountIdFromUrl) {
|
||||||
|
accountFilter = accountIdFromUrl;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
// Load accounts for the filter dropdown
|
||||||
|
const accountsResponse = await accountService.getAll();
|
||||||
|
if (accountsResponse && Array.isArray(accountsResponse.results)) {
|
||||||
|
accounts = accountsResponse.results;
|
||||||
|
}
|
||||||
|
// Load services with filters
|
||||||
|
await loadServices();
|
||||||
|
loading = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading services:', error);
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// LOAD SERVICES
|
||||||
|
async function loadServices() {
|
||||||
|
const params: ServiceParams = {};
|
||||||
|
if (accountFilter) {
|
||||||
|
params.account_id = accountFilter;
|
||||||
|
}
|
||||||
|
if (statusFilter !== 'all') {
|
||||||
|
params.status = statusFilter;
|
||||||
|
}
|
||||||
|
if (dateFromFilter) {
|
||||||
|
params.date_from = dateFromFilter;
|
||||||
|
}
|
||||||
|
if (dateToFilter) {
|
||||||
|
params.date_to = dateToFilter;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await serviceService.getAll(params);
|
||||||
|
if (response && Array.isArray(response.results)) {
|
||||||
|
services = response.results;
|
||||||
|
filterServices(); // Filtering happens after loading
|
||||||
|
} else {
|
||||||
|
console.error('Error: serviceService.getAll() did not return a paginated response with results array:', response);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading services:', error);
|
||||||
|
} finally {
|
||||||
|
loading = false; // Ensure loading is set to false even on error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FILTER SERVICES
|
||||||
|
function filterServices() {
|
||||||
|
filteredServices = services.filter(service => {
|
||||||
|
// Search term
|
||||||
|
if (searchTerm) {
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
let accountName = '';
|
||||||
|
|
||||||
|
if (typeof service.account === 'object') {
|
||||||
|
accountName = service.account.name.toLowerCase();
|
||||||
|
}
|
||||||
|
const teamMembersString = service.team_members
|
||||||
|
? service.team_members.map(tm => typeof tm === 'string' ? tm : `${tm.first_name} ${tm.last_name}`).join(' ').toLowerCase()
|
||||||
|
: '';
|
||||||
|
const notes = service.notes ? service.notes.toLowerCase() : '';
|
||||||
|
return (
|
||||||
|
accountName.includes(term) ||
|
||||||
|
teamMembersString.includes(term) ||
|
||||||
|
notes.includes(term)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
// Data sorting
|
||||||
|
filteredServices.sort((a, b) => {
|
||||||
|
// Ascending order by date
|
||||||
|
const dateA = new Date(a.date);
|
||||||
|
const dateB = new Date(b.date);
|
||||||
|
const dateDiff = dateA.getTime() - dateB.getTime();
|
||||||
|
// Alphabetical order
|
||||||
|
if (dateDiff === 0) {
|
||||||
|
const accountNameA = typeof a.account === 'object' ? a.account.name : getAccountName(a);
|
||||||
|
const accountNameB = typeof b.account === 'object' ? b.account.name : getAccountName(b);
|
||||||
|
return accountNameA.localeCompare(accountNameB);
|
||||||
|
}
|
||||||
|
return dateDiff;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// APPLY FILTERS - Now directly calls loadServices
|
||||||
|
async function applyFilters() {
|
||||||
|
loading = true;
|
||||||
|
await loadServices();
|
||||||
|
}
|
||||||
|
|
||||||
|
// RESET FILTERS - Now calls loadServices directly
|
||||||
|
function resetFilters() {
|
||||||
|
searchTerm = '';
|
||||||
|
statusFilter = 'all';
|
||||||
|
accountFilter = '';
|
||||||
|
dateFromFilter = '';
|
||||||
|
dateToFilter = '';
|
||||||
|
loading = true; // Set loading to true when resetting and reloading
|
||||||
|
loadServices();
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET ACCOUNT NAME
|
||||||
|
function getAccountName(service: Service): string {
|
||||||
|
if (typeof service.account === 'object') {
|
||||||
|
return service.account.name;
|
||||||
|
} else {
|
||||||
|
const account = accounts.find(a => a.id === service.account);
|
||||||
|
return account ? account.name : 'Unknown Account';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FORMAT DATE USING DATE-FNS FROM ISO 8601
|
||||||
|
function formatDate(dateTimeStr: string | undefined): string {
|
||||||
|
if (!dateTimeStr) return 'N/A';
|
||||||
|
try {
|
||||||
|
const date = parseISO(dateTimeStr);
|
||||||
|
return format(date, 'MMMM d, yyyy');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error formatting date:', error);
|
||||||
|
return 'Invalid Date';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FORMAT TIME USING DATE-FNS FROM ISO 8601
|
||||||
|
function formatTime(dateTimeStr: string | undefined): string {
|
||||||
|
if (!dateTimeStr) return 'N/A';
|
||||||
|
try {
|
||||||
|
const date = parseISO(dateTimeStr);
|
||||||
|
return format(date, 'h:mm aa');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error formatting time:', error);
|
||||||
|
return 'Invalid Time';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container-fluid p-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="mb-0">Services</h1>
|
||||||
|
{#if isAdmin || isTeamLeader}
|
||||||
|
<a href="/services/new" class="btn btn-primary">Schedule New Service</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-dark-grey-subtle">
|
||||||
|
<h5 class="mb-0">Filters</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="accountFilter" class="form-label">Account</label>
|
||||||
|
<select
|
||||||
|
class="form-select"
|
||||||
|
id="accountFilter"
|
||||||
|
bind:value={accountFilter}
|
||||||
|
>
|
||||||
|
<option value="">All Accounts</option>
|
||||||
|
{#each accounts as account (account.id)}
|
||||||
|
<option value={account.id}>{account.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="statusFilter" class="form-label">Status</label>
|
||||||
|
<select
|
||||||
|
class="form-select"
|
||||||
|
id="statusFilter"
|
||||||
|
bind:value={statusFilter}
|
||||||
|
>
|
||||||
|
{#each statusOptions as option (option.value)}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="dateFromFilter" class="form-label">From Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
class="form-control"
|
||||||
|
id="dateFromFilter"
|
||||||
|
bind:value={dateFromFilter}
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="dateToFilter" class="form-label">To Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
class="form-control"
|
||||||
|
id="dateToFilter"
|
||||||
|
bind:value={dateToFilter}
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row justify-content-end">
|
||||||
|
<div class="col-md-6 d-flex justify-content-end">
|
||||||
|
<button class="btn btn-outline-secondary me-2" onclick={resetFilters}>
|
||||||
|
Reset Filters
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" onclick={applyFilters}>
|
||||||
|
Apply Filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Services List -->
|
||||||
|
{#if loading}
|
||||||
|
<div class="text-center p-5">
|
||||||
|
<div class="spinner-border" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if filteredServices.length === 0}
|
||||||
|
<div class="alert alert-info" role="alert">
|
||||||
|
No services
|
||||||
|
found. {searchTerm || statusFilter !== 'all' || accountFilter || dateFromFilter || dateToFilter ? 'Try adjusting your filters.' : ''}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Account</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Time Window</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Team Members</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each filteredServices as service (service.id)}
|
||||||
|
<tr>
|
||||||
|
<td>{getAccountName(service)}</td>
|
||||||
|
<td>{formatDate(service.date)}</td>
|
||||||
|
<td>
|
||||||
|
{formatTime(service.deadline_start)} - {formatTime(service.deadline_end)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class={`badge ${
|
||||||
|
service.status === 'completed' ? 'bg-success' :
|
||||||
|
service.status === 'cancelled' ? 'bg-danger' :
|
||||||
|
service.status === 'in_progress' ? 'bg-primary' : 'bg-secondary'
|
||||||
|
}`}>
|
||||||
|
{service.status.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{#if service.team_members && service.team_members.length > 0}
|
||||||
|
{service.team_members.map(tm => typeof tm === 'string' ? tm : `${tm.first_name} ${tm.last_name}`).join(', ')}
|
||||||
|
{:else}
|
||||||
|
<span class="text-muted">Not assigned</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group">
|
||||||
|
<a
|
||||||
|
href={`/services/${service.id}`}
|
||||||
|
class="btn btn-sm btn-outline-primary"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
358
frontend/src/routes/services/[id]/+page.svelte
Normal file
358
frontend/src/routes/services/[id]/+page.svelte
Normal file
@ -0,0 +1,358 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {onMount} from 'svelte';
|
||||||
|
import {
|
||||||
|
serviceService,
|
||||||
|
accountService,
|
||||||
|
type Service,
|
||||||
|
type Account
|
||||||
|
} from '$lib/api.js';
|
||||||
|
import {profile, isAuthenticated} from '$lib/auth.js';
|
||||||
|
import {goto} from '$app/navigation';
|
||||||
|
import {page} from '$app/state';
|
||||||
|
import {format, parseISO} from 'date-fns';
|
||||||
|
|
||||||
|
// STATES
|
||||||
|
let service: Service | null = $state(null);
|
||||||
|
let account: Account | null = $state(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
let serviceId = $state('');
|
||||||
|
let isAdmin = $state(false);
|
||||||
|
let isTeamLeader = $state(false);
|
||||||
|
|
||||||
|
// LOAD SERVICE DATA
|
||||||
|
onMount(async () => {
|
||||||
|
if (!$isAuthenticated) {
|
||||||
|
await goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isAdmin = $profile?.role === 'admin';
|
||||||
|
isTeamLeader = $profile?.role === 'team_leader';
|
||||||
|
serviceId = page.params.id;
|
||||||
|
|
||||||
|
if (!serviceId) {
|
||||||
|
await goto('/services');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
|
||||||
|
// Fetch service details
|
||||||
|
service = await serviceService.getById(serviceId);
|
||||||
|
|
||||||
|
// Fetch related account
|
||||||
|
if (service) {
|
||||||
|
const accountId = typeof service.account === 'object'
|
||||||
|
? service.account.id
|
||||||
|
: service.account;
|
||||||
|
|
||||||
|
account = await accountService.getById(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading service:', error);
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// UPDATE SERVICE STATUS
|
||||||
|
async function updateStatus(newStatus: string) {
|
||||||
|
if (!service) return;
|
||||||
|
|
||||||
|
if (confirm(`Are you sure you want to mark this service as ${newStatus}?`)) {
|
||||||
|
try {
|
||||||
|
await serviceService.patch(service.id, {
|
||||||
|
status: newStatus as 'scheduled' | 'in_progress' | 'completed' | 'cancelled'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh service data
|
||||||
|
service = await serviceService.getById(service.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error updating service to ${newStatus}:`, error);
|
||||||
|
alert('Failed to update service. Please try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FORMAT DATE USING DATE-FNS
|
||||||
|
function formatDate(dateStr: string | undefined): string {
|
||||||
|
if (!dateStr) return 'N/A';
|
||||||
|
try {
|
||||||
|
const date = parseISO(dateStr);
|
||||||
|
return format(date, 'MMMM d, yyyy');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error formatting date:', error);
|
||||||
|
return 'Invalid Date';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FORMAT TIME USING DATE-FNS
|
||||||
|
function formatTime(dateTimeStr: string | undefined): string {
|
||||||
|
if (!dateTimeStr) return 'N/A';
|
||||||
|
try {
|
||||||
|
const date = parseISO(dateTimeStr);
|
||||||
|
return format(date, 'h:mm aa');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error formatting time:', error);
|
||||||
|
return 'Invalid Time';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container-fluid p-4">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<a href="/services" class="btn btn-outline-secondary mb-2">
|
||||||
|
<i class="bi bi-arrow-left"></i> Back to Services
|
||||||
|
</a>
|
||||||
|
<h1 class="mb-0">Service Details</h1>
|
||||||
|
{#if service && account}
|
||||||
|
<p class="text-muted">
|
||||||
|
Account: <a href={`/accounts/${account.id}`}>{account.name}</a>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if (isAdmin || isTeamLeader) && service && service.status !== 'completed' && service.status !== 'cancelled'}
|
||||||
|
<div>
|
||||||
|
<a href={`/services/${serviceId}/edit`} class="btn btn-primary">
|
||||||
|
Edit Service
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="text-center p-5">
|
||||||
|
<div class="spinner-border" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if !service}
|
||||||
|
<div class="alert alert-danger" role="alert">
|
||||||
|
Service not found
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Service Details -->
|
||||||
|
<div class="row">
|
||||||
|
<!-- Main Info -->
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">Service Information</h5>
|
||||||
|
<span class={`badge ${
|
||||||
|
service.status === 'completed' ? 'bg-success' :
|
||||||
|
service.status === 'cancelled' ? 'bg-danger' :
|
||||||
|
service.status === 'in_progress' ? 'bg-primary' : 'bg-secondary'
|
||||||
|
}`}>
|
||||||
|
{service.status.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<p class="mb-1 text-muted">Service Date</p>
|
||||||
|
<p class="mb-3">{formatDate(service.date)}</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<p class="mb-1 text-muted">Time Window</p>
|
||||||
|
<p class="mb-3">{formatTime(service.deadline_start)}
|
||||||
|
- {formatTime(service.deadline_end)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<p class="mb-1 text-muted">Account Address</p>
|
||||||
|
{#if account}
|
||||||
|
<p class="mb-3">
|
||||||
|
{account.street_address}<br>
|
||||||
|
{account.city}, {account.state} {account.zip_code}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-muted">Account details not available</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<p class="mb-1 text-muted">Team Members</p>
|
||||||
|
{#if service.team_members && service.team_members.length > 0}
|
||||||
|
<ul class="list-group">
|
||||||
|
{#each service.team_members as member (typeof member === 'object' ? member.id : member)}
|
||||||
|
<li class="list-group-item">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<strong>{typeof member === 'object' ? `${member.first_name} ${member.last_name}` : 'Unknown Member'}</strong>
|
||||||
|
<div class="text-muted small">{typeof member === 'object' ? member.role.replace('_', ' ') : ''}</div>
|
||||||
|
</div>
|
||||||
|
{#if typeof member === 'object'}
|
||||||
|
<div>
|
||||||
|
<a href={`tel:${member.primary_phone}`}
|
||||||
|
class="btn btn-sm btn-outline-secondary me-1">
|
||||||
|
<i class="bi bi-telephone"></i>
|
||||||
|
Call
|
||||||
|
</a>
|
||||||
|
<a href={`mailto:${member.email}`}
|
||||||
|
class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="bi bi-envelope"></i>
|
||||||
|
Email
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else}
|
||||||
|
<p class="text-muted">No team members assigned</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if service.notes}
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<p class="mb-1 text-muted">Notes</p>
|
||||||
|
<div class="p-3 bg-light rounded">
|
||||||
|
<p class="mb-0">{service.notes}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div class="col-lg-4">
|
||||||
|
{#if isAdmin || isTeamLeader}
|
||||||
|
<!-- Status Management -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Status Management</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{#if service.status === 'scheduled'}
|
||||||
|
<p>This service is currently scheduled.</p>
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
onclick={() => updateStatus('in_progress')}
|
||||||
|
>
|
||||||
|
Mark as In Progress
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-success"
|
||||||
|
onclick={() => updateStatus('completed')}
|
||||||
|
>
|
||||||
|
Mark as Completed
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-danger"
|
||||||
|
onclick={() => updateStatus('cancelled')}
|
||||||
|
>
|
||||||
|
Cancel Service
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else if service.status === 'in_progress'}
|
||||||
|
<p>This service is currently in progress.</p>
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<button
|
||||||
|
class="btn btn-success"
|
||||||
|
onclick={() => updateStatus('completed')}
|
||||||
|
>
|
||||||
|
Mark as Completed
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-danger"
|
||||||
|
onclick={() => updateStatus('cancelled')}
|
||||||
|
>
|
||||||
|
Cancel Service
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else if service.status === 'completed'}
|
||||||
|
<p class="text-success">
|
||||||
|
<i class="bi bi-check-circle"></i>
|
||||||
|
This service has been completed.
|
||||||
|
</p>
|
||||||
|
<div class="d-grid">
|
||||||
|
<button
|
||||||
|
class="btn btn-outline-secondary"
|
||||||
|
onclick={() => updateStatus('in_progress')}
|
||||||
|
>
|
||||||
|
Reopen Service
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else if service.status === 'cancelled'}
|
||||||
|
<p class="text-danger">
|
||||||
|
<i class="bi bi-x-circle"></i>
|
||||||
|
This service has been cancelled.
|
||||||
|
</p>
|
||||||
|
<div class="d-grid">
|
||||||
|
<button
|
||||||
|
class="btn btn-outline-secondary"
|
||||||
|
onclick={() => updateStatus('scheduled')}
|
||||||
|
>
|
||||||
|
Reschedule Service
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Contact Information -->
|
||||||
|
{#if account}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Account Contact</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="mb-1"><strong>{account.contact_first_name} {account.contact_last_name}</strong>
|
||||||
|
</p>
|
||||||
|
<p class="mb-3">
|
||||||
|
<a href={`tel:${account.contact_phone}`}>{account.contact_phone}</a><br>
|
||||||
|
<a href={`mailto:${account.contact_email}`}>{account.contact_email}</a>
|
||||||
|
</p>
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<a href={`tel:${account.contact_phone}`} class="btn btn-outline-primary">
|
||||||
|
<i class="bi bi-telephone"></i> Call Contact
|
||||||
|
</a>
|
||||||
|
<a href={`mailto:${account.contact_email}`} class="btn btn-outline-primary">
|
||||||
|
<i class="bi bi-envelope"></i> Email Contact
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Quick Actions</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
{#if account}
|
||||||
|
<a href={`/services/new?account_id=${account.id}`} class="btn btn-outline-primary">
|
||||||
|
<i class="bi bi-calendar-plus"></i> Schedule Another Service
|
||||||
|
</a>
|
||||||
|
<a href={`/accounts/${account.id}`} class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-building"></i> View Account
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
<a href="/services" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-list-check"></i> View All Services
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
339
frontend/src/routes/services/[id]/edit/+page.svelte
Normal file
339
frontend/src/routes/services/[id]/edit/+page.svelte
Normal file
@ -0,0 +1,339 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {onMount} from 'svelte';
|
||||||
|
import {
|
||||||
|
serviceService,
|
||||||
|
accountService,
|
||||||
|
profileService,
|
||||||
|
type Service,
|
||||||
|
type Account,
|
||||||
|
type Profile
|
||||||
|
} from '$lib/api.js';
|
||||||
|
import {profile, isAuthenticated} from '$lib/auth.js';
|
||||||
|
import {goto} from '$app/navigation';
|
||||||
|
import {page} from '$app/state';
|
||||||
|
|
||||||
|
// STATES
|
||||||
|
let service: Service | null = $state(null);
|
||||||
|
let account: Account | null = $state(null);
|
||||||
|
let allProfiles: Profile[] = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let saving = $state(false);
|
||||||
|
let serviceId = $state('');
|
||||||
|
let isAdmin = $state(false);
|
||||||
|
let isTeamLeader = $state(false);
|
||||||
|
let selectedTeamMembers: string[] = $state([]);
|
||||||
|
let datePickerInput = $state('');
|
||||||
|
let startTimeInput = $state('');
|
||||||
|
let endTimeInput = $state('');
|
||||||
|
let apiError = $state('');
|
||||||
|
|
||||||
|
// SERVICE FORM
|
||||||
|
let serviceForm: Partial<Service> & { team_members?: string[] } = $state({
|
||||||
|
account: '',
|
||||||
|
date: '',
|
||||||
|
status: 'scheduled',
|
||||||
|
notes: '',
|
||||||
|
deadline_start: '',
|
||||||
|
deadline_end: '',
|
||||||
|
team_members: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// STATUS OPTIONS
|
||||||
|
const statusOptions = [
|
||||||
|
{value: 'scheduled', label: 'Scheduled'},
|
||||||
|
{value: 'in_progress', label: 'In Progress'},
|
||||||
|
{value: 'completed', label: 'Completed'},
|
||||||
|
{value: 'cancelled', label: 'Cancelled'}
|
||||||
|
];
|
||||||
|
|
||||||
|
// UPDATE DATE/TIME FIELDS
|
||||||
|
$effect(() => {
|
||||||
|
if (datePickerInput) {
|
||||||
|
serviceForm.date = datePickerInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (datePickerInput && startTimeInput) {
|
||||||
|
serviceForm.deadline_start = `${datePickerInput}T${startTimeInput}:00`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (datePickerInput && endTimeInput) {
|
||||||
|
serviceForm.deadline_end = `${datePickerInput}T${endTimeInput}:00`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// LOAD SERVICE DATA
|
||||||
|
onMount(async () => {
|
||||||
|
if (!$isAuthenticated) {
|
||||||
|
await goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isAdmin = $profile?.role === 'admin';
|
||||||
|
isTeamLeader = $profile?.role === 'team_leader';
|
||||||
|
serviceId = page.params.id;
|
||||||
|
|
||||||
|
if (!serviceId) {
|
||||||
|
await goto('/services');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only admin and team leader can edit services
|
||||||
|
if (!isAdmin && !isTeamLeader) {
|
||||||
|
await goto(`/services/${serviceId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
|
||||||
|
// Load profiles for team assignment first so they're ready
|
||||||
|
const profilesResponse = await profileService.getAll();
|
||||||
|
if (profilesResponse && Array.isArray(profilesResponse.results)) {
|
||||||
|
allProfiles = profilesResponse.results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch service details
|
||||||
|
service = await serviceService.getById(serviceId);
|
||||||
|
|
||||||
|
// Check if service is completed or cancelled
|
||||||
|
if (service && (service.status === 'completed' || service.status === 'cancelled')) {
|
||||||
|
// Redirect to view page with a message
|
||||||
|
await goto(`/services/${serviceId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch related account
|
||||||
|
if (service) {
|
||||||
|
const accountId = typeof service.account === 'object'
|
||||||
|
? service.account.id
|
||||||
|
: service.account;
|
||||||
|
|
||||||
|
account = await accountService.getById(accountId);
|
||||||
|
|
||||||
|
// Pre-populate form values for editing
|
||||||
|
serviceForm = {
|
||||||
|
account: accountId,
|
||||||
|
date: service.date,
|
||||||
|
status: service.status,
|
||||||
|
notes: service.notes || '',
|
||||||
|
deadline_start: service.deadline_start,
|
||||||
|
deadline_end: service.deadline_end,
|
||||||
|
team_members: [] // Will be set separately
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract date and time values
|
||||||
|
datePickerInput = service.date;
|
||||||
|
|
||||||
|
const startDate = new Date(service.deadline_start);
|
||||||
|
startTimeInput = startDate.toTimeString().substring(0, 5);
|
||||||
|
|
||||||
|
const endDate = new Date(service.deadline_end);
|
||||||
|
endTimeInput = endDate.toTimeString().substring(0, 5);
|
||||||
|
|
||||||
|
// Set selected team members - these should be IDs
|
||||||
|
if (service.team_members && service.team_members.length > 0) {
|
||||||
|
selectedTeamMembers = service.team_members.map(tm =>
|
||||||
|
typeof tm === 'object' && 'id' in tm ? tm.id : tm
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading service:', error);
|
||||||
|
loading = false;
|
||||||
|
apiError = 'Failed to load service data. Please try again.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// HANDLE SERVICE UPDATE
|
||||||
|
async function handleSubmit(event: Event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!service || saving) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
saving = true;
|
||||||
|
apiError = '';
|
||||||
|
|
||||||
|
// Prepare the data for the API
|
||||||
|
const formData = {
|
||||||
|
account: serviceForm.account,
|
||||||
|
date: serviceForm.date,
|
||||||
|
status: serviceForm.status,
|
||||||
|
notes: serviceForm.notes,
|
||||||
|
deadline_start: serviceForm.deadline_start,
|
||||||
|
deadline_end: serviceForm.deadline_end,
|
||||||
|
team_members: selectedTeamMembers // This is already an array of IDs
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Submitting service update with data:', formData);
|
||||||
|
|
||||||
|
// Make the API call
|
||||||
|
await serviceService.update(service.id, formData);
|
||||||
|
|
||||||
|
// Navigate back to service details
|
||||||
|
await goto(`/services/${service.id}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating service:', error);
|
||||||
|
apiError = 'Failed to update service. Please try again.';
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container-fluid p-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<a href={`/services/${serviceId}`} class="btn btn-outline-secondary mb-2">
|
||||||
|
<i class="bi bi-arrow-left"></i> Back to Service Details
|
||||||
|
</a>
|
||||||
|
<h1 class="mb-0">Edit Service</h1>
|
||||||
|
{#if service && account}
|
||||||
|
<p class="text-muted">
|
||||||
|
Account: <a href={`/accounts/${account.id}`}>{account.name}</a>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="text-center p-5">
|
||||||
|
<div class="spinner-border" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if !service}
|
||||||
|
<div class="alert alert-danger" role="alert">
|
||||||
|
Service not found
|
||||||
|
</div>
|
||||||
|
{:else if !isAdmin && !isTeamLeader}
|
||||||
|
<div class="alert alert-warning" role="alert">
|
||||||
|
You don't have permission to edit services
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#if apiError}
|
||||||
|
<div class="alert alert-danger" role="alert">
|
||||||
|
{apiError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h5 class="mb-0">Edit Service</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form onsubmit={handleSubmit}>
|
||||||
|
<!-- Service Status -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="status" class="form-label">Status *</label>
|
||||||
|
<select
|
||||||
|
class="form-select"
|
||||||
|
id="status"
|
||||||
|
bind:value={serviceForm.status}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
{#each statusOptions as option (option.value)}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Service Date & Time -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="date" class="form-label">Service Date *</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
class="form-control"
|
||||||
|
id="date"
|
||||||
|
bind:value={datePickerInput}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="startTime" class="form-label">Start Time *</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
class="form-control"
|
||||||
|
id="startTime"
|
||||||
|
bind:value={startTimeInput}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="endTime" class="form-label">End Time *</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
class="form-control"
|
||||||
|
id="endTime"
|
||||||
|
bind:value={endTimeInput}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Team Members -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label" for="team">Team Members</label>
|
||||||
|
<p class="small text-muted mb-3">Select team members to assign to this service.</p>
|
||||||
|
|
||||||
|
{#if allProfiles.length === 0}
|
||||||
|
<p class="text-muted">No team members available.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="row">
|
||||||
|
{#each allProfiles as profileObj (profileObj.id)}
|
||||||
|
<div class="col-md-4 col-lg-3 mb-2">
|
||||||
|
<div class="form-check">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
id={`profile-${profileObj.id}`}
|
||||||
|
value={profileObj.id}
|
||||||
|
bind:group={selectedTeamMembers}
|
||||||
|
>
|
||||||
|
<label class="form-check-label" for={`profile-${profileObj.id}`}>
|
||||||
|
{profileObj.first_name} {profileObj.last_name}
|
||||||
|
<small class="d-block text-muted">
|
||||||
|
{profileObj.role.replace('_', ' ')}
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="notes" class="form-label">Notes</label>
|
||||||
|
<textarea
|
||||||
|
class="form-control"
|
||||||
|
id="notes"
|
||||||
|
rows="4"
|
||||||
|
bind:value={serviceForm.notes}
|
||||||
|
placeholder="Enter any special instructions or details about this service..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit Buttons -->
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<a
|
||||||
|
href={`/services/${serviceId}`}
|
||||||
|
class="btn btn-secondary me-2"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary"
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? 'Updating...' : 'Update Service'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
410
frontend/src/routes/services/generate/+page.svelte
Normal file
410
frontend/src/routes/services/generate/+page.svelte
Normal file
@ -0,0 +1,410 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {onMount} from 'svelte';
|
||||||
|
import {
|
||||||
|
scheduleService,
|
||||||
|
accountService,
|
||||||
|
type Schedule,
|
||||||
|
type Account
|
||||||
|
} from '$lib/api.js';
|
||||||
|
import {profile, isAuthenticated} from '$lib/auth.js';
|
||||||
|
import {goto} from '$app/navigation';
|
||||||
|
|
||||||
|
// STATES
|
||||||
|
let schedules: Schedule[] = $state([]);
|
||||||
|
let accounts: Account[] = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let generating = $state(false);
|
||||||
|
let successMessage = $state('');
|
||||||
|
let errorMessage = $state('');
|
||||||
|
let accountFilter = $state('');
|
||||||
|
let selectedAccounts: string[] = $state([]);
|
||||||
|
let isAdmin = $state(false);
|
||||||
|
let isTeamLeader = $state(false);
|
||||||
|
|
||||||
|
// Date for service generation
|
||||||
|
let generationMonth = $state(new Date().getMonth());
|
||||||
|
let generationYear = $state(new Date().getFullYear());
|
||||||
|
|
||||||
|
// Months for dropdown
|
||||||
|
const months = [
|
||||||
|
{value: 0, label: 'January'},
|
||||||
|
{value: 1, label: 'February'},
|
||||||
|
{value: 2, label: 'March'},
|
||||||
|
{value: 3, label: 'April'},
|
||||||
|
{value: 4, label: 'May'},
|
||||||
|
{value: 5, label: 'June'},
|
||||||
|
{value: 6, label: 'July'},
|
||||||
|
{value: 7, label: 'August'},
|
||||||
|
{value: 8, label: 'September'},
|
||||||
|
{value: 9, label: 'October'},
|
||||||
|
{value: 10, label: 'November'},
|
||||||
|
{value: 11, label: 'December'}
|
||||||
|
];
|
||||||
|
// AUTH CHECK
|
||||||
|
onMount(async () => {
|
||||||
|
if (!$isAuthenticated) {
|
||||||
|
await goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isAdmin = $profile?.role === 'admin';
|
||||||
|
isTeamLeader = $profile?.role === 'team_leader';
|
||||||
|
if (!isAdmin && !isTeamLeader) {
|
||||||
|
await goto('/schedules');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
// Load accounts for the filter dropdown
|
||||||
|
const accountsResponse = await accountService.getAll();
|
||||||
|
if (accountsResponse && Array.isArray(accountsResponse.results)) {
|
||||||
|
accounts = accountsResponse.results;
|
||||||
|
}
|
||||||
|
// Load active schedules
|
||||||
|
await loadActiveSchedules();
|
||||||
|
loading = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading data:', error);
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// LOAD ACTIVE SCHEDULES
|
||||||
|
async function loadActiveSchedules() {
|
||||||
|
const params: { account_id?: string } = {};
|
||||||
|
if (accountFilter) {
|
||||||
|
params.account_id = accountFilter;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await scheduleService.getAll(params);
|
||||||
|
if (response && Array.isArray(response.results)) {
|
||||||
|
// Filter only active schedules
|
||||||
|
schedules = response.results.filter(schedule =>
|
||||||
|
!schedule.end_date || new Date(schedule.end_date) > new Date()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error('Error: scheduleService.getAll() did not return a paginated response with results array:', response);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading schedules:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// APPLY ACCOUNT FILTER
|
||||||
|
async function applyFilter() {
|
||||||
|
loading = true;
|
||||||
|
await loadActiveSchedules();
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RESET ACCOUNT FILTER
|
||||||
|
function resetFilter() {
|
||||||
|
accountFilter = '';
|
||||||
|
applyFilter();
|
||||||
|
}
|
||||||
|
|
||||||
|
// GENERATE SERVICES
|
||||||
|
async function generateServices() {
|
||||||
|
if (!confirm(`Are you sure you want to generate services for ${months[generationMonth].label} ${generationYear}?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
generating = true;
|
||||||
|
errorMessage = '';
|
||||||
|
successMessage = '';
|
||||||
|
// Determine which accounts to process
|
||||||
|
const accountIds = selectedAccounts.length > 0 ? selectedAccounts : undefined;
|
||||||
|
// The actual API call
|
||||||
|
const result = await scheduleService.generateServices(
|
||||||
|
generationMonth,
|
||||||
|
generationYear,
|
||||||
|
accountIds
|
||||||
|
);
|
||||||
|
successMessage = result.message;
|
||||||
|
generating = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating services:', error);
|
||||||
|
errorMessage = 'Failed to generate services. Please try again.';
|
||||||
|
generating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FORMAT DATE
|
||||||
|
function formatDate(dateStr: string | undefined): string {
|
||||||
|
if (!dateStr) return 'N/A';
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleDateString('en-US', {year: 'numeric', month: 'long', day: 'numeric'});
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET ACCOUNT NAME
|
||||||
|
function getAccountName(accountId: string | Account): string {
|
||||||
|
if (typeof accountId === 'object') {
|
||||||
|
return accountId.name;
|
||||||
|
} else {
|
||||||
|
const account = accounts.find(a => a.id === accountId);
|
||||||
|
return account ? account.name : 'Unknown Account';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET SCHEDULE DAYS
|
||||||
|
function getScheduleDays(schedule: Schedule): string {
|
||||||
|
const days = [];
|
||||||
|
if (schedule.monday_service) days.push('Mon');
|
||||||
|
if (schedule.tuesday_service) days.push('Tue');
|
||||||
|
if (schedule.wednesday_service) days.push('Wed');
|
||||||
|
if (schedule.thursday_service) days.push('Thu');
|
||||||
|
if (schedule.friday_service) days.push('Fri');
|
||||||
|
if (schedule.saturday_service) days.push('Sat');
|
||||||
|
if (schedule.sunday_service) days.push('Sun');
|
||||||
|
if (schedule.weekend_service) days.push('Weekend');
|
||||||
|
return days.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// TOGGLE ACCOUNT SELECTION
|
||||||
|
function toggleAccountSelection(accountId: string) {
|
||||||
|
if (selectedAccounts.includes(accountId)) {
|
||||||
|
selectedAccounts = selectedAccounts.filter(id => id !== accountId);
|
||||||
|
} else {
|
||||||
|
selectedAccounts = [...selectedAccounts, accountId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SELECT ALL ACCOUNTS
|
||||||
|
function selectAllAccounts() {
|
||||||
|
// Get all unique account IDs from schedules
|
||||||
|
const uniqueAccounts = new Set<string>();
|
||||||
|
schedules.forEach(schedule => {
|
||||||
|
const accountId = typeof schedule.account === 'object' ? schedule.account.id : schedule.account;
|
||||||
|
uniqueAccounts.add(accountId);
|
||||||
|
});
|
||||||
|
selectedAccounts = Array.from(uniqueAccounts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLEAR ACCOUNT SELECTION
|
||||||
|
function clearAccountSelection() {
|
||||||
|
selectedAccounts = [];
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container-fluid p-4">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="mb-0">Generate Services</h1>
|
||||||
|
<a href="/services" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left"></i> Back to Services
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{#if successMessage}
|
||||||
|
<div class="alert alert-success alert-dismissible fade show mb-4" role="alert">
|
||||||
|
{successMessage}
|
||||||
|
<button type="button" class="btn-close" onclick={() => successMessage = ''} aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if errorMessage}
|
||||||
|
<div class="alert alert-danger alert-dismissible fade show mb-4" role="alert">
|
||||||
|
{errorMessage}
|
||||||
|
<button type="button" class="btn-close" onclick={() => errorMessage = ''} aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<!-- Service Generation Interface -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h5 class="mb-0">Service Generation</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label for="generationMonth" class="form-label">Month</label>
|
||||||
|
<select
|
||||||
|
class="form-select"
|
||||||
|
id="generationMonth"
|
||||||
|
bind:value={generationMonth}
|
||||||
|
>
|
||||||
|
{#each months as month (month.value)}
|
||||||
|
<option value={month.value}>{month.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label for="generationYear" class="form-label">Year</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="form-control"
|
||||||
|
id="generationYear"
|
||||||
|
bind:value={generationYear}
|
||||||
|
min="2000"
|
||||||
|
max="2100"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 d-flex align-items-end">
|
||||||
|
<button
|
||||||
|
class="btn btn-primary w-100"
|
||||||
|
onclick={generateServices}
|
||||||
|
disabled={generating || schedules.length === 0}
|
||||||
|
>
|
||||||
|
{generating ? 'Generating...' : 'Generate Services'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="accountFilter" class="form-label">Filter View by Account (Optional)</label>
|
||||||
|
<select
|
||||||
|
class="form-select"
|
||||||
|
id="accountFilter"
|
||||||
|
bind:value={accountFilter}
|
||||||
|
>
|
||||||
|
<option value="">All Accounts</option>
|
||||||
|
{#each accounts as account (account.id)}
|
||||||
|
<option value={account.id}>{account.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 d-flex align-items-end">
|
||||||
|
<button class="btn btn-outline-secondary me-2" onclick={resetFilter}>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-primary" onclick={applyFilter}>
|
||||||
|
Apply Filter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="bi bi-info-circle me-2"></i>
|
||||||
|
This tool will generate service records for the selected month based on active schedules.
|
||||||
|
Services will be created according to each schedule's service days, excluding common holidays.
|
||||||
|
Team members will need to be assigned to the services separately.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Account Selection -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-dark-gray-subtle">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">Select Accounts for Generation</h5>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-sm btn-outline-primary me-2" onclick={selectAllAccounts}>Select All
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" onclick={clearAccountSelection}>Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted mb-3">
|
||||||
|
Choose which accounts to generate services for. If none are selected, services will be generated
|
||||||
|
for all accounts.
|
||||||
|
</p>
|
||||||
|
<div class="row">
|
||||||
|
{#each accounts.filter(account => schedules.some(s =>
|
||||||
|
(typeof s.account === 'object' ? s.account.id : s.account) === account.id)) as account (account.id)}
|
||||||
|
<div class="col-md-6 mb-2">
|
||||||
|
<div class="form-check">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
id={`account-${account.id}`}
|
||||||
|
checked={selectedAccounts.includes(account.id)}
|
||||||
|
onchange={() => toggleAccountSelection(account.id)}
|
||||||
|
>
|
||||||
|
<label class="form-check-label" for={`account-${account.id}`}>
|
||||||
|
{account.name}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Active Schedules -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-dark-gray-subtle">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
Active Schedules
|
||||||
|
{#if accountFilter}
|
||||||
|
<span class="fs-6 fw-normal">for {getAccountName(accountFilter)}</span>
|
||||||
|
{/if}
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{#if loading}
|
||||||
|
<div class="text-center p-5">
|
||||||
|
<div class="spinner-border" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if schedules.length === 0}
|
||||||
|
<div class="p-4 text-center">
|
||||||
|
<p class="text-muted mb-0">No active schedules found.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Account</th>
|
||||||
|
<th>Service Days</th>
|
||||||
|
<th>Start Date</th>
|
||||||
|
<th>Exceptions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each schedules as schedule (schedule.id)}
|
||||||
|
<tr>
|
||||||
|
<td>{getAccountName(schedule.account)}</td>
|
||||||
|
<td>{getScheduleDays(schedule)}</td>
|
||||||
|
<td>{formatDate(schedule.start_date)}</td>
|
||||||
|
<td>{schedule.schedule_exception || 'None'}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<!-- Quick Links -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-dark-gray-subtle">
|
||||||
|
<h5 class="mb-0">Quick Links</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<a href="/services" class="btn btn-outline-primary">
|
||||||
|
<i class="bi bi-list-check me-1"></i> View Services
|
||||||
|
</a>
|
||||||
|
<a href="/schedules" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-calendar-week me-1"></i> Manage Schedules
|
||||||
|
</a>
|
||||||
|
<a href="/accounts" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-building me-1"></i> View Accounts
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Quick Info -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header bg-dark-gray-subtle">
|
||||||
|
<h5 class="mb-0">About Service Generation</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p>This tool automatically creates service records for the selected month based on active schedule
|
||||||
|
patterns.</p>
|
||||||
|
<ul>
|
||||||
|
<li>Services are only created for active schedules</li>
|
||||||
|
<li>Common holidays are automatically excluded</li>
|
||||||
|
<li>Weekend settings are respected</li>
|
||||||
|
<li>Duplicate services are prevented</li>
|
||||||
|
</ul>
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||||
|
After generation, you'll need to assign team members to each service.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
270
frontend/src/routes/services/new/+page.svelte
Normal file
270
frontend/src/routes/services/new/+page.svelte
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {onMount} from 'svelte';
|
||||||
|
import {
|
||||||
|
serviceService,
|
||||||
|
accountService,
|
||||||
|
profileService,
|
||||||
|
type Service,
|
||||||
|
type Account,
|
||||||
|
type Profile
|
||||||
|
} from '$lib/api.js';
|
||||||
|
import {profile, isAuthenticated} from '$lib/auth.js';
|
||||||
|
import {goto} from '$app/navigation';
|
||||||
|
|
||||||
|
// STATES
|
||||||
|
let accounts: Account[] = $state([]);
|
||||||
|
let allProfiles: Profile[] = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let saving = $state(false);
|
||||||
|
let isAdmin = $state(false);
|
||||||
|
let isTeamLeader = $state(false);
|
||||||
|
let selectedTeamMembers: string[] = $state([]);
|
||||||
|
let datePickerInput = $state(new Date().toISOString().split('T')[0]);
|
||||||
|
let startTimeInput = $state('09:00');
|
||||||
|
let endTimeInput = $state('17:00');
|
||||||
|
let accountIdFromUrl = $state('');
|
||||||
|
|
||||||
|
// SERVICE FORM
|
||||||
|
let serviceForm: Partial<Service> = $state({
|
||||||
|
account: '',
|
||||||
|
date: new Date().toISOString().split('T')[0],
|
||||||
|
status: 'scheduled',
|
||||||
|
team_members: [],
|
||||||
|
notes: '',
|
||||||
|
deadline_start: '',
|
||||||
|
deadline_end: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// Type-safe form data for submission
|
||||||
|
interface ServiceSubmitData extends Omit<Partial<Service>, 'team_members'> {
|
||||||
|
team_members: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// UPDATE DATE/TIME FIELDS
|
||||||
|
$effect(() => {
|
||||||
|
if (datePickerInput) {
|
||||||
|
serviceForm.date = datePickerInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (datePickerInput && startTimeInput) {
|
||||||
|
serviceForm.deadline_start = `${datePickerInput}T${startTimeInput}:00`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (datePickerInput && endTimeInput) {
|
||||||
|
serviceForm.deadline_end = `${datePickerInput}T${endTimeInput}:00`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// AUTH CHECK
|
||||||
|
onMount(async () => {
|
||||||
|
if (!$isAuthenticated) {
|
||||||
|
await goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isAdmin = $profile?.role === 'admin';
|
||||||
|
isTeamLeader = $profile?.role === 'team_leader';
|
||||||
|
|
||||||
|
// Only admin and team leader can create services
|
||||||
|
if (!isAdmin && !isTeamLeader) {
|
||||||
|
await goto('/services');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if account_id is in URL params
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
accountIdFromUrl = urlParams.get('account_id') || '';
|
||||||
|
if (accountIdFromUrl) {
|
||||||
|
serviceForm.account = accountIdFromUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
|
||||||
|
// Load accounts for the dropdown
|
||||||
|
const accountsResponse = await accountService.getAll();
|
||||||
|
if (accountsResponse && Array.isArray(accountsResponse.results)) {
|
||||||
|
accounts = accountsResponse.results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load profiles for team assignment
|
||||||
|
const profilesResponse = await profileService.getAll();
|
||||||
|
if (profilesResponse && Array.isArray(profilesResponse.results)) {
|
||||||
|
allProfiles = profilesResponse.results;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading data:', error);
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// HANDLE SERVICE CREATION
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (saving) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
saving = true;
|
||||||
|
|
||||||
|
// Create a submission-ready object with the correct types
|
||||||
|
const submitData: ServiceSubmitData = {
|
||||||
|
account: serviceForm.account,
|
||||||
|
date: serviceForm.date,
|
||||||
|
status: serviceForm.status as 'scheduled' | 'in_progress' | 'completed' | 'cancelled',
|
||||||
|
notes: serviceForm.notes,
|
||||||
|
deadline_start: serviceForm.deadline_start,
|
||||||
|
deadline_end: serviceForm.deadline_end,
|
||||||
|
team_members: selectedTeamMembers
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create service
|
||||||
|
const newService = await serviceService.create(submitData as unknown as Omit<Service, 'id'>);
|
||||||
|
saving = false;
|
||||||
|
|
||||||
|
// Redirect to the new service's detail page
|
||||||
|
await goto(`/services/${newService.id}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating service:', error);
|
||||||
|
alert('Failed to create service. Please try again.');
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container-fluid p-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="mb-0">Schedule New Service</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<a href="/services" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left"></i> Back to Services
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="text-center p-5">
|
||||||
|
<div class="spinner-border" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Create Form -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h5 class="mb-0">Service Information</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form onsubmit={handleSubmit}>
|
||||||
|
<!-- Account Selection -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="account" class="form-label">Account *</label>
|
||||||
|
<select
|
||||||
|
class="form-select"
|
||||||
|
id="account"
|
||||||
|
bind:value={serviceForm.account}
|
||||||
|
required
|
||||||
|
disabled={!!accountIdFromUrl}
|
||||||
|
>
|
||||||
|
<option value="">Select an account</option>
|
||||||
|
{#each accounts as account (account.id)}
|
||||||
|
<option value={account.id}>{account.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Service Date & Time -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="date" class="form-label">Service Date *</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
class="form-control"
|
||||||
|
id="date"
|
||||||
|
bind:value={datePickerInput}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="startTime" class="form-label">Start Time *</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
class="form-control"
|
||||||
|
id="startTime"
|
||||||
|
bind:value={startTimeInput}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="endTime" class="form-label">End Time *</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
class="form-control"
|
||||||
|
id="endTime"
|
||||||
|
bind:value={endTimeInput}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Team Members -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label" for="teamMembers">Team Members</label>
|
||||||
|
{#if allProfiles.length === 0}
|
||||||
|
<p class="text-muted">No team members available.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="row" id="teamMembers">
|
||||||
|
{#each allProfiles as profileObj (profileObj.id)}
|
||||||
|
<div class="col-md-4 col-lg-3 mb-2">
|
||||||
|
<div class="form-check">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
id={`profile-${profileObj.id}`}
|
||||||
|
value={profileObj.id}
|
||||||
|
bind:group={selectedTeamMembers}
|
||||||
|
>
|
||||||
|
<label class="form-check-label" for={`profile-${profileObj.id}`}>
|
||||||
|
{profileObj.first_name} {profileObj.last_name}
|
||||||
|
<small class="d-block text-muted">
|
||||||
|
{profileObj.role.replace('_', ' ')}
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="notes" class="form-label">Notes</label>
|
||||||
|
<textarea
|
||||||
|
class="form-control"
|
||||||
|
id="notes"
|
||||||
|
rows="4"
|
||||||
|
bind:value={serviceForm.notes}
|
||||||
|
placeholder="Enter any special instructions or details about this service..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit Buttons -->
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<a href="/services" class="btn btn-secondary me-2">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary"
|
||||||
|
disabled={saving || !serviceForm.account}
|
||||||
|
>
|
||||||
|
{saving ? 'Scheduling...' : 'Schedule Service'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
BIN
frontend/static/favicon.png
Normal file
BIN
frontend/static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
9
frontend/svelte.config.js
Normal file
9
frontend/svelte.config.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import adapter from '@sveltejs/adapter-node';
|
||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
kit: { adapter: adapter() }
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
19
frontend/tsconfig.json
Normal file
19
frontend/tsconfig.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
}
|
||||||
|
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||||
|
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||||
|
//
|
||||||
|
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||||
|
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||||
|
}
|
||||||
6
frontend/vite.config.ts
Normal file
6
frontend/vite.config.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [sveltekit()]
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user