diff --git a/db.sqlite3 b/db.sqlite3 index 4121ae1..462e62b 100644 Binary files a/db.sqlite3 and b/db.sqlite3 differ diff --git a/sheets/forms.py b/sheets/forms.py index 5704562..9521965 100644 --- a/sheets/forms.py +++ b/sheets/forms.py @@ -1,7 +1,27 @@ from django import forms -from .models import ExcelEntry +from .models import ExcelEntry, Betriebskosten class ExcelEntryForm(forms.ModelForm): class Meta: model = ExcelEntry - fields = ['name', 'age', 'email'] # Include only the fields you want users to input + fields = '__all__' # Include only the fields you want users to input + +class BetriebskostenForm(forms.ModelForm): + class Meta: + model = Betriebskosten + fields = ['buchungsdatum', 'rechnungsnummer', 'kostentyp', 'gas_volume', 'betrag', 'beschreibung'] + widgets = { + 'buchungsdatum': forms.DateInput(attrs={'type': 'date'}), + 'beschreibung': forms.Textarea(attrs={'rows': 3}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['gas_volume'].required = False + + # Add price per liter field (readonly) + self.fields['price_per_liter'] = forms.CharField( + label='Preis pro Liter (€)', + required=False, + widget=forms.TextInput(attrs={'readonly': 'readonly'}) + ) \ No newline at end of file diff --git a/sheets/migrations/0007_betriebskosten.py b/sheets/migrations/0007_betriebskosten.py new file mode 100644 index 0000000..1f73ebe --- /dev/null +++ b/sheets/migrations/0007_betriebskosten.py @@ -0,0 +1,25 @@ +# Generated by Django 5.1.5 on 2025-08-28 09:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sheets', '0006_remove_excelentry_age_remove_excelentry_email_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Betriebskosten', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('buchungsdatum', models.DateField(verbose_name='Buchungsdatum')), + ('rechnungsnummer', models.CharField(max_length=50, verbose_name='Rechnungsnummer')), + ('kostentyp', models.CharField(choices=[('sach', 'Sachkosten'), ('ln2', 'LN2'), ('helium', 'Helium'), ('inv', 'Inventar')], max_length=10, verbose_name='Kostentyp')), + ('gas_volume', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Gasvolumen (Liter)')), + ('betrag', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Betrag (€)')), + ('beschreibung', models.TextField(blank=True, verbose_name='Beschreibung')), + ], + ), + ] diff --git a/sheets/models.py b/sheets/models.py index 22cf221..8364335 100644 --- a/sheets/models.py +++ b/sheets/models.py @@ -3,6 +3,29 @@ from django.utils import timezone from django.core.validators import MinValueValidator, MaxValueValidator +class Betriebskosten(models.Model): + KOSTENTYP_CHOICES = [ + ('sach', 'Sachkostöen'), + ('ln2', 'LN2'), + ('helium', 'Helium'), + ('inv', 'Inventar'), + ] + + buchungsdatum = models.DateField('Buchungsdatum') + rechnungsnummer = models.CharField('Rechnungsnummer', max_length=50) + kostentyp = models.CharField('Kostentyp', max_length=10, choices=KOSTENTYP_CHOICES) + gas_volume = models.DecimalField('Gasvolumen (Liter)', max_digits=10, decimal_places=2, null=True, blank=True) + betrag = models.DecimalField('Betrag (€)', max_digits=10, decimal_places=2) + beschreibung = models.TextField('Beschreibung', blank=True) + + @property + def price_per_liter(self): + if self.kostentyp == 'helium' and self.gas_volume: + return self.betrag / self.gas_volume + return None + + def __str__(self): + return f"{self.buchungsdatum} - {self.get_kostentyp_display()} - {self.betrag}€" # Fixed the missing quote class Client(models.Model): name = models.CharField(max_length=100) diff --git a/sheets/templates/base.html b/sheets/templates/base.html index 151779f..8c92798 100644 --- a/sheets/templates/base.html +++ b/sheets/templates/base.html @@ -3,6 +3,9 @@ {% block title %}My App{% endblock %} + + + diff --git a/sheets/templates/betriebskosten_list.html b/sheets/templates/betriebskosten_list.html new file mode 100644 index 0000000..a745799 --- /dev/null +++ b/sheets/templates/betriebskosten_list.html @@ -0,0 +1,479 @@ + + + + + + Betriebskosten + + + + + + + + + ⇦ Go to Clients + + +

