commit e4c3e74624d6ab930302c8921f98ff73eeb0362f Author: Damien Coles Date: Mon Jan 26 09:45:31 2026 -0500 public-ready-init diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..14ab468 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..935f58f --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b6f5a49 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Dockerfile.backend b/Dockerfile.backend new file mode 100644 index 0000000..06bcf49 --- /dev/null +++ b/Dockerfile.backend @@ -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"] diff --git a/Dockerfile.frontend b/Dockerfile.frontend new file mode 100644 index 0000000..684c3f6 --- /dev/null +++ b/Dockerfile.frontend @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..0eb5774 --- /dev/null +++ b/README.md @@ -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 diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/admin.py b/api/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/api/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/api/apps.py b/api/apps.py new file mode 100644 index 0000000..66656fd --- /dev/null +++ b/api/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'api' diff --git a/api/gcalendar.py b/api/gcalendar.py new file mode 100644 index 0000000..67008da --- /dev/null +++ b/api/gcalendar.py @@ -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() diff --git a/api/gdrive.py b/api/gdrive.py new file mode 100644 index 0000000..cdfd226 --- /dev/null +++ b/api/gdrive.py @@ -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}") diff --git a/api/gmail.py b/api/gmail.py new file mode 100644 index 0000000..41473bb --- /dev/null +++ b/api/gmail.py @@ -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 diff --git a/api/gsheets.py b/api/gsheets.py new file mode 100644 index 0000000..5c50126 --- /dev/null +++ b/api/gsheets.py @@ -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 diff --git a/api/migrations/__init__.py b/api/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/models.py b/api/models.py new file mode 100644 index 0000000..1a89857 --- /dev/null +++ b/api/models.py @@ -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' diff --git a/api/redis/__init__.py b/api/redis/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/redis/client.py b/api/redis/client.py new file mode 100644 index 0000000..ca46118 --- /dev/null +++ b/api/redis/client.py @@ -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!') diff --git a/api/serializers.py b/api/serializers.py new file mode 100644 index 0000000..a784875 --- /dev/null +++ b/api/serializers.py @@ -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'] diff --git a/api/tests.py b/api/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/api/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/api/urls.py b/api/urls.py new file mode 100644 index 0000000..5deceac --- /dev/null +++ b/api/urls.py @@ -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//', views.AccountServiceDaysView.as_view(), name='service_days'), + + # Stores + path('stores/', views.StoresView.as_view(), name='stores'), + path('stores//', 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'), +] diff --git a/api/views.py b/api/views.py new file mode 100644 index 0000000..dcd93bb --- /dev/null +++ b/api/views.py @@ -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""" + + +

{team.capitalize()} says we need the following at {account}:

