public-ready-init

This commit is contained in:
Damien Coles 2026-01-26 09:45:31 -05:00
commit e4c3e74624
52 changed files with 3716 additions and 0 deletions

31
.env.example Normal file
View File

@ -0,0 +1,31 @@
# Django Configuration
SECRET_KEY=your-django-secret-key-here
DEBUG=False
# Database (PostgreSQL)
# Format: postgres://user:password@host:port/database
PSQL=postgres://user:password@localhost:5432/nexus
# Redis (optional, for caching)
REDIS=redis://localhost:6379/0
# Google Service Account Key (JSON string)
# The service account needs Calendar, Gmail, Drive, and Sheets API access
# with domain-wide delegation enabled
SERVICE_ACCOUNT_KEY={"type": "service_account", ...}
# Email Configuration (for Google Workspace domain-wide delegation)
DISPATCH_EMAIL=dispatch@yourdomain.com
# Team Member Emails (comma-separated for calendar invites)
TEAM_EMAILS=user1@example.com,user2@example.com,user3@example.com
# Google Drive Configuration
# Folder ID where punchlists are stored
PUNCHLIST_FOLDER_ID=your-google-drive-folder-id
# Template sheet ID for punchlists
PUNCHLIST_TEMPLATE_ID=your-google-sheet-template-id
# Frontend Configuration
FRONTEND_URL=https://app.yourdomain.com
API_URL=https://api.yourdomain.com

16
.gitignore vendored Normal file
View File

@ -0,0 +1,16 @@
.env
.venv/
venv/
__pycache__/
*.pyc
node_modules/
service_account_key.json
certs/
static/admin/
static/rest_framework/
static/build/
*.log
.DS_Store
.idea/
*.sqlite3
db.sqlite3

21
Dockerfile Normal file
View File

@ -0,0 +1,21 @@
# Nexus - Django API Dockerfile
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
COPY manage.py .
COPY api/ .
COPY nexus/ .
COPY static/ .
COPY templates/ .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["gunicorn", "nexus.asgi:application", "-k", "uvicorn.workers.UvicornWorker"]

24
Dockerfile.backend Normal file
View File

@ -0,0 +1,24 @@
# Nexus - Django API Dockerfile (with SSL)
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY manage.py .
COPY api/ ./api/
COPY nexus/ ./nexus/
COPY static/ ./static/
COPY templates/ ./templates/
# Create directory for SSL certificates
RUN mkdir -p /app/certs/
EXPOSE 8443
CMD ["gunicorn", "nexus.asgi:application", "-k", "uvicorn.workers.UvicornWorker", "--certfile=/app/certs/backend-cert.pem", "--keyfile=/app/certs/backend-key.pem", "--bind=0.0.0.0:8443"]

22
Dockerfile.frontend Normal file
View File

@ -0,0 +1,22 @@
# Nexus - Frontend (React) Dockerfile
FROM node:22.12.0-alpine as builder
WORKDIR /app
COPY frontend/ ./frontend/
COPY package.json .
COPY webpack.config.js .
RUN npm install
RUN npm run build
FROM nginx:1.27.3-alpine as server
COPY --from=builder /app/static/build /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Create directory for SSL certificates
RUN mkdir -p /etc/nginx/ssl/
EXPOSE 443

176
README.md Normal file
View File

