diff --git a/company_site/company_site/settings.py b/company_site/company_site/settings.py index 262f300..4e5f6db 100644 --- a/company_site/company_site/settings.py +++ b/company_site/company_site/settings.py @@ -33,6 +33,7 @@ ALLOWED_HOSTS = ["*"] INSTALLED_APPS = [ 'public.apps.PublicConfig', 'financial.apps.FinancialConfig', + 'planning.apps.PlanningConfig', 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', @@ -146,4 +147,8 @@ EMAIL_HOST = 'mail.smtp2go.com' EMAIL_HOST_USER = 'info.aimloperations.com' EMAIL_HOST_PASSWORD = 'ZDErIII2sipNNVMz' EMAIL_PORT = 2525 -EMAIL_USE_TLS = True \ No newline at end of file +EMAIL_USE_TLS = True + +# Authentication Redirects +LOGIN_REDIRECT_URL = '/' +LOGOUT_REDIRECT_URL = '/' \ No newline at end of file diff --git a/company_site/company_site/urls.py b/company_site/company_site/urls.py index 83dc8b0..2faedbe 100644 --- a/company_site/company_site/urls.py +++ b/company_site/company_site/urls.py @@ -21,5 +21,7 @@ from django.urls import include, path urlpatterns = [ path("public/", include("public.urls")), path("financial/", include("financial.urls")), + path("planning/", include("planning.urls")), + path("accounts/", include("django.contrib.auth.urls")), path('admin/', admin.site.urls), ] diff --git a/company_site/financial/apps.py b/company_site/financial/apps.py index be0fbfd..fa0da7d 100644 --- a/company_site/financial/apps.py +++ b/company_site/financial/apps.py @@ -4,3 +4,6 @@ from django.apps import AppConfig class FinancialConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'financial' + + def ready(self): + import financial.signals # noqa: F401 diff --git a/company_site/financial/forms.py b/company_site/financial/forms.py index 7d8968c..86909b2 100644 --- a/company_site/financial/forms.py +++ b/company_site/financial/forms.py @@ -52,7 +52,7 @@ class ContractForm(ModelForm): class ChargeNumberForm(ModelForm): class Meta: model = ChargeNumber - fields = ["charge_number_type","amount", "start_date","end_date"] + fields = ["charge_number_type","amount", "budget_hours", "percent_complete", "start_date","end_date"] class TimeLogForm(ModelForm): start_time = forms.TimeField(required=False, widget=forms.TimeInput(attrs={'type': 'time'})) @@ -62,7 +62,7 @@ class TimeLogForm(ModelForm): class Meta: model = TimeCardCell - fields = ["contract", "date", "start_time", "end_time", "hour"] + fields = ["charge_number", "date", "start_time", "end_time", "hour"] def clean(self): cleaned_data = super().clean() diff --git a/company_site/financial/migrations/0011_chargenumber_budget_hours.py b/company_site/financial/migrations/0011_chargenumber_budget_hours.py new file mode 100644 index 0000000..114aecf --- /dev/null +++ b/company_site/financial/migrations/0011_chargenumber_budget_hours.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0 on 2026-03-23 07:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('financial', '0010_alter_employee_manager_alter_employee_phonenumber'), + ] + + operations = [ + migrations.AddField( + model_name='chargenumber', + name='budget_hours', + field=models.FloatField(default=0.0), + ), + ] diff --git a/company_site/financial/migrations/0012_remove_timecardcell_contract_and_more.py b/company_site/financial/migrations/0012_remove_timecardcell_contract_and_more.py new file mode 100644 index 0000000..9cba46f --- /dev/null +++ b/company_site/financial/migrations/0012_remove_timecardcell_contract_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0 on 2026-03-23 07:43 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('financial', '0011_chargenumber_budget_hours'), + ] + + operations = [ + migrations.RemoveField( + model_name='timecardcell', + name='contract', + ), + migrations.AddField( + model_name='timecardcell', + name='charge_number', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='financial.chargenumber'), + ), + ] diff --git a/company_site/financial/migrations/0013_alter_employee_primaryaddress_and_more.py b/company_site/financial/migrations/0013_alter_employee_primaryaddress_and_more.py new file mode 100644 index 0000000..b6eac9b --- /dev/null +++ b/company_site/financial/migrations/0013_alter_employee_primaryaddress_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 5.0 on 2026-03-23 08:22 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('financial', '0012_remove_timecardcell_contract_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='employee', + name='primaryAddress', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='primary_address_employee', to='financial.addressmodel'), + ), + migrations.AlterField( + model_name='employee', + name='workAddress', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='work_address_employee', to='financial.addressmodel'), + ), + ] diff --git a/company_site/financial/models.py b/company_site/financial/models.py index 6c51cec..3920344 100644 --- a/company_site/financial/models.py +++ b/company_site/financial/models.py @@ -4,7 +4,9 @@ from phonenumber_field.modelfields import PhoneNumberField from django.utils import timezone from django_enum import EnumField from django.utils.text import slugify +from django.db.models import Sum import datetime +from dateutil.relativedelta import relativedelta def user_str(self): if self.first_name and self.last_name: @@ -62,7 +64,124 @@ class Contract(IdMixin, TimeMixin): def __str__(self): return f"{self.name} - {self.contract_type}" - # TODO: make calc ev func + @property + def ticket_percent_complete(self): + charge_numbers = self.chargenumber_set.all() + total_hours = sum(cn.budget_hours for cn in charge_numbers) + if total_hours == 0: + return 0.0 + + weighted_sum = sum(cn.budget_hours * cn.get_percent_complete for cn in charge_numbers) + return weighted_sum / total_hours + + def get_evm_data(self): + """Compute all Earned Value Management metrics for this contract.""" + today = timezone.now().date() + + # --- Budget at Completion (BAC) --- + bac_hours = self.budget_hours or 0.0 + bac_dollars = self.baseline_amount if self.baseline_amount > 0 else (self.funded_amount or 0.0) + + # --- Time fraction elapsed --- + if self.baseline_start and self.baseline_end and self.baseline_end > self.baseline_start: + total_days = (self.baseline_end - self.baseline_start).days + elapsed_days = max(0, (min(today, self.baseline_end) - self.baseline_start).days) + time_fraction = min(1.0, elapsed_days / total_days) if total_days > 0 else 0.0 + else: + total_days = 0 + elapsed_days = 0 + time_fraction = 0.0 + + # --- Planned Value (PV) --- + pv_hours = bac_hours * time_fraction + pv_dollars = bac_dollars * time_fraction + + # --- Earned Value (EV) --- + percent_complete = self.ticket_percent_complete # 0-100 + ev_hours = bac_hours * (percent_complete / 100.0) + ev_dollars = bac_dollars * (percent_complete / 100.0) + + # --- Actual Cost (AC) --- + from financial.models import TimeCardCell # avoid circular at module level + cells = TimeCardCell.objects.filter( + charge_number__contract=self + ).select_related('timeCard__employee') + ac_hours = cells.aggregate(total=Sum('hour'))['total'] or 0.0 + ac_dollars = sum( + (cell.hour or 0.0) * (cell.timeCard.employee.hourly_salary if cell.timeCard and cell.timeCard.employee else 0.0) + for cell in cells + ) + + # --- Variances --- + sv_hours = ev_hours - pv_hours + sv_dollars = ev_dollars - pv_dollars + cv_hours = ev_hours - ac_hours + cv_dollars = ev_dollars - ac_dollars + + # --- Performance Indices --- + spi = (ev_dollars / pv_dollars) if pv_dollars > 0 else 0.0 + cpi = (ev_dollars / ac_dollars) if ac_dollars > 0 else 0.0 + spi_hours = (ev_hours / pv_hours) if pv_hours > 0 else 0.0 + cpi_hours = (ev_hours / ac_hours) if ac_hours > 0 else 0.0 + + # --- Monthly Time-Series for S-Curve --- + time_series = [] + if self.baseline_start and self.baseline_end and total_days > 0: + # Build cumulative AC by month + ac_by_month = {} + for cell in cells: + if cell.date: + key = cell.date.strftime('%Y-%m') + cost = (cell.hour or 0.0) * ( + cell.timeCard.employee.hourly_salary if cell.timeCard and cell.timeCard.employee else 0.0 + ) + ac_by_month[key] = ac_by_month.get(key, 0.0) + cost + + cursor = self.baseline_start.replace(day=1) + end_limit = max(self.baseline_end, today) + cumulative_ac = 0.0 + + while cursor <= end_limit: + month_key = cursor.strftime('%Y-%m') + # PV at this point in time + days_into = max(0, (cursor - self.baseline_start).days) + pv_at = bac_dollars * min(1.0, days_into / total_days) + # Cumulative AC + cumulative_ac += ac_by_month.get(month_key, 0.0) + # EV: proportional to current % complete, scaled by time + if cursor <= today: + ev_at = ev_dollars * min(1.0, days_into / max(1, (today - self.baseline_start).days)) if (today - self.baseline_start).days > 0 else 0.0 + else: + ev_at = ev_dollars # flat after today + + time_series.append({ + 'month': month_key, + 'pv': round(pv_at, 2), + 'ev': round(ev_at, 2), + 'ac': round(cumulative_ac, 2), + }) + cursor += relativedelta(months=1) + + return { + 'bac_hours': round(bac_hours, 2), + 'bac_dollars': round(bac_dollars, 2), + 'pv_hours': round(pv_hours, 2), + 'pv_dollars': round(pv_dollars, 2), + 'ev_hours': round(ev_hours, 2), + 'ev_dollars': round(ev_dollars, 2), + 'ac_hours': round(ac_hours, 2), + 'ac_dollars': round(ac_dollars, 2), + 'sv_hours': round(sv_hours, 2), + 'sv_dollars': round(sv_dollars, 2), + 'cv_hours': round(cv_hours, 2), + 'cv_dollars': round(cv_dollars, 2), + 'spi': round(spi, 2), + 'cpi': round(cpi, 2), + 'spi_hours': round(spi_hours, 2), + 'cpi_hours': round(cpi_hours, 2), + 'percent_complete': round(percent_complete, 2), + 'time_series': time_series, + } class ChargeNumber(IdMixin, TimeMixin): class ChargeNumberTypeEnum(models.TextChoices): @@ -76,11 +195,45 @@ class ChargeNumber(IdMixin, TimeMixin): contract = models.ForeignKey(Contract, on_delete=models.CASCADE) amount = models.FloatField(default=0.0) + budget_hours = models.FloatField(default=0.0) percent_complete = models.FloatField(default=0.0) # TODO: add validator to make sure the range is 0.0 - 100 start_date = models.DateField(null=True, blank=True, default = None) end_date = models.DateField(null=True, blank=True, default = None) + def __str__(self): + return self.slug or f"CN-{self.id}" + + @property + def get_percent_complete(self): + if self.charge_number_type == self.ChargeNumberTypeEnum.QBD: + total = self.tickets.count() + if total == 0: + return 0.0 + done = self.tickets.filter(status='DONE').count() + return (done / total) * 100.0 + return self.percent_complete + + def clean(self): + from django.core.exceptions import ValidationError + super().clean() + + if self.charge_number_type == "0_100": + if self.percent_complete not in [0.0, 100.0]: + raise ValidationError({"percent_complete": "For ZERO_ONE_HUNDRED, percent complete must be 0 or 100."}) + if self.start_date and self.end_date: + delta = self.end_date - self.start_date + if delta.days > 31: + raise ValidationError({"end_date": "Duration cannot be more than a month for ZERO_ONE_HUNDRED."}) + + if self.charge_number_type == "50_50": + if self.percent_complete not in [0.0, 50.0, 100.0]: + raise ValidationError({"percent_complete": "For FIFTY_FIFTY, percent complete must be 0, 50, or 100."}) + if self.start_date and self.end_date: + delta = self.end_date - self.start_date + if delta.days > 62: + raise ValidationError({"end_date": "Duration cannot be more than two months for FIFTY_FIFTY."}) + class AddressModel(models.Model): address_1 = models.CharField(max_length=128) address_2 = models.CharField(max_length=128, blank=True) @@ -92,8 +245,8 @@ class AddressModel(models.Model): class Employee(IdMixin, TimeMixin): manager = models.ForeignKey("self", on_delete=models.CASCADE, related_name="manager_employee", null=True, blank=True) user = models.OneToOneField(User, on_delete=models.CASCADE) - primaryAddress = models.ForeignKey(AddressModel, on_delete=models.CASCADE, related_name="primary_address_employee") - workAddress = models.ForeignKey(AddressModel, on_delete=models.CASCADE, related_name="work_address_employee") + primaryAddress = models.ForeignKey(AddressModel, on_delete=models.CASCADE, related_name="primary_address_employee", null=True, blank=True) + workAddress = models.ForeignKey(AddressModel, on_delete=models.CASCADE, related_name="work_address_employee", null=True, blank=True) phoneNumber = PhoneNumberField(null=True, blank=True, unique=True) slary= models.FloatField(default=0.0) @@ -102,7 +255,7 @@ class Employee(IdMixin, TimeMixin): @property def hourly_salary(self): - return (self.slary or 0.0) / 2080.0 + return (self.slary or 0.0) / 2040.0 # TODO: roles, jpbTitles @@ -122,7 +275,7 @@ class TimeCardCell(IdMixin, TimeMixin): start_time = models.TimeField(null=True, blank=True) end_time = models.TimeField(null=True, blank=True) hour = models.FloatField(default = 0.0) - contract = models.ForeignKey(Contract, on_delete=models.CASCADE, null=True, blank=True) + charge_number = models.ForeignKey(ChargeNumber, on_delete=models.CASCADE, null=True, blank=True) diff --git a/company_site/financial/signals.py b/company_site/financial/signals.py new file mode 100644 index 0000000..e5a62c9 --- /dev/null +++ b/company_site/financial/signals.py @@ -0,0 +1,11 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.contrib.auth.models import User + + +@receiver(post_save, sender=User) +def create_employee_for_user(sender, instance, created, **kwargs): + """Auto-create an Employee record whenever a User is created.""" + from financial.models import Employee + if created: + Employee.objects.get_or_create(user=instance) diff --git a/company_site/financial/templates/financial/contract_detail.html b/company_site/financial/templates/financial/contract_detail.html index f18b4c0..436dc78 100644 --- a/company_site/financial/templates/financial/contract_detail.html +++ b/company_site/financial/templates/financial/contract_detail.html @@ -33,15 +33,143 @@
+ + + + {% if evm %} +

