diff --git a/vergabe_teilnahme/apps/aufgaben/forms.py b/vergabe_teilnahme/apps/aufgaben/forms.py
index a8c2e65..4a5e5c3 100644
--- a/vergabe_teilnahme/apps/aufgaben/forms.py
+++ b/vergabe_teilnahme/apps/aufgaben/forms.py
@@ -7,13 +7,14 @@ class AufgabeForm(forms.ModelForm):
class Meta:
model = Aufgabe
fields = [
- 'titel', 'beschreibung', 'typ', 'prioritaet', 'frist',
+ 'titel', 'beschreibung', 'typ', 'phase', 'prioritaet', 'frist',
'verantwortlicher', 'los', 'anforderung', 'bieterfrage',
]
widgets = {
'titel': forms.TextInput(attrs={'class': 'form-input', 'autofocus': True}),
'beschreibung': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}),
'typ': forms.Select(attrs={'class': 'form-input'}),
+ 'phase': forms.Select(attrs={'class': 'form-input'}),
'prioritaet': forms.RadioSelect(),
'frist': forms.DateInput(attrs={'class': 'form-input', 'type': 'date'}),
'verantwortlicher': forms.Select(attrs={'class': 'form-input'}),
@@ -30,6 +31,7 @@ class AufgabeForm(forms.ModelForm):
self.fields['anforderung'].queryset = Anforderung.objects.filter(ausschreibung=ausschreibung)
self.fields['bieterfrage'].queryset = Bieterfrage.objects.filter(ausschreibung=ausschreibung)
self.fields['beschreibung'].required = False
+ self.fields['phase'].required = False
self.fields['frist'].required = False
self.fields['verantwortlicher'].required = False
self.fields['los'].required = False
diff --git a/vergabe_teilnahme/apps/aufgaben/migrations/0003_phase_feld.py b/vergabe_teilnahme/apps/aufgaben/migrations/0003_phase_feld.py
new file mode 100644
index 0000000..04e6202
--- /dev/null
+++ b/vergabe_teilnahme/apps/aufgaben/migrations/0003_phase_feld.py
@@ -0,0 +1,18 @@
+# Generated by Django 6.0.5 on 2026-05-13 23:39
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('aufgaben', '0002_initial'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='aufgabe',
+ name='phase',
+ field=models.PositiveSmallIntegerField(blank=True, choices=[(1, 'Recherche & Unterlagen'), (2, 'Teilnahmeentscheidung'), (3, 'Detaillierte Durchsicht'), (4, 'Bieterfragen & Klärung'), (5, 'Preismodell'), (6, 'Unterlagen finalisieren'), (7, 'Abgabe'), (8, 'Zuschlag / Nachbetrachtung')], null=True),
+ ),
+ ]
diff --git a/vergabe_teilnahme/apps/aufgaben/models.py b/vergabe_teilnahme/apps/aufgaben/models.py
index 47deaa1..525e313 100644
--- a/vergabe_teilnahme/apps/aufgaben/models.py
+++ b/vergabe_teilnahme/apps/aufgaben/models.py
@@ -26,6 +26,16 @@ class Aufgabe(FlexibleModel):
('ueberfaellig', 'Überfällig'),
]
PRIORITAET_CHOICES = [(1, 'Hoch'), (2, 'Mittel'), (3, 'Niedrig')]
+ PHASE_CHOICES = [
+ (1, 'Recherche & Unterlagen'),
+ (2, 'Teilnahmeentscheidung'),
+ (3, 'Detaillierte Durchsicht'),
+ (4, 'Bieterfragen & Klärung'),
+ (5, 'Preismodell'),
+ (6, 'Unterlagen finalisieren'),
+ (7, 'Abgabe'),
+ (8, 'Zuschlag / Nachbetrachtung'),
+ ]
ausschreibung = models.ForeignKey(
'ausschreibungen.Ausschreibung', on_delete=models.CASCADE, related_name='aufgaben'
@@ -55,6 +65,7 @@ class Aufgabe(FlexibleModel):
'accounts.Mitarbeiter', on_delete=models.SET_NULL, null=True, blank=True
)
ergebnis = models.TextField(blank=True)
+ phase = models.PositiveSmallIntegerField(choices=PHASE_CHOICES, null=True, blank=True)
class Meta:
ordering = ['prioritaet', 'frist']
diff --git a/vergabe_teilnahme/apps/aufgaben/views.py b/vergabe_teilnahme/apps/aufgaben/views.py
index 5e0fe7b..294a871 100644
--- a/vergabe_teilnahme/apps/aufgaben/views.py
+++ b/vergabe_teilnahme/apps/aufgaben/views.py
@@ -40,6 +40,10 @@ def aufgaben_liste(request, ausschreibung_id=None):
if typ_filter:
qs = qs.filter(typ=typ_filter)
+ phase_filter = request.GET.get('phase')
+ if phase_filter:
+ qs = qs.filter(phase=phase_filter)
+
verantwortlicher_filter = request.GET.get('verantwortlicher')
if verantwortlicher_filter:
qs = qs.filter(verantwortlicher=verantwortlicher_filter)
@@ -67,6 +71,8 @@ def aufgaben_liste(request, ausschreibung_id=None):
'mitarbeiter': Mitarbeiter.objects.all(),
'current_status': status_filter or '',
'current_typ': typ_filter or '',
+ 'phase_choices': Aufgabe.PHASE_CHOICES,
+ 'current_phase': phase_filter or '',
'current_verantwortlicher': verantwortlicher_filter or '',
'breadcrumbs': breadcrumbs,
}
diff --git a/vergabe_teilnahme/apps/ausschreibungen/views.py b/vergabe_teilnahme/apps/ausschreibungen/views.py
index 5022fca..064d3cd 100644
--- a/vergabe_teilnahme/apps/ausschreibungen/views.py
+++ b/vergabe_teilnahme/apps/ausschreibungen/views.py
@@ -59,7 +59,15 @@ def ausschreibung_liste(request):
if bid_manager_filter:
qs = qs.filter(bid_manager=bid_manager_filter)
- qs = qs.select_related('bid_manager').order_by('-geaendert_am')
+ from django.db.models import Count, Q
+ qs = qs.select_related('bid_manager').annotate(
+ aufgaben_total=Count('aufgaben', distinct=True),
+ aufgaben_erledigt=Count(
+ 'aufgaben',
+ filter=Q(aufgaben__status__in=['erledigt', 'verworfen']),
+ distinct=True,
+ ),
+ ).order_by('-geaendert_am')
ctx = {
'ausschreibungen': qs,
@@ -100,7 +108,8 @@ def ausschreibung_neu(request):
def ausschreibung_detail(request, pk):
- from vergabe_teilnahme.apps.core.services import build_phase_nav, get_deadline_warnings
+ from vergabe_teilnahme.apps.aufgaben.models import Aufgabe
+ from vergabe_teilnahme.apps.core.services import aufgaben_score, build_phase_nav, get_deadline_warnings
a = get_object_or_404(Ausschreibung, pk=pk)
ctx = {
@@ -108,6 +117,7 @@ def ausschreibung_detail(request, pk):
'ausschreibung_id': pk,
'phases': build_phase_nav(a),
'warnungen': get_deadline_warnings(a),
+ 'aufgaben_score': aufgaben_score(Aufgabe.objects.filter(ausschreibung=a)),
'breadcrumbs': [
{'label': 'Ausschreibungen', 'url': '/ausschreibungen/'},
{'label': a.titel, 'url': None},
diff --git a/vergabe_teilnahme/apps/core/services.py b/vergabe_teilnahme/apps/core/services.py
index a5bdc54..a874e80 100644
--- a/vergabe_teilnahme/apps/core/services.py
+++ b/vergabe_teilnahme/apps/core/services.py
@@ -25,7 +25,15 @@ STATUS_TO_PHASE = {
}
+def aufgaben_score(qs):
+ total = qs.count()
+ abgeschlossen = qs.filter(status__in=['erledigt', 'verworfen']).count()
+ pct = round(abgeschlossen / total * 100) if total else 0
+ return {'total': total, 'abgeschlossen': abgeschlossen, 'score_pct': pct}
+
+
def build_phase_nav(ausschreibung, current_url=''):
+ from vergabe_teilnahme.apps.aufgaben.models import Aufgabe
aktuelle_phase = STATUS_TO_PHASE.get(ausschreibung.status, 1)
base = f'/ausschreibungen/{ausschreibung.pk}'
phase_urls = {
@@ -38,6 +46,11 @@ def build_phase_nav(ausschreibung, current_url=''):
7: f'{base}/abgabe/',
8: f'{base}/nachbetrachtung/',
}
+ aufgaben_qs = Aufgabe.objects.filter(ausschreibung=ausschreibung)
+ phasen_stats = {
+ num: aufgaben_score(aufgaben_qs.filter(phase=num))
+ for num, _ in PHASEN
+ }
return [
{
'nummer': num,
@@ -46,6 +59,7 @@ def build_phase_nav(ausschreibung, current_url=''):
'aktiv': num == aktuelle_phase,
'erledigt': num < aktuelle_phase,
'warnung': False,
+ 'aufgaben_score': phasen_stats[num],
}
for num, name in PHASEN
]
diff --git a/vergabe_teilnahme/apps/lose/views.py b/vergabe_teilnahme/apps/lose/views.py
index 7db9000..925afcf 100644
--- a/vergabe_teilnahme/apps/lose/views.py
+++ b/vergabe_teilnahme/apps/lose/views.py
@@ -24,8 +24,16 @@ def _ausschreibung_breadcrumbs(ausschreibung, *extra):
# ─── Lose ────────────────────────────────────────────────────────────────────
def lose_liste(request, ausschreibung_id):
+ from django.db.models import Count, Q
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
- lose = Los.objects.filter(ausschreibung=ausschreibung).order_by('losnummer')
+ lose = Los.objects.filter(ausschreibung=ausschreibung).annotate(
+ aufgaben_total=Count('aufgaben', distinct=True),
+ aufgaben_erledigt=Count(
+ 'aufgaben',
+ filter=Q(aufgaben__status__in=['erledigt', 'verworfen']),
+ distinct=True,
+ ),
+ ).order_by('losnummer')
return render(request, 'lose/liste.html', {
'ausschreibung': ausschreibung,
'lose': lose,
diff --git a/vergabe_teilnahme/templates/aufgaben/liste.html b/vergabe_teilnahme/templates/aufgaben/liste.html
index f09c93e..bb98bff 100644
--- a/vergabe_teilnahme/templates/aufgaben/liste.html
+++ b/vergabe_teilnahme/templates/aufgaben/liste.html
@@ -32,6 +32,15 @@
{% endfor %}
+
+
+
+
+ {% with s=aufgaben_score %}{% if s.total %}
Aufgaben: {{ s.abgeschlossen }}/{{ s.total }} ({{ s.score_pct }} %){% endif %}{% endwith %}
{% include "ausschreibungen/partials/status_widget.html" %}
Bearbeiten
Archivieren
@@ -60,6 +61,7 @@
{% render_field ausschreibung "ausschreiber" "Ausschreiber" %}
{% render_field ausschreibung "vergabeart" "Vergabeart" %}
{% render_field ausschreibung "rechtsgrundlage" "Rechtsgrundlage" %}
+ {% render_field ausschreibung "rechtsgrundlage_details" "Rechtsgrundlage Details" %}
{% render_field ausschreibung "vergabenummer" "Vergabenummer" %}
{% render_field ausschreibung "vergabeplattform" "Plattform" %}
{% render_field ausschreibung "branche" "Branche" %}
diff --git a/vergabe_teilnahme/templates/ausschreibungen/liste_partial.html b/vergabe_teilnahme/templates/ausschreibungen/liste_partial.html
index 9d4fbf3..eb99144 100644
--- a/vergabe_teilnahme/templates/ausschreibungen/liste_partial.html
+++ b/vergabe_teilnahme/templates/ausschreibungen/liste_partial.html
@@ -7,6 +7,7 @@
Ausschreiber |
Titel |
Volumen (gesch.) |
+
Aufgaben |
Status |
Abgabe |
Bid Manager |
@@ -24,6 +25,12 @@
{% if a.geschaetztes_volumen %}{{ a.geschaetztes_volumen|floatformat:0 }} €{% else %}—{% endif %}
|
+
+ {% if a.aufgaben_total %}
+ {{ a.aufgaben_erledigt }}/{{ a.aufgaben_total }}
+ ({% widthratio a.aufgaben_erledigt a.aufgaben_total 100 %} %)
+ {% else %}—{% endif %}
+ |
{% status_badge a.status a.get_status_display %}
|
diff --git a/vergabe_teilnahme/templates/lose/liste.html b/vergabe_teilnahme/templates/lose/liste.html
index 84ca5bf..a84d0e5 100644
--- a/vergabe_teilnahme/templates/lose/liste.html
+++ b/vergabe_teilnahme/templates/lose/liste.html
@@ -16,6 +16,7 @@
Nr. |
Titel |
Zuständig |
+
Aufgaben |
Teilnahme |
|
diff --git a/vergabe_teilnahme/templates/lose/partials/los_row.html b/vergabe_teilnahme/templates/lose/partials/los_row.html
index b6ef255..d4d3dba 100644
--- a/vergabe_teilnahme/templates/lose/partials/los_row.html
+++ b/vergabe_teilnahme/templates/lose/partials/los_row.html
@@ -6,6 +6,12 @@
class="text-brand-600 hover:underline font-medium">{{ los.lostitel }}
{{ los.zustaendiger|default:"—" }} |
+
+ {% if los.aufgaben_total %}
+ {{ los.aufgaben_erledigt }}/{{ los.aufgaben_total }}
+ ({% widthratio los.aufgaben_erledigt los.aufgaben_total 100 %} %)
+ {% else %}—{% endif %}
+ |
{% if los.teilnahme is None %}
Offen
diff --git a/vergabe_teilnahme/templates/partials/phase_nav.html b/vergabe_teilnahme/templates/partials/phase_nav.html
index 17f0e46..c1f9152 100644
--- a/vergabe_teilnahme/templates/partials/phase_nav.html
+++ b/vergabe_teilnahme/templates/partials/phase_nav.html
@@ -13,7 +13,9 @@
{{ phase.nummer }}
{{ phase.name }}
- {% if phase.warnung %}
+ {% if phase.aufgaben_score.total %}
+ {{ phase.aufgaben_score.abgeschlossen }}/{{ phase.aufgaben_score.total }}
+ {% elif phase.warnung %}
⚠
{% endif %}
diff --git a/workplans/WP-0014-aufgaben-lose-scores.md b/workplans/WP-0014-aufgaben-lose-scores.md
new file mode 100644
index 0000000..64be13d
--- /dev/null
+++ b/workplans/WP-0014-aufgaben-lose-scores.md
@@ -0,0 +1,242 @@
+---
+id: WP-0014
+title: Aufgaben-Phasenzuordnung und Fertigstellungs-Scores in Übersichten
+status: done
+phase: 14-of-n
+created: "2026-05-14"
+depends_on: WP-0013
+---
+
+# WP-0014 — Aufgaben-Phasenzuordnung und Scores
+
+Aufgaben bekommen ein Phasenfeld (1–8). In den Übersichten von
+Ausschreibungen, Losen und der Phasennavigation wird jeweils die Anzahl
+der verknüpften Aufgaben sowie ein Fertigstellungs-Score angezeigt
+(z.B. "8/10 (80%)"). Status "erledigt" und "verworfen" gelten als
+abgeschlossen.
+
+---
+
+```task
+id: WP-0014-T01
+title: Aufgabe.phase — Feld + Migration
+status: done
+
+`apps/aufgaben/models.py` — Feld ergänzen:
+
+```python
+PHASE_CHOICES = [
+ (1, 'Recherche & Unterlagen'),
+ (2, 'Teilnahmeentscheidung'),
+ (3, 'Detaillierte Durchsicht'),
+ (4, 'Bieterfragen & Klärung'),
+ (5, 'Preismodell'),
+ (6, 'Unterlagen finalisieren'),
+ (7, 'Abgabe'),
+ (8, 'Zuschlag / Nachbetrachtung'),
+]
+phase = models.PositiveSmallIntegerField(
+ choices=PHASE_CHOICES, null=True, blank=True
+)
+```
+
+`apps/aufgaben/forms.py` — `phase` zu den Feldern hinzufügen,
+Widget: `forms.Select(attrs={'class': 'form-input'})`.
+
+Migration erstellen und ausführen.
+```
+
+```task
+id: WP-0014-T02
+title: aufgaben_score() Helper in core/services.py
+status: done
+
+Wiederverwendbare Hilfsfunktion, die für ein beliebiges Aufgaben-QuerySet
+den Score berechnet:
+
+```python
+def aufgaben_score(qs):
+ """
+ Gibt dict zurück: total, abgeschlossen, score_pct
+ Abgeschlossen = status in ['erledigt', 'verworfen']
+ """
+ total = qs.count()
+ abgeschlossen = qs.filter(status__in=['erledigt', 'verworfen']).count()
+ pct = round(abgeschlossen / total * 100) if total else 0
+ return {'total': total, 'abgeschlossen': abgeschlossen, 'score_pct': pct}
+```
+
+Kein Template-Tag — die Views rufen die Funktion auf und übergeben
+das Ergebnis direkt im Kontext.
+```
+
+```task
+id: WP-0014-T03
+title: Aufgaben-Score in Ausschreibungen-Liste
+status: done
+
+`apps/ausschreibungen/views.py` — in der Liste-View das QuerySet mit
+`annotate` ergänzen:
+
+```python
+from django.db.models import Count, Q
+qs = qs.annotate(
+ aufgaben_total=Count('aufgaben', distinct=True),
+ aufgaben_erledigt=Count(
+ 'aufgaben',
+ filter=Q(aufgaben__status__in=['erledigt', 'verworfen']),
+ distinct=True,
+ ),
+)
+```
+
+`templates/ausschreibungen/liste_partial.html` — neue Spalte "Aufgaben"
+nach der Volumen-Spalte:
+
+```html
+ | Aufgaben |
+...
+
+ {% if a.aufgaben_total %}
+ {{ a.aufgaben_erledigt }}/{{ a.aufgaben_total }}
+ ({% widthratio a.aufgaben_erledigt a.aufgaben_total 100 %} %)
+ {% else %}—{% endif %}
+ |
+```
+```
+
+```task
+id: WP-0014-T04
+title: Aufgaben-Score in Ausschreibungs-Detail
+status: done
+
+`apps/ausschreibungen/views.py` — in `ausschreibung_detail` den Score
+berechnen und übergeben:
+
+```python
+from vergabe_teilnahme.apps.core.services import aufgaben_score
+from vergabe_teilnahme.apps.aufgaben.models import Aufgabe
+ctx['aufgaben_score'] = aufgaben_score(
+ Aufgabe.objects.filter(ausschreibung=a)
+)
+```
+
+`templates/ausschreibungen/detail.html` — Aufgaben-Score-Badge im
+Kopfbereich (neben Phase-Status), z.B.:
+
+```html
+{% with s=aufgaben_score %}
+{% if s.total %}
+
+ Aufgaben: {{ s.abgeschlossen }}/{{ s.total }} ({{ s.score_pct }} %)
+
+{% endif %}
+{% endwith %}
+```
+```
+
+```task
+id: WP-0014-T05
+title: Aufgaben-Score in Lose-Liste
+status: done
+
+`apps/lose/views.py` — in `lose_liste` das Lose-QuerySet annotieren:
+
+```python
+from django.db.models import Count, Q
+lose = Los.objects.filter(ausschreibung=a).annotate(
+ aufgaben_total=Count('aufgaben', distinct=True),
+ aufgaben_erledigt=Count(
+ 'aufgaben',
+ filter=Q(aufgaben__status__in=['erledigt', 'verworfen']),
+ distinct=True,
+ ),
+)
+```
+
+`templates/lose/partials/los_row.html` und `templates/lose/liste.html`
+— Spalte "Aufgaben" mit Score ergänzen.
+```
+
+```task
+id: WP-0014-T06
+title: Aufgaben-Score in Phase-Navigation
+status: done
+
+`apps/core/services.py` — `build_phase_nav` erweitern: für jede Phase
+die zugehörigen Aufgaben der Ausschreibung zählen und den Score
+berechnen. Nur sinnvoll wenn Aufgaben vorhanden sind.
+
+```python
+from vergabe_teilnahme.apps.aufgaben.models import Aufgabe
+from django.db.models import Count, Q
+
+aufgaben_qs = Aufgabe.objects.filter(ausschreibung=ausschreibung)
+phasen_stats = {
+ num: aufgaben_score(aufgaben_qs.filter(phase=num))
+ for num, _ in PHASEN
+}
+```
+
+Jeden Phase-Dict um `aufgaben_score`-Werte erweitern.
+
+`templates/partials/phase_nav.html` — pro Phaseneintrag einen
+Fortschrittsbalken oder Score-Text anzeigen wenn `aufgaben_total > 0`:
+
+```html
+{% if phase.aufgaben_score.total %}
+
+ {{ phase.aufgaben_score.abgeschlossen }}/{{ phase.aufgaben_score.total }}
+
+{% endif %}
+```
+```
+
+```task
+id: WP-0014-T07
+title: Phasen-Filter in Aufgaben-Liste
+status: done
+
+`apps/aufgaben/views.py` — Phasenfilter ergänzen (analog zum
+bestehenden Status- und Typ-Filter):
+
+```python
+phase_filter = request.GET.get('phase')
+if phase_filter:
+ qs = qs.filter(phase=phase_filter)
+```
+
+`templates/aufgaben/liste.html` — Phasen-Dropdown im Filterbereich:
+
+```html
+
+
+
+
+```
+
+Kontext: `phase_choices=Aufgabe.PHASE_CHOICES`, `current_phase=phase_filter or ''`.
+```
+
+```task
+id: WP-0014-T08
+title: Tests + Smoke-Check aller geänderten Seiten
+status: done
+
+pytest ausführen — alle 68 bestehenden Tests müssen grün bleiben.
+
+Zusätzlich per Django-Test-Client prüfen:
+- Ausschreibungen-Liste: Status 200, enthält Score-Spalte
+- Lose-Liste: Status 200
+- Aufgaben-Liste mit ?phase=1: Status 200
+- Ausschreibungs-Detail: Status 200
+- Aufgabe anlegen mit phase=3: wird korrekt gespeichert
+```
+```