This commit is contained in:
NABU Jena 2025-07-09 08:46:55 +02:00
commit 1656b1d5b1
33 changed files with 5014 additions and 2 deletions

0
MIGRATION_README.md Normal file
View file

0
SECURITY_NOTES.md Normal file
View file

140
STATISTIC_ADMIN_CONFIG.md Normal file
View file

@ -0,0 +1,140 @@
# Statistic Admin Configuration
## Überblick
Die Statistik-App verfügt jetzt über eine vollständig konfigurierbare Admin-Oberfläche mit drei separaten Bereichen für maximale Flexibilität:
1. **Statistik-Individuen**: Konfiguration der Balkendiagramme für Vogelarten
2. **Statistik-Jahr**: Konfiguration der Jahresstatistik-Karten
3. **Statistik-Insgesamt**: Konfiguration der Gesamtstatistik-Karten
## Neue Modell-Struktur
### StatisticIndividual (Statistik-Individuen)
- **Zweck**: Definiert Gruppierungen von BirdStatus für die Vogelarten-Balkendiagramme
- **Felder**:
- `name`: Name der Gruppe (z.B. "Gerettet", "Verstorben")
- `color`: Hex-Farbcode für die Darstellung (z.B. #28a745)
- `order`: Reihenfolge der Gruppen in den Balkendiagrammen
- `status_list`: ManyToMany-Beziehung zu BirdStatus
- `is_active`: Ob diese Gruppe angezeigt werden soll
### StatisticYearGroup (Statistik-Jahr)
- **Zweck**: Definiert Gruppierungen für die Jahresstatistik-Übersichtskarten
- **Felder**:
- `name`: Name der Jahresgruppe
- `color`: Hex-Farbcode für die Karten-Darstellung
- `order`: Reihenfolge der Karten in der Jahresübersicht
- `status_list`: Welche BirdStatus gehören zu dieser Jahresgruppe
- `is_active`: Aktivierung/Deaktivierung
### StatisticTotalGroup (Statistik-Insgesamt)
- **Zweck**: Definiert Gruppierungen für die Gesamtstatistik-Übersichtskarten
- **Felder**:
- `name`: Name der Gesamtgruppe
- `color`: Hex-Farbcode für die Karten-Darstellung
- `order`: Reihenfolge der Karten in der Gesamtübersicht
- `status_list`: Welche BirdStatus gehören zu dieser Gesamtgruppe
- `is_active`: Aktivierung/Deaktivierung
### StatisticConfiguration (Vereinfacht)
- **Zweck**: Globale Konfiguration für die Statistik-Anzeige
- **Felder**:
- `show_year_total_patients`: Checkbox für Anzeige der Gesamtanzahl aktuelles Jahr
- `show_total_patients`: Checkbox für Anzeige der Gesamtanzahl aller Jahre
- `show_percentages`: Prozentangaben in Balkendiagrammen anzeigen
- `show_absolute_numbers`: Absolute Zahlen in Balkendiagrammen anzeigen
- `is_active`: Aktive Konfiguration (nur eine möglich)
## Admin-Interface Struktur
### Statistik-Individuen
- **URL**: `/admin/statistic/statisticindividual/`
- **Zweck**: Konfiguration der Vogelarten-Balkendiagramme
- **Features**: Erweiterte Farbauswahl, Status-Zuordnung, Reihenfolge
### Statistik-Jahr
- **URL**: `/admin/statistic/statisticyeargroup/`
- **Zweck**: Konfiguration der Jahresstatistik-Karten
- **Features**: Separate Gruppen für Jahresübersicht, eigene Farben
### Statistik-Insgesamt
- **URL**: `/admin/statistic/statistictotalgroup/`
- **Zweck**: Konfiguration der Gesamtstatistik-Karten
- **Features**: Separate Gruppen für Gesamtübersicht, eigene Farben
### Statistik-Konfiguration
- **URL**: `/admin/statistic/statisticconfiguration/`
- **Zweck**: Globale Ein-/Ausschaltung von Bereichen
- **Features**: Checkboxen für Sichtbarkeit der Gesamtanzahl-Karten
## Vollständige Konfigurierbarkeit
Die Statistik-Seite (`http://localhost:8000/statistics/`) ist jetzt vollständig über das Admin-Interface konfigurierbar:
### Jahresstatistik-Bereich
- ✅ **Gesamtanzahl Patienten**: Ein-/Ausschaltbar über Konfiguration
- ✅ **Jahresgruppen**: Beliebig viele konfigurierbare Gruppen mit eigenen Farben
- ✅ **Status-Zuordnung**: Flexible Zuordnung von BirdStatus zu Gruppen
### Gesamtstatistik-Bereich
- ✅ **Gesamtanzahl aller Patienten**: Ein-/Ausschaltbar über Konfiguration
- ✅ **Gesamtgruppen**: Beliebig viele konfigurierbare Gruppen mit eigenen Farben
- ✅ **Prozentanzeige**: Automatische Berechnung und Anzeige
### Vogelarten-Statistik
- ✅ **Balkendiagramme**: Vollständig konfigurierbare Gruppierungen
- ✅ **Farben**: Individuelle Farbzuordnung pro Gruppe
- ✅ **Legende**: Dynamische Generierung basierend auf Konfiguration
## Standard-Konfiguration
### Statistik-Individuen (Balkendiagramme)
1. **Gerettet** (#28a745 - Grün): Ausgewildert, Übermittelt
2. **Verstorben** (#dc3545 - Rot): Verstorben
3. **In Behandlung/Auswilderung** (#ffc107 - Gelb): In Behandlung, In Auswilderung
### Statistik-Jahr (Jahresstatistik-Karten)
1. **Gerettet** (#28a745 - Grün): Ausgewildert, Übermittelt
2. **Verstorben** (#dc3545 - Rot): Verstorben
3. **In Behandlung** (#ffc107 - Gelb): In Behandlung, In Auswilderung
### Statistik-Insgesamt (Gesamtstatistik-Karten)
1. **Erfolgreich gerettet** (#28a745 - Grün): Ausgewildert, Übermittelt
2. **Verstorben** (#dc3545 - Rot): Verstorben
3. **Aktuell in Betreuung** (#17a2b8 - Türkis): In Behandlung, In Auswilderung
## Verwendung
### Neue Jahresgruppe erstellen
1. Admin → Statistic → Statistik-Jahr → Hinzufügen
2. Name eingeben (z.B. "Notfälle")
3. Farbe mit Color Picker auswählen
4. Reihenfolge festlegen
5. BirdStatus zuordnen
6. Aktivieren und speichern
### Gesamtstatistik anpassen
1. Admin → Statistic → Statistik-Insgesamt → Gruppe bearbeiten
2. Namen ändern oder neue Gruppe erstellen
3. Farben nach Bedarf anpassen
4. Status-Zuordnungen aktualisieren
### Sichtbarkeit steuern
1. Admin → Statistic → Statistik-Konfiguration
2. Checkboxen für Gesamtanzahl-Anzeige setzen/entfernen
3. Anzeige-Optionen für Balkendiagramme konfigurieren
## Migration und Kompatibilität
- ✅ **Automatische Migration**: Bestehende Daten wurden automatisch übernommen
- ✅ **Rückwärtskompatibilität**: Alle bisherigen Funktionen bleiben erhalten
- ✅ **Erweiterte Flexibilität**: Drei separate Konfigurationsbereiche
- ✅ **Vereinfachte Verwaltung**: Nur noch eine Statistik-Konfiguration notwendig
## Technische Details
- **Separate Models**: Getrennte Konfiguration für verschiedene Statistik-Bereiche
- **Dynamisches Rendering**: Template passt sich automatisch an Konfiguration an
- **Color-Coded UI**: Jede Gruppe kann individuelle Farben haben
- **Flexible Status-Zuordnung**: BirdStatus können frei zwischen Gruppen zugeordnet werden

View file

@ -43,7 +43,7 @@ SECURE_HSTS_PRELOAD = True
# ----------------------------------- # -----------------------------------
# Allowed Hosts # Allowed Hosts
# ----------------------------------- # -----------------------------------
ALLOWED_HOSTS = [env("ALLOWED_HOSTS")] ALLOWED_HOSTS = env("ALLOWED_HOSTS").split(",") if env("ALLOWED_HOSTS") else []
# ----------------------------------- # -----------------------------------
# Application definition # Application definition
@ -84,6 +84,7 @@ INSTALLED_APPS = [
"bird", "bird",
"contact", "contact",
"costs", "costs",
"statistic",
"export", "export",
"notizen", "notizen",
"reports", "reports",

View file

@ -11,6 +11,7 @@ urlpatterns = [
path("bird/", include("bird.urls")), path("bird/", include("bird.urls")),
path("contacts/", include("contact.urls")), path("contacts/", include("contact.urls")),
path("costs/", include("costs.urls")), path("costs/", include("costs.urls")),
path("statistics/", include("statistic.urls")),
path("export/", include("export.urls")), path("export/", include("export.urls")),
path("notizen/", include("notizen.urls")), path("notizen/", include("notizen.urls")),
# Admin # Admin

View file

@ -0,0 +1,72 @@
/* Custom CSS für die Statistik-Admin-Seite */
.field-color {
display: flex;
align-items: center;
gap: 10px;
}
.field-color input[type="color"] {
width: 60px !important;
height: 40px !important;
border: 2px solid #ddd !important;
border-radius: 8px !important;
cursor: pointer !important;
padding: 0 !important;
}
.field-color input[type="text"] {
width: 120px !important;
font-family: 'Courier New', monospace !important;
font-size: 14px !important;
font-weight: bold !important;
text-transform: uppercase !important;
letter-spacing: 1px !important;
padding: 8px 12px !important;
border: 2px solid #ddd !important;
border-radius: 6px !important;
background-color: #f8f9fa !important;
}
.field-color input[type="text"]:focus {
border-color: #007cba !important;
box-shadow: 0 0 0 1px #007cba !important;
outline: none !important;
}
.color-preview {
width: 30px;
height: 30px;
border-radius: 50%;
border: 3px solid #fff;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
display: inline-block;
margin-left: 10px;
}
.color-picker-container {
display: flex;
align-items: center;
gap: 15px;
padding: 10px 0;
}
.color-picker-label {
font-weight: bold;
color: #333;
min-width: 100px;
}
/* Responsive Design */
@media (max-width: 768px) {
.field-color {
flex-direction: column;
align-items: flex-start;
}
.color-picker-container {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
}

View file

@ -0,0 +1,210 @@
// Erweiterte Farbauswahl-Funktionalität für Statistik-Admin
document.addEventListener('DOMContentLoaded', function() {
// Warte kurz, damit alle Django-Admin-Skripte geladen sind
setTimeout(function() {
const colorField = document.querySelector('input[name="color"]');
if (colorField && !colorField.dataset.enhanced) {
// Markiere als bereits erweitert
colorField.dataset.enhanced = 'true';
// Erstelle Container für erweiterte Farbauswahl
const container = document.createElement('div');
container.className = 'color-picker-container';
// Erstelle Color Picker
const colorPicker = document.createElement('input');
colorPicker.type = 'color';
colorPicker.className = 'color-picker-input';
colorPicker.value = colorField.value || '#28a745';
// Erstelle Farb-Vorschau
const colorPreview = document.createElement('div');
colorPreview.className = 'color-preview';
colorPreview.style.backgroundColor = colorField.value || '#28a745';
// Erstelle vordefinierte Farbpalette
const colorPalette = document.createElement('div');
colorPalette.className = 'color-palette';
colorPalette.innerHTML = `
<div style="margin-top: 15px; padding: 10px; background-color: #f8f9fa; border-radius: 6px;">
<strong style="color: #333; margin-bottom: 8px; display: block;">Vordefinierte Farben:</strong>
<div style="display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap;">
<button type="button" class="quick-color" data-color="#28a745" style="background: #28a745;" title="Grün (Gerettet)"></button>
<button type="button" class="quick-color" data-color="#dc3545" style="background: #dc3545;" title="Rot (Verstorben)">×</button>
<button type="button" class="quick-color" data-color="#ffc107" style="background: #ffc107;" title="Gelb (In Behandlung)"></button>
<button type="button" class="quick-color" data-color="#007bff" style="background: #007bff;" title="Blau">💙</button>
<button type="button" class="quick-color" data-color="#6f42c1" style="background: #6f42c1;" title="Lila">💜</button>
<button type="button" class="quick-color" data-color="#fd7e14" style="background: #fd7e14;" title="Orange">🧡</button>
<button type="button" class="quick-color" data-color="#20c997" style="background: #20c997;" title="Türkis">💚</button>
<button type="button" class="quick-color" data-color="#6c757d" style="background: #6c757d;" title="Grau"></button>
</div>
</div>
`;
// Styling für Schnellauswahl-Buttons
if (!document.getElementById('color-picker-styles')) {
const style = document.createElement('style');
style.id = 'color-picker-styles';
style.textContent = `
.color-picker-container {
display: flex;
align-items: center;
gap: 15px;
margin: 10px 0;
padding: 10px;
background-color: #f0f0f0;
border-radius: 8px;
border: 1px solid #ddd;
}
.color-picker-input {
width: 60px !important;
height: 40px !important;
border: 2px solid #ddd !important;
border-radius: 8px !important;
cursor: pointer !important;
padding: 0 !important;
}
.color-preview {
width: 40px;
height: 40px;
border-radius: 50%;
border: 3px solid #fff;
box-shadow: 0 2px 6px rgba(0,0,0,0.15);
display: inline-block;
}
.quick-color {
width: 40px !important;
height: 40px !important;
border: 3px solid #fff !important;
border-radius: 50% !important;
cursor: pointer !important;
color: white !important;
font-weight: bold !important;
font-size: 14px !important;
box-shadow: 0 3px 6px rgba(0,0,0,0.2) !important;
transition: all 0.2s ease !important;
margin: 3px !important;
outline: none !important;
}
.quick-color:hover {
transform: scale(1.15) !important;
border-color: #333 !important;
box-shadow: 0 4px 8px rgba(0,0,0,0.3) !important;
}
.quick-color:active {
transform: scale(0.95) !important;
}
`;
document.head.appendChild(style);
}
// Organisiere das Layout
const fieldContainer = colorField.closest('.field-color') || colorField.parentNode;
// Füge Label hinzu
const label = document.createElement('div');
label.innerHTML = '<strong style="color: #333; margin-bottom: 5px; display: block;">🎨 Farbauswahl:</strong>';
container.appendChild(colorPicker);
container.appendChild(colorPreview);
// Füge Container vor dem ursprünglichen Feld ein
fieldContainer.insertBefore(label, colorField);
fieldContainer.insertBefore(container, colorField);
fieldContainer.appendChild(colorPalette);
// Aktualisiere das ursprüngliche Textfeld für bessere Sichtbarkeit
colorField.style.cssText = `
width: 140px !important;
font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace !important;
font-size: 16px !important;
font-weight: bold !important;
text-transform: uppercase !important;
letter-spacing: 2px !important;
padding: 12px 15px !important;
border: 2px solid #007cba !important;
border-radius: 8px !important;
background-color: #fff !important;
margin-top: 10px !important;
text-align: center !important;
box-shadow: 0 2px 4px rgba(0,0,0,0.1) !important;
`;
// Event Listeners
// Color Picker ändert Textfeld und Vorschau
colorPicker.addEventListener('input', function() {
const color = this.value.toUpperCase();
colorField.value = color;
colorPreview.style.backgroundColor = color;
});
// Textfeld ändert Color Picker und Vorschau
colorField.addEventListener('input', function() {
let color = this.value.trim().toUpperCase();
if (!color.startsWith('#') && color.length > 0) {
color = '#' + color;
this.value = color;
}
if (/^#[0-9A-Fa-f]{6}$/.test(color)) {
colorPicker.value = color;
colorPreview.style.backgroundColor = color;
}
});
// Schnellauswahl-Buttons
document.querySelectorAll('.quick-color').forEach(button => {
button.addEventListener('click', function(e) {
e.preventDefault();
const color = this.dataset.color.toUpperCase();
colorField.value = color;
colorPicker.value = color;
colorPreview.style.backgroundColor = color;
// Visuelles Feedback
this.style.transform = 'scale(0.9)';
setTimeout(() => {
this.style.transform = 'scale(1)';
}, 150);
});
});
// Validierung des Hex-Werts
colorField.addEventListener('blur', function() {
let color = this.value.trim().toUpperCase();
// Füge # hinzu falls vergessen
if (color && !color.startsWith('#')) {
color = '#' + color;
}
// Validiere Hex-Format
if (color && !/^#[0-9A-Fa-f]{6}$/.test(color)) {
alert('Bitte geben Sie einen gültigen Hex-Farbcode ein (z.B. #28A745)');
this.focus();
return;
}
if (color) {
this.value = color;
colorPicker.value = color;
colorPreview.style.backgroundColor = color;
}
});
// Initialisiere mit aktuellem Wert
const currentColor = (colorField.value || '#28A745').toUpperCase();
colorField.value = currentColor;
colorPicker.value = currentColor;
colorPreview.style.backgroundColor = currentColor;
}
}, 100);
});

97
app/statistic/README.md Normal file
View file

@ -0,0 +1,97 @@
# Statistik App
Die Statistik-App bietet umfassende Übersichten über die Patientendaten in der FBF (Fallen Birdy) Anwendung.
## 📊 Funktionen
### 1. Übersicht aktuelles Jahr
- **Aufgenommene Patienten**: Anzahl der neu aufgenommenen Patienten im aktuellen Jahr
- **In Behandlung/Auswilderung**: Aktuell aktive Fälle (Status: "In Behandlung" oder "In Auswilderung")
- **Gerettete Tiere**: Erfolgreich behandelte Patienten (Status: "Ausgewildert" oder "Übermittelt")
### 2. Gesamtübersicht (alle Jahre)
- **Patienten insgesamt**: Gesamtanzahl aller jemals erfassten Patienten
- **Erfolgreiche Rettungen**: Gesamtanzahl geretteter Tiere mit Erfolgsquote in Prozent
### 3. Statistik pro Vogelart (aufklappbar)
- **Interaktives Balkendiagramm** mit zweifarbigen Balken:
- 🟢 **Grün**: Gerettete Vögel (ausgewildert + übermittelt)
- 🔴 **Rot**: Verstorbene Vögel
- **Detaillierte Zahlen** an jedem Balken
- **Sortierung** nach Gesamtanzahl der Patienten (absteigend)
- **Zusatzinformationen**: Lateinischer Artname (falls verfügbar)
## 🎨 Design-Features
- **Responsive Design**: Optimiert für Desktop, Tablet und Mobile
- **Animierte Karten**: Hover-Effekte und sanfte Übergänge
- **Farbkodierung**: Intuitive Farben für verschiedene Statuskategorien
- **Aufklappbare Bereiche**: Übersichtliche Darstellung großer Datenmengen
- **Bootstrap 5**: Moderne, konsistente Benutzeroberfläche
## 🔧 Technische Details
### Datenmodell
Die Statistiken basieren auf folgenden Modellen:
- `FallenBird`: Patientendaten mit Status und Funddatum
- `Bird`: Vogelarten/Bezeichnungen
- `BirdStatus`: Status-Definitionen (In Behandlung, Ausgewildert, etc.)
### Status-Kategorien
1. **In Behandlung** (ID: 1) - Aktive Patienten
2. **In Auswilderung** (ID: 2) - Vorbereitung zur Entlassung
3. **Ausgewildert** (ID: 3) - Erfolgreich freigelassen
4. **Übermittelt** (ID: 4) - An andere Einrichtungen weitergegeben
5. **Verstorben** (ID: 5) - Nicht gerettete Patienten
### View-Logik
```python
# Beispiel für Jahresstatistik
patients_this_year = FallenBird.objects.filter(
date_found__year=current_year
).count()
# Beispiel für Erfolgsrate
rescued_count = FallenBird.objects.filter(
status__id__in=[3, 4] # Ausgewildert, Übermittelt
).count()
```
## 📍 Navigation
Die Statistik-App ist in der Hauptnavigation zwischen **"Volieren"** und **"Kosten"** positioniert.
**URL**: `/statistik/`
## 🔍 Datenanalyse
### Aktueller Datenstand (Beispiel)
- **Gesamte Patienten**: 1.267
- **Vogelarten**: 112 verschiedene Arten
- **Dieses Jahr (2025)**: 393 neue Patienten
- **Erfolgsquote**: ~62% (780 von 1.267 gerettet)
### Status-Verteilung
- In Behandlung: 143 Patienten
- Ausgewildert: 683 Patienten
- Übermittelt: 97 Patienten
- Verstorben: 344 Patienten
## 🎯 Zukünftige Erweiterungen
Mögliche weitere Features:
- **Zeitreihen-Diagramme**: Entwicklung über mehrere Jahre
- **Monatsstatistiken**: Saisonale Verteilungen
- **Fundort-Analyse**: Geografische Statistiken
- **Kosten-Integration**: Behandlungskosten pro Art
- **Export-Funktionen**: PDF/Excel-Reports
- **Interaktive Charts**: D3.js oder Chart.js Integration
## 📱 Responsive Verhalten
- **Desktop**: Drei-spaltige Kartenlayouts
- **Tablet**: Zwei-spaltige Anordnung
- **Mobile**: Ein-spaltige Darstellung
- **Balkendiagramm**: Automatische Anpassung der Beschriftungen
Die Statistik-App bietet eine umfassende, benutzerfreundliche Übersicht über alle wichtigen Kennzahlen der Wildvogel-Rettungsstation.

View file

249
app/statistic/admin.py Normal file
View file

@ -0,0 +1,249 @@
from django.contrib import admin
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from .models import StatisticIndividual, StatisticYearGroup, StatisticTotalGroup, StatisticConfiguration
@admin.register(StatisticIndividual)
class StatisticIndividualAdmin(admin.ModelAdmin):
"""
Admin-Interface für die Verwaltung von Statistik-Individuen (Vogelarten-Balkendiagramme).
"""
list_display = [
'name',
'color_display',
'order',
'status_count',
'is_active',
'updated'
]
list_filter = ['is_active', 'created', 'updated']
search_fields = ['name']
ordering = ['order', 'name']
fieldsets = (
(_("Grundeinstellungen"), {
'fields': ('name', 'color', 'order', 'is_active')
}),
(_("Status-Zuordnung"), {
'fields': ('status_list',),
'description': _("Wählen Sie die BirdStatus aus, die zu dieser Gruppe gehören sollen.")
}),
)
filter_horizontal = ('status_list',)
def color_display(self, obj):
"""Zeigt die Farbe als farbigen Block an."""
return format_html(
'<div style="width: 30px; height: 20px; background-color: {}; border: 1px solid #ccc; border-radius: 3px;"></div>',
obj.color
)
color_display.short_description = _("Farbe")
def status_count(self, obj):
"""Zeigt die Anzahl der zugeordneten Status an."""
count = obj.status_list.count()
return f"{count} Status"
status_count.short_description = _("Anzahl Status")
def get_form(self, request, obj=None, **kwargs):
"""Bereite das Form für erweiterte Farbauswahl vor."""
# Get the form class first
form = super().get_form(request, obj, **kwargs)
# Set default color for new objects
if obj is None and 'color' in form.base_fields:
form.base_fields['color'].initial = '#28a745'
return form
class Media:
css = {
'all': (
'admin/css/widgets.css',
'admin/css/statistic_admin.css',
)
}
js = (
'admin/js/admin/RelatedObjectLookups.js',
'admin/js/statistic_color_picker.js',
)
@admin.register(StatisticYearGroup)
class StatisticYearGroupAdmin(admin.ModelAdmin):
"""
Admin-Interface für die Verwaltung von Jahres-Statistik-Gruppen.
"""
list_display = [
'name',
'color_display',
'order',
'status_count',
'is_active',
'updated'
]
list_filter = ['is_active', 'created', 'updated']
search_fields = ['name']
ordering = ['order', 'name']
fieldsets = (
(_("Grundeinstellungen"), {
'fields': ('name', 'color', 'order', 'is_active'),
'description': _("Konfiguration für die Jahresstatistik-Karten")
}),
(_("Status-Zuordnung"), {
'fields': ('status_list',),
'description': _("Welche BirdStatus sollen in dieser Jahresgruppe zusammengefasst werden?")
}),
)
filter_horizontal = ('status_list',)
def color_display(self, obj):
"""Zeigt die Farbe als farbigen Block an."""
return format_html(
'<div style="width: 30px; height: 20px; background-color: {}; border: 1px solid #ccc; border-radius: 3px;"></div>',
obj.color
)
color_display.short_description = _("Farbe")
def status_count(self, obj):
"""Zeigt die Anzahl der zugeordneten Status an."""
count = obj.status_list.count()
return f"{count} Status"
status_count.short_description = _("Anzahl Status")
def get_form(self, request, obj=None, **kwargs):
"""Bereite das Form für erweiterte Farbauswahl vor."""
# Get the form class first
form = super().get_form(request, obj, **kwargs)
# Set default color for new objects
if obj is None and 'color' in form.base_fields:
form.base_fields['color'].initial = '#007bff'
return form
class Media:
css = {
'all': (
'admin/css/widgets.css',
'admin/css/statistic_admin.css',
)
}
js = (
'admin/js/admin/RelatedObjectLookups.js',
'admin/js/statistic_color_picker.js',
)
@admin.register(StatisticTotalGroup)
class StatisticTotalGroupAdmin(admin.ModelAdmin):
"""
Admin-Interface für die Verwaltung von Gesamt-Statistik-Gruppen.
"""
list_display = [
'name',
'color_display',
'order',
'status_count',
'is_active',
'updated'
]
list_filter = ['is_active', 'created', 'updated']
search_fields = ['name']
ordering = ['order', 'name']
fieldsets = (
(_("Grundeinstellungen"), {
'fields': ('name', 'color', 'order', 'is_active'),
'description': _("Konfiguration für die Gesamtstatistik-Karten")
}),
(_("Status-Zuordnung"), {
'fields': ('status_list',),
'description': _("Welche BirdStatus sollen in dieser Gesamtgruppe zusammengefasst werden?")
}),
)
filter_horizontal = ('status_list',)
def color_display(self, obj):
"""Zeigt die Farbe als farbigen Block an."""
return format_html(
'<div style="width: 30px; height: 20px; background-color: {}; border: 1px solid #ccc; border-radius: 3px;"></div>',
obj.color
)
color_display.short_description = _("Farbe")
def status_count(self, obj):
"""Zeigt die Anzahl der zugeordneten Status an."""
count = obj.status_list.count()
return f"{count} Status"
status_count.short_description = _("Anzahl Status")
def get_form(self, request, obj=None, **kwargs):
"""Bereite das Form für erweiterte Farbauswahl vor."""
# Get the form class first
form = super().get_form(request, obj, **kwargs)
# Set default color for new objects
if obj is None and 'color' in form.base_fields:
form.base_fields['color'].initial = '#28a745'
return form
class Media:
css = {
'all': (
'admin/css/widgets.css',
'admin/css/statistic_admin.css',
)
}
js = (
'admin/js/admin/RelatedObjectLookups.js',
'admin/js/statistic_color_picker.js',
)
@admin.register(StatisticConfiguration)
class StatisticConfigurationAdmin(admin.ModelAdmin):
"""
Admin-Interface für die Verwaltung der Statistik-Konfiguration.
"""
list_display = [
'get_name',
'show_year_total_patients',
'show_total_patients',
'show_percentages',
'show_absolute_numbers',
'is_active',
'updated'
]
list_filter = ['is_active', 'show_percentages', 'show_absolute_numbers', 'show_year_total_patients', 'show_total_patients']
fieldsets = (
(_("Jahresstatistik"), {
'fields': ('show_year_total_patients',),
'description': _("Konfiguration für die Anzeige der aktuellen Jahresstatistik")
}),
(_("Gesamtstatistik"), {
'fields': ('show_total_patients',),
'description': _("Konfiguration für die Anzeige der Gesamtstatistik")
}),
(_("Anzeige-Optionen"), {
'fields': ('show_percentages', 'show_absolute_numbers'),
'description': _("Allgemeine Anzeige-Optionen für Balkendiagramme")
}),
(_("System"), {
'fields': ('is_active',),
'description': _("Systemeinstellungen")
}),
)
def get_name(self, obj):
return "Statistik Konfiguration"
get_name.short_description = _("Konfiguration")
def has_add_permission(self, request):
"""Erlaube nur eine Konfiguration."""
return StatisticConfiguration.objects.count() == 0
def has_delete_permission(self, request, obj=None):
"""Verhindert das Löschen der Konfiguration."""
return False

7
app/statistic/apps.py Normal file
View file

@ -0,0 +1,7 @@
from django.apps import AppConfig
class StatisticConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'statistic'
verbose_name = 'Statistik'

View file

@ -0,0 +1,49 @@
# Generated by Django 5.2.4 on 2025-07-07 22:24
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('bird', '0009_merge_20250609_2033'),
]
operations = [
migrations.CreateModel(
name='StatisticConfiguration',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(default='Standard Konfiguration', max_length=100, verbose_name='Konfigurationsname')),
('is_active', models.BooleanField(default=True, help_text='Nur eine Konfiguration kann gleichzeitig aktiv sein', verbose_name='Aktive Konfiguration')),
('show_percentages', models.BooleanField(default=True, help_text='Sollen Prozentangaben in den Balkendiagrammen angezeigt werden?', verbose_name='Prozentangaben anzeigen')),
('show_absolute_numbers', models.BooleanField(default=True, help_text='Sollen absolute Zahlen in den Balkendiagrammen angezeigt werden?', verbose_name='Absolute Zahlen anzeigen')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='Erstellt am')),
('updated', models.DateTimeField(auto_now=True, verbose_name='Geändert am')),
],
options={
'verbose_name': 'Statistik-Konfiguration',
'verbose_name_plural': 'Statistik-Konfigurationen',
},
),
migrations.CreateModel(
name='StatisticGroup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text="Name der Gruppe (z.B. 'Gerettet', 'Verstorben')", max_length=100, verbose_name='Gruppenname')),
('color', models.CharField(default='#28a745', help_text='Hex-Farbcode (z.B. #28a745 für Grün)', max_length=7, verbose_name='Farbe')),
('order', models.PositiveIntegerField(default=0, help_text='Bestimmt die Reihenfolge der Gruppen in den Balkendiagrammen', verbose_name='Reihenfolge')),
('is_active', models.BooleanField(default=True, help_text='Soll diese Gruppe in der Statistik angezeigt werden?', verbose_name='Aktiv')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='Erstellt am')),
('updated', models.DateTimeField(auto_now=True, verbose_name='Geändert am')),
('status_list', models.ManyToManyField(help_text='Welche Status gehören zu dieser Gruppe?', to='bird.birdstatus', verbose_name='Status')),
],
options={
'verbose_name': 'Statistik-Gruppe',
'verbose_name_plural': 'Statistik-Gruppen',
'ordering': ['order', 'name'],
},
),
]

View file

@ -0,0 +1,76 @@
# Generated by Django 5.2.4 on 2025-07-07 22:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bird', '0009_merge_20250609_2033'),
('statistic', '0001_initial'),
]
operations = [
migrations.RenameModel(
old_name='StatisticGroup',
new_name='StatisticIndividual',
),
migrations.AlterModelOptions(
name='statisticconfiguration',
options={'verbose_name': 'Statistik-Konfiguration', 'verbose_name_plural': 'Statistik-Konfiguration'},
),
migrations.AlterModelOptions(
name='statisticindividual',
options={'ordering': ['order', 'name'], 'verbose_name': 'Statistik-Individuum', 'verbose_name_plural': 'Statistik-Individuen'},
),
migrations.RemoveField(
model_name='statisticconfiguration',
name='name',
),
migrations.AddField(
model_name='statisticconfiguration',
name='show_total_patients',
field=models.BooleanField(default=True, help_text='Zeigt die Gesamtanzahl aller Patienten seit Beginn der Aufzeichnungen', verbose_name='Gesamtanzahl aller Patienten anzeigen'),
),
migrations.AddField(
model_name='statisticconfiguration',
name='show_year_total_patients',
field=models.BooleanField(default=True, help_text='Zeigt die Gesamtanzahl aller aufgenommenen Patienten des aktuellen Jahres', verbose_name='Gesamtanzahl Patienten dieses Jahr anzeigen'),
),
migrations.CreateModel(
name='StatisticTotalGroup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Name der Gruppe für die Gesamtstatistik', max_length=100, verbose_name='Gruppenname')),
('color', models.CharField(default='#28a745', help_text='Hex-Farbcode für die Anzeige', max_length=7, verbose_name='Farbe')),
('order', models.PositiveIntegerField(default=0, help_text='Bestimmt die Reihenfolge der Karten in der Gesamtübersicht', verbose_name='Reihenfolge')),
('is_active', models.BooleanField(default=True, help_text='Soll diese Gruppe in der Gesamtstatistik angezeigt werden?', verbose_name='Aktiv')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='Erstellt am')),
('updated', models.DateTimeField(auto_now=True, verbose_name='Geändert am')),
('status_list', models.ManyToManyField(help_text='Welche Status gehören zu dieser Gesamtgruppe?', to='bird.birdstatus', verbose_name='Status')),
],
options={
'verbose_name': 'Statistik-Insgesamt Gruppe',
'verbose_name_plural': 'Statistik-Insgesamt',
'ordering': ['order', 'name'],
},
),
migrations.CreateModel(
name='StatisticYearGroup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Name der Gruppe für die Jahresstatistik', max_length=100, verbose_name='Gruppenname')),
('color', models.CharField(default='#007bff', help_text='Hex-Farbcode für die Anzeige', max_length=7, verbose_name='Farbe')),
('order', models.PositiveIntegerField(default=0, help_text='Bestimmt die Reihenfolge der Karten in der Jahresübersicht', verbose_name='Reihenfolge')),
('is_active', models.BooleanField(default=True, help_text='Soll diese Gruppe in der Jahresstatistik angezeigt werden?', verbose_name='Aktiv')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='Erstellt am')),
('updated', models.DateTimeField(auto_now=True, verbose_name='Geändert am')),
('status_list', models.ManyToManyField(help_text='Welche Status gehören zu dieser Jahresgruppe?', to='bird.birdstatus', verbose_name='Status')),
],
options={
'verbose_name': 'Statistik-Jahr Gruppe',
'verbose_name_plural': 'Statistik-Jahr',
'ordering': ['order', 'name'],
},
),
]

