public-ready-init
This commit is contained in:
commit
e4c3e74624
31
.env.example
Normal file
31
.env.example
Normal 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
16
.gitignore
vendored
Normal 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
21
Dockerfile
Normal 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
24
Dockerfile.backend
Normal 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
22
Dockerfile.frontend
Normal 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
176
README.md
Normal 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
0
api/__init__.py
Normal file
3
api/admin.py
Normal file
3
api/admin.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
6
api/apps.py
Normal file
6
api/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ApiConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'api'
|
||||||
50
api/gcalendar.py
Normal file
50
api/gcalendar.py
Normal 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
142
api/gdrive.py
Normal 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
76
api/gmail.py
Normal 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
83
api/gsheets.py
Normal 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
|
||||||
0
api/migrations/__init__.py
Normal file
0
api/migrations/__init__.py
Normal file
109
api/models.py
Normal file
109
api/models.py
Normal 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
0
api/redis/__init__.py
Normal file
83
api/redis/client.py
Normal file
83
api/redis/client.py
Normal 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
61
api/serializers.py
Normal 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
3
api/tests.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
40
api/urls.py
Normal file
40
api/urls.py
Normal 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
385
api/views.py
Normal 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
23
docker-compose.yml
Normal 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
61
frontend/App.jsx
Normal 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
21
frontend/api.js
Normal 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
|
||||||
59
frontend/components/CloseVisitModal.jsx
Normal file
59
frontend/components/CloseVisitModal.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
62
frontend/components/LoginForm.jsx
Normal file
62
frontend/components/LoginForm.jsx
Normal 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
|
||||||
39
frontend/components/Navbar.jsx
Normal file
39
frontend/components/Navbar.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
61
frontend/components/ProjectCloseModal.jsx
Normal file
61
frontend/components/ProjectCloseModal.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
206
frontend/components/ProjectCreateModal.jsx
Normal file
206
frontend/components/ProjectCreateModal.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
582
frontend/components/ProjectPunchlistModal.jsx
Normal file
582
frontend/components/ProjectPunchlistModal.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
58
frontend/components/ProtectedRoute.jsx
Normal file
58
frontend/components/ProtectedRoute.jsx
Normal 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
|
||||||
169
frontend/components/SuppliesModal.jsx
Normal file
169
frontend/components/SuppliesModal.jsx
Normal 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
7
frontend/constants.js
Normal 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
12
frontend/main.jsx
Normal 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>,
|
||||||
|
)
|
||||||
151
frontend/modules/AccountsModule.jsx
Normal file
151
frontend/modules/AccountsModule.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
9
frontend/modules/HomeModule.jsx
Normal file
9
frontend/modules/HomeModule.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
15
frontend/modules/LoginModule.jsx
Normal file
15
frontend/modules/LoginModule.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
101
frontend/modules/ProjectsModule.jsx
Normal file
101
frontend/modules/ProjectsModule.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
243
frontend/modules/ReportsModule.jsx
Normal file
243
frontend/modules/ReportsModule.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
65
frontend/modules/UserModule.jsx
Normal file
65
frontend/modules/UserModule.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
107
frontend/modules/VisitsModule.jsx
Normal file
107
frontend/modules/VisitsModule.jsx
Normal 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
22
manage.py
Normal 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
0
nexus/__init__.py
Normal file
8
nexus/asgi.py
Normal file
8
nexus/asgi.py
Normal 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
130
nexus/settings.py
Normal 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
7
nexus/urls.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.urls import path, include
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('admin/', admin.site.urls),
|
||||||
|
path('', include('api.urls')),
|
||||||
|
]
|
||||||
8
nexus/wsgi.py
Normal file
8
nexus/wsgi.py
Normal 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
16
nginx.conf
Normal 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
34
package.json
Normal 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
44
requirements.txt
Normal 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
16
templates/index.html
Normal 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
49
webpack.config.js
Normal 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',
|
||||||
|
},
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user