EVM complete

This commit is contained in:
2026-03-23 03:31:44 -05:00
parent c88e344198
commit 8cd3aa5f84
32 changed files with 1605 additions and 31 deletions

View File

@@ -33,6 +33,7 @@ ALLOWED_HOSTS = ["*"]
INSTALLED_APPS = [ INSTALLED_APPS = [
'public.apps.PublicConfig', 'public.apps.PublicConfig',
'financial.apps.FinancialConfig', 'financial.apps.FinancialConfig',
'planning.apps.PlanningConfig',
'django.contrib.admin', 'django.contrib.admin',
'django.contrib.auth', 'django.contrib.auth',
'django.contrib.contenttypes', 'django.contrib.contenttypes',
@@ -146,4 +147,8 @@ EMAIL_HOST = 'mail.smtp2go.com'
EMAIL_HOST_USER = 'info.aimloperations.com' EMAIL_HOST_USER = 'info.aimloperations.com'
EMAIL_HOST_PASSWORD = 'ZDErIII2sipNNVMz' EMAIL_HOST_PASSWORD = 'ZDErIII2sipNNVMz'
EMAIL_PORT = 2525 EMAIL_PORT = 2525
EMAIL_USE_TLS = True EMAIL_USE_TLS = True
# Authentication Redirects
LOGIN_REDIRECT_URL = '/'
LOGOUT_REDIRECT_URL = '/'

View File

@@ -21,5 +21,7 @@ from django.urls import include, path
urlpatterns = [ urlpatterns = [
path("public/", include("public.urls")), path("public/", include("public.urls")),
path("financial/", include("financial.urls")), path("financial/", include("financial.urls")),
path("planning/", include("planning.urls")),
path("accounts/", include("django.contrib.auth.urls")),
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
] ]

View File

@@ -4,3 +4,6 @@ from django.apps import AppConfig
class FinancialConfig(AppConfig): class FinancialConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = 'django.db.models.BigAutoField'
name = 'financial' name = 'financial'
def ready(self):
import financial.signals # noqa: F401

View File

@@ -52,7 +52,7 @@ class ContractForm(ModelForm):
class ChargeNumberForm(ModelForm): class ChargeNumberForm(ModelForm):
class Meta: class Meta:
model = ChargeNumber 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): class TimeLogForm(ModelForm):
start_time = forms.TimeField(required=False, widget=forms.TimeInput(attrs={'type': 'time'})) start_time = forms.TimeField(required=False, widget=forms.TimeInput(attrs={'type': 'time'}))
@@ -62,7 +62,7 @@ class TimeLogForm(ModelForm):
class Meta: class Meta:
model = TimeCardCell model = TimeCardCell
fields = ["contract", "date", "start_time", "end_time", "hour"] fields = ["charge_number", "date", "start_time", "end_time", "hour"]
def clean(self): def clean(self):
cleaned_data = super().clean() cleaned_data = super().clean()

View File

@@ -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),
),
]

View File

@@ -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'),
),
]

View File

@@ -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'),
),
]

View File

@@ -4,7 +4,9 @@ from phonenumber_field.modelfields import PhoneNumberField
from django.utils import timezone from django.utils import timezone
from django_enum import EnumField from django_enum import EnumField
from django.utils.text import slugify from django.utils.text import slugify
from django.db.models import Sum
import datetime import datetime
from dateutil.relativedelta import relativedelta
def user_str(self): def user_str(self):
if self.first_name and self.last_name: if self.first_name and self.last_name:
@@ -62,7 +64,124 @@ class Contract(IdMixin, TimeMixin):
def __str__(self): def __str__(self):
return f"{self.name} - {self.contract_type}" 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 ChargeNumber(IdMixin, TimeMixin):
class ChargeNumberTypeEnum(models.TextChoices): class ChargeNumberTypeEnum(models.TextChoices):
@@ -76,11 +195,45 @@ class ChargeNumber(IdMixin, TimeMixin):
contract = models.ForeignKey(Contract, on_delete=models.CASCADE) contract = models.ForeignKey(Contract, on_delete=models.CASCADE)
amount = models.FloatField(default=0.0) amount = models.FloatField(default=0.0)
budget_hours = models.FloatField(default=0.0)
percent_complete = models.FloatField(default=0.0) percent_complete = models.FloatField(default=0.0)
# TODO: add validator to make sure the range is 0.0 - 100 # TODO: add validator to make sure the range is 0.0 - 100
start_date = models.DateField(null=True, blank=True, default = None) start_date = models.DateField(null=True, blank=True, default = None)
end_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): class AddressModel(models.Model):
address_1 = models.CharField(max_length=128) address_1 = models.CharField(max_length=128)
address_2 = models.CharField(max_length=128, blank=True) address_2 = models.CharField(max_length=128, blank=True)
@@ -92,8 +245,8 @@ class AddressModel(models.Model):
class Employee(IdMixin, TimeMixin): class Employee(IdMixin, TimeMixin):
manager = models.ForeignKey("self", on_delete=models.CASCADE, related_name="manager_employee", null=True, blank=True) manager = models.ForeignKey("self", on_delete=models.CASCADE, related_name="manager_employee", null=True, blank=True)
user = models.OneToOneField(User, on_delete=models.CASCADE) user = models.OneToOneField(User, on_delete=models.CASCADE)
primaryAddress = models.ForeignKey(AddressModel, on_delete=models.CASCADE, related_name="primary_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") 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) phoneNumber = PhoneNumberField(null=True, blank=True, unique=True)
slary= models.FloatField(default=0.0) slary= models.FloatField(default=0.0)
@@ -102,7 +255,7 @@ class Employee(IdMixin, TimeMixin):
@property @property
def hourly_salary(self): def hourly_salary(self):
return (self.slary or 0.0) / 2080.0 return (self.slary or 0.0) / 2040.0
# TODO: roles, jpbTitles # TODO: roles, jpbTitles
@@ -122,7 +275,7 @@ class TimeCardCell(IdMixin, TimeMixin):
start_time = models.TimeField(null=True, blank=True) start_time = models.TimeField(null=True, blank=True)
end_time = models.TimeField(null=True, blank=True) end_time = models.TimeField(null=True, blank=True)
hour = models.FloatField(default = 0.0) 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)

View File

@@ -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)

View File

