Job Management CRUD

This commit is contained in:
Flatlogic Bot 2026-01-21 23:05:26 +00:00
parent 1dd37b9eac
commit 50d8918174
31 changed files with 1901 additions and 172 deletions

Binary file not shown.

Binary file not shown.

View File

@ -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')

View File

@ -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
View 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

View 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')},
},
),
]

View File

@ -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
View 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()

View File

@ -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>

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View File

@ -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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View File

@ -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"),
]

View File

@ -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})