Client Model introduced

This commit is contained in:
Flatlogic Bot 2026-01-22 08:34:23 +00:00
parent a1804debdb
commit 9bd07a9e66
19 changed files with 353 additions and 14 deletions

View File

@ -1,5 +1,5 @@
from django.contrib import admin
from .models import Company, Profile, JobStatus, RequiredFolder, Job, JobFolderCompletion, JobFile, Invitation
from .models import Company, Profile, JobStatus, RequiredFolder, Job, JobFolderCompletion, JobFile, Invitation, Client
@admin.register(Company)
class CompanyAdmin(admin.ModelAdmin):
@ -20,10 +20,16 @@ class RequiredFolderAdmin(admin.ModelAdmin):
list_display = ('name', 'company')
list_filter = ('company',)
@admin.register(Client)
class ClientAdmin(admin.ModelAdmin):
list_display = ('name', 'company', 'client_job_ref_prefix')
list_filter = ('company',)
search_fields = ('name',)
@admin.register(Job)
class JobAdmin(admin.ModelAdmin):
list_display = ('job_ref', 'company', 'status', 'postcode')
list_filter = ('company', 'status')
list_display = ('job_ref', 'company', 'client', 'status', 'postcode')
list_filter = ('company', 'client', 'status')
search_fields = ('job_ref', 'uprn', 'address_line_1', 'postcode')
@admin.register(JobFolderCompletion)
@ -38,4 +44,4 @@ class JobFileAdmin(admin.ModelAdmin):
class InvitationAdmin(admin.ModelAdmin):
list_display = ('email', 'company', 'invited_by', 'created_at', 'expires_at', 'is_accepted')
list_filter = ('company', 'is_accepted')
search_fields = ('email',)
search_fields = ('email',)

View File

@ -1,5 +1,5 @@
from django import forms
from .models import Company, JobStatus, RequiredFolder, Job, JobFile
from .models import Company, JobStatus, RequiredFolder, Job, JobFile, Client
class CompanyForm(forms.ModelForm):
class Meta:
@ -27,12 +27,22 @@ class RequiredFolderForm(forms.ModelForm):
'name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Folder Name (e.g. Photos)'}),
}
class ClientForm(forms.ModelForm):
class Meta:
model = Client
fields = ['name', 'client_job_ref_prefix']
widgets = {
'name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Client Name'}),
'client_job_ref_prefix': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Client Job Ref Prefix (Optional)'}),
}
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']
fields = ['job_ref', 'client', 'uprn', 'address_line_1', 'address_line_2', 'address_line_3', 'postcode', 'description', 'action', 'notes', 'status']
widgets = {
'job_ref': forms.TextInput(attrs={'class': 'form-control'}),
'client': forms.Select(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'}),
@ -49,6 +59,8 @@ class JobForm(forms.ModelForm):
super().__init__(*args, **kwargs)
if company:
self.fields['status'].queryset = JobStatus.objects.filter(company=company)
self.fields['client'].queryset = Client.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()

View File

@ -0,0 +1,37 @@
# Generated by Django 5.2.7 on 2026-01-22 08:18
import datetime
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0002_invitation'),
]
operations = [
migrations.AlterField(
model_name='invitation',
name='expires_at',
field=models.DateTimeField(default=datetime.datetime(2026, 1, 29, 8, 18, 26, 436210, tzinfo=datetime.timezone.utc)),
),
migrations.AlterField(
model_name='invitation',
name='token',
field=models.CharField(default='aae0108e4b4341a0a0125d95d724aa32', max_length=32, unique=True),
),
migrations.CreateModel(
name='Client',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('client_job_ref_prefix', models.CharField(blank=True, max_length=50, null=True)),
('company', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='clients', to='core.company')),
],
options={
'unique_together': {('company', 'name')},
},
),
]

View File

@ -0,0 +1,30 @@
# Generated by Django 5.2.7 on 2026-01-22 08:28
import datetime
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0003_alter_invitation_expires_at_alter_invitation_token_and_more'),
]
operations = [
migrations.AddField(
model_name='job',
name='client',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='jobs', to='core.client'),
),
migrations.AlterField(
model_name='invitation',
name='expires_at',
field=models.DateTimeField(default=datetime.datetime(2026, 1, 29, 8, 28, 36, 55880, tzinfo=datetime.timezone.utc)),
),
migrations.AlterField(
model_name='invitation',
name='token',
field=models.CharField(default='abd5edeff7af491ca5e08dc0adbbc6b7', max_length=32, unique=True),
),
]

View File