View file

219
app/statistic/models.py Normal file
View file

@ -0,0 +1,219 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from bird.models import BirdStatus
class StatisticIndividual(models.Model):
"""
Definiert Gruppierungen von BirdStatus für die Statistik-Anzeige der Vogelarten.
Ermöglicht die flexible Konfiguration der Balkendiagramm-Kategorien.
"""
name = models.CharField(
max_length=100,
verbose_name=_("Gruppenname"),
help_text=_("Name der Gruppe (z.B. 'Gerettet', 'Verstorben')")
)
color = models.CharField(
max_length=7,
default="#28a745",
verbose_name=_("Farbe"),
help_text=_("Hex-Farbcode (z.B. #28a745 für Grün)")
)
order = models.PositiveIntegerField(
default=0,
verbose_name=_("Reihenfolge"),
help_text=_("Bestimmt die Reihenfolge der Gruppen in den Balkendiagrammen")
)
status_list = models.ManyToManyField(
BirdStatus,
verbose_name=_("Status"),
help_text=_("Welche Status gehören zu dieser Gruppe?")
)
is_active = models.BooleanField(
default=True,
verbose_name=_("Aktiv"),
help_text=_("Soll diese Gruppe in der Statistik angezeigt werden?")
)
created = models.DateTimeField(auto_now_add=True, verbose_name=_("Erstellt am"))
updated = models.DateTimeField(auto_now=True, verbose_name=_("Geändert am"))
class Meta:
verbose_name = _("Statistik-Individuum")
verbose_name_plural = _("Statistik-Individuen")
ordering = ['order', 'name']
def __str__(self):
return f"{self.name} ({self.get_status_names()})"
def get_status_names(self):
"""Gibt eine kommaseparierte Liste der Status-Namen zurück."""
return ", ".join(self.status_list.values_list('description', flat=True))
def get_css_color(self):
"""Gibt die CSS-Farbe für die Anzeige zurück."""
return self.color
class StatisticYearGroup(models.Model):
"""
Definiert Gruppierungen von BirdStatus für die Jahres-Übersichtskarten.
"""
name = models.CharField(
max_length=100,
verbose_name=_("Gruppenname"),
help_text=_("Name der Gruppe für die Jahresstatistik")
)
color = models.CharField(
max_length=7,
default="#007bff",
verbose_name=_("Farbe"),
help_text=_("Hex-Farbcode für die Anzeige")
)
order = models.PositiveIntegerField(
default=0,
verbose_name=_("Reihenfolge"),
help_text=_("Bestimmt die Reihenfolge der Karten in der Jahresübersicht")
)
status_list = models.ManyToManyField(
BirdStatus,
verbose_name=_("Status"),
help_text=_("Welche Status gehören zu dieser Jahresgruppe?")
)
is_active = models.BooleanField(
default=True,
verbose_name=_("Aktiv"),
help_text=_("Soll diese Gruppe in der Jahresstatistik angezeigt werden?")
)
created = models.DateTimeField(auto_now_add=True, verbose_name=_("Erstellt am"))
updated = models.DateTimeField(auto_now=True, verbose_name=_("Geändert am"))
class Meta:
verbose_name = _("Statistik-Jahr Gruppe")
verbose_name_plural = _("Statistik-Jahr")
ordering = ['order', 'name']
def __str__(self):
return f"{self.name} ({self.get_status_names()})"
def get_status_names(self):
"""Gibt eine kommaseparierte Liste der Status-Namen zurück."""
return ", ".join(self.status_list.values_list('description', flat=True))
class StatisticTotalGroup(models.Model):
"""
Definiert Gruppierungen von BirdStatus für die Gesamt-Übersichtskarten.
"""
name = models.CharField(
max_length=100,
verbose_name=_("Gruppenname"),
help_text=_("Name der Gruppe für die Gesamtstatistik")
)
color = models.CharField(
max_length=7,
default="#28a745",
verbose_name=_("Farbe"),
help_text=_("Hex-Farbcode für die Anzeige")
)
order = models.PositiveIntegerField(
default=0,
verbose_name=_("Reihenfolge"),
help_text=_("Bestimmt die Reihenfolge der Karten in der Gesamtübersicht")
)
status_list = models.ManyToManyField(
BirdStatus,
verbose_name=_("Status"),
help_text=_("Welche Status gehören zu dieser Gesamtgruppe?")
)
is_active = models.BooleanField(
default=True,
verbose_name=_("Aktiv"),
help_text=_("Soll diese Gruppe in der Gesamtstatistik angezeigt werden?")
)
created = models.DateTimeField(auto_now_add=True, verbose_name=_("Erstellt am"))
updated = models.DateTimeField(auto_now=True, verbose_name=_("Geändert am"))
class Meta:
verbose_name = _("Statistik-Insgesamt Gruppe")
verbose_name_plural = _("Statistik-Insgesamt")
ordering = ['order', 'name']
def __str__(self):
return f"{self.name} ({self.get_status_names()})"
def get_status_names(self):
"""Gibt eine kommaseparierte Liste der Status-Namen zurück."""
return ", ".join(self.status_list.values_list('description', flat=True))
class StatisticConfiguration(models.Model):
"""
Globale Konfiguration für die Statistik-Anzeige.
"""
# Jahresstatistik Einstellungen
show_year_total_patients = models.BooleanField(
default=True,
verbose_name=_("Gesamtanzahl Patienten dieses Jahr anzeigen"),
help_text=_("Zeigt die Gesamtanzahl aller aufgenommenen Patienten des aktuellen Jahres")
)
# Gesamtstatistik Einstellungen
show_total_patients = models.BooleanField(
default=True,
verbose_name=_("Gesamtanzahl aller Patienten anzeigen"),
help_text=_("Zeigt die Gesamtanzahl aller Patienten seit Beginn der Aufzeichnungen")
)
# Weitere Anzeige-Optionen
show_percentages = models.BooleanField(
default=True,
verbose_name=_("Prozentangaben anzeigen"),
help_text=_("Sollen Prozentangaben in den Balkendiagrammen angezeigt werden?")
)
show_absolute_numbers = models.BooleanField(
default=True,
verbose_name=_("Absolute Zahlen anzeigen"),
help_text=_("Sollen absolute Zahlen in den Balkendiagrammen angezeigt werden?")
)
is_active = models.BooleanField(
default=True,
verbose_name=_("Aktive Konfiguration"),
help_text=_("Nur eine Konfiguration kann gleichzeitig aktiv sein")
)
created = models.DateTimeField(auto_now_add=True, verbose_name=_("Erstellt am"))
updated = models.DateTimeField(auto_now=True, verbose_name=_("Geändert am"))
class Meta:
verbose_name = _("Statistik-Konfiguration")
verbose_name_plural = _("Statistik-Konfiguration")
def __str__(self):
return "Statistik Konfiguration"
def save(self, *args, **kwargs):
# Stelle sicher, dass nur eine Konfiguration aktiv ist
if self.is_active:
StatisticConfiguration.objects.filter(is_active=True).update(is_active=False)
super().save(*args, **kwargs)
# Backward Compatibility Alias (temporär für Migration)
StatisticGroup = StatisticIndividual