+
    + {''.join(f'
  • {item}
  • ' for item in supply_items)} +
+

Additional Notes:

+
  • {notes if notes else 'None'}
+

Thank You!

+

Dispatch

+ + + """ + + 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) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0293222 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/frontend/App.jsx b/frontend/App.jsx new file mode 100644 index 0000000..7a6b73a --- /dev/null +++ b/frontend/App.jsx @@ -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 ( +
+ + + } /> + + + + + } /> + + + + + } /> + + + + + } /> + + + + + } /> + + + + + } /> + + + + + } /> + + +
+ ) +} + +export default App diff --git a/frontend/api.js b/frontend/api.js new file mode 100644 index 0000000..360e16b --- /dev/null +++ b/frontend/api.js @@ -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 diff --git a/frontend/components/CloseVisitModal.jsx b/frontend/components/CloseVisitModal.jsx new file mode 100644 index 0000000..6e87b8f --- /dev/null +++ b/frontend/components/CloseVisitModal.jsx @@ -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 ( +
+ + +
+ ) +} diff --git a/frontend/components/LoginForm.jsx b/frontend/components/LoginForm.jsx new file mode 100644 index 0000000..c3b7bee --- /dev/null +++ b/frontend/components/LoginForm.jsx @@ -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 ( +
+
+

Team Member Login

+ {setUsername(e.target.value)}} + placeholder="Username"/> + {setPassword(e.target.value)}} + placeholder="Password" + /> +
+ + {loading &&
} +
+ +
+
+ ) +} + +export default LoginForm diff --git a/frontend/components/Navbar.jsx b/frontend/components/Navbar.jsx new file mode 100644 index 0000000..c82aadf --- /dev/null +++ b/frontend/components/Navbar.jsx @@ -0,0 +1,39 @@ +import React from "react" +import {Link, NavLink} from "react-router-dom"; + +export default function Navbar() { + return ( + + ) +} diff --git a/frontend/components/ProjectCloseModal.jsx b/frontend/components/ProjectCloseModal.jsx new file mode 100644 index 0000000..20b0dec --- /dev/null +++ b/frontend/components/ProjectCloseModal.jsx @@ -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 ( +
+ + +
+ ) +} diff --git a/frontend/components/ProjectCreateModal.jsx b/frontend/components/ProjectCreateModal.jsx new file mode 100644 index 0000000..7a5e8e7 --- /dev/null +++ b/frontend/components/ProjectCreateModal.jsx @@ -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 ( +
+ + +
+ ) +} diff --git a/frontend/components/ProjectPunchlistModal.jsx b/frontend/components/ProjectPunchlistModal.jsx new file mode 100644 index 0000000..8c570d0 --- /dev/null +++ b/frontend/components/ProjectPunchlistModal.jsx @@ -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 ( +
+ + +
+ ) +} diff --git a/frontend/components/ProtectedRoute.jsx b/frontend/components/ProtectedRoute.jsx new file mode 100644 index 0000000..e9fad5b --- /dev/null +++ b/frontend/components/ProtectedRoute.jsx @@ -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
Loading...
+ } + return isAuthorized ? children : +} + +export default ProtectedRoute diff --git a/frontend/components/SuppliesModal.jsx b/frontend/components/SuppliesModal.jsx new file mode 100644 index 0000000..ab25b96 --- /dev/null +++ b/frontend/components/SuppliesModal.jsx @@ -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 ( +
+ + +
+ ) +} diff --git a/frontend/constants.js b/frontend/constants.js new file mode 100644 index 0000000..a6c64f2 --- /dev/null +++ b/frontend/constants.js @@ -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/' diff --git a/frontend/main.jsx b/frontend/main.jsx new file mode 100644 index 0000000..77585a6 --- /dev/null +++ b/frontend/main.jsx @@ -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( + + + , +) diff --git a/frontend/modules/AccountsModule.jsx b/frontend/modules/AccountsModule.jsx new file mode 100644 index 0000000..c2d64bd --- /dev/null +++ b/frontend/modules/AccountsModule.jsx @@ -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 ( +
+
+

Account Information

+
+ {loading ?

Loading accounts...

: +
+
+

Active Accounts

+ {activeAccounts.map(loc => ( +
+

+ +

+
+
+

{loc.street_address}, {loc.city}, {loc.state} {loc.zip_code}

+ {loadingDays ?

Loading service days...

: serviceDays.map(schedule => ( +
+ {schedule.mon_serv === true ?

Monday

: null} + {schedule.tues_serv === true ?

Tuesday

: null} + {schedule.wed_serv === true ?

Wednesday

: null} + {schedule.thurs_serv === true ?

Thursday

: null} + {schedule.fri_serv === true ?

Friday

: null} + {schedule.sat_serv === true ?

Saturday

: null} + {schedule.sun_serv === true ?

Sunday

: null} + {schedule.weekend_serv === true ?

Friday with weekend option

: null} + {schedule.exception_serv === true ?

Schedule exceptions exist - see scope of work

: null} +
+ ))} +
+
+
+ ))} +
+
+

Inactive Accounts

+ {inactiveAccounts.map(loc => ( +
+

+ +

+
+
+

{loc.street_address}, {loc.city}, {loc.state} {loc.zip_code}

+ {loadingDays ?

Loading service days...

: serviceDays.map(schedule => ( +
+ {schedule.mon_serv === true ?

Monday

: null} + {schedule.tues_serv === true ?

Tuesday

: null} + {schedule.wed_serv === true ?

Wednesday

: null} + {schedule.thurs_serv === true ?

Thursday

: null} + {schedule.fri_serv === true ?

Friday

: null} + {schedule.sat_serv === true ?

Saturday

: null} + {schedule.sun_serv === true ?

Sunday

: null} + {schedule.weekend_serv === true ?

Friday with weekend option

: null} + {schedule.exception_serv === true ?

Schedule exceptions exist - see scope of work

: null} +
+ ))} +
+
+
+ ))} +
+
+ } +
+ ) +} diff --git a/frontend/modules/HomeModule.jsx b/frontend/modules/HomeModule.jsx new file mode 100644 index 0000000..ab2b272 --- /dev/null +++ b/frontend/modules/HomeModule.jsx @@ -0,0 +1,9 @@ +import React from 'react' + +export default function HomeModule() { + return ( +
+

Choose an option from the menu to get started!

+
+ ) +} diff --git a/frontend/modules/LoginModule.jsx b/frontend/modules/LoginModule.jsx new file mode 100644 index 0000000..5caf4b3 --- /dev/null +++ b/frontend/modules/LoginModule.jsx @@ -0,0 +1,15 @@ +import React from 'react' +import LoginForm from '../components/LoginForm.jsx' + +export default function LoginModule() { + return ( +
+
+
+

Nexus Team Portal

+
+ +
+
+ ) +} diff --git a/frontend/modules/ProjectsModule.jsx b/frontend/modules/ProjectsModule.jsx new file mode 100644 index 0000000..f0d58c1 --- /dev/null +++ b/frontend/modules/ProjectsModule.jsx @@ -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 ( +
+
+

Upcoming

+
+ {loadingProjects ?

Loading Projects...

: +
+ {projects.filter(proj => proj.status === 'Open').map(proj => ( +
+

+ +

+
+
+
+ {proj.punchlist === null ? : null} + {proj.status === 'Open' ? : null} +
+
+
+
+ ))} +
} +
+

Stores

+
+ {loading ?

Loading Stores...

: +
+ {stores.map(loc => ( +
+

+ +

+
+
+

{loc.street_address}, {loc.city}, {loc.state} {loc.zip_code}

+

Entity: {loc.entity}

+

Phone: {loc.phone}

+

Store Contact: {loc.store_contact}

+

Store Email: {loc.store_contact_email}

+

Supervisor Contact: {loc.super_contact}

+

Supervisor Email: {loc.super_contact_email}

+ +
+
+
+ ))} +
+ } +
+ ) +} diff --git a/frontend/modules/ReportsModule.jsx b/frontend/modules/ReportsModule.jsx new file mode 100644 index 0000000..4976d37 --- /dev/null +++ b/frontend/modules/ReportsModule.jsx @@ -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 ( +
+
+

Reports Module

+
+
+
+ + + + +
+ {loading ?

Loading Reports...

: ( +
+

Closed Visits Detail

+ + + + + + + + + + + {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]) => ( + + + + + + + ) + )} + + + + + +
AccountVisitsRateTotal
{count.fullName}{count.closedVisits}{loadingRates ? "Loading..." : (rates[short_name] || "N/A")}{loadingRates ? "Loading..." : rates[short_name] !== "N/A" ? count.closedVisits * rates[short_name] : "N/A"}
Grand Total:{grandTotal}
+

Open Visits Detail

+ + + + + + + + + + {reports.filter(report => report.status !== 'Closed').map(report => ( + {/* Assuming you have an 'id' field */} + + + + + ))} + +
AccountDateNotes
{report.full_name}{report.date}{report.notes ? report.notes : null}
+
+ )} +
+
+ ) +} diff --git a/frontend/modules/UserModule.jsx b/frontend/modules/UserModule.jsx new file mode 100644 index 0000000..77d25f9 --- /dev/null +++ b/frontend/modules/UserModule.jsx @@ -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 ( +
+

User Module for {username.charAt(0).toUpperCase() + username.slice(1)}

+
+
Change Your Password
+
+
+
+ + { + setOldPassword(e.target.value) + }}> +
+
+ + { + setNewPassword1(e.target.value) + }}> +
+
+ + { + setNewPassword2(e.target.value) + }}> +
+
+ +
+
+ {loading ? ( +
+ Loading... +
+ ) : null + } +
+
+
+ ) +} diff --git a/frontend/modules/VisitsModule.jsx b/frontend/modules/VisitsModule.jsx new file mode 100644 index 0000000..7452dbf --- /dev/null +++ b/frontend/modules/VisitsModule.jsx @@ -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 ( +
+
+

Service Visits Module

+
+
+ +
{formattedDate}
+ +
+
+
+
+
+ {loading ?

Loading Visits...

: ( + + + + + + + + + + + {serviceVisits.map(visit => ( + + + + + + + ))} + +
AccountStatusNotesAction
{visit.full_name}{visit.status}{visit.notes ? visit.notes : null} +
+ + +
+
+ )} +
+
+
+
+
+ ) +} diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..aafe799 --- /dev/null +++ b/manage.py @@ -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() diff --git a/nexus/__init__.py b/nexus/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nexus/asgi.py b/nexus/asgi.py new file mode 100644 index 0000000..fcc1e70 --- /dev/null +++ b/nexus/asgi.py @@ -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() diff --git a/nexus/settings.py b/nexus/settings.py new file mode 100644 index 0000000..f5fa80f --- /dev/null +++ b/nexus/settings.py @@ -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 diff --git a/nexus/urls.py b/nexus/urls.py new file mode 100644 index 0000000..67d7a76 --- /dev/null +++ b/nexus/urls.py @@ -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')), +] diff --git a/nexus/wsgi.py b/nexus/wsgi.py new file mode 100644 index 0000000..a2d10bd --- /dev/null +++ b/nexus/wsgi.py @@ -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() diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..388b771 --- /dev/null +++ b/nginx.conf @@ -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; + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f563fc0 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ec75547 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..7cced6e --- /dev/null +++ b/templates/index.html @@ -0,0 +1,16 @@ + + + + + + + Nexus Team Portal + + + + + +
+ + + diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..f5989d2 --- /dev/null +++ b/webpack.config.js @@ -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', + }, +};