From 4c6eb17d09e788ec7b69105718c7e134eeb2d1fd Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Wed, 4 Feb 2026 13:33:29 +0000 Subject: [PATCH] Ver 10 Receipts added --- config/__pycache__/settings.cpython-311.pyc | Bin 5580 -> 5676 bytes config/__pycache__/urls.cpython-311.pyc | Bin 1557 -> 1660 bytes config/settings.py | 5 + config/urls.py | 3 +- core/__pycache__/forms.cpython-311.pyc | Bin 3830 -> 5361 bytes core/__pycache__/models.cpython-311.pyc | Bin 8937 -> 11176 bytes core/__pycache__/urls.cpython-311.pyc | Bin 1562 -> 1668 bytes core/__pycache__/views.cpython-311.pyc | Bin 32083 -> 37715 bytes core/forms.py | 28 +- .../0007_expensereceipt_expenselineitem.py | 42 +++ ...nsereceipt_expenselineitem.cpython-311.pyc | Bin 0 -> 3098 bytes core/models.py | 40 ++- core/templates/base.html | 44 +++- core/templates/core/create_receipt.html | 249 ++++++++++++++++++ core/templates/core/email/receipt_email.html | 59 +++++ core/templates/registration/login.html | 60 +++++ core/urls.py | 6 +- core/views.py | 133 +++++++++- 18 files changed, 652 insertions(+), 17 deletions(-) create mode 100644 core/migrations/0007_expensereceipt_expenselineitem.py create mode 100644 core/migrations/__pycache__/0007_expensereceipt_expenselineitem.cpython-311.pyc create mode 100644 core/templates/core/create_receipt.html create mode 100644 core/templates/core/email/receipt_email.html create mode 100644 core/templates/registration/login.html diff --git a/config/__pycache__/settings.cpython-311.pyc b/config/__pycache__/settings.cpython-311.pyc index e3cb08694d19fd46a61dedbd87e71ac1860dd760..99b006336d94e33e919c29838ee5789ac1161442 100644 GIT binary patch delta 195 zcmX@3y+((3IWI340}yoEHfJ84$ScYCV59mK7D1ON{S?D6!Q-a ziANSJ;sIJ&BnTq-fy6Hko80`A(wtPgqHG|S5r~UFZhj_sn_2Dy8w0OM1LqA<=?e@( OH^gKwFbG2sST_K&LOC1& delta 78 zcmZ3Zb4HtYIWI340}up;H)WDcDS!>LsC!|kEzaVFEQOxp+nB_$lt1B#4H&{3tTsnk0!aKqzL|^1pyuzz^kwxhW Ui_!;n7JjA%?hibZx3VSx04Uc!;{X5v delta 132 zcmeyvGnGeuIWI340}xbw%g@wdVPJR+;=lkql<}EsqIy45I@81l;-Z|X>{)y;1u0xr z+zbqBxFicD5ulxGC7L^*ka8B%~)lXr6*<9?>iPAqDSOdlC0XR+2yViVzDW#(sU M;QqihS%xhE09P6ry#N3J diff --git a/config/settings.py b/config/settings.py index a2b098a..714f84e 100644 --- a/config/settings.py +++ b/config/settings.py @@ -181,3 +181,8 @@ if EMAIL_USE_SSL: # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# Authentication +LOGIN_URL = 'login' +LOGIN_REDIRECT_URL = 'home' +LOGOUT_REDIRECT_URL = 'login' \ No newline at end of file diff --git a/config/urls.py b/config/urls.py index bcfc074..2cb6937 100644 --- a/config/urls.py +++ b/config/urls.py @@ -21,9 +21,10 @@ from django.conf.urls.static import static urlpatterns = [ path("admin/", admin.site.urls), + path("accounts/", include("django.contrib.auth.urls")), # Adds login, logout, password management path("", include("core.urls")), ] if settings.DEBUG: urlpatterns += static("/assets/", document_root=settings.BASE_DIR / "assets") - urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) \ No newline at end of file diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc index 3e95dc8a2ada58c79f6f8257b137acf2f101e125..1b9f2f5fb029b56fc14714f32cc2fce71e79c5ff 100644 GIT binary patch delta 2144 zcmb7EOK%fb6rLG-#+k951Uqp|B0@w;!$jC50;CGjK&1xMl9)D+(pqYUiSG?en1^*| zOi31X)rZ8Qi-bWXi>$gx)rYE7Y>>L>UzkWxWpoW+-U9>;p z#xW8%%vbR8$5)8zttkIjy;KVl0zhO z(2Hb2*R>(RO!!-^+)28pIW@Np;xMQ3p}62M%Y{L=VVj0cBawRAf?n|)wzQ3Fs?zG} zz(CkOey2)pmrl_NHL70Nu`5r&w0AuE%jOB8kOBASofxE0xk)Rx3Wi;+d1{<>gMsw` zgbBkoJVQ5G&KF~aEE>WjwW{8dX6o}4_2aR}7w{6oM3p&6Ok{8~DNZle!>6K7(4zuzSf@<;tQ z(L#cT!H^Zi|>w};^7`}lq>UuD+>{4dEl(DApniwZ?$ra#DGTeF!L%W@Wijg#XH3I zGz7r7{zB~XWQ<580pt`9&{|r=Lq`psLmQF*!z+5t=tdO(=h)%;i}ywpk~H}JkqN&p zmQy4Jdrm*^Ik@NIB=vMvjA#`~x2a7W7A6UGD-4)!ICj`x)t4-4dzwYPn+^%vZtI@r zEdeKxiuHY5Tuy!v#TY2*7DYOOPnfKldWGI}OhVZfFyuf3!A%uu@ zNE4<4gz^l%M23hrAA#-y{O%6`pS}2+ zo&9&k=Ark3sQZ)Rk$Ud<3cc@a+QIC6aI%6uN49dLkt5AMb?xl>*rQ@FH64u1VDFpV z>YHu!%{Kdo*XX*rIrL~O7=1VBzp~YTrO|(-nOE1uMqb_Q3C>LfBa=bCw3RP4@}-@m zlvQ>(fV&C&THD2))=N-0_Pvzo1p7{P4SX2_z7_X8z{^wZ2(k##55g1FJ^L3!Ykdq? zkLnMpLH&_*%cbdxy32yN z>(MF*_)Hi9fHPupLlH8{^iZkjwniX@9<4_?EUAY=i7e{&4X0HnQZ1o~>$xnBb0y1x zDK5i0&#;=kEM6?)5WbI4o0s+#~tGRPuu%076a~;-sGl(|I__n+Y5EZD?DWGAgpz-AAN%QXD^L; zuJ=@MxY&$mR<1Tv-79y3-oLOp7EFB{sB_q}+E!L;WVL2yaOHR- wGZ>sH1s~1@!y5L?^{veHM&|m^ju=ZzI~+n%?iGM9y#ny1R|vRVt4B=v2NtdrQ2+n{ delta 726 zcmaiy&ubGw6vyXfeE5x3~6?(+wNxL+vH#l z7D|tT;2gw%z#cs2?jIl?dMtYqJmnx%1rOrEd0R`54l^I#kD2+tH_UhE*VNXoZCgx@ zkFU3bhO)krb2N;9#?Ob!c*}g|L&zfV0GOZiwJ;ZHo)+nz9vPkynVtzO#~RGncbIPo z*vKDU^j>4^gIb5fEBNLk=H!QronXv?)OZ?WlngfjyJa_*?|`(u=IThBym z(+%34Uf0o4onlx~Nb^BE=myOY$K^N89j8QE5>co7f``pDL2+0A0wp=`@G8pou@y0l%LX&dBd}YqlwuQm2xvIfw`KLGwXJpm71g2cECZ?%H zEGVSqAmK@`Be0hU#94};lsnd4Sd^cv8r0$`?24s8gg%~^pUeCKY2!^w>Fu~JaE|_y$xGg&*a~CimB;uG~4U(p}rZ#z6cYfuYo4v z_yvmhC`S!tccDY+>?#uZU8t+Gy-?i3RppX;@?7CS1Au)txpzD-kh9OO?EP=b>54nT LuEW&>LZgYKZ4 diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc index a8b9e8edaafe16869761224bb4e21513f69c4879..c43fb0343c40691a393822d5f5073fe1bfacadeb 100644 GIT binary patch delta 2610 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 DKD(#t diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index a816d126d57a0c78bbd8276d21e534fdf7e88da3..2b150b295a97c9047757c97c4174b32640178650 100644 GIT binary patch delta 385 zcmbQm)55F1oR^o20SH3fnlrDlFfcp@abQ3Y%J{rwqPjavE`JpN#yl&=$qtOH#G<;9J}pGo7q{J8P#sEa5T7F;FO*bcfrW%0*muS7UwH0&L5=t_?a5GL9j>y G=o0|rc2>Xu delta 298 zcmZqSoyDWRoR^o20SKZRnlkHH7#JRdI4~dpWqfX%sP4|g$iT#q%9z5E%ACSFiBWXp zWJyN3be<^w6xm<~O}UpKwVI5#SPBwLGF~!*I6RY|Fz(yDiAjf%*-uko@( wT#`UJkbLpx$tzi8>UkNNJ}?X%!HaCW!scjJW=4SzlDzy(4cs7DBo1^b0JFzEnE(I) diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index 2f4097ce02df1284c4e62a5585a1dc30263bcb38..bed590abd2fa6b3ef13f87716f35dda06e07dd95 100644 GIT binary patch delta 16272 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

+ZibL zpjp5X0ObHTm31?*mkj(#C=HdF>9N|Ub7D=julCJA&CMI;D$1_5O|=EFp61I)n}`)S zhAM+p8xlut(`q;oOW{Im1AAp&7R$58xLdlSb^uto;HB|A@bsK;#Ryk-;J#FS6X5YQ zwDH!rAwGDntQhe7H6u`n-T@72GSJW!H+WM$;YG3Fgs3ncyG}1ay zqejz+RCt28Q&YoKnsI3M71H4!D>14fvEoc%*i#o-8$&E9u1=j^v&O-R_ zS+N``R`Fz(5^1a7uz;|BZ*4rno=G0v{~|P}2*79z$PsFVHKTSU4kR8Vc)rT`jH{+` zQs?xD-0tJBT@4bm{<7 z^i57pkI(EqGBF2W^aHopv^Fu1FSn@%LXNM6IfSW+ucPY@oL7vLKCF$-wW zhaYP<8G&17zscdqGPh#3;pxy5N5JLkG0n4{Skc{V@Xu|UblaNR*{7BGm>J%(Xx8uP!T*9w z;XFM*t>ZR?Tu1o9j7vomo>`^n)WJ#F#TsQEwfU5#23?^o(Un3KU2!GD7s#kD&<@#a&o%a{yA#74 z{8IRH?if40$1CckVh5)UC?wmN~%x=5fQ^Jps{CdK`kHv$Y3E zt@u)agT=Mm?~Xl{X$Wa6ghks=09*kOBf(!^VAEg6jn9(^-gP+-*DRHVgo6ji6O5V_ z9Foe>g=gCpQ$4PN9wO}iBQ2|NE*&er37UjBeJl~nCC|oCa)Y|f;Cc^F1|GhI0!nfm z9(a82WuxxU^K-*XcY>T(tb3t#EWZ#twBm~C2R2E#@pxMtq_`Aux9-bxqqM4Ee+^0> zfLh7bukHhG94c!2Hn(06obkEfH1uiW>=o>rpU~3Aup|iny&#dJQ^06Hg&RYJ+(qGq z`_7B7G2rhY?cala_Rbpv0o)(HH3AGb&YwMU`xY^n^j^@vKd1$cRaOh)KK1*=QonKq znOFgw8i=i=2F_?^8m`-CHTy_2uUXiav>@Cj!3}?a*w-3j`&aD<9w!m*Dg(71cy<54 zuELAq%B)~Nxvog{vx5E9&K~i#L=OwOI84q{np{`it$>GRKOF4?FJ6Mb9vR`k2X(u; zV5#%tIWx0eaQc1MU=gq5Lh=M-HM1WV0-H2}Z+5>X!IT0GQw&~dIAG-|8ez+a*b^5f z@8LU;Z}AEyDcVU=nWrfo5sbrlMVV_7xNkzswV%Txa7meeb#p&rAnHak0R$pU^_|gs zv1%Ne(e>=%)uz=+AdfurBY;SydIW0F6KVyHYvR@_dMUz|Ph-IA zP*TTT)oJa*3Gzv=hn!-C=K5IyS~6&OT&i9-g=;I2I`RU67&5zPG4yb|yQOHzD@Dsv zCZPgW%+fPBmtP=x8Ob-<gqnq{4t9-N7u{P=s=@ zE_%E9d~QLe8}rmL`>Qq?6r0EjVW_|p>x{S>;&jR<$L{Wv0eQ91(Ung+#J zG~w(O{r^$ujud5*oKIyF8Oq`7K5X)FBtJp&8zgTb!9XE*eL14zmY?f8u4lO1XtE0# zQCeKMZ(Htxc`av>qx2{=hL4~O8d&p_dCy%pg}M)W{>j|*>`md1{``v&y;=WZPdwGT zIwan4OQ?{O;ssG06K@H1KV!yKQQUD$sG&QJ+r_{wp+e4y4sj>6tD$WnvG0~pA;bGc yaRNB%ejYkuxK|Y8w}cvc$Pg6ox+PS|pi8{_mQW!vpV)CrsF1#XY<@f8!u@}Dbqr(x diff --git a/core/forms.py b/core/forms.py index 17cd42a..3a149ed 100644 --- a/core/forms.py +++ b/core/forms.py @@ -1,5 +1,6 @@ from django import forms -from .models import WorkLog, Project, Worker, Team +from django.forms import inlineformset_factory +from .models import WorkLog, Project, Worker, Team, ExpenseReceipt, ExpenseLineItem class WorkLogForm(forms.ModelForm): end_date = forms.DateField( @@ -56,4 +57,27 @@ class WorkLogForm(forms.ModelForm): else: self.fields['project'].queryset = projects_qs self.fields['workers'].queryset = workers_qs - self.fields['team'].queryset = teams_qs \ No newline at end of file + self.fields['team'].queryset = teams_qs + +class ExpenseReceiptForm(forms.ModelForm): + class Meta: + model = ExpenseReceipt + fields = ['date', 'vendor', 'description', 'payment_method', 'vat_type'] + widgets = { + 'date': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), + 'vendor': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Vendor Name'}), + 'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 2}), + 'payment_method': forms.Select(attrs={'class': 'form-control'}), + 'vat_type': forms.RadioSelect(attrs={'class': 'form-check-input'}), + } + +ExpenseLineItemFormSet = inlineformset_factory( + ExpenseReceipt, ExpenseLineItem, + fields=['product', 'amount'], + extra=1, + can_delete=True, + widgets={ + 'product': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Item Name'}), + 'amount': forms.NumberInput(attrs={'class': 'form-control item-amount', 'step': '0.01'}), + } +) diff --git a/core/migrations/0007_expensereceipt_expenselineitem.py b/core/migrations/0007_expensereceipt_expenselineitem.py new file mode 100644 index 0000000..91bd871 --- /dev/null +++ b/core/migrations/0007_expensereceipt_expenselineitem.py @@ -0,0 +1,42 @@ +# Generated by Django 5.2.7 on 2026-02-04 13:11 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0006_alter_payrolladjustment_type'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ExpenseReceipt', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField(default=django.utils.timezone.now)), + ('vendor', models.CharField(max_length=200)), + ('description', models.TextField(blank=True)), + ('payment_method', models.CharField(choices=[('CASH', 'Cash'), ('CARD', 'Card'), ('EFT', 'EFT'), ('OTHER', 'Other')], default='CARD', max_length=10)), + ('vat_type', models.CharField(choices=[('INCLUDED', 'VAT Included'), ('EXCLUDED', 'VAT Excluded'), ('NONE', 'No VAT')], default='INCLUDED', max_length=10)), + ('subtotal', models.DecimalField(decimal_places=2, default=0, max_digits=12)), + ('vat_amount', models.DecimalField(decimal_places=2, default=0, max_digits=12)), + ('total_amount', models.DecimalField(decimal_places=2, default=0, max_digits=12)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='generated_receipts', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='ExpenseLineItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('product', models.CharField(max_length=255, verbose_name='Product/Item')), + ('amount', models.DecimalField(decimal_places=2, max_digits=10)), + ('receipt', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='core.expensereceipt')), + ], + ), + ] diff --git a/core/migrations/__pycache__/0007_expensereceipt_expenselineitem.cpython-311.pyc b/core/migrations/__pycache__/0007_expensereceipt_expenselineitem.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..499f0df97465a4fe25ac75f278d1412ab525b729 GIT binary patch literal 3098 zcmbVOO>h&*6&{VG(T`OI32naSy3lAE_KjQmX@p-6{@y;E;olt*SYw`jo1id=mwyy(F(^Bw=F?Br}>f zuitz9`s?@8qrVLf206I?lK-RpPd~@~n+iZ1)j+izZR*E~g! zi|O2ow6M$aR~`2>Wcfm~m0RflX6Z z%SMK$!J1swB~#W^g91qU-a3weRPONZjc13c0`yJ^1$syLeuvgJpS1Cy1^sAZ8^|&#^F$Pu*X-2`rL4j zYX%Ol4jzKGhj7mV4KzbXYzA$cNZ-cO96Dk%eArK!L*c)=Py~%Mj~$LTtT}qb_Bhy{ z_@HgnwjJrO{RxVplg;=MpJN|sm-tA#Q?^|%zmS7w^2nZ3w#{e{8$|zjkFN&$+=)IH z?Qtl5up*kDzQ3FDKy&;#54ncNxnL#ty9!39|A!j+jg9hMhCf4Rnr9Dthn!?=tAlYK zX-*umI`@C?_&l2Yz&<_i?-Tmr6O@I1%+bKjUhhF8dPar*)Cf3Eew5@B3 zBB5%-Fl$&fMRU83=}>;Fj#UFM;}VwZCgo+cGEe#Ia~Tf}NexpIOFEWJjOajJmur%~ zEk4HEG+HB+-7?1~R(w3-T zwQN@C0K!H|hgIRc0F`%Lk<`a@2$0(NYS^r3DC1$dw!v z=n3YXr{1NNYx!k(nH8+-aS-maVSi z0E8igOAtXttSeFvi4zYbmu1tSK?X>wX${q+A=^oB6znEJluQVelNVKcB1#BRPXls6 zN6J{maP(p%>P{*RSTdlgA{@flgf!_`vDJ2(cvFL&d2C+Tkg{@ z$~E!M?fkNMb7?MLprO8nj14+4Ygh11_Bqno*O7+4lFKtt+w*MWG%yEoaaaLWCAF+k zZ^M)ogZfRmhIcd-Q#VAL24*Xg-kr08w@il(%{ggxMuW`R3QTi`!FkxWTvl&D&+urS z`t58$y;C2Q^z6)Bo(8o#OC0-V{)2l1@VH-Ba}}+IbB%SVghmc;;Szw=b5At=u~CQG z$+2MO-hFFx(C1%>hlAT2Dv(W+J7a{u#?Z)A71Tg{Os1hzlf{?o>OH{#WEUiB5dKX+~$LR zuWkC)rXY!I@a;&PBqn!Pt;8e5V9NpilGoVSwmB)nk9a)QNj;>GGuYu2T_ zb!pMMv`X+cVEWFrzyN;d`U69F@-D*+6huH7<~6<(4UGHPE~ z(s`1Mo!_0e#?HTV?`6r@T5D|08e1dbqMgVrOJw$?wD%Q>FSX)JR(y$sziCHc)+C!X z`BL~LU|qRpUAbjlStAt4c za`u`l!2Rl1!}#e@%_2Kss)h%LQHJN)s;S{My_Y>HXS0;Q{Sg!hPV^5z;Q9 V!yP5x2d(hw*G%#D16mv5e*qqPM9=^L literal 0 HcmV?d00001 diff --git a/core/models.py b/core/models.py index 1f48574..ce5f02d 100644 --- a/core/models.py +++ b/core/models.py @@ -101,4 +101,42 @@ class PayrollAdjustment(models.Model): type = models.CharField(max_length=20, choices=ADJUSTMENT_TYPES, default='DEDUCTION') def __str__(self): - return f"{self.get_type_display()} - {self.amount} for {self.worker.name}" \ No newline at end of file + return f"{self.get_type_display()} - {self.amount} for {self.worker.name}" + +class ExpenseReceipt(models.Model): + VAT_CHOICES = [ + ('INCLUDED', 'VAT Included'), + ('EXCLUDED', 'VAT Excluded'), + ('NONE', 'No VAT'), + ] + PAYMENT_METHODS = [ + ('CASH', 'Cash'), + ('CARD', 'Card'), + ('EFT', 'EFT'), + ('OTHER', 'Other'), + ] + + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='generated_receipts') + date = models.DateField(default=timezone.now) + vendor = models.CharField(max_length=200) + description = models.TextField(blank=True) + payment_method = models.CharField(max_length=10, choices=PAYMENT_METHODS, default='CARD') + vat_type = models.CharField(max_length=10, choices=VAT_CHOICES, default='INCLUDED') + + # Financials (Stored for record keeping) + subtotal = models.DecimalField(max_digits=12, decimal_places=2, default=0) + vat_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0) + total_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0) + + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"Receipt from {self.vendor} - {self.date}" + +class ExpenseLineItem(models.Model): + receipt = models.ForeignKey(ExpenseReceipt, on_delete=models.CASCADE, related_name='items') + product = models.CharField(max_length=255, verbose_name="Product/Item") + amount = models.DecimalField(max_digits=10, decimal_places=2) + + def __str__(self): + return f"{self.product} - {self.amount}" diff --git a/core/templates/base.html b/core/templates/base.html index 65cf9e5..f1305e7 100644 --- a/core/templates/base.html +++ b/core/templates/base.html @@ -12,6 +12,8 @@ + + + + +

+
+
RECEIPT FROM
+
{{ receipt.vendor }}
+
+ +
+ Date: {{ receipt.date }}
+ Payment Method: {{ receipt.get_payment_method_display }}
+ Description: {{ receipt.description|default:"-" }} +
+ + + + + + + + + + {% for item in items %} + + + + + {% endfor %} + +
ItemAmount
{{ item.product }}R {{ item.amount }}
+ +
+

Subtotal: R {{ receipt.subtotal }}

+

VAT ({{ receipt.get_vat_type_display }}): R {{ receipt.vat_amount }}

+

Total: R {{ receipt.total_amount }}

+
+ + +
+ + diff --git a/core/templates/registration/login.html b/core/templates/registration/login.html new file mode 100644 index 0000000..85d56e9 --- /dev/null +++ b/core/templates/registration/login.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} +{% block title %}Login - Fox Fitt{% endblock %} + +{% block content %} +
+
+
+
+
+
+

Fox Fitt

+

Sign in to manage work & payroll

+
+ + {% if form.errors %} +
+ Your username and password didn't match. Please try again. +
+ {% endif %} + + {% if next %} + {% if user.is_authenticated %} +
+ Your account doesn't have access to this page. To proceed, + please login with an account that has access. +
+ {% else %} +
+ Please login to see this page. +
+ {% endif %} + {% endif %} + +
+ {% csrf_token %} +
+ + +
+
+ + +
+ +
+ +
+ + +
+
+
+ +
+

Forgot your password? Please contact your administrator.

+
+
+
+
+{% endblock %} diff --git a/core/urls.py b/core/urls.py index 0bc5561..3b34f7b 100644 --- a/core/urls.py +++ b/core/urls.py @@ -11,7 +11,8 @@ from .views import ( payslip_detail, loan_list, add_loan, - add_adjustment + add_adjustment, + create_receipt ) urlpatterns = [ @@ -27,4 +28,5 @@ urlpatterns = [ path("loans/", loan_list, name="loan_list"), path("loans/add/", add_loan, name="add_loan"), path("payroll/adjustment/add/", add_adjustment, name="add_adjustment"), -] + path("receipts/create/", create_receipt, name="create_receipt"), +] \ No newline at end of file diff --git a/core/views.py b/core/views.py index 91a8460..1e77768 100644 --- a/core/views.py +++ b/core/views.py @@ -6,19 +6,32 @@ import calendar import datetime from django.shortcuts import render, redirect, get_object_or_404 from django.utils import timezone -from django.contrib.auth.decorators import login_required +from django.contrib.auth.decorators import login_required, user_passes_test from django.db.models import Sum, Q, Prefetch from django.core.mail import send_mail from django.conf import settings from django.contrib import messages from django.http import JsonResponse, HttpResponse -from .models import Worker, Project, Team, WorkLog, PayrollRecord, Loan, PayrollAdjustment -from .forms import WorkLogForm +from django.template.loader import render_to_string +from django.utils.html import strip_tags +from .models import Worker, Project, Team, WorkLog, PayrollRecord, Loan, PayrollAdjustment, ExpenseReceipt, ExpenseLineItem +from .forms import WorkLogForm, ExpenseReceiptForm, ExpenseLineItemFormSet from datetime import timedelta from decimal import Decimal +def is_staff_or_supervisor(user): + """Check if user is staff or manages at least one team/project.""" + if user.is_staff or user.is_superuser: + return True + return user.managed_teams.exists() or user.assigned_projects.exists() + +@login_required def home(request): """Render the landing screen with dashboard stats.""" + # If not staff or supervisor, redirect to log attendance + if not is_staff_or_supervisor(request.user): + return redirect('log_attendance') + workers_count = Worker.objects.count() projects_count = Project.objects.count() teams_count = Team.objects.count() @@ -66,6 +79,7 @@ def home(request): } return render(request, "core/index.html", context) +@login_required def log_attendance(request): # Build team workers map for frontend JS (needed for both GET and POST if re-rendering) teams_qs = Team.objects.filter(is_active=True) @@ -183,6 +197,7 @@ def log_attendance(request): msg += f" Overwrote {overwritten_count} previous entries." messages.success(request, msg) + # Redirect to home, which will then redirect back to log_attendance if restricted return redirect('home') else: form = WorkLogForm(user=request.user if request.user.is_authenticated else None) @@ -194,8 +209,12 @@ def log_attendance(request): return render(request, 'core/log_attendance.html', context) +@login_required def work_log_list(request): """View work log history with advanced filtering.""" + if not is_staff_or_supervisor(request.user): + return redirect('log_attendance') + worker_id = request.GET.get('worker') team_id = request.GET.get('team') project_id = request.GET.get('project') @@ -323,8 +342,12 @@ def work_log_list(request): return render(request, 'core/work_log_list.html', context) +@login_required def export_work_log_csv(request): """Export filtered work logs to CSV.""" + if not is_staff_or_supervisor(request.user): + return HttpResponse("Unauthorized", status=401) + worker_id = request.GET.get('worker') team_id = request.GET.get('team') project_id = request.GET.get('project') @@ -383,8 +406,12 @@ def export_work_log_csv(request): return response +@login_required def manage_resources(request): """View to manage active status of resources.""" + if not request.user.is_staff and not request.user.is_superuser: + return redirect('log_attendance') + # Prefetch teams for workers to avoid N+1 in template workers = Worker.objects.all().prefetch_related('teams').order_by('name') projects = Project.objects.all().order_by('name') @@ -397,8 +424,12 @@ def manage_resources(request): } return render(request, 'core/manage_resources.html', context) +@login_required def toggle_resource_status(request, model_type, pk): """Toggle the is_active status of a resource.""" + if not request.user.is_staff and not request.user.is_superuser: + return redirect('log_attendance') + if request.method == 'POST': model_map = { 'worker': Worker, @@ -416,13 +447,17 @@ def toggle_resource_status(request, model_type, pk): return JsonResponse({ 'success': True, 'is_active': obj.is_active, - 'message': f"{obj.name} is now {'Active' if obj.is_active else 'Inactive'}ணை." + 'message': f"{obj.name} is now {'Active' if obj.is_active else 'Inactive'}." }) return redirect('manage_resources') +@login_required def payroll_dashboard(request): """Dashboard for payroll management with filtering.""" + if not request.user.is_staff and not request.user.is_superuser: + return redirect('log_attendance') + status_filter = request.GET.get('status', 'pending') # pending, paid, all # Common Analytics @@ -501,8 +536,12 @@ def payroll_dashboard(request): } return render(request, 'core/payroll_dashboard.html', context) +@login_required def process_payment(request, worker_id): """Process payment for a worker, mark logs as paid, link adjustments, and email receipt.""" + if not request.user.is_staff and not request.user.is_superuser: + return redirect('log_attendance') + worker = get_object_or_404(Worker, pk=worker_id) if request.method == 'POST': @@ -574,8 +613,12 @@ def process_payment(request, worker_id): return redirect('payroll_dashboard') +@login_required def payslip_detail(request, pk): """Show details of a payslip (Payment Record).""" + if not request.user.is_staff and not request.user.is_superuser: + return redirect('log_attendance') + record = get_object_or_404(PayrollRecord, pk=pk) # Get the logs included in this payment @@ -597,8 +640,12 @@ def payslip_detail(request, pk): } return render(request, 'core/payslip.html', context) +@login_required def loan_list(request): """List outstanding and historical loans.""" + if not request.user.is_staff and not request.user.is_superuser: + return redirect('log_attendance') + filter_status = request.GET.get('status', 'active') # active, history if filter_status == 'history': @@ -613,8 +660,12 @@ def loan_list(request): } return render(request, 'core/loan_list.html', context) +@login_required def add_loan(request): """Create a new loan.""" + if not request.user.is_staff and not request.user.is_superuser: + return redirect('log_attendance') + if request.method == 'POST': worker_id = request.POST.get('worker') amount = request.POST.get('amount') @@ -633,8 +684,12 @@ def add_loan(request): return redirect('loan_list') +@login_required def add_adjustment(request): """Add a payroll adjustment (Bonus, Overtime, Deduction, Repayment).""" + if not request.user.is_staff and not request.user.is_superuser: + return redirect('log_attendance') + if request.method == 'POST': worker_id = request.POST.get('worker') adj_type = request.POST.get('type') @@ -677,3 +732,73 @@ def add_adjustment(request): messages.success(request, f"{adj_type} of R{amount} added for {worker.name}.") return redirect('payroll_dashboard') + +@login_required +def create_receipt(request): + """Create a new expense receipt and email it.""" + if not is_staff_or_supervisor(request.user): + return redirect('log_attendance') + + if request.method == 'POST': + form = ExpenseReceiptForm(request.POST) + items = ExpenseLineItemFormSet(request.POST) + + if form.is_valid() and items.is_valid(): + receipt = form.save(commit=False) + receipt.user = request.user + receipt.save() + + items.instance = receipt + line_items = items.save() + + # Backend Calculation for Consistency + sum_amount = sum(item.amount for item in line_items) + vat_type = receipt.vat_type + + if vat_type == 'INCLUDED': + receipt.total_amount = sum_amount + receipt.subtotal = sum_amount / Decimal('1.15') + receipt.vat_amount = receipt.total_amount - receipt.subtotal + elif vat_type == 'EXCLUDED': + receipt.subtotal = sum_amount + receipt.vat_amount = sum_amount * Decimal('0.15') + receipt.total_amount = receipt.subtotal + receipt.vat_amount + else: # NONE + receipt.subtotal = sum_amount + receipt.vat_amount = Decimal('0.00') + receipt.total_amount = sum_amount + + receipt.save() + + # Email Generation + subject = f"Receipt from {receipt.vendor} - {receipt.date}" + recipient_list = ['foxfitt-ed9wc+expense@to.sparkreceipt.com'] + + # Prepare HTML content + html_message = render_to_string('core/email/receipt_email.html', { + 'receipt': receipt, + 'items': line_items, + }) + plain_message = strip_tags(html_message) + + try: + send_mail( + subject, + plain_message, + settings.DEFAULT_FROM_EMAIL, + recipient_list, + html_message=html_message + ) + messages.success(request, "Receipt created and sent to SparkReceipt.") + return redirect('create_receipt') + except Exception as e: + messages.warning(request, f"Receipt saved, but email failed: {e}") + + else: + form = ExpenseReceiptForm(initial={'date': timezone.now().date()}) + items = ExpenseLineItemFormSet() + + return render(request, 'core/create_receipt.html', { + 'form': form, + 'items': items + }) \ No newline at end of file