View file

@ -0,0 +1,662 @@
{% extends "base.html" %}
{% load static %}
{% block head_title %}Statistik - Fallen Birdy{% endblock %}
{% block header %}
<style>
.stats-card {
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
border-radius: 15px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.stats-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 15px rgba(0, 0, 0, 0.2);
}
.stats-number {
font-size: 2.5rem;
font-weight: bold;
color: #2c3e50;
}
.chart-container {
display: none; /* Entfernt, da keine Charts mehr verwendet werden */
}
.section-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 15px 20px;
border-radius: 10px;
margin: 30px 0 20px 0;
position: static;
z-index: 1;
}
.collapsible {
cursor: pointer;
user-select: none;
position: static;
display: flex;
justify-content: space-between;
align-items: center;
overflow: visible;
}
.collapsible:hover {
background: linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%);
}
.collapsible::after {
content: '▼';
font-size: 1.2rem;
font-weight: bold;
color: rgba(255, 255, 255, 0.9);
transition: transform 0.3s ease;
margin-left: 10px;
}
.collapsible.collapsed::after {
transform: rotate(-90deg);
}
.pie-chart {
width: 200px;
height: 200px;
border-radius: 50%;
margin: 0 auto;
position: relative;
display: flex;
align-items: center;
justify-content: center;
background: #f8f9fa;
border: 2px solid #dee2e6;
}
.pie-legend {
margin-top: 20px;
}
.pie-legend-item {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.pie-legend-color {
width: 16px;
height: 16px;
border-radius: 3px;
margin-right: 10px;
}
.pie-legend-text {
flex-grow: 1;
font-weight: 500;
}
.pie-legend-value {
font-weight: bold;
color: #495057;
}
#birdStatsSection, #circumstancesSection {
margin-bottom: 30px;
clear: both;
}
#birdStatsSection .card, #circumstancesSection .card {
margin-bottom: 20px;
overflow: visible;
}
/* Segmentierte Balken für Vogelarten - neues horizontales Layout */
.bird-bar {
display: flex;
margin-bottom: 1%;
align-items: center;
clear: both;
overflow: hidden;
}
.bird-bar:last-child {
border-bottom: none;
}
.bird-name {
width: 200px;
font-weight: bold;
text-align: right;
padding-right: 15px;
font-size: 0.9rem;
flex-shrink: 0;
}
.bird-bar-container {
flex-grow: 1;
height: 30px;
background: #e9ecef;
border-radius: 15px;
overflow: hidden;
position: relative;
margin: 0 15px;
box-shadow: inset 0 2px 4px rgba(0,0,0,0.1);
}
.bird-bar-segments {
height: 100%;
display: flex;
border-radius: 15px;
overflow: hidden;
}
.bird-bar-segment {
height: 100%;
transition: all 0.3s ease;
cursor: pointer;
position: relative;
min-width: 1px;
display: block;
}
.bird-bar-segment:hover {
filter: brightness(110%);
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.bird-legend {
width: 300px;
flex-shrink: 0;
font-size: 0.8rem;
}
.legend-item-small {
display: flex;
align-items: center;
margin-bottom: 3px;
line-height: 1.3;
}
.legend-color-small {
width: 12px;
height: 12px;
border-radius: 2px;
margin-right: 6px;
flex-shrink: 0;
}
.total-info {
margin-bottom: 8px;
padding-top: 5px;
border-bottom: 1px solid #dee2e6;
font-size: 0.85rem;
}
.bird-name-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.total-count {
font-size: 0.9rem;
color: #6c757d;
font-weight: normal;
}
.segmented-bar-container {
width: 100%;
height: 30px;
background: #e9ecef;
border-radius: 15px;
overflow: hidden;
position: relative;
box-shadow: inset 0 2px 4px rgba(0,0,0,0.1);
}
.segmented-bar {
height: 100%;
display: flex;
border-radius: 15px;
overflow: hidden;
min-width: 100%;
}
.bar-segment {
height: 100%;
transition: all 0.3s ease;
cursor: pointer;
position: relative;
}
.bar-segment:hover {
filter: brightness(110%);
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.bird-summary {
margin-top: 15px;
}
.summary-item {
display: flex;
align-items: center;
margin-bottom: 5px;
font-size: 0.85rem;
}
.summary-color {
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 8px;
flex-shrink: 0;
}
.summary-text {
color: #495057;
}
.legend {
display: flex;
justify-content: center;
gap: 30px;
margin: 20px 0;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
}
.legend-color {
width: 20px;
height: 20px;
border-radius: 4px;
}
/* Jahr-Navigation */
.year-navigation .btn {
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
}
.year-navigation .btn:hover:not(:disabled) {
background-color: rgba(255, 255, 255, 0.2);
transform: scale(1.1);
}
.year-navigation .btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Responsive Jahr-Navigation */
@media (max-width: 768px) {
.section-header {
flex-direction: column;
gap: 15px;
text-align: center;
}
.year-navigation {
order: 2;
}
.collapsible::after {
order: 3;
margin-left: 0;
}
}
/* CSS-Pfeile für Jahr-Navigation */
.arrow {
border: solid white;
border-width: 0 3px 3px 0;
display: inline-block;
padding: 3px;
}
.right {
transform: rotate(-45deg);
-webkit-transform: rotate(-45deg);
}
.left {
transform: rotate(135deg);
-webkit-transform: rotate(135deg);
}
/* Pfeile für disabled buttons */
.btn:disabled .arrow {
border-color: rgba(255, 255, 255, 0.5);
}
/* Pfeile für secondary buttons in Card-Header */
.btn-outline-secondary .arrow,
.btn-secondary .arrow {
border-color: #6c757d;
}
.btn-outline-secondary:hover .arrow {
border-color: white;
}
.btn-outline-secondary:disabled .arrow {
border-color: rgba(108, 117, 125, 0.5);
}
</style>
{% endblock %}
{% block content %}
<div class="main-container">
<div class="container-fluid py-4">
<div class="row">
<div class="col-12">
<h1 class="text-center mb-4">
<i class="fas fa-chart-bar"></i> Statistik Übersicht
</h1>
</div>
</div>
<!-- 1. Übersicht ausgewähltes Jahr mit Navigation -->
<div class="section-header d-flex justify-content-between align-items-center">
<h2 class="mb-0">
<i class="fas fa-calendar-alt"></i> Übersicht {{ selected_year }}
</h2>
<div class="year-navigation d-flex align-items-center">
{% if can_go_previous %}
<a href="?year={{ previous_year }}" class="btn btn-outline-light me-2" title="Vorheriges Jahr ({{ previous_year }})">
<i class="arrow left"></i>
</a>
{% else %}
<button class="btn btn-outline-light me-2" disabled title="Keine Daten vor {{ earliest_year }}">
<i class="arrow left"></i>
</button>
{% endif %}
<span class="mx-3 fw-bold">{{ selected_year }}</span>
{% if can_go_next %}
<a href="?year={{ next_year }}" class="btn btn-outline-light ms-2" title="Nächstes Jahr ({{ next_year }})">
<i class="arrow right"></i>
</a>
{% else %}
<button class="btn btn-outline-light ms-2" disabled title="Aktuelles Jahr erreicht">
<i class="arrow right"></i>
</button>
{% endif %}
{% if selected_year != current_year %}
<a href="?year={{ current_year }}" class="btn btn-light ms-3" title="Zurück zu {{ current_year }}">
<i class="fas fa-calendar-day me-1"></i>{{ current_year }}
</a>
{% endif %}
</div>
</div>
<div class="row g-4 mb-4">
<div class="col-md-6 col-lg-3">
<div class="card stats-card h-100">
<div class="card-body text-center">
<h5 class="card-title text-primary">Patienten gesamt</h5>
<div class="stats-number text-primary">{{ patients_this_year }}</div>
</div>
</div>
</div>
{% for group in year_summary %}
<div class="col-md-6 col-lg-3">
<div class="card stats-card h-100">
<div class="card-body text-center">
<h5 class="card-title" style="color: {{ group.color }};">{{ group.name }}</h5>
<div class="stats-number" style="color: {{ group.color }};">{{ group.count }}</div>
{% if config.show_percentages %}
<div class="text-muted mt-1">{{ group.percentage }}%</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
<!-- 2. Gesamtstatistik -->
{% if config.show_total_patients %}
<div class="section-header">
<h2 class="mb-0">
<i class="fas fa-globe"></i> Gesamtstatistik
</h2>
</div>
<div class="row g-4 mb-4">
<div class="col-md-6 col-lg-3">
<div class="card stats-card h-100">
<div class="card-body text-center">
<h5 class="card-title text-info">Patienten gesamt</h5>
<div class="stats-number text-info">{{ total_patients }}</div>
</div>
</div>
</div>
{% for group in total_summary %}
<div class="col-md-6 col-lg-3">
<div class="card stats-card h-100">
<div class="card-body text-center">
<h5 class="card-title" style="color: {{ group.color }};">{{ group.name }}</h5>
<div class="stats-number" style="color: {{ group.color }};">{{ group.count }}</div>
{% if config.show_percentages %}
<div class="text-muted mt-1">{{ group.percentage }}%</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- 3. Statistik pro Vogelart -->
<div class="section-header collapsible collapsed" data-bs-toggle="collapse" data-bs-target="#birdStatsSection" aria-expanded="false">
<h2 class="mb-0">
<i class="fas fa-dove"></i> Statistik pro Vogelart
</h2>
</div>
<div class="collapse" id="birdStatsSection">
{% if bird_stats %}
<div class="legend">
{% for group in statistic_individuals %}
<div class="legend-item">
<div class="legend-color" style="background: {{ group.color }};"></div>
<span>{{ group.name }}</span>
</div>
{% endfor %}
</div>
<div class="row mb-4">
<div class="col-12">
{% for bird in bird_stats %}
<div class="bird-bar">
<!-- Vogelname links -->
<div class="bird-name">
{{ bird.name }}
</div>
<!-- Balkendiagramm in der Mitte -->
<div class="bird-bar-container">
<div class="bird-bar-segments">
{% for group_data in bird.groups %}
{% if group_data.count > 0 %}
<div class="bird-bar-segment"
style="width: {{ group_data.absolute_width }}%; background-color: {{ group_data.color }};"
title="{{ group_data.name }}: {{ group_data.count }} ({{ group_data.percentage }}%)">
</div>
{% endif %}
{% endfor %}
</div>
</div>
<!-- Legende rechts -->
<div class="bird-legend">
{% for group_data in bird.groups %}
{% if group_data.count > 0 %}
<div class="legend-item-small">
<span class="legend-color-small" style="background-color: {{ group_data.color }};"></span>
{{ group_data.name }}: {{ group_data.count }} ({{ group_data.percentage }}%)
</div>
{% endif %}
{% endfor %}
<div class="total-info">
<strong>Gesamt: {{ bird.total }}</strong>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="fas fa-info-circle fa-2x mb-3"></i>
<h6>Keine Vogelarten mit Patienten gefunden</h6>
<p>Sobald Patienten erfasst werden, erscheinen hier die Statistiken pro Vogelart.</p>
</div>
{% endif %}
</div>
<!-- 2. Fundumstände (ohne Tortendiagramme) -->
<div class="section-header collapsible collapsed" data-bs-toggle="collapse" data-bs-target="#circumstancesSection" aria-expanded="false">
<h2 class="mb-0">
<i class="fas fa-map-marker-alt"></i> Fundumstände
</h2>
</div>
<div class="collapse" id="circumstancesSection">
<div class="row g-4 mb-4">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="mb-0">{{ selected_year }}</h5>
</div>
<div class="card-body">
{% if circumstances_this_year %}
<div class="pie-legend">
{% for item in circumstances_this_year %}
<div class="pie-legend-item">
<div class="pie-legend-color" style="background-color: {{ item.color }};"></div>
<div class="pie-legend-text">{{ item.name }}</div>
<div class="pie-legend-value">{{ item.count }} ({{ item.percentage }}%)</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center text-muted">
<p>Keine Daten für {{ selected_year }} verfügbar</p>
</div>
{% endif %}
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Alle Jahre</h5>
</div>
<div class="card-body">
{% if circumstances_all_time %}
<div class="pie-legend">
{% for item in circumstances_all_time %}
<div class="pie-legend-item">
<div class="pie-legend-color" style="background-color: {{ item.color }};"></div>
<div class="pie-legend-text">{{ item.name }}</div>
<div class="pie-legend-value">{{ item.count }} ({{ item.percentage }}%)</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center text-muted">
<p>Keine Daten verfügbar</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Initialize Bootstrap collapse functionality
const collapsibles = document.querySelectorAll('.collapsible');
collapsibles.forEach(function(collapsible) {
collapsible.addEventListener('click', function(e) {
// Verhindere Collapse-Toggle wenn auf Jahr-Navigation geklickt wird
if (e.target.closest('.year-navigation')) {
return;
}
this.classList.toggle('collapsed');
});
const targetId = collapsible.getAttribute('data-bs-target');
if (targetId) {
const targetElement = document.querySelector(targetId);
if (targetElement) {
targetElement.addEventListener('shown.bs.collapse', function() {
collapsible.classList.remove('collapsed');
});
targetElement.addEventListener('hidden.bs.collapse', function() {
collapsible.classList.add('collapsed');
});
}
}
});
// Tastatur-Navigation für Jahre
document.addEventListener('keydown', function(e) {
// Nur reagieren wenn kein Input-Element fokussiert ist
if (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA') {
return;
}
if (e.key === 'ArrowLeft') {
// Vorheriges Jahr
const prevButton = document.querySelector('a[href*="year={{ previous_year }}"]');
if (prevButton) {
window.location.href = prevButton.href;
}
} else if (e.key === 'ArrowRight') {
// Nächstes Jahr
const nextButton = document.querySelector('a[href*="year={{ next_year }}"]');
if (nextButton) {
window.location.href = nextButton.href;
}
} else if (e.key === 'Home') {
// Zurück zum aktuellen Jahr
const currentYearButton = document.querySelector('a[href*="year={{ current_year }}"]');
if (currentYearButton) {
window.location.href = currentYearButton.href;
} else {
// Falls schon im aktuellen Jahr, zur Statistik-Hauptseite
window.location.href = window.location.pathname;
}
}
});
});
</script>
{% endblock %}

