generated from coulomb/repo-seed
We are building, workplan now registered with statehub
This commit is contained in:
3
vergabe_teilnahme/apps/aufgaben/bieterfragen_urls.py
Normal file
3
vergabe_teilnahme/apps/aufgaben/bieterfragen_urls.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.urls import path
|
||||
|
||||
urlpatterns = []
|
||||
40
vergabe_teilnahme/apps/ausschreibungen/forms.py
Normal file
40
vergabe_teilnahme/apps/ausschreibungen/forms.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from django import forms
|
||||
|
||||
from .models import Ausschreibung
|
||||
|
||||
|
||||
class AusschreibungForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Ausschreibung
|
||||
fields = [
|
||||
'titel', 'ausschreiber', 'vergabeplattform', 'vergabenummer', 'vergabeart',
|
||||
'fundstelle_url', 'bid_manager', 'leistungsbeschreibung',
|
||||
'branche', 'schlagwoerter', 'geschaetztes_volumen',
|
||||
'veroeffentlichungsdatum', 'bieterfragen_bis', 'abgabe_bis', 'bindefrist',
|
||||
'unterlagen_erhalten', 'unterlagen_erhalten_am',
|
||||
'teilnahmeentscheidung', 'entscheidungsbegruendung',
|
||||
]
|
||||
widgets = {
|
||||
'titel': forms.TextInput(attrs={'class': 'form-input', 'autofocus': True}),
|
||||
'ausschreiber': forms.TextInput(attrs={'class': 'form-input'}),
|
||||
'vergabeplattform': forms.TextInput(attrs={'class': 'form-input'}),
|
||||
'vergabenummer': forms.TextInput(attrs={'class': 'form-input'}),
|
||||
'vergabeart': forms.Select(attrs={'class': 'form-input'}),
|
||||
'fundstelle_url': forms.URLInput(attrs={'class': 'form-input'}),
|
||||
'bid_manager': forms.Select(attrs={'class': 'form-input'}),
|
||||
'leistungsbeschreibung': forms.Textarea(attrs={'class': 'form-input', 'rows': 4}),
|
||||
'branche': forms.TextInput(attrs={'class': 'form-input'}),
|
||||
'schlagwoerter': forms.TextInput(attrs={'class': 'form-input'}),
|
||||
'geschaetztes_volumen': forms.NumberInput(attrs={'class': 'form-input'}),
|
||||
'veroeffentlichungsdatum': forms.DateInput(attrs={'class': 'form-input', 'type': 'date'}),
|
||||
'bieterfragen_bis': forms.DateInput(attrs={'class': 'form-input', 'type': 'date'}),
|
||||
'abgabe_bis': forms.DateTimeInput(attrs={'class': 'form-input', 'type': 'datetime-local'}),
|
||||
'bindefrist': forms.DateInput(attrs={'class': 'form-input', 'type': 'date'}),
|
||||
'unterlagen_erhalten_am': forms.DateInput(attrs={'class': 'form-input', 'type': 'date'}),
|
||||
'teilnahmeentscheidung': forms.Select(attrs={'class': 'form-input'}),
|
||||
'entscheidungsbegruendung': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['teilnahmeentscheidung'].required = False
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.5 on 2026-05-08 12:39
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ausschreibungen', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='ausschreibung',
|
||||
name='archiviert',
|
||||
field=models.BooleanField(db_index=True, default=False),
|
||||
),
|
||||
]
|
||||
@@ -74,6 +74,8 @@ class Ausschreibung(FlexibleModel):
|
||||
unterlagen_erhalten = models.BooleanField(default=False)
|
||||
unterlagen_erhalten_am = models.DateField(null=True, blank=True)
|
||||
|
||||
archiviert = models.BooleanField(default=False, db_index=True)
|
||||
|
||||
# Timestamps
|
||||
erstellt_am = models.DateTimeField(auto_now_add=True)
|
||||
geaendert_am = models.DateTimeField(auto_now=True)
|
||||
|
||||
49
vergabe_teilnahme/apps/ausschreibungen/services.py
Normal file
49
vergabe_teilnahme/apps/ausschreibungen/services.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from datetime import date
|
||||
|
||||
|
||||
def entscheidungsregel_auswertung(ausschreibung):
|
||||
from vergabe_teilnahme.apps.bibliothek.models import Entscheidungsregel
|
||||
|
||||
regeln = Entscheidungsregel.objects.filter(aktiv=True).order_by('-gewichtung')
|
||||
return [
|
||||
{
|
||||
'regel': regel,
|
||||
**_wende_regel_an(regel, ausschreibung),
|
||||
}
|
||||
for regel in regeln
|
||||
]
|
||||
|
||||
|
||||
def _wende_regel_an(regel, ausschreibung):
|
||||
kat = regel.kategorie
|
||||
|
||||
if kat == 'ausschlusskriterium' and hasattr(ausschreibung, 'anforderungen'):
|
||||
hat_ausschluss = ausschreibung.anforderungen.filter(
|
||||
ausschlusskriterium=True, erfuellungsstatus='nicht_erfuellbar'
|
||||
).exists()
|
||||
if hat_ausschluss:
|
||||
return {
|
||||
'empfehlung': 'nicht_teilnehmen',
|
||||
'begruendung': 'Nicht erfüllbares Ausschlusskriterium vorhanden.',
|
||||
'warnung': True,
|
||||
}
|
||||
|
||||
if kat == 'frist' and ausschreibung.abgabe_bis:
|
||||
abgabe_date = (
|
||||
ausschreibung.abgabe_bis.date()
|
||||
if hasattr(ausschreibung.abgabe_bis, 'date')
|
||||
else ausschreibung.abgabe_bis
|
||||
)
|
||||
delta = (abgabe_date - date.today()).days
|
||||
if regel.schwellenwert and delta < int(regel.schwellenwert):
|
||||
return {
|
||||
'empfehlung': 'nicht_teilnehmen',
|
||||
'begruendung': f'Restlaufzeit {delta} Tage liegt unter Schwellenwert {int(regel.schwellenwert)} Tage.',
|
||||
'warnung': True,
|
||||
}
|
||||
|
||||
return {
|
||||
'empfehlung': 'pruefen',
|
||||
'begruendung': regel.begruendung or '—',
|
||||
'warnung': False,
|
||||
}
|
||||
@@ -1,3 +1,100 @@
|
||||
from django.test import TestCase
|
||||
import factory
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
|
||||
# Create your tests here.
|
||||
from .models import Ausschreibung
|
||||
|
||||
|
||||
class AusschreibungFactory(factory.django.DjangoModelFactory):
|
||||
class Meta:
|
||||
model = Ausschreibung
|
||||
|
||||
titel = factory.Sequence(lambda n: f"Ausschreibung {n}")
|
||||
ausschreiber = "Testausschreiber GmbH"
|
||||
status = 1
|
||||
|
||||
|
||||
# --- Model tests ---
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_ausschreibung_str():
|
||||
a = AusschreibungFactory(titel="Test Ausschreibung")
|
||||
assert str(a) == "Test Ausschreibung"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize("status,expected", [
|
||||
(1, True), (5, True), (9, True),
|
||||
(10, False), (11, False), (13, False),
|
||||
])
|
||||
def test_ist_aktiv(status, expected):
|
||||
a = AusschreibungFactory(status=status)
|
||||
assert a.ist_aktiv == expected
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_naechste_frist_returns_earlier():
|
||||
from datetime import date, timedelta
|
||||
heute = date.today()
|
||||
a = AusschreibungFactory(
|
||||
bieterfragen_bis=heute + timedelta(days=5),
|
||||
abgabe_bis=heute + timedelta(days=10),
|
||||
)
|
||||
assert a.naechste_frist == heute + timedelta(days=5)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_naechste_frist_none_when_past():
|
||||
from datetime import date, timedelta
|
||||
gestern = date.today() - timedelta(days=1)
|
||||
a = AusschreibungFactory(bieterfragen_bis=gestern, abgabe_bis=None)
|
||||
assert a.naechste_frist is None
|
||||
|
||||
|
||||
# --- View tests ---
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_liste_get(client):
|
||||
response = client.get(reverse("ausschreibungen:liste"))
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_neu_get(client):
|
||||
response = client.get(reverse("ausschreibungen:neu"))
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_neu_post_valid(client):
|
||||
data = {"titel": "Neue Ausschreibung", "ausschreiber": "Stadt XY"}
|
||||
response = client.post(reverse("ausschreibungen:neu"), data)
|
||||
assert response.status_code == 302
|
||||
a = Ausschreibung.objects.get(titel="Neue Ausschreibung")
|
||||
assert response.url == reverse("ausschreibungen:detail", kwargs={"pk": a.pk})
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_detail_get(client):
|
||||
a = AusschreibungFactory()
|
||||
response = client.get(reverse("ausschreibungen:detail", kwargs={"pk": a.pk}))
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_status_post(client):
|
||||
a = AusschreibungFactory(status=1)
|
||||
url = reverse("ausschreibungen:status", kwargs={"pk": a.pk})
|
||||
response = client.post(url, {"status": "4"})
|
||||
assert response.status_code == 200
|
||||
a.refresh_from_db()
|
||||
assert a.status == 4
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_status_htmx_returns_partial(client):
|
||||
a = AusschreibungFactory(status=1)
|
||||
url = reverse("ausschreibungen:status", kwargs={"pk": a.pk})
|
||||
response = client.post(url, {"status": "3"}, HTTP_HX_REQUEST="true")
|
||||
assert response.status_code == 200
|
||||
assert b"status-widget" in response.content
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
from django.urls import path
|
||||
from django.urls import include, path
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = 'ausschreibungen'
|
||||
|
||||
urlpatterns = [
|
||||
path('', views.dashboard, name='dashboard'),
|
||||
path('', views.ausschreibung_liste, name='liste'),
|
||||
path('dashboard/', views.dashboard, name='dashboard'),
|
||||
path('neu/', views.ausschreibung_neu, name='neu'),
|
||||
path('<int:pk>/', views.ausschreibung_detail, name='detail'),
|
||||
path('<int:pk>/bearbeiten/', views.ausschreibung_bearbeiten, name='bearbeiten'),
|
||||
path('<int:pk>/status/', views.ausschreibung_status, name='status'),
|
||||
path('<int:pk>/entscheidung/', views.ausschreibung_entscheidung, name='entscheidung'),
|
||||
path('<int:pk>/archivieren/', views.ausschreibung_archivieren, name='archivieren'),
|
||||
path('<int:ausschreibung_id>/lose/', include('vergabe_teilnahme.apps.lose.urls')),
|
||||
path('<int:ausschreibung_id>/aufgaben/', include('vergabe_teilnahme.apps.aufgaben.urls')),
|
||||
path('<int:ausschreibung_id>/bieterfragen/', include('vergabe_teilnahme.apps.aufgaben.bieterfragen_urls')),
|
||||
path('<int:ausschreibung_id>/dokumente/', include('vergabe_teilnahme.apps.dokumente.urls')),
|
||||
path('<int:ausschreibung_id>/preise/', include('vergabe_teilnahme.apps.preise.urls')),
|
||||
path('<int:ausschreibung_id>/abgabe/', include('vergabe_teilnahme.apps.nachbetrachtung.abgabe_urls')),
|
||||
path('<int:ausschreibung_id>/nachbetrachtung/', include('vergabe_teilnahme.apps.nachbetrachtung.urls')),
|
||||
path('<int:ausschreibung_id>/marktbegleiter/', include('vergabe_teilnahme.apps.marktbegleiter.passagen_urls')),
|
||||
]
|
||||
|
||||
@@ -1,7 +1,195 @@
|
||||
from django.shortcuts import render
|
||||
from datetime import date, timedelta
|
||||
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils import timezone
|
||||
|
||||
from vergabe_teilnahme.apps.accounts.models import Mitarbeiter
|
||||
|
||||
from .models import Ausschreibung
|
||||
|
||||
|
||||
def _is_htmx(request):
|
||||
return request.headers.get('HX-Request') == 'true'
|
||||
|
||||
|
||||
def dashboard(request):
|
||||
return render(request, 'ausschreibungen/dashboard.html', {
|
||||
from vergabe_teilnahme.apps.aufgaben.models import Aufgabe
|
||||
|
||||
heute = date.today()
|
||||
in_14_tagen = heute + timedelta(days=14)
|
||||
|
||||
ctx = {
|
||||
'kritische_fristen': Ausschreibung.objects.filter(
|
||||
abgabe_bis__date__lte=in_14_tagen,
|
||||
abgabe_bis__date__gte=heute,
|
||||
status__lt=10,
|
||||
).order_by('abgabe_bis')[:10],
|
||||
|
||||
'ohne_entscheidung': Ausschreibung.objects.filter(
|
||||
teilnahmeentscheidung='offen',
|
||||
erstellt_am__lte=timezone.now() - timedelta(days=3),
|
||||
status__lt=10,
|
||||
).order_by('erstellt_am')[:10],
|
||||
|
||||
'ueberfaellige_aufgaben': Aufgabe.objects.filter(
|
||||
frist__lt=heute,
|
||||
status__in=['offen', 'in_bearbeitung', 'wartend_intern', 'wartend_sub', 'wartend_ausschreiber'],
|
||||
).select_related('ausschreibung').order_by('frist')[:15],
|
||||
|
||||
'laufende_ausschreibungen': Ausschreibung.objects.filter(
|
||||
status__range=(1, 9),
|
||||
).order_by('-geaendert_am')[:10],
|
||||
|
||||
'breadcrumbs': [{'label': 'Übersicht', 'url': None}],
|
||||
}
|
||||
return render(request, 'ausschreibungen/dashboard.html', ctx)
|
||||
|
||||
|
||||
def ausschreibung_liste(request):
|
||||
qs = Ausschreibung.objects.all()
|
||||
|
||||
status_filter = request.GET.get('status')
|
||||
if status_filter:
|
||||
qs = qs.filter(status=status_filter)
|
||||
|
||||
archiviert = request.GET.get('archiviert', '0') == '1'
|
||||
qs = qs.filter(archiviert=archiviert)
|
||||
|
||||
bid_manager_filter = request.GET.get('bid_manager')
|
||||
if bid_manager_filter:
|
||||
qs = qs.filter(bid_manager=bid_manager_filter)
|
||||
|
||||
qs = qs.select_related('bid_manager').order_by('-geaendert_am')
|
||||
|
||||
ctx = {
|
||||
'ausschreibungen': qs,
|
||||
'status_choices': Ausschreibung.STATUS_CHOICES,
|
||||
'mitarbeiter': Mitarbeiter.objects.all(),
|
||||
'archiviert': archiviert,
|
||||
'current_status': status_filter or '',
|
||||
'current_bid_manager': bid_manager_filter or '',
|
||||
'breadcrumbs': [{'label': 'Ausschreibungen', 'url': None}],
|
||||
}
|
||||
|
||||
if _is_htmx(request):
|
||||
return render(request, 'ausschreibungen/liste_partial.html', ctx)
|
||||
return render(request, 'ausschreibungen/liste.html', ctx)
|
||||
|
||||
|
||||
def ausschreibung_neu(request):
|
||||
from .forms import AusschreibungForm
|
||||
|
||||
historisch = request.GET.get('historisch') == '1'
|
||||
if request.method == 'POST':
|
||||
form = AusschreibungForm(request.POST)
|
||||
if form.is_valid():
|
||||
a = form.save()
|
||||
return redirect('ausschreibungen:detail', pk=a.pk)
|
||||
else:
|
||||
form = AusschreibungForm()
|
||||
|
||||
return render(request, 'ausschreibungen/form.html', {
|
||||
'form': form,
|
||||
'historisch': historisch,
|
||||
'titel': 'Neue Ausschreibung',
|
||||
'breadcrumbs': [
|
||||
{'label': 'Ausschreibungen', 'url': '/ausschreibungen/'},
|
||||
{'label': 'Neu', 'url': None},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
def ausschreibung_detail(request, pk):
|
||||
from vergabe_teilnahme.apps.core.services import build_phase_nav, get_deadline_warnings
|
||||
|
||||
a = get_object_or_404(Ausschreibung, pk=pk)
|
||||
ctx = {
|
||||
'ausschreibung': a,
|
||||
'ausschreibung_id': pk,
|
||||
'phases': build_phase_nav(a),
|
||||
'warnungen': get_deadline_warnings(a),
|
||||
'breadcrumbs': [
|
||||
{'label': 'Ausschreibungen', 'url': '/ausschreibungen/'},
|
||||
{'label': a.titel, 'url': None},
|
||||
],
|
||||
}
|
||||
return render(request, 'ausschreibungen/detail.html', ctx)
|
||||
|
||||
|
||||
def ausschreibung_bearbeiten(request, pk):
|
||||
from .forms import AusschreibungForm
|
||||
|
||||
a = get_object_or_404(Ausschreibung, pk=pk)
|
||||
form = AusschreibungForm(request.POST or None, instance=a)
|
||||
if request.method == 'POST' and form.is_valid():
|
||||
form.save()
|
||||
return redirect('ausschreibungen:detail', pk=pk)
|
||||
return render(request, 'ausschreibungen/form.html', {
|
||||
'form': form,
|
||||
'titel': 'Ausschreibung bearbeiten',
|
||||
'ausschreibung': a,
|
||||
'breadcrumbs': [
|
||||
{'label': 'Ausschreibungen', 'url': '/ausschreibungen/'},
|
||||
{'label': a.titel, 'url': f'/ausschreibungen/{pk}/'},
|
||||
{'label': 'Bearbeiten', 'url': None},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
def ausschreibung_status(request, pk):
|
||||
a = get_object_or_404(Ausschreibung, pk=pk)
|
||||
if request.method == 'POST':
|
||||
neuer_status = request.POST.get('status')
|
||||
if neuer_status and neuer_status.isdigit():
|
||||
a.status = int(neuer_status)
|
||||
a.save(update_fields=['status', 'geaendert_am'])
|
||||
return render(request, 'ausschreibungen/partials/status_widget.html', {'ausschreibung': a})
|
||||
|
||||
|
||||
def ausschreibung_entscheidung(request, pk):
|
||||
from .services import entscheidungsregel_auswertung
|
||||
|
||||
a = get_object_or_404(Ausschreibung, pk=pk)
|
||||
if request.method == 'POST':
|
||||
a.teilnahmeentscheidung = request.POST.get('teilnahmeentscheidung', 'offen')
|
||||
a.entscheidungsbegruendung = request.POST.get('begruendung', a.entscheidungsbegruendung)
|
||||
if a.teilnahmeentscheidung in ['teilnahme', 'ablehnung']:
|
||||
a.status = max(a.status, 3)
|
||||
a.save()
|
||||
return redirect('ausschreibungen:detail', pk=pk)
|
||||
|
||||
from vergabe_teilnahme.apps.lose.models import Anforderung
|
||||
ausschlusskriterien = Anforderung.objects.filter(
|
||||
ausschreibung=a,
|
||||
ausschlusskriterium=True,
|
||||
erfuellungsstatus='nicht_erfuellbar',
|
||||
).select_related('los')
|
||||
|
||||
ctx = {
|
||||
'ausschreibung': a,
|
||||
'regelergebnis': entscheidungsregel_auswertung(a),
|
||||
'ausschlusskriterien_nicht_erfuellbar': ausschlusskriterien,
|
||||
'breadcrumbs': [
|
||||
{'label': 'Ausschreibungen', 'url': '/ausschreibungen/'},
|
||||
{'label': a.titel, 'url': f'/ausschreibungen/{pk}/'},
|
||||
{'label': 'Teilnahmeentscheidung', 'url': None},
|
||||
],
|
||||
}
|
||||
return render(request, 'ausschreibungen/entscheidung.html', ctx)
|
||||
|
||||
|
||||
def ausschreibung_archivieren(request, pk):
|
||||
a = get_object_or_404(Ausschreibung, pk=pk)
|
||||
if request.method == 'POST':
|
||||
a.archiviert = True
|
||||
a.status = 13
|
||||
a.save(update_fields=['archiviert', 'status', 'geaendert_am'])
|
||||
return redirect('ausschreibungen:liste')
|
||||
return render(request, 'ausschreibungen/archivieren_confirm.html', {
|
||||
'ausschreibung': a,
|
||||
'breadcrumbs': [
|
||||
{'label': 'Ausschreibungen', 'url': '/ausschreibungen/'},
|
||||
{'label': a.titel, 'url': f'/ausschreibungen/{pk}/'},
|
||||
{'label': 'Archivieren', 'url': None},
|
||||
],
|
||||
})
|
||||
|
||||
52
vergabe_teilnahme/apps/lose/forms.py
Normal file
52
vergabe_teilnahme/apps/lose/forms.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from django import forms
|
||||
|
||||
from vergabe_teilnahme.apps.accounts.models import Mitarbeiter
|
||||
|
||||
from .models import Anforderung, Los
|
||||
|
||||
|
||||
class LosForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Los
|
||||
fields = ['losnummer', 'lostitel', 'beschreibung', 'abgrenzung', 'zustaendiger', 'teilnahme']
|
||||
widgets = {
|
||||
'losnummer': forms.TextInput(attrs={'class': 'form-input', 'autofocus': True}),
|
||||
'lostitel': forms.TextInput(attrs={'class': 'form-input'}),
|
||||
'beschreibung': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}),
|
||||
'abgrenzung': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}),
|
||||
'zustaendiger': forms.Select(attrs={'class': 'form-input'}),
|
||||
'teilnahme': forms.NullBooleanSelect(attrs={'class': 'form-input'}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
ausschreibung = kwargs.pop('ausschreibung', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['teilnahme'].required = False
|
||||
|
||||
|
||||
class AnforderungForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Anforderung
|
||||
fields = [
|
||||
'titel', 'beschreibung', 'quelle_im_dokument', 'kategorie', 'verbindlichkeit',
|
||||
'ausschlusskriterium', 'bewertungskriterium', 'zustaendiger',
|
||||
'erfuellungsstatus', 'nachweis_erforderlich', 'los',
|
||||
]
|
||||
widgets = {
|
||||
'titel': forms.TextInput(attrs={'class': 'form-input', 'autofocus': True}),
|
||||
'beschreibung': forms.Textarea(attrs={'class': 'form-input', 'rows': 3}),
|
||||
'quelle_im_dokument': forms.TextInput(attrs={'class': 'form-input'}),
|
||||
'kategorie': forms.Select(attrs={'class': 'form-input'}),
|
||||
'verbindlichkeit': forms.RadioSelect(),
|
||||
'zustaendiger': forms.Select(attrs={'class': 'form-input'}),
|
||||
'erfuellungsstatus': forms.Select(attrs={'class': 'form-input'}),
|
||||
'los': forms.Select(attrs={'class': 'form-input'}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, ausschreibung=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if ausschreibung is not None:
|
||||
self.fields['los'].queryset = Los.objects.filter(ausschreibung=ausschreibung)
|
||||
self.fields['los'].required = False
|
||||
self.fields['zustaendiger'].required = False
|
||||
self.fields['kategorie'].required = False
|
||||
@@ -1,3 +1,114 @@
|
||||
from django.test import TestCase
|
||||
import factory
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
|
||||
# Create your tests here.
|
||||
from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung
|
||||
from vergabe_teilnahme.apps.ausschreibungen.tests import AusschreibungFactory
|
||||
from vergabe_teilnahme.apps.bibliothek.models import Nachweis
|
||||
|
||||
from .models import Anforderung, Los
|
||||
|
||||
|
||||
class LosFactory(factory.django.DjangoModelFactory):
|
||||
class Meta:
|
||||
model = Los
|
||||
|
||||
ausschreibung = factory.SubFactory(AusschreibungFactory)
|
||||
losnummer = factory.Sequence(lambda n: f"L{n:02d}")
|
||||
lostitel = factory.Sequence(lambda n: f"Los {n}")
|
||||
|
||||
|
||||
class AnforderungFactory(factory.django.DjangoModelFactory):
|
||||
class Meta:
|
||||
model = Anforderung
|
||||
|
||||
ausschreibung = factory.SubFactory(AusschreibungFactory)
|
||||
titel = factory.Sequence(lambda n: f"Anforderung {n}")
|
||||
verbindlichkeit = 'muss'
|
||||
erfuellungsstatus = 'offen'
|
||||
|
||||
|
||||
class NachweisFactory(factory.django.DjangoModelFactory):
|
||||
class Meta:
|
||||
model = Nachweis
|
||||
|
||||
titel = factory.Sequence(lambda n: f"Nachweis {n}")
|
||||
|
||||
|
||||
# ─── Lose ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_lose_liste_get(client):
|
||||
a = AusschreibungFactory()
|
||||
url = reverse('ausschreibungen:lose:liste', kwargs={'ausschreibung_id': a.pk})
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_los_neu_post(client):
|
||||
a = AusschreibungFactory()
|
||||
url = reverse('ausschreibungen:lose:neu', kwargs={'ausschreibung_id': a.pk})
|
||||
response = client.post(url, {'losnummer': 'L01', 'lostitel': 'Testlos'})
|
||||
assert response.status_code == 302
|
||||
assert Los.objects.filter(ausschreibung=a, losnummer='L01').exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_los_detail_get(client):
|
||||
los = LosFactory()
|
||||
url = reverse('ausschreibungen:lose:detail',
|
||||
kwargs={'ausschreibung_id': los.ausschreibung_id, 'los_pk': los.pk})
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
# ─── Anforderungen ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_anforderung_neu_post_muss(client):
|
||||
a = AusschreibungFactory()
|
||||
url = reverse('ausschreibungen:lose:anforderung_neu', kwargs={'ausschreibung_id': a.pk})
|
||||
response = client.post(url, {'titel': 'Neue Anforderung', 'verbindlichkeit': 'muss',
|
||||
'erfuellungsstatus': 'offen'})
|
||||
assert response.status_code == 302
|
||||
assert Anforderung.objects.filter(ausschreibung=a, titel='Neue Anforderung').exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_anforderung_status_htmx(client):
|
||||
anf = AnforderungFactory()
|
||||
url = reverse('ausschreibungen:lose:anforderung_status',
|
||||
kwargs={'ausschreibung_id': anf.ausschreibung_id, 'pk': anf.pk})
|
||||
response = client.post(url, {'erfuellungsstatus': 'nicht_erfuellbar'},
|
||||
HTTP_HX_REQUEST='true')
|
||||
assert response.status_code == 200
|
||||
anf.refresh_from_db()
|
||||
assert anf.erfuellungsstatus == 'nicht_erfuellbar'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_ausschlusskriterium_banner_shown(client):
|
||||
a = AusschreibungFactory()
|
||||
AnforderungFactory(
|
||||
ausschreibung=a,
|
||||
ausschlusskriterium=True,
|
||||
erfuellungsstatus='nicht_erfuellbar',
|
||||
)
|
||||
url = reverse('ausschreibungen:entscheidung', kwargs={'pk': a.pk})
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
assert b'Nicht erf\xc3\xbcllbare Ausschlusskriterien' in response.content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_nachweis_zuordnen(client):
|
||||
anf = AnforderungFactory()
|
||||
n = NachweisFactory()
|
||||
url = reverse('ausschreibungen:lose:nachweis_zuordnen',
|
||||
kwargs={'ausschreibung_id': anf.ausschreibung_id, 'pk': anf.pk})
|
||||
response = client.post(url, {'nachweis_pk': n.pk})
|
||||
assert response.status_code == 200
|
||||
assert anf.nachweise.filter(pk=n.pk).exists()
|
||||
|
||||
@@ -1,2 +1,22 @@
|
||||
from django.urls import path
|
||||
urlpatterns = []
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = 'lose'
|
||||
|
||||
urlpatterns = [
|
||||
path('', views.lose_liste, name='liste'),
|
||||
path('neu/', views.los_neu, name='neu'),
|
||||
path('<int:los_pk>/', views.los_detail, name='detail'),
|
||||
path('<int:los_pk>/bearbeiten/', views.los_bearbeiten, name='bearbeiten'),
|
||||
path('<int:los_pk>/loeschen/', views.los_loeschen, name='loeschen'),
|
||||
path('anforderungen/', views.anforderungen_liste, name='anforderungen_liste'),
|
||||
path('anforderungen/neu/', views.anforderung_neu, name='anforderung_neu'),
|
||||
path('anforderungen/<int:pk>/', views.anforderung_detail, name='anforderung_detail'),
|
||||
path('anforderungen/<int:pk>/bearbeiten/', views.anforderung_bearbeiten, name='anforderung_bearbeiten'),
|
||||
path('anforderungen/<int:pk>/status/', views.anforderung_status, name='anforderung_status'),
|
||||
path('anforderungen/<int:pk>/nachweis/', views.nachweis_suche_modal, name='nachweis_suche'),
|
||||
path('anforderungen/<int:pk>/nachweis/zuordnen/', views.nachweis_zuordnen, name='nachweis_zuordnen'),
|
||||
path('anforderungen/<int:pk>/nachweis/<int:nachweis_pk>/entfernen/', views.nachweis_entfernen, name='nachweis_entfernen'),
|
||||
path('anforderungen/<int:pk>/aufgabe/', views.anforderung_aufgabe_erstellen, name='anforderung_aufgabe'),
|
||||
]
|
||||
|
||||
@@ -1,3 +1,287 @@
|
||||
from django.shortcuts import render
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
|
||||
# Create your views here.
|
||||
from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung
|
||||
from vergabe_teilnahme.apps.bibliothek.models import Nachweis
|
||||
|
||||
from .forms import AnforderungForm, LosForm
|
||||
from .models import Anforderung, Los
|
||||
|
||||
|
||||
def _is_htmx(request):
|
||||
return request.headers.get('HX-Request') == 'true'
|
||||
|
||||
|
||||
def _ausschreibung_breadcrumbs(ausschreibung, *extra):
|
||||
crumbs = [
|
||||
{'label': 'Ausschreibungen', 'url': '/ausschreibungen/'},
|
||||
{'label': ausschreibung.titel, 'url': f'/ausschreibungen/{ausschreibung.pk}/'},
|
||||
]
|
||||
for label, url in extra:
|
||||
crumbs.append({'label': label, 'url': url})
|
||||
return crumbs
|
||||
|
||||
|
||||
# ─── Lose ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def lose_liste(request, ausschreibung_id):
|
||||
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
|
||||
lose = Los.objects.filter(ausschreibung=ausschreibung).order_by('losnummer')
|
||||
return render(request, 'lose/liste.html', {
|
||||
'ausschreibung': ausschreibung,
|
||||
'lose': lose,
|
||||
'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, ('Lose', None)),
|
||||
})
|
||||
|
||||
|
||||
def los_neu(request, ausschreibung_id):
|
||||
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
|
||||
if request.method == 'POST':
|
||||
form = LosForm(request.POST, ausschreibung=ausschreibung)
|
||||
if form.is_valid():
|
||||
los = form.save(commit=False)
|
||||
los.ausschreibung = ausschreibung
|
||||
los.save()
|
||||
if _is_htmx(request):
|
||||
return render(request, 'lose/partials/los_row.html', {'los': los, 'ausschreibung': ausschreibung})
|
||||
return redirect('ausschreibungen:lose:liste', ausschreibung_id=ausschreibung_id)
|
||||
else:
|
||||
form = LosForm(ausschreibung=ausschreibung)
|
||||
return render(request, 'lose/form.html', {
|
||||
'form': form,
|
||||
'ausschreibung': ausschreibung,
|
||||
'titel': 'Los hinzufügen',
|
||||
'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung,
|
||||
('Lose', f'/ausschreibungen/{ausschreibung_id}/lose/'),
|
||||
('Neu', None)),
|
||||
})
|
||||
|
||||
|
||||
def los_detail(request, ausschreibung_id, los_pk):
|
||||
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
|
||||
los = get_object_or_404(Los, pk=los_pk, ausschreibung=ausschreibung)
|
||||
anforderungen = los.anforderungen.all().order_by('verbindlichkeit', 'titel')
|
||||
return render(request, 'lose/detail.html', {
|
||||
'ausschreibung': ausschreibung,
|
||||
'los': los,
|
||||
'anforderungen': anforderungen,
|
||||
'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung,
|
||||
('Lose', f'/ausschreibungen/{ausschreibung_id}/lose/'),
|
||||
(str(los), None)),
|
||||
})
|
||||
|
||||
|
||||
def los_bearbeiten(request, ausschreibung_id, los_pk):
|
||||
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
|
||||
los = get_object_or_404(Los, pk=los_pk, ausschreibung=ausschreibung)
|
||||
form = LosForm(request.POST or None, instance=los, ausschreibung=ausschreibung)
|
||||
if request.method == 'POST' and form.is_valid():
|
||||
form.save()
|
||||
return redirect('ausschreibungen:lose:detail', ausschreibung_id=ausschreibung_id, los_pk=los_pk)
|
||||
return render(request, 'lose/form.html', {
|
||||
'form': form,
|
||||
'ausschreibung': ausschreibung,
|
||||
'los': los,
|
||||
'titel': 'Los bearbeiten',
|
||||
'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung,
|
||||
('Lose', f'/ausschreibungen/{ausschreibung_id}/lose/'),
|
||||
(str(los), f'/ausschreibungen/{ausschreibung_id}/lose/{los_pk}/'),
|
||||
('Bearbeiten', None)),
|
||||
})
|
||||
|
||||
|
||||
def los_loeschen(request, ausschreibung_id, los_pk):
|
||||
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
|
||||
los = get_object_or_404(Los, pk=los_pk, ausschreibung=ausschreibung)
|
||||
if request.method == 'POST':
|
||||
los.delete()
|
||||
return redirect('ausschreibungen:lose:liste', ausschreibung_id=ausschreibung_id)
|
||||
return render(request, 'lose/loeschen_confirm.html', {
|
||||
'ausschreibung': ausschreibung,
|
||||
'los': los,
|
||||
'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung,
|
||||
('Lose', f'/ausschreibungen/{ausschreibung_id}/lose/'),
|
||||
(str(los), f'/ausschreibungen/{ausschreibung_id}/lose/{los_pk}/'),
|
||||
('Löschen', None)),
|
||||
})
|
||||
|
||||
|
||||
# ─── Anforderungen ───────────────────────────────────────────────────────────
|
||||
|
||||
def anforderungen_liste(request, ausschreibung_id):
|
||||
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
|
||||
qs = Anforderung.objects.filter(ausschreibung=ausschreibung).select_related('los', 'zustaendiger')
|
||||
|
||||
verbindlichkeit = request.GET.get('verbindlichkeit', '')
|
||||
if verbindlichkeit:
|
||||
qs = qs.filter(verbindlichkeit=verbindlichkeit)
|
||||
|
||||
status_filter = request.GET.get('erfuellungsstatus', '')
|
||||
if status_filter:
|
||||
qs = qs.filter(erfuellungsstatus=status_filter)
|
||||
|
||||
los_filter = request.GET.get('los', '')
|
||||
if los_filter == 'allgemein':
|
||||
qs = qs.filter(los__isnull=True)
|
||||
elif los_filter:
|
||||
qs = qs.filter(los_id=los_filter)
|
||||
|
||||
lose = Los.objects.filter(ausschreibung=ausschreibung).order_by('losnummer')
|
||||
|
||||
grouped = {}
|
||||
for los in lose:
|
||||
grouped[los] = []
|
||||
grouped[None] = []
|
||||
|
||||
for a in qs.order_by('verbindlichkeit', 'titel'):
|
||||
grouped[a.los].append(a)
|
||||
|
||||
grouped = {k: v for k, v in grouped.items() if v}
|
||||
|
||||
ctx = {
|
||||
'ausschreibung': ausschreibung,
|
||||
'grouped': grouped,
|
||||
'lose': lose,
|
||||
'verbindlichkeit_choices': Anforderung.VERBINDLICHKEIT_CHOICES,
|
||||
'erfuellung_choices': Anforderung.ERFUELLUNG_CHOICES,
|
||||
'current_verbindlichkeit': verbindlichkeit,
|
||||
'current_status': status_filter,
|
||||
'current_los': los_filter,
|
||||
'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung, ('Anforderungen', None)),
|
||||
}
|
||||
|
||||
if _is_htmx(request):
|
||||
return render(request, 'lose/anforderungen_liste_partial.html', ctx)
|
||||
return render(request, 'lose/anforderungen_liste.html', ctx)
|
||||
|
||||
|
||||
def anforderung_neu(request, ausschreibung_id):
|
||||
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
|
||||
if request.method == 'POST':
|
||||
form = AnforderungForm(request.POST, ausschreibung=ausschreibung)
|
||||
if form.is_valid():
|
||||
a = form.save(commit=False)
|
||||
a.ausschreibung = ausschreibung
|
||||
a.save()
|
||||
return redirect('ausschreibungen:lose:anforderung_detail',
|
||||
ausschreibung_id=ausschreibung_id, pk=a.pk)
|
||||
else:
|
||||
los_pk = request.GET.get('los')
|
||||
initial = {'los': los_pk} if los_pk else {}
|
||||
form = AnforderungForm(ausschreibung=ausschreibung, initial=initial)
|
||||
return render(request, 'lose/anforderung_form.html', {
|
||||
'form': form,
|
||||
'ausschreibung': ausschreibung,
|
||||
'titel': 'Anforderung anlegen',
|
||||
'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung,
|
||||
('Anforderungen', f'/ausschreibungen/{ausschreibung_id}/lose/anforderungen/'),
|
||||
('Neu', None)),
|
||||
})
|
||||
|
||||
|
||||
def anforderung_detail(request, ausschreibung_id, pk):
|
||||
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
|
||||
anforderung = get_object_or_404(Anforderung, pk=pk, ausschreibung=ausschreibung)
|
||||
aufgaben = anforderung.aufgaben.all().order_by('prioritaet', 'frist')
|
||||
return render(request, 'lose/anforderung_detail.html', {
|
||||
'ausschreibung': ausschreibung,
|
||||
'anforderung': anforderung,
|
||||
'aufgaben': aufgaben,
|
||||
'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung,
|
||||
('Anforderungen', f'/ausschreibungen/{ausschreibung_id}/lose/anforderungen/'),
|
||||
(anforderung.titel[:50], None)),
|
||||
})
|
||||
|
||||
|
||||
def anforderung_bearbeiten(request, ausschreibung_id, pk):
|
||||
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
|
||||
anforderung = get_object_or_404(Anforderung, pk=pk, ausschreibung=ausschreibung)
|
||||
form = AnforderungForm(request.POST or None, instance=anforderung, ausschreibung=ausschreibung)
|
||||
if request.method == 'POST' and form.is_valid():
|
||||
form.save()
|
||||
return redirect('ausschreibungen:lose:anforderung_detail',
|
||||
ausschreibung_id=ausschreibung_id, pk=pk)
|
||||
return render(request, 'lose/anforderung_form.html', {
|
||||
'form': form,
|
||||
'ausschreibung': ausschreibung,
|
||||
'anforderung': anforderung,
|
||||
'titel': 'Anforderung bearbeiten',
|
||||
'breadcrumbs': _ausschreibung_breadcrumbs(ausschreibung,
|
||||
('Anforderungen', f'/ausschreibungen/{ausschreibung_id}/lose/anforderungen/'),
|
||||
(anforderung.titel[:50], f'/ausschreibungen/{ausschreibung_id}/lose/anforderungen/{pk}/'),
|
||||
('Bearbeiten', None)),
|
||||
})
|
||||
|
||||
|
||||
def anforderung_status(request, ausschreibung_id, pk):
|
||||
anforderung = get_object_or_404(Anforderung, pk=pk, ausschreibung_id=ausschreibung_id)
|
||||
if request.method == 'POST':
|
||||
neuer_status = request.POST.get('erfuellungsstatus')
|
||||
if neuer_status:
|
||||
anforderung.erfuellungsstatus = neuer_status
|
||||
anforderung.save(update_fields=['erfuellungsstatus'])
|
||||
return render(request, 'lose/partials/erfuellungsstatus_widget.html', {'anforderung': anforderung})
|
||||
|
||||
|
||||
# ─── Nachweise ───────────────────────────────────────────────────────────────
|
||||
|
||||
def nachweis_suche_modal(request, ausschreibung_id, pk):
|
||||
anforderung = get_object_or_404(Anforderung, pk=pk, ausschreibung_id=ausschreibung_id)
|
||||
q = request.GET.get('q', '')
|
||||
nachweise = Nachweis.objects.all()
|
||||
if q:
|
||||
nachweise = nachweise.filter(titel__icontains=q)
|
||||
bereits_zugeordnet = anforderung.nachweise.values_list('pk', flat=True)
|
||||
return render(request, 'lose/partials/nachweis_modal.html', {
|
||||
'anforderung': anforderung,
|
||||
'nachweise': nachweise[:20],
|
||||
'bereits_zugeordnet': list(bereits_zugeordnet),
|
||||
'q': q,
|
||||
'ausschreibung_id': ausschreibung_id,
|
||||
})
|
||||
|
||||
|
||||
def nachweis_zuordnen(request, ausschreibung_id, pk):
|
||||
anforderung = get_object_or_404(Anforderung, pk=pk, ausschreibung_id=ausschreibung_id)
|
||||
if request.method == 'POST':
|
||||
nachweis_pk = request.POST.get('nachweis_pk')
|
||||
if nachweis_pk:
|
||||
nachweis = get_object_or_404(Nachweis, pk=nachweis_pk)
|
||||
anforderung.nachweise.add(nachweis)
|
||||
return render(request, 'lose/partials/nachweise_liste.html', {
|
||||
'anforderung': anforderung,
|
||||
'ausschreibung_id': ausschreibung_id,
|
||||
})
|
||||
|
||||
|
||||
def nachweis_entfernen(request, ausschreibung_id, pk, nachweis_pk):
|
||||
anforderung = get_object_or_404(Anforderung, pk=pk, ausschreibung_id=ausschreibung_id)
|
||||
if request.method == 'POST':
|
||||
nachweis = get_object_or_404(Nachweis, pk=nachweis_pk)
|
||||
anforderung.nachweise.remove(nachweis)
|
||||
return render(request, 'lose/partials/nachweise_liste.html', {
|
||||
'anforderung': anforderung,
|
||||
'ausschreibung_id': ausschreibung_id,
|
||||
})
|
||||
|
||||
|
||||
# ─── Aufgabe aus Anforderung ─────────────────────────────────────────────────
|
||||
|
||||
def anforderung_aufgabe_erstellen(request, ausschreibung_id, pk):
|
||||
ausschreibung = get_object_or_404(Ausschreibung, pk=ausschreibung_id)
|
||||
anforderung = get_object_or_404(Anforderung, pk=pk, ausschreibung=ausschreibung)
|
||||
if request.method == 'POST':
|
||||
from vergabe_teilnahme.apps.aufgaben.models import Aufgabe
|
||||
Aufgabe.objects.create(
|
||||
ausschreibung=ausschreibung,
|
||||
los=anforderung.los,
|
||||
anforderung=anforderung,
|
||||
titel=f'Klärung: {anforderung.titel[:200]}',
|
||||
typ='fachlich',
|
||||
verantwortlicher=anforderung.zustaendiger,
|
||||
)
|
||||
return redirect('ausschreibungen:lose:anforderung_detail',
|
||||
ausschreibung_id=ausschreibung_id, pk=pk)
|
||||
return render(request, 'lose/aufgabe_erstellen_confirm.html', {
|
||||
'ausschreibung': ausschreibung,
|
||||
'anforderung': anforderung,
|
||||
})
|
||||
|
||||
3
vergabe_teilnahme/apps/marktbegleiter/passagen_urls.py
Normal file
3
vergabe_teilnahme/apps/marktbegleiter/passagen_urls.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.urls import path
|
||||
|
||||
urlpatterns = []
|
||||
3
vergabe_teilnahme/apps/nachbetrachtung/abgabe_urls.py
Normal file
3
vergabe_teilnahme/apps/nachbetrachtung/abgabe_urls.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.urls import path
|
||||
|
||||
urlpatterns = []
|
||||
@@ -0,0 +1,18 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Ausschreibung archivieren{% endblock %}
|
||||
{% block content %}
|
||||
<div class="max-w-md mx-auto mt-16">
|
||||
<div class="card text-center space-y-4">
|
||||
<h1 class="text-lg font-semibold text-slate-800">Ausschreibung archivieren?</h1>
|
||||
<p class="text-sm text-slate-600">
|
||||
<strong>{{ ausschreibung.titel }}</strong> wird archiviert und aus der aktiven Liste entfernt.
|
||||
Die Daten bleiben erhalten.
|
||||
</p>
|
||||
<form method="post" class="flex justify-center gap-3">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn-primary bg-red-600 hover:bg-red-700">Archivieren</button>
|
||||
<a href="{% url 'ausschreibungen:detail' ausschreibung.pk %}" class="btn-ghost">Abbrechen</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,6 +1,102 @@
|
||||
{% extends "base.html" %}
|
||||
{% load vergabe_tags %}
|
||||
{% block title %}Übersicht{% endblock %}
|
||||
{% block content %}
|
||||
<h1 class="page-title mb-6">Übersicht</h1>
|
||||
<p class="text-slate-500">Dashboard wird in WP-0004 implementiert.</p>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
|
||||
<!-- Kritische Fristen -->
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-sm font-semibold text-slate-700">Kritische Fristen (14 Tage)</h2>
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-red-100 text-red-700 text-xs font-bold">
|
||||
{{ kritische_fristen|length }}
|
||||
</span>
|
||||
</div>
|
||||
{% if kritische_fristen %}
|
||||
<ul class="space-y-1">
|
||||
{% for a in kritische_fristen %}
|
||||
<li class="flex items-center justify-between py-1 text-sm">
|
||||
<a href="/ausschreibungen/{{ a.pk }}/" class="text-brand-700 hover:underline truncate max-w-xs">{{ a.titel }}</a>
|
||||
<span class="ml-2 shrink-0 {% if a.abgabe_bis.date <= today %}text-red-600 font-medium{% else %}text-amber-600{% endif %}">
|
||||
{{ a.abgabe_bis|date:"d.m.Y H:i" }}
|
||||
</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="text-slate-400 text-sm">Keine kritischen Fristen.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Ohne Teilnahmeentscheidung -->
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-sm font-semibold text-slate-700">Ohne Teilnahmeentscheidung</h2>
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-amber-100 text-amber-700 text-xs font-bold">
|
||||
{{ ohne_entscheidung|length }}
|
||||
</span>
|
||||
</div>
|
||||
{% if ohne_entscheidung %}
|
||||
<ul class="space-y-1">
|
||||
{% for a in ohne_entscheidung %}
|
||||
<li class="py-1 text-sm">
|
||||
<a href="/ausschreibungen/{{ a.pk }}/entscheidung/" class="text-brand-700 hover:underline">{{ a.titel }}</a>
|
||||
<span class="text-slate-400 text-xs ml-1">seit {{ a.erstellt_am|date:"d.m.Y" }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="text-slate-400 text-sm">Alle Ausschreibungen haben eine Entscheidung.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Überfällige Aufgaben -->
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-sm font-semibold text-slate-700">Überfällige Aufgaben</h2>
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-red-100 text-red-700 text-xs font-bold">
|
||||
{{ ueberfaellige_aufgaben|length }}
|
||||
</span>
|
||||
</div>
|
||||
{% if ueberfaellige_aufgaben %}
|
||||
<ul class="space-y-1">
|
||||
{% for aufgabe in ueberfaellige_aufgaben %}
|
||||
<li class="flex items-center justify-between py-1 text-sm">
|
||||
<a href="/ausschreibungen/{{ aufgabe.ausschreibung_id }}/aufgaben/" class="text-brand-700 hover:underline truncate max-w-xs">
|
||||
{{ aufgabe.titel }}
|
||||
</a>
|
||||
<span class="text-red-600 text-xs ml-2 shrink-0">{{ aufgabe.frist|date:"d.m.Y" }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="text-slate-400 text-sm">Keine überfälligen Aufgaben.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Laufende Ausschreibungen -->
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-sm font-semibold text-slate-700">Laufende Ausschreibungen</h2>
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-brand-100 text-brand-700 text-xs font-bold">
|
||||
{{ laufende_ausschreibungen|length }}
|
||||
</span>
|
||||
</div>
|
||||
{% if laufende_ausschreibungen %}
|
||||
<ul class="space-y-1">
|
||||
{% for a in laufende_ausschreibungen %}
|
||||
<li class="flex items-center justify-between py-1 text-sm">
|
||||
<a href="/ausschreibungen/{{ a.pk }}/" class="text-brand-700 hover:underline truncate max-w-xs">{{ a.titel }}</a>
|
||||
{% status_badge a.get_status_display a.status %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="text-slate-400 text-sm">Keine laufenden Ausschreibungen.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
94
vergabe_teilnahme/templates/ausschreibungen/detail.html
Normal file
94
vergabe_teilnahme/templates/ausschreibungen/detail.html
Normal file
@@ -0,0 +1,94 @@
|
||||
{% extends "base.html" %}
|
||||
{% load vergabe_tags %}
|
||||
{% block title %}{{ ausschreibung.titel }}{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<!-- Title row -->
|
||||
<div class="flex items-start justify-between mb-4 gap-4">
|
||||
<div>
|
||||
<h1 class="page-title">{{ ausschreibung.titel }}</h1>
|
||||
<p class="text-sm text-slate-500 mt-0.5">{{ ausschreibung.ausschreiber }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
{% include "ausschreibungen/partials/status_widget.html" %}
|
||||
<a href="{% url 'ausschreibungen:bearbeiten' ausschreibung.pk %}" class="btn-ghost text-xs">Bearbeiten</a>
|
||||
<a href="{% url 'ausschreibungen:archivieren' ausschreibung.pk %}" class="btn-ghost text-xs text-slate-400">Archivieren</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Deadline warnings -->
|
||||
{% if warnungen %}
|
||||
<div class="space-y-2 mb-5">
|
||||
{% for w in warnungen %}
|
||||
<div class="rounded px-4 py-2 text-sm flex items-center gap-2
|
||||
{% if w.farbe == 'red' %}bg-red-50 border border-red-200 text-red-700
|
||||
{% else %}bg-amber-50 border border-amber-200 text-amber-700{% endif %}">
|
||||
<span class="font-medium">
|
||||
{% if w.typ == 'bieterfragen' %}Bieterfragen-Frist{% else %}Abgabe-Frist{% endif %}:
|
||||
</span>
|
||||
{% if w.tage < 0 %}
|
||||
überfällig
|
||||
{% elif w.tage == 0 %}
|
||||
heute!
|
||||
{% else %}
|
||||
noch {{ w.tage }} Tag{% if w.tage != 1 %}e{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Phase navigation tabs -->
|
||||
<div class="flex gap-1 border-b border-slate-200 mb-6 overflow-x-auto">
|
||||
{% for phase in phases %}
|
||||
<a href="{{ phase.url }}"
|
||||
class="px-3 py-2 text-xs font-medium whitespace-nowrap border-b-2 -mb-px
|
||||
{% if phase.aktiv %}border-brand-600 text-brand-700
|
||||
{% elif phase.erledigt %}border-transparent text-slate-500 hover:text-slate-700
|
||||
{% else %}border-transparent text-slate-400 hover:text-slate-600{% endif %}">
|
||||
<span class="mr-1 {% if phase.erledigt %}phase-done{% elif phase.aktiv %}phase-active{% else %}phase-todo{% endif %}">{{ phase.nummer }}</span>
|
||||
{{ phase.name }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Stammdaten -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="card">
|
||||
<h2 class="text-sm font-semibold text-slate-700 mb-3">Stammdaten</h2>
|
||||
<dl class="space-y-1">
|
||||
{% render_field ausschreibung "ausschreiber" "Ausschreiber" %}
|
||||
{% render_field ausschreibung "vergabeart" "Vergabeart" %}
|
||||
{% render_field ausschreibung "vergabenummer" "Vergabenummer" %}
|
||||
{% render_field ausschreibung "vergabeplattform" "Plattform" %}
|
||||
{% render_field ausschreibung "branche" "Branche" %}
|
||||
{% render_field ausschreibung "schlagwoerter" "Schlagwörter" %}
|
||||
{% render_field ausschreibung "geschaetztes_volumen" "Geschätztes Volumen (€)" %}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="text-sm font-semibold text-slate-700 mb-3">Fristen</h2>
|
||||
<dl class="space-y-1">
|
||||
{% render_field ausschreibung "veroeffentlichungsdatum" "Veröffentlicht" %}
|
||||
{% render_field ausschreibung "bieterfragen_bis" "Bieterfragen bis" %}
|
||||
{% render_field ausschreibung "abgabe_bis" "Abgabe bis" %}
|
||||
{% render_field ausschreibung "bindefrist" "Bindefrist" %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if ausschreibung.leistungsbeschreibung %}
|
||||
<div class="card mt-6">
|
||||
<h2 class="text-sm font-semibold text-slate-700 mb-2">Leistungsbeschreibung</h2>
|
||||
<p class="text-sm text-slate-600 whitespace-pre-line">{{ ausschreibung.leistungsbeschreibung }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex gap-3 mt-6">
|
||||
<a href="{% url 'ausschreibungen:entscheidung' ausschreibung.pk %}" class="btn-ghost text-xs">
|
||||
Teilnahmeentscheidung
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,69 @@
|
||||
{% extends "base.html" %}
|
||||
{% load vergabe_tags %}
|
||||
{% block title %}Teilnahmeentscheidung — {{ ausschreibung.titel }}{% endblock %}
|
||||
{% block content %}
|
||||
<h1 class="page-title mb-1">Teilnahmeentscheidung</h1>
|
||||
<p class="text-sm text-slate-500 mb-6">{{ ausschreibung.titel }}</p>
|
||||
|
||||
{% if ausschlusskriterien_nicht_erfuellbar %}
|
||||
<div class="bg-red-50 border border-red-300 rounded-lg p-4 mb-6">
|
||||
<p class="font-semibold text-red-700">⚠ Nicht erfüllbare Ausschlusskriterien</p>
|
||||
<ul class="mt-2 text-sm text-red-600 list-disc ml-4">
|
||||
{% for a in ausschlusskriterien_nicht_erfuellbar %}
|
||||
<li>{{ a.titel }} (Los: {{ a.los|default:"Allgemein" }})</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<p class="text-sm text-red-500 mt-2">Empfehlung: Nichtteilnahme</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if regelergebnis %}
|
||||
<div class="card mb-5">
|
||||
<h2 class="text-sm font-semibold text-slate-700 mb-3">Regelauswertung</h2>
|
||||
<ul class="space-y-2">
|
||||
{% for item in regelergebnis %}
|
||||
<li class="flex items-start gap-3 text-sm p-2 rounded
|
||||
{% if item.warnung %}bg-red-50 border border-red-200{% else %}bg-slate-50{% endif %}">
|
||||
<span class="shrink-0 font-medium {% if item.warnung %}text-red-700{% else %}text-slate-600{% endif %}">
|
||||
{{ item.regel.bezeichnung }}
|
||||
</span>
|
||||
<span class="text-slate-500">—</span>
|
||||
<span class="{% if item.warnung %}text-red-700{% else %}text-slate-600{% endif %}">
|
||||
{{ item.begruendung }}
|
||||
</span>
|
||||
<span class="ml-auto shrink-0 text-xs font-semibold
|
||||
{% if item.empfehlung == 'nicht_teilnehmen' %}text-red-600
|
||||
{% else %}text-slate-500{% endif %}">
|
||||
{{ item.empfehlung|upper }}
|
||||
</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card max-w-lg">
|
||||
<h2 class="text-sm font-semibold text-slate-700 mb-4">Entscheidung treffen</h2>
|
||||
<form method="post" class="space-y-4">
|
||||
{% csrf_token %}
|
||||
<div class="space-y-2">
|
||||
{% for val, label in ausschreibung.TEILNAHME_CHOICES %}
|
||||
<label class="flex items-center gap-3 cursor-pointer p-2 rounded hover:bg-slate-50">
|
||||
<input type="radio" name="teilnahmeentscheidung" value="{{ val }}"
|
||||
{% if ausschreibung.teilnahmeentscheidung == val %}checked{% endif %}
|
||||
class="text-brand-600">
|
||||
<span class="text-sm font-medium text-slate-700">{{ label }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Begründung</label>
|
||||
<textarea name="begruendung" rows="3" class="form-input">{{ ausschreibung.entscheidungsbegruendung }}</textarea>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button type="submit" class="btn-primary">Speichern</button>
|
||||
<a href="{% url 'ausschreibungen:detail' ausschreibung.pk %}" class="btn-ghost">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
125
vergabe_teilnahme/templates/ausschreibungen/form.html
Normal file
125
vergabe_teilnahme/templates/ausschreibungen/form.html
Normal file
@@ -0,0 +1,125 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ titel }}{% endblock %}
|
||||
{% block content %}
|
||||
<h1 class="page-title mb-6">{{ titel }}</h1>
|
||||
|
||||
<form method="post" class="max-w-2xl space-y-6">
|
||||
{% csrf_token %}
|
||||
{% if historisch %}
|
||||
<input type="hidden" name="historisch_erfassen" value="1">
|
||||
{% endif %}
|
||||
|
||||
<div class="card space-y-4">
|
||||
<h2 class="text-sm font-semibold text-slate-700 uppercase tracking-wide">Stammdaten</h2>
|
||||
<div>
|
||||
<label class="form-label">Titel *</label>
|
||||
{{ form.titel }}
|
||||
{% if form.titel.errors %}<p class="text-red-600 text-xs mt-1">{{ form.titel.errors.0 }}</p>{% endif %}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="form-label">Ausschreiber *</label>
|
||||
{{ form.ausschreiber }}
|
||||
{% if form.ausschreiber.errors %}<p class="text-red-600 text-xs mt-1">{{ form.ausschreiber.errors.0 }}</p>{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Vergabeart</label>
|
||||
{{ form.vergabeart }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="form-label">Vergabeplattform</label>
|
||||
{{ form.vergabeplattform }}
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Vergabenummer</label>
|
||||
{{ form.vergabenummer }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Fundstelle (URL)</label>
|
||||
{{ form.fundstelle_url }}
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Bid Manager</label>
|
||||
{{ form.bid_manager }}
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Leistungsbeschreibung</label>
|
||||
{{ form.leistungsbeschreibung }}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="form-label">Branche</label>
|
||||
{{ form.branche }}
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Schlagwörter</label>
|
||||
{{ form.schlagwoerter }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Geschätztes Volumen (€)</label>
|
||||
{{ form.geschaetztes_volumen }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card space-y-4">
|
||||
<h2 class="text-sm font-semibold text-slate-700 uppercase tracking-wide">Fristen</h2>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="form-label">Veröffentlichungsdatum</label>
|
||||
{{ form.veroeffentlichungsdatum }}
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Bieterfragen bis</label>
|
||||
{{ form.bieterfragen_bis }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="form-label">Abgabe bis</label>
|
||||
{{ form.abgabe_bis }}
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Bindefrist</label>
|
||||
{{ form.bindefrist }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
{{ form.unterlagen_erhalten }}
|
||||
<label class="text-sm text-slate-600">Unterlagen erhalten</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Unterlagen erhalten am</label>
|
||||
{{ form.unterlagen_erhalten_am }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if historisch %}
|
||||
<div class="card space-y-4">
|
||||
<h2 class="text-sm font-semibold text-slate-700 uppercase tracking-wide">Historische Erfassung</h2>
|
||||
<div>
|
||||
<label class="form-label">Teilnahmeentscheidung</label>
|
||||
{{ form.teilnahmeentscheidung }}
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Begründung</label>
|
||||
{{ form.entscheidungsbegruendung }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button type="submit" class="btn-primary">Speichern</button>
|
||||
{% if ausschreibung %}
|
||||
<a href="{% url 'ausschreibungen:detail' ausschreibung.pk %}" class="btn-ghost">Abbrechen</a>
|
||||
{% else %}
|
||||
<a href="{% url 'ausschreibungen:liste' %}" class="btn-ghost">Abbrechen</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
47
vergabe_teilnahme/templates/ausschreibungen/liste.html
Normal file
47
vergabe_teilnahme/templates/ausschreibungen/liste.html
Normal file
@@ -0,0 +1,47 @@
|
||||
{% extends "base.html" %}
|
||||
{% load vergabe_tags %}
|
||||
{% block title %}Ausschreibungen{% endblock %}
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<h1 class="page-title">Ausschreibungen</h1>
|
||||
<a href="{% url 'ausschreibungen:neu' %}" class="btn-primary">+ Neue Ausschreibung</a>
|
||||
</div>
|
||||
|
||||
<!-- Filter bar -->
|
||||
<div class="card mb-4">
|
||||
<form id="filter-form"
|
||||
hx-get="{% url 'ausschreibungen:liste' %}"
|
||||
hx-target="#ausschreibungen-table"
|
||||
hx-push-url="true"
|
||||
hx-trigger="change from:select, change from:input[type=checkbox]"
|
||||
class="flex flex-wrap gap-3 items-end">
|
||||
<div>
|
||||
<label class="form-label">Status</label>
|
||||
<select name="status" class="form-input">
|
||||
<option value="">Alle Status</option>
|
||||
{% for val, label in status_choices %}
|
||||
<option value="{{ val }}" {% if current_status == val|stringformat:"s" %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Bid Manager</label>
|
||||
<select name="bid_manager" class="form-input">
|
||||
<option value="">Alle</option>
|
||||
{% for m in mitarbeiter %}
|
||||
<option value="{{ m.pk }}" {% if current_bid_manager == m.pk|stringformat:"s" %}selected{% endif %}>{{ m.get_full_name|default:m.username }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 pb-1">
|
||||
<input type="checkbox" id="archiviert" name="archiviert" value="1" class="h-4 w-4 rounded border-slate-300 text-brand-600"
|
||||
{% if archiviert %}checked{% endif %}>
|
||||
<label for="archiviert" class="text-sm text-slate-600">Archivierte anzeigen</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="ausschreibungen-table">
|
||||
{% include "ausschreibungen/liste_partial.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,42 @@
|
||||
{% load vergabe_tags %}
|
||||
{% if ausschreibungen %}
|
||||
<div class="card overflow-hidden p-0">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-50 border-b border-slate-200">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-2 font-medium text-slate-600">Titel</th>
|
||||
<th class="text-left px-4 py-2 font-medium text-slate-600">Ausschreiber</th>
|
||||
<th class="text-left px-4 py-2 font-medium text-slate-600">Status</th>
|
||||
<th class="text-left px-4 py-2 font-medium text-slate-600">Abgabe</th>
|
||||
<th class="text-left px-4 py-2 font-medium text-slate-600">Bid Manager</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for a in ausschreibungen %}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-2">
|
||||
<a href="{% url 'ausschreibungen:detail' a.pk %}" class="text-brand-700 hover:underline font-medium">
|
||||
{{ a.titel }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-2 text-slate-600">{{ a.ausschreiber }}</td>
|
||||
<td class="px-4 py-2">
|
||||
{% status_badge a.status a.get_status_display %}
|
||||
</td>
|
||||
<td class="px-4 py-2 {% if a.abgabe_bis and a.abgabe_bis.date <= today %}text-red-600 font-medium{% elif a.abgabe_bis %}text-amber-600{% else %}text-slate-400{% endif %}">
|
||||
{% if a.abgabe_bis %}{{ a.abgabe_bis|date:"d.m.Y H:i" }}{% else %}—{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-slate-600">
|
||||
{% if a.bid_manager %}{{ a.bid_manager.get_full_name|default:a.bid_manager.username }}{% else %}—{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card text-center text-slate-400 py-10">
|
||||
Keine Ausschreibungen gefunden.
|
||||
<a href="{% url 'ausschreibungen:neu' %}" class="ml-2 text-brand-600 hover:underline">Jetzt anlegen</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,15 @@
|
||||
{% load vergabe_tags %}
|
||||
<div id="status-widget-{{ ausschreibung.pk }}"
|
||||
class="flex items-center gap-2">
|
||||
{% status_badge ausschreibung.status ausschreibung.get_status_display %}
|
||||
<select name="status"
|
||||
hx-post="{% url 'ausschreibungen:status' ausschreibung.pk %}"
|
||||
hx-target="#status-widget-{{ ausschreibung.pk }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-trigger="change"
|
||||
class="form-input text-xs py-0.5 h-7 w-auto">
|
||||
{% for val, label in ausschreibung.STATUS_CHOICES %}
|
||||
<option value="{{ val }}" {% if val == ausschreibung.status %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
93
vergabe_teilnahme/templates/lose/anforderung_detail.html
Normal file
93
vergabe_teilnahme/templates/lose/anforderung_detail.html
Normal file
@@ -0,0 +1,93 @@
|
||||
{% extends "base.html" %}
|
||||
{% load vergabe_tags %}
|
||||
{% block title %}{{ anforderung.titel }}{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h1 class="page-title">{{ anforderung.titel }}</h1>
|
||||
<p class="text-sm text-slate-500 mt-0.5">
|
||||
{% if anforderung.los %}Los {{ anforderung.los.losnummer }}{% else %}Allgemein{% endif %}
|
||||
· {{ ausschreibung.titel }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2 shrink-0">
|
||||
{% include "lose/partials/erfuellungsstatus_widget.html" %}
|
||||
<a href="{% url 'ausschreibungen:lose:anforderung_bearbeiten' ausschreibung.pk anforderung.pk %}"
|
||||
class="btn-ghost text-xs">Bearbeiten</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if anforderung.ausschlusskriterium and anforderung.erfuellungsstatus == 'nicht_erfuellbar' %}
|
||||
<div class="bg-red-50 border border-red-300 rounded-lg px-4 py-3 mb-5">
|
||||
<p class="text-sm font-semibold text-red-700">⚠ Nicht erfüllbares Ausschlusskriterium</p>
|
||||
<p class="text-xs text-red-600 mt-1">Diese Anforderung gefährdet die Teilnahmemöglichkeit.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="card">
|
||||
<h2 class="text-sm font-semibold text-slate-700 mb-3">Details</h2>
|
||||
<dl class="space-y-1">
|
||||
{% render_field anforderung "verbindlichkeit" "Verbindlichkeit" %}
|
||||
{% render_field anforderung "kategorie" "Kategorie" %}
|
||||
{% render_field anforderung "quelle_im_dokument" "Quelle im Dokument" %}
|
||||
{% render_field anforderung "zustaendiger" "Zuständiger" %}
|
||||
</dl>
|
||||
<div class="mt-3 flex gap-4 text-xs text-slate-600">
|
||||
{% if anforderung.ausschlusskriterium %}
|
||||
<span class="text-red-600 font-medium">Ausschlusskriterium</span>
|
||||
{% endif %}
|
||||
{% if anforderung.bewertungskriterium %}
|
||||
<span class="text-amber-700 font-medium">Bewertungskriterium</span>
|
||||
{% endif %}
|
||||
{% if anforderung.nachweis_erforderlich %}
|
||||
<span class="text-blue-700 font-medium">Nachweis erforderlich</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nachweise -->
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-sm font-semibold text-slate-700">Nachweise</h2>
|
||||
<button hx-get="{% url 'ausschreibungen:lose:nachweis_suche' ausschreibung.pk anforderung.pk %}"
|
||||
hx-target="#nachweis-modal"
|
||||
class="btn-ghost text-xs">Nachweis zuordnen</button>
|
||||
</div>
|
||||
<div id="nachweise-liste">
|
||||
{% include "lose/partials/nachweise_liste.html" %}
|
||||
</div>
|
||||
<div id="nachweis-modal"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if anforderung.beschreibung %}
|
||||
<div class="card mt-6">
|
||||
<h2 class="text-sm font-semibold text-slate-700 mb-2">Beschreibung</h2>
|
||||
<p class="text-sm text-slate-600 whitespace-pre-line">{{ anforderung.beschreibung }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Verbundene Aufgaben -->
|
||||
<div class="card mt-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-sm font-semibold text-slate-700">Verbundene Aufgaben ({{ aufgaben.count }})</h2>
|
||||
<a href="{% url 'ausschreibungen:lose:anforderung_aufgabe' ausschreibung.pk anforderung.pk %}"
|
||||
class="btn-ghost text-xs">+ Aufgabe erstellen</a>
|
||||
</div>
|
||||
{% if aufgaben %}
|
||||
<ul class="divide-y divide-slate-100 text-sm">
|
||||
{% for a in aufgaben %}
|
||||
<li class="py-2 flex items-center justify-between">
|
||||
<span>{{ a.titel }}</span>
|
||||
{% status_badge a.status a.get_status_display %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="text-sm text-slate-500">Keine Aufgaben verknüpft.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
93
vergabe_teilnahme/templates/lose/anforderung_form.html
Normal file
93
vergabe_teilnahme/templates/lose/anforderung_form.html
Normal file
@@ -0,0 +1,93 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ titel }}{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<h1 class="page-title mb-6">{{ titel }}</h1>
|
||||
|
||||
<form method="post" class="max-w-2xl space-y-6">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="card space-y-4">
|
||||
<h2 class="text-sm font-semibold text-slate-700">Allgemein</h2>
|
||||
|
||||
<div>
|
||||
<label class="form-label">Titel <span class="text-red-500">*</span></label>
|
||||
{{ form.titel }}
|
||||
{% if form.titel.errors %}<p class="text-xs text-red-600 mt-1">{{ form.titel.errors.0 }}</p>{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label">Beschreibung</label>
|
||||
{{ form.beschreibung }}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="form-label">Kategorie</label>
|
||||
{{ form.kategorie }}
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Los</label>
|
||||
{{ form.los }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label">Quelle im Dokument</label>
|
||||
{{ form.quelle_im_dokument }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card space-y-4">
|
||||
<h2 class="text-sm font-semibold text-slate-700">Verbindlichkeit & Bewertung</h2>
|
||||
|
||||
<div>
|
||||
<label class="form-label">Verbindlichkeit <span class="text-red-500">*</span></label>
|
||||
<div class="flex gap-4 mt-1">
|
||||
{% for widget in form.verbindlichkeit %}
|
||||
<label class="flex items-center gap-1.5 text-sm cursor-pointer">
|
||||
{{ widget.tag }}
|
||||
<span>{{ widget.choice_label }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6">
|
||||
<label class="flex items-center gap-2 text-sm cursor-pointer">
|
||||
{{ form.ausschlusskriterium }}
|
||||
<span>Ausschlusskriterium</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm cursor-pointer">
|
||||
{{ form.bewertungskriterium }}
|
||||
<span>Bewertungskriterium</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm cursor-pointer">
|
||||
{{ form.nachweis_erforderlich }}
|
||||
<span>Nachweis erforderlich</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="form-label">Erfüllungsstatus</label>
|
||||
{{ form.erfuellungsstatus }}
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Zuständiger</label>
|
||||
{{ form.zustaendiger }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button type="submit" class="btn-primary">Speichern</button>
|
||||
{% if anforderung %}
|
||||
<a href="{% url 'ausschreibungen:lose:anforderung_detail' ausschreibung.pk anforderung.pk %}" class="btn-ghost">Abbrechen</a>
|
||||
{% else %}
|
||||
<a href="{% url 'ausschreibungen:lose:anforderungen_liste' ausschreibung.pk %}" class="btn-ghost">Abbrechen</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
51
vergabe_teilnahme/templates/lose/anforderungen_liste.html
Normal file
51
vergabe_teilnahme/templates/lose/anforderungen_liste.html
Normal file
@@ -0,0 +1,51 @@
|
||||
{% extends "base.html" %}
|
||||
{% load vergabe_tags %}
|
||||
{% block title %}Anforderungen — {{ ausschreibung.titel }}{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="page-title">Anforderungen</h1>
|
||||
<a href="{% url 'ausschreibungen:lose:anforderung_neu' ausschreibung.pk %}" class="btn-primary text-xs">+ Anforderung</a>
|
||||
</div>
|
||||
|
||||
<!-- Filter -->
|
||||
<form id="filter-form" method="get"
|
||||
hx-get="{% url 'ausschreibungen:lose:anforderungen_liste' ausschreibung.pk %}"
|
||||
hx-target="#anforderungen-content"
|
||||
hx-trigger="change"
|
||||
class="card mb-5 flex flex-wrap gap-3 items-end">
|
||||
<div>
|
||||
<label class="form-label">Verbindlichkeit</label>
|
||||
<select name="verbindlichkeit" class="form-input text-xs h-8 w-auto">
|
||||
<option value="">Alle</option>
|
||||
{% for val, label in verbindlichkeit_choices %}
|
||||
<option value="{{ val }}" {% if val == current_verbindlichkeit %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Erfüllungsstatus</label>
|
||||
<select name="erfuellungsstatus" class="form-input text-xs h-8 w-auto">
|
||||
<option value="">Alle</option>
|
||||
{% for val, label in erfuellung_choices %}
|
||||
<option value="{{ val }}" {% if val == current_status %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Los</label>
|
||||
<select name="los" class="form-input text-xs h-8 w-auto">
|
||||
<option value="">Alle</option>
|
||||
<option value="allgemein" {% if current_los == 'allgemein' %}selected{% endif %}>Allgemein</option>
|
||||
{% for los in lose %}
|
||||
<option value="{{ los.pk }}" {% if current_los == los.pk|stringformat:"s" %}selected{% endif %}>Los {{ los.losnummer }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="anforderungen-content">
|
||||
{% include "lose/anforderungen_liste_partial.html" %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,49 @@
|
||||
{% load vergabe_tags %}
|
||||
{% if grouped %}
|
||||
{% for los, anforderungen in grouped.items %}
|
||||
<div x-data="{ open: true }" class="card mb-4">
|
||||
<button @click="open = !open"
|
||||
class="w-full flex items-center justify-between text-left">
|
||||
<h3 class="text-sm font-semibold text-slate-700">
|
||||
{% if los %}Los {{ los.losnummer }}: {{ los.lostitel }}{% else %}Allgemein{% endif %}
|
||||
<span class="text-slate-400 font-normal ml-1">({{ anforderungen|length }})</span>
|
||||
</h3>
|
||||
<span x-text="open ? '▲' : '▼'" class="text-xs text-slate-400"></span>
|
||||
</button>
|
||||
|
||||
<div x-show="open" class="mt-3">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-200 text-left text-xs text-slate-500">
|
||||
<th class="pb-2 pr-4">Titel</th>
|
||||
<th class="pb-2 pr-4">Verbindlichkeit</th>
|
||||
<th class="pb-2 pr-4">Status</th>
|
||||
<th class="pb-2">Zuständig</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for a in anforderungen %}
|
||||
<tr class="hover:bg-slate-50
|
||||
{% if a.ausschlusskriterium and a.erfuellungsstatus == 'nicht_erfuellbar' %}bg-red-50 border-l-2 border-red-400{% endif %}">
|
||||
<td class="py-2 pr-4">
|
||||
<a href="{% url 'ausschreibungen:lose:anforderung_detail' ausschreibung.pk a.pk %}"
|
||||
class="text-brand-600 hover:underline">{{ a.titel }}</a>
|
||||
{% if a.ausschlusskriterium %}
|
||||
<span class="ml-1 text-xs text-red-600 font-medium">⚠ AK</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="py-2 pr-4">{% status_badge a.verbindlichkeit a.get_verbindlichkeit_display %}</td>
|
||||
<td class="py-2 pr-4">{% status_badge a.erfuellungsstatus a.get_erfuellungsstatus_display %}</td>
|
||||
<td class="py-2 text-slate-500">{{ a.zustaendiger|default:"—" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="card text-center text-sm text-slate-500 py-8">
|
||||
Keine Anforderungen gefunden.
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,21 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Aufgabe erstellen{% endblock %}
|
||||
{% block content %}
|
||||
<div class="max-w-md mx-auto mt-16">
|
||||
<div class="card space-y-4">
|
||||
<h1 class="text-lg font-semibold text-slate-800">Aufgabe aus Anforderung erstellen?</h1>
|
||||
<p class="text-sm text-slate-600">
|
||||
Eine neue Aufgabe vom Typ <strong>Fachlich</strong> wird erstellt:
|
||||
</p>
|
||||
<p class="text-sm font-medium text-slate-800 bg-slate-50 rounded p-3">
|
||||
Klärung: {{ anforderung.titel|truncatechars:200 }}
|
||||
</p>
|
||||
<form method="post" class="flex gap-3">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn-primary">Aufgabe erstellen</button>
|
||||
<a href="{% url 'ausschreibungen:lose:anforderung_detail' ausschreibung.pk anforderung.pk %}"
|
||||
class="btn-ghost">Abbrechen</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
89
vergabe_teilnahme/templates/lose/detail.html
Normal file
89
vergabe_teilnahme/templates/lose/detail.html
Normal file
@@ -0,0 +1,89 @@
|
||||
{% extends "base.html" %}
|
||||
{% load vergabe_tags %}
|
||||
{% block title %}{{ los }} — {{ ausschreibung.titel }}{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h1 class="page-title">{{ los.lostitel }}</h1>
|
||||
<p class="text-sm text-slate-500 mt-0.5">Los {{ los.losnummer }} · {{ ausschreibung.titel }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2 shrink-0">
|
||||
<a href="{% url 'ausschreibungen:lose:bearbeiten' ausschreibung.pk los.pk %}" class="btn-ghost text-xs">Bearbeiten</a>
|
||||
<a href="{% url 'ausschreibungen:lose:loeschen' ausschreibung.pk los.pk %}" class="btn-ghost text-xs text-slate-400">Löschen</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="card">
|
||||
<h2 class="text-sm font-semibold text-slate-700 mb-3">Stammdaten</h2>
|
||||
<dl class="space-y-1">
|
||||
{% render_field los "losnummer" "Losnummer" %}
|
||||
{% render_field los "lostitel" "Lostitel" %}
|
||||
{% render_field los "zustaendiger" "Zuständiger" %}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="text-sm font-semibold text-slate-700 mb-3">Teilnahme</h2>
|
||||
<p class="text-sm">
|
||||
{% if los.teilnahme is None %}
|
||||
<span class="text-slate-500">Noch nicht entschieden</span>
|
||||
{% elif los.teilnahme %}
|
||||
<span class="text-green-700 font-medium">Teilnahme</span>
|
||||
{% else %}
|
||||
<span class="text-red-700 font-medium">Keine Teilnahme</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if los.beschreibung %}
|
||||
<div class="card mt-6">
|
||||
<h2 class="text-sm font-semibold text-slate-700 mb-2">Beschreibung</h2>
|
||||
<p class="text-sm text-slate-600 whitespace-pre-line">{{ los.beschreibung }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if los.abgrenzung %}
|
||||
<div class="card mt-6">
|
||||
<h2 class="text-sm font-semibold text-slate-700 mb-2">Abgrenzung</h2>
|
||||
<p class="text-sm text-slate-600 whitespace-pre-line">{{ los.abgrenzung }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Anforderungen -->
|
||||
<div class="card mt-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-sm font-semibold text-slate-700">Anforderungen ({{ anforderungen.count }})</h2>
|
||||
<a href="{% url 'ausschreibungen:lose:anforderung_neu' ausschreibung.pk %}?los={{ los.pk }}"
|
||||
class="btn-ghost text-xs">+ Anforderung</a>
|
||||
</div>
|
||||
{% if anforderungen %}
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-200 text-left text-xs text-slate-500">
|
||||
<th class="pb-2 pr-4">Titel</th>
|
||||
<th class="pb-2 pr-4">Verbindlichkeit</th>
|
||||
<th class="pb-2">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for a in anforderungen %}
|
||||
<tr class="hover:bg-slate-50 {% if a.ausschlusskriterium and a.erfuellungsstatus == 'nicht_erfuellbar' %}bg-red-50{% endif %}">
|
||||
<td class="py-2 pr-4">
|
||||
<a href="{% url 'ausschreibungen:lose:anforderung_detail' ausschreibung.pk a.pk %}"
|
||||
class="text-brand-600 hover:underline">{{ a.titel }}</a>
|
||||
</td>
|
||||
<td class="py-2 pr-4">{% status_badge a.verbindlichkeit a.get_verbindlichkeit_display %}</td>
|
||||
<td class="py-2">{% status_badge a.erfuellungsstatus a.get_erfuellungsstatus_display %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="text-sm text-slate-500">Noch keine Anforderungen für dieses Los.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
56
vergabe_teilnahme/templates/lose/form.html
Normal file
56
vergabe_teilnahme/templates/lose/form.html
Normal file
@@ -0,0 +1,56 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ titel }}{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<h1 class="page-title mb-6">{{ titel }}</h1>
|
||||
|
||||
<form method="post" class="max-w-2xl space-y-6">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="card space-y-4">
|
||||
<h2 class="text-sm font-semibold text-slate-700">Los-Daten</h2>
|
||||
|
||||
<div>
|
||||
<label class="form-label">Losnummer <span class="text-red-500">*</span></label>
|
||||
{{ form.losnummer }}
|
||||
{% if form.losnummer.errors %}<p class="text-xs text-red-600 mt-1">{{ form.losnummer.errors.0 }}</p>{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label">Lostitel <span class="text-red-500">*</span></label>
|
||||
{{ form.lostitel }}
|
||||
{% if form.lostitel.errors %}<p class="text-xs text-red-600 mt-1">{{ form.lostitel.errors.0 }}</p>{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label">Beschreibung</label>
|
||||
{{ form.beschreibung }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label">Abgrenzung</label>
|
||||
{{ form.abgrenzung }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label">Zuständiger</label>
|
||||
{{ form.zustaendiger }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="form-label">Teilnahme</label>
|
||||
{{ form.teilnahme }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button type="submit" class="btn-primary">Speichern</button>
|
||||
{% if los %}
|
||||
<a href="{% url 'ausschreibungen:lose:detail' ausschreibung.pk los.pk %}" class="btn-ghost">Abbrechen</a>
|
||||
{% else %}
|
||||
<a href="{% url 'ausschreibungen:lose:liste' ausschreibung.pk %}" class="btn-ghost">Abbrechen</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
36
vergabe_teilnahme/templates/lose/liste.html
Normal file
36
vergabe_teilnahme/templates/lose/liste.html
Normal file
@@ -0,0 +1,36 @@
|
||||
{% extends "base.html" %}
|
||||
{% load vergabe_tags %}
|
||||
{% block title %}Lose — {{ ausschreibung.titel }}{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="page-title">Lose</h1>
|
||||
<a href="{% url 'ausschreibungen:lose:neu' ausschreibung.pk %}" class="btn-primary text-xs">+ Los hinzufügen</a>
|
||||
</div>
|
||||
|
||||
{% if lose %}
|
||||
<div class="card">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-200 text-left text-xs text-slate-500">
|
||||
<th class="pb-2 pr-4">Nr.</th>
|
||||
<th class="pb-2 pr-4">Titel</th>
|
||||
<th class="pb-2 pr-4">Zuständig</th>
|
||||
<th class="pb-2 pr-4">Teilnahme</th>
|
||||
<th class="pb-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="lose-table" class="divide-y divide-slate-100">
|
||||
{% for los in lose %}
|
||||
{% include "lose/partials/los_row.html" %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card text-center text-sm text-slate-500 py-8">
|
||||
Noch keine Lose angelegt.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
17
vergabe_teilnahme/templates/lose/loeschen_confirm.html
Normal file
17
vergabe_teilnahme/templates/lose/loeschen_confirm.html
Normal file
@@ -0,0 +1,17 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Los löschen{% endblock %}
|
||||
{% block content %}
|
||||
<div class="max-w-md mx-auto mt-16">
|
||||
<div class="card text-center space-y-4">
|
||||
<h1 class="text-lg font-semibold text-slate-800">Los löschen?</h1>
|
||||
<p class="text-sm text-slate-600">
|
||||
<strong>{{ los }}</strong> und alle zugehörigen Anforderungen werden unwiderruflich gelöscht.
|
||||
</p>
|
||||
<form method="post" class="flex justify-center gap-3">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn-primary bg-red-600 hover:bg-red-700">Löschen</button>
|
||||
<a href="{% url 'ausschreibungen:lose:detail' ausschreibung.pk los.pk %}" class="btn-ghost">Abbrechen</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,14 @@
|
||||
{% load vergabe_tags %}
|
||||
<div id="erfuellungsstatus-widget-{{ anforderung.pk }}" class="flex items-center gap-2">
|
||||
{% status_badge anforderung.erfuellungsstatus anforderung.get_erfuellungsstatus_display %}
|
||||
<select name="erfuellungsstatus"
|
||||
hx-post="{% url 'ausschreibungen:lose:anforderung_status' anforderung.ausschreibung_id anforderung.pk %}"
|
||||
hx-target="#erfuellungsstatus-widget-{{ anforderung.pk }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-trigger="change"
|
||||
class="form-input text-xs py-0.5 h-7 w-auto">
|
||||
{% for val, label in anforderung.ERFUELLUNG_CHOICES %}
|
||||
<option value="{{ val }}" {% if val == anforderung.erfuellungsstatus %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
22
vergabe_teilnahme/templates/lose/partials/los_row.html
Normal file
22
vergabe_teilnahme/templates/lose/partials/los_row.html
Normal file
@@ -0,0 +1,22 @@
|
||||
{% load vergabe_tags %}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="py-2 pr-4 text-slate-500">{{ los.losnummer }}</td>
|
||||
<td class="py-2 pr-4">
|
||||
<a href="{% url 'ausschreibungen:lose:detail' ausschreibung.pk los.pk %}"
|
||||
class="text-brand-600 hover:underline font-medium">{{ los.lostitel }}</a>
|
||||
</td>
|
||||
<td class="py-2 pr-4 text-slate-600">{{ los.zustaendiger|default:"—" }}</td>
|
||||
<td class="py-2 pr-4">
|
||||
{% if los.teilnahme is None %}
|
||||
<span class="text-slate-400 text-xs">Offen</span>
|
||||
{% elif los.teilnahme %}
|
||||
<span class="text-green-600 text-xs font-medium">Ja</span>
|
||||
{% else %}
|
||||
<span class="text-red-600 text-xs font-medium">Nein</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="py-2 text-right">
|
||||
<a href="{% url 'ausschreibungen:lose:bearbeiten' ausschreibung.pk los.pk %}"
|
||||
class="btn-ghost text-xs">Bearbeiten</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -0,0 +1,53 @@
|
||||
{% load vergabe_tags %}
|
||||
<div class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
|
||||
x-data @click.self="$el.remove()">
|
||||
<div class="bg-white rounded-xl shadow-xl w-full max-w-lg p-6" @click.stop>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-sm font-semibold text-slate-800">Nachweis zuordnen</h2>
|
||||
<button @click="$el.closest('.fixed').remove()" class="text-slate-400 hover:text-slate-600 text-lg leading-none">×</button>
|
||||
</div>
|
||||
|
||||
<form hx-get="{% url 'ausschreibungen:lose:nachweis_suche' ausschreibung_id anforderung.pk %}"
|
||||
hx-target="#nachweis-modal"
|
||||
hx-trigger="input delay:300ms"
|
||||
class="mb-4">
|
||||
<input type="text" name="q" value="{{ q }}"
|
||||
placeholder="Nachweis suchen…"
|
||||
class="form-input w-full" autofocus>
|
||||
</form>
|
||||
|
||||
{% if nachweise %}
|
||||
<ul class="divide-y divide-slate-100 max-h-64 overflow-y-auto text-sm">
|
||||
{% for n in nachweise %}
|
||||
<li class="py-2 flex items-center justify-between">
|
||||
<div>
|
||||
<span class="font-medium">{{ n.titel }}</span>
|
||||
{% if n.gueltig_bis %}
|
||||
<span class="ml-2 text-xs {% if n.ist_abgelaufen %}text-red-600{% else %}text-slate-500{% endif %}">
|
||||
bis {{ n.gueltig_bis }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% status_badge n.freigabestatus n.get_freigabestatus_display %}
|
||||
</div>
|
||||
{% if n.pk in bereits_zugeordnet %}
|
||||
<span class="text-xs text-green-600 font-medium">Bereits zugeordnet</span>
|
||||
{% else %}
|
||||
<form method="post"
|
||||
action="{% url 'ausschreibungen:lose:nachweis_zuordnen' ausschreibung_id anforderung.pk %}"
|
||||
hx-post="{% url 'ausschreibungen:lose:nachweis_zuordnen' ausschreibung_id anforderung.pk %}"
|
||||
hx-target="#nachweise-liste"
|
||||
hx-swap="innerHTML"
|
||||
hx-on::after-request="$el.closest('.fixed').remove()">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="nachweis_pk" value="{{ n.pk }}">
|
||||
<button type="submit" class="btn-ghost text-xs">Zuordnen</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="text-sm text-slate-500 text-center py-4">Keine Nachweise gefunden.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,29 @@
|
||||
{% load vergabe_tags %}
|
||||
{% with nachweise=anforderung.nachweise.all %}
|
||||
{% if nachweise %}
|
||||
<ul class="divide-y divide-slate-100 text-sm">
|
||||
{% for n in nachweise %}
|
||||
<li class="py-2 flex items-center justify-between">
|
||||
<div>
|
||||
<span class="font-medium">{{ n.titel }}</span>
|
||||
{% if n.ist_abgelaufen %}
|
||||
<span class="ml-2 text-xs text-red-600">abgelaufen</span>
|
||||
{% elif n.gueltig_bis %}
|
||||
<span class="ml-2 text-xs text-slate-500">bis {{ n.gueltig_bis }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<form method="post"
|
||||
action="{% url 'ausschreibungen:lose:nachweis_entfernen' ausschreibung_id anforderung.pk n.pk %}"
|
||||
hx-post="{% url 'ausschreibungen:lose:nachweis_entfernen' ausschreibung_id anforderung.pk n.pk %}"
|
||||
hx-target="#nachweise-liste"
|
||||
hx-swap="innerHTML">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="text-xs text-slate-400 hover:text-red-600">Entfernen</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="text-sm text-slate-500">Keine Nachweise zugeordnet.</p>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: WP-0004
|
||||
title: Dashboard und Ausschreibungen-CRUD
|
||||
status: todo
|
||||
status: done
|
||||
phase: 4-of-12
|
||||
created: "2026-05-08"
|
||||
depends_on: WP-0003
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: WP-0005
|
||||
title: Lose und Anforderungen
|
||||
status: todo
|
||||
status: done
|
||||
phase: 5-of-12
|
||||
created: "2026-05-08"
|
||||
depends_on: WP-0004
|
||||
@@ -19,7 +19,7 @@ Implementiert alle Views, Forms und Templates für Lose (UC-LA-01) und Anforderu
|
||||
```task
|
||||
id: WP-0005-T01
|
||||
title: Lose-Liste und Lose anlegen (UC-LA-01)
|
||||
status: todo
|
||||
status: done
|
||||
|
||||
`lose/views.py` — lose_liste und los_neu:
|
||||
|
||||
@@ -46,7 +46,7 @@ path('<int:los_pk>/bearbeiten/', views.los_bearbeiten, name='bearbeiten'),
|
||||
```task
|
||||
id: WP-0005-T02
|
||||
title: Los-Detail-Seite mit eingebetteten Anforderungen
|
||||
status: todo
|
||||
status: done
|
||||
|
||||
`lose/views.py` — los_detail:
|
||||
```python
|
||||
@@ -73,7 +73,7 @@ def los_detail(request, ausschreibung_id, los_pk):
|
||||
```task
|
||||
id: WP-0005-T03
|
||||
title: Anforderungsliste nach Los gruppiert (UC-LA-02)
|
||||
status: todo
|
||||
status: done
|
||||
|
||||
`lose/views.py` — anforderungen_liste:
|
||||
Lädt alle Anforderungen der Ausschreibung, gruppiert nach Los.
|
||||
@@ -91,7 +91,7 @@ Template `lose/anforderungen_liste.html`:
|
||||
```task
|
||||
id: WP-0005-T04
|
||||
title: Anforderung anlegen und Detailseite (UC-LA-02, UC-LA-03)
|
||||
status: todo
|
||||
status: done
|
||||
|
||||
`AnforderungForm(ModelForm)`: alle Felder aus Modell.
|
||||
Besonderer Widget für verbindlichkeit: Radio-Buttons statt Dropdown.
|
||||
@@ -117,7 +117,7 @@ def anforderung_status(request, ausschreibung_id, pk):
|
||||
```task
|
||||
id: WP-0005-T05
|
||||
title: Nachweis-Verknüpfung mit Bibliothek (UC-LA-04)
|
||||
status: todo
|
||||
status: done
|
||||
|
||||
`lose/views.py` — nachweis_suche_modal und nachweis_zuordnen:
|
||||
|
||||
@@ -141,7 +141,7 @@ Zeige zugeordnete Nachweise auf Anforderungsdetail als Liste mit Ablaufstatus-Ba
|
||||
```task
|
||||
id: WP-0005-T06
|
||||
title: Ausschlusskriterium-Eskalation auf Phase-2-Seite (UC-LA-05)
|
||||
status: todo
|
||||
status: done
|
||||
|
||||
Ergänze `ausschreibungen/views.py` — ausschreibung_entscheidung:
|
||||
|
||||
@@ -170,7 +170,7 @@ dann Phase-2-Seite öffnen → Banner erscheint.
|
||||
```task
|
||||
id: WP-0005-T07
|
||||
title: Aufgabe aus Anforderung ableiten (UC-AU-02)
|
||||
status: todo
|
||||
status: done
|
||||
|
||||
Auf der Anforderungsdetailseite: Button "Aufgabe erstellen".
|
||||
```python
|
||||
@@ -197,7 +197,7 @@ Nach Erstellen: Anforderungsdetail zeigt die neue Aufgabe im Abschnitt "Verbunde
|
||||
```task
|
||||
id: WP-0005-T08
|
||||
title: Tests für Lose und Anforderungen
|
||||
status: todo
|
||||
status: done
|
||||
|
||||
`lose/tests/test_views.py`:
|
||||
- Test: Lose-Liste gibt 200 zurück
|
||||
|
||||
Reference in New Issue
Block a user