@ -0,0 +1,176 @@
# Nexus
A field service management platform built with Django REST Framework and React. Designed for managing accounts, service visits, projects, and team coordination with integrated Google Workspace services.
## Features
- **Account Management**: Track client accounts with service schedules and contact information
- **Service Visits**: Create, track, and close daily service visits with notes
- **Project Management**: Schedule and manage multi-store projects with punchlists
- **Reports**: Generate monthly reports with visit tracking and rate calculations
- **Supply Requests**: Submit supply requests from the field
- **Google Integration**: Calendar invites, email notifications, and Drive-based punchlist PDFs
- **JWT Authentication**: Secure team member login with token refresh
## Tech Stack
### Backend
- Python 3.12
- Django 5.1.3 with Django REST Framework
- PostgreSQL with psycopg2-binary
- Redis for caching
- Google APIs (Calendar, Gmail, Drive, Sheets)
- JWT authentication via djangorestframework-simplejwt
### Frontend
- React 18
- React Router 7
- Bootstrap 5
- Webpack 5
- Axios for API calls
## Prerequisites
- Python 3.12+
- Node.js 22+
- PostgreSQL
- Redis (optional, for caching)
- Google Workspace account with domain-wide delegation configured
## Google Workspace Setup
This application requires a Google Service Account with domain-wide delegation:
1. Create a service account in Google Cloud Console
2. Enable domain-wide delegation for the service account
3. Grant the following scopes in Google Workspace Admin:
- `https://www.googleapis.com/auth/calendar`
- `https://www.googleapis.com/auth/gmail.send`
- `https://www.googleapis.com/auth/drive`
- `https://www.googleapis.com/auth/spreadsheets`
4. Download the service account JSON key
## Configuration
1. Copy `.env.example` to `.env` and configure:
```bash
cp .env.example .env
```
2. Required environment variables:
- `SECRET_KEY`: Django secret key
- `PSQL`: PostgreSQL connection string
- `SERVICE_ACCOUNT_KEY`: Google service account JSON (as string)
- `DISPATCH_EMAIL`: Email address for the service account to impersonate
- `TEAM_EMAILS`: Comma-separated list of team member emails
- `PUNCHLIST_FOLDER_ID`: Google Drive folder ID for punchlists
- `PUNCHLIST_TEMPLATE_ID`: Google Sheets template ID for punchlists
## Development Setup
### Backend
```bash
# 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
# Install dependencies
npm install
# Start development server
npm start
# 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 nginx
- `backend-cert.pem` and `backend-key.pem` for gunicorn
### Build and Run
```bash
# Build and start containers
docker-compose up -d --build
# View logs
docker-compose logs -f
```
The frontend will be available on port 443, and the backend API on port 8443.
## Project Structure
```
nexus/
├── api/ # Django app with models, views, serializers
│ ├── models.py # Database models
│ ├── views.py # API endpoints
│ ├── serializers.py # DRF serializers
│ ├── gcalendar.py # Google Calendar integration
│ ├── gmail.py # Gmail integration
│ ├── gdrive.py # Google Drive integration
│ ├── gsheets.py # Google Sheets integration
│ └── redis_client.py # Redis caching
├── nexus/ # Django project settings
│ ├── settings.py # Django configuration
│ └── urls.py # URL routing
├── frontend/ # React application
│ ├── components/ # Reusable React components
│ ├── modules/ # Page-level React components
│ ├── api.js # Axios API client
│ ├── constants.js # Frontend constants
│ ├── App.jsx # Main React app
│ └── main.jsx # React entry point
├── templates/ # HTML templates
├── static/ # Static files
├── docker-compose.yml # Docker composition
├── Dockerfile.backend # Backend container
├── Dockerfile.frontend # Frontend container
└── nginx.conf # Nginx configuration
```
## API Endpoints
- `POST /token/` - Obtain JWT tokens
- `POST /token/refresh/` - Refresh JWT token
- `GET /accounts/` - List all accounts
- `GET /accounts/status/` - Get account status
- `POST /accounts/supplies/` - Submit supply request
- `GET /stores/` - List all stores
- `GET /projects/` - List all projects
- `POST /projects/close/` - Close a project
- `POST /projects/punch/` - Submit punchlist
- `POST /visits/` - Get visits for date
- `POST /visits/close/` - Close a visit
- `GET /days/:account/` - Get service days for account
- `POST /calendar/` - Create calendar event
- `GET /team/` - List team members
- `POST /user/password/change/` - Change user password
## License
MIT

0
api/__init__.py Normal file
View File

3
api/admin.py Normal file
View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
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'

50
api/gcalendar.py Normal file
View File

@ -0,0 +1,50 @@
import json
from google.oauth2 import service_account
from googleapiclient.discovery import build
def create_event(key, calendar_id, impersonator, summary, description, start, end, location, attendees, reminders):
"""
Creates a calendar event via Google Calendar API.
Arguments:
key: str : Service account credentials JSON string
calendar_id: str : Email address of the Google Calendar account
impersonator: str : Email address of the user to impersonate (domain-wide delegation)
summary: str : Event title
description: str : Event description
start: str : Start date/time in ISO format
end: str : End date/time in ISO format
location: str : Event location (address)
attendees: list : List of dicts with 'email' keys
reminders: list : List of dicts with 'method' and 'minutes' keys
Returns:
Created event object from Google Calendar API
"""
event = {
'summary': summary,
'description': description,
'start': {
'dateTime': start,
'timeZone': 'America/Detroit',
},
'end': {
'dateTime': end,
'timeZone': 'America/Detroit',
},
'location': location,
'attendees': attendees,
'reminders': {
'useDefault': False,
'overrides': reminders,
}
}
service_account_key = json.loads(key) if isinstance(key, str) else key
scopes = ['https://www.googleapis.com/auth/calendar']
credentials = service_account.Credentials.from_service_account_info(
service_account_key, scopes=scopes, subject=impersonator
)
service = build('calendar', 'v3', credentials=credentials)
return service.events().insert(calendarId=calendar_id, body=event).execute()

142
api/gdrive.py Normal file
View File

@ -0,0 +1,142 @@
import io
import json
import os
from google.oauth2 import service_account
from googleapiclient.discovery import build
from googleapiclient.http import MediaIoBaseDownload, MediaIoBaseUpload
from api.models import Projects
def get_drive_credentials():
"""Get Google Drive credentials from environment."""
key = os.environ.get('SERVICE_ACCOUNT_KEY')
if not key:
key_file_path = os.environ.get('SERVICE_ACCOUNT_KEY_FILE', '/app/service_account_key.json')
if os.path.exists(key_file_path):
with open(key_file_path, 'r') as f:
return json.load(f)
return None
return json.loads(key) if isinstance(key, str) else key
def duplicate_punchlist(new_name):
"""
Duplicates a punchlist template from Google Drive.
Arguments:
new_name: str : New filename for the punchlist
Returns:
dict : Google Drive file object for the new punchlist
"""
scopes = ['https://www.googleapis.com/auth/drive']
key = get_drive_credentials()
impersonator = os.environ.get('DISPATCH_EMAIL', 'dispatch@example.com')
creds = service_account.Credentials.from_service_account_info(
key, scopes=scopes, subject=impersonator
)
service = build('drive', 'v3', credentials=creds)
# Get folder and template IDs from environment
folder_id = os.environ.get('PUNCHLIST_FOLDER_ID')
template_id = os.environ.get('PUNCHLIST_TEMPLATE_ID')
request_body = {
'name': new_name,
'parents': [folder_id] if folder_id else [],
}
return service.files().copy(fileId=template_id, body=request_body).execute()
def create_pdf_from_punchlist(sheet_id):
"""
Creates a PDF from a Google Sheet.
Args:
sheet_id: str : The ID of the Google Sheet
Returns:
str : The file ID of the created PDF, or None on error
"""
try:
scopes = ['https://www.googleapis.com/auth/drive']
key = get_drive_credentials()
impersonator = os.environ.get('DISPATCH_EMAIL', 'dispatch@example.com')
creds = service_account.Credentials.from_service_account_info(
key, scopes=scopes, subject=impersonator
)
service = build('drive', 'v3', credentials=creds)
# Get the original sheet's metadata
file_metadata = service.files().get(fileId=sheet_id, fields='name,parents').execute()
sheet_name = file_metadata.get('name')
parent_folder_id = file_metadata.get('parents', [None])[0]
pdf_filename = f"{sheet_name}.pdf"
# Export as PDF
request = service.files().export_media(fileId=sheet_id, mimeType='application/pdf')
fh = io.BytesIO()
downloader = MediaIoBaseDownload(fh, request)
done = False
while not done:
status, done = downloader.next_chunk()
# Upload PDF to same folder
file_metadata = {
'name': pdf_filename,
'mimeType': 'application/pdf',
}
if parent_folder_id:
file_metadata['parents'] = [parent_folder_id]
fh.seek(0)
media = MediaIoBaseUpload(fh, mimetype='application/pdf')
file = service.files().create(
body=file_metadata, media_body=media, fields='id'
).execute()
return file.get('id')
except Exception as e:
print(f"An error occurred: {e}")
return None
def store_pdf_as_bytecode(pdf_id, proj_id):
"""
Retrieves a PDF from Google Drive and stores it in the database.
Args:
pdf_id: str : The ID of the PDF file in Google Drive
proj_id: str : The project ID in the database
"""
try:
scopes = ['https://www.googleapis.com/auth/drive']
key = get_drive_credentials()
impersonator = os.environ.get('DISPATCH_EMAIL', 'dispatch@example.com')
creds = service_account.Credentials.from_service_account_info(
key, scopes=scopes, subject=impersonator
)
service = build('drive', 'v3', credentials=creds)
request = service.files().get_media(fileId=pdf_id)
fh = io.BytesIO()
downloader = MediaIoBaseDownload(fh, request)
done = False
while not done:
status, done = downloader.next_chunk()
pdf_bytes = fh.getvalue()
project = Projects.objects.get(id=proj_id)
project.punchlist = pdf_bytes
project.save()
except Exception as e:
print(f"An error occurred: {e}")

76
api/gmail.py Normal file
View File

@ -0,0 +1,76 @@
import json
from google.oauth2 import service_account
from googleapiclient.discovery import build
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
import base64
class GmailClient:
"""Gmail API client using service account with domain-wide delegation."""
def __init__(self, key, impersonator):
"""
Initialize Gmail client.
Args:
key: str or dict : Service account credentials (JSON string or dict)
impersonator: str : Email address of user to impersonate
"""
try:
service_account_key = json.loads(key) if isinstance(key, str) else key
except json.JSONDecodeError:
raise ValueError("Invalid JSON key provided.")
self.impersonator = impersonator
self.scopes = ['https://www.googleapis.com/auth/gmail.send']
self.credentials = service_account.Credentials.from_service_account_info(
service_account_key, scopes=self.scopes
).with_subject(self.impersonator)
self.service = build('gmail', 'v1', credentials=self.credentials)
def create_message(self, sender, to, subject, message_text, message_html):
"""
Create an email message with both plain text and HTML parts.
Args:
sender: str : Sender email address
to: str : Recipient email address
subject: str : Email subject
message_text: str : Plain text body
message_html: str : HTML body
Returns:
dict : Message ready to be sent via Gmail API
"""
message = MIMEMultipart('alternative')
message['to'] = to
message['from'] = sender
message['subject'] = subject
text_part = MIMEText(message_text, 'plain')
message.attach(text_part)
html_part = MIMEText(message_html, 'html')
message.attach(html_part)
raw_message = {'raw': base64.urlsafe_b64encode(message.as_bytes()).decode()}
return raw_message
def send_message(self, message):
"""
Send an email message via Gmail API.
Args:
message: dict : Message object from create_message()
Returns:
dict : Sent message response or None on error
"""
try:
message = self.service.users().messages().send(userId='me', body=message).execute()
print(f'Message sent. Message ID: {message["id"]}')
return message
except Exception as e:
print(f'An error occurred: {e}')
return None

83
api/gsheets.py Normal file
View File

@ -0,0 +1,83 @@
import json
import os
import gspread
from google.oauth2 import service_account
def get_sheets_credentials():
"""Get Google Sheets credentials from environment."""
key = os.environ.get('SERVICE_ACCOUNT_KEY')
if not key:
key_file_path = os.environ.get('SERVICE_ACCOUNT_KEY_FILE', '/app/service_account_key.json')
if os.path.exists(key_file_path):
with open(key_file_path, 'r') as f:
return json.load(f)
return None
return json.loads(key) if isinstance(key, str) else key
def fill_new_punchlist(data, sheet_id):
"""
Fills out a new punchlist from template.
Arguments:
data: dict : Data from the request
sheet_id: str : Google Sheet ID
Returns:
str : URL to the filled sheet, or None on error
"""
try:
scopes = ["https://www.googleapis.com/auth/spreadsheets"]
key = get_sheets_credentials()
impersonator = os.environ.get('DISPATCH_EMAIL', 'dispatch@example.com')
creds = service_account.Credentials.from_service_account_info(
key, scopes=scopes, subject=impersonator
)
client = gspread.authorize(creds)
workbook = client.open_by_key(sheet_id)
sheet = workbook.worksheet("Sheet1")
# Map form fields to sheet cells
# Customize this mapping based on your punchlist template
fields_cell_map = {
'store': 'B4',
'date': 'D4',
'notes': 'A52',
# Add your punchlist fields here
# 'fieldName': 'CellReference',
}
updates = []
for key, cell in fields_cell_map.items():
if key not in data:
continue
if key == 'notes':
notes_data = [[line] for line in data['notes'].splitlines()]
updates.append({'range': cell, 'values': notes_data})
else:
value = data[key]
if value == 'on':
updates.append({'range': cell, 'values': [['yes']]})
elif not value:
updates.append({'range': cell, 'values': [['no']]})
else:
updates.append({'range': cell, 'values': [[value]]})
if updates:
sheet.batch_update(updates)
return f"https://docs.google.com/spreadsheets/d/{sheet_id}/edit"
except gspread.exceptions.APIError as e:
print(f"Google Sheets API error: {e}")
return None
except KeyError as e:
print(f"Missing key in data: {e}")
return None
except Exception as e:
print(f"An unexpected error occurred: {e}")
return None

View File

109
api/models.py Normal file
View File

@ -0,0 +1,109 @@
import uuid
from django.contrib.postgres.fields import ArrayField
from django.db import models
class Accounts(models.Model):
"""Customer accounts with service agreements."""
full_name = models.CharField(max_length=255)
short_name = models.CharField(primary_key=True, max_length=3)
street_address = models.CharField(max_length=255, blank=True, null=True)
city = models.CharField(max_length=255, blank=True, null=True)
state = models.CharField(max_length=2, blank=True, null=True)
zip_code = models.CharField(max_length=5, blank=True, null=True)
class Meta:
managed = False
db_table = 'accounts'
class ServiceDays(models.Model):
"""Weekly service schedule for each account."""
short_name = models.ForeignKey(Accounts, models.CASCADE, db_column='short_name', primary_key=True)
mon_serv = models.BooleanField(blank=True, null=True)
tues_serv = models.BooleanField(blank=True, null=True)
wed_serv = models.BooleanField(blank=True, null=True)
thurs_serv = models.BooleanField(blank=True, null=True)
fri_serv = models.BooleanField(blank=True, null=True)
sat_serv = models.BooleanField(blank=True, null=True)
sun_serv = models.BooleanField(blank=True, null=True)
weekend_serv = models.BooleanField(blank=True, null=True)
exception_serv = models.BooleanField(blank=True, null=True)
class Meta:
managed = False
db_table = 'service_days'
class ServiceVisits(models.Model):
"""Individual service visit records."""
short_name = models.ForeignKey(Accounts, models.CASCADE, db_column='short_name')
date = models.DateField()
status = models.CharField(max_length=10, blank=True, null=True)
team_member = ArrayField(models.TextField(), blank=True, null=True)
id = models.UUIDField(primary_key=True)
notes = models.TextField(blank=True, null=True)
class Meta:
managed = False
db_table = 'service_visits'
class MonthlyRevenue(models.Model):
"""Monthly revenue and labor tracking per account."""
short_name = models.ForeignKey(Accounts, models.CASCADE, db_column='short_name')
revenue = models.IntegerField(blank=True, null=True)
labor = models.IntegerField(blank=True, null=True)
id = models.UUIDField(primary_key=True)
class Meta:
managed = False
db_table = 'monthly_revenue'
class AccountStatus(models.Model):
"""Account status and bonding information."""
short_name = models.ForeignKey(Accounts, models.CASCADE, db_column='short_name')
is_active = models.BooleanField(blank=True, null=True)
last_day = models.DateField(blank=True, null=True)
is_bonded = models.BooleanField(blank=True, null=True)
id = models.UUIDField(primary_key=True)
class Meta:
managed = False
db_table = 'account_status'
class Stores(models.Model):
"""Store/location information for project-based clients."""
store = models.CharField(max_length=4, blank=True, null=True, unique=True)
src = models.CharField(max_length=10, blank=True, null=True)
street_address = models.CharField(max_length=255, blank=True, null=True)
city = models.CharField(max_length=100, blank=True, null=True)
state = models.CharField(max_length=10, blank=True, null=True)
zip_code = models.CharField(max_length=10, blank=True, null=True)
phone = models.CharField(max_length=20, blank=True, null=True)
entity = models.CharField(max_length=100, blank=True, null=True)
store_contact = models.CharField(max_length=100, blank=True, null=True)
store_contact_email = models.CharField(max_length=100, blank=True, null=True)
super_contact = models.CharField(max_length=100, blank=True, null=True)
super_contact_email = models.CharField(max_length=100, blank=True, null=True)
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
class Meta:
managed = False
db_table = 'stores'
class Projects(models.Model):
"""Project tracking for store-based work."""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
store = models.ForeignKey(Stores, models.CASCADE, db_column='store', to_field='store')
date = models.DateField()
status = models.CharField(max_length=10, blank=True, null=True)
punchlist = models.BinaryField(blank=True, null=True)
class Meta:
managed = False
unique_together = ('store', 'date')
db_table = 'projects'

0
api/redis/__init__.py Normal file
View File

83
api/redis/client.py Normal file
View File

@ -0,0 +1,83 @@
import redis
import os
from dotenv import load_dotenv
# Load environment variables
load_dotenv()
# Initialize Redis client
REDIS_URL = os.environ.get('REDIS')
if REDIS_URL:
client = redis.Redis.from_url(REDIS_URL, decode_responses=True)
else:
# Fallback to local Redis
client = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)
def get_rate(account):
"""Get labor rate for an account from Redis cache."""
try:
rate = client.get(f"account:{account}:rate")
return rate if rate else None
except redis.ConnectionError:
return None
def get_revenue(account):
"""Get revenue for an account from Redis cache."""
try:
revenue = client.get(f"account:{account}:revenue")
return revenue if revenue else None
except redis.ConnectionError:
return None
def refresh_redis():
"""Flush all Redis data."""
try:
client.flushdb()
print('Redis flushed!')
except redis.ConnectionError:
print('Redis connection failed')
def refresh_rates():
"""Refresh labor rates from database to Redis."""
from api.models import MonthlyRevenue
try:
revenues = MonthlyRevenue.objects.all()
for account in revenues:
short_name = account.short_name.short_name
rate = account.labor
if rate:
client.set(f'account:{short_name}:rate', rate)
print(f'Labor rate set for {short_name}')
print('Done refreshing rates!')
except Exception as e:
print(f'Error refreshing rates: {e}')
def refresh_revenues():
"""Refresh revenue data from database to Redis."""
from api.models import MonthlyRevenue
try:
revenues = MonthlyRevenue.objects.all()
for account in revenues:
short_name = account.short_name.short_name
revenue = account.revenue
if revenue:
client.set(f'account:{short_name}:revenue', revenue)
print(f'Revenue set for {short_name}')
print('Done refreshing revenues!')
except Exception as e:
print(f'Error refreshing revenues: {e}')
def renew_all():
"""Refresh all cached data from database."""
refresh_redis()
refresh_rates()
refresh_revenues()
print('Redis data has been refreshed!')

61
api/serializers.py Normal file
View File

@ -0,0 +1,61 @@
from rest_framework import serializers
from django.contrib.auth.models import User
from api.models import Accounts, ServiceDays, Stores, ServiceVisits, Projects, AccountStatus
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['id', 'username', 'password']
extra_kwargs = {'password': {'write_only': True}}
def create(self, validated_data):
user = User.objects.create_user(**validated_data)
return user
class AccountsSerializer(serializers.ModelSerializer):
class Meta:
model = Accounts
fields = ['full_name', 'short_name', 'street_address', 'city', 'state', 'zip_code']
class ServiceDaysSerializer(serializers.ModelSerializer):
class Meta:
model = ServiceDays
fields = [
'short_name', 'mon_serv', 'tues_serv', 'wed_serv', 'thurs_serv',
'fri_serv', 'sat_serv', 'sun_serv', 'weekend_serv', 'exception_serv'
]
class StoresSerializer(serializers.ModelSerializer):
class Meta:
model = Stores
fields = [
'store', 'src', 'street_address', 'city', 'state', 'zip_code',
'phone', 'entity', 'store_contact', 'store_contact_email',
'super_contact', 'super_contact_email', 'id'
]
class ServiceVisitsSerializer(serializers.ModelSerializer):
full_name = serializers.CharField(source='short_name.full_name', read_only=True)
class Meta:
model = ServiceVisits
fields = ['short_name', 'full_name', 'date', 'status', 'team_member', 'notes', 'id']
class ProjectsSerializer(serializers.ModelSerializer):
city = serializers.CharField(source='store.city', read_only=True)
class Meta:
model = Projects
fields = ['id', 'store', 'city', 'date', 'status', 'punchlist']
class AccountStatusSerializer(serializers.ModelSerializer):
class Meta:
model = AccountStatus
fields = ['short_name', 'is_active', 'last_day', 'is_bonded', 'id']

3
api/tests.py Normal file
View File

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

40
api/urls.py Normal file
View File

@ -0,0 +1,40 @@
from django.urls import path, include
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
from . import views
from api.views import CreateUserView, ChangePasswordView
urlpatterns = [
# Authentication
path('user/register/', CreateUserView.as_view(), name='register'),
path('user/password/change/', ChangePasswordView.as_view(), name='password_change'),
path('token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
path('auth/', include('rest_framework.urls')),
# Accounts
path('accounts/', views.AccountsView.as_view(), name='accounts'),
path('accounts/status/', views.GetAccountStatus.as_view(), name='account_status'),
path('accounts/rate/', views.GetAccountRate.as_view(), name='account_rate'),
path('accounts/revenue/', views.GetAccountRevenue.as_view(), name='account_revenue'),
path('accounts/supplies/', views.SupplyRequest.as_view(), name='account_supplies'),
# Service Days
path('days/', views.ServiceDaysView.as_view(), name='all_service_days'),
path('days/<str:account>/', views.AccountServiceDaysView.as_view(), name='service_days'),
# Stores
path('stores/', views.StoresView.as_view(), name='stores'),
path('stores/<str:store>/', views.StoreView.as_view(), name='store'),
# Projects
path('projects/', views.ProjectsView.as_view(), name='projects'),
path('projects/punch/', views.CreatePunchlist.as_view(), name='punchlist'),
path('projects/close/', views.CloseProject.as_view(), name='close_project'),
# Service Visits
path('visits/', views.ServiceVisitsView.as_view(), name='visits'),
path('visits/close/', views.CloseVisitView.as_view(), name='close_visit'),
# Calendar
path('calendar/', views.CreateCalendarEvents.as_view(), name='google-calendar'),
]

385
api/views.py Normal file
View File

@ -0,0 +1,385 @@
import os
from datetime import datetime
from django.http import JsonResponse
from googleapiclient.errors import HttpError
from rest_framework import generics
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.response import Response
from rest_framework.utils import json
from rest_framework.views import APIView
from django.contrib.auth.models import User
from api.models import Accounts, ServiceDays, Stores, ServiceVisits, Projects, AccountStatus
from api.serializers import (
UserSerializer, AccountsSerializer, ServiceDaysSerializer, StoresSerializer,
ServiceVisitsSerializer, ProjectsSerializer, AccountStatusSerializer
)
from api.gcalendar import create_event
from api.gdrive import duplicate_punchlist, create_pdf_from_punchlist, store_pdf_as_bytecode
from api.gsheets import fill_new_punchlist
from api.gmail import GmailClient
from api.redis.client import get_rate, get_revenue
def get_service_account_key():
"""Get service account key from environment or file."""
key = os.environ.get('SERVICE_ACCOUNT_KEY')
if not key:
key_file_path = os.environ.get('SERVICE_ACCOUNT_KEY_FILE', '/app/service_account_key.json')
if os.path.exists(key_file_path):
with open(key_file_path, 'r') as f:
key = json.load(f)
return json.dumps(key) if isinstance(key, dict) else key
return key
def get_team_emails():
"""Get team member email mappings from environment."""
emails_str = os.environ.get('TEAM_EMAILS', '')
emails = {}
for pair in emails_str.split(','):
if ':' in pair:
name, email = pair.strip().split(':', 1)
emails[name.strip().lower()] = email.strip()
return emails
class CreateUserView(generics.CreateAPIView):
queryset = User.objects.all()
serializer_class = UserSerializer
permission_classes = (AllowAny,)
class AccountsView(generics.ListAPIView):
queryset = Accounts.objects.all()
serializer_class = AccountsSerializer
permission_classes = (IsAuthenticated,)
class ServiceDaysView(generics.ListAPIView):
queryset = ServiceDays.objects.all()
serializer_class = ServiceDaysSerializer
permission_classes = (IsAuthenticated,)
class AccountServiceDaysView(generics.ListAPIView):
serializer_class = ServiceDaysSerializer
permission_classes = (IsAuthenticated,)
def get_queryset(self):
return ServiceDays.objects.filter(short_name=self.kwargs['account'])
class StoresView(generics.ListAPIView):
serializer_class = StoresSerializer
permission_classes = (IsAuthenticated,)
def get_queryset(self):
return Stores.objects.all()
class StoreView(generics.ListAPIView):
serializer_class = StoresSerializer
permission_classes = (IsAuthenticated,)
def get_queryset(self):
return Stores.objects.filter(store=self.kwargs['store'])
class ProjectsView(generics.ListAPIView):
serializer_class = ProjectsSerializer
permission_classes = (IsAuthenticated,)
def get_queryset(self):
return Projects.objects.all()
class ServiceVisitsView(APIView):
serializer_class = ServiceVisitsSerializer
permission_classes = (IsAuthenticated,)
def get(self, request, *args, **kwargs):
username = request.GET.get('username')
month = request.GET.get('month')
year = request.GET.get('year')
account = request.GET.get('account')
try:
if username and month and year and account:
if account == '*':
queryset = ServiceVisits.objects.filter(
team_member__overlap=[username],
date__month=int(month),
date__year=int(year),
)
else:
queryset = ServiceVisits.objects.filter(
team_member__overlap=[username],
date__month=int(month),
date__year=int(year),
short_name=Accounts.objects.get(short_name=account),
)
serializer = self.serializer_class(queryset, many=True)
return Response(serializer.data)
else:
return Response({"detail": "No username or filter data provided."}, status=404)
except ServiceVisits.DoesNotExist:
return Response({"detail": "No service visits found."}, status=404)
def post(self, request, *args, **kwargs):
date = request.data.get('date')
username = request.data.get('username')
try:
queryset = ServiceVisits.objects.filter(date=date, team_member__overlap=[username])
serializer = self.serializer_class(queryset, many=True)
return Response(serializer.data)
except ServiceVisits.DoesNotExist:
return Response({"detail": "No service visits found for this date."}, status=404)
class CloseVisitView(APIView):
serializer_class = ServiceVisitsSerializer
permission_classes = (IsAuthenticated,)
def post(self, request, *args, **kwargs):
visit_id = request.data.get('id')
notes = request.data.get('notes')
try:
visit = ServiceVisits.objects.get(id=visit_id)
if visit.notes:
visit.notes += f', {notes}'
else:
visit.notes = notes
visit.status = 'Closed'
visit.save()
except ServiceVisits.DoesNotExist:
return Response({"detail": "Error occurred while fetching service visit!"}, status=404)
return Response({"detail": "Service visit closed successfully!"}, status=200)
class CreateCalendarEvents(APIView):
permission_classes = (IsAuthenticated,)
def post(self, request, *args, **kwargs):
try:
data = json.loads(request.body)
store_id = data['id']
summary = data['summary']
description = data['description']
start1 = data['start1'] + ":00"
end1 = data['end1'] + ":00"
location = data['location']
# Build attendees list from environment-configured emails
team_emails = get_team_emails()
attendees1 = []
for attendee in data.get('attendees', []):
attendee_lower = attendee.lower()
if attendee_lower in team_emails:
attendees1.append({"email": team_emails[attendee_lower]})
reminders = [
{'method': 'email', 'minutes': 60},
{'method': 'popup', 'minutes': 60},
{'method': 'popup', 'minutes': 30},
]
dispatch_email = os.environ.get('DISPATCH_EMAIL', 'dispatch@example.com')
service_account_key = get_service_account_key()
create_event(
service_account_key, dispatch_email, dispatch_email,
summary, description, start1, end1, location, attendees1, reminders
)
# Check for second visit requirement
if data.get('secondVisit') == 'Yes':
summary2 = f"Store #{data['store']}: Return Visit"
description2 = "Second visit for follow-up work."
start2 = data['start2'] + ":00"
end2 = data['end2'] + ":00"
create_event(
service_account_key, dispatch_email, dispatch_email,
summary2, description2, start2, end2, location, attendees1, reminders
)
# Create project record
parent_store = Stores.objects.get(id=store_id)
project_date_str = start1[0:10]
project_date = datetime.strptime(project_date_str, '%Y-%m-%d')
project = Projects(store=parent_store, date=project_date, status='Open')
project.save()
return Response({"detail": "Calendar events created successfully!"}, status=200)
except HttpError as e:
return JsonResponse({'error': f'Google Calendar API Error: {e}'}, status=500)
except KeyError as e:
return JsonResponse({'error': f'Missing key in request data: {e}'}, status=400)
except Exception as e:
return JsonResponse({'error': str(e)}, status=500)
class CreatePunchlist(APIView):
permission_classes = (IsAuthenticated,)
def post(self, request, *args, **kwargs):
try:
data = json.loads(request.body)
proj_id = data['id']
store_no = data['store']
proj_date = data['date']
new_sheet_name = f"Punchlist-{store_no}-{proj_date}"
new_sheet_object = duplicate_punchlist(new_sheet_name)
new_sheet_id = new_sheet_object['id']
fill_new_punchlist(data, new_sheet_id)
new_pdf = create_pdf_from_punchlist(new_sheet_id)
store_pdf_as_bytecode(new_pdf, proj_id)
return Response({
"message": f"Punchlist and PDF created! https://drive.google.com/file/d/{new_pdf}/view"
}, status=200)
except HttpError as e:
return JsonResponse({'error': f'Google API Error: {e}'}, status=500)
except KeyError as e:
return JsonResponse({'error': f'Missing key in request data: {e}'}, status=400)
except Exception as e:
return JsonResponse({'error': str(e)}, status=500)
class CloseProject(APIView):
permission_classes = (IsAuthenticated,)
def post(self, request, *args, **kwargs):
try:
project_id = request.data.get('id')
project = Projects.objects.get(id=project_id)
project.status = 'Closed'
project.save()
except Projects.DoesNotExist:
return Response({"detail": "Error occurred while fetching project!"}, status=404)
return Response({"detail": "Project closed successfully!"}, status=200)
class GetAccountStatus(APIView):
permission_classes = (IsAuthenticated,)
serializer_class = AccountStatusSerializer
def get(self, request, *args, **kwargs):
query = AccountStatus.objects.all()
serializer = self.serializer_class(query, many=True)
return Response(serializer.data)
class GetAccountRate(APIView):
permission_classes = (IsAuthenticated,)
def post(self, request, *args, **kwargs):
account = request.data.get('account')
rate = get_rate(account)
return Response({"rate": rate}, status=200)
class GetAccountRevenue(APIView):
permission_classes = (IsAuthenticated,)
def post(self, request, *args, **kwargs):
account = request.data.get('account')
revenue = get_revenue(account)
return Response({"revenue": revenue}, status=200)
class ChangePasswordView(APIView):
permission_classes = (IsAuthenticated,)
serializer_class = UserSerializer
def post(self, request, *args, **kwargs):
username = request.data.get('username')
try:
user = User.objects.get(username=username)
except User.DoesNotExist:
return Response({"error": "User not found"}, status=400)
old_password = request.data.get('oldPassword')
if not user.check_password(old_password):
return Response({"error": "Old password is incorrect"}, status=400)
new_password1 = request.data.get('newPassword1')
new_password2 = request.data.get('newPassword2')
if new_password1 != new_password2:
return Response({"error": "Passwords do not match"}, status=400)
user.set_password(new_password1)
user.save()
return Response({"message": "Password changed successfully!"}, status=200)
class SupplyRequest(APIView):
permission_classes = (IsAuthenticated,)
def post(self, request, *args, **kwargs):
data = json.loads(request.body)
account = data['account']
team = data['team']
supplies = data['supplies']
notes = data['notes']
supplies_dict = {
'multiFoldTowels': 'Multfold Hand Towels',
'cFoldTowels': 'C-Fold Hand Towels',
'jumboPtRoll': 'Jumbo Paper Towel Rolls',
'kitPtRoll': 'Kitchen Paper Towel Rolls',
'jumboTp': 'Jumbo Toilet Paper Rolls',
'standardTp': 'Standard/Individual Toilet Paper Rolls',
'dispSoap': 'Auto/Manual Dispenser Hand Soap',
'indSoap': 'Individual Hand Soap',
'urinal': 'Urinal Screens',
'uMats': 'Urinal Mats',
'fem': 'Feminine Hygiene Waste Bags',
'air': 'Air Fresheners',
'lgBag': 'Large Trash Bags',
'smBag': 'Small Trash Bags',
'mdBag': 'Medium Trash Bags'
}
service_account_key = get_service_account_key()
dispatch_email = os.environ.get('DISPATCH_EMAIL', 'dispatch@example.com')
supply_request_email = os.environ.get('SUPPLY_REQUEST_EMAIL', dispatch_email)
gmail_client = GmailClient(service_account_key, dispatch_email)
sender = dispatch_email
to = supply_request_email
subject = f'Supply Request for {account}!'
# Build supply list
supply_items = [
supplies_dict[key] for key, label in supplies_dict.items()
if supplies.get(key)
]
message_text = f"{team} says {account} needs the following supplies:\n"
message_text += "\n".join(f"- {item}" for item in supply_items)
message_text += f"\n\nAdditional Notes:\n{notes if notes else 'None'}"
message_text += "\n\nThank You!\nDispatch"
message_html = f"""
<html>
<body>
<p>{team.capitalize()} says we need the following at {account}:</p>
<ul>
{''.join(f'<li>{item}</li>' for item in supply_items)}
</ul>
<p>Additional Notes:</p>
<ul><li>{notes if notes else 'None'}</li></ul>
<p>Thank You!</p>
<p>Dispatch</p>
</body>
</html>
"""
message = gmail_client.create_message(sender, to, subject, message_text, message_html)
gmail_client.send_message(message)
return Response({"message": "Request sent successfully!"}, status=200)

23
docker-compose.yml Normal file
View File

@ -0,0 +1,23 @@
services:
frontend:
build:
context: .
dockerfile: Dockerfile.frontend
ports:
- "443:443"
volumes:
- ./certs:/etc/nginx/ssl
restart: unless-stopped
backend:
build:
context: .
dockerfile: Dockerfile.backend
env_file:
- .env
ports:
- "8443:8443"
volumes:
- ./certs:/app/certs
- ./service_account_key.json:/app/service_account_key.json
restart: unless-stopped

61
frontend/App.jsx Normal file
View File

@ -0,0 +1,61 @@
import React from 'react'
import Navbar from "./components/Navbar.jsx"
import { BrowserRouter, Route, Routes } from "react-router-dom"
import LoginModule from "./modules/LoginModule.jsx"
import ProtectedRoute from "./components/ProtectedRoute.jsx"
import HomeModule from "./modules/HomeModule.jsx"
import AccountsModule from "./modules/AccountsModule.jsx"
import ProjectsModule from "./modules/ProjectsModule.jsx"
import VisitsModule from "./modules/VisitsModule.jsx"
import ReportsModule from "./modules/ReportsModule.jsx"
import UserModule from "./modules/UserModule.jsx"
function App() {
return (
<div className="d-flex flex-column">
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginModule />} />
<Route path="/" element={
<ProtectedRoute>
<Navbar />
<HomeModule />
</ProtectedRoute>
} />
<Route path="/accounts" element={
<ProtectedRoute>
<Navbar />
<AccountsModule />
</ProtectedRoute>
} />
<Route path="/projects" element={
<ProtectedRoute>
<Navbar />
<ProjectsModule />
</ProtectedRoute>
} />
<Route path="/visits" element={
<ProtectedRoute>
<Navbar />
<VisitsModule />
</ProtectedRoute>
} />
<Route path="/reports" element={
<ProtectedRoute>
<Navbar />
<ReportsModule />
</ProtectedRoute>
} />
<Route path="/user" element={
<ProtectedRoute>
<Navbar />
<UserModule />
</ProtectedRoute>
} />
</Routes>
</BrowserRouter>
</div>
)
}
export default App

21
frontend/api.js Normal file
View File

@ -0,0 +1,21 @@
import axios from 'axios'
import { ACCESS_TOKEN, API_URL } from "./constants"
const api = axios.create({
baseURL: API_URL,
})
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem(ACCESS_TOKEN)
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
export default api

View File

@ -0,0 +1,59 @@
import React from "react";
import api from "../api.js";
import {useState} from "react";
export default function CloseVisitModal({visit}) {
const modalId = `modal-${visit.id.replace(/\s+/g, '-')}`;
const username = localStorage.getItem("username");
const [notes, setNotes] = useState('');
const handleCloseVisit = async () => {
try {
const response = await api.post('visits/close/', {id: visit.id, notes: notes});
if (response.status === 200) {
onSuccess()
}
} catch (error) {
alert(error.detail);
}
}
const handleChange = (e) => {
setNotes(e.target.value);
}
const onSuccess = () => {
alert('Visit closed successfully!');
window.location.reload();
}
return (
<div>
<button type="button" className="btn btn-primary" data-bs-toggle="modal" disabled={visit.status === 'Closed'}
data-bs-target={`#${modalId}`}>Close</button>
<div className='modal fade' id={modalId} data-bs-backdrop="static"
data-bs-keyboard="false" tabIndex="-1" aria-labelledby="staticBackdropLabel" aria-hidden="true">
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<h1 className="modal-title fs-5" id="staticBackdropLabel">
Close service visit?
</h1>
<button type="button" className="btn-close" data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<div className="modal-body">
<p>Visit ID: {visit.id}</p>
<p>Date: {visit.date}</p>
<p>Account: {visit.full_name}</p>
<p>Submitted by: {username}</p>
<input onChange={handleChange} type="text" className="form-control" placeholder="Add notes here..."></input>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary"
data-bs-dismiss="modal">Close
</button>
<button type="button" className="btn btn-primary" onClick={handleCloseVisit} data-bs-dismiss="modal">Submit</button>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,62 @@
import React from 'react';
import {useState} from "react";
import api from '../api'
import {useNavigate} from "react-router-dom";
import {ACCESS_TOKEN, REFRESH_TOKEN, USERNAME, DATE_TOGGLE} from "../constants.js";
function LoginForm({route, method}) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const handleLogin = async (e) => {
setLoading(true)
e.preventDefault();
try {
const response = await api.post('token/', {username, password})
if (response.status === 200) {
localStorage.setItem(ACCESS_TOKEN, response.data.access)
localStorage.setItem(REFRESH_TOKEN, response.data.refresh)
localStorage.setItem(USERNAME, username)
localStorage.setItem(DATE_TOGGLE, new Date().toLocaleDateString());
navigate('/')
} else {
navigate('/login')
}
} catch (error) {
alert(error)
} finally {
setLoading(false)
}
}
return (
<div className="d-flex align-items-center justify-content-center p-2">
<form onSubmit={handleLogin} className='form-control'>
<h3 className="p-2">Team Member Login</h3>
<input
type={"text"}
className='form-control'
value={username}
onChange={(e) => {setUsername(e.target.value)}}
placeholder="Username"/>
<input
type={"password"}
className='form-control'
value={password}
onChange={(e) => {setPassword(e.target.value)}}
placeholder="Password"
/>
<div className="d-flex align-items-center">
<button className="m-2" type="submit">Login</button>
{loading && <div className="m-2 spinner-border"></div>}
</div>
</form>
</div>
)
}
export default LoginForm