View file

@ -0,0 +1,854 @@
{% extends "base.html" %}
{% load static %}
{% block head_title %}Statistik - Fallen Birdy{% endblock %}
{% block header %}
<style>
.stats-card {
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
border-radius: 15px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.stats-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 15px rgba(0, 0, 0, 0.2);
}
.stats-number {
font-size: 2.5rem;
font-weight: bold;
color: #2c3e50;
}
.chart-container {
position: static; /* Changed from relative */
min-height: 400px; /* Changed from fixed height to min-height */
width: 100%;
margin: 20px 0;
/* Ensure proper flow */
overflow: visible;
clear: both;
}
.bird-bar {
display: flex;
margin-bottom: 15px;
align-items: center;
/* Ensure proper clearing */
clear: both;
overflow: hidden; /* Contain floated elements */
}
.bird-name {
width: 200px;
font-weight: bold;
text-align: right;
padding-right: 15px;
font-size: 0.9rem;
flex-shrink: 0; /* Prevent shrinking */
}
.bar-container {
flex-grow: 1;
position: relative;
height: 30px;
background-color: #f8f9fa;
border-radius: 15px;
overflow: hidden;
border: 1px solid #dee2e6;
display: flex; /* Changed to flex instead of block */
align-items: stretch;
}
/* Remove the clearfix as we're using flexbox now */
.bar-container::after {
display: none;
}
.bar-rescued {
background: linear-gradient(90deg, #28a745, #20c997);
height: 100%;
position: relative;
min-width: 2px; /* Mindestbreite für Sichtbarkeit */
border-radius: 15px 0 0 15px; /* Runde nur die linke Seite */
flex: none; /* Use flex instead of float */
}
.bar-deceased {
background: linear-gradient(90deg, #dc3545, #e74c3c);
height: 100%;
position: relative;
min-width: 2px; /* Mindestbreite für Sichtbarkeit */
flex: none; /* Use flex instead of float */
}
.bar-in-treatment {
background: linear-gradient(90deg, #ffc107, #ffb300);
height: 100%;
position: relative;
min-width: 2px; /* Mindestbreite für Sichtbarkeit */
flex: none; /* Use flex instead of float */
}
/* Generic bar segment class for dynamic segments */
.bar-segment {
height: 100%;
position: relative;
min-width: 2px;
flex: none; /* Use flex instead of float */
display: flex;
align-items: center;
justify-content: center;
}
.bar-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-weight: bold;
font-size: 0.8rem;
text-shadow: 1px 1px 1px rgba(0,0,0,0.5);
}
.bar-numbers {
width: 120px;
text-align: center;
font-size: 0.9rem;
padding-left: 10px;
}
.legend {
display: flex;
justify-content: center;
gap: 30px;
margin: 20px 0;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
}
.legend-color {
width: 20px;
height: 20px;
border-radius: 4px;
}
.legend-rescued {
background: linear-gradient(90deg, #28a745, #20c997);
}
.legend-deceased {
background: linear-gradient(90deg, #dc3545, #e74c3c);
}
.section-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 15px 20px;
border-radius: 10px;
margin: 30px 0 20px 0;
/* Ensure proper stacking and flow */
position: static;
z-index: 1;
}
.collapsible {
cursor: pointer;
user-select: none;
position: static; /* Changed from relative to static */
display: flex;
justify-content: space-between;
align-items: center;
/* Ensure no overlap issues */
overflow: visible;
}
.collapsible:hover {
background: linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%);
}
.collapsible::after {
content: '▼';
font-size: 1.2rem;
font-weight: bold;
color: rgba(255, 255, 255, 0.9);
transition: transform 0.3s ease;
margin-left: 10px;
}
.collapsible.collapsed::after {
transform: rotate(-90deg);
}
/* Ensure collapse sections don't overlap */
.collapse {
position: static;
z-index: auto;
overflow: visible;
}
/* Fix for proper section spacing and flow */
.collapse .card {
margin-bottom: 20px;
position: static;
z-index: auto;
}
.main-container {
min-height: calc(100vh - 200px);
padding-bottom: 100px;
/* Ensure proper layout flow */
overflow: visible;
position: relative;
}
/* Ensure proper section spacing without overlap */
.section-header + .collapse,
.section-header + .row,
.section-header + .card {
clear: both;
margin-top: 0;
}
/* Bootstrap collapse override to ensure proper flow */
.collapse:not(.show) {
display: none !important;
}
.collapse.show {
display: block !important;
}
/* Prevent any absolute positioning issues */
.card, .card-body {
position: static;
z-index: auto;
}
/* Pie Chart Styles */
.pie-chart {
position: relative;
width: 300px;
height: 300px;
border-radius: 50%;
margin: 0 auto;
background: #f8f9fa; /* Default background */
border: 2px solid #dee2e6;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.pie-legend {
margin-top: 20px;
}
.pie-legend-item {
display: flex;
align-items: center;
margin-bottom: 8px;
font-size: 0.9rem;
}
.pie-legend-color {
width: 16px;
height: 16px;
border-radius: 4px;
margin-right: 10px;
flex-shrink: 0;
}
.pie-legend-text {
flex-grow: 1;
}
.pie-legend-value {
font-weight: bold;
margin-left: 10px;
}
/* Fallback for pie charts if conic-gradient fails */
.pie-chart-fallback {
display: none;
max-width: 300px;
margin: 0 auto;
}
.pie-chart-fallback .bar-item {
display: flex;
align-items: center;
margin-bottom: 8px;
padding: 8px;
border-radius: 4px;
background: #f8f9fa;
}
.pie-chart-fallback .bar-color {
width: 20px;
height: 20px;
border-radius: 50%;
margin-right: 10px;
flex-shrink: 0;
}
.pie-chart-fallback .bar-info {
flex-grow: 1;
display: flex;
justify-content: space-between;
align-items: center;
}
.pie-chart-fallback .bar-progress {
flex-grow: 1;
height: 8px;
background: #e9ecef;
border-radius: 4px;
margin: 0 10px;
overflow: hidden;
}
.pie-chart-fallback .bar-fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
/* Specific fixes for the bird statistics and circumstances sections */
#birdStatsSection, #circumstancesSection {
margin-bottom: 30px; /* Ensure space after each section */
clear: both;
}
#birdStatsSection .card, #circumstancesSection .card {
margin-bottom: 20px;
overflow: visible;
}
/* Fix any potential z-index issues */
.section-header.collapsible {
z-index: 1;
position: relative;
}
.collapse.show {
z-index: auto;
position: static;
}
/* Ensure footer doesn't overlap */
footer {
clear: both;
margin-top: 50px;
}
</style>
{% endblock %}
{% block content %}
<!-- Check if user is authenticated - if not, show login prompt -->
{% if not user.is_authenticated %}
<div class="alert alert-warning text-center" role="alert">
<h4>Anmeldung erforderlich</h4>
<p>Bitte melden Sie sich an, um die Statistiken zu sehen.</p>
<a href="/admin/login/?next={{ request.get_full_path }}" class="btn btn-primary">Anmelden</a>
</div>
{% else %}
<div class="main-container">
<div class="container-fluid py-4">
<div class="row">
<div class="col-12">
<h1 class="text-center mb-4">
<i class="fas fa-chart-bar"></i> Statistik Übersicht
</h1>
</div>
</div>
<!-- 1. Übersicht aktuelles Jahr -->
<div class="section-header">
<h2 class="mb-0">
<i class="fas fa-calendar-alt"></i> Übersicht {{ current_year }}
</h2>
</div>
<div class="row g-4 mb-4">
{% if config.show_year_total_patients %}
<div class="col-lg-4 col-md-6">
<div class="card stats-card h-100">
<div class="card-body text-center">
<h5 class="card-title text-primary">Aufgenommene Patienten</h5>
<div class="stats-number text-primary">{{ patients_this_year }}</div>
<p class="card-text text-muted">dieses Jahr ({{ current_year }})</p>
</div>
</div>
</div>
{% endif %}
<!-- Dynamische Karten basierend auf konfigurierten Jahres-Gruppen -->
{% for group in year_summary %}
<div class="col-lg-4 col-md-6">
<div class="card stats-card h-100">
<div class="card-body text-center">
<h5 class="card-title" style="color: {{ group.color }};">{{ group.name }}</h5>
<div class="stats-number" style="color: {{ group.color }};">{{ group.count }}</div>
<p class="card-text text-muted">dieses Jahr ({{ current_year }})</p>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- 2. Übersicht alle Jahre -->
<div class="section-header">
<h2 class="mb-0">
<i class="fas fa-globe"></i> Gesamtübersicht (alle Jahre)
</h2>
</div>
<div class="row g-4 mb-4">
{% if config.show_total_patients %}
<div class="col-lg-4 col-md-6">
<div class="card stats-card h-100">
<div class="card-body text-center">
<h5 class="card-title text-info">Patienten insgesamt</h5>
<div class="stats-number text-info">{{ total_patients }}</div>
<p class="card-text text-muted">seit Beginn der Aufzeichnungen</p>
</div>
</div>
</div>
{% endif %}
<!-- Dynamische Gesamtstatistik basierend auf konfigurierten Gesamt-Gruppen -->
{% for group in total_summary %}
<div class="col-lg-4 col-md-6">
<div class="card stats-card h-100">
<div class="card-body text-center">
<h5 class="card-title" style="color: {{ group.color }};">{{ group.name }}</h5>
<div class="stats-number" style="color: {{ group.color }};">{{ group.count }}</div>
<p class="card-text text-muted">
{% if total_patients > 0 %}
({{ group.count }}/{{ total_patients }} =
{% widthratio group.count total_patients 100 %}%)
{% endif %}
</p>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- 3. Statistik pro Vogelart -->
<div class="section-header collapsible" data-bs-toggle="collapse" data-bs-target="#birdStatsSection" aria-expanded="false">
<h2 class="mb-0">
<i class="fas fa-dove"></i> Statistik pro Vogelart
</h2>
</div>
<div class="collapse" id="birdStatsSection">
<div class="card">
<div class="card-body">
{% if bird_stats %}
<!-- Dynamische Legende basierend auf konfigurierten Individuen-Gruppen -->
<div class="legend">
{% for group in statistic_individuals %}
<div class="legend-item">
<div class="legend-color" style="background: {{ group.color }};"></div>
<span>{{ group.name }} ({{ group.get_status_names }})</span>
</div>
{% endfor %}
</div>
<div class="chart-container">
{% for bird in bird_stats %}
<div class="bird-bar">
<div class="bird-name" title="{{ bird.species }}">
{{ bird.name }}
{% if bird.species and bird.species != 'Unbekannt' %}
<br><small class="text-muted">{{ bird.species }}</small>
{% endif %}
</div>
<div class="bar-container">
<!-- Dynamische Balken basierend auf konfigurierten Gruppen -->
{% for group_data in bird.groups %}
{% if group_data.count > 0 %}
<div class="bar-segment" style="
width: {{ group_data.bar_width }}%;
background: {{ group_data.color }};
">
{% if group_data.bar_width|floatformat:0|add:0 > 15 %}
<div class="bar-text">{{ group_data.count }}</div>
{% endif %}
</div>
{% endif %}
{% endfor %}
</div>
<div class="bar-numbers">
<strong>{{ bird.total }}</strong>
<br>
{% for group_data in bird.groups %}
{% if group_data.count > 0 %}
<small style="color: {{ group_data.color }};">{{ group_data.count }}</small>{% if not forloop.last %} / {% endif %}
{% endif %}
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="fas fa-info-circle fa-3x mb-3"></i>
<h5>Keine Daten verfügbar</h5>
<p>Es wurden noch keine Patienten erfasst.</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- 4. Fundumstände -->
<div class="section-header collapsible" data-bs-toggle="collapse" data-bs-target="#circumstancesSection" aria-expanded="false">
<h2 class="mb-0">
<i class="fas fa-search-location"></i> Fundumstände
</h2>
</div>
<div class="collapse" id="circumstancesSection">
<div class="card">
<div class="card-body">
<div class="row">
<!-- Fundumstände aktuelles Jahr -->
<div class="col-lg-6">
<h4 class="text-center mb-4">
<i class="fas fa-calendar-alt"></i> {{ current_year }}
<small class="text-muted">({{ circumstances_this_year_total }} Patienten)</small>
</h4>
{% if circumstances_this_year %}
<div class="pie-chart" id="pieChartThisYear">
<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #6c757d; text-align: center; flex-direction: column;">
<i class="fas fa-chart-pie fa-3x mb-2" style="opacity: 0.3;"></i>
<small>Diagramm wird geladen...</small>
</div>
</div>
<!-- Debug: Data available with {{ circumstances_this_year|length }} items -->
<div class="pie-legend">
{% for item in circumstances_this_year %}
<div class="pie-legend-item">
<div class="pie-legend-color" style="background-color: {{ item.color }};"></div>
<div class="pie-legend-text">{{ item.name }}</div>
<div class="pie-legend-value">{{ item.count }} ({{ item.percentage }}%)</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="fas fa-info-circle fa-2x mb-3"></i>
<h6>Keine Fundumstände erfasst</h6>
<p>Für dieses Jahr wurden noch keine Fundumstände dokumentiert.</p>
</div>
{% endif %}
</div>
<!-- Fundumstände alle Jahre -->
<div class="col-lg-6">
<h4 class="text-center mb-4">
<i class="fas fa-globe"></i> Alle Jahre
<small class="text-muted">({{ circumstances_all_time_total }} Patienten)</small>
</h4>
{% if circumstances_all_time %}
<div class="pie-chart" id="pieChartAllTime">
<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #6c757d; text-align: center; flex-direction: column;">
<i class="fas fa-chart-pie fa-3x mb-2" style="opacity: 0.3;"></i>
<small>Diagramm wird geladen...</small>
</div>
</div>
<div class="pie-legend">
{% for item in circumstances_all_time %}
<div class="pie-legend-item">
<div class="pie-legend-color" style="background-color: {{ item.color }};"></div>
<div class="pie-legend-text">{{ item.name }}</div>
<div class="pie-legend-value">{{ item.count }} ({{ item.percentage }}%)</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="fas fa-info-circle fa-2x mb-3"></i>
<h6>Keine Fundumstände erfasst</h6>
<p>Es wurden noch keine Fundumstände dokumentiert.</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
</div> <!-- Close main-container -->
{% endif %} <!-- Close authentication check -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// Initialize Bootstrap collapse functionality properly
const collapsibles = document.querySelectorAll('.collapsible');
collapsibles.forEach(function(collapsible) {
// Handle the visual state change
collapsible.addEventListener('click', function() {
this.classList.toggle('collapsed');
});
// Listen to Bootstrap collapse events to ensure proper layout
const targetId = collapsible.getAttribute('data-bs-target');
if (targetId) {
const targetElement = document.querySelector(targetId);
if (targetElement) {
// When collapse is shown, remove collapsed class
targetElement.addEventListener('shown.bs.collapse', function() {
collapsible.classList.remove('collapsed');
});
// When collapse is hidden, add collapsed class
targetElement.addEventListener('hidden.bs.collapse', function() {
collapsible.classList.add('collapsed');
});
// Force layout recalculation on collapse events
targetElement.addEventListener('show.bs.collapse', function() {
// Ensure no fixed positioning interferes
setTimeout(() => {
window.dispatchEvent(new Event('resize'));
}, 50);
});
targetElement.addEventListener('hide.bs.collapse', function() {
// Ensure layout reflow
setTimeout(() => {
window.dispatchEvent(new Event('resize'));
}, 350); // After Bootstrap animation completes
});
}
}
});
// Initially set collapsed state
const birdStatsSection = document.getElementById('birdStatsSection');
const birdStatsHeader = document.querySelector('[data-bs-target="#birdStatsSection"]');
if (birdStatsSection && birdStatsHeader) {
birdStatsHeader.classList.add('collapsed');
}
const circumstancesSection = document.getElementById('circumstancesSection');
const circumstancesHeader = document.querySelector('[data-bs-target="#circumstancesSection"]');
if (circumstancesSection && circumstancesHeader) {
circumstancesHeader.classList.add('collapsed');
}
// Create pie charts using SVG (more reliable than CSS)
function createPieChart(elementId, data) {
const element = document.getElementById(elementId);
if (!element) {
console.error('Pie chart element not found:', elementId);
return;
}
if (!data || data.length === 0) {
console.warn('No data for pie chart:', elementId);
element.innerHTML = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #6c757d;">Keine Daten</div>';
return;
}
console.log('Creating SVG pie chart for:', elementId, data);
// Filter valid data
const validData = data.filter(item => item.percentage > 0).sort((a, b) => b.percentage - a.percentage);
if (validData.length === 0) {
element.innerHTML = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #6c757d;">Keine Daten</div>';
return;
}
// Create SVG
const size = 200;
const radius = 80;
const centerX = size / 2;
const centerY = size / 2;
let svg = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" style="transform: rotate(-90deg);">`;
let currentAngle = 0;
validData.forEach((item, index) => {
const angle = (item.percentage / 100) * 360;
const startAngle = currentAngle;
const endAngle = currentAngle + angle;
// Convert to radians
const startRad = (startAngle * Math.PI) / 180;
const endRad = (endAngle * Math.PI) / 180;
// Calculate path
const x1 = centerX + radius * Math.cos(startRad);
const y1 = centerY + radius * Math.sin(startRad);
const x2 = centerX + radius * Math.cos(endRad);
const y2 = centerY + radius * Math.sin(endRad);
const largeArcFlag = angle > 180 ? 1 : 0;
const pathData = [
`M ${centerX} ${centerY}`,
`L ${x1} ${y1}`,
`A ${radius} ${radius} 0 ${largeArcFlag} 1 ${x2} ${y2}`,
'Z'
].join(' ');
svg += `<path d="${pathData}" fill="${item.color}" stroke="white" stroke-width="2">`;
svg += `<title>${item.name}: ${item.percentage}%</title>`;
svg += `</path>`;
currentAngle = endAngle;
});
svg += '</svg>';
// Add percentage labels
let labelsHtml = '<div style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none;">';
currentAngle = 0;
validData.forEach((item, index) => {
if (item.percentage >= 8) { // Only show labels for significant segments
const angle = (item.percentage / 100) * 360;
const midAngle = currentAngle + (angle / 2);
const midRad = (midAngle * Math.PI) / 180;
const labelRadius = radius * 0.7;
const labelX = 50 + (labelRadius / radius) * 40 * Math.cos(midRad);
const labelY = 50 + (labelRadius / radius) * 40 * Math.sin(midRad);
labelsHtml += `<div style="
position: absolute;
left: ${labelX}%;
top: ${labelY}%;
transform: translate(-50%, -50%);
color: white;
font-weight: bold;
font-size: 12px;
text-shadow: 1px 1px 2px rgba(0,0,0,0.7);
pointer-events: none;
">${Math.round(item.percentage)}%</div>`;
}
currentAngle += (item.percentage / 100) * 360;
});
labelsHtml += '</div>';
element.style.position = 'relative';
element.innerHTML = svg + labelsHtml;
}
// Function to initialize pie charts when elements are visible (LAZY LOADING)
function initializePieCharts() {
console.log('Lazy loading pie charts - starting initialization...');
// Clear existing content and show loading state
const thisYearElement = document.getElementById('pieChartThisYear');
const allTimeElement = document.getElementById('pieChartAllTime');
if (thisYearElement) {
thisYearElement.innerHTML = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #007bff;"><i class="fas fa-spinner fa-spin fa-2x"></i></div>';
}
if (allTimeElement) {
allTimeElement.innerHTML = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #007bff;"><i class="fas fa-spinner fa-spin fa-2x"></i></div>';
}
// Small delay to show loading state, then generate charts
setTimeout(() => {
// Generate pie chart data for this year
{% if circumstances_this_year %}
const thisYearData = [
{% for item in circumstances_this_year %}
{
name: '{{ item.name|escapejs }}',
percentage: {{ item.percentage }},
color: '{{ item.color }}'
}{% if not forloop.last %},{% endif %}
{% endfor %}
];
console.log('This year data loaded:', thisYearData);
createPieChart('pieChartThisYear', thisYearData);
{% else %}
if (thisYearElement) {
thisYearElement.innerHTML = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #6c757d;">Keine Daten für dieses Jahr</div>';
}
{% endif %}
// Generate pie chart data for all time
{% if circumstances_all_time %}
const allTimeData = [
{% for item in circumstances_all_time %}
{
name: '{{ item.name|escapejs }}',
percentage: {{ item.percentage }},
color: '{{ item.color }}'
}{% if not forloop.last %},{% endif %}
{% endfor %}
];
console.log('All time data loaded:', allTimeData);
createPieChart('pieChartAllTime', allTimeData);
{% else %}
if (allTimeElement) {
allTimeElement.innerHTML = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #6c757d;">Keine Daten verfügbar</div>';
}
{% endif %}
}, 300);
}
// Set initial placeholder content for pie charts (before lazy loading)
function setPlaceholderContent() {
const thisYearElement = document.getElementById('pieChartThisYear');
const allTimeElement = document.getElementById('pieChartAllTime');
const placeholderHtml = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #6c757d;"><i class="fas fa-chart-pie fa-2x"></i><span style="margin-left: 10px;">Klicken Sie auf "Fundumstände" um die Diagramme zu laden</span></div>';
if (thisYearElement) {
thisYearElement.innerHTML = placeholderHtml;
}
if (allTimeElement) {
allTimeElement.innerHTML = placeholderHtml;
}
}
// Initialize placeholders first
setPlaceholderContent();
// Initialize pie charts ONLY when the circumstances section becomes visible (LAZY LOADING)
const circumstancesSection = document.getElementById('circumstancesSection');
if (circumstancesSection) {
let chartsInitialized = false;
// Listen for when the section is shown
circumstancesSection.addEventListener('shown.bs.collapse', function() {
console.log('Circumstances section expanded - initializing charts');
if (!chartsInitialized) {
initializePieCharts();
chartsInitialized = true;
}
});
// Check if section is already visible on page load
if (circumstancesSection.classList.contains('show')) {
console.log('Circumstances section already visible - initializing charts');
setTimeout(() => {
initializePieCharts();
chartsInitialized = true;
}, 500);
}
} else {
console.error('Circumstances section not found');
}
});
</script>
{% endif %} <!-- Close authentication check -->
{% endblock %}

