snapshot: preserve dashboard redesign and live protocol workflow state

This commit is contained in:
Md Bayazid Bostame
2026-03-19 16:10:30 +01:00
parent 3bf43921ff
commit 1cb92682cf
14 changed files with 1948 additions and 121 deletions

View File

@@ -3,7 +3,7 @@ from django.conf import settings
from django import forms
from .emailing import send_system_email
from .models import EmployeeProfile, FormFieldConfig, FormOption, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingRequest, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig
from .models import EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig
@admin.register(EmployeeProfile)
@@ -43,6 +43,23 @@ class FormFieldConfigAdmin(admin.ModelAdmin):
list_editable = ('page_key', 'sort_order', 'is_visible', 'is_required')
@admin.register(IntroChecklistItem)
class IntroChecklistItemAdmin(admin.ModelAdmin):
list_display = ('section', 'label', 'condition_field', 'condition_operator', 'condition_value', 'sort_order', 'is_active')
list_filter = ('section', 'condition_operator', 'is_active')
search_fields = ('label', 'condition_field', 'condition_value')
ordering = ('section', 'sort_order', 'label')
list_editable = ('sort_order', 'is_active')
@admin.register(OnboardingIntroductionSession)
class OnboardingIntroductionSessionAdmin(admin.ModelAdmin):
list_display = ('onboarding_request', 'status', 'completed_by_name', 'completed_at', 'updated_at')
list_filter = ('status', 'completed_at', 'updated_at')
search_fields = ('onboarding_request__full_name', 'onboarding_request__work_email', 'completed_by_name')
ordering = ('-updated_at', '-id')
@admin.register(WorkflowConfig)
class WorkflowConfigAdmin(admin.ModelAdmin):
list_display = (

View File

@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('workflows', '0024_workflowconfig_welcome_email_delay_days_and_more'),
]
operations = [
migrations.AddField(
model_name='onboardingrequest',
name='intro_pdf_path',
field=models.CharField(blank=True, max_length=500),
),
]

View File

@@ -0,0 +1,27 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('workflows', '0025_onboardingrequest_intro_pdf_path'),
]
operations = [
migrations.CreateModel(
name='IntroChecklistItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('section', models.CharField(choices=[('workplace', 'Geräte und Arbeitsplatz'), ('accounts', 'Konten und Berechtigungen'), ('software', 'Software und Tools'), ('process', 'Prozesse und Hinweise')], max_length=30)),
('label', models.CharField(max_length=255)),
('sort_order', models.PositiveIntegerField(default=0)),
('is_active', models.BooleanField(default=True)),
('condition_field', models.CharField(blank=True, max_length=80)),
('condition_operator', models.CharField(choices=[('always', 'Immer anzeigen'), ('contains', 'Enthält'), ('equals', 'Ist gleich'), ('is_true', 'Ist Ja / aktiv'), ('is_false', 'Ist Nein / inaktiv')], default='always', max_length=20)),
('condition_value', models.CharField(blank=True, max_length=255)),
],
options={
'ordering': ['section', 'sort_order', 'label'],
},
),
]

View File

@@ -0,0 +1,26 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('workflows', '0026_introchecklistitem'),
]
operations = [
migrations.CreateModel(
name='OnboardingIntroductionSession',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('checklist_state', models.JSONField(blank=True, default=dict)),
('notes', models.TextField(blank=True)),
('status', models.CharField(choices=[('draft', 'Entwurf'), ('completed', 'Abgeschlossen')], default='draft', max_length=20)),
('completed_at', models.DateTimeField(blank=True, null=True)),
('completed_by_name', models.CharField(blank=True, max_length=255)),
('updated_at', models.DateTimeField(auto_now=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('onboarding_request', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='workflows.onboardingrequest')),
],
),
]

View File

@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('workflows', '0027_onboardingintroductionsession'),
]
operations = [
migrations.AddField(
model_name='onboardingintroductionsession',
name='exported_pdf_path',
field=models.CharField(blank=True, max_length=500),
),
]

View File

@@ -78,6 +78,7 @@ class OnboardingRequest(models.Model):
)
generated_pdf_path = models.CharField(max_length=500, blank=True)
intro_pdf_path = models.CharField(max_length=500, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self) -> str:
@@ -231,6 +232,56 @@ class ScheduledWelcomeEmail(models.Model):
return f'Welcome #{self.id} | {self.recipient_email} | {self.status}'
class IntroChecklistItem(models.Model):
SECTION_CHOICES = [
('workplace', 'Geräte und Arbeitsplatz'),
('accounts', 'Konten und Berechtigungen'),
('software', 'Software und Tools'),
('process', 'Prozesse und Hinweise'),
]
OPERATOR_CHOICES = [
('always', 'Immer anzeigen'),
('contains', 'Enthält'),
('equals', 'Ist gleich'),
('is_true', 'Ist Ja / aktiv'),
('is_false', 'Ist Nein / inaktiv'),
]
section = models.CharField(max_length=30, choices=SECTION_CHOICES)
label = models.CharField(max_length=255)
sort_order = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True)
condition_field = models.CharField(max_length=80, blank=True)
condition_operator = models.CharField(max_length=20, choices=OPERATOR_CHOICES, default='always')
condition_value = models.CharField(max_length=255, blank=True)
class Meta:
ordering = ['section', 'sort_order', 'label']
def __str__(self) -> str:
return f'{self.get_section_display()}: {self.label}'
class OnboardingIntroductionSession(models.Model):
STATUS_CHOICES = [
('draft', 'Entwurf'),
('completed', 'Abgeschlossen'),
]
onboarding_request = models.OneToOneField(OnboardingRequest, on_delete=models.CASCADE)
checklist_state = models.JSONField(default=dict, blank=True)
notes = models.TextField(blank=True)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft')
completed_at = models.DateTimeField(null=True, blank=True)
completed_by_name = models.CharField(max_length=255, blank=True)
exported_pdf_path = models.CharField(max_length=500, blank=True)
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self) -> str:
return f'Einweisung #{self.id} | {self.onboarding_request.full_name} | {self.status}'
class WorkflowConfig(models.Model):
name = models.CharField(max_length=120, default='Default', unique=True)
it_onboarding_email = models.EmailField(blank=True)

