Initial commit
This commit is contained in:
@@ -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",
|
||||
})
|
||||
Reference in New Issue
Block a user