This commit is contained in:
Felix Baumann
2026-06-28 10:20:36 +00:00
parent 64b32d0b44
commit 148efd919d
86 changed files with 2475 additions and 760 deletions
+9 -3
View File
@@ -1,17 +1,23 @@
from projects.models import ProjectMembership
from projects.models import ProjectMembership, Project
from .models import CompanySetting
def project_switcher(request):
if not request.user.is_authenticated:
return {"user_projects": []}
return {"user_projects": [], "archived_count": 0}
memberships = ProjectMembership.objects.select_related("project").filter(
user=request.user,
project__active=True
)
archived_count = ProjectMembership.objects.filter(
user=request.user,
project__active=False
).count()
return {
"user_projects": [m.project for m in memberships]
"user_projects": [m.project for m in memberships],
"archived_count": archived_count,
}
+7 -4
View File
@@ -4,10 +4,13 @@ from django import forms
class BaseForm(forms.ModelForm):
default_class = (
"w-full px-4 py-3 rounded-lg border border-slate-300 dark:border-slate-600 "
"bg-slate-50 dark:bg-slate-700 text-slate-900 dark:text-white "
"placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 "
"focus:border-transparent transition"
"w-full px-4 py-2 rounded-lg border mt-1 "
"border-slate-300 dark:border-slate-600 "
"bg-slate-50 dark:bg-slate-700 "
"text-slate-900 dark:text-white "
"placeholder-slate-400 focus:outline-none "
"focus:border-transparent transition "
"[--tw-ring-color:var(--company-color)] focus:ring-2 "
)
def apply_style(self):
+2 -1
View File
@@ -1,4 +1,4 @@
# Generated by Django 6.0.6 on 2026-06-27 17:19
# Generated by Django 6.0.6 on 2026-06-28 10:19
from django.db import migrations, models
@@ -18,6 +18,7 @@ class Migration(migrations.Migration):
('companyname', models.CharField(max_length=255, verbose_name='Firmenname')),
('name', models.CharField(max_length=255, verbose_name='Geschäftsführer')),
('logo', models.ImageField(blank=True, null=True, upload_to='company/', verbose_name='Logo')),
('favicon', models.ImageField(blank=True, null=True, upload_to='company/', verbose_name='Favicon')),
('slogan', models.CharField(blank=True, max_length=255, verbose_name='Slogan')),
('color', models.CharField(default='#0f172a', max_length=20, verbose_name='Farbe')),
('street', models.CharField(max_length=255, verbose_name='Straße')),
+1
View File
@@ -5,6 +5,7 @@ class CompanySetting(models.Model):
companyname = models.CharField(max_length=255,verbose_name="Firmenname")
name = models.CharField(max_length=255,verbose_name="Geschäftsführer")
logo = models.ImageField(upload_to="company/",blank=True,null=True,verbose_name="Logo")
favicon = models.ImageField(upload_to="company/",blank=True,null=True,verbose_name="Favicon")
slogan = models.CharField(max_length=255,blank=True,verbose_name="Slogan")
color = models.CharField(max_length=20,default="#0f172a",verbose_name="Farbe")
street = models.CharField(max_length=255,verbose_name="Straße")
+48 -19
View File
@@ -18,6 +18,11 @@
</script>
{% tailwind_css %}
{% if company_settings.favicon %}
<link rel="icon" type="image/png" href="{{ company_settings.favicon.url }}">
{% else %}
<link rel="icon" type="image/png" href="{% static 'favicon.png' %}">
{% endif %}
<style>
:root {
@@ -34,14 +39,18 @@
onclick="toggleSidebar()"></div>
<!-- Sidebar -->
<aside id="sidebar" class="fixed top-0 z-40 w-72 h-screen bg-slate-800 text-white transform -translate-x-full lg:translate-x-0 transition-transform duration-300 flex flex-col border-r border-white/20">
<div class="p-6 border-b border-white/20 " style="background-color: {{ company_settings.color }};">
<h2 class="text-xl font-bold">
{{ company_settings.companyname|default:"Projektverwaltung" }}
</h2>
<aside id="sidebar" class="fixed top-0 z-40 w-72 h-screen bg-slate-800 text-white transform -translate-x-full lg:translate-x-0 transition-transform duration-300 flex flex-col border-r border-white/10">
<div class="px-5 py-5 border-b border-white/10">
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full flex-shrink-0" style="background:var(--company-color);"></div>
<div>
<p class="text-sm font-semibold text-white leading-none">
{{ company_settings.companyname|default:"Projektverwaltung" }}
</p>
<p class="text-xs mt-1" style="color:rgba(255,255,255,.3);">Projektverwaltung</p>
</div>
</div>
</div>
<nav class="flex-1 p-4 space-y-2">
@@ -60,7 +69,7 @@
onclick="toggleProjects()"
class="w-full flex items-center justify-between px-4 py-3 hover:bg-slate-700">
<span>{{ current_project.name|default:"Projekt wählen" }}</span>
<span>Projekte</span>
<svg id="projectArrow"
class="w-4 h-4 transition-transform"
@@ -84,6 +93,21 @@
</a>
{% endfor %}
{# Trennlinie #}
<div class="mx-4 my-1 border-t border-slate-700/60"></div>
<a href="{% url 'projects:add' %}"
class="block px-8 py-3 hover:bg-slate-700 text-slate-400 hover:text-white transition-colors">
Hinzufügen
</a>
{% if archived_count %}
<a href="{% url 'projects:archived' %}"
class="block px-8 py-3 hover:bg-slate-700 text-slate-400 hover:text-white transition-colors">
Archiviert ({{ archived_count }})
</a>
{% endif %}
</div>
</div>
@@ -145,6 +169,11 @@
Posten
</a>
<a href="{% url 'invoices:invoices_payments_overview' %}"
class="block px-8 py-3 hover:bg-slate-700 transition">
Zahlungen
</a>
</div>
</div>
@@ -188,17 +217,17 @@
</nav>
<div class="p-4 border-t border-white/20">
<div class="p-4 border-t border-white/10">
{% if user.is_superuser %}
<div class="w-full rounded-xl flex items-center justify-between px-4 py-3 hover:bg-slate-700 mb-4 border-white/20 border">
<div class="w-full rounded-xl flex items-center justify-between px-4 py-3 hover:bg-slate-700 mb-4 border-white/10 border">
<a href="{% url 'admin:index' %}">Admin</a>
</div>
{% endif %}
<div class="flex gap-2 w-full">
<button id="theme-toggle" class="rounded-xl px-4 py-3 hover:bg-slate-700 border-white/20 border">🌙</button>
<button id="theme-toggle" class="rounded-xl px-4 py-3 hover:bg-slate-700 border-white/10 border">🌙</button>
<form method="post" action="{% url 'accounts:logout' %}" class="w-full">
{% csrf_token %}
<button class="w-full rounded-xl px-4 py-3 border-white/20 border" style="background-color: {{ company_settings.color }};">Abmelden</button>
<button class="w-full rounded-xl px-4 py-3 border-white/10 border" style="background-color: {{ company_settings.color }};">Abmelden</button>
</form>
</div>
</div>
@@ -217,16 +246,16 @@
<span class="ml-4 font-bold">{{ company_settings.companyname|default:"Projektverwaltung" }}</span>
</header>
<main class="flex-1 p-6 overflow-x-auto w-screen lg:w-auto">
<div class="p-6 rounded-xl shadow mb-8 bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<h1 class="text-3xl font-bold">
{% block header_title %}
{% endblock %}
{% block header %}
<div class="border border-slate-100 dark:border-slate-700 p-6 rounded-xl shadow mb-8 bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<h1 class="text-xl font-bold">
{% block header_title %}{% endblock %}
</h1>
<p class="text-slate-400 mt-1">
{% block header_description %}
{% endblock %}
<p class="text-slate-400 mt-1 text-md">
{% block header_description %}{% endblock %}
</p>
</div>
{% endblock %}
{% block content %}
{% endblock %}
</main>
+233 -93
View File
@@ -2,9 +2,7 @@
{% block title %}Dashboard{% endblock %}
{% block header_title %}
Dashboard
{% endblock %}
{% block header_title %}Dashboard{% endblock %}
{% block header_description %}
Willkommen {{ user.username }}
@@ -12,103 +10,245 @@ Willkommen {{ user.username }}
{% block content %}
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-4 mb-8">
<a href="{% url 'invoices:overview' %}" class="border border-white/20 p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex items-center gap-3 justify-center">
<h2 class="text-xl font-bold">Rechnungswesen</h2>
</div>
</a>
<!--
<a href="" class="border border-white/20 p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex items-center gap-3 justify-center">
<h2 class="text-xl font-bold">Finanzübersicht</h2>
</div>
</a>
<a href="" class="border border-white/20 p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex items-center gap-3 justify-center">
<h2 class="text-xl font-bold">Arbeitszeiten</h2>
</div>
</a> -->
<a href="{% url 'inventory_global:overview' %}" class="border border-white/20 p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex items-center gap-3 justify-center">
<h2 class="text-xl font-bold">Inventar</h2>
</div>
</a>
<!--
<a href="" class="border border-white/20 p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex items-center gap-3 justify-center">
<h2 class="text-xl font-bold">Investitionen</h2>
</div>
</a>
-->
<a href="{% url 'accounts:overview' %}" class="border border-white/20 p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex items-center gap-3 justify-center">
<h2 class="text-xl font-bold">Mitarbeiter</h2>
</div>
</a>
<a href="{% url 'masterdata:overview' %}" class="border border-white/20 p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex items-center gap-3 justify-center">
<h2 class="text-xl font-bold">Einstellungen</h2>
</div>
</a>
{# Navigation #}
<div class="grid grid-cols-2 md:grid-cols-4 gap-2 mb-6">
<a href="{% url 'invoices:overview' %}" class="group p-4 rounded-xl bg-white dark:bg-slate-800 border border-slate-100 dark:border-slate-700 hover:border-[var(--company-color)]/40 hover:shadow-sm transition-all duration-200">
<p class="text-[11px] text-slate-400 mb-0.5">Modul</p>
<p class="text-sm font-semibold text-slate-800 dark:text-white">Rechnungswesen</p>
</a>
<a href="{% url 'inventory_global:overview' %}" class="group p-4 rounded-xl bg-white dark:bg-slate-800 border border-slate-100 dark:border-slate-700 hover:border-[var(--company-color)]/40 hover:shadow-sm transition-all duration-200">
<p class="text-[11px] text-slate-400 mb-0.5">Modul</p>
<p class="text-sm font-semibold text-slate-800 dark:text-white">Inventar</p>
</a>
<a href="{% url 'accounts:overview' %}" class="group p-4 rounded-xl bg-white dark:bg-slate-800 border border-slate-100 dark:border-slate-700 hover:border-[var(--company-color)]/40 hover:shadow-sm transition-all duration-200">
<p class="text-[11px] text-slate-400 mb-0.5">Modul</p>
<p class="text-sm font-semibold text-slate-800 dark:text-white">Mitarbeiter</p>
</a>
<a href="{% url 'masterdata:overview' %}" class="group p-4 rounded-xl bg-white dark:bg-slate-800 border border-slate-100 dark:border-slate-700 hover:border-[var(--company-color)]/40 hover:shadow-sm transition-all duration-200">
<p class="text-[11px] text-slate-400 mb-0.5">Modul</p>
<p class="text-sm font-semibold text-slate-800 dark:text-white">Einstellungen</p>
</a>
</div>
{# Haupt-Dashboard #}
<div class="bg-white dark:bg-slate-800 rounded-2xl border border-slate-100 dark:border-slate-700 overflow-hidden mb-6">
<div class="mb-8">
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div>
<h2 class="text-xl font-bold">Aktionen</h2>
</div>
<div class="flex flex-col sm:flex-row gap-2">
<a href="{% url 'projects:add' %}" class="btn btn-add">+ Projekt</a>
</div>
{# KPIs #}
<div class="px-5 pt-5 pb-4 border-b border-slate-100 dark:border-slate-700">
<p class="text-[11px] font-semibold uppercase tracking-widest text-slate-400 mb-3">Handlungsbedarf</p>
<div class="grid grid-cols-2 md:grid-cols-4 gap-2" id="kpiMetrics">
<div class="rounded-xl bg-slate-100 dark:bg-slate-700 p-4 animate-pulse h-16"></div>
<div class="rounded-xl bg-slate-100 dark:bg-slate-700 p-4 animate-pulse h-16"></div>
<div class="rounded-xl bg-slate-100 dark:bg-slate-700 p-4 animate-pulse h-16"></div>
<div class="rounded-xl bg-slate-100 dark:bg-slate-700 p-4 animate-pulse h-16"></div>
</div>
</div>
</div>
{% if memberships %}
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
{% for m in memberships %}
<a href="{% url 'projects:overview' m.project.id %}"
class="p-6 rounded-xl shadow hover:shadow-lg transition block text-white"
style="background: linear-gradient(135deg, {{ m.project.color }}, {{ m.project.color }}ff);">
<h2 class="text-xl font-bold">
{{ m.project.name }}
</h2>
<p class="text-sm mt-2">
Rolle: {{ m.get_role_display }}
</p>
</a>
{% endfor %}
</div>
{% else %}
<div class="bg-white dark:bg-slate-800 border border-orange-500 rounded-xl p-6 text-orange-500">
Du bist noch keinem aktiven Projekt zugewiesen.
</div>
{% endif %}
{# Charts #}
<div class="px-5 py-4 border-b border-slate-100 dark:border-slate-700">
<p class="text-[11px] font-semibold uppercase tracking-widest text-slate-400 mb-3">Aktueller Monat</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-2">
{% if archived_count %}
<a href="{% url 'projects:archived' %}" class="text-sm text-slate-400 hover:text-slate-500">
{{ archived_count }} archivierte
{% if archived_count == 1 %}
Projekt anzeigen
{% else %}
Projekte anzeigen
{% endif %}
</a>
{% endif %}
<div class="rounded-xl bg-slate-100 dark:bg-slate-700 p-4">
<div class="flex items-center justify-between mb-3">
<p class="text-xs font-semibold text-slate-600 dark:text-slate-300">Rechnungen</p>
<a href="{% url 'invoices:invoices_invoices_overview' %}" class="text-[11px] text-[var(--company-color)] hover:underline">Alle →</a>
</div>
<div style="position:relative;height:120px;">
<canvas id="invoiceChart" role="img" aria-label="Rechnungen nach Status"></canvas>
</div>
<div id="invoiceLegend" class="flex flex-wrap gap-x-2 gap-y-1 mt-3"></div>
</div>
<div class="rounded-xl bg-slate-100 dark:bg-slate-700 p-4">
<div class="flex items-center justify-between mb-3">
<p class="text-xs font-semibold text-slate-600 dark:text-slate-300">Angebote</p>
<a href="{% url 'invoices:invoices_offers_overview' %}" class="text-[11px] text-[var(--company-color)] hover:underline">Alle →</a>
</div>
<div style="position:relative;height:120px;">
<canvas id="offerChart" role="img" aria-label="Angebote nach Status"></canvas>
</div>
<div id="offerLegend" class="flex flex-wrap gap-x-2 gap-y-1 mt-3"></div>
</div>
<div class="rounded-xl bg-slate-100 dark:bg-slate-700 p-4">
<div class="flex items-center justify-between mb-3">
<p class="text-xs font-semibold text-slate-600 dark:text-slate-300">Mahnungen</p>
<a href="{% url 'invoices:invoices_reminders_overview' %}" class="text-[11px] text-[var(--company-color)] hover:underline">Alle →</a>
</div>
<div style="position:relative;height:120px;">
<canvas id="reminderChart" role="img" aria-label="Mahnungen nach Status"></canvas>
</div>
<div id="reminderLegend" class="flex flex-wrap gap-x-2 gap-y-1 mt-3"></div>
</div>
</div>
</div>
{# Kunden #}
<div class="px-5 py-4">
<p class="text-[11px] font-semibold uppercase tracking-widest text-slate-400 mb-3">Kunden</p>
<div class="grid grid-cols-2 md:grid-cols-4 gap-2" id="customerMetrics">
<div class="rounded-xl bg-slate-100 dark:bg-slate-700 p-4 animate-pulse h-16"></div>
<div class="rounded-xl bg-slate-100 dark:bg-slate-700 p-4 animate-pulse h-16"></div>
<div class="rounded-xl bg-slate-100 dark:bg-slate-700 p-4 animate-pulse h-16"></div>
<div class="rounded-xl bg-slate-100 dark:bg-slate-700 p-4 animate-pulse h-16"></div>
</div>
</div>
</div>
<script>
window.URLS = {
invoiceInfo: "{% url 'invoices:ajax_invoice_info' %}",
offerInfo: "{% url 'invoices:ajax_offer_info' %}",
reminderInfo: "{% url 'invoices:ajax_reminder_info' %}",
customerInfo: "{% url 'invoices:ajax_customer_info' %}",
kpiInfo: "{% url 'invoices:ajax_kpi_info' %}",
};
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.js"></script>
<script>
const STATUS_META = {
draft: { label: "Entwurf", color: "#94a3b8" },
sent: { label: "Versendet", color: "#2a78d6" },
paid: { label: "Bezahlt", color: "#1baf7a" },
overdue: { label: "Überfällig", color: "#e34948" },
canceled: { label: "Storniert", color: "#eb6834" },
accepted: { label: "Akzeptiert", color: "#1baf7a" },
rejected: { label: "Abgelehnt", color: "#e34948" },
expired: { label: "Frist abgelaufen", color: "#eda100" },
};
function buildDonut(canvasId, legendId, data) {
const entries = Object.entries(data).filter(([, v]) => v > 0);
if (!entries.length) {
document.getElementById(canvasId).parentElement.innerHTML =
'<p class="text-[11px] text-slate-400 text-center py-8">Keine Einträge diesen Monat</p>';
return;
}
const keys = entries.map(([k]) => k);
const values = entries.map(([, v]) => v);
const colors = keys.map(k => (STATUS_META[k] || { color: "#888" }).color);
const labels = keys.map(k => (STATUS_META[k] || { label: k }).label);
const total = values.reduce((a, b) => a + b, 0);
new Chart(document.getElementById(canvasId), {
type: 'doughnut',
data: {
labels,
datasets: [{
data: values,
backgroundColor: colors,
borderWidth: 0,
hoverOffset: 3,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '72%',
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: ctx => ` ${ctx.label}: ${ctx.parsed} (${Math.round(ctx.parsed / total * 100)}%)`
}
}
}
}
});
const legend = document.getElementById(legendId);
keys.forEach((k, i) => {
const item = document.createElement('span');
item.className = 'flex items-center gap-1 text-[11px] text-slate-400 dark:text-slate-400';
item.innerHTML = `
<span style="width:6px;height:6px;border-radius:50%;background:${colors[i]};display:inline-block;flex-shrink:0;"></span>
${labels[i]}: <span class="font-medium text-slate-700 dark:text-slate-200">${values[i]}</span>
`;
legend.appendChild(item);
});
}
async function fetchAndRender(url, canvasId, legendId) {
try {
const r = await fetch(url);
if (!r.ok) throw new Error();
buildDonut(canvasId, legendId, await r.json());
} catch {
document.getElementById(canvasId).parentElement.innerHTML =
'<p class="text-[11px] text-slate-400 text-center py-8">Keine Daten</p>';
}
}
async function fetchKpis(url) {
try {
const r = await fetch(url);
if (!r.ok) throw new Error();
const d = await r.json();
document.getElementById('kpiMetrics').innerHTML = `
<a href="{% url 'invoices:invoices_invoices_overview' %}?status=overdue"
class="rounded-xl bg-slate-100 dark:bg-slate-700 p-4 transition-colors block">
<p class="text-[11px] text-slate-400 mb-1">Überfällige Rechnungen</p>
<p class="text-2xl font-semibold ${d.overdue_invoices > 0 ? 'text-red-500' : 'text-slate-800 dark:text-white'}">${d.overdue_invoices}</p>
</a>
<a href="{% url 'invoices:invoices_invoices_overview' %}?status=sent"
class="rounded-xl bg-slate-100 dark:bg-slate-700 p-4 transition-colors block">
<p class="text-[11px] text-slate-400 mb-1">Offene Rechnungen</p>
<p class="text-2xl font-semibold text-slate-800 dark:text-white">${d.open_invoices}</p>
</a>
<a href="{% url 'invoices:invoices_offers_overview' %}?status=sent"
class="rounded-xl bg-slate-100 dark:bg-slate-700 p-4 transition-colors block">
<p class="text-[11px] text-slate-400 mb-1">Offene Angebote</p>
<p class="text-2xl font-semibold text-slate-800 dark:text-white">${d.open_offers}</p>
</a>
<a href="{% url 'invoices:invoices_reminders_overview' %}?status=sent"
class="rounded-xl bg-slate-100 dark:bg-slate-700 p-4 transition-colors block">
<p class="text-[11px] text-slate-400 mb-1">Offene Mahnungen</p>
<p class="text-2xl font-semibold ${d.open_reminders > 0 ? 'text-amber-500' : 'text-slate-800 dark:text-white'}">${d.open_reminders}</p>
</a>
`;
} catch {
document.getElementById('kpiMetrics').innerHTML =
'<p class="text-[11px] text-slate-400">Keine Daten</p>';
}
}
async function fetchCustomers(url) {
try {
const r = await fetch(url);
if (!r.ok) throw new Error();
const d = await r.json();
document.getElementById('customerMetrics').innerHTML = `
<div class="rounded-xl bg-slate-100 dark:bg-slate-700 p-4">
<p class="text-[11px] text-slate-400 mb-1">Gesamt</p>
<p class="text-2xl font-semibold text-slate-800 dark:text-white">${d.total}</p>
</div>
<div class="rounded-xl bg-slate-100 dark:bg-slate-700 p-4">
<p class="text-[11px] text-slate-400 mb-1">Neue diese Woche</p>
<p class="text-2xl font-semibold text-slate-800 dark:text-white">${d.new_this_week}</p>
</div>
<div class="rounded-xl bg-slate-100 dark:bg-slate-700 p-4">
<p class="text-[11px] text-slate-400 mb-1">Privatkunden</p>
<p class="text-2xl font-semibold text-slate-800 dark:text-white">${d.private}</p>
</div>
<div class="rounded-xl bg-slate-100 dark:bg-slate-700 p-4">
<p class="text-[11px] text-slate-400 mb-1">Unternehmen</p>
<p class="text-2xl font-semibold text-slate-800 dark:text-white">${d.companies}</p>
</div>
`;
} catch {
document.getElementById('customerMetrics').innerHTML =
'<p class="text-[11px] text-slate-400">Keine Daten</p>';
}
}
fetchKpis(window.URLS.kpiInfo);
fetchAndRender(window.URLS.invoiceInfo, 'invoiceChart', 'invoiceLegend');
fetchAndRender(window.URLS.offerInfo, 'offerChart', 'offerLegend');
fetchAndRender(window.URLS.reminderInfo, 'reminderChart', 'reminderLegend');
fetchCustomers(window.URLS.customerInfo);
</script>
{% endblock %}