diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc index ba31260..3e75121 100644 Binary files a/core/__pycache__/admin.cpython-311.pyc and b/core/__pycache__/admin.cpython-311.pyc differ diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc index b6214bf..c1198be 100644 Binary files a/core/__pycache__/forms.cpython-311.pyc and b/core/__pycache__/forms.cpython-311.pyc differ diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc index 7428f1b..dfee85b 100644 Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index 61533cc..cd54cbe 100644 Binary files a/core/__pycache__/urls.cpython-311.pyc and b/core/__pycache__/urls.cpython-311.pyc differ diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index 9f2c181..4c75d13 100644 Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ diff --git a/core/admin.py b/core/admin.py index ed63395..0d4871d 100644 --- a/core/admin.py +++ b/core/admin.py @@ -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',) \ No newline at end of file diff --git a/core/forms.py b/core/forms.py index df8bcc0..2ebd8b4 100644 --- a/core/forms.py +++ b/core/forms.py @@ -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() diff --git a/core/migrations/0003_alter_invitation_expires_at_alter_invitation_token_and_more.py b/core/migrations/0003_alter_invitation_expires_at_alter_invitation_token_and_more.py new file mode 100644 index 0000000..60895b1 --- /dev/null +++ b/core/migrations/0003_alter_invitation_expires_at_alter_invitation_token_and_more.py @@ -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')}, + }, + ), + ] diff --git a/core/migrations/0004_job_client_alter_invitation_expires_at_and_more.py b/core/migrations/0004_job_client_alter_invitation_expires_at_and_more.py new file mode 100644 index 0000000..04b9ded --- /dev/null +++ b/core/migrations/0004_job_client_alter_invitation_expires_at_and_more.py @@ -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), + ), + ] diff --git a/core/migrations/__pycache__/0003_alter_invitation_expires_at_alter_invitation_token_and_more.cpython-311.pyc b/core/migrations/__pycache__/0003_alter_invitation_expires_at_alter_invitation_token_and_more.cpython-311.pyc new file mode 100644 index 0000000..2f35899 Binary files /dev/null and b/core/migrations/__pycache__/0003_alter_invitation_expires_at_alter_invitation_token_and_more.cpython-311.pyc differ diff --git a/core/migrations/__pycache__/0004_job_client_alter_invitation_expires_at_and_more.cpython-311.pyc b/core/migrations/__pycache__/0004_job_client_alter_invitation_expires_at_and_more.cpython-311.pyc new file mode 100644 index 0000000..a30f913 Binary files /dev/null and b/core/migrations/__pycache__/0004_job_client_alter_invitation_expires_at_and_more.cpython-311.pyc differ diff --git a/core/models.py b/core/models.py index ce6efa8..42153c5 100644 --- a/core/models.py +++ b/core/models.py @@ -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) diff --git a/core/templates/core/client_confirm_delete.html b/core/templates/core/client_confirm_delete.html new file mode 100644 index 0000000..5bdc72c --- /dev/null +++ b/core/templates/core/client_confirm_delete.html @@ -0,0 +1,26 @@ +{% extends 'base.html' %} +{% block title %}Delete Client{% endblock %} +{% block content %} +
+
+
+
+
+
+ +
+

Delete Client: {{ client.name }}?

+

Are you sure you want to delete this client? This action cannot be undone.

+
+ {% csrf_token %} +
+ + Cancel +
+
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/core/templates/core/client_form.html b/core/templates/core/client_form.html new file mode 100644 index 0000000..2265aa6 --- /dev/null +++ b/core/templates/core/client_form.html @@ -0,0 +1,24 @@ +{% extends 'base.html' %} +{% load crispy_forms_tags %} + +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
+
+
+
+
+

{{ title }}

+
+ {% csrf_token %} + {{ form|crispy }} + + Cancel +
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/core/templates/core/client_list.html b/core/templates/core/client_list.html new file mode 100644 index 0000000..5ce20fe --- /dev/null +++ b/core/templates/core/client_list.html @@ -0,0 +1,49 @@ +{% extends 'base.html' %} +{% load crispy_forms_tags %} + +{% block title %}Clients in {{ company.name }}{% endblock %} + +{% block content %} +
+
+

Clients in {{ company.name }}

+ Add New Client +
+ + {% if clients %} +
+
+
+ + + + + + + + + + {% for client_obj in clients %} + + + + + + {% endfor %} + +
Client NameJob Ref PrefixActions
{{ client_obj.name }}{{ client_obj.client_job_ref_prefix|default:"N/A" }} + Edit + Delete +
+
+
+
+ {% else %} +
No clients found in your company yet.
+ {% endif %} + +
+ Back to Settings +
+
+{% endblock %} \ No newline at end of file diff --git a/core/templates/core/dashboard.html b/core/templates/core/dashboard.html index fc69151..7d55a4d 100644 --- a/core/templates/core/dashboard.html +++ b/core/templates/core/dashboard.html @@ -149,6 +149,46 @@ +
+
+
Jobs by Client
+
+
+ {% if jobs_by_client %} + {% for client_name, client_jobs in jobs_by_client.items %} +
{{ client_name }} ({{ client_jobs|length }} Jobs)
+
+ + + + + + + + + + + {% for job in client_jobs %} + + + + + + + {% endfor %} + +
Job RefAddressStatusActions
{{ job.job_ref }}{{ job.address_line_1 }}, {{ job.postcode }}{{ job.status.name }} + View +
+
+ {% endfor %} + {% else %} +

No jobs found or clients assigned.

+ {% endif %} +
+
+ +
Recent Repair Jobs
diff --git a/core/templates/core/settings.html b/core/templates/core/settings.html index e1f5f14..31e6d91 100644 --- a/core/templates/core/settings.html +++ b/core/templates/core/settings.html @@ -61,7 +61,29 @@
-
+
+
+
+
+
Client Management
+ Manage Clients +
+
+ {% for client in clients %} +
+ {{ client.name }} +
+ + +
+
+ {% endfor %} +
+
+
+
+ +
diff --git a/core/urls.py b/core/urls.py index 1994a6c..0ba9d72 100644 --- a/core/urls.py +++ b/core/urls.py @@ -29,6 +29,10 @@ urlpatterns = [ path('settings/status//delete/', views.status_delete, name='status_delete'), path('settings/folder/create/', views.folder_create, name='folder_create'), path('settings/folder//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//edit/', views.client_update, name='client_update'), + path('settings/clients//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//update-role/', views.user_update_role, name='user_update_role'), path('settings/users//delete/', views.user_delete, name='user_delete'), path('accept-invite//', views.accept_invite, name='accept_invite'), -] +] \ No newline at end of file diff --git a/core/views.py b/core/views.py index 4447876..2532ad1 100644 --- a/core/views.py +++ b/core/views.py @@ -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()