View file

@ -0,0 +1,388 @@
{% extends "base.html" %}
{% load static %}
{% block head_title %}Statistik - Fallen Birdy{% endblock %}
{% block header %}
<style>
.stats-card {
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
border-radius: 15px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.stats-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 15px rgba(0, 0, 0, 0.2);
}
.stats-number {
font-size: 2.5rem;
font-weight: bold;
color: #2c3e50;
}
.chart-container {
position: static;
min-height: 400px;
width: 100%;
margin: 20px 0;
overflow: visible;
clear: both;
}
.section-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 15px 20px;
border-radius: 10px;
margin: 30px 0 20px 0;
position: static;
z-index: 1;
}
.collapsible {
cursor: pointer;
user-select: none;
position: static;
display: flex;
justify-content: space-between;
align-items: center;
overflow: visible;
}
.collapsible:hover {
background: linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%);
}
.collapsible::after {
content: '▼';
font-size: 1.2rem;
font-weight: bold;
color: rgba(255, 255, 255, 0.9);
transition: transform 0.3s ease;
margin-left: 10px;
}
.collapsible.collapsed::after {
transform: rotate(-90deg);
}
.pie-chart {
width: 200px;
height: 200px;
border-radius: 50%;
margin: 0 auto;
position: relative;
display: flex;
align-items: center;
justify-content: center;
background: #f8f9fa;
border: 2px solid #dee2e6;
}
.pie-legend {
margin-top: 20px;
}
.pie-legend-item {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.pie-legend-color {
width: 16px;
height: 16px;
border-radius: 3px;
margin-right: 10px;
}
.pie-legend-text {
flex-grow: 1;
font-weight: 500;
}
.pie-legend-value {
font-weight: bold;
color: #495057;
}
#birdStatsSection, #circumstancesSection {
margin-bottom: 30px;
clear: both;
}
#birdStatsSection .card, #circumstancesSection .card {
margin-bottom: 20px;
overflow: visible;
}
</style>
{% endblock %}
{% block content %}
<div class="main-container">
<div class="container-fluid py-4">
<div class="row">
<div class="col-12">
<h1 class="text-center mb-4">
<i class="fas fa-chart-bar"></i> Statistik Übersicht
</h1>
</div>
</div>
<!-- 1. Übersicht aktuelles Jahr -->
<div class="section-header">
<h2 class="mb-0">
<i class="fas fa-calendar-alt"></i> Übersicht {{ current_year }}
</h2>
</div>
<div class="row g-4 mb-4">
<div class="col-md-6 col-lg-3">
<div class="card stats-card h-100">
<div class="card-body text-center">
<h5 class="card-title text-primary">Patienten gesamt</h5>
<div class="stats-number text-primary">{{ patients_this_year }}</div>
</div>
</div>
</div>
{% for group in year_groups %}
<div class="col-md-6 col-lg-3">
<div class="card stats-card h-100">
<div class="card-body text-center">
<h5 class="card-title" style="color: {{ group.color }};">{{ group.name }}</h5>
<div class="stats-number" style="color: {{ group.color }};">{{ group.count }}</div>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- 2. Fundumstände (Lazy loaded pie charts) -->
<div class="section-header collapsible collapsed" data-bs-toggle="collapse" data-bs-target="#circumstancesSection" aria-expanded="false">
<h2 class="mb-0">
<i class="fas fa-map-marker-alt"></i> Fundumstände
</h2>
</div>
<div class="collapse" id="circumstancesSection">
<div class="row g-4 mb-4">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="mb-0">{{ current_year }}</h5>
</div>
<div class="card-body">
<div class="chart-container">
<div class="pie-chart" id="pieChartThisYear">
<!-- Lazy loading placeholder -->
<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #6c757d;">
<i class="fas fa-chart-pie fa-2x"></i>
<span style="margin-left: 10px;">Wird geladen...</span>
</div>
</div>
</div>
{% if circumstances_this_year %}
<div class="pie-legend">
{% for item in circumstances_this_year %}
<div class="pie-legend-item">
<div class="pie-legend-color" style="background-color: {{ item.color }};"></div>
<div class="pie-legend-text">{{ item.name }}</div>
<div class="pie-legend-value">{{ item.count }} ({{ item.percentage }}%)</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Alle Jahre</h5>
</div>
<div class="card-body">
<div class="chart-container">
<div class="pie-chart" id="pieChartAllTime">
<!-- Lazy loading placeholder -->
<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #6c757d;">
<i class="fas fa-chart-pie fa-2x"></i>
<span style="margin-left: 10px;">Wird geladen...</span>
</div>
</div>
</div>
{% if circumstances_all_time %}
<div class="pie-legend">
{% for item in circumstances_all_time %}
<div class="pie-legend-item">
<div class="pie-legend-color" style="background-color: {{ item.color }};"></div>
<div class="pie-legend-text">{{ item.name }}</div>
<div class="pie-legend-value">{{ item.count }} ({{ item.percentage }}%)</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Initialize Bootstrap collapse functionality
const collapsibles = document.querySelectorAll('.collapsible');
collapsibles.forEach(function(collapsible) {
collapsible.addEventListener('click', function() {
this.classList.toggle('collapsed');
});
const targetId = collapsible.getAttribute('data-bs-target');
if (targetId) {
const targetElement = document.querySelector(targetId);
if (targetElement) {
targetElement.addEventListener('shown.bs.collapse', function() {
collapsible.classList.remove('collapsed');
});
targetElement.addEventListener('hidden.bs.collapse', function() {
collapsible.classList.add('collapsed');
});
}
}
});
// Initialize collapsed state
const circumstancesSection = document.getElementById('circumstancesSection');
const circumstancesHeader = document.querySelector('[data-bs-target="#circumstancesSection"]');
if (circumstancesSection && circumstancesHeader) {
circumstancesHeader.classList.add('collapsed');
}
// SVG Pie Chart Creator
function createPieChart(elementId, data) {
const element = document.getElementById(elementId);
if (!element) {
console.error('Pie chart element not found:', elementId);
return;
}
if (!data || data.length === 0) {
element.innerHTML = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #6c757d;">Keine Daten</div>';
return;
}
console.log('Creating SVG pie chart for:', elementId, data);
const validData = data.filter(item => item.percentage > 0).sort((a, b) => b.percentage - a.percentage);
if (validData.length === 0) {
element.innerHTML = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #6c757d;">Keine Daten</div>';
return;
}
const size = 200;
const radius = 80;
const centerX = size / 2;
const centerY = size / 2;
let svg = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" style="transform: rotate(-90deg);">`;
let currentAngle = 0;
validData.forEach((item, index) => {
const angle = (item.percentage / 100) * 360;
const startAngle = currentAngle;
const endAngle = currentAngle + angle;
const startRad = (startAngle * Math.PI) / 180;
const endRad = (endAngle * Math.PI) / 180;
const x1 = centerX + radius * Math.cos(startRad);
const y1 = centerY + radius * Math.sin(startRad);
const x2 = centerX + radius * Math.cos(endRad);
const y2 = centerY + radius * Math.sin(endRad);
const largeArcFlag = angle > 180 ? 1 : 0;
const pathData = [
`M ${centerX} ${centerY}`,
`L ${x1} ${y1}`,
`A ${radius} ${radius} 0 ${largeArcFlag} 1 ${x2} ${y2}`,
'Z'
].join(' ');
svg += `<path d="${pathData}" fill="${item.color}" stroke="white" stroke-width="2">`;
svg += `<title>${item.name}: ${item.percentage}%</title>`;
svg += `</path>`;
currentAngle = endAngle;
});
svg += '</svg>';
element.style.position = 'relative';
element.innerHTML = svg;
}
// Lazy loading function
function initializePieCharts() {
console.log('Initializing pie charts...');
// Generate pie chart data for this year
{% if circumstances_this_year %}
const thisYearData = [
{% for item in circumstances_this_year %}
{
name: '{{ item.name|escapejs }}',
percentage: {{ item.percentage }},
color: '{{ item.color }}'
}{% if not forloop.last %},{% endif %}
{% endfor %}
];
console.log('This year data:', thisYearData);
createPieChart('pieChartThisYear', thisYearData);
{% endif %}
// Generate pie chart data for all time
{% if circumstances_all_time %}
const allTimeData = [
{% for item in circumstances_all_time %}
{
name: '{{ item.name|escapejs }}',
percentage: {{ item.percentage }},
color: '{{ item.color }}'
}{% if not forloop.last %},{% endif %}
{% endfor %}
];
console.log('All time data:', allTimeData);
createPieChart('pieChartAllTime', allTimeData);
{% endif %}
}
// LAZY LOADING: Initialize pie charts only when circumstances section is expanded
if (circumstancesSection) {
let chartsInitialized = false;
circumstancesSection.addEventListener('shown.bs.collapse', function() {
console.log('Circumstances section expanded - lazy loading charts');
if (!chartsInitialized) {
setTimeout(initializePieCharts, 100);
chartsInitialized = true;
}
});
} else {
console.error('Circumstances section not found');
}
});
</script>
{% endblock %}

View file

