public-ready-init

This commit is contained in:
Damien Coles 2026-01-26 10:12:01 -05:00
commit a5f846474c
72 changed files with 17087 additions and 0 deletions

27
.gitignore vendored Normal file
View 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
View 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
View 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
View File

@ -0,0 +1,4 @@
.venv
.env
api/data
google-sa.json

14
backend/Dockerfile Normal file
View 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
View File

17
backend/api/admin.py Normal file
View 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
View 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
View 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

View File

310
backend/api/models.py Normal file
View 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
View 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
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

37
backend/api/urls.py Normal file
View 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
View 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
)

View File

16
backend/config/asgi.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View File

View File

37
docker-compose.yml Normal file
View 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
View 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
View File

@ -0,0 +1 @@
engine-strict=true

6
frontend/.prettierignore Normal file
View File

@ -0,0 +1,6 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb

15
frontend/.prettierrc Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

39
frontend/package.json Normal file
View 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
View 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
View 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
View 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
View 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};

View 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
View 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;

View 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}/`)
};

View File

@ -0,0 +1,6 @@
<script>
import Navbar from "$lib/components/Navbar.svelte";
let { children } = $props();
</script>
<Navbar/>
{@render children()}

View 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">&copy; {new Date().getFullYear()} Nexus Portal. All rights reserved.</p>
</footer>
</main>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View 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
View 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
View File

@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});