Initial commit
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
from django.contrib import admin
|
||||
from .models import Project, ProjectMembership, ProjectContact, ProjectLocation
|
||||
|
||||
|
||||
class ProjectMembershipInline(admin.TabularInline):
|
||||
model = ProjectMembership
|
||||
extra = 1
|
||||
|
||||
|
||||
@admin.register(Project)
|
||||
class ProjectAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"name",
|
||||
"active",
|
||||
"created_at",
|
||||
)
|
||||
|
||||
search_fields = (
|
||||
"name",
|
||||
)
|
||||
|
||||
inlines = [
|
||||
ProjectMembershipInline
|
||||
]
|
||||
|
||||
|
||||
@admin.register(ProjectMembership)
|
||||
class ProjectMembershipAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"user",
|
||||
"project",
|
||||
"role",
|
||||
)
|
||||
|
||||
list_filter = (
|
||||
"role",
|
||||
)
|
||||
|
||||
|
||||
@admin.register(ProjectContact)
|
||||
class ProjectContactAdmin(admin.ModelAdmin):
|
||||
list_display = ("project", "contact", "created_at")
|
||||
search_fields = ("project__name", "contact__first_name", "contact__last_name")
|
||||
autocomplete_fields = ("project", "contact")
|
||||
list_filter = ("project",)
|
||||
|
||||
|
||||
@admin.register(ProjectLocation)
|
||||
class ProjectLocationAdmin(admin.ModelAdmin):
|
||||
list_display = ("project", "location", "created_at")
|
||||
search_fields = ("project__name", "location__name")
|
||||
autocomplete_fields = ("project", "location")
|
||||
list_filter = ("project",)
|
||||
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ProjectsConfig(AppConfig):
|
||||
name = 'projects'
|
||||
@@ -0,0 +1,102 @@
|
||||
from django import forms
|
||||
from core.forms.base import BaseForm
|
||||
from invoices.models import CustomerContact, CustomerLocation
|
||||
from .models import ProjectContact, ProjectLocation, Project, ProjectMembership
|
||||
from django.contrib.auth import get_user_model
|
||||
User = get_user_model()
|
||||
|
||||
class ProjectAddForm(BaseForm):
|
||||
class Meta:
|
||||
model = Project
|
||||
fields = [
|
||||
"name",
|
||||
"customer",
|
||||
"logo",
|
||||
"color",
|
||||
"icon",
|
||||
"hourly_rate",
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.apply_style()
|
||||
|
||||
class ProjectForm(BaseForm):
|
||||
class Meta:
|
||||
model = Project
|
||||
fields = [
|
||||
"name",
|
||||
"logo",
|
||||
"color",
|
||||
"icon",
|
||||
"hourly_rate",
|
||||
"active"
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.apply_style()
|
||||
|
||||
class ProjectAddContactForm(BaseForm):
|
||||
class Meta:
|
||||
model = ProjectContact
|
||||
fields = ["contact"]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
project = kwargs.pop("project", None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
qs = CustomerContact.objects.none()
|
||||
|
||||
if project and project.customer:
|
||||
qs = CustomerContact.objects.filter(
|
||||
customer=project.customer
|
||||
).exclude(
|
||||
project_links__project=project
|
||||
)
|
||||
|
||||
self.fields["contact"].queryset = qs
|
||||
self.apply_style()
|
||||
|
||||
class ProjectAddLocationForm(BaseForm):
|
||||
class Meta:
|
||||
model = ProjectLocation
|
||||
fields = ["location"]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
project = kwargs.pop("project", None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
qs = CustomerLocation.objects.none()
|
||||
|
||||
if project and project.customer:
|
||||
qs = CustomerLocation.objects.filter(
|
||||
customer=project.customer
|
||||
).exclude(
|
||||
project_links__project=project
|
||||
)
|
||||
|
||||
self.fields["location"].queryset = qs
|
||||
self.apply_style()
|
||||
|
||||
|
||||
class ProjectAddWorkerForm(BaseForm):
|
||||
|
||||
class Meta:
|
||||
model = ProjectMembership
|
||||
|
||||
fields = [
|
||||
"user",
|
||||
"role",
|
||||
]
|
||||
|
||||
def __init__(self, *args, project=None, **kwargs):
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.apply_style()
|
||||
|
||||
if project:
|
||||
self.fields["user"].queryset = User.objects.exclude(
|
||||
project_memberships__project=project
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
# Generated by Django 6.0.6 on 2026-06-23 22:12
|
||||
|
||||
import django.db.models.deletion
|
||||
import projects.models
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('invoices', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Project',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255, verbose_name='Name')),
|
||||
('logo', models.ImageField(blank=True, null=True, upload_to=projects.models.logo_upload_path, verbose_name='Logo')),
|
||||
('color', models.CharField(default='#2563eb', max_length=20, verbose_name='Farbe')),
|
||||
('icon', models.CharField(blank=True, max_length=100, verbose_name='Icon')),
|
||||
('active', models.BooleanField(default=True, verbose_name='Status')),
|
||||
('hourly_rate', models.DecimalField(decimal_places=2, default=25, max_digits=10, verbose_name='Stundenlohn')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('customer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='projects', to='invoices.customer', verbose_name='Kunde')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ProjectContact',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Erstellt am')),
|
||||
('contact', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_links', to='invoices.customercontact', verbose_name='Ansprechpartner')),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_contacts', to='projects.project', verbose_name='Projekt')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('project', 'contact')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ProjectLocation',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Erstellt am')),
|
||||
('location', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_links', to='invoices.customerlocation', verbose_name='Standort')),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_locations', to='projects.project', verbose_name='Projekt')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('project', 'location')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ProjectMembership',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('role', models.CharField(choices=[('owner', 'Eigentümer'), ('manager', 'Manager'), ('employee', 'Mitarbeiter')], default='employee', max_length=20)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='projects.project')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_memberships', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('project', 'user')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,131 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth import get_user_model
|
||||
from invoices.models import Customer, CustomerContact, CustomerLocation
|
||||
import os
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
def logo_upload_path(instance, filename):
|
||||
return f"projects/{instance.id}/logo/{filename}"
|
||||
|
||||
class Project(models.Model):
|
||||
name = models.CharField(max_length=255, verbose_name="Name")
|
||||
logo = models.ImageField(upload_to=logo_upload_path, blank=True, null=True, verbose_name="Logo")
|
||||
color = models.CharField(max_length=20, default="#2563eb", verbose_name="Farbe")
|
||||
icon = models.CharField(max_length=100, blank=True, verbose_name="Icon")
|
||||
active = models.BooleanField(default=True, verbose_name="Status")
|
||||
customer = models.ForeignKey(
|
||||
Customer,
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="projects",
|
||||
verbose_name="Kunde"
|
||||
)
|
||||
hourly_rate = models.DecimalField(max_digits=10,decimal_places=2,default=25,verbose_name="Stundenlohn")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
customer_changed = False
|
||||
|
||||
if self.pk:
|
||||
old_customer_id = (
|
||||
Project.objects
|
||||
.filter(pk=self.pk)
|
||||
.values_list("customer_id", flat=True)
|
||||
.first()
|
||||
)
|
||||
|
||||
customer_changed = old_customer_id != self.customer_id
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
if customer_changed:
|
||||
self.project_contacts.all().delete()
|
||||
self.project_locations.all().delete()
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class ProjectMembership(models.Model):
|
||||
class Role(models.TextChoices):
|
||||
OWNER = "owner", "Eigentümer"
|
||||
MANAGER = "manager", "Manager"
|
||||
EMPLOYEE = "employee", "Mitarbeiter"
|
||||
|
||||
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="memberships")
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="project_memberships")
|
||||
role = models.CharField(max_length=20, choices=Role.choices, default=Role.EMPLOYEE)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("project", "user")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} - {self.project} ({self.role})"
|
||||
|
||||
|
||||
class ProjectContact(models.Model):
|
||||
project = models.ForeignKey(
|
||||
"projects.Project",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="project_contacts",
|
||||
verbose_name="Projekt"
|
||||
)
|
||||
|
||||
contact = models.ForeignKey(
|
||||
CustomerContact,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="project_links",
|
||||
verbose_name="Ansprechpartner"
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am")
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
if not self.project_id or not self.location_id:
|
||||
return
|
||||
|
||||
if self.location.customer_id != self.project.customer_id:
|
||||
raise ValidationError({
|
||||
"location": "Der Ansprechpartner gehört nicht zum Kunden des Projekts."
|
||||
})
|
||||
|
||||
class Meta:
|
||||
unique_together = ("project", "contact")
|
||||
|
||||
|
||||
class ProjectLocation(models.Model):
|
||||
project = models.ForeignKey(
|
||||
"projects.Project",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="project_locations",
|
||||
verbose_name="Projekt"
|
||||
)
|
||||
|
||||
location = models.ForeignKey(
|
||||
CustomerLocation,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="project_links",
|
||||
verbose_name="Standort"
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am")
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
if not self.project_id or not self.location_id:
|
||||
return
|
||||
|
||||
if self.location.customer_id != self.project.customer_id:
|
||||
raise ValidationError({
|
||||
"location": "Der Standort gehört nicht zum Kunden des Projekts."
|
||||
})
|
||||
|
||||
class Meta:
|
||||
unique_together = ("project", "location")
|
||||
@@ -0,0 +1,32 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
Projekt - Erstellen
|
||||
{% endblock %}
|
||||
|
||||
{% block header_title %}
|
||||
Projekt erstellen
|
||||
{% endblock %}
|
||||
|
||||
{% block header_description %}
|
||||
Ein neues Projekt erstellen
|
||||
{% 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,43 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
Projekt - Archiviert
|
||||
{% endblock %}
|
||||
|
||||
{% block header_title %}
|
||||
Archivierte Projekte
|
||||
{% endblock %}
|
||||
|
||||
{% block header_description %}
|
||||
Abgeschlossene und deaktivierte Projekte
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% 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 }}cc);">
|
||||
<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-yellow-100 border border-yellow-300 rounded-xl p-6">
|
||||
Keine archivierten Projekte vorhanden.
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,42 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
{{ project.name }} - Informationen
|
||||
{% endblock %}
|
||||
|
||||
{% block header_title %}
|
||||
Ansprechpartner hinzufügen
|
||||
{% endblock %}
|
||||
|
||||
{% block header_description %}
|
||||
Neuen Ansprechpartner für das Projekt 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">
|
||||
|
||||
<form method="post" class="space-y-4">
|
||||
|
||||
{% csrf_token %}
|
||||
|
||||
{{ form.as_p }}
|
||||
|
||||
<div class="mt-6 flex gap-2">
|
||||
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn mt-4 btn-save">
|
||||
Speichern
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,249 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
{{ project.name }} - Informationen
|
||||
{% endblock %}
|
||||
|
||||
{% block header_title %}
|
||||
Projektinformationen
|
||||
{% endblock %}
|
||||
|
||||
{% block header_description %}
|
||||
Standorte & Ansprechpartner
|
||||
{% 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">Standorte und Ansprechpartner hinzufügen</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-2">
|
||||
<a href="{% url 'projects:location_add' project.id %}" class="btn btn-add">+ Standort</a>
|
||||
<a href="{% url 'projects:contact_add' project.id %}" class="btn btn-add">+ Ansprechpartner</a>
|
||||
<a href="{% url 'projects:worker_add' project.id %}" class="btn btn-add">+ Mitarbeiter</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
|
||||
<h2 class="text-xl font-bold mb-2">Allgemein</h2>
|
||||
|
||||
<form method="post" enctype="multipart/form-data" action="{% url 'projects:edit' project.id %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="update_project">
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
|
||||
{% for field in form %}
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">
|
||||
{{ field.label }}
|
||||
</label>
|
||||
|
||||
{{ field }}
|
||||
|
||||
{% if field.errors %}
|
||||
<div class="text-red-600 text-sm mt-1">
|
||||
{{ field.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
|
||||
<button type="submit"
|
||||
class="btn mt-4 btn-save">
|
||||
Speichern
|
||||
</button>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="grid grid-cols-1 gap-5 mb-8">
|
||||
|
||||
<!-- Mitarbeiter -->
|
||||
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
|
||||
|
||||
<h2 class="text-xl font-bold mb-3">
|
||||
Mitarbeiter
|
||||
</h2>
|
||||
|
||||
{% for worker in workers %}
|
||||
|
||||
<div class="bg-slate-100 dark:bg-slate-700 rounded-xl p-3 mb-2 flex justify-between">
|
||||
|
||||
<div>
|
||||
<div class="font-medium">{{ worker.user.get_full_name|default:worker.user.username }}</div>
|
||||
<div class="text-slate-400 text-sm">{{ worker.user.email }}</div>
|
||||
<div class="text-slate-400 text-sm">Rolle: {{ worker.get_role_display }}</div>
|
||||
</div>
|
||||
|
||||
{% if worker.role != "owner" %}
|
||||
<form method="post" action="{% url 'projects:worker_delete' project.id worker.id %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="delete_worker">
|
||||
<input type="hidden" name="worker_id" value="{{ worker.id }}">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-delete">
|
||||
Löschen
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% empty %}
|
||||
<div class="bg-yellow-100 border border-yellow-300 rounded-xl p-6">
|
||||
Keine Mitarbeiter vorhanden
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
|
||||
<!-- Standorte -->
|
||||
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
|
||||
|
||||
<h2 class="text-xl font-bold mb-3">
|
||||
Standorte
|
||||
</h2>
|
||||
|
||||
{% for location in locations %}
|
||||
|
||||
<div class="bg-slate-100 dark:bg-slate-700 rounded-xl p-3 mb-2 flex justify-between">
|
||||
<div>
|
||||
<div class="font-medium">
|
||||
{{ location.location.name }}
|
||||
</div>
|
||||
|
||||
<div class="text-slate-400 text-sm">
|
||||
{{ location.location.street }}
|
||||
</div>
|
||||
|
||||
<div class="text-slate-400 text-sm">
|
||||
{{ location.location.zip_code }} {{ location.location.city }}
|
||||
</div>
|
||||
|
||||
<div class="text-slate-400 text-sm">
|
||||
{{ location.location.country }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{% url 'projects:location_delete' project.id location.id %}">
|
||||
{% csrf_token %}
|
||||
|
||||
<input
|
||||
type="hidden"
|
||||
name="action"
|
||||
value="delete_location">
|
||||
|
||||
<input
|
||||
type="hidden"
|
||||
name="location_id"
|
||||
value="{{ location.id }}">
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-delete">
|
||||
Löschen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% empty %}
|
||||
|
||||
<div class="bg-white dark:bg-slate-800 border border-orange-500 rounded-xl p-6 text-orange-500">
|
||||
Keine Standorte vorhanden
|
||||
</div>
|
||||
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Ansprechpartner -->
|
||||
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
|
||||
|
||||
<h2 class="text-xl font-bold mb-3">
|
||||
Ansprechpartner
|
||||
</h2>
|
||||
|
||||
{% for contact in contacts %}
|
||||
|
||||
<div class="bg-slate-100 dark:bg-slate-700 rounded-xl p-3 mb-2 flex justify-between">
|
||||
<div>
|
||||
<div class="font-medium">
|
||||
{{ contact.contact.first_name }} {{ contact.contact.last_name }}
|
||||
</div>
|
||||
|
||||
{% if contact.contact.company %}
|
||||
<div class="text-slate-400 text-sm">
|
||||
{{ contact.contact.company }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if contact.contact.phone %}
|
||||
<div class="text-slate-400 text-sm">
|
||||
{{ contact.contact.phone }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if contact.contact.email %}
|
||||
<div class="text-slate-400 text-sm">
|
||||
{{ contact.contact.email }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<form method="post" action="{% url 'projects:contact_delete' project.id contact.id %}">
|
||||
{% csrf_token %}
|
||||
|
||||
<input
|
||||
type="hidden"
|
||||
name="action"
|
||||
value="delete_contact">
|
||||
|
||||
<input
|
||||
type="hidden"
|
||||
name="contact_id"
|
||||
value="{{ contact.id }}">
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-delete">
|
||||
Löschen
|
||||
</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
{% empty %}
|
||||
|
||||
<div class="bg-white dark:bg-slate-800 border border-orange-500 rounded-xl p-6 text-orange-500">
|
||||
Keine Ansprechpartner vorhanden
|
||||
</div>
|
||||
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,42 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
{{ project.name }} - Informationen
|
||||
{% endblock %}
|
||||
|
||||
{% block header_title %}
|
||||
Standort hinzufügen
|
||||
{% endblock %}
|
||||
|
||||
{% block header_description %}
|
||||
Neuen Projektstandort 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">
|
||||
|
||||
<form method="post" class="space-y-4">
|
||||
|
||||
{% csrf_token %}
|
||||
|
||||
{{ form.as_p }}
|
||||
|
||||
<div class="mt-6 flex gap-2">
|
||||
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn mt-4 btn-save">
|
||||
Speichern
|
||||
</button>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,80 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}
|
||||
{{ project.name }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block header_title %}
|
||||
{{ project.name }}
|
||||
{% endblock %}
|
||||
|
||||
{% block header_description %}
|
||||
Projektübersicht und Module
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<!-- MODULE GRID -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
||||
|
||||
<!-- FINANZEN -->
|
||||
<a href="{% url 'finance:overview' project.id %}" class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
|
||||
<div class="text-2xl mb-2">💰</div>
|
||||
<h2 class="text-xl font-bold">Finanzen</h2>
|
||||
<p class="text-slate-400 text-sm mt-1">Einnahmen & Ausgaben</p>
|
||||
</a>
|
||||
|
||||
<!-- KASSE -->
|
||||
<a href="" class="relative p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
|
||||
<div class="absolute top-3 right-3 text-xs font-semibold px-2 py-1 rounded-full text-white" style="background-color: {{ company_settings.color }};">
|
||||
In Arbeit
|
||||
</div>
|
||||
<div class="text-2xl mb-2">🏦</div>
|
||||
<h2 class="text-xl font-bold">Kasse</h2>
|
||||
<p class="text-slate-400 text-sm mt-1">Buchungen vornehmen</p>
|
||||
</a>
|
||||
|
||||
<!-- ZEITERFASSUNG -->
|
||||
<a href="{% url 'time_tracking:overview' project.id %}" class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
|
||||
<div class="text-2xl mb-2">⏱</div>
|
||||
<h2 class="text-xl font-bold">Zeiterfassung</h2>
|
||||
<p class="text-slate-400 text-sm mt-1">Arbeitszeiten der Helfer</p>
|
||||
</a>
|
||||
|
||||
<!-- DOKUMENTE -->
|
||||
<a href="{% url 'documents:overview' project.id %}" class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
|
||||
<div class="text-2xl mb-2">📁</div>
|
||||
<h2 class="text-xl font-bold">Dokumente</h2>
|
||||
<p class="text-slate-400 text-sm mt-1">Dateien & Bilder</p>
|
||||
</a>
|
||||
|
||||
<!-- LISTE -->
|
||||
<a href="{% url 'inventory:overview' project.id %}" class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
|
||||
<div class="text-2xl mb-2">🛒</div>
|
||||
<h2 class="text-xl font-bold">Pack-/Einkaufsliste</h2>
|
||||
<p class="text-slate-400 text-sm mt-1">Vorhandenen & benötigtes Material</p>
|
||||
</a>
|
||||
|
||||
<!-- Projektinformationen -->
|
||||
<a href="{% url 'projects:info' project.id %}" class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
|
||||
<div class="text-2xl mb-2">ℹ️</div>
|
||||
<h2 class="text-xl font-bold">Projektinformationen</h2>
|
||||
<p class="text-slate-400 text-sm mt-1">Standorte & Ansprechpartner</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- OPTIONAL INFO BOX -->
|
||||
<div class="p-6 rounded-xl shadow mb-8 bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
|
||||
|
||||
<h2 class="text-xl font-bold">
|
||||
Zusammenfassung
|
||||
</h2>
|
||||
|
||||
<p class="text-slate-400 text-sm mt-1">
|
||||
folgt
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,46 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
|
||||
{% block title %}
|
||||
{{ project.name }} - Informationen
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block header_title %}
|
||||
Mitarbeiter hinzufügen
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block header_description %}
|
||||
Benutzer zum Projekt 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 %}
|
||||
|
||||
{{ form.as_p }}
|
||||
|
||||
<div class="mt-6 flex gap-2">
|
||||
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn mt-4 btn-save">
|
||||
Speichern
|
||||
</button>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1,18 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = "projects"
|
||||
|
||||
urlpatterns = [
|
||||
path("add/", views.project_add, name="add"),
|
||||
path("archived/", views.projects_archived, name="archived"),
|
||||
path("<int:project_id>/", views.project_overview, name="overview"),
|
||||
path("<int:project_id>/info/", views.project_info, name="info"),
|
||||
path("<int:project_id>/info/edit/",views.project_edit,name="edit"),
|
||||
path("<int:project_id>/info/contact/add/", views.project_contact_add, name="contact_add"),
|
||||
path("<int:project_id>/info/contact/<int:contact_id>/delete/", views.project_contact_delete, name="contact_delete"),
|
||||
path("<int:project_id>/info/location/add/", views.project_location_add, name="location_add"),
|
||||
path("<int:project_id>/info/location/<int:location_id>/delete/", views.project_location_delete, name="location_delete"),
|
||||
path("<int:project_id>/info/worker/add/", views.project_worker_add, name="worker_add"),
|
||||
path("<int:project_id>/info/worker/<int:worker_id>/delete/", views.project_worker_delete, name="worker_delete"),
|
||||
]
|
||||
@@ -0,0 +1,310 @@
|
||||
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 .forms import ProjectForm, ProjectAddLocationForm, ProjectAddContactForm, ProjectAddForm, ProjectAddWorkerForm
|
||||
|
||||
from .models import (
|
||||
Project,
|
||||
ProjectMembership,
|
||||
ProjectContact,
|
||||
ProjectLocation,
|
||||
)
|
||||
|
||||
from invoices.models import CustomerContact, CustomerLocation
|
||||
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required("projects_add")
|
||||
def project_add(request):
|
||||
form = ProjectAddForm(request.POST or None,request.FILES or None,)
|
||||
if request.method == "POST" and form.is_valid():
|
||||
project = form.save()
|
||||
ProjectMembership.objects.create(project=project,user=request.user,role=ProjectMembership.Role.OWNER,)
|
||||
return redirect("projects:overview",project.id)
|
||||
|
||||
return render(request,"projects/add.html",{"form": form,})
|
||||
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required("projects_archived_view")
|
||||
def projects_archived(request):
|
||||
memberships = ProjectMembership.objects.select_related("project").filter(user=request.user,project__active=False)
|
||||
return render(request,"projects/archived.html",{"memberships": memberships,})
|
||||
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# OVERVIEW
|
||||
# ---------------------------
|
||||
@login_required
|
||||
@permission_required("projects_view")
|
||||
def project_overview(request, project_id):
|
||||
|
||||
membership = get_object_or_404(
|
||||
ProjectMembership,
|
||||
user=request.user,
|
||||
project_id=project_id
|
||||
)
|
||||
|
||||
project = membership.project
|
||||
|
||||
return render(request, "projects/overview.html", {
|
||||
"project": project,
|
||||
"role": membership.role
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required("projects_detail_view")
|
||||
def project_info(request, project_id):
|
||||
|
||||
membership = get_object_or_404(
|
||||
ProjectMembership,
|
||||
user=request.user,
|
||||
project_id=project_id
|
||||
)
|
||||
|
||||
project = membership.project
|
||||
|
||||
form = ProjectForm(
|
||||
instance=project
|
||||
)
|
||||
|
||||
workers = project.memberships.select_related(
|
||||
"user"
|
||||
).all()
|
||||
|
||||
|
||||
return render(
|
||||
request,
|
||||
"projects/info.html",
|
||||
{
|
||||
"project": project,
|
||||
"form": form,
|
||||
|
||||
"locations": project.project_locations.select_related(
|
||||
"location"
|
||||
),
|
||||
|
||||
"contacts": project.project_contacts.select_related(
|
||||
"contact"
|
||||
),
|
||||
|
||||
"workers": workers,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required("projects_edit")
|
||||
def project_edit(request, project_id):
|
||||
|
||||
membership = get_object_or_404(
|
||||
ProjectMembership,
|
||||
user=request.user,
|
||||
project_id=project_id
|
||||
)
|
||||
|
||||
project = membership.project
|
||||
|
||||
|
||||
if request.method != "POST":
|
||||
return redirect(
|
||||
"projects:info",
|
||||
project.id
|
||||
)
|
||||
|
||||
|
||||
form = ProjectForm(
|
||||
request.POST,
|
||||
request.FILES,
|
||||
instance=project
|
||||
)
|
||||
|
||||
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
|
||||
|
||||
return redirect(
|
||||
"projects:info",
|
||||
project.id
|
||||
)
|
||||
|
||||
# ---------------------------
|
||||
# ADD CONTACT TO PROJECT
|
||||
# ---------------------------
|
||||
@login_required
|
||||
@permission_required("projects_contact_add")
|
||||
def project_contact_add(request, project_id):
|
||||
|
||||
project = get_object_or_404(Project, pk=project_id)
|
||||
|
||||
if request.method == "POST":
|
||||
form = ProjectAddContactForm(
|
||||
request.POST,
|
||||
project=project,
|
||||
)
|
||||
|
||||
if form.is_valid():
|
||||
contact = form.cleaned_data["contact"]
|
||||
|
||||
ProjectContact.objects.get_or_create(
|
||||
project=project,
|
||||
contact=contact
|
||||
)
|
||||
|
||||
return redirect("projects:info", project.id)
|
||||
|
||||
else:
|
||||
form = ProjectAddContactForm(project=project)
|
||||
|
||||
return render(request, "projects/contact_add.html", {
|
||||
"form": form,
|
||||
"project": project,
|
||||
})
|
||||
|
||||
@login_required
|
||||
@permission_required("projects_contact_delete")
|
||||
def project_contact_delete(request, project_id, contact_id):
|
||||
|
||||
membership = get_object_or_404(
|
||||
ProjectMembership,
|
||||
user=request.user,
|
||||
project_id=project_id
|
||||
)
|
||||
|
||||
project = membership.project
|
||||
|
||||
|
||||
if request.method == "POST":
|
||||
|
||||
ProjectContact.objects.filter(
|
||||
id=contact_id,
|
||||
project=project
|
||||
).delete()
|
||||
|
||||
|
||||
return redirect(
|
||||
"projects:info",
|
||||
project.id
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# ADD LOCATION TO PROJECT
|
||||
# ---------------------------
|
||||
@login_required
|
||||
@permission_required("projects_location_add")
|
||||
def project_location_add(request, project_id):
|
||||
|
||||
project = get_object_or_404(Project, pk=project_id)
|
||||
|
||||
if request.method == "POST":
|
||||
form = ProjectAddLocationForm(
|
||||
request.POST,
|
||||
project=project,
|
||||
)
|
||||
|
||||
if form.is_valid():
|
||||
location = form.cleaned_data["location"]
|
||||
|
||||
ProjectLocation.objects.get_or_create(
|
||||
project=project,
|
||||
location=location
|
||||
)
|
||||
|
||||
return redirect("projects:info", project.id)
|
||||
|
||||
else:
|
||||
form = ProjectAddLocationForm(project=project)
|
||||
|
||||
return render(request, "projects/location_add.html", {
|
||||
"form": form,
|
||||
"project": project,
|
||||
})
|
||||
|
||||
@login_required
|
||||
@permission_required("projects_location_delete")
|
||||
def project_location_delete(request, project_id, location_id):
|
||||
|
||||
membership = get_object_or_404(
|
||||
ProjectMembership,
|
||||
user=request.user,
|
||||
project_id=project_id
|
||||
)
|
||||
|
||||
project = membership.project
|
||||
|
||||
|
||||
if request.method == "POST":
|
||||
|
||||
ProjectLocation.objects.filter(
|
||||
id=location_id,
|
||||
project=project
|
||||
).delete()
|
||||
|
||||
|
||||
return redirect(
|
||||
"projects:info",
|
||||
project.id
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required("projects_user_add")
|
||||
def project_worker_add(request, project_id):
|
||||
project = get_object_or_404(Project, pk=project_id)
|
||||
get_object_or_404(ProjectMembership,project=project,user=request.user)
|
||||
form = ProjectAddWorkerForm(request.POST or None,project=project)
|
||||
|
||||
if request.method == "POST" and form.is_valid():
|
||||
membership = form.save(commit=False)
|
||||
membership.project = project
|
||||
membership.save()
|
||||
return redirect("projects:info",project.id)
|
||||
|
||||
return render(request,"projects/worker_add.html",{"form": form,"project": project,})
|
||||
|
||||
|
||||
@login_required
|
||||
@permission_required("projects_user_delete")
|
||||
def project_worker_delete(request, project_id, worker_id):
|
||||
|
||||
membership = get_object_or_404(
|
||||
ProjectMembership,
|
||||
user=request.user,
|
||||
project_id=project_id
|
||||
)
|
||||
|
||||
project = membership.project
|
||||
|
||||
|
||||
worker = get_object_or_404(
|
||||
ProjectMembership,
|
||||
id=worker_id,
|
||||
project=project
|
||||
)
|
||||
|
||||
|
||||
if request.method == "POST":
|
||||
|
||||
if worker.role != ProjectMembership.Role.OWNER:
|
||||
worker.delete()
|
||||
|
||||
|
||||
return redirect(
|
||||
"projects:info",
|
||||
project.id
|
||||
)
|
||||
Reference in New Issue
Block a user