Betriebskosten

+
+ + + + + + + + + + + + + + + + {% for item in items %} + + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
#BuchungsdatumRechnungsnummerKostentypGasvolumen (L)Betrag (€)BeschreibungActions
{{ forloop.counter }}{{ item.buchungsdatum|date:"Y-m-d" }}{{ item.rechnungsnummer }}{{ item.get_kostentyp_display }}{{ item.gas_volume|default_if_none:"-" }}{{ item.betrag }}{{ item.beschreibung|default:"" }} + + +
No entries found
+
+ + + + + + + + + + \ No newline at end of file diff --git a/sheets/templates/clients_table.html b/sheets/templates/clients_table.html index 8591a0c..985b14a 100644 --- a/sheets/templates/clients_table.html +++ b/sheets/templates/clients_table.html @@ -2,6 +2,9 @@ {% block content %}
+ +

Helium Output Yearly Summary

+
@@ -40,12 +43,10 @@
@@ -56,6 +57,12 @@ padding: 20px; } + .page-title { + text-align: center; + color: #333; + margin-bottom: 20px; + } + .year-filter { margin: 20px 0; text-align: right; @@ -120,5 +127,13 @@ .nav-button:hover { background-color: #0056b3; } + + .admin-button { + background-color: #6c757d; + } + + .admin-button:hover { + background-color: #5a6268; + } {% endblock %} \ No newline at end of file diff --git a/sheets/templates/table_two.html b/sheets/templates/table_two.html index 5c74be4..a740a29 100644 --- a/sheets/templates/table_two.html +++ b/sheets/templates/table_two.html @@ -316,7 +316,7 @@ $('#save-add-two').on('click', function() { let formData = { 'client_id': $('#add-client-id').val(), - 'date': $('#add-date').val(), + 'date': $('#add-date').val(), // Ensure this is in YYYY-MM-DD format 'is_warm': $('#add-is-warm').is(':checked'), 'lhe_delivery': $('#add-lhe-delivery').val(), 'lhe_output': $('#add-lhe-output').val(), @@ -324,6 +324,12 @@ 'csrfmiddlewaretoken': '{{ csrf_token }}' }; + // Validate date format + if (!formData.date) { + alert('Please select a date'); + return; + } + // Validate LHe Output is a number if (isNaN(parseFloat(formData.lhe_output))) { alert('LHe Output must be a valid number'); @@ -335,16 +341,32 @@ method: 'POST', data: formData, success: function(response) { - // Clear the form - $('#add-popup-two').find('input, textarea, select').val(''); - $('#add-is-warm').prop('checked', false); - $('#add-popup-two').fadeOut(); - - // Reload the table data - loadTableData(); + if (response.status === 'success') { + // Add the new row to the table + let newRow = ` + + ${$('#table-two tbody tr').length + 1} + ${response.id} + ${response.client_name} + ${response.date || ''} + ${response.is_warm ? 'Yes' : 'No'} + ${response.lhe_delivery} + ${response.lhe_output || ''} + ${response.notes || ''} + + + + + + `; + $('#table-two tbody').append(newRow); + $('#add-popup-two').fadeOut(); + } else { + alert('Error: ' + (response.message || 'Failed to add entry')); + } }, error: function(xhr) { - alert('Error: ' + xhr.responseJSON?.message || 'Failed to add entry'); + alert('Error: ' + (xhr.responseJSON?.message || 'Server error')); } }); }); @@ -366,18 +388,22 @@ } // Open Edit Popup - $(document).on('click', '.edit-btn-two', function () { + $(document).on('click', '.edit-btn-two', function() { let row = $(this).closest('tr'); - currentTableId = row.closest('table').attr('id'); - currentModelName = 'SecondTableEntry'; - - // Get data from the row - let is_warm = row.find('td:eq(4)').text().trim() === 'Yes'; - $('#edit-id').val(row.data('id')); - $('#edit-client-id').val(row.find('td:eq(2)').text().trim()); - $('#edit-date').val(row.find('td:eq(3)').text().trim()); - $('#edit-is-warm').prop('checked', is_warm); + + // Set client - find by name since we're showing names in the table + let clientName = row.find('td:eq(2)').text().trim(); + $(`#edit-client-id option:contains("${clientName}")`).prop('selected', true); + + // Set date - ensure proper format + let dateText = row.find('td:eq(3)').text().trim(); + if (dateText) { + $('#edit-date').val(dateText); + } + + // Set other fields + $('#edit-is-warm').prop('checked', row.find('td:eq(4)').text().trim() === 'Yes'); $('#edit-lhe-delivery').val(row.find('td:eq(5)').text().trim()); $('#edit-lhe-output').val(row.find('td:eq(6)').text().trim()); $('#edit-notes').val(row.find('td:eq(7)').text().trim()); @@ -386,45 +412,48 @@ }); // Save Edit Entry - $('#save-edit-two').on('click', function () { - let id = $('#edit-id').val(); - let client_id = $('#edit-client-id').val(); - let date = $('#edit-date').val(); - let is_warm = $('#edit-is-warm').is(':checked'); - let lhe_delivery = $('#edit-lhe-delivery').val(); - let lhe_output = $('#edit-lhe-output').val(); - let notes = $('#edit-notes').val(); + $('#save-edit-two').on('click', function() { + let formData = { + 'id': $('#edit-id').val(), + 'client_id': $('#edit-client-id').val(), + 'date': $('#edit-date').val(), // Already in YYYY-MM-DD format + 'is_warm': $('#edit-is-warm').is(':checked'), + 'lhe_delivery': $('#edit-lhe-delivery').val(), + 'lhe_output': $('#edit-lhe-output').val(), + 'notes': $('#edit-notes').val(), + 'csrfmiddlewaretoken': '{{ csrf_token }}' + }; + + // Validate inputs + if (!formData.date) { + alert('Please select a date'); + return; + } + if (isNaN(parseFloat(formData.lhe_output))) { + alert('Please enter a valid LHe Output value'); + return; + } $.ajax({ - url: `/update-entry/${currentModelName}/`, + url: '/update-entry/SecondTableEntry/', method: 'POST', - data: { - 'id': id, - 'client_id': client_id, - 'date': date, - 'is_warm': is_warm, - 'lhe_delivery': lhe_delivery, - 'lhe_output': lhe_output, - 'notes': notes, - 'csrfmiddlewaretoken': '{{ csrf_token }}' - }, - success: function (response) { + data: formData, + success: function(response) { if (response.status === 'success') { let row = $(`tr[data-id="${response.id}"]`); row.find('td:eq(2)').text(response.client_name); - row.find('td:eq(3)').text(response.date); + row.find('td:eq(3)').text(response.date || ''); row.find('td:eq(4)').text(response.is_warm ? 'Yes' : 'No'); row.find('td:eq(5)').text(response.lhe_delivery); - row.find('td:eq(6)').text(response.lhe_output); - row.find('td:eq(7)').text(response.notes); + row.find('td:eq(6)').text(response.lhe_output || ''); + row.find('td:eq(7)').text(response.notes || ''); $('#edit-popup-two').fadeOut(); } else { - alert('Update failed: ' + (response.message || 'Please try again later')); + alert('Update failed: ' + (response.message || 'Unknown error')); } }, - error: function (xhr, status, error) { - alert('Update failed: ' + (xhr.responseJSON?.message || 'Please try again later')); - console.error('Error:', error); + error: function(xhr) { + alert('Error: ' + (xhr.responseJSON?.message || 'Server error')); } }); }); diff --git a/sheets/urls.py b/sheets/urls.py index 9944e4a..ee7436f 100644 --- a/sheets/urls.py +++ b/sheets/urls.py @@ -8,4 +8,7 @@ urlpatterns = [ path('add-entry//', views.add_entry, name='add_entry'), path('update-entry//', views.update_entry, name='update_entry'), path('delete-entry//', views.delete_entry, name='delete_entry'), + path('betriebskosten/', views.betriebskosten_list, name='betriebskosten_list'), + path('betriebskosten/create/', views.betriebskosten_create, name='betriebskosten_create'), + path('betriebskosten/delete/', views.betriebskosten_delete, name='betriebskosten_delete'), ] diff --git a/sheets/views.py b/sheets/views.py index a909350..2365ff7 100644 --- a/sheets/views.py +++ b/sheets/views.py @@ -1,4 +1,5 @@ -from django.shortcuts import render +from django.shortcuts import render ,redirect +from django.db.models import Sum, Value, DecimalField from django.http import JsonResponse from django.db.models import Q from decimal import Decimal, InvalidOperation @@ -7,41 +8,62 @@ from datetime import date, datetime from django.utils import timezone from .models import Client, SecondTableEntry from django.db.models import Sum +from django.urls import reverse +from django.db.models.functions import Coalesce +from .forms import BetriebskostenForm +from .models import Betriebskosten +from django.utils.dateparse import parse_date # Clients Page (Main) +from django.shortcuts import render +from django.db.models import Sum +from datetime import datetime +from .models import Client, SecondTableEntry + def clients_list(request): - # Annual summary data - current_year = int(request.GET.get('year', datetime.now().year)) + # Get all clients clients = Client.objects.all() - + + # Get all years available in SecondTableEntry + available_years_qs = SecondTableEntry.objects.dates('date', 'year', order='DESC') + available_years = [y.year for y in available_years_qs] + + # Determine selected year + year_param = request.GET.get('year') + if year_param: + selected_year = int(year_param) + else: + # If no year in GET, default to latest available year or current year if DB empty + selected_year = available_years[0] if available_years else datetime.now().year + + # Prepare monthly totals per client monthly_data = [] for client in clients: monthly_totals = [] for month in range(1, 13): total = SecondTableEntry.objects.filter( client=client, - date__year=current_year, + date__year=selected_year, date__month=month - ).aggregate(total=Sum('lhe_output'))['total'] or 0 + ).aggregate( + total=Coalesce(Sum('lhe_output'), Value(0, output_field=DecimalField())) + )['total'] monthly_totals.append(total) - + monthly_data.append({ 'client': client, 'monthly_totals': monthly_totals, 'year_total': sum(monthly_totals) }) - - available_years = SecondTableEntry.objects.dates('date', 'year').distinct() - + return render(request, 'clients_table.html', { 'monthly_data': monthly_data, - 'current_year': current_year, - 'available_years': [y.year for y in available_years], + 'current_year': selected_year, + 'available_years': available_years, 'months': ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] }) - # Table One View (ExcelEntry) def table_one_view(request): ExcelEntry = apps.get_model('sheets', 'ExcelEntry') @@ -80,20 +102,26 @@ def add_entry(request, model_name): try: model = apps.get_model('sheets', model_name) - common_data = { - 'client': Client.objects.get(id=request.POST.get('client_id')), - 'date': request.POST.get('date'), - 'notes': request.POST.get('notes', '') - } - if model_name == 'SecondTableEntry': + # Handle date conversion + date_str = request.POST.get('date') + try: + date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else None + except (ValueError, TypeError): + return JsonResponse({ + 'status': 'error', + 'message': 'Invalid date format. Use YYYY-MM-DD' + }, status=400) + # Handle Helium Output (Table Two) lhe_output = request.POST.get('lhe_output') entry = model.objects.create( - **common_data, + client=Client.objects.get(id=request.POST.get('client_id')), + date=date_obj, is_warm=request.POST.get('is_warm') == 'true', lhe_delivery=request.POST.get('lhe_delivery', ''), - lhe_output=Decimal(lhe_output) if lhe_output else None + lhe_output=Decimal(lhe_output) if lhe_output else None, + notes=request.POST.get('notes', '') ) return JsonResponse({ @@ -158,15 +186,33 @@ def update_entry(request, model_name): # Common updates for both models entry.client = Client.objects.get(id=request.POST.get('client_id')) - entry.date = request.POST.get('date') entry.notes = request.POST.get('notes', '') + # Handle date properly for both models + date_str = request.POST.get('date') + if date_str: + try: + entry.date = datetime.strptime(date_str, '%Y-%m-%d').date() + except ValueError: + return JsonResponse({ + 'status': 'error', + 'message': 'Invalid date format. Use YYYY-MM-DD' + }, status=400) + if model_name == 'SecondTableEntry': - # Handle Helium Output (Table Two) - lhe_output = request.POST.get('lhe_output') + # Handle Helium Output specific fields entry.is_warm = request.POST.get('is_warm') == 'true' entry.lhe_delivery = request.POST.get('lhe_delivery', '') - entry.lhe_output = Decimal(lhe_output) if lhe_output else None + + lhe_output = request.POST.get('lhe_output') + try: + entry.lhe_output = Decimal(lhe_output) if lhe_output else None + except InvalidOperation: + return JsonResponse({ + 'status': 'error', + 'message': 'Invalid LHe Output value' + }, status=400) + entry.save() return JsonResponse({ @@ -181,29 +227,28 @@ def update_entry(request, model_name): }) elif model_name == 'ExcelEntry': - # Handle Helium Input (Table One) - date_str = request.POST.get('date') + # Handle Helium Input specific fields try: - date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else None - except (ValueError, TypeError): - date_obj = None + entry.pressure = Decimal(request.POST.get('pressure', 0)) + entry.purity = Decimal(request.POST.get('purity', 0)) + except InvalidOperation: + return JsonResponse({ + 'status': 'error', + 'message': 'Invalid pressure or purity value' + }, status=400) - entry.client = Client.objects.get(id=request.POST.get('client_id')) - entry.date = date_obj - entry.pressure = Decimal(request.POST.get('pressure', 0)) - entry.purity = Decimal(request.POST.get('purity', 0)) - entry.notes = request.POST.get('notes', '') entry.save() return JsonResponse({ 'status': 'success', 'id': entry.id, 'client_name': entry.client.name, + 'date': entry.date.strftime('%Y-%m-%d') if entry.date else '', 'pressure': str(entry.pressure), 'purity': str(entry.purity), - 'date': entry.date.strftime('%Y-%m-%d') if entry.date else '', 'notes': entry.notes }) + except model.DoesNotExist: return JsonResponse({'status': 'error', 'message': 'Entry not found'}, status=404) except Exception as e: @@ -225,4 +270,84 @@ def delete_entry(request, model_name): except Exception as e: return JsonResponse({'status': 'error', 'message': str(e)}, status=400) - return JsonResponse({'status': 'error', 'message': 'Invalid request'}, status=400) \ No newline at end of file + return JsonResponse({'status': 'error', 'message': 'Invalid request'}, status=400) + +def betriebskosten_list(request): + items = Betriebskosten.objects.all().order_by('-buchungsdatum') + return render(request, 'betriebskosten_list.html', {'items': items}) + +def betriebskosten_create(request): + if request.method == 'POST': + try: + entry_id = request.POST.get('id') + if entry_id: + # Update existing entry + entry = Betriebskosten.objects.get(id=entry_id) + else: + # Create new entry + entry = Betriebskosten() + + # Get form data + buchungsdatum_str = request.POST.get('buchungsdatum') + rechnungsnummer = request.POST.get('rechnungsnummer') + kostentyp = request.POST.get('kostentyp') + betrag = request.POST.get('betrag') + beschreibung = request.POST.get('beschreibung') + gas_volume = request.POST.get('gas_volume') + + # Validate required fields + if not all([buchungsdatum_str, rechnungsnummer, kostentyp, betrag]): + return JsonResponse({'status': 'error', 'message': 'All required fields must be filled'}) + + # Convert date string to date object + try: + buchungsdatum = parse_date(buchungsdatum_str) + if not buchungsdatum: + return JsonResponse({'status': 'error', 'message': 'Invalid date format'}) + except (ValueError, TypeError): + return JsonResponse({'status': 'error', 'message': 'Invalid date format'}) + + # Set entry values + entry.buchungsdatum = buchungsdatum + entry.rechnungsnummer = rechnungsnummer + entry.kostentyp = kostentyp + entry.betrag = betrag + entry.beschreibung = beschreibung + + # Only set gas_volume if kostentyp is helium and gas_volume is provided + if kostentyp == 'helium' and gas_volume: + entry.gas_volume = gas_volume + else: + entry.gas_volume = None + + entry.save() + + return JsonResponse({ + 'status': 'success', + 'id': entry.id, + 'buchungsdatum': entry.buchungsdatum.strftime('%Y-%m-%d'), + 'rechnungsnummer': entry.rechnungsnummer, + 'kostentyp_display': entry.get_kostentyp_display(), + 'gas_volume': str(entry.gas_volume) if entry.gas_volume else '-', + 'betrag': str(entry.betrag), + 'beschreibung': entry.beschreibung or '' + }) + + except Exception as e: + return JsonResponse({'status': 'error', 'message': str(e)}) + + return JsonResponse({'status': 'error', 'message': 'Invalid request method'}) + +def betriebskosten_delete(request): + if request.method == 'POST': + try: + entry_id = request.POST.get('id') + entry = Betriebskosten.objects.get(id=entry_id) + entry.delete() + return JsonResponse({'status': 'success'}) + except Betriebskosten.DoesNotExist: + return JsonResponse({'status': 'error', 'message': 'Entry not found'}) + except Exception as e: + return JsonResponse({'status': 'error', 'message': str(e)}) + + return JsonResponse({'status': 'error', 'message': 'Invalid request method'}) \ No newline at end of file