@ -0,0 +1,855 @@
{% extends "base.html" %}
{% load static %}
{% block head_title %}Statistik - Fallen Birdy{% endblock %}
{% block header %}
<style>
.stats-card {
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
border-radius: 15px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.stats-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 15px rgba(0, 0, 0, 0.2);
}
.stats-number {
font-size: 2.5rem;
font-weight: bold;
color: #2c3e50;
}
.chart-container {
position: static; /* Changed from relative */
min-height: 400px; /* Changed from fixed height to min-height */
width: 100%;
margin: 20px 0;
/* Ensure proper flow */
overflow: visible;
clear: both;
}
.bird-bar {
display: flex;
margin-bottom: 15px;
align-items: center;
/* Ensure proper clearing */
clear: both;
overflow: hidden; /* Contain floated elements */
}
.bird-name {
width: 200px;
font-weight: bold;
text-align: right;
padding-right: 15px;
font-size: 0.9rem;
flex-shrink: 0; /* Prevent shrinking */
}
.bar-container {
flex-grow: 1;
position: relative;
height: 30px;
background-color: #f8f9fa;
border-radius: 15px;
overflow: hidden;
border: 1px solid #dee2e6;
display: flex; /* Changed to flex instead of block */
align-items: stretch;
}
/* Remove the clearfix as we're using flexbox now */
.bar-container::after {
display: none;
}
.bar-rescued {
background: linear-gradient(90deg, #28a745, #20c997);
height: 100%;
position: relative;
min-width: 2px; /* Mindestbreite für Sichtbarkeit */
border-radius: 15px 0 0 15px; /* Runde nur die linke Seite */
flex: none; /* Use flex instead of float */
}
.bar-deceased {
background: linear-gradient(90deg, #dc3545, #e74c3c);
height: 100%;
position: relative;
min-width: 2px; /* Mindestbreite für Sichtbarkeit */
flex: none; /* Use flex instead of float */
}
.bar-in-treatment {
background: linear-gradient(90deg, #ffc107, #ffb300);
height: 100%;
position: relative;
min-width: 2px; /* Mindestbreite für Sichtbarkeit */
flex: none; /* Use flex instead of float */
}
/* Generic bar segment class for dynamic segments */
.bar-segment {
height: 100%;
position: relative;
min-width: 2px;
flex: none; /* Use flex instead of float */
display: flex;
align-items: center;
justify-content: center;
}
.bar-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-weight: bold;
font-size: 0.8rem;
text-shadow: 1px 1px 1px rgba(0,0,0,0.5);
}
.bar-numbers {
width: 120px;
text-align: center;
font-size: 0.9rem;
padding-left: 10px;
}
.legend {
display: flex;
justify-content: center;
gap: 30px;
margin: 20px 0;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
}
.legend-color {
width: 20px;
height: 20px;
border-radius: 4px;
}
.legend-rescued {
background: linear-gradient(90deg, #28a745, #20c997);
}
.legend-deceased {
background: linear-gradient(90deg, #dc3545, #e74c3c);
}
.section-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 15px 20px;
border-radius: 10px;
margin: 30px 0 20px 0;
/* Ensure proper stacking and flow */
position: static;
z-index: 1;
}
.collapsible {
cursor: pointer;
user-select: none;
position: static; /* Changed from relative to static */
display: flex;
justify-content: space-between;
align-items: center;
/* Ensure no overlap issues */
overflow: visible;
}
.collapsible:hover {
background: linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%);
}
.collapsible::after {
content: '▼';
font-size: 1.2rem;
font-weight: bold;
color: rgba(255, 255, 255, 0.9);
transition: transform 0.3s ease;
margin-left: 10px;
}
.collapsible.collapsed::after {
transform: rotate(-90deg);
}
/* Ensure collapse sections don't overlap */
.collapse {
position: static;
z-index: auto;
overflow: visible;
}
/* Fix for proper section spacing and flow */
.collapse .card {
margin-bottom: 20px;
position: static;
z-index: auto;
}
.main-container {
min-height: calc(100vh - 200px);
padding-bottom: 100px;
/* Ensure proper layout flow */
overflow: visible;
position: relative;
}
/* Ensure proper section spacing without overlap */
.section-header + .collapse,
.section-header + .row,
.section-header + .card {
clear: both;
margin-top: 0;
}
/* Bootstrap collapse override to ensure proper flow */
.collapse:not(.show) {
display: none !important;
}
.collapse.show {
display: block !important;
}
/* Prevent any absolute positioning issues */
.card, .card-body {
position: static;
z-index: auto;
}
/* Pie Chart Styles */
.pie-chart {
position: relative;
width: 300px;
height: 300px;
border-radius: 50%;
margin: 0 auto;
background: #f8f9fa; /* Default background */
border: 2px solid #dee2e6;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.pie-legend {
margin-top: 20px;
}
.pie-legend-item {
display: flex;
align-items: center;
margin-bottom: 8px;
font-size: 0.9rem;
}
.pie-legend-color {
width: 16px;
height: 16px;
border-radius: 4px;
margin-right: 10px;
flex-shrink: 0;
}
.pie-legend-text {
flex-grow: 1;
}
.pie-legend-value {
font-weight: bold;
margin-left: 10px;
}
/* Fallback for pie charts if conic-gradient fails */
.pie-chart-fallback {
display: none;
max-width: 300px;
margin: 0 auto;
}
.pie-chart-fallback .bar-item {
display: flex;
align-items: center;
margin-bottom: 8px;
padding: 8px;
border-radius: 4px;
background: #f8f9fa;
}
.pie-chart-fallback .bar-color {
width: 20px;
height: 20px;
border-radius: 50%;
margin-right: 10px;
flex-shrink: 0;
}
.pie-chart-fallback .bar-info {
flex-grow: 1;
display: flex;
justify-content: space-between;
align-items: center;
}
.pie-chart-fallback .bar-progress {
flex-grow: 1;
height: 8px;
background: #e9ecef;
border-radius: 4px;
margin: 0 10px;
overflow: hidden;
}
.pie-chart-fallback .bar-fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
/* Specific fixes for the bird statistics and circumstances sections */
#birdStatsSection, #circumstancesSection {
margin-bottom: 30px; /* Ensure space after each section */
clear: both;
}
#birdStatsSection .card, #circumstancesSection .card {
margin-bottom: 20px;
overflow: visible;
}
/* Fix any potential z-index issues */
.section-header.collapsible {
z-index: 1;
position: relative;
}
.collapse.show {
z-index: auto;
position: static;
}
/* Ensure footer doesn't overlap */
footer {
clear: both;
margin-top: 50px;
}
</style>
{% endblock %}
{% block content %}
<!-- Check if user is authenticated - if not, show login prompt -->
{% if not user.is_authenticated %}
<div class="alert alert-warning text-center" role="alert">
<h4>Anmeldung erforderlich</h4>
<p>Bitte melden Sie sich an, um die Statistiken zu sehen.</p>
<a href="/admin/login/?next={{ request.get_full_path }}" class="btn btn-primary">Anmelden</a>
</div>
{% else %}
<div class="main-container">
<div class="container-fluid py-4">
<div class="row">
<div class="col-12">
<h1 class="text-center mb-4">
<i class="fas fa-chart-bar"></i> Statistik Übersicht
</h1>
</div>
</div>
<!-- 1. Übersicht aktuelles Jahr -->
<div class="section-header">
<h2 class="mb-0">
<i class="fas fa-calendar-alt"></i> Übersicht {{ current_year }}
</h2>
</div>
<div class="row g-4 mb-4">
{% if config.show_year_total_patients %}
<div class="col-lg-4 col-md-6">
<div class="card stats-card h-100">
<div class="card-body text-center">
<h5 class="card-title text-primary">Aufgenommene Patienten</h5>
<div class="stats-number text-primary">{{ patients_this_year }}</div>
<p class="card-text text-muted">dieses Jahr ({{ current_year }})</p>
</div>
</div>
</div>
{% endif %}
<!-- Dynamische Karten basierend auf konfigurierten Jahres-Gruppen -->
{% for group in year_summary %}
<div class="col-lg-4 col-md-6">
<div class="card stats-card h-100">
<div class="card-body text-center">
<h5 class="card-title" style="color: {{ group.color }};">{{ group.name }}</h5>
<div class="stats-number" style="color: {{ group.color }};">{{ group.count }}</div>
<p class="card-text text-muted">dieses Jahr ({{ current_year }})</p>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- 2. Übersicht alle Jahre -->
<div class="section-header">
<h2 class="mb-0">
<i class="fas fa-globe"></i> Gesamtübersicht (alle Jahre)
</h2>
</div>
<div class="row g-4 mb-4">
{% if config.show_total_patients %}
<div class="col-lg-4 col-md-6">
<div class="card stats-card h-100">
<div class="card-body text-center">
<h5 class="card-title text-info">Patienten insgesamt</h5>
<div class="stats-number text-info">{{ total_patients }}</div>
<p class="card-text text-muted">seit Beginn der Aufzeichnungen</p>
</div>
</div>
</div>
{% endif %}
<!-- Dynamische Gesamtstatistik basierend auf konfigurierten Gesamt-Gruppen -->
{% for group in total_summary %}
<div class="col-lg-4 col-md-6">
<div class="card stats-card h-100">
<div class="card-body text-center">
<h5 class="card-title" style="color: {{ group.color }};">{{ group.name }}</h5>
<div class="stats-number" style="color: {{ group.color }};">{{ group.count }}</div>
<p class="card-text text-muted">
{% if total_patients > 0 %}
({{ group.count }}/{{ total_patients }} =
{% widthratio group.count total_patients 100 %}%)
{% endif %}
</p>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- 3. Statistik pro Vogelart -->
<div class="section-header collapsible" data-bs-toggle="collapse" data-bs-target="#birdStatsSection" aria-expanded="false">
<h2 class="mb-0">
<i class="fas fa-dove"></i> Statistik pro Vogelart
</h2>
</div>
<div class="collapse" id="birdStatsSection">
<div class="card">
<div class="card-body">
{% if bird_stats %}
<!-- Dynamische Legende basierend auf konfigurierten Individuen-Gruppen -->
<div class="legend">
{% for group in statistic_individuals %}
<div class="legend-item">
<div class="legend-color" style="background: {{ group.color }};"></div>
<span>{{ group.name }} ({{ group.get_status_names }})</span>
</div>
{% endfor %}
</div>
<div class="chart-container">
{% for bird in bird_stats %}
<div class="bird-bar">
<div class="bird-name" title="{{ bird.species }}">
{{ bird.name }}
{% if bird.species and bird.species != 'Unbekannt' %}
<br><small class="text-muted">{{ bird.species }}</small>
{% endif %}
</div>
<div class="bar-container">
<!-- Dynamische Balken basierend auf konfigurierten Gruppen -->
{% for group_data in bird.groups %}
{% if group_data.count > 0 %}
<div class="bar-segment" style="
width: {{ group_data.bar_width }}%;
background: {{ group_data.color }};
">
{% if group_data.bar_width|floatformat:0|add:0 > 15 %}
<div class="bar-text">{{ group_data.count }}</div>
{% endif %}
</div>
{% endif %}
{% endfor %}
</div>
<div class="bar-numbers">
<strong>{{ bird.total }}</strong>
<br>
{% for group_data in bird.groups %}
{% if group_data.count > 0 %}
<small style="color: {{ group_data.color }};">{{ group_data.count }}</small>{% if not forloop.last %} / {% endif %}
{% endif %}
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="fas fa-info-circle fa-3x mb-3"></i>
<h5>Keine Daten verfügbar</h5>
<p>Es wurden noch keine Patienten erfasst.</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- 4. Fundumstände -->
<div class="section-header collapsible" data-bs-toggle="collapse" data-bs-target="#circumstancesSection" aria-expanded="false">
<h2 class="mb-0">
<i class="fas fa-search-location"></i> Fundumstände
</h2>
</div>
<div class="collapse" id="circumstancesSection">
<div class="card">
<div class="card-body">
<div class="row">
<!-- Fundumstände aktuelles Jahr -->
<div class="col-lg-6">
<h4 class="text-center mb-4">
<i class="fas fa-calendar-alt"></i> {{ current_year }}
<small class="text-muted">({{ circumstances_this_year_total }} Patienten)</small>
</h4>
{% if circumstances_this_year %}
<div class="pie-chart" id="pieChartThisYear">
<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #6c757d; text-align: center; flex-direction: column;">
<i class="fas fa-chart-pie fa-3x mb-2" style="opacity: 0.3;"></i>
<small>Diagramm wird geladen...</small>
</div>
</div>
<!-- Debug: Data available with {{ circumstances_this_year|length }} items -->
<div class="pie-legend">
{% for item in circumstances_this_year %}
<div class="pie-legend-item">
<div class="pie-legend-color" style="background-color: {{ item.color }};"></div>
<div class="pie-legend-text">{{ item.name }}</div>
<div class="pie-legend-value">{{ item.count }} ({{ item.percentage }}%)</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="fas fa-info-circle fa-2x mb-3"></i>
<h6>Keine Fundumstände erfasst</h6>
<p>Für dieses Jahr wurden noch keine Fundumstände dokumentiert.</p>
</div>
{% endif %}
</div>
<!-- Fundumstände alle Jahre -->
<div class="col-lg-6">
<h4 class="text-center mb-4">
<i class="fas fa-globe"></i> Alle Jahre
<small class="text-muted">({{ circumstances_all_time_total }} Patienten)</small>
</h4>
{% if circumstances_all_time %}
<div class="pie-chart" id="pieChartAllTime">
<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #6c757d; text-align: center; flex-direction: column;">
<i class="fas fa-chart-pie fa-3x mb-2" style="opacity: 0.3;"></i>
<small>Diagramm wird geladen...</small>
</div>
</div>
<div class="pie-legend">
{% for item in circumstances_all_time %}
<div class="pie-legend-item">
<div class="pie-legend-color" style="background-color: {{ item.color }};"></div>
<div class="pie-legend-text">{{ item.name }}</div>
<div class="pie-legend-value">{{ item.count }} ({{ item.percentage }}%)</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="fas fa-info-circle fa-2x mb-3"></i>
<h6>Keine Fundumstände erfasst</h6>
<p>Es wurden noch keine Fundumstände dokumentiert.</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
</div> <!-- Close main-container -->
{% endif %} <!-- Close authentication check -->
<script>
document.addEventListener('DOMContentLoaded', function() {
// Initialize Bootstrap collapse functionality properly
const collapsibles = document.querySelectorAll('.collapsible');
collapsibles.forEach(function(collapsible) {
// Handle the visual state change
collapsible.addEventListener('click', function() {
this.classList.toggle('collapsed');
});
// Listen to Bootstrap collapse events to ensure proper layout
const targetId = collapsible.getAttribute('data-bs-target');
if (targetId) {
const targetElement = document.querySelector(targetId);
if (targetElement) {
// When collapse is shown, remove collapsed class
targetElement.addEventListener('shown.bs.collapse', function() {
collapsible.classList.remove('collapsed');
});
// When collapse is hidden, add collapsed class
targetElement.addEventListener('hidden.bs.collapse', function() {
collapsible.classList.add('collapsed');
});
// Force layout recalculation on collapse events
targetElement.addEventListener('show.bs.collapse', function() {
// Ensure no fixed positioning interferes
setTimeout(() => {
window.dispatchEvent(new Event('resize'));
}, 50);
});
targetElement.addEventListener('hide.bs.collapse', function() {
// Ensure layout reflow
setTimeout(() => {
window.dispatchEvent(new Event('resize'));
}, 350); // After Bootstrap animation completes
});
}
}
});
// Initially set collapsed state
const birdStatsSection = document.getElementById('birdStatsSection');
const birdStatsHeader = document.querySelector('[data-bs-target="#birdStatsSection"]');
if (birdStatsSection && birdStatsHeader) {
birdStatsHeader.classList.add('collapsed');
}
const circumstancesSection = document.getElementById('circumstancesSection');
const circumstancesHeader = document.querySelector('[data-bs-target="#circumstancesSection"]');
if (circumstancesSection && circumstancesHeader) {
circumstancesHeader.classList.add('collapsed');
}
// Create pie charts using SVG (more reliable than CSS)
function createPieChart(elementId, data) {
const element = document.getElementById(elementId);
if (!element) {
console.error('Pie chart element not found:', elementId);
return;
}
if (!data || data.length === 0) {
console.warn('No data for pie chart:', elementId);
element.innerHTML = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #6c757d;">Keine Daten</div>';
return;
}
console.log('Creating SVG pie chart for:', elementId, data);
// Filter valid data
const validData = data.filter(item => item.percentage > 0).sort((a, b) => b.percentage - a.percentage);
if (validData.length === 0) {
element.innerHTML = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #6c757d;">Keine Daten</div>';
return;
}
// Create SVG
const size = 200;
const radius = 80;
const centerX = size / 2;
const centerY = size / 2;
let svg = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" style="transform: rotate(-90deg);">`;
let currentAngle = 0;
validData.forEach((item, index) => {
const angle = (item.percentage / 100) * 360;
const startAngle = currentAngle;
const endAngle = currentAngle + angle;
// Convert to radians
const startRad = (startAngle * Math.PI) / 180;
const endRad = (endAngle * Math.PI) / 180;
// Calculate path
const x1 = centerX + radius * Math.cos(startRad);
const y1 = centerY + radius * Math.sin(startRad);
const x2 = centerX + radius * Math.cos(endRad);
const y2 = centerY + radius * Math.sin(endRad);
const largeArcFlag = angle > 180 ? 1 : 0;
const pathData = [
`M ${centerX} ${centerY}`,
`L ${x1} ${y1}`,
`A ${radius} ${radius} 0 ${largeArcFlag} 1 ${x2} ${y2}`,
'Z'
].join(' ');
svg += `<path d="${pathData}" fill="${item.color}" stroke="white" stroke-width="2">`;
svg += `<title>${item.name}: ${item.percentage}%</title>`;
svg += `</path>`;
currentAngle = endAngle;
});
svg += '</svg>';
// Add percentage labels
let labelsHtml = '<div style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none;">';
currentAngle = 0;
validData.forEach((item, index) => {
if (item.percentage >= 8) { // Only show labels for significant segments
const angle = (item.percentage / 100) * 360;
const midAngle = currentAngle + (angle / 2);
const midRad = (midAngle * Math.PI) / 180;
const labelRadius = radius * 0.7;
const labelX = 50 + (labelRadius / radius) * 40 * Math.cos(midRad);
const labelY = 50 + (labelRadius / radius) * 40 * Math.sin(midRad);
labelsHtml += `<div style="
position: absolute;
left: ${labelX}%;
top: ${labelY}%;
transform: translate(-50%, -50%);
color: white;
font-weight: bold;
font-size: 12px;
text-shadow: 1px 1px 2px rgba(0,0,0,0.7);
pointer-events: none;
">${Math.round(item.percentage)}%</div>`;
}
currentAngle += (item.percentage / 100) * 360;
});
labelsHtml += '</div>';
element.style.position = 'relative';
element.innerHTML = svg + labelsHtml;
}
// Function to initialize pie charts when elements are visible (LAZY LOADING)
function initializePieCharts() {
console.log('Lazy loading pie charts - starting initialization...');
// Clear existing content and show loading state
const thisYearElement = document.getElementById('pieChartThisYear');
const allTimeElement = document.getElementById('pieChartAllTime');
if (thisYearElement) {
thisYearElement.innerHTML = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #007bff;"><i class="fas fa-spinner fa-spin fa-2x"></i></div>';
}
if (allTimeElement) {
allTimeElement.innerHTML = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #007bff;"><i class="fas fa-spinner fa-spin fa-2x"></i></div>';
}
// Small delay to show loading state, then generate charts
setTimeout(() => {
// Generate pie chart data for this year
{% if circumstances_this_year %}
const thisYearData = [
{% for item in circumstances_this_year %}
{
name: '{{ item.name|escapejs }}',
percentage: {{ item.percentage }},
color: '{{ item.color }}'
}{% if not forloop.last %},{% endif %}
{% endfor %}
];
console.log('This year data loaded:', thisYearData);
createPieChart('pieChartThisYear', thisYearData);
{% else %}
if (thisYearElement) {
thisYearElement.innerHTML = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #6c757d;">Keine Daten für dieses Jahr</div>';
}
{% endif %}
// Generate pie chart data for all time
{% if circumstances_all_time %}
const allTimeData = [
{% for item in circumstances_all_time %}
{
name: '{{ item.name|escapejs }}',
percentage: {{ item.percentage }},
color: '{{ item.color }}'
}{% if not forloop.last %},{% endif %}
{% endfor %}
];
console.log('All time data loaded:', allTimeData);
createPieChart('pieChartAllTime', allTimeData);
{% else %}
if (allTimeElement) {
allTimeElement.innerHTML = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #6c757d;">Keine Daten verfügbar</div>';
}
{% endif %}
}, 300);
}
// Set initial placeholder content for pie charts (before lazy loading)
function setPlaceholderContent() {
const thisYearElement = document.getElementById('pieChartThisYear');
const allTimeElement = document.getElementById('pieChartAllTime');
const placeholderHtml = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #6c757d;"><i class="fas fa-chart-pie fa-2x"></i><span style="margin-left: 10px;">Klicken Sie auf "Fundumstände" um die Diagramme zu laden</span></div>';
if (thisYearElement) {
thisYearElement.innerHTML = placeholderHtml;
}
if (allTimeElement) {
allTimeElement.innerHTML = placeholderHtml;
}
}
// Initialize placeholders first
setPlaceholderContent();
// Initialize pie charts ONLY when the circumstances section becomes visible (LAZY LOADING)
const circumstancesSection = document.getElementById('circumstancesSection');
if (circumstancesSection) {
let chartsInitialized = false;
// Listen for when the section is shown
circumstancesSection.addEventListener('shown.bs.collapse', function() {
console.log('Circumstances section expanded - initializing charts');
if (!chartsInitialized) {
initializePieCharts();
chartsInitialized = true;
}
});
// Check if section is already visible on page load
if (circumstancesSection.classList.contains('show')) {
console.log('Circumstances section already visible - initializing charts');
setTimeout(() => {
initializePieCharts();
chartsInitialized = true;
}, 500);
}
} else {
console.error('Circumstances section not found');
}
});
</script>
</div> <!-- Close main-container -->
{% endif %} <!-- Close authentication check -->
{% endblock %}