Earned Value Management

+ + +
+ +
+ BAC (Budget) + ${{ evm.bac_dollars|floatformat:2 }} + {{ evm.bac_hours }} hrs +
+ +
+ PV (Planned) + ${{ evm.pv_dollars|floatformat:2 }} + {{ evm.pv_hours }} hrs +
+ +
+ EV (Earned) + ${{ evm.ev_dollars|floatformat:2 }} + {{ evm.ev_hours }} hrs · {{ evm.percent_complete }}% done +
+ +
+ AC (Actual Cost) + ${{ evm.ac_dollars|floatformat:2 }} + {{ evm.ac_hours }} hrs spent +
+
+ + +
+ +
+ Schedule Variance + + ${{ evm.sv_dollars|floatformat:2 }} + + {% if evm.sv_dollars >= 0 %}Ahead{% else %}Behind{% endif %} schedule +
+ +
+ Cost Variance + + ${{ evm.cv_dollars|floatformat:2 }} + + {% if evm.cv_dollars >= 0 %}Under{% else %}Over{% endif %} budget +
+ +
+ SPI + + {{ evm.spi }} + + {% if evm.spi >= 1 %}On/ahead{% elif evm.spi > 0 %}Behind{% else %}No data{% endif %} +
+ +
+ CPI + + {{ evm.cpi }} + + {% if evm.cpi >= 1 %}Efficient{% elif evm.cpi > 0 %}Over-spending{% else %}No data{% endif %} +
+
+ + +
+ +
+