@@ -33,15 +33,143 @@
<hr style="margin: 40px 0; border: 0; border-top: 1px solid rgba(255,255,255,0.1);"> <hr style="margin: 40px 0; border: 0; border-top: 1px solid rgba(255,255,255,0.1);">
<!-- ═══════════════════════════════════════════════════════ -->
<!-- EARNED VALUE MANAGEMENT SECTION -->
<!-- ═══════════════════════════════════════════════════════ -->
{% if evm %}
<h2 class="section-title" style="text-align: left; font-size: 2rem;">Earned Value Management</h2>
<!-- EVM KPI Cards -->
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 16px; margin-bottom: 2rem;">
<!-- BAC -->
<div class="card evm-kpi-card">
<span class="evm-kpi-label">BAC (Budget)</span>
<span class="evm-kpi-value">${{ evm.bac_dollars|floatformat:2 }}</span>
<span class="evm-kpi-sub">{{ evm.bac_hours }} hrs</span>
</div>
<!-- PV -->
<div class="card evm-kpi-card">
<span class="evm-kpi-label">PV (Planned)</span>
<span class="evm-kpi-value">${{ evm.pv_dollars|floatformat:2 }}</span>
<span class="evm-kpi-sub">{{ evm.pv_hours }} hrs</span>
</div>
<!-- EV -->
<div class="card evm-kpi-card">
<span class="evm-kpi-label">EV (Earned)</span>
<span class="evm-kpi-value" style="color: #39ff14;">${{ evm.ev_dollars|floatformat:2 }}</span>
<span class="evm-kpi-sub">{{ evm.ev_hours }} hrs · {{ evm.percent_complete }}% done</span>
</div>
<!-- AC -->
<div class="card evm-kpi-card">
<span class="evm-kpi-label">AC (Actual Cost)</span>
<span class="evm-kpi-value" style="color: #bc13fe;">${{ evm.ac_dollars|floatformat:2 }}</span>
<span class="evm-kpi-sub">{{ evm.ac_hours }} hrs spent</span>
</div>
</div>
<!-- Variance & Index Cards -->
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 16px; margin-bottom: 2rem;">
<!-- SV -->
<div class="card evm-kpi-card">
<span class="evm-kpi-label">Schedule Variance</span>
<span class="evm-kpi-value {% if evm.sv_dollars >= 0 %}evm-positive{% else %}evm-negative{% endif %}">
${{ evm.sv_dollars|floatformat:2 }}
</span>
<span class="evm-kpi-sub">{% if evm.sv_dollars >= 0 %}Ahead{% else %}Behind{% endif %} schedule</span>
</div>
<!-- CV -->
<div class="card evm-kpi-card">
<span class="evm-kpi-label">Cost Variance</span>
<span class="evm-kpi-value {% if evm.cv_dollars >= 0 %}evm-positive{% else %}evm-negative{% endif %}">
${{ evm.cv_dollars|floatformat:2 }}
</span>
<span class="evm-kpi-sub">{% if evm.cv_dollars >= 0 %}Under{% else %}Over{% endif %} budget</span>
</div>
<!-- SPI -->
<div class="card evm-kpi-card">
<span class="evm-kpi-label">SPI</span>
<span class="evm-kpi-value {% if evm.spi >= 1 %}evm-positive{% elif evm.spi > 0 %}evm-negative{% endif %}">
{{ evm.spi }}
</span>
<span class="evm-kpi-sub">{% if evm.spi >= 1 %}On/ahead{% elif evm.spi > 0 %}Behind{% else %}No data{% endif %}</span>
</div>
<!-- CPI -->
<div class="card evm-kpi-card">
<span class="evm-kpi-label">CPI</span>
<span class="evm-kpi-value {% if evm.cpi >= 1 %}evm-positive{% elif evm.cpi > 0 %}evm-negative{% endif %}">
{{ evm.cpi }}
</span>
<span class="evm-kpi-sub">{% if evm.cpi >= 1 %}Efficient{% elif evm.cpi > 0 %}Over-spending{% else %}No data{% endif %}</span>
</div>
</div>
<!-- Charts Row -->
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 20px; margin-bottom: 2rem;">
<!-- S-Curve Chart -->
<div class="card" style="padding: 20px;">
<h3 style="margin-bottom: 1rem; font-size: 1.1rem; color: var(--text-muted);">S-Curve (PV vs EV vs AC)</h3>
<canvas id="evmSCurveChart"></canvas>
</div>
<!-- SPI / CPI Bar Chart -->
<div class="card" style="padding: 20px;">
<h3 style="margin-bottom: 1rem; font-size: 1.1rem; color: var(--text-muted);">Performance Indices</h3>
<canvas id="evmIndexChart"></canvas>
</div>
</div>
{% endif %}
<hr style="margin: 40px 0; border: 0; border-top: 1px solid rgba(255,255,255,0.1);">
<h2 class="section-title" style="text-align: left; font-size: 2rem;">Charge Numbers</h2> <h2 class="section-title" style="text-align: left; font-size: 2rem;">Charge Numbers</h2>
{% if charge_numbes %} {% if charge_numbers %}
{% if mermaid_gantt %}
<div class="card" style="margin-bottom: 2rem; background: var(--surface-color);">
<div class="mermaid">
{{ mermaid_gantt|safe }}
</div>
</div>
{% endif %}
<div class="card" style="margin-bottom: 2rem;"> <div class="card" style="margin-bottom: 2rem;">
<p style="color: var(--text-muted);">put charge number table here</p> <div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Slug/ID</th>
<th>Type</th>
<th>Amount</th>
<th>% Complete</th>
<th>Start Date</th>
<th>End Date</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for cn in charge_numbers %}
<tr>
<td>{{ cn.slug }}</td>
<td>{{ cn.get_charge_number_type_display }}</td>
<td>${{ cn.amount|floatformat:2 }}</td>
<td>
<span class="badge" style="background: rgba(0, 243, 255, 0.1); border: 1px solid var(--primary-color); color: var(--primary-color);">
{{ cn.percent_complete|floatformat:0 }}%
</span>
</td>
<td>{{ cn.start_date|default:"-" }}</td>
<td>{{ cn.end_date|default:"-" }}</td>
<td>
<a href="{% url 'update_charge_number' cn.slug %}" class="text-cyber-cyan" style="font-size: 0.9rem;">Edit</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div> </div>
<div class="card" style="max-width: 600px;"> <div class="card" style="max-width: 600px;">
<h3 style="margin-bottom: 1rem;">Create a new charge number</h3> <h3 style="margin-bottom: 1rem;">Create a new charge number</h3>
<form method="post" action='{% url "new_charge_number" %}'> <form method="post" action='{% url "new_charge_number" contract.slug %}'>
{% csrf_token %} {% csrf_token %}
{{ charge_number_form.as_p }} {{ charge_number_form.as_p }}
<button type="submit" class="btn" style="margin-top: 1rem;">Add Charge Number</button> <button type="submit" class="btn" style="margin-top: 1rem;">Add Charge Number</button>
@@ -49,9 +177,8 @@
</div> </div>
{% else %} {% else %}
<div class="card" style="max-width: 600px;"> <div class="card" style="max-width: 600px;">
<p style="color: var(--text-muted); margin-bottom: 1.5rem;">There are no charge numbers for this contract. <p style="color: var(--text-muted); margin-bottom: 1.5rem;">There are no charge numbers for this contract.</p>
</p> <form method="post" action='{% url "new_charge_number" contract.slug %}'>
<form method="post" action='{% url "new_charge_number" %}'>
{% csrf_token %} {% csrf_token %}
{{ charge_number_form.as_p }} {{ charge_number_form.as_p }}
<button type="submit" class="btn" style="margin-top: 1rem;">Add Charge Number</button> <button type="submit" class="btn" style="margin-top: 1rem;">Add Charge Number</button>
@@ -62,4 +189,213 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
<style>
.evm-kpi-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 16px;
text-align: center;
background: var(--surface-color);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 12px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.evm-kpi-card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 24px rgba(0, 243, 255, 0.08);
}
.evm-kpi-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--text-muted, #888);
margin-bottom: 6px;
font-weight: 600;
}
.evm-kpi-value {
font-size: 1.5rem;
font-weight: 800;
color: #fff;
line-height: 1.2;
}
.evm-kpi-sub {
font-size: 0.75rem;
color: var(--text-muted, #888);
margin-top: 4px;
}
.evm-positive { color: #39ff14 !important; }
.evm-negative { color: #ff4444 !important; }
</style>
<script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs';
mermaid.initialize({ startOnLoad: true, theme: 'dark' });
</script>
{% if evm_chart_json %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
document.addEventListener("DOMContentLoaded", function () {
const raw = '{{ evm_chart_json|escapejs }}';
if (!raw) return;
const data = JSON.parse(raw);
Chart.defaults.color = '#e0e0e0';
Chart.defaults.borderColor = 'rgba(255, 255, 255, 0.1)';
/* ─────── S-Curve Chart ─────── */
const sCurveCanvas = document.getElementById('evmSCurveChart');
if (sCurveCanvas && data.time_series && data.time_series.length > 0) {
const labels = data.time_series.map(d => d.month);
const pvData = data.time_series.map(d => d.pv);
const evData = data.time_series.map(d => d.ev);
const acData = data.time_series.map(d => d.ac);
new Chart(sCurveCanvas.getContext('2d'), {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: 'Planned Value (PV)',
data: pvData,
borderColor: '#00f3ff',
backgroundColor: 'rgba(0, 243, 255, 0.08)',
borderWidth: 2,
fill: true,
tension: 0.3,
pointRadius: 3,
pointBackgroundColor: '#00f3ff',
},
{
label: 'Earned Value (EV)',
data: evData,
borderColor: '#39ff14',
backgroundColor: 'rgba(57, 255, 20, 0.08)',
borderWidth: 2,
fill: true,
tension: 0.3,
pointRadius: 3,
pointBackgroundColor: '#39ff14',
},
{
label: 'Actual Cost (AC)',
data: acData,
borderColor: '#bc13fe',
backgroundColor: 'rgba(188, 19, 254, 0.08)',
borderWidth: 2,
fill: true,
tension: 0.3,
pointRadius: 3,
pointBackgroundColor: '#bc13fe',
}
]
},
options: {
responsive: true,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { labels: { color: '#e0e0e0', usePointStyle: true, padding: 16 } },
tooltip: {
callbacks: {
label: ctx => `${ctx.dataset.label}: $${ctx.parsed.y.toLocaleString()}`
}
}
},
scales: {
x: {
grid: { color: 'rgba(255,255,255,0.05)' },
ticks: { color: '#a0a0a0' }
},
y: {
beginAtZero: true,
title: { display: true, text: 'Dollars ($)', color: '#a0a0a0' },
grid: { color: 'rgba(255,255,255,0.05)' },
ticks: {
color: '#a0a0a0',
callback: v => '$' + v.toLocaleString()
}
}
}
}
});
}
/* ─────── SPI / CPI Bar Chart ─────── */
const indexCanvas = document.getElementById('evmIndexChart');
if (indexCanvas) {
const spi = data.spi || 0;
const cpi = data.cpi || 0;
new Chart(indexCanvas.getContext('2d'), {
type: 'bar',
data: {
labels: ['SPI', 'CPI'],
datasets: [{
label: 'Performance Index',
data: [spi, cpi],
backgroundColor: [
spi >= 1 ? 'rgba(57, 255, 20, 0.6)' : 'rgba(255, 68, 68, 0.6)',
cpi >= 1 ? 'rgba(57, 255, 20, 0.6)' : 'rgba(255, 68, 68, 0.6)',
],
borderColor: [
spi >= 1 ? '#39ff14' : '#ff4444',
cpi >= 1 ? '#39ff14' : '#ff4444',
],
borderWidth: 2,
borderRadius: 6,
barPercentage: 0.5,
}]
},
plugins: [{
id: 'baselineLine',
afterDraw(chart) {
const yScale = chart.scales.y;
const ctx = chart.ctx;
const yPixel = yScale.getPixelForValue(1);
ctx.save();
ctx.strokeStyle = '#00f3ff';
ctx.lineWidth = 2;
ctx.setLineDash([6, 4]);
ctx.beginPath();
ctx.moveTo(chart.chartArea.left, yPixel);
ctx.lineTo(chart.chartArea.right, yPixel);
ctx.stroke();
// Label
ctx.fillStyle = '#00f3ff';
ctx.font = '11px Inter, sans-serif';
ctx.fillText('Target = 1.0', chart.chartArea.right - 72, yPixel - 6);
ctx.restore();
}
}],
options: {
responsive: true,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: ctx => `${ctx.label}: ${ctx.parsed.y.toFixed(2)}`
}
}
},
scales: {
x: {
grid: { display: false },
ticks: { color: '#e0e0e0', font: { size: 14, weight: 'bold' } }
},
y: {
beginAtZero: true,
suggestedMax: Math.max(spi, cpi, 1.5) + 0.3,
grid: { color: 'rgba(255,255,255,0.05)' },
ticks: { color: '#a0a0a0' }
}
}
}
});
}
});
</script>
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -32,6 +32,7 @@
<th>Name</th> <th>Name</th>
<th>Identifier</th> <th>Identifier</th>
<th>Type</th> <th>Type</th>
<th>% Complete</th>
<th>Start Date</th> <th>Start Date</th>
<th>End Date</th> <th>End Date</th>
<th>Proposed Amount</th> <th>Proposed Amount</th>
@@ -48,6 +49,11 @@
<td><a href="{% url 'contract_detail' contract.slug %}">{{ contract.name }}</a></td> <td><a href="{% url 'contract_detail' contract.slug %}">{{ contract.name }}</a></td>
<td>{{ contract.slug }}</td> <td>{{ contract.slug }}</td>
<td>{{ contract.contract_type }}</td> <td>{{ contract.contract_type }}</td>
<td>
<span class="badge" style="background: rgba(0, 243, 255, 0.1); border: 1px solid var(--primary-color); color: var(--primary-color);">
{{ contract.ticket_percent_complete|floatformat:0 }}%
</span>
</td>
<td>{{ contract.baseline_start }}</td> <td>{{ contract.baseline_start }}</td>
<td>{{ contract.baseline_end }}</td> <td>{{ contract.baseline_end }}</td>
<td>{{ contract.proposed_amount }}</td> <td>{{ contract.proposed_amount }}</td>

