v1.0
This commit is contained in:
@@ -3,7 +3,10 @@ __pycache__/
|
||||
*.pyo
|
||||
.env
|
||||
db.sqlite3
|
||||
*.sqlite3
|
||||
media/
|
||||
staticfiles/
|
||||
venv/
|
||||
.venv/
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
@@ -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')),
|
||||
|
||||
+8
-6
@@ -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
|
||||
# =====================
|
||||
|
||||
@@ -1,60 +1,60 @@
|
||||
{% 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">
|
||||
{{ field.label }}
|
||||
</label>
|
||||
<input
|
||||
type="{{ field.field.widget.input_type }}"
|
||||
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 }}"
|
||||
>
|
||||
{% if field.errors %}
|
||||
<p class="text-sm text-red-500">{{ field.errors|join:", " }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label for="{{ field.id_for_label }}"
|
||||
class="block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{{ field.label }}
|
||||
</label>
|
||||
<input
|
||||
type="{{ field.field.widget.input_type }}"
|
||||
name="{{ field.html_name }}"
|
||||
id="{{ field.id_for_label }}"
|
||||
{% if field.value %}value="{{ field.value }}"{% endif %}
|
||||
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>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<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 %}
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
+7
-4
@@ -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 "
|
||||
"focus:border-transparent transition"
|
||||
"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):
|
||||
|
||||
@@ -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')),
|
||||
|
||||
@@ -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")
|
||||
|
||||
+48
-19
@@ -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">
|
||||
|
||||
<div class="p-6 border-b border-white/20 " style="background-color: {{ company_settings.color }};">
|
||||
|
||||
<h2 class="text-xl font-bold">
|
||||
{{ company_settings.companyname|default:"Projektverwaltung" }}
|
||||
</h2>
|
||||
<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="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" }}
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
</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>
|
||||
<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>
|
||||
<!--
|
||||
<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>
|
||||
{# 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="{% 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="{% 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="{% 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>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-2">
|
||||
<a href="{% url 'projects:add' %}" class="btn btn-add">+ Projekt</a>
|
||||
</div>
|
||||
|
||||
{# 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>
|
||||
|
||||
{% 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>
|
||||
</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 %}
|
||||
{# 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">
|
||||
|
||||
{% 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>
|
||||
{% endif %}
|
||||
<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>
|
||||
|
||||
</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>
|
||||
<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>
|
||||
<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,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,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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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')),
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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,32 +72,82 @@ 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">
|
||||
{{ total_income }} €
|
||||
</p>
|
||||
</div>
|
||||
<!-- Einnahmen -->
|
||||
<div class="p-6 rounded-xl border border-green-200 dark:border-green-900 bg-white dark:bg-slate-800 transition">
|
||||
|
||||
<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">
|
||||
{{ total_expense }} €
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||
Einnahmen
|
||||
</p>
|
||||
|
||||
<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">
|
||||
{{ total_profit }} €
|
||||
</p>
|
||||
<p class="text-3xl font-bold text-green-600 mt-2">
|
||||
{{ total_income }} €
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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="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>
|
||||
|
||||
|
||||
<!-- ===================== -->
|
||||
<!-- LIST CARDS -->
|
||||
<!-- ===================== -->
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -5,7 +5,6 @@ from core.forms.base import BaseForm
|
||||
|
||||
|
||||
class InventoryItemForm(BaseForm):
|
||||
|
||||
class Meta:
|
||||
model = InventoryItem
|
||||
fields = [
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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,20 +10,20 @@
|
||||
name="item_id"
|
||||
value="{{ record.id }}"
|
||||
form="save-form-{{ record.id }}">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
form="save-form-{{ record.id }}"
|
||||
class="btn btn-save">
|
||||
Speichern
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
form="save-form-{{ record.id }}"
|
||||
class="btn btn-save">
|
||||
Speichern
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
form="save-form-{{ record.id }}"
|
||||
formaction="{% url 'inventory:delete' project.id record.id %}"
|
||||
class="btn btn-delete">
|
||||
Löschen
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
form="save-form-{{ record.id }}"
|
||||
formaction="{% url 'inventory:delete' project.id record.id %}"
|
||||
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 }}
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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")
|
||||
@@ -428,4 +431,94 @@ def inventory_global_edit(request, item_id):
|
||||
"locations": InventoryLocation.objects.all(),
|
||||
"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
|
||||
)
|
||||
@@ -0,0 +1,2 @@
|
||||
# invoices/__init__.py
|
||||
default_app_config = "invoices.apps.InvoicesConfig"
|
||||
+67
-1
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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>
|
||||
{% endif %}
|
||||
|
||||
<th class="left">
|
||||
{% if show_service_date %}
|
||||
|
||||
<th>
|
||||
Leistungsdatum
|
||||
</th>
|
||||
|
||||
{% endif %}
|
||||
|
||||
|
||||
<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">
|
||||
{% 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">
|
||||
{% if show_service_date %}
|
||||
|
||||
<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="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.
|
||||
|
||||
<br><br>
|
||||
|
||||
Die ursprüngliche Rechnung wurde bereits bezahlt.
|
||||
{% if paid_amount > 0 %}
|
||||
|
||||
Es entsteht ein Guthaben in Höhe von
|
||||
<br><br>
|
||||
|
||||
{% if company.is_small_business %}
|
||||
{{ total_net|floatformat:2 }}
|
||||
{% else %}
|
||||
{{ total_gross|floatformat:2 }}
|
||||
{% endif %}
|
||||
|
||||
€.
|
||||
Die ursprüngliche Rechnung wurde bereits bezahlt.
|
||||
|
||||
|
||||
Der zu erstattende Betrag beträgt:
|
||||
|
||||
|
||||
<strong>
|
||||
{{ refund_amount|floatformat:2 }} €
|
||||
</strong>
|
||||
|
||||
|
||||
<br><br>
|
||||
|
||||
|
||||
Die Rückerstattung erfolgt separat.
|
||||
|
||||
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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
]
|
||||
+305
-158
@@ -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",
|
||||
]
|
||||
)
|
||||
|
||||
return redirect(
|
||||
"invoices:invoices_detail",
|
||||
invoice_id=invoice.id,
|
||||
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,
|
||||
)
|
||||
else:
|
||||
print(form.errors)
|
||||
else:
|
||||
form = InvoicePaymentForm()
|
||||
return render(
|
||||
request,
|
||||
"invoices/invoices/add_payment.html",
|
||||
{
|
||||
"form": form,
|
||||
"invoice": invoice,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required("invoices_invoices_invoices_overdue")
|
||||
def invoices_overdue(request, invoice_id):
|
||||
|
||||
invoice = get_object_or_404(
|
||||
Invoice,
|
||||
id=invoice_id,
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
reminder.status = "expired"
|
||||
reminder.save(update_fields=["status",])
|
||||
table = InvoicePaymentTable(
|
||||
payments
|
||||
)
|
||||
|
||||
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,
|
||||
}
|
||||
)
|
||||
@@ -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(),
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
+32
-6
@@ -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,28 +134,50 @@ 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"])
|
||||
log, _ = _git(["git", "log", f"{local}..{remote}", "--oneline"])
|
||||
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
|
||||
@permission_required("settings_update_apply")
|
||||
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,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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 |
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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,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
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user