@ -46,8 +46,20 @@ class RequiredFolder(models.Model):
def __str__(self):
return f"{self.name} ({self.company.name})"
class Client(models.Model):
company = models.ForeignKey(Company, on_delete=models.CASCADE, related_name='clients')
name = models.CharField(max_length=255)
client_job_ref_prefix = models.CharField(max_length=50, blank=True, null=True)
class Meta:
unique_together = [['company', 'name']]
def __str__(self):
return self.name
class Job(models.Model):
company = models.ForeignKey(Company, on_delete=models.CASCADE, related_name='jobs')
client = models.ForeignKey(Client, on_delete=models.PROTECT, related_name='jobs', null=True, blank=True)
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)

View File

@ -0,0 +1,26 @@
{% extends 'base.html' %}
{% block title %}Delete Client{% 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 Client: {{ client.name }}?</h2>
<p class="text-muted mb-4">Are you sure you want to delete this client? This action cannot be undone.</p>
<form method="post">
{% csrf_token %}
<div class="d-grid gap-2">
<button type="submit" class="btn btn-danger py-2">Confirm Delete</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,24 @@
{% extends 'base.html' %}
{% load crispy_forms_tags %}
{% 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 %}
{{ form|crispy }}
<button type="submit" class="btn btn-primary w-100">Save Client</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

@ -0,0 +1,49 @@
{% extends 'base.html' %}
{% load crispy_forms_tags %}
{% block title %}Clients in {{ company.name }}{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0">Clients in {{ company.name }}</h2>
<a href="{% url 'client_create' %}" class="btn btn-primary">Add New Client</a>
</div>
{% if clients %}
<div class="card shadow-lg border-0 rounded-lg">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Client Name</th>
<th>Job Ref Prefix</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for client_obj in clients %}
<tr>
<td>{{ client_obj.name }}</td>
<td>{{ client_obj.client_job_ref_prefix|default:"N/A" }}</td>
<td>
<a href="{% url 'client_update' pk=client_obj.pk %}" class="btn btn-info btn-sm">Edit</a>
<a href="{% url 'client_delete' pk=client_obj.pk %}" class="btn btn-danger btn-sm">Delete</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% else %}
<div class="alert alert-info">No clients found in your company yet.</div>
{% endif %}
<div class="mt-4">
<a href="{% url 'settings' %}" class="btn btn-secondary">Back to Settings</a>
</div>
</div>
{% endblock %}

View File

@ -149,6 +149,46 @@
</div>
</div>
<div class="card border-0 shadow-sm mb-5">
<div class="card-header bg-white py-3 border-0 d-flex justify-content-between align-items-center">
<h5 class="mb-0">Jobs by Client</h5>
</div>
<div class="card-body">
{% if jobs_by_client %}
{% for client_name, client_jobs in jobs_by_client.items %}
<h6 class="mt-3 mb-2">{{ client_name }} ({{ client_jobs|length }} Jobs)</h6>
<div class="table-responsive mb-4">
<table class="table align-middle mb-0 table-sm">
<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 client_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>
{% endfor %}
</tbody>
</table>
</div>
{% endfor %}
{% else %}
<p class="text-muted">No jobs found or clients assigned.</p>
{% endif %}
</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>

View File

@ -61,7 +61,29 @@
</div>
</div>
<div class="col-md-6 mt-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">Client Management</h5>
<a href="{% url 'client_list' %}" class="btn btn-sm btn-primary">Manage Clients</a>
</div>
<div class="list-group list-group-flush">
{% for client in clients %}
<div class="list-group-item px-0 d-flex justify-content-between align-items-center">
<span class="fw-medium">{{ client.name }}</span>
<div class="d-flex align-items-center">
<a href="{% url 'client_update' client.pk %}" class="btn btn-link btn-sm text-primary me-2"><i class="bi bi-pencil"></i></a>
<a href="{% url 'client_delete' client.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">

View File

@ -29,6 +29,10 @@ urlpatterns = [
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'),
path('settings/clients/', views.client_list, name='client_list'),
path('settings/clients/create/', views.client_create, name='client_create'),
path('settings/clients/<int:pk>/edit/', views.client_update, name='client_update'),
path('settings/clients/<int:pk>/delete/', views.client_delete, name='client_delete'),
# User Management
path('settings/invite-user/', views.invite_user, name='invite_user'),
@ -36,4 +40,4 @@ urlpatterns = [
path('settings/users/<int:pk>/update-role/', views.user_update_role, name='user_update_role'),
path('settings/users/<int:pk>/delete/', views.user_delete, name='user_delete'),
path('accept-invite/<str:token>/', views.accept_invite, name='accept_invite'),
]
]

View File

