This commit is contained in:
Felix Baumann
2026-06-28 10:20:36 +00:00
parent 64b32d0b44
commit 148efd919d
86 changed files with 2475 additions and 760 deletions
+3
View File
@@ -3,7 +3,10 @@ __pycache__/
*.pyo
.env
db.sqlite3
*.sqlite3
media/
staticfiles/
venv/
.venv/
.vscode/
.idea/
+8 -5
View File
@@ -1,4 +1,4 @@
# Generated by Django 6.0.6 on 2026-06-27 17:19
# Generated by Django 6.0.6 on 2026-06-28 10:19
import django.db.models.deletion
from django.conf import settings
@@ -46,12 +46,15 @@ class Migration(migrations.Migration):
('projects_inventory_shopping_add', models.BooleanField(default=False, help_text='Benutzer darf Einkaufspositionen erstellen.', verbose_name='Einkauf hinzufügen')),
('projects_inventory_shopping_edit', models.BooleanField(default=False, help_text='Benutzer darf Einkaufspositionen ändern.', verbose_name='Einkauf bearbeiten')),
('projects_inventory_shopping_delete', models.BooleanField(default=False, help_text='Benutzer darf Einkaufspositionen löschen.', verbose_name='Einkauf löschen')),
('projects_inventory_shopping_to_expense', models.BooleanField(default=False, help_text='Benutzer darf Einkäufe als erledigt markieren.', verbose_name='Einkäufe durchführen')),
('inventory_view', models.BooleanField(default=False, help_text='Benutzer darf Inventar einsehen.', verbose_name='Inventar anzeigen')),
('inventory_add', models.BooleanField(default=False, help_text='Benutzer darf Inventareinträge erstellen.', verbose_name='Inventar hinzufügen')),
('inventory_edit', models.BooleanField(default=False, help_text='Benutzer darf Inventareinträge ändern.', verbose_name='Inventar bearbeiten')),
('inventory_delete', models.BooleanField(default=False, help_text='Benutzer darf Inventareinträge löschen.', verbose_name='Inventar löschen')),
('invoices_view', models.BooleanField(default=False, help_text='Benutzer darf Rechnungen verwalten.', verbose_name='Rechnungsbereich anzeigen')),
('invoices_info', models.BooleanField(default=False, help_text='Benutzer darf Statistiken sehen.', verbose_name='Statistiken ansehen')),
('invoices_customers_view', models.BooleanField(default=False, help_text='Benutzer darf Kunden sehen.', verbose_name='Kunden anzeigen')),
('invoices_customers_info', models.BooleanField(default=False, help_text='Benutzer darf Kundenstatistiken sehen.', verbose_name='Kundenstatistiken ansehen')),
('invoices_customers_detail_view', models.BooleanField(default=False, help_text='Benutzer darf Details des Kunden sehen.', verbose_name='Kundendetails anzeigen')),
('invoices_customers_add', models.BooleanField(default=False, help_text='Benutzer darf Kunden anlegen.', verbose_name='Kunden erstellen')),
('invoices_customers_edit', models.BooleanField(default=False, help_text='Benutzer darf Kunden ändern.', verbose_name='Kunden bearbeiten')),
@@ -62,6 +65,7 @@ class Migration(migrations.Migration):
('invoices_items_edit', models.BooleanField(default=False, help_text='Benutzer darf Artikel ändern.', verbose_name='Artikel bearbeiten')),
('invoices_items_delete', models.BooleanField(default=False, help_text='Benutzer darf Artikel löschen.', verbose_name='Artikel löschen')),
('invoices_invoices_view', models.BooleanField(default=False, help_text='Benutzer darf Rechnungen sehen.', verbose_name='Rechnungen anzeigen')),
('invoices_invoices_info', models.BooleanField(default=False, help_text='Benutzer darf Rechnungsstatistiken sehen.', verbose_name='Rechnungsstatistiken ansehen')),
('invoices_invoices_project_add', models.BooleanField(default=False, help_text='Benutzer darf Rechnungen aus Projekten erstellen.', verbose_name='Projekt-Rechnung erstellen')),
('invoices_invoices_customer_add', models.BooleanField(default=False, help_text='Benutzer darf Rechnungen für Kunden erstellen.', verbose_name='Kunden-Rechnung erstellen')),
('invoices_invoices_invoices_view', models.BooleanField(default=False, help_text='Benutzer darf Rechnungsdetails sehen.', verbose_name='Rechnungsdetails anzeigen')),
@@ -72,24 +76,23 @@ class Migration(migrations.Migration):
('invoices_invoices_invoices_cancel', models.BooleanField(default=False, help_text='Benutzer darf Rechnungen stornieren.', verbose_name='Rechnung stornieren')),
('invoices_invoices_invoices_paid', models.BooleanField(default=False, help_text='Benutzer darf Rechnungsstatus ändern.', verbose_name='Rechnung auf bezahlt setzen')),
('invoices_invoices_invoices_delete', models.BooleanField(default=False, help_text='Benutzer darf Rechnungen löschen.', verbose_name='Rechnungen löschen')),
('invoices_invoices_invoices_overdue', models.BooleanField(default=False, help_text='Benutzer darf überfällige Rechnungen einsehen.', verbose_name='Überfällige Rechnungen anzeigen')),
('invoices_offers_view', models.BooleanField(default=False, help_text='Benutzer darf Angebote sehen.', verbose_name='Angebote anzeigen')),
('invoices_offers_info', models.BooleanField(default=False, help_text='Benutzer darf Angebotsstatistiken sehen.', verbose_name='Angebotsstatistiken ansehen')),
('invoices_offers_add', models.BooleanField(default=False, help_text='Benutzer darf Angebote für Kunden erstellen.', verbose_name='Angebot erstellen')),
('invoices_offers_offers_view', models.BooleanField(default=False, help_text='Benutzer darf Angebotsdetails sehen.', verbose_name='Angebotsdetails anzeigen')),
('invoices_offers_offers_item_add', models.BooleanField(default=False, help_text='Benutzer darf Positionen hinzufügen.', verbose_name='Angebotsposition hinzufügen')),
('invoices_offers_offers_item_delete', models.BooleanField(default=False, help_text='Benutzer darf Positionen löschen.', verbose_name='Angebotsposition löschen')),
('invoices_offers_offers_preview', models.BooleanField(default=False, help_text='Benutzer darf Vorschauen öffnen.', verbose_name='Angebotsvorschau anzeigen')),
('invoices_offers_offers_download', models.BooleanField(default=False, help_text='Benutzer darf Angebote herunterladen.', verbose_name='Angebot herunterladen')),
('invoices_offers_offers_status', models.BooleanField(default=False, help_text='Benutzer darf Angebotsstatus ändern.', verbose_name='Angebotsstatus bearbeiten')),
('invoices_offers_offers_delete', models.BooleanField(default=False, help_text='Benutzer darf Angebote löschen.', verbose_name='Angebote löschen')),
('invoices_reminders_view', models.BooleanField(default=False, help_text='Benutzer darf Mahnungen einsehen.', verbose_name='Mahnungen anzeigen')),
('invoices_reminders_info', models.BooleanField(default=False, help_text='Benutzer darf Mahnungsstatistiken sehen.', verbose_name='Mahnungsstatistiken ansehen')),
('invoices_reminders_add', models.BooleanField(default=False, help_text='Benutzer darf Mahnungen erstellen.', verbose_name='Mahnungen hinzufügen')),
('invoices_reminders_reminders_view', models.BooleanField(default=False, help_text='Benutzer darf Zahlungserinnerungen einsehen.', verbose_name='Erinnerungen anzeigen')),
('invoices_reminders_reminders_cancel', models.BooleanField(default=False, help_text='Benutzer darf Zahlungserinnerungen stornieren.', verbose_name='Erinnerungen stornieren')),
('invoices_reminders_reminders_delete', models.BooleanField(default=False, help_text='Benutzer darf Zahlungserinnerungen löschen.', verbose_name='Erinnerungen löschen')),
('invoices_reminders_reminders_download', models.BooleanField(default=False, help_text='Benutzer darf Zahlungserinnerungen herunterladen.', verbose_name='Erinnerungen herunterladen')),
('invoices_reminders_reminders_paid', models.BooleanField(default=False, help_text='Benutzer darf Zahlungserinnerungen als bezahlt markieren.', verbose_name='Erinnerungen als bezahlt markieren')),
('invoices_reminders_reminders_expire', models.BooleanField(default=False, help_text='Benutzer darf Zahlungserinnerungen als abgelaufen markieren.', verbose_name='Erinnerungen ablaufen lassen')),
('invoices_payments_view', models.BooleanField(default=False, help_text='Benutzer darf Zahlungen einsehen.', verbose_name='Zahlungen anzeigen')),
('users_view', models.BooleanField(default=False, help_text='Benutzer darf Mitarbeiter sehen.', verbose_name='Benutzer anzeigen')),
('users_add', models.BooleanField(default=False, help_text='Benutzer darf neue Mitarbeiter anlegen.', verbose_name='Benutzer erstellen')),
('users_edit', models.BooleanField(default=False, help_text='Benutzer darf Mitarbeiter bearbeiten.', verbose_name='Benutzer bearbeiten')),
+7 -5
View File
@@ -42,7 +42,7 @@ class Role(models.Model):
projects_inventory_shopping_add = models.BooleanField(default=False, verbose_name="Einkauf hinzufügen", help_text="Benutzer darf Einkaufspositionen erstellen.")
projects_inventory_shopping_edit = models.BooleanField(default=False, verbose_name="Einkauf bearbeiten", help_text="Benutzer darf Einkaufspositionen ändern.")
projects_inventory_shopping_delete = models.BooleanField(default=False, verbose_name="Einkauf löschen", help_text="Benutzer darf Einkaufspositionen löschen.")
#projects_inventory_shopping_buy = models.BooleanField(default=False, verbose_name="Einkäufe durchführen", help_text="Benutzer darf Einkäufe als erledigt markieren.")
projects_inventory_shopping_to_expense = models.BooleanField(default=False, verbose_name="Einkäufe durchführen", help_text="Benutzer darf Einkäufe als erledigt markieren.")
# =====================
@@ -58,8 +58,10 @@ class Role(models.Model):
# Rechnungen
# =====================
invoices_view = models.BooleanField(default=False, verbose_name="Rechnungsbereich anzeigen", help_text="Benutzer darf Rechnungen verwalten.")
invoices_info = models.BooleanField(default=False, verbose_name="Statistiken ansehen", help_text="Benutzer darf Statistiken sehen.")
invoices_customers_view = models.BooleanField(default=False, verbose_name="Kunden anzeigen", help_text="Benutzer darf Kunden sehen.")
invoices_customers_info = models.BooleanField(default=False, verbose_name="Kundenstatistiken ansehen", help_text="Benutzer darf Kundenstatistiken sehen.")
invoices_customers_detail_view = models.BooleanField(default=False, verbose_name="Kundendetails anzeigen", help_text="Benutzer darf Details des Kunden sehen.")
invoices_customers_add = models.BooleanField(default=False, verbose_name="Kunden erstellen", help_text="Benutzer darf Kunden anlegen.")
invoices_customers_edit = models.BooleanField(default=False, verbose_name="Kunden bearbeiten", help_text="Benutzer darf Kunden ändern.")
@@ -72,6 +74,7 @@ class Role(models.Model):
invoices_items_delete = models.BooleanField(default=False, verbose_name="Artikel löschen", help_text="Benutzer darf Artikel löschen.")
invoices_invoices_view = models.BooleanField(default=False, verbose_name="Rechnungen anzeigen", help_text="Benutzer darf Rechnungen sehen.")
invoices_invoices_info = models.BooleanField(default=False, verbose_name="Rechnungsstatistiken ansehen", help_text="Benutzer darf Rechnungsstatistiken sehen.")
invoices_invoices_project_add = models.BooleanField(default=False, verbose_name="Projekt-Rechnung erstellen", help_text="Benutzer darf Rechnungen aus Projekten erstellen.")
invoices_invoices_customer_add = models.BooleanField(default=False, verbose_name="Kunden-Rechnung erstellen", help_text="Benutzer darf Rechnungen für Kunden erstellen.")
invoices_invoices_invoices_view = models.BooleanField(default=False, verbose_name="Rechnungsdetails anzeigen", help_text="Benutzer darf Rechnungsdetails sehen.")
@@ -82,27 +85,26 @@ class Role(models.Model):
invoices_invoices_invoices_cancel = models.BooleanField(default=False, verbose_name="Rechnung stornieren", help_text="Benutzer darf Rechnungen stornieren.")
invoices_invoices_invoices_paid = models.BooleanField(default=False, verbose_name="Rechnung auf bezahlt setzen", help_text="Benutzer darf Rechnungsstatus ändern.")
invoices_invoices_invoices_delete = models.BooleanField(default=False, verbose_name="Rechnungen löschen", help_text="Benutzer darf Rechnungen löschen.")
invoices_invoices_invoices_overdue = models.BooleanField(default=False, verbose_name="Überfällige Rechnungen anzeigen", help_text="Benutzer darf überfällige Rechnungen einsehen.")
invoices_offers_view = models.BooleanField(default=False, verbose_name="Angebote anzeigen", help_text="Benutzer darf Angebote sehen.")
invoices_offers_info = models.BooleanField(default=False, verbose_name="Angebotsstatistiken ansehen", help_text="Benutzer darf Angebotsstatistiken sehen.")
invoices_offers_add = models.BooleanField(default=False, verbose_name="Angebot erstellen", help_text="Benutzer darf Angebote für Kunden erstellen.")
invoices_offers_offers_view = models.BooleanField(default=False, verbose_name="Angebotsdetails anzeigen", help_text="Benutzer darf Angebotsdetails sehen.")
invoices_offers_offers_item_add = models.BooleanField(default=False, verbose_name="Angebotsposition hinzufügen", help_text="Benutzer darf Positionen hinzufügen.")
invoices_offers_offers_item_delete = models.BooleanField(default=False, verbose_name="Angebotsposition löschen", help_text="Benutzer darf Positionen löschen.")
invoices_offers_offers_preview = models.BooleanField(default=False, verbose_name="Angebotsvorschau anzeigen", help_text="Benutzer darf Vorschauen öffnen.")
invoices_offers_offers_download = models.BooleanField(default=False, verbose_name="Angebot herunterladen", help_text="Benutzer darf Angebote herunterladen.")
invoices_offers_offers_status = models.BooleanField(default=False, verbose_name="Angebotsstatus bearbeiten", help_text="Benutzer darf Angebotsstatus ändern.")
invoices_offers_offers_delete = models.BooleanField(default=False, verbose_name="Angebote löschen", help_text="Benutzer darf Angebote löschen.")
invoices_reminders_view = models.BooleanField(default=False, verbose_name="Mahnungen anzeigen", help_text="Benutzer darf Mahnungen einsehen.")
invoices_reminders_info = models.BooleanField(default=False, verbose_name="Mahnungsstatistiken ansehen", help_text="Benutzer darf Mahnungsstatistiken sehen.")
invoices_reminders_add = models.BooleanField(default=False, verbose_name="Mahnungen hinzufügen", help_text="Benutzer darf Mahnungen erstellen.")
invoices_reminders_reminders_view = models.BooleanField(default=False, verbose_name="Erinnerungen anzeigen", help_text="Benutzer darf Zahlungserinnerungen einsehen.")
invoices_reminders_reminders_cancel = models.BooleanField(default=False, verbose_name="Erinnerungen stornieren", help_text="Benutzer darf Zahlungserinnerungen stornieren.")
invoices_reminders_reminders_delete = models.BooleanField(default=False, verbose_name="Erinnerungen löschen", help_text="Benutzer darf Zahlungserinnerungen löschen.")
invoices_reminders_reminders_download = models.BooleanField(default=False, verbose_name="Erinnerungen herunterladen", help_text="Benutzer darf Zahlungserinnerungen herunterladen.")
invoices_reminders_reminders_paid = models.BooleanField(default=False, verbose_name="Erinnerungen als bezahlt markieren", help_text="Benutzer darf Zahlungserinnerungen als bezahlt markieren.")
invoices_reminders_reminders_expire = models.BooleanField(default=False, verbose_name="Erinnerungen ablaufen lassen", help_text="Benutzer darf Zahlungserinnerungen als abgelaufen markieren.")
invoices_payments_view = models.BooleanField(default=False, verbose_name="Zahlungen anzeigen", help_text="Benutzer darf Zahlungen einsehen.")
# =====================
# Benutzerverwaltung
# =====================
+26 -27
View File
@@ -1,41 +1,36 @@
{% extends "base.html" %}
{% block title %}
Login
{% endblock %}
{% block title %}Login{% endblock %}
{% block header_title %}
<div class="flex justify-center">
{% if company_settings.logo %}
<img src="{{ company_settings.logo.url }}" alt="" class="h-32">
{% elif company_settings.companyname %}
{{ company_settings.companyname }}
{% else %}
Projektverwaltung
{% endif %}
</div>
{% endblock %}
{% block header_description %}
{% endblock %}
{% block header %}{% endblock %}
{% block content %}
<div class="fixed inset-0 flex justify-center items-center">
<div class="p-8 rounded-2xl shadow-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors w-full max-w-md">
<div class="flex justify-center items-center min-h-[80vh]">
<!-- Logo / Firmenname -->
<div class="flex justify-center mb-6">
{% if company_settings.logo %}
<img src="{{ company_settings.logo.url }}" alt="" class="h-30 object-contain">
{% elif company_settings.companyname %}
<span class="text-2xl font-bold tracking-tight">{{ company_settings.companyname }}</span>
{% else %}
<span class="text-2xl font-bold tracking-tight">Projektverwaltung</span>
{% endif %}
</div>
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors w-full max-w-md">
<!-- Trennlinie mit Company Color -->
<div class="h-0.5 rounded-full mb-6" style="background: var(--company-color, #6366f1);"></div>
<h1 class="text-3xl font-bold text-center mb-6">
Anmelden
</h1>
<h1 class="text-xl font-semibold text-center mb-6">Anmelden</h1>
<form method="post" class="space-y-4">
{% csrf_token %}
{% for field in form %}
<div class="space-y-1">
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300">
<label for="{{ field.id_for_label }}"
class="block text-sm font-medium text-slate-700 dark:text-slate-300">
{{ field.label }}
</label>
<input
@@ -43,8 +38,12 @@ Login
name="{{ field.html_name }}"
id="{{ field.id_for_label }}"
{% if field.value %}value="{{ field.value }}"{% endif %}
class="w-full px-4 py-3 rounded-lg border border-slate-300 dark:border-slate-600 bg-slate-50 dark:bg-slate-700 text-slate-900 dark:text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"
placeholder="{{ field.label }}"
class="mt-1 w-full px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-600
bg-slate-50 dark:bg-slate-700 text-slate-900 dark:text-white
placeholder-slate-400 focus:outline-none focus:ring-2
focus:border-transparent transition"
style="--tw-ring-color: var(--company-color, #6366f1);"
>
{% if field.errors %}
<p class="text-sm text-red-500">{{ field.errors|join:", " }}</p>
@@ -54,7 +53,8 @@ Login
<button
type="submit"
class="btn w-full btn-action"
class="w-full py-3 rounded-lg font-semibold text-white transition hover:opacity-90 active:scale-95 mt-2"
style="background: var(--company-color, #6366f1);"
>
Anmelden
</button>
@@ -62,7 +62,6 @@ Login
</form>
</div>
</div>
{% endblock %}
+4 -4
View File
@@ -18,7 +18,7 @@ Mitarbeiter, Rollen und Berechtigungen verwalten
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div>
<h2 class="text-xl font-bold">Aktionen</h2>
<h2 class="text-lg font-bold">Aktionen</h2>
<p class="text-slate-400 text-sm mt-1">Mitarbeiter oder Rollen hinzufügen</p>
</div>
@@ -35,7 +35,7 @@ Mitarbeiter, Rollen und Berechtigungen verwalten
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors mb-8">
<div class="mb-6">
<h2 class="text-xl font-bold">
<h2 class="text-lg font-bold">
Benutzer
</h2>
@@ -59,7 +59,7 @@ Mitarbeiter, Rollen und Berechtigungen verwalten
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors mb-8">
<div class="mb-6">
<h2 class="text-xl font-bold">
<h2 class="text-lg font-bold">
Archivierte Benutzer
</h2>
@@ -79,7 +79,7 @@ Mitarbeiter, Rollen und Berechtigungen verwalten
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors mt-8">
<div class="mb-6">
<h2 class="text-xl font-bold">
<h2 class="text-lg font-bold">
Rollen
</h2>
@@ -91,7 +91,7 @@
type="text"
disabled
value="{{ item.last_login|date:'d.m.Y H:i' }}"
class="border rounded px-3 py-2 w-full bg-slate-100">
class="border rounded px-3 py-2 w-full bg-slate-100 dark:bg-slate-700">
</div>
@@ -86,12 +86,11 @@
<label class="block text-sm mb-1">
Letzter Login
</label>
<input
type="text"
disabled
value="{{ item.last_login|date:'d.m.Y H:i' }}"
class="border rounded px-3 py-2 w-full bg-slate-100">
class="border rounded px-3 py-2 w-full bg-slate-100 dark:bg-slate-700">
</div>
+4
View File
@@ -22,6 +22,7 @@ INSTALLED_APPS = [
'time_tracking',
'tailwind',
'theme',
'django_apscheduler',
"django_tables2",
"django_filters",
'django.contrib.admin',
@@ -83,6 +84,9 @@ USE_I18N = True
USE_TZ = True
STATIC_URL = 'static/'
STATICFILES_DIRS = [
BASE_DIR / "static",
]
STATIC_ROOT = BASE_DIR / 'staticfiles'
TAILWIND_APP_NAME = 'theme'
+9 -3
View File
@@ -1,17 +1,23 @@
from projects.models import ProjectMembership
from projects.models import ProjectMembership, Project
from .models import CompanySetting
def project_switcher(request):
if not request.user.is_authenticated:
return {"user_projects": []}
return {"user_projects": [], "archived_count": 0}
memberships = ProjectMembership.objects.select_related("project").filter(
user=request.user,
project__active=True
)
archived_count = ProjectMembership.objects.filter(
user=request.user,
project__active=False
).count()
return {
"user_projects": [m.project for m in memberships]
"user_projects": [m.project for m in memberships],
"archived_count": archived_count,
}
+6 -3
View File
@@ -4,10 +4,13 @@ from django import forms
class BaseForm(forms.ModelForm):
default_class = (
"w-full px-4 py-3 rounded-lg border border-slate-300 dark:border-slate-600 "
"bg-slate-50 dark:bg-slate-700 text-slate-900 dark:text-white "
"placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 "
"w-full px-4 py-2 rounded-lg border mt-1 "
"border-slate-300 dark:border-slate-600 "
"bg-slate-50 dark:bg-slate-700 "
"text-slate-900 dark:text-white "
"placeholder-slate-400 focus:outline-none "
"focus:border-transparent transition "
"[--tw-ring-color:var(--company-color)] focus:ring-2 "
)
def apply_style(self):
+2 -1
View File
@@ -1,4 +1,4 @@
# Generated by Django 6.0.6 on 2026-06-27 17:19
# Generated by Django 6.0.6 on 2026-06-28 10:19
from django.db import migrations, models
@@ -18,6 +18,7 @@ class Migration(migrations.Migration):
('companyname', models.CharField(max_length=255, verbose_name='Firmenname')),
('name', models.CharField(max_length=255, verbose_name='Geschäftsführer')),
('logo', models.ImageField(blank=True, null=True, upload_to='company/', verbose_name='Logo')),
('favicon', models.ImageField(blank=True, null=True, upload_to='company/', verbose_name='Favicon')),
('slogan', models.CharField(blank=True, max_length=255, verbose_name='Slogan')),
('color', models.CharField(default='#0f172a', max_length=20, verbose_name='Farbe')),
('street', models.CharField(max_length=255, verbose_name='Straße')),
+1
View File
@@ -5,6 +5,7 @@ class CompanySetting(models.Model):
companyname = models.CharField(max_length=255,verbose_name="Firmenname")
name = models.CharField(max_length=255,verbose_name="Geschäftsführer")
logo = models.ImageField(upload_to="company/",blank=True,null=True,verbose_name="Logo")
favicon = models.ImageField(upload_to="company/",blank=True,null=True,verbose_name="Favicon")
slogan = models.CharField(max_length=255,blank=True,verbose_name="Slogan")
color = models.CharField(max_length=20,default="#0f172a",verbose_name="Farbe")
street = models.CharField(max_length=255,verbose_name="Straße")
+47 -18
View File
@@ -18,6 +18,11 @@
</script>
{% tailwind_css %}
{% if company_settings.favicon %}
<link rel="icon" type="image/png" href="{{ company_settings.favicon.url }}">
{% else %}
<link rel="icon" type="image/png" href="{% static 'favicon.png' %}">
{% endif %}
<style>
:root {
@@ -34,14 +39,18 @@
onclick="toggleSidebar()"></div>
<!-- Sidebar -->
<aside id="sidebar" class="fixed top-0 z-40 w-72 h-screen bg-slate-800 text-white transform -translate-x-full lg:translate-x-0 transition-transform duration-300 flex flex-col border-r border-white/20">
<aside id="sidebar" class="fixed top-0 z-40 w-72 h-screen bg-slate-800 text-white transform -translate-x-full lg:translate-x-0 transition-transform duration-300 flex flex-col border-r border-white/10">
<div class="p-6 border-b border-white/20 " style="background-color: {{ company_settings.color }};">
<h2 class="text-xl font-bold">
<div class="px-5 py-5 border-b border-white/10">
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full flex-shrink-0" style="background:var(--company-color);"></div>
<div>
<p class="text-sm font-semibold text-white leading-none">
{{ company_settings.companyname|default:"Projektverwaltung" }}
</h2>
</p>
<p class="text-xs mt-1" style="color:rgba(255,255,255,.3);">Projektverwaltung</p>
</div>
</div>
</div>
<nav class="flex-1 p-4 space-y-2">
@@ -60,7 +69,7 @@
onclick="toggleProjects()"
class="w-full flex items-center justify-between px-4 py-3 hover:bg-slate-700">
<span>{{ current_project.name|default:"Projekt wählen" }}</span>
<span>Projekte</span>
<svg id="projectArrow"
class="w-4 h-4 transition-transform"
@@ -84,6 +93,21 @@
</a>
{% endfor %}
{# Trennlinie #}
<div class="mx-4 my-1 border-t border-slate-700/60"></div>
<a href="{% url 'projects:add' %}"
class="block px-8 py-3 hover:bg-slate-700 text-slate-400 hover:text-white transition-colors">
Hinzufügen
</a>
{% if archived_count %}
<a href="{% url 'projects:archived' %}"
class="block px-8 py-3 hover:bg-slate-700 text-slate-400 hover:text-white transition-colors">
Archiviert ({{ archived_count }})
</a>
{% endif %}
</div>
</div>
@@ -145,6 +169,11 @@
Posten
</a>
<a href="{% url 'invoices:invoices_payments_overview' %}"
class="block px-8 py-3 hover:bg-slate-700 transition">
Zahlungen
</a>
</div>
</div>
@@ -188,17 +217,17 @@
</nav>
<div class="p-4 border-t border-white/20">
<div class="p-4 border-t border-white/10">
{% if user.is_superuser %}
<div class="w-full rounded-xl flex items-center justify-between px-4 py-3 hover:bg-slate-700 mb-4 border-white/20 border">
<div class="w-full rounded-xl flex items-center justify-between px-4 py-3 hover:bg-slate-700 mb-4 border-white/10 border">
<a href="{% url 'admin:index' %}">Admin</a>
</div>
{% endif %}
<div class="flex gap-2 w-full">
<button id="theme-toggle" class="rounded-xl px-4 py-3 hover:bg-slate-700 border-white/20 border">🌙</button>
<button id="theme-toggle" class="rounded-xl px-4 py-3 hover:bg-slate-700 border-white/10 border">🌙</button>
<form method="post" action="{% url 'accounts:logout' %}" class="w-full">
{% csrf_token %}
<button class="w-full rounded-xl px-4 py-3 border-white/20 border" style="background-color: {{ company_settings.color }};">Abmelden</button>
<button class="w-full rounded-xl px-4 py-3 border-white/10 border" style="background-color: {{ company_settings.color }};">Abmelden</button>
</form>
</div>
</div>
@@ -217,16 +246,16 @@
<span class="ml-4 font-bold">{{ company_settings.companyname|default:"Projektverwaltung" }}</span>
</header>
<main class="flex-1 p-6 overflow-x-auto w-screen lg:w-auto">
<div class="p-6 rounded-xl shadow mb-8 bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<h1 class="text-3xl font-bold">
{% block header_title %}
{% endblock %}
{% block header %}
<div class="border border-slate-100 dark:border-slate-700 p-6 rounded-xl shadow mb-8 bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<h1 class="text-xl font-bold">
{% block header_title %}{% endblock %}
</h1>
<p class="text-slate-400 mt-1">
{% block header_description %}
{% endblock %}
<p class="text-slate-400 mt-1 text-md">
{% block header_description %}{% endblock %}
</p>
</div>
{% endblock %}
{% block content %}
{% endblock %}
</main>
+223 -83
View File
@@ -2,9 +2,7 @@
{% block title %}Dashboard{% endblock %}
{% block header_title %}
Dashboard
{% endblock %}
{% block header_title %}Dashboard{% endblock %}
{% block header_description %}
Willkommen {{ user.username }}
@@ -12,103 +10,245 @@ Willkommen {{ user.username }}
{% block content %}
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-4 mb-8">
<a href="{% url 'invoices:overview' %}" class="border border-white/20 p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex items-center gap-3 justify-center">
<h2 class="text-xl font-bold">Rechnungswesen</h2>
</div>
{# Navigation #}
<div class="grid grid-cols-2 md:grid-cols-4 gap-2 mb-6">
<a href="{% url 'invoices:overview' %}" class="group p-4 rounded-xl bg-white dark:bg-slate-800 border border-slate-100 dark:border-slate-700 hover:border-[var(--company-color)]/40 hover:shadow-sm transition-all duration-200">
<p class="text-[11px] text-slate-400 mb-0.5">Modul</p>
<p class="text-sm font-semibold text-slate-800 dark:text-white">Rechnungswesen</p>
</a>
<!--
<a href="" class="border border-white/20 p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex items-center gap-3 justify-center">
<h2 class="text-xl font-bold">Finanzübersicht</h2>
</div>
<a href="{% url 'inventory_global:overview' %}" class="group p-4 rounded-xl bg-white dark:bg-slate-800 border border-slate-100 dark:border-slate-700 hover:border-[var(--company-color)]/40 hover:shadow-sm transition-all duration-200">
<p class="text-[11px] text-slate-400 mb-0.5">Modul</p>
<p class="text-sm font-semibold text-slate-800 dark:text-white">Inventar</p>
</a>
<a href="" class="border border-white/20 p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex items-center gap-3 justify-center">
<h2 class="text-xl font-bold">Arbeitszeiten</h2>
</div>
</a> -->
<a href="{% url 'inventory_global:overview' %}" class="border border-white/20 p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex items-center gap-3 justify-center">
<h2 class="text-xl font-bold">Inventar</h2>
</div>
<a href="{% url 'accounts:overview' %}" class="group p-4 rounded-xl bg-white dark:bg-slate-800 border border-slate-100 dark:border-slate-700 hover:border-[var(--company-color)]/40 hover:shadow-sm transition-all duration-200">
<p class="text-[11px] text-slate-400 mb-0.5">Modul</p>
<p class="text-sm font-semibold text-slate-800 dark:text-white">Mitarbeiter</p>
</a>
<!--
<a href="" class="border border-white/20 p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex items-center gap-3 justify-center">
<h2 class="text-xl font-bold">Investitionen</h2>
</div>
</a>
-->
<a href="{% url 'accounts:overview' %}" class="border border-white/20 p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex items-center gap-3 justify-center">
<h2 class="text-xl font-bold">Mitarbeiter</h2>
</div>
</a>
<a href="{% url 'masterdata:overview' %}" class="border border-white/20 p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex items-center gap-3 justify-center">
<h2 class="text-xl font-bold">Einstellungen</h2>
</div>
<a href="{% url 'masterdata:overview' %}" class="group p-4 rounded-xl bg-white dark:bg-slate-800 border border-slate-100 dark:border-slate-700 hover:border-[var(--company-color)]/40 hover:shadow-sm transition-all duration-200">
<p class="text-[11px] text-slate-400 mb-0.5">Modul</p>
<p class="text-sm font-semibold text-slate-800 dark:text-white">Einstellungen</p>
</a>
</div>
{# Haupt-Dashboard #}
<div class="bg-white dark:bg-slate-800 rounded-2xl border border-slate-100 dark:border-slate-700 overflow-hidden mb-6">
<div class="mb-8">
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div>
<h2 class="text-xl font-bold">Aktionen</h2>
{# KPIs #}
<div class="px-5 pt-5 pb-4 border-b border-slate-100 dark:border-slate-700">
<p class="text-[11px] font-semibold uppercase tracking-widest text-slate-400 mb-3">Handlungsbedarf</p>
<div class="grid grid-cols-2 md:grid-cols-4 gap-2" id="kpiMetrics">
<div class="rounded-xl bg-slate-100 dark:bg-slate-700 p-4 animate-pulse h-16"></div>
<div class="rounded-xl bg-slate-100 dark:bg-slate-700 p-4 animate-pulse h-16"></div>
<div class="rounded-xl bg-slate-100 dark:bg-slate-700 p-4 animate-pulse h-16"></div>
<div class="rounded-xl bg-slate-100 dark:bg-slate-700 p-4 animate-pulse h-16"></div>
</div>
</div>
<div class="flex flex-col sm:flex-row gap-2">
<a href="{% url 'projects:add' %}" class="btn btn-add">+ Projekt</a>
{# Charts #}
<div class="px-5 py-4 border-b border-slate-100 dark:border-slate-700">
<p class="text-[11px] font-semibold uppercase tracking-widest text-slate-400 mb-3">Aktueller Monat</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-2">
<div class="rounded-xl bg-slate-100 dark:bg-slate-700 p-4">
<div class="flex items-center justify-between mb-3">
<p class="text-xs font-semibold text-slate-600 dark:text-slate-300">Rechnungen</p>
<a href="{% url 'invoices:invoices_invoices_overview' %}" class="text-[11px] text-[var(--company-color)] hover:underline">Alle →</a>
</div>
<div style="position:relative;height:120px;">
<canvas id="invoiceChart" role="img" aria-label="Rechnungen nach Status"></canvas>
</div>
<div id="invoiceLegend" class="flex flex-wrap gap-x-2 gap-y-1 mt-3"></div>
</div>
<div class="rounded-xl bg-slate-100 dark:bg-slate-700 p-4">
<div class="flex items-center justify-between mb-3">
<p class="text-xs font-semibold text-slate-600 dark:text-slate-300">Angebote</p>
<a href="{% url 'invoices:invoices_offers_overview' %}" class="text-[11px] text-[var(--company-color)] hover:underline">Alle →</a>
</div>
<div style="position:relative;height:120px;">
<canvas id="offerChart" role="img" aria-label="Angebote nach Status"></canvas>
</div>
<div id="offerLegend" class="flex flex-wrap gap-x-2 gap-y-1 mt-3"></div>
</div>
<div class="rounded-xl bg-slate-100 dark:bg-slate-700 p-4">
<div class="flex items-center justify-between mb-3">
<p class="text-xs font-semibold text-slate-600 dark:text-slate-300">Mahnungen</p>
<a href="{% url 'invoices:invoices_reminders_overview' %}" class="text-[11px] text-[var(--company-color)] hover:underline">Alle →</a>
</div>
<div style="position:relative;height:120px;">
<canvas id="reminderChart" role="img" aria-label="Mahnungen nach Status"></canvas>
</div>
<div id="reminderLegend" class="flex flex-wrap gap-x-2 gap-y-1 mt-3"></div>
</div>
</div>
</div>
{# Kunden #}
<div class="px-5 py-4">
<p class="text-[11px] font-semibold uppercase tracking-widest text-slate-400 mb-3">Kunden</p>
<div class="grid grid-cols-2 md:grid-cols-4 gap-2" id="customerMetrics">
<div class="rounded-xl bg-slate-100 dark:bg-slate-700 p-4 animate-pulse h-16"></div>
<div class="rounded-xl bg-slate-100 dark:bg-slate-700 p-4 animate-pulse h-16"></div>
<div class="rounded-xl bg-slate-100 dark:bg-slate-700 p-4 animate-pulse h-16"></div>
<div class="rounded-xl bg-slate-100 dark:bg-slate-700 p-4 animate-pulse h-16"></div>
</div>
</div>
{% if memberships %}
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
{% for m in memberships %}
<a href="{% url 'projects:overview' m.project.id %}"
class="p-6 rounded-xl shadow hover:shadow-lg transition block text-white"
style="background: linear-gradient(135deg, {{ m.project.color }}, {{ m.project.color }}ff);">
<h2 class="text-xl font-bold">
{{ m.project.name }}
</h2>
<p class="text-sm mt-2">
Rolle: {{ m.get_role_display }}
</p>
</div>
<script>
window.URLS = {
invoiceInfo: "{% url 'invoices:ajax_invoice_info' %}",
offerInfo: "{% url 'invoices:ajax_offer_info' %}",
reminderInfo: "{% url 'invoices:ajax_reminder_info' %}",
customerInfo: "{% url 'invoices:ajax_customer_info' %}",
kpiInfo: "{% url 'invoices:ajax_kpi_info' %}",
};
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.js"></script>
<script>
const STATUS_META = {
draft: { label: "Entwurf", color: "#94a3b8" },
sent: { label: "Versendet", color: "#2a78d6" },
paid: { label: "Bezahlt", color: "#1baf7a" },
overdue: { label: "Überfällig", color: "#e34948" },
canceled: { label: "Storniert", color: "#eb6834" },
accepted: { label: "Akzeptiert", color: "#1baf7a" },
rejected: { label: "Abgelehnt", color: "#e34948" },
expired: { label: "Frist abgelaufen", color: "#eda100" },
};
function buildDonut(canvasId, legendId, data) {
const entries = Object.entries(data).filter(([, v]) => v > 0);
if (!entries.length) {
document.getElementById(canvasId).parentElement.innerHTML =
'<p class="text-[11px] text-slate-400 text-center py-8">Keine Einträge diesen Monat</p>';
return;
}
const keys = entries.map(([k]) => k);
const values = entries.map(([, v]) => v);
const colors = keys.map(k => (STATUS_META[k] || { color: "#888" }).color);
const labels = keys.map(k => (STATUS_META[k] || { label: k }).label);
const total = values.reduce((a, b) => a + b, 0);
new Chart(document.getElementById(canvasId), {
type: 'doughnut',
data: {
labels,
datasets: [{
data: values,
backgroundColor: colors,
borderWidth: 0,
hoverOffset: 3,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '72%',
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: ctx => ` ${ctx.label}: ${ctx.parsed} (${Math.round(ctx.parsed / total * 100)}%)`
}
}
}
}
});
const legend = document.getElementById(legendId);
keys.forEach((k, i) => {
const item = document.createElement('span');
item.className = 'flex items-center gap-1 text-[11px] text-slate-400 dark:text-slate-400';
item.innerHTML = `
<span style="width:6px;height:6px;border-radius:50%;background:${colors[i]};display:inline-block;flex-shrink:0;"></span>
${labels[i]}: <span class="font-medium text-slate-700 dark:text-slate-200">${values[i]}</span>
`;
legend.appendChild(item);
});
}
async function fetchAndRender(url, canvasId, legendId) {
try {
const r = await fetch(url);
if (!r.ok) throw new Error();
buildDonut(canvasId, legendId, await r.json());
} catch {
document.getElementById(canvasId).parentElement.innerHTML =
'<p class="text-[11px] text-slate-400 text-center py-8">Keine Daten</p>';
}
}
async function fetchKpis(url) {
try {
const r = await fetch(url);
if (!r.ok) throw new Error();
const d = await r.json();
document.getElementById('kpiMetrics').innerHTML = `
<a href="{% url 'invoices:invoices_invoices_overview' %}?status=overdue"
class="rounded-xl bg-slate-100 dark:bg-slate-700 p-4 transition-colors block">
<p class="text-[11px] text-slate-400 mb-1">Überfällige Rechnungen</p>
<p class="text-2xl font-semibold ${d.overdue_invoices > 0 ? 'text-red-500' : 'text-slate-800 dark:text-white'}">${d.overdue_invoices}</p>
</a>
{% endfor %}
</div>
{% else %}
<div class="bg-white dark:bg-slate-800 border border-orange-500 rounded-xl p-6 text-orange-500">
Du bist noch keinem aktiven Projekt zugewiesen.
</div>
{% endif %}
{% if archived_count %}
<a href="{% url 'projects:archived' %}" class="text-sm text-slate-400 hover:text-slate-500">
{{ archived_count }} archivierte
{% if archived_count == 1 %}
Projekt anzeigen
{% else %}
Projekte anzeigen
{% endif %}
<a href="{% url 'invoices:invoices_invoices_overview' %}?status=sent"
class="rounded-xl bg-slate-100 dark:bg-slate-700 p-4 transition-colors block">
<p class="text-[11px] text-slate-400 mb-1">Offene Rechnungen</p>
<p class="text-2xl font-semibold text-slate-800 dark:text-white">${d.open_invoices}</p>
</a>
{% endif %}
<a href="{% url 'invoices:invoices_offers_overview' %}?status=sent"
class="rounded-xl bg-slate-100 dark:bg-slate-700 p-4 transition-colors block">
<p class="text-[11px] text-slate-400 mb-1">Offene Angebote</p>
<p class="text-2xl font-semibold text-slate-800 dark:text-white">${d.open_offers}</p>
</a>
<a href="{% url 'invoices:invoices_reminders_overview' %}?status=sent"
class="rounded-xl bg-slate-100 dark:bg-slate-700 p-4 transition-colors block">
<p class="text-[11px] text-slate-400 mb-1">Offene Mahnungen</p>
<p class="text-2xl font-semibold ${d.open_reminders > 0 ? 'text-amber-500' : 'text-slate-800 dark:text-white'}">${d.open_reminders}</p>
</a>
`;
} catch {
document.getElementById('kpiMetrics').innerHTML =
'<p class="text-[11px] text-slate-400">Keine Daten</p>';
}
}
async function fetchCustomers(url) {
try {
const r = await fetch(url);
if (!r.ok) throw new Error();
const d = await r.json();
document.getElementById('customerMetrics').innerHTML = `
<div class="rounded-xl bg-slate-100 dark:bg-slate-700 p-4">
<p class="text-[11px] text-slate-400 mb-1">Gesamt</p>
<p class="text-2xl font-semibold text-slate-800 dark:text-white">${d.total}</p>
</div>
<div class="rounded-xl bg-slate-100 dark:bg-slate-700 p-4">
<p class="text-[11px] text-slate-400 mb-1">Neue diese Woche</p>
<p class="text-2xl font-semibold text-slate-800 dark:text-white">${d.new_this_week}</p>
</div>
<div class="rounded-xl bg-slate-100 dark:bg-slate-700 p-4">
<p class="text-[11px] text-slate-400 mb-1">Privatkunden</p>
<p class="text-2xl font-semibold text-slate-800 dark:text-white">${d.private}</p>
</div>
<div class="rounded-xl bg-slate-100 dark:bg-slate-700 p-4">
<p class="text-[11px] text-slate-400 mb-1">Unternehmen</p>
<p class="text-2xl font-semibold text-slate-800 dark:text-white">${d.companies}</p>
</div>
`;
} catch {
document.getElementById('customerMetrics').innerHTML =
'<p class="text-[11px] text-slate-400">Keine Daten</p>';
}
}
fetchKpis(window.URLS.kpiInfo);
fetchAndRender(window.URLS.invoiceInfo, 'invoiceChart', 'invoiceLegend');
fetchAndRender(window.URLS.offerInfo, 'offerChart', 'offerLegend');
fetchAndRender(window.URLS.reminderInfo, 'reminderChart', 'reminderLegend');
fetchCustomers(window.URLS.customerInfo);
</script>
{% endblock %}
+1 -1
View File
@@ -1,4 +1,4 @@
# Generated by Django 6.0.6 on 2026-06-27 17:19
# Generated by Django 6.0.6 on 2026-06-28 10:19
import documents.models
from django.db import migrations, models
+1 -1
View File
@@ -1,4 +1,4 @@
# Generated by Django 6.0.6 on 2026-06-27 17:19
# Generated by Django 6.0.6 on 2026-06-28 10:19
import django.db.models.deletion
from django.conf import settings
+2 -2
View File
@@ -17,7 +17,7 @@ Dateien & Bilder
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div>
<h2 class="text-xl font-bold">Aktionen</h2>
<h2 class="text-lg font-bold">Aktionen</h2>
<p class="text-slate-400 text-sm mt-1">Dokumente hochladen</p>
</div>
@@ -39,7 +39,7 @@ Dateien & Bilder
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors mb-2" >
<div class="flex items-center justify-between mb-2">
<h2 class="text-xl font-bold">
<h2 class="text-lg font-bold">
{{ cat.name }}
</h2>
+4 -3
View File
@@ -1,5 +1,6 @@
# Generated by Django 6.0.6 on 2026-06-27 17:19
# Generated by Django 6.0.6 on 2026-06-28 10:19
import django.core.validators
import django.db.models.deletion
import finance.models
from django.conf import settings
@@ -31,7 +32,7 @@ class Migration(migrations.Migration):
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, null=True, verbose_name='Name')),
('amount', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Betrag (€)')),
('amount', models.DecimalField(decimal_places=2, max_digits=10, validators=[django.core.validators.MinValueValidator(0, message='Der Betrag darf nicht negativ sein.')], verbose_name='Betrag (€)')),
('date', models.DateTimeField(verbose_name='Datum')),
('note', models.TextField(blank=True, verbose_name='Notiz')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Erstellt am')),
@@ -64,7 +65,7 @@ class Migration(migrations.Migration):
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='Name')),
('amount', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Betrag (€)')),
('amount', models.DecimalField(decimal_places=2, max_digits=10, validators=[django.core.validators.MinValueValidator(0, message='Der Betrag darf nicht negativ sein.')], verbose_name='Betrag (€)')),
('date', models.DateTimeField(verbose_name='Datum')),
('receipt', models.FileField(blank=True, null=True, upload_to=finance.models.receipt_upload_path, verbose_name='Beleg')),
('note', models.TextField(blank=True, verbose_name='Notiz')),
+7 -1
View File
@@ -1,4 +1,4 @@
# Generated by Django 6.0.6 on 2026-06-27 17:19
# Generated by Django 6.0.6 on 2026-06-28 10:19
import django.db.models.deletion
from django.conf import settings
@@ -11,6 +11,7 @@ class Migration(migrations.Migration):
dependencies = [
('finance', '0001_initial'),
('invoices', '0001_initial'),
('projects', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
@@ -31,6 +32,11 @@ class Migration(migrations.Migration):
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Erstellt von'),
),
migrations.AddField(
model_name='income',
name='invoice',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='income', to='invoices.invoice'),
),
migrations.AddField(
model_name='income',
name='project',
+13 -2
View File
@@ -2,6 +2,9 @@ from django.db import models
from django.contrib.auth import get_user_model
from projects.models import Project
import os
from django.core.validators import MinValueValidator
User = get_user_model()
@@ -42,7 +45,7 @@ class Expense(models.Model):
supplier = models.ForeignKey(Supplier, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Lieferant")
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, verbose_name="Erstellt von")
payment_method = models.ForeignKey(PaymentMethod, on_delete=models.SET_NULL, null=True, verbose_name="Zahlungsmethode")
amount = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Betrag (€)")
amount = models.DecimalField(max_digits=10,decimal_places=2,verbose_name="Betrag (€)",validators=[MinValueValidator(0,message="Der Betrag darf nicht negativ sein.")])
date = models.DateTimeField(verbose_name="Datum")
receipt = models.FileField(upload_to=receipt_upload_path, null=True, blank=True, verbose_name="Beleg")
note = models.TextField(blank=True, verbose_name="Notiz")
@@ -54,8 +57,16 @@ class Expense(models.Model):
class Income(models.Model):
name = models.CharField(max_length=255, verbose_name="Name", null=True)
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="incomes", verbose_name="Projekt")
invoice = models.OneToOneField(
"invoices.Invoice",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="income"
)
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, verbose_name="Kategorie")
amount = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Betrag (€)")
amount = models.DecimalField(max_digits=10,decimal_places=2,verbose_name="Betrag (€)",validators=[MinValueValidator(0,message="Der Betrag darf nicht negativ sein.")])
date = models.DateTimeField(verbose_name="Datum")
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, verbose_name="Erstellt von")
payment_method = models.ForeignKey(PaymentMethod, on_delete=models.SET_NULL, null=True, verbose_name="Zahlungsmethode")
+71 -21
View File
@@ -20,7 +20,7 @@ Einnahmen & Ausgaben
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div>
<h2 class="text-xl font-bold">Aktionen</h2>
<h2 class="text-lg font-bold">Aktionen</h2>
<p class="text-slate-400 text-sm mt-1">Einnahmen und Ausgaben tracken</p>
</div>
@@ -40,7 +40,7 @@ Einnahmen & Ausgaben
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="mb-6">
<h2 class="text-xl font-bold">Filter</h2>
<h2 class="text-lg font-bold">Filter</h2>
<p class="text-slate-400 text-sm mt-1">Zeitraum für aktuelle Auswertung</p>
</div>
@@ -72,29 +72,79 @@ Einnahmen & Ausgaben
<!-- ===================== -->
<!-- KPI FLOATING CARDS -->
<!-- ===================== -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-2">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<p class="text-slate-400 text-sm mt-1">Einnahmen</p>
<p class="text-3xl font-bold text-green-600 mt-1">
<!-- Einnahmen -->
<div class="p-6 rounded-xl border border-green-200 dark:border-green-900 bg-white dark:bg-slate-800 transition">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-slate-500 dark:text-slate-400">
Einnahmen
</p>
<p class="text-3xl font-bold text-green-600 mt-2">
{{ total_income }} €
</p>
</div>
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<p class="text-slate-400 text-sm mt-1">Ausgaben</p>
<p class="text-3xl font-bold text-red-600 mt-1">
<div class="flex items-center justify-center w-12 h-12 rounded-xl bg-green-100 dark:bg-green-900/40">
<span class="text-2xl text-green-600">
↑
</span>
</div>
</div>
</div>
<!-- Ausgaben -->
<div class="p-6 rounded-xl border border-red-200 dark:border-red-900 bg-white dark:bg-slate-800 transition">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-slate-500 dark:text-slate-400">
Ausgaben
</p>
<p class="text-3xl font-bold text-red-600 mt-2">
{{ total_expense }} €
</p>
</div>
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<p class="text-slate-400 text-sm mt-1">Gewinn</p>
<p class="text-3xl font-bold text-blue-600 mt-1">
<div class="flex items-center justify-center w-12 h-12 rounded-xl bg-red-100 dark:bg-red-900/40">
<span class="text-2xl text-red-600">
↓
</span>
</div>
</div>
</div>
<!-- Gewinn -->
<div class="p-6 rounded-xl border border-blue-200 dark:border-blue-900 bg-white dark:bg-slate-800 transition">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-slate-500 dark:text-slate-400">
Gewinn
</p>
<p class="text-3xl font-bold text-blue-600 mt-2">
{{ total_profit }} €
</p>
</div>
<div class="flex items-center justify-center w-12 h-12 rounded-xl bg-blue-100 dark:bg-blue-900/40">
<span class="text-2xl text-blue-600">
€
</span>
</div>
</div>
</div>
</div>
@@ -106,21 +156,21 @@ Einnahmen & Ausgaben
<!-- INCOME -->
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="p-4">
<div class="pb-4">
<h3 class="font-semibold text-green-600">Einnahmen</h3>
</div>
<div class="divide-y">
<div class="flex flex-col gap-2 max-h-80 overflow-y-auto hide-scrollbar">
{% for i in incomes %}
<div class="p-4 flex justify-between">
<div class="p-4 flex justify-between bg-slate-100 dark:bg-slate-700 rounded-xl">
<div>
<p class="font-medium text-green-600">+{{ i.amount }} € {{ i.name }}</p>
<p class="text-slate-400 text-sm mt-1">{{ i.date|date:"d.m.Y" }} · {{ i.category }}</p>
</div>
</div>
{% empty %}
<p class="p-4 text-slate-400">Keine Einnahmen</p>
<div class="bg-white dark:bg-slate-800 border border-slate-500 rounded-xl p-6 text-slate-500">Keine Einnahmen</div>
{% endfor %}
</div>
@@ -131,21 +181,21 @@ Einnahmen & Ausgaben
<!-- EXPENSE -->
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="p-4">
<div class="pb-4">
<h3 class="font-semibold text-red-600">Ausgaben</h3>
</div>
<div class="divide-y">
<div class="flex flex-col gap-2 max-h-80 overflow-y-auto hide-scrollbar">
{% for e in expenses %}
<div class="p-4 flex justify-between">
<div class="p-4 flex justify-between bg-slate-100 dark:bg-slate-700 rounded-xl">
<div>
<p class="font-medium text-red-600">-{{ e.amount }} € {{ e.name }}</p>
<p class="text-slate-400 text-sm mt-1">{{ e.date|date:"d.m.Y" }} · {{ e.category }}</p>
</div>
</div>
{% empty %}
<p class="p-4 text-slate-400">Keine Ausgaben</p>
<div class="bg-white dark:bg-slate-800 border border-slate-500 rounded-xl p-6 text-slate-500">Keine Ausgaben</div>
{% endfor %}
</div>
@@ -160,7 +210,7 @@ Einnahmen & Ausgaben
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="mb-6">
<h2 class="text-xl font-bold">
<h2 class="text-lg font-bold">
Zeitraum-Analyse
</h2>
+44 -10
View File
@@ -11,6 +11,8 @@ from django.http import HttpResponse
from calendar import monthrange
from .forms import IncomeForm, ExpenseForm
from .tables import PeriodAnalyticsTable, FinanceTransactionTable
import json
from inventory.models import ShoppingItem
# =========================================================
# OVERVIEW
@@ -102,23 +104,35 @@ def finance_overview(request, project_id):
}
period_data = []
period_keys = set()
for i in income_stats:
period_start = i["period_key"]
income_total = i["total"] or 0
expense_total = expense_map.get(str(period_start), 0)
period_keys.add(i["period_key"])
for e in expense_stats:
period_keys.add(e["period_key"])
income_map = {
str(i["period_key"]): i["total"] or 0
for i in income_stats
}
expense_map = {
str(e["period_key"]): e["total"] or 0
for e in expense_stats
}
for period_start in sorted(period_keys, reverse=True):
income_total = income_map.get(str(period_start), 0)
expense_total = expense_map.get(str(period_start), 0)
if period == "year":
period_end = period_start.replace(month=12, day=31)
elif period == "week":
period_end = period_start + timedelta(days=6)
elif period == "day":
period_end = period_start
else:
last_day = monthrange(period_start.year, period_start.month)[1]
last_day = monthrange(
period_start.year,
period_start.month
)[1]
period_end = period_start.replace(day=last_day)
period_data.append({
@@ -319,10 +333,30 @@ def add_expense(request, project_id):
expense.project = project
expense.created_by = request.user
expense.save()
return redirect("finance:overview", project_id=project.id)
shopping_ids = request.session.pop(
"shopping_item_ids",
[]
)
if shopping_ids:
ShoppingItem.objects.filter(
id__in=shopping_ids,
project=project
).delete()
return redirect(
"finance:overview",
project_id=project.id
)
else:
form = ExpenseForm()
initial = request.session.pop(
"expense_initial",
{}
)
form = ExpenseForm(initial=initial)
return render(request, "finance/form.html", {
"form": form,
-1
View File
@@ -5,7 +5,6 @@ from core.forms.base import BaseForm
class InventoryItemForm(BaseForm):
class Meta:
model = InventoryItem
fields = [
+6 -5
View File
@@ -1,5 +1,6 @@
# Generated by Django 6.0.6 on 2026-06-27 17:19
# Generated by Django 6.0.6 on 2026-06-28 10:19
import django.core.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
@@ -44,8 +45,8 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='Name')),
('description', models.TextField(blank=True, verbose_name='Beschreibung')),
('quantity', models.PositiveIntegerField(default=1, verbose_name='Anzahl')),
('price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Preis')),
('quantity', models.PositiveIntegerField(default=1, validators=[django.core.validators.MinValueValidator(0, message='Die Anzahl darf nicht negativ sein.')], verbose_name='Anzahl')),
('price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, validators=[django.core.validators.MinValueValidator(0, message='Der Betrag darf nicht negativ sein.')], verbose_name='Preis')),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
@@ -60,7 +61,7 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='Name')),
('description', models.TextField(blank=True, verbose_name='Beschreibung')),
('quantity', models.PositiveIntegerField(default=0, verbose_name='Anzahl')),
('quantity', models.PositiveIntegerField(default=0, validators=[django.core.validators.MinValueValidator(0, message='Die Anzahl darf nicht negativ sein.')], verbose_name='Anzahl')),
('article_number', models.CharField(blank=True, max_length=255, verbose_name='Artikelnummer')),
('created_at', models.DateTimeField(auto_now_add=True)),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='inventory.inventorycategory', verbose_name='Kategorie')),
@@ -77,7 +78,7 @@ class Migration(migrations.Migration):
name='ProjectInventoryItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.PositiveIntegerField(default=1, verbose_name='Anzahl')),
('quantity', models.PositiveIntegerField(default=1, validators=[django.core.validators.MinValueValidator(0, message='Die Anzahl darf nicht negativ sein.')], verbose_name='Anzahl')),
('status', models.CharField(choices=[('open', 'Offen'), ('packed', 'Eingepackt'), ('on_site', 'Vor Ort')], default='open', max_length=20, verbose_name='Status')),
('created_at', models.DateTimeField(auto_now_add=True)),
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='inventory.inventoryitem', verbose_name='Artikel')),
+1 -1
View File
@@ -1,4 +1,4 @@
# Generated by Django 6.0.6 on 2026-06-27 17:19
# Generated by Django 6.0.6 on 2026-06-28 10:19
import django.db.models.deletion
from django.db import migrations, models
+5 -4
View File
@@ -3,6 +3,7 @@ from django.contrib.auth.models import User
from projects.models import Project
from finance.models import Category, Supplier
from django.db.models import Sum
from django.core.validators import MinValueValidator
class InventoryCategory(models.Model):
name = models.CharField(max_length=255, unique=True, verbose_name="Name")
@@ -31,7 +32,7 @@ class InventoryLocation(models.Model):
class InventoryItem(models.Model):
name = models.CharField(max_length=255, verbose_name="Name")
description = models.TextField(blank=True, verbose_name="Beschreibung")
quantity = models.PositiveIntegerField(default=0, verbose_name="Anzahl")
quantity = models.PositiveIntegerField(default=0, verbose_name="Anzahl", validators=[MinValueValidator(0,message="Die Anzahl darf nicht negativ sein.")])
location = models.ForeignKey(InventoryLocation, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Standort")
article_number = models.CharField(max_length=255, blank=True, verbose_name="Artikelnummer")
responsible = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Verantwortlicher")
@@ -55,7 +56,7 @@ class ProjectInventoryItem(models.Model):
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="inventory_items", verbose_name="Projekt")
item = models.ForeignKey(InventoryItem, on_delete=models.CASCADE, verbose_name="Artikel")
quantity = models.PositiveIntegerField(default=1, verbose_name="Anzahl")
quantity = models.PositiveIntegerField(default=1, verbose_name="Anzahl", validators=[MinValueValidator(0,message="Die Anzahl darf nicht negativ sein.")])
status = models.CharField(max_length=20, choices=Status.choices, default=Status.OPEN, verbose_name="Status")
created_at = models.DateTimeField(auto_now_add=True)
@@ -72,9 +73,9 @@ class ShoppingItem(models.Model):
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="shopping_items", verbose_name="Projekt")
name = models.CharField(max_length=255, verbose_name="Name")
description = models.TextField(blank=True, verbose_name="Beschreibung")
quantity = models.PositiveIntegerField(default=1, verbose_name="Anzahl")
quantity = models.PositiveIntegerField(default=1, verbose_name="Anzahl", validators=[MinValueValidator(0,message="Die Anzahl darf nicht negativ sein.")])
supplier = models.ForeignKey(Supplier, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Lieferant")
price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, verbose_name="Preis")
price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, verbose_name="Preis",validators=[MinValueValidator(0,message="Der Betrag darf nicht negativ sein.")])
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Kategorie")
created_at = models.DateTimeField(auto_now_add=True)
+8 -1
View File
@@ -32,7 +32,6 @@ class InventoryGlobalTable(tables.Table):
model = InventoryItem
template_name = "django_tables2/table_toggle.html"
fields = (
"expand",
"name",
"category",
"available",
@@ -85,6 +84,11 @@ class InventoryAddTable(tables.Table):
}
class ShoppingItemTable(tables.Table):
selection = tables.TemplateColumn(
template_name="inventory/tables/shopping/selection.html",
orderable=False,
verbose_name="",
)
name = tables.TemplateColumn(
template_name="inventory/tables/shopping/name.html",
@@ -125,6 +129,7 @@ class ShoppingItemTable(tables.Table):
class Meta:
model = ShoppingItem
fields = (
"selection",
"name",
"category",
"quantity",
@@ -137,6 +142,8 @@ class ShoppingItemTable(tables.Table):
"class": "w-full text-sm"
}
class ProjectInventoryItemTable(tables.Table):
name = tables.Column(
@@ -18,7 +18,7 @@ Globale Inventarverwaltung
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div>
<h2 class="text-xl font-bold">Aktionen</h2>
<h2 class="text-lg font-bold">Aktionen</h2>
<p class="text-slate-400 text-sm mt-1">Inventarartikel hinzufügen</p>
</div>
@@ -34,7 +34,7 @@ Globale Inventarverwaltung
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="mb-6">
<h2 class="text-xl font-bold">Inventarliste</h2>
<h2 class="text-lg font-bold">Inventarliste</h2>
<p class="text-slate-400 text-sm mt-1">Gelagerte und verfügbare Artikel</p>
</div>
+9 -5
View File
@@ -18,7 +18,7 @@ Vorhandenen & benötigtes Material
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div>
<h2 class="text-xl font-bold">Aktionen</h2>
<h2 class="text-lg font-bold">Aktionen</h2>
<p class="text-slate-400 text-sm mt-1">Artikel zur Packliste oder Einkaufsliste hinzufügen</p>
</div>
@@ -34,7 +34,7 @@ Vorhandenen & benötigtes Material
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="mb-6">
<h2 class="text-xl font-bold">Packliste</h2>
<h2 class="text-lg font-bold">Packliste</h2>
<p class="text-slate-400 text-sm mt-1">Vorhandene und benötigte Artikel</p>
</div>
@@ -53,7 +53,7 @@ Vorhandenen & benötigtes Material
<div class="flex flex-wrap justify-between items-center mb-6">
<div>
<h2 class="text-xl font-bold">
<h2 class="text-lg font-bold">
Einkaufsliste
</h2>
@@ -64,9 +64,13 @@ Vorhandenen & benötigtes Material
</div>
<div class="overflow-x-auto rounded-xl">
<form id="bulk-shopping-form" method="post" action="{% url 'inventory:shopping_to_expense' project.id %}" class="rounded-xl">
{% csrf_token %}
<button type="submit" class="btn btn-action mb-2">
Zu Ausgabe konvertieren
</button>
</form>
{% render_table shopping_table %}
</div>
</div>
@@ -10,7 +10,7 @@
name="item_id"
value="{{ record.id }}"
form="save-form-{{ record.id }}">
<div class="flex gap-2">
<button
type="submit"
form="save-form-{{ record.id }}"
@@ -25,5 +25,5 @@
class="btn btn-delete">
Löschen
</button>
</div>
</form>
@@ -3,8 +3,12 @@
{{ record.available_quantity }}
</span>
{% elif record.available_quantity == 0 %}
<span class="text-orange-600 font-semibold">
<span class="text-yellow-600 font-semibold">
0
</span>
{% elif record.available_quantity < 0 %}
<span class="text-orange-600 font-semibold">
{{ record.available_quantity }}
</span>
{% endif %}
/ {{ record.quantity }}
@@ -1,7 +1,7 @@
<form
id="shopping-save-form-{{ record.id }}"
method="post"
action="{% url 'inventory:shopping_edit' table.project.id record.id %}">
action="{% url 'inventory:shopping_edit' shopping_table.project.id record.id %}">
{% csrf_token %}
@@ -22,7 +22,7 @@
<button
type="submit"
form="shopping-save-form-{{ record.id }}"
formaction="{% url 'inventory:shopping_delete' table.project.id record.id %}"
formaction="{% url 'inventory:shopping_delete' shopping_table.project.id record.id %}"
class="btn btn-delete">
Löschen
</button>
@@ -5,7 +5,7 @@
<option value="">---</option>
{% for c in table.categories %}
{% for c in shopping_table.categories %}
<option value="{{ c.id }}"
{% if record.category_id == c.id %}selected{% endif %}>
{{ c.name }}
@@ -0,0 +1,5 @@
<input
type="checkbox"
name="selection"
value="{{ record.id }}"
form="bulk-shopping-form">
@@ -5,7 +5,7 @@
<option value="">---</option>
{% for s in table.suppliers %}
{% for s in shopping_table.suppliers %}
<option value="{{ s.id }}"
{% if record.supplier_id == s.id %}selected{% endif %}>
{{ s.name }}
+2
View File
@@ -13,4 +13,6 @@ urlpatterns = [
path("<int:project_id>/inventory/delete/<int:item_id>/", views.inventory_delete, name="delete"),
path("<int:project_id>/inventory/shopping-delete/<int:item_id>/", views.shopping_delete, name="shopping_delete"),
path("<int:project_id>/inventory/shopping-to-expense/", views.shopping_to_expense, name="shopping_to_expense"),
]
+93
View File
@@ -9,6 +9,9 @@ from django.db.models import Sum, Value, F
from django_tables2 import RequestConfig
from .tables import ProjectInventoryItemTable, ShoppingItemTable, InventoryAddTable, InventoryGlobalTable
from django.db.models.functions import Coalesce
import json
from collections import Counter
from django.utils import timezone
@login_required
@permission_required("projects_inventory_inventory_view")
@@ -429,3 +432,93 @@ def inventory_global_edit(request, item_id):
"users": User.objects.order_by("username"),
}
)
@login_required
@permission_required("projects_inventory_shopping_to_expense")
def shopping_to_expense(request, project_id):
ids = request.POST.getlist("selection")
items = ShoppingItem.objects.filter(pk__in=ids)
data = []
total_amount = 0
categories = []
suppliers = []
for item in items:
total_amount += (item.quantity * item.price)
if item.category:
categories.append(item.category.id)
if item.supplier:
suppliers.append(item.supplier.id)
data.append({
"name": item.name,
"quantity": item.quantity,
"price": str(item.price),
"supplier": str(item.supplier) if item.supplier else None,
"category": str(item.category) if item.category else None,
"description": item.description,
})
most_category = (
Counter(categories).most_common(1)[0][0]
if categories else None
)
most_supplier = (
Counter(suppliers).most_common(1)[0][0]
if suppliers else None
)
request.session["expense_initial"] = {
"name": "Einkauf vom " + timezone.now().strftime("%d.%m.%Y"),
"amount": str(total_amount),
"date": timezone.now().strftime("%Y-%m-%dT%H:%M"),
"category": most_category,
"supplier": most_supplier,
"note": json.dumps(
data,
ensure_ascii=False,
indent=2
)
}
request.session["shopping_item_ids"] = list(
items.values_list("id", flat=True)
)
return redirect(
"finance:add_expense",
project_id=project_id
)
+2
View File
@@ -0,0 +1,2 @@
# invoices/__init__.py
default_app_config = "invoices.apps.InvoicesConfig"
+67 -1
View File
@@ -7,7 +7,9 @@ from .models import (
Invoice,
InvoiceItem,
Unit,
Reminder
Reminder,
InvoicePayment,
InvoicePaymentAllocation,
)
@admin.register(Unit)
@@ -273,3 +275,67 @@ class ReminderAdmin(admin.ModelAdmin):
)
ordering = ("-created_at",)
@admin.register(InvoicePayment)
class InvoicePaymentAdmin(admin.ModelAdmin):
list_display = (
"invoice",
"amount",
"date",
"payment_method",
"created_by",
"created_at",
)
list_filter = (
"payment_method",
"date",
"created_at",
)
search_fields = (
"invoice__invoice_number",
"invoice__customer__company_name",
"note",
)
readonly_fields = (
"created_at",
"created_by",
)
@admin.register(InvoicePaymentAllocation)
class InvoicePaymentAllocationAdmin(admin.ModelAdmin):
list_display = (
"payment",
"amount",
"reminder",
)
+20 -2
View File
@@ -1,5 +1,23 @@
from django.apps import AppConfig
class InvoicesConfig(AppConfig):
name = 'invoices'
name = "invoices"
def ready(self):
from django_apscheduler.jobstores import DjangoJobStore
from apscheduler.schedulers.background import BackgroundScheduler
from . import scheduler
sched = BackgroundScheduler()
sched.add_jobstore(DjangoJobStore(), "default")
sched.add_job(
scheduler.mark_overdue,
trigger="cron",
hour=1,
minute=0,
id="mark_overdue",
replace_existing=True,
)
sched.start()
+33 -1
View File
@@ -1,12 +1,41 @@
from django import forms
from core.forms.base import BaseForm
from .models import Customer, Item, Invoice, InvoiceItem, CustomerContact, CustomerLocation, Offer, OfferItem, Reminder
from .models import Customer, Item, Invoice, InvoiceItem, CustomerContact, CustomerLocation, Offer, OfferItem, Reminder, InvoicePayment
from projects.models import Project
from time_tracking.models import TimeEntry
from finance.models import Expense
from django.utils import timezone
from django.db.models import Q, Max
class InvoicePaymentForm(BaseForm):
class Meta:
model = InvoicePayment
fields = [
"amount",
"date",
"payment_method",
"note",
]
widgets = {
"date": forms.DateInput(
attrs={
"type": "date"
}
),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["date"].initial = timezone.now().date()
self.apply_style()
class CustomerForm(BaseForm):
class Meta:
model = Customer
@@ -32,9 +61,12 @@ class ItemForm(BaseForm):
fields = [
"item_type",
"description",
"category",
"unit",
"price_net",
"tax_rate",
"price_buy",
"supplier",
]
def __init__(self, *args, **kwargs):
+32 -12
View File
@@ -1,5 +1,6 @@
# Generated by Django 6.0.6 on 2026-06-27 17:19
# Generated by Django 6.0.6 on 2026-06-28 10:19
import django.core.validators
import django.db.models.deletion
from django.db import migrations, models
@@ -40,10 +41,10 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('source_type', models.CharField(choices=[('item', 'Artikel'), ('time', 'Zeiterfassung'), ('expense', 'Ausgabe')], default='item', max_length=20, verbose_name='Quelle')),
('description', models.CharField(max_length=255, verbose_name='Beschreibung')),
('quantity', models.DecimalField(decimal_places=2, default=1, max_digits=10, verbose_name='Menge')),
('quantity', models.DecimalField(decimal_places=2, default=1, max_digits=10, validators=[django.core.validators.MinValueValidator(0, message='Die Anzahl darf nicht negativ sein.')], verbose_name='Menge')),
('unit', models.CharField(max_length=50, verbose_name='Einheit')),
('unit_price_net', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Einzelpreis Netto')),
('unit_tax_rate', models.DecimalField(decimal_places=2, default=19.0, max_digits=5, verbose_name='MwSt. Satz (%)')),
('unit_price_net', models.DecimalField(decimal_places=2, max_digits=10, validators=[django.core.validators.MinValueValidator(0, message='Der Betrag darf nicht negativ sein.')], verbose_name='Einzelpreis Netto')),
('unit_tax_rate', models.DecimalField(decimal_places=2, default=19.0, max_digits=5, validators=[django.core.validators.MinValueValidator(0, message='Der Satz darf nicht negativ sein.')], verbose_name='MwSt. Satz (%)')),
('service_period_from', models.DateField(blank=True, null=True, verbose_name='Leistungszeitraum von')),
('service_period_to', models.DateField(blank=True, null=True, verbose_name='Leistungszeitraum bis')),
('created_at', models.DateTimeField(auto_now_add=True)),
@@ -53,14 +54,32 @@ class Migration(migrations.Migration):
'verbose_name_plural': 'Rechnungspositionen',
},
),
migrations.CreateModel(
name='InvoicePayment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('amount', models.DecimalField(decimal_places=2, max_digits=10, validators=[django.core.validators.MinValueValidator(0, message='Der Betrag darf nicht negativ sein.')], verbose_name='Betrag (€)')),
('date', models.DateTimeField(verbose_name='Datum')),
('note', models.TextField(blank=True, verbose_name='Notiz')),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name='InvoicePaymentAllocation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('amount', models.DecimalField(decimal_places=2, max_digits=10)),
],
),
migrations.CreateModel(
name='Item',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('item_type', models.CharField(choices=[('service', 'Dienstleistung'), ('product', 'Ware')], default='product', max_length=20, verbose_name='Art')),
('description', models.CharField(max_length=255, verbose_name='Beschreibung')),
('price_net', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Preis Netto')),
('tax_rate', models.DecimalField(decimal_places=2, default=19.0, max_digits=5, verbose_name='MwSt. Satz (%)')),
('price_net', models.DecimalField(decimal_places=2, max_digits=10, validators=[django.core.validators.MinValueValidator(0, message='Der Betrag darf nicht negativ sein.')], verbose_name='Preis Netto')),
('tax_rate', models.DecimalField(decimal_places=2, default=19.0, max_digits=5, validators=[django.core.validators.MinValueValidator(0, message='Der Satz darf nicht negativ sein.')], verbose_name='MwSt. Satz (%)')),
('price_buy', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, validators=[django.core.validators.MinValueValidator(0, message='Der Betrag darf nicht negativ sein.')], verbose_name='Einkaufspreis')),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
@@ -95,7 +114,7 @@ class Migration(migrations.Migration):
options={
'verbose_name': 'Angebot',
'verbose_name_plural': 'Angebote',
'ordering': ['-issue_date'],
'ordering': ['-offer_number'],
},
),
migrations.CreateModel(
@@ -103,10 +122,10 @@ class Migration(migrations.Migration):
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('description', models.CharField(max_length=255, verbose_name='Beschreibung')),
('quantity', models.DecimalField(decimal_places=2, default=1, max_digits=10, verbose_name='Menge')),
('quantity', models.DecimalField(decimal_places=2, default=1, max_digits=10, validators=[django.core.validators.MinValueValidator(0, message='Die Anzahl darf nicht negativ sein.')], verbose_name='Menge')),
('unit', models.CharField(max_length=50, verbose_name='Einheit')),
('unit_price_net', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Einzelpreis Netto')),
('unit_tax_rate', models.DecimalField(decimal_places=2, default=19.0, max_digits=5, verbose_name='MwSt. Satz (%)')),
('unit_price_net', models.DecimalField(decimal_places=2, max_digits=10, validators=[django.core.validators.MinValueValidator(0, message='Der Betrag darf nicht negativ sein.')], verbose_name='Einzelpreis Netto')),
('unit_tax_rate', models.DecimalField(decimal_places=2, default=19.0, max_digits=5, validators=[django.core.validators.MinValueValidator(0, message='Der Satz darf nicht negativ sein.')], verbose_name='MwSt. Satz (%)')),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
@@ -123,7 +142,7 @@ class Migration(migrations.Migration):
('level', models.PositiveSmallIntegerField(default=1, help_text='1 = Erste Mahnung, 2 = Zweite Mahnung, 3 = Letzte Mahnung', verbose_name='Mahnstufe')),
('issue_date', models.DateField(verbose_name='Mahndatum')),
('due_date', models.DateField(blank=True, null=True, verbose_name='Neue Zahlungsfrist')),
('fee', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Mahngebühr')),
('fee', models.DecimalField(decimal_places=2, default=0, max_digits=10, validators=[django.core.validators.MinValueValidator(0, message='Der Betrag darf nicht negativ sein.')], verbose_name='Mahngebühr')),
('note', models.TextField(blank=True, verbose_name='Hinweis')),
('pdf_file', models.FileField(blank=True, null=True, upload_to='reminder_pdfs/', verbose_name='PDF')),
('pdf_generated_at', models.DateTimeField(blank=True, null=True, verbose_name='PDF erstellt am')),
@@ -210,6 +229,7 @@ class Migration(migrations.Migration):
('due_date', models.DateField(blank=True, null=True, verbose_name='Fälligkeitsdatum')),
('note', models.TextField(blank=True, verbose_name='Notiz')),
('created_at', models.DateTimeField(auto_now_add=True)),
('is_small_business', models.BooleanField(default=False, verbose_name='Kleinunternehmer (§19 UStG)')),
('pdf_file', models.FileField(blank=True, null=True, upload_to='invoice_pdfs/', verbose_name='PDF')),
('pdf_generated_at', models.DateTimeField(blank=True, null=True, verbose_name='PDF erstellt am')),
('cancel_number', models.CharField(blank=True, max_length=50, null=True, unique=True, verbose_name='Stornorechnungsnummer')),
@@ -230,7 +250,7 @@ class Migration(migrations.Migration):
options={
'verbose_name': 'Rechnung',
'verbose_name_plural': 'Rechnungen',
'ordering': ['-issue_date'],
'ordering': ['-invoice_number'],
},
),
]
+38 -1
View File
@@ -1,6 +1,7 @@
# Generated by Django 6.0.6 on 2026-06-27 17:19
# Generated by Django 6.0.6 on 2026-06-28 10:19
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
@@ -13,6 +14,7 @@ class Migration(migrations.Migration):
('invoices', '0001_initial'),
('projects', '0001_initial'),
('time_tracking', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
@@ -36,6 +38,36 @@ class Migration(migrations.Migration):
name='time_entry',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='time_tracking.timeentry', verbose_name='Zeiterfassung'),
),
migrations.AddField(
model_name='invoicepayment',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='invoicepayment',
name='invoice',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payments', to='invoices.invoice'),
),
migrations.AddField(
model_name='invoicepayment',
name='payment_method',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='finance.paymentmethod', verbose_name='Zahlungsmethode'),
),
migrations.AddField(
model_name='invoicepaymentallocation',
name='payment',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='allocations', to='invoices.invoicepayment'),
),
migrations.AddField(
model_name='item',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='finance.category', verbose_name='Kategorie'),
),
migrations.AddField(
model_name='item',
name='supplier',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='finance.supplier', verbose_name='Lieferant'),
),
migrations.AddField(
model_name='invoiceitem',
name='item',
@@ -61,6 +93,11 @@ class Migration(migrations.Migration):
name='invoice',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='reminders', to='invoices.invoice', verbose_name='Rechnung'),
),
migrations.AddField(
model_name='invoicepaymentallocation',
name='reminder',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='payment_allocations', to='invoices.reminder'),
),
migrations.AddField(
model_name='item',
name='unit',
+261 -14
View File
@@ -1,6 +1,12 @@
from django.db import models
from django.core.exceptions import ValidationError
from core.models import CompanySetting
from django.db.models import Sum, F, DecimalField, ExpressionWrapper
from django.contrib.auth import get_user_model
from django.utils import timezone
from decimal import Decimal
from django.core.validators import MinValueValidator
User = get_user_model()
class Unit(models.Model):
name = models.CharField(
@@ -92,15 +98,21 @@ class Item(models.Model):
item_type = models.CharField(max_length=20, choices=ItemType.choices, default=ItemType.PRODUCT, verbose_name="Art")
description = models.CharField(max_length=255, verbose_name="Beschreibung")
category = models.ForeignKey("finance.Category", on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Kategorie")
supplier = models.ForeignKey("finance.Supplier", on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Lieferant")
unit = models.ForeignKey(Unit,on_delete=models.PROTECT,related_name="item_items",verbose_name="Einheit")
price_net = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Preis Netto")
price_net = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Preis Netto",validators=[MinValueValidator(0,message="Der Betrag darf nicht negativ sein.")])
tax_rate = models.DecimalField(
max_digits=5,
decimal_places=2,
default=19.00,
verbose_name="MwSt. Satz (%)"
verbose_name="MwSt. Satz (%)",
validators=[MinValueValidator(0,message="Der Satz darf nicht negativ sein.")]
)
price_buy = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Einkaufspreis", null=True, blank=True,validators=[MinValueValidator(0,message="Der Betrag darf nicht negativ sein.")])
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
@@ -142,6 +154,8 @@ class Invoice(models.Model):
note = models.TextField(blank=True, verbose_name="Notiz")
created_at = models.DateTimeField(auto_now_add=True)
is_small_business = models.BooleanField(default=False,verbose_name="Kleinunternehmer (§19 UStG)")
pdf_file = models.FileField(upload_to="invoice_pdfs/",null=True,blank=True,verbose_name="PDF")
pdf_generated_at = models.DateTimeField(null=True,blank=True,verbose_name="PDF erstellt am")
@@ -163,7 +177,7 @@ class Invoice(models.Model):
class Meta:
verbose_name = "Rechnung"
verbose_name_plural = "Rechnungen"
ordering = ["-issue_date"]
ordering = ["-invoice_number"]
def save(self, *args, **kwargs):
@@ -201,9 +215,106 @@ class Invoice(models.Model):
super().save(*args, **kwargs)
def clean(self):
super().clean()
if self.issue_date and self.due_date:
if self.issue_date > self.due_date:
raise ValidationError(
{
"due_date": "Das Fälligkeitsdatum muss nach dem Rechnungsdatum liegen."
}
)
def __str__(self):
return self.invoice_number
@property
def total_net(self):
result = self.items.aggregate(
total=Sum(
ExpressionWrapper(
F("quantity") * F("unit_price_net"),
output_field=DecimalField()
)
)
)
return result["total"] or Decimal("0.00")
@property
def total_tax(self):
total = Decimal("0.00")
for item in self.items.all():
item_net = item.calculated_total_net
total += (
item_net *
item.unit_tax_rate /
Decimal("100")
)
return total
@property
def total_amount(self):
if self.is_small_business:
return self.total_net
return self.total_net + self.total_tax
@property
def paid_amount(self):
return self.payments.aggregate(
total=Sum("amount")
)["total"] or Decimal("0.00")
@property
def paid_reminder_amount(self):
return self.reminders.aggregate(
total=Sum(
"payment_allocations__amount"
)
)["total"] or Decimal("0.00")
@property
def paid_invoice_amount(self):
return (
self.paid_amount
- self.paid_reminder_amount
)
@property
def reminder_amount(self):
return self.reminders.filter(
status__in=[
Reminder.Status.SENT,
Reminder.Status.EXPIRED,
Reminder.Status.PAID,
]
).aggregate(
total=Sum("fee")
)["total"] or Decimal("0.00")
@property
def total_with_reminders(self):
return (
self.total_amount
+ self.reminder_amount
)
@property
def open_amount(self):
return (
self.total_with_reminders
- self.paid_amount
)
class InvoiceItem(models.Model):
invoice = models.ForeignKey(Invoice,on_delete=models.CASCADE,related_name="items",verbose_name="Rechnung")
@@ -212,10 +323,10 @@ class InvoiceItem(models.Model):
time_entry = models.ForeignKey("time_tracking.TimeEntry",on_delete=models.SET_NULL,null=True,blank=True,verbose_name="Zeiterfassung") # Quelle 2
expense = models.ForeignKey("finance.Expense",on_delete=models.SET_NULL,null=True,blank=True,verbose_name="Ausgabe") # Quelle 3
description = models.CharField(max_length=255,verbose_name="Beschreibung")
quantity = models.DecimalField(max_digits=10,decimal_places=2,default=1,verbose_name="Menge")
quantity = models.DecimalField(max_digits=10,decimal_places=2,default=1,verbose_name="Menge",validators=[MinValueValidator(0,message="Die Anzahl darf nicht negativ sein.")])
unit = models.CharField(max_length=50,verbose_name="Einheit")
unit_price_net = models.DecimalField(max_digits=10,decimal_places=2,verbose_name="Einzelpreis Netto")
unit_tax_rate = models.DecimalField(max_digits=5,decimal_places=2,default=19.00,verbose_name="MwSt. Satz (%)")
unit_price_net = models.DecimalField(max_digits=10,decimal_places=2,verbose_name="Einzelpreis Netto",validators=[MinValueValidator(0,message="Der Betrag darf nicht negativ sein.")])
unit_tax_rate = models.DecimalField(max_digits=5,decimal_places=2,default=19.00,verbose_name="MwSt. Satz (%)",validators=[MinValueValidator(0,message="Der Satz darf nicht negativ sein.")])
service_period_from = models.DateField(null=True,blank=True,verbose_name="Leistungszeitraum von",)
service_period_to = models.DateField(null=True,blank=True,verbose_name="Leistungszeitraum bis",)
created_at = models.DateTimeField(auto_now_add=True)
@@ -229,7 +340,6 @@ class InvoiceItem(models.Model):
verbose_name_plural = "Rechnungspositionen"
def clean(self):
sources = [
self.item,
self.time_entry,
@@ -239,9 +349,13 @@ class InvoiceItem(models.Model):
selected = sum(1 for s in sources if s)
if selected != 1:
raise ValidationError(
"Genau eine Quelle muss ausgewählt werden."
)
raise ValidationError("Genau eine Quelle muss ausgewählt werden.")
if self.service_period_from and self.service_period_to:
if self.service_period_from >= self.service_period_to:
raise ValidationError({
'service_period_to': 'Das Ende des Leistungszeitraums muss nach dem Beginn liegen.'
})
def __str__(self):
return self.description
@@ -272,7 +386,6 @@ class Reminder(models.Model):
DRAFT = "draft", "Entwurf"
SENT = "sent", "Versendet"
PAID = "paid", "Bezahlt"
#PARTIALLY_PAID = "partial", "Teilweise bezahlt"
CANCELED = "canceled", "Storniert"
EXPIRED = "expired", "Frist abgelaufen"
@@ -288,7 +401,7 @@ class Reminder(models.Model):
level = models.PositiveSmallIntegerField(default=1,verbose_name="Mahnstufe",help_text="1 = Erste Mahnung, 2 = Zweite Mahnung, 3 = Letzte Mahnung",)
issue_date = models.DateField(verbose_name="Mahndatum",)
due_date = models.DateField(null=True,blank=True,verbose_name="Neue Zahlungsfrist",)
fee = models.DecimalField(max_digits=10,decimal_places=2,default=0,verbose_name="Mahngebühr",)
fee = models.DecimalField(max_digits=10,decimal_places=2,default=0,verbose_name="Mahngebühr",validators=[MinValueValidator(0,message="Der Betrag darf nicht negativ sein.")])
note = models.TextField(blank=True,verbose_name="Hinweis",)
pdf_file = models.FileField(upload_to="reminder_pdfs/",null=True,blank=True,verbose_name="PDF",)
pdf_generated_at = models.DateTimeField(null=True,blank=True,verbose_name="PDF erstellt am",)
@@ -302,6 +415,27 @@ class Reminder(models.Model):
def __str__(self):
return self.reminder_number
def clean(self):
super().clean()
if self.issue_date and self.due_date:
if self.issue_date > self.due_date:
raise ValidationError(
{
"due_date": "Das Fälligkeitsdatum muss nach dem Mahnungsdatum liegen."
}
)
@property
def paid_amount(self):
return self.payment_allocations.aggregate(
total=Sum("amount")
)["total"] or Decimal("0.00")
@property
def is_paid(self):
return self.paid_amount >= self.fee
@@ -451,10 +585,9 @@ class Offer(models.Model):
class Meta:
verbose_name = "Angebot"
verbose_name_plural = "Angebote"
ordering = ["-issue_date"]
ordering = ["-offer_number"]
def save(self, *args, **kwargs):
if self.pk:
old = Offer.objects.get(pk=self.pk)
@@ -485,6 +618,17 @@ class Offer(models.Model):
super().save(*args, **kwargs)
def clean(self):
super().clean()
if self.issue_date and self.due_date:
if self.issue_date > self.due_date:
raise ValidationError(
{
"due_date": "Das Gültigkeitsdatum muss nach dem Angebotsdatum liegen."
}
)
def __str__(self):
return self.offer_number
@@ -515,6 +659,7 @@ class OfferItem(models.Model):
decimal_places=2,
default=1,
verbose_name="Menge",
validators=[MinValueValidator(0,message="Die Anzahl darf nicht negativ sein.")]
)
unit = models.CharField(
@@ -526,6 +671,7 @@ class OfferItem(models.Model):
max_digits=10,
decimal_places=2,
verbose_name="Einzelpreis Netto",
validators=[MinValueValidator(0,message="Der Betrag darf nicht negativ sein.")]
)
unit_tax_rate = models.DecimalField(
@@ -533,6 +679,7 @@ class OfferItem(models.Model):
decimal_places=2,
default=19.00,
verbose_name="MwSt. Satz (%)",
validators=[MinValueValidator(0,message="Der Satz darf nicht negativ sein.")]
)
created_at = models.DateTimeField(
@@ -593,3 +740,103 @@ class DocumentNumberSettings(models.Model):
class Meta:
verbose_name = "Dokumentnummer"
verbose_name_plural = "Dokumentnummern"
class InvoicePayment(models.Model):
invoice = models.ForeignKey(
Invoice,
on_delete=models.CASCADE,
related_name="payments"
)
amount = models.DecimalField(
max_digits=10,
decimal_places=2,
verbose_name="Betrag (€)",
validators=[MinValueValidator(0,message="Der Betrag darf nicht negativ sein.")]
)
date = models.DateTimeField(verbose_name="Datum")
payment_method = models.ForeignKey(
"finance.PaymentMethod",
on_delete=models.SET_NULL,
null=True,
blank=True,
verbose_name="Zahlungsmethode",
)
note = models.TextField(blank=True, verbose_name="Notiz")
created_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
)
created_at = models.DateTimeField(
auto_now_add=True
)
def clean(self):
if self.date and self.invoice_id:
if self.date.date() < self.invoice.issue_date:
raise ValidationError({
'date': 'Das Zahlungsdatum darf nicht vor dem Rechnungsdatum liegen.'
})
def save(self, *args, **kwargs):
self.full_clean()
super().save(*args, **kwargs)
@property
def allocated_amount(self):
return self.allocations.aggregate(
total=Sum("amount")
)["total"] or 0
@property
def remaining_amount(self):
return self.amount - self.allocated_amount
class InvoicePaymentAllocation(models.Model):
payment = models.ForeignKey(
InvoicePayment,
on_delete=models.CASCADE,
related_name="allocations"
)
reminder = models.ForeignKey(
Reminder,
null=True,
blank=True,
on_delete=models.CASCADE,
related_name="payment_allocations"
)
amount = models.DecimalField(
max_digits=10,
decimal_places=2
)
+20
View File
@@ -0,0 +1,20 @@
from django.utils import timezone
from .models import Invoice, Offer, Reminder
def mark_overdue():
today = timezone.now().date()
Invoice.objects.filter(
status="sent",
due_date__lt=today,
).update(status="overdue")
Offer.objects.filter(
status="sent",
due_date__lt=today,
).update(status="expired")
Reminder.objects.filter(
status="sent",
due_date__lt=today,
).update(status="expired")
+47 -38
View File
@@ -1,8 +1,42 @@
import django_tables2 as tables
from .models import Item, Customer, Reminder, Invoice, InvoiceItem, Offer, OfferItem
from .models import Item, Customer, Reminder, Invoice, InvoiceItem, Offer, OfferItem, InvoicePayment
class InvoicePaymentTable(tables.Table):
invoice = tables.Column(
verbose_name="Rechnung"
)
amount = tables.Column(
verbose_name="Betrag"
)
date = tables.DateColumn(
format="d.m.Y",
verbose_name="Datum"
)
payment_method = tables.Column(
verbose_name="Zahlungsmethode"
)
created_by = tables.Column(
verbose_name="Erfasst von"
)
class Meta:
model = InvoicePayment
fields = [
"invoice",
"amount",
"date",
"payment_method",
"created_by",
]
attrs = {
"class": "w-full text-sm"
}
class OfferItemTable(tables.Table):
unit_price_net = tables.TemplateColumn(
template_name="invoices/tables/offer_items/unit_price.html",
verbose_name="Einzelpreis",
@@ -246,55 +280,30 @@ class ReminderTable(tables.Table):
}
class ItemTable(tables.Table):
item_type = tables.TemplateColumn(
template_name="invoices/tables/items/item_type.html",
verbose_name="Typ",
orderable=False
)
toggle_prefix = "item"
description = tables.TemplateColumn(
template_name="invoices/tables/items/description.html",
verbose_name="Beschreibung",
orderable=False
detail_template = (
"invoices/tables/items/detail.html"
)
unit = tables.TemplateColumn(
template_name="invoices/tables/items/unit.html",
verbose_name="Einheit",
orderable=False
)
price_net = tables.TemplateColumn(
template_name="invoices/tables/items/price_net.html",
verbose_name="Preis Netto",
orderable=False
)
tax_rate = tables.TemplateColumn(
template_name="invoices/tables/items/tax_rate.html",
verbose_name="MwSt. Satz (%)",
orderable=False
)
actions = tables.TemplateColumn(
template_name="invoices/tables/items/actions.html",
verbose_name="Aktion",
orderable=False
)
class Meta:
model = Item
template_name = (
"django_tables2/table_toggle.html"
)
fields = (
"item_type",
"description",
"category",
"unit",
"price_net",
"tax_rate",
"actions",
)
attrs = {
"class": "w-full text-sm min-w-[700px]"
"class": "w-full text-sm min-w-[900px]",
}
@@ -18,7 +18,7 @@ Kunde bearbeiten
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<h2 class="text-xl font-bold mb-2">Kundendaten</h2>
<h2 class="text-lg font-bold mb-2">Kundendaten</h2>
<div class="grid md:grid-cols-2 gap-4 mb-8">
@@ -18,7 +18,7 @@ Kundenstammdaten verwalten
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div>
<h2 class="text-xl font-bold">Aktionen</h2>
<h2 class="text-lg font-bold">Aktionen</h2>
<p class="text-slate-400 text-sm mt-1">
Kunden anlegen
</p>
@@ -36,7 +36,7 @@ Kundenstammdaten verwalten
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors mb-8">
<div class="mb-6">
<h2 class="text-xl font-bold">Kundenliste</h2>
<h2 class="text-lg font-bold">Kundenliste</h2>
<p class="text-slate-400 text-sm mt-1">Alle Kunden, inkl. Standorte und Ansprechpartner</p>
</div>
@@ -49,7 +49,7 @@ Kundenstammdaten verwalten
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="mb-6">
<h2 class="text-xl font-bold">Archivierte Kunden</h2>
<h2 class="text-lg font-bold">Archivierte Kunden</h2>
<p class="text-slate-400 text-sm mt-1">Alle inaktiven Kunden, inkl. Standorte und Ansprechpartner</p>
</div>
@@ -0,0 +1,249 @@
{% extends "base.html" %}
{% block title %}Rechnungswesen - Rechnungen{% endblock %}
{% block header_title %}
Zahlung erfassen
{% endblock %}
{% block header_description %}
Zahlung für Rechnung {{ invoice.invoice_number }} hinzufügen
{% endblock %}
{% block content %}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- PAYMENT FORM -->
<div class="lg:col-span-2">
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<form method="post" enctype="multipart/form-data" class="space-y-4">
{% csrf_token %}
{{ form.as_p }}
<button class="btn btn-save">
Speichern
</button>
</form>
</div>
</div>
<!-- PAYMENT SUMMARY -->
<div class="lg:col-span-1">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow p-6 font-mono text-sm">
<!-- HEADER -->
<div class="text-center">
<h2 class="text-lg font-bold">
Zahlungsübersicht
</h2>
<p class="text-xs text-slate-400 mt-1">
Rechnung {{ invoice.invoice_number }}
</p>
</div>
<div class="border-t border-dashed border-slate-300 dark:border-slate-600 my-5"></div>
<!-- RECHNUNG -->
<div class="space-y-2">
{% if not invoice.is_small_business %}
<div class="flex justify-between">
<span class="text-slate-400">
Netto
</span>
<span>
{{ invoice.total_net|floatformat:2 }} €
</span>
</div>
<div class="flex justify-between">
<span class="text-slate-400">
Umsatzsteuer
</span>
<span>
{{ invoice.total_tax|floatformat:2 }} €
</span>
</div>
{% endif %}
<div class="flex justify-between font-semibold">
<span>
Rechnungsbetrag
</span>
<span>
{{ invoice.total_amount|floatformat:2 }} €
</span>
</div>
</div>
<!-- MAHNUNGEN -->
{% if invoice.reminder_amount > 0 %}
<div class="border-t border-dashed border-slate-300 dark:border-slate-600 my-5"></div>
<div class="space-y-2">
<div class="flex justify-between text-red-600">
<span>
Mahngebühren
</span>
<span>
+ {{ invoice.reminder_amount|floatformat:2 }} €
</span>
</div>
{% for reminder in invoice.reminders.all %}
{% if reminder.status != "canceled" %}
<div class="flex justify-between text-xs text-slate-400">
<span>
{{ reminder.level }}. Mahnung
</span>
<span>
{{ reminder.fee|floatformat:2 }} €
</span>
</div>
{% endif %}
{% endfor %}
</div>
{% endif %}
<!-- GESAMT -->
<div class="border-t border-dashed border-slate-300 dark:border-slate-600 my-5"></div>
<div class="flex justify-between text-lg font-bold">
<span>
Gesamtforderung
</span>
<span>
{{ invoice.total_with_reminders|floatformat:2 }} €
</span>
</div>
<!-- ZAHLUNGEN -->
<div class="mt-4 space-y-2">
<div class="flex justify-between text-green-600">
<span>
Bereits bezahlt
</span>
<span>
- {{ invoice.paid_amount|floatformat:2 }} €
</span>
</div>
{% for payment in invoice.payments.all %}
<div class="flex justify-between text-xs text-slate-400">
<span>
Zahlung {{ payment.date|date:"d.m.Y" }}
</span>
<span>
{{ payment.amount|floatformat:2 }} €
</span>
</div>
{% endfor %}
</div>
<!-- OFFEN -->
<div class="border-t border-dashed border-slate-300 dark:border-slate-600 my-5"></div>
<div class="bg-slate-100 dark:bg-slate-700 rounded-xl p-4 text-center">
<p class="text-xs uppercase text-slate-400">
Offener Betrag
</p>
<p class="text-3xl font-bold text-red-600 mt-1">
{{ invoice.open_amount|floatformat:2 }} €
</p>
</div>
<!-- FOOTER -->
<div class="border-t border-dashed border-slate-300 dark:border-slate-600 my-5"></div>
<div class="text-center text-xs text-slate-400">
Zahlungserfassung
</div>
</div>
</div>
</div>
{% endblock %}
@@ -16,7 +16,7 @@ Details & Positionen
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div>
<h2 class="text-xl font-bold">Aktionen</h2>
<h2 class="text-lg font-bold">Aktionen</h2>
<p class="text-slate-400 text-sm mt-1">
Positionen zur Rechnung hinzufügen
</p>
@@ -32,7 +32,6 @@ Details & Positionen
<a href="{% url 'invoices:invoices_download' invoice.id %}" class="btn btn-action">Download</a>
<a href="{% url 'invoices:invoices_cancel' invoice.id %}" onclick="startDownloadAndRedirect()" class="btn btn-delete">Stornieren</a>
<a href="{% url 'invoices:invoices_paid' invoice.id %}" class="btn btn-action">Zahlung verbuchen</a>
<a href="{% url 'invoices:invoices_overdue' invoice.id %}" class="btn btn-action">Überfällig</a>
{% endif %}
{% if invoice.status == "paid" %}
<a href="{% url 'invoices:invoices_download' invoice.id %}" class="btn btn-action">Download</a>
@@ -44,6 +43,8 @@ Details & Positionen
{% endif %}
{% if invoice.status == "overdue" %}
<a href="{% url 'invoices:invoices_download' invoice.id %}" class="btn btn-action">Download</a>
<a href="{% url 'invoices:invoices_cancel' invoice.id %}" onclick="startDownloadAndRedirect()" class="btn btn-delete">Stornieren</a>
<a href="{% url 'invoices:invoices_paid' invoice.id %}" class="btn btn-action">Zahlung verbuchen</a>
{% endif %}
</div>
@@ -11,7 +11,7 @@
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div>
<h2 class="text-xl font-bold">Aktionen</h2>
<h2 class="text-lg font-bold">Aktionen</h2>
<p class="text-slate-400 text-sm mt-1">Neue Rechnung erstellen</p>
</div>
@@ -24,7 +24,7 @@
</div>
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="mb-6">
<h2 class="text-xl font-bold">Rechnungen</h2>
<h2 class="text-lg font-bold">Rechnungen</h2>
<p class="text-slate-400 text-sm mt-1">Alle Rechnungen mit aktuellem Status</p>
</div>
<div class="overflow-x-auto rounded-xl">
@@ -158,7 +158,7 @@ table {
margin-top:30px;
margin-left:auto;
width:250px;
width: 300px;
}
@@ -178,7 +178,7 @@ table {
border-top:1px solid #cbd5e1;
margin-top:10px;
padding-top:10px;
font-size:16px;
font-size:14px;
font-weight:bold;
}
@@ -454,7 +454,7 @@ Preis
</th>
{% if not company.is_small_business %}
{% if not invoice.is_small_business %}
<th class="left">
Steuer
@@ -518,7 +518,7 @@ Gesamt
</td>
{% if not company.is_small_business %}
{% if not invoice.is_small_business %}
<td class="left">
{{ item.unit_tax_rate|floatformat:0 }} %
@@ -569,7 +569,7 @@ Netto
{% if not company.is_small_business %}
{% if not invoice.is_small_business %}
{% for tax_rate,tax_amount in tax_summary.items %}
<div class="total-row">
@@ -603,7 +603,7 @@ Gesamt
<span>
{% if company.is_small_business %}
{% if invoice.is_small_business %}
{{ total_net|floatformat:2 }}
{% else %}
{{ total_gross|floatformat:2 }}
@@ -644,7 +644,7 @@ Rechnungshinweis
{% if company.is_small_business %}
{% if invoice.is_small_business %}
<div class="note">
@@ -155,10 +155,9 @@ table {
.total-box {
margin-top:30px;
margin-left:auto;
width:250px;
width:300px;
}
@@ -174,11 +173,10 @@ table {
.total-final {
border-top:1px solid #cbd5e1;
margin-top:10px;
padding-top:10px;
font-size:16px;
font-size:14px;
font-weight:bold;
}
@@ -414,12 +412,11 @@ Stornodatum: {{ cancel_date|date:"d.m.Y" }}
<!-- POSITIONEN -->
<div class="items-wrapper">
<table class="items">
<table class="items">
<thead>
@@ -429,37 +426,46 @@ Stornodatum: {{ cancel_date|date:"d.m.Y" }}
Nr.
</th>
<th>
Beschreibung
</th>
{% if show_service_date %}
<th class="left">Leistungsdatum</th>
<th>
Leistungsdatum
</th>
{% endif %}
<th class="left">
<th class="right">
Menge
</th>
<th class="left">
<th>
Einheit
</th>
<th class="left">
Preis
<th class="right">
Einzelpreis
</th>
{% if not company.is_small_business %}
{% if not invoice.is_small_business %}
<th class="left">
<th class="right">
Steuer
</th>
{% endif %}
<th class="left">
<th class="right">
Gesamt
</th>
@@ -469,7 +475,6 @@ Gesamt
</thead>
<tbody>
@@ -483,40 +488,52 @@ Gesamt
</td>
<td>
{{ item.description }}
Storno: {{ item.description }}
</td>
{% if show_service_date %}
<td class="left">
<td>
{% if item.service_period_from %}
{{ item.service_period_from|date:"d.m.Y" }}
{% endif %}
{% if item.service_period_to %}
- {{ item.service_period_to|date:"d.m.Y" }}
{% endif %}
</td>
{% endif %}
<td class="left">
<td class="right">
{{ item.quantity }}
</td>
<td class="left">
<td>
{{ item.unit }}
</td>
<td class="left">
<td class="right">
-{{ item.unit_price_net|floatformat:2 }} €
</td>
{% if not company.is_small_business %}
<td class="left">
{% if not invoice.is_small_business %}
<td class="right">
{{ item.unit_tax_rate|floatformat:0 }} %
</td>
@@ -524,7 +541,7 @@ Gesamt
<td class="left">
<td class="right">
-{{ item.calculated_total_net|floatformat:2 }} €
</td>
@@ -540,14 +557,28 @@ Gesamt
</table>
</div>
<!-- SUMMEN -->
<!-- SUMMEN -->
<div class="total-box">
@@ -565,7 +596,9 @@ Netto
{% if not company.is_small_business %}
{% if not invoice.is_small_business %}
{% for tax_rate,tax_amount in tax_summary.items %}
<div class="total-row">
@@ -574,32 +607,27 @@ Netto
Umsatzsteuer {{ tax_rate|floatformat:0 }} %
</span>
<span>
-{{ tax_amount|floatformat:2 }} €
</span>
</div>
{% endfor %}
{% endif %}
<div class="total-row total-final">
<span>
Gesamt
Rechnungsbetrag
</span>
<span>
{% if company.is_small_business %}
{% if invoice.is_small_business %}
-{{ total_net|floatformat:2 }}
{% else %}
-{{ total_gross|floatformat:2 }}
@@ -609,12 +637,41 @@ Gesamt
</span>
</div>
<div class="total-row">
<span>
Bereits bezahlt
</span>
<span>
-{{ paid_amount|floatformat:2 }} €
</span>
</div>
<div class="total-row total-final">
<span>
Erstattungsbetrag
</span>
<span>
{{ refund_amount|floatformat:2 }} €
</span>
</div>
</div>
<div class="note">
<div class="receiver-title">
@@ -623,37 +680,45 @@ Gesamt
<br>
Die Rechnung {{ invoice.invoice_number }}
wird durch diese Stornorechnung vollständig aufgehoben.
{% if original_status == "paid" %}
Die Rechnung
<strong>{{ invoice.invoice_number }}</strong>
vom {{ invoice.issue_date|date:"d.m.Y" }}
wird durch diese Stornorechnung aufgehoben.
{% if paid_amount > 0 %}
<br><br>
Die ursprüngliche Rechnung wurde bereits bezahlt.
Es entsteht ein Guthaben in Höhe von
{% if company.is_small_business %}
{{ total_net|floatformat:2 }}
{% else %}
{{ total_gross|floatformat:2 }}
{% endif %}
Der zu erstattende Betrag beträgt:
<strong>
{{ refund_amount|floatformat:2 }} €
</strong>
<br><br>
€.
Die Rückerstattung erfolgt separat.
{% endif %}
</div>
{% if company.is_small_business %}
{% if invoice.is_small_business %}
<div class="note">
@@ -18,7 +18,7 @@ Leistungen und Artikel verwalten
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div>
<h2 class="text-xl font-bold">Aktionen</h2>
<h2 class="text-lg font-bold">Aktionen</h2>
<p class="text-slate-400 text-sm mt-1">
Leistungen und Artikel anlegen
</p>
@@ -38,7 +38,7 @@ Leistungen und Artikel verwalten
<div class="flex flex-wrap justify-between items-center mb-6">
<div>
<h2 class="text-xl font-bold">
<h2 class="text-lg font-bold">
Artikel und Leistungen
</h2>
@@ -16,7 +16,7 @@ Details & Positionen
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div>
<h2 class="text-xl font-bold">Aktionen</h2>
<h2 class="text-lg font-bold">Aktionen</h2>
<p class="text-slate-400 text-sm mt-1">
Positionen zum Angebot hinzufügen
</p>
@@ -32,7 +32,6 @@ Details & Positionen
<a href="{% url 'invoices:offers_download' offer.id %}" class="btn btn-action">Download</a>
<a href="{% url 'invoices:offers_accept' offer.id %}" class="btn btn-action">Akzeptiert</a>
<a href="{% url 'invoices:offers_reject' offer.id %}" class="btn btn-action">Abgelehnt</a>
<a href="{% url 'invoices:offers_expire' offer.id %}" class="btn btn-action">Ausgelaufen</a>
{% endif %}
{% if offer.status == "accepted" or offer.status == "rejected" or offer.status == "expired" %}
<a href="{% url 'invoices:offers_download' offer.id %}" class="btn btn-action">Download</a>
@@ -11,7 +11,7 @@
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div>
<h2 class="text-xl font-bold">Aktionen</h2>
<h2 class="text-lg font-bold">Aktionen</h2>
<p class="text-slate-400 text-sm mt-1">Neues Angebot erstellen</p>
</div>
@@ -24,7 +24,7 @@
</div>
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="mb-6">
<h2 class="text-xl font-bold">Angebote</h2>
<h2 class="text-lg font-bold">Angebote</h2>
<p class="text-slate-400 text-sm mt-1">Alle Angebote mit aktuellem Status</p>
</div>
<div class="overflow-x-auto rounded-xl">
+2 -2
View File
@@ -158,7 +158,7 @@ table {
margin-top:30px;
margin-left:auto;
width:250px;
width: 300px;
}
@@ -178,7 +178,7 @@ table {
border-top:1px solid #cbd5e1;
margin-top:10px;
padding-top:10px;
font-size:16px;
font-size:14px;
font-weight:bold;
}
+15 -22
View File
@@ -18,44 +18,37 @@ Angebote, Rechnungen, Mahnungen verwalten
<!-- MODULE GRID -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<a href="{% url 'invoices:invoices_offers_overview' %}" class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<a href="{% url 'invoices:invoices_offers_overview' %}" class="border border-slate-100 dark:border-slate-700 hover:border-[var(--company-color)]/40 hover:shadow-sm transition-all duration-200 p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="text-2xl mb-2">📄</div>
<h2 class="text-xl font-bold">Angebote</h2>
<h2 class="text-lg font-bold">Angebote</h2>
<p class="text-slate-400 text-sm mt-1">Angebote erstellen und verwalten</p>
</a>
<a href="{% url 'invoices:invoices_invoices_overview' %}" class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<a href="{% url 'invoices:invoices_invoices_overview' %}" class="border border-slate-100 dark:border-slate-700 hover:border-[var(--company-color)]/40 hover:shadow-sm transition-all duration-200 p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="text-2xl mb-2">🧾</div>
<h2 class="text-xl font-bold">Rechnungen</h2>
<h2 class="text-lg font-bold">Rechnungen</h2>
<p class="text-slate-400 text-sm mt-1">Rechnungen erstellen und verwalten</p>
</a>
<a href="{% url 'invoices:invoices_reminders_overview' %}" class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<a href="{% url 'invoices:invoices_reminders_overview' %}" class="border border-slate-100 dark:border-slate-700 hover:border-[var(--company-color)]/40 hover:shadow-sm transition-all duration-200 p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="text-2xl mb-2">⚠️</div>
<h2 class="text-xl font-bold">Mahnungen</h2>
<h2 class="text-lg font-bold">Mahnungen</h2>
<p class="text-slate-400 text-sm mt-1">Offene Forderungen verfolgen</p>
</a>
<a href="{% url 'invoices:customers_overview' %}" class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<a href="{% url 'invoices:customers_overview' %}" class="border border-slate-100 dark:border-slate-700 hover:border-[var(--company-color)]/40 hover:shadow-sm transition-all duration-200 p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="text-2xl mb-2">👥</div>
<h2 class="text-xl font-bold">Kunden</h2>
<h2 class="text-lg font-bold">Kunden</h2>
<p class="text-slate-400 text-sm mt-1">Kundenstammdaten verwalten</p>
</a>
<a href="{% url 'invoices:items_overview' %}" class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<a href="{% url 'invoices:items_overview' %}" class="border border-slate-100 dark:border-slate-700 hover:border-[var(--company-color)]/40 hover:shadow-sm transition-all duration-200 p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="text-2xl mb-2">📦</div>
<h2 class="text-xl font-bold">Posten</h2>
<h2 class="text-lg font-bold">Posten</h2>
<p class="text-slate-400 text-sm mt-1">Leistungen und Artikel verwalten</p>
</a>
</div>
<!-- OPTIONAL INFO BOX -->
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors mt-8">
<h2 class="text-xl font-bold">
Zusammenfassung
</h2>
<p class="text-slate-400 text-sm mt-1">
folgt
</p>
<a href="{% url 'invoices:invoices_payments_overview' %}" class="border border-slate-100 dark:border-slate-700 hover:border-[var(--company-color)]/40 hover:shadow-sm transition-all duration-200 p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="text-2xl mb-2">🏦</div>
<h2 class="text-lg font-bold">Zahlungen</h2>
<p class="text-slate-400 text-sm mt-1">Zahlungsvorgänge verfolgen</p>
</a>
</div>
{% endblock %}
@@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block title %}Rechnungswesen - Zahlungen{% endblock %}
{% block header_title %}Zahlungen{% endblock %}
{% block header_description %}Zahlungsvorgänge verfolgen{% endblock %}
{% block content %}
{% load django_tables2 %}
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="mb-6">
<h2 class="text-lg font-bold">Zahlungen</h2>
<p class="text-slate-400 text-sm mt-1">Alle Zahlungsvorgänge</p>
</div>
<div class="overflow-x-auto rounded-xl">
{% render_table table %}
</div>
</div>
{% endblock %}
@@ -15,7 +15,7 @@ Details
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div>
<h2 class="text-xl font-bold">Aktionen</h2>
<h2 class="text-lg font-bold">Aktionen</h2>
<p class="text-slate-400 text-sm mt-1">
Mahnung verwalten
</p>
@@ -28,8 +28,6 @@ Details
{% if reminder.status == "sent" %}
<a href="{% url 'invoices:reminders_download' reminder.id %}" class="btn btn-action">Download</a>
<a href="{% url 'invoices:reminders_cancel' reminder.id %}" class="btn btn-delete">Stornieren</a>
<a href="{% url 'invoices:reminders_paid' reminder.id %}" class="btn btn-action">Zahlung verbuchen</a>
<a href="{% url 'invoices:reminders_expire' reminder.id %}" class="btn btn-action">Überfällig</a>
{% endif %}
{% if reminder.status == "paid" or reminder.status == "canceled" or reminder.status == "expired" %}
<a href="{% url 'invoices:reminders_download' reminder.id %}" class="btn btn-action">Download</a>
@@ -11,7 +11,7 @@
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div>
<h2 class="text-xl font-bold">Aktionen</h2>
<h2 class="text-lg font-bold">Aktionen</h2>
<p class="text-slate-400 text-sm mt-1">Neue Mahnung erstellen</p>
</div>
@@ -24,7 +24,7 @@
</div>
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="mb-6">
<h2 class="text-xl font-bold">Mahnungen</h2>
<h2 class="text-lg font-bold">Mahnungen</h2>
<p class="text-slate-400 text-sm mt-1">Alle Mahnungen mit aktuellem Status</p>
</div>
<div class="overflow-x-auto rounded-xl">
+59 -14
View File
@@ -149,16 +149,15 @@ table {
.right {
text-align:right;
text-align: right !important;
}
.total-box {
margin-top:30px;
margin-left:auto;
width:250px;
width:300px;
}
@@ -178,7 +177,7 @@ table {
border-top:1px solid #cbd5e1;
margin-top:10px;
padding-top:10px;
font-size:16px;
font-size:14px;
font-weight:bold;
}
@@ -440,7 +439,7 @@ Betrag
Rechnung {{ reminder.invoice.invoice_number }}
</td>
<td>
<td class="right">
{{ invoice_amount|floatformat:2 }} €
</td>
@@ -459,7 +458,7 @@ Rechnung {{ reminder.invoice.invoice_number }}
({{ old_reminder.issue_date|date:"d.m.Y" }})
</td>
<td>
<td class="right">
{{ old_reminder.fee|floatformat:2 }} €
</td>
@@ -474,7 +473,7 @@ Rechnung {{ reminder.invoice.invoice_number }}
Aktuelle Mahngebühr
</td>
<td>
<td class="right">
{{ reminder.fee|floatformat:2 }} €
</td>
@@ -484,6 +483,32 @@ Rechnung {{ reminder.invoice.invoice_number }}
{% if payments %}
{% for payment in payments %}
<tr>
<td>
Zahlung vom {{ payment.date|date:"d.m.Y" }}
{% if payment.payment_method %}
({{ payment.payment_method }})
{% endif %}
</td>
<td class="right">
- {{ payment.amount|floatformat:2 }} €
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
@@ -494,12 +519,20 @@ Rechnung {{ reminder.invoice.invoice_number }}
<!-- GESAMTFORDERUNG -->
<div class="total-box">
<div class="total-row">
<span>
@@ -513,7 +546,6 @@ Offener Rechnungsbetrag
</div>
{% if previous_reminders or reminder.fee > 0 %}
<div class="total-row">
@@ -531,23 +563,36 @@ Mahngebühren
{% endif %}
<div class="total-row">
<span>
Bereits bezahlt
</span>
<span>
- {{ total_paid|floatformat:2 }} €
</span>
</div>
<div class="total-row total-final">
<span>
Gesamtforderung
Gesamt
</span>
<span>
{{ total_due|floatformat:2 }} €
{{ open_amount|floatformat:2 }} €
</span>
</div>
</div>
</div>
@@ -1,35 +0,0 @@
<form
id="save-form-{{ record.id }}"
method="post"
action="{% url 'invoices:items_edit' record.id %}">
{% csrf_token %}
</form>
<div class="flex gap-2">
<button
type="submit"
form="save-form-{{ record.id }}"
class="btn btn-save">
Speichern
</button>
<form
method="post"
action="{% url 'invoices:items_delete' record.id %}">
{% csrf_token %}
<button
type="submit"
class="btn btn-delete">
Löschen
</button>
</form>
</div>
@@ -1,6 +0,0 @@
<input
type="text"
name="description"
form="save-form-{{ record.id }}"
value="{{ record.description }}"
class="border rounded px-2 py-1 w-full">
@@ -0,0 +1,176 @@
<form method="post" action="{% url 'invoices:items_edit' item.id %}">
{% csrf_token %}
<div class="grid md:grid-cols-2 xl:grid-cols-3 gap-4">
<div>
<label class="block text-sm mb-1">
Typ
</label>
<select
name="item_type"
class="border rounded px-3 py-2 w-full">
{% for value,label in item.ItemType.choices %}
<option
value="{{ value }}"
{% if item.item_type == value %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm mb-1">
Beschreibung
</label>
<input
type="text"
name="description"
value="{{ item.description }}"
class="border rounded px-3 py-2 w-full">
</div>
<div>
<label class="block text-sm mb-1">
Kategorie
</label>
<select
name="category"
class="border rounded px-3 py-2 w-full">
<option value="">
---
</option>
{% for c in table.categories %}
<option
value="{{ c.id }}"
{% if item.category_id == c.id %}selected{% endif %}>
{{ c.name }}
</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm mb-1">
Beschreibung
</label>
<input
type="text"
name="description"
value="{{ item.description }}"
class="border rounded px-3 py-2 w-full">
</div>
<div>
<label class="block text-sm mb-1">
Einheit
</label>
<select
name="unit"
class="border rounded px-3 py-2 w-full">
<option value="">
---
</option>
{% for u in table.units %}
<option
value="{{ u.id }}"
{% if item.unit_id == u.id %}selected{% endif %}>
{{ u.name }}
</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm mb-1">
Einzelpreis Netto
</label>
<input
type="number"
step="0.01"
name="price_net"
value="{{ item.price_net }}"
class="border rounded px-3 py-2 w-full">
</div>
<div>
<label class="block text-sm mb-1">
MwSt. (%)
</label>
<input
type="number"
step="0.01"
name="tax_rate"
value="{{ item.tax_rate }}"
class="border rounded px-3 py-2 w-full">
</div>
<div>
<label class="block text-sm mb-1">
Einkaufspreis
</label>
<input
type="number"
step="0.01"
name="price_buy"
value="{{ item.price_buy }}"
class="border rounded px-3 py-2 w-full">
</div>
<div>
<label class="block text-sm mb-1">
Lieferant
</label>
<select
name="supplier"
class="border rounded px-3 py-2 w-full">
<option value="">
---
</option>
{% for s in table.suppliers %}
<option
value="{{ s.id }}"
{% if item.supplier_id == s.id %}selected{% endif %}>
{{ s.name }}
</option>
{% endfor %}
</select>
</div>
</div>
<div class="mt-5 flex gap-2">
<button
type="submit"
class="btn btn-save">
Speichern
</button>
<button
type="submit"
formaction="{% url 'invoices:items_delete' item.id %}"
class="btn btn-delete">
Löschen
</button>
</div>
</form>
@@ -1,16 +0,0 @@
<select
name="item_type"
form="save-form-{{ record.id }}"
class="border rounded px-2 py-1 w-full">
{% for v,l in table.types %}
<option
value="{{ v }}"
{% if record.item_type == v %}selected{% endif %}>
{{ l }}
</option>
{% endfor %}
</select>
@@ -1,7 +0,0 @@
<input
type="number"
step="0.01"
name="price_net"
form="save-form-{{ record.id }}"
value="{{ record.price_net }}"
class="border rounded px-2 py-1 w-full">
@@ -1,7 +0,0 @@
<input
type="number"
step="0.01"
name="tax_rate"
form="save-form-{{ record.id }}"
value="{{ record.tax_rate }}"
class="border rounded px-2 py-1 w-full">
@@ -1,20 +0,0 @@
<select
name="unit"
form="save-form-{{ record.id }}"
class="border rounded px-2 py-1 w-full">
{% for unit in table.units %}
<option
value="{{ unit.id }}"
{% if record.unit_id == unit.id %}selected{% endif %}>
{{ unit.name }}
</option>
{% endfor %}
</select>
+9 -5
View File
@@ -1,5 +1,5 @@
from django.urls import path
from . import views
from . import views, views_info
app_name = "invoices"
@@ -37,7 +37,6 @@ urlpatterns = [
path("invoices/<int:invoice_id>/download/", views.invoices_download, name="invoices_download"),
path("invoices/<int:invoice_id>/cancel/", views.invoices_cancel, name="invoices_cancel"),
path("invoices/<int:invoice_id>/paid/", views.invoices_paid, name="invoices_paid"),
path("invoices/<int:invoice_id>/overdue/", views.invoices_overdue, name="invoices_overdue"),
path("invoices/<int:invoice_id>/delete/", views.invoices_delete, name="invoices_delete"),
@@ -54,7 +53,6 @@ urlpatterns = [
path("offers/<int:offer_id>/download/", views.offers_download, name="offers_download"),
path("offers/<int:offer_id>/accept/", views.offers_accept, name="offers_accept"),
path("offers/<int:offer_id>/reject/", views.offers_reject, name="offers_reject"),
path("offers/<int:offer_id>/expire/", views.offers_expire, name="offers_expire"),
path("offers/<int:offer_id>/delete/", views.offers_delete, name="offers_delete"),
# Mahnungen
@@ -64,9 +62,9 @@ urlpatterns = [
path("reminders/<int:reminder_id>/download/", views.reminders_download, name="reminders_download"),
path("reminders/<int:reminder_id>/cancel/", views.reminders_cancel, name="reminders_cancel"),
path("reminders/<int:reminder_id>/delete/", views.reminders_delete, name="reminders_delete"),
path("reminders/<int:reminder_id>/expire/", views.reminders_expire, name="reminders_expire"),
path("reminders/<int:reminder_id>/paid/", views.reminders_paid, name="reminders_paid"),
# Zahlungen
path("payments/", views.invoices_payments_overview, name="invoices_payments_overview"),
# AJAX
path("ajax/customer/<int:customer_id>/contacts/", views.ajax_contacts, name="ajax_contacts"),
@@ -74,4 +72,10 @@ urlpatterns = [
path("ajax/project/<int:project_id>/invoice-data/", views.ajax_invoicedata, name="ajax_invoicedata"),
path("ajax/invoice/<int:invoice_id>/sources/", views.ajax_invoiceitemsources, name="ajax_invoice_item_sources"),
path("ajax/invoice-item-data/", views.ajax_invoice_item_data, name="ajax_invoice_item_data"),
path("ajax/invoice-info/", views_info.ajax_invoice_info, name="ajax_invoice_info"),
path("ajax/offer-info/", views_info.ajax_offer_info, name="ajax_offer_info"),
path("ajax/reminder-info/",views_info.ajax_reminder_info,name="ajax_reminder_info"),
path("ajax/customer-info/",views_info.ajax_customer_info, name="ajax_customer_info"),
path("ajax/kpi-info/", views_info.ajax_kpi_info, name="ajax_kpi_info"),
]
+302 -155
View File
@@ -1,8 +1,8 @@
from django.contrib.auth.decorators import login_required
from accounts.decorators import permission_required
from django.shortcuts import render, redirect, get_object_or_404
from .models import Customer, CustomerContact, CustomerLocation, Item, Invoice, InvoiceItem, Unit, Offer, OfferItem, Reminder
from .forms import CustomerForm, ItemForm, CustomerInvoiceForm, ProjectInvoiceForm, InvoiceItemForm, OfferForm, OfferItemForm, ReminderForm
from .models import Customer, CustomerContact, CustomerLocation, Item, Invoice, InvoiceItem, Unit, Offer, OfferItem, Reminder, InvoicePaymentAllocation, InvoicePayment
from .forms import CustomerForm, ItemForm, CustomerInvoiceForm, ProjectInvoiceForm, InvoiceItemForm, OfferForm, OfferItemForm, ReminderForm, InvoicePaymentForm
from datetime import date
from projects.models import Project
from django.http import JsonResponse, FileResponse
@@ -18,8 +18,9 @@ from django.utils import timezone
from weasyprint import HTML
import io
from django.db.models import Prefetch, Sum
from .tables import ItemTable, CustomerTable, CustomerArchivedTable, ReminderTable, InvoiceTable, InvoiceItemTable, OfferTable, OfferItemTable
from .tables import ItemTable, CustomerTable, CustomerArchivedTable, ReminderTable, InvoiceTable, InvoiceItemTable, OfferTable, OfferItemTable, InvoicePaymentTable
from django_tables2 import RequestConfig
from finance.models import Category, Supplier
def generate_document_number(template, number):
today = date.today()
@@ -416,6 +417,8 @@ def items_overview(request):
table = ItemTable(items)
table.types = Item.ItemType.choices
table.units = Unit.objects.order_by("name")
table.categories = Category.objects.order_by("name")
table.suppliers = Supplier.objects.order_by("name")
RequestConfig(
request,
@@ -450,13 +453,14 @@ def items_add(request):
@login_required
@permission_required("invoices_items_edit")
def items_edit(request, item_id):
if request.method != "POST":
print("Invalid request method for items_edit")
return redirect(
"invoices:items_overview"
)
item = get_object_or_404(
Item,
id=item_id
@@ -467,7 +471,8 @@ def items_edit(request, item_id):
request.POST,
instance=item
)
if not form.is_valid():
print(form.errors.as_json())
if form.is_valid():
form.save()
@@ -751,11 +756,12 @@ def ajax_invoice_item_data(request):
@login_required
@permission_required("invoices_invoices_view")
def invoices_invoices_overview(request):
status = request.GET.get("status")
invoices = Invoice.objects.select_related(
"customer",
"project"
).all()
invoices = Invoice.objects.select_related("customer", "project").all()
if status:
invoices = invoices.filter(status=status)
table = InvoiceTable(invoices)
@@ -1174,7 +1180,11 @@ def invoices_download(request, invoice_id):
)
number_settings.invoice_next_number += 1
number_settings.save(update_fields=["invoice_next_number"])
invoice.save(update_fields=["invoice_number"])
company = CompanySetting.objects.first()
invoice.is_small_business = company.is_small_business
invoice.save(update_fields=["invoice_number", "is_small_business"])
# ==========================
# PDF HTML
@@ -1290,40 +1300,87 @@ def invoices_cancel(request, invoice_id):
id=invoice_id,
)
if invoice.status == "canceled" and invoice.pdf_canceled_file:
# ==========================
# Existierendes Storno zurückgeben
# ==========================
if invoice.status == Invoice.Status.CANCELED and invoice.pdf_canceled_file:
return FileResponse(
invoice.pdf_canceled_file.open("rb"),
as_attachment=True,
filename=invoice.pdf_canceled_file.name.split("/")[-1],
)
# ==========================
# Nur finale Rechnungen stornierbar
# ==========================
if invoice.status not in [
Invoice.Status.SENT,
Invoice.Status.PAID,
Invoice.Status.OVERDUE,
]:
return redirect(
"invoices:invoices_detail",
invoice.id,
)
company = CompanySetting.objects.first()
number_settings = company.number_settings
# ==========================
# Stornonummer
# ==========================
invoice.cancel_number = generate_document_number(
number_settings.cancel_format,
number_settings.cancel_next_number,
)
number_settings.cancel_next_number += 1
number_settings.save(
update_fields=[
"cancel_next_number"
]
)
if invoice.status not in ["sent", "paid"]:
return redirect(
"invoices:invoices_detail",
invoice.id,
)
# ==========================
# Positionen
# ==========================
items = invoice.items.all()
# ==========================
# Summen
# Bereits bezahlt
# ==========================
total_net = Decimal("0.00")
paid_amount = invoice.paid_amount
# ==========================
# Rückerstattung berechnen
# ==========================
refund_amount = min(
paid_amount,
invoice.total_amount
)
show_service_date = any(
item.service_period_from
for item in items
)
tax_summary = defaultdict(
lambda: Decimal("0.00")
@@ -1331,42 +1388,19 @@ def invoices_cancel(request, invoice_id):
for item in items:
item_net = Decimal(
str(item.calculated_total_net)
)
total_net += item_net
tax_rate = Decimal(
str(item.unit_tax_rate)
)
item_net = item.calculated_total_net
tax_amount = (
item_net *
tax_rate /
item.unit_tax_rate /
Decimal("100")
)
tax_summary[tax_rate] += tax_amount
tax_summary[item.unit_tax_rate] += tax_amount
total_tax = sum(
tax_summary.values(),
Decimal("0.00")
)
total_gross = (
total_net +
total_tax
)
company = CompanySetting.objects.first()
original_status = invoice.status
show_service_date = any(
item.service_period_from
for item in items
)
# ==========================
# PDF HTML
# ==========================
html_string = render_to_string(
"invoices/invoices/pdf_cancel.html",
@@ -1376,42 +1410,54 @@ def invoices_cancel(request, invoice_id):
"company": company,
"cancel_date": timezone.now(),
"cancel_number": invoice.cancel_number,
"original_status": original_status,
"total_net": total_net,
"tax_summary": dict(tax_summary),
"total_tax": total_tax,
"total_gross": total_gross,
"paid_amount": paid_amount,
"refund_amount": refund_amount,
"total_net": invoice.total_net,
"total_tax": invoice.total_tax,
"total_gross": invoice.total_amount,
"show_service_date": show_service_date,
"tax_summary": dict(tax_summary),
},
)
# ==========================
# PDF erstellen
# ==========================
pdf_file = HTML(
string=html_string,
base_url=request.build_absolute_uri("/"),
).write_pdf()
filename = (
f"Stornorechnung-{invoice.cancel_number}.pdf"
)
invoice.pdf_canceled_file.save(
filename,
ContentFile(pdf_file),
save=False,
)
invoice.pdf_canceled_generated_at = timezone.now()
invoice.status = "canceled"
invoice.status = Invoice.Status.CANCELED
invoice.save(
update_fields=[
"pdf_canceled_file",
"pdf_canceled_generated_at",
"status",
'cancel_number',
"cancel_number",
]
)
return FileResponse(
io.BytesIO(pdf_file),
as_attachment=True,
@@ -1438,64 +1484,179 @@ def invoices_delete(request, invoice_id):
return redirect("invoices:invoices_invoices_overview",)
def create_invoice_income(invoice):
from finance.models import Income
if Income.objects.filter(
invoice=invoice
).exists():
return
last_payment = invoice.payments.order_by(
"-date"
).first()
if not last_payment:
return
Income.objects.create(
invoice=invoice,
amount=invoice.paid_invoice_amount,
date=last_payment.date,
name=f"Zahlung Rechnung {invoice.invoice_number}",
project=invoice.project,
payment_method=last_payment.payment_method,
created_by=last_payment.created_by,
)
def allocate_invoice_payment(payment):
remaining = payment.amount
invoice = payment.invoice
reminders = invoice.reminders.filter(
status__in=[
Reminder.Status.SENT,
Reminder.Status.EXPIRED,
]
).order_by("level")
for reminder in reminders:
already_paid = reminder.payment_allocations.aggregate(
total=Sum("amount")
)["total"] or Decimal("0.00")
open_fee = reminder.fee - already_paid
if open_fee <= 0:
continue
allocation_amount = min(
remaining,
open_fee
)
if allocation_amount > 0:
InvoicePaymentAllocation.objects.create(
payment=payment,
reminder=reminder,
amount=allocation_amount
)
remaining -= allocation_amount
if remaining <= 0:
break
if remaining > 0:
InvoicePaymentAllocation.objects.create(
payment=payment,
reminder=None,
amount=remaining
)
def update_invoice_payment_status(invoice):
# Mahnungen prüfen
reminders = invoice.reminders.filter(
status__in=[
Reminder.Status.SENT,
Reminder.Status.EXPIRED,
]
)
for reminder in reminders:
if reminder.paid_amount >= reminder.fee:
reminder.status = Reminder.Status.PAID
reminder.save(
update_fields=[
"status"
]
)
if invoice.paid_invoice_amount >= invoice.total_amount:
if invoice.status != Invoice.Status.PAID:
invoice.status = Invoice.Status.PAID
invoice.save(
update_fields=[
"status"
]
)
if invoice.project:
create_invoice_income(invoice)
@login_required
@permission_required("invoices_invoices_invoices_paid")
def invoices_paid(request, invoice_id):
invoice = get_object_or_404(
Invoice,
id=invoice_id,
)
if invoice.status != "sent":
if invoice.status not in [
Invoice.Status.SENT,
Invoice.Status.OVERDUE,
]:
return redirect(
"invoices:invoices_detail",
invoice_id=invoice.id,
)
invoice.status = "paid"
invoice.save(
update_fields=[
"status",
]
)
if request.method == "POST":
form = InvoicePaymentForm(request.POST)
if form.is_valid():
payment = form.save(commit=False)
payment.invoice = invoice
payment.created_by = request.user
payment.save()
allocate_invoice_payment(payment)
update_invoice_payment_status(invoice)
return redirect(
"invoices:invoices_detail",
invoice_id=invoice.id,
)
@login_required
@permission_required("invoices_invoices_invoices_overdue")
def invoices_overdue(request, invoice_id):
invoice = get_object_or_404(
Invoice,
id=invoice_id,
else:
print(form.errors)
else:
form = InvoicePaymentForm()
return render(
request,
"invoices/invoices/add_payment.html",
{
"form": form,
"invoice": invoice,
}
)
today = timezone.now().date()
if invoice.status != "sent":
return redirect(
"invoices:invoices_detail",
invoice_id=invoice.id,
)
if invoice.due_date and invoice.due_date < today:
invoice.status = "overdue"
invoice.save(
update_fields=[
"status",
]
)
return redirect(
"invoices:invoices_detail",
invoice_id=invoice.id,
)
@@ -1561,8 +1722,12 @@ def invoices_overdue(request, invoice_id):
@login_required
@permission_required("invoices_offers_view")
def invoices_offers_overview(request):
status = request.GET.get("status")
offers = Offer.objects.select_related("customer",).all()
if status:
offers = offers.filter(status=status)
table = OfferTable(offers)
RequestConfig(
@@ -2059,33 +2224,6 @@ def offers_reject(request, offer_id):
)
@login_required
@permission_required("invoices_offers_offers_status")
def offers_expire(request, offer_id):
offer = get_object_or_404(
Offer,
id=offer_id,
)
if offer.status != "sent":
return redirect(
"invoices:offers_detail",
offer_id=offer.id,
)
offer.status = "expired"
offer.save(
update_fields=[
"status",
]
)
return redirect(
"invoices:offers_detail",
offer_id=offer.id,
)
@@ -2163,9 +2301,10 @@ def offers_expire(request, offer_id):
@login_required
@permission_required("invoices_reminders_view")
def invoices_reminders_overview(request):
status = request.GET.get("status")
reminders = Reminder.objects.select_related("invoice").all()
if status:
reminders = reminders.filter(status=status)
table = ReminderTable(reminders)
RequestConfig(
@@ -2330,6 +2469,12 @@ def reminders_download(request, reminder_id):
invoice = reminder.invoice
payments = invoice.payments.order_by("date", "created_at")
total_paid = sum(
payment.amount
for payment in payments
)
# ==========================
# Rechnungsbetrag berechnen
@@ -2382,6 +2527,7 @@ def reminders_download(request, reminder_id):
+ reminder.fee
)
open_amount = total_due - total_paid
total_fees = (total_previous_fees + reminder.fee)
company = CompanySetting.objects.first()
@@ -2429,11 +2575,13 @@ def reminders_download(request, reminder_id):
"invoice_amount": invoice_amount,
"previous_reminders": previous_reminders,
"total_previous_fees": total_previous_fees,
"total_fees": total_fees,
"payments": payments,
"total_paid": total_paid,
"total_due": total_due,
"total_fees": total_due,
"open_amount": open_amount,
}
)
# ==========================
# PDF erstellen
# ==========================
@@ -2475,50 +2623,49 @@ def reminders_download(request, reminder_id):
@login_required
@permission_required("invoices_reminders_reminders_paid")
def reminders_paid(request, reminder_id):
reminder = get_object_or_404(
Reminder,
id=reminder_id,
)
if reminder.status != "sent":
return redirect(
"invoices:reminders_detail",
reminder_id=reminder.id,
)
reminder.status = "paid"
reminder.save(update_fields=["status",])
reminder.invoice.status = "paid"
reminder.invoice.save(update_fields=["status",])
return redirect(
"invoices:reminders_detail",
reminder_id=reminder.id,
)
@login_required
@permission_required("invoices_reminders_reminders_expire")
def reminders_expire(request, reminder_id):
reminder = get_object_or_404(
Reminder,
id=reminder_id,
@permission_required("invoices_payments_view")
def invoices_payments_overview(request):
payments = InvoicePayment.objects.select_related(
"invoice",
"invoice__customer",
"payment_method",
"created_by",
).order_by(
"-date"
)
if reminder.status != "sent":
return redirect(
"invoices:reminders_detail",
reminder_id=reminder.id,
table = InvoicePaymentTable(
payments
)
reminder.status = "expired"
reminder.save(update_fields=["status",])
return redirect(
"invoices:reminders_detail",
reminder_id=reminder.id,
RequestConfig(
request,
paginate={
"per_page": 25
}
).configure(
table
)
return render(
request,
"invoices/payments/overview.html",
{
"table": table,
}
)
+81
View File
@@ -0,0 +1,81 @@
from django.contrib.auth.decorators import login_required, permission_required
from django.db.models import Count, Q, Sum
from django.http import JsonResponse
from django.utils import timezone
from datetime import timedelta
from .models import Invoice, Offer, Reminder, Customer
@login_required
@permission_required("invoices_invoices_info")
def ajax_invoice_info(request):
now = timezone.now()
data = (
Invoice.objects
.filter(issue_date__year=now.year, issue_date__month=now.month)
.values("status")
.annotate(count=Count("id"))
)
return JsonResponse({item["status"]: item["count"] for item in data})
@login_required
@permission_required("invoices_offers_info")
def ajax_offer_info(request):
now = timezone.now()
data = (
Offer.objects
.filter(issue_date__year=now.year, issue_date__month=now.month)
.values("status")
.annotate(count=Count("id"))
)
return JsonResponse({item["status"]: item["count"] for item in data})
@login_required
@permission_required("invoices_reminders_info")
def ajax_reminder_info(request):
now = timezone.now()
data = (
Reminder.objects
.filter(issue_date__year=now.year, issue_date__month=now.month)
.values("status")
.annotate(count=Count("id"))
)
return JsonResponse({item["status"]: item["count"] for item in data})
@login_required
@permission_required("invoices_info")
def ajax_kpi_info(request):
today = timezone.now().date()
overdue_invoices = Invoice.objects.filter(status="overdue").count()
open_invoices = Invoice.objects.filter(status="sent").count()
open_offers = Offer.objects.filter(status="sent").count()
open_reminders = Reminder.objects.filter(status="sent").count()
overdue_amount = (
Invoice.objects
.filter(status="overdue")
.aggregate(total=Sum("items__unit_price_net"))["total"] or 0
)
return JsonResponse({
"overdue_invoices": overdue_invoices,
"open_invoices": open_invoices,
"open_offers": open_offers,
"open_reminders": open_reminders,
})
@login_required
@permission_required("invoices_customers_info")
def ajax_customer_info(request):
start_of_week = timezone.now().date() - timedelta(days=timezone.now().weekday())
return JsonResponse({
"total": Customer.objects.count(),
"new_this_week": Customer.objects.filter(created_at__date__gte=start_of_week).count(),
"companies": Customer.objects.filter(customer_type="company").count(),
"private": Customer.objects.filter(customer_type="private").count(),
})
+2 -2
View File
@@ -19,7 +19,7 @@ Einstellungen der App und Module bearbeiten
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div>
<h2 class="text-xl font-bold">Aktionen</h2>
<h2 class="text-lg font-bold">Aktionen</h2>
<p class="text-slate-400 text-sm mt-1">Einträge hinzufügen</p>
</div>
@@ -35,7 +35,7 @@ Einstellungen der App und Module bearbeiten
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<h2 class="text-xl font-bold">Einträge</h2>
<h2 class="text-lg font-bold">Einträge</h2>
{% for obj in objects %}
<div class="flex justify-between border-b py-3">
<span>{{ obj }}</span>
+133 -3
View File
@@ -22,7 +22,7 @@ Einstellungen der App und Module bearbeiten
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors mb-8">
<div class="mb-6">
<h2 class="text-xl font-bold">
<h2 class="text-lg font-bold">
{{ group }}
</h2>
</div>
@@ -34,9 +34,9 @@ Einstellungen der App und Module bearbeiten
<a
href="{% url 'masterdata:detail' entry.model_name %}"
class="bg-slate-100 dark:bg-slate-700 rounded-xl p-6"
class="bg-slate-100 dark:bg-slate-700 rounded-xl p-6 border border-slate-100 dark:border-slate-700 hover:border-[var(--company-color)]/40 hover:shadow-sm transition-all duration-200"
>
<h2 class="text-xl font-bold">
<h2 class="text-lg font-bold">
{{ entry.name }}
</h2>
<p class="text-sm mt-2">
@@ -56,4 +56,134 @@ Einstellungen der App und Module bearbeiten
</div>
{% endfor %}
<!-- System / Developer Panel -->
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors mb-8">
<div class="flex items-center justify-between flex-wrap gap-3 mb-6">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-indigo-100 dark:bg-indigo-900 flex items-center justify-center text-indigo-600 dark:text-indigo-300">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path d="M8 9l3 3-3 3m5 0h3M3 12a9 9 0 1 0 18 0A9 9 0 0 0 3 12z"/></svg>
</div>
<div>
<h2 class="text-lg font-bold">System</h2>
<p class="text-xs text-slate-400 mt-1">ProjectApp · Entwicklerinfo</p>
</div>
</div>
<span id="status-badge" class="hidden items-center gap-1 text-xs font-medium px-2.5 py-1 rounded-full"></span>
</div>
<!-- Meta-Grid -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-2 mb-3" id="meta-grid" style="display:none">
<div class="bg-slate-100 dark:bg-slate-700 rounded-lg px-3 py-2">
<p class="text-xs text-slate-400 mb-1">Branch</p>
<p class="text-sm font-mono font-medium" id="meta-branch">—</p>
</div>
<div class="bg-slate-100 dark:bg-slate-700 rounded-lg px-3 py-2">
<p class="text-xs text-slate-400 mb-1">Lokaler commit</p>
<p class="text-sm font-mono font-medium" id="meta-local">—</p>
</div>
<div class="bg-slate-100 dark:bg-slate-700 rounded-lg px-3 py-2">
<p class="text-xs text-slate-400 mb-1">Letzter commit</p>
<p class="text-sm font-medium" id="meta-date">—</p>
</div>
<div class="bg-slate-100 dark:bg-slate-700 rounded-lg px-3 py-2">
<p class="text-xs text-slate-400 mb-1">Autor</p>
<p class="text-sm font-medium" id="meta-author">—</p>
</div>
</div>
<!-- Letzte Commit-Message -->
<div class="bg-slate-100 dark:bg-slate-700 rounded-lg px-3 py-2 mb-4 hidden" id="meta-msg-box">
<p class="text-xs text-slate-400 mb-1">Letzte Änderung</p>
<p class="text-xs font-medium" id="meta-msg">—</p>
</div>
<div class="border-t border-slate-200 dark:border-slate-700 my-4"></div>
<!-- Aktionen -->
<div class="flex items-center gap-3 flex-wrap">
<button onclick="checkUpdate()" id="check-btn" class="btn btn-action flex items-center gap-2 text-sm">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 4v6h6M20 20v-6h-6M4.93 15a9 9 0 1 0 .67-6.27"/></svg>
Auf Updates prüfen
</button>
<span class="text-xs text-slate-400">Vergleicht mit origin/main</span>
</div>
<!-- Changelog -->
<div id="changelog-box" class="hidden mt-4 pl-3 border-l-2 border-indigo-400">
<p class="text-xs text-slate-400 uppercase tracking-wider mb-2">Neue commits</p>
<div id="changelog-items" class="space-y-1"></div>
<button onclick="applyUpdate()" id="apply-btn" class="hidden btn btn-add mt-3 text-sm">
Update installieren
</button>
</div>
</div>
<script>
async function checkUpdate() {
const btn = document.getElementById('check-btn');
btn.disabled = true;
btn.innerHTML = '<svg class="animate-spin" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 4v6h6M20 20v-6h-6M4.93 15a9 9 0 1 0 .67-6.27"/></svg> Prüfe…';
const res = await fetch("{% url 'masterdata:update_check' %}");
const data = await res.json();
// Meta-Grid befüllen
document.getElementById('meta-branch').textContent = data.branch || '—';
document.getElementById('meta-local').textContent = data.local || '—';
document.getElementById('meta-date').textContent = data.last_commit_date || '—';
document.getElementById('meta-author').textContent = data.last_commit_author || '—';
document.getElementById('meta-msg').textContent = data.last_commit_msg || '—';
document.getElementById('meta-grid').style.display = 'grid';
document.getElementById('meta-msg-box').classList.remove('hidden');
// Badge
const badge = document.getElementById('status-badge');
badge.classList.remove('hidden');
badge.classList.add('inline-flex');
if (data.up_to_date) {
badge.className = 'inline-flex items-center gap-1 text-xs font-medium px-2.5 py-1 rounded-full bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300';
badge.innerHTML = '<svg width="10" height="10" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414L8.414 15 3.293 9.879a1 1 0 011.414-1.414L8.414 12.172l6.879-6.879a1 1 0 011.414 0z"/></svg> Aktuell';
document.getElementById('changelog-box').classList.add('hidden');
} else {
badge.className = 'inline-flex items-center gap-1 text-xs font-medium px-2.5 py-1 rounded-full bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300';
badge.innerHTML = `<svg width="10" height="10" fill="currentColor" viewBox="0 0 20 20"><path d="M10 2a8 8 0 100 16A8 8 0 0010 2zm0 4a1 1 0 011 1v3a1 1 0 01-2 0V7a1 1 0 011-1zm0 7a1 1 0 100 2 1 1 0 000-2z"/></svg> Update verfügbar → ${data.remote}`;
const items = document.getElementById('changelog-items');
items.innerHTML = data.changes.map(c => {
const [hash, ...rest] = c.split(' ');
return `<p class="text-xs font-mono"><span class="text-indigo-400">${hash}</span> <span class="text-slate-400">${rest.join(' ')}</span></p>`;
}).join('');
document.getElementById('apply-btn').classList.remove('hidden');
document.getElementById('changelog-box').classList.remove('hidden');
}
btn.disabled = false;
btn.innerHTML = '<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 4v6h6M20 20v-6h-6M4.93 15a9 9 0 1 0 .67-6.27"/></svg> Erneut prüfen';
}
async function applyUpdate() {
if (!confirm('Update jetzt installieren?')) return;
const res = await fetch("{% url 'masterdata:update_apply' %}", {
method: 'POST',
headers: { 'X-CSRFToken': '{{ csrf_token }}' }
});
const data = await res.json();
if (data.success) { location.reload(); }
else { alert('Fehler: ' + data.error); }
}
</script>
{% endblock %}
+28 -2
View File
@@ -9,6 +9,8 @@ from core.forms.base import BaseForm
import subprocess
from django.http import JsonResponse
from django.conf import settings
import sys
from pathlib import Path
def get_registry_entry(model_name):
entry = next(
@@ -121,6 +123,8 @@ def setting_delete(request, model_name, pk):
@@ -130,18 +134,32 @@ def _git(cmd):
r = subprocess.run(cmd, capture_output=True, text=True, cwd=BASE_DIR)
return r.stdout.strip(), r.returncode
def _run(cmd):
r = subprocess.run(cmd, capture_output=True, text=True, cwd=BASE_DIR)
return r.stdout.strip(), r.returncode
@login_required
@permission_required("settings_update_check")
def update_check(request):
_git(["git", "fetch", "origin"])
local, _ = _git(["git", "rev-parse", "HEAD"])
remote, _ = _git(["git", "rev-parse", "origin/main"])
branch, _ = _git(["git", "rev-parse", "--abbrev-ref", "HEAD"])
log, _ = _git(["git", "log", f"{local}..{remote}", "--oneline"])
date, _ = _git(["git", "log", "-1", "--format=%cd", "--date=format:%d.%m.%Y %H:%M"])
author, _ = _git(["git", "log", "-1", "--format=%an"])
msg, _ = _git(["git", "log", "-1", "--format=%s"])
return JsonResponse({
"up_to_date": local == remote,
"local": local[:7],
"remote": remote[:7],
"branch": branch,
"changes": log.splitlines() if log else [],
"last_commit_date": date,
"last_commit_author": author,
"last_commit_msg": msg,
})
@login_required
@@ -149,9 +167,17 @@ def update_check(request):
def update_apply(request):
if request.method != "POST":
return JsonResponse({"error": "POST required"}, status=405)
_, code = _git(["git", "pull", "origin", "main"])
if code != 0:
return JsonResponse({"error": "git pull fehlgeschlagen"}, status=500)
_git(["pip", "install", "-r", f"{BASE_DIR}/requirements.txt", "-q"])
_git(["python", "manage.py", "migrate", "--no-input"])
_, pip_code = _run([sys.executable, "-m", "pip", "install", "-r", str(BASE_DIR / "requirements.txt"), "-q"])
if pip_code != 0:
return JsonResponse({"error": "pip install fehlgeschlagen"}, status=500)
_, migrate_code = _run([sys.executable, "manage.py", "migrate", "--no-input"])
if migrate_code != 0:
return JsonResponse({"error": "Migration fehlgeschlagen"}, status=500)
return JsonResponse({"success": True})
+1 -1
View File
@@ -1,4 +1,4 @@
# Generated by Django 6.0.6 on 2026-06-27 17:19
# Generated by Django 6.0.6 on 2026-06-28 10:19
import django.db.models.deletion
import projects.models
+1 -1
View File
@@ -21,7 +21,7 @@ Abgeschlossene und deaktivierte Projekte
<a href="{% url 'projects:overview' m.project.id %}"
class="p-6 rounded-xl shadow hover:shadow-lg transition block text-white"
style="background: linear-gradient(135deg, {{ m.project.color }}, {{ m.project.color }}cc);">
<h2 class="text-xl font-bold">
<h2 class="text-lg font-bold">
{{ m.project.name }}
</h2>
<p class="text-sm mt-2">
+5 -5
View File
@@ -17,7 +17,7 @@ Standorte & Ansprechpartner
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div>
<h2 class="text-xl font-bold">Aktionen</h2>
<h2 class="text-lg font-bold">Aktionen</h2>
<p class="text-slate-400 text-sm mt-1">Standorte und Ansprechpartner hinzufügen</p>
</div>
@@ -36,7 +36,7 @@ Standorte & Ansprechpartner
<div class="mb-8">
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<h2 class="text-xl font-bold mb-2">Allgemein</h2>
<h2 class="text-lg font-bold mb-2">Allgemein</h2>
<form method="post" enctype="multipart/form-data" action="{% url 'projects:edit' project.id %}">
{% csrf_token %}
@@ -78,7 +78,7 @@ Standorte & Ansprechpartner
<!-- Mitarbeiter -->
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<h2 class="text-xl font-bold mb-3">
<h2 class="text-lg font-bold mb-2">
Mitarbeiter
</h2>
@@ -122,7 +122,7 @@ Standorte & Ansprechpartner
<!-- Standorte -->
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<h2 class="text-xl font-bold mb-3">
<h2 class="text-lg font-bold mb-2">
Standorte
</h2>
@@ -181,7 +181,7 @@ Standorte & Ansprechpartner
<!-- Ansprechpartner -->
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<h2 class="text-xl font-bold mb-3">
<h2 class="text-lg font-bold mb-2">
Ansprechpartner
</h2>
+12 -25
View File
@@ -19,62 +19,49 @@ Projektübersicht und Module
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<!-- FINANZEN -->
<a href="{% url 'finance:overview' project.id %}" class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<a href="{% url 'finance:overview' project.id %}" class="border border-slate-100 dark:border-slate-700 hover:border-[var(--company-color)]/40 hover:shadow-sm transition-all duration-200 p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="text-2xl mb-2">💰</div>
<h2 class="text-xl font-bold">Finanzen</h2>
<h2 class="text-lg font-bold">Finanzen</h2>
<p class="text-slate-400 text-sm mt-1">Einnahmen & Ausgaben</p>
</a>
<!-- KASSE -->
<a href="" class="relative p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<a href="" class="border border-slate-100 dark:border-slate-700 hover:border-[var(--company-color)]/40 hover:shadow-sm transition-all duration-200 relative p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="absolute top-3 right-3 text-xs font-semibold px-2 py-1 rounded-full text-white" style="background-color: {{ company_settings.color }};">
In Arbeit
</div>
<div class="text-2xl mb-2">🏦</div>
<h2 class="text-xl font-bold">Kasse</h2>
<h2 class="text-lg font-bold">Kasse</h2>
<p class="text-slate-400 text-sm mt-1">Buchungen vornehmen</p>
</a>
<!-- ZEITERFASSUNG -->
<a href="{% url 'time_tracking:overview' project.id %}" class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<a href="{% url 'time_tracking:overview' project.id %}" class="border border-slate-100 dark:border-slate-700 hover:border-[var(--company-color)]/40 hover:shadow-sm transition-all duration-200 p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="text-2xl mb-2">⏱</div>
<h2 class="text-xl font-bold">Zeiterfassung</h2>
<h2 class="text-lg font-bold">Zeiterfassung</h2>
<p class="text-slate-400 text-sm mt-1">Arbeitszeiten der Helfer</p>
</a>
<!-- DOKUMENTE -->
<a href="{% url 'documents:overview' project.id %}" class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<a href="{% url 'documents:overview' project.id %}" class="border border-slate-100 dark:border-slate-700 hover:border-[var(--company-color)]/40 hover:shadow-sm transition-all duration-200 p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="text-2xl mb-2">📁</div>
<h2 class="text-xl font-bold">Dokumente</h2>
<h2 class="text-lg font-bold">Dokumente</h2>
<p class="text-slate-400 text-sm mt-1">Dateien & Bilder</p>
</a>
<!-- LISTE -->
<a href="{% url 'inventory:overview' project.id %}" class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<a href="{% url 'inventory:overview' project.id %}" class="border border-slate-100 dark:border-slate-700 hover:border-[var(--company-color)]/40 hover:shadow-sm transition-all duration-200 p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="text-2xl mb-2">🛒</div>
<h2 class="text-xl font-bold">Pack-/Einkaufsliste</h2>
<h2 class="text-lg font-bold">Pack-/Einkaufsliste</h2>
<p class="text-slate-400 text-sm mt-1">Vorhandenen & benötigtes Material</p>
</a>
<!-- Projektinformationen -->
<a href="{% url 'projects:info' project.id %}" class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<a href="{% url 'projects:info' project.id %}" class="border border-slate-100 dark:border-slate-700 hover:border-[var(--company-color)]/40 hover:shadow-sm transition-all duration-200 p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="text-2xl mb-2">ℹ️</div>
<h2 class="text-xl font-bold">Projektinformationen</h2>
<h2 class="text-lg font-bold">Projektinformationen</h2>
<p class="text-slate-400 text-sm mt-1">Standorte & Ansprechpartner</p>
</a>
</div>
<!-- OPTIONAL INFO BOX -->
<div class="p-6 rounded-xl shadow mb-8 bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<h2 class="text-xl font-bold">
Zusammenfassung
</h2>
<p class="text-slate-400 text-sm mt-1">
folgt
</p>
</div>
{% endblock %}
+3
View File
@@ -1,3 +1,4 @@
APScheduler==3.11.2
arrow==1.4.0
asgiref==3.11.1
binaryornot==0.6.0
@@ -9,6 +10,7 @@ click==8.4.2
cookiecutter==2.7.1
cssselect2==0.9.0
Django==6.0.6
django-apscheduler==0.7.0
django-browser-reload==1.21.0
django-filter==25.2
django-tables2==3.0.0
@@ -40,6 +42,7 @@ text-unidecode==1.3
tinycss2==1.5.1
tinyhtml5==2.1.0
tzdata==2026.2
tzlocal==5.4.3
urllib3==2.7.0
weasyprint==69.0
webencodings==0.5.1
Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

+1 -1
View File
File diff suppressed because one or more lines are too long
+13
View File
@@ -75,4 +75,17 @@
@apply text-white;
background-color: #cc4d49;
}
select {
@apply text-black dark:text-white dark:bg-slate-800;
}
.hide-scrollbar {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
.hide-scrollbar::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
# Generated by Django 6.0.6 on 2026-06-27 17:19
# Generated by Django 6.0.6 on 2026-06-28 10:19
import django.db.models.deletion
from django.conf import settings
+11 -1
View File
@@ -1,6 +1,6 @@
from django.db import models
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
User = get_user_model()
@@ -71,6 +71,16 @@ class TimeEntry(models.Model):
def __str__(self):
return f"{self.user} - {self.start:%d.%m.%Y} - {self.duration_hours} Stunden"
def clean(self):
if self.start and self.end and self.start >= self.end:
raise ValidationError({
'end': 'Das Ende muss nach dem Start liegen.'
})
def save(self, *args, **kwargs):
self.full_clean()
super().save(*args, **kwargs)
@property
def duration_minutes(self):
@@ -19,7 +19,7 @@ Zeiten erfassen und verwalten
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div>
<h2 class="text-xl font-bold">
<h2 class="text-lg font-bold">
Aktionen
</h2>
@@ -59,7 +59,7 @@ Zeiten erfassen und verwalten
<div class="mb-6">
<h2 class="text-xl font-bold">
<h2 class="text-lg font-bold">
Zeiterfassungen
</h2>