Files
vergabe-teilnahme/workplans/WP-0003-basis-ui.md
2026-05-08 14:26:48 +02:00

503 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
id: WP-0003
title: Basis-UI — Shell-Layout, Templates, Template-Tags, Navigation
status: done
phase: 3-of-12
created: "2026-05-08"
depends_on: WP-0002
---
# WP-0003 — Basis-UI
Implementiert das Basis-Template-System: Shell-Layout mit Topbar und Sidebar,
alle Template-Tags (status_badge, phase_badge, render_field, flex_fields),
die globale und kontextuelle Navigation, den Feedback-Button und Error-Pages.
**Arbeitsverzeichnis:** `/home/worsch/vergabe-teilnahme/`
**Templates-Pfad:** `vergabe_teilnahme/templates/`
**Template-Tags:** `vergabe_teilnahme/apps/core/templatetags/`
Tailwind-Klassen aus WP-0001 (`static/src/main.css`) werden hier genutzt.
---
```task
id: WP-0003-T01
title: Template-Verzeichnisstruktur und base.html Shell-Layout
status: done
Erstelle Verzeichnisstruktur:
```
vergabe_teilnahme/templates/
├── base.html
├── partials/
│ ├── topbar.html
│ ├── sidebar.html
│ ├── breadcrumb.html
│ ├── feedback_button.html
│ ├── feedback_modal.html
│ └── phase_nav.html
├── errors/
│ ├── 404.html
│ └── 500.html
└── (app-spezifische Unterverzeichnisse folgen in späteren Workplans)
```
Füge in `settings/base.py` hinzu:
```python
TEMPLATES = [{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'vergabe_teilnahme' / 'templates'],
'APP_DIRS': True,
...
}]
```
`base.html` implementiert das Shell-Layout aus Blueprint Abschnitt 6.1:
```html
<!DOCTYPE html>
<html lang="de" x-data="{ sidebarOpen: true }">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Vergabe Teilnahme{% endblock %}</title>
<link rel="stylesheet" href="{% static 'dist/main.css' %}">
<script src="{% static 'vendor/alpinejs/alpine.min.js' %}" defer></script>
</head>
<body class="bg-slate-50 min-h-screen">
<!-- Topbar -->
{% include "partials/topbar.html" %}
<div class="flex h-[calc(100vh-56px)]">
<!-- Sidebar -->
{% include "partials/sidebar.html" %}
<!-- Hauptinhalt -->
<main class="flex-1 overflow-y-auto p-6">
{% include "partials/breadcrumb.html" %}
{% block content %}{% endblock %}
</main>
</div>
<!-- Feedback-Button (persistent) -->
{% include "partials/feedback_button.html" %}
<div id="modal-container"></div>
<!-- HTMX -->
<script src="{% static 'vendor/htmx/htmx.min.js' %}"></script>
{% block extra_js %}{% endblock %}
</body>
</html>
```
```
```task
id: WP-0003-T02
title: Topbar-Partial
status: done
`vergabe_teilnahme/templates/partials/topbar.html`:
Implementiere die Topbar mit:
- Logo / Appname "Vergabe Teilnahme" (links, Link zu /)
- Globale Suchleiste (Mitte):
```html
<form hx-get="/suche/" hx-target="#search-results" hx-trigger="input changed delay:300ms"
class="relative">
<input name="q" type="search" placeholder="Ausschreibung, Aufgabe, Dokument suchen..."
class="form-input w-96">
<div id="search-results" class="absolute top-full left-0 w-full bg-white rounded-b-lg
shadow-lg z-50 hidden [&:not(:empty)]:block">
</div>
</form>
```
- Avatar-Dropdown (rechts): Nutzername + Rolle, "Abmelden"-Link
(nutze Alpine.js x-show für Dropdown)
Topbar-Höhe: `h-14` (56px), `bg-white border-b border-slate-200`.
```
```task
id: WP-0003-T03
title: Sidebar globale Navigation
status: done
`vergabe_teilnahme/templates/partials/sidebar.html`:
Implementiere die feste linke Sidebar (240px Breite) aus Blueprint Abschnitt 6.2.
Struktur mit Alpine.js für aufklappbare Unterabschnitte:
```html
<aside class="w-60 bg-white border-r border-slate-200 flex flex-col overflow-y-auto"
x-show="sidebarOpen">
<!-- Globale Navpunkte -->
<nav class="p-3 space-y-1">
<!-- Übersicht -->
<a href="/" class="sidebar-link {% if request.resolver_match.url_name == 'dashboard' %}sidebar-link-active{% endif %}">
Übersicht
</a>
<!-- Ausschreibungen -->
<div x-data="{ open: true }">
<button @click="open = !open" class="sidebar-section-btn">
Ausschreibungen <span x-text="open ? '▾' : '▸'"></span>
</button>
<div x-show="open" class="ml-3 space-y-1">
<a href="/ausschreibungen/" class="sidebar-link">Alle Ausschreibungen</a>
<a href="/ausschreibungen/neu/" class="sidebar-link text-brand-600">+ Neu</a>
</div>
</div>
<!-- Bibliothek (aufklappbar) -->
<!-- Partner (aufklappbar) -->
<!-- Marktbegleiter -->
<!-- Feedback-Backlog -->
<!-- Administration -->
</nav>
<!-- Kontextueller Phasen-Navigator (nur wenn Ausschreibung aktiv) -->
{% if current_ausschreibung %}
{% include "partials/phase_nav.html" %}
{% endif %}
</aside>
```
Füge CSS-Hilfsklassen in `static/src/main.css` hinzu:
```css
.sidebar-link { @apply flex items-center px-3 py-2 rounded-lg text-sm text-slate-700 hover:bg-slate-100; }
.sidebar-link-active { @apply bg-brand-50 text-brand-700 font-medium; }
.sidebar-section-btn { @apply w-full flex items-center justify-between px-3 py-2 text-xs font-semibold text-slate-500 uppercase tracking-wide hover:text-slate-700; }
```
`current_ausschreibung` wird via Context-Processor bereitgestellt (nächster Task).
```
```task
id: WP-0003-T04
title: Context-Processor und Phasen-Navigator-Partial
status: done
**Context-Processor** `vergabe_teilnahme/apps/core/context_processors.py`:
```python
def vergabe_context(request):
context = {}
# Aktueller Ausschreibungs-Kontext aus URL
ausschreibung_id = None
if hasattr(request, 'resolver_match') and request.resolver_match:
kwargs = request.resolver_match.kwargs
ausschreibung_id = kwargs.get('ausschreibung_id') or kwargs.get('pk')
if ausschreibung_id:
try:
from vergabe_teilnahme.apps.ausschreibungen.models import Ausschreibung
context['current_ausschreibung'] = Ausschreibung.objects.get(pk=ausschreibung_id)
except (Ausschreibung.DoesNotExist, ValueError):
pass
return context
```
Registriere in `settings/base.py` unter `TEMPLATES[0]['OPTIONS']['context_processors']`.
**Phasen-Navigator** `partials/phase_nav.html`:
Zeigt die 8 Phasen als klickbare Links mit Statusindikator.
```html
<div class="border-t border-slate-200 p-3">
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
{{ current_ausschreibung.titel|truncatechars:30 }}
</p>
{% for phase in phases %}
<a href="{{ phase.url }}"
class="flex items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-slate-100
{% if phase.aktiv %}text-brand-700 font-medium{% else %}text-slate-600{% endif %}">
<span class="{% if phase.erledigt %}phase-done{% elif phase.aktiv %}phase-active{% elif phase.warnung %}phase-warn{% else %}phase-todo{% endif %}">
{{ phase.nummer }}
</span>
{{ phase.name }}
{% if phase.warnung %}<span class="ml-auto text-amber-500 text-xs"></span>{% endif %}
</a>
{% endfor %}
</div>
```
Die `phases`-Liste wird von einer View-Hilfsfunktion `build_phase_nav(ausschreibung, current_url)` befüllt.
Implementiere diese Funktion in `core/services.py`.
```
```task
id: WP-0003-T05
title: Breadcrumb-Partial
status: done
`vergabe_teilnahme/templates/partials/breadcrumb.html`:
Breadcrumb rendert eine Liste von Links aus dem Template-Context-Variable `breadcrumbs`.
```html
{% if breadcrumbs %}
<nav class="flex items-center gap-2 text-sm text-slate-500 mb-4">
{% for crumb in breadcrumbs %}
{% if not forloop.last %}
<a href="{{ crumb.url }}" class="hover:text-slate-900">{{ crumb.label }}</a>
<span></span>
{% else %}
<span class="text-slate-900 font-medium">{{ crumb.label }}</span>
{% endif %}
{% endfor %}
</nav>
{% endif %}
```
`breadcrumbs` wird in jeder View-Funktion als Liste von Dicts übergeben:
`[{'label': 'Ausschreibungen', 'url': '/ausschreibungen/'}, {'label': 'Titel', 'url': None}]`
Erstelle eine Hilfsfunktion `core.views_helpers.make_breadcrumbs(*args)` die diese Liste baut.
```
```task
id: WP-0003-T06
title: Template-Tags: status_badge und phase_badge
status: done
Erstelle `vergabe_teilnahme/apps/core/templatetags/__init__.py` (leer).
Erstelle `vergabe_teilnahme/apps/core/templatetags/vergabe_tags.py`.
```python
from django import template
register = template.Library()
STATUS_COLORS = {
# Ausschreibung / Aufgabe / Dokument Status
'offen': 'bg-slate-100 text-slate-700',
'in_bearbeitung': 'bg-blue-100 text-blue-700',
'erledigt': 'bg-green-100 text-green-700',
'freigegeben': 'bg-green-100 text-green-700',
'erteilt': 'bg-green-100 text-green-700',
'gewonnen': 'bg-green-100 text-green-700',
'ueberfaellig': 'bg-red-100 text-red-700',
'nicht_erfuellbar': 'bg-red-100 text-red-700',
'verloren': 'bg-red-100 text-red-700',
'abgelehnt': 'bg-red-100 text-red-700',
'ausstehend': 'bg-amber-100 text-amber-700',
'in_pruefung': 'bg-amber-100 text-amber-700',
'wartend_intern': 'bg-amber-100 text-amber-700',
'wartend_sub': 'bg-amber-100 text-amber-700',
'wartend_ausschreiber': 'bg-amber-100 text-amber-700',
'archiviert': 'bg-gray-100 text-gray-500',
'ersetzt': 'bg-gray-100 text-gray-500',
'verworfen': 'bg-gray-100 text-gray-500',
}
@register.inclusion_tag('partials/status_badge.html')
def status_badge(value, display_label=None):
css = STATUS_COLORS.get(value, 'bg-slate-100 text-slate-700')
return {'css': css, 'label': display_label or value.replace('_', ' ').capitalize()}
@register.simple_tag
def phase_badge(nummer, zustand='todo'):
css_map = {'todo': 'phase-todo', 'active': 'phase-active',
'done': 'phase-done', 'warn': 'phase-warn'}
return f'<span class="{css_map.get(zustand, \"phase-todo\")}">{nummer}</span>'
```
Erstelle `partials/status_badge.html`:
```html
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium {{ css }}">
{{ label }}
</span>
```
```
```task
id: WP-0003-T07
title: Template-Tag: render_field (EntityFieldConfig-aware)
status: done
Ergänze `vergabe_tags.py`:
```python
from vergabe_teilnahme.apps.core.models import EntityFieldConfig
_HIDDEN_FIELDS_CACHE = {}
def _is_field_hidden(entity_type, field_name):
key = (entity_type, field_name)
if key not in _HIDDEN_FIELDS_CACHE:
_HIDDEN_FIELDS_CACHE[key] = EntityFieldConfig.objects.filter(
entity_type=entity_type, field_name=field_name, is_hidden=True
).exists()
return _HIDDEN_FIELDS_CACHE[key]
def _get_field_label(entity_type, field_name, default_label):
try:
cfg = EntityFieldConfig.objects.get(entity_type=entity_type, field_name=field_name)
return cfg.display_label or default_label
except EntityFieldConfig.DoesNotExist:
return default_label
@register.inclusion_tag('partials/field_row.html')
def render_field(obj, field_name, label=None, force_show=False):
entity_type = obj._meta.model_name
if not force_show and _is_field_hidden(entity_type, field_name):
return {'hidden': True}
value = getattr(obj, field_name, None)
display_label = _get_field_label(entity_type, field_name, label or field_name)
return {'hidden': False, 'label': display_label, 'value': value, 'field_name': field_name}
```
Erstelle `partials/field_row.html`:
```html
{% if not hidden %}
<div class="field-row">
<dt class="field-label">{{ label }}</dt>
<dd class="field-value">
{% if value %}{{ value }}{% else %}<span class="text-slate-400"></span>{% endif %}
</dd>
</div>
{% endif %}
```
Wichtig: `_HIDDEN_FIELDS_CACHE` per Request invalidieren (oder einfach kein Cache in v1,
da Admin-Änderungen sofort wirken sollen — entscheide dich für kein Caching in v1).
```
```task
id: WP-0003-T08
title: Feedback-Button und Feedback-Modal-Partial
status: done
`partials/feedback_button.html`:
```html
<button hx-get="/feedback/modal/"
hx-target="#modal-container"
hx-swap="innerHTML"
class="fixed bottom-6 right-6 bg-white border border-slate-300 shadow-lg
rounded-full p-3 hover:bg-slate-50 z-40"
title="Feedback geben">
💬
</button>
```
`partials/feedback_modal.html` (wird vom HTMX-Endpunkt zurückgegeben):
```html
<div x-data="{ open: true }" x-show="open"
class="fixed inset-0 bg-black/30 z-50 flex items-center justify-center">
<div class="bg-white rounded-xl shadow-xl p-6 w-full max-w-md"
@click.outside="open = false">
<h2 class="page-title text-xl mb-4">Feedback</h2>
<form hx-post="/feedback/" hx-target="#modal-container" hx-swap="innerHTML">
{% csrf_token %}
<input type="hidden" name="seite_kontext" value="{{ request.path }}">
{% if current_ausschreibung %}
<input type="hidden" name="ausschreibung" value="{{ current_ausschreibung.pk }}">
{% endif %}
<div class="space-y-3">
<!-- Kategorie, Titel, Beschreibung, Dringlichkeit -->
<div>
<label class="form-label">Kategorie</label>
<select name="kategorie" class="form-input">
<option value="hinweis">Hinweis</option>
<option value="verbesserung">Verbesserungsvorschlag</option>
<option value="fehler">Fehler</option>
</select>
</div>
<div>
<label class="form-label">Beschreibung *</label>
<textarea name="beschreibung" rows="3" class="form-input" required></textarea>
</div>
</div>
<div class="flex justify-end gap-2 mt-4">
<button type="button" @click="open = false" class="btn-secondary">Abbrechen</button>
<button type="submit" class="btn-primary">Senden</button>
</div>
</form>
</div>
</div>
```
Erstelle in `feedback/views.py`:
- `GET /feedback/modal/` → rendert `partials/feedback_modal.html`
- `POST /feedback/` → speichert Feedbackeintrag, gibt Danke-Fragment zurück
Verkable URLs in `feedback/urls.py` und include in Haupt-URLs.
```
```task
id: WP-0003-T09
title: Error-Templates und Django-URL-Konfiguration
status: done
`vergabe_teilnahme/templates/errors/404.html`:
```html
{% extends "base.html" %}
{% block title %}Seite nicht gefunden{% endblock %}
{% block content %}
<div class="card text-center py-16">
<p class="text-6xl font-bold text-slate-200 mb-4">404</p>
<h1 class="page-title mb-2">Seite nicht gefunden</h1>
<p class="text-slate-500 mb-6">Die angeforderte Seite existiert nicht oder wurde verschoben.</p>
<a href="/" class="btn-primary">Zur Übersicht</a>
</div>
{% endblock %}
```
Analog `500.html`.
In `settings/base.py`:
```python
handler404 = 'vergabe_teilnahme.apps.core.views.custom_404'
handler500 = 'vergabe_teilnahme.apps.core.views.custom_500'
```
In `core/views.py`:
```python
from django.shortcuts import render
def custom_404(request, exception=None):
return render(request, 'errors/404.html', status=404)
def custom_500(request):
return render(request, 'errors/500.html', status=500)
```
Füge alle App-URL-Dateien in `vergabe_teilnahme/urls.py` ein (auch wenn die Views
noch nicht existieren — mit leeren `urlpatterns = []` als Platzhalter):
```python
path('ausschreibungen/', include('vergabe_teilnahme.apps.ausschreibungen.urls')),
path('lose/', include('vergabe_teilnahme.apps.lose.urls')),
# ... alle Apps
```
```
```task
id: WP-0003-T10
title: Einfache Startseite mit Redirect und Smoke-Test
status: done
Erstelle `core/views.py` mit einer einfachen Redirect-View auf das Dashboard:
```python
from django.shortcuts import redirect
def home(request):
return redirect('ausschreibungen:dashboard')
```
Füge URL hinzu: `path('', core_views.home, name='home')`
Erstelle eine minimale Dashboard-Placeholder-View in `ausschreibungen/views.py`:
```python
from django.shortcuts import render
def dashboard(request):
return render(request, 'ausschreibungen/dashboard.html', {
'breadcrumbs': [{'label': 'Übersicht', 'url': None}]
})
```
Erstelle `vergabe_teilnahme/templates/ausschreibungen/dashboard.html`:
```html
{% extends "base.html" %}
{% 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>
{% endblock %}
```
Verkable URL: `ausschreibungen/urls.py``path('', views.dashboard, name='dashboard')`
In Haupt-URLs: `path('', include('vergabe_teilnahme.apps.ausschreibungen.urls', namespace='ausschreibungen'))`
Prüfe: `make dev` startet, `http://localhost:8000/` leitet auf Dashboard weiter,
Seite rendert ohne Template-Fehler. Sidebar und Topbar sind sichtbar.
```