Client Model introduced
This commit is contained in:
parent
a1804debdb
commit
9bd07a9e66
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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',)
|
||||
@ -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()
|
||||
|
||||
@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
@ -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)
|
||||
|
||||
26
core/templates/core/client_confirm_delete.html
Normal file
26
core/templates/core/client_confirm_delete.html
Normal 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 %}
|
||||
24
core/templates/core/client_form.html
Normal file
24
core/templates/core/client_form.html
Normal 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 %}
|
||||
49
core/templates/core/client_list.html
Normal file
49
core/templates/core/client_list.html
Normal 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 %}
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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'),
|
||||
]
|
||||
]
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user