Initial commit
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.env
|
||||
db.sqlite3
|
||||
media/
|
||||
staticfiles/
|
||||
venv/
|
||||
.venv/
|
||||
@@ -0,0 +1,2 @@
|
||||
django: python manage.py runserver
|
||||
tailwind: python manage.py tailwind start
|
||||
@@ -0,0 +1,34 @@
|
||||
from django.contrib import admin
|
||||
from .models import Role, UserProfile
|
||||
|
||||
|
||||
@admin.register(Role)
|
||||
class RoleAdmin(admin.ModelAdmin):
|
||||
|
||||
list_display = [
|
||||
"name",
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
"name",
|
||||
]
|
||||
|
||||
|
||||
@admin.register(UserProfile)
|
||||
class UserProfileAdmin(admin.ModelAdmin):
|
||||
|
||||
list_display = [
|
||||
"user",
|
||||
"role",
|
||||
]
|
||||
|
||||
list_filter = [
|
||||
"role",
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
"user__username",
|
||||
"user__first_name",
|
||||
"user__last_name",
|
||||
"user__email",
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
name = 'accounts'
|
||||
@@ -0,0 +1,50 @@
|
||||
from functools import wraps
|
||||
|
||||
from django.shortcuts import redirect
|
||||
from django.contrib import messages
|
||||
|
||||
|
||||
def permission_required(permission):
|
||||
|
||||
def decorator(view_func):
|
||||
|
||||
@wraps(view_func)
|
||||
def wrapper(request, *args, **kwargs):
|
||||
|
||||
# Superuser haben alles
|
||||
if request.user.is_superuser:
|
||||
return view_func(
|
||||
request,
|
||||
*args,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
|
||||
profile = getattr(
|
||||
request.user,
|
||||
"profile",
|
||||
None
|
||||
)
|
||||
|
||||
|
||||
if profile and profile.has_permission(permission):
|
||||
|
||||
return view_func(
|
||||
request,
|
||||
*args,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
|
||||
messages.error(
|
||||
request,
|
||||
"Keine Berechtigung für diese Aktion!"
|
||||
)
|
||||
|
||||
|
||||
return redirect("core:dashboard")
|
||||
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
@@ -0,0 +1,61 @@
|
||||
from django import forms
|
||||
from django.contrib.auth.models import User
|
||||
from .models import Role
|
||||
from core.forms.base import BaseForm
|
||||
|
||||
class UserAddForm(BaseForm):
|
||||
|
||||
password = forms.CharField(
|
||||
label="Passwort",
|
||||
widget=forms.PasswordInput
|
||||
)
|
||||
|
||||
role = forms.ModelChoiceField(
|
||||
queryset=Role.objects.order_by("name"),
|
||||
required=False,
|
||||
label="Rolle"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
|
||||
fields = [
|
||||
"username",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"email",
|
||||
]
|
||||
|
||||
labels = {
|
||||
"username": "Benutzername",
|
||||
"first_name": "Vorname",
|
||||
"last_name": "Nachname",
|
||||
"email": "E-Mail",
|
||||
}
|
||||
|
||||
help_texts = {
|
||||
"username": "",
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.apply_style()
|
||||
|
||||
|
||||
class RoleForm(BaseForm):
|
||||
|
||||
class Meta:
|
||||
model = Role
|
||||
|
||||
fields = [
|
||||
"name",
|
||||
]
|
||||
|
||||
labels = {
|
||||
"name": "Rollenname",
|
||||
}
|
||||
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.apply_style()
|
||||
@@ -0,0 +1,97 @@
|
||||
# Generated by Django 6.0.6 on 2026-06-23 22:12
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Role',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, unique=True, verbose_name='Rolle')),
|
||||
('projects_view', models.BooleanField(default=False, help_text='Benutzer darf Projekte einsehen.', verbose_name='Projekte anzeigen')),
|
||||
('projects_archived_view', models.BooleanField(default=False, help_text='Benutzer darf archivierte Projekte einsehen.', verbose_name='Archivierte Projekte anzeigen')),
|
||||
('projects_detail_view', models.BooleanField(default=False, help_text='Benutzer darf Details des Projekts einsehen.', verbose_name='Projektdetails anzeigen')),
|
||||
('projects_add', models.BooleanField(default=False, help_text='Benutzer darf neue Projekte anlegen.', verbose_name='Projekt erstellen')),
|
||||
('projects_contact_add', models.BooleanField(default=False, help_text='Benutzer darf Kontakte mit dem Projekt verknüpfen.', verbose_name='Projektkontakt verknüpfen')),
|
||||
('projects_contact_delete', models.BooleanField(default=False, help_text='Benutzer darf Kontakte aus dem Projekt entfernen.', verbose_name='Projektkontakt entfernen')),
|
||||
('projects_location_add', models.BooleanField(default=False, help_text='Benutzer darf Standorte mit dem Projekt verknüpfen.', verbose_name='Projektstandort verknüpfen')),
|
||||
('projects_location_delete', models.BooleanField(default=False, help_text='Benutzer darf Standorte aus dem Projekt entfernen.', verbose_name='Projektstandort entfernen')),
|
||||
('projects_edit', models.BooleanField(default=False, help_text='Benutzer darf Projekte ändern.', verbose_name='Projekte bearbeiten')),
|
||||
('projects_user_add', models.BooleanField(default=False, help_text='Benutzer darf Mitarbeiter im Projekt hinzufügen.', verbose_name='Projektmitarbeiter erstellen')),
|
||||
('projects_user_delete', models.BooleanField(default=False, help_text='Benutzer darf Mitarbeiter aus dem Projekt entfernen.', verbose_name='Projektmitarbeiter entfernen')),
|
||||
('projects_finance_view', models.BooleanField(default=False, help_text='Benutzer darf Projektfinanzen einsehen.', verbose_name='Projektfinanzen anzeigen')),
|
||||
('projects_finance_income_add', models.BooleanField(default=False, help_text='Benutzer darf Einnahmen hinzufügen.', verbose_name='Projekteinnahmen erfassen')),
|
||||
('projects_finance_expense_add', models.BooleanField(default=False, help_text='Benutzer darf Ausgaben hinzufügen.', verbose_name='Projektausgaben erfassen')),
|
||||
('projects_time_tracking_view', models.BooleanField(default=False, help_text='Benutzer darf Projektzeiten sehen.', verbose_name='Projektzeiten anzeigen')),
|
||||
('projects_time_tracking_edit', models.BooleanField(default=False, help_text='Benutzer darf Projektzeiten ändern.', verbose_name='Projektzeiten bearbeiten')),
|
||||
('projects_time_tracking_delete', models.BooleanField(default=False, help_text='Benutzer darf Projektzeiten löschen.', verbose_name='Projektzeiten löschen')),
|
||||
('projects_documents_view', models.BooleanField(default=False, help_text='Benutzer darf Dokumente sehen.', verbose_name='Projektdokumente anzeigen')),
|
||||
('projects_documents_add', models.BooleanField(default=False, help_text='Benutzer darf Dokumente hochladen.', verbose_name='Projektdokumente hinzufügen')),
|
||||
('projects_documents_delete', models.BooleanField(default=False, help_text='Benutzer darf Dokumente löschen.', verbose_name='Projektdokumente löschen')),
|
||||
('projects_inventory_inventory_view', models.BooleanField(default=False, help_text='Benutzer darf Projektinventar sehen.', verbose_name='Projektinventar anzeigen')),
|
||||
('projects_inventory_inventory_add', models.BooleanField(default=False, help_text='Benutzer darf Inventar hinzufügen.', verbose_name='Projektinventar hinzufügen')),
|
||||
('projects_inventory_inventory_edit', models.BooleanField(default=False, help_text='Benutzer darf Inventar ändern.', verbose_name='Projektinventar bearbeiten')),
|
||||
('projects_inventory_inventory_delete', models.BooleanField(default=False, help_text='Benutzer darf Inventar löschen.', verbose_name='Projektinventar löschen')),
|
||||
('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')),
|
||||
('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_customers_view', models.BooleanField(default=False, help_text='Benutzer darf Kunden sehen.', verbose_name='Kunden anzeigen')),
|
||||
('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')),
|
||||
('invoices_customers_archive', models.BooleanField(default=False, help_text='Benutzer darf Kunden archivieren.', verbose_name='Kunden archivieren')),
|
||||
('invoices_customers_activate', models.BooleanField(default=False, help_text='Benutzer darf Kunden wieder aktivieren.', verbose_name='Kunden reaktivieren')),
|
||||
('invoices_items_view', models.BooleanField(default=False, help_text='Benutzer darf Artikel sehen.', verbose_name='Artikel anzeigen')),
|
||||
('invoices_items_add', models.BooleanField(default=False, help_text='Benutzer darf Artikel erstellen.', verbose_name='Artikel erstellen')),
|
||||
('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_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')),
|
||||
('invoices_invoices_invoices_item_add', models.BooleanField(default=False, help_text='Benutzer darf Positionen hinzufügen.', verbose_name='Rechnungsposition hinzufügen')),
|
||||
('invoices_invoices_invoices_item_delete', models.BooleanField(default=False, help_text='Benutzer darf Positionen löschen.', verbose_name='Rechnungsposition löschen')),
|
||||
('invoices_invoices_invoices_preview', models.BooleanField(default=False, help_text='Benutzer darf Vorschauen öffnen.', verbose_name='Rechnungsvorschau 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')),
|
||||
('users_archive', models.BooleanField(default=False, help_text='Benutzer darf Mitarbeiter deaktivieren.', verbose_name='Benutzer archivieren')),
|
||||
('users_activate', models.BooleanField(default=False, help_text='Benutzer darf Mitarbeiter aktivieren.', verbose_name='Benutzer aktivieren')),
|
||||
('users_groups_add', models.BooleanField(default=False, help_text='Benutzer darf neue Rollen erstellen.', verbose_name='Rollen erstellen')),
|
||||
('users_groups_edit', models.BooleanField(default=False, help_text='Benutzer darf Rollen und Berechtigungen ändern.', verbose_name='Rollen bearbeiten')),
|
||||
('users_groups_delete', models.BooleanField(default=False, help_text='Benutzer darf Rollen entfernen.', verbose_name='Rollen löschen')),
|
||||
('settings_view', models.BooleanField(default=False, help_text='Benutzer darf Einstellungskategorien sehen.', verbose_name='Einstellungsübersicht anzeigen')),
|
||||
('settings_detail_view', models.BooleanField(default=False, help_text='Benutzer darf Systemeinstellungen sehen.', verbose_name='Einstellungen anzeigen')),
|
||||
('settings_add', models.BooleanField(default=False, help_text='Benutzer darf neue Einstellungen hinzufügen.', verbose_name='Einstellungen erstellen')),
|
||||
('settings_delete', models.BooleanField(default=False, help_text='Benutzer darf Einstellungen entfernen.', verbose_name='Einstellungen löschen')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Rolle',
|
||||
'verbose_name_plural': 'Rollen',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='UserProfile',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('role', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='accounts.role')),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.6 on 2026-06-23 23:44
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='role',
|
||||
name='invoices_invoices_invoices_download',
|
||||
field=models.BooleanField(default=False, help_text='Benutzer darf Rechnungen herunterladen.', verbose_name='Rechnung herunterladen'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.6 on 2026-06-24 08:59
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0002_role_invoices_invoices_invoices_download'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='role',
|
||||
name='invoices_invoices_invoices_cancel',
|
||||
field=models.BooleanField(default=False, help_text='Benutzer darf Rechnungen stornieren.', verbose_name='Rechnung stornieren'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.6 on 2026-06-24 09:13
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0003_role_invoices_invoices_invoices_cancel'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='role',
|
||||
name='invoices_invoices_invoices_cancel_download',
|
||||
field=models.BooleanField(default=False, help_text='Benutzer darf Stornorechnungen herunterladen.', verbose_name='Stornorechnung Download'),
|
||||
),
|
||||
]
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 6.0.6 on 2026-06-24 13:00
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0004_role_invoices_invoices_invoices_cancel_download'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='role',
|
||||
name='invoices_invoices_invoices_cancel_download',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='role',
|
||||
name='invoices_invoices_invoices_paid',
|
||||
field=models.BooleanField(default=False, help_text='Benutzer darf Rechnungsstatus ändern.', verbose_name='Rechnung auf bezahlt setzen'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,53 @@
|
||||
# Generated by Django 6.0.6 on 2026-06-25 16:29
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0005_remove_role_invoices_invoices_invoices_cancel_download_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='role',
|
||||
name='invoices_offers_add',
|
||||
field=models.BooleanField(default=False, help_text='Benutzer darf Angebote für Kunden erstellen.', verbose_name='Angebot erstellen'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='role',
|
||||
name='invoices_offers_offers_download',
|
||||
field=models.BooleanField(default=False, help_text='Benutzer darf Angebote herunterladen.', verbose_name='Angebot herunterladen'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='role',
|
||||
name='invoices_offers_offers_item_add',
|
||||
field=models.BooleanField(default=False, help_text='Benutzer darf Positionen hinzufügen.', verbose_name='Angebotsposition hinzufügen'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='role',
|
||||
name='invoices_offers_offers_item_delete',
|
||||
field=models.BooleanField(default=False, help_text='Benutzer darf Positionen löschen.', verbose_name='Angebotsposition löschen'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='role',
|
||||
name='invoices_offers_offers_preview',
|
||||
field=models.BooleanField(default=False, help_text='Benutzer darf Vorschauen öffnen.', verbose_name='Angebotsvorschau anzeigen'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='role',
|
||||
name='invoices_offers_offers_status',
|
||||
field=models.BooleanField(default=False, help_text='Benutzer darf Angebotsstatus ändern.', verbose_name='Angebotsstatus bearbeiten'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='role',
|
||||
name='invoices_offers_offers_view',
|
||||
field=models.BooleanField(default=False, help_text='Benutzer darf Angebotsdetails sehen.', verbose_name='Angebotsdetails anzeigen'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='role',
|
||||
name='invoices_offers_view',
|
||||
field=models.BooleanField(default=False, help_text='Benutzer darf Angebote sehen.', verbose_name='Angebote anzeigen'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,193 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
class Role(models.Model):
|
||||
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
unique=True,
|
||||
verbose_name="Rolle"
|
||||
)
|
||||
|
||||
projects_view = models.BooleanField(default=False, verbose_name="Projekte anzeigen", help_text="Benutzer darf Projekte einsehen.")
|
||||
projects_archived_view = models.BooleanField(default=False, verbose_name="Archivierte Projekte anzeigen", help_text="Benutzer darf archivierte Projekte einsehen.")
|
||||
projects_detail_view = models.BooleanField(default=False, verbose_name="Projektdetails anzeigen", help_text="Benutzer darf Details des Projekts einsehen.")
|
||||
projects_add = models.BooleanField(default=False, verbose_name="Projekt erstellen", help_text="Benutzer darf neue Projekte anlegen.")
|
||||
projects_contact_add = models.BooleanField(default=False, verbose_name="Projektkontakt verknüpfen", help_text="Benutzer darf Kontakte mit dem Projekt verknüpfen.")
|
||||
projects_contact_delete = models.BooleanField(default=False, verbose_name="Projektkontakt entfernen", help_text="Benutzer darf Kontakte aus dem Projekt entfernen.")
|
||||
projects_location_add = models.BooleanField(default=False, verbose_name="Projektstandort verknüpfen", help_text="Benutzer darf Standorte mit dem Projekt verknüpfen.")
|
||||
projects_location_delete = models.BooleanField(default=False, verbose_name="Projektstandort entfernen", help_text="Benutzer darf Standorte aus dem Projekt entfernen.")
|
||||
projects_edit = models.BooleanField(default=False, verbose_name="Projekte bearbeiten", help_text="Benutzer darf Projekte ändern.")
|
||||
|
||||
projects_user_add = models.BooleanField(default=False, verbose_name="Projektmitarbeiter erstellen", help_text="Benutzer darf Mitarbeiter im Projekt hinzufügen.")
|
||||
projects_user_delete = models.BooleanField(default=False, verbose_name="Projektmitarbeiter entfernen", help_text="Benutzer darf Mitarbeiter aus dem Projekt entfernen.")
|
||||
|
||||
projects_finance_view = models.BooleanField(default=False, verbose_name="Projektfinanzen anzeigen", help_text="Benutzer darf Projektfinanzen einsehen.")
|
||||
projects_finance_income_add = models.BooleanField(default=False, verbose_name="Projekteinnahmen erfassen", help_text="Benutzer darf Einnahmen hinzufügen.")
|
||||
projects_finance_expense_add = models.BooleanField(default=False, verbose_name="Projektausgaben erfassen", help_text="Benutzer darf Ausgaben hinzufügen.")
|
||||
|
||||
projects_time_tracking_view = models.BooleanField(default=False, verbose_name="Projektzeiten anzeigen", help_text="Benutzer darf Projektzeiten sehen.")
|
||||
projects_time_tracking_edit = models.BooleanField(default=False, verbose_name="Projektzeiten bearbeiten", help_text="Benutzer darf Projektzeiten ändern.")
|
||||
projects_time_tracking_delete = models.BooleanField(default=False, verbose_name="Projektzeiten löschen", help_text="Benutzer darf Projektzeiten löschen.")
|
||||
|
||||
projects_documents_view = models.BooleanField(default=False, verbose_name="Projektdokumente anzeigen", help_text="Benutzer darf Dokumente sehen.")
|
||||
projects_documents_add = models.BooleanField(default=False, verbose_name="Projektdokumente hinzufügen", help_text="Benutzer darf Dokumente hochladen.")
|
||||
projects_documents_delete = models.BooleanField(default=False, verbose_name="Projektdokumente löschen", help_text="Benutzer darf Dokumente löschen.")
|
||||
|
||||
projects_inventory_inventory_view = models.BooleanField(default=False, verbose_name="Projektinventar anzeigen", help_text="Benutzer darf Projektinventar sehen.")
|
||||
projects_inventory_inventory_add = models.BooleanField(default=False, verbose_name="Projektinventar hinzufügen", help_text="Benutzer darf Inventar hinzufügen.")
|
||||
projects_inventory_inventory_edit = models.BooleanField(default=False, verbose_name="Projektinventar bearbeiten", help_text="Benutzer darf Inventar ändern.")
|
||||
projects_inventory_inventory_delete = models.BooleanField(default=False, verbose_name="Projektinventar löschen", help_text="Benutzer darf Inventar löschen.")
|
||||
|
||||
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.")
|
||||
|
||||
|
||||
# =====================
|
||||
# Inventar
|
||||
# =====================
|
||||
inventory_view = models.BooleanField(default=False, verbose_name="Inventar anzeigen", help_text="Benutzer darf Inventar einsehen.")
|
||||
inventory_add = models.BooleanField(default=False, verbose_name="Inventar hinzufügen", help_text="Benutzer darf Inventareinträge erstellen.")
|
||||
inventory_edit = models.BooleanField(default=False, verbose_name="Inventar bearbeiten", help_text="Benutzer darf Inventareinträge ändern.")
|
||||
inventory_delete = models.BooleanField(default=False, verbose_name="Inventar löschen", help_text="Benutzer darf Inventareinträge löschen.")
|
||||
|
||||
|
||||
# =====================
|
||||
# Rechnungen
|
||||
# =====================
|
||||
invoices_view = models.BooleanField(default=False, verbose_name="Rechnungsbereich anzeigen", help_text="Benutzer darf Rechnungen verwalten.")
|
||||
|
||||
invoices_customers_view = models.BooleanField(default=False, verbose_name="Kunden anzeigen", help_text="Benutzer darf Kunden 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.")
|
||||
invoices_customers_archive = models.BooleanField(default=False, verbose_name="Kunden archivieren", help_text="Benutzer darf Kunden archivieren.")
|
||||
invoices_customers_activate = models.BooleanField(default=False, verbose_name="Kunden reaktivieren", help_text="Benutzer darf Kunden wieder aktivieren.")
|
||||
|
||||
invoices_items_view = models.BooleanField(default=False, verbose_name="Artikel anzeigen", help_text="Benutzer darf Artikel sehen.")
|
||||
invoices_items_add = models.BooleanField(default=False, verbose_name="Artikel erstellen", help_text="Benutzer darf Artikel erstellen.")
|
||||
invoices_items_edit = models.BooleanField(default=False, verbose_name="Artikel bearbeiten", help_text="Benutzer darf Artikel ändern.")
|
||||
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_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.")
|
||||
invoices_invoices_invoices_item_add = models.BooleanField(default=False, verbose_name="Rechnungsposition hinzufügen", help_text="Benutzer darf Positionen hinzufügen.")
|
||||
invoices_invoices_invoices_item_delete = models.BooleanField(default=False, verbose_name="Rechnungsposition löschen", help_text="Benutzer darf Positionen löschen.")
|
||||
invoices_invoices_invoices_preview = models.BooleanField(default=False, verbose_name="Rechnungsvorschau anzeigen", help_text="Benutzer darf Vorschauen öffnen.")
|
||||
invoices_invoices_invoices_download = models.BooleanField(default=False, verbose_name="Rechnung herunterladen", help_text="Benutzer darf Rechnungen herunterladen.")
|
||||
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_offers_view = models.BooleanField(default=False, verbose_name="Angebote anzeigen", help_text="Benutzer darf Angebote 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.")
|
||||
|
||||
|
||||
|
||||
|
||||
# =====================
|
||||
# Benutzerverwaltung
|
||||
# =====================
|
||||
users_view = models.BooleanField(default=False, verbose_name="Benutzer anzeigen", help_text="Benutzer darf Mitarbeiter sehen.")
|
||||
users_add = models.BooleanField(default=False, verbose_name="Benutzer erstellen", help_text="Benutzer darf neue Mitarbeiter anlegen.")
|
||||
users_edit = models.BooleanField(default=False, verbose_name="Benutzer bearbeiten", help_text="Benutzer darf Mitarbeiter bearbeiten.")
|
||||
users_archive = models.BooleanField(default=False, verbose_name="Benutzer archivieren", help_text="Benutzer darf Mitarbeiter deaktivieren.")
|
||||
users_activate = models.BooleanField(default=False, verbose_name="Benutzer aktivieren", help_text="Benutzer darf Mitarbeiter aktivieren.")
|
||||
|
||||
users_groups_add = models.BooleanField(default=False, verbose_name="Rollen erstellen", help_text="Benutzer darf neue Rollen erstellen.")
|
||||
users_groups_edit = models.BooleanField(default=False, verbose_name="Rollen bearbeiten", help_text="Benutzer darf Rollen und Berechtigungen ändern.")
|
||||
users_groups_delete = models.BooleanField(default=False, verbose_name="Rollen löschen", help_text="Benutzer darf Rollen entfernen.")
|
||||
|
||||
|
||||
# =====================
|
||||
# Einstellungen
|
||||
# =====================
|
||||
settings_view = models.BooleanField(default=False, verbose_name="Einstellungsübersicht anzeigen", help_text="Benutzer darf Einstellungskategorien sehen.")
|
||||
settings_detail_view = models.BooleanField(default=False, verbose_name="Einstellungen anzeigen", help_text="Benutzer darf Systemeinstellungen sehen.")
|
||||
settings_add = models.BooleanField(default=False, verbose_name="Einstellungen erstellen", help_text="Benutzer darf neue Einstellungen hinzufügen.")
|
||||
#settings_edit = models.BooleanField(default=False, verbose_name="Einstellungen bearbeiten", help_text="Benutzer darf Einstellungen ändern.")
|
||||
settings_delete = models.BooleanField(default=False, verbose_name="Einstellungen löschen", help_text="Benutzer darf Einstellungen entfernen.")
|
||||
|
||||
|
||||
# =====================
|
||||
# Zeiterfassung
|
||||
# =====================
|
||||
#time_tracking_own_view = models.BooleanField(default=False, verbose_name="Eigene Zeiten anzeigen", help_text="Benutzer darf eigene Arbeitszeiten sehen.")
|
||||
#time_tracking_own_add = models.BooleanField(default=False, verbose_name="Eigene Zeiten erfassen", help_text="Benutzer darf eigene Arbeitszeiten hinzufügen.")
|
||||
#time_tracking_own_edit = models.BooleanField(default=False, verbose_name="Eigene Zeiten bearbeiten", help_text="Benutzer darf eigene Arbeitszeiten ändern.")
|
||||
#time_tracking_own_delete = models.BooleanField(default=False, verbose_name="Eigene Zeiten löschen", help_text="Benutzer darf eigene Arbeitszeiten löschen.")
|
||||
|
||||
#time_tracking_all_view = models.BooleanField(default=False, verbose_name="Alle Zeiten anzeigen", help_text="Benutzer darf Arbeitszeiten aller Benutzer sehen.")
|
||||
#time_tracking_all_add = models.BooleanField(default=False, verbose_name="Alle Zeiten erfassen", help_text="Benutzer darf Arbeitszeiten für andere Benutzer hinzufügen.")
|
||||
#time_tracking_all_edit = models.BooleanField(default=False, verbose_name="Alle Zeiten bearbeiten", help_text="Benutzer darf Arbeitszeiten anderer Benutzer ändern.")
|
||||
#time_tracking_all_delete = models.BooleanField(default=False, verbose_name="Alle Zeiten löschen", help_text="Benutzer darf Arbeitszeiten anderer Benutzer löschen.")
|
||||
|
||||
|
||||
# =====================
|
||||
# Finanzen
|
||||
# =====================
|
||||
#finance_view = models.BooleanField(default=False, verbose_name="Finanzen anzeigen", help_text="Benutzer darf Finanzdaten sehen.")
|
||||
#finance_export = models.BooleanField(default=False, verbose_name="Finanzen exportieren", help_text="Benutzer darf Finanzdaten exportieren.")
|
||||
|
||||
|
||||
# =====================
|
||||
# Investments
|
||||
# =====================
|
||||
#invests_view = models.BooleanField(default=False, verbose_name="Investitionen anzeigen", help_text="Benutzer darf Investitionen sehen.")
|
||||
#invests_add = models.BooleanField(default=False, verbose_name="Investitionen erstellen", help_text="Benutzer darf neue Investitionen anlegen.")
|
||||
#invests_edit = models.BooleanField(default=False, verbose_name="Investitionen bearbeiten", help_text="Benutzer darf Investitionen ändern.")
|
||||
#invests_delete = models.BooleanField(default=False, verbose_name="Investitionen löschen", help_text="Benutzer darf Investitionen entfernen.")
|
||||
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Rolle"
|
||||
verbose_name_plural = "Rollen"
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
|
||||
|
||||
class UserProfile(models.Model):
|
||||
|
||||
user = models.OneToOneField(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="profile"
|
||||
)
|
||||
|
||||
role = models.ForeignKey(
|
||||
Role,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
|
||||
|
||||
def has_permission(self, permission):
|
||||
|
||||
if self.user.is_superuser:
|
||||
return True
|
||||
|
||||
if not self.role:
|
||||
return False
|
||||
|
||||
return getattr(
|
||||
self.role,
|
||||
permission.replace(".", "_"),
|
||||
False
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.user.username
|
||||
@@ -0,0 +1,102 @@
|
||||
import django_tables2 as tables
|
||||
from django.contrib.auth.models import User
|
||||
from .models import Role
|
||||
|
||||
class RoleTable(tables.Table):
|
||||
toggle_prefix = "role"
|
||||
detail_template = "accounts/tables/user/role_edit_form.html"
|
||||
name = tables.Column(
|
||||
verbose_name="Rolle"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Role
|
||||
template_name = "django_tables2/table_toggle.html"
|
||||
fields = (
|
||||
"name",
|
||||
)
|
||||
attrs = {
|
||||
"class": "w-full text-sm min-w-[700px]"
|
||||
}
|
||||
|
||||
class ArchivedUserTable(tables.Table):
|
||||
toggle_prefix = "user"
|
||||
detail_template = "accounts/tables/user/archive_edit_form.html"
|
||||
username = tables.Column(
|
||||
verbose_name="Benutzername"
|
||||
)
|
||||
first_name = tables.Column(
|
||||
verbose_name="Vorname"
|
||||
)
|
||||
last_name = tables.Column(
|
||||
verbose_name="Nachname"
|
||||
)
|
||||
email = tables.Column(
|
||||
verbose_name="E-Mail"
|
||||
)
|
||||
role = tables.TemplateColumn(
|
||||
template_name="accounts/tables/user/role.html",
|
||||
verbose_name="Rolle",
|
||||
orderable=False
|
||||
)
|
||||
superuser = tables.TemplateColumn(
|
||||
template_name="accounts/tables/user/superuser.html",
|
||||
verbose_name="Superuser",
|
||||
orderable=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
template_name = "django_tables2/table_toggle.html"
|
||||
fields = (
|
||||
"username",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"email",
|
||||
"role",
|
||||
"superuser",
|
||||
)
|
||||
attrs = {
|
||||
"class": "w-full text-sm min-w-[700px]"
|
||||
}
|
||||
|
||||
class UserTable(tables.Table):
|
||||
toggle_prefix = "user"
|
||||
detail_template = "accounts/tables/user/edit_form.html"
|
||||
username = tables.Column(
|
||||
verbose_name="Benutzername"
|
||||
)
|
||||
first_name = tables.Column(
|
||||
verbose_name="Vorname"
|
||||
)
|
||||
last_name = tables.Column(
|
||||
verbose_name="Nachname"
|
||||
)
|
||||
email = tables.Column(
|
||||
verbose_name="E-Mail"
|
||||
)
|
||||
role = tables.TemplateColumn(
|
||||
template_name="accounts/tables/user/role.html",
|
||||
verbose_name="Rolle",
|
||||
orderable=False
|
||||
)
|
||||
superuser = tables.TemplateColumn(
|
||||
template_name="accounts/tables/user/superuser.html",
|
||||
verbose_name="Superuser",
|
||||
orderable=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
template_name = "django_tables2/table_toggle.html"
|
||||
fields = (
|
||||
"username",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"email",
|
||||
"role",
|
||||
"superuser",
|
||||
)
|
||||
attrs = {
|
||||
"class": "w-full text-sm min-w-[700px]"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
Mitarbeiter
|
||||
{% endblock %}
|
||||
|
||||
{% block header_title %}
|
||||
Mitarbeiter hinzufügen
|
||||
{% endblock %}
|
||||
|
||||
{% block header_description %}
|
||||
Neuen Mitarbeiter anlegen
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors mb-8">
|
||||
|
||||
<form method="post" enctype="multipart/form-data" class="space-y-4">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
{{ form.as_p }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn mt-4 btn-save">
|
||||
Speichern
|
||||
</button>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,68 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% 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 content %}
|
||||
|
||||
<div class="flex justify-center items-center min-h-[80vh]">
|
||||
|
||||
<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">
|
||||
|
||||
<h1 class="text-3xl font-bold 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>
|
||||
{% endfor %}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn w-full btn-action"
|
||||
>
|
||||
Anmelden
|
||||
</button>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,100 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
Mitarbeiter
|
||||
{% endblock %}
|
||||
|
||||
{% block header_title %}
|
||||
Mitarbeiter
|
||||
{% endblock %}
|
||||
|
||||
{% block header_description %}
|
||||
Mitarbeiter, Rollen und Berechtigungen verwalten
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% load django_tables2 %}
|
||||
<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>
|
||||
<p class="text-slate-400 text-sm mt-1">Mitarbeiter oder Rollen hinzufügen</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-2">
|
||||
<a href="{% url 'accounts:user_add' %}" class="btn btn-add">+ Mitarbeiter</a>
|
||||
<a href="{% url 'accounts:role_add' %}" class="btn btn-add">+ Rolle</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<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">
|
||||
Benutzer
|
||||
</h2>
|
||||
|
||||
<p class="text-slate-400 text-sm mt-1">
|
||||
Aktive Benutzer mit Rollen und Berechtigungen
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto rounded-xl">
|
||||
|
||||
{% render_table table %}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<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">
|
||||
Archivierte Benutzer
|
||||
</h2>
|
||||
|
||||
<p class="text-slate-400 text-sm mt-1">
|
||||
Deaktivierte Benutzer
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto rounded-xl">
|
||||
|
||||
{% render_table archived_table %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<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">
|
||||
Rollen
|
||||
</h2>
|
||||
|
||||
<p class="text-slate-400 text-sm mt-1">
|
||||
Rollen und Berechtigungen verwalten
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="overflow-x-auto rounded-xl">
|
||||
|
||||
{% render_table role_table %}
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,36 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
Mitarbeiter
|
||||
{% endblock %}
|
||||
|
||||
{% block header_title %}
|
||||
Rolle hinzufügen
|
||||
{% endblock %}
|
||||
|
||||
{% block header_description %}
|
||||
Neue Rolle anlegen
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors mb-8">
|
||||
|
||||
<form method="post" enctype="multipart/form-data" class="space-y-4">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
{{ form.as_p }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn mt-4 btn-save">
|
||||
Speichern
|
||||
</button>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,121 @@
|
||||
<form method="post" action="{% url 'accounts:user_edit' item.id %}">
|
||||
{% csrf_token %}
|
||||
|
||||
<div>
|
||||
|
||||
<div class="grid md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
|
||||
<div>
|
||||
<label class="block text-sm mb-1">
|
||||
Benutzername
|
||||
</label>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
value="{{ item.username }}"
|
||||
class="border rounded px-3 py-2 w-full">
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label class="block text-sm mb-1">
|
||||
Vorname
|
||||
</label>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
name="first_name"
|
||||
value="{{ item.first_name }}"
|
||||
class="border rounded px-3 py-2 w-full">
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label class="block text-sm mb-1">
|
||||
Nachname
|
||||
</label>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
name="last_name"
|
||||
value="{{ item.last_name }}"
|
||||
class="border rounded px-3 py-2 w-full">
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label class="block text-sm mb-1">
|
||||
E-Mail
|
||||
</label>
|
||||
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value="{{ item.email }}"
|
||||
class="border rounded px-3 py-2 w-full">
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label class="block text-sm mb-1">
|
||||
Rolle
|
||||
</label>
|
||||
|
||||
<select
|
||||
name="role"
|
||||
class="border rounded px-3 py-2 w-full">
|
||||
|
||||
<option value="">
|
||||
---
|
||||
</option>
|
||||
|
||||
{% for role in table.roles %}
|
||||
<option
|
||||
value="{{ role.id }}"
|
||||
{% if item.profile.role_id == role.id %}selected{% endif %}>
|
||||
{{ role.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<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">
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mt-5 flex gap-2">
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-save">
|
||||
Speichern
|
||||
</button>
|
||||
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
formaction="{% url 'accounts:user_reactivate' item.id %}"
|
||||
class="btn btn-action">
|
||||
Aktivieren
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</form>
|
||||
@@ -0,0 +1,121 @@
|
||||
<form method="post" action="{% url 'accounts:user_edit' item.id %}">
|
||||
{% csrf_token %}
|
||||
|
||||
<div>
|
||||
|
||||
<div class="grid md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
|
||||
<div>
|
||||
<label class="block text-sm mb-1">
|
||||
Benutzername
|
||||
</label>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
value="{{ item.username }}"
|
||||
class="border rounded px-3 py-2 w-full">
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label class="block text-sm mb-1">
|
||||
Vorname
|
||||
</label>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
name="first_name"
|
||||
value="{{ item.first_name }}"
|
||||
class="border rounded px-3 py-2 w-full">
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label class="block text-sm mb-1">
|
||||
Nachname
|
||||
</label>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
name="last_name"
|
||||
value="{{ item.last_name }}"
|
||||
class="border rounded px-3 py-2 w-full">
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label class="block text-sm mb-1">
|
||||
E-Mail
|
||||
</label>
|
||||
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value="{{ item.email }}"
|
||||
class="border rounded px-3 py-2 w-full">
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<label class="block text-sm mb-1">
|
||||
Rolle
|
||||
</label>
|
||||
|
||||
<select
|
||||
name="role"
|
||||
class="border rounded px-3 py-2 w-full">
|
||||
|
||||
<option value="">
|
||||
---
|
||||
</option>
|
||||
|
||||
{% for role in table.roles %}
|
||||
<option
|
||||
value="{{ role.id }}"
|
||||
{% if item.profile.role_id == role.id %}selected{% endif %}>
|
||||
{{ role.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<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">
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mt-5 flex gap-2">
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-save">
|
||||
Speichern
|
||||
</button>
|
||||
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
formaction="{% url 'accounts:user_archive' item.id %}"
|
||||
class="btn btn-action">
|
||||
Archivieren
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</form>
|
||||
@@ -0,0 +1 @@
|
||||
{{ record.profile.role.name|default:"-" }}
|
||||
@@ -0,0 +1,63 @@
|
||||
<form method="post" action="{% url 'accounts:role_edit' item.id %}">
|
||||
{% csrf_token %}
|
||||
|
||||
<div>
|
||||
|
||||
<h3 class="font-bold mb-4">
|
||||
Berechtigungen
|
||||
</h3>
|
||||
|
||||
|
||||
<div class="grid md:grid-cols-2 xl-grid-cols-3 gap-4">
|
||||
|
||||
{% for permission in item.permissions %}
|
||||
|
||||
<label class="
|
||||
bg-slate-100 dark:bg-slate-700
|
||||
rounded-xl
|
||||
p-3
|
||||
flex
|
||||
items-center
|
||||
gap-3
|
||||
">
|
||||
|
||||
<input
|
||||
type="checkbox"
|
||||
name="{{ permission.name }}"
|
||||
{% if permission.value %}
|
||||
checked
|
||||
{% endif %}
|
||||
class="w-4 h-4">
|
||||
|
||||
<span>
|
||||
{{ permission.verbose_name }}
|
||||
</span>
|
||||
|
||||
</label>
|
||||
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mt-5 flex gap-2">
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-save">
|
||||
Speichern
|
||||
</button>
|
||||
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
formaction="{% url 'accounts:role_delete' item.id %}"
|
||||
class="btn btn-delete">
|
||||
Löschen
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</form>
|
||||
@@ -0,0 +1,5 @@
|
||||
{% if record.is_superuser %}
|
||||
Ja
|
||||
{% else %}
|
||||
Nein
|
||||
{% endif %}
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1,19 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = "accounts"
|
||||
|
||||
urlpatterns = [
|
||||
path("login/", views.CustomLoginView.as_view(), name="login"),
|
||||
path("logout/", views.CustomLogoutView.as_view(), name="logout"),
|
||||
path("", views.overview, name="overview"),
|
||||
|
||||
path("add/",views.user_add,name="user_add"),
|
||||
path("<int:user_id>/edit/",views.user_edit,name="user_edit"),
|
||||
path("<int:user_id>/archive/",views.user_archive,name="user_archive"),
|
||||
path("<int:user_id>/reactivate/",views.user_reactivate,name="user_reactivate"),
|
||||
|
||||
path("role/add/",views.role_add,name="role_add"),
|
||||
path("role/<int:role_id>/edit/",views.role_edit,name="role_edit"),
|
||||
path("role/<int:role_id>/delete/",views.role_delete,name="role_delete"),
|
||||
]
|
||||
@@ -0,0 +1,398 @@
|
||||
from django.contrib.auth.views import LoginView, LogoutView
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from accounts.decorators import permission_required
|
||||
from django.contrib.auth.models import User
|
||||
from django.shortcuts import render, redirect, get_object_or_404
|
||||
from .forms import UserAddForm, RoleForm
|
||||
from .models import Role, UserProfile
|
||||
from .tables import UserTable, ArchivedUserTable, RoleTable
|
||||
from django_tables2 import RequestConfig
|
||||
|
||||
|
||||
class CustomLoginView(LoginView):
|
||||
template_name = "accounts/login.html"
|
||||
redirect_authenticated_user = True
|
||||
|
||||
class CustomLogoutView(LogoutView):
|
||||
pass
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required("users_view")
|
||||
def overview(request):
|
||||
|
||||
users = (
|
||||
User.objects
|
||||
.filter(is_active=True)
|
||||
.select_related(
|
||||
"profile",
|
||||
"profile__role"
|
||||
)
|
||||
.order_by("username")
|
||||
)
|
||||
|
||||
archived_users = (
|
||||
User.objects
|
||||
.filter(is_active=False)
|
||||
.select_related(
|
||||
"profile",
|
||||
"profile__role"
|
||||
)
|
||||
.order_by("username")
|
||||
)
|
||||
|
||||
roles = list(
|
||||
Role.objects.order_by("name")
|
||||
)
|
||||
|
||||
|
||||
# ==================================================
|
||||
# ROLE PERMISSIONS FÜR CHECKBOXEN
|
||||
# ==================================================
|
||||
|
||||
permission_fields = []
|
||||
|
||||
for field in Role._meta.fields:
|
||||
|
||||
if field.get_internal_type() == "BooleanField":
|
||||
|
||||
permission_fields.append({
|
||||
"name": field.name,
|
||||
"verbose_name": field.verbose_name or field.name
|
||||
})
|
||||
|
||||
|
||||
for role in roles:
|
||||
|
||||
role.permissions = []
|
||||
|
||||
for permission in permission_fields:
|
||||
|
||||
role.permissions.append({
|
||||
|
||||
"name": permission["name"],
|
||||
|
||||
"verbose_name": permission["verbose_name"],
|
||||
|
||||
"value": getattr(
|
||||
role,
|
||||
permission["name"]
|
||||
)
|
||||
|
||||
})
|
||||
|
||||
|
||||
# ==================================================
|
||||
# USER ROLLEN-PERMISSIONS
|
||||
# ==================================================
|
||||
|
||||
for user in list(users) + list(archived_users):
|
||||
|
||||
role = getattr(
|
||||
getattr(user, "profile", None),
|
||||
"role",
|
||||
None
|
||||
)
|
||||
|
||||
user.role_permissions = []
|
||||
|
||||
if role:
|
||||
|
||||
for permission in permission_fields:
|
||||
|
||||
if getattr(
|
||||
role,
|
||||
permission["name"]
|
||||
):
|
||||
user.role_permissions.append(
|
||||
permission["verbose_name"]
|
||||
)
|
||||
|
||||
|
||||
# ==================================================
|
||||
# TABLES
|
||||
# ==================================================
|
||||
table = UserTable(users)
|
||||
table.roles = roles
|
||||
RequestConfig(
|
||||
request,
|
||||
paginate=False
|
||||
).configure(table)
|
||||
|
||||
archived_table = ArchivedUserTable(archived_users)
|
||||
archived_table.roles = roles
|
||||
RequestConfig(
|
||||
request,
|
||||
paginate=False
|
||||
).configure(archived_table)
|
||||
|
||||
|
||||
role_table = RoleTable(roles)
|
||||
role_table.roles = roles
|
||||
RequestConfig(
|
||||
request,
|
||||
paginate=False
|
||||
).configure(role_table)
|
||||
|
||||
|
||||
return render(
|
||||
request,
|
||||
"accounts/overview.html",
|
||||
{
|
||||
"table": table,
|
||||
"archived_table": archived_table,
|
||||
"role_table": role_table,
|
||||
}
|
||||
)
|
||||
|
||||
@login_required
|
||||
@permission_required("users_edit")
|
||||
def user_edit(request, user_id):
|
||||
|
||||
user = get_object_or_404(
|
||||
User.objects.select_related(
|
||||
"profile"
|
||||
),
|
||||
id=user_id
|
||||
)
|
||||
|
||||
if user.is_superuser:
|
||||
return redirect("accounts:overview")
|
||||
|
||||
if request.method != "POST":
|
||||
return redirect("accounts:overview")
|
||||
|
||||
# -------------------------
|
||||
# USER
|
||||
# -------------------------
|
||||
user.username = request.POST.get(
|
||||
"username",
|
||||
user.username
|
||||
)
|
||||
|
||||
user.first_name = request.POST.get(
|
||||
"first_name",
|
||||
user.first_name
|
||||
)
|
||||
|
||||
user.last_name = request.POST.get(
|
||||
"last_name",
|
||||
user.last_name
|
||||
)
|
||||
|
||||
user.email = request.POST.get(
|
||||
"email",
|
||||
user.email
|
||||
)
|
||||
|
||||
user.save()
|
||||
|
||||
# -------------------------
|
||||
# PROFILE / ROLE
|
||||
# -------------------------
|
||||
profile, _ = UserProfile.objects.get_or_create(
|
||||
user=user
|
||||
)
|
||||
|
||||
role_id = request.POST.get("role")
|
||||
|
||||
if role_id:
|
||||
profile.role_id = role_id
|
||||
else:
|
||||
profile.role = None
|
||||
|
||||
profile.save()
|
||||
|
||||
return redirect(
|
||||
"accounts:overview"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required("users_archive")
|
||||
def user_archive(request, user_id):
|
||||
|
||||
user = get_object_or_404(
|
||||
User,
|
||||
id=user_id
|
||||
)
|
||||
|
||||
if user.is_superuser:
|
||||
return redirect("accounts:overview")
|
||||
|
||||
if request.method == "POST":
|
||||
user.is_active = False
|
||||
user.save()
|
||||
|
||||
return redirect("accounts:overview")
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required("users_activate")
|
||||
def user_reactivate(request, user_id):
|
||||
|
||||
user = get_object_or_404(
|
||||
User,
|
||||
id=user_id
|
||||
)
|
||||
|
||||
if user.is_superuser:
|
||||
return redirect("accounts:overview")
|
||||
|
||||
if request.method == "POST":
|
||||
user.is_active = True
|
||||
user.save()
|
||||
|
||||
return redirect("accounts:overview")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required("users_add")
|
||||
def user_add(request):
|
||||
|
||||
form = UserAddForm(
|
||||
request.POST or None
|
||||
)
|
||||
|
||||
if form.is_valid():
|
||||
|
||||
user = form.save(commit=False)
|
||||
|
||||
password = form.cleaned_data["password"]
|
||||
|
||||
user.set_password(password)
|
||||
user.save()
|
||||
|
||||
UserProfile.objects.create(
|
||||
user=user,
|
||||
role=form.cleaned_data["role"]
|
||||
)
|
||||
|
||||
return redirect(
|
||||
"accounts:overview"
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"accounts/add.html",
|
||||
{
|
||||
"form": form
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def get_role_permissions():
|
||||
|
||||
fields = []
|
||||
|
||||
for field in Role._meta.fields:
|
||||
|
||||
if field.get_internal_type() == "BooleanField":
|
||||
|
||||
fields.append({
|
||||
"name": field.name,
|
||||
"label": field.verbose_name or field.name
|
||||
})
|
||||
|
||||
return fields
|
||||
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required("users_groups_edit")
|
||||
def role_edit(request, role_id):
|
||||
|
||||
role = get_object_or_404(
|
||||
Role,
|
||||
id=role_id
|
||||
)
|
||||
|
||||
|
||||
if request.method == "POST":
|
||||
|
||||
for field in Role._meta.fields:
|
||||
|
||||
if (
|
||||
field.get_internal_type()
|
||||
== "BooleanField"
|
||||
):
|
||||
|
||||
setattr(
|
||||
role,
|
||||
field.name,
|
||||
field.name in request.POST
|
||||
)
|
||||
|
||||
|
||||
role.name = request.POST.get(
|
||||
"name",
|
||||
role.name
|
||||
)
|
||||
|
||||
|
||||
role.save()
|
||||
|
||||
|
||||
return redirect(
|
||||
"accounts:overview"
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required("users_groups_delete")
|
||||
def role_delete(request, role_id):
|
||||
|
||||
role = get_object_or_404(
|
||||
Role,
|
||||
id=role_id
|
||||
)
|
||||
|
||||
|
||||
if request.method == "POST":
|
||||
|
||||
role.delete()
|
||||
|
||||
|
||||
return redirect(
|
||||
"accounts:overview"
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required("users_groups_add")
|
||||
def role_add(request):
|
||||
|
||||
if request.method == "POST":
|
||||
|
||||
form = RoleForm(
|
||||
request.POST
|
||||
)
|
||||
|
||||
if form.is_valid():
|
||||
|
||||
form.save()
|
||||
|
||||
return redirect(
|
||||
"accounts:overview"
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
form = RoleForm()
|
||||
|
||||
|
||||
return render(
|
||||
request,
|
||||
"accounts/role_add.html",
|
||||
{
|
||||
"form": form
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path("/opt/projects")
|
||||
|
||||
EXCLUDE_DIRS = {
|
||||
"venv",
|
||||
"__pycache__",
|
||||
".git",
|
||||
"node_modules",
|
||||
"staticfiles",
|
||||
"media",
|
||||
}
|
||||
|
||||
CLASS_RE = re.compile(r'class="([^"]*)"')
|
||||
|
||||
|
||||
for path in ROOT.rglob("*"):
|
||||
|
||||
if not path.is_file():
|
||||
continue
|
||||
|
||||
if any(part in EXCLUDE_DIRS for part in path.parts):
|
||||
continue
|
||||
|
||||
if path.suffix not in {".html", ".htm"}:
|
||||
continue
|
||||
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
|
||||
|
||||
changed = False
|
||||
|
||||
|
||||
def replace(match):
|
||||
classes = match.group(1).split()
|
||||
|
||||
has_btn = "btn" in classes
|
||||
has_btn_variant = any(
|
||||
c.startswith("btn-")
|
||||
for c in classes
|
||||
)
|
||||
|
||||
if has_btn_variant and not has_btn:
|
||||
classes.insert(0, "btn")
|
||||
return f'class="{" ".join(classes)}"'
|
||||
|
||||
return match.group(0)
|
||||
|
||||
|
||||
new_text = CLASS_RE.sub(replace, text)
|
||||
|
||||
|
||||
if new_text != text:
|
||||
path.write_text(
|
||||
new_text,
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
print(f"✔ geändert: {path}")
|
||||
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
ASGI config for config project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
|
||||
application = get_asgi_application()
|
||||
@@ -0,0 +1,132 @@
|
||||
from pathlib import Path
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = 'django-insecure-a*^y8tl19j6+i9029ye&n1nhyaze(sd&!drt1++kfq_lok)5o@'
|
||||
DEBUG = True
|
||||
ALLOWED_HOSTS = ["91.99.235.6", "localhost"]
|
||||
|
||||
|
||||
# Application definition
|
||||
INSTALLED_APPS = [
|
||||
'accounts',
|
||||
'core',
|
||||
'projects',
|
||||
'finance',
|
||||
'inventory',
|
||||
'documents',
|
||||
'invoices',
|
||||
'masterdata.apps.MasterdataConfig',
|
||||
'time_tracking',
|
||||
'tailwind',
|
||||
'theme',
|
||||
"django_tables2",
|
||||
"django_filters",
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'config.urls'
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
"core.context_processors.company_settings",
|
||||
"core.context_processors.project_switcher",
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = 'config.wsgi.application'
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': BASE_DIR / 'db.sqlite3',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/6.0/topics/i18n/
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
TIME_ZONE = 'UTC'
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/6.0/howto/static-files/
|
||||
STATIC_URL = 'static/'
|
||||
|
||||
|
||||
|
||||
# TAILWIND
|
||||
TAILWIND_APP_NAME = 'theme'
|
||||
|
||||
|
||||
# ACCOUNTS
|
||||
LOGIN_URL = "accounts:login"
|
||||
LOGIN_REDIRECT_URL = "core:dashboard"
|
||||
LOGOUT_REDIRECT_URL = "accounts:login"
|
||||
#AUTH_USER_MODEL = "accounts.User"
|
||||
|
||||
# MEDIEN
|
||||
MEDIA_URL = "/media/"
|
||||
MEDIA_ROOT = BASE_DIR / "media"
|
||||
|
||||
|
||||
|
||||
if DEBUG:
|
||||
INSTALLED_APPS += ["django_browser_reload"]
|
||||
MIDDLEWARE += [
|
||||
"django_browser_reload.middleware.BrowserReloadMiddleware",
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
from django.contrib import admin
|
||||
from django.urls import path, include
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
|
||||
urlpatterns = [
|
||||
path("", include("core.urls")),
|
||||
path("admin/", admin.site.urls),
|
||||
path("accounts/", include("accounts.urls")),
|
||||
path("projects/", include("projects.urls")),
|
||||
path("projects/", include("finance.urls")),
|
||||
path("projects/", include("documents.urls")),
|
||||
path("projects/", include("time_tracking.urls")),
|
||||
path("projects/", include("inventory.urls")),
|
||||
path("inventory/", include("inventory.urls_global")),
|
||||
path("invoices/", include("invoices.urls")),
|
||||
path("settings/", include("masterdata.urls")),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static(settings.MEDIA_URL,document_root=settings.MEDIA_ROOT)
|
||||
urlpatterns += [path("__reload__/", include("django_browser_reload.urls")),]
|
||||
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
WSGI config for config project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
|
||||
application = get_wsgi_application()
|
||||
@@ -0,0 +1,12 @@
|
||||
from django.contrib import admin
|
||||
from .models import CompanySetting
|
||||
|
||||
|
||||
@admin.register(CompanySetting)
|
||||
class CompanySettingAdmin(admin.ModelAdmin):
|
||||
|
||||
list_display = (
|
||||
"name",
|
||||
"city",
|
||||
"updated_at",
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
name = 'core'
|
||||
@@ -0,0 +1,21 @@
|
||||
from projects.models import ProjectMembership
|
||||
from .models import CompanySetting
|
||||
|
||||
def project_switcher(request):
|
||||
if not request.user.is_authenticated:
|
||||
return {"user_projects": []}
|
||||
|
||||
memberships = ProjectMembership.objects.select_related("project").filter(
|
||||
user=request.user,
|
||||
project__active=True
|
||||
)
|
||||
|
||||
return {
|
||||
"user_projects": [m.project for m in memberships]
|
||||
}
|
||||
|
||||
|
||||
def company_settings(request):
|
||||
return {
|
||||
"company_settings": CompanySetting.objects.first()
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
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"
|
||||
)
|
||||
|
||||
def apply_style(self):
|
||||
for field_name, field in self.fields.items():
|
||||
existing = field.widget.attrs.get("class", "")
|
||||
field.widget.attrs["class"] = f"{self.default_class} {existing}".strip()
|
||||
@@ -0,0 +1,46 @@
|
||||
# Generated by Django 6.0.6 on 2026-06-23 22:12
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CompanySetting',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('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')),
|
||||
('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')),
|
||||
('zip', models.CharField(max_length=20, verbose_name='PLZ')),
|
||||
('city', models.CharField(max_length=255, verbose_name='Ort')),
|
||||
('country', models.CharField(default='Deutschland', max_length=100, verbose_name='Land')),
|
||||
('phone', models.CharField(blank=True, max_length=100, verbose_name='Telefon')),
|
||||
('email', models.EmailField(blank=True, max_length=100, verbose_name='E-Mail')),
|
||||
('website', models.URLField(blank=True, max_length=100, verbose_name='Webseite')),
|
||||
('tax_number', models.CharField(blank=True, max_length=100, verbose_name='Steuernummer')),
|
||||
('vat_number', models.CharField(blank=True, max_length=100, verbose_name='USt-IdNr.')),
|
||||
('is_small_business', models.BooleanField(default=False, verbose_name='Kleinunternehmer (§19 UStG)')),
|
||||
('small_business_text', models.TextField(blank=True, verbose_name='Kleinunternehmer Hinweis')),
|
||||
('invoice_header_text', models.TextField(blank=True, verbose_name='Rechnungszusatztext')),
|
||||
('bank_name', models.CharField(blank=True, max_length=255, verbose_name='Bank')),
|
||||
('iban', models.CharField(blank=True, max_length=50, verbose_name='IBAN')),
|
||||
('bic', models.CharField(blank=True, max_length=50, verbose_name='BIC')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Firmeneinstellung',
|
||||
'verbose_name_plural': 'Firmeneinstellungen',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
# Generated by Django 6.0.6 on 2026-06-24 09:51
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='companysetting',
|
||||
name='invoice_number',
|
||||
field=models.CharField(blank=True, max_length=50, verbose_name='BIC'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DocumentNumberSettings',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('invoice_prefix', models.CharField(default='RE', max_length=20)),
|
||||
('invoice_next_number', models.PositiveIntegerField(default=1)),
|
||||
('cancel_prefix', models.CharField(default='ST', max_length=20)),
|
||||
('cancel_next_number', models.PositiveIntegerField(default=1)),
|
||||
('offer_prefix', models.CharField(default='ANG', max_length=20)),
|
||||
('offer_next_number', models.PositiveIntegerField(default=1)),
|
||||
('company', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='number_settings', to='core.companysetting')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
# Generated by Django 6.0.6 on 2026-06-24 09:53
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0002_companysetting_invoice_number_documentnumbersettings'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name='DocumentNumberSettings',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.6 on 2026-06-25 14:57
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0003_delete_documentnumbersettings'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='companysetting',
|
||||
name='offer_header_text',
|
||||
field=models.TextField(blank=True, verbose_name='Angebotszusatztext'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 6.0.6 on 2026-06-26 06:13
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0004_companysetting_offer_header_text'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='companysetting',
|
||||
name='invoice_number',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.6 on 2026-06-27 08:53
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0005_remove_companysetting_invoice_number'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='companysetting',
|
||||
name='reminder_header_text',
|
||||
field=models.TextField(blank=True, verbose_name='Mahnungszusatztext'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,41 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
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")
|
||||
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")
|
||||
zip = models.CharField(max_length=20,verbose_name="PLZ")
|
||||
city = models.CharField(max_length=255,verbose_name="Ort")
|
||||
country = models.CharField(max_length=100,default="Deutschland",verbose_name="Land")
|
||||
phone = models.CharField(max_length=100,blank=True,verbose_name="Telefon" )
|
||||
email = models.EmailField(max_length=100,blank=True,verbose_name="E-Mail")
|
||||
website = models.URLField(max_length=100,blank=True,verbose_name="Webseite")
|
||||
|
||||
tax_number = models.CharField(max_length=100,blank=True,verbose_name="Steuernummer")
|
||||
vat_number = models.CharField(max_length=100,blank=True,verbose_name="USt-IdNr.")
|
||||
|
||||
is_small_business = models.BooleanField(default=False,verbose_name="Kleinunternehmer (§19 UStG)")
|
||||
small_business_text = models.TextField(blank=True,verbose_name="Kleinunternehmer Hinweis")
|
||||
invoice_header_text = models.TextField(blank=True,verbose_name="Rechnungszusatztext")
|
||||
offer_header_text = models.TextField(blank=True,verbose_name="Angebotszusatztext")
|
||||
reminder_header_text = models.TextField(blank=True,verbose_name="Mahnungszusatztext")
|
||||
|
||||
bank_name = models.CharField(max_length=255,blank=True,verbose_name="Bank")
|
||||
iban = models.CharField(max_length=50,blank=True,verbose_name="IBAN")
|
||||
bic = models.CharField(max_length=50,blank=True,verbose_name="BIC")
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Firmeneinstellung"
|
||||
verbose_name_plural = "Firmeneinstellungen"
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
from masterdata.registry import register_setting
|
||||
from .models import CompanySetting
|
||||
|
||||
|
||||
register_setting(
|
||||
group="System",
|
||||
name="Firmeneinstellungen",
|
||||
model=CompanySetting,
|
||||
setting_type="single",
|
||||
)
|
||||
@@ -0,0 +1,342 @@
|
||||
{% load static tailwind_tags %}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<title>
|
||||
{% block title %}
|
||||
Projektverwaltung
|
||||
{% endblock %}
|
||||
</title>
|
||||
<script>
|
||||
if (localStorage.getItem("theme") === "dark") {
|
||||
document.documentElement.classList.add("dark");
|
||||
}
|
||||
</script>
|
||||
{% tailwind_css %}
|
||||
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--company-color: {{ company_settings.color }};
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-100 dark:bg-slate-900 text-slate-900 dark:text-white transition-colors">
|
||||
<div class="flex min-h-screen">
|
||||
|
||||
{% if user.is_authenticated %}
|
||||
<div id="overlay"
|
||||
class="fixed inset-0 bg-black/40 hidden z-30 lg:hidden"
|
||||
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>
|
||||
|
||||
</div>
|
||||
|
||||
<nav class="flex-1 p-4 space-y-2">
|
||||
<div class="w-full rounded-lg flex items-center justify-between px-4 py-3 hover:bg-slate-700">
|
||||
<a href="{% url 'core:dashboard' %}">
|
||||
Dashboard
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
|
||||
<!-- Projekt wechseln -->
|
||||
<div class="rounded-lg overflow-hidden">
|
||||
|
||||
<button
|
||||
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>
|
||||
|
||||
<svg id="projectArrow"
|
||||
class="w-4 h-4 transition-transform"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
|
||||
</button>
|
||||
|
||||
<div id="projectMenu" class="hidden border-t border-slate-700">
|
||||
|
||||
{% for p in user_projects %}
|
||||
<a href="{% url 'projects:overview' p.id %}"
|
||||
class="block px-8 py-3 hover:bg-slate-700">
|
||||
{{ p.name }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Rechnungswesen -->
|
||||
<div class="rounded-lg overflow-hidden">
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick="toggleInvoicesMenu()"
|
||||
class="w-full flex items-center justify-between px-4 py-3 hover:bg-slate-700 transition">
|
||||
|
||||
<span>
|
||||
Rechnungswesen
|
||||
</span>
|
||||
|
||||
<svg id="invoiceArrow"
|
||||
class="w-4 h-4 transition-transform duration-200"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
|
||||
</button>
|
||||
|
||||
<div id="invoiceMenu" class="hidden border-t border-slate-700">
|
||||
|
||||
<a href="{% url 'invoices:overview' %}"
|
||||
class="block px-8 py-3 hover:bg-slate-700 transition">
|
||||
Übersicht
|
||||
</a>
|
||||
|
||||
<a href="{% url 'invoices:invoices_offers_overview' %}"
|
||||
class="block px-8 py-3 hover:bg-slate-700 transition">
|
||||
Angebote
|
||||
</a>
|
||||
|
||||
<a href="{% url 'invoices:invoices_invoices_overview' %}"
|
||||
class="block px-8 py-3 hover:bg-slate-700 transition">
|
||||
Rechnungen
|
||||
</a>
|
||||
|
||||
<a href="{% url 'invoices:invoices_reminders_overview' %}"
|
||||
class="block px-8 py-3 hover:bg-slate-700 transition">
|
||||
Mahnungen
|
||||
</a>
|
||||
|
||||
<a href="{% url 'invoices:customers_overview' %}"
|
||||
class="block px-8 py-3 hover:bg-slate-700 transition">
|
||||
Kunden
|
||||
</a>
|
||||
|
||||
<a href="{% url 'invoices:items_overview' %}"
|
||||
class="block px-8 py-3 hover:bg-slate-700 transition">
|
||||
Posten
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<!--
|
||||
<div class="w-full rounded-lg flex items-center justify-between px-4 py-3 hover:bg-slate-700">
|
||||
<a href="{% url 'core:dashboard' %}">
|
||||
Finanzübersicht
|
||||
</a>
|
||||
</div>
|
||||
<div class="w-full rounded-lg flex items-center justify-between px-4 py-3 hover:bg-slate-700">
|
||||
<a href="{% url 'core:dashboard' %}">
|
||||
Arbeitszeiten
|
||||
</a>
|
||||
</div>
|
||||
-->
|
||||
<div class="w-full rounded-lg flex items-center justify-between px-4 py-3 hover:bg-slate-700">
|
||||
<a href="{% url 'inventory_global:overview' %}">
|
||||
Inventar
|
||||
</a>
|
||||
</div>
|
||||
<!--
|
||||
<div class="w-full rounded-lg flex items-center justify-between px-4 py-3 hover:bg-slate-700">
|
||||
<a href="{% url 'core:dashboard' %}">
|
||||
Investitionen
|
||||
</a>
|
||||
</div>
|
||||
-->
|
||||
<div class="w-full rounded-lg flex items-center justify-between px-4 py-3 hover:bg-slate-700">
|
||||
<a href="{% url 'accounts:overview' %}">
|
||||
Mitarbeiter
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="w-full rounded-lg flex items-center justify-between px-4 py-3 hover:bg-slate-700">
|
||||
<a href="{% url 'masterdata:overview' %}">
|
||||
Einstellungen
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</nav>
|
||||
|
||||
<div class="p-4 border-t border-white/20">
|
||||
{% 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">
|
||||
<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>
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
{% endif %}
|
||||
|
||||
<!-- Hauptbereich -->
|
||||
{% if user.is_authenticated %}
|
||||
<div class="flex-1 flex flex-col lg:ml-72">
|
||||
{% else %}
|
||||
<div class="flex-1 flex flex-col">
|
||||
{% endif %}
|
||||
<header class="lg:hidden bg-white shadow h-16 flex items-center px-4 dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
|
||||
<button onclick="toggleSidebar()" class="text-3xl">☰</button>
|
||||
<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 %}
|
||||
</h1>
|
||||
<p class="text-slate-400 mt-1">
|
||||
{% block header_description %}
|
||||
{% endblock %}
|
||||
</p>
|
||||
</div>
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{% if messages %}
|
||||
<div class="fixed top-5 right-5 z-50 space-y-3">
|
||||
{% for message in messages %}
|
||||
<div
|
||||
class="rounded-xl shadow-lg px-5 py-4 flex items-center gap-4
|
||||
{% if message.tags == 'success' %}
|
||||
bg-green-100 border border-green-300 text-green-800
|
||||
{% elif message.tags == 'error' %}
|
||||
bg-red-100 border border-red-300 text-red-800
|
||||
{% elif message.tags == 'warning' %}
|
||||
bg-yellow-100 border border-yellow-300 text-yellow-800
|
||||
{% else %}
|
||||
bg-blue-100 border border-blue-300 text-blue-800
|
||||
{% endif %}
|
||||
"
|
||||
>
|
||||
<div>
|
||||
{{ message }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<script>
|
||||
setTimeout(() => {
|
||||
document
|
||||
.querySelectorAll(".fixed.top-5.right-5 > div")
|
||||
.forEach(el => {
|
||||
|
||||
el.style.opacity = "0";
|
||||
|
||||
setTimeout(() => {
|
||||
el.remove();
|
||||
}, 500);
|
||||
|
||||
});
|
||||
|
||||
}, 3000);
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<script>
|
||||
function toggleProjects() {
|
||||
|
||||
const menu = document.getElementById("projectMenu");
|
||||
const arrow = document.getElementById("projectArrow");
|
||||
|
||||
menu.classList.toggle("hidden");
|
||||
arrow.classList.toggle("rotate-180");
|
||||
|
||||
}
|
||||
|
||||
function toggleInvoicesMenu() {
|
||||
|
||||
const menu = document.getElementById("invoiceMenu");
|
||||
const arrow = document.getElementById("invoiceArrow");
|
||||
|
||||
menu.classList.toggle("hidden");
|
||||
arrow.classList.toggle("rotate-180");
|
||||
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
|
||||
const sidebar = document.getElementById("sidebar");
|
||||
const overlay = document.getElementById("overlay");
|
||||
|
||||
sidebar.classList.toggle("-translate-x-full");
|
||||
overlay.classList.toggle("hidden");
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const btn = document.getElementById("theme-toggle");
|
||||
const html = document.documentElement;
|
||||
|
||||
// Gespeichertes Theme anwenden
|
||||
const saved = localStorage.getItem("theme");
|
||||
if (saved === "dark") {
|
||||
html.classList.add("dark");
|
||||
} else {
|
||||
html.classList.remove("dark");
|
||||
}
|
||||
|
||||
// Toggle
|
||||
btn.addEventListener("click", () => {
|
||||
html.classList.toggle("dark");
|
||||
localStorage.setItem("theme", html.classList.contains("dark") ? "dark" : "light");
|
||||
btn.textContent = html.classList.contains("dark") ? "☀️" : "🌙";
|
||||
});
|
||||
|
||||
// Icon initial setzen
|
||||
btn.textContent = html.classList.contains("dark") ? "☀️" : "🌙";
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,114 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
|
||||
{% block header_title %}
|
||||
Dashboard
|
||||
{% endblock %}
|
||||
|
||||
{% block header_description %}
|
||||
Willkommen {{ user.username }}
|
||||
{% endblock %}
|
||||
|
||||
{% 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>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<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>
|
||||
|
||||
</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 %}
|
||||
|
||||
{% 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 %}
|
||||
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,47 @@
|
||||
{% load django_tables2 %}
|
||||
|
||||
<table {% render_attrs table.attrs %}>
|
||||
|
||||
<thead class="bg-slate-100 dark:bg-slate-700 text-left">
|
||||
<tr>
|
||||
{% for column in table.columns %}
|
||||
<th class="p-3">
|
||||
{{ column.header }}
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
|
||||
<tbody>
|
||||
|
||||
{% for row in table.paginated_rows %}
|
||||
|
||||
<tr class="border-t">
|
||||
|
||||
{% for column, cell in row.items %}
|
||||
|
||||
<td class="p-3">
|
||||
{{ cell }}
|
||||
</td>
|
||||
|
||||
{% endfor %}
|
||||
|
||||
</tr>
|
||||
|
||||
{% empty %}
|
||||
|
||||
<tr class="border-t">
|
||||
<td colspan="{{ table.columns|length }}"
|
||||
class="p-6 text-center text-slate-400">
|
||||
|
||||
Keine Daten vorhanden
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{% endfor %}
|
||||
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
@@ -0,0 +1,95 @@
|
||||
{% load django_tables2 %}
|
||||
|
||||
<div class="overflow-x-auto rounded-xl">
|
||||
|
||||
<table {% render_attrs table.attrs %}>
|
||||
|
||||
<thead class="bg-slate-100 dark:bg-slate-700 text-left">
|
||||
<tr>
|
||||
<th class="p-3"></th>
|
||||
|
||||
{% for column in table.columns %}
|
||||
<th class="p-3">
|
||||
{{ column.header }}
|
||||
</th>
|
||||
{% endfor %}
|
||||
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
|
||||
<tbody>
|
||||
|
||||
{% for row in table.paginated_rows %}
|
||||
|
||||
<tr class="border-t">
|
||||
|
||||
<td class="p-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick="toggleItem('{{ table.toggle_prefix }}-{{ row.record.id }}')"
|
||||
class="font-bold">
|
||||
▼
|
||||
</button>
|
||||
</td>
|
||||
|
||||
|
||||
{% for column, cell in row.items %}
|
||||
|
||||
<td class="p-3">
|
||||
{{ cell }}
|
||||
</td>
|
||||
|
||||
{% endfor %}
|
||||
|
||||
</tr>
|
||||
|
||||
|
||||
{% if table.detail_template %}
|
||||
|
||||
<tr id="{{ table.toggle_prefix }}-{{ row.record.id }}"
|
||||
class="hidden border-t">
|
||||
|
||||
<td colspan="{{ table.columns|length|add:'1' }}"
|
||||
class="p-6">
|
||||
|
||||
{% include table.detail_template with item=row.record categories=table.categories locations=table.locations users=table.users %}
|
||||
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% empty %}
|
||||
|
||||
<tr>
|
||||
<td colspan="{{ table.columns|length|add:1 }}"
|
||||
class="p-6 text-center text-slate-400">
|
||||
Keine Daten vorhanden
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{% endfor %}
|
||||
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
function toggleItem(id) {
|
||||
|
||||
const element = document.getElementById(id);
|
||||
|
||||
if (!element) {
|
||||
console.log("Nicht gefunden:", id);
|
||||
return;
|
||||
}
|
||||
|
||||
element.classList.toggle("hidden");
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1,8 @@
|
||||
from django.urls import path
|
||||
from .views import dashboard
|
||||
|
||||
app_name = "core"
|
||||
|
||||
urlpatterns = [
|
||||
path("", dashboard, name="dashboard"),
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.shortcuts import render
|
||||
from projects.models import ProjectMembership
|
||||
|
||||
@login_required
|
||||
def dashboard(request):
|
||||
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 render(request,"core/dashboard.html",{"memberships": memberships,"archived_count": archived_count,})
|
||||
@@ -0,0 +1,46 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import DocumentCategory, Document
|
||||
|
||||
|
||||
@admin.register(DocumentCategory)
|
||||
class DocumentCategoryAdmin(admin.ModelAdmin):
|
||||
list_display = ("name",)
|
||||
search_fields = ("name",)
|
||||
|
||||
@admin.register(Document)
|
||||
class DocumentAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"title",
|
||||
"project",
|
||||
"category",
|
||||
"uploaded_by",
|
||||
"created_at",
|
||||
)
|
||||
|
||||
list_filter = (
|
||||
"project",
|
||||
"category",
|
||||
"created_at",
|
||||
)
|
||||
|
||||
search_fields = (
|
||||
"title",
|
||||
)
|
||||
|
||||
readonly_fields = (
|
||||
"created_at",
|
||||
)
|
||||
|
||||
autocomplete_fields = (
|
||||
"project",
|
||||
"category",
|
||||
"uploaded_by",
|
||||
)
|
||||
|
||||
ordering = (
|
||||
"-created_at",
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class DocumentsConfig(AppConfig):
|
||||
name = 'documents'
|
||||
@@ -0,0 +1,11 @@
|
||||
from .models import Document
|
||||
from core.forms.base import BaseForm
|
||||
|
||||
class DocumentForm(BaseForm):
|
||||
class Meta:
|
||||
model = Document
|
||||
fields = ["title", "category", "file"]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.apply_style()
|
||||
@@ -0,0 +1,36 @@
|
||||
# Generated by Django 6.0.6 on 2026-06-23 22:12
|
||||
|
||||
import documents.models
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Document',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=255, verbose_name='Name')),
|
||||
('file', models.FileField(upload_to=documents.models.document_upload_path, verbose_name='Datei')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DocumentCategory',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=120, unique=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Dokumentenkategorie',
|
||||
'verbose_name_plural': 'Dokumentenkategorien',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 6.0.6 on 2026-06-23 22:12
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('documents', '0001_initial'),
|
||||
('projects', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='project',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='projects.project'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='uploaded_by',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='document',
|
||||
name='category',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='documents.documentcategory', verbose_name='Kategorie'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
from django.db import models
|
||||
from projects.models import Project
|
||||
import os
|
||||
|
||||
def document_upload_path(instance, filename):
|
||||
return f"projects/{instance.project.id}/documents/{filename}"
|
||||
|
||||
class DocumentCategory(models.Model):
|
||||
name = models.CharField(max_length=120, unique=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Dokumentenkategorie"
|
||||
verbose_name_plural = "Dokumentenkategorien"
|
||||
ordering = ["name"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Document(models.Model):
|
||||
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="documents")
|
||||
category = models.ForeignKey(DocumentCategory, on_delete=models.CASCADE, verbose_name="Kategorie")
|
||||
title = models.CharField(max_length=255, verbose_name="Name")
|
||||
file = models.FileField(upload_to=document_upload_path, verbose_name="Datei")
|
||||
uploaded_by = models.ForeignKey("auth.User", on_delete=models.SET_NULL, null=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
@property
|
||||
def extension(self):
|
||||
return os.path.splitext(self.file.name)[1].lower()
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
@@ -0,0 +1,12 @@
|
||||
from masterdata.registry import register_setting
|
||||
|
||||
from .models import (
|
||||
DocumentCategory,
|
||||
)
|
||||
|
||||
|
||||
register_setting(
|
||||
group="Dokumente",
|
||||
name="Kategorien",
|
||||
model=DocumentCategory,
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
{{ project.name }} - Dokumente
|
||||
{% endblock %}
|
||||
|
||||
{% block header_title %}
|
||||
Dokumente hochladen
|
||||
{% endblock %}
|
||||
|
||||
{% block header_description %}
|
||||
Dokumente und Belege hochladen
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<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">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
{{ form.as_p }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn mt-4 btn-save">
|
||||
Speichern
|
||||
</button>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,111 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
{{ project.name }} - Dokumente
|
||||
{% endblock %}
|
||||
|
||||
{% block header_title %}
|
||||
Dokumente
|
||||
{% endblock %}
|
||||
|
||||
{% block header_description %}
|
||||
Dateien & Bilder
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<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>
|
||||
<p class="text-slate-400 text-sm mt-1">Dokumente hochladen</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-2">
|
||||
<a href="{% url 'documents:add' project.id %}" class="btn btn-add">+ Dokument</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<!-- Kategorien -->
|
||||
{% for cat, docs in grouped.items %}
|
||||
|
||||
<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">
|
||||
{{ cat.name }}
|
||||
</h2>
|
||||
|
||||
<span class="text-sm text-slate-400">
|
||||
{{ docs|length }} Dokumente
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if docs %}
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
|
||||
{% for d in docs %}
|
||||
|
||||
<div class="bg-slate-100 dark:bg-slate-700 rounded-xl shadow overflow-hidden transition">
|
||||
|
||||
<!-- Vorschau klickbar -->
|
||||
<a href="{{ d.file.url }}" target="_blank">
|
||||
|
||||
{% if d.extension == ".jpg" or d.extension == ".jpeg" or d.extension == ".png" or d.extension == ".webp" %}
|
||||
|
||||
<div class="h-64 bg-slate-100 flex items-center justify-center overflow-hidden">
|
||||
<img src="{{ d.file.url }}" class="w-full h-full object-cover">
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
|
||||
<div class="h-64 flex flex-col items-center justify-center bg-slate-100">
|
||||
<div class="text-5xl mb-2">📄</div>
|
||||
<div class="text-slate-400 text-sm">Keine Vorschau verfügbar</div>
|
||||
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
|
||||
</a>
|
||||
|
||||
<!-- Infos -->
|
||||
<div class="p-4">
|
||||
<h3 class="font-semibold truncate">{{ d.title }}</h3>
|
||||
<div class="mt-2 text-sm text-slate-400">Hochgeladen von {{ d.uploaded_by }}</div>
|
||||
<div class="text-sm text-slate-400">{{ d.created_at|date:"d.m.Y H:i" }}</div>
|
||||
<div class="mt-4 flex">
|
||||
<form method="post" action="{% url 'documents:delete' project.id d.id %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-delete">Löschen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="bg-white dark:bg-slate-800 border border-orange-500 rounded-xl p-6 text-orange-500">
|
||||
Keine Dokumente vorhanden.
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
{% empty %}
|
||||
<div class="bg-white dark:bg-slate-800 border border-orange-500 rounded-xl p-6 text-orange-500">
|
||||
Noch keine Kategorien vorhanden.
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1,10 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = "documents"
|
||||
|
||||
urlpatterns = [
|
||||
path("<int:project_id>/documents/", views.document_overview, name="overview"),
|
||||
path("<int:project_id>/documents/delete/<int:doc_id>/", views.document_delete, name="delete"),
|
||||
path("<int:project_id>/documents/add/", views.document_add, name="add"),
|
||||
]
|
||||
@@ -0,0 +1,88 @@
|
||||
from django.shortcuts import render, get_object_or_404, redirect
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from accounts.decorators import permission_required
|
||||
from projects.models import ProjectMembership
|
||||
from .models import Document, DocumentCategory
|
||||
from .forms import DocumentForm
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required("projects_documents_view")
|
||||
def document_overview(request, project_id):
|
||||
|
||||
membership = get_object_or_404(ProjectMembership, user=request.user, project_id=project_id)
|
||||
project = membership.project
|
||||
|
||||
documents = Document.objects.filter(project=project).order_by("-created_at")
|
||||
categories = DocumentCategory.objects.all()
|
||||
|
||||
form = DocumentForm()
|
||||
|
||||
if request.method == "POST":
|
||||
form = DocumentForm(request.POST, request.FILES)
|
||||
if form.is_valid():
|
||||
doc = form.save(commit=False)
|
||||
doc.project = project
|
||||
doc.uploaded_by = request.user
|
||||
doc.save()
|
||||
return redirect("documents:overview", project.id)
|
||||
|
||||
# gruppiert nach Kategorie
|
||||
grouped = {}
|
||||
|
||||
for cat in categories:
|
||||
docs = list(documents.filter(category=cat))
|
||||
|
||||
if docs:
|
||||
grouped[cat] = docs
|
||||
|
||||
uncategorized = documents.filter(category__isnull=True)
|
||||
|
||||
return render(request, "documents/overview.html", {
|
||||
"project": project,
|
||||
"form": form,
|
||||
"grouped": grouped,
|
||||
"uncategorized": uncategorized,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required("projects_documents_delete")
|
||||
def document_delete(request, project_id, doc_id):
|
||||
membership = get_object_or_404(ProjectMembership, user=request.user, project_id=project_id)
|
||||
doc = get_object_or_404(Document, id=doc_id, project=membership.project)
|
||||
doc.file.delete()
|
||||
doc.delete()
|
||||
return redirect("documents:overview", project_id)
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required("projects_documents_add")
|
||||
def document_add(request, project_id):
|
||||
membership = get_object_or_404(ProjectMembership, user=request.user, project_id=project_id)
|
||||
project = membership.project
|
||||
|
||||
documents = Document.objects.filter(project=project).order_by("-created_at")
|
||||
categories = DocumentCategory.objects.all()
|
||||
|
||||
form = DocumentForm()
|
||||
|
||||
if request.method == "POST":
|
||||
form = DocumentForm(request.POST, request.FILES)
|
||||
if form.is_valid():
|
||||
doc = form.save(commit=False)
|
||||
doc.project = project
|
||||
doc.uploaded_by = request.user
|
||||
doc.save()
|
||||
return redirect("documents:overview", project.id)
|
||||
else:
|
||||
form = DocumentForm()
|
||||
|
||||
return render(
|
||||
request,
|
||||
"documents/add.html",
|
||||
{
|
||||
"project": project,
|
||||
"form": form,
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
from django.contrib import admin
|
||||
from .models import Category, Supplier, PaymentMethod, Expense, Income
|
||||
|
||||
|
||||
admin.site.register(Category)
|
||||
admin.site.register(Supplier)
|
||||
admin.site.register(PaymentMethod)
|
||||
|
||||
|
||||
@admin.register(Expense)
|
||||
class ExpenseAdmin(admin.ModelAdmin):
|
||||
list_display = ("amount", "project", "category", "date", "created_by")
|
||||
list_filter = ("project", "category", "date")
|
||||
search_fields = (
|
||||
"name",
|
||||
"project__name",
|
||||
)
|
||||
|
||||
|
||||
@admin.register(Income)
|
||||
class IncomeAdmin(admin.ModelAdmin):
|
||||
list_display = ("amount", "project", "category", "date", "created_by")
|
||||
list_filter = ("project", "category", "date")
|
||||
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class FinanceConfig(AppConfig):
|
||||
name = 'finance'
|
||||
@@ -0,0 +1,38 @@
|
||||
from django import forms
|
||||
from .models import Income, Expense
|
||||
from core.forms.base import BaseForm
|
||||
from django.utils import timezone
|
||||
|
||||
class IncomeForm(BaseForm):
|
||||
class Meta:
|
||||
model = Income
|
||||
fields = ["name", "amount", "date", "category", "payment_method", "note"]
|
||||
widgets = {
|
||||
"date": forms.DateTimeInput(attrs={"type": "datetime-local"}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if not self.instance.pk:
|
||||
self.fields["date"].initial = timezone.now()
|
||||
|
||||
self.apply_style()
|
||||
|
||||
|
||||
class ExpenseForm(BaseForm):
|
||||
class Meta:
|
||||
model = Expense
|
||||
fields = ["name", "amount", "date", "category", "supplier", "payment_method", "receipt", "note"]
|
||||
|
||||
widgets = {
|
||||
"date": forms.DateTimeInput(attrs={"type": "datetime-local"}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if not self.instance.pk:
|
||||
self.fields["date"].initial = timezone.now()
|
||||
|
||||
self.apply_style()
|
||||
@@ -0,0 +1,77 @@
|
||||
# Generated by Django 6.0.6 on 2026-06-23 22:12
|
||||
|
||||
import django.db.models.deletion
|
||||
import finance.models
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Category',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Finanzkategorie',
|
||||
'verbose_name_plural': 'Finanzkategorien',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Income',
|
||||
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 (€)')),
|
||||
('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')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PaymentMethod',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=50)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Zahlungsmethode',
|
||||
'verbose_name_plural': 'Zahlungsmethoden',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Supplier',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=150)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Lieferant',
|
||||
'verbose_name_plural': 'Lieferanten',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Expense',
|
||||
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 (€)')),
|
||||
('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')),
|
||||
('invoiced', models.BooleanField(default=False, verbose_name='Abgerechnet')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Erstellt am')),
|
||||
('category', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='finance.category', verbose_name='Kategorie')),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Erstellt von')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,54 @@
|
||||
# Generated by Django 6.0.6 on 2026-06-23 22:12
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('finance', '0001_initial'),
|
||||
('projects', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='expense',
|
||||
name='project',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='expenses', to='projects.project', verbose_name='Projekt'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='income',
|
||||
name='category',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='finance.category', verbose_name='Kategorie'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='income',
|
||||
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='project',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='incomes', to='projects.project', verbose_name='Projekt'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='income',
|
||||
name='payment_method',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='finance.paymentmethod', verbose_name='Zahlungsmethode'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='expense',
|
||||
name='payment_method',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='finance.paymentmethod', verbose_name='Zahlungsmethode'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='expense',
|
||||
name='supplier',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='finance.supplier', verbose_name='Lieferant'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,65 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth import get_user_model
|
||||
from projects.models import Project
|
||||
import os
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
def receipt_upload_path(instance, filename):
|
||||
return f"projects/{instance.project.id}/receipts/{filename}"
|
||||
|
||||
class Category(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
class Meta:
|
||||
verbose_name = "Finanzkategorie"
|
||||
verbose_name_plural = "Finanzkategorien"
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Supplier(models.Model):
|
||||
name = models.CharField(max_length=150)
|
||||
class Meta:
|
||||
verbose_name = "Lieferant"
|
||||
verbose_name_plural = "Lieferanten"
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class PaymentMethod(models.Model):
|
||||
name = models.CharField(max_length=50)
|
||||
class Meta:
|
||||
verbose_name = "Zahlungsmethode"
|
||||
verbose_name_plural = "Zahlungsmethoden"
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Expense(models.Model):
|
||||
name = models.CharField(max_length=255, verbose_name="Name")
|
||||
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="expenses", verbose_name="Projekt")
|
||||
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, verbose_name="Kategorie")
|
||||
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 (€)")
|
||||
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")
|
||||
invoiced = models.BooleanField(default=False,verbose_name="Abgerechnet")
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am")
|
||||
def __str__(self):
|
||||
return f"{self.name} - {self.amount} €"
|
||||
|
||||
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")
|
||||
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 (€)")
|
||||
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")
|
||||
note = models.TextField(blank=True, verbose_name="Notiz")
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am")
|
||||
def __str__(self):
|
||||
return f"+{self.amount} € - {self.project.name}"
|
||||
@@ -0,0 +1,26 @@
|
||||
from masterdata.registry import register_setting
|
||||
|
||||
from .models import (
|
||||
Category,
|
||||
Supplier,
|
||||
PaymentMethod,
|
||||
)
|
||||
|
||||
|
||||
register_setting(
|
||||
group="Finanzen",
|
||||
name="Kategorien",
|
||||
model=Category,
|
||||
)
|
||||
|
||||
register_setting(
|
||||
group="Finanzen",
|
||||
name="Lieferanten",
|
||||
model=Supplier,
|
||||
)
|
||||
|
||||
register_setting(
|
||||
group="Finanzen",
|
||||
name="Zahlungsarten",
|
||||
model=PaymentMethod,
|
||||
)
|
||||
@@ -0,0 +1,163 @@
|
||||
import django_tables2 as tables
|
||||
from django.utils.html import format_html
|
||||
|
||||
class PeriodAnalyticsTable(tables.Table):
|
||||
period = tables.Column(verbose_name="Zeitraum")
|
||||
income = tables.Column(verbose_name="Einnahmen")
|
||||
expense = tables.Column(verbose_name="Ausgaben")
|
||||
profit = tables.Column(verbose_name="Gewinn")
|
||||
|
||||
actions = tables.TemplateColumn(
|
||||
template_name="finance/tables/period_actions.html",
|
||||
verbose_name="Aktionen",
|
||||
orderable=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
attrs = {
|
||||
"class": "w-full text-sm min-w-[700px]"
|
||||
}
|
||||
template_name = "django_tables2/table.html"
|
||||
fields = (
|
||||
"period",
|
||||
"income",
|
||||
"expense",
|
||||
"profit",
|
||||
"actions",
|
||||
)
|
||||
|
||||
def __init__(self, *args, period_type="day", **kwargs):
|
||||
self.period_type = period_type
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def render_period(self, value):
|
||||
if self.period_type == "year":
|
||||
return value.strftime("%Y")
|
||||
|
||||
if self.period_type == "month":
|
||||
return value.strftime("%m / %Y") # z.B. 06.2026
|
||||
|
||||
if self.period_type == "week":
|
||||
week = value.isocalendar().week
|
||||
return f"KW {week:02d} / {value.year}"
|
||||
|
||||
return value.strftime("%d.%m.%Y")
|
||||
|
||||
def render_income(self, value):
|
||||
return f"{value:.2f} €"
|
||||
|
||||
def render_expense(self, value):
|
||||
return f"{value:.2f} €"
|
||||
|
||||
def render_profit(self, value):
|
||||
return f"{value:.2f} €"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class FinanceTransactionTable(tables.Table):
|
||||
|
||||
type = tables.Column(
|
||||
verbose_name="Typ"
|
||||
)
|
||||
|
||||
name = tables.Column(
|
||||
verbose_name="Name"
|
||||
)
|
||||
|
||||
amount = tables.Column(
|
||||
verbose_name="Betrag"
|
||||
)
|
||||
|
||||
date = tables.DateColumn(
|
||||
verbose_name="Datum",
|
||||
format="d.m.Y"
|
||||
)
|
||||
|
||||
category = tables.Column(
|
||||
verbose_name="Kategorie"
|
||||
)
|
||||
|
||||
created_by = tables.Column(
|
||||
verbose_name="Erstellt von"
|
||||
)
|
||||
|
||||
payment_method = tables.Column(
|
||||
verbose_name="Zahlungsmethode"
|
||||
)
|
||||
|
||||
supplier = tables.Column(
|
||||
verbose_name="Lieferant"
|
||||
)
|
||||
|
||||
receipt = tables.TemplateColumn(
|
||||
template_name="finance/tables/receipt.html",
|
||||
verbose_name="Beleg",
|
||||
orderable=False,
|
||||
)
|
||||
|
||||
note = tables.Column(
|
||||
verbose_name="Notiz"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
attrs = {
|
||||
"class": "w-full text-sm min-w-[700px]"
|
||||
}
|
||||
template_name = "django_tables2/table.html"
|
||||
fields = (
|
||||
"type",
|
||||
"name",
|
||||
"amount",
|
||||
"date",
|
||||
"category",
|
||||
"created_by",
|
||||
"payment_method",
|
||||
"supplier",
|
||||
"receipt",
|
||||
"note",
|
||||
)
|
||||
|
||||
|
||||
|
||||
def render_type(self, value):
|
||||
color = "text-green-600" if value == "Einnahme" else "text-red-600"
|
||||
return format_html(
|
||||
'<span class="font-bold {}">{}</span>',
|
||||
color,
|
||||
value,
|
||||
)
|
||||
|
||||
def render_amount(self, value):
|
||||
if value is None:
|
||||
return "/"
|
||||
return f"{value:.2f} €"
|
||||
|
||||
def render_name(self, value):
|
||||
return value or "/"
|
||||
|
||||
def render_category(self, value):
|
||||
return value or "/"
|
||||
|
||||
def render_created_by(self, value):
|
||||
return value or "/"
|
||||
|
||||
def render_payment_method(self, value):
|
||||
return value or "/"
|
||||
|
||||
def render_supplier(self, value):
|
||||
return value or "/"
|
||||
|
||||
def render_note(self, value):
|
||||
return value or "/"
|
||||
@@ -0,0 +1,32 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
{{ project.name }} - Finanzen
|
||||
{% endblock %}
|
||||
|
||||
{% block header_title %}
|
||||
Finanzen tracken
|
||||
{% endblock %}
|
||||
|
||||
{% block header_description %}
|
||||
Einnahme oder Ausgabe hinzufügen
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<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>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,206 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
{{ project.name }} - Finanzen
|
||||
{% endblock %}
|
||||
|
||||
{% block header_title %}
|
||||
Finanzen
|
||||
{% endblock %}
|
||||
|
||||
{% block header_description %}
|
||||
Einnahmen & Ausgaben
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% load django_tables2 %}
|
||||
|
||||
<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>
|
||||
<p class="text-slate-400 text-sm mt-1">Einnahmen und Ausgaben tracken</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-2">
|
||||
<a href="{% url 'finance:add_income' project.id %}" class="btn btn-action-start">+ Einnahme</a>
|
||||
<a href="{% url 'finance:add_expense' project.id %}" class="btn btn-action-stop">+ Ausgabe</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 mb-8">
|
||||
<!-- ===================== -->
|
||||
<!-- FILTER CARD -->
|
||||
<!-- ===================== -->
|
||||
<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>
|
||||
<p class="text-slate-400 text-sm mt-1">Zeitraum für aktuelle Auswertung</p>
|
||||
</div>
|
||||
|
||||
<form method="get" class="flex flex-wrap gap-4 items-end">
|
||||
|
||||
<div>
|
||||
<label class="text-sm text-slate-400">Start</label>
|
||||
<input type="date" name="start"
|
||||
value="{{ request.GET.start|default_if_none:'' }}"
|
||||
class="border rounded px-3 py-2">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-sm text-slate-400">Ende</label>
|
||||
<input type="date" name="end"
|
||||
value="{{ request.GET.end|default_if_none:'' }}"
|
||||
class="border rounded px-3 py-2">
|
||||
</div>
|
||||
|
||||
<button class="btn btn-save">
|
||||
Anwenden
|
||||
</button>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ===================== -->
|
||||
<!-- KPI FLOATING CARDS -->
|
||||
<!-- ===================== -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-2">
|
||||
|
||||
<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>
|
||||
|
||||
<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="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>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ===================== -->
|
||||
<!-- LIST CARDS -->
|
||||
<!-- ===================== -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
|
||||
<!-- 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">
|
||||
<h3 class="font-semibold text-green-600">Einnahmen</h3>
|
||||
</div>
|
||||
|
||||
<div class="divide-y">
|
||||
|
||||
{% for i in incomes %}
|
||||
<div class="p-4 flex justify-between">
|
||||
<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>
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 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">
|
||||
<h3 class="font-semibold text-red-600">Ausgaben</h3>
|
||||
</div>
|
||||
|
||||
<div class="divide-y">
|
||||
|
||||
{% for e in expenses %}
|
||||
<div class="p-4 flex justify-between">
|
||||
<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>
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</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">
|
||||
Zeitraum-Analyse
|
||||
</h2>
|
||||
|
||||
<p class="text-slate-400 text-sm mt-1">
|
||||
Aggregierte Auswertung nach Zeitraum
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex gap-2 mb-6">
|
||||
|
||||
<a href="?period=year"
|
||||
class="px-3 py-1 rounded border {% if period == 'year' %}bg-slate-900 text-white{% endif %}">
|
||||
Jahr
|
||||
</a>
|
||||
|
||||
<a href="?period=month"
|
||||
class="px-3 py-1 rounded border {% if period == 'month' %}bg-slate-900 text-white{% endif %}">
|
||||
Monat
|
||||
</a>
|
||||
|
||||
<a href="?period=week"
|
||||
class="px-3 py-1 rounded border {% if period == 'week' %}bg-slate-900 text-white{% endif %}">
|
||||
Woche
|
||||
</a>
|
||||
|
||||
<a href="?period=day"
|
||||
class="px-3 py-1 rounded border {% if period == 'day' %}bg-slate-900 text-white{% endif %}">
|
||||
Tag
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div class="overflow-x-auto rounded-xl">
|
||||
|
||||
{% render_table table %}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,8 @@
|
||||
<div class="flex gap-2">
|
||||
|
||||
<a href="{% url 'finance:view' project.id %}?start={{ record.period_start|date:'Y-m-d' }}&end={{ record.period_end|date:'Y-m-d' }}"
|
||||
class="btn btn-edit">
|
||||
Details
|
||||
</a>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
{% if record.receipt %}
|
||||
<a href="{{ record.receipt.url }}"
|
||||
target="_blank"
|
||||
class="btn btn-action">
|
||||
Download
|
||||
</a>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
@@ -0,0 +1,20 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ project.name }} - Finanzen{% endblock %}
|
||||
|
||||
{% block header_title %}
|
||||
Finanzen Details
|
||||
{% endblock %}
|
||||
|
||||
{% block header_description %}
|
||||
Zeitraum: {{ start_date|date:"d.m.Y" }} → {{ end_date|date:"d.m.Y" }}
|
||||
{% 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="overflow-x-auto rounded-xl">{% render_table table %}</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1,11 @@
|
||||
from django.urls import path
|
||||
from .views import finance_overview, finance_view, add_income, add_expense
|
||||
|
||||
app_name = "finance"
|
||||
|
||||
urlpatterns = [
|
||||
path("<int:project_id>/finance/", finance_overview, name="overview"),
|
||||
path("<int:project_id>/finance/view/", finance_view, name="view"),
|
||||
path("<int:project_id>/finance/add-income/", add_income, name="add_income"),
|
||||
path("<int:project_id>/finance/add-expense/", add_expense, name="add_expense"),
|
||||
]
|
||||
@@ -0,0 +1,331 @@
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from accounts.decorators import permission_required
|
||||
from django.shortcuts import render, get_object_or_404, redirect
|
||||
from django.db.models.functions import TruncMonth, TruncYear, TruncWeek, TruncDay
|
||||
from django.db.models import Sum
|
||||
from datetime import date, datetime, timedelta
|
||||
from projects.models import ProjectMembership
|
||||
from .models import Income, Expense
|
||||
import csv
|
||||
from django.http import HttpResponse
|
||||
from calendar import monthrange
|
||||
from .forms import IncomeForm, ExpenseForm
|
||||
from .tables import PeriodAnalyticsTable, FinanceTransactionTable
|
||||
|
||||
# =========================================================
|
||||
# OVERVIEW
|
||||
# =========================================================
|
||||
@login_required
|
||||
@permission_required("projects_finance_view")
|
||||
def finance_overview(request, project_id):
|
||||
|
||||
membership = get_object_or_404(
|
||||
ProjectMembership,
|
||||
user=request.user,
|
||||
project_id=project_id
|
||||
)
|
||||
|
||||
project = membership.project
|
||||
|
||||
# -------------------------
|
||||
# DATE FILTER (for KPI ONLY)
|
||||
# -------------------------
|
||||
start = request.GET.get("start")
|
||||
end = request.GET.get("end")
|
||||
|
||||
if not start or not end:
|
||||
start_date = end_date = date.today()
|
||||
else:
|
||||
start_date = datetime.strptime(start, "%Y-%m-%d").date()
|
||||
end_date = datetime.strptime(end, "%Y-%m-%d").date()
|
||||
|
||||
# -------------------------
|
||||
# KPI DATA (FILTERED)
|
||||
# -------------------------
|
||||
filtered_incomes = Income.objects.filter(
|
||||
project=project,
|
||||
date__date__range=[start_date, end_date]
|
||||
)
|
||||
|
||||
filtered_expenses = Expense.objects.filter(
|
||||
project=project,
|
||||
date__date__range=[start_date, end_date]
|
||||
)
|
||||
|
||||
total_income = sum(i.amount for i in filtered_incomes)
|
||||
total_expense = sum(e.amount for e in filtered_expenses)
|
||||
total_profit = total_income - total_expense
|
||||
|
||||
incomes = Income.objects.filter(
|
||||
project=project,
|
||||
date__date__range=[start_date, end_date]
|
||||
).order_by("-date")
|
||||
|
||||
expenses = Expense.objects.filter(
|
||||
project=project,
|
||||
date__date__range=[start_date, end_date]
|
||||
).order_by("-date")
|
||||
|
||||
# -------------------------
|
||||
# PERIOD VIEW
|
||||
# -------------------------
|
||||
period = request.GET.get("period", "year")
|
||||
|
||||
if period == "year":
|
||||
trunc = TruncYear("date")
|
||||
elif period == "week":
|
||||
trunc = TruncWeek("date")
|
||||
elif period == "day":
|
||||
trunc = TruncDay("date")
|
||||
else:
|
||||
trunc = TruncMonth("date")
|
||||
|
||||
income_stats = (
|
||||
Income.objects.filter(project=project)
|
||||
.annotate(period_key=trunc)
|
||||
.values("period_key")
|
||||
.annotate(total=Sum("amount"))
|
||||
.order_by("-period_key")
|
||||
)
|
||||
|
||||
expense_stats = (
|
||||
Expense.objects.filter(project=project)
|
||||
.annotate(period_key=trunc)
|
||||
.values("period_key")
|
||||
.annotate(total=Sum("amount"))
|
||||
.order_by("-period_key")
|
||||
)
|
||||
|
||||
expense_map = {
|
||||
str(e["period_key"]): e["total"]
|
||||
for e in expense_stats
|
||||
}
|
||||
|
||||
period_data = []
|
||||
|
||||
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)
|
||||
|
||||
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]
|
||||
period_end = period_start.replace(day=last_day)
|
||||
|
||||
period_data.append({
|
||||
"period": period_start,
|
||||
"period_start": period_start,
|
||||
"period_end": period_end,
|
||||
"income": income_total,
|
||||
"expense": expense_total,
|
||||
"profit": income_total - expense_total,
|
||||
})
|
||||
|
||||
# -------------------------
|
||||
# RETURN
|
||||
# -------------------------
|
||||
|
||||
table = PeriodAnalyticsTable(
|
||||
period_data,
|
||||
period_type=period,
|
||||
request=request,
|
||||
)
|
||||
|
||||
return render(request, "finance/overview.html", {
|
||||
"project": project,
|
||||
|
||||
# KPI (filtered)
|
||||
"total_income": total_income,
|
||||
"total_expense": total_expense,
|
||||
"total_profit": total_profit,
|
||||
|
||||
# LISTS (all data)
|
||||
"incomes": incomes,
|
||||
"expenses": expenses,
|
||||
|
||||
# PERIOD
|
||||
"period_data": period_data,
|
||||
"period": period,
|
||||
|
||||
# UI
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
|
||||
"table": table,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required("projects_finance_view")
|
||||
def finance_view(request, project_id):
|
||||
|
||||
membership = get_object_or_404(
|
||||
ProjectMembership,
|
||||
user=request.user,
|
||||
project_id=project_id
|
||||
)
|
||||
|
||||
project = membership.project
|
||||
|
||||
start = request.GET.get("start")
|
||||
end = request.GET.get("end")
|
||||
|
||||
incomes = Income.objects.filter(project=project)
|
||||
expenses = Expense.objects.filter(project=project)
|
||||
|
||||
if start:
|
||||
try:
|
||||
start_date = datetime.strptime(start, "%Y-%m-%d").date()
|
||||
incomes = incomes.filter(date__date__gte=start_date)
|
||||
expenses = expenses.filter(date__date__gte=start_date)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if end:
|
||||
try:
|
||||
end_date = datetime.strptime(end, "%Y-%m-%d").date()
|
||||
incomes = incomes.filter(date__date__lte=end_date)
|
||||
expenses = expenses.filter(date__date__lte=end_date)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# -------------------------
|
||||
# MERGED LIST (LIKE EXPORT ORDER)
|
||||
# -------------------------
|
||||
transactions = []
|
||||
|
||||
for i in incomes:
|
||||
transactions.append({
|
||||
"type": "Einnahme",
|
||||
"date": i.date,
|
||||
"amount": i.amount,
|
||||
"category": i.category,
|
||||
"name": i.name,
|
||||
"created_by": i.created_by,
|
||||
"note": i.note,
|
||||
})
|
||||
|
||||
for e in expenses:
|
||||
transactions.append({
|
||||
"type": "Ausgabe",
|
||||
"date": e.date,
|
||||
"amount": e.amount,
|
||||
"category": e.category,
|
||||
"name": e.name,
|
||||
"created_by": e.created_by,
|
||||
"note": e.note,
|
||||
"supplier": e.supplier,
|
||||
"payment_method": e.payment_method,
|
||||
"receipt": e.receipt,
|
||||
})
|
||||
|
||||
transactions.sort(
|
||||
key=lambda x: x["date"],
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
table = FinanceTransactionTable(
|
||||
transactions,
|
||||
request=request,
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"finance/view.html",
|
||||
{
|
||||
"project": project,
|
||||
"table": table,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# =========================
|
||||
# INCOME CREATE
|
||||
# =========================
|
||||
@login_required
|
||||
@permission_required("projects_finance_income_add")
|
||||
def add_income(request, project_id):
|
||||
|
||||
membership = get_object_or_404(
|
||||
ProjectMembership,
|
||||
user=request.user,
|
||||
project_id=project_id
|
||||
)
|
||||
|
||||
project = membership.project
|
||||
|
||||
if request.method == "POST":
|
||||
form = IncomeForm(request.POST)
|
||||
|
||||
if form.is_valid():
|
||||
income = form.save(commit=False)
|
||||
income.project = project
|
||||
income.created_by = request.user
|
||||
income.save()
|
||||
return redirect("finance:overview", project_id=project.id)
|
||||
|
||||
else:
|
||||
form = IncomeForm()
|
||||
|
||||
return render(request, "finance/form.html", {
|
||||
"form": form,
|
||||
"project": project,
|
||||
"type": "income",
|
||||
})
|
||||
|
||||
|
||||
# =========================
|
||||
# EXPENSE CREATE
|
||||
# =========================
|
||||
@login_required
|
||||
@permission_required("projects_finance_expense_add")
|
||||
def add_expense(request, project_id):
|
||||
|
||||
membership = get_object_or_404(
|
||||
ProjectMembership,
|
||||
user=request.user,
|
||||
project_id=project_id
|
||||
)
|
||||
|
||||
project = membership.project
|
||||
|
||||
if request.method == "POST":
|
||||
form = ExpenseForm(request.POST, request.FILES)
|
||||
|
||||
if form.is_valid():
|
||||
expense = form.save(commit=False)
|
||||
expense.project = project
|
||||
expense.created_by = request.user
|
||||
expense.save()
|
||||
return redirect("finance:overview", project_id=project.id)
|
||||
|
||||
else:
|
||||
form = ExpenseForm()
|
||||
|
||||
return render(request, "finance/form.html", {
|
||||
"form": form,
|
||||
"project": project,
|
||||
"type": "expense",
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import (
|
||||
InventoryCategory,
|
||||
InventoryLocation,
|
||||
InventoryItem,
|
||||
ProjectInventoryItem,
|
||||
ShoppingItem,
|
||||
)
|
||||
|
||||
@admin.register(InventoryCategory)
|
||||
class InventoryCategoryAdmin(admin.ModelAdmin):
|
||||
list_display = ("name",)
|
||||
search_fields = ("name",)
|
||||
|
||||
|
||||
@admin.register(InventoryLocation)
|
||||
class InventoryLocationAdmin(admin.ModelAdmin):
|
||||
list_display = ("name",)
|
||||
search_fields = ("name",)
|
||||
|
||||
|
||||
@admin.register(InventoryItem)
|
||||
class InventoryItemAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"name",
|
||||
"quantity",
|
||||
"category",
|
||||
"location",
|
||||
"responsible",
|
||||
"article_number",
|
||||
)
|
||||
list_filter = (
|
||||
"category",
|
||||
"location",
|
||||
)
|
||||
search_fields = (
|
||||
"name",
|
||||
"article_number",
|
||||
"description",
|
||||
)
|
||||
|
||||
|
||||
@admin.register(ProjectInventoryItem)
|
||||
class ProjectInventoryItemAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"project",
|
||||
"item",
|
||||
"quantity",
|
||||
"status",
|
||||
"created_at",
|
||||
)
|
||||
list_filter = (
|
||||
"status",
|
||||
"project",
|
||||
)
|
||||
search_fields = (
|
||||
"project__name",
|
||||
"item__name",
|
||||
)
|
||||
|
||||
|
||||
@admin.register(ShoppingItem)
|
||||
class ShoppingItemAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"name",
|
||||
"project",
|
||||
"quantity",
|
||||
"price",
|
||||
"category",
|
||||
"supplier",
|
||||
"created_at",
|
||||
)
|
||||
list_filter = (
|
||||
"project",
|
||||
"category",
|
||||
"supplier",
|
||||
)
|
||||
search_fields = (
|
||||
"name",
|
||||
"description",
|
||||
"project__name",
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class InventoryConfig(AppConfig):
|
||||
name = 'inventory'
|
||||
@@ -0,0 +1,43 @@
|
||||
from django import forms
|
||||
|
||||
from .models import InventoryItem, ShoppingItem
|
||||
from core.forms.base import BaseForm
|
||||
|
||||
|
||||
class InventoryItemForm(BaseForm):
|
||||
|
||||
class Meta:
|
||||
model = InventoryItem
|
||||
fields = [
|
||||
"name",
|
||||
"description",
|
||||
"quantity",
|
||||
"location",
|
||||
"article_number",
|
||||
"responsible",
|
||||
"category",
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.apply_style()
|
||||
|
||||
|
||||
class ShoppingItemForm(BaseForm):
|
||||
|
||||
class Meta:
|
||||
model = ShoppingItem
|
||||
|
||||
fields = [
|
||||
"name",
|
||||
"description",
|
||||
"quantity",
|
||||
"supplier",
|
||||
"price",
|
||||
"category",
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.apply_style()
|
||||
@@ -0,0 +1,90 @@
|
||||
# Generated by Django 6.0.6 on 2026-06-23 22:12
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='InventoryCategory',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255, unique=True, verbose_name='Name')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Inventarkategorie',
|
||||
'verbose_name_plural': 'Inventarkategorien',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='InventoryLocation',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255, unique=True, verbose_name='Name')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Inventarstandort',
|
||||
'verbose_name_plural': 'Inventarstandorte',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ShoppingItem',
|
||||
fields=[
|
||||
('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')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Einkaufslistenartikel',
|
||||
'verbose_name_plural': 'Einkaufslistenartikel',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='InventoryItem',
|
||||
fields=[
|
||||
('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')),
|
||||
('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')),
|
||||
('responsible', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Verantwortlicher')),
|
||||
('location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='inventory.inventorylocation', verbose_name='Standort')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Inventarartikel',
|
||||
'verbose_name_plural': 'Inventarartikel',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
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')),
|
||||
('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')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Projektartikel',
|
||||
'verbose_name_plural': 'Projektartikel',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,42 @@
|
||||
# Generated by Django 6.0.6 on 2026-06-23 22:12
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('finance', '0002_initial'),
|
||||
('inventory', '0001_initial'),
|
||||
('projects', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='projectinventoryitem',
|
||||
name='project',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inventory_items', to='projects.project', verbose_name='Projekt'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='shoppingitem',
|
||||
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='shoppingitem',
|
||||
name='project',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shopping_items', to='projects.project', verbose_name='Projekt'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='shoppingitem',
|
||||
name='supplier',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='finance.supplier', verbose_name='Lieferant'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='projectinventoryitem',
|
||||
unique_together={('project', 'item')},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,87 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import User
|
||||
from projects.models import Project
|
||||
from finance.models import Category, Supplier
|
||||
from django.db.models import Sum
|
||||
|
||||
class InventoryCategory(models.Model):
|
||||
name = models.CharField(max_length=255, unique=True, verbose_name="Name")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Inventarkategorie"
|
||||
verbose_name_plural = "Inventarkategorien"
|
||||
ordering = ["name"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class InventoryLocation(models.Model):
|
||||
name = models.CharField(max_length=255, unique=True, verbose_name="Name")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Inventarstandort"
|
||||
verbose_name_plural = "Inventarstandorte"
|
||||
ordering = ["name"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
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")
|
||||
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")
|
||||
category = models.ForeignKey(InventoryCategory, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Kategorie")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Inventarartikel"
|
||||
verbose_name_plural = "Inventarartikel"
|
||||
ordering = ["name"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class ProjectInventoryItem(models.Model):
|
||||
class Status(models.TextChoices):
|
||||
OPEN = "open", "Offen"
|
||||
PACKED = "packed", "Eingepackt"
|
||||
ON_SITE = "on_site", "Vor Ort"
|
||||
|
||||
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")
|
||||
status = models.CharField(max_length=20, choices=Status.choices, default=Status.OPEN, verbose_name="Status")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Projektartikel"
|
||||
verbose_name_plural = "Projektartikel"
|
||||
unique_together = ("project", "item")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.project} - {self.item}"
|
||||
|
||||
|
||||
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")
|
||||
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")
|
||||
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Kategorie")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Einkaufslistenartikel"
|
||||
verbose_name_plural = "Einkaufslistenartikel"
|
||||
ordering = ["name"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -0,0 +1,19 @@
|
||||
from masterdata.registry import register_setting
|
||||
|
||||
from .models import (
|
||||
InventoryCategory,
|
||||
InventoryLocation,
|
||||
)
|
||||
|
||||
|
||||
register_setting(
|
||||
group="Inventar",
|
||||
name="Kategorien",
|
||||
model=InventoryCategory,
|
||||
)
|
||||
|
||||
register_setting(
|
||||
group="Inventar",
|
||||
name="Standorte",
|
||||
model=InventoryLocation,
|
||||
)
|
||||
@@ -0,0 +1,197 @@
|
||||
import django_tables2 as tables
|
||||
from .models import ProjectInventoryItem, ShoppingItem, InventoryItem
|
||||
import django_tables2 as tables
|
||||
|
||||
class InventoryGlobalTable(tables.Table):
|
||||
toggle_prefix = "inv"
|
||||
detail_template = "inventory/tables/inventory_global/edit_form.html"
|
||||
|
||||
name = tables.Column(
|
||||
verbose_name="Name"
|
||||
)
|
||||
|
||||
category = tables.Column(
|
||||
verbose_name="Kategorie"
|
||||
)
|
||||
|
||||
available = tables.TemplateColumn(
|
||||
template_name="inventory/tables/inventory_global/available.html",
|
||||
verbose_name="Anzahl",
|
||||
orderable=False
|
||||
)
|
||||
|
||||
location = tables.Column(
|
||||
verbose_name="Standort"
|
||||
)
|
||||
|
||||
article_number = tables.Column(
|
||||
verbose_name="Artikelnummer"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = InventoryItem
|
||||
template_name = "django_tables2/table_toggle.html"
|
||||
fields = (
|
||||
"expand",
|
||||
"name",
|
||||
"category",
|
||||
"available",
|
||||
"location",
|
||||
"article_number",
|
||||
)
|
||||
attrs = {
|
||||
"class": "w-full text-sm min-w-[700px]"
|
||||
}
|
||||
|
||||
|
||||
class InventoryAddTable(tables.Table):
|
||||
name = tables.Column(
|
||||
accessor="name",
|
||||
verbose_name="Name"
|
||||
)
|
||||
|
||||
category = tables.Column(
|
||||
accessor="category",
|
||||
verbose_name="Kategorie"
|
||||
)
|
||||
|
||||
available = tables.Column(
|
||||
accessor="available_quantity",
|
||||
verbose_name="Verfügbar"
|
||||
)
|
||||
|
||||
article_number = tables.Column(
|
||||
accessor="article_number",
|
||||
verbose_name="Artikelnummer"
|
||||
)
|
||||
|
||||
quantity = tables.TemplateColumn(
|
||||
template_name="inventory/tables/inventory_add/quantity.html",
|
||||
verbose_name="Anzahl hinzufügen",
|
||||
orderable=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = InventoryItem
|
||||
fields = (
|
||||
"name",
|
||||
"category",
|
||||
"available",
|
||||
"article_number",
|
||||
"quantity",
|
||||
)
|
||||
attrs = {
|
||||
"class": "w-full text-sm min-w-[700px]"
|
||||
}
|
||||
|
||||
class ShoppingItemTable(tables.Table):
|
||||
|
||||
name = tables.TemplateColumn(
|
||||
template_name="inventory/tables/shopping/name.html",
|
||||
verbose_name="Name"
|
||||
)
|
||||
|
||||
category = tables.TemplateColumn(
|
||||
template_name="inventory/tables/shopping/category.html",
|
||||
verbose_name="Kategorie"
|
||||
)
|
||||
|
||||
quantity = tables.TemplateColumn(
|
||||
template_name="inventory/tables/shopping/quantity.html",
|
||||
verbose_name="Anzahl"
|
||||
)
|
||||
|
||||
supplier = tables.TemplateColumn(
|
||||
template_name="inventory/tables/shopping/supplier.html",
|
||||
verbose_name="Lieferant"
|
||||
)
|
||||
|
||||
price = tables.TemplateColumn(
|
||||
template_name="inventory/tables/shopping/price.html",
|
||||
verbose_name="Preis"
|
||||
)
|
||||
|
||||
description = tables.TemplateColumn(
|
||||
template_name="inventory/tables/shopping/description.html",
|
||||
verbose_name="Beschreibung"
|
||||
)
|
||||
|
||||
actions = tables.TemplateColumn(
|
||||
template_name="inventory/tables/shopping/actions.html",
|
||||
verbose_name="Aktionen",
|
||||
orderable=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ShoppingItem
|
||||
fields = (
|
||||
"name",
|
||||
"category",
|
||||
"quantity",
|
||||
"supplier",
|
||||
"price",
|
||||
"description",
|
||||
"actions",
|
||||
)
|
||||
attrs = {
|
||||
"class": "w-full text-sm"
|
||||
}
|
||||
|
||||
class ProjectInventoryItemTable(tables.Table):
|
||||
|
||||
name = tables.Column(
|
||||
accessor="item.name",
|
||||
verbose_name="Name"
|
||||
)
|
||||
|
||||
category = tables.Column(
|
||||
accessor="item.category",
|
||||
verbose_name="Kategorie"
|
||||
)
|
||||
|
||||
quantity = tables.TemplateColumn(
|
||||
template_name="inventory/tables/quantity.html",
|
||||
verbose_name="Anzahl"
|
||||
)
|
||||
|
||||
location = tables.Column(
|
||||
accessor="item.location",
|
||||
verbose_name="Standort"
|
||||
)
|
||||
|
||||
article_number = tables.Column(
|
||||
accessor="item.article_number",
|
||||
verbose_name="Artikelnummer"
|
||||
)
|
||||
|
||||
responsible = tables.Column(
|
||||
accessor="item.responsible",
|
||||
verbose_name="Verantwortlicher"
|
||||
)
|
||||
|
||||
status = tables.TemplateColumn(
|
||||
template_name="inventory/tables/status.html",
|
||||
verbose_name="Status"
|
||||
)
|
||||
|
||||
actions = tables.TemplateColumn(
|
||||
template_name="inventory/tables/actions.html",
|
||||
verbose_name="Aktionen",
|
||||
orderable=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ProjectInventoryItem
|
||||
fields = (
|
||||
"name",
|
||||
"category",
|
||||
"quantity",
|
||||
"location",
|
||||
"article_number",
|
||||
"responsible",
|
||||
"status",
|
||||
"actions",
|
||||
)
|
||||
attrs = {
|
||||
"class": "w-full text-sm"
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
{{ project.name }} - Inventar
|
||||
{% endblock %}
|
||||
|
||||
{% block header_title %}
|
||||
Packartikel hinzufügen
|
||||
{% endblock %}
|
||||
|
||||
{% block header_description %}
|
||||
Artikel zur Packliste hinzufügen
|
||||
{% 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">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="overflow-x-auto rounded-xl">
|
||||
{% render_table table %}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn mt-4 btn-save">
|
||||
Speichern
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,37 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
Inventar
|
||||
{% endblock %}
|
||||
|
||||
{% block header_title %}
|
||||
Inventarartikel hinzufügen
|
||||
{% endblock %}
|
||||
|
||||
{% block header_description %}
|
||||
Neuen Artikel zum globalen Inventar hinzufügen
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
|
||||
|
||||
|
||||
<form method="post" class="space-y-4">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
{{ form.as_p }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn mt-4 btn-save">
|
||||
Speichern
|
||||
</button>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,46 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
Inventar
|
||||
{% endblock %}
|
||||
|
||||
{% block header_title %}
|
||||
Inventar
|
||||
{% endblock %}
|
||||
|
||||
{% block header_description %}
|
||||
Globale Inventarverwaltung
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% load django_tables2 %}
|
||||
<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>
|
||||
<p class="text-slate-400 text-sm mt-1">Inventarartikel hinzufügen</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-2">
|
||||
<a href="{% url 'inventory_global:add' %}" class="btn btn-add">+ Artikel</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</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">Inventarliste</h2>
|
||||
<p class="text-slate-400 text-sm mt-1">Gelagerte und verfügbare Artikel</p>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto rounded-xl">
|
||||
{% render_table table %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user