View File

@@ -12,7 +12,7 @@ from jinja2 import Template
from pypdf import PageObject, PdfReader, PdfWriter
from xhtml2pdf import pisa
from .models import EmployeeProfile, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingRequest, ScheduledWelcomeEmail, WorkflowConfig
from .models import EmployeeProfile, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, ScheduledWelcomeEmail, WorkflowConfig
from .emailing import send_system_email
from .services import upload_to_nextcloud
from .services import get_email_test_redirect, is_email_test_mode
@@ -269,6 +269,111 @@ def _resolve_workflow_emails() -> tuple[str, str, str, str, str]:
return it_email, general_info_email, business_card_email, hr_works_email, key_email
def _matches_intro_condition(request_obj: OnboardingRequest, item: IntroChecklistItem) -> bool:
operator = (item.condition_operator or 'always').strip()
field_name = (item.condition_field or '').strip()
expected = (item.condition_value or '').strip()
if operator == 'always' or not field_name:
return True
raw_value = getattr(request_obj, field_name, '')
if raw_value is None:
raw_value = ''
if operator == 'is_true':
return bool(raw_value)
if operator == 'is_false':
return not bool(raw_value)
text_value = str(raw_value).strip()
if operator == 'equals':
return text_value.lower() == expected.lower()
if operator == 'contains':
return expected.lower() in text_value.lower()
return True
def _build_intro_sections_from_admin(request_obj: OnboardingRequest) -> dict[str, list[str]]:
items = list(IntroChecklistItem.objects.filter(is_active=True).order_by('section', 'sort_order', 'label'))
if not items:
return {}
section_map = {key: [] for key, _label in IntroChecklistItem.SECTION_CHOICES}
for item in items:
if item.section not in section_map:
continue
if _matches_intro_condition(request_obj, item):
section_map[item.section].append(item.label)
return {key: values for key, values in section_map.items() if values}
def build_intro_sections_for_request(request_obj: OnboardingRequest) -> list[dict]:
devices = _split_multiline(request_obj.needed_devices)
software = _split_multiline(request_obj.needed_software)
accesses = _split_multiline(request_obj.needed_accesses)
groups = _split_multiline(request_obj.needed_workspace_groups)
resources = _split_multiline(request_obj.needed_resources)
extra_hardware = _split_multiline(request_obj.additional_hardware)
extra_software = _split_multiline(request_obj.additional_software)
group_mailboxes = _split_multiline(request_obj.group_mailboxes)
workplace_items = []
for item in devices:
workplace_items.append(f'{item} übergeben und Grundfunktionen erklärt')
for item in resources:
workplace_items.append(f'{item} gezeigt bzw. Nutzung erklärt')
if request_obj.phone_number:
workplace_items.append(f'Telefonnummer / Direktwahl erklärt: {request_obj.phone_number}')
if not workplace_items:
workplace_items.append('Arbeitsplatz, Geräte und allgemeine Nutzung besprochen')
account_items = [f'{item} Zugang erklärt' for item in accesses]
account_items.extend([f'{item} Gruppe / Berechtigung erläutert' for item in groups])
if request_obj.work_email:
account_items.insert(0, f'Dienstliche E-Mail-Adresse erläutert: {request_obj.work_email}')
if group_mailboxes:
account_items.extend([f'Gruppenpostfach erklärt: {item}' for item in group_mailboxes])
if not account_items:
account_items.append('Zugänge, Konten und Anmeldelogik besprochen')
software_items = [f'{item} Einführung durchgeführt' for item in software]
software_items.extend([f'{item} zusätzlich besprochen' for item in extra_software])
if not software_items:
software_items.append('Benötigte Standardsoftware und tägliche Nutzung erklärt')
process_items = [
'Passwortregeln und sicherer Umgang besprochen',
'Dateiablage, Nextcloud und Freigaben erklärt',
'Kommunikationswege und Support-Prozess erklärt',
]
if extra_hardware:
process_items.extend([f'{item} als zusätzliche Ausstattung besprochen' for item in extra_hardware])
if request_obj.additional_access_text:
process_items.extend([f'Zusätzlicher Zugang besprochen: {item}' for item in _split_multiline(request_obj.additional_access_text)])
if request_obj.successor_name:
process_items.append(f'Übergabe-/Nachfolgekontext besprochen: {request_obj.successor_name}')
custom_intro_items = _build_intro_sections_from_admin(request_obj)
intro_sections_raw = [
('workplace', 'Geräte und Arbeitsplatz', workplace_items),
('accounts', 'Konten und Berechtigungen', account_items),
('software', 'Software und Tools', software_items),
('process', 'Prozesse und Hinweise', process_items),
]
sections = []
for key, title, default_items in intro_sections_raw:
merged_items = list(default_items)
merged_items.extend(custom_intro_items.get(key, []))
section_items = []
for idx, label in enumerate(merged_items, start=1):
section_items.append({'id': f'{key}_{idx}', 'label': label})
if section_items:
sections.append({'key': key, 'title': title, 'items': section_items})
return sections
def _send_workflow_email(
subject: str,
body: str,
@@ -609,6 +714,107 @@ def _generate_onboarding_pdf(request_obj: OnboardingRequest) -> Path:
return output_pdf
def _generate_onboarding_intro_pdf(request_obj: OnboardingRequest) -> Path:
safe_name = _safe_filename_fragment(request_obj.full_name, fallback=f'onboarding_intro_{request_obj.id}')
output_pdf = settings.PDF_OUTPUT_DIR / f'onboarding_intro_{safe_name}.pdf'
temp_pdf = settings.PDF_OUTPUT_DIR / f'_temp_onboarding_intro_{safe_name}.pdf'
template_path = settings.PDF_TEMPLATES_DIR / 'onboarding_intro_template.html'
letterhead_path = settings.PDF_TEMPLATES_DIR / 'templates.pdf'
salutation = (request_obj.get_gender_display() or '').strip()
display_name = f"{salutation} {request_obj.full_name}".strip() if salutation else request_obj.full_name
intro_sections = [
{
'title': section['title'],
'rows': _chunk_list([item['label'] for item in section['items']], chunk_size=2),
}
for section in build_intro_sections_for_request(request_obj)
]
requester_email = request_obj.onboarded_by_email or '-'
requester_name = request_obj.onboarded_by_name or _resolve_user_display_name(request_obj.onboarded_by_email) or '-'
context = {
'DISPLAY_NAME': display_name,
'ABTEILUNG': request_obj.department or '-',
'BERUFSBEZEICHNUNG': request_obj.job_title or '-',
'VERTRAGSBEGINN': request_obj.contract_start,
'EMAIL': request_obj.work_email or '-',
'REQUESTED_BY_NAME': requester_name,
'REQUESTED_BY_EMAIL': requester_email,
'INTRO_SECTIONS': intro_sections,
}
html = _render_html(template_path, context)
_generate_content_pdf(html, temp_pdf)
_overlay_with_letterhead(temp_pdf, letterhead_path, output_pdf)
if temp_pdf.exists():
temp_pdf.unlink(missing_ok=True)
return output_pdf
def _generate_onboarding_intro_session_pdf(session: OnboardingIntroductionSession, admin_signature_name: str = '-') -> Path:
request_obj = session.onboarding_request
safe_name = _safe_filename_fragment(request_obj.full_name, fallback=f'onboarding_intro_session_{request_obj.id}')
version = timezone.now().strftime('%Y%m%d%H%M%S')
output_pdf = settings.PDF_OUTPUT_DIR / f'onboarding_intro_session_{safe_name}_{version}.pdf'
temp_pdf = settings.PDF_OUTPUT_DIR / f'_temp_onboarding_intro_session_{safe_name}_{version}.pdf'
template_path = settings.PDF_TEMPLATES_DIR / 'onboarding_intro_session_pdf.html'
letterhead_path = settings.PDF_TEMPLATES_DIR / 'templates.pdf'
salutation = (request_obj.get_gender_display() or '').strip()
display_name = f"{salutation} {request_obj.full_name}".strip() if salutation else request_obj.full_name
raw_sections = build_intro_sections_for_request(request_obj)
checked_map = session.checklist_state or {}
exported_sections = []
checked_count = 0
total_count = 0
for section in raw_sections:
checked_items = []
for item in section['items']:
checked = bool(checked_map.get(item['id']))
total_count += 1
if checked:
checked_count += 1
checked_items.append({'label': item['label']})
if checked_items:
exported_sections.append({
'title': section['title'],
'rows': [checked_items[i:i + 2] for i in range(0, len(checked_items), 2)],
})
requester_email = request_obj.onboarded_by_email or '-'
requester_name = request_obj.onboarded_by_name or _resolve_user_display_name(request_obj.onboarded_by_email) or '-'
context = {
'DISPLAY_NAME': display_name,
'ABTEILUNG': request_obj.department or '-',
'BERUFSBEZEICHNUNG': request_obj.job_title or '-',
'VERTRAGSBEGINN': request_obj.contract_start,
'EMAIL': request_obj.work_email or '-',
'REQUESTED_BY_NAME': requester_name,
'REQUESTED_BY_EMAIL': requester_email,
'SESSION_STATUS': session.get_status_display(),
'SESSION_COMPLETED_BY': session.completed_by_name or '-',
'SESSION_COMPLETED_AT': session.completed_at or '-',
'SESSION_UPDATED_AT': session.updated_at,
'SESSION_NOTES': session.notes or '-',
'INTRO_SECTIONS': exported_sections,
}
html = _render_html(template_path, context)
_generate_content_pdf(html, temp_pdf)
_overlay_with_letterhead(temp_pdf, letterhead_path, output_pdf)
if temp_pdf.exists():
temp_pdf.unlink(missing_ok=True)
return output_pdf
def _generate_offboarding_pdf(request_obj: OffboardingRequest) -> Path:
safe_name = _safe_filename_fragment(request_obj.full_name, fallback=f'offboarding_{request_obj.id}')
output_pdf = settings.PDF_OUTPUT_DIR / f'offboarding_letter_{safe_name}.pdf'

View File

@@ -383,6 +383,11 @@
<p>Felder, Schritte und Optionen verwalten.</p>
<a class="btn btn-secondary" href="/admin-tools/form-builder/">Öffnen</a>
</section>
<section class="admin-card">
<h3>Einweisungs-Builder</h3>
<p>Checklistenpunkte für das Einweisungsprotokoll konfigurieren.</p>
<a class="btn btn-secondary" href="/admin-tools/intro-builder/">Öffnen</a>
</section>
<section class="admin-card">
<h3>Projekt Wiki</h3>
<p>Dokumentation, Architektur und Runbook.</p>

View File

@@ -0,0 +1,144 @@
{% load static %}
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Einweisungs-Builder</title>
<link rel="stylesheet" href="{% static 'workflows/css/buttons.css' %}" />
<style>
body { margin: 0; font-family: Arial, sans-serif; background: #f4f8ff; color: #1b2b43; padding: 20px; }
.shell { max-width: 1180px; margin: 0 auto; background: #fff; border: 1px solid #d7e0ea; border-radius: 14px; padding: 18px; }
.topbar { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
.brand-logo { width: 190px; max-width: 100%; height: auto; display: block; }
h1 { margin: 0; color: #000078; font-size: 28px; }
.sub { margin: 8px 0 14px; color: #5f6f85; }
.flash { margin: 0 0 12px; padding: 10px; border-radius: 8px; border: 1px solid #dbe5f2; background: #f8fbff; }
.flash.error { border-color: #f4c7c7; background: #fff1f1; color: #8e1e1e; }
.flash.success { border-color: #bfe6c9; background: #edf9f1; color: #116634; }
.card { border: 1px solid #d7e0ea; border-radius: 12px; padding: 14px; background: #fcfdff; margin-bottom: 14px; }
.grid { display: grid; grid-template-columns: 1.1fr 1.4fr 1fr 1fr 1fr auto; gap: 10px; align-items: end; }
.field label { display: block; font-weight: 600; margin-bottom: 6px; }
.field input, .field select { width: 100%; min-height: 40px; padding: 8px 10px; border: 1px solid #cfd9e8; border-radius: 8px; box-sizing: border-box; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th, td { border: 1px solid #e2e8f0; padding: 8px; text-align: left; vertical-align: top; }
th { background: #f6f8fb; color: #334155; }
.mini { color: #64748b; font-size: 12px; }
.hint { margin-top: 6px; color: #5f6f85; font-size: 13px; }
.table-controls input[type="text"], .table-controls select { width: 100%; min-height: 36px; padding: 7px 9px; border: 1px solid #cfd9e8; border-radius: 8px; box-sizing: border-box; }
.table-controls input[type="checkbox"] { transform: scale(1.1); }
.actions { white-space: nowrap; }
.toolbar { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; }
</style>
</head>
<body>
<div class="shell">
<div class="topbar">
<img class="brand-logo" src="https://tub.co/media/site/0856bfc615-1750234287/tubco-wortbildmarke.svg" alt="TUB/CO Logo" />
<a class="btn btn-secondary" href="/">Zur Startseite</a>
</div>
<div class="toolbar">
<div>
<h1>Einweisungs-Builder</h1>
<p class="sub">Checklistenpunkte für das Einweisungs- und Übergabeprotokoll verwalten.</p>
</div>
<a class="btn btn-secondary" href="/requests/">Zum Dashboard</a>
</div>
{% if messages %}
{% for message in messages %}
<div class="flash {% if message.tags == 'error' %}error{% else %}success{% endif %}">{{ message }}</div>
{% endfor %}
{% endif %}
<form class="card" method="post" action="/admin-tools/intro-builder/">
{% csrf_token %}
<input type="hidden" name="builder_action" value="add_item" />
<div class="grid">
<div class="field">
<label for="section">Abschnitt</label>
<select id="section" name="section">
{% for value, label in section_choices %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="label">Checklistenpunkt</label>
<input id="label" name="label" placeholder="z. B. Nextcloud Ordnerstruktur erklärt" required />
</div>
<div class="actions">
<button class="btn btn-primary" type="submit">Punkt hinzufügen</button>
</div>
</div>
<div class="hint">Bedingungen und Sortierung können anschließend in der Tabelle bearbeitet werden.</div>
</form>
<form class="card" method="post" action="/admin-tools/intro-builder/">
{% csrf_token %}
<input type="hidden" name="builder_action" value="save_items" />
<div class="table-wrap">
<table class="table-controls">
<thead>
<tr>
<th>Sortierung</th>
<th>Abschnitt</th>
<th>Checklistenpunkt</th>
<th>Feld-Bedingung</th>
<th>Operator</th>
<th>Wert</th>
<th>Aktiv</th>
<th>Löschen</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>
<input type="hidden" name="item_ids" value="{{ item.id }}" />
{{ forloop.counter }}
</td>
<td>
<select name="section_{{ item.id }}">
{% for value, label in section_choices %}
<option value="{{ value }}" {% if item.section == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</td>
<td><input type="text" name="label_{{ item.id }}" value="{{ item.label }}" required /></td>
<td>
<select name="field_{{ item.id }}">
{% for value, label in condition_field_choices %}
<option value="{{ value }}" {% if item.condition_field == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</td>
<td>
<select name="operator_{{ item.id }}">
{% for value, label in operator_choices %}
<option value="{{ value }}" {% if item.condition_operator == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</td>
<td><input type="text" name="value_{{ item.id }}" value="{{ item.condition_value }}" placeholder="z. B. HR Works" /></td>
<td><input type="checkbox" name="active_{{ item.id }}" {% if item.is_active %}checked{% endif %} /></td>
<td class="actions">
<button class="btn btn-secondary" type="submit" name="delete_item_id" value="{{ item.id }}" onclick="return confirm('Checklistenpunkt wirklich löschen?');">Löschen</button>
</td>
</tr>
{% empty %}
<tr><td colspan="8">Noch keine benutzerdefinierten Checklistenpunkte angelegt. Solange die Liste leer ist, nutzt das System die integrierten Standardpunkte.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="hint">Reihenfolge folgt derzeit der Tabellenreihenfolge beim Speichern.</div>
<div style="margin-top:12px;">
<button class="btn btn-primary" type="submit">Checkliste speichern</button>
</div>
</form>
</div>
</body>
</html>

View File

@@ -0,0 +1,161 @@
{% load static %}
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Einweisung durchführen</title>
<link rel="stylesheet" href="{% static 'workflows/css/buttons.css' %}" />
<style>
:root {
--brand-blue: #000078;
--ink: #17253b;
--muted: #607087;
--line: #d7e0ea;
--panel: #ffffff;
--soft: #f4f8ff;
--soft-strong: #eef4ff;
--ok-bg: #edf9f1;
--ok-ink: #116634;
--warn-bg: #fff8ea;
--warn-ink: #8a5a00;
}
* { box-sizing: border-box; }
body { margin: 0; font-family: "Segoe UI", Arial, sans-serif; background: linear-gradient(180deg, #eef4ff, #f8fbff); color: var(--ink); padding: 24px; }
.shell { max-width: 1180px; margin: 0 auto; background: var(--panel); border: 1px solid var(--line); border-radius: 18px; box-shadow: 0 18px 42px rgba(16,32,57,.10); overflow: hidden; }
.topbar { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; padding: 18px 22px; border-bottom: 1px solid var(--line); background: #fff; }
.brand-logo { width: 210px; max-width: 100%; height: auto; display: block; }
.top-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
.hero { padding: 20px 22px 18px; border-bottom: 1px solid var(--line); background: linear-gradient(135deg, rgba(0,0,120,.06), rgba(0,0,120,0) 48%), linear-gradient(180deg, #ffffff, #f8fbff); }
.hero h1 { margin: 0; font-size: 32px; line-height: 1.08; color: var(--brand-blue); }
.sub { margin: 8px 0 0; color: var(--muted); max-width: 780px; }
.content { padding: 20px 22px 24px; }
.flash { margin: 0 0 12px; padding: 10px 12px; border-radius: 10px; border: 1px solid #bfe6c9; background: var(--ok-bg); color: var(--ok-ink); }
.meta { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin-bottom: 16px; }
.card { border: 1px solid var(--line); border-radius: 14px; background: #fff; padding: 14px; }
.card h2 { margin: 0 0 10px; font-size: 16px; color: #1b3764; }
.progress-block { border: 1px solid var(--line); border-radius: 14px; background: linear-gradient(180deg, #ffffff, #f9fbff); padding: 14px; margin-bottom: 16px; }
.progress-top { display: flex; justify-content: space-between; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
.progress-label { font-size: 15px; font-weight: 700; color: #18345f; }
.progress-meta { color: var(--muted); font-size: 13px; }
.progress-bar { width: 100%; height: 12px; border-radius: 999px; overflow: hidden; background: #e7eefb; border: 1px solid #d6e1f5; }
.progress-fill { height: 100%; background: linear-gradient(90deg, #3056a3, #0f5fcf); }
.meta-grid { display: grid; grid-template-columns: 170px 1fr; gap: 8px 10px; font-size: 14px; }
.meta-grid strong { color: #334155; }
.status-pill { display: inline-block; padding: 4px 10px; border-radius: 999px; border: 1px solid #d7e0ea; background: #f8fbff; color: #486183; font-size: 12px; font-weight: 700; }
.status-pill.done { background: var(--ok-bg); color: var(--ok-ink); border-color: #bfe6c9; }
.status-pill.draft { background: var(--warn-bg); color: var(--warn-ink); border-color: #f5d8a8; }
.section { border: 1px solid var(--line); border-radius: 14px; overflow: hidden; margin-bottom: 14px; background: #fff; }
.section-head { padding: 11px 14px; font-weight: 700; color: #1f376b; background: var(--soft-strong); border-bottom: 1px solid #d5e2f9; }
.items { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0; padding: 6px 14px 10px; }
.item { display: flex; align-items: flex-start; gap: 12px; padding: 10px 0; border-bottom: 1px solid #eef3f8; }
.item:nth-last-child(-n+2) { border-bottom: 0; }
.item input { margin-top: 2px; width: 18px; height: 18px; accent-color: var(--brand-blue); }
.item span { line-height: 1.45; }
textarea { width: 100%; min-height: 150px; border: 1px solid #cfd9e8; border-radius: 10px; padding: 11px 12px; font: inherit; resize: vertical; }
.help { color: var(--muted); font-size: 13px; margin-top: 8px; }
.actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 14px; }
@media (max-width: 900px) { .meta, .items { grid-template-columns: 1fr; } .topbar { flex-direction: column; } .top-actions { justify-content: flex-start; } .meta-grid { grid-template-columns: 1fr; } .item:nth-last-child(-n+2) { border-bottom: 1px solid #eef3f8; } .item:last-child { border-bottom: 0; } }
</style>
</head>
<body>
<div class="shell">
<div class="topbar">
<img class="brand-logo" src="https://tub.co/media/site/0856bfc615-1750234287/tubco-wortbildmarke.svg" alt="TUB/CO Logo" />
<div class="top-actions">
<a class="btn btn-secondary" href="/requests/">Zum Dashboard</a>
<a class="btn btn-secondary" href="/">Zur Startseite</a>
</div>
</div>
<div class="hero">
<h1>Einweisung durchführen</h1>
<p class="sub">Einfache Live-Checkliste für das persönliche Onboarding-Gespräch. Punkte abhaken, Notizen ergänzen, als Entwurf speichern oder als abgeschlossen markieren.</p>
</div>
<div class="content">
{% if messages %}
{% for message in messages %}
<div class="flash">{{ message }}</div>
{% endfor %}
{% endif %}
<div class="meta">
<div class="card">
<h2>Mitarbeitende Person</h2>
<div class="meta-grid">
<strong>Name</strong><span>{{ display_name|default:onboarding.full_name }}</span>
<strong>Abteilung</strong><span>{{ onboarding.department|default:"-" }}</span>
<strong>Berufsbezeichnung</strong><span>{{ onboarding.job_title|default:"-" }}</span>
<strong>Dienstliche E-Mail</strong><span>{{ onboarding.work_email|default:"-" }}</span>
<strong>Vertragsbeginn</strong><span>{{ onboarding.contract_start|default:"-" }}</span>
</div>
</div>
<div class="card">
<h2>Sitzungsstatus</h2>
<div class="meta-grid">
<strong>Status</strong>
<span><span class="status-pill {% if session.status == 'completed' %}done{% else %}draft{% endif %}">{{ session.get_status_display }}</span></span>
<strong>Abgeschlossen von</strong><span>{{ session.completed_by_name|default:"-" }}</span>
<strong>Abgeschlossen am</strong><span>{% if session.completed_at %}{{ session.completed_at|date:"Y-m-d H:i" }}{% else %}-{% endif %}</span>
<strong>Letzte Änderung</strong><span>{{ session.updated_at|date:"Y-m-d H:i" }}</span>
</div>
</div>
</div>
<div class="progress-block">
<div class="progress-top">
<div>
<div class="progress-label">Fortschritt der Einweisung</div>
<div class="progress-meta">{{ checked_count }} von {{ total_count }} Punkten erledigt</div>
</div>
<div class="status-pill {% if session.status == 'completed' %}done{% else %}draft{% endif %}">{{ progress_percent }}%</div>
</div>
<div class="progress-bar"><div class="progress-fill" style="width: {{ progress_percent }}%;"></div></div>
</div>
<form method="post">
{% csrf_token %}
{% for section in sections %}
<section class="section">
<div class="section-head">{{ section.title }}</div>
<div class="items">
{% for item in section.items %}
<label class="item">
<input type="checkbox" name="checked_items" value="{{ item.id }}" {% if item.checked %}checked{% endif %} />
<span>{{ item.label }}</span>
</label>
{% endfor %}
</div>
</section>
{% endfor %}
<div class="card">
<h2>Notizen</h2>
<textarea id="notes" name="notes">{{ session.notes }}</textarea>
<div class="help">Diese Seite bleibt bewusst einfach: echte Web-Checkboxen, Notizen und ein klarer Entwurf/Abschluss-Status. Kein zusätzlicher komplexer PDF-Signatur-Workflow.</div>
<div class="actions">
<button class="btn btn-secondary" type="submit" name="session_action" value="save">Als Entwurf speichern</button>
<button class="btn btn-primary" type="submit" name="session_action" value="complete">Als abgeschlossen markieren</button>
<button class="btn btn-secondary" type="submit" name="session_action" value="reset" onclick="return confirm('Einweisung wirklich zurücksetzen?');">Alles zurücksetzen</button>
</div>
</div>
</form>
<div class="card">
<h2>Live-Protokoll</h2>
<div class="help">Erzeugt das Live-Protokoll nur aus den aktuell gespeicherten Haken und Notizen.</div>
<div class="actions">
<form method="post" action="/requests/onboarding/{{ onboarding.id }}/intro-session/pdf/" style="display:inline;">
{% csrf_token %}
<button class="btn btn-secondary" type="submit">Live-Protokoll erzeugen</button>
</form>
{% if session_pdf_url %}
<a class="btn btn-secondary" href="{{ session_pdf_url }}" target="_blank" rel="noopener">Live-Protokoll öffnen</a>
{% endif %}
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -98,6 +98,8 @@
<li>Form saves request; requester identity is taken from logged-in user.</li>
<li>Task <code>process_onboarding_request</code> runs in worker.</li>
<li>PDF is generated using HTML template + letterhead overlay.</li>
<li>Staff can additionally generate a separate <code>Einweisungs- und Übergabeprotokoll</code> from the Requests Dashboard for face-to-face employee introduction and sign-off.</li>
<li>Staff can also open a simple live <code>Einweisung durchführen</code> page with real web checkboxes, notes, and draft/completed tracking.</li>
<li>Default notification emails + optional rule-based emails are sent.</li>
<li>Welcome email job is scheduled (configurable delay).</li>
<li>PDF is uploaded to Nextcloud if enabled.</li>
@@ -134,11 +136,16 @@
<h2 id="pdfs">7) PDF Engine</h2>
<ul>
<li>Template source: <code>/backend/media/templates/onboarding_template.html</code> and <code>offboarding_template.html</code>.</li>
<li>Additional onboarding intro template: <code>/backend/media/templates/onboarding_intro_template.html</code>.</li>
<li>Letterhead: <code>/backend/media/templates/templates.pdf</code>.</li>
<li>Output folder: <code>/backend/media/pdfs/</code>.</li>
<li>Signature images are embedded for compatibility with xhtml2pdf rendering.</li>
<li>Conditional sections are hidden if no data is provided.</li>
<li>Offboarding fallback behavior: if no onboarding record is available, the PDF renders a manual onboarding review layout grouped into <code>Stammdaten</code>, <code>Vertrag</code>, <code>IT-Setup</code>, and <code>Abschluss</code>, plus checkbox grids for devices, software, accesses, workspace groups, resources, and additional IT items.</li>
<li>The onboarding intro PDF is a separate checklist-style document for live walkthroughs. It uses printable blank checkboxes and signature lines for both the employee and the trainer.</li>
<li>The onboarding intro PDF can now use admin-configured checklist items from the dedicated <code>Einweisungs-Builder</code>. If no custom items are configured, the system falls back to built-in defaults.</li>
<li>The live introduction page uses the same checklist structure as the intro PDF, so admins maintain one checklist definition and can use it both on screen and on paper.</li>
<li>The live introduction page shows progress in percent and can generate a separate PDF export of the saved live checklist state.</li>
</ul>
<h2 id="integrations">8) Integrations</h2>
@@ -157,9 +164,12 @@
<h2 id="admin">9) Admin Apps (Home)</h2>
<ul>
<li><strong>Form Builder:</strong> manage field visibility/order/options.</li>
<li><strong>Einweisungs-Builder:</strong> manage custom checklist items for the intro PDF and live introduction checklist, including section, visibility, and conditional display logic.</li>
<li><strong>Integrations:</strong> Nextcloud, SMTP, default routing addresses, notification rules.</li>
<li><strong>Welcome Emails:</strong> scheduled jobs, pause/resume/cancel/trigger now.</li>
<li><strong>Requests Dashboard:</strong> search records, open PDFs, delete records (single/bulk for staff).</li>
<li><strong>Einweisungs- und Übergabeprotokoll:</strong> staff-only <code>PDF erzeugen</code>, <code>Neu erzeugen</code>, and <code>PDF öffnen</code> actions directly on onboarding rows in the Requests Dashboard.</li>
<li><strong>Einweisung durchführen:</strong> staff-only live checklist page opened from onboarding rows, with draft/completed status, notes, progress tracking, and a separate live-status PDF export.</li>
<li><strong>Project Wiki:</strong> this documentation page.</li>
</ul>
@@ -182,6 +192,7 @@
<li>After template/form/task changes, restart web and worker containers.</li>
<li>Run <code>python manage.py check</code> before release.</li>
<li>On a fresh database, the web container boot sequence now runs <code>python manage.py bootstrap_initial_users</code> right after migrations.</li>
<li>After adding or editing intro checklist items, regenerate the intro PDF from the Requests Dashboard to reflect the updated checklist.</li>
</ul>
<h3>Initial Bootstrap Users</h3>

File diff suppressed because it is too large Load Diff

View File

@@ -30,5 +30,9 @@ urlpatterns = [
path('admin-tools/wiki/', views.project_wiki_page, name='project_wiki_page'),
path('admin-tools/form-builder/', views.form_builder_page, name='form_builder_page'),
path('admin-tools/form-builder/save-order/', views.form_builder_save_order, name='form_builder_save_order'),
path('admin-tools/intro-builder/', views.intro_builder_page, name='intro_builder_page'),
path('requests/onboarding/<int:request_id>/intro-session/', views.onboarding_intro_session_page, name='onboarding_intro_session_page'),
path('requests/onboarding/<int:request_id>/intro-session/pdf/', views.generate_onboarding_intro_session_pdf, name='generate_onboarding_intro_session_pdf'),
path('requests/onboarding/<int:request_id>/intro-pdf/generate/', views.generate_onboarding_intro_pdf, name='generate_onboarding_intro_pdf'),
path('requests/delete/<str:kind>/<int:request_id>/', views.delete_request_from_dashboard, name='delete_request_from_dashboard'),
]

View File

@@ -25,11 +25,14 @@ from .form_builder import (
ONBOARDING_PAGE_ORDER,
ensure_form_field_configs,
)
from .models import EmployeeProfile, FormFieldConfig, FormOption, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingRequest, ScheduledWelcomeEmail, WorkflowConfig
from .models import EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, ScheduledWelcomeEmail, WorkflowConfig
from .emailing import send_system_email
from .services import get_email_test_redirect, is_email_test_mode, is_nextcloud_enabled, upload_to_nextcloud
from .tasks import (
DEFAULT_NOTIFICATION_TEMPLATES,
_generate_onboarding_intro_pdf,
_generate_onboarding_intro_session_pdf,
build_intro_sections_for_request,
process_offboarding_request,
process_onboarding_request,
send_scheduled_welcome_email,
@@ -272,6 +275,9 @@ def requests_dashboard(request):
rows = []
for obj in onboarding_items:
intro_session = OnboardingIntroductionSession.objects.filter(onboarding_request=obj).first()
if intro_session and intro_session.exported_pdf_path:
intro_session.exported_pdf_url = f"/media/pdfs/{Path(intro_session.exported_pdf_path).name}"
rows.append(
{
'id': obj.id,
@@ -281,6 +287,8 @@ def requests_dashboard(request):
'work_email': obj.work_email,
'created_at': obj.created_at,
'pdf_url': f"/media/pdfs/{Path(obj.generated_pdf_path).name}" if obj.generated_pdf_path else None,
'intro_pdf_url': f"/media/pdfs/{Path(obj.intro_pdf_path).name}" if obj.intro_pdf_path else None,
'intro_session': intro_session,
'status': 'PDF erstellt' if obj.generated_pdf_path else 'In Bearbeitung',
}
)
@@ -294,6 +302,8 @@ def requests_dashboard(request):
'work_email': obj.work_email,
'created_at': obj.created_at,
'pdf_url': f"/media/pdfs/{Path(obj.generated_pdf_path).name}" if obj.generated_pdf_path else None,
'intro_pdf_url': None,
'intro_session': None,
'status': 'PDF erstellt' if obj.generated_pdf_path else 'In Bearbeitung',
}
)
@@ -400,6 +410,101 @@ def onboarding_success(request, request_id: int):
return render(request, 'workflows/onboarding_success.html', {'obj': obj, 'pdf_url': pdf_url})
@login_required
@user_passes_test(_is_staff)
@require_POST
def generate_onboarding_intro_pdf(request, request_id: int):
obj = get_object_or_404(OnboardingRequest, id=request_id)
pdf_path = _generate_onboarding_intro_pdf(obj)
obj.intro_pdf_path = str(pdf_path)
obj.save(update_fields=['intro_pdf_path'])
messages.success(request, 'Einweisungs- und Übergabeprotokoll wurde erzeugt.')
return redirect('requests_dashboard')
@login_required
@user_passes_test(_is_staff)
@require_POST
def generate_onboarding_intro_session_pdf(request, request_id: int):
onboarding = get_object_or_404(OnboardingRequest, id=request_id)
session, _created = OnboardingIntroductionSession.objects.get_or_create(onboarding_request=onboarding)
pdf_path = _generate_onboarding_intro_session_pdf(session, admin_signature_name=_display_user_name(request.user))
session.exported_pdf_path = str(pdf_path)
session.save(update_fields=['exported_pdf_path'])
messages.success(request, 'Einweisungsprotokoll aus Live-Status wurde erzeugt.')
return redirect('onboarding_intro_session_page', request_id=request_id)
@login_required
@user_passes_test(_is_staff)
def onboarding_intro_session_page(request, request_id: int):
onboarding = get_object_or_404(OnboardingRequest, id=request_id)
session, _created = OnboardingIntroductionSession.objects.get_or_create(onboarding_request=onboarding)
sections = build_intro_sections_for_request(onboarding)
if request.method == 'POST':
checked_ids = set(request.POST.getlist('checked_items'))
checklist_state = {}
for section in sections:
for item in section['items']:
checklist_state[item['id']] = item['id'] in checked_ids
action = (request.POST.get('session_action') or 'save').strip()
session.checklist_state = checklist_state
session.notes = (request.POST.get('notes') or '').strip()
if action == 'reset':
session.checklist_state = {}
session.notes = ''
session.status = 'draft'
session.completed_at = None
session.completed_by_name = ''
session.exported_pdf_path = ''
session.save(update_fields=['checklist_state', 'notes', 'status', 'completed_at', 'completed_by_name', 'exported_pdf_path'])
messages.success(request, 'Einweisung wurde zurückgesetzt.')
return redirect('onboarding_intro_session_page', request_id=request_id)
if action == 'complete':
session.status = 'completed'
session.completed_at = timezone.now()
session.completed_by_name = _display_user_name(request.user)
messages.success(request, 'Einweisung wurde als abgeschlossen gespeichert.')
else:
session.status = 'draft'
session.completed_at = None
session.completed_by_name = ''
messages.success(request, 'Einweisung wurde als Entwurf gespeichert.')
session.save()
return redirect('onboarding_intro_session_page', request_id=request_id)
checked_map = session.checklist_state or {}
checked_count = 0
total_count = 0
for section in sections:
for item in section['items']:
item['checked'] = bool(checked_map.get(item['id']))
total_count += 1
if item['checked']:
checked_count += 1
salutation = (onboarding.get_gender_display() or '').strip()
display_name = f"{salutation} {onboarding.full_name}".strip() if salutation else onboarding.full_name
progress_percent = int((checked_count / total_count) * 100) if total_count else 0
return render(
request,
'workflows/onboarding_intro_session.html',
{
'onboarding': onboarding,
'session': session,
'display_name': display_name,
'sections': sections,
'checked_count': checked_count,
'total_count': total_count,
'progress_percent': progress_percent,
'session_pdf_url': f"/media/pdfs/{Path(session.exported_pdf_path).name}" if session.exported_pdf_path else None,
},
)
@login_required
@ensure_csrf_cookie
def offboarding_create(request):
@@ -608,6 +713,108 @@ def form_builder_page(request):
)
@login_required
@user_passes_test(_is_staff)
def intro_builder_page(request):
if request.method == 'POST':
delete_id = (request.POST.get('delete_item_id') or '').strip()
if delete_id:
item = IntroChecklistItem.objects.filter(id=delete_id).first()
if item:
item.delete()
messages.success(request, 'Checklistenpunkt wurde gelöscht.')
else:
messages.error(request, 'Checklistenpunkt nicht gefunden.')
return redirect('intro_builder_page')
action = (request.POST.get('builder_action') or '').strip()
if action == 'add_item':
section = (request.POST.get('section') or '').strip()
label = (request.POST.get('label') or '').strip()
if section not in {k for k, _ in IntroChecklistItem.SECTION_CHOICES}:
messages.error(request, 'Ungültiger Abschnitt.')
return redirect('intro_builder_page')
if not label:
messages.error(request, 'Bitte eine Bezeichnung für den Checklistenpunkt angeben.')
return redirect('intro_builder_page')
next_sort = (
IntroChecklistItem.objects.filter(section=section).order_by('-sort_order').values_list('sort_order', flat=True).first()
)
IntroChecklistItem.objects.create(
section=section,
label=label,
sort_order=(next_sort + 1) if next_sort is not None else 0,
is_active=True,
condition_operator='always',
)
messages.success(request, 'Checklistenpunkt wurde hinzugefügt.')
return redirect('intro_builder_page')
if action == 'save_items':
item_ids = request.POST.getlist('item_ids')
valid_sections = {k for k, _ in IntroChecklistItem.SECTION_CHOICES}
valid_ops = {k for k, _ in IntroChecklistItem.OPERATOR_CHOICES}
for pos, raw_id in enumerate(item_ids):
item = IntroChecklistItem.objects.filter(id=raw_id).first()
if not item:
continue
section = (request.POST.get(f'section_{item.id}') or item.section).strip()
if section not in valid_sections:
section = item.section
operator = (request.POST.get(f'operator_{item.id}') or item.condition_operator).strip()
if operator not in valid_ops:
operator = 'always'
item.section = section
item.label = (request.POST.get(f'label_{item.id}') or item.label).strip() or item.label
item.is_active = request.POST.get(f'active_{item.id}') == 'on'
item.condition_field = (request.POST.get(f'field_{item.id}') or '').strip()
item.condition_operator = operator
item.condition_value = (request.POST.get(f'value_{item.id}') or '').strip()
item.sort_order = pos
item.save(
update_fields=[
'section',
'label',
'is_active',
'condition_field',
'condition_operator',
'condition_value',
'sort_order',
]
)
messages.success(request, 'Einweisungs-Checkliste wurde gespeichert.')
return redirect('intro_builder_page')
condition_field_choices = [
('', 'Keine Bedingung'),
('needed_devices', 'Benötigte Geräte und Gegenstände'),
('needed_software', 'Benötigte Software'),
('needed_accesses', 'Benötigte Zugänge'),
('needed_workspace_groups', 'Benötigte Gruppen im Workspace'),
('needed_resources', 'Benötigte Ressourcen'),
('additional_hardware', 'Zusätzliche Hardware'),
('additional_software', 'Zusätzliche Software'),
('additional_access_text', 'Weitere Zugänge (Freitext)'),
('group_mailboxes_required', 'Gruppenpostfächer erforderlich'),
('order_business_cards', 'Visitenkarten bestellt'),
('phone_number', 'Direktwahl vorhanden'),
('successor_name', 'Nachfolge vorhanden'),
('department', 'Abteilung'),
]
items = list(IntroChecklistItem.objects.all().order_by('section', 'sort_order', 'label'))
return render(
request,
'workflows/intro_builder.html',
{
'items': items,
'section_choices': IntroChecklistItem.SECTION_CHOICES,
'operator_choices': IntroChecklistItem.OPERATOR_CHOICES,
'condition_field_choices': condition_field_choices,
},
)
@login_required
@user_passes_test(_is_staff)
def integrations_setup_page(request):