View File

@@ -22,7 +22,7 @@
<thead> <thead>
<tr> <tr>
<th>Employee</th> <th>Employee</th>
<th>Contract</th> <th>Charge Number</th>
<th>Date</th> <th>Date</th>
<th>Start Time</th> <th>Start Time</th>
<th>End Time</th> <th>End Time</th>
@@ -34,7 +34,7 @@
{% for log in logs %} {% for log in logs %}
<tr> <tr>
<td>{{ log.timeCard.employee }}</td> <td>{{ log.timeCard.employee }}</td>
<td>{{ log.contract }}</td> <td>{{ log.charge_number }}</td>
<td>{{ log.date }}</td> <td>{{ log.date }}</td>
<td>{{ log.start_time|default_if_none:"" }}</td> <td>{{ log.start_time|default_if_none:"" }}</td>
<td>{{ log.end_time|default_if_none:"" }}</td> <td>{{ log.end_time|default_if_none:"" }}</td>

View File

@@ -0,0 +1,39 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Update Charge Number - AI ML Operations{% endblock %}
{% block content %}
<div class="section">
<div class="container">
<div style="margin-bottom: 2rem;">
<a href="{% url 'contract_detail' charge_number.contract.slug %}" class="btn" style="padding: 0.5rem 1.5rem; font-size: 0.9rem;">Back to {{ charge_number.contract.name }}</a>
</div>
<h1 class="section-title" style="text-align: left;">Update Charge Number: {{ charge_number.slug }}</h1>
{% if form.errors %}
<div class="alert alert-danger" style="max-width: 600px; margin-bottom: 1.5rem; padding: 1rem; border: 1px solid #ff4444; border-radius: 4px; background: rgba(255, 68, 68, 0.1);">
<ul style="margin: 0; padding-left: 20px; color: #ff4444;">
{% for field in form %}
{% for error in field.errors %}
<li><strong>{{ field.label }}:</strong> {{ error }}</li>
{% endfor %}
{% endfor %}
{% for error in form.non_field_errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<div class="card" style="max-width: 600px;">
<form method="post" action="{% url 'update_charge_number' charge_number.slug %}">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn" style="margin-top: 1rem;">Update Charge Number</button>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -13,7 +13,7 @@ urlpatterns = [
path("<str:contract_slug>/contract_detail", views.contract_detail, name="contract_detail"), path("<str:contract_slug>/contract_detail", views.contract_detail, name="contract_detail"),
path("new_contract", views.new_contract, name="new_contract"), path("new_contract", views.new_contract, name="new_contract"),
path("new_employee", views.new_employee, name="new_employee"), path("new_employee", views.new_employee, name="new_employee"),
path("new_charge_number", views.new_charge_number, name="new_charge_number"), path("<str:contract_slug>/new_charge_number", views.new_charge_number, name="new_charge_number"),
path("<str:charge_number_slug>/update_charge_number", views.update_charge_number, name="update_charge_number"), path("<str:charge_number_slug>/update_charge_number", views.update_charge_number, name="update_charge_number"),
#path("contracts/<int:contract_id>/", views.contract_detail, name="contract"), #path("contracts/<int:contract_id>/", views.contract_detail, name="contract"),
path("procurements", views.procurement, name="procurements"), path("procurements", views.procurement, name="procurements"),

View File

@@ -1,7 +1,7 @@
from django.shortcuts import render, redirect from django.shortcuts import render, redirect
from django.contrib.auth.decorators import user_passes_test from django.contrib.auth.decorators import user_passes_test
from .forms import EmployeeForm, ContractForm, ChargeNumberForm, TimeLogForm, NewEmployeeForm 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.utils import timezone
from django.db.models import Sum from django.db.models import Sum
from datetime import timedelta from datetime import timedelta
@@ -14,7 +14,7 @@ def is_admin(user):
def index(request): def index(request):
contracts = Contract.objects.all() contracts = Contract.objects.all()
for c in contracts: 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.total_logged = total if total else 0.0
employees = Employee.objects.all() employees = Employee.objects.all()
@@ -22,7 +22,7 @@ def index(request):
for e in employees: for e in employees:
contract_hours = [] contract_hours = []
for c in contracts: 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}) 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}) employee_data.append({'employee': e, 'contract_hours': contract_hours})
@@ -50,7 +50,7 @@ def contracts(request):
chart_data_list = [] chart_data_list = []
for c in contracts_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_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) 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) @user_passes_test(is_admin)
def contract_detail(request, contract_slug): def contract_detail(request, contract_slug):
contract = Contract.objects.filter(slug=contract_slug).first() 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': if request.method == 'POST':
form = ContractForm(request.POST, instance=contract) form = ContractForm(request.POST, instance=contract)
if form.is_valid(): if form.is_valid():
@@ -113,7 +138,16 @@ def contract_detail(request, contract_slug):
else: else:
form = ContractForm(instance=contract) form = ContractForm(instance=contract)
charge_number_form = ChargeNumberForm() 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) @user_passes_test(is_admin)
def new_contract(request): def new_contract(request):
@@ -131,11 +165,7 @@ def timekeeping(request):
if request.method == "POST": if request.method == "POST":
form = TimeLogForm(request.POST) form = TimeLogForm(request.POST)
if form.is_valid(): if form.is_valid():
employee = Employee.objects.filter(user=request.user).first() employee, _ = Employee.objects.get_or_create(user=request.user)
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."})
time_card, _ = TimeCard.objects.get_or_create(employee=employee, startDate=timezone.now().date(), endDate=timezone.now().date()) time_card, _ = TimeCard.objects.get_or_create(employee=employee, startDate=timezone.now().date(), endDate=timezone.now().date())
cell = form.save(commit=False) cell = form.save(commit=False)
@@ -179,18 +209,41 @@ def delete_time_log(request, log_id):
def client_reports(request): def client_reports(request):
contracts = Contract.objects.all() contracts = Contract.objects.all()
for c in contracts: 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.total_logged = total if total else 0.0
c.remaining_budget = c.budget_hours - c.total_logged c.remaining_budget = c.budget_hours - c.total_logged
return render(request, 'financial/reports.html', {'contracts': contracts}) return render(request, 'financial/reports.html', {'contracts': contracts})
@user_passes_test(is_admin) @user_passes_test(is_admin)
def update_charge_number(request, charge_number_slug): 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) @user_passes_test(is_admin)
def new_charge_number(request, charge_number_slug): def new_charge_number(request, contract_slug):
return render(request, 'financial/not_created.html', {}) 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) @user_passes_test(is_admin)
def timeapproval(request): def timeapproval(request):