View File

@ -0,0 +1,39 @@
import React from "react"
import {Link, NavLink} from "react-router-dom";
export default function Navbar() {
return (
<nav className="navbar bg-body sticky-top">
<div className="container-fluid">
<Link className="navbar-brand" to="/"><b>Nexus</b></Link>
<button className="navbar-toggler" type="button" data-bs-toggle="collapse"
data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent"
aria-expanded="false" aria-label="Toggle navigation">
<span className="navbar-toggler-icon"></span>
</button>
<div className="collapse navbar-collapse" id="navbarSupportedContent">
<ul className="navbar-nav me-auto mb-2 mb-lg-0">
<li className="nav-item">
<NavLink className="nav-link" to="/">Home</NavLink>
</li>
<li className="nav-item">
<NavLink className="nav-link" to="/accounts">Accounts</NavLink>
</li>
<li className="nav-item">
<NavLink className="nav-link" to="/projects">Projects</NavLink>
</li>
<li className="nav-item">
<NavLink className="nav-link" to="/visits">Service Visits</NavLink>
</li>
<li className="nav-item">
<NavLink className="nav-link" to="/reports">Reports</NavLink>
</li>
<li className="nav-item">
<NavLink className="nav-link" to="/user">User</NavLink>
</li>
</ul>
</div>
</div>
</nav>
)
}

