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
+
+
+
+
+ | Slug/ID |
+ Type |
+ Amount |
+ % Complete |
+ Start Date |
+ End Date |
+ Actions |
+
+
+
+ {% for cn in charge_numbers %}
+
+ | {{ 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
+ |
+
+ {% endfor %}
+
+
+
Create a new charge number
-
{% else %}
-
There are no charge numbers for this contract.
-
-
+
+
+
+
+
+{% 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 %}
+
+
+
+
+
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 %}
+
+
+
+
+
+
+{% 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 %}
+
+
+
+
+
+
+
+
+
+ | Title |
+ Charge Number |
+ Status |
+ Created |
+ Last Updated |
+
+
+
+ {% for item in items %}
+
+ | {{ 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" }} |
+
+ {% empty %}
+
+ | No items found. |
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
+
+
+{% 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 %}
+
+
+
+
+
+
+
+
+
×
+
New Item
+
+
+
+
+
+
+
+
+{% 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 %}
+
+
+
+
+
+{% 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
+
+
+
+
+
+ - {{ user.get_full_name|default:user.username }}
+ -
+
+
+
+
+ {% 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 %}
+
+
+
+
+
+{% endblock %}