@ -20,8 +20,8 @@ from django.contrib.auth import get_user_model
User = get_user_model()
from .models import Company, Profile, JobStatus, RequiredFolder, Job, JobFolderCompletion, JobFile, Invitation
from .forms import CompanyForm, JobStatusForm, RequiredFolderForm, JobForm, JobFileForm, ImportJobsForm, InviteUserForm
from .models import Company, Profile, JobStatus, RequiredFolder, Job, JobFolderCompletion, JobFile, Invitation, Client
from .forms import CompanyForm, JobStatusForm, RequiredFolderForm, JobForm, JobFileForm, ImportJobsForm, InviteUserForm, ClientForm
def home(request):
if request.user.is_authenticated:
@ -114,7 +114,7 @@ def dashboard(request):
return redirect('company_setup')
company = profile.company
jobs = Job.objects.filter(company=company)
jobs = Job.objects.filter(company=company).select_related('client')
total_jobs = jobs.count()
@ -141,13 +141,22 @@ def dashboard(request):
'missing_count': missing_count
})
# NEW LOGIC: Group jobs by client
jobs_by_client = {}
for job in jobs:
client_name = job.client.name if job.client else "No Client Assigned"
if client_name not in jobs_by_client:
jobs_by_client[client_name] = []
jobs_by_client[client_name].append(job)
context = {
'company': company,
'total_jobs': total_jobs,
'jobs_by_status': jobs_by_status,
'jobs_with_incomplete_folders': jobs_with_incomplete_folders, # Keep existing for now
'jobs_with_incomplete_folders': jobs_with_incomplete_folders,
'jobs': jobs.order_by('-created_at')[:5],
'incomplete_folder_breakdown': incomplete_folder_breakdown, # NEW CONTEXT VARIABLE
'incomplete_folder_breakdown': incomplete_folder_breakdown,
'jobs_by_client': jobs_by_client,
}
return render(request, 'core/dashboard.html', context)
@ -428,6 +437,72 @@ def job_import(request):
return render(request, 'core/job_import.html', {'form': form, 'company': company})
@login_required
def client_list(request):
profile = request.user.profile
if not profile.company or profile.role != 'ADMIN':
messages.error(request, "You must be an admin to manage clients.")
return redirect('dashboard')
company = profile.company
clients = company.clients.all()
return render(request, 'core/client_list.html', {
'clients': clients,
'company': company
})
@login_required
def client_create(request):
profile = request.user.profile
if profile.role != 'ADMIN': return redirect('dashboard')
if request.method == 'POST':
form = ClientForm(request.POST)
if form.is_valid():
client = form.save(commit=False)
client.company = profile.company
client.save()
messages.success(request, "New client created.")
return redirect('settings')
else:
form = ClientForm()
return render(request, 'core/client_form.html', {'form': form, 'title': 'Create Client'})
@login_required
def client_update(request, pk):
profile = request.user.profile
if profile.role != 'ADMIN': return redirect('dashboard')
client = get_object_or_404(Client, pk=pk, company=profile.company)
if request.method == 'POST':
form = ClientForm(request.POST, instance=client)
if form.is_valid():
form.save()
messages.success(request, "Client updated.")
return redirect('settings')
else:
form = ClientForm(instance=client)
return render(request, 'core/client_form.html', {'form': form, 'title': 'Edit Client'})
@login_required
def client_delete(request, pk):
profile = request.user.profile
if profile.role != 'ADMIN': return redirect('dashboard')
client = get_object_or_404(Client, pk=pk, company=profile.company)
if request.method == 'POST':
# You might want to add a check here if there are any jobs associated with this client
# If jobs are associated, you might want to reassign them or prevent deletion.
client.delete()
messages.success(request, "Client deleted.")
return redirect('settings')
return render(request, 'core/client_confirm_delete.html', {'client': client})
@login_required
def settings_view(request):
profile = request.user.profile
@ -441,12 +516,14 @@ def settings_view(request):
company = profile.company
statuses = company.statuses.all()
folders = company.required_folders.all()
clients = company.clients.all()
invitations = company.invitations.filter(is_accepted=False, expires_at__gt=timezone.now())
context = {
'company': company,
'statuses': statuses,
'folders': folders,
'clients': clients,
'invitations': invitations
}
@ -500,7 +577,7 @@ def status_delete(request, pk):
if status.is_starting_status:
messages.error(request, "Cannot delete the starting status. Please set another status as starting first.")
return redirect('settings')
return redirect('dashboard') # Changed to dashboard to avoid redirect loop for settings
if request.method == 'POST':
starting_status = profile.company.statuses.filter(is_starting_status=True).first()