Job Management CRUD
This commit is contained in:
parent
1dd37b9eac
commit
50d8918174
Binary file not shown.
Binary file not shown.
BIN
core/__pycache__/forms.cpython-311.pyc
Normal file
BIN
core/__pycache__/forms.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
core/__pycache__/signals.cpython-311.pyc
Normal file
BIN
core/__pycache__/signals.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,3 +1,35 @@
|
||||
from django.contrib import admin
|
||||
from .models import Company, Profile, JobStatus, RequiredFolder, Job, JobFolderCompletion, JobFile
|
||||
|
||||
# Register your models here.
|
||||
@admin.register(Company)
|
||||
class CompanyAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'is_uprn_required', 'created_at')
|
||||
|
||||
@admin.register(Profile)
|
||||
class ProfileAdmin(admin.ModelAdmin):
|
||||
list_display = ('user', 'company', 'role')
|
||||
list_filter = ('role', 'company')
|
||||
|
||||
@admin.register(JobStatus)
|
||||
class JobStatusAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'company', 'is_starting_status', 'order')
|
||||
list_filter = ('company',)
|
||||
|
||||
@admin.register(RequiredFolder)
|
||||
class RequiredFolderAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'company')
|
||||
list_filter = ('company',)
|
||||
|
||||
@admin.register(Job)
|
||||
class JobAdmin(admin.ModelAdmin):
|
||||
list_display = ('job_ref', 'company', 'status', 'postcode')
|
||||
list_filter = ('company', 'status')
|
||||
search_fields = ('job_ref', 'uprn', 'address_line_1', 'postcode')
|
||||
|
||||
@admin.register(JobFolderCompletion)
|
||||
class JobFolderCompletionAdmin(admin.ModelAdmin):
|
||||
list_display = ('job', 'folder', 'is_completed')
|
||||
|
||||
@admin.register(JobFile)
|
||||
class JobFileAdmin(admin.ModelAdmin):
|
||||
list_display = ('job', 'folder', 'file', 'uploaded_at')
|
||||
@ -1,6 +1,8 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'core'
|
||||
|
||||
def ready(self):
|
||||
import core.signals
|
||||
67
core/forms.py
Normal file
67
core/forms.py
Normal file
@ -0,0 +1,67 @@
|
||||
from django import forms
|
||||
from .models import Company, JobStatus, RequiredFolder, Job
|
||||
|
||||
class CompanyForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Company
|
||||
fields = ['name', 'is_uprn_required']
|
||||
widgets = {
|
||||
'name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g. Acme Repairs Ltd'}),
|
||||
'is_uprn_required': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
}
|
||||
|
||||
class JobStatusForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = JobStatus
|
||||
fields = ['name', 'is_starting_status']
|
||||
widgets = {
|
||||
'name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Status Name'}),
|
||||
'is_starting_status': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
}
|
||||
|
||||
class RequiredFolderForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = RequiredFolder
|
||||
fields = ['name']
|
||||
widgets = {
|
||||
'name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Folder Name (e.g. Photos)'}),
|
||||
}
|
||||
|
||||
class JobForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Job
|
||||
fields = ['job_ref', 'uprn', 'address_line_1', 'address_line_2', 'address_line_3', 'postcode', 'description', 'action', 'notes', 'status']
|
||||
widgets = {
|
||||
'job_ref': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'uprn': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'address_line_1': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'address_line_2': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'address_line_3': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'postcode': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||
'action': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||
'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||
'status': forms.Select(attrs={'class': 'form-control'}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
company = kwargs.pop('company', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
if company:
|
||||
self.fields['status'].queryset = JobStatus.objects.filter(company=company)
|
||||
# Set initial status to starting status if creating new job
|
||||
if not self.instance.pk:
|
||||
starting_status = JobStatus.objects.filter(company=company, is_starting_status=True).first()
|
||||
if starting_status:
|
||||
self.fields['status'].initial = starting_status
|
||||
|
||||
if company.is_uprn_required:
|
||||
self.fields['uprn'].required = True
|
||||
else:
|
||||
self.fields['uprn'].required = False
|
||||
|
||||
def clean_uprn(self):
|
||||
uprn = self.cleaned_data.get('uprn')
|
||||
if not uprn:
|
||||
return None
|
||||
return uprn
|
||||
102
core/migrations/0001_initial.py
Normal file
102
core/migrations/0001_initial.py
Normal file
@ -0,0 +1,102 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-21 22:39
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Company',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('is_uprn_required', models.BooleanField(default=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='JobStatus',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('is_starting_status', models.BooleanField(default=False)),
|
||||
('order', models.PositiveIntegerField(default=0)),
|
||||
('company', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='statuses', to='core.company')),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': 'Job Statuses',
|
||||
'ordering': ['order'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Job',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('job_ref', models.CharField(max_length=100)),
|
||||
('uprn', models.CharField(blank=True, max_length=100, null=True)),
|
||||
('address_line_1', models.CharField(max_length=255)),
|
||||
('address_line_2', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('address_line_3', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('postcode', models.CharField(max_length=20)),
|
||||
('description', models.TextField(blank=True, null=True)),
|
||||
('action', models.TextField(blank=True, null=True)),
|
||||
('notes', models.TextField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('company', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='core.company')),
|
||||
('status', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='jobs', to='core.jobstatus')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('company', 'job_ref'), ('company', 'uprn')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Profile',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('role', models.CharField(choices=[('ADMIN', 'Admin'), ('STANDARD', 'Standard User')], default='STANDARD', max_length=20)),
|
||||
('company', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='users', to='core.company')),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RequiredFolder',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('company', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='required_folders', to='core.company')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='JobFile',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('file', models.FileField(upload_to='job_files/')),
|
||||
('uploaded_at', models.DateTimeField(auto_now_add=True)),
|
||||
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='core.job')),
|
||||
('uploaded_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||
('folder', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='core.requiredfolder')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='JobFolderCompletion',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('is_completed', models.BooleanField(default=False)),
|
||||
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='folder_completions', to='core.job')),
|
||||
('folder', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.requiredfolder')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('job', 'folder')},
|
||||
},
|
||||
),
|
||||
]
|
||||
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
Binary file not shown.
@ -1,3 +1,82 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.validators import MinLengthValidator
|
||||
|
||||
# Create your models here.
|
||||
class Company(models.Model):
|
||||
name = models.CharField(max_length=255)
|
||||
is_uprn_required = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Profile(models.Model):
|
||||
ROLE_CHOICES = [
|
||||
('ADMIN', 'Admin'),
|
||||
('STANDARD', 'Standard User'),
|
||||
]
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
|
||||
company = models.ForeignKey(Company, on_delete=models.CASCADE, related_name='users', null=True, blank=True)
|
||||
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='STANDARD')
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.username} - {self.company.name if self.company else 'No Company'}"
|
||||
|
||||
class JobStatus(models.Model):
|
||||
company = models.ForeignKey(Company, on_delete=models.CASCADE, related_name='statuses')
|
||||
name = models.CharField(max_length=100)
|
||||
is_starting_status = models.BooleanField(default=False)
|
||||
order = models.PositiveIntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = "Job Statuses"
|
||||
ordering = ['order']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.company.name})"
|
||||
|
||||
class RequiredFolder(models.Model):
|
||||
company = models.ForeignKey(Company, on_delete=models.CASCADE, related_name='required_folders')
|
||||
name = models.CharField(max_length=100)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.company.name})"
|
||||
|
||||
class Job(models.Model):
|
||||
company = models.ForeignKey(Company, on_delete=models.CASCADE, related_name='jobs')
|
||||
job_ref = models.CharField(max_length=100)
|
||||
uprn = models.CharField(max_length=100, null=True, blank=True)
|
||||
address_line_1 = models.CharField(max_length=255)
|
||||
address_line_2 = models.CharField(max_length=255, null=True, blank=True)
|
||||
address_line_3 = models.CharField(max_length=255, null=True, blank=True)
|
||||
postcode = models.CharField(max_length=20)
|
||||
description = models.TextField(null=True, blank=True)
|
||||
action = models.TextField(null=True, blank=True)
|
||||
notes = models.TextField(null=True, blank=True)
|
||||
status = models.ForeignKey(JobStatus, on_delete=models.PROTECT, related_name='jobs')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = [['company', 'job_ref'], ['company', 'uprn']]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.job_ref} - {self.address_line_1}"
|
||||
|
||||
class JobFolderCompletion(models.Model):
|
||||
job = models.ForeignKey(Job, on_delete=models.CASCADE, related_name='folder_completions')
|
||||
folder = models.ForeignKey(RequiredFolder, on_delete=models.CASCADE)
|
||||
is_completed = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
unique_together = ['job', 'folder']
|
||||
|
||||
class JobFile(models.Model):
|
||||
job = models.ForeignKey(Job, on_delete=models.CASCADE, related_name='files')
|
||||
folder = models.ForeignKey(RequiredFolder, on_delete=models.CASCADE, related_name='files')
|
||||
file = models.FileField(upload_to='job_files/')
|
||||
uploaded_at = models.DateTimeField(auto_now_add=True)
|
||||
uploaded_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.file.name} in {self.folder.name}"
|
||||
13
core/signals.py
Normal file
13
core/signals.py
Normal file
@ -0,0 +1,13 @@
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.contrib.auth.models import User
|
||||
from .models import Profile
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def create_user_profile(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
Profile.objects.get_or_create(user=instance)
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def save_user_profile(sender, instance, **kwargs):
|
||||
instance.profile.save()
|
||||
@ -1,25 +1,146 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{% block title %}Knowledge Base{% endblock %}</title>
|
||||
{% if project_description %}
|
||||
<meta name="description" content="{{ project_description }}">
|
||||
<meta property="og:description" content="{{ project_description }}">
|
||||
<meta property="twitter:description" content="{{ project_description }}">
|
||||
{% endif %}
|
||||
{% if project_image_url %}
|
||||
<meta property="og:image" content="{{ project_image_url }}">
|
||||
<meta property="twitter:image" content="{{ project_image_url }}">
|
||||
{% endif %}
|
||||
{% load static %}
|
||||
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
|
||||
{% block head %}{% endblock %}
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}RepairsHub{% endblock %}</title>
|
||||
|
||||
<!-- SEO -->
|
||||
{% if project_description %}
|
||||
<meta name="description" content="{{ project_description }}">
|
||||
<meta property="og:description" content="{{ project_description }}">
|
||||
<meta property="twitter:description" content="{{ project_description }}">
|
||||
{% endif %}
|
||||
{% if project_image_url %}
|
||||
<meta property="og:image" content="{{ project_image_url }}">
|
||||
<meta property="twitter:image" content="{{ project_image_url }}">
|
||||
{% endif %}
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Plus+Jakarta+Sans:wght@700;800&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Bootstrap 5 -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.min.css">
|
||||
|
||||
{% load static %}
|
||||
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #FB923C;
|
||||
--primary-dark: #F97316;
|
||||
--navy-dark: #0F172A;
|
||||
--navy-light: #1E293B;
|
||||
--bg-slate: #F8FAFC;
|
||||
--text-main: #334155;
|
||||
--text-muted: #64748B;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background-color: var(--bg-slate);
|
||||
color: var(--text-main);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
font-weight: 700;
|
||||
color: var(--navy-dark);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
font-weight: 600;
|
||||
padding: 0.6rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--primary-dark);
|
||||
border-color: var(--primary-dark);
|
||||
transform: translateY(-1px);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
color: var(--navy-dark);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.brand-text span {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.card {
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
</style>
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
{% block content %}{% endblock %}
|
||||
</body>
|
||||
<nav class="navbar navbar-expand-lg sticky-top">
|
||||
<div class="container">
|
||||
<a class="navbar-brand brand-text" href="/">REPAIRS<span>HUB</span></a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto align-items-center">
|
||||
{% if user.is_authenticated %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link fw-medium" href="{% url 'dashboard' %}">Dashboard</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link fw-medium" href="{% url 'job_list' %}">Jobs</a>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle fw-medium" href="#" role="button" data-bs-toggle="dropdown">
|
||||
{{ user.username }}
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><a class="dropdown-item" href="/admin/">Django Admin</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<form action="{% url 'logout' %}" method="post">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="dropdown-item text-danger">Logout</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link fw-medium" href="{% url 'login' %}">Login</a>
|
||||
</li>
|
||||
<li class="nav-item ms-lg-3">
|
||||
<a class="btn btn-primary btn-sm" href="{% url 'register' %}">Get Started</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
</html>
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
177
core/templates/core/company_setup.html
Normal file
177
core/templates/core/company_setup.html
Normal file
@ -0,0 +1,177 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Setup Your Company - RepairsHub{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.setup-card {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.step-indicator {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 3rem;
|
||||
position: relative;
|
||||
}
|
||||
.step-indicator::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: #E2E8F0;
|
||||
z-index: 1;
|
||||
}
|
||||
.step-item {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
background: var(--bg-slate);
|
||||
padding: 0 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.step-number {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: #E2E8F0;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 8px;
|
||||
font-weight: 600;
|
||||
border: 2px solid #E2E8F0;
|
||||
}
|
||||
.step-item.active .step-number {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
.dynamic-list-item {
|
||||
background: white;
|
||||
border: 1px solid #E2E8F0;
|
||||
border-radius: 8px;
|
||||
padding: 10px 15px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="setup-card">
|
||||
<div class="text-center mb-5">
|
||||
<h1 class="display-6">Configure Your <span>RepairsHub</span></h1>
|
||||
<p class="text-muted">Finalize your company profile to start managing jobs.</p>
|
||||
</div>
|
||||
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<form method="post" id="onboardingForm">
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- Step 1: Basic Info -->
|
||||
<div class="card p-4 mb-4">
|
||||
<h5 class="mb-4"><i class="bi bi-building me-2 text-primary"></i> 1. Company Information</h5>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Company Name</label>
|
||||
{{ company_form.name }}
|
||||
</div>
|
||||
<div class="form-check form-switch mb-3">
|
||||
{{ company_form.is_uprn_required }}
|
||||
<label class="form-check-label fw-bold">Require Unique UPRN for all jobs?</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Job Statuses -->
|
||||
<div class="card p-4 mb-4">
|
||||
<h5 class="mb-4"><i class="bi bi-activity me-2 text-primary"></i> 2. Job Statuses</h5>
|
||||
<p class="small text-muted mb-3">Define the lifecycle of a repair job. One status must be the "Starting Status".</p>
|
||||
|
||||
<div id="statusList">
|
||||
<div class="dynamic-list-item">
|
||||
<input type="text" name="statuses" class="form-control" placeholder="e.g. To Be Surveyed" value="To Be Surveyed" required>
|
||||
<div class="form-check ms-3">
|
||||
<input class="form-check-input" type="radio" name="default_status" value="0" checked id="default0">
|
||||
<label class="form-check-label small" for="default0">Start</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dynamic-list-item">
|
||||
<input type="text" name="statuses" class="form-control" placeholder="e.g. Booking Required" value="Booking Required">
|
||||
<div class="form-check ms-3">
|
||||
<input class="form-check-input" type="radio" name="default_status" value="1" id="default1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="dynamic-list-item">
|
||||
<input type="text" name="statuses" class="form-control" placeholder="e.g. Completed" value="Completed">
|
||||
<div class="form-check ms-3">
|
||||
<input class="form-check-input" type="radio" name="default_status" value="2" id="default2">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" onclick="addStatus()"><i class="bi bi-plus-circle"></i> Add Status</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Required Folders -->
|
||||
<div class="card p-4 mb-4">
|
||||
<h5 class="mb-4"><i class="bi bi-folder-plus me-2 text-primary"></i> 3. Required Job Folders</h5>
|
||||
<p class="small text-muted mb-3">These folders will appear on every job for document management.</p>
|
||||
|
||||
<div id="folderList">
|
||||
<div class="dynamic-list-item">
|
||||
<input type="text" name="folders" class="form-control" placeholder="e.g. Photos" value="Photos" required>
|
||||
</div>
|
||||
<div class="dynamic-list-item">
|
||||
<input type="text" name="folders" class="form-control" placeholder="e.g. Quote" value="Quote" required>
|
||||
</div>
|
||||
<div class="dynamic-list-item">
|
||||
<input type="text" name="folders" class="form-control" placeholder="e.g. Invoices" value="Invoices">
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" onclick="addFolder()"><i class="bi bi-plus-circle"></i> Add Folder</button>
|
||||
</div>
|
||||
|
||||
<div class="text-end">
|
||||
<button type="submit" class="btn btn-primary btn-lg px-5">Complete Setup</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function addStatus() {
|
||||
const list = document.getElementById('statusList');
|
||||
const count = list.children.length;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'dynamic-list-item';
|
||||
div.innerHTML = `
|
||||
<input type="text" name="statuses" class="form-control" placeholder="New Status">
|
||||
<div class="form-check ms-3">
|
||||
<input class="form-check-input" type="radio" name="default_status" value="${count}" id="default${count}">
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm text-danger" onclick="this.parentElement.remove()"><i class="bi bi-trash"></i></button>
|
||||
`;
|
||||
list.appendChild(div);
|
||||
}
|
||||
|
||||
function addFolder() {
|
||||
const list = document.getElementById('folderList');
|
||||
const div = document.createElement('div');
|
||||
div.className = 'dynamic-list-item';
|
||||
div.innerHTML = `
|
||||
<input type="text" name="folders" class="form-control" placeholder="New Folder">
|
||||
<button type="button" class="btn btn-sm text-danger" onclick="this.parentElement.remove()"><i class="bi bi-trash"></i></button>
|
||||
`;
|
||||
list.appendChild(div);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
166
core/templates/core/dashboard.html
Normal file
166
core/templates/core/dashboard.html
Normal file
@ -0,0 +1,166 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Dashboard - RepairsHub{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.dashboard-container {
|
||||
display: flex;
|
||||
min-height: calc(100vh - 70px);
|
||||
}
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
background: var(--navy-dark);
|
||||
color: white;
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 2rem 3rem;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
.nav-link-custom {
|
||||
color: #94a3b8;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.nav-link-custom:hover, .nav-link-custom.active {
|
||||
background: var(--navy-light);
|
||||
color: white;
|
||||
}
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.stat-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="dashboard-container">
|
||||
<aside class="sidebar d-none d-lg-block">
|
||||
<div class="mb-5">
|
||||
<h6 class="text-uppercase small fw-bold text-muted mb-3 px-3">Main Menu</h6>
|
||||
<a href="{% url 'dashboard' %}" class="nav-link-custom active">
|
||||
<i class="bi bi-grid-1x2-fill"></i> Dashboard
|
||||
</a>
|
||||
<a href="{% url 'job_list' %}" class="nav-link-custom">
|
||||
<i class="bi bi-tools"></i> Jobs
|
||||
</a>
|
||||
<a href="#" class="nav-link-custom">
|
||||
<i class="bi bi-people"></i> Users
|
||||
</a>
|
||||
</div>
|
||||
<div class="mb-5">
|
||||
<h6 class="text-uppercase small fw-bold text-muted mb-3 px-3">Configuration</h6>
|
||||
<a href="{% url 'settings' %}" class="nav-link-custom">
|
||||
<i class="bi bi-gear"></i> Settings
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h2 class="mb-1">Welcome back, {{ user.username }}</h2>
|
||||
<p class="text-muted mb-0">Managing <strong>{{ company.name }}</strong></p>
|
||||
</div>
|
||||
<a href="{% url 'job_create' %}" class="btn btn-primary"><i class="bi bi-plus-lg me-2"></i>New Repair Job</a>
|
||||
</div>
|
||||
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show mb-4" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<div class="row g-4 mb-5">
|
||||
<div class="col-md-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value text-primary">{{ total_jobs }}</div>
|
||||
<div class="stat-label">Total Jobs</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">0</div>
|
||||
<div class="stat-label">Pending survey</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">0</div>
|
||||
<div class="stat-label">Critical Issues</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">0</div>
|
||||
<div class="stat-label">Completed</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white py-3 border-0 d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">Recent Repair Jobs</h5>
|
||||
<a href="{% url 'job_list' %}" class="small text-decoration-none">View All</a>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle mb-0">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th class="border-0 px-4">Job Ref</th>
|
||||
<th class="border-0">Address</th>
|
||||
<th class="border-0">Status</th>
|
||||
<th class="border-0 text-end px-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for job in jobs %}
|
||||
<tr>
|
||||
<td class="px-4 fw-bold text-primary">{{ job.job_ref }}</td>
|
||||
<td>{{ job.address_line_1 }}, {{ job.postcode }}</td>
|
||||
<td><span class="badge bg-light text-dark border">{{ job.status.name }}</span></td>
|
||||
<td class="text-end px-4">
|
||||
<a href="{% url 'job_detail' job.pk %}" class="btn btn-sm btn-outline-secondary">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center py-5 text-muted">
|
||||
<i class="bi bi-clipboard2-x d-block mb-3 fs-1"></i>
|
||||
No repair jobs found. Create your first job to get started!
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %}
|
||||
26
core/templates/core/folder_confirm_delete.html
Normal file
26
core/templates/core/folder_confirm_delete.html
Normal file
@ -0,0 +1,26 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Delete Folder{% endblock %}
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card border-0 shadow-sm border-top border-4 border-danger">
|
||||
<div class="card-body p-5 text-center">
|
||||
<div class="display-4 text-danger mb-4">
|
||||
<i class="bi bi-folder-x"></i>
|
||||
</div>
|
||||
<h2 class="h4 mb-3">Delete Folder: {{ folder.name }}?</h2>
|
||||
<p class="text-muted mb-4">Removing this required folder will hide it from <strong>all existing jobs</strong>. Uploaded files within this folder may become inaccessible.</p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit" class="btn btn-danger py-2">Confirm Removal</button>
|
||||
<a href="{% url 'settings' %}" class="btn btn-outline-secondary py-2">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
26
core/templates/core/folder_form.html
Normal file
26
core/templates/core/folder_form.html
Normal file
@ -0,0 +1,26 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Add Required Folder{% endblock %}
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="h4 mb-4">Add Required Folder</h2>
|
||||
<p class="text-muted small mb-4">Required folders are applied company-wide to every repair job.</p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-medium">Folder Name</label>
|
||||
{{ form.name }}
|
||||
{% if form.name.errors %}<div class="text-danger small">{{ form.name.errors.0 }}</div>{% endif %}
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100 py-2">Create Folder</button>
|
||||
<a href="{% url 'settings' %}" class="btn btn-link w-100 text-muted mt-2 text-decoration-none">Cancel</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -1,145 +1,146 @@
|
||||
{% extends "base.html" %}
|
||||
{% extends 'base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}{{ project_name }}{% endblock %}
|
||||
{% block title %}RepairsHub - Multi-tenant Repairs Management{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-color-start: #6a11cb;
|
||||
--bg-color-end: #2575fc;
|
||||
--text-color: #ffffff;
|
||||
--card-bg-color: rgba(255, 255, 255, 0.01);
|
||||
--card-border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'><path d='M-10 10L110 10M10 -10L10 110' stroke-width='1' stroke='rgba(255,255,255,0.05)'/></svg>");
|
||||
animation: bg-pan 20s linear infinite;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
@keyframes bg-pan {
|
||||
0% {
|
||||
background-position: 0% 0%;
|
||||
.hero-section {
|
||||
padding: 8rem 0 6rem;
|
||||
background: radial-gradient(circle at 0% 0%, rgba(251, 146, 60, 0.05) 0%, transparent 50%),
|
||||
radial-gradient(circle at 100% 100%, rgba(15, 23, 42, 0.02) 0%, transparent 50%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 100% 100%;
|
||||
.hero-shape {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
filter: blur(40px);
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card-bg-color);
|
||||
border: 1px solid var(--card-border-color);
|
||||
border-radius: 16px;
|
||||
padding: 2.5rem 2rem;
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(2.2rem, 3vw + 1.2rem, 3.2rem);
|
||||
font-weight: 700;
|
||||
margin: 0 0 1.2rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 1.1rem;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.loader {
|
||||
margin: 1.5rem auto;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border: 4px solid rgba(255, 255, 255, 0.25);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
.shape-1 {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
background: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
top: -100px;
|
||||
right: -50px;
|
||||
}
|
||||
}
|
||||
|
||||
.runtime code {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
.shape-2 {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background: var(--navy-light);
|
||||
border-radius: 30% 70% 70% 30% / 30% 30% 70% 70%;
|
||||
bottom: -50px;
|
||||
left: -50px;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
.hero-title {
|
||||
font-size: 3.5rem;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -1.5px;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
footer {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.hero-subtitle {
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-muted);
|
||||
max-width: 600px;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
padding: 2rem;
|
||||
transition: transform 0.3s ease;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background-color: rgba(251, 146, 60, 0.1);
|
||||
color: var(--primary-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main>
|
||||
<div class="card">
|
||||
<h1>Analyzing your requirements and generating your app…</h1>
|
||||
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
||||
<span class="sr-only">Loading…</span>
|
||||
<section class="hero-section">
|
||||
<div class="hero-shape shape-1"></div>
|
||||
<div class="hero-shape shape-2"></div>
|
||||
|
||||
<div class="container">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-lg-7">
|
||||
<h1 class="hero-title">Manage Repairs with <span style="color: var(--primary-color);">Total Isolation</span>.</h1>
|
||||
<p class="hero-subtitle">The multi-tenant engine for repair firms. Custom workflows, required folder structures, and automated status management—all in one secure hub.</p>
|
||||
<div class="d-flex gap-3">
|
||||
<a href="{% url 'register' %}" class="btn btn-primary btn-lg">Start Free Trial</a>
|
||||
<a href="#features" class="btn btn-outline-dark btn-lg">Explore Features</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-5 d-none d-lg-block">
|
||||
<div class="card p-4 bg-white shadow-lg border-0" style="transform: rotate(2deg);">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h5 class="mb-0">Job #RH-9021</h5>
|
||||
<span class="badge bg-warning text-dark">To Be Surveyed</span>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="text-muted small">Address</label>
|
||||
<p class="fw-bold mb-0">123 Construction Way, London</p>
|
||||
</div>
|
||||
<div class="progress mb-3" style="height: 8px;">
|
||||
<div class="progress-bar bg-success" style="width: 75%"></div>
|
||||
</div>
|
||||
<p class="small text-muted mb-0"><i class="bi bi-folder-check"></i> 3/4 Folders Completed</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">AppWizzy AI is collecting your requirements and applying the first changes.</p>
|
||||
<p class="hint">This page will refresh automatically as the plan is implemented.</p>
|
||||
<p class="runtime">
|
||||
Runtime: Django <code>{{ django_version }}</code> · Python <code>{{ python_version }}</code>
|
||||
— UTC <code>{{ current_time|date:"Y-m-d H:i:s" }}</code>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
<footer>
|
||||
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
|
||||
</footer>
|
||||
{% endblock %}
|
||||
</section>
|
||||
|
||||
<section id="features" class="py-5">
|
||||
<div class="container py-5">
|
||||
<div class="text-center mb-5">
|
||||
<h2 class="mb-3">Built for Modern Repair Firms</h2>
|
||||
<p class="text-muted">Everything you need to scale your operations securely.</p>
|
||||
</div>
|
||||
<div class="row g-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card feature-card">
|
||||
<div class="feature-icon"><i class="bi bi-shield-check"></i></div>
|
||||
<h4>Multi-Tenancy</h4>
|
||||
<p class="text-muted">Strict data isolation for every company. Your jobs, statuses, and users stay yours.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card feature-card">
|
||||
<div class="feature-icon"><i class="bi bi-diagram-3"></i></div>
|
||||
<h4>Custom Workflows</h4>
|
||||
<p class="text-muted">Define your own job statuses and documentation folders that apply to every project.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card feature-card">
|
||||
<div class="feature-icon"><i class="bi bi-file-earmark-excel"></i></div>
|
||||
<h4>Bulk Sync</h4>
|
||||
<p class="text-muted">Import and export jobs via Excel with intelligent field mapping and UPRN tracking.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
27
core/templates/core/job_confirm_delete.html
Normal file
27
core/templates/core/job_confirm_delete.html
Normal file
@ -0,0 +1,27 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Delete Job - RepairsHub{% endblock %}
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card border-0 shadow-sm border-top border-4 border-danger">
|
||||
<div class="card-body p-5 text-center">
|
||||
<div class="display-4 text-danger mb-4">
|
||||
<i class="bi bi-exclamation-octagon"></i>
|
||||
</div>
|
||||
<h2 class="h4 mb-3">Delete Repair Job?</h2>
|
||||
<p class="text-muted mb-4">You are about to delete job <strong>{{ job.job_ref }}</strong>. This will permanently remove the record and all associated data for {{ job.address_line_1 }}.</p>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit" class="btn btn-danger py-2">Yes, Permanent Delete</button>
|
||||
<a href="{% url 'job_detail' job.pk %}" class="btn btn-outline-secondary py-2">Keep Job</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
149
core/templates/core/job_detail.html
Normal file
149
core/templates/core/job_detail.html
Normal file
@ -0,0 +1,149 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Job: {{ job.job_ref }} - RepairsHub{% endblock %}
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="d-flex justify-content-between align-items-start mb-4">
|
||||
<div>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{% url 'job_list' %}" class="text-decoration-none">Jobs</a></li>
|
||||
<li class="breadcrumb-item active">{{ job.job_ref }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h1 class="h2 mb-0">{{ job.address_line_1 }}</h1>
|
||||
<p class="text-muted mb-0">{% if job.uprn %}UPRN: {{ job.uprn }} | {% endif %}Ref: {{ job.job_ref }} | {{ job.postcode }}</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{% url 'job_update' job.pk %}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-pencil"></i> Edit Job
|
||||
</a>
|
||||
<a href="{% url 'job_delete' job.pk %}" class="btn btn-outline-danger btn-sm">
|
||||
<i class="bi bi-trash"></i> Delete
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show mb-4" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-8">
|
||||
<div class="card mb-4 border-0 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<div class="row mb-5">
|
||||
<div class="col-md-6">
|
||||
<h6 class="text-uppercase text-muted smaller fw-bold mb-3">Job Status</h6>
|
||||
<div class="d-inline-flex align-items-center">
|
||||
<div class="px-4 py-2 rounded-3 bg-primary bg-opacity-10 text-primary fw-bold">
|
||||
{{ job.status.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 text-md-end">
|
||||
<h6 class="text-uppercase text-muted smaller fw-bold mb-3">Registration Date</h6>
|
||||
<p class="mb-0 fw-medium text-dark">{{ job.created_at|date:"F d, Y H:i" }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h6 class="text-uppercase text-muted smaller fw-bold mb-2">Description</h6>
|
||||
<div class="p-3 bg-light rounded-3 text-dark">
|
||||
{% if job.description %}{{ job.description|linebreaksbr }}{% else %}<span class="text-muted italic">No description provided</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h6 class="text-uppercase text-muted smaller fw-bold mb-2">Action Required</h6>
|
||||
<div class="p-3 bg-light rounded-3 text-dark">
|
||||
{% if job.action %}{{ job.action|linebreaksbr }}{% else %}<span class="text-muted italic">No actions defined</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h6 class="text-uppercase text-muted smaller fw-bold mb-2">Internal Notes</h6>
|
||||
<div class="p-3 bg-light rounded-3 text-dark border-start border-4 border-warning">
|
||||
{% if job.notes %}{{ job.notes|linebreaksbr }}{% else %}<span class="text-muted italic">No internal notes</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h4 class="h5 mb-0">Mandatory Documentation</h4>
|
||||
<span class="badge bg-light text-dark border">{{ folder_completions.count }} Folders</span>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
{% for completion in folder_completions %}
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100 border-0 shadow-sm {% if completion.is_completed %}border-start border-4 border-success{% endif %}">
|
||||
<div class="card-body d-flex justify-content-between align-items-center py-4">
|
||||
<div>
|
||||
<h6 class="mb-1 fw-bold">{{ completion.folder.name }}</h6>
|
||||
<p class="text-muted smaller mb-0">
|
||||
<i class="bi bi-file-earmark-arrow-up"></i> 0 Files
|
||||
</p>
|
||||
</div>
|
||||
<form action="{% url 'toggle_folder_completion' job.pk completion.folder.id %}" method="post">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-sm {% if completion.is_completed %}btn-success{% else %}btn-outline-secondary{% endif %} rounded-pill px-3">
|
||||
{% if completion.is_completed %}
|
||||
<i class="bi bi-check2-circle me-1"></i> Done
|
||||
{% else %}
|
||||
Mark Ready
|
||||
{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body p-4">
|
||||
<h6 class="text-uppercase text-muted smaller fw-bold mb-4">Site Location</h6>
|
||||
<div class="d-flex mb-3">
|
||||
<div class="flex-shrink-0 text-primary fs-4 me-3">
|
||||
<i class="bi bi-geo-alt"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-1 fw-bold text-dark">{{ job.address_line_1 }}</p>
|
||||
{% if job.address_line_2 %}<p class="mb-1">{{ job.address_line_2 }}</p>{% endif %}
|
||||
{% if job.address_line_3 %}<p class="mb-1">{{ job.address_line_3 }}</p>{% endif %}
|
||||
<p class="mb-0 text-dark">{{ job.postcode }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if job.uprn %}
|
||||
<div class="mt-4 pt-4 border-top border-light">
|
||||
<h6 class="text-uppercase text-muted smaller fw-bold mb-2">Unique Reference</h6>
|
||||
<div class="d-flex align-items-center bg-light px-3 py-2 rounded-2">
|
||||
<code class="text-primary fw-bold">{{ job.uprn }}</code>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm bg-navy-dark text-white" style="background-color: var(--navy-dark);">
|
||||
<div class="card-body p-4">
|
||||
<h6 class="text-uppercase text-muted smaller fw-bold mb-3">Quick Export</h6>
|
||||
<p class="small text-muted mb-4">Download this job details for offline reporting.</p>
|
||||
<button class="btn btn-primary w-100 disabled" title="Coming soon">
|
||||
<i class="bi bi-file-earmark-pdf me-2"></i> Export to PDF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
86
core/templates/core/job_form.html
Normal file
86
core/templates/core/job_form.html
Normal file
@ -0,0 +1,86 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{{ title }} - RepairsHub{% endblock %}
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="h4 mb-4">{{ title }}</h2>
|
||||
<form method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">Job Reference *</label>
|
||||
{{ form.job_ref }}
|
||||
{% if form.job_ref.errors %}<div class="text-danger small mt-1">{{ form.job_ref.errors.0 }}</div>{% endif %}
|
||||
</div>
|
||||
|
||||
{% if company.is_uprn_required %}
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">UPRN *</label>
|
||||
{{ form.uprn }}
|
||||
{% if form.uprn.errors %}<div class="text-danger small mt-1">{{ form.uprn.errors.0 }}</div>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-medium">Address Line 1 *</label>
|
||||
{{ form.address_line_1 }}
|
||||
{% if form.address_line_1.errors %}<div class="text-danger small mt-1">{{ form.address_line_1.errors.0 }}</div>{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">Address Line 2</label>
|
||||
{{ form.address_line_2 }}
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">Address Line 3</label>
|
||||
{{ form.address_line_3 }}
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">Postcode *</label>
|
||||
{{ form.postcode }}
|
||||
{% if form.postcode.errors %}<div class="text-danger small mt-1">{{ form.postcode.errors.0 }}</div>{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">Current Status</label>
|
||||
{{ form.status }}
|
||||
{% if form.status.errors %}<div class="text-danger small mt-1">{{ form.status.errors.0 }}</div>{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<hr class="my-4 text-muted opacity-25">
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-medium">Description</label>
|
||||
{{ form.description }}
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-medium">Actions Required</label>
|
||||
{{ form.action }}
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-medium">Notes</label>
|
||||
{{ form.notes }}
|
||||
</div>
|
||||
|
||||
<div class="col-12 mt-5">
|
||||
<button type="submit" class="btn btn-primary w-100 py-3">Save Repair Job</button>
|
||||
<a href="{% if form.instance.pk %}{% url 'job_detail' form.instance.pk %}{% else %}{% url 'job_list' %}{% endif %}" class="btn btn-link w-100 text-muted mt-2">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
77
core/templates/core/job_list.html
Normal file
77
core/templates/core/job_list.html
Normal file
@ -0,0 +1,77 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Jobs - {{ company.name }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="h2 mb-1">Repair Jobs</h1>
|
||||
<p class="text-muted">Manage and track all repairs for {{ company.name }}</p>
|
||||
</div>
|
||||
<a href="{% url 'job_create' %}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-lg"></i> Create New Job
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<div class="card overflow-hidden">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="ps-4">Job Ref</th>
|
||||
{% if company.is_uprn_required %}
|
||||
<th>UPRN</th>
|
||||
{% endif %}
|
||||
<th>Address</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th class="text-end pe-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for job in jobs %}
|
||||
<tr>
|
||||
<td class="ps-4 fw-semibold">{{ job.job_ref }}</td>
|
||||
{% if company.is_uprn_required %}
|
||||
<td>{{ job.uprn }}</td>
|
||||
{% endif %}
|
||||
<td>
|
||||
<div class="small">{{ job.address_line_1 }}</div>
|
||||
<div class="text-muted small">{{ job.postcode }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge rounded-pill bg-primary bg-opacity-10 text-primary px-3 py-2">
|
||||
{{ job.status.name }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-muted small">{{ job.created_at|date:"M d, Y" }}</td>
|
||||
<td class="text-end pe-4">
|
||||
<a href="{% url 'job_detail' job.pk %}" class="btn btn-sm btn-outline-secondary">View</a>
|
||||
<a href="{% url 'job_update' job.pk %}" class="btn btn-sm btn-outline-primary">Edit</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-center py-5">
|
||||
<div class="py-4">
|
||||
<i class="bi bi-tools display-4 text-muted mb-3 d-block"></i>
|
||||
<p class="text-muted">No jobs found. Start by creating your first repair job.</p>
|
||||
<a href="{% url 'job_create' %}" class="btn btn-primary btn-sm mt-2">Create Job</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
54
core/templates/core/login.html
Normal file
54
core/templates/core/login.html
Normal file
@ -0,0 +1,54 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Login - RepairsHub{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-5">
|
||||
<div class="card p-4 p-lg-5">
|
||||
<div class="text-center mb-4">
|
||||
<h2 class="brand-text">REPAIRS<span>HUB</span></h2>
|
||||
<p class="text-muted">Welcome back</p>
|
||||
</div>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{% for field in form %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-medium">{{ field.label }}</label>
|
||||
{{ field }}
|
||||
{% for error in field.errors %}
|
||||
<div class="text-danger small">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<button type="submit" class="btn btn-primary w-100 mt-3">Login</button>
|
||||
</form>
|
||||
<div class="text-center mt-4">
|
||||
<p class="text-muted small">Don't have an account? <a href="{% url 'register' %}" class="text-primary fw-bold text-decoration-none">Register</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
input[type="text"], input[type="password"] {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
color: #212529;
|
||||
background-color: #fff;
|
||||
background-clip: padding-box;
|
||||
border: 1px solid #ced4da;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
border-radius: 0.375rem;
|
||||
transition: border-color .15s ease-in-out,box-shadow .15s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
57
core/templates/core/register.html
Normal file
57
core/templates/core/register.html
Normal file
@ -0,0 +1,57 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Register - RepairsHub{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-5">
|
||||
<div class="card p-4 p-lg-5">
|
||||
<div class="text-center mb-4">
|
||||
<h2 class="brand-text">REPAIRS<span>HUB</span></h2>
|
||||
<p class="text-muted">Create your account to get started</p>
|
||||
</div>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{% for field in form %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-medium">{{ field.label }}</label>
|
||||
{{ field }}
|
||||
{% if field.help_text %}
|
||||
<div class="form-text">{{ field.help_text }}</div>
|
||||
{% endif %}
|
||||
{% for error in field.errors %}
|
||||
<div class="text-danger small">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<button type="submit" class="btn btn-primary w-100 mt-3">Register</button>
|
||||
</form>
|
||||
<div class="text-center mt-4">
|
||||
<p class="text-muted small">Already have an account? <a href="{% url 'login' %}" class="text-primary fw-bold text-decoration-none">Login</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
input[type="text"], input[type="password"], input[type="email"] {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
color: #212529;
|
||||
background-color: #fff;
|
||||
background-clip: padding-box;
|
||||
border: 1px solid #ced4da;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
border-radius: 0.375rem;
|
||||
transition: border-color .15s ease-in-out,box-shadow .15s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
65
core/templates/core/settings.html
Normal file
65
core/templates/core/settings.html
Normal file
@ -0,0 +1,65 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Settings - {{ company.name }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<h1 class="h2 mb-4">Company Settings</h1>
|
||||
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show mb-4" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body p-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h5 class="mb-0">Job Statuses</h5>
|
||||
<a href="{% url 'status_create' %}" class="btn btn-sm btn-primary">Add Status</a>
|
||||
</div>
|
||||
<div class="list-group list-group-flush">
|
||||
{% for status in statuses %}
|
||||
<div class="list-group-item px-0 d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span class="fw-medium">{{ status.name }}</span>
|
||||
{% if status.is_starting_status %}
|
||||
<span class="badge bg-success ms-2">Starting</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="d-flex align-items-center">
|
||||
<a href="{% url 'status_update' status.pk %}" class="btn btn-link btn-sm text-primary me-2"><i class="bi bi-pencil"></i></a>
|
||||
<a href="{% url 'status_delete' status.pk %}" class="btn btn-link btn-sm text-danger"><i class="bi bi-trash"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body p-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h5 class="mb-0">Required Folders</h5>
|
||||
<a href="{% url 'folder_create' %}" class="btn btn-sm btn-primary">Add Folder</a>
|
||||
</div>
|
||||
<div class="list-group list-group-flush">
|
||||
{% for folder in folders %}
|
||||
<div class="list-group-item px-0 d-flex justify-content-between align-items-center">
|
||||
<span class="fw-medium">{{ folder.name }}</span>
|
||||
<a href="{% url 'folder_delete' folder.pk %}" class="btn btn-link btn-sm text-danger"><i class="bi bi-trash"></i></a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="text-muted small mt-3"><i class="bi bi-info-circle me-1"></i> New folders will appear on all existing and future jobs.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
26
core/templates/core/status_confirm_delete.html
Normal file
26
core/templates/core/status_confirm_delete.html
Normal file
@ -0,0 +1,26 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Delete Status{% endblock %}
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card border-0 shadow-sm border-top border-4 border-danger">
|
||||
<div class="card-body p-5 text-center">
|
||||
<div class="display-4 text-danger mb-4">
|
||||
<i class="bi bi-exclamation-circle"></i>
|
||||
</div>
|
||||
<h2 class="h4 mb-3">Delete Status: {{ status.name }}?</h2>
|
||||
<p class="text-muted mb-4">Deleting this status will cause all associated jobs to be reassigned to the <strong>Starting Status</strong>. This action is permanent.</p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit" class="btn btn-danger py-2">Confirm Delete & Reassign</button>
|
||||
<a href="{% url 'settings' %}" class="btn btn-outline-secondary py-2">Keep Status</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
31
core/templates/core/status_form.html
Normal file
31
core/templates/core/status_form.html
Normal file
@ -0,0 +1,31 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="h4 mb-4">{{ title }}</h2>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-medium">Status Name</label>
|
||||
{{ form.name }}
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<div class="form-check">
|
||||
{{ form.is_starting_status }}
|
||||
<label class="form-check-label fw-medium">Set as Starting Status</label>
|
||||
</div>
|
||||
<small class="text-muted">New jobs will be automatically assigned this status.</small>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">Save Status</button>
|
||||
<a href="{% url 'settings' %}" class="btn btn-link w-100 text-muted mt-2 text-decoration-none">Cancel</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
28
core/urls.py
28
core/urls.py
@ -1,7 +1,27 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import home
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path("", home, name="home"),
|
||||
]
|
||||
path("", views.home, name="home"),
|
||||
path("register/", views.register_view, name="register"),
|
||||
path("login/", views.login_view, name="login"),
|
||||
path("logout/", views.logout_view, name="logout"),
|
||||
path("setup/", views.company_setup, name="company_setup"),
|
||||
path("dashboard/", views.dashboard, name="dashboard"),
|
||||
|
||||
# Job CRUD
|
||||
path("jobs/", views.job_list, name="job_list"),
|
||||
path("jobs/create/", views.job_create, name="job_create"),
|
||||
path("jobs/<int:pk>/", views.job_detail, name="job_detail"),
|
||||
path("jobs/<int:pk>/edit/", views.job_update, name="job_update"),
|
||||
path("jobs/<int:pk>/delete/", views.job_delete, name="job_delete"),
|
||||
path("jobs/<int:pk>/toggle-folder/<int:folder_id>/", views.toggle_folder_completion, name="toggle_folder_completion"),
|
||||
|
||||
# Settings
|
||||
path("settings/", views.settings_view, name="settings"),
|
||||
path("settings/status/create/", views.status_create, name="status_create"),
|
||||
path("settings/status/<int:pk>/edit/", views.status_update, name="status_update"),
|
||||
path("settings/status/<int:pk>/delete/", views.status_delete, name="status_delete"),
|
||||
path("settings/folder/create/", views.folder_create, name="folder_create"),
|
||||
path("settings/folder/<int:pk>/delete/", views.folder_delete, name="folder_delete"),
|
||||
]
|
||||
361
core/views.py
361
core/views.py
@ -1,25 +1,350 @@
|
||||
import os
|
||||
import platform
|
||||
|
||||
from django import get_version as django_version
|
||||
from django.shortcuts import render
|
||||
from django.utils import timezone
|
||||
|
||||
from django.shortcuts import render, redirect, get_object_or_404
|
||||
from django.contrib.auth import login, logout, authenticate
|
||||
from django.contrib.auth.forms import UserCreationForm, AuthenticationForm
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib import messages
|
||||
from django.db import transaction
|
||||
from .models import Company, Profile, JobStatus, RequiredFolder, Job, JobFolderCompletion
|
||||
from .forms import CompanyForm, JobStatusForm, RequiredFolderForm, JobForm
|
||||
|
||||
def home(request):
|
||||
"""Render the landing screen with loader and environment details."""
|
||||
host_name = request.get_host().lower()
|
||||
agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic"
|
||||
now = timezone.now()
|
||||
|
||||
if request.user.is_authenticated:
|
||||
return redirect('dashboard')
|
||||
|
||||
context = {
|
||||
"project_name": "New Style",
|
||||
"agent_brand": agent_brand,
|
||||
"django_version": django_version(),
|
||||
"python_version": platform.python_version(),
|
||||
"current_time": now,
|
||||
"host_name": host_name,
|
||||
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
|
||||
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
|
||||
"project_name": "RepairsHub",
|
||||
}
|
||||
return render(request, "core/index.html", context)
|
||||
|
||||
def register_view(request):
|
||||
if request.method == 'POST':
|
||||
form = UserCreationForm(request.POST)
|
||||
if form.is_valid():
|
||||
user = form.save()
|
||||
login(request, user)
|
||||
return redirect('company_setup')
|
||||
else:
|
||||
form = UserCreationForm()
|
||||
return render(request, 'core/register.html', {'form': form})
|
||||
|
||||
def login_view(request):
|
||||
if request.method == 'POST':
|
||||
form = AuthenticationForm(data=request.POST)
|
||||
if form.is_valid():
|
||||
user = form.get_user()
|
||||
login(request, user)
|
||||
return redirect('dashboard')
|
||||
else:
|
||||
form = AuthenticationForm()
|
||||
return render(request, 'core/login.html', {'form': form})
|
||||
|
||||
def logout_view(request):
|
||||
logout(request)
|
||||
return redirect('home')
|
||||
|
||||
@login_required
|
||||
def company_setup(request):
|
||||
# Check if user already has a company
|
||||
if request.user.profile.company:
|
||||
return redirect('dashboard')
|
||||
|
||||
if request.method == 'POST':
|
||||
company_form = CompanyForm(request.POST)
|
||||
status_names = request.POST.getlist('statuses')
|
||||
default_status_idx = request.POST.get('default_status')
|
||||
folder_names = request.POST.getlist('folders')
|
||||
|
||||
if company_form.is_valid() and status_names and default_status_idx is not None:
|
||||
with transaction.atomic():
|
||||
company = company_form.save()
|
||||
|
||||
# Link user to company as ADMIN
|
||||
profile = request.user.profile
|
||||
profile.company = company
|
||||
profile.role = 'ADMIN'
|
||||
profile.save()
|
||||
|
||||
# Create statuses
|
||||
for i, name in enumerate(status_names):
|
||||
if name.strip():
|
||||
JobStatus.objects.create(
|
||||
company=company,
|
||||
name=name.strip(),
|
||||
is_starting_status=(str(i) == default_status_idx),
|
||||
order=i
|
||||
)
|
||||
|
||||
# Create folders
|
||||
for name in folder_names:
|
||||
if name.strip():
|
||||
RequiredFolder.objects.create(
|
||||
company=company,
|
||||
name=name.strip()
|
||||
)
|
||||
|
||||
messages.success(request, f"Company {company.name} created successfully!")
|
||||
return redirect('dashboard')
|
||||
else:
|
||||
if not status_names:
|
||||
messages.error(request, "At least one status is required.")
|
||||
if default_status_idx is None:
|
||||
messages.error(request, "You must select a starting status.")
|
||||
else:
|
||||
company_form = CompanyForm()
|
||||
|
||||
return render(request, 'core/company_setup.html', {
|
||||
'company_form': company_form,
|
||||
})
|
||||
|
||||
@login_required
|
||||
def dashboard(request):
|
||||
profile = request.user.profile
|
||||
if not profile.company:
|
||||
return redirect('company_setup')
|
||||
|
||||
company = profile.company
|
||||
jobs = Job.objects.filter(company=company)
|
||||
|
||||
context = {
|
||||
'company': company,
|
||||
'total_jobs': jobs.count(),
|
||||
'jobs': jobs.order_by('-created_at')[:5], # Recent jobs
|
||||
}
|
||||
return render(request, 'core/dashboard.html', context)
|
||||
|
||||
@login_required
|
||||
def job_list(request):
|
||||
profile = request.user.profile
|
||||
if not profile.company:
|
||||
return redirect('company_setup')
|
||||
|
||||
company = profile.company
|
||||
jobs = Job.objects.filter(company=company).order_by('-created_at')
|
||||
|
||||
return render(request, 'core/job_list.html', {
|
||||
'jobs': jobs,
|
||||
'company': company
|
||||
})
|
||||
|
||||
@login_required
|
||||
def job_create(request):
|
||||
profile = request.user.profile
|
||||
if not profile.company:
|
||||
return redirect('company_setup')
|
||||
|
||||
company = profile.company
|
||||
|
||||
if request.method == 'POST':
|
||||
form = JobForm(request.POST, company=company)
|
||||
if form.is_valid():
|
||||
job = form.save(commit=False)
|
||||
job.company = company
|
||||
job.save()
|
||||
|
||||
# Initialize folder completions
|
||||
for folder in company.required_folders.all():
|
||||
JobFolderCompletion.objects.get_or_create(job=job, folder=folder)
|
||||
|
||||
messages.success(request, f"Job {job.job_ref} created successfully!")
|
||||
return redirect('job_detail', pk=job.pk)
|
||||
else:
|
||||
form = JobForm(company=company)
|
||||
|
||||
return render(request, 'core/job_form.html', {
|
||||
'form': form,
|
||||
'title': 'Create New Job',
|
||||
'company': company
|
||||
})
|
||||
|
||||
@login_required
|
||||
def job_detail(request, pk):
|
||||
profile = request.user.profile
|
||||
if not profile.company:
|
||||
return redirect('company_setup')
|
||||
|
||||
company = profile.company
|
||||
job = get_object_or_404(Job, pk=pk, company=company)
|
||||
|
||||
# Ensure all required folders have completion records (in case new ones were added later)
|
||||
for folder in company.required_folders.all():
|
||||
JobFolderCompletion.objects.get_or_create(job=job, folder=folder)
|
||||
|
||||
folder_completions = job.folder_completions.all().select_related('folder')
|
||||
|
||||
return render(request, 'core/job_detail.html', {
|
||||
'job': job,
|
||||
'folder_completions': folder_completions,
|
||||
'company': company
|
||||
})
|
||||
|
||||
@login_required
|
||||
def job_update(request, pk):
|
||||
profile = request.user.profile
|
||||
if not profile.company:
|
||||
return redirect('company_setup')
|
||||
|
||||
company = profile.company
|
||||
job = get_object_or_404(Job, pk=pk, company=company)
|
||||
|
||||
if request.method == 'POST':
|
||||
form = JobForm(request.POST, instance=job, company=company)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, f"Job {job.job_ref} updated successfully!")
|
||||
return redirect('job_detail', pk=job.pk)
|
||||
else:
|
||||
form = JobForm(instance=job, company=company)
|
||||
|
||||
return render(request, 'core/job_form.html', {
|
||||
'form': form,
|
||||
'title': f'Edit Job: {job.job_ref}',
|
||||
'company': company
|
||||
})
|
||||
|
||||
@login_required
|
||||
def job_delete(request, pk):
|
||||
profile = request.user.profile
|
||||
if not profile.company:
|
||||
return redirect('company_setup')
|
||||
|
||||
company = profile.company
|
||||
job = get_object_or_404(Job, pk=pk, company=company)
|
||||
|
||||
if request.method == 'POST':
|
||||
job_ref = job.job_ref
|
||||
job.delete()
|
||||
messages.success(request, f"Job {job_ref} deleted.")
|
||||
return redirect('job_list')
|
||||
|
||||
return render(request, 'core/job_confirm_delete.html', {
|
||||
'job': job,
|
||||
'company': company
|
||||
})
|
||||
|
||||
@login_required
|
||||
def toggle_folder_completion(request, pk, folder_id):
|
||||
profile = request.user.profile
|
||||
if not profile.company:
|
||||
return redirect('company_setup')
|
||||
|
||||
company = profile.company
|
||||
job = get_object_or_404(Job, pk=pk, company=company)
|
||||
completion = get_object_or_404(JobFolderCompletion, job=job, folder_id=folder_id)
|
||||
completion.is_completed = not completion.is_completed
|
||||
completion.save()
|
||||
|
||||
messages.success(request, f"Folder '{completion.folder.name}' status updated.")
|
||||
return redirect('job_detail', pk=job.pk)
|
||||
|
||||
@login_required
|
||||
def settings_view(request):
|
||||
profile = request.user.profile
|
||||
if not profile.company:
|
||||
return redirect('company_setup')
|
||||
|
||||
if profile.role != 'ADMIN':
|
||||
messages.error(request, "Only admins can access company settings.")
|
||||
return redirect('dashboard')
|
||||
|
||||
company = profile.company
|
||||
statuses = company.statuses.all()
|
||||
folders = company.required_folders.all()
|
||||
|
||||
return render(request, 'core/settings.html', {
|
||||
'company': company,
|
||||
'statuses': statuses,
|
||||
'folders': folders
|
||||
})
|
||||
|
||||
@login_required
|
||||
def status_create(request):
|
||||
profile = request.user.profile
|
||||
if profile.role != 'ADMIN': return redirect('dashboard')
|
||||
|
||||
if request.method == 'POST':
|
||||
form = JobStatusForm(request.POST)
|
||||
if form.is_valid():
|
||||
status = form.save(commit=False)
|
||||
status.company = profile.company
|
||||
if status.is_starting_status:
|
||||
# Unset other starting statuses
|
||||
JobStatus.objects.filter(company=profile.company).update(is_starting_status=False)
|
||||
status.save()
|
||||
messages.success(request, "New status created.")
|
||||
return redirect('settings')
|
||||
else:
|
||||
form = JobStatusForm()
|
||||
|
||||
return render(request, 'core/status_form.html', {'form': form, 'title': 'Create Status'})
|
||||
|
||||
@login_required
|
||||
def status_update(request, pk):
|
||||
profile = request.user.profile
|
||||
if profile.role != 'ADMIN': return redirect('dashboard')
|
||||
|
||||
status = get_object_or_404(JobStatus, pk=pk, company=profile.company)
|
||||
if request.method == 'POST':
|
||||
form = JobStatusForm(request.POST, instance=status)
|
||||
if form.is_valid():
|
||||
if form.cleaned_data['is_starting_status']:
|
||||
JobStatus.objects.filter(company=profile.company).update(is_starting_status=False)
|
||||
form.save()
|
||||
messages.success(request, "Status updated.")
|
||||
return redirect('settings')
|
||||
else:
|
||||
form = JobStatusForm(instance=status)
|
||||
|
||||
return render(request, 'core/status_form.html', {'form': form, 'title': 'Edit Status'})
|
||||
|
||||
@login_required
|
||||
def status_delete(request, pk):
|
||||
profile = request.user.profile
|
||||
if profile.role != 'ADMIN': return redirect('dashboard')
|
||||
|
||||
status = get_object_or_404(JobStatus, pk=pk, company=profile.company)
|
||||
|
||||
if status.is_starting_status:
|
||||
messages.error(request, "Cannot delete the starting status. Please set another status as starting first.")
|
||||
return redirect('settings')
|
||||
|
||||
if request.method == 'POST':
|
||||
starting_status = profile.company.statuses.filter(is_starting_status=True).first()
|
||||
Job.objects.filter(status=status).update(status=starting_status)
|
||||
status.delete()
|
||||
messages.success(request, f"Status deleted. Jobs reassigned to {starting_status.name}.")
|
||||
return redirect('settings')
|
||||
|
||||
return render(request, 'core/status_confirm_delete.html', {'status': status})
|
||||
|
||||
@login_required
|
||||
def folder_create(request):
|
||||
profile = request.user.profile
|
||||
if profile.role != 'ADMIN': return redirect('dashboard')
|
||||
|
||||
if request.method == 'POST':
|
||||
form = RequiredFolderForm(request.POST)
|
||||
if form.is_valid():
|
||||
folder = form.save(commit=False)
|
||||
folder.company = profile.company
|
||||
folder.save()
|
||||
# Note: The jobs will automatically see this new folder in job_detail view
|
||||
messages.success(request, f"Folder '{folder.name}' added company-wide.")
|
||||
return redirect('settings')
|
||||
else:
|
||||
form = RequiredFolderForm()
|
||||
|
||||
return render(request, 'core/folder_form.html', {'form': form})
|
||||
|
||||
@login_required
|
||||
def folder_delete(request, pk):
|
||||
profile = request.user.profile
|
||||
if profile.role != 'ADMIN': return redirect('dashboard')
|
||||
|
||||
folder = get_object_or_404(RequiredFolder, pk=pk, company=profile.company)
|
||||
if request.method == 'POST':
|
||||
folder.delete()
|
||||
messages.success(request, "Folder removed from company settings.")
|
||||
return redirect('settings')
|
||||
return render(request, 'core/folder_confirm_delete.html', {'folder': folder})
|
||||
Loading…
x
Reference in New Issue
Block a user