View File

View File

@@ -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')

View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class PlanningConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'planning'

View File

@@ -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'],
},
),
]

View File

@@ -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'),
),
]

View File

@@ -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),
]

View File

@@ -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'),
),
]

View File

@@ -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

View File

@@ -0,0 +1,126 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Planning Backlog | AI ML Operations{% endblock %}
{% block content %}
<div class="container section">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
<h2 class="section-title" style="margin-bottom: 0;">Backlog</h2>
<div>
<a href="{% url 'planning:board_view' %}" class="btn" style="padding: 0.5rem 1rem; border-radius: 8px; margin-right: 1rem; background: var(--surface-color); color: var(--text-color); border: 1px solid var(--primary-color);">View Board</a>
{% if is_developer %}
<button class="btn" id="openAddModalBtn" style="padding: 0.5rem 1rem; border-radius: 8px;">Add Item</button>
{% endif %}
</div>
</div>
<form method="get" action="{% url 'planning:backlog_view' %}" style="margin-bottom: 2rem; display: flex; gap: 1rem; align-items: center;">
<label for="status" style="color: var(--text-muted);">Filter by Status:</label>
<select name="status" id="status" class="form-control" style="width: 200px; margin-bottom: 0;" onchange="this.form.submit()">
<option value="">All</option>
{% for status_value, status_label in statuses %}
<option value="{{ status_value }}" {% if request.GET.status == status_value %}selected{% endif %}>{{ status_label }}</option>
{% endfor %}
</select>
</form>
<div class="table-responsive" style="background: var(--surface-color); border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.05); padding: 1rem;">
<table class="table">
<thead>
<tr>
<th>Title</th>
<th>Charge Number</th>
<th>Status</th>
<th>Created</th>
<th>Last Updated</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr style="cursor: pointer; transition: background 0.2s;" onmouseover="this.style.background='rgba(255,255,255,0.05)'" onmouseout="this.style.background='transparent'" onclick="showItemDetails({{ item.id }})">
<td style="font-weight: 500; color: white;">{{ item.title }}</td>
<td>{% if item.charge_number %}<span class="badge" style="background: rgba(188, 19, 254, 0.2); color: var(--secondary-color); border: 1px solid var(--secondary-color);">{{ item.charge_number.contract.name }} - {{ item.charge_number.slug }} ({{ item.charge_number.get_percent_complete|floatformat:0 }}%)</span>{% else %}-{% endif %}</td>
<td><span class="badge" style="border: 1px solid var(--primary-color);">{{ item.get_status_display }}</span></td>
<td style="color: var(--text-muted); font-size: 0.9rem;">{{ item.created_at|date:"M d, Y" }}</td>
<td style="color: var(--text-muted); font-size: 0.9rem;">{{ item.updated_at|date:"M d, Y" }}</td>
</tr>
{% empty %}
<tr>
<td colspan="5" style="text-align: center; color: var(--text-muted); padding: 2rem;">No items found.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Item Details Modal (Reused from Board) -->
<div id="itemDetailModal" class="modal" style="display: none; position: fixed; z-index: 2000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.8); backdrop-filter: blur(5px);">
<div class="modal-content" style="background-color: var(--surface-color); margin: 10% auto; padding: 2rem; border: 1px solid var(--primary-color); border-radius: 12px; width: 90%; max-width: 600px; color: var(--text-color); box-shadow: 0 0 30px rgba(0, 243, 255, 0.2);">
<span class="close" id="closeDetailModal" style="color: var(--text-muted); float: right; font-size: 28px; font-weight: bold; cursor: pointer;">&times;</span>
<div id="itemDetailContent">
<!-- Loaded via AJAX -->
</div>
</div>
</div>
<!-- Add Item Modal -->
<div id="addItemModal" class="modal" style="display: none; position: fixed; z-index: 2000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.8); backdrop-filter: blur(5px);">
<div class="modal-content" style="background-color: var(--surface-color); margin: 10% auto; padding: 2rem; border: 1px solid var(--primary-color); border-radius: 12px; width: 90%; max-width: 600px; color: var(--text-color); box-shadow: 0 0 30px rgba(0, 243, 255, 0.2);">
<span class="close" id="closeAddModal" style="color: var(--text-muted); float: right; font-size: 28px; font-weight: bold; cursor: pointer;">&times;</span>
<h2 style="color: var(--primary-color); margin-bottom: 1.5rem;">New Item</h2>
<form method="post" action="{% url 'planning:create_item' %}">
{% csrf_token %}
<div class="form-group">
<label style="color: var(--text-muted); margin-bottom: 0.5rem; display: block;">Title</label>
<input type="text" name="title" class="form-control" required>
</div>
<div class="form-group">
<label style="color: var(--text-muted); margin-bottom: 0.5rem; display: block;">Charge Number (QBD)</label>
<select name="charge_number" class="form-control">
<option value="">-- No Charge Number --</option>
{% for cn in qbd_charge_numbers %}
<option value="{{ cn.id }}">{{ cn.contract.name }} - {{ cn.slug }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label style="color: var(--text-muted); margin-bottom: 0.5rem; display: block;">Description</label>
<textarea name="description" class="form-control" rows="4"></textarea>
</div>
<input type="hidden" name="next" value="{{ request.path }}">
<button type="submit" class="btn">Create</button>
</form>
</div>
</div>
<script>
const detailModal = document.getElementById('itemDetailModal');
const closeDetailBtn = document.getElementById('closeDetailModal');
const detailContent = document.getElementById('itemDetailContent');
const addModal = document.getElementById('addItemModal');
const openAddBtn = document.getElementById('openAddModalBtn');
const closeAddBtn = document.getElementById('closeAddModal');
closeDetailBtn.onclick = () => detailModal.style.display = "none";
if (openAddBtn) openAddBtn.onclick = () => addModal.style.display = "block";
if (closeAddBtn) closeAddBtn.onclick = () => addModal.style.display = "none";
window.onclick = (event) => {
if (event.target == detailModal) detailModal.style.display = "none";
if (addModal && event.target == addModal) addModal.style.display = "none";
}
function showItemDetails(itemId) {
fetch(`/planning/item/${itemId}/`)
.then(res => res.text())
.then(html => {
detailContent.innerHTML = html;
detailModal.style.display = "block";
});
}
</script>
{% endblock %}

View File

@@ -0,0 +1,313 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Planning Board | AI ML Operations{% endblock %}
{% block content %}
<style>
/* Custom Kanban styles to match the cyber aesthetic */
.kanban-board {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 2rem;
padding: 2rem 0;
align-items: start;
}
.kanban-column {
background: rgba(26, 26, 26, 0.6);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 1.5rem;
min-height: 500px;
transition: border-color 0.3s;
}
.kanban-column h3 {
margin-bottom: 1rem;
color: var(--primary-color);
text-transform: uppercase;
font-size: 1.25rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.kanban-items {
min-height: 400px; /* For drop zone */
}
.kanban-item {
background: var(--surface-color);
border: 1px solid rgba(0, 243, 255, 0.1);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
cursor: grab;
transition: transform 0.2s, box-shadow 0.2s;
}
.kanban-item:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0, 243, 255, 0.2);
border-color: var(--primary-color);
}
.kanban-item:active {
cursor: grabbing;
}
.kanban-item-title {
font-weight: 600;
margin-bottom: 0.5rem;
color: white;
}
.kanban-item-desc {
font-size: 0.85rem;
color: var(--text-muted);
}
/* Modal Styles */
.modal {
display: none;
position: fixed;
z-index: 2000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.8);
backdrop-filter: blur(5px);
}
.modal-content {
background-color: var(--surface-color);
margin: 10% auto;
padding: 2rem;
border: 1px solid var(--primary-color);
border-radius: 12px;
width: 90%;
max-width: 600px;
color: var(--text-color);
box-shadow: 0 0 30px rgba(0, 243, 255, 0.2);
}
.close {
color: var(--text-muted);
float: right;
font-size: 28px;
font-weight: bold;
cursor: pointer;
}
.close:hover {
color: var(--secondary-color);
}
.badge {
background: rgba(255, 255, 255, 0.1);
padding: 0.2rem 0.5rem;
border-radius: 12px;
font-size: 0.8rem;
color: white;
}
@media (max-width: 968px) {
.kanban-board {
grid-template-columns: 1fr;
}
}
</style>
<div class="container section">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
<h2 class="section-title" style="margin-bottom: 0;">Planning Board</h2>
<div>
<a href="{% url 'planning:backlog_view' %}" class="btn" style="padding: 0.5rem 1rem; border-radius: 8px; margin-right: 1rem; background: var(--surface-color); color: var(--text-color); border: 1px solid var(--primary-color);">View Backlog</a>
{% if is_developer %}
<button class="btn" id="openAddModalBtn">Add Item</button>
{% endif %}
</div>
</div>
<div class="kanban-board">
<!-- TODO Column -->
<div class="kanban-column" id="col-TODO">
<h3>Todo <span class="badge">{{ todo_items.count }}</span></h3>
<div class="kanban-items" data-status="TODO">
{% for item in todo_items %}
<div class="kanban-item" draggable="{% if is_developer %}true{% else %}false{% endif %}" data-id="{{ item.id }}">
<div class="kanban-item-title">{{ item.title }}</div>
{% if item.charge_number %}
<div style="margin-bottom: 0.5rem;"><span class="badge" style="background: rgba(188, 19, 254, 0.2); color: var(--secondary-color); border: 1px solid var(--secondary-color);">{{ item.charge_number.contract.name }} ({{ item.charge_number.slug }})</span></div>
{% endif %}
{% if item.description %}
<div class="kanban-item-desc">{{ item.description|truncatechars:50 }}</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
<!-- In Progress Column -->
<div class="kanban-column" id="col-IN_PROGRESS">
<h3>In Progress <span class="badge">{{ in_progress_items.count }}</span></h3>
<div class="kanban-items" data-status="IN_PROGRESS">
{% for item in in_progress_items %}
<div class="kanban-item" draggable="{% if is_developer %}true{% else %}false{% endif %}" data-id="{{ item.id }}">
<div class="kanban-item-title">{{ item.title }}</div>
{% if item.charge_number %}
<div style="margin-bottom: 0.5rem;"><span class="badge" style="background: rgba(188, 19, 254, 0.2); color: var(--secondary-color); border: 1px solid var(--secondary-color);">{{ item.charge_number.contract.name }} ({{ item.charge_number.slug }})</span></div>
{% endif %}
{% if item.description %}
<div class="kanban-item-desc">{{ item.description|truncatechars:50 }}</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
<!-- Done Column -->
<div class="kanban-column" id="col-DONE">
<h3>Done <span class="badge">{{ done_items.count }}</span></h3>
<div class="kanban-items" data-status="DONE">
{% for item in done_items %}
<div class="kanban-item" draggable="{% if is_developer %}true{% else %}false{% endif %}" data-id="{{ item.id }}">
<div class="kanban-item-title">{{ item.title }}</div>
{% if item.charge_number %}
<div style="margin-bottom: 0.5rem;"><span class="badge" style="background: rgba(188, 19, 254, 0.2); color: var(--secondary-color); border: 1px solid var(--secondary-color);">{{ item.charge_number.contract.name }} ({{ item.charge_number.slug }})</span></div>
{% endif %}
{% if item.description %}
<div class="kanban-item-desc">{{ item.description|truncatechars:50 }}</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<!-- Add Item Modal -->
<div id="addItemModal" class="modal">
<div class="modal-content">
<span class="close" id="closeAddModal">&times;</span>
<h2 style="color: var(--primary-color); margin-bottom: 1.5rem;">New Item</h2>
<form method="post" action="{% url 'planning:create_item' %}">
{% csrf_token %}
<div class="form-group">
<label style="color: var(--text-muted); margin-bottom: 0.5rem; display: block;">Title</label>
<input type="text" name="title" class="form-control" required>
</div>
<div class="form-group">
<label style="color: var(--text-muted); margin-bottom: 0.5rem; display: block;">Charge Number (QBD)</label>
<select name="charge_number" class="form-control">
<option value="">-- No Charge Number --</option>
{% for cn in qbd_charge_numbers %}
<option value="{{ cn.id }}">{{ cn.contract.name }} - {{ cn.slug }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label style="color: var(--text-muted); margin-bottom: 0.5rem; display: block;">Description</label>
<textarea name="description" class="form-control" rows="4"></textarea>
</div>
<input type="hidden" name="next" value="{{ request.path }}">
<button type="submit" class="btn">Create</button>
</form>
</div>
</div>
<!-- Item Details Modal -->
<div id="itemDetailModal" class="modal">
<div class="modal-content">
<span class="close" id="closeDetailModal">&times;</span>
<div id="itemDetailContent">
<!-- Loaded via AJAX -->
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Modal logic
const addModal = document.getElementById('addItemModal');
const openAddBtn = document.getElementById('openAddModalBtn');
const closeAddBtn = document.getElementById('closeAddModal');
if (openAddBtn) {
openAddBtn.onclick = () => addModal.style.display = "block";
}
if (closeAddBtn) {
closeAddBtn.onclick = () => addModal.style.display = "none";
}
const detailModal = document.getElementById('itemDetailModal');
const closeDetailBtn = document.getElementById('closeDetailModal');
const detailContent = document.getElementById('itemDetailContent');
closeDetailBtn.onclick = () => detailModal.style.display = "none";
window.onclick = (event) => {
if (event.target == addModal) addModal.style.display = "none";
if (event.target == detailModal) detailModal.style.display = "none";
}
// Drag and Drop Logic
const items = document.querySelectorAll('.kanban-item');
const dropzones = document.querySelectorAll('.kanban-items');
items.forEach(item => {
item.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', e.target.closest('.kanban-item').dataset.id);
setTimeout(() => e.target.closest('.kanban-item').style.opacity = '0.5', 0);
});
item.addEventListener('dragend', (e) => {
e.target.closest('.kanban-item').style.opacity = '1';
});
item.addEventListener('click', (e) => {
const itemId = item.dataset.id;
fetch(`/planning/item/${itemId}/`)
.then(res => res.text())
.then(html => {
detailContent.innerHTML = html;
detailModal.style.display = "block";
});
});
});
dropzones.forEach(zone => {
zone.addEventListener('dragover', (e) => {
e.preventDefault(); // Necessary to allow dropping
zone.parentElement.style.borderColor = 'var(--primary-color)';
});
zone.addEventListener('dragleave', (e) => {
zone.parentElement.style.borderColor = 'rgba(255, 255, 255, 0.05)';
});
zone.addEventListener('drop', (e) => {
e.preventDefault();
zone.parentElement.style.borderColor = 'rgba(255, 255, 255, 0.05)';
const id = e.dataTransfer.getData('text/plain');
if(!id) return;
const draggable = document.querySelector(`.kanban-item[data-id='${id}']`);
if (!draggable) return;
zone.appendChild(draggable);
const newStatus = zone.dataset.status;
// Send AJAX update
fetch(`/planning/item/${id}/update/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
},
body: JSON.stringify({
status: newStatus,
order: Array.from(zone.children).indexOf(draggable)
})
}).then(res => res.json())
.then(data => {
if(data.status !== 'success') {
alert('Failed to update status');
} else {
// Update counts if necessary, though reloading or dynamic updates are better.
// Simple approach: user just sees it moved.
}
});
});
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,78 @@
{% if is_developer %}
<form method="post" action="{% url 'planning:edit_item' item.pk %}">
{% csrf_token %}
<input type="hidden" name="next" id="editItemNext">
<div class="form-group" style="margin-bottom: 1.5rem;">
<label style="color: var(--text-muted); display: block; margin-bottom: 0.5rem;">Title</label>
<input type="text" name="title" class="form-control" value="{{ item.title }}" style="font-size: 1.2rem; font-weight: bold; color: var(--primary-color);" required>
</div>
<div style="display: flex; gap: 1rem; margin-bottom: 1.5rem;">
<div class="form-group" style="flex: 1; margin-bottom: 0;">
<label style="color: var(--text-muted); display: block; margin-bottom: 0.5rem;">Status</label>
<select name="status" class="form-control">
{% for value, label in statuses %}
<option value="{{ value }}" {% if item.status == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="form-group" style="flex: 1; margin-bottom: 0;">
<label style="color: var(--text-muted); display: block; margin-bottom: 0.5rem;">Charge Number</label>
<select name="charge_number" class="form-control">
<option value="">-- No Charge Number --</option>
{% for cn in qbd_charge_numbers %}
<option value="{{ cn.id }}" {% if item.charge_number_id == cn.id %}selected{% endif %}>{{ cn.contract.name }} - {{ cn.slug }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="form-group">
<label style="color: var(--text-muted); display: block; margin-bottom: 0.5rem;">Description</label>
<textarea name="description" class="form-control" rows="6">{{ item.description }}</textarea>
</div>
<hr style="border: 0; border-top: 1px solid rgba(255,255,255,0.1); margin: 2rem 0 1rem;">
<div style="font-size: 0.8rem; color: var(--text-muted); margin-bottom: 1.5rem;">
Created: {{ item.created_at|date:"M d, Y H:i" }}<br>
Updated: {{ item.updated_at|date:"M d, Y H:i" }}
</div>
<div style="display: flex; justify-content: space-between;">
<button type="submit" class="btn" style="padding: 0.8rem 2rem;">Save Changes</button>
<button type="button" class="btn" style="background: transparent; border: 1px solid #ff4444; color: #ff4444; padding: 0.8rem 2rem;" onclick="if(confirm('Are you sure you want to delete this ticket?')) document.getElementById('deleteItemForm').submit();">Delete Ticket</button>
</div>
</form>
<form id="deleteItemForm" method="post" action="{% url 'planning:delete_item' item.pk %}" style="display:none;">
{% csrf_token %}
<input type="hidden" name="next" id="deleteItemNext">
</form>
<script>
// Ensure form redirects to the current page upon save or delete
document.getElementById('editItemNext').value = window.location.pathname;
document.getElementById('deleteItemNext').value = window.location.pathname;
</script>
{% else %}
<h2 style="color: var(--primary-color); margin-bottom: 1rem;">{{ item.title }}</h2>
<div style="margin-bottom: 1.5rem; display: flex; gap: 1rem; align-items: center;">
<span style="background: var(--surface-color); padding: 0.3rem 0.6rem; border-radius: 4px; font-size: 0.8rem; border: 1px solid var(--secondary-color);">{{ item.get_status_display }}</span>
{% if item.charge_number %}
<span style="background: rgba(188, 19, 254, 0.1); padding: 0.3rem 0.6rem; border-radius: 4px; font-size: 0.8rem; border: 1px solid var(--secondary-color); color: var(--secondary-color);">Charge Number: <strong>{{ item.charge_number.contract.name }} ({{ item.charge_number.slug }})</strong> ({{ item.charge_number.get_percent_complete|floatformat:0 }}% Complete)</span>
{% endif %}
</div>
<div style="color: var(--text-color); line-height: 1.6; white-space: pre-wrap;">
{% if item.description %}
{{ item.description }}
{% else %}
<span style="color: var(--text-muted); font-style: italic;">No description provided.</span>
{% endif %}
</div>
<hr style="border: 0; border-top: 1px solid rgba(255,255,255,0.1); margin: 2rem 0 1rem;">
<div style="font-size: 0.8rem; color: var(--text-muted);">
Created: {{ item.created_at|date:"M d, Y H:i" }}<br>
Updated: {{ item.updated_at|date:"M d, Y H:i" }}
</div>
{% endif %}

View File

@@ -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/<int:pk>/', views.item_detail, name='item_detail'),
path('item/<int:pk>/update/', views.update_item_status, name='update_item_status'),
path('item/<int:pk>/edit/', views.edit_item, name='edit_item'),
path('item/<int:pk>/delete/', views.delete_item, name='delete_item'),
]

View File

@@ -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)

View File

@@ -382,6 +382,66 @@ nav {
margin-left: 5px; 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 { .text-cyber-cyan {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(0 243 255 / var(--tw-text-opacity, 1)); color: rgb(0 243 255 / var(--tw-text-opacity, 1));

View File

@@ -70,6 +70,29 @@
</li> </li>
<li><a href="{% url 'contact' %}" <li><a href="{% url 'contact' %}"
class="{% if request.resolver_match.url_name == 'contact' %}active{% endif %}">Contact</a></li> class="{% if request.resolver_match.url_name == 'contact' %}active{% endif %}">Contact</a></li>
{% if user.is_authenticated %}
<li><a href="{% url 'planning:board_view' %}" class="{% if 'planning' in request.path %}active{% endif %}">Planning</a></li>
<li><a href="{% url 'financial_index' %}" class="{% if 'financial' in request.path %}active{% endif %}">Financials</a></li>
<li class="dropdown" id="user-profile-dropdown">
<a href="#" class="profile-icon-link" title="{{ user.get_full_name|default:user.username }}">
<svg class="profile-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="8" r="4"/>
<path d="M4 21v-1a6 6 0 0 1 12 0v1"/>
</svg>
</a>
<ul class="dropdown-content profile-dropdown-content">
<li class="profile-name-item">{{ user.get_full_name|default:user.username }}</li>
<li>
<form action="{% url 'logout' %}" method="post" style="margin: 0;">
{% csrf_token %}
<button type="submit" class="profile-logout-btn">Log Out</button>
</form>
</li>
</ul>
</li>
{% else %}
<li><a href="{% url 'login' %}" class="btn text-cyber-cyan" style="border: 1px solid var(--primary-color); padding: 0.4rem 1rem; border-radius: 4px; margin-left: 1rem;">Sign In</a></li>
{% endif %}
</ul> </ul>
</nav> </nav>

View File

@@ -0,0 +1,36 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Sign In - AI ML Operations{% endblock %}
{% block content %}
<div class="section" style="min-height: 70vh; display: flex; align-items: center; justify-content: center;">
<div class="container" style="max-width: 450px;">
<div style="background: var(--surface-color); border: 1px solid rgba(0, 243, 255, 0.2); border-radius: 12px; padding: 2rem; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);">
<h2 class="section-title text-center" style="margin-bottom: 2rem;">Sign In</h2>
{% if form.errors %}
<div style="background: rgba(255, 68, 68, 0.1); border: 1px solid #ff4444; color: #ff4444; padding: 1rem; border-radius: 8px; margin-bottom: 1.5rem;">
Your username and password didn't match. Please try again.
</div>
{% endif %}
<form method="post" action="{% url 'login' %}">
{% csrf_token %}
<div class="form-group" style="margin-bottom: 1.5rem;">
<label for="id_username" style="color: var(--text-color); display: block; margin-bottom: 0.5rem; font-weight: 500;">Username / Email</label>
<input type="text" name="username" autofocus autocapitalize="none" autocomplete="username" maxlength="150" required id="id_username" class="form-control" style="width: 100%; padding: 0.8rem; background: rgba(0,0,0,0.2); border: 1px solid rgba(255,255,255,0.1); border-radius: 6px; color: white;">
</div>
<div class="form-group" style="margin-bottom: 2rem;">
<label for="id_password" style="color: var(--text-color); display: block; margin-bottom: 0.5rem; font-weight: 500;">Password</label>
<input type="password" name="password" autocomplete="current-password" required id="id_password" class="form-control" style="width: 100%; padding: 0.8rem; background: rgba(0,0,0,0.2); border: 1px solid rgba(255,255,255,0.1); border-radius: 6px; color: white;">
</div>
<button type="submit" class="btn" style="width: 100%; padding: 0.8rem; font-size: 1.1rem; border-radius: 6px; background: var(--primary-color); color: #000; font-weight: bold; border: none; cursor: pointer; transition: 0.2s;" onmouseover="this.style.boxShadow='0 0 15px var(--primary-color)'" onmouseout="this.style.boxShadow='none'">Sign In</button>
<input type="hidden" name="next" value="{{ next|default:'/' }}">
</form>
</div>
</div>
</div>
{% endblock %}