View file

@ -0,0 +1,388 @@
{% extends "base.html" %}
{% load static %}
{% block head_title %}Statistik - Fallen Birdy{% endblock %}
{% block header %}
<style>
.stats-card {
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
border-radius: 15px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.stats-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 15px rgba(0, 0, 0, 0.2);
}
.stats-number {
font-size: 2.5rem;
font-weight: bold;
color: #2c3e50;
}
.chart-container {
position: static;
min-height: 400px;
width: 100%;
margin: 20px 0;
overflow: visible;
clear: both;
}
.section-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 15px 20px;
border-radius: 10px;
margin: 30px 0 20px 0;
position: static;
z-index: 1;
}
.collapsible {
cursor: pointer;
user-select: none;
position: static;
display: flex;
justify-content: space-between;
align-items: center;
overflow: visible;
}
.collapsible:hover {
background: linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%);
}
.collapsible::after {
content: '▼';
font-size: 1.2rem;
font-weight: bold;
color: rgba(255, 255, 255, 0.9);
transition: transform 0.3s ease;
margin-left: 10px;
}
.collapsible.collapsed::after {
transform: rotate(-90deg);
}
.pie-chart {
width: 200px;
height: 200px;
border-radius: 50%;
margin: 0 auto;
position: relative;
display: flex;
align-items: center;
justify-content: center;
background: #f8f9fa;
border: 2px solid #dee2e6;
}
.pie-legend {
margin-top: 20px;
}
.pie-legend-item {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.pie-legend-color {
width: 16px;
height: 16px;
border-radius: 3px;
margin-right: 10px;
}
.pie-legend-text {
flex-grow: 1;
font-weight: 500;
}
.pie-legend-value {
font-weight: bold;
color: #495057;
}
#birdStatsSection, #circumstancesSection {
margin-bottom: 30px;
clear: both;
}
#birdStatsSection .card, #circumstancesSection .card {
margin-bottom: 20px;
overflow: visible;
}
</style>
{% endblock %}
{% block content %}
<div class="main-container">
<div class="container-fluid py-4">
<div class="row">
<div class="col-12">
<h1 class="text-center mb-4">
<i class="fas fa-chart-bar"></i> Statistik Übersicht
</h1>
</div>
</div>
<!-- 1. Übersicht aktuelles Jahr -->
<div class="section-header">
<h2 class="mb-0">
<i class="fas fa-calendar-alt"></i> Übersicht {{ current_year }}
</h2>
</div>
<div class="row g-4 mb-4">
<div class="col-md-6 col-lg-3">
<div class="card stats-card h-100">
<div class="card-body text-center">
<h5 class="card-title text-primary">Patienten gesamt</h5>
<div class="stats-number text-primary">{{ patients_this_year }}</div>
</div>
</div>
</div>
{% for group in year_groups %}
<div class="col-md-6 col-lg-3">
<div class="card stats-card h-100">
<div class="card-body text-center">
<h5 class="card-title" style="color: {{ group.color }};">{{ group.name }}</h5>
<div class="stats-number" style="color: {{ group.color }};">{{ group.count }}</div>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- 2. Fundumstände (Lazy loaded pie charts) -->
<div class="section-header collapsible collapsed" data-bs-toggle="collapse" data-bs-target="#circumstancesSection" aria-expanded="false">
<h2 class="mb-0">
<i class="fas fa-map-marker-alt"></i> Fundumstände
</h2>
</div>
<div class="collapse" id="circumstancesSection">
<div class="row g-4 mb-4">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="mb-0">{{ current_year }}</h5>
</div>
<div class="card-body">
<div class="chart-container">
<div class="pie-chart" id="pieChartThisYear">
<!-- Lazy loading placeholder -->
<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #6c757d;">
<i class="fas fa-chart-pie fa-2x"></i>
<span style="margin-left: 10px;">Wird geladen...</span>
</div>
</div>
</div>
{% if circumstances_this_year %}
<div class="pie-legend">
{% for item in circumstances_this_year %}
<div class="pie-legend-item">
<div class="pie-legend-color" style="background-color: {{ item.color }};"></div>
<div class="pie-legend-text">{{ item.name }}</div>
<div class="pie-legend-value">{{ item.count }} ({{ item.percentage }}%)</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Alle Jahre</h5>
</div>
<div class="card-body">
<div class="chart-container">
<div class="pie-chart" id="pieChartAllTime">
<!-- Lazy loading placeholder -->
<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #6c757d;">
<i class="fas fa-chart-pie fa-2x"></i>
<span style="margin-left: 10px;">Wird geladen...</span>
</div>
</div>
</div>
{% if circumstances_all_time %}
<div class="pie-legend">
{% for item in circumstances_all_time %}
<div class="pie-legend-item">
<div class="pie-legend-color" style="background-color: {{ item.color }};"></div>
<div class="pie-legend-text">{{ item.name }}</div>
<div class="pie-legend-value">{{ item.count }} ({{ item.percentage }}%)</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Initialize Bootstrap collapse functionality
const collapsibles = document.querySelectorAll('.collapsible');
collapsibles.forEach(function(collapsible) {
collapsible.addEventListener('click', function() {
this.classList.toggle('collapsed');
});
const targetId = collapsible.getAttribute('data-bs-target');
if (targetId) {
const targetElement = document.querySelector(targetId);
if (targetElement) {
targetElement.addEventListener('shown.bs.collapse', function() {
collapsible.classList.remove('collapsed');
});
targetElement.addEventListener('hidden.bs.collapse', function() {
collapsible.classList.add('collapsed');
});
}
}
});
// Initialize collapsed state
const circumstancesSection = document.getElementById('circumstancesSection');
const circumstancesHeader = document.querySelector('[data-bs-target="#circumstancesSection"]');
if (circumstancesSection && circumstancesHeader) {
circumstancesHeader.classList.add('collapsed');
}
// SVG Pie Chart Creator
function createPieChart(elementId, data) {
const element = document.getElementById(elementId);
if (!element) {
console.error('Pie chart element not found:', elementId);
return;
}
if (!data || data.length === 0) {
element.innerHTML = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #6c757d;">Keine Daten</div>';
return;
}
console.log('Creating SVG pie chart for:', elementId, data);
const validData = data.filter(item => item.percentage > 0).sort((a, b) => b.percentage - a.percentage);
if (validData.length === 0) {
element.innerHTML = '<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #6c757d;">Keine Daten</div>';
return;
}
const size = 200;
const radius = 80;
const centerX = size / 2;
const centerY = size / 2;
let svg = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" style="transform: rotate(-90deg);">`;
let currentAngle = 0;
validData.forEach((item, index) => {
const angle = (item.percentage / 100) * 360;
const startAngle = currentAngle;
const endAngle = currentAngle + angle;
const startRad = (startAngle * Math.PI) / 180;
const endRad = (endAngle * Math.PI) / 180;
const x1 = centerX + radius * Math.cos(startRad);
const y1 = centerY + radius * Math.sin(startRad);
const x2 = centerX + radius * Math.cos(endRad);
const y2 = centerY + radius * Math.sin(endRad);
const largeArcFlag = angle > 180 ? 1 : 0;
const pathData = [
`M ${centerX} ${centerY}`,
`L ${x1} ${y1}`,
`A ${radius} ${radius} 0 ${largeArcFlag} 1 ${x2} ${y2}`,
'Z'
].join(' ');
svg += `<path d="${pathData}" fill="${item.color}" stroke="white" stroke-width="2">`;
svg += `<title>${item.name}: ${item.percentage}%</title>`;
svg += `</path>`;
currentAngle = endAngle;
});
svg += '</svg>';
element.style.position = 'relative';
element.innerHTML = svg;
}
// Lazy loading function
function initializePieCharts() {
console.log('Initializing pie charts...');
// Generate pie chart data for this year
{% if circumstances_this_year %}
const thisYearData = [
{% for item in circumstances_this_year %}
{
name: '{{ item.name|escapejs }}',
percentage: {{ item.percentage }},
color: '{{ item.color }}'
}{% if not forloop.last %},{% endif %}
{% endfor %}
];
console.log('This year data:', thisYearData);
createPieChart('pieChartThisYear', thisYearData);
{% endif %}
// Generate pie chart data for all time
{% if circumstances_all_time %}
const allTimeData = [
{% for item in circumstances_all_time %}
{
name: '{{ item.name|escapejs }}',
percentage: {{ item.percentage }},
color: '{{ item.color }}'
}{% if not forloop.last %},{% endif %}
{% endfor %}
];
console.log('All time data:', allTimeData);
createPieChart('pieChartAllTime', allTimeData);
{% endif %}
}
// LAZY LOADING: Initialize pie charts only when circumstances section is expanded
if (circumstancesSection) {
let chartsInitialized = false;
circumstancesSection.addEventListener('shown.bs.collapse', function() {
console.log('Circumstances section expanded - lazy loading charts');
if (!chartsInitialized) {
setTimeout(initializePieCharts, 100);
chartsInitialized = true;
}
});
} else {
console.error('Circumstances section not found');
}
});
</script>
{% endblock %}

3
app/statistic/tests.py Normal file
View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

8
app/statistic/urls.py Normal file
View file

@ -0,0 +1,8 @@
from django.urls import path
from . import views
app_name = 'statistic'
urlpatterns = [
path('', views.StatisticView.as_view(), name='overview'),
]

214
app/statistic/views.py Normal file
View file

@ -0,0 +1,214 @@
from django.views.generic import TemplateView
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Count, Q, Min
from django.utils import timezone
from datetime import datetime
from bird.models import FallenBird, Bird, BirdStatus, Circumstance
from .models import StatisticIndividual, StatisticYearGroup, StatisticTotalGroup, StatisticConfiguration
class StatisticView(LoginRequiredMixin, TemplateView):
template_name = 'statistic/overview.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Jahr aus URL-Parameter oder aktuelles Jahr
selected_year = self.request.GET.get('year', timezone.now().year)
try:
selected_year = int(selected_year)
except (ValueError, TypeError):
selected_year = timezone.now().year
current_year = timezone.now().year
# Stelle sicher, dass das ausgewählte Jahr nicht in der Zukunft liegt
if selected_year > current_year:
selected_year = current_year
# Finde das früheste Jahr mit Daten
earliest_year_with_data = FallenBird.objects.aggregate(
earliest=Min('date_found__year')
)['earliest']
if earliest_year_with_data is None:
earliest_year_with_data = current_year
# Stelle sicher, dass das ausgewählte Jahr nicht vor dem frühesten Jahr liegt
if selected_year < earliest_year_with_data:
selected_year = earliest_year_with_data
# Lade aktive Konfiguration
try:
config = StatisticConfiguration.objects.get(is_active=True)
except StatisticConfiguration.DoesNotExist:
# Fallback: Erstelle Standard-Konfiguration wenn keine vorhanden
config = StatisticConfiguration.objects.create(
is_active=True,
show_year_total_patients=True,
show_total_patients=True,
show_percentages=True,
show_absolute_numbers=True
)
context['config'] = config
context['current_year'] = current_year
context['selected_year'] = selected_year
context['earliest_year'] = earliest_year_with_data
context['can_go_previous'] = selected_year > earliest_year_with_data
context['can_go_next'] = selected_year < current_year
context['previous_year'] = selected_year - 1 if context['can_go_previous'] else None
context['next_year'] = selected_year + 1 if context['can_go_next'] else None
# 1. Jahresstatistik
if config.show_year_total_patients:
patients_this_year = FallenBird.objects.filter(
date_found__year=selected_year
).count()
context['patients_this_year'] = patients_this_year
# Lade aktive Jahres-Gruppen und berechne Statistiken
year_groups = StatisticYearGroup.objects.filter(is_active=True).order_by('order')
year_summary = []
for group in year_groups:
status_ids = list(group.status_list.values_list('id', flat=True))
year_count = FallenBird.objects.filter(
date_found__year=selected_year,
status__id__in=status_ids
).count()
# Berechne Prozentanteil
year_percentage = (year_count / patients_this_year * 100) if patients_this_year > 0 else 0
year_summary.append({
'name': group.name,
'count': year_count,
'percentage': round(year_percentage, 1),
'color': group.color,
'order': group.order
})
context['year_summary'] = year_summary
# 2. Gesamtstatistik
if config.show_total_patients:
total_patients = FallenBird.objects.count()
context['total_patients'] = total_patients
# Lade aktive Gesamt-Gruppen und berechne Statistiken
total_groups = StatisticTotalGroup.objects.filter(is_active=True).order_by('order')
total_summary = []
for group in total_groups:
status_ids = list(group.status_list.values_list('id', flat=True))
total_count = FallenBird.objects.filter(
status__id__in=status_ids
).count()
# Berechne Prozentanteil
total_percentage = (total_count / total_patients * 100) if total_patients > 0 else 0
total_summary.append({
'name': group.name,
'count': total_count,
'percentage': round(total_percentage, 1),
'color': group.color,
'order': group.order
})
context['total_summary'] = total_summary
# 3. Statistik pro Vogelart (Individuen - dynamisch basierend auf Konfiguration)
individual_groups = StatisticIndividual.objects.filter(is_active=True).order_by('order')
context['statistic_individuals'] = individual_groups
bird_stats = []
for bird in Bird.objects.all():
fallen_birds = FallenBird.objects.filter(bird=bird)
total_count = fallen_birds.count()
if total_count > 0: # Nur Vögel anzeigen, die auch Patienten haben
bird_data = {
'name': bird.name,
'species': bird.species or 'Unbekannt',
'total': total_count,
'groups': []
}
# Berechne Statistiken für jede konfigurierte Individuen-Gruppe
for group in individual_groups:
status_ids = list(group.status_list.values_list('id', flat=True))
group_count = fallen_birds.filter(status__id__in=status_ids).count()
group_percentage = (group_count / total_count) * 100 if total_count > 0 else 0
bird_data['groups'].append({
'name': group.name,
'color': group.color,
'count': group_count,
'percentage': round(group_percentage, 1),
'order': group.order
})
bird_stats.append(bird_data)
# Sortiere nach Gesamtanzahl (absteigend)
bird_stats.sort(key=lambda x: x['total'], reverse=True)
# Berechne Balkenbreiten basierend auf der höchsten Vogelart für bessere Visualisierung
if bird_stats:
max_count = bird_stats[0]['total'] # Höchste Anzahl (wird 100% der Balkenbreite)
for bird in bird_stats:
# Berechne Balkenbreite für Gesamtanzahl (proportional zur häufigsten Art)
total_bar_width = (bird['total'] / max_count) * 100 if max_count > 0 else 0
bird['total_bar_width'] = f"{total_bar_width:.1f}".replace(',', '.')
# Berechne absolute Segmentbreiten (bezogen auf die gesamte verfügbare Container-Breite)
for group_data in bird['groups']:
# Absolute Breite = (Anteil dieser Gruppe / 100) * Gesamtbalkenbreite
absolute_width = (group_data['percentage'] / 100) * total_bar_width
group_data['absolute_width'] = f"{absolute_width:.1f}".replace(',', '.')
context['bird_stats'] = bird_stats
# 4. Fundumstände-Statistiken
# Fundumstände für ausgewähltes Jahr
circumstances_this_year = FallenBird.objects.filter(
date_found__year=selected_year,
find_circumstances__isnull=False
).values('find_circumstances__name', 'find_circumstances__description').annotate(
count=Count('id')
).order_by('-count')
# Fundumstände für alle Jahre
circumstances_all_time = FallenBird.objects.filter(
find_circumstances__isnull=False
).values('find_circumstances__name', 'find_circumstances__description').annotate(
count=Count('id')
).order_by('-count')
# Formatiere Daten für Tortendiagramme
def format_circumstances_data(circumstances_data):
total = sum(item['count'] for item in circumstances_data)
formatted_data = []
colors = [
'#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF',
'#FF9F40', '#FF6384', '#C9CBCF', '#4BC0C0', '#FF6384'
]
for i, item in enumerate(circumstances_data):
name = item['find_circumstances__name'] or item['find_circumstances__description']
percentage = round((item['count'] / total) * 100, 1) if total > 0 else 0
formatted_data.append({
'name': name,
'count': item['count'],
'percentage': percentage,
'color': colors[i % len(colors)]
})
return formatted_data, total
context['circumstances_this_year'], context['circumstances_this_year_total'] = format_circumstances_data(circumstances_this_year)
context['circumstances_all_time'], context['circumstances_all_time_total'] = format_circumstances_data(circumstances_all_time)
return context