S-Curve (PV vs EV vs AC)

+ +
+ +
+

Performance Indices

+ +
+
+ {% endif %} + +
+

Charge Numbers

- {% if charge_numbes %} + {% if charge_numbers %} + {% if mermaid_gantt %} +
+
+{{ mermaid_gantt|safe }} +
+
+ {% endif %} +
-

put charge number table here

+
+ + + + + + + + + + + + + + {% for cn in charge_numbers %} + + + + + + + + + + {% endfor %} + +
Slug/IDTypeAmount% CompleteStart DateEnd DateActions
{{ cn.slug }}{{ cn.get_charge_number_type_display }}${{ cn.amount|floatformat:2 }} + + {{ cn.percent_complete|floatformat:0 }}% + + {{ cn.start_date|default:"-" }}{{ cn.end_date|default:"-" }} + Edit +
+

Create a new charge number

-
+ {% csrf_token %} {{ charge_number_form.as_p }} @@ -49,9 +177,8 @@
{% else %}
-

There are no charge numbers for this contract. -

- +

There are no charge numbers for this contract.

+ {% csrf_token %} {{ charge_number_form.as_p }} @@ -62,4 +189,213 @@ {% endif %}
+ + + + + +{% if evm_chart_json %} + + +{% endif %} {% endblock %} \ No newline at end of file diff --git a/company_site/financial/templates/financial/contracts.html b/company_site/financial/templates/financial/contracts.html index d0c151d..32074ae 100644 --- a/company_site/financial/templates/financial/contracts.html +++ b/company_site/financial/templates/financial/contracts.html @@ -32,6 +32,7 @@ Name Identifier Type + % Complete Start Date End Date Proposed Amount @@ -48,6 +49,11 @@ {{ contract.name }} {{ contract.slug }} {{ contract.contract_type }} + + + {{ contract.ticket_percent_complete|floatformat:0 }}% + + {{ contract.baseline_start }} {{ contract.baseline_end }} {{ contract.proposed_amount }} diff --git a/company_site/financial/templates/financial/time_logs.html b/company_site/financial/templates/financial/time_logs.html index 0386475..ac3ee9a 100644 --- a/company_site/financial/templates/financial/time_logs.html +++ b/company_site/financial/templates/financial/time_logs.html @@ -22,7 +22,7 @@ Employee - Contract + Charge Number Date Start Time End Time @@ -34,7 +34,7 @@ {% for log in logs %} {{ log.timeCard.employee }} - {{ log.contract }} + {{ log.charge_number }} {{ log.date }} {{ log.start_time|default_if_none:"" }} {{ log.end_time|default_if_none:"" }} diff --git a/company_site/financial/templates/financial/update_charge_number.html b/company_site/financial/templates/financial/update_charge_number.html new file mode 100644 index 0000000..ce91c2c --- /dev/null +++ b/company_site/financial/templates/financial/update_charge_number.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Update Charge Number - AI ML Operations{% endblock %} + +{% block content %} +
+
+
+ Back to {{ charge_number.contract.name }} +
+ +