View File

@ -0,0 +1,61 @@
import React from "react";
import api from "../api";
export default function ProjectCloseModal({ proj }) {
const modalId = `modal-project-close-${proj.id.replace(/\s+/g, '-')}`;
const handleSubmit = async (e) => {
e.preventDefault();
try {
const response = await api.post('projects/close/', {
id: proj.id
});
if (response.status === 200) {
onSuccess()
}
} catch (error) {
alert(error.detail);
}
}
const onSuccess = () => {
alert('Project closed successfully!');
window.location.reload();
}
return (
<div>
<button type="button" className="btn btn-primary" data-bs-toggle="modal"
data-bs-target={`#${modalId}`}>
Close Project
</button>
<div className='modal fade' id={modalId} data-bs-backdrop="static"
data-bs-keyboard="false" tabIndex="-1" aria-labelledby="staticBackdropLabel" aria-hidden="true">
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<h1 className="modal-title fs-5" id="staticBackdropLabel">
Close Project
</h1>
<button type="button" className="btn-close" data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<div className="modal-body">
<p>Project ID: {proj.id}</p>
<p>Store #: {proj.store} - {proj.city}</p>
<p>Date: {proj.date}</p>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary"
data-bs-dismiss="modal">Cancel
</button>
<button onClick={handleSubmit} type="button" className="btn btn-primary"
data-bs-dismiss="modal">
Submit
</button>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,206 @@
import React from "react";
import api from "../api.js";
import {useState, useEffect} from "react";
export default function ProjectCreateModal({loc}) {
const modalId = `modal-${loc.id.replace(/\s+/g, '-')}`;
const [notes, setNotes] = useState('');
const [showSecondVisit, setShowSecondVisit] = useState(false);
const [teamMembers, setTeamMembers] = useState([]);
useEffect(() => {
const fetchTeamMembers = async () => {
try {
const response = await api.get('team/');
if (response.status === 200) {
setTeamMembers(response.data);
}
} catch (error) {
console.error('Error fetching team members:', error);
}
};
fetchTeamMembers();
}, []);
const handleNotes = (e) => {
setNotes(e.target.value);
}
const [start1, setStart1] = useState('');
const handleStart1 = (e) => {
setStart1(e.target.value);
}
const [end1, setEnd1] = useState('');
const handleEnd1 = (e) => {
setEnd1(e.target.value);
}
const [serviceType, setServiceType] = useState('');
const handleServiceType = (e) => {
setServiceType(e.target.value);
}
const [secondVisit, setSecondVisit] = useState('');
const handleSecondVisit = (e) => {
setShowSecondVisit(e.target.value === 'Yes');
setSecondVisit(e.target.value);
}
const [start2, setStart2] = useState('');
const handleStart2 = (e) => {
setStart2(e.target.value);
}
const [end2, setEnd2] = useState('');
const handleEnd2 = (e) => {
setEnd2(e.target.value);
}
const [attendees, setAttendees] = useState([]);
const handleAttendees = (e) => {
const attendee = (e.target.value);
if (e.target.checked) {
setAttendees([...attendees, attendee]);
} else {
setAttendees(attendees.filter(a => a !== attendee));
}
}
const handleSubmit = async (e) => {
e.preventDefault();
try {
const response = await api.post('calendar/', {
id: loc.id,
store: loc.store,
summary: `#${loc.store} - ${loc.city}: ${serviceType}`,
description: `${serviceType} : ${notes}`,
location: `${loc.street_address}, ${loc.city}, ${loc.state} ${loc.zip_code}`,
start1: start1,
end1: end1,
serviceType: serviceType,
secondVisit: secondVisit,
start2: start2,
end2: end2,
attendees: attendees,
});
if (response.status === 200) {
onSuccess()
}
} catch (error) {
alert(error.detail);
}
}
const onSuccess = () => {
alert('Project created and calendar invites sent!');
window.location.reload();
}
return (
<div>
<button type="button" className="btn btn-primary m-3" data-bs-toggle="modal"
data-bs-target={`#${modalId}`}>
Schedule Service
</button>
<div className='modal fade' id={modalId} data-bs-backdrop="static"
data-bs-keyboard="false" tabIndex="-1" aria-labelledby="staticBackdropLabel" aria-hidden="true">
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<h1 className="modal-title fs-5" id="staticBackdropLabel">
Create a New Service Instance
</h1>
<button type="button" className="btn-close" data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<div className="modal-body">
<p>Store ID: {loc.id}</p>
<p>Store #: {loc.store} - {loc.city}</p>
<form>
<div className="mb-3">
<label htmlFor="start-1" className="form-label">
Start Date & Time
</label>
<input onChange={handleStart1} id="start-1" type="datetime-local" className="form-control" required></input>
</div>
<div className="mb-3">
<label htmlFor="end-1" className="form-label">
End Date & Time
</label>
<input onChange={handleEnd1} id="end-1" type="datetime-local" className="form-control" required></input>
</div>
<div className="mb-3">
<label htmlFor="service-type" className="form-label">
Select a Service Type
</label>
<select onChange={handleServiceType} id="service-type" className="form-select" aria-label="Service type selection" required>
<option defaultValue>
Select service type...
</option>
<option value="Regular Service">
Regular Service
</option>
<option value="Deep Clean">
Deep Clean
</option>
<option value="Other">
Other - Please add notes
</option>
</select>
</div>
<div className="mb-3">
<label htmlFor="second-visit" className="form-label">
Second Visit Required?
</label>
<select id="second-visit" className="form-select" aria-label="Second visit selection"
onChange={handleSecondVisit} required>
<option defaultValue value="No">
No
</option>
<option value="Yes">
Yes
</option>
</select>
</div>
<div className="mb-3" id="second-start" style={{ display: showSecondVisit ? "block" : "none" }}>
<label htmlFor="start-2" className="form-label">
Second Visit Start Date & Time
</label>
<input onChange={handleStart2} id="start-2" type="datetime-local" className="form-control"></input>
</div>
<div className="mb-3" id="second-end" style={{ display: showSecondVisit ? "block" : "none" }}>
<label htmlFor="end-2" className="form-label">
Second Visit End Date & Time
</label>
<input onChange={handleEnd2} id="end-2" type="datetime-local" className="form-control"></input>
</div>
<div className="mb-3">
<label htmlFor="notes" className="form-label">
Notes
</label>
<textarea onChange={handleNotes} id="notes" className="form-control" rows="3"></textarea>
</div>
<div className="mb-3">
<label htmlFor="team-members" className="form-check-label">
Select Team Members:
</label>
<div id="team-members" className="mb-3">
{teamMembers.map((member, index) => (
<div key={index} className="form-check form-check-inline">
<input className="form-check-input" type="checkbox" id={`checkbox-${member.username}`}
onChange={handleAttendees} value={member.username}/>
<label className="form-check-label" htmlFor={`checkbox-${member.username}`}>
{member.display_name || member.username}
</label>
</div>
))}
</div>
</div>
</form>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary"
data-bs-dismiss="modal">Cancel
</button>
<button onClick={handleSubmit} type="button" className="btn btn-primary" data-bs-dismiss="modal">
Submit
</button>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,582 @@
import React from "react";
import api from "../api.js";
import {useState} from "react";
export default function ProjectPunchlistModal({proj}) {
// Form data states
const [ceilingTilesService, setCeilingTilesService] = useState(false);
const handleSetCeilingTilesService = (e) => {
setCeilingTilesService(e.target.value);
}
const [ceilingVentsService, setCeilingVentsService] = useState(false);
const handleSetCeilingVentsService = (e) => {
setCeilingVentsService(e.target.value);
}
const [ceilingVentsWaiting, setCeilingVentsWaiting] = useState(false);
const handleSetCeilingVentsWaiting = (e) => {
setCeilingVentsWaiting(e.target.value);
}
const [posService, setPosService] = useState(false);
const handleSetPosService = (e) => {
setPosService(e.target.value);
}
const [serviceCounter, setServiceCounter] = useState(false);
const handleSetServiceCounter = (e) => {
setServiceCounter(e.target.value);
}
const [oven, setOven] = useState('');
const handleSetOven = (e) => {
setOven(e.target.value);
}
const [ovenDisassembled, setOvenDisassembled] = useState(false);
const handleSetOvenDisassembled = (e) => {
setOvenDisassembled(e.target.value);
}
const [ovenReassembled, setOvenReassembled] = useState(false);
const handleSetOvenReassembled = (e) => {
setOvenReassembled(e.target.value);
}
const [ovenAlerts, setOvenAlerts] = useState(false);
const handleSetOvenAlerts = (e) => {
setOvenAlerts(e.target.value);
}
const [ovenExterior, setOvenExterior] = useState(false);
const handleSetOvenExterior = (e) => {
setOvenExterior(e.target.value);
}
const [walls, setWalls] = useState(false)
const handleSetWalls = (e) => {
setWalls(e.target.value);
}
const [posWall, setPosWall] = useState(false);
const handleSetPosWall = (e) => {
setPosWall(e.target.value);
}
const [ceilingTilesPrep,setCeilingTilesPrep] = useState(false);
const handleSetCeilingTilesPrep = (e) => {
setCeilingTilesPrep(e.target.value);
}
const [ceilingVentsPrep,setCeilingVentsPrep] = useState(false);
const handleSetCeilingVentsPrep = (e) => {
setCeilingVentsPrep(e.target.value);
}
const [quarryTile, setQuarryTile] = useState(false);
const handleSetQuarryTile = (e) => {
setQuarryTile(e.target.value);
}
const [cutTable, setCutTable] = useState(false);
const handleSetCutTable = (e) => {
setCutTable(e.target.value);
}
const [makeLine,setMakeLine] = useState(false);
const handleSetMakeLine = (e) => {
setMakeLine(e.target.value);
}
const [subLine, setSubLine] = useState(false);
const handleSetSubLine = (e) => {
setSubLine(e.target.value);
}
const [hotBoxes, setHotBoxes] = useState(false);
const handleSetHotBoxes = (e) => {
setHotBoxes(e.target.value);
}
const [doughPrep, setDoughPrep] = useState(false);
const handleSetDoughPrep = (e) => {
setDoughPrep(e.target.value);
}
const [posDelivery, setPosDelivery] = useState(false);
const handleSetPosDelivery = (e) => {
setPosDelivery(e.target.value);
}
const [managerStation, setManagerStation] = useState(false);
const handleSetManagerStation = (e) => {
setManagerStation(e.target.value);
}
const [handSinks, setHandSinks] = useState(false);
const handleSetHandSinks = (e) => {
setHandSinks(e.target.value);
}
const [dispensers, setDispensers] = useState(false);
const handleSetDispensers = (e) => {
setDispensers(e.target.value);
}
const [otherEquipment, setOtherEquipment] = useState(false);
const handleSetOtherEquipment = (e) => {
setOtherEquipment(e.target.value);
}
const [ceilingTilesBack, setCeilingTilesBack] = useState(false);
const handleSetCeilingTilesBack = (e) => {
setCeilingTilesBack(e.target.value);
}
const [ceilingVentsBack, setCeilingVentsBack] = useState(false);
const handleSetCeilingVentsBack = (e) => {
setCeilingVentsBack(e.target.value);
}
const [trash, setTrash] = useState(false);
const handleSetTrash = (e) => {
setTrash(e.target.value);
}
const [cleanup, setCleanup] = useState(false);
const handleSetCleanup = (e) => {
setCleanup(e.target.value);
}
const [alarm, setAlarm] = useState(false);
const handleSetAlarm = (e) => {
setAlarm(e.target.value);
}
const [notes, setNotes] = useState("");
const handleSetNotes = (e) => {
setNotes(e.target.value);
}
// How form submission is handled
const handleSubmit = async (e) => {
e.preventDefault();
try {
const response = await api.post('projects/punch/', {
id: proj.id,
store: proj.store,
date: proj.date,
ceilingTilesService: ceilingTilesService,
ceilingVentsService: ceilingVentsService,
ceilingVentsWaiting: ceilingVentsWaiting,
posService: posService,
serviceCounter: serviceCounter,
oven: oven,
ovenDisassembled: ovenDisassembled,
ovenReassembled: ovenReassembled,
ovenAlerts: ovenAlerts,
ovenExterior: ovenExterior,
walls: walls,
posWall: posWall,
ceilingTilesPrep: ceilingTilesPrep,
ceilingVentsPrep: ceilingVentsPrep,
quarryTile: quarryTile,
cutTable: cutTable,
makeLine: makeLine,
subLine: subLine,
hotBoxes: hotBoxes,
doughPrep: doughPrep,
posDelivery: posDelivery,
managerStation: managerStation,
handSinks: handSinks,
dispensers: dispensers,
otherEquipment: otherEquipment,
ceilingTilesBack: ceilingTilesBack,
ceilingVentsBack: ceilingVentsBack,
trash: trash,
cleanup: cleanup,
alarm: alarm,
notes: notes,
});
if (response.status === 200) {
onSuccess(response.data.message);
}
} catch (error) {
alert(error.detail);
}
}
const onSuccess = (message) => {
alert(message);
window.location.reload();
}
const modalId = `modal-${proj.id.replace(/\s+/g, '-')}`;
return (
<div>
<button type="button" className="btn btn-primary m-3" data-bs-toggle="modal"
data-bs-target={`#${modalId}`}>
Create Punchlist
</button>
<div className='modal fade' id={modalId} data-bs-backdrop="static"
data-bs-keyboard="false" tabIndex="-1" aria-labelledby="staticBackdropLabel" aria-hidden="true">
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<h1 className="modal-title fs-5" id="staticBackdropLabel">
Create a New Punchlist
</h1>
<button type="button" className="btn-close" data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<div className="modal-body">
<p>Project ID: {proj.id}</p>
<p>Store #: {proj.store} - {proj.city}</p>
<p>Date: {proj.date}</p>
<form>
<label className="form-label" htmlFor="punchlist-lobby">
Lobby
</label>
<div className="mb-3" id="punchlist-lobby">
<div className="mb-3">
<div className="form-check form-switch">
<input className="form-check-input" type="checkbox" role="switch"
onChange={handleSetCeilingTilesService} id="ceiling-tiles-service"/>
<label className="form-check-label"
htmlFor="ceiling-tiles-service">
Ceiling tiles above service counter wiped clean
</label>
</div>
</div>
<div className="mb-3">
<div className="form-check form-switch">
<input className="form-check-input" type="checkbox" role="switch"
onChange={handleSetCeilingVentsService} id="ceiling-vents-service"/>
<label className="form-check-label"
htmlFor="ceiling-vents-service">
Ceiling vents above service counter dusted and cleaned
</label>
</div>
</div>
<div className="mb-3">
<div className="form-check form-switch">
<input className="form-check-input" type="checkbox" role="switch"
onChange={handleSetCeilingVentsWaiting} id="ceiling-vents-waiting"/>
<label className="form-check-label"
htmlFor="ceiling-vents-waiting">
Ceiling vents in waiting area dusted and cleaned
</label>
</div>
</div>
<div className="mb-3">
<div className="form-check form-switch">
<input className="form-check-input" type="checkbox" role="switch"
onChange={handleSetPosService} id="pos-service"/>
<label className="form-check-label"
htmlFor="pos-service">
POS systems and peripherals wiped clean
</label>
</div>
</div>
<div className="mb-3">
<div className="form-check form-switch">
<input className="form-check-input" type="checkbox" role="switch"
onChange={handleSetServiceCounter} id="service-counter"/>
<label className="form-check-label"
htmlFor="service-counter">
Service counter wiped clean and polished
</label>
</div>
</div>
</div>
<label className="form-label" htmlFor="punchlist-kitchen">
Kitchen
</label>
<div className="mb-3">
<label htmlFor="oven-type">
Oven type:
</label>
<div id="oven-type" className="mb-3">
<div className="form-check">
<input className="form-check-input" type="radio" name="oven"
onClick={handleSetOven} id="middleby" value="Middleby-Marshall"/>
<label className="form-check-label" htmlFor="middleby">
Middleby-Marshall
</label>
</div>
<div className="form-check">
<input className="form-check-input" type="radio" name="oven"
onClick={handleSetOven} id="xlt" value="XLT"/>
<label className="form-check-label" htmlFor="xlt">
XLT
</label>
</div>
<div className="form-check">
<input className="form-check-input" type="radio" name="oven"
onClick={handleSetOven} id="other-oven" value="Other"/>
<label className="form-check-label" htmlFor="other-oven">
Other - add notes
</label>
</div>
</div>
</div>
<div className="mb-3" id="punchlist-kitchen">
<div className="mb-3">
<div className="form-check form-switch">
<input className="form-check-input" type="checkbox" role="switch"
onChange={handleSetOvenDisassembled} id="oven-diassembled"/>
<label className="form-check-label"
htmlFor="oven-diassembled">
Oven disassembled and cleaned
</label>
</div>
</div>
</div>
<div className="mb-3">
<div className="form-check form-switch">
<input className="form-check-input" type="checkbox" role="switch"
onChange={handleSetOvenReassembled} id="oven-reassembled"/>
<label className="form-check-label"
htmlFor="oven-reassembled">
Oven reassembled to original configuration
</label>
</div>
</div>
<div className="mb-3">
<div className="form-check form-switch">
<input className="form-check-input" type="checkbox" role="switch"
onChange={handleSetOvenAlerts} id="oven-alerts"/>
<label className="form-check-label"
htmlFor="oven-alerts">
Oven alerts identified - add notes to supervisor
</label>
</div>
</div>
<div className="mb-3">
<div className="form-check form-switch">
<input className="form-check-input" type="checkbox" role="switch"
onChange={handleSetOvenExterior} id="oven-exterior"/>
<label className="form-check-label"
htmlFor="oven-exterior">
Oven exterior wiped clean and polished
</label>
</div>
</div>
<div className="mb-3">
<div className="form-check form-switch">
<input className="form-check-input" type="checkbox" role="switch"
onChange={handleSetWalls} id="walls"/>
<label className="form-check-label"
htmlFor="walls">
All walls, floor-to-ceiling, wiped clean
</label>
</div>
</div>
<div className="mb-3">
<div className="form-check form-switch">
<input className="form-check-input" type="checkbox" role="switch"
onChange={handleSetPosWall} id="pos-wall"/>
<label className="form-check-label"
htmlFor="pos-wall">
Wall-mounted POS systems and peripherals wiped clean
</label>
</div>
</div>
<div className="mb-3">
<div className="form-check form-switch">
<input className="form-check-input" type="checkbox" role="switch"
onChange={handleSetCeilingTilesPrep} id="ceiling-tiles-prep"/>
<label className="form-check-label"
htmlFor="ceiling-tiles-prep">
Ceiling tiles wiped clean
</label>
</div>
</div>
<div className="mb-3">
<div className="form-check form-switch">
<input className="form-check-input" type="checkbox" role="switch"
onChange={handleSetCeilingVentsPrep} id="ceiling-vents-prep"/>
<label className="form-check-label"
htmlFor="ceiling-vents-prep">
Ceiling vents dusted and cleaned
</label>
</div>
</div>
<div className="mb-3">
<div className="form-check form-switch">
<input className="form-check-input" type="checkbox" role="switch"
onChange={handleSetQuarryTile} id="quarry-tile"/>
<label className="form-check-label"
htmlFor="quarry-tile">
Quarry tile floors - add notes to supervisor
</label>
</div>
</div>
<label className="form-label" htmlFor="punchlist-kitchen-equipment">
Kitchen Equipment
</label>
<div className="mb-3" id="punchlist-kitchen-equipment">
<div className="mb-3">
<div className="form-check form-switch">
<input className="form-check-input" type="checkbox" role="switch"
onChange={handleSetCutTable} id="cut-table"/>
<label className="form-check-label"
htmlFor="cut-table">
Cut table
</label>
</div>
</div>
<div className="mb-3">
<div className="form-check form-switch">
<input className="form-check-input" type="checkbox" role="switch"
onChange={handleSetMakeLine} id="make-line"/>
<label className="form-check-label"
htmlFor="make-line">
Make line
</label>
</div>
</div>
<div className="mb-3">
<div className="form-check form-switch">
<input className="form-check-input" type="checkbox" role="switch"
onChange={handleSetSubLine} id="sub-line"/>
<label className="form-check-label"
htmlFor="sub-line">
Sub line
</label>
</div>
</div>
<div className="mb-3">
<div className="form-check form-switch">
<input className="form-check-input" type="checkbox" role="switch"
onChange={handleSetHotBoxes} id="hot-boxes"/>
<label className="form-check-label"
htmlFor="hot-boxes">
Hot boxes
</label>
</div>
</div>
<div className="mb-3">
<div className="form-check form-switch">
<input className="form-check-input" type="checkbox" role="switch"
onChange={handleSetDoughPrep} id="dough-prep"/>
<label className="form-check-label"
htmlFor="dough-prep">
Dough prep
</label>
</div>
</div>
<div className="mb-3">
<div className="form-check form-switch">
<input className="form-check-input" type="checkbox" role="switch"
onChange={handleSetPosDelivery} id="pos-delivery"/>
<label className="form-check-label"
htmlFor="pos-delivery">
Delivery POS systems
</label>
</div>
</div>
<div className="mb-3">
<div className="form-check form-switch">
<input className="form-check-input" type="checkbox" role="switch"
onChange={handleSetManagerStation} id="manager-station"/>
<label className="form-check-label"
htmlFor="manager-station">
Manager station(s)
</label>
</div>
</div>
<div className="mb-3">
<div className="form-check form-switch">
<input className="form-check-input" type="checkbox" role="switch"
onChange={handleSetHandSinks} id="hand-sinks"/>
<label className="form-check-label"
htmlFor="hand-sinks">
Hand-washing sinks
</label>
</div>
</div>
<div className="mb-3">
<div className="form-check form-switch">
<input className="form-check-input" type="checkbox" role="switch"
onChange={handleSetDispensers} id="dispensers"/>
<label className="form-check-label"
htmlFor="dispensers">
Soap and paper dispensers
</label>
</div>
</div>
<div className="mb-3">
<div className="form-check form-switch">
<input className="form-check-input" type="checkbox" role="switch"
onChange={handleSetOtherEquipment} id="other-equipment"/>
<label className="form-check-label"
htmlFor="other">
Other - add notes
</label>
</div>
</div>
</div>
<label className="form-label" htmlFor="punchlist-back">
Back Room / Dishwashing Area
</label>
<div className="mb-3" id="punchlist-back">
<div className="mb-3">
<div className="form-check form-switch">
<input className="form-check-input" type="checkbox" role="switch"
onChange={handleSetCeilingTilesBack} id="ceiling-tiles-back"/>
<label className="form-check-label"
htmlFor="ceiling-tiles-back">
Ceiling tiles wiped clean
</label>
</div>
</div>
<div className="mb-3">
<div className="form-check form-switch">
<input className="form-check-input" type="checkbox" role="switch"
onChange={handleSetCeilingVentsBack} id="ceiling-vents-back"/>
<label className="form-check-label"
htmlFor="ceiling-vents-back">
Ceiling vents dusted and cleaned
</label>
</div>
</div>
</div>
<label className="form-label" htmlFor="punchlist-wrapup">
Nightly Wrap-Up
</label>
<div className="mb-3" id="punchlist-wrapup">
<div className="mb-3">
<div className="form-check form-switch">
<input className="form-check-input" type="checkbox" role="switch"
onChange={handleSetTrash} id="trash"/>
<label className="form-check-label"
htmlFor="trash">
Trash receptacles emptied
</label>
</div>
</div>
<div className="mb-3">
<div className="form-check form-switch">
<input className="form-check-input" type="checkbox" role="switch"
onChange={handleSetCleanup} id="cleanup"/>
<label className="form-check-label"
htmlFor="cleanup">
Store left in clean and presentable condition
</label>
</div>
</div>
<div className="mb-3">
<div className="form-check form-switch">
<input className="form-check-input" type="checkbox" role="switch"
onChange={handleSetAlarm} id="alarm"/>
<label className="form-check-label"
htmlFor="alarm">
Store locked and alarm set (if applicable)
</label>
</div>
</div>
</div>
<div className="mb-3" id="punchlist-notes">
<div className="mb-3">
<label htmlFor="notes" className="form-label">
Notes
</label>
<textarea onChange={handleSetNotes} id="notes" className="form-control"
rows="5"></textarea>
</div>
</div>
</form>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary"
data-bs-dismiss="modal">
Cancel
</button>
<button onClick={handleSubmit} type="button" className="btn btn-primary"
data-bs-dismiss="modal">
Submit
</button>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,58 @@
import React from 'react'
import {Navigate} from 'react-router-dom'
import {jwtDecode} from 'jwt-decode'
import api from '../api'
import {REFRESH_TOKEN, ACCESS_TOKEN} from "../constants";
import {useEffect} from "react";
function ProtectedRoute({children}) {
const [isAuthorized, setIsAuthorized] = React.useState(null)
useEffect(() => {
auth().catch(() => setIsAuthorized(false))
}, [])
const refreshToken = async () => {
const refreshToken = localStorage.getItem(REFRESH_TOKEN)
try {
const response = await api.post('token/refresh/', {refresh: refreshToken})
if (response.status === 200) {
localStorage.setItem(ACCESS_TOKEN, response.data.access)
setIsAuthorized(true)
} else {
setIsAuthorized(false)
}
} catch (error) {
console.log(error)
setIsAuthorized(false)
}
}
const auth = async () => {
const token = localStorage.getItem(ACCESS_TOKEN)
if (!token) {
setIsAuthorized(false)
return
}
try {
const decoded = jwtDecode(token)
const tokenExpiration = decoded.exp
const now = Date.now() / 1000
if (tokenExpiration < now) {
await refreshToken()
} else {
setIsAuthorized(true)
}
} catch (error) {
console.error('Error decoding token:', error);
setIsAuthorized(false);
}
}
if (isAuthorized === null) {
return <div>Loading...</div>
}
return isAuthorized ? children : <Navigate to="/login"/>
}
export default ProtectedRoute

View File

@ -0,0 +1,169 @@
import React from "react";
import api from "../api.js";
import {useState} from "react";
export default function SuppliesModal({visit}) {
const modalId = `modal2-${visit.id.replace(/\s+/g, '-')}`;
const username = localStorage.getItem("username");
const [notes, setNotes] = useState('');
const [multifold, setMultifold] = useState(false);
const [cfold, setCfold] = useState(false);
const [ptroll, setPtroll] = useState(false);
const [kitroll, setKitroll] = useState(false);
const [jumbotp, setJumbotp] = useState(false);
const [standardtp, setStandardtp] = useState(false);
const [dispsoap, setDispsoap] = useState(false);
const [indsoap, setIndsoap] = useState(false);
const [urinal, setUrinal] = useState(false);
const [umats, setUmats] = useState(false);
const [fem, setFem] = useState(false);
const [air, setAir] = useState(false);
const [lgbag, setLgbag] = useState(false);
const [smbag, setSmbag] = useState(false);
const [mdbag, setMdbag] = useState(false);
const handleMultifold = (e) => setMultifold(e.target.checked);
const handleCfold = (e) => setCfold(e.target.checked);
const handlePtroll = (e) => setPtroll(e.target.checked);
const handleKitroll = (e) => setKitroll(e.target.checked);
const handleJumbotp = (e) => setJumbotp(e.target.checked);
const handleStandardtp = (e) => setStandardtp(e.target.checked);
const handleDispsoap = (e) => setDispsoap(e.target.checked);
const handleIndsoap = (e) => setIndsoap(e.target.checked);
const handleUrinal = (e) => setUrinal(e.target.checked);
const handleUmats = (e) => setUmats(e.target.checked);
const handleFem = (e) => setFem(e.target.checked);
const handleAir = (e) => setAir(e.target.checked);
const handleLgbag = (e) => setLgbag(e.target.checked);
const handleSmbag = (e) => setSmbag(e.target.checked);
const handleMdbag = (e) => setMdbag(e.target.checked);
const handleRequest = async () => {
const checkedItems = {
multiFoldTowels: multifold,
cFoldTowels: cfold,
jumboPtRoll: ptroll,
kitPtRoll: kitroll,
jumboTp: jumbotp,
standardTp: standardtp,
dispSoap: dispsoap,
indSoap: indsoap,
urinal: urinal,
uMats: umats,
fem: fem,
air: air,
lgBag: lgbag,
smBag: smbag,
mdBag: mdbag
}
try {
const response = await api.post('accounts/supplies/', {account: visit.full_name, team: username, supplies: checkedItems, notes: notes});
if (response.status === 200) {
onSuccess()
}
} catch (error) {
alert(error.detail);
}
}
const handleChange = (e) => {
setNotes(e.target.value);
}
const onSuccess = () => {
alert('Supply request submitted successfully!');
}
return (
<div>
<button type="button" className="btn btn-primary" data-bs-toggle="modal" disabled={visit.status === 'Closed'}
data-bs-target={`#${modalId}`}>Request Supplies</button>
<div className='modal fade' id={modalId} data-bs-backdrop="static"
data-bs-keyboard="false" tabIndex="-1" aria-labelledby="staticBackdropLabel" aria-hidden="true">
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<h1 className="modal-title fs-5" id="staticBackdropLabel">
Supply Request Form
</h1>
<button type="button" className="btn-close" data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<div className="modal-body">
<p>Account: {visit.full_name}</p>
<p>Date: {visit.date}</p>
<p>Requested By: {username}</p>
<form>
<div className="mb-3 form-check">
<label htmlFor="multifold">Multifold Towels</label>
<input className="form-check-input" checked={multifold} onChange={handleMultifold} type="checkbox" id="multifold"/>
</div>
<div className="mb-3 form-check">
<label htmlFor="cfold">C-Fold Towels</label>
<input className="form-check-input" checked={cfold} onChange={handleCfold} type="checkbox" id="cfold"/>
</div>
<div className="mb-3 form-check">
<label htmlFor="ptroll">Jumbo Paper Towel Rolls</label>
<input className="form-check-input" checked={ptroll} onChange={handlePtroll} type="checkbox" id="ptroll"/>
</div>
<div className="mb-3 form-check">
<label htmlFor="kitroll">Kitchen Paper Towel Rolls</label>
<input className="form-check-input" checked={kitroll} onChange={handleKitroll} type="checkbox" id="kitroll"/>
</div>
<div className="mb-3 form-check">
<label htmlFor="jumbotp">Jumbo Toilet Paper</label>
<input className="form-check-input" checked={jumbotp} onChange={handleJumbotp} type="checkbox" id="jumbotp"/>
</div>
<div className="mb-3 form-check">
<label htmlFor="standardtp">Standard Toilet Paper</label>
<input className="form-check-input" checked={standardtp} onChange={handleStandardtp} type="checkbox" id="standardtp"/>
</div>
<div className="mb-3 form-check">
<label htmlFor="dispsoap">Hand Soap (Dispenser)</label>
<input className="form-check-input" checked={dispsoap} onChange={handleDispsoap} type="checkbox" id="dispsoap"/>
</div>
<div className="mb-3 form-check">
<label htmlFor="indsoap">Hand Soap (Individual)</label>
<input className="form-check-input" checked={indsoap} onChange={handleIndsoap} type="checkbox" id="indsoap"/>
</div>
<div className="mb-3 form-check">
<label htmlFor="urinal">Urinal Screens</label>
<input className="form-check-input" checked={urinal} onChange={handleUrinal} type="checkbox" id="urinal"/>
</div>
<div className="mb-3 form-check">
<label htmlFor="umats">Urinal Mats</label>
<input className="form-check-input" checked={umats} onChange={handleUmats} type="checkbox" id="umats"/>
</div>
<div className="mb-3 form-check">
<label htmlFor="fem">Feminine Hygiene Waste Bags</label>
<input className="form-check-input" checked={fem} onChange={handleFem} type="checkbox" id="fem"/>
</div>
<div className="mb-3 form-check">
<label htmlFor="air">Air Fresheners</label>
<input className="form-check-input" checked={air} onChange={handleAir} type="checkbox" id="air"/>
</div>
<div className="mb-3 form-check">
<label htmlFor="lgbag">Large Trash Bags</label>
<input className="form-check-input" checked={lgbag} onChange={handleLgbag} type="checkbox" id="lgbag"/>
</div>
<div className="mb-3 form-check">
<label htmlFor="smbag">Small Trash Bags</label>
<input className="form-check-input" checked={smbag} onChange={handleSmbag} type="checkbox" id="smbag"/>
</div>
<div className="mb-3 form-check">
<label htmlFor="mdbag">Medium Trash Bags</label>
<input className="form-check-input" checked={mdbag} onChange={handleMdbag} type="checkbox" id="mdbag"/>
</div>
</form>
<input onChange={handleChange} type="text" className="form-control" placeholder="Add notes here..."></input>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary"
data-bs-dismiss="modal">Close
</button>
<button type="button" className="btn btn-primary" onClick={handleRequest} data-bs-dismiss="modal">Submit</button>
</div>
</div>
</div>
</div>
</div>
)
}

7
frontend/constants.js Normal file
View File

@ -0,0 +1,7 @@
export const ACCESS_TOKEN = 'access'
export const REFRESH_TOKEN = 'refresh'
export const USERNAME = 'username'
export const DATE_TOGGLE = 'dateToggle'
// API URL - configure in webpack or build environment
export const API_URL = process.env.API_URL || 'http://localhost:8000/'

12
frontend/main.jsx Normal file
View File

@ -0,0 +1,12 @@
import React from 'react'
import { StrictMode } from 'react'
import App from './App.jsx'
import ReactDOM from 'react-dom/client'
const root = document.getElementById('root')
ReactDOM.createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@ -0,0 +1,151 @@
import React from "react";
import {useEffect, useState} from "react";
import api from "../api.js";
export default function AccountsModule() {
const [accounts, setAccounts] = useState([])
const [loading, setLoading] = useState(false)
const [serviceDays, setServiceDays] = useState([])
const [loadingDays, setLoadingDays] = useState(false)
const [activeAccount, setActiveAccount] = useState([])
const [accountStatus, setAccountStatus] = useState([])
const fetchServiceDays = async (account) => {
setLoadingDays(true);
try {
const response = await api.get(`days/${account}/`);
if (response.status === 200) {
setServiceDays(response.data
);}
} catch (error) {
alert(error);
} finally {
setLoadingDays(false);
}
};
const handleToggle = (account) => {
if (activeAccount === account) {
setServiceDays([])
setActiveAccount([])
} else {
setServiceDays([])
setActiveAccount(account)
fetchServiceDays(account)
}
}
useEffect(() => {
const fetchAccounts = async () => {
setLoading(true);
try {
const response = await api.get('/accounts/');
if (response.status === 200) {
const sortedAccounts = response.data.sort((a, b) => a.full_name.localeCompare(b.full_name));
setAccounts(sortedAccounts);
}
} catch (error) {
alert(error);
} finally {
setLoading(false);
}
};
const fetchAccountStatus = async () => {
setLoading(true);
try {
const response = await api.get('/accounts/status/');
if (response.status === 200) {
setAccountStatus(response.data);
}
} catch (error) {
alert(error);
} finally {
setLoading(false);
}
};
fetchAccounts();
fetchAccountStatus();
}, []);
const activeAccounts = accounts.filter(account => {
const status = accountStatus.find(status => status.short_name === account.short_name);
return status && status.is_active;
});
const inactiveAccounts = accounts.filter(account => {
const status = accountStatus.find(status => status.short_name === account.short_name);
return status?!status.is_active: false;
});
return (
<div>
<div className="d-flex align-items-center justify-content-center p-2">
<h4>Account Information</h4>
</div>
{loading ? <div className="d-flex align-items-center"><h4 className="m-2 p-2">Loading accounts...</h4><div className="m-2 spinner-border"></div></div> :
<div className="accordion p-2" id="accountsAccordion">
<div>
<h3 className="m-2 p-2">Active Accounts</h3>
{activeAccounts.map(loc => (
<div key={loc.short_name} className="accordion-item">
<h2 className="accordion-header">
<button className="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target={`#collapse${loc.short_name}`} aria-expanded="false" aria-controls={`collapse${loc.short_name}`}
onClick={() => handleToggle(loc.short_name)}>
{loc.full_name}
</button>
</h2>
<div id={`collapse${loc.short_name}`} className="accordion-collapse collapse" data-bs-parent="#accountsAccordion">
<div className="accordion-body">
<p>{loc.street_address}, {loc.city}, {loc.state} {loc.zip_code}</p>
{loadingDays ? <p>Loading service days...</p> : serviceDays.map(schedule => (
<div key={schedule.short_name}>
{schedule.mon_serv === true ? <p>Monday</p> : null}
{schedule.tues_serv === true ? <p>Tuesday</p> : null}
{schedule.wed_serv === true ? <p>Wednesday</p> : null}
{schedule.thurs_serv === true ? <p>Thursday</p> : null}
{schedule.fri_serv === true ? <p>Friday</p> : null}
{schedule.sat_serv === true ? <p>Saturday</p> : null}
{schedule.sun_serv === true ? <p>Sunday</p> : null}
{schedule.weekend_serv === true ? <p>Friday with weekend option</p> : null}
{schedule.exception_serv === true ? <p>Schedule exceptions exist - see scope of work</p> : null}
</div>
))}
</div>
</div>
</div>
))}
</div>
<div>
<h3 className="m-2 p-2">Inactive Accounts</h3>
{inactiveAccounts.map(loc => (
<div key={loc.short_name} className="accordion-item">
<h2 className="accordion-header">
<button className="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target={`#collapse${loc.short_name}`} aria-expanded="false" aria-controls={`collapse${loc.short_name}`}
onClick={() => handleToggle(loc.short_name)}>
{loc.full_name}
</button>
</h2>
<div id={`collapse${loc.short_name}`} className="accordion-collapse collapse" data-bs-parent="#accountsAccordion">
<div className="accordion-body">
<p>{loc.street_address}, {loc.city}, {loc.state} {loc.zip_code}</p>
{loadingDays ? <p>Loading service days...</p> : serviceDays.map(schedule => (
<div key={schedule.short_name}>
{schedule.mon_serv === true ? <p>Monday</p> : null}
{schedule.tues_serv === true ? <p>Tuesday</p> : null}
{schedule.wed_serv === true ? <p>Wednesday</p> : null}
{schedule.thurs_serv === true ? <p>Thursday</p> : null}
{schedule.fri_serv === true ? <p>Friday</p> : null}
{schedule.sat_serv === true ? <p>Saturday</p> : null}
{schedule.sun_serv === true ? <p>Sunday</p> : null}
{schedule.weekend_serv === true ? <p>Friday with weekend option</p> : null}
{schedule.exception_serv === true ? <p>Schedule exceptions exist - see scope of work</p> : null}
</div>
))}
</div>
</div>
</div>
))}
</div>
</div>
}
</div>
)
}

View File

@ -0,0 +1,9 @@
import React from 'react'
export default function HomeModule() {
return (
<div>
<h4>Choose an option from the menu to get started!</h4>
</div>
)
}

View File

@ -0,0 +1,15 @@
import React from 'react'
import LoginForm from '../components/LoginForm.jsx'
export default function LoginModule() {
return (
<div className="d-flex flex-column">
<div className="d-flex flex-column align-items-center justify-content-center p-2">
<div className="d-flex align-items-center justify-content-center p-2">
<h1 className="p-2">Nexus Team Portal</h1>
</div>
<LoginForm/>
</div>
</div>
)
}

View File

@ -0,0 +1,101 @@
import React from "react";
import {useEffect, useState} from "react";
import api from "../api.js";
import ProjectCreateModal from "../components/ProjectCreateModal.jsx";
import ProjectPunchlistModal from "../components/ProjectPunchlistModal.jsx";
import ProjectCloseModal from "../components/ProjectCloseModal.jsx";
export default function ProjectsModule() {
const [stores, setStores] = useState([])
const [loading, setLoading] = useState(false)
const [loadingProjects, setLoadingProjects] = useState(false)
const [projects, setProjects] = useState([])
useEffect(() => {
const fetchProjects = async () => {
setLoadingProjects(true);
try {
const response = await api.get('projects/');
if (response.status === 200) {
setProjects(response.data);
}
} catch (error) {
alert(error);
} finally {
setLoadingProjects(false);
}
};
const fetchStores = async () => {
setLoading(true);
try {
const response = await api.get('/stores/');
if (response.status === 200) {
const sortedStores = [...response.data].sort((a, b) => a.store - b.store);
setStores(sortedStores);
}
} catch (error) {
alert(error);
} finally {
setLoading(false);
}
};
fetchStores();
fetchProjects();
}, []);
return (
<div>
<div className="d-flex flex-row align-items-center justify-content-center p-2">
<h4>Upcoming</h4>
</div>
{loadingProjects ? <div className="d-flex align-items-center"><h4 className="m-2 p-2">Loading Projects...</h4><div className="m-2 spinner-border"></div></div> :
<div className="accordion p-2" id="projectsAccordion">
{projects.filter(proj => proj.status === 'Open').map(proj => (
<div key={proj.id} className="accordion-item">
<h2 className="accordion-header">
<button className="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target={`#collapse${proj.id}`} aria-expanded="false" aria-controls={`collapse${proj.id}`}>
{proj.date} - #{proj.store} - {proj.city}
</button>
</h2>
<div id={`collapse${proj.id}`} className="accordion-collapse collapse" data-bs-parent="#projectsAccordion">
<div className="accordion-body">
<div className="d-flex flex-row align-items-center">
{proj.punchlist === null ? <ProjectPunchlistModal proj={proj}/> : null}
{proj.status === 'Open' ? <ProjectCloseModal proj={proj}/> : null}
</div>
</div>
</div>
</div>
))}
</div>}
<div className="d-flex align-items-center justify-content-center p-2">
<h4>Stores</h4>
</div>
{loading ? <div className="d-flex align-items-center"><h4 className="m-2 p-2">Loading Stores...</h4><div className="m-2 spinner-border"></div></div> :
<div className="accordion p-2" id="storesAccordion">
{stores.map(loc => (
<div key={loc.id} className="accordion-item">
<h2 className="accordion-header">
<button className="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target={`#collapse${loc.id}`} aria-expanded="false" aria-controls={`collapse${loc.id}`}>
#{loc.store} - {loc.city}
</button>
</h2>
<div id={`collapse${loc.id}`} className="accordion-collapse collapse" data-bs-parent="#storesAccordion">
<div className="accordion-body">
<p>{loc.street_address}, {loc.city}, {loc.state} {loc.zip_code}</p>
<p>Entity: {loc.entity}</p>
<p>Phone: {loc.phone}</p>
<p>Store Contact: {loc.store_contact}</p>
<p>Store Email: {loc.store_contact_email}</p>
<p>Supervisor Contact: {loc.super_contact}</p>
<p>Supervisor Email: {loc.super_contact_email}</p>
<ProjectCreateModal loc={loc}/>
</div>
</div>
</div>
))}
</div>
}
</div>
)
}

View File

@ -0,0 +1,243 @@
import React from "react";
import api from "../api.js";
import {useState, useEffect} from "react";
export default function ReportsModule() {
const username = localStorage.getItem('username');
const [reports, setReports] = useState([]);
const [month, setMonth] = useState("");
const [year, setYear] = useState("");
const [account, setAccount] = useState("");
const [accounts, setAccounts] = useState([]);
const [loading, setLoading] = useState(false);
const [rates, setRates] = useState({});
const [loadingRates, setLoadingRates] = useState(false);
const [grandTotal, setGrandTotal] = useState(0);
useEffect(() => {
const fetchAccounts = async () => {
setLoading(true);
try {
const response = await api.get('/accounts/');
if (response.status === 200) {
const sortedAccounts = [...response.data].sort((a, b) => a.store - b.store);
setAccounts(sortedAccounts);
}
} catch (error) {
alert(error);
} finally {
setLoading(false);
}
};
fetchAccounts();
}, []);
const fetchServiceVisits = async () => {
if (month !== "" && year !== "" && account !== "") {
setLoading(true);
try {
const response = await api.get(
`visits/?username=${username}&month=${month}&year=${year}&account=${account}`
);
if (response.status === 200) {
const sortedVisits = [...response.data].sort((a, b) => {
if (a.short_name < b.short_name) return -1;
if (a.short_name > b.short_name) return 1;
if (a.date < b.date) return -1;
if (a.date > b.date) return 1;
return 0;
});
setReports(sortedVisits);
setGrandTotal(null); // Reset to null when fetching
setRates({}); // Reset rates
}
} catch (error) {
alert(error);
} finally {
setLoading(false);
}
} else {
alert("All fields are required!!")
}
};
const fetchRate = async (account) => {
setLoading(true);
try {
const response = await api.post('/accounts/rate/', {'account': account});
if (response.status === 200) {
return response.data['rate'];
}
} catch (error) {
alert(error);
} finally {
setLoading(false);
}
}
useEffect(() => {
const fetchAndSetRates = async () => {
if (reports.length > 0) {
setLoadingRates(true);
try {
const uniqueAccounts = [...new Set(reports.map(report => report.short_name))];
const ratePromises = uniqueAccounts.map(account => fetchRate(account));
const fetchedRates = await Promise.all(ratePromises);
const ratesObject = {};
uniqueAccounts.forEach((account, index) => {
ratesObject[account] = fetchedRates[index];
});
setRates(ratesObject);
} catch (error) {
alert(error); // Handle errors appropriately
} finally {
setLoadingRates(false);
}
}
};
fetchAndSetRates();
}, [reports]);
useEffect(() => {
if (!loadingRates && Object.keys(rates).length > 0) {
const closedVisitsData = reports.reduce((count, visit) => {
if (visit.status === "Closed") {
count[visit.short_name] = {
closedVisits: (count[visit.short_name]?.closedVisits || 0) + 1,
fullName: visit.full_name,
};
}
return count;
}, {});
let totalByAccount = 0;
for (let key in closedVisitsData) {
if (rates[key] && rates[key] !== "N/A") {
totalByAccount += closedVisitsData[key].closedVisits * rates[key];
}
}
setGrandTotal(totalByAccount);
} else if (!loadingRates && reports.length === 0) {
setGrandTotal(0);
}
}, [reports, rates, loadingRates]);
const handleYear = (e) => {
setYear(e.target.value)
}
const handleMonth = (e) => {
setMonth(e.target.value)
}
const handleAccount = (e) => {
setAccount(e.target.value)
}
// Generate year options dynamically
const currentYear = new Date().getFullYear();
const yearOptions = [];
for (let y = 2024; y <= currentYear + 1; y++) {
yearOptions.push(y);
}
return (
<div className="d-flex flex-column">
<div className="d-flex align-items-center justify-content-center p-2">
<h4>Reports Module</h4>
</div>
<div>
<form className="form-control">
<select defaultValue="" onChange={handleYear} className="form-select">
<option value="">Select a year...</option>
{yearOptions.map(y => (
<option key={y} value={y}>{y}</option>
))}
</select>
<select defaultValue="" required onChange={handleMonth} className="form-select">
<option value="">Select a month...</option>
<option value="1">January</option>
<option value="2">February</option>
<option value="3">March</option>
<option value="4">April</option>
<option value="5">May</option>
<option value="6">June</option>
<option value="7">July</option>
<option value="8">August</option>
<option value="9">September</option>
<option value="10">October</option>
<option value="11">November</option>
<option value="12">December</option>
</select>
<select defaultValue="" required onChange={handleAccount} className="form-select">
<option value="">Select an account...</option>
<option value="*">*Any*</option>
{accounts.map((acc, index) => (<option key={index} value={acc.short_name}>{acc.full_name}</option>))}
</select>
<button type="button" onClick={fetchServiceVisits} className="btn btn-primary">Fetch Visits</button>
</form>
{loading ? <div className="d-flex align-items-center"><h4 className="m-2 p-2">Loading Reports...</h4><div className="m-2 spinner-border"></div></div> : (
<div className="d-flex m-2 p-2 flex-column align-items-center justify-content-center">
<h4>Closed Visits Detail</h4>
<table className="table">
<thead>
<tr>
<th scope="col" className="w-25">Account</th>
<th scope="col" className="w-25">Visits</th>
<th scope="col" className="w-25">Rate</th>
<th scope="col" className="w-25">Total</th>
</tr>
</thead>
<tbody>
{Object.entries(
reports.reduce(
(count, visit) => {
if (visit.status === "Closed") {
count[visit.short_name] = {
closedVisits: (count[visit.short_name]?.closedVisits || 0) + 1,
fullName: visit.full_name
}
}
return count;
}, {})
).map(
([short_name, count]) => (
<tr key={short_name}>
<th scope="row">{count.fullName}</th>
<td>{count.closedVisits}</td>
<td>{loadingRates ? "Loading..." : (rates[short_name] || "N/A")}</td>
<td>{loadingRates ? "Loading..." : rates[short_name] !== "N/A" ? count.closedVisits * rates[short_name] : "N/A"}</td>
</tr>
)
)}
<tr>
<th colSpan="3">Grand Total:</th>
<td>{grandTotal}</td>
</tr>
</tbody>
</table>
<h4>Open Visits Detail</h4>
<table className="table">
<thead>
<tr>
<th scope="col" className="w-50">Account</th>
<th scope="col" className="w-20">Date</th>
<th scope="col" className="w-20">Notes</th>
</tr>
</thead>
<tbody>
{reports.filter(report => report.status !== 'Closed').map(report => (
<tr key={report.id}> {/* Assuming you have an 'id' field */}
<th scope="row">{report.full_name}</th>
<td>{report.date}</td>
<td>{report.notes ? report.notes : null}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,65 @@
import React, {useState} from 'react'
import api from "../api";
export default function UserModule() {
const username = localStorage.getItem("username")
const [oldPassword, setOldPassword] = useState("")
const [newPassword1, setNewPassword1] = useState("")
const [newPassword2, setNewPassword2] = useState("")
const [loading, setLoading] = useState(false)
const handlePasswordChange = async () => {
setLoading(true);
try {
const response = await api.post(`user/password/change/`, {username, oldPassword, newPassword1, newPassword2});
if (response.status === 200) {
alert(response.data.message)
window.location.reload()
}
} catch (error) {
alert(error);
} finally {
setLoading(false);
}
};
return (
<div className="d-flex flex-column align-items-center justify-content-center p-2">
<h4>User Module for {username.charAt(0).toUpperCase() + username.slice(1)}</h4>
<div className="d-flex flex-column align-items-center justify-content-center p-2">
<h5>Change Your Password</h5>
<div className="d-flex flex-column align-items-center justify-content-center p-2">
<form>
<div className="mb-3 d-flex flex-column">
<label htmlFor="oldPassword" className="form-label">Enter current password:</label>
<input id="oldPassword" type="text" onChange={(e) => {
setOldPassword(e.target.value)
}}></input>
</div>
<div className="mb-3 d-flex flex-column">
<label htmlFor="newPassword1" className="form-label">Enter new password:</label>
<input id="newPassword1" type="password" onChange={(e) => {
setNewPassword1(e.target.value)
}}></input>
</div>
<div className="mb-3 d-flex flex-column">
<label htmlFor="newPassword2" className="form-label">Confirm new password:</label>
<input id="newPassword2" type="password" onChange={(e) => {
setNewPassword2(e.target.value)
}}></input>
</div>
<div className="mb-3 d-flex flex-column">
<button type="button" className="btn btn-secondary" onClick={handlePasswordChange}>Submit</button>
</div>
</form>
{loading ? (
<div className="spinner-border" role="status">
<span className="sr-only">Loading...</span>
</div>
) : null
}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,107 @@
import React from "react";
import {useEffect, useState} from "react";
import CloseVisitModal from "../components/CloseVisitModal.jsx";
import api from "../api.js";
import {DATE_TOGGLE} from "../constants.js";
import SuppliesModal from "../components/SuppliesModal.jsx";
export default function VisitsModule() {
const username = localStorage.getItem('username');
const [currentDate, setCurrentDate] = useState(new Date(localStorage.getItem(DATE_TOGGLE)));
const [serviceVisits, setServiceVisits] = useState([]);
const [loading, setLoading] = useState(false);
const handlePrevious = () => {
setCurrentDate(prevDate => {
const newDate = new Date(prevDate)
newDate.setDate(prevDate.getDate() - 1)
localStorage.setItem(DATE_TOGGLE, newDate.toLocaleDateString())
return newDate
})
}
const handleNext = () => {
setCurrentDate(prevDate => {
const newDate = new Date(prevDate)
newDate.setDate(prevDate.getDate() + 1)
localStorage.setItem(DATE_TOGGLE, newDate.toLocaleDateString())
return newDate
})
}
const fetchServiceVisits = async () => {
setLoading(true);
try {
const date = currentDate.toLocaleDateString('en-CA', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).replace(/\//g, '-');
const response = await api.post(`visits/`, {date, username});
if (response.status === 200) {
setServiceVisits(response.data);
}
} catch (error) {
alert(error);
} finally {
setLoading(false);
}
};
const formattedDate = currentDate.toLocaleDateString(
'en-US', {
year: 'numeric', month: 'long', day: 'numeric'
})
useEffect(() => {
fetchServiceVisits();
}, [currentDate]);
return (
<div className="d-flex flex-column">
<div className="d-flex align-items-center justify-content-center p-2">
<h4>Service Visits Module</h4>
</div>
<div className="d-flex align-items-center justify-content-evenly p-2">
<button onClick={handlePrevious} className="btn btn-secondary" type="button"
data-bs-target="#serviceVisitsCarousel">Prev
</button>
<h5>{formattedDate}</h5>
<button onClick={handleNext} className='btn btn-secondary'
data-bs-target="#serviceVisitsCarousel">Next
</button>
</div>
<div id="serviceVisitsCarousel" className="carousel slide d-flex align-items-center">
<div className="carousel-inner flex-grow-1">
<div className="carousel-item active">
<div className='d-flex p-2 flex-column align-items-center justify-content-center'>
{loading ? <div className="d-flex align-items-center"><h4 className="m-2 p-2">Loading Visits...</h4><div className="m-2 spinner-border"></div></div> : (
<table className="table">
<thead>
<tr>
<th scope="col" className="w-50">Account</th>
<th scope="col" className="w-20">Status</th>
<th scope="col" className="w-20">Notes</th>
<th scope="col" className="w-10">Action</th>
</tr>
</thead>
<tbody>
{serviceVisits.map(visit => (
<tr key={visit.id}>
<th scope="row">{visit.full_name}</th>
<td>{visit.status}</td>
<td>{visit.notes ? visit.notes : null}</td>
<td>
<div className="d-flex">
<CloseVisitModal visit={visit}/>
<SuppliesModal visit={visit}/>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
</div>
</div>
)
}

22
manage.py Normal 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', 'nexus.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()

0
nexus/__init__.py Normal file
View File

8
nexus/asgi.py Normal file
View File

@ -0,0 +1,8 @@
"""
ASGI config for nexus project.
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'nexus.settings')
application = get_asgi_application()

130
nexus/settings.py Normal file
View File

@ -0,0 +1,130 @@
import os
from datetime import timedelta
from pathlib import Path
import dj_database_url
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = Path(__file__).resolve().parent.parent
# SECURITY: Use environment variable for SECRET_KEY in production
SECRET_KEY = os.environ.get('SECRET_KEY', 'django-insecure-change-me-in-production')
DEBUG = os.environ.get('DEBUG', 'False').lower() == 'true'
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '*').split(',')
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework_simplejwt.authentication.JWTAuthentication"
],
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated"
]
}
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=30),
"REFRESH_TOKEN_LIFETIME": timedelta(days=1),
}
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'api.apps.ApiConfig',
'rest_framework',
'corsheaders',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
SESSION_COOKIE_AGE = 3600
SESSION_EXPIRE_AT_BROWSER_CLOSE = True
SESSION_ENGINE = 'django.contrib.sessions.backends.signed_cookies'
ROOT_URLCONF = 'nexus.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'nexus.wsgi.application'
# Database configuration via environment variable
DATABASES = {
'default': dj_database_url.config(
default=os.environ.get("PSQL"),
conn_max_age=600
)
}
# Redis configuration (optional)
REDIS_URL = os.environ.get('REDIS')
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'
TIME_ZONE = 'America/Detroit'
USE_I18N = True
USE_TZ = True
STATIC_URL = '/assets/'
STATIC_ROOT = os.path.join(BASE_DIR, 'static')
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# CORS Configuration
CORS_ALLOW_ALL_ORIGINS = os.environ.get('CORS_ALLOW_ALL', 'False').lower() == 'true'
CORS_ALLOWED_ORIGINS = [
origin.strip()
for origin in os.environ.get('CORS_ALLOWED_ORIGINS', 'http://localhost:5173').split(',')
if origin.strip()
]
CORS_ALLOW_CREDENTIALS = True
# CSRF Configuration
CSRF_TRUSTED_ORIGINS = [
origin.strip()
for origin in os.environ.get('CSRF_TRUSTED_ORIGINS', 'http://localhost:5173').split(',')
if origin.strip()
]
# Security settings for production
if not DEBUG:
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True

7
nexus/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('', include('api.urls')),
]

8
nexus/wsgi.py Normal file
View File

@ -0,0 +1,8 @@
"""
WSGI config for nexus project.
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'nexus.settings')
application = get_wsgi_application()

16
nginx.conf Normal file
View File

@ -0,0 +1,16 @@
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name localhost;
ssl_certificate /etc/nginx/ssl/frontend-cert.pem;
ssl_certificate_key /etc/nginx/ssl/frontend-key.pem;
ssl_protocols TLSv1.3;
ssl_prefer_server_ciphers off;
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
}

34
package.json Normal file
View File

@ -0,0 +1,34 @@
{
"name": "nexus",
"version": "1.0.0",
"description": "Field service management platform with Django REST Framework backend and React frontend",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "webpack serve --mode development",
"build": "webpack --mode production"
},
"private": true,
"dependencies": {
"@babel/core": "^7.26.0",
"@babel/preset-env": "^7.26.0",
"@babel/preset-react": "^7.25.9",
"axios": "^1.7.8",
"babel-loader": "^9.2.1",
"bootstrap": "^5.3.3",
"jwt-decode": "^4.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.0.1"
},
"devDependencies": {
"css-loader": "^7.1.2",
"file-loader": "^6.2.0",
"html-webpack-plugin": "^5.6.3",
"serve": "^14.2.4",
"style-loader": "^4.0.0",
"webpack": "^5.96.1",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.1.0"
}
}

44
requirements.txt Normal file
View File

@ -0,0 +1,44 @@
asgiref==3.8.1
beautifulsoup4==4.12.3
cachetools==5.5.0
certifi==2024.8.30
charset-normalizer==3.4.0
click==8.1.7
dj-database-url==2.3.0
Django==5.1.3
django-cors-headers==4.6.0
djangorestframework==3.15.2
djangorestframework-simplejwt==5.4.0
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
gunicorn==23.0.0
h11==0.14.0
httplib2==0.22.0
idna==3.10
oauthlib==3.2.2
packaging==24.2
proto-plus==1.25.0
protobuf==5.29.0
psycopg2-binary==2.9.10
pyasn1==0.6.1
pyasn1_modules==0.4.1
PyJWT==2.10.1
pyparsing==3.2.0
requests==2.32.3
requests-oauthlib==2.0.0
rsa==4.9
soupsieve==2.6
sqlparse==0.5.2
typing_extensions==4.12.2
uritemplate==4.1.1
urllib3==2.2.3
uvicorn==0.32.1
whitenoise==6.8.2
gspread~=6.1.4
redis~=5.2.1
python-dotenv~=1.0.1

16
templates/index.html Normal file
View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en-US">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nexus Team Portal</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
</head>
<body class="bg-dark-subtle p-3 m-0 border-0 bd-example m-0 border-0" data-bs-theme="dark">
<div class='bg-dark' id="root"></div>
</body>
</html>

49
webpack.config.js Normal file
View File

@ -0,0 +1,49 @@
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpack = require('webpack');
module.exports = {
entry: './frontend/main.jsx',
output: {
path: path.resolve(__dirname, 'static/build'),
filename: 'index.js',
},
module: {
rules: [
{
test: /\.css$/i,
use: ['style-loader', 'css-loader'],
},
{
test: /\.(png|svg|jpg|jpeg|gif)$/i,
type: 'asset/resource',
},
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use:
{
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react'],
},
},
},
],
},
plugins: [
new HtmlWebpackPlugin({
template: './templates/index.html',
}),
new webpack.DefinePlugin({
'process.env.API_URL': JSON.stringify(process.env.API_URL || 'http://localhost:8000/'),
}),
],
devServer:
{
static: './assets',
hot: true,
historyApiFallback: true,
allowedHosts: 'all',
},
};