first init statistics
This commit is contained in:
parent
8b0d78f25e
commit
ab11148521
22 changed files with 3227 additions and 2 deletions
|
@ -43,7 +43,7 @@ SECURE_HSTS_PRELOAD = True
|
|||
# -----------------------------------
|
||||
# Allowed Hosts
|
||||
# -----------------------------------
|
||||
ALLOWED_HOSTS = [env("ALLOWED_HOSTS")]
|
||||
ALLOWED_HOSTS = env("ALLOWED_HOSTS").split(",") if env("ALLOWED_HOSTS") else []
|
||||
|
||||
# -----------------------------------
|
||||
# Application definition
|
||||
|
@ -84,6 +84,7 @@ INSTALLED_APPS = [
|
|||
"bird",
|
||||
"contact",
|
||||
"costs",
|
||||
"statistic",
|
||||
"export",
|
||||
"notizen",
|
||||
"reports",
|
||||
|
|
|
@ -11,6 +11,7 @@ urlpatterns = [
|
|||
path("bird/", include("bird.urls")),
|
||||
path("contacts/", include("contact.urls")),
|
||||
path("costs/", include("costs.urls")),
|
||||
path("statistics/", include("statistic.urls")),
|
||||
path("export/", include("export.urls")),
|
||||
path("notizen/", include("notizen.urls")),
|
||||
# Admin
|
||||
|
|
72
app/static/admin/css/statistic_admin.css
Normal file
72
app/static/admin/css/statistic_admin.css
Normal 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;
|
||||
}
|
||||
}
|
210
app/static/admin/js/statistic_color_picker.js
Normal file
210
app/static/admin/js/statistic_color_picker.js
Normal 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
97
app/statistic/README.md
Normal 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.
|
0
app/statistic/__init__.py
Normal file
0
app/statistic/__init__.py
Normal file
249
app/statistic/admin.py
Normal file
249
app/statistic/admin.py
Normal 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
7
app/statistic/apps.py
Normal 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'
|
49
app/statistic/migrations/0001_initial.py
Normal file
49
app/statistic/migrations/0001_initial.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
|
@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
0
app/statistic/migrations/__init__.py
Normal file
0
app/statistic/migrations/__init__.py
Normal file
219
app/statistic/models.py
Normal file
219
app/statistic/models.py
Normal 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
|
661
app/statistic/templates/statistic/overview.html
Normal file
661
app/statistic/templates/statistic/overview.html
Normal file
|
@ -0,0 +1,661 @@
|
|||
{% 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;
|
||||
}
|
||||
|
||||
/* Segmentierte Balken für Vogelarten - neues horizontales Layout */
|
||||
.bird-bar {
|
||||
display: flex;
|
||||
margin-bottom: 15px;
|
||||
align-items: center;
|
||||
clear: both;
|
||||
overflow: hidden;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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-top: 8px;
|
||||
padding-top: 5px;
|
||||
border-top: 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;
|
||||
}
|
||||
</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_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" style="width: {{ bird.total_bar_width }}%;">
|
||||
{% for group_data in bird.groups %}
|
||||
{% if group_data.count > 0 %}
|
||||
<div class="bird-bar-segment"
|
||||
style="width: {{ group_data.percentage }}%; 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 (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 %}
|
854
app/statistic/templates/statistic/overview_broken.html
Normal file
854
app/statistic/templates/statistic/overview_broken.html
Normal 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 %}
|
388
app/statistic/templates/statistic/overview_minimal.html
Normal file
388
app/statistic/templates/statistic/overview_minimal.html
Normal 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
3
app/statistic/tests.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
8
app/statistic/urls.py
Normal file
8
app/statistic/urls.py
Normal file
|
@ -0,0 +1,8 @@
|
|||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = 'statistic'
|
||||
|
||||
urlpatterns = [
|
||||
path('', views.StatisticView.as_view(), name='overview'),
|
||||
]
|
184
app/statistic/views.py
Normal file
184
app/statistic/views.py
Normal file
|
@ -0,0 +1,184 @@
|
|||
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, Circumstance
|
||||
from .models import StatisticIndividual, StatisticYearGroup, StatisticTotalGroup, StatisticConfiguration
|
||||
|
||||
|
||||
class StatisticView(TemplateView):
|
||||
template_name = 'statistic/overview.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
# Aktuelles Jahr
|
||||
current_year = timezone.now().year
|
||||
|
||||
# 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
|
||||
|
||||
# 1. Jahresstatistik
|
||||
if config.show_year_total_patients:
|
||||
patients_this_year = FallenBird.objects.filter(
|
||||
date_found__year=current_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=current_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
|
||||
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 Balkenbreiten für jede Gruppe
|
||||
for group_data in bird['groups']:
|
||||
group_bar_width = (group_data['count'] / max_count) * 100 if max_count > 0 else 0
|
||||
group_data['bar_width'] = f"{group_bar_width:.1f}".replace(',', '.')
|
||||
|
||||
context['bird_stats'] = bird_stats
|
||||
|
||||
# 4. Fundumstände-Statistiken (unverändert)
|
||||
# Fundumstände für aktuelles Jahr
|
||||
circumstances_this_year = FallenBird.objects.filter(
|
||||
date_found__year=current_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
|
|
@ -1,7 +1,7 @@
|
|||
{% if user.is_authenticated %}
|
||||
<div class="container-lg">
|
||||
<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">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link px-2 text-muted" href="{% url 'bird_all' %}">Home</a>
|
||||
|
|
|
@ -28,6 +28,10 @@
|
|||
<a class="nav-link {% if '/aviary/all' in request.path %} active {% endif %}"
|
||||
href="{% url 'aviary_all' %}">Volieren</a>
|
||||
</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">
|
||||
<a class="nav-link {% if '/costs/all' in request.path %} active {% endif %}"
|
||||
href="{% url 'costs_all' %}">Kosten</a>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue