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