From 9bd07a9e6654b65eacf1619850513b0a0d22af4e Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Thu, 22 Jan 2026 08:34:23 +0000 Subject: [PATCH] Client Model introduced --- core/__pycache__/admin.cpython-311.pyc | Bin 3328 -> 3714 bytes core/__pycache__/forms.cpython-311.pyc | Bin 6441 -> 7352 bytes core/__pycache__/models.cpython-311.pyc | Bin 8516 -> 9490 bytes core/__pycache__/urls.cpython-311.pyc | Bin 3033 -> 3740 bytes core/__pycache__/views.cpython-311.pyc | Bin 37860 -> 41719 bytes core/admin.py | 14 ++- core/forms.py | 16 +++- ...ires_at_alter_invitation_token_and_more.py | 37 ++++++++ ...nt_alter_invitation_expires_at_and_more.py | 30 ++++++ ..._invitation_token_and_more.cpython-311.pyc | Bin 0 -> 2063 bytes ...tation_expires_at_and_more.cpython-311.pyc | Bin 0 -> 1670 bytes core/models.py | 12 +++ .../templates/core/client_confirm_delete.html | 26 +++++ core/templates/core/client_form.html | 24 +++++ core/templates/core/client_list.html | 49 ++++++++++ core/templates/core/dashboard.html | 40 ++++++++ core/templates/core/settings.html | 24 ++++- core/urls.py | 6 +- core/views.py | 89 ++++++++++++++++-- 19 files changed, 353 insertions(+), 14 deletions(-) create mode 100644 core/migrations/0003_alter_invitation_expires_at_alter_invitation_token_and_more.py create mode 100644 core/migrations/0004_job_client_alter_invitation_expires_at_and_more.py create mode 100644 core/migrations/__pycache__/0003_alter_invitation_expires_at_alter_invitation_token_and_more.cpython-311.pyc create mode 100644 core/migrations/__pycache__/0004_job_client_alter_invitation_expires_at_and_more.cpython-311.pyc create mode 100644 core/templates/core/client_confirm_delete.html create mode 100644 core/templates/core/client_form.html create mode 100644 core/templates/core/client_list.html diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc index ba312609c94adf0b04eebf60e7b838b9eccaa3fe..3e751211fc3d7961ced2918f03bd9bf5a28f70ec 100644 GIT binary patch delta 1392 zcmaJ=O-~a+7~a{o+wB)0{h%MVrHIvTkpOBCQ$ip}6vcAzW+1g?m$;-*oGnB>NMpP! zPGY=q(-0FbT#Sjvp8$qKvlqE?5)w})M(3SriMEY1yU*@E@67YQ^UQ1|@+{W&&f{?k zw0-&ZP=6zBwuQumWivJGUUuDcAwd*0wB+7gOd<~%L`i#FjrhQcKIA4&R1bFWAepN( z{hgcveQ28NEfR$H^yWarlTB%|sB0@_^1?nq zUR$=Lb{fO&Bx6lTVg8!8&QEs3Yn(!M+lKw{5di1{2vRUzMcpXp7WHCT!#D&qn;n6- zCBdFwDK*t}8)MB?O$t>+vQ#|a=BruPB|;30X*@0+U3RK4-aZRk8nFVl-k z!8F%{@#iUITgs&%oFem5t(p{_9VXx9o(&hbVgfu;EcjH+3@k8@a!aK`4r`0KRSNne zmP0{oTy|S(Wee`1$xM%-W)@ajmG7rp&kH|vqyY%VO4*uatAtJUAkW1P5D-- zz>Bk)_F64$Q%s2$Xi7#oUtTjzd9`Ge7fPx|mjTA&A%II16e}Jizof{y%k(3QF9P4- z_W*DJF>>GT4^4yLLr~V#-(?N3vS#Hl`D`D__*&>(1r7lA&VUh>(|+i?Qkj8{!(?BM zlUZ4jMqvCk@>C9uw~`x$Fu(qxx!=YC8Lh+LFUPI z8l&4cK_9J+7^e{o&<944Er-|54mD6&B}hIwrbO%^za5bc_Sdj;&Q2>kV{BuYnal}R zKTK`#`=>m*TvFGH+8CauQTR8FLs1ZpEeN5ikl6e8stW6SO;>+2^&e*UqcefVqO3#-h@Jmk6!QY zJ!pLn47DWxg&&?;l{p{pv$(c8Y?ry2;q$T-^2Ud3yu_)Xah^0gmf*fegy+Tt=Hg{Z zaL8p?OgKCt$)NFLNs4$eZ~0ts?wQXF)A?~~xWkKdx)VxAC>{0aY|M<^_37~uPN8#d zz%X}P-4TZ)84Ujhe3Rzk*#BP9C`AwixEhRUmj<`+CP_xaM`81N9zSyw>iYo_#X$@q zjxbNbRXZ))X>7MTjzvfUY35GhSB3#J0%ehr85+3?p8^qa7L^u& z$8h?#EARDeXRBvgI?n{MM8kA}EJ9sQ*G}V{`{J~6kOI}*kZl=cYll|F>X`QAwsiUn z(czVpi&h5fN3q)AHs0g9>1>t9{uKO_FXSWm>qJ$SkweJQo@K{{U1qK)LA|NyIo zKzm}jYJacOGfc~D7*3r`%R7$_gd+S1X1OG62J=`Zs4D3QHpf+3-G?oQMl>IhFzhG` zXOSjXDCiui(pR0FL1tp5Ysev#;k6WluS#Tw9RsB5dK^Yd4OY~|Iy;5ztgw!=#<3!? xt+Ms8sDI;6upMzg&fjig;l{f)c`Ui&4;Jg3; diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc index b6214bfd08c03a48b6db5c7365a32431a55a9d52..c1198bee92a5668c3f532546e5144c0b6fb9bc7c 100644 GIT binary patch delta 2678 zcmb7GU2GIp6rP#got>Ti|6ib`+d^rl>=tMNDdJxXEfrXm(nN`6+3rm1mg#Pp*#>Bc z)kw@f5T)G4Xo8VMqcrkB^u_pOj1R`B*^;WwBqW6Rf>E;##26lo=iF^~VQGTi?fvH7 z@0@$*+;h%7)47f>x?G<)ogxD*dh=xFH}{My!Vb^0Yz~YIM+KJQm_9}}pJQZ;!j9QU zlnu{(z}t8rtSf}uPL%}UY!%KyIT1K}g>zEQ0i3hKxhUrX&b`bvvmShn+e;-rkoZAD zxjLU57+|E}Z8TS64a-PQ8`p2MIOE9~wnH%Z9yOz6^Q6e{`@F=mY=a+!_dKW=Prst1 zPsB6XiOIagX>Jf`9^x^5D7s*1fvz7T^Zp>YZaTEH5v3ad5_4uPf|E4}YgcG3iX*Dk zgd+z9GHCASDd`|T@cYT{=DBHnkKxPcNj;y^@|o-?4)T+_<^{o&eSl7)iPX5F`EZjU z0$Qzw0agrcgKVLd_v0Qt`D;qf4vMB4ZhyQ&A%HX<8EsGg$* zrwC3QG*^TzaGsxd>N797Vr|$~u{9ytYOS*3vRbQ%myeVUX(u7!K=UIEiQyhG@}{ut z&=@+jNi2uA7s8nag-Sy|V7sFduVwE=QJgl(R@riiyUKseGR&vY%S{$8aYqH&25BVT zb60n3Kcql-Xsm*1nnKC3+;CD;Mv@Z%nbXVRDzB&s*Hmg8U;YL-bB~VNZHRZku2PUPab0Yq?W;LbBH;qJY}9iHHmM0Yz2Pc|$m&oQAk7 zsf$7|O0##Y~_~3N=Tc`3^20rAN4?2vLy^_IyXL%%AS!}tsgeg*7qRp1~>wN zq7;@f?Qwu!$xS48yc5foA)K64v@^Pr*YF+~LM96(GMiQmZsG(K`HZIL4QnQw$!Ah3 zlZ7pOrl~k*#)fbV##0bF4jr5dYW5q#5=DZ`z79e zJ>LE8W5sy)e0*RbK2X?u{=mD!f}?T4(N^M_K!m*LdELIwY1>g^0JKK(t0&!#CQF>* zNUKUJtH?uE(*lSy#jOTg+_v!2%I5qF|00#;@{2k>__HA|e^0{QeXEHUmDi+HyxY4TX$ zRI9*7qWPN2AFiGB!*$E|M(n}+wo1{$R9wDYWX4}lSRo+NlMRF5F!|oU?@?cyafU@r4J)d_pUTMK`ozP2HNj%T)|*k4-i)Hknv9#J5uylm zZ|cwyoFY(9KLLZPKNsz53p>s~RU|J5V`ZO=khg*tSube}h3hNdJ#7?L)!0A=L#=C~ zz~A>O6{iS8QMXX z&QTOs4t7u2GoF(tRpm)74+0jt{tbCMT(|XLk@+8gB{N|o5%Nb^#pg4@*@h*?%0eyy ilqmRG*+7YT7z)by+!FG4tHL8JiATcZ{Yb%#mi`M7yFQ)( delta 2011 zcma)7&2Jk;6yJ5cYkSvr{8`&cnruFrb_q$_&;St?lt7)dC>G!f(NgL-_B3p=j+3#Y zwoyf>sMH(~O8UwP4oH>kkb)|asy(0{DDRC)LOu>1dw+iO zo451cypQp%{^j1_*MWd0;rQmvw6zpi3?}6l7x#9BWXUBBN=5f&si-h{+zV53qTVMs z6*5(af^Scz916yNCe3Myd(!@AvJ}Hk*s7!SI9L} zc9gSaUaG9fG*jTZ+zYGm^!0X^+!8_Rafp_?3m>q;M7K3LHB&WRJctXt9tP^JD*+T0 z7*S*JPhEDmK?`jNnPoRm&?HHa5;R=WqN+}!4p-dARHG(7QHP*eS)QX!orqP*RjPSj zwI;_%sLoV)2p4Acte6;?e2MWey%{4Q7x^xPHLpyRGp4rHxJr5r1dRlB1Wg3H37Qe$ zvKE*3!D)5EZxAO=!Q08{R3{MxbXwj)U=Vb|6ZKbBL?Z-_r{(!|$1iguUK+>fUY zBc8y7h#A#8KZKArgOH7M_fUO1-su^}FH~67@miDEMw12RxTYqs*DYSDIv#7%s#f(p8~0~qjgJORJO_1Z0T0J~}k`gVPM zoT^;hIHLAb^9!|+)$ zT|06PeotQQqb{Efvr^u&kFfLk(v;15u-^W6!;YZF7HhDS+Lxl{tvM|6!)QwI7ObSk zYNw@X#tg&hhMspF-*CCeY`Sx}_zw_ws<_=mYAFzoTPzE2#eSP@JoX+gm5VbrdzqI| zn~I}y74A1QJ@fi)>8ZY}Zup@w32!#q-FxKNsAvuTTI0 diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc index 7428f1b77b24b58224809098a491f031f94e9318..dfee85b53a0417c81c6f8bf8b3b812195f427956 100644 GIT binary patch delta 2963 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 diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index 9f2c181c1f63c13c1b7230fa86e2d7c306030b58..4c75d1344c68635e5f4118c17bbaee0f1cd2021f 100644 GIT binary patch delta 9019 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 delta 7111 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 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 0000000000000000000000000000000000000000..2f3589947fac6931d82079a2d3e257479e5a8dc3 GIT binary patch literal 2063 zcmZuy&2JM&6rWw&>yJ2z*CZHI0%=Gi?26cgAQDh%3lzu@S#TzJ#geWk@jGXRH;%=?#Yy#xU_F}vkCc_^}d-m@9Q`3y&XUA z?@u5Yi+`?Yvk`>;=1eev_r|+hF#U=!!h()U{w$P4;6**u2$#Z*NGT#95#2;M^fSU? z60S=-wrDei&`WszQIfE*h;orGXYb@hmS|{IOLa8Uu$j~_G12YHj;=SQc9?fm;1~!h z2^c|ap(>O@B7#T(Nr{Ga@Xb<9`d~O@9xN0#>4n{JF^)E zH1PUkNyH}(#LKo%?newdA05z<@hQMGvL6%Zo%Zww0`Fw79%LWw)w3=K@_IU0;WK!& z{qaG6)i55zXKR8cw8!_$s-e;`{0a2I1dHDe>JQBju9y~KgT-QTO3{pmn&UMFOMJK{ zt4+-!wgRGmpwL}jn(hcEyl7pHse2NL_V05Ezc7-!YYc%lG@UpTnM_3cba0T z4Z^}6jVx(I$1LKQ%fw)#s!EC%iZf)oJbiKcf?8CI7p5-Z*-QBH)U>Kzoa&B3hUKK*9;piU1!mX*=VZ98Z10Rdp*gsJh`(CHgVHXAREMi*cQ<_I6pI@W3oa2IErId ziBlt1PP8Tr*Q&-hka7vvRikP$5tqR&KR;YTEIL28I6rs&21}St zvU4&0hTZ`b^xiJi%myj6$}Pib704>7fMcuh$h4O2rdlBdp4h_Pg;sz@zNo1DL?5)X zve&_fWnTL!s(~SAP^9LX#V=Bi@b|gRQ z&E%gxbTj!^;Wy*7q)@}68G9>ZyBV9NoQ|BK+1#_^ZZ`M(BF)ZjWoO;&EKObM$meMO z%8Lm%f93TM{q`RHL818vTloiW{sB#?9r+|Zb^h6cd+Piv>5sTO^QAj;*PXdfYb!m3 z9tk%@m_`=Fw;tX0DD22L#a=uzG|(GB@)#YzOwZ0hDu)MpavwiDkZn$SnM_*R_?kb~ zTu_T#)S8Ah%lbVuiOZAzf7fKtNY-au9^aLF?d`R+U(u`FmFMpotlq~y_rH-ZtQ)}c yJI!7O(hCWK096)J8-x7mpivt9I_Mld>erJ-1gVD(lcxzcHU5?hwvUi{VEzXj<2Jqk literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a30f913c64b9f20d198a3665f990ddf4238c6c3d GIT binary patch literal 1670 zcmah}&rjP{9DjBkCou$qVWns=Awa0nq&QPy?WAb}b#&9#(ya}f8cnsFyq93(*uk;4 zlnumThaG#{f51AyVTb(z9Cql@NIpdG(!);KDF{wI?fdLFQA@Qw+h2cw_+O60g>@IV&r1&L_SKxitt3^Wbz1hdJIqB z{yzp&A%i$i2)~1`Vc}%@b>468qn;?s_&QW#hGy=^6@YRGRofwStynDHR5jBjj;dSV z>uy*em9(3>Lt3ilUX8i-4zX0t!fJydBn-Pk#j2?j%Pmm>tk&KL!!AU=e3B+Bre^I> z!SYO#W^7A^+7p-1QHPkCOR#Eb4MKU>&L^NtSZ72YA@nC}Ix#VohV-a3uDz21hX({5(^Wns*{$(jtNb#V|NOZ3(gHq zef97`Y2!|bO6#m*;ar;i6mnS78zeH*7)%#Tnr3p(wxC9yTcw#h+gg8$WH(9wDE^Kv zU~TlOu58;4qIeb0ay^B#N!4?0NBO~ac3MrXN)%R7Wq5=YXq*)ls;x$4zS<|l8V#DI z(0uB!-$f``rCJhH?r?DRM3mX$dI^3?JiOPFP-gZQDM-KDnV#)T%yi_%XUl=S_>;$yZ}RY(e88WF@-mdYl#r3m5r|7B80LEnbFMSJ*qNB? z$jc0K*;m${w*zJEbn0~X#lj2C|N4=?^{xN-iC?#adON896$wnX>Z%&-f$>QM`>~dD( +
+
+
+
+
+ +
+

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 %} + + +
+{% 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()