Update Charge Number: {{ charge_number.slug }}

+ + {% if form.errors %} +
+
    + {% for field in form %} + {% for error in field.errors %} +
  • {{ field.label }}: {{ error }}
  • + {% endfor %} + {% endfor %} + {% for error in form.non_field_errors %} +
  • {{ error }}
  • + {% endfor %} +
+
+ {% endif %} + +
+ + {% csrf_token %} + {{ form.as_p }} + + +
+
+
+{% endblock %} diff --git a/company_site/financial/urls.py b/company_site/financial/urls.py index c09a196..0497286 100644 --- a/company_site/financial/urls.py +++ b/company_site/financial/urls.py @@ -13,7 +13,7 @@ urlpatterns = [ path("/contract_detail", views.contract_detail, name="contract_detail"), path("new_contract", views.new_contract, name="new_contract"), path("new_employee", views.new_employee, name="new_employee"), - path("new_charge_number", views.new_charge_number, name="new_charge_number"), + path("/new_charge_number", views.new_charge_number, name="new_charge_number"), path("/update_charge_number", views.update_charge_number, name="update_charge_number"), #path("contracts//", views.contract_detail, name="contract"), path("procurements", views.procurement, name="procurements"), diff --git a/company_site/financial/views.py b/company_site/financial/views.py index 69ae737..5d7185a 100644 --- a/company_site/financial/views.py +++ b/company_site/financial/views.py @@ -1,7 +1,7 @@ from django.shortcuts import render, redirect from django.contrib.auth.decorators import user_passes_test from .forms import EmployeeForm, ContractForm, ChargeNumberForm, TimeLogForm, NewEmployeeForm -from .models import Contract, TimeCard, TimeCardCell, Employee +from .models import Contract, ChargeNumber, TimeCard, TimeCardCell, Employee from django.utils import timezone from django.db.models import Sum from datetime import timedelta @@ -14,7 +14,7 @@ def is_admin(user): def index(request): contracts = Contract.objects.all() for c in contracts: - total = TimeCardCell.objects.filter(contract=c).aggregate(Sum('hour'))['hour__sum'] + total = TimeCardCell.objects.filter(charge_number__contract=c).aggregate(Sum('hour'))['hour__sum'] c.total_logged = total if total else 0.0 employees = Employee.objects.all() @@ -22,7 +22,7 @@ def index(request): for e in employees: contract_hours = [] for c in contracts: - total_e_c = TimeCardCell.objects.filter(contract=c, timeCard__employee=e).aggregate(Sum('hour'))['hour__sum'] + total_e_c = TimeCardCell.objects.filter(charge_number__contract=c, timeCard__employee=e).aggregate(Sum('hour'))['hour__sum'] contract_hours.append({'contract': c, 'hours': total_e_c if total_e_c else 0.0}) employee_data.append({'employee': e, 'contract_hours': contract_hours}) @@ -50,7 +50,7 @@ def contracts(request): chart_data_list = [] for c in contracts_list: - cells = TimeCardCell.objects.filter(contract=c).select_related('timeCard__employee').order_by('date') + cells = TimeCardCell.objects.filter(charge_number__contract=c).select_related('timeCard__employee').order_by('date') total_hours = sum((cell.hour or 0.0) for cell in cells) total_money = sum((cell.hour or 0.0) * cell.timeCard.employee.hourly_salary for cell in cells) @@ -105,6 +105,31 @@ def contracts(request): @user_passes_test(is_admin) def contract_detail(request, contract_slug): contract = Contract.objects.filter(slug=contract_slug).first() + + charge_numbers = contract.chargenumber_set.all() if contract else [] + mermaid_gantt_lines = [] + has_dates = False + for cn in charge_numbers: + if cn.start_date and cn.end_date: + has_dates = True + start = cn.start_date.strftime("%Y-%m-%d") + end = cn.end_date.strftime("%Y-%m-%d") + slug_label = cn.slug or f"ID-{cn.id}" + mermaid_gantt_lines.append(f" {slug_label} : {start}, {end}") + + mermaid_gantt = None + if has_dates: + lines_str = "\n".join(mermaid_gantt_lines) + mermaid_gantt = f"gantt\n title {contract.name} Charge Numbers Timeline\n dateFormat YYYY-MM-DD\n section Charge Numbers\n{lines_str}" + + # --- EVM Data --- + evm = contract.get_evm_data() if contract else {} + evm_chart_json = json.dumps({ + 'time_series': evm.get('time_series', []), + 'spi': evm.get('spi', 0), + 'cpi': evm.get('cpi', 0), + }) + if request.method == 'POST': form = ContractForm(request.POST, instance=contract) if form.is_valid(): @@ -113,7 +138,16 @@ def contract_detail(request, contract_slug): else: form = ContractForm(instance=contract) charge_number_form = ChargeNumberForm() - return render(request, 'financial/contract_detail.html', {'is_new': False, 'form': form, 'charge_number_form':charge_number_form, 'contract': contract}) + return render(request, 'financial/contract_detail.html', { + 'is_new': False, + 'form': form, + 'charge_number_form': charge_number_form, + 'contract': contract, + 'charge_numbers': charge_numbers, + 'mermaid_gantt': mermaid_gantt, + 'evm': evm, + 'evm_chart_json': evm_chart_json, + }) @user_passes_test(is_admin) def new_contract(request): @@ -131,11 +165,7 @@ def timekeeping(request): if request.method == "POST": form = TimeLogForm(request.POST) if form.is_valid(): - employee = Employee.objects.filter(user=request.user).first() - if not employee: - employee = Employee.objects.first() - if not employee: - return render(request, 'financial/timekeeping.html', {"form": form, "error": "No Employee exists. Cannot auto-create TimeCard. Please create an Employee first."}) + employee, _ = Employee.objects.get_or_create(user=request.user) time_card, _ = TimeCard.objects.get_or_create(employee=employee, startDate=timezone.now().date(), endDate=timezone.now().date()) cell = form.save(commit=False) @@ -179,18 +209,41 @@ def delete_time_log(request, log_id): def client_reports(request): contracts = Contract.objects.all() for c in contracts: - total = TimeCardCell.objects.filter(contract=c).aggregate(Sum('hour'))['hour__sum'] + total = TimeCardCell.objects.filter(charge_number__contract=c).aggregate(Sum('hour'))['hour__sum'] c.total_logged = total if total else 0.0 c.remaining_budget = c.budget_hours - c.total_logged return render(request, 'financial/reports.html', {'contracts': contracts}) @user_passes_test(is_admin) def update_charge_number(request, charge_number_slug): - return render(request, 'financial/not_created.html', {}) + charge_number = ChargeNumber.objects.filter(slug=charge_number_slug).first() + if not charge_number: + return redirect('contracts') + + if request.method == "POST": + form = ChargeNumberForm(request.POST, instance=charge_number) + if form.is_valid(): + form.save() + return redirect('contract_detail', contract_slug=charge_number.contract.slug) + else: + form = ChargeNumberForm(instance=charge_number) + + return render(request, 'financial/update_charge_number.html', { + 'form': form, + 'charge_number': charge_number, + }) @user_passes_test(is_admin) -def new_charge_number(request, charge_number_slug): - return render(request, 'financial/not_created.html', {}) +def new_charge_number(request, contract_slug): + contract = Contract.objects.filter(slug=contract_slug).first() + if request.method == "POST": + form = ChargeNumberForm(request.POST) + if form.is_valid(): + charge_number = form.save(commit=False) + charge_number.contract = contract + charge_number.save() + return redirect('contract_detail', contract_slug=contract.slug) + return redirect('contract_detail', contract_slug=contract_slug) @user_passes_test(is_admin) def timeapproval(request): diff --git a/company_site/planning/__init__.py b/company_site/planning/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/company_site/planning/admin.py b/company_site/planning/admin.py new file mode 100644 index 0000000..e157e14 --- /dev/null +++ b/company_site/planning/admin.py @@ -0,0 +1,8 @@ +from django.contrib import admin +from .models import Item + +@admin.register(Item) +class ItemAdmin(admin.ModelAdmin): + list_display = ('title', 'status', 'order', 'created_at', 'updated_at') + list_filter = ('status',) + search_fields = ('title', 'description') diff --git a/company_site/planning/apps.py b/company_site/planning/apps.py new file mode 100644 index 0000000..d289605 --- /dev/null +++ b/company_site/planning/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + +class PlanningConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'planning' diff --git a/company_site/planning/migrations/0001_initial.py b/company_site/planning/migrations/0001_initial.py new file mode 100644 index 0000000..a57c1fa --- /dev/null +++ b/company_site/planning/migrations/0001_initial.py @@ -0,0 +1,29 @@ +# Generated by Django 5.0 on 2026-03-22 23:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Item', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255)), + ('description', models.TextField(blank=True)), + ('status', models.CharField(choices=[('TODO', 'Todo'), ('IN_PROGRESS', 'In Progress'), ('DONE', 'Done')], default='TODO', max_length=20)), + ('order', models.IntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'ordering': ['status', 'order', '-created_at'], + }, + ), + ] diff --git a/company_site/planning/migrations/0002_item_contract.py b/company_site/planning/migrations/0002_item_contract.py new file mode 100644 index 0000000..c1af633 --- /dev/null +++ b/company_site/planning/migrations/0002_item_contract.py @@ -0,0 +1,20 @@ +# Generated by Django 5.0 on 2026-03-23 00:27 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('financial', '0010_alter_employee_manager_alter_employee_phonenumber'), + ('planning', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='item', + name='contract', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tickets', to='financial.contract'), + ), + ] diff --git a/company_site/planning/migrations/0003_create_groups.py b/company_site/planning/migrations/0003_create_groups.py new file mode 100644 index 0000000..549a326 --- /dev/null +++ b/company_site/planning/migrations/0003_create_groups.py @@ -0,0 +1,14 @@ +from django.db import migrations + +def create_groups(apps, schema_editor): + Group = apps.get_model('auth', 'Group') + Group.objects.get_or_create(name='developer') + Group.objects.get_or_create(name='stakeholder') + +class Migration(migrations.Migration): + dependencies = [ + ('planning', '0002_item_contract'), + ] + operations = [ + migrations.RunPython(create_groups), + ] diff --git a/company_site/planning/migrations/0004_remove_item_contract_item_charge_number.py b/company_site/planning/migrations/0004_remove_item_contract_item_charge_number.py new file mode 100644 index 0000000..67b61e6 --- /dev/null +++ b/company_site/planning/migrations/0004_remove_item_contract_item_charge_number.py @@ -0,0 +1,24 @@ +# Generated by Django 5.0 on 2026-03-23 07:41 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('financial', '0011_chargenumber_budget_hours'), + ('planning', '0003_create_groups'), + ] + + operations = [ + migrations.RemoveField( + model_name='item', + name='contract', + ), + migrations.AddField( + model_name='item', + name='charge_number', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tickets', to='financial.chargenumber'), + ), + ] diff --git a/company_site/planning/migrations/__init__.py b/company_site/planning/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/company_site/planning/models.py b/company_site/planning/models.py new file mode 100644 index 0000000..d815cba --- /dev/null +++ b/company_site/planning/models.py @@ -0,0 +1,26 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +class Item(models.Model): + class Status(models.TextChoices): + TODO = 'TODO', _('Todo') + IN_PROGRESS = 'IN_PROGRESS', _('In Progress') + DONE = 'DONE', _('Done') + + title = models.CharField(max_length=255) + description = models.TextField(blank=True) + status = models.CharField( + max_length=20, + choices=Status.choices, + default=Status.TODO, + ) + order = models.IntegerField(default=0) + charge_number = models.ForeignKey('financial.ChargeNumber', on_delete=models.SET_NULL, null=True, blank=True, related_name='tickets') + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['status', 'order', '-created_at'] + + def __str__(self): + return self.title diff --git a/company_site/planning/templates/planning/backlog.html b/company_site/planning/templates/planning/backlog.html new file mode 100644 index 0000000..01f26ef --- /dev/null +++ b/company_site/planning/templates/planning/backlog.html @@ -0,0 +1,126 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Planning Backlog | AI ML Operations{% endblock %} + +{% block content %} +
+
+

Backlog

+
+ View Board + {% if is_developer %} + + {% endif %} +
+
+ +
+ + +
+ +
+ + + + + + + + + + + + {% for item in items %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
TitleCharge NumberStatusCreatedLast Updated
{{ item.title }}{% if item.charge_number %}{{ item.charge_number.contract.name }} - {{ item.charge_number.slug }} ({{ item.charge_number.get_percent_complete|floatformat:0 }}%){% else %}-{% endif %}{{ item.get_status_display }}{{ item.created_at|date:"M d, Y" }}{{ item.updated_at|date:"M d, Y" }}
No items found.
+
+
+ + + + + + + + +{% endblock %} diff --git a/company_site/planning/templates/planning/board.html b/company_site/planning/templates/planning/board.html new file mode 100644 index 0000000..0a46703 --- /dev/null +++ b/company_site/planning/templates/planning/board.html @@ -0,0 +1,313 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Planning Board | AI ML Operations{% endblock %} + +{% block content %} + + +
+
+

Planning Board

+
+ View Backlog + {% if is_developer %} + + {% endif %} +
+
+ +
+ +
+

Todo {{ todo_items.count }}

+
+ {% for item in todo_items %} +
+
{{ item.title }}
+ {% if item.charge_number %} +
{{ item.charge_number.contract.name }} ({{ item.charge_number.slug }})
+ {% endif %} + {% if item.description %} +
{{ item.description|truncatechars:50 }}
+ {% endif %} +
+ {% endfor %} +
+
+ + +
+

In Progress {{ in_progress_items.count }}

+
+ {% for item in in_progress_items %} +
+
{{ item.title }}
+ {% if item.charge_number %} +
{{ item.charge_number.contract.name }} ({{ item.charge_number.slug }})
+ {% endif %} + {% if item.description %} +
{{ item.description|truncatechars:50 }}
+ {% endif %} +
+ {% endfor %} +
+
+ + +
+

Done {{ done_items.count }}

+
+ {% for item in done_items %} +
+
{{ item.title }}
+ {% if item.charge_number %} +
{{ item.charge_number.contract.name }} ({{ item.charge_number.slug }})
+ {% endif %} + {% if item.description %} +
{{ item.description|truncatechars:50 }}
+ {% endif %} +
+ {% endfor %} +
+
+
+
+ + + + + + + + +{% endblock %} diff --git a/company_site/planning/templates/planning/partials/item_detail.html b/company_site/planning/templates/planning/partials/item_detail.html new file mode 100644 index 0000000..701afaa --- /dev/null +++ b/company_site/planning/templates/planning/partials/item_detail.html @@ -0,0 +1,78 @@ +{% if is_developer %} +
+ {% csrf_token %} + +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+
+ Created: {{ item.created_at|date:"M d, Y H:i" }}
+ Updated: {{ item.updated_at|date:"M d, Y H:i" }} +
+ +
+ + +
+
+ + + + +{% else %} +

{{ item.title }}

+
+ {{ item.get_status_display }} + {% if item.charge_number %} + Charge Number: {{ item.charge_number.contract.name }} ({{ item.charge_number.slug }}) ({{ item.charge_number.get_percent_complete|floatformat:0 }}% Complete) + {% endif %} +
+
+{% if item.description %} +{{ item.description }} +{% else %} +No description provided. +{% endif %} +
+
+
+ Created: {{ item.created_at|date:"M d, Y H:i" }}
+ Updated: {{ item.updated_at|date:"M d, Y H:i" }} +
+{% endif %} diff --git a/company_site/planning/urls.py b/company_site/planning/urls.py new file mode 100644 index 0000000..c9d3c5d --- /dev/null +++ b/company_site/planning/urls.py @@ -0,0 +1,14 @@ +from django.urls import path +from . import views + +app_name = 'planning' + +urlpatterns = [ + path('', views.board_view, name='board_view'), + path('backlog/', views.backlog_view, name='backlog_view'), + path('create/', views.create_item, name='create_item'), + path('item//', views.item_detail, name='item_detail'), + path('item//update/', views.update_item_status, name='update_item_status'), + path('item//edit/', views.edit_item, name='edit_item'), + path('item//delete/', views.delete_item, name='delete_item'), +] diff --git a/company_site/planning/views.py b/company_site/planning/views.py new file mode 100644 index 0000000..d17bbb3 --- /dev/null +++ b/company_site/planning/views.py @@ -0,0 +1,125 @@ +import json +from django.shortcuts import render, get_object_or_404, redirect +from django.http import JsonResponse +from django.views.decorators.http import require_POST +from django.contrib.auth.decorators import login_required +from django.core.exceptions import PermissionDenied +from financial.models import Contract, ChargeNumber +from .models import Item + +def is_developer(user): + return user.is_active and (user.is_superuser or user.groups.filter(name__iexact='developer').exists()) + +def developer_required(view_func): + def _wrapped_view(request, *args, **kwargs): + if is_developer(request.user): + return view_func(request, *args, **kwargs) + raise PermissionDenied + return _wrapped_view + +@login_required +def board_view(request): + items = Item.objects.select_related('charge_number', 'charge_number__contract').all() + todo_items = items.filter(status=Item.Status.TODO) + in_progress_items = items.filter(status=Item.Status.IN_PROGRESS) + done_items = items.filter(status=Item.Status.DONE) + + context = { + 'todo_items': todo_items, + 'in_progress_items': in_progress_items, + 'done_items': done_items, + 'statuses': Item.Status.choices, + 'qbd_charge_numbers': ChargeNumber.objects.filter(charge_number_type='QBD'), + 'is_developer': is_developer(request.user), + } + return render(request, 'planning/board.html', context) + +@login_required +def backlog_view(request): + items = Item.objects.select_related('charge_number', 'charge_number__contract').all().order_by('-created_at') + + status_filter = request.GET.get('status') + if status_filter and status_filter in dict(Item.Status.choices): + items = items.filter(status=status_filter) + + context = { + 'items': items, + 'statuses': Item.Status.choices, + 'qbd_charge_numbers': ChargeNumber.objects.filter(charge_number_type='QBD'), + 'is_developer': is_developer(request.user), + } + return render(request, 'planning/backlog.html', context) + +@login_required +@developer_required +@require_POST +def create_item(request): + title = request.POST.get('title') + description = request.POST.get('description', '') + charge_number_id = request.POST.get('charge_number') + next_url = request.POST.get('next', 'planning:board_view') + if title: + max_order = Item.objects.filter(status=Item.Status.TODO).count() + item = Item.objects.create(title=title, description=description, status=Item.Status.TODO, order=max_order) + if charge_number_id: + item.charge_number_id = charge_number_id + item.save() + return redirect(next_url) + +@login_required +def item_detail(request, pk): + item = get_object_or_404(Item.objects.select_related('charge_number', 'charge_number__contract'), pk=pk) + context = { + 'item': item, + 'statuses': Item.Status.choices, + 'qbd_charge_numbers': ChargeNumber.objects.filter(charge_number_type='QBD'), + 'is_developer': is_developer(request.user), + } + return render(request, 'planning/partials/item_detail.html', context) + +@login_required +@developer_required +@require_POST +def edit_item(request, pk): + item = get_object_or_404(Item, pk=pk) + item.title = request.POST.get('title', item.title) + item.description = request.POST.get('description', item.description) + item.status = request.POST.get('status', item.status) + charge_number_id = request.POST.get('charge_number') + if charge_number_id: + item.charge_number_id = charge_number_id + else: + item.charge_number = None + item.save() + next_url = request.POST.get('next', 'planning:board_view') + return redirect(next_url) + +@login_required +@developer_required +@require_POST +def delete_item(request, pk): + item = get_object_or_404(Item, pk=pk) + item.delete() + next_url = request.POST.get('next', 'planning:board_view') + return redirect(next_url) + +@login_required +@developer_required +@require_POST +def update_item_status(request, pk): + try: + data = json.loads(request.body) + new_status = data.get('status') + new_order = data.get('order') + + item = get_object_or_404(Item, pk=pk) + + if new_status and new_status in dict(Item.Status.choices): + item.status = new_status + if new_order is not None: + item.order = new_order + item.save() + return JsonResponse({'status': 'success'}) + return JsonResponse({'status': 'error', 'message': 'Invalid status'}, status=400) + except Exception as e: + return JsonResponse({'status': 'error', 'message': str(e)}, status=400) diff --git a/company_site/public/static/public/css/style.css b/company_site/public/static/public/css/style.css index 764b028..d4a57ba 100644 --- a/company_site/public/static/public/css/style.css +++ b/company_site/public/static/public/css/style.css @@ -382,6 +382,66 @@ nav { margin-left: 5px; } +/* Profile Dropdown */ +.profile-icon-link { + display: flex; + align-items: center; + padding: 0.25rem; +} + +.profile-icon-link::after { + display: none !important; +} + +.profile-icon { + width: 28px; + height: 28px; + color: var(--text-color); + transition: color var(--transition-speed), filter var(--transition-speed); +} + +.profile-icon-link:hover .profile-icon { + color: var(--primary-color); + filter: drop-shadow(0 0 6px rgba(0, 243, 255, 0.5)); +} + +.profile-dropdown-content { + right: 0; + left: auto; + min-width: 180px; +} + +.profile-name-item { + padding: 12px 16px; + color: var(--primary-color); + font-weight: 600; + font-size: 0.9rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + text-transform: none; + letter-spacing: 0; + list-style: none; +} + +.profile-logout-btn { + width: 100%; + background: none; + border: none; + color: #ff4444; + padding: 12px 16px; + text-align: left; + font-family: var(--font-main); + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + text-transform: uppercase; + letter-spacing: 0.5px; + transition: background-color var(--transition-speed); +} + +.profile-logout-btn:hover { + background-color: rgba(255, 68, 68, 0.1); +} + .text-cyber-cyan { --tw-text-opacity: 1; color: rgb(0 243 255 / var(--tw-text-opacity, 1)); diff --git a/company_site/public/templates/base.html b/company_site/public/templates/base.html index 4c87c10..e37b1ee 100644 --- a/company_site/public/templates/base.html +++ b/company_site/public/templates/base.html @@ -70,6 +70,29 @@
  • Contact
  • + {% if user.is_authenticated %} +
  • Planning
  • +
  • Financials
  • + + {% else %} +
  • Sign In
  • + {% endif %} diff --git a/company_site/public/templates/registration/login.html b/company_site/public/templates/registration/login.html new file mode 100644 index 0000000..f8346db --- /dev/null +++ b/company_site/public/templates/registration/login.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Sign In - AI ML Operations{% endblock %} + +{% block content %} +
    +
    +
    +

    Sign In

    + + {% if form.errors %} +
    + Your username and password didn't match. Please try again. +
    + {% endif %} + +
    + {% csrf_token %} +
    + + +
    + +
    + + +
    + + + +
    +
    +
    +
    +{% endblock %}