97
app/statistik/README.md Normal file
View file

@ -0,0 +1,97 @@
# Statistik App
Die Statistik-App bietet umfassende Übersichten über die Patientendaten in der FBF (Fallen Birdy) Anwendung.
## 📊 Funktionen
### 1. Übersicht aktuelles Jahr
- **Aufgenommene Patienten**: Anzahl der neu aufgenommenen Patienten im aktuellen Jahr
- **In Behandlung/Auswilderung**: Aktuell aktive Fälle (Status: "In Behandlung" oder "In Auswilderung")
- **Gerettete Tiere**: Erfolgreich behandelte Patienten (Status: "Ausgewildert" oder "Übermittelt")
### 2. Gesamtübersicht (alle Jahre)
- **Patienten insgesamt**: Gesamtanzahl aller jemals erfassten Patienten
- **Erfolgreiche Rettungen**: Gesamtanzahl geretteter Tiere mit Erfolgsquote in Prozent
### 3. Statistik pro Vogelart (aufklappbar)
- **Interaktives Balkendiagramm** mit zweifarbigen Balken:
- 🟢 **Grün**: Gerettete Vögel (ausgewildert + übermittelt)
- 🔴 **Rot**: Verstorbene Vögel
- **Detaillierte Zahlen** an jedem Balken
- **Sortierung** nach Gesamtanzahl der Patienten (absteigend)
- **Zusatzinformationen**: Lateinischer Artname (falls verfügbar)
## 🎨 Design-Features
- **Responsive Design**: Optimiert für Desktop, Tablet und Mobile
- **Animierte Karten**: Hover-Effekte und sanfte Übergänge
- **Farbkodierung**: Intuitive Farben für verschiedene Statuskategorien
- **Aufklappbare Bereiche**: Übersichtliche Darstellung großer Datenmengen
- **Bootstrap 5**: Moderne, konsistente Benutzeroberfläche
## 🔧 Technische Details
### Datenmodell
Die Statistiken basieren auf folgenden Modellen:
- `FallenBird`: Patientendaten mit Status und Funddatum
- `Bird`: Vogelarten/Bezeichnungen
- `BirdStatus`: Status-Definitionen (In Behandlung, Ausgewildert, etc.)
### Status-Kategorien
1. **In Behandlung** (ID: 1) - Aktive Patienten
2. **In Auswilderung** (ID: 2) - Vorbereitung zur Entlassung
3. **Ausgewildert** (ID: 3) - Erfolgreich freigelassen
4. **Übermittelt** (ID: 4) - An andere Einrichtungen weitergegeben
5. **Verstorben** (ID: 5) - Nicht gerettete Patienten
### View-Logik
```python
# Beispiel für Jahresstatistik
patients_this_year = FallenBird.objects.filter(
date_found__year=current_year
).count()
# Beispiel für Erfolgsrate
rescued_count = FallenBird.objects.filter(
status__id__in=[3, 4] # Ausgewildert, Übermittelt
).count()
```
## 📍 Navigation
Die Statistik-App ist in der Hauptnavigation zwischen **"Volieren"** und **"Kosten"** positioniert.
**URL**: `/statistik/`
## 🔍 Datenanalyse
### Aktueller Datenstand (Beispiel)
- **Gesamte Patienten**: 1.267
- **Vogelarten**: 112 verschiedene Arten
- **Dieses Jahr (2025)**: 393 neue Patienten
- **Erfolgsquote**: ~62% (780 von 1.267 gerettet)
### Status-Verteilung
- In Behandlung: 143 Patienten
- Ausgewildert: 683 Patienten
- Übermittelt: 97 Patienten
- Verstorben: 344 Patienten
## 🎯 Zukünftige Erweiterungen
Mögliche weitere Features:
- **Zeitreihen-Diagramme**: Entwicklung über mehrere Jahre
- **Monatsstatistiken**: Saisonale Verteilungen
- **Fundort-Analyse**: Geografische Statistiken
- **Kosten-Integration**: Behandlungskosten pro Art
- **Export-Funktionen**: PDF/Excel-Reports
- **Interaktive Charts**: D3.js oder Chart.js Integration
## 📱 Responsive Verhalten
- **Desktop**: Drei-spaltige Kartenlayouts
- **Tablet**: Zwei-spaltige Anordnung
- **Mobile**: Ein-spaltige Darstellung
- **Balkendiagramm**: Automatische Anpassung der Beschriftungen
Die Statistik-App bietet eine umfassende, benutzerfreundliche Übersicht über alle wichtigen Kennzahlen der Wildvogel-Rettungsstation.

5
app/statistik/admin.py Normal file
View file

@ -0,0 +1,5 @@
from django.contrib import admin
# Da die Statistik-App keine eigenen Modelle hat,
# ist keine Admin-Registrierung erforderlich.
# Die Statistik ist über die normale Web-Oberfläche unter /statistik/ zugänglich.

7
app/statistik/apps.py Normal file
View file

@ -0,0 +1,7 @@
from django.apps import AppConfig
class StatistikConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'statistik'
verbose_name = 'Statistik'

View file

@ -0,0 +1,311 @@
{% extends "base.html" %}
{% load static %}
{% block head_title %}Statistik - Fallen Birdy{% endblock %}
{% block header %}
<style>
.stats-card {
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
border-radius: 15px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.stats-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 15px rgba(0, 0, 0, 0.2);
}
.stats-number {
font-size: 2.5rem;
font-weight: bold;
color: #2c3e50;
}
.chart-container {
position: relative;
height: 400px;
width: 100%;
margin: 20px 0;
}
.bird-bar {
display: flex;
margin-bottom: 1%;
align-items: center;
}
.bird-name {
width: 200px;
font-weight: bold;
text-align: right;
padding-right: 15px;
font-size: 0.9rem;
}
.bar-container {
flex-grow: 1;
position: relative;
height: 30px;
background-color: #f8f9fa;
border-radius: 15px;
overflow: hidden;
border: 1px solid #dee2e6;
}
.bar-rescued {
background: linear-gradient(90deg, #28a745, #20c997);
height: 100%;
float: left;
position: relative;
}
.bar-deceased {
background: linear-gradient(90deg, #dc3545, #e74c3c);
height: 100%;
float: left;
position: relative;
}
.bar-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-weight: bold;
font-size: 0.8rem;
text-shadow: 1px 1px 1px rgba(0,0,0,0.5);
}
.bar-numbers {
width: 120px;
text-align: center;
font-size: 0.9rem;
padding-left: 10px;
}
.legend {
display: flex;
justify-content: center;
gap: 30px;
margin: 20px 0;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
}
.legend-color {
width: 20px;
height: 20px;
border-radius: 4px;
}
.legend-rescued {
background: linear-gradient(90deg, #28a745, #20c997);
}
.legend-deceased {
background: linear-gradient(90deg, #dc3545, #e74c3c);
}
.section-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 15px 20px;
border-radius: 10px;
margin: 30px 0 20px 0;
}
.collapsible {
cursor: pointer;
user-select: none;
}
.collapsible:hover {
background: linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%);
}
.collapsible::after {
content: ' ▼';
float: right;
transition: transform 0.3s ease;
}
.collapsible.collapsed::after {
transform: rotate(-90deg);
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="row">
<div class="col-12">
<h1 class="text-center mb-4">
<i class="fas fa-chart-bar"></i> Statistik Übersicht
</h1>
</div>
</div>
<!-- 1. Übersicht aktuelles Jahr -->
<div class="section-header">
<h2 class="mb-0">
<i class="fas fa-calendar-alt"></i> Übersicht {{ current_year }}
</h2>
</div>
<div class="row g-4 mb-4">
<div class="col-lg-4 col-md-6">
<div class="card stats-card h-100">
<div class="card-body text-center">
<h5 class="card-title text-primary">Aufgenommene Patienten</h5>
<div class="stats-number text-primary">{{ patients_this_year }}</div>
<p class="card-text text-muted">dieses Jahr ({{ current_year }})</p>
</div>
</div>
</div>
<div class="col-lg-4 col-md-6">
<div class="card stats-card h-100">
<div class="card-body text-center">
<h5 class="card-title text-warning">In Behandlung / Auswilderung</h5>
<div class="stats-number text-warning">{{ in_treatment_or_release }}</div>
<p class="card-text text-muted">aktuell aktive Fälle</p>
</div>
</div>
</div>
<div class="col-lg-4 col-md-6">
<div class="card stats-card h-100">
<div class="card-body text-center">
<h5 class="card-title text-success">Gerettete Tiere</h5>
<div class="stats-number text-success">{{ rescued_this_year }}</div>
<p class="card-text text-muted">ausgewildert & übermittelt</p>
</div>
</div>
</div>
</div>
<!-- 2. Übersicht alle Jahre -->
<div class="section-header">
<h2 class="mb-0">
<i class="fas fa-globe"></i> Gesamtübersicht (alle Jahre)
</h2>
</div>
<div class="row g-4 mb-4">
<div class="col-lg-6 col-md-6">
<div class="card stats-card h-100">
<div class="card-body text-center">
<h5 class="card-title text-info">Patienten insgesamt</h5>
<div class="stats-number text-info">{{ total_patients }}</div>
<p class="card-text text-muted">seit Beginn der Aufzeichnungen</p>
</div>
</div>
</div>
<div class="col-lg-6 col-md-6">
<div class="card stats-card h-100">
<div class="card-body text-center">
<h5 class="card-title text-success">Erfolgreiche Rettungen</h5>
<div class="stats-number text-success">{{ total_rescued }}</div>
<p class="card-text text-muted">
{% if total_patients > 0 %}
({{ total_rescued|floatformat:0 }}/{{ total_patients }} =
{% widthratio total_rescued total_patients 100 %}%)
{% endif %}
</p>
</div>
</div>
</div>
</div>
<!-- 3. Statistik pro Vogelart -->
<div class="section-header collapsible" data-bs-toggle="collapse" data-bs-target="#birdStatsSection" aria-expanded="false">
<h2 class="mb-0">
<i class="fas fa-dove"></i> Statistik pro Vogelart
</h2>
</div>
<div class="collapse" id="birdStatsSection">
<div class="card">
<div class="card-body">
{% if bird_stats %}
<div class="legend">
<div class="legend-item">
<div class="legend-color legend-rescued"></div>
<span>Gerettet (ausgewildert + übermittelt)</span>
</div>
<div class="legend-item">
<div class="legend-color legend-deceased"></div>
<span>Verstorben</span>
</div>
</div>
<div class="chart-container">
{% for bird in bird_stats %}
<div class="bird-bar">
<div class="bird-name" title="{{ bird.species }}">
{{ bird.name }}
{% if bird.species and bird.species != 'Unbekannt' %}
<br><small class="text-muted">{{ bird.species }}</small>
{% endif %}
</div>
<div class="bar-container">
{% if bird.rescued > 0 %}
<div class="bar-rescued" style="width: {{ bird.rescued_percentage }}%;">
{% if bird.rescued_percentage > 15 %}
<div class="bar-text">{{ bird.rescued }}</div>
{% endif %}
</div>
{% endif %}
{% if bird.deceased > 0 %}
<div class="bar-deceased" style="width: {{ bird.deceased_percentage }}%;">
{% if bird.deceased_percentage > 15 %}
<div class="bar-text">{{ bird.deceased }}</div>
{% endif %}
</div>
{% endif %}
</div>
<div class="bar-numbers">
<strong>{{ bird.total }}</strong>
<br>
<small class="text-success">{{ bird.rescued }}</small> /
<small class="text-danger">{{ bird.deceased }}</small>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="fas fa-info-circle fa-3x mb-3"></i>
<h5>Keine Daten verfügbar</h5>
<p>Es wurden noch keine Patienten erfasst.</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Collapsible functionality
const collapsibles = document.querySelectorAll('.collapsible');
collapsibles.forEach(function(collapsible) {
collapsible.addEventListener('click', function() {
this.classList.toggle('collapsed');
});
});
// Initially set collapsed state
const birdStatsSection = document.getElementById('birdStatsSection');
const birdStatsHeader = document.querySelector('[data-bs-target="#birdStatsSection"]');
if (birdStatsSection && birdStatsHeader) {
birdStatsHeader.classList.add('collapsed');
}
});
</script>
{% endblock %}

8
app/statistik/urls.py Normal file
View file

@ -0,0 +1,8 @@
from django.urls import path
from . import views
app_name = 'statistik'
urlpatterns = [
path('', views.StatistikView.as_view(), name='overview'),
]

85
app/statistik/views.py Normal file
View file

@ -0,0 +1,85 @@
from django.views.generic import TemplateView
from django.db.models import Count, Q
from django.utils import timezone
from datetime import datetime
from bird.models import FallenBird, Bird, BirdStatus
class StatistikView(TemplateView):
template_name = 'statistik/overview.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Aktuelles Jahr
current_year = timezone.now().year
# 1. Übersicht über das aktuelle Jahr
context['current_year'] = current_year
# Patienten dieses Jahr aufgenommen
patients_this_year = FallenBird.objects.filter(
date_found__year=current_year
).count()
context['patients_this_year'] = patients_this_year
# Aktuell in Behandlung oder Auswilderung
in_treatment_or_release = FallenBird.objects.filter(
date_found__year=current_year,
status__id__in=[1, 2] # In Behandlung, In Auswilderung
).count()
context['in_treatment_or_release'] = in_treatment_or_release
# Ausgewildert + Übermittelt dieses Jahr
rescued_this_year = FallenBird.objects.filter(
date_found__year=current_year,
status__id__in=[3, 4] # Ausgewildert, Übermittelt
).count()
context['rescued_this_year'] = rescued_this_year
# 2. Übersicht über alle Jahre
total_patients = FallenBird.objects.count()
context['total_patients'] = total_patients
total_rescued = FallenBird.objects.filter(
status__id__in=[3, 4] # Ausgewildert, Übermittelt
).count()
context['total_rescued'] = total_rescued
# 3. Statistik pro Vogelart
bird_stats = []
for bird in Bird.objects.all():
fallen_birds = FallenBird.objects.filter(bird=bird)
total_count = fallen_birds.count()
rescued_count = fallen_birds.filter(status__id__in=[3, 4]).count()
deceased_count = fallen_birds.filter(status__id=5).count()
if total_count > 0: # Nur Vögel anzeigen, die auch Patienten haben
bird_stats.append({
'name': bird.name,
'species': bird.species or 'Unbekannt',
'total': total_count,
'rescued': rescued_count,
'deceased': deceased_count,
'rescued_percentage': round((rescued_count / total_count) * 100, 1) if total_count > 0 else 0,
'deceased_percentage': round((deceased_count / total_count) * 100, 1) if total_count > 0 else 0
})
# Sortiere nach Gesamtanzahl (absteigend)
bird_stats.sort(key=lambda x: x['total'], reverse=True)
context['bird_stats'] = bird_stats
# Status-Namen für das Template
try:
context['status_names'] = {
1: BirdStatus.objects.get(id=1).description,
2: BirdStatus.objects.get(id=2).description,
3: BirdStatus.objects.get(id=3).description,
4: BirdStatus.objects.get(id=4).description,
5: BirdStatus.objects.get(id=5).description,
}
except BirdStatus.DoesNotExist:
context['status_names'] = {}
return context

View file

@ -1,7 +1,7 @@
{% if user.is_authenticated %} {% if user.is_authenticated %}
<div class="container-lg"> <div class="container-lg">
<footer class="footer d-flex flex-wrap justify-content-between align-items-center py-3 my-4 border-top"> <footer class="footer d-flex flex-wrap justify-content-between align-items-center py-3 my-4 border-top">
<p class="col-md-4 mb-0 text-muted">Version: 1.0 © 2024 fbf </p> <p class="col-md-4 mb-0 text-muted">Version: 2.0 © 2025 fbf </p>
<ul class="nav col-md-5 justify-content-end"> <ul class="nav col-md-5 justify-content-end">
<li class="nav-item"> <li class="nav-item">
<a class="nav-link px-2 text-muted" href="{% url 'bird_all' %}">Home</a> <a class="nav-link px-2 text-muted" href="{% url 'bird_all' %}">Home</a>

View file

@ -28,6 +28,10 @@
<a class="nav-link {% if '/aviary/all' in request.path %} active {% endif %}" <a class="nav-link {% if '/aviary/all' in request.path %} active {% endif %}"
href="{% url 'aviary_all' %}">Volieren</a> href="{% url 'aviary_all' %}">Volieren</a>
</li> </li>
<li class="nav-item">
<a class="nav-link {% if '/statistics' in request.path %} active {% endif %}"
href="{% url 'statistic:overview' %}">Statistik</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if '/costs/all' in request.path %} active {% endif %}" <a class="nav-link {% if '/costs/all' in request.path %} active {% endif %}"
href="{% url 'costs_all' %}">Kosten</a> href="{% url 'costs_all' %}">Kosten</a>

View file

@ -10,6 +10,8 @@ services:
python manage.py runserver 0.0.0.0:8000' python manage.py runserver 0.0.0.0:8000'
volumes: volumes:
- ./app:/app - ./app:/app
ports:
- "8000:8000"
expose: expose:
- 8000 - 8000
environment: environment:

0
migrate_live_data.sh Normal file
View file